أرغب في مشاركة عكاز في حل مشكلة عادية إلى حد ما: كيفية تكوين أصدقاء MSSQL بنص كامل للبحث باستخدام Entity Framework. الموضوع متخصص للغاية ، لكن يبدو لي أن هذا الأمر مهم اليوم. للمهتمين ، أطلب القط.
بدأ كل شيء بالألم
أقوم بتطوير مشاريع في C # (ASP.NET) وأحيانًا أكتب خدمات microservices. في معظم الحالات ، أستخدم قاعدة بيانات MSSQL للعمل مع البيانات. يستخدم Entity Framework كحلقة وصل بين قاعدة البيانات ومشروعي. مع EF ، أحصل على فرص وافرة للعمل مع البيانات ، وإنشاء الاستعلامات الصحيحة ، وتنظيم الحمل على الخادم. آلية LINQ السحرية تجسد ببساطة قدراتها. على مر السنين ، لم أعد أتخيل طرقًا أسرع وأكثر ملاءمة للعمل مع قاعدة البيانات. ولكن مثل أي ORM تقريبًا ، لدى EF عدد من العيوب. أولاً ، هذا أداء ، لكن هذا موضوع مقالة منفصلة. وثانياً ، تغطي إمكانيات قاعدة البيانات نفسها.
لدى MSSQL بحث نص كامل مضمن يعمل خارج المربع. لتنفيذ استعلامات النص الكامل ، يمكنك استخدام المسندات المدمجة (CONTAINS و FREETEXT) أو الدالات (CONTAINSTABLE و FREETEXTTABLE). هناك مشكلة واحدة فقط: EF لا يدعم استعلامات النص الكامل ، من الكلمة على الإطلاق!
سأقدم مثالاً من التجربة الحقيقية. افترض أن لدي جدول مقالات - مقالة ، وأنشئ فصلاً لها يصف هذا الجدول:
/// 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; } } 
ثم أحتاج إلى عمل مجموعة مختارة من هذه المقالات ، على سبيل المثال ، لإخراج آخر 10 مقالات منشورة:
 /// c# dbEntities db = new dbEntities(); var articles = db.Article .Where(n => n.Active) .OrderByDescending(n => n.Date) .Take(10) .ToArray(); 
كل شيء جميل للغاية حتى تظهر مهمة إضافة بحث النص الكامل. نظرًا لعدم وجود دعم لوظائف تحديد النص الكامل في EF (يحتوي .NET .NET 2.1 بالفعل جزئيًا على ذلك ) ، يبقى إما استخدام بعض مكتبة الجهات الخارجية أو كتابة استعلام في SQL خالص.
استعلام SQL من المثال أعلاه غير معقد للغاية:
 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 
في المشاريع الحقيقية ، الأمور ليست بهذه البساطة. استعلامات قاعدة البيانات أكثر تعقيدًا ومن الصعب الاحتفاظ بها يدويًا. نتيجة لذلك ، أول مرة كتبت فيها استعلامًا باستخدام LINQ ، ثم حصلت على النص الذي تم إنشاؤه لاستعلام SQL في قاعدة البيانات ، وأدخلت بالفعل شروط اختيار بيانات النص الكامل فيه. ثم أرسلتها إلى db.Database.SqlQuery وتلقيت البيانات التي db.Database.SqlQuery . هذا كل شيء جيد ، بالطبع ، طالما أن الطلب لا يحتاج إلى تعليق عشرات المرشحات المختلفة مع شروط انضمام معقدة لنا.
لذلك - لدي ألم محدد. يجب علينا حلها!
بحثا عن حل
مرة أخرى ، أثناء جلوسي في بحثي المفضل على أمل إيجاد حل على الأقل ، واجهت هذا المستودع . مع هذا الحل ، يمكن تنفيذ دعم LINQ الأصلي (CONTAINS و FREETEXT). بفضل دعم EF 6 لواجهة IDbCommandInterceptor الخاصة ، والتي تسمح لك باعتراض استعلام SQL النهائي ، تم تنفيذ هذا الحل قبل إرساله إلى قاعدة البيانات. يتم استبدال سلسلة علامة خاصة تم إنشاؤها في حقل Contains ، وبعد ذلك بعد إنشاء الطلب ، يتم استبدال هذا المكان بمثال أساسي:
 /// c# var text = FullTextSearchModelUtil.Contains("code"); db.Tables.Where(c=>c.Fullname.Contains(text)); 
