sqrtspace-dotnet/samples/SampleWebApi/Services/ProductService.cs
2025-07-20 03:41:39 -04:00

288 lines
10 KiB
C#

using Microsoft.EntityFrameworkCore;
using SqrtSpace.SpaceTime.Core;
using SqrtSpace.SpaceTime.EntityFramework;
using SqrtSpace.SpaceTime.Linq;
using SampleWebApi.Data;
using SampleWebApi.Models;
using System.Text;
namespace SampleWebApi.Services;
public interface IProductService
{
Task<PagedResult<Product>> GetProductsPagedAsync(int page, int pageSize);
IAsyncEnumerable<Product> StreamProductsAsync(string? category, decimal? minPrice);
Task<IEnumerable<Product>> SearchProductsAsync(string query, string sortBy, bool descending);
Task<BulkUpdateResult> BulkUpdatePricesAsync(string? categoryFilter, decimal priceMultiplier, string operationId, CheckpointManager? checkpoint);
Task ExportToCsvAsync(Stream outputStream, string? category);
Task<ProductStatistics> GetStatisticsAsync(string? category);
}
public class ProductService : IProductService
{
private readonly SampleDbContext _context;
private readonly ILogger<ProductService> _logger;
public ProductService(SampleDbContext context, ILogger<ProductService> logger)
{
_context = context;
_logger = logger;
}
public async Task<PagedResult<Product>> GetProductsPagedAsync(int page, int pageSize)
{
var query = _context.Products.AsQueryable();
var totalCount = await query.CountAsync();
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResult<Product>
{
Items = items,
Page = page,
PageSize = pageSize,
TotalCount = totalCount
};
}
public async IAsyncEnumerable<Product> StreamProductsAsync(string? category, decimal? minPrice)
{
var query = _context.Products.AsQueryable();
if (!string.IsNullOrEmpty(category))
{
query = query.Where(p => p.Category == category);
}
if (minPrice.HasValue)
{
query = query.Where(p => p.Price >= minPrice.Value);
}
// Use BatchBySqrtN to process in memory-efficient chunks
await foreach (var batch in query.BatchBySqrtNAsync())
{
foreach (var product in batch)
{
yield return product;
}
}
}
public async Task<IEnumerable<Product>> SearchProductsAsync(string query, string sortBy, bool descending)
{
var searchQuery = _context.Products
.Where(p => p.Name.Contains(query) || p.Description.Contains(query));
// Count to determine if we need external sorting
var count = await searchQuery.CountAsync();
_logger.LogInformation("Search found {count} products for query '{query}'", count, query);
IQueryable<Product> sortedQuery = sortBy.ToLower() switch
{
"price" => descending ? searchQuery.OrderByDescending(p => p.Price) : searchQuery.OrderBy(p => p.Price),
"category" => descending ? searchQuery.OrderByDescending(p => p.Category) : searchQuery.OrderBy(p => p.Category),
_ => descending ? searchQuery.OrderByDescending(p => p.Name) : searchQuery.OrderBy(p => p.Name)
};
// Use external sorting for large result sets
if (count > 10000)
{
_logger.LogInformation("Using external sorting for {count} products", count);
sortedQuery = sortedQuery.UseExternalSorting();
}
return await sortedQuery.ToListAsync();
}
public async Task<BulkUpdateResult> BulkUpdatePricesAsync(
string? categoryFilter,
decimal priceMultiplier,
string operationId,
CheckpointManager? checkpoint)
{
var state = new BulkUpdateState { OperationId = operationId };
// Try to restore from checkpoint
if (checkpoint != null)
{
var previousState = await checkpoint.RestoreLatestCheckpointAsync<BulkUpdateState>();
if (previousState != null)
{
state = previousState;
_logger.LogInformation("Resuming bulk update from checkpoint. Already processed: {count}",
state.ProcessedCount);
}
}
var query = _context.Products.AsQueryable();
if (!string.IsNullOrEmpty(categoryFilter))
{
query = query.Where(p => p.Category == categoryFilter);
}
var totalProducts = await query.CountAsync();
var products = query.Skip(state.ProcessedCount);
// Process in batches using √n strategy
await foreach (var batch in products.BatchBySqrtNAsync())
{
try
{
foreach (var product in batch)
{
product.Price *= priceMultiplier;
product.UpdatedAt = DateTime.UtcNow;
state.ProcessedCount++;
state.UpdatedCount++;
}
await _context.SaveChangesAsync();
// Save checkpoint
if (checkpoint?.ShouldCheckpoint() == true)
{
state.LastCheckpoint = DateTime.UtcNow;
await checkpoint.CreateCheckpointAsync(state);
_logger.LogInformation("Checkpoint saved. Processed: {count}/{total}",
state.ProcessedCount, totalProducts);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating batch. Processed so far: {count}", state.ProcessedCount);
state.FailedCount += batch.Count - (state.ProcessedCount % batch.Count);
// Save checkpoint on error
if (checkpoint != null)
{
await checkpoint.CreateCheckpointAsync(state);
}
throw;
}
}
return new BulkUpdateResult
{
OperationId = operationId,
TotalProducts = totalProducts,
UpdatedProducts = state.UpdatedCount,
FailedProducts = state.FailedCount,
Completed = true,
CheckpointId = state.LastCheckpoint.ToString("O")
};
}
public async Task ExportToCsvAsync(Stream outputStream, string? category)
{
using var writer = new StreamWriter(outputStream, Encoding.UTF8);
// Write header
await writer.WriteLineAsync("Id,Name,Category,Price,StockQuantity,CreatedAt,UpdatedAt");
var query = _context.Products.AsQueryable();
if (!string.IsNullOrEmpty(category))
{
query = query.Where(p => p.Category == category);
}
// Stream products in batches to minimize memory usage
await foreach (var batch in query.BatchBySqrtNAsync())
{
foreach (var product in batch)
{
await writer.WriteLineAsync(
$"{product.Id}," +
$"\"{product.Name.Replace("\"", "\"\"")}\"," +
$"\"{product.Category}\"," +
$"{product.Price}," +
$"{product.StockQuantity}," +
$"{product.CreatedAt:yyyy-MM-dd HH:mm:ss}," +
$"{product.UpdatedAt:yyyy-MM-dd HH:mm:ss}");
}
await writer.FlushAsync();
}
}
public async Task<ProductStatistics> GetStatisticsAsync(string? category)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var query = _context.Products.AsQueryable();
if (!string.IsNullOrEmpty(category))
{
query = query.Where(p => p.Category == category);
}
var totalCount = await query.CountAsync();
var computationMethod = totalCount > 100000 ? "External" : "InMemory";
ProductStatistics stats;
if (computationMethod == "External")
{
_logger.LogInformation("Using external aggregation for {count} products", totalCount);
// For large datasets, compute statistics in batches
decimal totalPrice = 0;
decimal minPrice = decimal.MaxValue;
decimal maxPrice = decimal.MinValue;
var categoryStats = new Dictionary<string, (int count, decimal totalPrice)>();
await foreach (var batch in query.BatchBySqrtNAsync())
{
foreach (var product in batch)
{
totalPrice += product.Price;
minPrice = Math.Min(minPrice, product.Price);
maxPrice = Math.Max(maxPrice, product.Price);
if (!categoryStats.ContainsKey(product.Category))
{
categoryStats[product.Category] = (0, 0);
}
var current = categoryStats[product.Category];
categoryStats[product.Category] = (current.count + 1, current.totalPrice + product.Price);
}
}
stats = new ProductStatistics
{
TotalProducts = totalCount,
AveragePrice = totalCount > 0 ? totalPrice / totalCount : 0,
MinPrice = minPrice == decimal.MaxValue ? 0 : minPrice,
MaxPrice = maxPrice == decimal.MinValue ? 0 : maxPrice,
ProductsByCategory = categoryStats.ToDictionary(k => k.Key, v => v.Value.count),
AveragePriceByCategory = categoryStats.ToDictionary(
k => k.Key,
v => v.Value.count > 0 ? v.Value.totalPrice / v.Value.count : 0)
};
}
else
{
// For smaller datasets, use in-memory aggregation
var products = await query.ToListAsync();
stats = new ProductStatistics
{
TotalProducts = products.Count,
AveragePrice = products.Any() ? products.Average(p => p.Price) : 0,
MinPrice = products.Any() ? products.Min(p => p.Price) : 0,
MaxPrice = products.Any() ? products.Max(p => p.Price) : 0,
ProductsByCategory = products.GroupBy(p => p.Category)
.ToDictionary(g => g.Key, g => g.Count()),
AveragePriceByCategory = products.GroupBy(p => p.Category)
.ToDictionary(g => g.Key, g => g.Average(p => p.Price))
};
}
stats.ComputationTimeMs = stopwatch.ElapsedMilliseconds;
stats.ComputationMethod = computationMethod;
return stats;
}
}