diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7ed72e8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET (version from global.json) + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + # The smoke tests boot the API in-process and need a real SQL Server (Delta reads @@DBTS). + # The SA password is generated fresh for every run and never written to the repo or the log. + - name: Start SQL Server + run: | + set -euo pipefail + SA_PASSWORD="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9')Aa1!" + echo "::add-mask::$SA_PASSWORD" + + docker run -d --name sqlserver \ + -e ACCEPT_EULA=Y \ + -e "MSSQL_SA_PASSWORD=$SA_PASSWORD" \ + -p 1433:1433 \ + mcr.microsoft.com/mssql/server:2022-latest + + for i in $(seq 1 30); do + if docker exec sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -Q "SELECT 1" > /dev/null 2>&1; then + echo "SQL Server is ready after $((i * 2))s" + break + fi + if [ "$i" -eq 30 ]; then + echo "SQL Server did not become ready in time" >&2 + docker logs sqlserver >&2 + exit 1 + fi + sleep 2 + done + + echo "HYPERCACHE_TEST_CONNECTION=Server=localhost,1433;Database=HyperCacheDB_Test;User ID=sa;Password=$SA_PASSWORD;TrustServerCertificate=True;MultipleActiveResultSets=true" >> "$GITHUB_ENV" + + - name: Restore + run: dotnet restore HyperCache.slnx + + - name: Build + run: dotnet build HyperCache.slnx --configuration Release --no-restore + + - name: Test + run: dotnet test HyperCache.slnx --configuration Release --no-build --logger "trx;LogFileName=test-results.trx" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: "**/TestResults/*.trx" diff --git a/.gitignore b/.gitignore index 429cdbe..6f0845c 100644 --- a/.gitignore +++ b/.gitignore @@ -362,3 +362,6 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd /HyperCache/appsettings.Development.json + +# Claude Code guidance file (local only) +CLAUDE.md diff --git a/HyperCache.Api.Tests/CustomPropertiesEndpointTests.cs b/HyperCache.Api.Tests/CustomPropertiesEndpointTests.cs new file mode 100644 index 0000000..12386b0 --- /dev/null +++ b/HyperCache.Api.Tests/CustomPropertiesEndpointTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Net.Http.Json; + +namespace HyperCache.Api.Tests; + +public sealed class CustomPropertiesEndpointTests(HyperCacheFactory factory) : IClassFixture +{ + private const string PagedUrl = "/api/customproperties/paged?page=1&pageSize=5"; + + private sealed record PagedResponse(List Items, int CurrentPage, int TotalPages, bool HasPreviousPage, bool HasNextPage); + private sealed record Item(Guid Id, string Name, string Value, string ParentTable, string CreatedBy, string ModifiedBy); + + [Fact] + public async Task Paged_returns_page_with_etag() + { + using var client = factory.CreateClient(); + + var response = await client.GetAsync(PagedUrl); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Headers.ETag); + + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(5, body.Items.Count); + Assert.Equal(1, body.CurrentPage); + Assert.Equal(HyperCacheFactory.SeedCount / 5, body.TotalPages); + Assert.False(body.HasPreviousPage); + Assert.True(body.HasNextPage); + } + + [Fact] + public async Task Paged_with_matching_etag_returns_304() + { + using var client = factory.CreateClient(); + + var first = await client.GetAsync(PagedUrl); + var etag = first.Headers.ETag; + Assert.NotNull(etag); + + using var request = new HttpRequestMessage(HttpMethod.Get, PagedUrl); + request.Headers.IfNoneMatch.Add(etag); + var second = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotModified, second.StatusCode); + } + + [Fact] + public async Task Search_returns_matches_and_rejects_empty_keyword() + { + using var client = factory.CreateClient(); + + var ok = await client.GetAsync("/api/customproperties/search?keyword=_Property_1"); + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + var items = await ok.Content.ReadFromJsonAsync>(); + Assert.NotNull(items); + Assert.NotEmpty(items); + Assert.All(items, i => Assert.Contains("_Property_1", i.Name)); + + var bad = await client.GetAsync("/api/customproperties/search?keyword="); + Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode); + } + + [Fact] + public async Task Details_validates_id_and_returns_404_for_unknown() + { + using var client = factory.CreateClient(); + + var bad = await client.GetAsync("/api/customproperties/not-a-guid"); + Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode); + + var missing = await client.GetAsync($"/api/customproperties/{Guid.NewGuid()}"); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + + var page = await client.GetFromJsonAsync(PagedUrl); + Assert.NotNull(page); + var found = await client.GetAsync($"/api/customproperties/{page.Items[0].Id}"); + Assert.Equal(HttpStatusCode.OK, found.StatusCode); + } + + [Fact] + public async Task OpenApi_document_is_served_in_development() + { + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/openapi/v1.json"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + // Route templates keep the controller's casing in the document: /api/CustomProperties/paged + Assert.Contains("/api/customproperties/paged", json, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/HyperCache.Api.Tests/HyperCache.Api.Tests.csproj b/HyperCache.Api.Tests/HyperCache.Api.Tests.csproj new file mode 100644 index 0000000..deefeef --- /dev/null +++ b/HyperCache.Api.Tests/HyperCache.Api.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/HyperCache.Api.Tests/HyperCacheFactory.cs b/HyperCache.Api.Tests/HyperCacheFactory.cs new file mode 100644 index 0000000..a0e6350 --- /dev/null +++ b/HyperCache.Api.Tests/HyperCacheFactory.cs @@ -0,0 +1,91 @@ +using HyperCache.Api.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace HyperCache.Api.Tests; + +/// +/// Boots the real API in-process against a throw-away SQL Server database. +/// Delta reads @@DBTS, so these tests need a reachable SQL Server; there is no in-memory substitute. +/// +/// Connection string resolution: +/// 1. HYPERCACHE_TEST_CONNECTION environment variable, if set. +/// 2. Otherwise the API's own ConnectionStrings:DefaultConnection (appsettings*.json) with the +/// database name replaced by . +/// The database is dropped and re-created from migrations on every run. +/// +public sealed class HyperCacheFactory : WebApplicationFactory +{ + public const string TestDatabaseName = "HyperCacheDB_Test"; + public const string ConnectionEnvVar = "HYPERCACHE_TEST_CONNECTION"; + public const int SeedCount = 200; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment(Environments.Development); + builder.UseSetting("Seed:Count", SeedCount.ToString()); + + builder.ConfigureAppConfiguration((context, config) => + { + var connectionString = ResolveConnectionString(context.Configuration); + + // Runs while the host is being built, i.e. before Program's seeder executes. + RecreateDatabase(connectionString); + + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = connectionString + }); + }); + } + + private static string ResolveConnectionString(IConfiguration appConfiguration) + { + var fromEnv = Environment.GetEnvironmentVariable(ConnectionEnvVar); + if (!string.IsNullOrWhiteSpace(fromEnv)) + return fromEnv; + + var fromApp = appConfiguration.GetConnectionString("DefaultConnection"); + if (string.IsNullOrWhiteSpace(fromApp) || fromApp.Contains("")) + { + throw new InvalidOperationException( + $"No usable SQL Server connection string. Set the {ConnectionEnvVar} environment variable " + + "or configure ConnectionStrings:DefaultConnection in HyperCache/appsettings.Development.json."); + } + + return new SqlConnectionStringBuilder(fromApp) { InitialCatalog = TestDatabaseName }.ConnectionString; + } + + private static void RecreateDatabase(string connectionString) + { + // This drops the database. Refuse anything that doesn't look like a test database so a + // mis-set HYPERCACHE_TEST_CONNECTION can never wipe HyperCacheDB. + var database = new SqlConnectionStringBuilder(connectionString).InitialCatalog; + if (!database.EndsWith("_Test", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Refusing to drop database '{database}': test connection strings must target a database whose name ends in '_Test'."); + } + + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString) + .Options; + + using var db = new AppDbContext(options); + try + { + db.Database.EnsureDeleted(); + db.Database.Migrate(); + } + catch (SqlException ex) + { + throw new InvalidOperationException( + $"Could not reach SQL Server to create the test database '{TestDatabaseName}'. " + + $"Set {ConnectionEnvVar} to a reachable server. Underlying error: {ex.Message}", ex); + } + } +} diff --git a/HyperCache.Web/Constants/ApiConstant.cs b/HyperCache.Web/Constants/ApiConstant.cs index f3030b6..ddaa25c 100644 --- a/HyperCache.Web/Constants/ApiConstant.cs +++ b/HyperCache.Web/Constants/ApiConstant.cs @@ -1,10 +1,10 @@ -namespace HyperCache.Web.Constants; +namespace HyperCache.Web.Constants; -public class ApiConstant +public static class ApiConstant { public const string BaseUrl = "https://localhost:7148"; public const string CustomProperties = "api/customproperties"; public const string CustomPropertiesPaged = "api/customproperties/paged"; public const string CustomPropertiesSearch = "api/customproperties/search"; public const string CustomPropertiesAll = "api/customproperties/all"; -} \ No newline at end of file +} diff --git a/HyperCache.Web/HyperCache.Web.csproj b/HyperCache.Web/HyperCache.Web.csproj index 2ede3a0..b51965c 100644 --- a/HyperCache.Web/HyperCache.Web.csproj +++ b/HyperCache.Web/HyperCache.Web.csproj @@ -1,14 +1,15 @@ - net9.0 + net10.0 enable enable + true - - + + diff --git a/HyperCache.Web/Layout/MainLayout.razor b/HyperCache.Web/Layout/MainLayout.razor index 76eb725..9ee6c7f 100644 --- a/HyperCache.Web/Layout/MainLayout.razor +++ b/HyperCache.Web/Layout/MainLayout.razor @@ -6,7 +6,7 @@
diff --git a/HyperCache.Web/Pages/Home.razor b/HyperCache.Web/Pages/Home.razor index 9001e0b..a3831c9 100644 --- a/HyperCache.Web/Pages/Home.razor +++ b/HyperCache.Web/Pages/Home.razor @@ -1,7 +1,21 @@ -@page "/" +@page "/" -Home +HyperCache -

