diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/MultipartIdentifier.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/MultipartIdentifier.cs index b89a080e99..8e3ee0b7d4 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/MultipartIdentifier.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/MultipartIdentifier.cs @@ -2,30 +2,29 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics; using System.Text; +#nullable enable + namespace Microsoft.Data.Common { internal class MultipartIdentifier { + private const char IdentifierSeparator = '.'; + // The indexes of identifier start and end characters in these strings need to + // align 1-to-1. An identifier which starts with [ can only end with ]. As such, + // both of the below constants must be the same length. + // Separately, neither constant may contain the identifier separator. + private const string IdentifierStartCharacters = "[\""; + private const string IdentifierEndCharacters = "]\""; + private const int MaxParts = 4; internal const int ServerIndex = 0; internal const int CatalogIndex = 1; internal const int SchemaIndex = 2; internal const int TableIndex = 3; - /* - Left quote strings need to correspond 1 to 1 with the right quote strings - example: "ab" "cd", passed in for the left and the right quote - would set a or b as a starting quote character. - If a is the starting quote char then c would be the ending quote char - otherwise if b is the starting quote char then d would be the ending quote character. - */ - internal static string[] ParseMultipartIdentifier(string name, string leftQuote, string rightQuote, string property, bool ThrowOnEmptyMultipartName) - { - return ParseMultipartIdentifier(name, leftQuote, rightQuote, '.', MaxParts, true, property, ThrowOnEmptyMultipartName); - } - private enum MPIState { MPI_Value, @@ -36,83 +35,74 @@ private enum MPIState MPI_RightQuote, } - /* Core function for parsing the multipart identifier string. - * parameters: name - string to parse - * leftquote: set of characters which are valid quoting characters to initiate a quote - * rightquote: set of characters which are valid to stop a quote, array index's correspond to the leftquote array. - * separator: separator to use - * limit: number of names to parse out - * removequote:to remove the quotes on the returned string - */ - private static void IncrementStringCount(string name, string[] ary, ref int position, string property) + private static void IncrementStringCount(string identifier, string?[] ary, ref int position, string property) { ++position; int limit = ary.Length; if (position >= limit) { - throw ADP.InvalidMultipartNameToManyParts(property, name, limit); + throw ADP.InvalidMultipartNameToManyParts(property, identifier, limit); } ary[position] = string.Empty; } - private static bool IsWhitespace(char ch) - { - return char.IsWhiteSpace(ch); - } + private static bool ContainsChar(string str, char ch) => +#if NET + str.Contains(ch); +#else + str.IndexOf(ch) != -1; +#endif - internal static string[] ParseMultipartIdentifier(string name, string leftQuote, string rightQuote, char separator, int limit, bool removequotes, string property, bool ThrowOnEmptyMultipartName) + /// + /// Core function for parsing the multipart identifier string. + /// + /// String to parse. + /// Name of the property containing the multipart identifier. If an exception is thrown, its message will include this property name. + /// If true, throw if the multipart identifier is whitespace. + /// Number of parts to parse out. Defaults to four (to allow for an identifier formatted as [server].[database].[schema].[object].) + /// An array of strings containing the various parts in the identifier. + internal static string?[] ParseMultipartIdentifier(string identifier, string property, bool throwOnEmptyMultipartIdentifier, int limit = MaxParts) { - if (limit <= 0) - { - throw ADP.InvalidMultipartNameToManyParts(property, name, limit); - } + Debug.Assert(limit > 0 && limit <= MaxParts); - if (-1 != leftQuote.IndexOf(separator) || -1 != rightQuote.IndexOf(separator) || leftQuote.Length != rightQuote.Length) - { - throw ADP.InvalidMultipartNameIncorrectUsageOfQuotes(property, name); - } - - string[] parsedNames = new string[limit]; // return string array + string?[] parts = new string?[limit]; // return string array int stringCount = 0; // index of current string in the buffer MPIState state = MPIState.MPI_Value; // Initialize the starting state - StringBuilder sb = new StringBuilder(name.Length); // String buffer to hold the string being currently built, init the string builder so it will never be resized - StringBuilder whitespaceSB = null; // String buffer to hold whitespace used when parsing nonquoted strings 'a b . c d' = 'a b' and 'c d' - char rightQuoteChar = ' '; // Right quote character to use given the left quote character found. - for (int index = 0; index < name.Length; ++index) + StringBuilder sb = new StringBuilder(identifier.Length); // String buffer to hold the string being currently built, init the string builder so it will never be resized + StringBuilder? whitespaceSB = null; // String buffer to hold whitespace used when parsing nonquoted strings 'a b . c d' = 'a b' and 'c d' + char rightQuoteChar = ' '; // Right quote character to use given the left quote character found. + for (int index = 0; index < identifier.Length; ++index) { - char testchar = name[index]; + char testchar = identifier[index]; switch (state) { case MPIState.MPI_Value: { int quoteIndex; - if (IsWhitespace(testchar)) - { // Is White Space then skip the whitespace + if (char.IsWhiteSpace(testchar)) + { + // Skip whitespace continue; } - else - if (testchar == separator) - { // If we found a separator, no string was found, initialize the string we are parsing to Empty and the next one to Empty. - // This is NOT a redundant setting of string.Empty it solves the case where we are parsing ".foo" and we should be returning null, null, empty, foo - parsedNames[stringCount] = string.Empty; - IncrementStringCount(name, parsedNames, ref stringCount, property); + else if (testchar == IdentifierSeparator) + { + // If we found a separator, no string was found, initialize the string we are parsing to Empty and the next one to Empty. + // This is NOT a redundant setting of string.Empty. It solves the case where we are parsing ".foo" and we should be returning null, null, empty, foo + parts[stringCount] = string.Empty; + IncrementStringCount(identifier, parts, ref stringCount, property); } - else - if (-1 != (quoteIndex = leftQuote.IndexOf(testchar))) - { // If we are a left quote - rightQuoteChar = rightQuote[quoteIndex]; // record the corresponding right quote for the left quote + else if ((quoteIndex = IdentifierStartCharacters.IndexOf(testchar)) != -1) + { + // If we are a left quote, record the corresponding right quote for the left quote + rightQuoteChar = IdentifierEndCharacters[quoteIndex]; sb.Length = 0; - if (!removequotes) - { - sb.Append(testchar); - } state = MPIState.MPI_ParseQuote; } - else - if (-1 != rightQuote.IndexOf(testchar)) - { // If we shouldn't see a right quote - throw ADP.InvalidMultipartNameIncorrectUsageOfQuotes(property, name); + else if (ContainsChar(IdentifierEndCharacters, testchar)) + { + // If we shouldn't see a right quote + throw ADP.InvalidMultipartNameIncorrectUsageOfQuotes(property, identifier); } else { @@ -125,32 +115,31 @@ internal static string[] ParseMultipartIdentifier(string name, string leftQuote, case MPIState.MPI_ParseNonQuote: { - if (testchar == separator) + if (testchar == IdentifierSeparator) { - parsedNames[stringCount] = sb.ToString(); // set the currently parsed string - IncrementStringCount(name, parsedNames, ref stringCount, property); + // Set the currently parsed string + parts[stringCount] = sb.ToString(); + IncrementStringCount(identifier, parts, ref stringCount, property); state = MPIState.MPI_Value; } - else // Quotes are not valid inside a non-quoted name - if (-1 != rightQuote.IndexOf(testchar)) + else if (ContainsChar(IdentifierEndCharacters, testchar)) { - throw ADP.InvalidMultipartNameIncorrectUsageOfQuotes(property, name); + // Quotes are not valid inside a non-quoted identifier + throw ADP.InvalidMultipartNameIncorrectUsageOfQuotes(property, identifier); } - else - if (-1 != leftQuote.IndexOf(testchar)) + else if (ContainsChar(IdentifierStartCharacters, testchar)) { - throw ADP.InvalidMultipartNameIncorrectUsageOfQuotes(property, name); + throw ADP.InvalidMultipartNameIncorrectUsageOfQuotes(property, identifier); } - else - if (IsWhitespace(testchar)) - { // If it is Whitespace - parsedNames[stringCount] = sb.ToString(); // Set the currently parsed string - if (whitespaceSB == null) - { - whitespaceSB = new StringBuilder(); - } + else if (char.IsWhiteSpace(testchar)) + { + // If it is whitespace, set the currently parsed string + parts[stringCount] = sb.ToString(); + + whitespaceSB ??= new StringBuilder(); + // Start to record the whitespace. If we are parsing an identifier like "foo bar" we should return "foo bar" whitespaceSB.Length = 0; - whitespaceSB.Append(testchar); // start to record the whitespace, if we are parsing a name like "foo bar" we should return "foo bar" + whitespaceSB.Append(testchar); state = MPIState.MPI_LookForNextCharOrSeparator; } else @@ -162,23 +151,22 @@ internal static string[] ParseMultipartIdentifier(string name, string leftQuote, case MPIState.MPI_LookForNextCharOrSeparator: { - if (!IsWhitespace(testchar)) - { // If it is not whitespace - if (testchar == separator) - { - IncrementStringCount(name, parsedNames, ref stringCount, property); - state = MPIState.MPI_Value; - } - else - { // If its not a separator and not whitespace - sb.Append(whitespaceSB); - sb.Append(testchar); - parsedNames[stringCount] = sb.ToString(); // Need to set the name here in case the string ends here. - state = MPIState.MPI_ParseNonQuote; - } + if (testchar == IdentifierSeparator) + { + IncrementStringCount(identifier, parts, ref stringCount, property); + state = MPIState.MPI_Value; + } + else if (!char.IsWhiteSpace(testchar)) + { + sb.Append(whitespaceSB); + sb.Append(testchar); + // Need to set the identifier part here in case the string ends here. + parts[stringCount] = sb.ToString(); + state = MPIState.MPI_ParseNonQuote; } else { + whitespaceSB ??= new StringBuilder(); whitespaceSB.Append(testchar); } break; @@ -186,17 +174,14 @@ internal static string[] ParseMultipartIdentifier(string name, string leftQuote, case MPIState.MPI_ParseQuote: { + // If we are on a right quote, see if we are escaping the right quote or ending the quoted string if (testchar == rightQuoteChar) - { // if se are on a right quote see if we are escaping the right quote or ending the quoted string - if (!removequotes) - { - sb.Append(testchar); - } + { state = MPIState.MPI_RightQuote; } else { - sb.Append(testchar); // Append what we are currently parsing + sb.Append(testchar); } break; } @@ -204,25 +189,27 @@ internal static string[] ParseMultipartIdentifier(string name, string leftQuote, case MPIState.MPI_RightQuote: { if (testchar == rightQuoteChar) - { // If the next char is another right quote then we were escaping the right quote + { + // If the next char is another right quote then we were escaping the right quote sb.Append(testchar); state = MPIState.MPI_ParseQuote; } - else - if (testchar == separator) - { // If its a separator then record what we've parsed - parsedNames[stringCount] = sb.ToString(); - IncrementStringCount(name, parsedNames, ref stringCount, property); + else if (testchar == IdentifierSeparator) + { + // If it's a separator then record what we've parsed + parts[stringCount] = sb.ToString(); + IncrementStringCount(identifier, parts, ref stringCount, property); state = MPIState.MPI_Value; } - else - if (!IsWhitespace(testchar)) - { // If it is not whitespace we got problems - throw ADP.InvalidMultipartNameIncorrectUsageOfQuotes(property, name); + else if (!char.IsWhiteSpace(testchar)) + { + // If it is not whitespace then we have problems + throw ADP.InvalidMultipartNameIncorrectUsageOfQuotes(property, identifier); } else - { // It is a whitespace character so the following char should be whitespace, separator, or end of string anything else is bad - parsedNames[stringCount] = sb.ToString(); + { + // It is a whitespace character so the following char should be whitespace, separator, or end of string. Anything else is bad + parts[stringCount] = sb.ToString(); state = MPIState.MPI_LookForSeparator; } break; @@ -230,17 +217,16 @@ internal static string[] ParseMultipartIdentifier(string name, string leftQuote, case MPIState.MPI_LookForSeparator: { - if (!IsWhitespace(testchar)) - { // If it is not whitespace - if (testchar == separator) - { // If it is a separator - IncrementStringCount(name, parsedNames, ref stringCount, property); - state = MPIState.MPI_Value; - } - else - { // Otherwise not a separator - throw ADP.InvalidMultipartNameIncorrectUsageOfQuotes(property, name); - } + if (testchar == IdentifierSeparator) + { + // If it is a separator + IncrementStringCount(identifier, parts, ref stringCount, property); + state = MPIState.MPI_Value; + } + else if (!char.IsWhiteSpace(testchar)) + { + // Otherwise not a separator + throw ADP.InvalidMultipartNameIncorrectUsageOfQuotes(property, identifier); } break; } @@ -250,42 +236,46 @@ internal static string[] ParseMultipartIdentifier(string name, string leftQuote, // Resolve final states after parsing the string switch (state) { - case MPIState.MPI_Value: // These states require no extra action + // These states require no extra action + case MPIState.MPI_Value: case MPIState.MPI_LookForSeparator: case MPIState.MPI_LookForNextCharOrSeparator: break; - case MPIState.MPI_ParseNonQuote: // Dump what ever was parsed + // Dump whatever was parsed + case MPIState.MPI_ParseNonQuote: case MPIState.MPI_RightQuote: - parsedNames[stringCount] = sb.ToString(); + parts[stringCount] = sb.ToString(); break; - case MPIState.MPI_ParseQuote: // Invalid Ending States + // Invalid Ending States + case MPIState.MPI_ParseQuote: default: - throw ADP.InvalidMultipartNameIncorrectUsageOfQuotes(property, name); + throw ADP.InvalidMultipartNameIncorrectUsageOfQuotes(property, identifier); } - if (parsedNames[0] == null) + if (parts[0] == null) { - if (ThrowOnEmptyMultipartName) + // Identifier is entirely made up of whitespace + if (throwOnEmptyMultipartIdentifier) { - throw ADP.InvalidMultipartName(property, name); // Name is entirely made up of whitespace + throw ADP.InvalidMultipartName(property, identifier); } } else { - // Shuffle the parsed name, from left justification to right justification, i.e. [a][b][null][null] goes to [null][null][a][b] + // Shuffle the identifier parts, from left justification to right justification, i.e. [a][b][null][null] goes to [null][null][a][b] int offset = limit - stringCount - 1; if (offset > 0) { for (int x = limit - 1; x >= offset; --x) { - parsedNames[x] = parsedNames[x - offset]; - parsedNames[x - offset] = null; + parts[x] = parts[x - offset]; + parts[x - offset] = null; } } } - return parsedNames; + return parts; } } } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlBulkCopy.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlBulkCopy.cs index 39e4f570c7..980b066e80 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlBulkCopy.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlBulkCopy.cs @@ -411,7 +411,7 @@ private string CreateInitialQuery() string[] parts; try { - parts = MultipartIdentifier.ParseMultipartIdentifier(DestinationTableName, "[\"", "]\"", Strings.SQL_BulkCopyDestinationTableName, true); + parts = MultipartIdentifier.ParseMultipartIdentifier(DestinationTableName, Strings.SQL_BulkCopyDestinationTableName, true); } catch (Exception e) { @@ -542,7 +542,7 @@ private string AnalyzeTargetAndCreateUpdateBulkCommand(BulkCopySimpleResultSet i throw SQL.BulkLoadNoCollation(); } - string[] parts = MultipartIdentifier.ParseMultipartIdentifier(DestinationTableName, "[\"", "]\"", Strings.SQL_BulkCopyDestinationTableName, true); + string[] parts = MultipartIdentifier.ParseMultipartIdentifier(DestinationTableName, Strings.SQL_BulkCopyDestinationTableName, true); updateBulkCommandText.AppendFormat("insert bulk {0} (", ADP.BuildMultiPartName(parts)); // Throw if there is a transaction but no flag is set diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs index f3a85723c6..c073bef4d9 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs @@ -1291,11 +1291,9 @@ internal void DeriveParameters() // Use common parser for SqlClient and OleDb - parse into 4 parts - Server, Catalog, // Schema, ProcedureName string[] parsedSProc = MultipartIdentifier.ParseMultipartIdentifier( - name: CommandText, - leftQuote: "[\"", - rightQuote: "]\"", + identifier: CommandText, property: Strings.SQL_SqlCommandCommandText, - ThrowOnEmptyMultipartName: false); + throwOnEmptyMultipartIdentifier: false); if (string.IsNullOrEmpty(parsedSProc[3])) { @@ -2809,10 +2807,8 @@ private void SetUpRPCParameters(_SqlRPC rpc, bool inSchema, SqlParameterCollecti { string[] parts = MultipartIdentifier.ParseMultipartIdentifier( parameter.TypeName, - leftQuote: "[\"", - rightQuote: "]\"", property: Strings.SQL_TDSParserTableName, - ThrowOnEmptyMultipartName: false); + throwOnEmptyMultipartIdentifier: false); // @TODO: Combine this and inner if statement if (parts?.Length == 4) { diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs index e9d18d9f66..2defc2e950 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs @@ -2514,7 +2514,7 @@ internal static string[] ParseTypeName(string typeName, bool isUdtTypeName) try { string errorMsg = isUdtTypeName ? Strings.SQL_UDTTypeName : Strings.SQL_TypeName; - return MultipartIdentifier.ParseMultipartIdentifier(typeName, "[\"", "]\"", '.', 3, true, errorMsg, true); + return MultipartIdentifier.ParseMultipartIdentifier(typeName, errorMsg, true, limit: 3); } catch (ArgumentException) { diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserHelperClasses.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserHelperClasses.cs index f189030d1e..06965068b3 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserHelperClasses.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserHelperClasses.cs @@ -765,7 +765,7 @@ private void ParseMultipartName() { if (_multipartName != null) { - string[] parts = MultipartIdentifier.ParseMultipartIdentifier(_multipartName, "[\"", "]\"", Strings.SQL_TDSParserTableName, false); + string[] parts = MultipartIdentifier.ParseMultipartIdentifier(_multipartName, Strings.SQL_TDSParserTableName, false); _serverName = parts[0]; _catalogName = parts[1]; _schemaName = parts[2]; diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj index ec13e9a857..9b33898dcf 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj @@ -15,7 +15,6 @@ - diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/MultipartIdentifierTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/MultipartIdentifierTests.cs deleted file mode 100644 index 6e6ba3a751..0000000000 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/MultipartIdentifierTests.cs +++ /dev/null @@ -1,260 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using Microsoft.Data.Common; -using Xunit; - -namespace Microsoft.Data.SqlClient.Tests -{ - public class MultipartIdentifierTests - { - [Fact] - public void SingleUnquoted() => RunParse("foo", new[] { "foo" }); - - [Fact] - public void SingleUnquotedOvercount() => RunParse("foo", new[] { null, "foo" }, maxCount: 2); - - [Fact] - public void SingleUnquotedContainsWhitespace() => RunParse("foo bar", new[] { "foo bar" }); - - [Fact] - public void SingleUnquotedStartWithShitespace() => RunParse(" foo", new[] { "foo" }); - - [Fact] - public void SingleUnquotedEndWithShitespace() => RunParse("foo ", new[] { "foo" }); - - [Fact] - public void SingleQuotedRemoveQuote() => RunParse("[foo]", new[] { "[foo]" }, false); - - [Fact] - public void SingleQuotedKeepQuote() => RunParse("[foo]", new[] { "foo" }, true); - - [Fact] - public void SingleQuotedLeadingWhitespace() => RunParse("[ foo]", new[] { " foo" }, true); - - [Fact] - public void SingleQuotedTrailingWhitespace() => RunParse("[foo ]", new[] { "foo " }, true); - - [Fact] - public void QuotedContainsWhitespace() => RunParse("[foo bar]", new[] { "foo bar" }, true); - - [Fact] - public void SingleQuotedContainsAndTrailingWhitespace() => RunParse("[foo bar ]", new[] { "foo bar " }); - - [Fact] - public void SingleQuotedInternalAndLeadingWhitespace() => RunParse("[ foo bar]", new[] { " foo bar" }); - - [Fact] - public void SingleQuotedContainsAndLeadingAndTrailingWhitespace() => RunParse("[ foo bar ]", new[] { " foo bar " }); - - [Fact] - public void SingleQuotedEscapedQuote() => RunParse("[foo]]bar]", new[] { "foo]bar" }, true); - - - [Fact] - public void DoubleUnquotedParts() => RunParse("foo.bar", new[] { "foo", "bar" }); - - [Fact] - public void DoubleUnquotedPartContainsTrailngWhitespace() => RunParse("foo .bar", new[] { "foo", "bar" }); - - [Fact] - public void DoubleUnquotedPartContainsLeadingWhitespace() => RunParse("foo. bar", new[] { "foo", "bar" }); - - [Fact] - public void DoubleUnquotedEmptyFirst() => RunParse(".bar", new[] { "", "bar" }); - - [Fact] - public void DoubleUnquotedEmptyLast() => RunParse("foo.", new[] { "foo", "" }); - - [Fact] - public void DoubleQuotedParts() => RunParse("[foo].[bar]", new string[] { "foo", "bar" }); - - [Fact] - public void DoubleQuotedPartContainsLeadingWhitespace() => RunParse("[foo]. [bar]", new[] { "foo", "bar" }); - - [Fact] - public void DoubleQuotedPartContainsTrailngWhitespace() => RunParse("[foo] .[bar]", new[] { "foo", "bar" }); - - - [Fact] - public void TripleUnquotedParts() => RunParse("foo.bar.ed", new[] { "foo", "bar", "ed" }); - - [Fact] - public void TripleUnquotedMissingMiddle() => RunParse("foo..bar", new[] { "foo", "", "bar" }); - - [Fact] - public void TripleUnquotedPartContainsTrailingWhitespace() => RunParse("foo .bar .ed", new[] { "foo", "bar", "ed" }); - - [Fact] - public void TripleUnquotedPartContainsEmptyAndTrailngWhitespace() => RunParse(" .bar .ed", new[] { "", "bar", "ed" }); - - [Fact] - public void TripleUnquotedPartContainsLeadingWhitespace() => RunParse("foo. bar.", new[] { "foo", "bar", "" }); - - [Fact] - public void TripleUnquotedEmptyPart() => RunParse(".bar", new[] { "", "bar" }); - - [Fact] - public void TripleQuotedParts() => RunParse("[foo].[bar]", new[] { "foo", "bar" }); - - [Fact] - public void TripleQuotedPartContainsLeadingWhitespace() => RunParse("[foo]. [bar]", new[] { "foo", "bar" }); - - [Fact] - public void TripleQuotedPartContainsTrailngWhitespace() => RunParse("[foo] .[bar]", new[] { "foo", "bar" }); - - [Fact] - public void InvalidUnquotedEmpty() => ThrowParse("", new[] { "" }); - - [Fact] - public void InvalidContainsOpen() => ThrowParse("foo[bar", new[] { "foo[bar" }); - - [Fact] - public void InvalidContainsClose() => ThrowParse("foo]bar", new[] { "foo]bar" }); - - [Fact] - public void InvalidStartsWithClose() => ThrowParse("]bar", new[] { "]bar" }); - - [Fact] - public void InvalidEndsWithClose() => ThrowParse("bar]", new[] { "bar]" }); - - [Fact] - public void InvalidUnfinishedBraceOpen() => ThrowParse("[foo", new[] { "[foo" }); - - [Fact] - public void InvalidUnfinishedQuoteOpen() => ThrowParse("\"foo", new[] { "\"foo" }); - - [Fact] - public void InvalidCapacity() - { - ThrowParse("", Array.Empty()); - } - - [Fact] - public void InvalidLeftQuote() - { - ThrowParse("foo", new[] { "foo" }, leftQuotes: "[."); - } - - [Fact] - public void InvalidRightQuote() - { - ThrowParse("foo", new[] { "foo" }, rightQuotes: "[."); - } - - [Fact] - public void InvalidQuotedPartContainsTrailngNonWhitespace() => ThrowParse("[foo]!.[bar]", new[] { "foo", "bar" }); - - [Fact] - public void InvalidQuotedPartContainsTrailngWhiteSpaceThenNonWhitespace() => ThrowParse("[foo] !.[bar]", new[] { "foo", "bar" }); - - [Fact] - public void InvalidTooManyParts_2to1() => ThrowParse("foo.bar", new[] { "foo" }); - - [Fact] - public void InvalidTooManyPartsEndsInSeparator() => ThrowParse("a.", 1); - - [Fact] - public void InvalidTooManyPartsAfterTrailingWhitespace() => ThrowParse("foo .bar .ed", 1); - - [Fact] - public void InvalidTooManyPartsEndsWithCloseQuote() => ThrowParse("a.[b]", 1); - - [Fact] - public void InvalidTooManyPartsEndsWithWhitespace() => ThrowParse("a.foo ", 1); - - [Fact] - public void InvalidTooManyPartsQuotedPartContainsLeadingWhitespace() => ThrowParse("a.[b].c", 1); - - [Fact] - public void InvalidTooManyPartsWhiteSpaceBeforeSeparator() => ThrowParse("a.b ..", 2); - - [Fact] - public void InvalidTooManyPartsAfterCloseQuote() => ThrowParse("a.[b] .c", 1); - - [Fact] - public void InvalidTooManyPartsSeparatorAfterPart() => ThrowParse("a.b.c", 1); - - - private static void RunParse(string name, string[] expected, bool removeQuotes = true, int maxCount = 0) - { - if (maxCount == 0) - { - for (int index = 0; index < expected.Length; index++) - { - if (expected[index] != null) - { - maxCount += 1; - } - } - } - - string[] originalParts = MultipartIdentifier.ParseMultipartIdentifier(name, "[\"", "]\"", '.', maxCount, removeQuotes, "", true); - - for (int index = 0; index < expected.Length; index++) - { - string expectedPart = expected[index]; - string originalPart = originalParts[index]; - - Assert.Equal(expectedPart, originalPart); - } - } - - private static void ThrowParse(string name, string[] expected, bool removeQuotes = true, string leftQuotes = "[\"", string rightQuotes = "]\"", char separator = '.') - where TException : Exception - { - int maxCount = 0; - for (int index = 0; index < expected.Length; index++) - { - if (expected[index] != null) - { - maxCount += 1; - } - } - - Exception originalException = Assert.Throws(() => - MultipartIdentifier.ParseMultipartIdentifier(name, leftQuotes, rightQuotes, separator, maxCount, removeQuotes, "", true) - ); - - Assert.NotNull(originalException); - } - - - - private static void ThrowParse(string name, int expectedLength, bool removeQuotes = true, string leftQuotes = "[\"", string rightQuotes = "]\"", char separator = '.') - { - Exception originalException = Assert.Throws( - () => - { - MultipartIdentifier.ParseMultipartIdentifier(name, leftQuotes, rightQuotes, separator, expectedLength, removeQuotes, "test", true); - } - ); - Assert.NotNull(originalException); - } - - } -} - -namespace Microsoft.Data.Common -{ - // this is needed for the inclusion of MultipartIdentifier class - internal class ADP - { - internal static ArgumentException InvalidMultipartName(string property, string name) - { - return new ArgumentException(); - } - - internal static ArgumentException InvalidMultipartNameIncorrectUsageOfQuotes(string property, string name) - { - return new ArgumentException(); - } - - internal static ArgumentException InvalidMultipartNameToManyParts(string property, string name, int limit) - { - return new ArgumentException(); - } - } -} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/Common/MultipartIdentifierTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/Common/MultipartIdentifierTests.cs new file mode 100644 index 0000000000..72f07573a1 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/Common/MultipartIdentifierTests.cs @@ -0,0 +1,374 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Xunit; + +namespace Microsoft.Data.Common.UnitTests; + +public class MultipartIdentifierTests +{ + /// + /// Gets a collection of test data containing various part identifier strings and their expected + /// parse results. + /// + /// + /// The returned data includes different combinations of part identifier formats, such as + /// those with embedded whitespace, leading or trailing whitespace, and bracket characters. + /// + /// + public static TheoryData ValidSinglePartIdentifierVariations + { + get + { + ReadOnlySpan part1Words = ["word1", "word 1"]; + TheoryData data = []; + + // Combination 1: embedded and non-embedded whitespace. + // Combination 2: leading and/or trailing whitespace, and no whitespace + // Combination 3: bracket characters "" or [] + // Combination 4: wrapped in bracket characters, no bracket characters + // Combination 5: if wrapped in bracket characters, embedded (escaped) characters + foreach (string part1 in part1Words) + { + foreach ((string p1Combination, string p1Expected) in GeneratePartCombinations(part1)) + { + string onePartCombination = p1Combination; + string[] onePartExpected = [p1Expected]; + + data.Add(onePartCombination, onePartExpected); + } + } + + return data; + } + } + + /// + /// Gets a collection of test cases representing various formats and structures of multipart identifiers. + /// + /// + /// This property provides examples of multipart identifiers with different numbers of + /// parts, including cases with empty segments, variations of whitespace around the separator, and bracketed components. + /// + /// + public static TheoryData ValidMultipartIdentifierVariations => + new() + { + // Two parts, bracketed and unbracketed + { "[word1].[word2]", ["word1", "word2"] }, + { "word1.word2", ["word1", "word2"] }, + // Two parts, one of which is empty + { ".word2", ["", "word2"] }, + { "word1.", ["word1", ""] }, + // Two parts, with whitespace around the separator + { "word1 .word2", ["word1", "word2"] }, + { "word1. word2", ["word1", "word2"] }, + { "word1 . word2", ["word1", "word2"] }, + { "[word1] .[word2]", ["word1", "word2"] }, + { "[word1]. [word2]", ["word1", "word2"] }, + { "[word1] . [word2]", ["word1", "word2"] }, + // Three parts, one of which is empty + { ".word2.word3", ["", "word2", "word3"] }, + { "word1..word3", ["word1", "", "word3"] }, + { "word1.word2.", ["word1", "word2", ""] }, + // Four parts, one of which is empty + { ".word2.word3.word4", ["", "word2", "word3", "word4"] }, + { "word1..word3.word4", ["word1", "", "word3", "word4"] }, + { "word1.word2..word4", ["word1", "word2", "", "word4"] }, + { "word1.word2.word3.", ["word1", "word2", "word3", ""] }, + }; + + /// + /// Gets a collection of test cases representing various formats of invalid part identifiers. + /// + /// + /// This property provides examples of single part identifiers with mismatched brackets, invalid bracket + /// placement, unclosed brackets or quotes, and cases with more parts than expected. + /// These cases are intended to test the parser's ability to correctly identify and reject invalid formats. + /// + /// + public static TheoryData InvalidSinglePartIdentifierVariations => + new() + { + // Empty string + { "" }, + // Bracket halfway through a part + { "word1[word2" }, + { "word1]word2" }, + { "word1\"word2" }, + // Invalid bracket placement (i.e. starts with a close bracket or ends with an open bracket) + { "]word1" }, + { "word1[" }, + // Unclosed brackets or quotes + { "[word1" }, + { "\"word1" }, + // Part starts with one bracket and ends with another + { "[word1\"" }, + { "\"word1]" }, + // More parts than expected, in various conditions. + // Additional part is empty + { "word1." }, + // Additional part, wrapped in brackets + { "word1.word2" }, + { "word1.[word2]" }, + { "word1.\"word2\"" }, + // Additional part, with whitespace before or after the separator + { "word1 .word2" }, + { "word1. word2" }, + // Additional part, with whitespace after the second part + { "word1.word2 " }, + { "word1.[word2 ]" }, + { "word1.\"word2 \"" }, + // Additional part, with separator after the second part + { "word1.word2." }, + { "word1.word2.word3" }, + { "word1.[word2]." }, + { "word1.\"word2\"." }, + { "word1.[word2].word3" }, + { "word1.\"word2\".word3" }, + }; + + /// + /// Gets a collection of test cases representing various formats and structures of invalid multipart identifiers. + /// + /// + /// This property provides examples of multipart identifiers with trailing non-whitespace characters after bracketed + /// parts, and cases with whitespace followed by non-whitespace characters after bracketed parts. + /// These cases are intended to test the parser's ability to correctly identify and reject invalid multipart identifier formats. + /// + /// + public static TheoryData InvalidMultipartIdentifierVariations => + new() + { + // Bracketed part with trailing non-whitespace characters + { "[foo]!.[bar]", 2 }, + { "\"foo\"!.\"bar\"", 2 }, + { "[foo].[bar]!", 2 }, + { "\"foo\".\"bar\"!", 2 }, + // Bracketed part with trailing whitespace followed by non-whitespace characters + { "[foo] !.[bar]", 2 }, + { "\"foo\" !.\"bar\"", 2 }, + { "[foo]. ![bar]", 2 }, + { "\"foo\". !\"bar\"", 2 }, + { "[foo].[bar] !", 2 }, + { "\"foo\".\"bar\" !", 2 }, + }; + + /// + /// Gets a collection of test cases representing the results of processing an identifier with fewer parts than expected. + /// + /// + public static TheoryData OvercountMultipartIdentifierVariations => + new() + { + { "word1", [null, "word1"], 2 }, + { "word1", [null, null, "word1"], 3 }, + { "word1", [null, null, null, "word1"], 4 }, + + { "word1.word2", [null, "word1", "word2"], 3 }, + { "word1.word2", [null, null, "word1", "word2"], 4 }, + + { "word1.word2.word3", [null, "word1", "word2", "word3"], 4 }, + }; + + /// + /// Verifies that one part in an identifier parses successfully when it contains various + /// combinations of brackets and whitespace, and that the expected value is returned. + /// + /// The raw identifier to parse. + /// The expected output of parsing the identifier. + [Theory] + [MemberData(nameof(ValidSinglePartIdentifierVariations))] + public void SinglePartIdentifierWithBracketsAndWhiteSpace_ParsesSuccessfully(string partIdentifier, string[] expected) => + RunParse(partIdentifier, expected); + + /// + /// Verifies that a multi-part identifier parses successfully when its parts are combinations + /// of bracketed and unbracketed identifiers, with various placements of whitespace, and that + /// the expected values are returned. + /// + /// The raw identifier to parse. + /// The expected output of parsing the identifier. + [Theory] + [MemberData(nameof(ValidMultipartIdentifierVariations))] + public void MultipartIdentifier_ParsesSuccessfully(string partIdentifier, string[] expected) => + RunParse(partIdentifier, expected); + + /// + /// Verifies that parsing one part in an identifier throws an exception when it is invalid. + /// This encompasses mismatched, misplaced or unclosed brackets and more parts than expected, + /// in various combinations with whitespace. + /// + /// The raw identifier to parse. + [Theory] + [MemberData(nameof(InvalidSinglePartIdentifierVariations))] + public void InvalidSinglePartIdentifier_Throws(string partIdentifier) => + ThrowParse(partIdentifier, expectedLength: 1); + + /// + /// Verifies that parsing a multi-part identifier throws an exception when it is invalid (such as + /// containing non-whitespace characters between a closing bracket and the separator, or between a + /// closing bracket and the end of the string.) + /// + /// The raw identifier to parse. + /// The expected number of components in the identifier. + [Theory] + [MemberData(nameof(InvalidMultipartIdentifierVariations))] + public void InvalidMultipartIdentifier_Throws(string partIdentifier, int expectedLength) => + ThrowParse(partIdentifier, expectedLength); + + /// + /// Verifies that when a multipart identifier contains fewer parts than expected, the parser fills the + /// missing elements in the array with null values (starting from the first element) and successfully + /// parses the identifier. + /// + /// The raw identifier to parse. + /// The expected output of parsing the identifier. + /// The number of parts which the part parsing should normally expect. + [Theory] + [MemberData(nameof(OvercountMultipartIdentifierVariations))] + public void SingleUnbracketedOvercount_FillsFirstElementsWithNull(string partIdentifier, string?[] parts, int maxCount) => + RunParse(partIdentifier, parts, maxCount); + + /// + /// Verifies that multipart identifier strings containing zero-length segments are parsed into the expected + /// array of empty strings. + /// + /// + /// This test case contrasts with , where the + /// input is a completely empty string rather than a multipart identifier with empty segments. + /// + /// The raw identifier to parse. + [Theory] + [InlineData("[].[].[].[]")] + [InlineData("...")] + [InlineData(".[].[].")] + [InlineData("[]...[]")] + [InlineData(" . . . ")] + [InlineData(" []. [] . [] .[] ")] + public void MultipartIdentifierOfZeroLengthParts_ParsesSuccessfully(string partIdentifier) => + RunParse(partIdentifier, ["", "", "", ""]); + + /// + /// Verifies that parsing a multipart identifier with more parts than expected throws an exception. + /// + /// The number of parts which the part parsing should normally expect. + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void MultipartIdentifierWithMorePartsThanExpected_Throws(int maxCount) => + ThrowParse("word1.word2.word3.word4", maxCount); + + /// + /// Verifies that parsing an empty multipart identifier with the throwOnEmpty flag set to false returns an array + /// of nulls with the specified number of parts (rather than throwing an exception.) + /// + /// The expected number of components in the identifier. + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void EmptyMultipartIdentifierWithThrowOnEmptyFalse_ReturnsArrayOfNulls(int expectedLength) => + RunParse("", new string?[expectedLength], expectedLength, throwOnEmpty: false); + + /// + /// Verifies that parsing an empty multipart identifier with the throwOnEmpty flag set to true throws an exception. + /// + /// + /// This test case contrasts with , where the + /// input is a multipart identifier with empty segments rather than a completely empty string. + /// + /// The expected number of components in the identifier. + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void EmptyMultipartIdentifierWithThrowOnEmptyTrue_Throws(int expectedLength) => + ThrowParse("", expectedLength); + + private static void RunParse(string name, string?[] expected, int maxCount = 0, bool throwOnEmpty = true) + { + if (maxCount == 0) + { + for (int index = 0; index < expected.Length; index++) + { + if (expected[index] != null) + { + maxCount += 1; + } + } + } + + string?[] originalParts = MultipartIdentifier.ParseMultipartIdentifier(name, "", throwOnEmpty, maxCount); + + Assert.Equal(expected.Length, originalParts.Length); + for (int index = 0; index < expected.Length; index++) + { + string? expectedPart = expected[index]; + string? originalPart = originalParts[index]; + + Assert.Equal(expectedPart, originalPart); + } + } + + private static void ThrowParse(string name, int expectedLength) + { + ArgumentException originalException = Assert.Throws(() => + MultipartIdentifier.ParseMultipartIdentifier(name, "test", true, expectedLength) + ); + + Assert.NotNull(originalException); + } + + private static IEnumerable<(string, string)> GeneratePartCombinations(string word) + { + Debug.Assert(word is "word1" or "word 1"); + (string OpeningBracket, string ClosingBracket)[] bracketCombinations = [("[", "]"), ("\"", "\"")]; + + // Combinations of whitespace, contained entirely within various combinations of brackets + foreach (string wsCombination in GenerateWhitespaceCombinations(word)) + { + foreach ((string openingBracket, string closingBracket) in bracketCombinations) + { + foreach ((string bracketCombination, string expectedValue) in GenerateBracketCombinations(wsCombination, openingBracket, closingBracket)) + { + yield return (bracketCombination, expectedValue); + } + } + } + + // Combinations of brackets, with whitespace outside the brackets + foreach ((string openingBracket, string closingBracket) in bracketCombinations) + { + foreach ((string bracketCombination, string unbracketedValue) in GenerateBracketCombinations(word, openingBracket, closingBracket)) + { + foreach (string wsCombination in GenerateWhitespaceCombinations(bracketCombination)) + { + yield return (wsCombination, unbracketedValue); + } + } + } + + static IEnumerable GenerateWhitespaceCombinations(string word) + { + yield return word; + yield return $" {word}"; + yield return $"{word} "; + yield return $" {word} "; + } + + static IEnumerable<(string Combination, string Expected)> GenerateBracketCombinations(string word, string openingBracket, string closingBracket) + { + yield return (word, word.Trim()); + yield return (openingBracket + word + closingBracket, word); + yield return (openingBracket + word.Insert(word.Length - 3, closingBracket + closingBracket) + closingBracket, word.Insert(word.Length - 3, closingBracket)); + } + } +}