Code

Recherche surlignée avec Entity Framework

Publié le jeudi jeudi 12 août 2021
Identicon de monjici
Par monjici
Blogueur du dimanche

Un site sans engin de recherche c'est comme un livre de recettes sans index: on ne trouve que ce qui est en couverture. C'est pourquoi j'ai décidé d'ajouter une fonctionnalité de recherche pour le carnet Web. En même temps, c'est un excellent complémenter au filtrage par étiquettes.

Ce qui semblait d'une apparente simplicité fut finalement un mini projet intéressant. Je vous partage mon aventure au travers les multiples étapes d'essaie et erreur pour finalement arriver à l'idée initiale que j'avais en tête: surligner l'expression de recheche dans les billets.

La base

Pour mon exemple, j'utilise .NET 5 et l'Entity Framework. Je n'expliquerai pas en détails ce prérequis, c'est déjà bien documenté.

Il faudra aussi un objet d'entité que l'on utilisera pour la recherche. Pour cet exemple, nous utiliseront une entité Article. Voici un exemple de code minimal:

Article.cs

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Demo.Entities
{
    public class Article
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int ArticleID { get; set; }
        public string Titre { get; set; }
        public string Texte { get; set; }
    }
}

Au minimum, il vous faudra un ID unique, une titre et le texte. Nous feront la recherche dans les champs Titre et Texte.

Nous ajouterons cette entité dans un DbContext.

using Microsoft.EntityFrameworkCore;
using Demo.Entities;
namespace Demo.Data
{
    public class GlobalDbContext : DbContext
    {
        public GlobalDbContext(DbContextOptions<GlobalDbContext> options) : base(options)
        {
        }
        public DbSet<Article> Article { get; set; }
    }
}

Finalement, nous ajoutons le DbContext lors de la configuration des services dans une classe IHostingStartup.

