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
25 changes: 13 additions & 12 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,32 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageVersion Include="AltaSoft.DomainPrimitives" Version="8.0.3" />
<PackageVersion Include="Microsoft.OData.Core" Version="8.4.4" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
<PackageVersion Include="Microsoft.Build.Framework" Version="18.8.2" />
<PackageVersion Include="Microsoft.Build.Utilities.Core" Version="18.8.2" />
<PackageVersion Include="AltaSoft.DomainPrimitives" Version="8.1.3" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
<PackageVersion Include="Microsoft.Build.Framework" Version="18.9.6" />
<PackageVersion Include="Microsoft.Build.Utilities.Core" Version="18.9.6" />
<PackageVersion Include="Meziantou.Analyzer" Version="2.0.145" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.ObjectPool" Version="10.0.10" />
<PackageVersion Include="System.Configuration.ConfigurationManager" Version="10.0.10" />
<PackageVersion Include="System.Text.Json" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.ObjectPool" Version="10.0.12" />
<PackageVersion Include="System.Configuration.ConfigurationManager" Version="10.0.12" />
<PackageVersion Include="System.Text.Json" Version="10.0.12" />
<PackageVersion Include="Microsoft.OData.Core" Version="9.0.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.18" />
<PackageVersion Include="Microsoft.Extensions.ObjectPool" Version="9.0.18" />
<PackageVersion Include="System.Configuration.ConfigurationManager" Version="9.0.18" />
<PackageVersion Include="System.Text.Json" Version="9.0.18" />
<PackageVersion Include="Microsoft.OData.Core" Version="8.4.4" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.3" />
<PackageVersion Include="Microsoft.Extensions.ObjectPool" Version="8.0.29" />
<PackageVersion Include="System.Configuration.ConfigurationManager" Version="8.0.1" />
<PackageVersion Include="System.Text.Json" Version="8.0.6" />
<PackageVersion Include="Microsoft.OData.Core" Version="8.4.4" />
</ItemGroup>
</Project>
</Project>
6 changes: 3 additions & 3 deletions Tests/Benchmarks/AltaSoft.Storm.Benchmarks.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
<PackageReference Include="Dapper" Version="2.1.79" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" />
<PackageReference Include="Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers" Version="18.7.37220.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.10" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
</ItemGroup>

Expand Down
4 changes: 2 additions & 2 deletions Tests/OrmTests/AltaSoft.Storm.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="FluentAssertions" Version="8.10.0" />

<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.0" />

<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Expand Down
87 changes: 87 additions & 0 deletions Tests/OrmTests/DeleteTopIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using AltaSoft.Storm.TestModels;
using FluentAssertions;
using Xunit;

namespace AltaSoft.Storm.Tests;

