Yksinkertainen haku käyttäen HTMX- ja EF-ydintä ASP.NET-ytimeen (Suomi (Finnish))

Yksinkertainen haku käyttäen HTMX- ja EF-ydintä ASP.NET-ytimeen

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

//

8 minute read

Johdanto

Tämä on vain nopea artikkeli, koska se perustuu muihin tekstihakusarjoihin, kuten Typeahead dropdown sekä Postgres koko tekstin haku. Tässä viestissä näytän, miten voit toteuttaa yksinkertaisen hakusivun käyttämällä HTMX:ää ja EF Corea ASP.NET Core -sovelluksessa.

Eikö minulla ole jo etsintää?

No kyllä, sivuston otsikossa Minulla on hakutoiminto, joka tarjoaa hakusanoja (jossa tulokset kirjoitetaan reaaliajassa). Piilotan sen kuitenkin mobiilitilassa ja halusin myös linkittää hakutulokset (kuten /search/umami) omalle hakusivulle. Tämä antaa paremman käyttökokemuksen sekä mobiililaitteiden parissa työskentelyn.

Hakupalvelu

Tätä varten muokkasin, miten tein kotietsinnät. Minä loin BlogSearchServiceTämä perustuu kahteen Full Text -kyselymenetelmääni. Nämä on valitettavasti jaettava kahteen menetelmään, koska kyselyt on järjestetty Postgres Full Text Search -laajennuksilla. EF.Functions.WebSearchToTsQuery("english", processedQuery) sekä EF.Functions.ToTsQuery("english", query + ":*").

Ensimmäinen vaatii oikeat hakuehdot ja toinen villikorttihaut.

    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
    }

Taas nämä käyttävät esilaukaisuani SearchVector kolumni, joka päivitetään jälkiluonnista ja päivityksestä. Tämä on luotu minun DbContext Käyttäen darbepoetiini alfaa ja darbepoetiini alfaa (ks. kohta 5. 2). OnModelCreating menetelmä.

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

Tämän lähestymistavan haittapuoli on jälleen se, että se toimii vain englantilaisille. Tarvitsisin tietokannan radikaalin uudelleenrakennuksen, jotta se toimisi muilla kielillä (todennäköisesti taulukko jokaiselle kielelle).

Sitten käytän näitä menetelmiä minun BlogSearchService palauttaa tulokset hakukyselyn perusteella.

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

Käytän yksinkertaista tarkistaa, onko välilyöntejä kyselyssä selvittää, mitä menetelmää soittaa.

Etsi ohjain

Haku-ohjain noudattaa useimmille ohjaimilleni käyttämääni kaavaa, jossa se havaitsee, tuleeko puhelu HTMX:ltä vai ei, jotta se voi lähettää joko osittaisen tai täyden layout-sivun (mikä tarkoittaa, että se toimii suoran navigoinnin sekä HTMX-pyyntöjen vuoksi).

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

Tämä on koko ohjain, näet, että minulla on kaksi toimintoa, joista toinen palauttaa sivun (valinnaisesti asutut tulokset) ja toinen palauttaa vain tulokset HTMX-pyyntöihin.

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

Etsinnän tulokset osittain

Huomaat, että tämä palauttaa valinnaisesti _SearchResultsPartial katso, jos pyyntö on HTMX-tulospyyntö.

Tämä on aika yksinkertainen Paritial View, jossa on haku bittejä ja tuloksia.

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

Luettele osittainen näkymä@ info: whatsthis

Käytän samaa _ListPost Osittainen näkymä aina, kun minun tarvitsee listata virkoja.

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

HtMX:n käyttö täällä on jälleen aika yksinkertaista. Kiinnitän vain nappiin (tällä kertaa päätin, etten muuta näppäimistön syötettä tai muuta URL-osoitetta) ja lähetän pyynnön, kun painiketta napsautetaan. Sisällytän kyselyn pyyntöön käyttäen hx-include ja kohdistaa #content Div korvaa tulokset.

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

Päivitä

Joten seuraavat joitakin kommentteja Khalid Päätin tehostaa tätä toiminnallisuutta, jotta haku olisi:

  1. Käynnistetty Enter-näppäinpainikkeella
  2. Käynnistettiin yli kahden merkin syöttämisessä (joten siitä tulee löyhä tyyppi edessä).

Tulevaisuudessa minun täytyy lisätä sivun koko toiminnallisuus takaisin; se on biisi hakata ja tarvitsee tukea lisävaatimuksia.

Päivitetty hakulomake

Tätä varten käärin syötteen ensin muotoon ja käytin Alpine.js:ia lähettääkseni lomakkeen käyttäjän kirjoittaessa. Huomaat, että käytän x-data Luon kyselylle reaktiivisen muuttujan ja tarkistan sitten kyselyn pituuden, jotta voin päättää, toimitetaanko lomake.

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

Jotta sama ohjaintoiminto voitaisiin käyttää uudelleen, asetin myös pagerequest Otsikko osoittaa, että kyseessä on paginoitu pyyntö.

Käytän myös Alpine.js x-on:keydown.enter.prevent Käynnistääksesi painikkeen painalluksen, kun Enter-näppäintä painetaan, ja poistaaksesi syötteestä liikaa pyyntöjä.

Hallintapäivitys

Ohjaimessa poistin SearchResults -toiminnon ja lisäsin sen sijaan pääosaan lisää "älykkyyttä". Search toimia sekä alustavan haun että paginoitujen pyyntöjen käsittelemiseksi.

Tässä näet Lisään ylimääräisen parametrin nimeltä pagerequest Määrittääkseen, onko kyseessä paginoitu pyyntö vai ei, ja ilmoittaakseen, että tämä pitäisi kirjata otsikkokokoelmasta.


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

Sitten saan tulokset ja havaitsen tämän otsikon selvittääkseni, mikä näkymä / osittainen näkymä palaa.

Lisäsin lisäksi itsenäisen eläkkeelle siirtymistoimen, jolla hoidellaan sellaisia asioita kuin /search/umami Voit suunnata päähakusivulle kyselyn myötä.

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

Johtopäätöksenä

Aika yksinkertaista, vai mitä? Tämä on suoraviivainen toteutus hakusivulle, jossa käytetään HTMX:ää ja EF Corea ASP.NET Core -sovelluksessa. Voit helposti laajentaa tämän koskemaan useampia ominaisuuksia, kuten suodatusta, lajittelua tai jopa integroitumista muihin hakupalveluihin. Keskeistä takea on se, miten HTMX:ää voidaan hyödyntää sujuvan käyttökokemuksen saavuttamiseksi ja samalla pitää backend-logiikka puhtaana ja tehokkaana.

logo

©2024 Scott Galloway