Recherche simple utilisant HTMX & EF Core pour ASP.NET Core (Français (French))

Recherche simple utilisant HTMX & EF Core pour 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

//

10 minute read

Présentation

Ce n'est qu'un article rapide car il s'appuie sur les autres dans la série complète de recherche de texte tels que le typeahead déroulant et Postgres recherche texte complet. Dans ce post, je vais vous montrer comment implémenter une page de recherche simple en utilisant HTMX et EF Core dans une application ASP.NET Core.

N'ai-je pas déjà une recherche?

Eh bien oui, dans l'en-tête du site j'ai une fonction de recherche qui fournit typeahead (où lorsque vous tapez les résultats viennent en temps réel). Cependant, je cache qu'en mode mobile et je voulais aussi pouvoir lier les résultats de la recherche (comme /recherche/umami) à une page de recherche dédiée. Cela donne une meilleure expérience utilisateur ainsi que de travailler sur les appareils mobiles.

Service de recherche

Pour ce faire, j'ai modifié la façon dont j'ai fait mes recherches. J'ai créé un BlogSearchService, ceci est basé sur mes deux méthodes de requête Full Text. Ceux-ci doivent malheureusement être divisés en deux méthodes en raison de la façon dont les requêtes sont structurées avec les extensions Postgres Full Text Search, EF.Functions.WebSearchToTsQuery("english", processedQuery) et EF.Functions.ToTsQuery("english", query + ":*").

Le premier prend des termes de recherche appropriés et le second prend des recherches wildcard.

    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
    }

Encore une fois, ceux-ci utilisent mon précalculé SearchVector colonne qui est mise à jour après la création et la mise à jour. Ceci est créé dans mon DbContext à l'aide de la OnModelCreating méthode.

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

Encore une fois, l'inconvénient de cette approche est qu'elle ne fonctionne que pour l'anglais tel qu'il est. J'aurais besoin d'une reconstruction radicale de la base de données pour la faire fonctionner pour d'autres langues (probablement une table pour chaque langue).

J'utilise ensuite ces méthodes dans mon BlogSearchService pour retourner les résultats en fonction de la recherche.

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

J'utilise la simple vérification pour savoir s'il y a des espaces dans la requête pour déterminer quelle méthode appeler.

Contrôleur de recherche

Le contrôleur de recherche suit le modèle que j'utilise pour la plupart de mes contrôleurs où il détecte si l'appel vient de HTMX ou non pour permettre l'envoi de la page de mise en page partielle ou complète (ce qui signifie qu'il fonctionne pour la navigation directe ainsi que les requêtes 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);
    }
}

C'est l'ensemble du contrôleur, vous pouvez voir que j'ai deux Actions, une qui renvoie la page (facultativement remplie avec des résultats) et une qui renvoie juste les résultats pour les requêtes HTMX.

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

Résultats de la recherche Partielle

Vous pouvez voir que cette option renvoie le _SearchResultsPartial voir si la demande est une demande de résultats HTMX.

Il s'agit d'une vue paritiale assez simple qui a des bits de pagination et les résultats.

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

Affichage partiel de la liste

J'utilise la même chose. _ListPost vue partielle où j'ai besoin d'énumérer les messages.

@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 et la page de recherche

Encore une fois, mon utilisation de HTMX ici est assez simple. Je m'accroche simplement au bouton (cette fois, j'ai décidé de NE PAS modifier l'entrée sur le keyup / changer l'URL) et d'envoyer la demande lorsque le bouton est cliqué. J'inclus la requête dans la requête en utilisant hx-include et de cibler la #content div remplacer les résultats.

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

Mise à jour

Donc, après quelques commentaires de Khalid J'ai décidé d'améliorer cette fonctionnalité pour que la recherche soit :

  1. Déclenchement de la touche Entrée sur la touche Entrée
  2. Démarré à l'entrée de plus de deux caractères (il devient donc un typeahead lâche).

À l'avenir, j'ai besoin d'ajouter la fonctionnalité de taille de page à nouveau; c'est un biit d'un hack et doit supporter d'autres exigences.

Formulaire de recherche mis à jour

Pour ce faire, j'ai d'abord emballé l'entrée dans un formulaire et utilisé Alpine.js pour soumettre le formulaire quand un utilisateur tape. Vous pouvez voir que j'utilise x-data pour créer une variable réactive pour la requête, puis je vérifie la longueur de la requête pour déterminer s'il faut soumettre le formulaire.

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

Afin de réutiliser la même action de contrôleur, j'ai également défini le pagerequest en-tête pour indiquer qu'il s'agit d'une requête paginée.

J'utilise aussi le Alpine.js x-on:keydown.enter.prevent pour déclencher le bouton cliquez lorsque la touche Entrée est pressée et un débonflage sur l'entrée pour éviter un trop grand nombre de requêtes.

Mise à jour du contrôleur

Dans le contrôleur, j'ai supprimé l'action SearchResults et j'ai plutôt ajouté plus 'intelligence' au principal Search action pour gérer à la fois la recherche initiale et les requêtes paginées.

Ici vous pouvez voir que j'ajoute un paramètre supplémentaire appelé pagerequest pour déterminer s'il s'agit d'une demande paginée ou non et indiquer qu'elle devrait être remplie à partir de la collection d'en-têtes.


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

Je reçois ensuite les résultats et détecte cet en-tête pour déterminer quelle vue / vue partielle retourner.

J'ai ajouté une action de retraite séparée pour gérer les semblables de /search/umami pour rediriger vers la page de recherche principale avec la requête.

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

En conclusion

Donc, c'est assez simple, n'est-ce pas? Il s'agit d'une implémentation simple d'une page de recherche utilisant HTMX et EF Core dans une application ASP.NET Core. Vous pouvez facilement l'étendre pour inclure d'autres fonctionnalités comme le filtrage, le tri ou même l'intégration avec d'autres services de recherche. La clé à retenir est de savoir comment utiliser HTMX pour une expérience utilisateur fluide tout en maintenant la logique du moteur propre et efficace.

logo

©2024 Scott Galloway