Compile-time faceted search generation - attributes and source generators
$ dotnet add package Facet.SearchCompile-time faceted search generation for .NET, Zero boilerplate, type-safe, and performant.
Facet.Search uses source generators to automatically create search filter classes, LINQ extension methods, facet aggregations, and metadata from your domain models, all at compile time with no runtime overhead.
dotnet add package Facet.Search
For Entity Framework Core integration:
dotnet add package Facet.Search.EFCore
using Facet.Search;
[FacetedSearch]
public class Product
{
public int Id { get; set; }
[FullTextSearch]
public string Name { get; set; } = null!;
[FullTextSearch(Weight = 0.5f)]
public string? Description { get; set; }
[SearchFacet(Type = FacetType.Categorical, DisplayName = "Brand")]
public string Brand { get; set; } = null!;
[SearchFacet(Type = FacetType.Range, DisplayName = "Price")]
public decimal Price { get; set; }
[SearchFacet(Type = FacetType.Boolean, DisplayName = "In Stock")]
public bool InStock { get; set; }
[SearchFacet(Type = FacetType.DateRange, DisplayName = "Created Date")]
public DateTime CreatedAt { get; set; }
}
The source generator automatically creates:
ProductSearchFilter > Filter class with all facet propertiesProductSearchExtensions > LINQ extension methodsProductFacetAggregations > Aggregation resultsProductSearchMetadata > Facet metadata for frontendsusing YourNamespace.Search;
// Create a filter
var filter = new ProductSearchFilter
{
Brand = ["Apple", "Samsung"],
MinPrice = 100m,
MaxPrice = 1000m,
InStock = true,
SearchText = "laptop",
SortBy = "Price", // Sort by any facet or searchable property
SortDescending = false // Ascending order
};
// Apply to any IQueryable<Product>
var results = products.AsQueryable()
.ApplyFacetedSearch(filter)
.ToList();
// Get facet aggregations
var aggregations = products.AsQueryable().GetFacetAggregations();
// aggregations.Brand = { "Apple": 5, "Samsung": 3, ... }
// aggregations.PriceMin = 99.99m
// aggregations.PriceMax = 2499.99m
// Access metadata for UI
foreach (var facet in ProductSearchMetadata.Facets)
{
Console.WriteLine($"{facet.DisplayName} ({facet.Type})");
}
All generated filters are translated to SQL, no client-side evaluation for facet filters.
| Filter Type | Generated Code | SQL Translation |
|---|---|---|
| Categorical | .Where(x => filter.Brand.Contains(x.Brand)) | WHERE Brand IN ('Apple', 'Samsung') |
| Range | .Where(x => x.Price >= min && x.Price <= max) | WHERE Price >= @min AND Price <= @max |
| Boolean | .Where(x => x.InStock == true) | WHERE InStock = 1 |
| DateRange | .Where(x => x.CreatedAt >= from) | WHERE CreatedAt >= @from |
| Full-Text | .Where(x => x.Name.Contains(term)) | WHERE Name LIKE '%term%' |
By default, full-text search uses LIKE '%term%' which works with all databases but doesn't use full-text indexes. You can configure different strategies:
[FacetedSearch(FullTextStrategy = FullTextSearchStrategy.LinqContains)] // Default: LIKE '%term%'
[FacetedSearch(FullTextStrategy = FullTextSearchStrategy.SqlServerFreeText)] // SQL Server FREETEXT
[FacetedSearch(FullTextStrategy = FullTextSearchStrategy.PostgreSqlFullText)] // PostgreSQL tsvector
[FacetedSearch(FullTextStrategy = FullTextSearchStrategy.ClientSide)] // In-memory (use with caution)
public class Product { }
| Strategy | Database | Requires Index | Performance |
|---|---|---|---|
LinqContains | All | No | Slow on large data |
EfLike | All | No | Same as LinqContains |
SqlServerFreeText | SQL Server | FULLTEXT index | Fast |
SqlServerContains | SQL Server | FULLTEXT index | Fast |
PostgreSqlFullText | PostgreSQL | GIN index | Fast |
ClientSide | N/A | No | Loads to memory |
Use the Facet.Search.EFCore package for async operations:
using Facet.Search.EFCore;
// Async search execution
var results = await dbContext.Products
.ApplyFacetedSearch(filter)
.ExecuteSearchAsync();
// Paginated results
var pagedResult = await dbContext.Products
.ApplyFacetedSearch(filter)
.ToPagedResultAsync(page: 1, pageSize: 20);
// pagedResult.Items, pagedResult.TotalCount, pagedResult.TotalPages
// Get ALL facet aggregations asynchronously
var aggregations = await dbContext.Products
.GetFacetAggregationsAsync<Product, ProductFacetResults>();
// aggregations.Brand, aggregations.PriceMin/Max, aggregations.InStockTrueCount, etc.
// Or individual facet aggregations
var brandCounts = await dbContext.Products
.AggregateFacetAsync(p => p.Brand, limit: 10);
var (minPrice, maxPrice) = await dbContext.Products
.GetRangeAsync(p => p.Price);
All facet properties and properties marked with [Searchable(Sortable = true)] are automatically sortable:
// Sort by any facet property (Brand, Category, Price, etc.)
var filter = new ProductSearchFilter
{
SortBy = "Price",
SortDescending = true // false for ascending (default)
};
// Sort by searchable properties
[Searchable(Sortable = true)]
public int Rating { get; set; }
var filter = new ProductSearchFilter
{
SortBy = "Rating",
SortDescending = false
};
Validation:
| Type | Description | Generated Filter Properties |
|---|---|---|
Categorical | Discrete values (Brand, Category) | string[]? PropertyName |
Range | Numeric ranges (Price, Rating) | decimal? MinPropertyName, decimal? MaxPropertyName |
Boolean | True/false filters (InStock) | bool? PropertyName |
DateRange | Date/time ranges | DateTime? PropertyNameFrom, DateTime? PropertyNameTo |
Hierarchical | Nested categories | string[]? PropertyName |
[FacetedSearch]Marks a class for search generation.
[FacetedSearch(
FilterClassName = "CustomFilter", // Custom filter class name
GenerateAggregations = true, // Generate aggregation methods
GenerateMetadata = true, // Generate metadata class
Namespace = "Custom.Namespace", // Custom namespace for generated code
FullTextStrategy = FullTextSearchStrategy.LinqContains // Full-text search strategy
)]
public class Product { }
[SearchFacet]Marks a property as a filterable facet.
[SearchFacet(
Type = FacetType.Categorical, // Facet type
DisplayName = "Product Brand", // UI display name
OrderBy = FacetOrder.Count, // Aggregation ordering (Count, Alphabetical)
Limit = 10, // Max aggregation values
DependsOn = "Category", // Dependent facet
IsHierarchical = false, // Hierarchical category
NavigationPath = "Category.Name", // For navigation properties
AutoInclude = true // Auto-include in EF Core queries
)]
public string Brand { get; set; }
[FullTextSearch]Marks a property for full-text search.
[FullTextSearch(
Weight = 1.0f, // Search relevance weight (higher = more important)
CaseSensitive = false, // Case sensitivity
Behavior = TextSearchBehavior.Contains // Match behavior: Contains, StartsWith, EndsWith, Exact
)]
public string Name { get; set; }
[Searchable]Marks a property as searchable but not a facet (useful for sorting).
[Searchable(Sortable = true)]
public int Rating { get; set; }
Filter on related entities by specifying the navigation path:
public class Product
{
[SearchFacet(
Type = FacetType.Categorical,
DisplayName = "Category",
NavigationPath = "Category.Name" // Filter on Category.Name
)]
public Category Category { get; set; }
}
Generated files appear in your project's obj folder:
obj/Debug/net8.0/generated/Facet.Search.Generators/
> ProductSearchFilter.g.cs
> ProductSearchExtensions.g.cs
> ProductFacetAggregations.g.cs
> ProductSearchMetadata.g.cs
Limit on categorical facets to avoid loading all distinct valuesMIT License — see LICENSE.txt for details.
Contributions are welcome! Please feel free to submit a Pull Request.