Enkel sökning med hjälp av HTMX & EF Core för ASP.NET Core (Svenska (Swedish))

Enkel sökning med hjälp av HTMX & EF Core för ASP.NET Core

Comments

NOTE: Apart from English (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

//

9 minute read

Inledning

Detta är bara en snabb artikel som bygger på de andra i fulltext sökserien som typeahead dropdown och Postgres fulltextsökning. I det här inlägget kommer jag att visa dig hur du implementerar en enkel sökmotor med hjälp av HTMX och EF Core i en ASP.NET Core ansökan.

Har jag inte redan en sökning?

Ja, i sidhuvudet på webbplatsen har jag en sökfunktion som ger typeahead (där som du skriver resultaten kommer i realtid). Men jag döljer det i mobilt läge och jag ville också kunna länka sökresultaten (som /sökning/umami) till en särskild söksida. Detta ger en bättre användarupplevelse samt arbete med mobila enheter.

Söktjänst

För att göra detta ändrade jag hur jag gjorde mina sökningar. Jag skapade en BlogSearchService, Detta är baserat på mina två Full Text frågemetoder. Dessa måste tyvärr delas upp i två metoder på grund av hur frågorna är strukturerade med Postgres Full Text Search tillägg, EF.Functions.WebSearchToTsQuery("english", processedQuery) och EF.Functions.ToTsQuery("english", query + ":*").

Den första tar riktiga sökord och den andra tar wildcard sökningar.

    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
    }

Återigen dessa använder min föruträknade SearchVector kolumn som uppdateras efter skapande och uppdatering. Detta är skapat i min DbContext användning av OnModelCreating Metod.

      entity.Property(b => b.SearchVector)
                .HasComputedColumnSql("to_tsvector('english', coalesce(\"Title\", '') || ' ' || coalesce(\"PlainTextContent\", ''))", stored: true);
            
           entity.HasIndex(b => b.SearchVector)
                .HasMethod("GIN");

Återigen är nackdelen med detta tillvägagångssätt att det bara fungerar för engelska as-is. Jag skulle behöva en radikal ombyggnad av databasen för att få den att fungera för andra språk (troligen en tabell för varje språk).

Jag använder sedan dessa metoder i min BlogSearchService för att returnera resultaten baserat på sökfrågan.

    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
        };
        
    }

Jag använder den enkla kontrollen för att avgöra om det finns mellanslag i frågan för att avgöra vilken metod som ska anropas.

Sökkontroll

Sökkontrollen följer mönstret jag använder för de flesta av mina regulatorer där den upptäcker om samtalet kommer från HTMX eller inte för att möjliggöra att antingen den partiella eller fullständiga layout sidan (vilket innebär att det fungerar för direkt navigering samt HTMX-förfrågningar).

[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);
    }
}

Detta är hela controllern, du kan se att jag har två åtgärder, en som returnerar sidan (valfritt befolkad med resultat) och en som returnerar bara resultaten för HTMX-förfrågningar.

 if (Request.IsHtmx()) return PartialView("_SearchResultsPartial", searchModel.SearchResults);

Delvis sökresultat

Du kan se att detta valfritt returnerar _SearchResultsPartial visa om begäran är en HTMX begäran om resultat.

Detta är en ganska enkel Paritial View som har personsökning bitar och resultaten.

@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>

Lista inlägg Partiell vy

Jag använder samma _ListPost Delvis vy där jag behöver lista inlägg.

@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>

HTMX och söksidan

Återigen är min användning av HTMX här ganska enkel. Jag kopplar helt enkelt in i knappen (den här gången bestämde jag mig för att INTE ändra inmatningen på tangentupp / ändra webbadressen) och skicka begäran när knappen klickas. Jag Inkluderar frågan i begäran med hx-include och rikta in sig på #content div för att ersätta resultaten.

<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>

Uppdatera

