Back to "Απλή αναζήτηση με τη χρήση πυρήνα HTMX & EF για 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 HTMX Postgres

Απλή αναζήτηση με τη χρήση πυρήνα HTMX & EF για ASP.NET Core

Tuesday, 17 September 2024

Εισαγωγή

Αυτό είναι απλά ένα γρήγορο άρθρο, όπως βασίζεται στα άλλα στην πλήρη σειρά αναζήτησης κειμένου, όπως η typeproof dropdown dropdown και Αναζήτηση πλήρους κειμένου Postgres. Σε αυτή την ανάρτηση, θα σας δείξω πώς να εφαρμόσετε μια απλή σελίδα αναζήτησης χρησιμοποιώντας HTMX και EF Core σε μια εφαρμογή ASP.NET Core.

Δεν έχω ήδη ψάξει;

Λοιπόν ναι, στην επικεφαλίδα του site έχω μια λειτουργία αναζήτησης που παρέχει το laward τύπου (όπου πληκτρολογείτε τα αποτελέσματα έρχονται σε πραγματικό χρόνο). Ωστόσο, το κρύβω σε mobile mode και ήθελα επίσης να μπορέσω να συνδέσω τα αποτελέσματα της αναζήτησης (όπως /Αναζήτηση/ουμάμι) σε μια ειδική σελίδα αναζήτησης. Αυτό δίνει μια καλύτερη εμπειρία χρήστη καθώς και την εργασία σε κινητές συσκευές.

Υπηρεσία αναζήτησης

Για να το κάνω αυτό, άλλαξα τον τρόπο με τον οποίο έκανα τις έρευνές μου. Δημιούργησα ένα... BlogSearchServiceΑυτό βασίζεται στις δύο μεθόδους έρευνας πλήρους κειμένου μου. Αυτές δυστυχώς πρέπει να χωριστούν σε δύο μεθόδους λόγω του τρόπου με τον οποίο τα ερωτήματα είναι δομημένα με τις επεκτάσεις πλήρους αναζήτησης κειμένου Postgres, EF.Functions.WebSearchToTsQuery("english", processedQuery) και EF.Functions.ToTsQuery("english", query + ":*").

Ο πρώτος παίρνει τους κατάλληλους όρους αναζήτησης και ο δεύτερος τους αναζητά 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
    }

Και πάλι αυτά χρησιμοποιούν την προεπιλεγμένη μου SearchVector στήλη η οποία ενημερώνεται σχετικά με τη μετά τη δημιουργία και την επικαιροποίηση. Αυτό δημιουργήθηκε στο... DbContext χύ OnModelCreating μέθοδος.

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

Και πάλι το μειονέκτημα αυτής της προσέγγισης είναι ότι λειτουργεί μόνο για τα αγγλικά ως-είναι. Θα χρειαστώ μια ριζική ανοικοδόμηση της βάσης δεδομένων για να το κάνω να λειτουργήσει για άλλες γλώσσες (πιθανόν ένα τραπέζι για κάθε γλώσσα).

Στη συνέχεια, χρησιμοποιώ αυτές τις μεθόδους στην BlogSearchService να επιστρέψει τα αποτελέσματα με βάση το ερώτημα αναζήτησης.

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

Χρησιμοποιώ τον απλό έλεγχο για το αν υπάρχουν κενά στο ερώτημα για να καθορίσω ποια μέθοδο να καλέσω.

Έλεγχος αναζήτησης

Ο ελεγκτής αναζήτησης ακολουθεί το μοτίβο που χρησιμοποιώ για τους περισσότερους ελεγκτές μου όπου ανιχνεύει αν η κλήση προέρχεται από HTMX ή όχι για να επιτρέψει την αποστολή είτε μερικής είτε πλήρους σελίδας διάταξης (που σημαίνει ότι λειτουργεί για απευθείας πλοήγηση, καθώς και αιτήματα 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);
    }
}

Αυτό είναι ολόκληρο το χειριστήριο, μπορείτε να δείτε ότι έχω δύο δράσεις, μία που επιστρέφει τη σελίδα (προαιρετικά γεμάτη αποτελέσματα) και μία που επιστρέφει μόνο τα αποτελέσματα για αιτήματα HTMX.

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

Αποτελέσματα αναζήτησης Μερική

Μπορείτε να δείτε ότι αυτό προαιρετικά επιστρέφει το _SearchResultsPartial δείτε εάν το αίτημα είναι αίτηση HTMX για αποτελέσματα.

Αυτή είναι μια αρκετά απλή Αρχική άποψη που έχει paging bits και τα αποτελέσματα.

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