ومع ذلك ، إذا كان اختيار البيانات بحاجة إلى فرز حسب ترتيب التطابقات ، فلن يكون هذا الحل مناسبًا بعد ذلك وسيكون عليك كتابة استعلام SQL يدويًا. في جوهرها ، يستبدل هذا الحل LIKE المعتاد باختيار أصلي.
لذا ، في هذه المرحلة ، كان لدي سؤال: هل من الممكن تنفيذ بحث نص كامل حقيقي باستخدام وظائف MS SQL المضمنة (CONTAINSTABLE و FREETEXTTABLE) بحيث يمكن إنشاء كل هذا عبر LINQ وحتى مع دعم فرز الاستعلام حسب ترتيب التطابقات؟ كما اتضح ، يمكنك!
تطبيق
بادئ ذي بدء ، كان من الضروري تطوير المنطق لكتابة الاستعلام نفسه باستخدام LINQ. نظرًا لأنه في استعلامات SQL الحقيقية مع تحديدات النص الكامل ، يتم استخدام JOIN غالبًا للانضمام إلى جدول افتراضي به صفوف ، فقد قررت الانتقال بنفس الطريقة في استعلام LINQ.
فيما يلي مثال لاستعلام 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); 
لا يمكن بعد تجميع مثل هذا الكود ، لكنه حل بصريًا بالفعل مشكلة فرز البيانات الناتجة حسب الترتيب. بقي لوضعها موضع التنفيذ.
فئة إضافية FTS_Int المستخدمة في هذا الطلب:
 /// c# public partial class FTS_Int { public int Key { get; set; } public int Rank { get; set; } public string Query { get; set; } } 
لم يتم اختيار الاسم عن طريق الصدفة ، نظرًا لأن عمود المفتاح في هذه الفئة يجب أن يتزامن مع عمود المفتاح في جدول البحث (في مثالي ، مع [Article].[Id] type int ). إذا احتجت إلى إجراء استعلامات على جداول أخرى مع أنواع أخرى من أعمدة المفاتيح ، فقد افترضت ببساطة نسخ فئة مماثلة وإنشاء مفتاح النوع المطلوب.
كان من المفترض أن يتم تمرير شرط تكوين استعلام النص الكامل في متغير queryText . لتكوين نص هذا المتغير ، تم تنفيذ وظيفة منفصلة:
 /// c# string queryText = FtsSearch.Query( dbContext: db, //   ,       ftsEnum: FtsEnum.CONTAINS, //  : CONTAINS  FREETEXT tableQuery: typeof(News), //       tableFts: typeof(FTS_Int), //    search: "text"); //    
تلبية طلب جاهز والحصول على البيانات:
 /// c# var result = FtsSearch.Execute(() => query.ToList()); 
