diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index d94f892..f680caa 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -110,4 +110,75 @@ public class VendorDataServiceTests active.Items.Single().CompanyName.Should().Be("Active Co"); inactive.Items.Single().CompanyName.Should().Be("Inactive Co"); } + + [Fact] + public async Task GetPagedAsync_LocationFacetMatchesCityStateAndFullLabels() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor { CompanyName = "CityOnly", City = "Austin", State = null, TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "StateOnly", City = null, State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "FullLoc", City = "Austin", State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var cityOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin" }); + cityOnly.TotalCount.Should().Be(1); + cityOnly.Items.Single().CompanyName.Should().Be("CityOnly"); + + var stateOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "TX" }); + stateOnly.TotalCount.Should().Be(1); + stateOnly.Items.Single().CompanyName.Should().Be("StateOnly"); + + var full = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin, TX" }); + full.TotalCount.Should().Be(1); + full.Items.Single().CompanyName.Should().Be("FullLoc"); + } + + [Fact] + public async Task GetPagedAsync_TradeFacetMatchesExactTokensNotSubstrings() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor { CompanyName = "HvacOnly", City = "Chicago", State = "IL", TradeSpecialties = "HVAC", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "HvacControls", City = "Chicago", State = "IL", TradeSpecialties = "HVAC Controls", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "PlumbingCo", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var hvac = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC" }); + hvac.TotalCount.Should().Be(1); + hvac.Items.Single().CompanyName.Should().Be("HvacOnly"); + + var hvacControls = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC Controls" }); + hvacControls.TotalCount.Should().Be(1); + hvacControls.Items.Single().CompanyName.Should().Be("HvacControls"); + + var plumb = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumb" }); + plumb.TotalCount.Should().Be(0); + plumb.Items.Should().BeEmpty(); + + var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" }); + plumbing.TotalCount.Should().Be(1); + plumbing.Items.Single().CompanyName.Should().Be("PlumbingCo"); + } + + [Fact] + public async Task GetPagedAsync_TradeFacetMatchesTokenInCommaDelimitedList() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor { CompanyName = "Multi", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing, HVAC", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "Middle", City = "Chicago", State = "IL", TradeSpecialties = "Electrical, Plumbing, Roofing", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" }); + plumbing.TotalCount.Should().Be(2); + plumbing.Items.Select(v => v.CompanyName).Should().BeEquivalentTo("Multi", "Middle"); + + var roofing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Roofing" }); + roofing.TotalCount.Should().Be(1); + roofing.Items.Single().CompanyName.Should().Be("Middle"); + } } diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 648764b..0be1854 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; using System.Linq.Expressions; +using System.Reflection; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -87,7 +88,7 @@ namespace SeaHaven.DataServices.Implementation .Distinct() .ToArray() ?? Array.Empty(); if (selectedLocations.Length > 0) - query = query.Where(v => selectedLocations.Contains(((v.City ?? "") + ", " + (v.State ?? "")).ToLower())); + query = query.Where(BuildLocationPredicate(selectedLocations)); var selectedJobBuckets = jobBuckets? .Where(value => !string.IsNullOrWhiteSpace(value)) @@ -150,6 +151,9 @@ namespace SeaHaven.DataServices.Implementation .Count(); } + private static readonly MethodInfo StringConcatMethod = + typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!; + private static Expression> BuildTradePredicate( IReadOnlyCollection selectedTrades) { @@ -158,21 +162,83 @@ namespace SeaHaven.DataServices.Implementation Expression.Property(vendor, nameof(Vendor.TradeSpecialties)), Expression.Constant(string.Empty)); var lowered = Expression.Call(specialties, nameof(string.ToLower), Type.EmptyTypes); + + var delimiters = new[] { ", ", "," }; Expression? body = null; foreach (var trade in selectedTrades) { - var contains = Expression.Call( - lowered, - nameof(string.Contains), - Type.EmptyTypes, - Expression.Constant(trade)); - body = body == null ? contains : Expression.OrElse(body, contains); + var token = Expression.Constant(trade); + + body = OrElse(body, Expression.Equal(lowered, token)); + + foreach (var delimiter in delimiters) + { + var delim = Expression.Constant(delimiter); + var tokenWithTrailing = Concat(token, delim); + var leadingToken = Concat(delim, token); + var wrappedToken = Concat(leadingToken, delim); + + body = OrElse(body, StartsWith(lowered, tokenWithTrailing)); + body = OrElse(body, EndsWith(lowered, leadingToken)); + body = OrElse(body, Contains(lowered, wrappedToken)); + } } return Expression.Lambda>(body ?? Expression.Constant(false), vendor); } + private static Expression> BuildLocationPredicate( + IReadOnlyCollection selectedLocations) + { + var vendor = Expression.Parameter(typeof(Vendor), "vendor"); + var label = BuildLocationLabelBody(vendor); + var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes); + + Expression? body = null; + foreach (var location in selectedLocations) + body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location))); + + return Expression.Lambda>(body ?? Expression.Constant(false), vendor); + } + + private static Expression BuildLocationLabelBody(ParameterExpression vendor) + { + var city = Expression.Property(vendor, nameof(Vendor.City)); + var state = Expression.Property(vendor, nameof(Vendor.State)); + var cityBlank = IsBlank(city); + var stateBlank = IsBlank(state); + + var cityCommaState = Concat(Concat(city, Expression.Constant(", ")), state); + + var onlyState = Expression.Condition(stateBlank, Expression.Constant(string.Empty), state); + var cityOrBoth = Expression.Condition(stateBlank, city, cityCommaState); + return Expression.Condition(cityBlank, onlyState, cityOrBoth); + } + + private static Expression IsBlank(Expression value) + { + var isNull = Expression.Equal(value, Expression.Constant(null, value.Type)); + var trimmed = Expression.Call(value, nameof(string.Trim), Type.EmptyTypes); + var isEmpty = Expression.Equal(trimmed, Expression.Constant(string.Empty)); + return Expression.Condition(isNull, Expression.Constant(true), isEmpty); + } + + private static Expression Concat(Expression left, Expression right) => + Expression.Call(StringConcatMethod, left, right); + + private static Expression StartsWith(Expression instance, Expression value) => + Expression.Call(instance, nameof(string.StartsWith), Type.EmptyTypes, value); + + private static Expression EndsWith(Expression instance, Expression value) => + Expression.Call(instance, nameof(string.EndsWith), Type.EmptyTypes, value); + + private static Expression Contains(Expression instance, Expression value) => + Expression.Call(instance, nameof(string.Contains), Type.EmptyTypes, value); + + private static Expression OrElse(Expression? accumulated, Expression next) => + accumulated is null ? next : Expression.OrElse(accumulated, next); + public async Task AddAsync(Vendor vendor) { await _context.Vendors.AddAsync(vendor);