Entity Framework 6 avec recherche en texte intégral via LINQ

Je veux partager ma béquille pour résoudre un problÚme plutÎt banal: comment se faire des amis recherche MSSQL en texte intégral avec Entity Framework. Le sujet est trÚs spécialisé, mais il me semble pertinent aujourd'hui. Pour ceux intéressés, je demande chat.


Tout a commencé avec de la douleur


Je dĂ©veloppe des projets en C # (ASP.NET) et j'Ă©cris parfois des microservices. Dans la plupart des cas, j'utilise la base de donnĂ©es MSSQL pour travailler avec des donnĂ©es. Entity Framework est utilisĂ© comme lien entre la base de donnĂ©es et mon projet. Avec EF, j'ai de nombreuses opportunitĂ©s de travailler avec des donnĂ©es, de gĂ©nĂ©rer les bonnes requĂȘtes, de rĂ©guler la charge sur le serveur. Le mĂ©canisme magique LINQ sĂ©duit simplement par ses capacitĂ©s. Au fil des ans, je n'imagine plus de façons plus rapides et plus pratiques de travailler avec la base de donnĂ©es. Mais comme presque n'importe quel ORM, EF prĂ©sente un certain nombre d'inconvĂ©nients. Tout d'abord, ce sont les performances, mais c'est le sujet d'un article sĂ©parĂ©. Et deuxiĂšmement, il couvre les capacitĂ©s de la base de donnĂ©es elle-mĂȘme.


MSSQL a une recherche de texte intĂ©gral intĂ©grĂ©e qui fonctionne dĂšs la sortie de la boĂźte. Pour effectuer des requĂȘtes de texte intĂ©gral, vous pouvez utiliser les prĂ©dicats intĂ©grĂ©s (CONTAINS et FREETEXT) ou les fonctions (CONTAINSTABLE et FREETEXTTABLE). Il n'y a qu'un seul problĂšme: EF ne prend pas du tout en charge les requĂȘtes de texte intĂ©gral!


Je vais donner un exemple d'expérience réelle. Supposons que j'ai un tableau d'articles - Article, et que je crée pour lui une classe qui décrit ce tableau:


/// c# public partial class Article { public int Id { get; set; } public System.DateTime Date { get; set; } public string Text { get; set; } public bool Active { get; set; } } 

Ensuite, je dois faire une sélection de ces articles, disons, produire les 10 derniers articles publiés:


 /// c# dbEntities db = new dbEntities(); var articles = db.Article .Where(n => n.Active) .OrderByDescending(n => n.Date) .Take(10) .ToArray(); 

Tout est trĂšs beau jusqu'Ă  ce que la tĂąche d'ajout de recherche en texte intĂ©gral apparaisse. Comme il n'y a pas de prise en charge des fonctions de sĂ©lection de texte intĂ©gral dans EF (.NET core 2.1 l'a dĂ©jĂ  partiellement), il reste soit Ă  utiliser une bibliothĂšque tierce, soit Ă  Ă©crire une requĂȘte en SQL pur.


La requĂȘte SQL de l'exemple ci-dessus n'est pas si compliquĂ©e:


 SELECT TOP (10) [Extent1].[Id] AS [Id], [Extent1].[Date] AS [Date], [Extent1].[Text] AS [Text], [Extent1].[Active] AS [Active] FROM [dbo].[Article] AS [Extent1] WHERE [Extent1].[Active] = 1 ORDER BY [Extent1].[Date] DESC 

Dans les vrais projets, les choses ne sont pas si simples. Les requĂȘtes vers la base de donnĂ©es sont beaucoup plus compliquĂ©es et il est difficile de les gĂ©rer manuellement. Par consĂ©quent, la premiĂšre fois que j'ai Ă©crit une requĂȘte Ă  l'aide de LINQ, j'ai ensuite obtenu le texte gĂ©nĂ©rĂ© de la requĂȘte SQL dans la base de donnĂ©es et y ai dĂ©jĂ  introduit les conditions de sĂ©lection des donnĂ©es en texte intĂ©gral. Ensuite, je l'ai envoyĂ© Ă  db.Database.SqlQuery et reçu les donnĂ©es dont j'avais besoin. Tout cela est bien sĂ»r, tant que la demande n'a pas besoin de bloquer une douzaine de filtres diffĂ©rents avec des conditions et des conditions de participation complexes.


