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
+190
View File
@@ -0,0 +1,190 @@
# testContainers - Database Integration Tests
This directory contains comprehensive integration tests using [Testcontainers](https://testcontainers.com/) for testing the SQL utilities against real database instances running in Docker containers.
## Overview
The testContainers projects provide true integration tests that:
- Spin up containerized database instances (PostgreSQL, SQL Server)
- Execute QueryBreakdown operations against real databases
- Verify SQL generation and execution across different database dialects
- Test data type handling and database-specific features
- Ensure compatibility with actual database behaviors
## Projects
### Strata.SqlTools.PostgreSql.TestContainers
Integration tests for PostgreSQL QueryBreakdown functionality using Testcontainers.PostgreSql.
**Features Tested:**
- Basic SELECT queries (filtering, ordering, pagination)
- JOIN operations (INNER, LEFT, RIGHT)
- Aggregate functions (COUNT, SUM, AVG, MIN, MAX)
- GROUP BY with HAVING clauses
- PostgreSQL-specific LIMIT/OFFSET syntax
- Double-quoted case-sensitive identifiers
- Data types: SERIAL, VARCHAR, DECIMAL, BOOLEAN, TIMESTAMP
- CTE (Common Table Expressions) via WITH clause
- Parameterized queries with $1, $2, ... positional syntax
**Test Files:**
- `PostgreSqlTestContainerFixture.cs` - Base class handling container lifecycle
- `PostgreSqlQueryBreakdownIntegrationTests.cs` - Integration test cases
**Running PostgreSQL Tests:**
```bash
dotnet test testContainers/Strata.SqlTools.PostgreSql.TestContainers
```
### Strata.SqlTools.SqlServer.TestContainers
Integration tests for SQL Server QueryBreakdown functionality using Testcontainers.MsSql.
**Features Tested:**
- Basic SELECT queries (filtering, ordering)
- JOIN operations (INNER, LEFT, RIGHT)
- Aggregate functions (COUNT, SUM, AVG, MIN, MAX)
- GROUP BY with HAVING clauses
- SQL Server TOP/OFFSET FETCH syntax
- Square bracket identifiers for case sensitivity
- Data types: INT, NVARCHAR, DECIMAL, BIT, DATETIME
- CTE (Common Table Expressions) via WITH clause
- Parameterized queries with @parameter syntax
**Test Files:**
- `SqlServerTestContainerFixture.cs` - Base class handling container lifecycle
- `SqlServerQueryBreakdownIntegrationTests.cs` - Integration test cases
**Running SQL Server Tests:**
```bash
dotnet test testContainers/Strata.SqlTools.SqlServer.TestContainers
```
## Requirements
- Docker daemon running locally (required for Testcontainers)
- .NET 8.0 SDK
- 2GB+ free disk space and RAM for running containers
## Container Images Used
- **PostgreSQL**: `postgres:16-alpine` (lightweight Alpine Linux version)
- **SQL Server**: `mcr.microsoft.com/mssql/server:2022-latest` (official Microsoft SQL Server image)
## Test Structure
Each test project follows NUnit's test organization pattern:
1. **Fixture Base Class** - Handles container lifecycle
- `OneTimeSetUp`: Creates and starts container
- `OneTimeTearDown`: Stops and cleans up container
- Helper methods for executing queries
2. **Test Classes** - Organized by feature
- BasicSELECT tests
- JOIN tests
- WHERE clause and parameter tests
- Aggregate function tests
- Pagination tests (LIMIT/OFFSET or TOP/FETCH)
- Data type handling tests
- Complex query tests
- Case sensitivity tests
## Example Test
```csharp
[Test]
public async Task QueryBreakdown_SelectActiveUsers_ReturnsActiveOnly()
{
// Arrange - Create QueryBreakdown with WHERE filter
var query = new QueryBreakdown("id, name", "users", "active = true");
// Act - Generate SQL and execute against containerized database
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert - Verify results match expected behavior
Assert.That(results.Count, Is.GreaterThan(0));
Assert.That(results.All(r => (bool)r["active"]), Is.True);
}
```
## Database Schema
Both test projects create the same test schema:
**users table:**
```sql
- id (PRIMARY KEY, auto-increment)
- name (100+ chars)
- email (unique, 100+ chars)
- active (boolean)
- created_at (timestamp)
```
**orders table:**
```sql
- id (PRIMARY KEY, auto-increment)
- user_id (FOREIGN KEY references users.id)
- order_total (decimal)
- created_at (timestamp)
```
**products table:**
```sql
- id (PRIMARY KEY, auto-increment)
- name (100+ chars)
- price (decimal)
- in_stock (boolean)
```
## Performance Considerations
- Container startup/teardown is performed once per test class (`OneTimeSetUp`/`OneTimeTearDown`)
- Individual test setup/teardown clears only test data (`SetUp`/`ClearTestData`)
- Test runs may take 30-60 seconds total depending on container image sizes and system performance
## Continuous Integration
When running in CI/CD pipelines:
1. Ensure Docker is available in the CI environment
2. Consider caching Docker images to speed up test execution
3. Monitor disk space as containers can consume significant storage
4. Set timeouts appropriately for container startup (default is usually 60 seconds)
## Future Enhancements
- [ ] Add Snowflake testcontainer tests (community image or alternative)
- [ ] Add MongoDB testcontainer tests for document-based queries
- [ ] Add performance benchmarking tests comparing query execution times
- [ ] Add stress tests with larger data sets
- [ ] Add transaction/rollback scenario tests
- [ ] Create fixtures for common test data scenarios (e.g., TpcH benchmark data)
- [ ] Add tests for advanced features (window functions, recursive CTEs, etc.)
## Troubleshooting
**Container fails to start:**
- Ensure Docker daemon is running
- Check available disk space and RAM
- Verify firewall rules allow Docker
- Review Docker logs: `docker logs <container_id>`
**Connection timeouts:**
- Increase container startup timeout in fixture
- Check Docker resource limits
- Verify network configuration
**Permission errors:**
- Ensure Docker socket is accessible
- Check user group membership for Docker
**Test failures on Mac M1/M2:**
- SQL Server image requires specific architecture variants
- Consider using PostgreSQL for primary testing on ARM systems
## References
- [Testcontainers documentation](https://testcontainers.com)
- [Testcontainers .NET](https://github.com/testcontainers/testcontainers-dotnet)
- [PostgreSQL Official Image](https://hub.docker.com/_/postgres)
- [SQL Server Official Image](https://hub.docker.com/_/microsoft-mssql-server)
@@ -0,0 +1,365 @@
using NUnit.Framework;
using Strata.SqlTools.Breakdowns.PostgreSql;
namespace Strata.SqlTools.Tests.PostgreSql.TestContainers;
/// <summary>
/// Integration tests for PostgreSQL QueryBreakdown using testcontainers.
/// </summary>
[TestFixture]
public class PostgreSqlQueryBreakdownIntegrationTests : PostgreSqlTestContainerFixture
{
[SetUp]
public async Task Setup()
{
await ClearTestData();
await InsertTestData();
}
private async Task InsertTestData()
{
if (DataSource == null)
return;
using var command = DataSource.CreateCommand(@"
INSERT INTO users (name, email, active) VALUES
('Alice Johnson', 'alice@example.com', true),
('Bob Smith', 'bob@example.com', true),
('Charlie Brown', 'charlie@example.com', false),
('Diana Prince', 'diana@example.com', true);
INSERT INTO orders (user_id, order_total) VALUES
(1, 99.99),
(1, 150.50),
(2, 75.25),
(3, 200.00),
(4, 125.75);
INSERT INTO products (name, price, in_stock) VALUES
('Laptop', 999.99, true),
('Mouse', 29.99, true),
('Keyboard', 79.99, false),
('Monitor', 299.99, true);
");
await command.ExecuteNonQueryAsync();
}
#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 = true");
// 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 = $1");
query.AddParameter("user_id", 1);
// Act
var sql = query.GetSql();
// In real scenario, would use parameterized query with the actual parameter value
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 = $1");
query.AddParameter("email", "bob@example.com");
// Act
// Execute with actual parameter substitution
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((long)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.Null);
}
[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((long)row["order_count"], Is.GreaterThan(1));
}
}
#endregion
#region LIMIT Tests
[Test]
public async Task QueryBreakdown_SelectWithLimit_ReturnsLimitedResults()
{
// Arrange - LIMIT is appended to ORDER BY clause
var query = new QueryBreakdown("id, name", "users", null, "id ASC LIMIT 2");
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results.Count, Is.EqualTo(2));
}
[Test]
public async Task QueryBreakdown_SelectWithLimitAndOffset_SkipsAndLimitsResults()
{
// Arrange - OFFSET and LIMIT in ORDER BY clause
var query = new QueryBreakdown("id, name", "users", null, "id ASC LIMIT 2 OFFSET 2");
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results.Count, Is.EqualTo(2));
}
#endregion
#region Case Sensitivity Tests
[Test]
public async Task QueryBreakdown_DoubleQuotedIdentifiers_PreservesCaseSensitivity()
{
// Arrange - PostgreSQL preserves case in double-quoted identifiers
var query = new QueryBreakdown("\"id\", \"name\"", "users");
// Act
var sql = query.GetSql();
// Should not fail due to case mismatch
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results, Is.Not.Empty);
}
#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 = true";
query.GroupByClause.Clause = "u.id, u.name";
query.OrderByClause.Clause = "revenue DESC NULLS LAST";
// Act
var sql = query.GetSql();
var results = await ExecuteQuery(sql);
// Assert
Assert.That(results, Is.Not.Empty);
Assert.That(results.Count, Is.GreaterThanOrEqualTo(2));
}
[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 = true 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 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_HandlesBooleanDataTypes()
{
// 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_HandlesTimestampDataTypes()
{
// 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
}
@@ -0,0 +1,130 @@
using NUnit.Framework;
using Npgsql;
using Testcontainers.PostgreSql;
namespace Strata.SqlTools.Tests.PostgreSql.TestContainers;
/// <summary>
/// Base class for PostgreSQL testcontainer tests that handles container lifecycle.
/// </summary>
[TestFixture]
[Category("Integration")]
public abstract class PostgreSqlTestContainerFixture
{
protected PostgreSqlContainer? Container { get; set; }
protected NpgsqlDataSource? DataSource { get; set; }
[OneTimeSetUp]
[Timeout(120000)] // 2 minutes for container startup + schema initialization
public async Task OneTimeSetup()
{
// Create and start PostgreSQL container
Container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("testdb")
.WithUsername("testuser")
.WithPassword("testpassword")
.WithCleanUp(true)
.Build();
await Container.StartAsync();
// Create data source for connection pooling
var connectionString = Container.GetConnectionString();
DataSource = NpgsqlDataSource.Create(connectionString);
// Initialize test database schema
await InitializeSchema();
}
[OneTimeTearDown]
public async Task OneTimeTearDown()
{
if (DataSource != null)
{
await DataSource.DisposeAsync();
}
if (Container != null)
{
await Container.StopAsync();
await Container.DisposeAsync();
}
}
/// <summary>
/// Override to initialize the test database schema with tables, etc.
/// </summary>
protected virtual async Task InitializeSchema()
{
if (DataSource == null)
return;
using var command = DataSource.CreateCommand(@"
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
order_total DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
in_stock BOOLEAN DEFAULT true
);
");
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 (DataSource == null)
throw new InvalidOperationException("DataSource is not initialized");
var results = new List<Dictionary<string, object>>();
using var command = DataSource.CreateCommand(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);
}
results.Add(row);
}
return results;
}
/// <summary>
/// Clears all test data from tables.
/// </summary>
protected async Task ClearTestData()
{
if (DataSource == null)
return;
using var command = DataSource.CreateCommand(@"
TRUNCATE TABLE orders, users, products RESTART IDENTITY CASCADE;
");
await command.ExecuteNonQueryAsync();
}
}
@@ -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.PostgreSql" Version="3.9.0" />
<PackageReference Include="Npgsql" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Strata.SqlTools.SqlBreakdown\Strata.SqlTools.SqlBreakdown.csproj" />
<ProjectReference Include="..\..\src\Strata.SqlTools.PostgreSql\Strata.SqlTools.PostgreSql.csproj" />
</ItemGroup>
</Project>
@@ -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>