يتم استخدام الدالة المجمّع FtsSearch.Execute النهائية للاتصال مؤقتاً واجهة IDbCommandInterceptor . في المثال المقدم من الرابط أعلاه ، فضل المؤلف استخدام خوارزمية استبدال الاستعلام باستمرار لجميع الطلبات. نتيجة لذلك ، بعد توصيل آلية استبدال الاستعلام ، يبحث كل طلب عن المجموعة الضرورية للاستبدال. بدا هذا الخيار مضيعة لي ، لذلك ، يتم تنفيذ طلب البيانات نفسه في الوظيفة المرسلة ، والتي قبل أن تتصل المكالمة بالاستعلام التلقائي للاستبدال ، وبعد المكالمة ، تقوم بفصله.
تطبيق
أنا أستخدم الإنشاء التلقائي لفئات نماذج البيانات من قاعدة بيانات باستخدام ملف edmx. نظرًا FTS_Int لا يمكنك ببساطة استخدام فئة FTS_Int تم إنشاؤها في EF نظرًا لعدم وجود البيانات DbContext اللازمة في DbContext ، فقد أنشأت جدولًا حقيقيًا وفقًا DbContext (ربما شخص ما يعرف طريقة أفضل ، سأكون سعيدًا بمساعدتك في التعليقات):
لقطة شاشة للجدول تم إنشاؤها في ملف 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) ); 
بعد ذلك ، عند تحديث ملف edmx من قاعدة البيانات ، أضف الجدول الذي تم إنشاؤه واحصل على الفئة التي تم إنشاؤها:
 /// c# public partial class FTS_Int { public int Key { get; set; } public int Rank { get; set; } public string Query { get; set; } } 
لن يتم تقديم أي استعلامات في هذا الجدول ؛ فهي مطلوبة فقط حتى يتم تكوين بيانات التعريف بشكل صحيح لإنشاء الاستعلام. المثال الأخير لاستخدام استعلام قاعدة بيانات النص الكامل:
 /// 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()); 
يوجد أيضًا دعم للطلبات غير المتزامنة:
 /// c# var result = await FtsSearch.ExecuteAsync(async () => await query.ToListAsync()); 
استعلام SQL الذي تم إنشاؤه قبل التصحيح التلقائي:
 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 
استعلام SQL الذي تم إنشاؤه بعد التصحيح التلقائي:
 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 
بشكل افتراضي ، يعمل البحث عن النص الكامل على جميع أعمدة الجدول:
 CONTAINSTABLE([dbo].[Article],(*),'text') 
إذا كنت بحاجة إلى تحديد حقول معينة فقط ، فيمكنك تحديدها في معلمة الحقول للدالة FtsSearch.Query .
في المجموع
والنتيجة هي دعم البحث عن النص الكامل في LINQ.
الفروق الدقيقة في هذا النهج.
- FtsSearch.Queryتستخدم المعلمة البحث في دالة- FtsSearch.Queryأو أغلفة للحماية من حقن SQL. يتم تمرير قيمة هذا المتغير كما هو في نص الطلب. إذا كان لديك أي أفكار حول هذا ، فاكتب في التعليقات. لقد استخدمت التعبير العادي المعتاد الذي يزيل كل الأحرف بخلاف الحروف والأرقام.
 
 
- تحتاج أيضًا إلى مراعاة ميزات إنشاء التعبيرات لاستعلامات النص الكامل. المعلمة للعمل 
 -  -  CONTAINSTABLE([dbo].[News],(*),' ')
 
 - يحتوي على تنسيق غير صالح لأن MS SQL يتطلب فصل الكلمات عن طريق الحرفي المنطقي. لاستكمال الطلب بنجاح ، تحتاج إلى إصلاح مثل هذا: 
 -  -  CONTAINSTABLE([dbo].[News],(*),' and ')
 
 - أو تغيير وظيفة استرجاع البيانات 
 -  -  FREETEXTTABLE([dbo].[News],(*),' ')
 
 - لمزيد من المعلومات حول ميزات إنشاء الاستعلامات ، من الأفضل الرجوع إلى الوثائق الرسمية . 
 
- التسجيل القياسي مع هذا الحل لا يعمل بشكل صحيح. تمت إضافة مسجل خاص لهذا: 
 -  - /// c# db.Database.Log = (val) => Console.WriteLine(val);
 
 - إذا نظرت إلى الاستعلام الذي تم إنشاؤه في قاعدة البيانات ، فسيتم إنشاؤه قبل معالجة وظائف الاستبدال التلقائي. 
 
أثناء الاختبار ، راجعت استعلامات أكثر تعقيدًا مع اختيارات متعددة من جداول مختلفة ولم تكن هناك أية مشكلات.
مصادر جيثب