Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions Utils.Data/Sql/SqlParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -928,14 +928,34 @@ private void SkipBlockComment()
_ => false,
};

/// <summary>
/// Determines whether the provided character can start an identifier or parameter token.
/// </summary>
/// <param name="c">The character to evaluate.</param>
/// <returns><c>true</c> when the character can begin an identifier; otherwise, <c>false</c>.</returns>
private bool IsIdentifierStart(char c)
{
return char.IsLetter(c) || c == '_' || c == '$' || syntaxOptions.IsIdentifierPrefix(c);
return char.IsLetter(c) || c == '_' || IsIdentifierPrefix(c);
}

/// <summary>
/// Determines whether the provided character can appear within an identifier or parameter token.
/// </summary>
/// <param name="c">The character to evaluate.</param>
/// <returns><c>true</c> when the character can appear in an identifier; otherwise, <c>false</c>.</returns>
private bool IsIdentifierPart(char c)
{
return char.IsLetterOrDigit(c) || c == '_' || c == '$' || syntaxOptions.IsIdentifierPrefix(c);
return char.IsLetterOrDigit(c) || c == '_' || IsIdentifierPrefix(c);
}

/// <summary>
/// Checks whether a character is configured as an identifier prefix or matches a common parameter prefix.
/// </summary>
/// <param name="c">The character to evaluate.</param>
/// <returns><c>true</c> when the character is treated as an identifier prefix; otherwise, <c>false</c>.</returns>
private bool IsIdentifierPrefix(char c)
{
return syntaxOptions.IsIdentifierPrefix(c);
}

