Back to "Einfache Suche mit HTMX & EF Core für ASP.NET Core"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

ASP.NET Entity Framework Postgres HTMX

Einfache Suche mit HTMX & EF Core für ASP.NET Core

Tuesday, 17 September 2024

Einleitung

Dies ist nur ein kurzer Artikel, wie es baut auf den anderen in der Volltext-Suchreihe wie die typeahead Dropdown und Postgres Volltextsuche. In diesem Beitrag werde ich Ihnen zeigen, wie Sie eine einfache Suchseite mit HTMX und EF Core in einer ASP.NET Core Anwendung implementieren.

Habe ich nicht schon eine Durchsuchung?

Nun ja, in der Kopfzeile der Website habe ich eine Suchfunktion, die Typeahead bietet (wobei Sie die Ergebnisse in Echtzeit eingeben). Allerdings verberge ich, dass im mobilen Modus und ich wollte auch in der Lage sein, die Suchergebnisse zu verknüpfen (wie /Suche/Umami) zu einer speziellen Suchseite. Dies gibt eine bessere Benutzererfahrung sowie die Arbeit an mobilen Geräten.

Suchdienst

Um dies zu tun, änderte ich, wie ich meine Durchsuchungen machte. Ich habe eine BlogSearchService, dies basiert auf meinen beiden Volltext-Abfragemethoden. Diese müssen leider in zwei Methoden aufgeteilt werden, da die Abfragen mit den Postgres Volltext-Sucherweiterungen strukturiert sind, EF.Functions.WebSearchToTsQuery("english", processedQuery) und EF.Functions.ToTsQuery("english", query + ":*").

Die erste nimmt die richtigen Suchbegriffe und die zweite nimmt die Platzhaltersuche.

    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
    }

Auch diese verwenden meine vorgerechneten SearchVector Spalte, die nach der Erstellung und Aktualisierung aktualisiert wird. Das ist in meinem DbContext Verwendung der OnModelCreating verfahren.

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

Auch der Nachteil dieses Ansatzes ist, dass es nur für Englisch as-is funktioniert. Ich würde einen radikalen Wiederaufbau der Datenbank benötigen, damit sie für andere Sprachen funktioniert (wahrscheinlich eine Tabelle für jede Sprache).

Ich verwende diese Methoden dann in meinem BlogSearchService um die Ergebnisse basierend auf der Suchanfrage zurückzugeben.

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

Ich verwende die einfache Überprüfung, ob es Leerzeichen in der Abfrage gibt, um festzustellen, welche Methode aufgerufen werden soll.

Such-Controller

Der Search Controller folgt dem Muster, das ich für die meisten meiner Controller verwende, wo er erkennt, ob der Anruf von HTMX kommt oder nicht, um das Senden der Teil- oder Volllayout-Seite zu ermöglichen (d.h. es funktioniert sowohl für die Direktnavigation als auch für HTMX-Anfragen).

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

Dies ist der gesamte Controller, Sie können sehen, dass ich zwei Aktionen habe, eine, die die Seite zurückgibt (optional mit Ergebnissen gefüllt) und eine, die nur die Ergebnisse für HTMX-Anfragen zurückgibt.

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

Suchergebnis Teilweise

Sie können sehen, dass dies optional die _SearchResultsPartial sehen, wenn die Anfrage eine HTMX-Anfrage für Ergebnisse ist.

Dies ist eine ziemlich einfache Paritial-Ansicht, die paging-Bits und die Ergebnisse hat.

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

Liste nach der Teilansicht

Ich benutze dasselbe _ListPost Teilansicht, wo immer ich Beiträge auflisten muss.

@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 und die Suchseite

Auch hier ist meine Verwendung von HTMX recht einfach. Ich schalte mich einfach in den Button ein (ich habe mich diesmal entschieden, die Eingabe auf dem Keyup NICHT zu ändern / die URL zu ändern) und sende die Anfrage, wenn der Button geklickt wird. Ich füge die Abfrage in die Anfrage ein mit hx-include und zielen auf die #content div, um die Ergebnisse zu ersetzen.

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

Aktualisieren

Im Anschluß an einige Bemerkungen von Khalid Ich beschloss, diese Funktionalität zu verbessern, um die Suche zu ermöglichen:

  1. Getriggert auf Eingabetaste drücken
  2. Bei der Eingabe von mehr als zwei Zeichen ausgelöst (so wird ein loser Typahead).

In Zukunft muss ich die Seitengröße Funktionalität wieder hinzufügen; es ist eine Biit eines Hack und muss weitere Anforderungen zu unterstützen.

Aktualisiertes Suchformular

Dazu habe ich zuerst die Eingabe in eine Form gewickelt und Alpine.js benutzt, um das Formular beim Tippen eines Benutzers abzuschicken. Du kannst sehen, dass ich x-data um eine reaktive Variable für die Abfrage zu erstellen und dann überprüfe ich die Länge der Abfrage, um festzustellen, ob das Formular eingereicht werden soll.

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

Um die gleiche Controller-Aktion wiederzuverwenden, habe ich auch die pagerequest header, um anzuzeigen, dass es sich um eine paginierte Anfrage handelt.

Ich benutze auch die Alpine.js x-on:keydown.enter.prevent zum Auslösen der Schaltfläche klicken, wenn die Enter-Taste gedrückt wird und ein Debounce auf die Eingabe, um zu viele Anfragen zu verhindern.

Controller-Aktualisierung

In der Steuerung entfernte ich die SearchResults Aktion und fügte stattdessen mehr 'Intelligenz' zum Haupt Search Aktion, um sowohl die erste Suche als auch die paginierten Anfragen zu behandeln.

Hier sehen Sie, dass ich einen zusätzlichen Parameter füge. pagerequest zu bestimmen, ob es sich um eine paginierte Anfrage handelt oder nicht und anzugeben, dass diese aus der Header-Sammlung bevölkert werden sollte.


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

Ich erhalte dann die Ergebnisse und detektiere diesen Header, um zu bestimmen, welche Ansicht / Teilansicht zurückzugeben ist.

Ich fügte hinzu, eine gesonderte Pensionierung Aktion, um die gleichen zu behandeln /search/umami um auf die Hauptseite der Suche mit der Abfrage umzuleiten.

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

Schlussfolgerung

Also, ziemlich einfach, richtig? Dies ist eine einfache Implementierung einer Suchseite mit HTMX und EF Core in einer ASP.NET Core Anwendung. Sie können dies leicht erweitern, um weitere Funktionen wie Filtern, Sortieren oder sogar die Integration in andere Suchdienste aufzunehmen. Der Schlüssel zum Mitnehmen ist, wie Sie HTMX für eine reibungslose Benutzererfahrung nutzen, während Sie die Backend-Logik sauber und effizient halten.

logo

©2024 Scott Galloway