Donc - j'ai une douleur spécifique. Nous devons le résoudre!


A la recherche d'une solution


Une fois de plus, assis dans ma recherche prĂ©fĂ©rĂ©e dans l'espoir de trouver au moins une solution, je suis tombĂ© sur ce rĂ©fĂ©rentiel . Avec cette solution, la prise en charge des prĂ©dicats LINQ (CONTAINS et FREETEXT) peut ĂȘtre implĂ©mentĂ©e. GrĂące au support de EF 6 de l'interface spĂ©ciale IDbCommandInterceptor , qui vous permet d'intercepter la requĂȘte SQL terminĂ©e, cette solution a Ă©tĂ© implĂ©mentĂ©e avant de l'envoyer Ă  la base de donnĂ©es. Une chaĂźne de marqueurs gĂ©nĂ©rĂ©e spĂ©ciale est substituĂ©e dans le champ Contains , puis aprĂšs avoir gĂ©nĂ©rĂ© la demande, cet endroit est remplacĂ© par un prĂ©dicat Exemple:


 /// c# var text = FullTextSearchModelUtil.Contains("code"); db.Tables.Where(c=>c.Fullname.Contains(text)); 

Cependant, si la sĂ©lection des donnĂ©es doit ĂȘtre triĂ©e par le rang des correspondances, cette solution ne sera plus appropriĂ©e et vous devrez Ă©crire la requĂȘte SQL manuellement. En substance, cette solution remplace le LIKE habituel par une sĂ©lection de prĂ©dicats.


Donc, Ă  ce stade, j'avais une question: est-il possible d'implĂ©menter une vĂ©ritable recherche de texte intĂ©gral en utilisant les fonctions MS SQL intĂ©grĂ©es (CONTAINSTABLE et FREETEXTTABLE) afin que tout cela puisse ĂȘtre gĂ©nĂ©rĂ© via LINQ et mĂȘme avec un support pour trier la requĂȘte par le rang des correspondances? Il s'est avĂ©rĂ© que vous le pouvez!


Implémentation


Pour commencer, il Ă©tait nĂ©cessaire de dĂ©velopper la logique d'Ă©criture de la requĂȘte elle-mĂȘme Ă  l'aide de LINQ. Étant donnĂ© que dans les requĂȘtes SQL rĂ©elles avec des sĂ©lections de texte intĂ©gral, JOIN est le plus souvent utilisĂ© pour joindre une table virtuelle avec des rangs, j'ai dĂ©cidĂ© de suivre la mĂȘme voie dans la requĂȘte LINQ.


Voici un exemple d'une telle requĂȘte LINQ:


 /// c# var queryFts = db.FTS_Int.Where(n => n.Query.Contains(queryText)); var query = db.Article .Join(queryFts, article => article.Id, fts => fts.Key, (article, fts) => new { article.Id, article.Text, fts.Key, fts.Rank, }) .OrderByDescending(n => n.Rank); 

Un tel code n'a pas encore pu ĂȘtre compilĂ©, mais il a dĂ©jĂ  rĂ©solu visuellement le problĂšme du tri des donnĂ©es rĂ©sultantes par rang. Restait Ă  le mettre en pratique.


Classe supplémentaire FTS_Int utilisée dans cette demande:


 /// c# public partial class FTS_Int { public int Key { get; set; } public int Rank { get; set; } public string Query { get; set; } } 

Le nom n'a pas Ă©tĂ© choisi par hasard, car la colonne clĂ© de cette classe doit coĂŻncider en cochant avec la colonne clĂ© de la table de recherche (dans mon exemple, avec [Article].[Id] type int ). Au cas oĂč vous auriez besoin de faire des requĂȘtes sur d'autres tables avec d'autres types de colonnes clĂ©s, j'ai supposĂ© simplement copier une classe similaire et crĂ©er sa clĂ© du type nĂ©cessaire.


