Простий пошук за допомогою HTMX і EF- ядра для ядра ASP. NET (Українська (Ukrainian))

Простий пошук за допомогою HTMX і EF- ядра для ядра ASP. NET

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

Вступ

Це лише швидка стаття, яка будується на інших у повному текстовому пошуку, як-от locatedown typeahead і Postgres повний текстовий пошук. У цьому дописі я покажу вам, як реалізувати просту сторінку пошуку за допомогою HTMX і EF Core у програмі ASP.NET.

Хіба я вже не обшукую?

Ну так, у заголовку сайту у мене є функція пошуку, яка забезпечує типагед (де, як ви вводите результати приходять в реальному часі). Але я приховую це у режимі мобільного, а також хочу мати змогу пов' язати результати пошуку (на зразок / search/ umami) на віддану сторінку пошуку. Це дає більше досвіду для користувача, а також дає можливість працювати над мобільними пристроями.

Служба пошуку

Для цього я змінив спосіб своїх пошуків. Я створив BlogSearchService, це базується на моїх двох методах повнотекстового запиту. На жаль, ці варіанти слід розділити на два методи, оскільки спосіб побудови запитів складається з розширень Postgres Повного текстового пошуку, EF.Functions.WebSearchToTsQuery("english", processedQuery) і EF.Functions.ToTsQuery("english", query + ":*").

Перший приймає відповідні критерії пошуку, а другий виконує пошук за шаблоном.

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

Я використовую просту перевірку для того, чи є пробіли у запиті, щоб визначити, який метод викликати.

Контролер пошуку

Контролер пошуку стежить за шаблоном, який буде використано для більшості моїх контролерів, за допомогою якого він визначатиме, чи прийде виклик з GTMX, чи ні, щоб увімкнути надсилання часткової або повної сторінки розкладки (це означає, що він працює для безпосередньої навігації, а також запитів до 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 перегляд результатів, якщо запит є запитом на GTMX.

Це доволі простий перегляд паріталії, який має біти розмазування і результати.

@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 тут досить просте. Я просто зв' яжуся з цією кнопкою (на цей час я вирішив НЕ змінювати вхідні дані за допомогою клавіші keyup / змінити адресу URL) і надіслати запит після натискання кнопки. Я включаю запит до запиту за допомогою hx-include і ціль #content Дрв, щоб замінити результати.

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

Оновити

Отже, після деяких відгуків Халідafghanistan. kgm Я вирішив збільшити цю функціональність, щоб уможливити пошук:

  1. Ввімкнено за натискання клавіші Enter
  2. Якщо ви ввели більше ніж два символи (так, що вони стають невибагливими).

В майбутньому мені потрібно додати функціональні можливості розміром сторінки, це бік хакера, і це потребує підтримки подальших вимог.

Форма оновленого пошуку

Для цього я спочатку загорнув вхідні дані у форму і використав альпійську.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 заголовок, щоб вказати, що це запит з пагінцями.

Я також використовую альпійські.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. Ви можете просто розширити цю можливість, щоб включити більше можливостей, зокрема фільтрування, впорядкування або навіть інтеграції з іншими службами пошуку. Ключем є те, як використати HTMX для здорового користувача, зберігаючи логічну логіку сервера чистою і ефективною.

logo

©2024 Scott Galloway