Semplice ricerca utilizzando HTMX & EF Core per ASP.NET Core (Italiano (Italian))

Semplice ricerca utilizzando HTMX & EF Core per 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

Introduzione

Questo è solo un articolo veloce come si basa sugli altri nella serie di ricerca di testo completo come il dropdown di typeahead e Ricerca testo completo Postgres. In questo post, vi mostrerò come implementare una semplice pagina di ricerca utilizzando HTMX e EF Core in un'applicazione ASP.NET Core.

Non ho gia' una ricerca?

Ebbene sì, nell'intestazione del sito ho una funzione di ricerca che fornisce il tipoahead (dove si digita i risultati vengono in tempo reale). Tuttavia nascondo che in modalità mobile e volevo anche essere in grado di collegare i risultati della ricerca (come /search/umami) ad una pagina di ricerca dedicata. Questo offre un'esperienza utente migliore oltre a lavorare su dispositivi mobili.

Servizio di ricerca

Per fare questo ho modificato come ho fatto le mie ricerche. Ho creato un BlogSearchService, si basa sui miei due metodi di query Full Text. Questi purtroppo devono essere suddivisi in due metodi a causa del modo in cui le query sono strutturati con le estensioni Postgres Full Text Search, EF.Functions.WebSearchToTsQuery("english", processedQuery) e EF.Functions.ToTsQuery("english", query + ":*").

Il primo prende termini di ricerca adeguati e il secondo prende ricerche jolly.

    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
    }

Ancora una volta questi usano il mio precomputato SearchVector colonna che viene aggiornata sulla creazione e l'aggiornamento post. Questo è creato nel mio DbContext utilizzando il OnModelCreating metodo.

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

Ancora una volta lo svantaggio di questo approccio è che funziona solo per l'inglese as-is. Avrei bisogno di una ricostruzione radicale del Database per farlo funzionare per altre lingue (probabilmente una tabella per ogni lingua).

Poi uso questi metodi nel mio BlogSearchService per restituire i risultati in base alla query di ricerca.

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

Uso il semplice controllo per verificare se ci sono spazi nella query per determinare quale metodo chiamare.

Controllore di ricerca

Il controller di ricerca segue il modello che uso per la maggior parte dei miei controller in cui rileva se la chiamata proviene da HTMX o meno per consentire l'invio della pagina di layout parziale o completa (cioè funziona per la navigazione diretta così come le richieste HTMX).

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

Questo è l'intero controller, potete vedere che ho due Azioni, una che restituisce la pagina (opzionalmente popolata di risultati) e una che restituisce solo i risultati per le richieste HTMX.

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

Risultati Della Ricerca Parziale

Puoi vedere che questo restituisce opzionalmente il _SearchResultsPartial visualizzare se la richiesta è una richiesta HTMX per i risultati.

Questa è una vista iniziale abbastanza semplice che ha bit di ricerca e i risultati.

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

Vista parziale post elenco

Io uso lo stesso. _ListPost vista parziale ovunque ho bisogno di elencare i post.

@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 e la pagina di ricerca

Ancora una volta il mio utilizzo di HTMX qui è abbastanza semplice. Ho semplicemente agganciato il pulsante (questa volta ho deciso di NON cambiare l'ingresso sul tasto / cambiare l'URL) e inviare la richiesta quando il pulsante è cliccato. Includi la query nella richiesta utilizzando hx-include e puntare al #content div per sostituire i risultati.

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

Aggiorna

Quindi, a seguito di alcuni commenti da KhalidCity name (optional, probably does not need a translation) Ho deciso di migliorare questa funzionalità per consentire alla ricerca di essere:

  1. Avviato premendo il tasto Invio
  2. Attivata all'inserimento di più di due caratteri (quindi diventa un tipo libero).

In futuro ho bisogno di aggiungere la funzionalità di dimensione della pagina di nuovo in; è un biit di un hack e ha bisogno di supportare ulteriori requisiti.

Modulo di ricerca aggiornato

Per fare questo ho prima avvolto l'input in un modulo e usato Alpine.js per inviare il modulo quando un utente sta digitando. Puoi vedere che uso x-data per creare una variabile reattiva per la query e poi controllo la lunghezza della query per determinare se inviare il form.

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

Al fine di riutilizzare la stessa azione controller ho anche impostato il pagerequest Intestazione per indicare che si tratta di una richiesta immaginata.

Uso anche gli Alpine.js x-on:keydown.enter.prevent per attivare il pulsante fare clic quando il tasto Invio è premuto e un debounce sull'input per evitare troppe richieste.

Aggiornamento controllore

Nel controller ho rimosso l'azione SearchResults ed ho invece aggiunto più 'intelligenza' alla principale Search azione per gestire sia la ricerca iniziale che le richieste paginate.

Qui puoi vedere che aggiungo un parametro in più chiamato pagerequest per determinare se si tratta di una richiesta paginata o meno e indicare che tale richiesta dovrebbe essere popolata dalla collezione di intestazione.


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

Ottengo quindi i risultati e rilevo questa intestazione per determinare quale vista / vista parziale tornare.

Inoltre ho aggiunto un'azione separata in pensione per gestire i tipi di /search/umami per reindirizzare alla pagina di ricerca principale con la query.

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

In conclusione

Quindi, abbastanza semplice, giusto? Si tratta di una semplice implementazione di una pagina di ricerca utilizzando HTMX e EF Core in un'applicazione ASP.NET Core. Si può facilmente estendere questo per includere più funzionalità come il filtraggio, l'ordinamento, o anche l'integrazione con altri servizi di ricerca. Il takeaway chiave è come sfruttare HTMX per un'esperienza utente fluida mantenendo la logica del backend pulita ed efficiente.

logo

©2024 Scott Galloway