Skip to content

Commit 19b4305

Browse files
authored
Tests | Introduce RAII SQL object primitives (#4050)
* Create DatabaseObject base class for fixture This contains GenerateLongName and GenerateShortName, which are lifted from DataTestUtility.GetLongName and GetShortName in ManualTests. * Create derived types, use these in a few locations This eliminates DropUserDefinedType from DataTestUtility. * Further removal of ad-hoc utility methods in ParametersTest * Further removal of ad-hoc CREATE/DROP scripts in ParametersTest * Use Table type in SqlGraphTables.cs * Use Table and StoredProcedure types in JsonTest.cs * Use Table type in JsonStreamTest.cs * Remove duplicate helper function from ApiShould.cs
1 parent a8d349e commit 19b4305

File tree

11 files changed

+937
-1069
lines changed

11 files changed

+937
-1069
lines changed
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.Text;
7+
using System.Threading;
8+
9+
namespace Microsoft.Data.SqlClient.Tests.Common.Fixtures.DatabaseObjects;
10+
11+
/// <summary>
12+
/// Base class for a transient database object (such as a table, type or
13+
/// stored procedure.)
14+
/// </summary>
15+
public abstract class DatabaseObject : IDisposable
16+
{
17+
private readonly bool _shouldDrop;
18+
19+
protected SqlConnection Connection { get; }
20+
21+
public string Name { get; }
22+
23+
protected DatabaseObject(SqlConnection connection, string name, string definition, bool shouldCreate, bool shouldDrop)
24+
{
25+
_shouldDrop = shouldDrop;
26+
27+
Connection = connection;
28+
Name = name;
29+
30+
if (shouldCreate)
31+
{
32+
EnsureConnectionOpen();
33+
DropObject();
34+
CreateObject(definition);
35+
}
36+
}
37+
38+
private void EnsureConnectionOpen()
39+
{
40+
const int MaxWaits = 2;
41+
int counter = MaxWaits;
42+
43+
if (Connection.State is System.Data.ConnectionState.Closed)
44+
{
45+
Connection.Open();
46+
}
47+
while (counter-- > 0 && Connection.State is System.Data.ConnectionState.Connecting)
48+
{
49+
Thread.Sleep(80);
50+
}
51+
}
52+
53+
/// <summary>
54+
/// Generate a new GUID and return the characters from its 1st and 4th
55+
/// parts, as shown here:
56+
///
57+
/// <code>
58+
/// 7ff01cb8-88c7-11f0-b433-00155d7e531e
59+
/// ^^^^^^^^ ^^^^
60+
/// </code>
61+
///
62+
/// These 12 characters are concatenated together without any
63+
/// separators. These 2 parts typically comprise a timestamp and clock
64+
/// sequence, most likely to be unique for tests that generate names in
65+
/// quick succession.
66+
/// </summary>
67+
private static string GetGuidParts()
68+
{
69+
var guid = Guid.NewGuid().ToString();
70+
// GOTCHA: The slice operator is inclusive of the start index and
71+
// exclusive of the end index!
72+
return guid.Substring(0, 8) + guid.Substring(19, 4);
73+
}
74+
75+
/// <summary>
76+
/// Generate a long unique database object name, whose maximum length is
77+
/// 96 characters, with the format:
78+
///
79+
/// <c>{Prefix}_{GuidParts}_{UserName}_{MachineName}</c>
80+
///
81+
/// The Prefix will be truncated to satisfy the overall maximum length.
82+
///
83+
/// The GUID Parts will be the characters from the 1st and 4th blocks
84+
/// from a traditional string representation, as shown here:
85+
///
86+
/// <code>
87+
/// 7ff01cb8-88c7-11f0-b433-00155d7e531e
88+
/// ^^^^^^^^ ^^^^
89+
/// </code>
90+
///
91+
/// These 2 parts typically comprise a timestamp and clock sequence,
92+
/// most likely to be unique for tests that generate names in quick
93+
/// succession. The 12 characters are concatenated together without any
94+
/// separators.
95+
///
96+
/// The UserName and MachineName are obtained from the Environment,
97+
/// and will be truncated to satisfy the maximum overall length.
98+
/// </summary>
99+
///
100+
/// <param name="prefix">
101+
/// The prefix to use when generating the unique name, truncated to at
102+
/// most 32 characters.
103+
///
104+
/// This should not contain any characters that cannot be used in
105+
/// database object names. See:
106+
///
107+
/// https://learn.microsoft.com/en-us/sql/relational-databases/databases/database-identifiers?view=sql-server-ver17#rules-for-regular-identifiers
108+
/// </param>
109+
///
110+
/// <param name="escape">
111+
/// When true, the entire generated name will be enclosed in square
112+
/// brackets, for example:
113+
///
114+
/// <c>[MyPrefix_7ff01cb811f0_test_user_ci_agent_machine_name]</c>
115+
/// </param>
116+
///
117+
/// <returns>
118+
/// A unique database object name, no more than 96 characters long.
119+
/// </returns>
120+
public static string GenerateLongName(string prefix, bool escape = true)
121+
{
122+
StringBuilder name = new(96);
123+
124+
if (escape)
125+
{
126+
name.Append('[');
127+
}
128+
129+
if (prefix.Length > 32)
130+
{
131+
prefix = prefix.Substring(0, 32);
132+
}
133+
134+
name.Append(prefix);
135+
name.Append('_');
136+
name.Append(GetGuidParts());
137+
name.Append('_');
138+
139+
var suffix =
140+
Environment.UserName + '_' +
141+
Environment.MachineName;
142+
143+
int maxSuffixLength = 96 - name.Length;
144+
if (escape)
145+
{
146+
--maxSuffixLength;
147+
}
148+
if (suffix.Length > maxSuffixLength)
149+
{
150+
suffix = suffix.Substring(0, maxSuffixLength);
151+
}
152+
153+
name.Append(suffix);
154+
155+
if (escape)
156+
{
157+
name.Append(']');
158+
}
159+
160+
return name.ToString();
161+
}
162+
163+
/// <summary>
164+
/// Generate a short unique database object name, whose maximum length
165+
/// is 30 characters, with the format:
166+
///
167+
/// <c>{Prefix}_{GuidParts}</c>
168+
///
169+
/// The Prefix will be truncated to satisfy the overall maximum length.
170+
///
171+
/// The GUID parts will be the characters from the 1st and 4th blocks
172+
/// from a traditional string representation, as shown here:
173+
///
174+
/// <code>
175+
/// 7ff01cb8-88c7-11f0-b433-00155d7e531e
176+
/// ^^^^^^^^ ^^^^
177+
/// </code>
178+
///
179+
/// These 2 parts typically comprise a timestamp and clock sequence,
180+
/// most likely to be unique for tests that generate names in quick
181+
/// succession. The 12 characters are concatenated together without any
182+
/// separators.
183+
/// </summary>
184+
///
185+
/// <param name="prefix">
186+
/// The prefix to use when generating the unique name, truncated to at
187+
/// most 18 characters when withBracket is false, and 16 characters when
188+
/// withBracket is true.
189+
///
190+
/// This should not contain any characters that cannot be used in
191+
/// database object names. See:
192+
///
193+
/// https://learn.microsoft.com/en-us/sql/relational-databases/databases/database-identifiers?view=sql-server-ver17#rules-for-regular-identifiers
194+
/// </param>
195+
///
196+
/// <param name="escape">
197+
/// When true, the entire generated name will be enclosed in square
198+
/// brackets, for example:
199+
///
200+
/// <c>[MyPrefix_7ff01cb811f0]</c>
201+
/// </param>
202+
///
203+
/// <returns>
204+
/// A unique database object name, no more than 30 characters long.
205+
/// </returns>
206+
public static string GenerateShortName(string prefix, bool escape = true)
207+
{
208+
StringBuilder name = new(30);
209+
210+
if (escape)
211+
{
212+
name.Append('[');
213+
}
214+
215+
int maxPrefixLength = escape ? 16 : 18;
216+
if (prefix.Length > maxPrefixLength)
217+
{
218+
prefix = prefix.Substring(0, maxPrefixLength);
219+
}
220+
221+
name.Append(prefix);
222+
name.Append('_');
223+
name.Append(GetGuidParts());
224+
225+
if (escape)
226+
{
227+
name.Append(']');
228+
}
229+
230+
return name.ToString();
231+
}
232+
233+
/// <summary>
234+
/// Creates the object with a given definition.
235+
/// </summary>
236+
/// <param name="definition">Definition of the object to create.</param>
237+
/// <remarks>
238+
/// By the time this is called, <see cref="Connection"/> will be open.
239+
/// </remarks>
240+
protected abstract void CreateObject(string definition);
241+
242+
/// <summary>
243+
/// Drops the object created by <see cref="CreateObject"/>.
244+
/// </summary>
245+
/// <remarks>
246+
/// By the time this is called, <see cref="Connection"/> will be open.
247+
/// Must not throw an exception if the object does not exist.
248+
/// </remarks>
249+
protected abstract void DropObject();
250+
251+
public void Dispose()
252+
{
253+
if (_shouldDrop)
254+
{
255+
EnsureConnectionOpen();
256+
DropObject();
257+
}
258+
// This explicitly does not drop the wrapped SqlConnection; this is sometimes
259+
// used in a loop to create multiple UDTs.
260+
261+
GC.SuppressFinalize(this);
262+
}
263+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace Microsoft.Data.SqlClient.Tests.Common.Fixtures.DatabaseObjects;
6+
7+
/// <summary>
8+
/// A transient stored procedure, created at the start of its scope and dropped when disposed.
9+
/// </summary>
10+
public sealed class StoredProcedure : DatabaseObject
11+
{
12+
/// <summary>
13+
/// Initializes a new instance of the StoredProcedure class using the specified SQL connection,
14+
/// name and definition.
15+
/// </summary>
16+
/// <remarks>
17+
/// If a stored procedure with the specified name already exists, it will be dropped automatically
18+
/// before creation.
19+
/// </remarks>
20+
/// <param name="connection">The SQL connection used to interact with the database.</param>
21+
/// <param name="prefix">The stored procedure name. Can begin with '#' or '##' to indicate a temporary procedure.</param>
22+
/// <param name="definition">The SQL definition of the stored procedure.</param>
23+
public StoredProcedure(SqlConnection connection, string prefix, string definition)
24+
: base(connection, GenerateLongName(prefix), definition, shouldCreate: true, shouldDrop: true)
25+
{
26+
}
27+
28+
protected override void CreateObject(string definition)
29+
{
30+
using SqlCommand createCommand = new($"CREATE PROCEDURE {Name} {definition}", Connection);
31+
32+
createCommand.ExecuteNonQuery();
33+
}
34+
35+
protected override void DropObject()
36+
{
37+
using SqlCommand dropCommand = new($"IF (OBJECT_ID('{Name}') IS NOT NULL) DROP PROCEDURE {Name}", Connection);
38+
39+
dropCommand.ExecuteNonQuery();
40+
}
41+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace Microsoft.Data.SqlClient.Tests.Common.Fixtures.DatabaseObjects;
6+
7+
/// <summary>
8+
/// A transient table, created at the start of its scope and dropped when disposed.
9+
/// </summary>
10+
public sealed class Table : DatabaseObject
11+
{
12+
/// <summary>
13+
/// Initializes a new instance of the Table class using the specified SQL connection, table name prefix, and table
14+
/// definition.
15+
/// </summary>
16+
/// <remarks>
17+
/// If a table with the specified name already exists, it will be dropped automatically before
18+
/// creation.
19+
/// </remarks>
20+
/// <param name="connection">The SQL connection used to interact with the database.</param>
21+
/// <param name="prefix">The prefix for the table name. Can begin with '#' or '##' to indicate a temporary table.</param>
22+
/// <param name="definition">The SQL definition describing the structure of the table, including columns and data types.</param>
23+
public Table(SqlConnection connection, string prefix, string definition)
24+
: base(connection, GenerateLongName(prefix), definition, shouldCreate: true, shouldDrop: true)
25+
{
26+
}
27+
28+
protected override void CreateObject(string definition)
29+
{
30+
using SqlCommand createCommand = new($"CREATE TABLE {Name} {definition}", Connection);
31+
32+
createCommand.ExecuteNonQuery();
33+
}
34+
35+
protected override void DropObject()
36+
{
37+
using SqlCommand dropCommand = new($"IF (OBJECT_ID('{Name}') IS NOT NULL) DROP TABLE {Name}", Connection);
38+
39+
dropCommand.ExecuteNonQuery();
40+
}
41+
42+
/// <summary>
43+
/// Deletes all data from the table.
44+
/// </summary>
45+
public void DeleteData()
46+
{
47+
using SqlCommand deleteCommand = new($"DELETE FROM {Name}", Connection);
48+
49+
deleteCommand.ExecuteNonQuery();
50+
}
51+
52+
/// <summary>
53+
/// Truncates the table.
54+
/// </summary>
55+
public void Truncate()
56+
{
57+
using SqlCommand truncateCommand = new($"TRUNCATE TABLE {Name}", Connection);
58+
59+
truncateCommand.ExecuteNonQuery();
60+
}
61+
}

0 commit comments

Comments
 (0)