Hello, world!

+

HyperCache

-Welcome to your new app. +

+ A demo of HTTP caching with the Delta middleware + on .NET 10, Entity Framework Core 10 and SQL Server. +

+ +

+ The API behind this page holds one million rows. Every GET carries an ETag derived from + the database's change stamp, so repeating a request costs a 304 Not Modified instead of a query. + Open the browser's network tab, page back and forth, and watch the repeat requests come back in milliseconds. +

+ + diff --git a/HyperCache.Web/wwwroot/index.html b/HyperCache.Web/wwwroot/index.html index a7a1a3c..5d17423 100644 --- a/HyperCache.Web/wwwroot/index.html +++ b/HyperCache.Web/wwwroot/index.html @@ -1,17 +1,17 @@ - HyperCache + + -
@@ -20,13 +20,11 @@
-
An unhandled error has occurred. Reload 🗙
- + - diff --git a/HyperCache.Web/wwwroot/sample-data/weather.json b/HyperCache.Web/wwwroot/sample-data/weather.json deleted file mode 100644 index b745973..0000000 --- a/HyperCache.Web/wwwroot/sample-data/weather.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2022-01-06", - "temperatureC": 1, - "summary": "Freezing" - }, - { - "date": "2022-01-07", - "temperatureC": 14, - "summary": "Bracing" - }, - { - "date": "2022-01-08", - "temperatureC": -13, - "summary": "Freezing" - }, - { - "date": "2022-01-09", - "temperatureC": -16, - "summary": "Balmy" - }, - { - "date": "2022-01-10", - "temperatureC": -2, - "summary": "Chilly" - } -] diff --git a/HyperCache.sln b/HyperCache.sln deleted file mode 100644 index a77aafa..0000000 --- a/HyperCache.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.13.35617.110 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HyperCache.Api", "HyperCache\HyperCache.Api.csproj", "{25E73FD1-AA64-49EF-8076-BBF122492549}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HyperCache.Web", "HyperCache.Web\HyperCache.Web.csproj", "{26CC1505-16F6-4B6B-ADE4-5CAE532CBAEE}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {25E73FD1-AA64-49EF-8076-BBF122492549}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {25E73FD1-AA64-49EF-8076-BBF122492549}.Debug|Any CPU.Build.0 = Debug|Any CPU - {25E73FD1-AA64-49EF-8076-BBF122492549}.Release|Any CPU.ActiveCfg = Release|Any CPU - {25E73FD1-AA64-49EF-8076-BBF122492549}.Release|Any CPU.Build.0 = Release|Any CPU - {26CC1505-16F6-4B6B-ADE4-5CAE532CBAEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {26CC1505-16F6-4B6B-ADE4-5CAE532CBAEE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {26CC1505-16F6-4B6B-ADE4-5CAE532CBAEE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {26CC1505-16F6-4B6B-ADE4-5CAE532CBAEE}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {1210998D-1B2B-4245-8114-A8DD5B0A0F3D} - EndGlobalSection -EndGlobal diff --git a/HyperCache.slnx b/HyperCache.slnx new file mode 100644 index 0000000..242bf1c --- /dev/null +++ b/HyperCache.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/HyperCache/Controllers/CustomPropertiesController.cs b/HyperCache/Controllers/CustomPropertiesController.cs index 94b77d0..f2cefd7 100644 --- a/HyperCache/Controllers/CustomPropertiesController.cs +++ b/HyperCache/Controllers/CustomPropertiesController.cs @@ -1,4 +1,5 @@ using HyperCache.Api.Data; +using HyperCache.Api.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -10,17 +11,12 @@ public class CustomPropertiesController(AppDbContext context) : ControllerBase { [HttpGet("paged")] - public async Task GetPagedProperties([FromQuery] int page = 1, [FromQuery] int pageSize = 20) + public async Task GetPagedProperties([FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default) { if (page < 1 || pageSize < 1) return BadRequest("Page and PageSize must be greater than 0."); - var totalCount = await context.CustomProperties.CountAsync(); - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - - var properties = await context.CustomProperties - .Skip((page - 1) * pageSize) - .Take(pageSize) + var response = await context.CustomProperties .Select(p => new { p.Id, @@ -30,27 +26,18 @@ public async Task GetPagedProperties([FromQuery] int page = 1, [F p.CreatedBy, p.ModifiedBy }) - .ToListAsync(); - - var response = new - { - Items = properties, - CurrentPage = page, - TotalPages = totalPages, - HasPreviousPage = page > 1, - HasNextPage = page < totalPages - }; + .ToPagedAsync(page, pageSize, cancellationToken); return Ok(response); } [HttpGet("{id}")] - public async Task GetPropertyDetails(string id) + public async Task GetPropertyDetails(string id, CancellationToken cancellationToken) { if (!Guid.TryParse(id, out var propertyId)) return BadRequest("Invalid GUID format."); - var customProperty = await context.CustomProperties.FindAsync(propertyId); + var customProperty = await context.CustomProperties.FindAsync([propertyId], cancellationToken); if (customProperty == null) return NotFound(); @@ -58,22 +45,22 @@ public async Task GetPropertyDetails(string id) } [HttpGet("all")] - public async Task Get() + public async Task Get(CancellationToken cancellationToken) { - var customProperties = await context.CustomProperties.ToListAsync(); + var customProperties = await context.CustomProperties.ToListAsync(cancellationToken); return Ok(customProperties); } [HttpGet("search")] - public async Task Search([FromQuery] string keyword) + public async Task Search([FromQuery] string keyword, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(keyword)) return BadRequest("Keyword can't be empty!"); var customProperties = await context.CustomProperties .Where(x => x.Name.Contains(keyword)) - .ToListAsync(); + .ToListAsync(cancellationToken); return Ok(customProperties); } -} \ No newline at end of file +} diff --git a/HyperCache/Data/AppDbContext.cs b/HyperCache/Data/AppDbContext.cs index 68837ea..c1cacb7 100644 --- a/HyperCache/Data/AppDbContext.cs +++ b/HyperCache/Data/AppDbContext.cs @@ -1,4 +1,4 @@ -using HyperCache.Api.Models; +using HyperCache.Api.Models; using Microsoft.EntityFrameworkCore; namespace HyperCache.Api.Data; @@ -14,11 +14,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) var customProperty = modelBuilder.Entity(); customProperty.HasKey(cp => cp.Id); - // Configures RowVersion for Delta Package + // SQL Server rowversion column. Every write bumps @@DBTS, which is what Delta reads to build its ETag. customProperty .Property(cp => cp.RowVersion) - .IsRowVersion() - .HasConversion(); + .IsRowVersion(); // Configure required properties for the CustomProperty entity. customProperty.Property(cp => cp.Name).IsRequired(); @@ -27,4 +26,4 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) customProperty.Property(cp => cp.ModifiedBy).IsRequired(); customProperty.Property(cp => cp.ParentTable).IsRequired(); } -} \ No newline at end of file +} diff --git a/HyperCache/Data/DataSeeder.cs b/HyperCache/Data/DataSeeder.cs index 96c3398..32c8ad7 100644 --- a/HyperCache/Data/DataSeeder.cs +++ b/HyperCache/Data/DataSeeder.cs @@ -1,60 +1,56 @@ -using HyperCache.Api.Models; +using HyperCache.Api.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace HyperCache.Api.Data; -public class DataSeeder(AppDbContext context) +public class DataSeeder(AppDbContext context, IOptions options) { + // Sample parent table names and user names, picked with Random.GetItems (arrays convert to ReadOnlySpan implicitly). + private static readonly string[] ParentTableNames = ["Orders", "Products", "Users", "Invoices"]; + private static readonly string[] Users = ["Alice", "Bob", "Charlie", "Diana"]; + /// /// Seeds the CustomProperties table with sample data if it is empty. + /// The row count comes from (default 1,000,000). /// - public async Task SeedCustomPropertiesAsync() + public async Task SeedCustomPropertiesAsync(CancellationToken cancellationToken = default) { - if (await context.CustomProperties.AnyAsync()) + if (await context.CustomProperties.AnyAsync(cancellationToken)) return; // Skip seeding if data already exists. - var customProperties = GenerateCustomProperties(1_000_000); - foreach (var batch in customProperties.Chunk(10_000)) + // Rows are generated lazily and inserted in batches so the full set is never held in memory. + foreach (var batch in GenerateCustomProperties(options.Value.Count).Chunk(10_000)) { - await context.CustomProperties.AddRangeAsync(batch); - await context.SaveChangesAsync(); + await context.CustomProperties.AddRangeAsync(batch, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); } } - /// - /// Generates a list of custom properties with random data. + /// Generates custom properties with random data, one at a time. /// /// The number of custom properties to generate. - /// A list of CustomProperty objects. - private List GenerateCustomProperties(int count) + private static IEnumerable GenerateCustomProperties(int count) { - var random = new Random(); - var customProperties = new List(); + var random = Random.Shared; - // Sample parent table names and user names. - var parentTableNames = new[] { "Orders", "Products", "Users", "Invoices" }; - var users = new[] { "Alice", "Bob", "Charlie", "Diana" }; - - // Create and add a new custom property with random values. - for (int i = 0; i < count; i++) + for (var i = 0; i < count; i++) { - var parentId = Guid.NewGuid(); - var parentTable = parentTableNames[random.Next(parentTableNames.Length)]; - customProperties.Add(new CustomProperty + var parentTable = random.GetItems(ParentTableNames, 1)[0]; + yield return new CustomProperty { Id = Guid.NewGuid(), - ParentId = parentId, + ParentId = Guid.NewGuid(), Name = $"{parentTable}_Property_{i}", Value = $"Value_{random.Next(1, 1000)}", CreatedOn = DateTime.UtcNow.AddDays(-random.Next(1, 365)), ModifiedOn = DateTime.UtcNow.AddMinutes(-random.Next(1, 10000)), - CreatedBy = users[random.Next(users.Length)], - ModifiedBy = users[random.Next(users.Length)], + CreatedBy = random.GetItems(Users, 1)[0], + ModifiedBy = random.GetItems(Users, 1)[0], ParentTable = parentTable - }); + }; } - - return customProperties; } -} \ No newline at end of file +} diff --git a/HyperCache/Data/SeedOptions.cs b/HyperCache/Data/SeedOptions.cs new file mode 100644 index 0000000..9001d65 --- /dev/null +++ b/HyperCache/Data/SeedOptions.cs @@ -0,0 +1,22 @@ +namespace HyperCache.Api.Data; + +/// +/// Controls the one-time seeding that runs at startup. Bound from the "Seed" configuration section, +/// so the row count can be lowered for local runs and tests via Seed:Count / Seed__Count. +/// +public sealed class SeedOptions +{ + public const string SectionName = "Seed"; + + /// + /// Number of rows to insert when the CustomProperties table is empty. Defaults to 1,000,000. + /// + public int Count + { + get; + // C# 14 `field` keyword: validate in the setter without declaring a backing field by hand. + set => field = value > 0 + ? value + : throw new ArgumentOutOfRangeException(nameof(Count), value, "Seed:Count must be greater than 0."); + } = 1_000_000; +} diff --git a/HyperCache/Extensions/QueryableExtensions.cs b/HyperCache/Extensions/QueryableExtensions.cs new file mode 100644 index 0000000..f4541d7 --- /dev/null +++ b/HyperCache/Extensions/QueryableExtensions.cs @@ -0,0 +1,27 @@ +using HyperCache.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace HyperCache.Api.Extensions; + +public static class QueryableExtensions +{ + // C# 14 extension members: the receiver is declared once for the whole block. + extension(IQueryable source) + { + /// + /// Runs a COUNT plus a Skip/Take page query and wraps the results with paging metadata. + /// + public async Task> ToPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default) + { + var totalCount = await source.CountAsync(cancellationToken); + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + + var items = await source + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return new PagedResponse(items, page, totalPages); + } + } +} diff --git a/HyperCache/HyperCache.Api.csproj b/HyperCache/HyperCache.Api.csproj index a14b0ba..449e2d8 100644 --- a/HyperCache/HyperCache.Api.csproj +++ b/HyperCache/HyperCache.Api.csproj @@ -1,20 +1,21 @@ - net9.0 + net10.0 enable enable - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/HyperCache/HyperCache.http b/HyperCache/HyperCache.http index 7fe63a1..8f81821 100644 --- a/HyperCache/HyperCache.http +++ b/HyperCache/HyperCache.http @@ -1,6 +1,25 @@ -@HyperCache_HostAddress = http://localhost:5012 +@HyperCache_HostAddress = https://localhost:7148 -GET {{HyperCache_HostAddress}}/weatherforecast/ +### Paged list. Note the ETag header on the response. +GET {{HyperCache_HostAddress}}/api/customproperties/paged?page=1&pageSize=20 Accept: application/json -### +### Same request with the ETag from above: Delta answers 304 Not Modified without hitting the table. +GET {{HyperCache_HostAddress}}/api/customproperties/paged?page=1&pageSize=20 +Accept: application/json +If-None-Match: "" + +### Search by name +GET {{HyperCache_HostAddress}}/api/customproperties/search?keyword=Orders_Property_1 +Accept: application/json + +### Single item (use an Id from the paged response) +GET {{HyperCache_HostAddress}}/api/customproperties/00000000-0000-0000-0000-000000000000 +Accept: application/json + +### Everything (large payload; the request the README benchmarks) +GET {{HyperCache_HostAddress}}/api/customproperties/all +Accept: application/json + +### OpenAPI document (Development only). Browsable UI at {{HyperCache_HostAddress}}/scalar +GET {{HyperCache_HostAddress}}/openapi/v1.json diff --git a/HyperCache/Models/PagedResponse.cs b/HyperCache/Models/PagedResponse.cs new file mode 100644 index 0000000..aae5515 --- /dev/null +++ b/HyperCache/Models/PagedResponse.cs @@ -0,0 +1,11 @@ +namespace HyperCache.Api.Models; + +/// +/// Page of results plus navigation metadata. The JSON shape is mirrored by +/// HyperCache.Web/Dtos/PagedResponseDto.cs; keep the two in sync. +/// +public sealed record PagedResponse(IReadOnlyList Items, int CurrentPage, int TotalPages) +{ + public bool HasPreviousPage => CurrentPage > 1; + public bool HasNextPage => CurrentPage < TotalPages; +} diff --git a/HyperCache/Program.cs b/HyperCache/Program.cs index 86db680..1dc61b0 100644 --- a/HyperCache/Program.cs +++ b/HyperCache/Program.cs @@ -1,20 +1,27 @@ using Delta; using HyperCache.Api.Data; using Microsoft.EntityFrameworkCore; +using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.Configure(builder.Configuration.GetSection(SeedOptions.SectionName)); builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddCors(); +builder.Services.AddOpenApi(); var app = builder.Build(); +// CORS must run before Delta: Delta short-circuits with 304 Not Modified, and that response +// still needs Access-Control-Allow-Origin for the cross-origin Blazor client. +app.UseCors(x => x.AllowAnyOrigin()); + // Applies the Delta middleware to the specified AppDbContext for all incoming requests. // This enables Delta's features such as tracking and managing changes in the specified database context Globally. app.UseDelta(); @@ -31,9 +38,18 @@ await seeder.SeedCustomPropertiesAsync(); } -app.UseCors(x => x.AllowAnyOrigin()); +if (app.Environment.IsDevelopment()) +{ + // OpenAPI document at /openapi/v1.json and a browsable reference UI at /scalar. + app.MapOpenApi(); + app.MapScalarApiReference(); +} + app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run(); + +// Exposes the implicit Program class to WebApplicationFactory in the test project. +public partial class Program; diff --git a/HyperCache/appsettings.json b/HyperCache/appsettings.json index a7ef236..5e70c9c 100644 --- a/HyperCache/appsettings.json +++ b/HyperCache/appsettings.json @@ -6,7 +6,10 @@ } }, "ConnectionStrings": { - "DefaultConnection": "Server=;Database=HyperCacheDB;User ID=sa;Password=************;Trusted_Connection=True;TrustServerCertificate=True;TrustServerCertificate=True;MultipleActiveResultSets=true" + "DefaultConnection": "Server=;Database=HyperCacheDB;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=true" + }, + "Seed": { + "Count": 1000000 }, "AllowedHosts": "*" } diff --git a/LICENSE.txt b/LICENSE.txt index 8aa2645..3606b35 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) [year] [fullname] +Copyright (c) 2024 Biswajit Panday Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6583a04..b40cbf6 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,36 @@ # HyperCache -HyperCache is a lightweight and efficient caching solution built on the simple implementation of the Delta NuGet Package ([Delta](https://www.nuget.org/packages/Delta)). This project demonstrates the integration of modern technologies, including .NET 9.0, Entity Framework Core 9.0, Blazor, MSSQL, and Code-First database design patterns. +[![CI](https://github.com/biswajitpanday/HyperCache/actions/workflows/ci.yml/badge.svg)](https://github.com/biswajitpanday/HyperCache/actions/workflows/ci.yml) +![.NET 10](https://img.shields.io/badge/.NET-10.0-512BD4) +![C# 14](https://img.shields.io/badge/C%23-14-239120) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt) + +HyperCache is a lightweight and efficient caching solution built on the simple implementation of the Delta NuGet Package ([Delta.EF](https://www.nuget.org/packages/Delta.EF)). This project demonstrates the integration of modern technologies, including .NET 10, C# 14, Entity Framework Core 10, Blazor WebAssembly, MSSQL, and Code-First database design patterns. ## Project Overview -HyperCache is developed in **Microsoft Visual Studio Enterprise 2022 (64-bit) - Preview** and aims to provide a seamless, high-performance caching mechanism for .NET applications. By leveraging Entity Framework Core's Code-First approach and Blazor's modern UI framework, the project provides a robust and scalable solution for handling caching with SQL Server. +HyperCache is developed with the **.NET 10 SDK** and **Visual Studio 2026** and aims to provide a seamless, high-performance caching mechanism for .NET applications. By leveraging Entity Framework Core's Code-First approach and Blazor's modern UI framework, the project provides a robust and scalable solution for handling caching with SQL Server. + +The solution has three projects: + +| Project | Purpose | +|---|---| +| `HyperCache/` (`HyperCache.Api`) | ASP.NET Core Web API + EF Core 10 + Delta middleware. Seeds 1,000,000 rows on first start. | +| `HyperCache.Web/` | Standalone Blazor WebAssembly client that calls the API. | +| `HyperCache.Api.Tests/` | xUnit smoke tests that boot the API in-process against a throw-away SQL Server database. | ## Key Features -- **.NET 9.0**: Utilizes the latest advancements in the .NET framework for improved performance and productivity. -- **Entity Framework Core 9.0**: Implements Code-First database design for flexibility and maintainability. -- **Blazor**: Builds modern and interactive UI components. -- **Delta NuGet Package**: Provides a simple and effective caching implementation. +- **.NET 10 (LTS) / C# 14**: Utilizes the latest advancements in the .NET framework and language. +- **Entity Framework Core 10**: Implements Code-First database design for flexibility and maintainability. +- **Blazor WebAssembly**: Builds modern and interactive UI components, with .NET 10 fingerprinted static assets. +- **Delta.EF**: Provides a simple and effective HTTP caching implementation (ETag / `304 Not Modified`). +- **OpenAPI + Scalar**: Self-describing API with a browsable reference UI at `/scalar` in Development. - **MSSQL**: Ensures data persistence with SQL Server. - **Scalable Architecture**: Designed for ease of use and scalability in enterprise environments. ## Performance Comparison Table (1M Rows) +> Measured on the original .NET 9 build of this project. After the .NET 10 upgrade the behaviour was re-verified on a developer laptop: `GET /api/customproperties/all` over 1,000,000 rows took ~7 s uncached and ~16 ms with a matching `If-None-Match` (`304 Not Modified`). + | Metric | Before Implementation (1M Rows) | After Implementation (1M Rows) | |----------------------------|----------------------|----------------------| | **Total Requests** | 10 | 14 (Demonstration Purpose) | @@ -36,6 +52,12 @@ HyperCache is developed in **Microsoft Visual Studio Enterprise 2022 (64-bit) - ### After Implementation: ![After Implementation - 1](Screenshots/after%20Implementation%20-%201.png) +## Prerequisites +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (the repo's `global.json` pins the 10.0.4xx band) +- SQL Server (any edition; tested with SQL Server 2025 Developer) +- Optional: Visual Studio 2026 (18.0+) or any editor +- Optional: `dotnet tool install --global dotnet-ef` for migrations + ## Installation Follow these steps to set up the HyperCache project: @@ -45,43 +67,84 @@ Follow these steps to set up the HyperCache project: cd HyperCache ``` -2. Open the project in **Visual Studio Enterprise 2022**. - -3. Restore the required NuGet packages: - - Delta - - Entity Framework Core 9.0 etc. - - NuGet package restoration can be performed by running: +2. Restore the NuGet packages: ```bash dotnet restore ``` -4. Configure the connection string in the `appsettings.json` file to point to your MSSQL instance: +3. Point the API at your SQL Server. Create `HyperCache/appsettings.Development.json` (git-ignored) or edit `HyperCache/appsettings.json`: ```json - "ConnectionStrings": { - "DefaultConnection": "Server=YOUR_SERVER_NAME;Database=HyperCacheDb;Trusted_Connection=True;" + { + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Database=HyperCacheDB;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=true" + } } ``` + For SQL authentication replace `Trusted_Connection=True` with `User ID=...;Password=...`. -5. Apply the database migrations: +4. Apply the database migrations (run from the API project folder): ```bash + cd HyperCache dotnet ef database update + cd .. + ``` + +5. Run the API. Use the `https` profile — the Blazor client is hard-wired to `https://localhost:7148`: + ```bash + dotnet run --project HyperCache/HyperCache.Api.csproj --launch-profile https ``` + On first start against an empty database the API seeds **1,000,000** rows before it begins listening; expect a few minutes. See [Configuration](#configuration) to lower that for local work. -6. Build and run the project: +6. Run the Blazor client in a second terminal and open `https://localhost:7222`: ```bash - dotnet run + dotnet run --project HyperCache.Web/HyperCache.Web.csproj --launch-profile https ``` - -4. **Build and run the Blazor UI**: Use Blazor components for interactive user interfaces. -## How to Configure Delta Package +## Configuration -To properly configure and use the Delta package in HyperCache, follow these steps: +| Setting | Where | Default | Notes | +|---|---|---|---| +| `ConnectionStrings:DefaultConnection` | `appsettings*.json` / env `ConnectionStrings__DefaultConnection` | placeholder | SQL Server connection string. | +| `Seed:Count` | `appsettings*.json` / env `Seed__Count` | `1000000` | Rows inserted when the `CustomProperties` table is empty. Seeding is skipped if the table already has rows; use `dotnet ef database drop` to start over. | -### Step 1: Modify `OnModelCreating` in the `DbContext` +Example of a fast local start: +```bash +Seed__Count=20000 dotnet run --project HyperCache/HyperCache.Api.csproj --launch-profile https +``` + +## API + +| Endpoint | Description | +|---|---| +| `GET /api/customproperties/paged?page=1&pageSize=20` | Paged list with `CurrentPage`, `TotalPages`, `HasPreviousPage`, `HasNextPage`. | +| `GET /api/customproperties/all` | Every row (the request used in the benchmarks above). | +| `GET /api/customproperties/search?keyword=Orders` | Name contains `keyword`. | +| `GET /api/customproperties/{id}` | Single row by GUID; `400` for a malformed id, `404` if unknown. | +| `GET /openapi/v1.json` | OpenAPI 3.1 document (Development only). | +| `GET /scalar` | Browsable API reference UI (Development only). | + +`HyperCache/HyperCache.http` contains ready-made requests for all of these, including the `If-None-Match` round-trip that shows Delta returning `304`. + +### API Reference (Scalar) +![Scalar API reference](Screenshots/Scalar.jpg) + +## How Delta Is Wired Up -In your `AppDbContext.cs` file, configure the `RowVersion` column for delta tracking: +Delta is a middleware, not a service. Three pieces make it work in this project: + +### 1. A `rowversion` column on the entity (`Models/CustomProperty.cs`) + +```csharp +public class CustomProperty +{ + public Guid Id { get; set; } + // ... + [Timestamp] // SQL Server rowversion; every write bumps @@DBTS + public byte[] RowVersion { get; set; } = []; +} +``` + +### 2. The column mapped as a row version (`Data/AppDbContext.cs`) ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -91,56 +154,70 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) var customProperty = modelBuilder.Entity(); customProperty.HasKey(cp => cp.Id); - // Configures RowVersion for Delta Package customProperty .Property(cp => cp.RowVersion) - .IsRowVersion() - .HasConversion(); + .IsRowVersion(); } ``` -### Step 2: Register Delta Middleware in `Program.cs` +The `AddDeltaRowVersion` migration adds this column to the existing table. -Ensure the Delta package is registered before mapping controllers: +### 3. The middleware, registered after CORS (`Program.cs`) ```csharp -var builder = WebApplication.CreateBuilder(args); - builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); -builder.Services.AddControllers(); - -// Register Delta middleware -builder.Services.AddDelta(); - var app = builder.Build(); -app.UseHttpsRedirection(); -app.UseAuthorization(); +// CORS first: Delta short-circuits with 304 and that response still needs the CORS headers +// for the cross-origin Blazor client. +app.UseCors(x => x.AllowAnyOrigin()); -// Enable Delta middleware +// Every GET gets an ETag; a matching If-None-Match is answered with 304 before any controller runs. app.UseDelta(); -app.MapControllers(); +// Or scope it to specific paths: +// app.UseDelta(shouldExecute: ctx => ctx.Request.Path.ToString().Contains("CustomProperties")); +app.MapControllers(); app.Run(); ``` -### Step 3: Define Entity Model with `RowVersion` +### How Delta detects changes -Ensure the `CustomProperty` entity includes a `RowVersion` column: +Delta derives the ETag from a database-wide "last change" stamp, so it never has to look at the rows a request would return. On SQL Server it uses, in order of preference: -```csharp -public class CustomProperty -{ - public int Id { get; set; } +1. `sys.dm_db_log_stats` (requires `VIEW SERVER STATE`), +2. `change_tracking_current_version()` if change tracking is enabled on the database, +3. `@@DBTS`, the database's current `rowversion` value. - [Timestamp] // Required for EF Core concurrency tracking - public byte[] RowVersion { get; set; } -} +This project relies on option 3, which is why the `rowversion` column matters: any insert/update to `CustomProperties` advances `@@DBTS`, invalidating every cached ETag. Enabling SQL Server change tracking (`ALTER TABLE ... ENABLE CHANGE_TRACKING`) is optional and not required for the demo. + +## Running the Tests + +The tests boot the real API in-process (`WebApplicationFactory`) and need a reachable SQL Server because Delta reads `@@DBTS`. On each run they drop and re-create a database named `HyperCacheDB_Test` and seed it with 200 rows. + +```bash +dotnet test HyperCache.slnx ``` +The same suite runs in GitHub Actions on every push and pull request (`.github/workflows/ci.yml`), against a SQL Server 2022 container started in the job with a password generated per run. + +The connection is taken from `HYPERCACHE_TEST_CONNECTION` if set, otherwise from the API's `ConnectionStrings:DefaultConnection` with the database name swapped to `HyperCacheDB_Test`. + +Covered: paged response shape and `ETag`, `If-None-Match` → `304`, search (including the empty-keyword `400`), details (`400` / `404` / `200`), and the OpenAPI document. + +## C# 14 Features Used + +| Feature | Where | +|---|---| +| Extension members (`extension(IQueryable source) { ... }`) | `HyperCache/Extensions/QueryableExtensions.cs` — `ToPagedAsync` used by the controller | +| `field` keyword | `HyperCache/Data/SeedOptions.cs` — setter validation with no hand-written backing field | +| Collection expressions, primary constructors, `required` members | throughout | + +Features such as null-conditional assignment, `nameof` on unbound generics, and partial constructors have no natural home in a codebase this small and are deliberately not shoe-horned in. + ## Summary of Delta Package Implementation ### **Performance Gains** @@ -188,7 +265,7 @@ Contributions are welcome! If you want to improve the project, follow these step 4. Submit a pull request. ## License -This project is licensed under the [MIT License](LICENSE). +This project is licensed under the [MIT License](LICENSE.txt). ## Contact For any queries or suggestions, feel free to contact the repository owner: diff --git a/Screenshots/Scalar.jpg b/Screenshots/Scalar.jpg new file mode 100644 index 0000000..075f241 Binary files /dev/null and b/Screenshots/Scalar.jpg differ diff --git a/global.json b/global.json new file mode 100644 index 0000000..f3e8020 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.401", + "rollForward": "latestPatch" + } +}