/// <summary>
/// End-to-end (real database) verification that DELETE ... Top(n) actually
/// limits the number of deleted rows per call, instead of deleting every
/// row that matches the WHERE clause.
/// </summary>
public class DeleteTopIntegrationTests : IClassFixture<DatabaseFixture>, IAsyncLifetime
{
private const int BatchSize = 10;
private const int TotalUsers = 100;
private const int FirstUserId = 1000;

private readonly TestStormContext _context;

public DeleteTopIntegrationTests(DatabaseFixture fixture)
{
_context = new TestStormContext(fixture.ConnectionString);
}

public async Task InitializeAsync()
{
// Inserted in small batches - a single 100-row batch with all of User's
// detail-table columns (Cars, Dates, lists, ...) exceeds SQL Server's
// 2100-parameter-per-request limit.
const int insertBatchSize = 10;
for (var batchStart = 0; batchStart < TotalUsers; batchStart += insertBatchSize)
{
var batch = new List<User>(insertBatchSize);
for (var i = batchStart; i < batchStart + insertBatchSize; i++)
{
batch.Add(DatabaseHelper.NewUser(FirstUserId + i));
}

await _context.InsertIntoUsersTable().Values(batch).GoAsync();
}
}

[Fact]
public async Task DeleteWithTop_RemovesExactlyTopRowsPerCall_UntilNoneLeft()
{
// Sanity check: all 100 rows were inserted
var remainingBeforeDelete = await _context.SelectFromUsersTable()
.Where(x => x.UserId >= FirstUserId)
.ListAsync();
remainingBeforeDelete.Should().HaveCount(TotalUsers);

var iterations = 0;
var totalDeleted = 0;

while (true)
{
var deletedCount = await _context.DeleteFromUsersTable()
.Where(x => x.UserId >= FirstUserId)
.Top(BatchSize)
.GoAsync();

if (deletedCount == 0)
break;

// Every call must delete exactly BatchSize rows, never more.
deletedCount.Should().Be(BatchSize);

iterations++;
totalDeleted += deletedCount;
}

iterations.Should().Be(TotalUsers / BatchSize);
totalDeleted.Should().Be(TotalUsers);

var remainingAfterDelete = await _context.SelectFromUsersTable()
.Where(x => x.UserId >= FirstUserId)
.ListAsync();
remainingAfterDelete.Should().BeEmpty();
}

public async Task DisposeAsync()
{
await _context.DisposeAsync().ConfigureAwait(false);
}
}
185 changes: 185 additions & 0 deletions Tests/OrmTests/ODataFilterStatementGeneratorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Text;
using System.Threading.Tasks;
using AltaSoft.Storm.Crud;
using AltaSoft.Storm.Extensions;
using AltaSoft.Storm.Helpers;
using AltaSoft.Storm.TestModels;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Xunit;
using Xunit.Abstractions;
using StormDbParameter = Microsoft.Data.SqlClient.SqlParameter;

namespace AltaSoft.Storm.Tests;

