fix(vendors): align facet filtering

This commit is contained in:
Alexandre Brandizzi 2026-07-22 11:30:02 -03:00
parent 9a35db58b3
commit d649940a8f
2 changed files with 144 additions and 7 deletions

View file

@ -110,4 +110,75 @@ public class VendorDataServiceTests
active.Items.Single().CompanyName.Should().Be("Active Co"); active.Items.Single().CompanyName.Should().Be("Active Co");
inactive.Items.Single().CompanyName.Should().Be("Inactive 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");
}
} }

View file

@ -1,6 +1,7 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation namespace SeaHaven.DataServices.Implementation
@ -87,7 +88,7 @@ namespace SeaHaven.DataServices.Implementation
.Distinct() .Distinct()
.ToArray() ?? Array.Empty<string>(); .ToArray() ?? Array.Empty<string>();
if (selectedLocations.Length > 0) if (selectedLocations.Length > 0)
query = query.Where(v => selectedLocations.Contains(((v.City ?? "") + ", " + (v.State ?? "")).ToLower())); query = query.Where(BuildLocationPredicate(selectedLocations));
var selectedJobBuckets = jobBuckets? var selectedJobBuckets = jobBuckets?
.Where(value => !string.IsNullOrWhiteSpace(value)) .Where(value => !string.IsNullOrWhiteSpace(value))
@ -150,6 +151,9 @@ namespace SeaHaven.DataServices.Implementation
.Count(); .Count();
} }
private static readonly MethodInfo StringConcatMethod =
typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!;
private static Expression<Func<Vendor, bool>> BuildTradePredicate( private static Expression<Func<Vendor, bool>> BuildTradePredicate(
IReadOnlyCollection<string> selectedTrades) IReadOnlyCollection<string> selectedTrades)
{ {
@ -158,21 +162,83 @@ namespace SeaHaven.DataServices.Implementation
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)), Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
Expression.Constant(string.Empty)); Expression.Constant(string.Empty));
var lowered = Expression.Call(specialties, nameof(string.ToLower), Type.EmptyTypes); var lowered = Expression.Call(specialties, nameof(string.ToLower), Type.EmptyTypes);
var delimiters = new[] { ", ", "," };
Expression? body = null; Expression? body = null;
foreach (var trade in selectedTrades) foreach (var trade in selectedTrades)
{ {
var contains = Expression.Call( var token = Expression.Constant(trade);
lowered,
nameof(string.Contains), body = OrElse(body, Expression.Equal(lowered, token));
Type.EmptyTypes,
Expression.Constant(trade)); foreach (var delimiter in delimiters)
body = body == null ? contains : Expression.OrElse(body, contains); {
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<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor); return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
} }
private static Expression<Func<Vendor, bool>> BuildLocationPredicate(
IReadOnlyCollection<string> 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<Func<Vendor, bool>>(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<Vendor> AddAsync(Vendor vendor) public async Task<Vendor> AddAsync(Vendor vendor)
{ {
await _context.Vendors.AddAsync(vendor); await _context.Vendors.AddAsync(vendor);