La condition pour la formation d'une requĂȘte de texte intĂ©gral Ă©tait censĂ©e ĂȘtre passĂ©e dans la variable queryText . Pour former le texte de cette variable, une fonction distincte a Ă©tĂ© implĂ©mentĂ©e:


 /// c# string queryText = FtsSearch.Query( dbContext: db, //   ,       ftsEnum: FtsEnum.CONTAINS, //  : CONTAINS  FREETEXT tableQuery: typeof(News), //       tableFts: typeof(FTS_Int), //    search: "text"); //    

RĂ©alisation d'une demande prĂȘte et acquisition de donnĂ©es:


 /// c# var result = FtsSearch.Execute(() => query.ToList()); 

La fonction finale de wrapper FtsSearch.Execute est utilisĂ©e pour connecter temporairement l'interface IDbCommandInterceptor . Dans l'exemple fourni par le lien ci-dessus, l'auteur a prĂ©fĂ©rĂ© utiliser l'algorithme de substitution de requĂȘte en permanence pour toutes les demandes. Par consĂ©quent, aprĂšs avoir connectĂ© le mĂ©canisme de remplacement de requĂȘte, chaque demande recherche la combinaison nĂ©cessaire pour le remplacement. Cette option m'a semblĂ© inutile, par consĂ©quent, l'exĂ©cution de la demande de donnĂ©es elle-mĂȘme est effectuĂ©e dans la fonction transmise qui, avant l'appel, connecte l'auto-remplacement de la requĂȘte et, aprĂšs l'appel, la dĂ©connecte.


Candidature


J'utilise la gĂ©nĂ©ration automatique de classes de modĂšles de donnĂ©es Ă  partir d'une base de donnĂ©es Ă  l'aide d'un fichier edmx. Comme vous ne pouvez tout simplement pas utiliser la classe FTS_Int créée dans EF en raison du manque de mĂ©tadonnĂ©es nĂ©cessaires dans DbContext , j'ai créé une vraie table selon son modĂšle (peut-ĂȘtre que quelqu'un connaĂźt une meilleure façon, je serai heureux de votre aide dans les commentaires):


Capture d'écran de la table créée dans le fichier edmx



 CREATE TABLE [dbo].[FTS_Int] ( [Key] INT NOT NULL, [Rank] INT NOT NULL, [Query] NVARCHAR (1) NOT NULL, CONSTRAINT [PK_FTS_Int] PRIMARY KEY CLUSTERED ([Key] ASC) ); 

AprÚs cela, lors de la mise à jour du fichier edmx à partir de la base de données, ajoutez la table créée et obtenez sa classe générée:


 /// c# public partial class FTS_Int { public int Key { get; set; } public int Rank { get; set; } public string Query { get; set; } } 

Aucune requĂȘte ne sera effectuĂ©e sur cette table; elle est uniquement nĂ©cessaire pour que les mĂ©tadonnĂ©es soient correctement formĂ©es pour crĂ©er la requĂȘte. Le dernier exemple d'utilisation d'une requĂȘte de base de donnĂ©es en texte intĂ©gral:


 /// c# string queryText = FtsSearch.Query( dbContext: db, ftsEnum: FtsEnum.CONTAINS, tableQuery: typeof(Article), tableFts: typeof(FTS_Int), search: "text"); var queryFts = db.FTS_Int.Where(n => n.Query.Contains(queryText)); var query = db.Article .Where(n => n.Active) .Join(queryFts, article => article.Id, fts => fts.Key, (article, fts) => new { article, fts.Rank, }) .OrderByDescending(n => n.Rank) .Take(10) .Select(n => n.article); var result = FtsSearch.Execute(() => query.ToList()); 

Il existe Ă©galement un support pour les requĂȘtes asynchrones:


 /// c# var result = await FtsSearch.ExecuteAsync(async () => await query.ToListAsync()); 