/// <summary>
/// Verifies that OData $filter expressions are translated to parameterized SQL correctly,
/// specifically for DateOnly and TimeOnly properties (Edm.Date / Edm.TimeOfDay).
///
/// Mirrors SqlStatementGeneratorTests, but drives the OData filter generator
/// (ODataFilterStatementGenerator.GenerateSql) instead of LINQ where-expressions.
/// No database connection is opened - only the SQL-generation pipeline runs.
/// </summary>
public class ODataFilterStatementGeneratorTests : IClassFixture<DatabaseFixture>, IAsyncLifetime
{
private readonly TestStormContext _context;

public ODataFilterStatementGeneratorTests(DatabaseFixture fixture, ITestOutputHelper output)
{
var logger = new XunitLogger<DatabaseFixture>(output);

StormManager.SetLogger(logger);

_context = new TestStormContext(fixture.ConnectionString);
}

public Task InitializeAsync() => Task.CompletedTask;

public async Task DisposeAsync() => await _context.DisposeAsync().ConfigureAwait(false);

private sealed class FakeCommand : IVirtualStormDbCommand
{
public readonly List<(string Name, UnifiedDbType DbType, int Size, object? Value)> Params = [];
public string CommandText { get; set; } = string.Empty;
public CommandType CommandType { get; set; }

public string AddDbParameter(int paramIdx, StormColumnDef column, object? value)
{
var name = "@p" + paramIdx.ToString(System.Globalization.CultureInfo.InvariantCulture);
Params.Add((name, column.DbType, column.Size, value));
return name;
}

public StormDbParameter AddDbParameter(string paramName, UnifiedDbType dbType, int size, object? value)
{
Params.Add((paramName, dbType, size, value));
return default!;
}

public void AddDbParameters(List<StormCallParameter> callParameters) { }

public void SetStormCommandBaseParameters(SqlConnection connection, SqlTransaction? transaction, string sql,
QueryParameters queryParameters, CommandType commandType = CommandType.Text) => throw new NotImplementedException();

public void SetStormCommandBaseParameters(string sql, QueryParameters queryParameters,
CommandType commandType = CommandType.Text) => throw new NotImplementedException();

public void SetStormCommandBaseParameters(SqlConnection connection, SqlTransaction? transaction) => throw new NotImplementedException();

public string? GenerateCallParameters(List<StormCallParameter>? queryParametersCallParameters, CallParameterType type) => null;
}

private static (string sql, FakeCommand cmd) RunODataFilter(string filter, StormColumnDef[] columns)
{
var sb = new StringBuilder();
var cmd = new FakeCommand();
var idx = 0;
ODataFilterStatementGenerator.GenerateSql(cmd, filter, columns, null, ref idx, sb);
return (sb.ToString(), cmd);
}

[Fact]
public void DateOnly_Equal_ShouldGenerateParameterAndSql()
{
var ctrl = StormControllerCache.Get<SqlWhereTestEntity>(0);
var cols = ctrl.ColumnDefs;

var (sql, cmd) = RunODataFilter("DateValue eq 2024-05-17", cols);

sql.Should().Be("[DateValue] = @p0");
cmd.Params.Should().HaveCount(1);
cmd.Params[0].DbType.Should().Be(UnifiedDbType.Date);
cmd.Params[0].Value.Should().Be(new DateOnly(2024, 5, 17));
}

[Fact]
public void DateOnly_GreaterThan_ShouldGenerateParameterAndSql()
{
var ctrl = StormControllerCache.Get<SqlWhereTestEntity>(0);
var cols = ctrl.ColumnDefs;

var (sql, cmd) = RunODataFilter("DateValue gt 2024-01-01", cols);

sql.Should().Be("[DateValue] > @p0");
cmd.Params.Should().HaveCount(1);
cmd.Params[0].Value.Should().Be(new DateOnly(2024, 1, 1));
}

[Fact]
public void NullableDateOnly_EqualNull_ShouldGenerateIsNull()
{
var ctrl = StormControllerCache.Get<SqlWhereTestEntity>(0);
var cols = ctrl.ColumnDefs;

var (sql, cmd) = RunODataFilter("DateValueN eq null", cols);

sql.Should().Be("[DateValueN] IS NULL");
cmd.Params.Should().BeEmpty();
}

[Fact]
public void NullableDateOnly_NotEqualNull_ShouldGenerateIsNotNull()
{
var ctrl = StormControllerCache.Get<SqlWhereTestEntity>(0);
var cols = ctrl.ColumnDefs;

var (sql, cmd) = RunODataFilter("DateValueN ne null", cols);

sql.Should().Be("[DateValueN] IS NOT NULL");
cmd.Params.Should().BeEmpty();
}

[Fact]
public void TimeOnly_Equal_ShouldGenerateParameterAndSql()
{
var ctrl = StormControllerCache.Get<SqlWhereTestEntity>(0);
var cols = ctrl.ColumnDefs;

var (sql, cmd) = RunODataFilter("TimeValue eq 13:45:30", cols);

sql.Should().Be("[TimeValue] = @p0");
cmd.Params.Should().HaveCount(1);
cmd.Params[0].DbType.Should().Be(UnifiedDbType.Time);
cmd.Params[0].Value.Should().Be(new TimeOnly(13, 45, 30));
}

[Fact]
public void TimeOnly_LessThan_ShouldGenerateParameterAndSql()
{
var ctrl = StormControllerCache.Get<SqlWhereTestEntity>(0);
var cols = ctrl.ColumnDefs;

var (sql, cmd) = RunODataFilter("TimeValue lt 08:30:00", cols);

sql.Should().Be("[TimeValue] < @p0");
cmd.Params.Should().HaveCount(1);
cmd.Params[0].Value.Should().Be(new TimeOnly(8, 30, 0));
}

[Fact]
public void NullableTimeOnly_EqualNull_ShouldGenerateIsNull()
{
var ctrl = StormControllerCache.Get<SqlWhereTestEntity>(0);
var cols = ctrl.ColumnDefs;

var (sql, cmd) = RunODataFilter("TimeValueN eq null", cols);

sql.Should().Be("[TimeValueN] IS NULL");
cmd.Params.Should().BeEmpty();
}

[Fact]
public void NullableTimeOnly_NotEqualNull_ShouldGenerateIsNotNull()
{
var ctrl = StormControllerCache.Get<SqlWhereTestEntity>(0);
var cols = ctrl.ColumnDefs;

var (sql, cmd) = RunODataFilter("TimeValueN ne null", cols);

sql.Should().Be("[TimeValueN] IS NOT NULL");
cmd.Params.Should().BeEmpty();
}
}
Loading