Så efter några kommentarer från Khalid Ordförande Jag bestämde mig för att förbättra denna funktionalitet för att göra det möjligt för sökningen att vara:

  1. Utlöses vid tryck på Enter- tangent
  2. Utlöses vid inmatning av mer än två tecken (så blir en lös typeahead).

I framtiden måste jag lägga till sidstorlek funktionalitet tillbaka i; det är en biit av en hacka och behöver för att stödja ytterligare krav.

Uppdaterad sökblankett

För att göra detta svepte jag först in inmatningen i en form och använde Alpine.js för att skicka in formuläret när en användare skriver. Du kan se att jag använder x-data för att skapa en reaktiv variabel för frågan och sedan kontrollerar jag längden på frågan för att avgöra om formuläret ska lämnas in.

<form
    x-data="{ query: '@Model.Query', checkSubmit() { if (this.query.length > 2) { $refs.searchButton.click(); } } }"
    class="flex items-center gap-2 bg-neutral-500 bg-opacity-10 p-2 rounded-lg"
    action="@Url.Action("Search", "Search")"
    hx-push-url="true"
    hx-boost="true"
    hx-target="#content"
    hx-swap="outerHTML show:window:top"
    hx-headers='{"pagerequest": "true"}'>
    <button
        type="submit"
        x-ref="searchButton"
        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..."
        name="query"
        value="@Model.Query"
        x-model="query"
        x-on:input.debounce.200ms="checkSubmit"
        x-on:keydown.enter.prevent="$refs.searchButton.click()"
        class="input input-sm border-0 grow text-black dark:text-white bg-transparent focus:outline-none"
    />
</form>

För att återanvända samma controller åtgärder jag också ställa in pagerequest rubrik för att indikera att detta är en sidig begäran.

Jag använder också Alpine.js x-on:keydown.enter.prevent för att starta knappen klicka när Enter-tangenten trycks på och en debounce på inmatningen för att förhindra för många förfrågningar.

Uppdatering av styrenhet

I controllern tog jag bort SearchResult handling och lade istället till mer "intelligens" till huvud Search åtgärd för att hantera både den inledande sökningen och de paginerade förfrågningar.

Här kan du se att jag lägger till en extra parameter som kallas pagerequest för att avgöra om detta är en sidnumrerad begäran eller inte och ange att detta bör befolkas från huvudsamlingen.


    [HttpGet]
    [Route("")]
    public async Task<IActionResult> Search(string? query, int page = 1, int pageSize = 10,[FromHeader] bool pagerequest=false)
    {
        var searchResults = await searchService.GetPosts(query, page, pageSize);
        var searchModel = new SearchResultsModel
        {
            Query = query,
            SearchResults = searchResults
        };
        searchModel = await PopulateBaseModel(searchModel);
        var linkUrl = Url.Action("Search", "Search");
        searchModel.SearchResults.LinkUrl = linkUrl;
        if(pagerequest && Request.IsHtmx()) return PartialView("_SearchResultsPartial", searchModel.SearchResults);
        
        if (Request.IsHtmx()) return PartialView("SearchResults", searchModel);
        return View("SearchResults", searchModel);
    }

Jag får sedan resultaten och upptäcker det här huvudet för att avgöra vilken vy/delvy som ska returneras.

Jag lade vidare till en separat pensionär handling för att hantera liknande av /search/umami att omdirigera till huvudsöksidan med frågan.

   [HttpGet]
    [Route("{query}")]
    public  IActionResult InitialSearch([FromRoute] string query)
    {
        return RedirectToAction("Search", new { query });
    }

Slutsatser

Ganska enkelt, eller hur? Detta är en enkel implementering av en sökmotor med hjälp av HTMX och EF Core i en ASP.NET Core-applikation. Du kan enkelt utöka detta till att omfatta fler funktioner som filtrering, sortering, eller ens integrera med andra söktjänster. Nyckeln takeaway är hur man kan utnyttja HTMX för en smidig användarupplevelse samtidigt som backendlogiken hålls ren och effektiv.

logo

©2024 Scott Galloway