RequĂȘte SQL gĂ©nĂ©rĂ©e avant la correction automatique:


 SELECT TOP (10) [Project1].[Id] AS [Id], [Project1].[Date] AS [Date], [Project1].[Text] AS [Text], [Project1].[Active] AS [Active] FROM ( SELECT [Extent1].[Id] AS [Id], [Extent1].[Date] AS [Date], [Extent1].[Text] AS [Text], [Extent1].[Active] AS [Active], [Extent2].[Rank] AS [Rank] FROM [dbo].[Article] AS [Extent1] INNER JOIN [dbo].[FTS_Int] AS [Extent2] ON [Extent1].[Id] = [Extent2].[Key] WHERE ([Extent1].[Active] = 1) AND ([Extent2].[Query] LIKE @p__linq__0 ESCAPE N'~') ) AS [Project1] ORDER BY [Project1].[Rank] DESC 

RequĂȘte SQL gĂ©nĂ©rĂ©e aprĂšs correction automatique:


 SELECT TOP (10) [Project1].[Id] AS [Id], [Project1].[Date] AS [Date], [Project1].[Text] AS [Text], [Project1].[Active] AS [Active] FROM ( SELECT [Extent1].[Id] AS [Id], [Extent1].[Date] AS [Date], [Extent1].[Text] AS [Text], [Extent1].[Active] AS [Active], [Extent2].[Rank] AS [Rank] FROM [dbo].[Article] AS [Extent1] INNER JOIN CONTAINSTABLE([dbo].[Article],(*),'text') AS [Extent2] ON [Extent1].[Id] = [Extent2].[Key] WHERE ([Extent1].[Active] = 1) AND (1=1) ) AS [Project1] ORDER BY [Project1].[Rank] DESC 

Par défaut, la recherche en texte intégral fonctionne sur toutes les colonnes du tableau:


 CONTAINSTABLE([dbo].[Article],(*),'text') 

Si vous devez sélectionner uniquement certains champs, vous pouvez les spécifier dans le paramÚtre fields de la fonction FtsSearch.Query .


Total


Le résultat est la prise en charge de la recherche en texte intégral dans LINQ.


Les nuances de cette approche.


  1. Le paramĂštre de recherche dans la fonction FtsSearch.Query n'utilise aucun contrĂŽle ou wrapper pour se protĂ©ger contre l'injection SQL. La valeur de cette variable est transmise telle quelle au texte de la requĂȘte. Si vous avez des idĂ©es Ă  ce sujet, Ă©crivez dans les commentaires. J'ai utilisĂ© l'expression rĂ©guliĂšre habituelle qui supprime simplement tous les caractĂšres autres que les lettres et les chiffres.


  2. Vous devez Ă©galement prendre en compte les fonctionnalitĂ©s de crĂ©ation d'expressions pour les requĂȘtes de texte intĂ©gral. ParamĂštre pour fonctionner


     /*    */ CONTAINSTABLE([dbo].[News],(*),' ') 

    Il a un format non valide car MS SQL nécessite la séparation des mots par des littéraux logiques. Pour que la demande aboutisse, vous devez la corriger comme ceci:


     /*   */ CONTAINSTABLE([dbo].[News],(*),' and ') 

    ou modifiez la fonction d'échantillonnage des données


     /*   */ FREETEXTTABLE([dbo].[News],(*),' ') 

    Pour plus d'informations sur les fonctionnalitĂ©s de crĂ©ation de requĂȘtes, il est prĂ©fĂ©rable de se rĂ©fĂ©rer Ă  la documentation officielle .


  3. La journalisation standard avec cette solution ne fonctionne pas correctement. Un enregistreur spécial a été ajouté pour cela:


     /// c# db.Database.Log = (val) => Console.WriteLine(val); 

    Si vous regardez la requĂȘte gĂ©nĂ©rĂ©e dans la base de donnĂ©es, elle sera gĂ©nĂ©rĂ©e avant de traiter les fonctions de remplacement automatique.



Pendant les tests, j'ai vĂ©rifiĂ© des requĂȘtes plus complexes avec plusieurs sĂ©lections dans diffĂ©rentes tables et il n'y a eu aucun problĂšme.


Sources GitHub

Source: https://habr.com/ru/post/fr455160/


All Articles