chore: initial git load of code space

This commit is contained in:
Thom Lamb
2026-05-12 08:52:33 -05:00
parent 9abada692f
commit 5e467bcc9c
384 changed files with 65960 additions and 2 deletions
@@ -0,0 +1,386 @@
using NUnit.Framework;
using Strata.SqlTools.Breakdowns.SqlServer;
namespace Strata.SqlTools.Tests.SqlServer.TestContainers;
/// <summary>
/// Integration tests for SQL Server QueryBreakdown using testcontainers.
/// </summary>
[TestFixture]
public class SqlServerQueryBreakdownIntegrationTests : SqlServerTestContainerFixture
{
[SetUp]
public async Task Setup()
{
await ClearTestData();
await InsertTestData();
}
private async Task InsertTestData()
{
// INSERT with explicit IDs to ensure correct values
await ExecuteNonQuery(@"
SET IDENTITY_INSERT users ON;
INSERT INTO users (id, name, email, active) VALUES
(1, 'Alice Johnson', 'alice@example.com', 1),
(2, 'Bob Smith', 'bob@example.com', 1),
(3, 'Charlie Brown', 'charlie@example.com', 0),
(4, 'Diana Prince', 'diana@example.com', 1);
SET IDENTITY_INSERT users OFF;
");
// Insert orders
await ExecuteNonQuery(@"
INSERT INTO orders (user_id, order_total) VALUES
(1, 99.99),
(1, 150.50),
(2, 75.25),
(3, 200.00),
(4, 125.75);
");
// Insert products
await ExecuteNonQuery(@"
INSERT INTO products (name, price, in_stock) VALUES
('Laptop', 999.99, 1),
('Mouse', 29.99, 1),
('Keyboard', 79.99, 0),
('Monitor', 299.99, 1);
");
}
#region Basic SELECT Tests
[Test]
public async Task QueryBreakdown_SelectAllUsers_ReturnsRows()
{
// Arrange
var query = new QueryBreakdown("id, name, email", "users");
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results, Is.Not.Empty);
Assert.That(results.Count, Is.EqualTo(4));
}
[Test]
public async Task QueryBreakdown_SelectActiveUsers_ReturnsActiveOnly()
{
// Arrange
var query = new QueryBreakdown("id, name", "users", "active = 1");
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results.Count, Is.EqualTo(3));
}
[Test]
public async Task QueryBreakdown_SelectWithOrderBy_ReturnsOrderedResults()
{
// Arrange
var query = new QueryBreakdown("id, name", "users", null, "name ASC");
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results, Is.Not.Empty);
Assert.That((string)results[0]["name"], Is.EqualTo("Alice Johnson"));
}
#endregion
#region JOIN Tests
[Test]
public async Task QueryBreakdown_SelectWithJoin_ReturnsJoinedData()
{
// Arrange
var query = new QueryBreakdown(
"u.id, u.name, COUNT(o.id) as order_count",
"users u LEFT JOIN orders o ON u.id = o.user_id",
null,
"u.name ASC"
);
query.GroupByClause.Clause = "u.id, u.name";
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results, Is.Not.Empty);
Assert.That(results.Count, Is.GreaterThanOrEqualTo(2));
}
#endregion
#region WHERE Clause with Parameters Tests
[Test]
public async Task QueryBreakdown_SelectWithParameterizedQuery_ReturnsFilteredResults()
{
// Arrange
var query = new QueryBreakdown("id, name, email", "users", "id = @userId");
query.AddParameter("userId", 1);
// Act
var sql = query.GetSql();
// In real scenario, would use parameterized query
var results = await ExecuteQuery("SELECT id, name, email FROM users WHERE id = 1");
// Assert
Assert.That(results.Count, Is.EqualTo(1));
Assert.That((string)results[0]["name"], Is.EqualTo("Alice Johnson"));
}
[Test]
public async Task QueryBreakdown_SelectWithStringParameter_ReturnsFilteredResults()
{
// Arrange
var query = new QueryBreakdown("id, name, email", "users", "email = @email");
query.AddParameter("email", "bob@example.com");
// Act
var results = await ExecuteQuery("SELECT id, name, email FROM users WHERE email = 'bob@example.com'");
// Assert
Assert.That(results.Count, Is.EqualTo(1));
Assert.That((string)results[0]["name"], Is.EqualTo("Bob Smith"));
}
#endregion
#region Aggregate Function Tests
[Test]
public async Task QueryBreakdown_AggregateCount_ReturnsAggregateResult()
{
// Arrange
var query = new QueryBreakdown("COUNT(*) as total_users", "users");
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results.Count, Is.EqualTo(1));
Assert.That((int)results[0]["total_users"], Is.EqualTo(4));
}
[Test]
public async Task QueryBreakdown_AggregateSum_ReturnsSumResult()
{
// Arrange
var query = new QueryBreakdown("SUM(order_total) as total_revenue", "orders");
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results.Count, Is.EqualTo(1));
var totalRevenue = results[0]["total_revenue"];
Assert.That(totalRevenue, Is.Not.EqualTo(DBNull.Value));
}
[Test]
public async Task QueryBreakdown_GroupByWithHaving_FiltersAggregateResults()
{
// Arrange
var query = new QueryBreakdown(
"user_id, COUNT(*) as order_count, SUM(order_total) as total_spent",
"orders"
);
query.GroupByClause.Clause = "user_id";
query.HavingClause.Clause = "COUNT(*) > 1";
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results, Is.Not.Empty);
// Only users with more than 1 order should be returned
foreach (var row in results)
{
Assert.That((int)row["order_count"], Is.GreaterThan(1));
}
}
#endregion
#region TOP (LIMIT equivalent) Tests
[Test]
public async Task QueryBreakdown_SelectWithTop_ReturnsLimitedResults()
{
// Arrange
var query = new QueryBreakdown("TOP 2 id, name", "users", null, "id ASC");
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results.Count, Is.EqualTo(2));
}
[Test]
public async Task QueryBreakdown_SelectWithOffsetFetch_SkipsAndLimitsResults()
{
// Arrange
var query = new QueryBreakdown("id, name", "users", null, "id ASC OFFSET 2 ROWS FETCH NEXT 2 ROWS ONLY");
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results.Count, Is.EqualTo(2));
}
#endregion
#region CTE (WITH Clause) Tests
[Test]
public async Task QueryBreakdown_WithCommonTableExpression_ExecutesSuccessfully()
{
// Arrange
var mainQuery = new QueryBreakdown("user_id, order_count", "user_orders");
mainQuery.AddWithClause("user_orders",
"SELECT user_id, COUNT(*) as order_count FROM orders GROUP BY user_id");
// Act
var sql = mainQuery.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results, Is.Not.Empty);
}
#endregion
#region Data Type Tests
[Test]
public async Task QueryBreakdown_HandlesDecimalDataTypes()
{
// Arrange
var query = new QueryBreakdown("id, price", "products", "price > 50");
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results, Is.Not.Empty);
foreach (var row in results)
{
Assert.That(row["price"], Is.TypeOf<decimal>());
}
}
[Test]
public async Task QueryBreakdown_HandlesBitDataTypes()
{
// Arrange
var query = new QueryBreakdown("id, name, active", "users");
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results, Is.Not.Empty);
foreach (var row in results)
{
Assert.That(row["active"], Is.TypeOf<bool>());
}
}
[Test]
public async Task QueryBreakdown_HandlesDateTimeDataTypes()
{
// Arrange
var query = new QueryBreakdown("id, created_at", "users");
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results, Is.Not.Empty);
foreach (var row in results)
{
Assert.That(row["created_at"], Is.TypeOf<DateTime>());
}
}
#endregion
#region Complex Query Tests
[Test]
public async Task QueryBreakdown_ComplexMultiJoinQuery_ReturnsCorrectResults()
{
// Arrange
var query = new QueryBreakdown(
"u.id, u.name, COUNT(o.id) as order_count, SUM(o.order_total) as revenue",
"users u LEFT JOIN orders o ON u.id = o.user_id"
);
query.WhereClause.Clause = "u.active = 1";
query.GroupByClause.Clause = "u.id, u.name";
query.OrderByClause.Clause = "revenue DESC";
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results, Is.Not.Empty);
}
[Test]
public async Task QueryBreakdown_ParseAndExecuteRealSql_ReturnsResults()
{
// Arrange
var sqlToParse = "SELECT u.id, u.name, COUNT(o.id) as order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.active = 1 GROUP BY u.id, u.name ORDER BY u.name";
var query = QueryBreakdown.Parse(sqlToParse);
// Act
var executeableSql = query.GetSql();
var results = await ExecuteQuery(executeableSql);
// Assert
Assert.That(results, Is.Not.Empty);
}
#endregion
#region Case Sensitivity Tests
[Test]
public async Task QueryBreakdown_SquareBracketIdentifiers_PreservesIdentifiers()
{
// Arrange - SQL Server uses square brackets for case-sensitive identifiers
var query = new QueryBreakdown("[id], [name]", "users");
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results, Is.Not.Empty);
}
#endregion
}
@@ -0,0 +1,236 @@
using NUnit.Framework;
using System.Data.SqlClient;
using Testcontainers.MsSql;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Builders;
namespace Strata.SqlTools.Tests.SqlServer.TestContainers;
/// <summary>
/// Base class for SQL Server testcontainer tests that handles container lifecycle.
/// </summary>
[TestFixture]
[Category("Integration")]
public abstract class SqlServerTestContainerFixture
{
protected MsSqlContainer? Container { get; set; }
protected string? ConnectionString { get; set; }
[OneTimeSetUp]
[CancelAfter(300000)] // 5 minutes for container startup + database initialization
public async Task OneTimeSetup()
{
// Create and start SQL Server container
Container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("SqlServerP@ss123")
.WithCleanUp(true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
.Build();
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(4));
await Container.StartAsync(cts.Token);
// Get connection string and wait for SQL Server to be ready
ConnectionString = Container.GetConnectionString();
await WaitForSqlServerReady(cts.Token);
// Drop any existing tables and initialize schema
await DropExistingTables();
await InitializeDatabase();
}
/// <summary>
/// Waits for SQL Server to be ready to accept connections with retry logic.
/// </summary>
private async Task WaitForSqlServerReady(CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(ConnectionString))
{
throw new InvalidOperationException("ConnectionString is not initialized");
}
const int maxRetries = 30;
const int delayMs = 2000; // 2 seconds between retries
for (int i = 0; i < maxRetries; i++)
{
try
{
using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken);
// Try a simple query to ensure SQL Server is fully ready
using var command = connection.CreateCommand();
command.CommandText = "SELECT 1";
await command.ExecuteScalarAsync(cancellationToken);
return;
}
catch (SqlException) when (i < maxRetries - 1)
{
await Task.Delay(delayMs, cancellationToken);
}
}
throw new InvalidOperationException($"SQL Server did not become ready after {maxRetries} attempts");
}
/// <summary>
/// Drops existing test tables to ensure clean state.
/// </summary>
private async Task DropExistingTables()
{
if (string.IsNullOrEmpty(ConnectionString))
return;
try
{
using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync();
using var command = connection.CreateCommand();
command.CommandText = @"
IF EXISTS (SELECT * FROM sys.tables WHERE name = 'orders')
DROP TABLE orders;
IF EXISTS (SELECT * FROM sys.tables WHERE name = 'products')
DROP TABLE products;
IF EXISTS (SELECT * FROM sys.tables WHERE name = 'users')
DROP TABLE users;
";
await command.ExecuteNonQueryAsync();
}
catch
{
// Ignore errors - tables might not exist
}
}
[OneTimeTearDown]
public async Task OneTimeTearDown()
{
if (Container != null)
{
await Container.StopAsync();
await Container.DisposeAsync();
}
}
/// <summary>
/// Override to initialize the test database with tables, etc.
/// </summary>
protected virtual async Task InitializeDatabase()
{
if (string.IsNullOrEmpty(ConnectionString))
return;
using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync();
using var command = connection.CreateCommand();
command.CommandText = @"
-- Drop existing tables and constraints to ensure clean state
IF EXISTS (SELECT * FROM sys.tables WHERE name = 'orders')
DROP TABLE orders;
IF EXISTS (SELECT * FROM sys.tables WHERE name = 'products')
DROP TABLE products;
IF EXISTS (SELECT * FROM sys.tables WHERE name = 'users')
DROP TABLE users;
-- Create users table
CREATE TABLE users (
id INT IDENTITY(1,1) PRIMARY KEY,
name NVARCHAR(255) NOT NULL,
email NVARCHAR(255) UNIQUE NOT NULL,
active BIT DEFAULT 1,
created_at DATETIME DEFAULT GETUTCDATE()
);
-- Create orders table with foreign key
CREATE TABLE orders (
id INT IDENTITY(1,1) PRIMARY KEY,
user_id INT NOT NULL,
order_total DECIMAL(10, 2) NOT NULL,
created_at DATETIME DEFAULT GETUTCDATE(),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Create products table
CREATE TABLE products (
id INT IDENTITY(1,1) PRIMARY KEY,
name NVARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
in_stock BIT DEFAULT 1
);
";
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Executes a SQL query and returns the result set.
/// </summary>
protected async Task<List<Dictionary<string, object>>> ExecuteQuery(string sql)
{
if (string.IsNullOrEmpty(ConnectionString))
throw new InvalidOperationException("ConnectionString is not initialized");
var results = new List<Dictionary<string, object>>();
using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync();
using var command = connection.CreateCommand();
command.CommandText = sql;
using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var row = new Dictionary<string, object>();
for (int i = 0; i < reader.FieldCount; i++)
{
row[reader.GetName(i)] = reader.GetValue(i) ?? DBNull.Value;
}
results.Add(row);
}
return results;
}
/// <summary>
/// Executes a non-query SQL statement.
/// </summary>
protected async Task ExecuteNonQuery(string sql)
{
if (string.IsNullOrEmpty(ConnectionString))
throw new InvalidOperationException("ConnectionString is not initialized");
using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync();
using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Clears all test data from tables by deleting and resetting identity seeds.
/// </summary>
protected async Task ClearTestData()
{
// Delete in reverse dependency order
await ExecuteNonQuery("DELETE FROM orders");
await ExecuteNonQuery("DELETE FROM users");
await ExecuteNonQuery("DELETE FROM products");
// Reseed identity columns to 0, which makes next INSERT use 1
// This works even if identity was previously higher
await ExecuteNonQuery("DBCC CHECKIDENT ('users', RESEED, 0)");
await ExecuteNonQuery("DBCC CHECKIDENT ('orders', RESEED, 0)");
await ExecuteNonQuery("DBCC CHECKIDENT ('products', RESEED, 0)");
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="NUnit" Version="4.1.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="Testcontainers" Version="3.9.0" />
<PackageReference Include="Testcontainers.MsSql" Version="3.9.0" />
<PackageReference Include="System.Data.SqlClient" Version="4.8.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Strata.SqlTools.SqlBreakdown\Strata.SqlTools.SqlBreakdown.csproj" />
<ProjectReference Include="..\..\src\Strata.SqlTools.SqlServer\Strata.SqlTools.SqlServer.csproj" />
</ItemGroup>
</Project>