private char Peek() => sql[index];
Expand Down
35 changes: 32 additions & 3 deletions Utils.Data/Sql/SqlSyntaxOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ namespace Utils.Data.Sql;
/// </summary>
public sealed class SqlSyntaxOptions
{
private static readonly char[] DefaultIdentifierPrefixes = { '@', '#', '$' };
private static readonly char[] SqlServerIdentifierPrefixes = { '@', '#', '$' };
private static readonly char[] OracleIdentifierPrefixes = { ':' };
private static readonly char[] MySqlIdentifierPrefixes = { '@' };
private static readonly char[] SqliteIdentifierPrefixes = { '@', ':', '$', '?' };
private static readonly char[] PostgreSqlIdentifierPrefixes = { '$' };

private readonly HashSet<char> identifierPrefixes;

Expand All @@ -20,7 +24,7 @@ public sealed class SqlSyntaxOptions
public SqlSyntaxOptions(IEnumerable<char>? identifierPrefixes = null, char autoParameterPrefix = '@')
{
var resolvedPrefixes = identifierPrefixes == null
? new HashSet<char>(DefaultIdentifierPrefixes)
? new HashSet<char>(SqlServerIdentifierPrefixes)
: new HashSet<char>(identifierPrefixes);

if (resolvedPrefixes.Count == 0)
Expand All @@ -34,10 +38,35 @@ public SqlSyntaxOptions(IEnumerable<char>? identifierPrefixes = null, char autoP
AutoParameterPrefix = autoParameterPrefix;
}

/// <summary>
/// Gets syntax options configured for Microsoft SQL Server.
/// </summary>
public static SqlSyntaxOptions SqlServer { get; } = new SqlSyntaxOptions(SqlServerIdentifierPrefixes, '@');

/// <summary>
/// Gets syntax options configured for Oracle databases.
/// </summary>
public static SqlSyntaxOptions Oracle { get; } = new SqlSyntaxOptions(OracleIdentifierPrefixes, ':');

/// <summary>
/// Gets syntax options configured for MySQL databases.
/// </summary>
public static SqlSyntaxOptions MySql { get; } = new SqlSyntaxOptions(MySqlIdentifierPrefixes, '@');

/// <summary>
/// Gets syntax options configured for SQLite databases.
/// </summary>
public static SqlSyntaxOptions Sqlite { get; } = new SqlSyntaxOptions(SqliteIdentifierPrefixes, '@');

/// <summary>
/// Gets syntax options configured for PostgreSQL databases.
/// </summary>
public static SqlSyntaxOptions PostgreSql { get; } = new SqlSyntaxOptions(PostgreSqlIdentifierPrefixes, '$');

/// <summary>
/// Gets the default syntax options supporting common SQL Server style prefixes.
/// </summary>
public static SqlSyntaxOptions Default { get; } = new SqlSyntaxOptions(DefaultIdentifierPrefixes, '@');
public static SqlSyntaxOptions Default { get; } = SqlServer;

/// <summary>
/// Gets the characters that can prefix identifiers.
Expand Down
55 changes: 55 additions & 0 deletions UtilsTest/Data/SqlQueryAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,61 @@ public void ParseSupportsCustomParameterPrefixes()
Assert.AreEqual(sql, query.ToSql());
}

[TestMethod]
public void ParseSqlServerPrefixesWithDedicatedOptions()
{
const string sql = "SELECT * FROM #temp WHERE Id = @id";

SqlQuery query = SqlQueryAnalyzer.Parse(sql, SqlSyntaxOptions.SqlServer);

Assert.AreSame(SqlSyntaxOptions.SqlServer, query.SyntaxOptions);
Assert.AreEqual(sql, query.ToSql());
}

[TestMethod]
public void ParseOraclePrefixesWithDedicatedOptions()
{
const string sql = "SELECT * FROM accounts WHERE id = :account_id";

SqlQuery query = SqlQueryAnalyzer.Parse(sql, SqlSyntaxOptions.Oracle);

Assert.AreSame(SqlSyntaxOptions.Oracle, query.SyntaxOptions);
Assert.AreEqual(sql, query.ToSql());
}

[TestMethod]
public void ParseMySqlPrefixesWithDedicatedOptions()
{
const string sql = "SELECT * FROM users WHERE id = @user_id";

SqlQuery query = SqlQueryAnalyzer.Parse(sql, SqlSyntaxOptions.MySql);

Assert.AreSame(SqlSyntaxOptions.MySql, query.SyntaxOptions);
Assert.AreEqual(sql, query.ToSql());
}

[TestMethod]
public void ParseSqlitePrefixesWithDedicatedOptions()
{
const string sql = "SELECT * FROM accounts WHERE id = ?1";

SqlQuery query = SqlQueryAnalyzer.Parse(sql, SqlSyntaxOptions.Sqlite);

Assert.AreSame(SqlSyntaxOptions.Sqlite, query.SyntaxOptions);
Assert.AreEqual(sql, query.ToSql());
}

[TestMethod]
public void ParsePostgreSqlPrefixesWithDedicatedOptions()
{
const string sql = "SELECT * FROM users WHERE id = $1";

SqlQuery query = SqlQueryAnalyzer.Parse(sql, SqlSyntaxOptions.PostgreSql);

Assert.AreSame(SqlSyntaxOptions.PostgreSql, query.SyntaxOptions);
Assert.AreEqual(sql, query.ToSql());
}

[TestMethod]
public void ToSqlSupportsFormattingModes()
{
Expand Down
8 changes: 4 additions & 4 deletions UtilsTest/Objects/RangeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,10 @@ public void RangeTestReverse()
{
int[] table = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int[] expected = { 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 };
table.Reverse();
Assert.AreEqual(expected[0], table[0]);
Assert.AreEqual(expected[1], table[1]);
Assert.IsTrue(comparer.Equals(expected, table));
var range = ((IReadOnlyList<int>)table).Reverse();
Assert.AreEqual(expected[0], range[0]);
Assert.AreEqual(expected[1], range[1]);
Assert.IsTrue(comparer.Equals(expected, range));
}

}
Expand Down
96 changes: 76 additions & 20 deletions UtilsTest/Reflection/MultiDelegateInvokerTests.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,50 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Utils.Reflection;

namespace UtilsTest.Reflection;

/// <summary>
/// Tests for <see cref="MultiDelegateInvoker{T, TResult}"/> behavior.
/// </summary>
[TestClass]
public class MultiDelegateInvokerTests
{
/// <summary>
/// Adds one to the provided integer value.
/// </summary>
/// <param name="i">The input value.</param>
/// <returns>The input value incremented by one.</returns>
private static int AddOne(int i) => i + 1;

/// <summary>
/// Adds two to the provided integer value.
/// </summary>
/// <param name="i">The input value.</param>
/// <returns>The input value incremented by two.</returns>
private static int AddTwo(int i) => i + 2;

/// <summary>
/// Measures the execution time of a delegate invoker call.
/// </summary>
/// <param name="invocation">The invocation to measure.</param>
/// <returns>The invocation results and elapsed time in milliseconds.</returns>
private static async Task<(int[] Results, long ElapsedMilliseconds)> MeasureInvocationAsync(Func<Task<int[]>> invocation)
{
Stopwatch stopwatch = Stopwatch.StartNew();
int[] results = await invocation();
stopwatch.Stop();

return (results, stopwatch.ElapsedMilliseconds);
}

[TestMethod]
/// <summary>
/// Ensures that synchronous invocation returns every delegate result in order.
/// </summary>
public void Invoke_Returns_All_Results()
{
var invoker = new MultiDelegateInvoker<int, int>();
Expand All @@ -23,6 +56,9 @@ public void Invoke_Returns_All_Results()
}

[TestMethod]
/// <summary>
/// Ensures that asynchronous invocation returns every delegate result in order.
/// </summary>
public async Task InvokeAsync_Returns_All_Results()
{
var invoker = new MultiDelegateInvoker<int, int>();
Expand All @@ -34,38 +70,58 @@ public async Task InvokeAsync_Returns_All_Results()
}

[TestMethod]
/// <summary>
/// Verifies that parallel invocation completes faster than sequential execution while returning all results.
/// </summary>
public async Task InvokeParallelAsync_Executes_In_Parallel()
{
var gate = new ManualResetEventSlim(false);
int started = 0;
var invoker = new MultiDelegateInvoker<int, int>();
invoker.Add<int>(i => { System.Threading.Thread.Sleep(100); return i + 1; });
invoker.Add<int>(i => { System.Threading.Thread.Sleep(100); return i + 2; });
invoker.Add<int>(i =>
{
Interlocked.Increment(ref started);
gate.Wait();
return i + 1;
});
invoker.Add<int>(i =>
{
Interlocked.Increment(ref started);
gate.Wait();
return i + 2;
});

Stopwatch sw = Stopwatch.StartNew();
int[] results = await invoker.InvokeParallelAsync(3);
sw.Stop();
Task<int[]> invocation = invoker.InvokeParallelAsync(3);
bool bothStarted = SpinWait.SpinUntil(() => Volatile.Read(ref started) == 2, 1000);
gate.Set();

CollectionAssert.AreEqual(new[] { 4, 5 }, results);
Assert.IsTrue(sw.ElapsedMilliseconds < 190);
int[] parallelResults = await invocation;
int[] sequentialResults = await invoker.InvokeAsync(3);

CollectionAssert.AreEqual(new[] { 4, 5 }, parallelResults);
CollectionAssert.AreEqual(new[] { 4, 5 }, sequentialResults);
Assert.IsTrue(bothStarted, "Delegates did not start concurrently.");
}

[TestMethod]
/// <summary>
/// Confirms that the smart invocation strategy switches between sequential and parallel execution based on the configured threshold.
/// </summary>
public async Task InvokeSmartAsync_Switches_Based_On_Threshold()
{
var sequential = new MultiDelegateInvoker<int, int>(3);
sequential.Add<int>(i => { System.Threading.Thread.Sleep(100); return i + 1; });
sequential.Add<int>(i => { System.Threading.Thread.Sleep(100); return i + 2; });
Stopwatch sw1 = Stopwatch.StartNew();
await sequential.InvokeSmartAsync(3);
sw1.Stop();
Assert.IsTrue(sw1.ElapsedMilliseconds >= 190);
sequential.Add<int>(i => { System.Threading.Thread.Sleep(150); return i + 1; });
sequential.Add<int>(i => { System.Threading.Thread.Sleep(150); return i + 2; });
(int[] sequentialResults, long sequentialDuration) = await MeasureInvocationAsync(() => sequential.InvokeSmartAsync(3));

var parallel = new MultiDelegateInvoker<int, int>(1);
parallel.Add<int>(i => { System.Threading.Thread.Sleep(100); return i + 1; });
parallel.Add<int>(i => { System.Threading.Thread.Sleep(100); return i + 2; });
Stopwatch sw2 = Stopwatch.StartNew();
await parallel.InvokeSmartAsync(3);
sw2.Stop();
Assert.IsTrue(sw2.ElapsedMilliseconds < 190);
parallel.Add<int>(i => { System.Threading.Thread.Sleep(150); return i + 1; });
parallel.Add<int>(i => { System.Threading.Thread.Sleep(150); return i + 2; });
(int[] parallelResults, long parallelDuration) = await MeasureInvocationAsync(() => parallel.InvokeSmartAsync(3));

CollectionAssert.AreEqual(new[] { 4, 5 }, sequentialResults);
CollectionAssert.AreEqual(new[] { 4, 5 }, parallelResults);
Assert.IsTrue(parallelDuration + 40 < sequentialDuration);
}
}

Loading