public void Configure(IWebHostBuilder builder)
{
    builder.ConfigureServices((context, services) =>
    {
        services.AddDbContext<GlobalDbContext>(options =>
            options.UseSqlServer(
                context.Configuration.GetConnectionString("DemoContextConnection")));
    }
}

La fondation de la structure des données est maintenant complétée.

Les données

Vous pouvez ajouter quelques articles manuellement dans la BD à l'aide de SQL Server Management Studio. De préférence, j'utilise le format Markdown pour lequel il y a plusieurs bons éditeurs Javascript.

La recherche

La recherche devrait s'effectuer dans un service. Nous ajouterons une méthode Recherche qui prendra en paramètre la requête de recherche.

La partie clé est la syntaxe Linq dans laquelle on filtrera les articles contenant la requête dans le champs Titre ou Texte. En bonis, on convertira le code Markdown en HTML avec la libraire MarkdownSharp.

IArticleService.cs

using Demo.Entities;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace Demo.Services.Article
{
    public interface IArticleService
    {
        Task<IList<Article>> Recherche(string requete);
    }
}

ArticleService.cs

using Demo.Entities;
using HeyRed.MarkdownSharp;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
namespace Demo.Services.Article
{
    public class ArticleService : IArticleService
    {
        private readonly GlobalDbContext _context;
        public ArticleService(GlobalDbContext context)
        {
            _context = context;
        }
        public async Task<IList<Article>> Recherche(string requete)
        {
            IList<Article> articleList = null;
            IQueryable<Article> articles = from a in _context.Article
                .Where(a => (a.Titre.Contains(requete) || a.Texte.Contains(requete)) )
                select a;
            articleList = await articles.AsNoTracking().ToListAsync();
            Markdown mark = new Markdown();
            articles = articles.Select(x =>
            {
                x.Titre = mark.Transform(x.Titre);
                x.Texte = mark.Transform(x.Texte);
                return x;
            }).ToList();
            return articles;
        }
    }
}

La méthode retourne une liste d'articles. Normalement, nous convertirerions cette liste en ViewModel pour ne pas exposer les modèles internes, mais pour les besoins de ce démo nous sauterons cette étape.

Les accents et la case

Ma première surprise dans cette aventure fût que mes recherches ne retournaient pas les articles contenant ma requête si ceux-ci avait une différence sur les accents. Par exemple, rechercher pour debut ne retournait aucun article contenant début, vice versa. Impossible de réglèr le problème au niveau du code, il me fallut migrer la base de données vers une nouvelle créée avec l'option CI AI (Case Insensitive, Accent Insensitive) car la configuration par défault était CI AS. Avec cette nouvelle configuration ignorant les accents, l'engin de base de données indexe ou représente les chaînes de caractère différemment à l'interne de façon à permettre une recherche où DÉJÀ et deja sont équivalent lors de la recherche.

Le surlignage

La prochaine étape logique était de surligner l'expresion de recherche dans les résultats. On commence avec un simple string.Replace en entourant l'expression par une balise <mark>. Exemple :

titre = titre.Replace(requete, "<mark>" + requete + "</mark>");
texte = texte.Replace(requete, "<mark>" + requete + "</mark>");

Le premier problème que cela pose, c'est la réutilisation de code. On ne veut pas faire une substitution dans chaque endroit du site où un article est affiché, mais centraliser cette opération. On cré donc un StringHelper qui sera appelé du code cshtml.

@Html.Raw(StringHelper.HighlightText(requete, titre))

StringHelper.cs

namespace Demo.Helper
{
    public static class StringHelper
    {
        public static string HighlightText(string textToHightlight, string text)
        {
            string resultText = null;
            if (!String.IsNullOrEmpty(text) && !String.IsNullOrEmpty(textToHightlight))
            {
                text = text.Replace(textToHightlight, "<mark>" + textToHightlight + "</mark>");
            }
            else
            {
                resultText = text;
            }
            return resultText;
        }
    }
}

Page Razor

Nous devons créer une page de recherche qui prend un paramètre la requête de recherche. Exemple:

Rechercher.cshtml

@page "{q?}"
@model Demo.Web.Pages.Article.RechercheModel
@using Demo.Helper;
<div class="row">
    <div class="col-lg-8">
        <div class="row article-list">
            @foreach (var article in Model.Articles)
            {
                <div class="col-xs-12">
                    <h2>
                      <a asp-page="./article" asp-route-id="@article.ArticleID">
                        @Html.Raw(StringHelper.HighlightText(Model.Query,article.Title))
                      </a>
                    </h2>
                    <div>
                        @Html.Raw(StringHelper.HighlightText(Model.Query, article.Texte))
                    </div>
                </div>
            }
        </div>
    </div>
</div>

Recherche.cshtml.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Demo.Models;
using Demo.Services.Article;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Demo.Web.Pages.Articles
{
    public class RechercheModel : PageModel
    {
        private readonly IArticleService _articleService;
        public IList<Article> Articles { get; set; }
        public string Query { get; set; }
        public RechercheModel(IArticleService articleService)
        {
            _articleService = articleService;
        }
        public async Task OnGetAsync(string q)
        {
            this.Articles = await _articleService.Recherche(q);
            this.Query = q;
        }
    }
}

Validation

Maintenant que nous avons une bonne base, faisons un test en cherchant "patate" dans un texte contenant "J'aime les patates.". Le résultat donne le texte suivant:

J'aime les patates.

Ce test est concluant ! Cherchons maintenant pour "apres" dans le texte "Je te rencontre après-midi.". Le résultat donne le texte suivant:

Je te rencontre apres-midi.

Ce test démontre un petit défi. La fonction HighlightText remplace l'expression trouvée par l'expression recherchée. La recherche étant non sensible à la case, chercher apres retoune les textes contenant après mais nous ne devrions pas remplacer le mot trouvé par le mot recherché, mais tout simplement surligner le mot trouvé tel quel.

Un autre test démontre un problème relié aux articles contenant des balises HTML. Nous voulons ignorer les balises lors de la recherche pour prévenir les remplacement non voulus.

Pour prendre en compte tous les cas, nous devons changer la fonction HighlightText pour ce qui suit.

public static string HighlightText(string textToHightlight, string text)
{
    string resultText = null;
    if (!String.IsNullOrEmpty(text) && !String.IsNullOrEmpty(textToHightlight))
    {
        // Case and accent insensitive search
        var cleanText = RemoveDiacritics(text);
        textToHightlight = RemoveDiacritics(textToHightlight);
        var matches = Regex.Matches(cleanText, "(?<!</?[^>]*|&[^;]*)(" + textToHightlight + ")", RegexOptions.IgnoreCase);
        var textToHightlightLength = textToHightlight.Length;
        StringBuilder sb = new StringBuilder(text.Length + matches.Count * 13);
        var cursorPos = 0;
        foreach (Match match in matches)
        {
            sb.Append(text.Substring(cursorPos, match.Index - cursorPos));
            sb.Append("<mark>");
            sb.Append(text.Substring(match.Index, textToHightlightLength));
            sb.Append("</mark>");
            cursorPos = match.Index + textToHightlightLength;
        }
        if (cursorPos < text.Length)
        {
            sb.Append(text.Substring(cursorPos));
        }
        resultText = sb.ToString();
    }
    else
    {
        resultText = text;
    }
    return resultText;
}

private static string RemoveDiacritics(string text)
{
    var normalizedString = text.Normalize(NormalizationForm.FormD);
    var stringBuilder = new StringBuilder();
    foreach (var c in normalizedString)
    {
        var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
        if (unicodeCategory != UnicodeCategory.NonSpacingMark)
        {
            stringBuilder.Append(c);
        }
    }
    return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
}

L'approche utilisée est d'avoir deux versions du texte: le texte original et le texte épuré d'accents et de balises prêt pour la recherche. On effectue donc les étapes suivantes:

  1. Nettoyer le texte en enlevant les accents avec la fonction utilitaire RemoveDiacritics.
  2. Rechercher le texte nettoyé avec un RegEx excluant les balises et incluant le texte recherché: "(?<!</?[^>]|&[^;])(" + textToHightlight + ")".
  3. Recréer le texte final en substituant les expressions recherchées dans le texte original par lui-même toujours entouré de la balise <mark>. Notez l'utilisation de la class StringBuilder qui permet une concaténation plus efficace des chaînes de caractères si on la compare à l'expression +.

Conclusion

La pièce maitresse de cet article est la fonction HighlightText. Je ne sais pas si l'approche utilisée est commune ou la plus efficace, mais elle fonctionne et c'est parfois mieux de faire quelques étapes de plus que de rendre les choses trop complexes et difficiles à comprende lorsqu'on y retourne plus tard.

Le choix de la configuration de base de données nous rappelle l'importance de prendre les bonnes décisions architecturales dès le départ pour éviter des problèmes importants plus tard dans le cycle de vie d'une solution dans son ensemble.

Comme on dit dans le métier, certains problèmes sont bons à avoir ! Par exemple, lorsque que le site slashdot.org a dû changer le type de donnée de la colonne du numéro de commentaire pour un nombre de 32 bits car le nombre de commentaires approchait la limite existante. Ceci ne représentait pas un mauvais choix architectural car à l'origine, 32 bits aurait pris de l'espace inutile. C'était plutôt le reflet d'un excellent succès.

Commentaires

Vous devez être connecté pour ajouter un commentaire.