diff --git a/CHANGELOG.md b/CHANGELOG.md index 67b8cd725a..5bc41d03ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added public command `Invoke-SqlDscScalarQuery` to execute scalar queries using + `Server.ConnectionContext.ExecuteScalar()`. Server-level, lightweight execution + that does not require any database to be online + ([issue #2423](https://github.com/dsccommunity/SqlServerDsc/issues/2423)). +- Added public command `Get-SqlDscDateTime` to retrieve current date and time from + SQL Server instance. Supports multiple T-SQL date/time functions to eliminate + clock-skew and timezone issues between client and server + ([issue #2423](https://github.com/dsccommunity/SqlServerDsc/issues/2423)). - Added public command `Backup-SqlDscDatabase` to perform database backups using SMO's `Microsoft.SqlServer.Management.Smo.Backup` class. Supports full, differential, and transaction log backups with options for compression, diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 48d17697b5..43e7e2060f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -302,6 +302,8 @@ stages: 'tests/Integration/Commands/Connect-SqlDscDatabaseEngine.Integration.Tests.ps1' 'tests/Integration/Commands/Disconnect-SqlDscDatabaseEngine.Integration.Tests.ps1' 'tests/Integration/Commands/Invoke-SqlDscQuery.Integration.Tests.ps1' + 'tests/Integration/Commands/Invoke-SqlDscScalarQuery.Integration.Tests.ps1' + 'tests/Integration/Commands/Get-SqlDscDateTime.Integration.Tests.ps1' # Group 4 'tests/Integration/Commands/Assert-SqlDscLogin.Integration.Tests.ps1' 'tests/Integration/Commands/New-SqlDscLogin.Integration.Tests.ps1' diff --git a/source/Public/Get-SqlDscDateTime.ps1 b/source/Public/Get-SqlDscDateTime.ps1 new file mode 100644 index 0000000000..24e12f9d33 --- /dev/null +++ b/source/Public/Get-SqlDscDateTime.ps1 @@ -0,0 +1,126 @@ +<# + .SYNOPSIS + Retrieves the current date and time from a SQL Server instance. + + .DESCRIPTION + Retrieves the current date and time from a SQL Server instance using the + specified T-SQL date/time function. This command helps eliminate clock-skew + and timezone issues when coordinating time-sensitive operations between the + client and SQL Server. + + The command queries SQL Server using the server connection context, which + does not require any database to be online. + + .PARAMETER ServerObject + Specifies current server connection object. + + .PARAMETER DateTimeFunction + Specifies which T-SQL date/time function to use for retrieving the date and time. + Valid values are: + - `SYSDATETIME` (default): Returns datetime2(7) with server local time + - `SYSDATETIMEOFFSET`: Returns datetimeoffset(7) with server local time and timezone offset + - `SYSUTCDATETIME`: Returns datetime2(7) with UTC time + - `GETDATE`: Returns datetime with server local time + - `GETUTCDATE`: Returns datetime with UTC time + + .PARAMETER StatementTimeout + Specifies the query StatementTimeout in seconds. Default 600 seconds (10 minutes). + + .INPUTS + `Microsoft.SqlServer.Management.Smo.Server` + + Accepts input via the pipeline. + + .OUTPUTS + `System.DateTime` + + Returns the current date and time from the SQL Server instance. + + .EXAMPLE + $serverObject = Connect-SqlDscDatabaseEngine + Get-SqlDscDateTime -ServerObject $serverObject + + Connects to the default instance and retrieves the current date and time + using the default SYSDATETIME function. + + .EXAMPLE + $serverObject = Connect-SqlDscDatabaseEngine + $serverObject | Get-SqlDscDateTime -DateTimeFunction 'SYSUTCDATETIME' + + Connects to the default instance and retrieves the current UTC date and time + from the SQL Server instance. + + .EXAMPLE + $serverObject = Connect-SqlDscDatabaseEngine + $serverTime = Get-SqlDscDateTime -ServerObject $serverObject + Restore-SqlDscDatabase -ServerObject $serverObject -Name 'MyDatabase' -StopAt $serverTime.AddHours(-1) + + Demonstrates using the server's clock for a point-in-time restore operation, + avoiding clock skew issues between client and server. +#> +function Get-SqlDscDateTime +{ + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('UseSyntacticallyCorrectExamples', '', Justification = 'Because the rule does not yet support parsing the code when a parameter type is not available. The ScriptAnalyzer rule UseSyntacticallyCorrectExamples will always error in the editor due to https://github.com/indented-automation/Indented.ScriptAnalyzerRules/issues/8.')] + [OutputType([System.DateTime])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [Microsoft.SqlServer.Management.Smo.Server] + $ServerObject, + + [Parameter()] + [ValidateSet('SYSDATETIME', 'SYSDATETIMEOFFSET', 'SYSUTCDATETIME', 'GETDATE', 'GETUTCDATE')] + [System.String] + $DateTimeFunction = 'SYSDATETIME', + + [Parameter()] + [ValidateNotNull()] + [System.Int32] + $StatementTimeout = 600 + ) + + process + { + Write-Verbose -Message ( + $script:localizedData.Get_SqlDscDateTime_RetrievingDateTime -f $DateTimeFunction + ) + + $query = "SELECT $DateTimeFunction()" + + $invokeSqlDscScalarQueryParameters = @{ + ServerObject = $ServerObject + Query = $query + StatementTimeout = $StatementTimeout + ErrorAction = 'Stop' + Verbose = $VerbosePreference + } + + try + { + $result = Invoke-SqlDscScalarQuery @invokeSqlDscScalarQueryParameters + + # Convert the result to DateTime if it's a DateTimeOffset + if ($result -is [System.DateTimeOffset]) + { + $result = $result.DateTime + } + + return $result + } + catch + { + $writeErrorParameters = @{ + Message = $script:localizedData.Get_SqlDscDateTime_FailedToRetrieve -f $DateTimeFunction, $_.Exception.Message + Category = 'InvalidOperation' + ErrorId = 'GSDD0001' # cSpell: disable-line + TargetObject = $DateTimeFunction + Exception = $_.Exception + } + + Write-Error @writeErrorParameters + + return + } + } +} diff --git a/source/Public/Invoke-SqlDscScalarQuery.ps1 b/source/Public/Invoke-SqlDscScalarQuery.ps1 new file mode 100644 index 0000000000..c76a273c3f --- /dev/null +++ b/source/Public/Invoke-SqlDscScalarQuery.ps1 @@ -0,0 +1,134 @@ +<# + .SYNOPSIS + Executes a scalar query on the specified server. + + .DESCRIPTION + Executes a scalar query on the specified server using the server connection + context. This command is designed for queries that return a single value, + such as `SELECT @@VERSION` or `SELECT SYSDATETIME()`. + + The command uses `Server.ConnectionContext.ExecuteScalar()` which is + server-level and does not require any database to be online. + + .PARAMETER ServerObject + Specifies current server connection object. + + .PARAMETER Query + Specifies the scalar query string to execute. + + .PARAMETER StatementTimeout + Specifies the query StatementTimeout in seconds. Default 600 seconds (10 minutes). + + .PARAMETER RedactText + Specifies one or more text strings to redact from the query when verbose messages + are written to the console. Strings will be escaped so they will not + be interpreted as regular expressions (RegEx). + + .INPUTS + `Microsoft.SqlServer.Management.Smo.Server` + + Accepts input via the pipeline. + + .OUTPUTS + `System.Object` + + Returns the scalar value returned by the query. + + .EXAMPLE + $serverObject = Connect-SqlDscDatabaseEngine + Invoke-SqlDscScalarQuery -ServerObject $serverObject -Query 'SELECT @@VERSION' + + Connects to the default instance and then runs a query to return the SQL Server version. + + .EXAMPLE + $serverObject = Connect-SqlDscDatabaseEngine + $serverObject | Invoke-SqlDscScalarQuery -Query 'SELECT SYSDATETIME()' + + Connects to the default instance and then runs the query to return the current + date and time from the SQL Server instance. + + .EXAMPLE + $serverObject = Connect-SqlDscDatabaseEngine + Invoke-SqlDscScalarQuery -ServerObject $serverObject -Query "SELECT name FROM sys.databases WHERE name = 'MyPassword123'" -RedactText @('MyPassword123') -Verbose + + Shows how to redact sensitive information in the query when the query string + is output as verbose information when the parameter Verbose is used. +#> +function Invoke-SqlDscScalarQuery +{ + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('UseSyntacticallyCorrectExamples', '', Justification = 'Because the rule does not yet support parsing the code when a parameter type is not available. The ScriptAnalyzer rule UseSyntacticallyCorrectExamples will always error in the editor due to https://github.com/indented-automation/Indented.ScriptAnalyzerRules/issues/8.')] + [OutputType([System.Object])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [Microsoft.SqlServer.Management.Smo.Server] + $ServerObject, + + [Parameter(Mandatory = $true)] + [System.String] + $Query, + + [Parameter()] + [ValidateNotNull()] + [System.Int32] + $StatementTimeout = 600, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [System.String[]] + $RedactText + ) + + process + { + $redactedQuery = $Query + + if ($PSBoundParameters.ContainsKey('RedactText')) + { + $redactedQuery = ConvertTo-RedactedText -Text $Query -RedactPhrase $RedactText + } + + Write-Verbose -Message ( + $script:localizedData.Invoke_SqlDscScalarQuery_ExecutingQuery -f $redactedQuery + ) + + $previousStatementTimeout = $null + + if ($PSBoundParameters.ContainsKey('StatementTimeout')) + { + # Make sure we can return the StatementTimeout before exiting. + $previousStatementTimeout = $ServerObject.ConnectionContext.StatementTimeout + + $ServerObject.ConnectionContext.StatementTimeout = $StatementTimeout + } + + try + { + $result = $ServerObject.ConnectionContext.ExecuteScalar($Query) + + return $result + } + catch + { + $writeErrorParameters = @{ + Message = $script:localizedData.Invoke_SqlDscScalarQuery_FailedToExecute -f $_.Exception.Message + Category = 'InvalidOperation' + ErrorId = 'ISDSQ0002' # cSpell: disable-line + TargetObject = $redactedQuery + Exception = $_.Exception + } + + Write-Error @writeErrorParameters + + return + } + finally + { + if ($null -ne $previousStatementTimeout) + { + $ServerObject.ConnectionContext.StatementTimeout = $previousStatementTimeout + } + } + } +} diff --git a/source/en-US/SqlServerDsc.strings.psd1 b/source/en-US/SqlServerDsc.strings.psd1 index a7ebeb084e..1f5032a2e3 100644 --- a/source/en-US/SqlServerDsc.strings.psd1 +++ b/source/en-US/SqlServerDsc.strings.psd1 @@ -632,4 +632,12 @@ ConvertFrom-StringData @' Get_SqlDscSetupLog_Header = ==== SQL Server Setup {0} (from {1}) ==== (GSDSL0004) Get_SqlDscSetupLog_Footer = ==== End of {0} ==== (GSDSL0005) Get_SqlDscSetupLog_PathNotFound = Path '{0}' does not exist. (GSDSL0006) + + ## Invoke-SqlDscScalarQuery + Invoke_SqlDscScalarQuery_ExecutingQuery = Executing the scalar query `{0}`. (ISDSQ0001) + Invoke_SqlDscScalarQuery_FailedToExecute = Failed to execute scalar query: {0} (ISDSQ0002) + + ## Get-SqlDscDateTime + Get_SqlDscDateTime_RetrievingDateTime = Retrieving date and time using {0}(). (GSDD0001) + Get_SqlDscDateTime_FailedToRetrieve = Failed to retrieve date and time using {0}(): {1} (GSDD0002) '@ diff --git a/tests/Integration/Commands/Get-SqlDscDateTime.Integration.Tests.ps1 b/tests/Integration/Commands/Get-SqlDscDateTime.Integration.Tests.ps1 new file mode 100644 index 0000000000..15e662d89d --- /dev/null +++ b/tests/Integration/Commands/Get-SqlDscDateTime.Integration.Tests.ps1 @@ -0,0 +1,132 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies have not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' + } +} + +BeforeAll { + $script:moduleName = 'SqlServerDsc' + + Import-Module -Name $script:moduleName -Force -ErrorAction 'Stop' +} + +Describe 'Get-SqlDscDateTime' -Tag @('Integration_SQL2017', 'Integration_SQL2019', 'Integration_SQL2022') { + BeforeAll { + $script:mockInstanceName = 'DSCSQLTEST' + $script:mockComputerName = Get-ComputerName + + $mockSqlAdministratorUserName = 'SqlAdmin' + $mockSqlAdministratorPassword = ConvertTo-SecureString -String 'P@ssw0rd1' -AsPlainText -Force + + $script:mockSqlAdminCredential = [System.Management.Automation.PSCredential]::new($mockSqlAdministratorUserName, $mockSqlAdministratorPassword) + + $script:serverObject = Connect-SqlDscDatabaseEngine -InstanceName $script:mockInstanceName -Credential $script:mockSqlAdminCredential + } + + AfterAll { + Disconnect-SqlDscDatabaseEngine -ServerObject $script:serverObject -Force + } + + Context 'When retrieving date and time from SQL Server' { + Context 'When using default DateTimeFunction parameter' { + It 'Should return a DateTime value' { + $result = Get-SqlDscDateTime -ServerObject $script:serverObject -ErrorAction 'Stop' + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType [System.DateTime] + + # Verify the result is close to current time (within 5 minutes) + $timeDifference = [Math]::Abs(([System.DateTime]::Now - $result).TotalMinutes) + $timeDifference | Should -BeLessThan 5 + } + } + + Context 'When using different DateTimeFunction values' { + It 'Should return a DateTime value using ' -ForEach @( + @{ DateTimeFunction = 'SYSDATETIME' } + @{ DateTimeFunction = 'SYSUTCDATETIME' } + @{ DateTimeFunction = 'GETDATE' } + @{ DateTimeFunction = 'GETUTCDATE' } + ) { + $result = Get-SqlDscDateTime -ServerObject $script:serverObject -DateTimeFunction $DateTimeFunction -ErrorAction 'Stop' + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType [System.DateTime] + } + + It 'Should return a DateTime value when using SYSDATETIMEOFFSET (converted from DateTimeOffset)' { + $result = Get-SqlDscDateTime -ServerObject $script:serverObject -DateTimeFunction 'SYSDATETIMEOFFSET' -ErrorAction 'Stop' + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType [System.DateTime] + } + } + + Context 'When comparing UTC and local time functions' { + It 'Should return consistent UTC time when converting local time to UTC' { + $localTime = Get-SqlDscDateTime -ServerObject $script:serverObject -DateTimeFunction 'SYSDATETIME' -ErrorAction 'Stop' + $utcTime = Get-SqlDscDateTime -ServerObject $script:serverObject -DateTimeFunction 'SYSUTCDATETIME' -ErrorAction 'Stop' + + $localTime | Should -BeOfType [System.DateTime] + $utcTime | Should -BeOfType [System.DateTime] + + # Both should be within 1 second of each other when converted to UTC + $localTimeUtc = $localTime.ToUniversalTime() + $timeDifference = [Math]::Abs(($localTimeUtc - $utcTime).TotalSeconds) + $timeDifference | Should -BeLessThan 2 + } + } + + Context 'When using custom StatementTimeout' { + It 'Should execute successfully with custom timeout' { + $result = Get-SqlDscDateTime -ServerObject $script:serverObject -StatementTimeout 30 -ErrorAction 'Stop' + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType [System.DateTime] + } + } + + Context 'When passing ServerObject via pipeline' { + It 'Should execute successfully' { + $result = $script:serverObject | Get-SqlDscDateTime -ErrorAction 'Stop' + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType [System.DateTime] + } + } + + Context 'When verifying clock consistency' { + It 'Should return consistent times when called multiple times rapidly' { + $result1 = Get-SqlDscDateTime -ServerObject $script:serverObject -ErrorAction 'Stop' + $result2 = Get-SqlDscDateTime -ServerObject $script:serverObject -ErrorAction 'Stop' + $result3 = Get-SqlDscDateTime -ServerObject $script:serverObject -ErrorAction 'Stop' + + # All three calls should be within 2 seconds of each other + $timeDiff1 = [Math]::Abs(($result2 - $result1).TotalSeconds) + $timeDiff2 = [Math]::Abs(($result3 - $result2).TotalSeconds) + + $timeDiff1 | Should -BeLessThan 2 + $timeDiff2 | Should -BeLessThan 2 + } + } + } +} diff --git a/tests/Integration/Commands/Invoke-SqlDscScalarQuery.Integration.Tests.ps1 b/tests/Integration/Commands/Invoke-SqlDscScalarQuery.Integration.Tests.ps1 new file mode 100644 index 0000000000..ddb1e3ed43 --- /dev/null +++ b/tests/Integration/Commands/Invoke-SqlDscScalarQuery.Integration.Tests.ps1 @@ -0,0 +1,123 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies have not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' + } +} + +BeforeAll { + $script:moduleName = 'SqlServerDsc' + + $env:SqlServerDscCI = $true + + Import-Module -Name $script:moduleName -Force -ErrorAction 'Stop' +} + +AfterAll { + Remove-Item -Path 'env:SqlServerDscCI' -ErrorAction 'SilentlyContinue' +} + +Describe 'Invoke-SqlDscScalarQuery' -Tag @('Integration_SQL2017', 'Integration_SQL2019', 'Integration_SQL2022') { + BeforeAll { + $script:mockInstanceName = 'DSCSQLTEST' + $script:mockComputerName = Get-ComputerName + + $mockSqlAdministratorUserName = 'SqlAdmin' + $mockSqlAdministratorPassword = ConvertTo-SecureString -String 'P@ssw0rd1' -AsPlainText -Force + + $script:mockSqlAdminCredential = [System.Management.Automation.PSCredential]::new($mockSqlAdministratorUserName, $mockSqlAdministratorPassword) + + $script:serverObject = Connect-SqlDscDatabaseEngine -InstanceName $script:mockInstanceName -Credential $script:mockSqlAdminCredential + } + + AfterAll { + Disconnect-SqlDscDatabaseEngine -ServerObject $script:serverObject -Force + } + + Context 'When executing a scalar query' { + Context 'When querying for SQL Server version' { + It 'Should return the version string' { + $result = Invoke-SqlDscScalarQuery -ServerObject $script:serverObject -Query 'SELECT @@VERSION' -ErrorAction 'Stop' + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType [System.String] + $result | Should -BeLike '*Microsoft SQL Server*' + } + } + + Context 'When querying for a numeric value' { + It 'Should return the numeric result' { + $result = Invoke-SqlDscScalarQuery -ServerObject $script:serverObject -Query 'SELECT 42' -ErrorAction 'Stop' + + $result | Should -Be 42 + } + } + + Context 'When querying for the current date and time' { + It 'Should return a DateTime value using SYSDATETIME' { + $result = Invoke-SqlDscScalarQuery -ServerObject $script:serverObject -Query 'SELECT SYSDATETIME()' -ErrorAction 'Stop' + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType [System.DateTime] + } + + It 'Should return a DateTime value using GETDATE' { + $result = Invoke-SqlDscScalarQuery -ServerObject $script:serverObject -Query 'SELECT GETDATE()' -ErrorAction 'Stop' + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType [System.DateTime] + } + + It 'Should return a DateTimeOffset value using SYSDATETIMEOFFSET' { + $result = Invoke-SqlDscScalarQuery -ServerObject $script:serverObject -Query 'SELECT SYSDATETIMEOFFSET()' -ErrorAction 'Stop' + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType [System.DateTimeOffset] + } + } + + Context 'When querying with a custom timeout' { + It 'Should execute the query successfully with custom timeout' { + $result = Invoke-SqlDscScalarQuery -ServerObject $script:serverObject -Query 'SELECT @@SERVERNAME' -StatementTimeout 30 -ErrorAction 'Stop' + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType [System.String] + } + } + + Context 'When passing ServerObject via pipeline' { + It 'Should execute the query successfully' { + $result = $script:serverObject | Invoke-SqlDscScalarQuery -Query 'SELECT DB_NAME()' -ErrorAction 'Stop' + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType [System.String] + } + } + + Context 'When querying for NULL' { + It 'Should return null' { + $result = Invoke-SqlDscScalarQuery -ServerObject $script:serverObject -Query 'SELECT NULL' -ErrorAction 'Stop' + + $result | Should -BeNullOrEmpty + } + } + } +} diff --git a/tests/Unit/Public/Get-SqlDscDateTime.Tests.ps1 b/tests/Unit/Public/Get-SqlDscDateTime.Tests.ps1 new file mode 100644 index 0000000000..91c4199b3a --- /dev/null +++ b/tests/Unit/Public/Get-SqlDscDateTime.Tests.ps1 @@ -0,0 +1,213 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies have not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'SqlServerDsc' + + $env:SqlServerDscCI = $true + + Import-Module -Name $script:dscModuleName -Force -ErrorAction 'Stop' + + # Loading mocked classes + Add-Type -Path (Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '../Stubs') -ChildPath 'SMO.cs') + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force + + Remove-Item -Path 'env:SqlServerDscCI' +} + +Describe 'Get-SqlDscDateTime' -Tag 'Public' { + It 'Should have the correct parameters in parameter set ' -ForEach @( + @{ + MockParameterSetName = '__AllParameterSets' + # cSpell: disable-next + MockExpectedParameters = '[-ServerObject] [[-DateTimeFunction] ] [[-StatementTimeout] ] []' + } + ) { + $result = (Get-Command -Name 'Get-SqlDscDateTime').ParameterSets | + Where-Object -FilterScript { + $_.Name -eq $mockParameterSetName + } | + Select-Object -Property @( + @{ + Name = 'ParameterSetName' + Expression = { $_.Name } + }, + @{ + Name = 'ParameterListAsString' + Expression = { $_.ToString() } + } + ) + + $result.ParameterSetName | Should -Be $MockParameterSetName + $result.ParameterListAsString | Should -Be $MockExpectedParameters + } + + Context 'When retrieving date and time from SQL Server' { + BeforeAll { + $mockServerObject = New-Object -TypeName 'Microsoft.SqlServer.Management.Smo.Server' + + $mockDateTime = [System.DateTime]::Parse('2023-12-13 10:30:45.1234567') + } + + Context 'When calling the command with only mandatory parameters' { + BeforeAll { + Mock -CommandName Invoke-SqlDscScalarQuery -MockWith { + return $mockDateTime + } + } + + It 'Should execute the query using default SYSDATETIME function and return the DateTime result' { + $result = Get-SqlDscDateTime -ServerObject $mockServerObject + + $result | Should -BeOfType [System.DateTime] + $result | Should -Be $mockDateTime + + Should -Invoke -CommandName Invoke-SqlDscScalarQuery -Exactly -Times 1 -Scope It -ParameterFilter { + $Query -eq 'SELECT SYSDATETIME()' + } + } + } + + Context 'When passing parameter ServerObject over the pipeline' { + BeforeAll { + Mock -CommandName Invoke-SqlDscScalarQuery -MockWith { + return $mockDateTime + } + } + + It 'Should execute the query and return the DateTime result' { + $result = $mockServerObject | Get-SqlDscDateTime + + $result | Should -BeOfType [System.DateTime] + $result | Should -Be $mockDateTime + + Should -Invoke -CommandName Invoke-SqlDscScalarQuery -Exactly -Times 1 -Scope It + } + } + + Context 'When calling the command with DateTimeFunction parameter' { + BeforeAll { + Mock -CommandName Invoke-SqlDscScalarQuery -MockWith { + return $mockDateTime + } + } + + It 'Should execute the query using function' -ForEach @( + @{ DateTimeFunction = 'SYSDATETIME'; ExpectedQuery = 'SELECT SYSDATETIME()' } + @{ DateTimeFunction = 'SYSDATETIMEOFFSET'; ExpectedQuery = 'SELECT SYSDATETIMEOFFSET()' } + @{ DateTimeFunction = 'SYSUTCDATETIME'; ExpectedQuery = 'SELECT SYSUTCDATETIME()' } + @{ DateTimeFunction = 'GETDATE'; ExpectedQuery = 'SELECT GETDATE()' } + @{ DateTimeFunction = 'GETUTCDATE'; ExpectedQuery = 'SELECT GETUTCDATE()' } + ) { + $result = Get-SqlDscDateTime -ServerObject $mockServerObject -DateTimeFunction $DateTimeFunction + + $result | Should -BeOfType [System.DateTime] + + Should -Invoke -CommandName Invoke-SqlDscScalarQuery -Exactly -Times 1 -Scope It -ParameterFilter { + $Query -eq $ExpectedQuery + } + } + } + + Context 'When calling the command with StatementTimeout parameter' { + BeforeAll { + Mock -CommandName Invoke-SqlDscScalarQuery -MockWith { + return $mockDateTime + } + } + + It 'Should execute the query with the specified timeout' { + $result = Get-SqlDscDateTime -ServerObject $mockServerObject -StatementTimeout 900 + + $result | Should -BeOfType [System.DateTime] + + Should -Invoke -CommandName Invoke-SqlDscScalarQuery -Exactly -Times 1 -Scope It -ParameterFilter { + $StatementTimeout -eq 900 + } + } + } + + Context 'When the query returns a DateTimeOffset value' { + BeforeAll { + $mockDateTimeOffset = [System.DateTimeOffset]::Parse('2023-12-13 10:30:45.1234567 -05:00') + + Mock -CommandName Invoke-SqlDscScalarQuery -MockWith { + return $mockDateTimeOffset + } + } + + It 'Should convert DateTimeOffset to DateTime and return the result' { + $result = Get-SqlDscDateTime -ServerObject $mockServerObject -DateTimeFunction 'SYSDATETIMEOFFSET' + + $result | Should -BeOfType [System.DateTime] + $result | Should -Be $mockDateTimeOffset.DateTime + + Should -Invoke -CommandName Invoke-SqlDscScalarQuery -Exactly -Times 1 -Scope It + } + } + } + + Context 'When an exception is thrown' { + BeforeAll { + $mockServerObject = New-Object -TypeName 'Microsoft.SqlServer.Management.Smo.Server' + + Mock -CommandName Invoke-SqlDscScalarQuery -MockWith { + throw 'Mocked error' + } + } + + Context 'When ErrorAction is set to Stop' { + It 'Should throw the correct error' { + { + Get-SqlDscDateTime -ServerObject $mockServerObject -ErrorAction 'Stop' + } | Should -Throw + + Should -Invoke -CommandName Invoke-SqlDscScalarQuery -Exactly -Times 1 -Scope It + } + } + + Context 'When ErrorAction is set to Ignore or SilentlyContinue' { + It 'Should not throw an exception and does not return any result' { + $result = Get-SqlDscDateTime -ServerObject $mockServerObject -ErrorAction 'Ignore' + + $result | Should -BeNullOrEmpty + + Should -Invoke -CommandName Invoke-SqlDscScalarQuery -Exactly -Times 1 -Scope It + } + } + } +} diff --git a/tests/Unit/Public/Invoke-SqlDscScalarQuery.Tests.ps1 b/tests/Unit/Public/Invoke-SqlDscScalarQuery.Tests.ps1 new file mode 100644 index 0000000000..4b630584d1 --- /dev/null +++ b/tests/Unit/Public/Invoke-SqlDscScalarQuery.Tests.ps1 @@ -0,0 +1,232 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies have not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'SqlServerDsc' + + $env:SqlServerDscCI = $true + + Import-Module -Name $script:dscModuleName -Force -ErrorAction 'Stop' + + # Loading mocked classes + Add-Type -Path (Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '../Stubs') -ChildPath 'SMO.cs') + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force + + Remove-Item -Path 'env:SqlServerDscCI' +} + +Describe 'Invoke-SqlDscScalarQuery' -Tag 'Public' { + It 'Should have the correct parameters in parameter set ' -ForEach @( + @{ + MockParameterSetName = '__AllParameterSets' + # cSpell: disable-next + MockExpectedParameters = '[-ServerObject] [-Query] [[-StatementTimeout] ] [[-RedactText] ] []' + } + ) { + $result = (Get-Command -Name 'Invoke-SqlDscScalarQuery').ParameterSets | + Where-Object -FilterScript { + $_.Name -eq $mockParameterSetName + } | + Select-Object -Property @( + @{ + Name = 'ParameterSetName' + Expression = { $_.Name } + }, + @{ + Name = 'ParameterListAsString' + Expression = { $_.ToString() } + } + ) + + $result.ParameterSetName | Should -Be $MockParameterSetName + $result.ParameterListAsString | Should -Be $MockExpectedParameters + } + + It 'Should have ServerObject as a mandatory parameter' { + $parameterInfo = (Get-Command -Name 'Invoke-SqlDscScalarQuery').Parameters['ServerObject'] + $parameterInfo.Attributes.Mandatory | Should -BeTrue + } + + It 'Should have Query as a mandatory parameter' { + $parameterInfo = (Get-Command -Name 'Invoke-SqlDscScalarQuery').Parameters['Query'] + $parameterInfo.Attributes.Mandatory | Should -BeTrue + } + + It 'Should accept ServerObject from pipeline' { + $parameterInfo = (Get-Command -Name 'Invoke-SqlDscScalarQuery').Parameters['ServerObject'] + $parameterInfo.Attributes.ValueFromPipeline | Should -BeTrue + } + + Context 'When executing a scalar query' { + BeforeAll { + $mockConnectionContext = New-Object -TypeName 'Microsoft.SqlServer.Management.Smo.ServerConnection' + $mockConnectionContext.StatementTimeout = 100 + $mockConnectionContext | Add-Member -MemberType 'ScriptMethod' -Name 'ExecuteScalar' -Value { + param + ( + [Parameter()] + [System.String] + $sqlCommand + ) + + $script:mockMethodExecuteScalarCallCount += 1 + + return $script:mockExecuteScalarResult + } -Force + + $mockServerObject = New-Object -TypeName 'Microsoft.SqlServer.Management.Smo.Server' | + Add-Member -MemberType 'NoteProperty' -Name 'ConnectionContext' -Value $mockConnectionContext -PassThru -Force + } + + BeforeEach { + $script:mockMethodExecuteScalarCallCount = 0 + $script:mockExecuteScalarResult = $null + } + + Context 'When calling the command with only mandatory parameters' { + It 'Should execute the query without throwing and return the scalar result' { + $script:mockExecuteScalarResult = 'TestResult' + + $result = Invoke-SqlDscScalarQuery -ServerObject $mockServerObject -Query 'SELECT @@VERSION' + + $result | Should -Be 'TestResult' + + $mockMethodExecuteScalarCallCount | Should -Be 1 + } + } + + Context 'When passing parameter ServerObject over the pipeline' { + It 'Should execute the query without throwing and return the scalar result' { + $script:mockExecuteScalarResult = '12345' + + $result = $mockServerObject | Invoke-SqlDscScalarQuery -Query 'SELECT 12345' + + $result | Should -Be '12345' + + $mockMethodExecuteScalarCallCount | Should -Be 1 + } + } + + Context 'When calling the command with optional parameter StatementTimeout' { + It 'Should execute the query without throwing and return the scalar result' { + $script:mockExecuteScalarResult = 42 + + $result = Invoke-SqlDscScalarQuery -StatementTimeout 900 -ServerObject $mockServerObject -Query 'SELECT 42' + + $result | Should -Be 42 + + $mockMethodExecuteScalarCallCount | Should -Be 1 + } + } + + Context 'When calling the command with optional parameter RedactText' { + It 'Should execute the query without throwing and return the scalar result' { + $script:mockExecuteScalarResult = 'Success' + + $result = Invoke-SqlDscScalarQuery -RedactText @('MySecret') -ServerObject $mockServerObject -Query 'SELECT MySecret' + + $result | Should -Be 'Success' + + $mockMethodExecuteScalarCallCount | Should -Be 1 + } + } + + Context 'When the query returns a DateTime value' { + It 'Should return the DateTime value' { + $script:mockExecuteScalarResult = [System.DateTime]::Parse('2023-01-01 12:00:00') + + $result = Invoke-SqlDscScalarQuery -ServerObject $mockServerObject -Query 'SELECT SYSDATETIME()' + + $result | Should -BeOfType [System.DateTime] + $result | Should -Be ([System.DateTime]::Parse('2023-01-01 12:00:00')) + + $mockMethodExecuteScalarCallCount | Should -Be 1 + } + } + + Context 'When the query returns null' { + It 'Should return null' { + $script:mockExecuteScalarResult = $null + + $result = Invoke-SqlDscScalarQuery -ServerObject $mockServerObject -Query 'SELECT NULL' + + $result | Should -BeNullOrEmpty + + $mockMethodExecuteScalarCallCount | Should -Be 1 + } + } + } + + Context 'When an exception is thrown' { + BeforeAll { + $mockConnectionContext = New-Object -TypeName 'Microsoft.SqlServer.Management.Smo.ServerConnection' + $mockConnectionContext.StatementTimeout = 100 + $mockConnectionContext | Add-Member -MemberType 'ScriptMethod' -Name 'ExecuteScalar' -Value { + $script:mockMethodExecuteScalarCallCount += 1 + + throw 'Mocked error' + } -Force + + $mockServerObject = New-Object -TypeName 'Microsoft.SqlServer.Management.Smo.Server' | + Add-Member -MemberType 'NoteProperty' -Name 'ConnectionContext' -Value $mockConnectionContext -PassThru -Force + } + + BeforeEach { + $script:mockMethodExecuteScalarCallCount = 0 + } + + Context 'When ErrorAction is set to Stop' { + It 'Should throw the correct error' { + { + Invoke-SqlDscScalarQuery -ServerObject $mockServerObject -Query 'SELECT invalid' -ErrorAction 'Stop' + } | Should -Throw -ExpectedMessage '*Mocked error*' + + $mockMethodExecuteScalarCallCount | Should -Be 1 + } + } + + Context 'When ErrorAction is set to Ignore' { + It 'Should not throw an exception and does not return any result' { + $result = Invoke-SqlDscScalarQuery -ServerObject $mockServerObject -Query 'SELECT invalid' -ErrorAction 'Ignore' + + $result | Should -BeNullOrEmpty + + $mockMethodExecuteScalarCallCount | Should -Be 1 + } + } + } +} diff --git a/tests/Unit/Stubs/SMO.cs b/tests/Unit/Stubs/SMO.cs index 17cdacaeb0..778d0e6448 100644 --- a/tests/Unit/Stubs/SMO.cs +++ b/tests/Unit/Stubs/SMO.cs @@ -1323,12 +1323,21 @@ public void Create() // TypeName: Microsoft.SqlServer.Management.Common.ServerConnection // Used by: // SqlAGDatabase + // Invoke-SqlDscScalarQuery public class ServerConnection { public string TrueLogin; + public int StatementTimeout; public void Create() {} + + // Method: ExecuteScalar + // Used for testing scalar query execution in Invoke-SqlDscScalarQuery + public object ExecuteScalar(string query) + { + return null; + } } // TypeName: Microsoft.SqlServer.Management.Smo.AvailabilityDatabase