diff --git a/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs b/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs index 71b5a49915..952c2a5fba 100644 --- a/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs @@ -126,17 +126,39 @@ public virtual async Task> GetProductsByIds(string[] productIds, if (productIds == null || productIds.Length == 0) return new List(); - var products = new List(); - foreach (var id in productIds) + var products = new Dictionary(); + var missing = new List(); + + foreach (var id in productIds.Distinct()) { - var product = await GetProductById(id); - if (product != null && (showHidden || (_aclService.Authorize(product, _contextAccessor.WorkContext.CurrentCustomer) && - _aclService.Authorize(product, _contextAccessor.StoreContext.CurrentStore.Id) && - product.IsAvailable()))) - products.Add(product); + if (_cacheBase.TryGetValue(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, id), out Product cached)) + products[id] = cached; + else + missing.Add(id); } - return products; + if (missing.Count > 0) + { + var query = _productRepository.Table.Where(product => missing.Contains(product.Id)); + var fromDb = (await _productRepository.ToListAsync(query)).ToDictionary(product => product.Id); + + foreach (var id in missing) + { + //cache the miss as well, same as GetProductById does + fromDb.TryGetValue(id, out var product); + await _cacheBase.SetAsync(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, id), () => Task.FromResult(product)); + products[id] = product; + } + } + + //keep the order of the identifiers given - recently viewed products rely on it + return productIds + .Select(id => products[id]) + .Where(product => product != null && (showHidden || + (_aclService.Authorize(product, _contextAccessor.WorkContext.CurrentCustomer) && + _aclService.Authorize(product, _contextAccessor.StoreContext.CurrentStore.Id) && + product.IsAvailable()))) + .ToList(); } /// diff --git a/src/Core/Grand.Infrastructure/Caching/ICacheBase.cs b/src/Core/Grand.Infrastructure/Caching/ICacheBase.cs index 068520fcea..35d819979c 100644 --- a/src/Core/Grand.Infrastructure/Caching/ICacheBase.cs +++ b/src/Core/Grand.Infrastructure/Caching/ICacheBase.cs @@ -5,6 +5,7 @@ namespace Grand.Infrastructure.Caching; /// public interface ICacheBase { + bool TryGetValue(string key, out T value); T Get(string key, Func acquire); T Get(string key, Func acquire, int cacheTime); Task GetAsync(string key, Func> acquire); diff --git a/src/Core/Grand.Infrastructure/Caching/MemoryCacheBase.cs b/src/Core/Grand.Infrastructure/Caching/MemoryCacheBase.cs index cf4e54c615..74b073a307 100644 --- a/src/Core/Grand.Infrastructure/Caching/MemoryCacheBase.cs +++ b/src/Core/Grand.Infrastructure/Caching/MemoryCacheBase.cs @@ -37,6 +37,11 @@ public MemoryCacheBase(IMemoryCache cache, IMediator mediator, CacheConfig cache #region Methods + public virtual bool TryGetValue(string key, out T value) + { + return _cache.TryGetValue(key, out value); + } + public virtual T Get(string key, Func acquire) { return Get(key, acquire, _cacheConfig.DefaultCacheTimeMinutes); diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductServiceBatchTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductServiceBatchTests.cs new file mode 100644 index 0000000000..f4fd7d4650 --- /dev/null +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductServiceBatchTests.cs @@ -0,0 +1,138 @@ +using Grand.Business.Catalog.Services.Products; +using Grand.Business.Common.Services.Security; +using Grand.Data; +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Tests.Caching; +using Grand.Mediator; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Catalog.Tests.Services.Products; + +[TestClass] +public class ProductServiceBatchTests +{ + private readonly List _products = [ + new() { Id = "1", Published = true, VisibleIndividually = true }, + new() { Id = "2", Published = true, VisibleIndividually = true }, + new() { Id = "3", Published = true, VisibleIndividually = true } + ]; + + private MemoryCacheBase _cacheBase; + private ProductService _productService; + private List _queries; + + [TestInitialize] + public void InitializeTests() + { + _queries = []; + var repository = new Mock>(); + repository.Setup(x => x.Table).Returns(() => _products.AsQueryable()); + repository.Setup(x => x.ToListAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IQueryable query, CancellationToken _) => + { + var result = query.ToList(); + _queries.Add(result.Select(x => x.Id).ToArray()); + return result; + }); + + var customer = new Customer { Id = "customer" }; + var contextAccessor = new Mock(); + contextAccessor.Setup(c => c.StoreContext.CurrentStore).Returns(() => new Store { Id = "store" }); + contextAccessor.Setup(c => c.WorkContext.CurrentCustomer).Returns(() => customer); + var mediator = new Mock(); + _cacheBase = new MemoryCacheBase(MemoryCacheTest.Get(), mediator.Object, + new CacheConfig { DefaultCacheTimeMinutes = 1 }); + _productService = new ProductService(_cacheBase, repository.Object, contextAccessor.Object, + mediator.Object, new AclService(new AccessControlConfig())); + } + + [TestMethod] + public async Task ColdCache_ReadsEveryProductInOneQuery() + { + var result = await _productService.GetProductsByIds(["1", "2", "3"], true); + + Assert.HasCount(3, result); + Assert.HasCount(1, _queries); + } + + [TestMethod] + public async Task WarmCache_DoesNotQuery() + { + await _productService.GetProductsByIds(["1", "2", "3"], true); + + var result = await _productService.GetProductsByIds(["1", "2", "3"], true); + + Assert.HasCount(3, result); + Assert.HasCount(1, _queries); + } + + [TestMethod] + public async Task PartlyWarmCache_QueriesOnlyTheMissingProducts() + { + await _productService.GetProductsByIds(["1"], true); + + var result = await _productService.GetProductsByIds(["1", "2", "3"], true); + + Assert.HasCount(3, result); + Assert.HasCount(2, _queries); + CollectionAssert.AreEquivalent(new[] { "2", "3" }, _queries[1]); + } + + [TestMethod] + public async Task MissingProduct_IsCachedAndNotQueriedAgain() + { + await _productService.GetProductsByIds(["missing"], true); + + var result = await _productService.GetProductsByIds(["missing"], true); + + Assert.IsEmpty(result); + Assert.HasCount(1, _queries); + } + + [TestMethod] + public async Task SharesCacheEntriesWithGetProductById() + { + await _productService.GetProductsByIds(["1"], true); + _products.Clear(); + + Assert.IsNotNull(await _productService.GetProductById("1")); + } + + [TestMethod] + public async Task KeepsTheOrderOfTheIdentifiersGiven() + { + var result = await _productService.GetProductsByIds(["3", "1", "2"], true); + + CollectionAssert.AreEqual(new[] { "3", "1", "2" }, result.Select(x => x.Id).ToArray()); + } + + [TestMethod] + public async Task SkipsAnIdentifierThatMatchesNothing() + { + var result = await _productService.GetProductsByIds(["1", "missing", "2"], true); + + CollectionAssert.AreEqual(new[] { "1", "2" }, result.Select(x => x.Id).ToArray()); + } + + [TestMethod] + public async Task RepeatsAProductWhoseIdentifierRepeats() + { + var result = await _productService.GetProductsByIds(["1", "1"], true); + + CollectionAssert.AreEqual(new[] { "1", "1" }, result.Select(x => x.Id).ToArray()); + Assert.HasCount(1, _queries); + } + + [TestMethod] + public async Task ReturnsNothingForAnEmptyRequest() + { + Assert.IsEmpty(await _productService.GetProductsByIds([], true)); + Assert.IsEmpty(_queries); + } +}