diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/CSVCommands.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/CSVCommands.cs index 0c40089205..5a2cd9e9ff 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/CSVCommands.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/CSVCommands.cs @@ -59,8 +59,36 @@ namespace Microsoft.PowerShell.Commands [Alias("NTI")] public SwitchParameter NoTypeInformation { get; set; } = true; + /// + /// Gets or sets option to use or suppress quotes in output. + /// + [Parameter] + [Alias("UQ")] + public QuoteKind UseQuotes { get; set; } = QuoteKind.Always; + #endregion Command Line Parameters + /// + /// Kind of output quoting. + /// + public enum QuoteKind + { + /// + /// Never quote output. + /// + Never, + + /// + /// Always quote output. + /// + Always, + + /// + /// Quote output as needed (a field contains used delimiter). + /// + AsNeeded + } + /// /// Write the string to a file or pipeline. /// @@ -217,7 +245,7 @@ namespace Microsoft.PowerShell.Commands CreateFileStream(); - _helper = new ExportCsvHelper(this, base.Delimiter); + _helper = new ExportCsvHelper(base.Delimiter, base.UseQuotes); } /// @@ -644,7 +672,7 @@ namespace Microsoft.PowerShell.Commands protected override void BeginProcessing() { base.BeginProcessing(); - _helper = new ExportCsvHelper(this, base.Delimiter); + _helper = new ExportCsvHelper(base.Delimiter, base.UseQuotes); } /// @@ -806,28 +834,24 @@ namespace Microsoft.PowerShell.Commands #region ExportHelperConversion /// - /// Helper class for Export-Csvlper. + /// Helper class for Export-Csv and ConvertTo-Csv. /// internal class ExportCsvHelper : IDisposable { - private PSCmdlet _cmdlet; - private char _delimiter; + readonly private BaseCsvWritingCommand.QuoteKind _quoteKind; + readonly private StringBuilder _outputString; /// + /// Create ExportCsvHelper instance. /// - /// - /// - /// Throw if cmdlet is null. - internal ExportCsvHelper(PSCmdlet cmdlet, char delimiter) + /// Delimiter char. + /// Kind of quoting. + internal ExportCsvHelper(char delimiter, BaseCsvWritingCommand.QuoteKind quoteKind) { - if (cmdlet == null) - { - throw new ArgumentNullException("cmdlet"); - } - - _cmdlet = cmdlet; _delimiter = delimiter; + _quoteKind = quoteKind; + _outputString = new StringBuilder(128); } // Name of properties to be written in CSV format @@ -865,11 +889,12 @@ namespace Microsoft.PowerShell.Commands { if (propertyNames == null) { - throw new ArgumentNullException("propertyNames"); + throw new ArgumentNullException(nameof(propertyNames)); } - StringBuilder dest = new StringBuilder(); + _outputString.Clear(); bool first = true; + foreach (string propertyName in propertyNames) { if (first) @@ -878,14 +903,32 @@ namespace Microsoft.PowerShell.Commands } else { - // changed to delimiter - dest.Append(_delimiter); + _outputString.Append(_delimiter); } - EscapeAndAppendString(dest, propertyName); + switch (_quoteKind) + { + case BaseCsvWritingCommand.QuoteKind.Always: + AppendStringWithEscapeAlways(_outputString, propertyName); + break; + case BaseCsvWritingCommand.QuoteKind.AsNeeded: + if (propertyName.Contains(_delimiter)) + { + AppendStringWithEscapeAlways(_outputString, propertyName); + } + else + { + _outputString.Append(propertyName); + } + + break; + case BaseCsvWritingCommand.QuoteKind.Never: + _outputString.Append(propertyName); + break; + } } - return dest.ToString(); + return _outputString.ToString(); } /// @@ -898,10 +941,10 @@ namespace Microsoft.PowerShell.Commands { if (propertyNames == null) { - throw new ArgumentNullException("propertyNames"); + throw new ArgumentNullException(nameof(propertyNames)); } - StringBuilder dest = new StringBuilder(); + _outputString.Clear(); bool first = true; foreach (string propertyName in propertyNames) @@ -912,21 +955,41 @@ namespace Microsoft.PowerShell.Commands } else { - dest.Append(_delimiter); + _outputString.Append(_delimiter); } - PSPropertyInfo property = mshObject.Properties[propertyName] as PSPropertyInfo; - string value = null; - // If property is not present, assume value is null - if (property != null) + // If property is not present, assume value is null and skip it. + if (mshObject.Properties[propertyName] is PSPropertyInfo property) { - value = GetToStringValueForProperty(property); - } + var value = GetToStringValueForProperty(property); - EscapeAndAppendString(dest, value); + switch (_quoteKind) + { + case BaseCsvWritingCommand.QuoteKind.Always: + AppendStringWithEscapeAlways(_outputString, value); + break; + case BaseCsvWritingCommand.QuoteKind.AsNeeded: + if (value.Contains(_delimiter)) + { + AppendStringWithEscapeAlways(_outputString, value); + } + else + { + _outputString.Append(value); + } + + break; + case BaseCsvWritingCommand.QuoteKind.Never: + _outputString.Append(value); + break; + default: + Diagnostics.Assert(false, "BaseCsvWritingCommand.QuoteKind has new item."); + break; + } + } } - return dest.ToString(); + return _outputString.ToString(); } /// @@ -938,7 +1001,7 @@ namespace Microsoft.PowerShell.Commands { if (property == null) { - throw new ArgumentNullException("property"); + throw new ArgumentNullException(nameof(property)); } string value = null; @@ -999,12 +1062,13 @@ namespace Microsoft.PowerShell.Commands /// Escapes the " in string if necessary. /// Encloses the string in double quotes if necessary. /// - internal static void EscapeAndAppendString(StringBuilder dest, string source) + internal static void AppendStringWithEscapeAlways(StringBuilder dest, string source) { if (source == null) { return; } + // Adding Double quote to all strings dest.Append('"'); for (int i = 0; i < source.Length; i++) @@ -1102,12 +1166,12 @@ namespace Microsoft.PowerShell.Commands { if (cmdlet == null) { - throw new ArgumentNullException("cmdlet"); + throw new ArgumentNullException(nameof(cmdlet)); } if (streamReader == null) { - throw new ArgumentNullException("streamReader"); + throw new ArgumentNullException(nameof(streamReader)); } _cmdlet = cmdlet; diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Csv.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Csv.Tests.ps1 index f5f8c18ee6..edb67e1883 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Csv.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Csv.Tests.ps1 @@ -19,16 +19,15 @@ Describe "ConvertTo-Csv DRT Unit Tests" -Tags "CI" { } It "Test convertto-csv with a useculture flag" { - #The default value is ',' + $delimiter = [CultureInfo]::CurrentCulture.TextInfo.ListSeparator $returnObject = $inputObject | ConvertTo-Csv -UseCulture -IncludeTypeInformation $returnObject.Count | Should -Be 3 $returnObject[0] | Should -BeExactly "#TYPE System.Management.Automation.PSCustomObject" - $returnObject[1] | Should -BeExactly "`"First`",`"Second`"" - $returnObject[2] | Should -BeExactly "`"1`",`"2`"" + $returnObject[1] | Should -BeExactly "`"First`"$($delimiter)`"Second`"" + $returnObject[2] | Should -BeExactly "`"1`"$($delimiter)`"2`"" } It "Test convertto-csv with Delimiter" { - #The default value is ',' $returnObject = $inputObject | ConvertTo-Csv -Delimiter ";" -IncludeTypeInformation $returnObject.Count | Should -Be 3 $returnObject[0] | Should -BeExactly "#TYPE System.Management.Automation.PSCustomObject" @@ -38,11 +37,13 @@ Describe "ConvertTo-Csv DRT Unit Tests" -Tags "CI" { } Describe "ConvertTo-Csv" -Tags "CI" { - $Name = "Hello"; $Data = "World"; - $testObject = New-Object psobject -Property @{ FirstColumn = $Name; SecondColumn = $Data } + BeforeAll { + $Name = "Hello"; $Data = "World"; + $testObject = [pscustomobject]@{ FirstColumn = $Name; SecondColumn = $Data } + } It "Should Be able to be called without error" { - { $testObject | ConvertTo-Csv } | Should -Not -Throw + { $testObject | ConvertTo-Csv } | Should -Not -Throw } It "Should output an array of objects" { @@ -51,22 +52,22 @@ Describe "ConvertTo-Csv" -Tags "CI" { } It "Should return the type of data in the first element of the output array" { - $result = $testObject | ConvertTo-Csv -IncludeTypeInformation + $result = $testObject | ConvertTo-Csv -IncludeTypeInformation - $result[0] | Should -BeExactly "#TYPE System.Management.Automation.PSCustomObject" + $result[0] | Should -BeExactly "#TYPE System.Management.Automation.PSCustomObject" } It "Should return the column info in the second element of the output array" { - $result = $testObject | ConvertTo-Csv -IncludeTypeInformation + $result = $testObject | ConvertTo-Csv -IncludeTypeInformation - $result[1] | Should -Match "`"FirstColumn`"" - $result[1] | Should -Match "`"SecondColumn`"" + $result[1] | Should -Match "`"FirstColumn`"" + $result[1] | Should -Match "`"SecondColumn`"" } It "Should return the data as a comma-separated list in the third element of the output array" { - $result = $testObject | ConvertTo-Csv -IncludeTypeInformation - $result[2] | Should -Match "`"Hello`"" - $result[2] | Should -Match "`"World`"" + $result = $testObject | ConvertTo-Csv -IncludeTypeInformation + $result[2] | Should -Match "`"Hello`"" + $result[2] | Should -Match "`"World`"" } It "Includes type information when -IncludeTypeInformation is supplied" { @@ -76,7 +77,7 @@ Describe "ConvertTo-Csv" -Tags "CI" { } It "Does not include type information by default" { - $result = $testObject | ConvertTo-Csv + $result = $testObject | ConvertTo-Csv $result | Should -Not -Match ([regex]::Escape('System.Management.Automation.PSCustomObject')) $result | Should -Not -Match ([regex]::Escape('#TYPE')) @@ -90,8 +91,37 @@ Describe "ConvertTo-Csv" -Tags "CI" { } It "Does not support -IncludeTypeInformation and -NoTypeInformation at the same time" { - { $testObject | ConvertTo-Csv -IncludeTypeInformation -NoTypeInformation } | + { $testObject | ConvertTo-Csv -IncludeTypeInformation -NoTypeInformation } | Should -Throw -ErrorId "CannotSpecifyIncludeTypeInformationAndNoTypeInformation,Microsoft.PowerShell.Commands.ConvertToCsvCommand" } + Context "UseQuotes parameter" { + It "UseQuotes Always" { + $result = $testObject | ConvertTo-Csv -UseQuotes Always -Delimiter ',' + + $result[0] | Should -BeExactly "`"FirstColumn`",`"SecondColumn`"" + $result[1] | Should -BeExactly "`"Hello`",`"World`"" + } + + It "UseQuotes Always is default" { + $result = $testObject | ConvertTo-Csv -UseQuotes Always -Delimiter ',' + $result2 = $testObject | ConvertTo-Csv -Delimiter ',' + + $result | Should -BeExactly $result2 + } + + It "UseQuotes Never" { + $result = $testObject | ConvertTo-Csv -UseQuotes Never -Delimiter ',' + + $result[0] | Should -BeExactly "FirstColumn,SecondColumn" + $result[1] | Should -BeExactly "Hello,World" + } + + It "UseQuotes AsNeeded" { + $result = $testObject | ConvertTo-Csv -UseQuotes AsNeeded -Delimiter 'r' + + $result[0] | Should -BeExactly "`"FirstColumn`"rSecondColumn" + $result[1] | Should -BeExactly "Hellor`"World`"" + } + } } diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/Export-Csv.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/Export-Csv.Tests.ps1 index f5e93a4444..e54544baac 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/Export-Csv.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/Export-Csv.Tests.ps1 @@ -233,4 +233,33 @@ Describe "Export-Csv" -Tags "CI" { $contents[0].Contains($delimiter) | Should -BeTrue $contents[1].Contains($delimiter) | Should -BeTrue } + + Context "UseQuotes parameter" { + + # A minimum of tests. The rest are in ConvertTo-Csv.Tests.ps1 + + BeforeAll { + $Name = "Hello"; $Data = "World"; + $testOutputObject = [pscustomobject]@{ FirstColumn = $Name; SecondColumn = $Data } + $testFile = Join-Path -Path $TestDrive -ChildPath "output.csv" + $testFile2 = Join-Path -Path $TestDrive -ChildPath "output2.csv" + } + + It "UseQuotes Always" { + $testOutputObject | Export-Csv -Path $testFile -UseQuotes Always -Delimiter ',' + $result = Get-Content -Path $testFile + + $result[0] | Should -BeExactly "`"FirstColumn`",`"SecondColumn`"" + $result[1] | Should -BeExactly "`"Hello`",`"World`"" + } + + It "UseQuotes Always is default" { + $testOutputObject | Export-Csv -Path $testFile -UseQuotes Always -Delimiter ',' + $result = Get-Content -Raw -Path $testFile + $testOutputObject | Export-Csv -Path $testFile2 -Delimiter ',' + $result2 = Get-Content -Raw -Path $testFile2 + + $result | Should -BeExactly $result2 + } + } }