Λίστα μετά μερική προβολή

Χρησιμοποιώ το ίδιο. _ListPost Μερική προβολή όπου χρειάζομαι για να καταγράφω αναρτήσεις.

@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 και η σελίδα αναζήτησης

Και πάλι η χρήση του HTMX εδώ είναι πολύ απλή. Απλά συνδέω το κουμπί (αυτή τη φορά αποφάσισα να μην αλλάξω την είσοδο στο πληκτρολόγιο / αλλάξω το URL) και να στείλω το αίτημα όταν κάνετε κλικ στο κουμπί. I include the question in the request using hx-include και στοχεύστε το #content div για την αντικατάσταση των αποτελεσμάτων.

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

Ενημέρωση

Έτσι, ακολουθώντας ορισμένα σχόλια από ΚαλίντCity name (optional, probably does not need a translation) Αποφάσισα να ενισχύσω αυτή τη λειτουργία για να μπορέσω η αναζήτηση να είναι:

  1. Εισάγετε το πλήκτρο πληκτρολόγησης
  2. Εναλλαγή κατά την εισαγωγή περισσότερων από δύο χαρακτήρων (γι 'αυτό γίνεται ένα χαλαρό laward).

Στο μέλλον θα πρέπει να προσθέσετε το μέγεθος της σελίδας λειτουργικότητα πίσω σε? είναι ένα δίδυμο ενός hack και πρέπει να υποστηρίξει περαιτέρω απαιτήσεις.

Ενημερωμένη Φόρμα Αναζήτησης

Για να το κάνω αυτό, πρώτα τύλιξα την εισαγωγή σε μια μορφή και χρησιμοποίησα Alpine.js για να υποβάλλω τη φόρμα όταν ένας χρήστης πληκτρολογεί. Μπορείς να δεις ότι χρησιμοποιώ x-data να δημιουργήσω μια αντιδραστική μεταβλητή για το ερώτημα και μετά να ελέγξω το μήκος της ερώτησης για να καθορίσω αν θα υποβάλω το έντυπο.

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

Για την επαναχρησιμοποίηση της ίδιας δράσης του ελεγκτή, έθεσα επίσης το pagerequest header για να δείξει ότι αυτό είναι ένα paginated αίτημα.

Χρησιμοποιώ επίσης τα Alpine.js x-on:keydown.enter.prevent για να ενεργοποιήσετε το κουμπί κάντε κλικ όταν πατήσετε το πλήκτρο Enter και μια αποβολή στην είσοδο για να αποτρέψετε πάρα πολλά αιτήματα.

Ενημέρωση ελεγκτή

Στο χειριστήριο αφαίρεσα τη δράση SearchResults και αντ' αυτού πρόσθεσα περισσότερη "κατανόηση" στο κύριο Search δράση για τη διαχείριση τόσο της αρχικής αναζήτησης όσο και των επισημοποιημένων αιτημάτων.

Εδώ μπορείτε να δείτε ότι προσθέτω μια επιπλέον παράμετρο που ονομάζεται pagerequest για να προσδιοριστεί αν πρόκειται για αίτημα που έχει επικολληθεί ή όχι και να αναφερθεί ότι αυτό θα πρέπει να κατοικείται από τη συλλογή κεφαλίδων.


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

Στη συνέχεια, παίρνω τα αποτελέσματα και ανιχνεύω αυτή την κεφαλίδα για να καθορίσει ποια προβολή / μερική προβολή για να επιστρέψει.

Πρόσθεσα περαιτέρω μια ξεχωριστή αποστρατευμένη δράση για να χειριστεί τους ομοίους του /search/umami για να ανακατευθύνεστε στην κύρια σελίδα αναζήτησης με το ερώτημα.

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

Συμπέρασμα

Λοιπόν, αρκετά απλό, σωστά; Πρόκειται για μια απλή εφαρμογή μιας σελίδας αναζήτησης χρησιμοποιώντας HTMX και EF Core σε μια εφαρμογή ASP.NET Core. Μπορείτε εύκολα να επεκτείνετε αυτό για να περιλαμβάνει περισσότερα χαρακτηριστικά όπως φιλτράρισμα, διαλογή, ή ακόμη και ενσωμάτωση με άλλες υπηρεσίες αναζήτησης. Το βασικό takeaway είναι πώς να μόχλευση HTMX για μια ομαλή εμπειρία χρήστη, διατηρώντας παράλληλα το backend λογική καθαρή και αποτελεσματική.

logo

©2024 Scott Galloway