NOTE: Apart from
(and even then it's questionable, I'm Scottish). These are machine translated in languages I don't read. If they're terrible please contact me.
You can see how this translation was done in this article.
Tuesday, 17 September 2024
//6 minute read
Dit is slechts een snel artikel als het bouwt op de anderen in de full text zoekreeks zoals de typeahead dropdown en Postgres full text search. In dit bericht zal ik u laten zien hoe u een eenvoudige zoekpagina kunt implementeren met behulp van HTMX en EF Core in een ASP.NET Core applicatie.
Nou ja, in de header van de site heb ik een zoekfunctie die typahead biedt (waar u de resultaten in real time typt). Maar ik verberg dat in mobiele modus en ik wilde ook in staat zijn om de zoekresultaten te koppelen (zoals Zoeken/umami) naar een speciale zoekpagina.
Om dit te doen heb ik aangepast hoe ik mijn zoekopdrachten deed. Ik creëerde een BlogSearchService, dit is gebaseerd op mijn twee Full Text query methoden. Deze moeten helaas worden opgesplitst in twee methoden vanwege de manier waarop de queries zijn gestructureerd met de Postgres Full Text Search extensies, EF.Functions.WebSearchToTsQuery("english", processedQuery) en EF.Functions.ToTsQuery("english", query + ":*").
De eerste neemt de juiste zoektermen en de tweede neemt wildcard zoekopdrachten.
private IQueryable<BlogPostEntity> QueryForSpaces(string processedQuery)
{
return context.BlogPosts
.Include(x => x.Categories)
.Include(x => x.LanguageEntity)
.AsNoTrackingWithIdentityResolution()
.Where(x =>
// Search using the precomputed SearchVector
(x.SearchVector.Matches(EF.Functions.WebSearchToTsQuery("english",
processedQuery)) // Use precomputed SearchVector for title and content
|| x.Categories.Any(c =>
EF.Functions.ToTsVector("english", c.Name)
.Matches(EF.Functions.WebSearchToTsQuery("english", processedQuery)))) // Search in categories
&& x.LanguageEntity.Name == "en") // Filter by language
.OrderByDescending(x =>
// Rank based on the precomputed SearchVector
x.SearchVector.Rank(EF.Functions.WebSearchToTsQuery("english",
processedQuery)));
}
private IQueryable<BlogPostEntity> QueryForWildCard(string query)
{
return context.BlogPosts
.Include(x => x.Categories)
.Include(x => x.LanguageEntity)
.AsNoTrackingWithIdentityResolution()
.Where(x =>
// Search using the precomputed SearchVector
(x.SearchVector.Matches(EF.Functions.ToTsQuery("english",
query + ":*")) // Use precomputed SearchVector for title and content
|| x.Categories.Any(c =>
EF.Functions.ToTsVector("english", c.Name)
.Matches(EF.Functions.ToTsQuery("english", query + ":*")))) // Search in categories
&& x.LanguageEntity.Name == "en") // Filter by language
.OrderByDescending(x =>
// Rank based on the precomputed SearchVector
x.SearchVector.Rank(EF.Functions.ToTsQuery("english",
query + ":*"))); // Use precomputed SearchVector for ranking
}
Opnieuw deze gebruiken mijn voorberekende SearchVector kolom die wordt bijgewerkt bij het aanmaken en bijwerken van berichten. Dit is gemaakt in mijn DbContext met behulp van de OnModelCreating methode.
entity.Property(b => b.SearchVector)
.HasComputedColumnSql("to_tsvector('english', coalesce(\"Title\", '') || ' ' || coalesce(\"PlainTextContent\", ''))", stored: true);
entity.HasIndex(b => b.SearchVector)
.HasMethod("GIN");
Opnieuw het nadeel van deze aanpak is dat het alleen werkt voor Engels as-is. Ik zou een radicale heropbouw van de Database nodig hebben om het te laten werken voor andere talen (waarschijnlijk een tabel voor elke taal).
Ik gebruik dan deze methoden in mijn BlogSearchService om de resultaten te retourneren op basis van de zoekopdracht.
public async Task<PostListViewModel> GetPosts(string? query, int page = 1, int pageSize = 10)
{
if(string.IsNullOrEmpty(query))
{
return new PostListViewModel();
}
IQueryable<BlogPostEntity> blogPostQuery = query.Contains(" ") ? QueryForSpaces(query) : QueryForWildCard(query);
var totalPosts = await blogPostQuery.CountAsync();
var results = await blogPostQuery
.Select(x => x.ToListModel())
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PostListViewModel()
{
Posts = results,
TotalItems = totalPosts,
Page = page,
PageSize = pageSize
};
}
Ik gebruik de eenvoudige controle voor of er spaties in de query om te bepalen welke methode te bellen.
De Zoekcontroller volgt het patroon dat ik gebruik voor de meeste van mijn controllers waar het detecteert of de oproep afkomstig is van HTMX of niet om het verzenden van de gedeeltelijke of volledige lay-out pagina (wat betekent dat het werkt voor directe navigatie en HTMX verzoeken).
[Route("search")]
public class SearchController(
BaseControllerService baseControllerService,
BlogSearchService searchService,
ILogger<SearchController> logger)
: BaseController(baseControllerService, logger)
{
[HttpGet]
[Route("{query?}")]
public async Task<IActionResult> Search([FromRoute] string? query)
{
var searchResults = await searchService.GetPosts(query);
var searchModel = new SearchResultsModel
{
Query = query,
SearchResults = searchResults
};
searchModel = await PopulateBaseModel(searchModel);
searchModel.SearchResults.LinkUrl = Url.Action("SearchResults", "Search");
if (Request.IsHtmx()) return PartialView("SearchResults", searchModel);
return View("SearchResults", searchModel);
}
[HttpGet]
[Route("results")]
public async Task<IActionResult> SearchResults([Required] string query, int page = 1, int pageSize = 10)
{
var searchResults = await searchService.GetPosts(query, page, pageSize);
var searchModel = new SearchResultsModel
{
Query = query,
SearchResults = searchResults
};
searchModel = await PopulateBaseModel(searchModel);
searchModel.SearchResults.LinkUrl = Url.Action("SearchResults", "Search");
if (Request.IsHtmx()) return PartialView("_SearchResultsPartial", searchModel.SearchResults);
return View("SearchResults", searchModel);
}
}
Dit is de hele controller, je kunt zien dat ik twee Acties, een die de pagina (optioneel gevuld met resultaten) en een die alleen de resultaten voor HTMX-verzoeken retourneert.
if (Request.IsHtmx()) return PartialView("_SearchResultsPartial", searchModel.SearchResults);
U kunt zien dat dit optioneel de _SearchResultsPartial bekijken of het verzoek een HTMX-verzoek voor resultaten is.
Dit is een vrij eenvoudige Paritial View die paging bits en de resultaten heeft.
@model Mostlylucid.Models.Blog.PostListViewModel
<div class="pt-2" id="content">
@if (Model.Posts?.Any() is true)
{
<div class="inline-flex w-full items-center justify-center print:!hidden">
@if (Model.TotalItems > Model.PageSize)
{
<pager
x-ref="pager"
link-url="@Model.LinkUrl"
hx-boost="true"
hx-target="#content"
hx-swap="show:none"
page="@Model.Page"
page-size="@Model.PageSize"
total-items="@Model.TotalItems"
hx-headers='{"pagerequest": "true"}'>
</pager>
}
<partial name="_Pager" model="Model"/>
</div>
@foreach (var post in Model.Posts)
{
<partial name="_ListPost" model="post"/>
}
}
</div>
Ik gebruik hetzelfde. _ListPost gedeeltelijke weergave waar ik nodig heb om posten te rangschikken.
@model Mostlylucid.Models.Blog.PostListModel
<div class="border-b border-grey-lighter pb-8 mb-8">
<a asp-controller="Blog" asp-action="Show" hx-boost="true" hx-swap="show:window:top" hx-target="#contentcontainer" asp-route-slug="@Model.Slug"
class="block font-body text-lg font-semibold transition-colors hover:text-green text-blue-dark dark:text-white dark:hover:text-secondary">@Model.Title</a>
<div class="flex flex-wrap space-x-2 items-center py-4 print:!hidden">
@foreach (var category in Model.Categories)
{
<partial name="_Category" model="category"/>
}
@{ var languageModel = (Model.Slug, Model.Languages, Model.Language); }
<partial name="_LanguageList" model="languageModel"/>
</div>
<div class="block font-body text-black dark:text-white">@Model.Summary</div>
<div class="flex items-center pt-4">
<p class="pr-2 font-body font-light text-primary light:text-black dark:text-white">
@Model.PublishedDate.ToString("f")
</p>
<span class="font-body text-grey dark:text-white">//</span>
<p class="pl-2 font-body font-light text-primary light:text-black dark:text-white">
@Model.ReadingTime
</p>
</div>
</div>
Nogmaals mijn gebruik van HTMX hier is vrij eenvoudig. Ik gewoon haak in de knop (deze keer heb ik besloten om NIET de invoer op keyup / wijzigen van de URL) en stuur het verzoek wanneer de knop wordt geklikt.
Ik Includeer de vraag in het verzoek met behulp van hx-include en zich richten op de #content div om de resultaten te vervangen.
<div class="flex items-center gap-2 bg-neutral-500 bg-opacity-10 p-2 rounded-lg">
<button
hx-get="@Url.Action("SearchResults", "Search")"
hx-target="#content"
hx-include="[name='query']"
hx-swap="outerHTML"
class="btn btn-outline btn-sm flex items-center gap-2 text-black dark:text-white">
Search
<i class="bx bx-search text-lg"></i>
</button>
<input
type="text"
placeholder="Search..."
value="@Model.Query"
name="query"
class="input input-sm border-0 grow text-black dark:text-white bg-transparent focus:outline-none"/>
</div>
Dus, vrij simpel toch? Dit is een eenvoudige implementatie van een zoekpagina met behulp van HTMX en EF Core in een ASP.NET Core applicatie. U kunt dit eenvoudig uitbreiden tot meer functies zoals filteren, sorteren of zelfs integreren met andere zoekdiensten. De belangrijkste takeaway is hoe HTMX te benutten voor een soepele gebruikerservaring terwijl het houden van de backend logica schoon en efficiënt.