diff --git a/doc/mapping-and-translation.md b/doc/mapping-and-translation.md index 3f9925c82..aedfd4cb9 100644 --- a/doc/mapping-and-translation.md +++ b/doc/mapping-and-translation.md @@ -49,3 +49,8 @@ Below are some Npgsql-specific translations, many additional standard ones are s | .Where(c => c.SomeArray.Length == 3) | [WHERE array_length("c"."SomeArray, 1) == 3](https://www.postgresql.org/docs/current/static/functions-array.html#ARRAY-FUNCTIONS-TABLE) | .Where(c => EF.Functions.Like(c.Name, "foo%") | [WHERE "c"."Name" LIKE 'foo%'](https://www.postgresql.org/docs/current/static/functions-matching.html#FUNCTIONS-LIKE) | .Where(c => EF.Functions.ILike(c.Name, "foo%") | [WHERE "c"."Name" ILIKE 'foo%'](https://www.postgresql.org/docs/current/static/functions-matching.html#FUNCTIONS-LIKE) (case-insensitive LIKE) +| .Select(c => EF.Functions.ToTsVector("english", c.Name)) | [SELECT to_tsvector('english'::regconfig, "c"."Name")](https://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-PARSING-DOCUMENTS) +| .Select(c => EF.Functions.ToTsQuery("english", "pgsql")) | [SELECT to_tsquery('english'::regconfig, 'pgsql')](https://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES) +| .Where(c => c.SearchVector.Matches("Npgsql")) | [WHERE "c"."SearchVector" @@ 'Npgsql'](https://www.postgresql.org/docs/current/static/textsearch-intro.html#TEXTSEARCH-MATCHING) +| .Select(c => EF.Functions.ToTsQuery(c.SearchQuery).ToNegative()) | [SELECT (!! to_tsquery("c"."SearchQuery"))](https://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSQUERY) +| .Select(c => EF.Functions.ToTsVector(c.Name).SetWeight(NpgsqlTsVector.Lexeme.Weight.A)) | [SELECT setweight(to_tsvector("c"."Name"), 'A')](https://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSVECTOR) \ No newline at end of file diff --git a/doc/migration/2.1.md b/doc/migration/2.1.md index 746e96b95..9f5f55a04 100644 --- a/doc/migration/2.1.md +++ b/doc/migration/2.1.md @@ -12,6 +12,7 @@ Aside from general EF Core features new in 2.1.0, the Npgsql provider contains t * Support PostgreSQL 10 sequences with type `int` and `smallint` ([#301](https://github.com/npgsql/Npgsql.EntityFrameworkCore.PostgreSQL/issues/301)). * Identifiers will only be quoted if needed ([#327](https://github.com/npgsql/Npgsql.EntityFrameworkCore.PostgreSQL/issues/327)), this should make the generated SQL much easier to read. * You can now specify the [tablespace](https://www.postgresql.org/docs/10/static/manage-ag-tablespaces.html) when creating your databases ([#332](https://github.com/npgsql/Npgsql.EntityFrameworkCore.PostgreSQL/issues/332)). +* You can now use the PostgreSQL Full Text Search functions and operators from LINQ queries (except for ```array_to_tsquery```, which is not possible to implement currently due to Entity Framework Core limitations). ```NpgsqlTsQuery``` and ```NpgsqlTsVector``` are now fully supported property types that will create ```tsquery``` and ```tsvector``` columns. Full text search functions are implemented as extensions on ```DbFunctions``` and both full text types. Raw SQL migrations are still needed to create and drop update triggers however. Here's the [full list of issues](https://github.com/npgsql/Npgsql.EntityFrameworkCore.PostgreSQL/milestone/8?closed=1). Please report any problems to https://github.com/npgsql/Npgsql.EntityFrameworkCore.PostgreSQL. diff --git a/src/EFCore.PG/Extensions/NpgsqlServiceCollectionExtensions.cs b/src/EFCore.PG/Extensions/NpgsqlServiceCollectionExtensions.cs index 29c6b95d5..ee76be5df 100644 --- a/src/EFCore.PG/Extensions/NpgsqlServiceCollectionExtensions.cs +++ b/src/EFCore.PG/Extensions/NpgsqlServiceCollectionExtensions.cs @@ -28,6 +28,7 @@ using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query.ExpressionTranslators; using Microsoft.EntityFrameworkCore.Query.ExpressionVisitors; +using Microsoft.EntityFrameworkCore.Query.Internal; using Microsoft.EntityFrameworkCore.Query.Sql; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Update; @@ -45,6 +46,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Update.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Utilities; using Npgsql.EntityFrameworkCore.PostgreSQL.ValueGeneration.Internal; +using Remotion.Linq.Parsing.ExpressionVisitors.TreeEvaluation; // ReSharper disable once CheckNamespace namespace Microsoft.Extensions.DependencyInjection @@ -107,6 +109,7 @@ public static IServiceCollection AddEntityFrameworkNpgsql([NotNull] this IServic .TryAdd() .TryAdd() .TryAdd(p => p.GetService()) + .TryAdd() .TryAddProviderSpecificServices(b => b .TryAddSingleton() .TryAddSingleton() diff --git a/src/EFCore.PG/FullTextSearch/NpgsqlFullTextSearchDbFunctionsExtensions.cs b/src/EFCore.PG/FullTextSearch/NpgsqlFullTextSearchDbFunctionsExtensions.cs new file mode 100644 index 000000000..7f8500ccc --- /dev/null +++ b/src/EFCore.PG/FullTextSearch/NpgsqlFullTextSearchDbFunctionsExtensions.cs @@ -0,0 +1,96 @@ +#region License +// The PostgreSQL License +// +// Copyright (C) 2016 The Npgsql Development Team +// +// Permission to use, copy, modify, and distribute this software and its +// documentation for any purpose, without fee, and without a written +// agreement is hereby granted, provided that the above copyright notice +// and this paragraph and the following two paragraphs appear in all copies. +// +// IN NO EVENT SHALL THE NPGSQL DEVELOPMENT TEAM BE LIABLE TO ANY PARTY +// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +// INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS +// DOCUMENTATION, EVEN IF THE NPGSQL DEVELOPMENT TEAM HAS BEEN ADVISED OF +// THE POSSIBILITY OF SUCH DAMAGE. +// +// THE NPGSQL DEVELOPMENT TEAM SPECIFICALLY DISCLAIMS ANY WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +// ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS +// TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +#endregion + +using System; +using System.Diagnostics.CodeAnalysis; +using NpgsqlTypes; + +namespace Microsoft.EntityFrameworkCore +{ + [SuppressMessage("ReSharper", "UnusedParameter.Global")] + public static class NpgsqlFullTextSearchDbFunctionsExtensions + { + /// + /// Reduce to tsvector. + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-PARSING-DOCUMENTS + /// + public static NpgsqlTsVector ToTsVector(this DbFunctions _, string document) => + throw new NotSupportedException(); + + /// + /// Reduce to tsvector using the text search configuration specified + /// by . + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-PARSING-DOCUMENTS + /// + public static NpgsqlTsVector ToTsVector(this DbFunctions _, string config, string document) => + throw new NotSupportedException(); + + /// + /// Produce tsquery from ignoring punctuation. + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static NpgsqlTsQuery PlainToTsQuery(this DbFunctions _, string query) => + throw new NotSupportedException(); + + /// + /// Produce tsquery from ignoring punctuation and using the text search + /// configuration specified by . + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static NpgsqlTsQuery PlainToTsQuery(this DbFunctions _, string config, string query) => + throw new NotSupportedException(); + + /// + /// Produce tsquery that searches for a phrase from ignoring punctuation. + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static NpgsqlTsQuery PhraseToTsQuery(this DbFunctions _, string query) => + throw new NotSupportedException(); + + /// + /// Produce tsquery that searches for a phrase from ignoring punctuation + /// and using the text search configuration specified by . + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static NpgsqlTsQuery PhraseToTsQuery(this DbFunctions _, string config, string query) => + throw new NotSupportedException(); + + /// + /// Normalize words in and convert to tsquery. If your input + /// contains punctuation that should not be treated as text search operators, use + /// instead. + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static NpgsqlTsQuery ToTsQuery(this DbFunctions _, string query) => throw new NotSupportedException(); + + /// + /// Normalize words in and convert to tsquery using the text search + /// configuration specified by . If your input contains punctuation + /// that should not be treated as text search operators, use + /// instead. + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static NpgsqlTsQuery ToTsQuery(this DbFunctions _, string config, string query) => + throw new NotSupportedException(); + } +} diff --git a/src/EFCore.PG/FullTextSearch/NpgsqlFullTextSearchLinqExtensions.cs b/src/EFCore.PG/FullTextSearch/NpgsqlFullTextSearchLinqExtensions.cs new file mode 100644 index 000000000..c3d64cec9 --- /dev/null +++ b/src/EFCore.PG/FullTextSearch/NpgsqlFullTextSearchLinqExtensions.cs @@ -0,0 +1,269 @@ +#region License +// The PostgreSQL License +// +// Copyright (C) 2016 The Npgsql Development Team +// +// Permission to use, copy, modify, and distribute this software and its +// documentation for any purpose, without fee, and without a written +// agreement is hereby granted, provided that the above copyright notice +// and this paragraph and the following two paragraphs appear in all copies. +// +// IN NO EVENT SHALL THE NPGSQL DEVELOPMENT TEAM BE LIABLE TO ANY PARTY +// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +// INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS +// DOCUMENTATION, EVEN IF THE NPGSQL DEVELOPMENT TEAM HAS BEEN ADVISED OF +// THE POSSIBILITY OF SUCH DAMAGE. +// +// THE NPGSQL DEVELOPMENT TEAM SPECIFICALLY DISCLAIMS ANY WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +// ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS +// TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +#endregion + +using System; +using System.Diagnostics.CodeAnalysis; +using NpgsqlTypes; + +namespace Microsoft.EntityFrameworkCore +{ + [SuppressMessage("ReSharper", "UnusedParameter.Global")] + public static class NpgsqlFullTextSearchLinqExtensions + { + /// + /// AND tsquerys together. Generates the "&&" operator. + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSQUERY + /// + public static NpgsqlTsQuery And(this NpgsqlTsQuery query1, NpgsqlTsQuery query2) => + throw new NotSupportedException(); + + /// + /// OR tsquerys together. Generates the "||" operator. + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSQUERY + /// + public static NpgsqlTsQuery Or(this NpgsqlTsQuery query1, NpgsqlTsQuery query2) => + throw new NotSupportedException(); + + /// + /// Negate a tsquery. Generates the "!!" operator. + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSQUERY + /// + public static NpgsqlTsQuery ToNegative(this NpgsqlTsQuery query) => + throw new NotSupportedException(); + + /// + /// Returns whether contains . + /// Generates the "@>" operator. + /// http://www.postgresql.org/docs/current/static/functions-textsearch.html + /// + public static bool Contains(this NpgsqlTsQuery query1, NpgsqlTsQuery query2) => + throw new NotSupportedException(); + + /// + /// Returns whether is contained within . + /// Generates the "<@" operator. + /// http://www.postgresql.org/docs/current/static/functions-textsearch.html + /// + public static bool IsContainedIn(this NpgsqlTsQuery query1, NpgsqlTsQuery query2) => + throw new NotSupportedException(); + + /// + /// Returns the number of lexemes plus operators in . + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSQUERY + /// + public static int GetNodeCount(this NpgsqlTsQuery query) => throw new NotSupportedException(); + + /// + /// Get the indexable part of . + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSQUERY + /// + public static string GetQueryTree(this NpgsqlTsQuery query) => throw new NotSupportedException(); + + /// + /// Returns a string suitable for display containing a query match. + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-HEADLINE + /// + public static string GetResultHeadline(this NpgsqlTsQuery query, string document) => + throw new NotSupportedException(); + + /// + /// Returns a string suitable for display containing a query match. + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-HEADLINE + /// + public static string GetResultHeadline(this NpgsqlTsQuery query, string document, string options) => + throw new NotSupportedException(); + + /// + /// Returns a string suitable for display containing a query match using the text + /// search configuration specified by . + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-HEADLINE + /// + public static string GetResultHeadline( + this NpgsqlTsQuery query, + string config, + string document, + string options) => throw new NotSupportedException(); + + /// + /// Searchs for occurrences of , and replaces + /// each occurrence with a . All parameters are of type tsquery. + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSQUERY + /// + public static NpgsqlTsQuery Rewrite(this NpgsqlTsQuery query, NpgsqlTsQuery target, NpgsqlTsQuery substitute) => + throw new NotSupportedException(); + + /// + /// Returns a tsquery that searches for a match to followed by a match + /// to . + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSQUERY + /// + public static NpgsqlTsQuery ToPhrase(this NpgsqlTsQuery query1, NpgsqlTsQuery query2) => + throw new NotSupportedException(); + + /// + /// Returns a tsquery that searches for a match to followed by a match + /// to at a distance of lexemes using + /// the <N> tsquery operator + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSQUERY + /// + public static NpgsqlTsQuery ToPhrase(this NpgsqlTsQuery query1, NpgsqlTsQuery query2, int distance) => + throw new NotSupportedException(); + + /// + /// This method generates the "@@" match operator. The parameter is + /// assumed to be a plain search query and will be converted to a tsquery using plainto_tsquery. + /// http://www.postgresql.org/docs/current/static/textsearch-intro.html#TEXTSEARCH-MATCHING + /// + public static bool Matches(this NpgsqlTsVector vector, string query) => throw new NotSupportedException(); + + /// + /// This method generates the "@@" match operator. + /// http://www.postgresql.org/docs/current/static/textsearch-intro.html#TEXTSEARCH-MATCHING + /// + public static bool Matches(this NpgsqlTsVector vector, NpgsqlTsQuery query) => + throw new NotSupportedException(); + + /// + /// Assign weight to each element of and return a new + /// weighted tsvector. + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSVECTOR + /// + public static NpgsqlTsVector SetWeight(this NpgsqlTsVector vector, NpgsqlTsVector.Lexeme.Weight weight) => + throw new NotSupportedException(); + + /// + /// Assign weight to elements of that are in and + /// return a new weighted tsvector. + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSVECTOR + /// + public static NpgsqlTsVector SetWeight( + this NpgsqlTsVector vector, + NpgsqlTsVector.Lexeme.Weight weight, + string[] lexemes) => + throw new NotSupportedException(); + + /// + /// Assign weight to each element of and return a new + /// weighted tsvector. + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSVECTOR + /// + public static NpgsqlTsVector SetWeight(this NpgsqlTsVector vector, char weight) => + throw new NotSupportedException(); + + /// + /// Assign weight to elements of that are in and + /// return a new weighted tsvector. + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSVECTOR + /// + public static NpgsqlTsVector SetWeight(this NpgsqlTsVector vector, char weight, string[] lexemes) => + throw new NotSupportedException(); + + /// + /// Returns the number of lexemes in . + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSVECTOR + /// + public static int GetLength(this NpgsqlTsVector vector) => throw new NotSupportedException(); + + /// + /// Removes weights and positions from and returns + /// a new stripped tsvector. + /// http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-MANIPULATE-TSVECTOR + /// + public static NpgsqlTsVector ToStripped(this NpgsqlTsVector vector) => throw new NotSupportedException(); + + /// + /// Calculates the rank of for . + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-RANKING + /// + public static float Rank(this NpgsqlTsVector vector, NpgsqlTsQuery query) => throw new NotSupportedException(); + + /// + /// Calculates the rank of for while normalizing + /// the result according to the behaviors specified by . + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-RANKING + /// + public static float Rank( + this NpgsqlTsVector vector, + NpgsqlTsQuery query, + NpgsqlTsRankingNormalization normalization) => throw new NotSupportedException(); + + /// + /// Calculates the rank of for with custom + /// weighting for word instances depending on their labels (D, C, B or A). + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-RANKING + /// + public static float Rank(this NpgsqlTsVector vector, float[] weights, NpgsqlTsQuery query) => + throw new NotSupportedException(); + + /// + /// Calculates the rank of for while normalizing + /// the result according to the behaviors specified by + /// and using custom weighting for word instances depending on their labels (D, C, B or A). + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-RANKING + /// + public static float Rank( + this NpgsqlTsVector vector, + float[] weights, + NpgsqlTsQuery query, + NpgsqlTsRankingNormalization normalization) => throw new NotSupportedException(); + + /// + /// Calculates the rank of for using the cover + /// density method. + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-RANKING + /// + public static float RankCoverDensity(this NpgsqlTsVector vector, NpgsqlTsQuery query) => + throw new NotSupportedException(); + + /// + /// Calculates the rank of for using the cover + /// density method while normalizing the result according to the behaviors specified by + /// . + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-RANKING + /// + public static float RankCoverDensity( + this NpgsqlTsVector vector, + NpgsqlTsQuery query, + NpgsqlTsRankingNormalization normalization) => throw new NotSupportedException(); + + /// + /// Calculates the rank of for using the cover + /// density method with custom weighting for word instances depending on their labels (D, C, B or A). + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-RANKING + /// + public static float RankCoverDensity(this NpgsqlTsVector vector, float[] weights, NpgsqlTsQuery query) => + throw new NotSupportedException(); + + /// + /// Calculates the rank of for using the cover density + /// method while normalizing the result according to the behaviors specified by + /// and using custom weighting for word instances depending on their labels (D, C, B or A). + /// http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-RANKING + /// + public static float RankCoverDensity( + this NpgsqlTsVector vector, + float[] weights, + NpgsqlTsQuery query, + NpgsqlTsRankingNormalization normalization) => throw new NotSupportedException(); + } +} diff --git a/src/EFCore.PG/FullTextSearch/NpgsqlTsRankingNormalization.cs b/src/EFCore.PG/FullTextSearch/NpgsqlTsRankingNormalization.cs new file mode 100644 index 000000000..4fe2490bf --- /dev/null +++ b/src/EFCore.PG/FullTextSearch/NpgsqlTsRankingNormalization.cs @@ -0,0 +1,75 @@ +#region License +// The PostgreSQL License +// +// Copyright (C) 2016 The Npgsql Development Team +// +// Permission to use, copy, modify, and distribute this software and its +// documentation for any purpose, without fee, and without a written +// agreement is hereby granted, provided that the above copyright notice +// and this paragraph and the following two paragraphs appear in all copies. +// +// IN NO EVENT SHALL THE NPGSQL DEVELOPMENT TEAM BE LIABLE TO ANY PARTY +// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +// INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS +// DOCUMENTATION, EVEN IF THE NPGSQL DEVELOPMENT TEAM HAS BEEN ADVISED OF +// THE POSSIBILITY OF SUCH DAMAGE. +// +// THE NPGSQL DEVELOPMENT TEAM SPECIFICALLY DISCLAIMS ANY WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +// ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS +// TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +#endregion + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.EntityFrameworkCore +{ + /// + /// Specifies whether and how a document's length should impact its rank. + /// This is used with the ranking functions in . + /// + /// See http://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-RANKING + /// for more information about the behaviors that are controlled by this value. + /// + [Flags] + [SuppressMessage("ReSharper", "UnusedMember.Global")] + public enum NpgsqlTsRankingNormalization + { + /// + /// Ignores the document length. + /// + Default = 0, + + /// + /// Divides the rank by 1 + the logarithm of the document length. + /// + DivideBy1PlusLogLength = 1, + + /// + /// Divides the rank by the document length. + /// + DivideByLength = 2, + + /// + /// Divides the rank by the mean harmonic distance between extents (this is implemented only by ts_rank_cd). + /// + DivideByMeanHarmonicDistanceBetweenExtents = 4, + + /// + /// Divides the rank by the number of unique words in document. + /// + DivideByUniqueWordCount = 8, + + /// + /// Divides the rank by 1 + the logarithm of the number of unique words in document. + /// + DividesBy1PlusLogUniqueWordCount = 16, + + /// + /// Divides the rank by itself + 1. + /// + DivideByItselfPlusOne = 32 + } +} diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlCompositeMethodCallTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlCompositeMethodCallTranslator.cs index 46766496f..deb52b380 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlCompositeMethodCallTranslator.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlCompositeMethodCallTranslator.cs @@ -23,6 +23,7 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore.Query.ExpressionTranslators; +using Microsoft.EntityFrameworkCore.Query.ExpressionTranslators.Internal; namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.ExpressionTranslators.Internal { @@ -47,7 +48,8 @@ public class NpgsqlCompositeMethodCallTranslator : RelationalCompositeMethodCall new NpgsqlStringTrimTranslator(), new NpgsqlStringTrimEndTranslator(), new NpgsqlStringTrimStartTranslator(), - new NpgsqlRegexIsMatchTranslator() + new NpgsqlRegexIsMatchTranslator(), + new NpgsqlFullTextSearchMethodTranslator() }; public NpgsqlCompositeMethodCallTranslator( diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlFullTextSearchMethodTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlFullTextSearchMethodTranslator.cs new file mode 100644 index 000000000..468bb2966 --- /dev/null +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlFullTextSearchMethodTranslator.cs @@ -0,0 +1,266 @@ +#region License +// The PostgreSQL License +// +// Copyright (C) 2016 The Npgsql Development Team +// +// Permission to use, copy, modify, and distribute this software and its +// documentation for any purpose, without fee, and without a written +// agreement is hereby granted, provided that the above copyright notice +// and this paragraph and the following two paragraphs appear in all copies. +// +// IN NO EVENT SHALL THE NPGSQL DEVELOPMENT TEAM BE LIABLE TO ANY PARTY +// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +// INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS +// DOCUMENTATION, EVEN IF THE NPGSQL DEVELOPMENT TEAM HAS BEEN ADVISED OF +// THE POSSIBILITY OF SUCH DAMAGE. +// +// THE NPGSQL DEVELOPMENT TEAM SPECIFICALLY DISCLAIMS ANY WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +// ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS +// TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +#endregion + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.EntityFrameworkCore.Query.Expressions; +using Microsoft.EntityFrameworkCore.Query.Expressions.Internal; +using NpgsqlTypes; + +namespace Microsoft.EntityFrameworkCore.Query.ExpressionTranslators.Internal +{ + public class NpgsqlFullTextSearchMethodTranslator : IMethodCallTranslator + { + static readonly MethodInfo _tsQueryParse = typeof(NpgsqlTsQuery).GetMethod( + nameof(NpgsqlTsQuery.Parse), + BindingFlags.Public | BindingFlags.Static); + + static readonly MethodInfo _tsVectorParse = typeof(NpgsqlTsVector).GetMethod( + nameof(NpgsqlTsVector.Parse), + BindingFlags.Public | BindingFlags.Static); + + static readonly IReadOnlyDictionary _sqlNameByMethodName = + new Dictionary + { + [nameof(NpgsqlFullTextSearchDbFunctionsExtensions.ToTsVector)] = "to_tsvector", + [nameof(NpgsqlFullTextSearchDbFunctionsExtensions.PlainToTsQuery)] = "plainto_tsquery", + [nameof(NpgsqlFullTextSearchDbFunctionsExtensions.PhraseToTsQuery)] = "phraseto_tsquery", + [nameof(NpgsqlFullTextSearchDbFunctionsExtensions.ToTsQuery)] = "to_tsquery" + }; + + public Expression Translate(MethodCallExpression methodCallExpression) + { + if (methodCallExpression.Method == _tsQueryParse || methodCallExpression.Method == _tsVectorParse) + return new ExplicitCastExpression( + methodCallExpression.Arguments[0], + methodCallExpression.Method.ReturnType); + + if (methodCallExpression.Method.DeclaringType == typeof(NpgsqlFullTextSearchDbFunctionsExtensions) + && _sqlNameByMethodName.TryGetValue(methodCallExpression.Method.Name, out var sqlFunctionName)) + return new SqlFunctionExpression( + sqlFunctionName, + methodCallExpression.Method.ReturnType, + methodCallExpression.Arguments.Skip(1)); + + if (methodCallExpression.Method.DeclaringType == typeof(NpgsqlFullTextSearchLinqExtensions)) + return TryTranslateOperator(methodCallExpression) ?? TryTranslateFunction(methodCallExpression); + + return null; + } + + static Expression TryTranslateOperator(MethodCallExpression methodCallExpression) + { + switch (methodCallExpression.Method.Name) + { + case nameof(NpgsqlFullTextSearchLinqExtensions.And): + return FullTextSearchExpression.TsQueryAnd( + methodCallExpression.Arguments[0], + methodCallExpression.Arguments[1]); + + case nameof(NpgsqlFullTextSearchLinqExtensions.Or): + return FullTextSearchExpression.TsQueryOr( + methodCallExpression.Arguments[0], + methodCallExpression.Arguments[1]); + + case nameof(NpgsqlFullTextSearchLinqExtensions.ToNegative): + return FullTextSearchExpression.TsQueryNegate(methodCallExpression.Arguments[0]); + + case nameof(NpgsqlFullTextSearchLinqExtensions.Contains): + return FullTextSearchExpression.TsQueryContains( + methodCallExpression.Arguments[0], + methodCallExpression.Arguments[1]); + + case nameof(NpgsqlFullTextSearchLinqExtensions.IsContainedIn): + return FullTextSearchExpression.TsQueryIsContainedIn( + methodCallExpression.Arguments[0], + methodCallExpression.Arguments[1]); + + case nameof(NpgsqlFullTextSearchLinqExtensions.Matches): + return FullTextSearchExpression.TsVectorMatches( + methodCallExpression.Arguments[0], + methodCallExpression.Arguments[1]); + + default: + return null; + } + } + + static Expression TryTranslateFunction(MethodCallExpression methodCallExpression) + { + switch (methodCallExpression.Method.Name) + { + case nameof(NpgsqlFullTextSearchLinqExtensions.GetNodeCount): + return new SqlFunctionExpression( + "numnode", + methodCallExpression.Method.ReturnType, + methodCallExpression.Arguments); + + case nameof(NpgsqlFullTextSearchLinqExtensions.GetQueryTree): + return new SqlFunctionExpression( + "querytree", + methodCallExpression.Method.ReturnType, + methodCallExpression.Arguments); + + case nameof(NpgsqlFullTextSearchLinqExtensions.GetResultHeadline): + var tsheadlineFunctionName = "ts_headline"; + switch (methodCallExpression.Arguments.Count) + { + case 2: + return new SqlFunctionExpression( + tsheadlineFunctionName, + methodCallExpression.Method.ReturnType, + methodCallExpression.Arguments.Reverse()); + + case 3: + return new SqlFunctionExpression( + tsheadlineFunctionName, + methodCallExpression.Method.ReturnType, + new[] + { + methodCallExpression.Arguments[1], + methodCallExpression.Arguments[0], + methodCallExpression.Arguments[2] + }); + + case 4: + return new SqlFunctionExpression( + tsheadlineFunctionName, + methodCallExpression.Method.ReturnType, + new[] + { + methodCallExpression.Arguments[1], + methodCallExpression.Arguments[2], + methodCallExpression.Arguments[0], + methodCallExpression.Arguments[3] + }); + + default: + throw new ArgumentException( + $"Invalid method overload for {tsheadlineFunctionName}", + nameof(methodCallExpression)); + } + + case nameof(NpgsqlFullTextSearchLinqExtensions.Rewrite): + return new SqlFunctionExpression( + "ts_rewrite", + methodCallExpression.Method.ReturnType, + methodCallExpression.Arguments); + + case nameof(NpgsqlFullTextSearchLinqExtensions.ToPhrase): + return new SqlFunctionExpression( + "tsquery_phrase", + methodCallExpression.Method.ReturnType, + methodCallExpression.Arguments); + + case nameof(NpgsqlFullTextSearchLinqExtensions.SetWeight): + var arguments = methodCallExpression.Arguments.ToArray(); + + if (arguments[1].Type == typeof(NpgsqlTsVector.Lexeme.Weight)) + { + if (!(arguments[1] is ConstantExpression weightExpression)) + { + throw new ArgumentException( + "Enum 'weight' argument for 'SetWeight' must be a constant expression."); + } + + arguments[1] = Expression.Constant(weightExpression.Value.ToString()[0]); + } + + return new SqlFunctionExpression( + "setweight", + methodCallExpression.Method.ReturnType, + arguments); + + case nameof(NpgsqlFullTextSearchLinqExtensions.GetLength): + return new SqlFunctionExpression( + "length", + methodCallExpression.Method.ReturnType, + methodCallExpression.Arguments); + + case nameof(NpgsqlFullTextSearchLinqExtensions.ToStripped): + return new SqlFunctionExpression( + "strip", + methodCallExpression.Method.ReturnType, + methodCallExpression.Arguments); + + case nameof(NpgsqlFullTextSearchLinqExtensions.Rank): + case nameof(NpgsqlFullTextSearchLinqExtensions.RankCoverDensity): + var rankFunctionName = methodCallExpression.Method.Name == nameof(NpgsqlFullTextSearchLinqExtensions.Rank) + ? "ts_rank" + : "ts_rank_cd"; + + switch (methodCallExpression.Arguments.Count) + { + case 2: + return new SqlFunctionExpression( + rankFunctionName, + methodCallExpression.Method.ReturnType, + methodCallExpression.Arguments); + + case 3: + var firstArgument = methodCallExpression.Arguments[0]; + var secondArgument = methodCallExpression.Arguments[1]; + if (methodCallExpression.Arguments[1].Type == typeof(float[])) + { + var temp = firstArgument; + firstArgument = secondArgument; + secondArgument = temp; + } + + return new SqlFunctionExpression( + rankFunctionName, + methodCallExpression.Method.ReturnType, + new[] + { + firstArgument, + secondArgument, + methodCallExpression.Arguments[2] + }); + + case 4: + return new SqlFunctionExpression( + rankFunctionName, + methodCallExpression.Method.ReturnType, + new[] + { + methodCallExpression.Arguments[1], + methodCallExpression.Arguments[0], + methodCallExpression.Arguments[2], + methodCallExpression.Arguments[3] + }); + + default: + throw new ArgumentException( + $"Invalid method overload for {rankFunctionName}", + nameof(methodCallExpression)); + } + + default: + return null; + } + } + } +} diff --git a/src/EFCore.PG/Query/Expressions/Internal/FullTextSearchExpression.cs b/src/EFCore.PG/Query/Expressions/Internal/FullTextSearchExpression.cs new file mode 100644 index 000000000..1fbf6d676 --- /dev/null +++ b/src/EFCore.PG/Query/Expressions/Internal/FullTextSearchExpression.cs @@ -0,0 +1,118 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using JetBrains.Annotations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Sql.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Utilities; +using NpgsqlTypes; + +namespace Microsoft.EntityFrameworkCore.Query.Expressions.Internal +{ + public class FullTextSearchExpression : Expression + { + private FullTextSearchExpression( + string binaryOperator, + [NotNull] Expression left, + [CanBeNull] Expression right, + [NotNull] Type type) + { + Check.NotEmpty(binaryOperator, nameof(binaryOperator)); + Check.NotNull(left, nameof(left)); + Check.NotNull(type, nameof(type)); + + Operator = binaryOperator; + Left = left; + Right = right; + Type = type; + } + + public string Operator { get; } + public Expression Left { get; } + public Expression Right { get; } + public override Type Type { get; } + public override ExpressionType NodeType => ExpressionType.Extension; + + protected override Expression Accept(ExpressionVisitor visitor) + { + Check.NotNull(visitor, nameof(visitor)); + + return visitor is NpgsqlQuerySqlGenerator npgsqlGenerator + ? npgsqlGenerator.VisitFullTextSearch(this) + : base.Accept(visitor); + } + + protected override Expression VisitChildren(ExpressionVisitor visitor) + { + var newLeft = visitor.Visit(Left); + var newRight = Right != null ? visitor.Visit(Right) : null; + + return newLeft != Left + || newRight != Right + ? new FullTextSearchExpression(Operator, newLeft, newRight, Type) + : this; + } + + public override string ToString() => Right != null + ? $"{Left} {Operator} {Right}" + : $"{Operator} {Left}"; + + public static FullTextSearchExpression TsQueryAnd([NotNull] Expression left, [NotNull] Expression right) + { + ValidateArguments(left, typeof(NpgsqlTsQuery), right, typeof(NpgsqlTsQuery)); + return new FullTextSearchExpression("&&", left, right, typeof(NpgsqlTsQuery)); + } + + public static FullTextSearchExpression TsQueryOr([NotNull] Expression left, [NotNull] Expression right) + { + ValidateArguments(left, typeof(NpgsqlTsQuery), right, typeof(NpgsqlTsQuery)); + return new FullTextSearchExpression("||", left, right, typeof(NpgsqlTsQuery)); + } + + public static FullTextSearchExpression TsQueryNegate([NotNull] Expression expression) + { + ValidateArguments(expression, typeof(NpgsqlTsQuery), null); + return new FullTextSearchExpression("!!", expression, null, typeof(NpgsqlTsQuery)); + } + + public static FullTextSearchExpression TsQueryContains([NotNull] Expression left, [NotNull] Expression right) + { + ValidateArguments(left, typeof(NpgsqlTsQuery), right, typeof(NpgsqlTsQuery)); + return new FullTextSearchExpression("@>", left, right, typeof(bool)); + } + + public static FullTextSearchExpression TsQueryIsContainedIn([NotNull] Expression left, [NotNull] Expression right) + { + ValidateArguments(left, typeof(NpgsqlTsQuery), right, typeof(NpgsqlTsQuery)); + return new FullTextSearchExpression("<@", left, right, typeof(bool)); + } + + public static FullTextSearchExpression TsVectorMatches([NotNull] Expression left, [NotNull] Expression right) + { + ValidateArguments(left, typeof(NpgsqlTsVector), right, typeof(NpgsqlTsQuery), typeof(string)); + return new FullTextSearchExpression("@@", left, right, typeof(bool)); + } + + private static void ValidateArguments( + [NotNull] Expression left, + [NotNull] Type validLeftType, + Expression right, + params Type[] validRightTypes) + { + Check.NotNull(left, nameof(left)); + Check.NotNull(validLeftType, nameof(validLeftType)); + + if (left.Type != validLeftType) + throw new ArgumentException( + $"Expression must be of type {validLeftType.FullName}", + nameof(left)); + + if (validRightTypes == null || validRightTypes.Length == 0) return; + + Check.NotNull(right, nameof(right)); + if (validRightTypes.All(x => right.Type != x)) + throw new ArgumentException( + $"Expression must be of one of types: {string.Join(", ", validRightTypes.Select(x => x.FullName))}", + nameof(right)); + } + } +} diff --git a/src/EFCore.PG/Query/Internal/NpgsqlEvaluatableExpressionFilter.cs b/src/EFCore.PG/Query/Internal/NpgsqlEvaluatableExpressionFilter.cs new file mode 100644 index 000000000..843f88cd3 --- /dev/null +++ b/src/EFCore.PG/Query/Internal/NpgsqlEvaluatableExpressionFilter.cs @@ -0,0 +1,30 @@ +using System.Linq.Expressions; +using System.Reflection; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Query.Internal; +using NpgsqlTypes; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal +{ + public class NpgsqlEvaluatableExpressionFilter : RelationalEvaluatableExpressionFilter + { + static readonly MethodInfo _tsQueryParse = typeof(NpgsqlTsQuery).GetMethod( + nameof(NpgsqlTsQuery.Parse), + BindingFlags.Public | BindingFlags.Static); + + static readonly MethodInfo _tsVectorParse = typeof(NpgsqlTsVector).GetMethod( + nameof(NpgsqlTsVector.Parse), + BindingFlags.Public | BindingFlags.Static); + + public NpgsqlEvaluatableExpressionFilter([NotNull] IModel model) : base(model) { } + + public override bool IsEvaluatableMethodCall(MethodCallExpression methodCallExpression) => + methodCallExpression.Method != _tsQueryParse + && methodCallExpression.Method != _tsVectorParse + && methodCallExpression.Method.DeclaringType != typeof(NpgsqlFullTextSearchDbFunctionsExtensions) + && methodCallExpression.Method.DeclaringType != typeof(NpgsqlFullTextSearchLinqExtensions) + && base.IsEvaluatableMethodCall(methodCallExpression); + } +} diff --git a/src/EFCore.PG/Query/Sql/Internal/NpgsqlQuerySqlGenerator.cs b/src/EFCore.PG/Query/Sql/Internal/NpgsqlQuerySqlGenerator.cs index ba6775e56..96abde428 100644 --- a/src/EFCore.PG/Query/Sql/Internal/NpgsqlQuerySqlGenerator.cs +++ b/src/EFCore.PG/Query/Sql/Internal/NpgsqlQuerySqlGenerator.cs @@ -27,6 +27,7 @@ using System.Text.RegularExpressions; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore.Query.Expressions; +using Microsoft.EntityFrameworkCore.Query.Expressions.Internal; using Microsoft.EntityFrameworkCore.Query.Sql; using Microsoft.EntityFrameworkCore.Storage; using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions; @@ -314,5 +315,29 @@ protected override void GenerateOrdering([NotNull] Ordering ordering) if (_nullFirstOrderingEnabled) Sql.Append(" NULLS FIRST"); } + + public virtual Expression VisitFullTextSearch(FullTextSearchExpression expression) + { + Check.NotNull(expression, nameof(expression)); + + Sql.Append("("); + if (expression.Right != null) + { + Visit(expression.Left); + Sql.Append(" "); + Sql.Append(expression.Operator); + Sql.Append(" "); + Visit(expression.Right); + } + else + { + Sql.Append(expression.Operator); + Sql.Append(" "); + Visit(expression.Left); + } + Sql.Append(")"); + + return expression; + } } } diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlTsQueryTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlTsQueryTypeMapping.cs new file mode 100644 index 000000000..0b0d493aa --- /dev/null +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlTsQueryTypeMapping.cs @@ -0,0 +1,25 @@ +using System.Text; +using Npgsql.EntityFrameworkCore.PostgreSQL.Utilities; +using NpgsqlTypes; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping +{ + public class NpgsqlTsQueryTypeMapping : NpgsqlTypeMapping + { + public NpgsqlTsQueryTypeMapping() : base("tsquery", typeof(NpgsqlTsQuery), NpgsqlDbType.TsQuery) { } + + protected override string GenerateNonNullSqlLiteral(object value) + { + Check.NotNull(value, nameof(value)); + var query = (NpgsqlTsQuery)value; + var builder = new StringBuilder(); + builder.Append("TSQUERY "); + var indexOfFirstQuote = builder.Length - 1; + query.Write(builder, true); + builder.Replace("'", "''"); + builder[indexOfFirstQuote] = '\''; + builder.Append("'"); + return builder.ToString(); + } + } +} diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlTsRankingNormalizationTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlTsRankingNormalizationTypeMapping.cs new file mode 100644 index 000000000..5e5ed2600 --- /dev/null +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlTsRankingNormalizationTypeMapping.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NpgsqlTypes; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping +{ + public class NpgsqlTsRankingNormalizationTypeMapping : NpgsqlTypeMapping + { + public NpgsqlTsRankingNormalizationTypeMapping() : base( + "integer", + typeof(NpgsqlTsRankingNormalization), + new EnumToNumberConverter(), + null, + null, + NpgsqlDbType.Integer) { } + } +} diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlTsVectorMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlTsVectorMapping.cs new file mode 100644 index 000000000..7bbb0fb9c --- /dev/null +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlTsVectorMapping.cs @@ -0,0 +1,25 @@ +using System.Text; +using Npgsql.EntityFrameworkCore.PostgreSQL.Utilities; +using NpgsqlTypes; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping +{ + public class NpgsqlTsVectorTypeMapping : NpgsqlTypeMapping + { + public NpgsqlTsVectorTypeMapping() : base("tsvector", typeof(NpgsqlTsVector), NpgsqlDbType.TsVector) { } + + protected override string GenerateNonNullSqlLiteral(object value) + { + Check.NotNull(value, nameof(value)); + var vector = (NpgsqlTsVector)value; + var builder = new StringBuilder(); + builder.Append("TSVECTOR "); + var indexOfFirstQuote = builder.Length - 1; + builder.Append(vector); + builder.Replace("'", "''"); + builder[indexOfFirstQuote] = '\''; + builder.Append("'"); + return builder.ToString(); + } + } +} diff --git a/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs b/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs index e7f32c8db..8fc7b37f2 100644 --- a/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs +++ b/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs @@ -31,9 +31,8 @@ using System.Net.NetworkInformation; using System.Reflection; using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping; using NpgsqlTypes; @@ -90,6 +89,11 @@ public class NpgsqlTypeMappingSource : RelationalTypeMappingSource readonly NpgsqlCidTypeMapping _cid = new NpgsqlCidTypeMapping(); readonly NpgsqlRegtypeTypeMapping _regtype = new NpgsqlRegtypeTypeMapping(); + // Full text search mappings + readonly NpgsqlTsQueryTypeMapping _tsquery = new NpgsqlTsQueryTypeMapping(); + readonly NpgsqlTsVectorTypeMapping _tsvector = new NpgsqlTsVectorTypeMapping(); + readonly NpgsqlTsRankingNormalizationTypeMapping _rankingNormalization = new NpgsqlTsRankingNormalizationTypeMapping(); + // Range mappings readonly NpgsqlRangeTypeMapping _int4range; readonly NpgsqlRangeTypeMapping _int8range; @@ -179,7 +183,10 @@ public NpgsqlTypeMappingSource([NotNull] TypeMappingSourceDependencies dependenc { "numrange", new[] { _numrange } }, { "tsrange", new[] { _tsrange } }, { "tstzrange", new[] { _tstzrange } }, - { "daterange", new[] { _daterange } } + { "daterange", new[] { _daterange } }, + + { "tsquery", new[] { _tsquery } }, + { "tsvector", new[] { _tsvector } } }; var clrTypeMappings = new Dictionary @@ -213,7 +220,11 @@ public NpgsqlTypeMappingSource([NotNull] TypeMappingSourceDependencies dependenc { typeof(NpgsqlRange), _int4range }, { typeof(NpgsqlRange), _int8range }, { typeof(NpgsqlRange), _numrange }, - { typeof(NpgsqlRange), _tsrange } + { typeof(NpgsqlRange), _tsrange }, + + { typeof(NpgsqlTsQuery), _tsquery }, + { typeof(NpgsqlTsVector), _tsvector }, + { typeof(NpgsqlTsRankingNormalization), _rankingNormalization } }; _storeTypeMappings = new ConcurrentDictionary(storeTypeMappings, StringComparer.OrdinalIgnoreCase); diff --git a/test/EFCore.PG.FunctionalTests/BuiltInDataTypesNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/BuiltInDataTypesNpgsqlTest.cs index 6310e1420..a3789724b 100644 --- a/test/EFCore.PG.FunctionalTests/BuiltInDataTypesNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/BuiltInDataTypesNpgsqlTest.cs @@ -3,7 +3,6 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using System.Net.NetworkInformation; -using System.Reflection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.TestUtilities; @@ -124,6 +123,10 @@ public virtual void Can_query_using_any_mapped_data_type() PhysicalAddressArrayAsMacaddrArray= new[] { PhysicalAddress.Parse("08-00-2B-01-02-03"), PhysicalAddress.Parse("08-00-2B-01-02-04") }, UintAsXid = (uint)int.MaxValue + 1, + + SearchQuery = NpgsqlTsQuery.Parse("a & b"), + SearchVector = NpgsqlTsVector.Parse("a b"), + RankingNormalization = NpgsqlTsRankingNormalization.DivideByLength }); Assert.Equal(1, context.SaveChanges()); @@ -240,6 +243,15 @@ public virtual void Can_query_using_any_mapped_data_type() var param34 = (uint)int.MaxValue + 1; Assert.Same(entity, context.Set().Single(e => e.Int == 999 && e.UintAsXid == param34)); + + var param35 = NpgsqlTsQuery.Parse("a & b"); + Assert.Same(entity, context.Set().Single(e => e.Int == 999 && e.SearchQuery == param35)); + + var param36 = NpgsqlTsVector.Parse("a b"); + Assert.Same(entity, context.Set().Single(e => e.Int == 999 && e.SearchVector == param36)); + + var param37 = NpgsqlTsRankingNormalization.DivideByLength; + Assert.Same(entity, context.Set().Single(e => e.Int == 999 && e.RankingNormalization == param37)); } } @@ -368,6 +380,15 @@ public virtual void Can_query_using_any_mapped_data_types_with_nulls() uint? param34 = null; Assert.Same(entity, context.Set().Single(e => e.Int == 911 && e.UintAsXid == param34)); + + NpgsqlTsQuery param35 = null; + Assert.Same(entity, context.Set().Single(e => e.Int == 911 && e.SearchQuery == param35)); + + NpgsqlTsVector param36 = null; + Assert.Same(entity, context.Set().Single(e => e.Int == 911 && e.SearchVector == param36)); + + NpgsqlTsRankingNormalization? param37 = null; + Assert.Same(entity, context.Set().Single(e => e.Int == 911 && e.RankingNormalization == param37)); } } @@ -407,19 +428,22 @@ public virtual void Can_insert_and_read_back_all_mapped_data_types() @p20='[4,8)' (DbType = Object) @p21='System.Net.NetworkInformation.PhysicalAddress[]' (Nullable = false) (DbType = Object) @p22='08002B010203' (Nullable = false) (DbType = Object) -@p23='79' -@p24='{""a"": ""b""}' (Nullable = false) -@p25='{""a"": ""b""}' (Nullable = false) (DbType = Object) -@p26='Gumball Rules!' (Nullable = false) -@p27='Gumball Rules OK' (Nullable = false) -@p28='11:15:12' (DbType = Object) -@p29='11:15:12' (DbType = Object) -@p30='65535' -@p31='-1' -@p32='4294967295' -@p33='-1' -@p34='2147483648' (DbType = Object) -@p35='-1'", +@p23='2' +@p24=''a' & 'b'' (Nullable = false) (DbType = Object) +@p25=''a' 'b'' (Nullable = false) (DbType = Object) +@p26='79' +@p27='{""a"": ""b""}' (Nullable = false) +@p28='{""a"": ""b""}' (Nullable = false) (DbType = Object) +@p29='Gumball Rules!' (Nullable = false) +@p30='Gumball Rules OK' (Nullable = false) +@p31='11:15:12' (DbType = Object) +@p32='11:15:12' (DbType = Object) +@p33='65535' +@p34='-1' +@p35='4294967295' +@p36='-1' +@p37='2147483648' (DbType = Object) +@p38='-1'", parameters, ignoreLineEndingDifferences: true); } @@ -474,6 +498,10 @@ static void AssertMappedDataTypes(MappedDataTypes entity, int id) Assert.Equal(new[] { PhysicalAddress.Parse("08-00-2B-01-02-03"), PhysicalAddress.Parse("08-00-2B-01-02-04") }, entity.PhysicalAddressArrayAsMacaddrArray); Assert.Equal((uint)int.MaxValue + 1, entity.UintAsXid); + + Assert.Equal(NpgsqlTsQuery.Parse("a & b").ToString(), entity.SearchQuery.ToString()); + Assert.Equal(NpgsqlTsVector.Parse("a b").ToString(), entity.SearchVector.ToString()); + Assert.Equal(NpgsqlTsRankingNormalization.DivideByLength, entity.RankingNormalization); } static MappedDataTypes CreateMappedDataTypes(int id) @@ -524,6 +552,10 @@ static MappedDataTypes CreateMappedDataTypes(int id) PhysicalAddressArrayAsMacaddrArray= new[] { PhysicalAddress.Parse("08-00-2B-01-02-03"), PhysicalAddress.Parse("08-00-2B-01-02-04") }, UintAsXid = (uint)int.MaxValue + 1, + + SearchQuery = NpgsqlTsQuery.Parse("a & b"), + SearchVector = NpgsqlTsVector.Parse("a b"), + RankingNormalization = NpgsqlTsRankingNormalization.DivideByLength }; [Fact] @@ -589,6 +621,10 @@ static void AssertNullMappedNullableDataTypes(MappedNullableDataTypes entity, in Assert.Null(entity.PhysicalAddressArrayAsMacaddrArray); Assert.Null(entity.UintAsXid); + + Assert.Null(entity.SearchQuery); + Assert.Null(entity.SearchVector); + Assert.Null(entity.RankingNormalization); } string Sql => Fixture.TestSqlLoggerFactory.Sql; @@ -671,6 +707,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con modelBuilder.Entity() .Property(e => e.Id) .ValueGeneratedNever(); + + // Full text + modelBuilder.Entity().Property(x => x.SearchQuery).HasColumnType("tsquery"); + modelBuilder.Entity().Property(x => x.SearchVector).HasColumnType("tsvector"); + modelBuilder.Entity().Property(x => x.RankingNormalization).HasColumnType("integer"); + modelBuilder.Entity().Property(x => x.SearchQuery).HasColumnType("tsquery"); + modelBuilder.Entity().Property(x => x.SearchVector).HasColumnType("tsvector"); + modelBuilder.Entity().Property(x => x.RankingNormalization).HasColumnType("integer"); } public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) @@ -817,6 +861,10 @@ protected class MappedDataTypes [Column(TypeName = "xid")] public uint UintAsXid { get; set; } + + public NpgsqlTsQuery SearchQuery { get; set; } + public NpgsqlTsVector SearchVector { get; set; } + public NpgsqlTsRankingNormalization RankingNormalization { get; set; } } public class MappedSizedDataTypes @@ -983,6 +1031,10 @@ protected class MappedNullableDataTypes [Column(TypeName = "xid")] public uint? UintAsXid { get; set; } + + public NpgsqlTsQuery SearchQuery { get; set; } + public NpgsqlTsVector SearchVector { get; set; } + public NpgsqlTsRankingNormalization? RankingNormalization { get; set; } } } } diff --git a/test/EFCore.PG.FunctionalTests/Query/FullTextSearchDbFunctionsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/FullTextSearchDbFunctionsNpgsqlTest.cs new file mode 100644 index 000000000..5438e9045 --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/Query/FullTextSearchDbFunctionsNpgsqlTest.cs @@ -0,0 +1,725 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.TestModels.Northwind; +using Microsoft.EntityFrameworkCore.TestUtilities; +using NpgsqlTypes; +using Xunit; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query +{ + public class FullTextSearchDbFunctionsNpgsqlTest : IClassFixture> + { + protected NorthwindQueryNpgsqlFixture Fixture { get; } + + public FullTextSearchDbFunctionsNpgsqlTest(NorthwindQueryNpgsqlFixture fixture) + { + Fixture = fixture; + Fixture.TestSqlLoggerFactory.Clear(); + } + + [Fact] + public void TsVectorParse_converted_to_cast() + { + using (var context = CreateContext()) + { + var tsvector = context.Customers.Select(c => NpgsqlTsVector.Parse("a b")).First(); + Assert.NotNull(tsvector); + } + + AssertSql( + @"SELECT CAST('a b' AS tsvector) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void ToTsVector() + { + using (var context = CreateContext()) + { + var tsvector = context.Customers.Select(c => EF.Functions.ToTsVector(c.CompanyName)).First(); + Assert.NotNull(tsvector); + } + + AssertSql( + @"SELECT to_tsvector(c.""CompanyName"") +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void ToTsVector_With_Config() + { + using (var context = CreateContext()) + { + var tsvector = context.Customers.Select(c => EF.Functions.ToTsVector("english", c.CompanyName)).First(); + Assert.NotNull(tsvector); + } + + AssertSql( + @"SELECT to_tsvector('english', c.""CompanyName"") +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void TsQueryParse_converted_to_cast() + { + using (var context = CreateContext()) + { + var tsquery = context.Customers.Select(c => NpgsqlTsQuery.Parse("a & b")).First(); + Assert.NotNull(tsquery); + } + + AssertSql( + @"SELECT CAST('a & b' AS tsquery) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void PlainToTsQuery() + { + using (var context = CreateContext()) + { + var tsquery = context.Customers.Select(c => EF.Functions.PlainToTsQuery("a")).First(); + Assert.NotNull(tsquery); + } + + AssertSql( + @"SELECT plainto_tsquery('a') +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void PlainToTsQuery_With_Config() + { + using (var context = CreateContext()) + { + var tsquery = context.Customers.Select(c => EF.Functions.PlainToTsQuery("english", "a")).First(); + Assert.NotNull(tsquery); + } + + AssertSql( + @"SELECT plainto_tsquery('english', 'a') +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void PhraseToTsQuery() + { + using (var context = CreateContext()) + { + var tsquery = context.Customers.Select(c => EF.Functions.PhraseToTsQuery("a b")).First(); + Assert.NotNull(tsquery); + } + + AssertSql( + @"SELECT phraseto_tsquery('a b') +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void PhraseToTsQuery_With_Config() + { + using (var context = CreateContext()) + { + var tsquery = context.Customers.Select(c => EF.Functions.PhraseToTsQuery("english", "a b")).First(); + Assert.NotNull(tsquery); + } + + AssertSql( + @"SELECT phraseto_tsquery('english', 'a b') +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void ToTsQuery() + { + using (var context = CreateContext()) + { + var tsquery = context.Customers.Select(c => EF.Functions.ToTsQuery("a & b")).First(); + Assert.NotNull(tsquery); + } + + AssertSql( + @"SELECT to_tsquery('a & b') +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void ToTsQuery_With_Config() + { + using (var context = CreateContext()) + { + var tsquery = context.Customers.Select(c => EF.Functions.ToTsQuery("english", "a & b")).First(); + Assert.NotNull(tsquery); + } + + AssertSql( + @"SELECT to_tsquery('english', 'a & b') +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void TsQueryAnd() + { + using (var context = CreateContext()) + { + var tsquery = context.Customers + .Select(c => EF.Functions.ToTsQuery("a & b").And(EF.Functions.ToTsQuery("c & d"))) + .First(); + Assert.NotNull(tsquery); + } + + AssertSql( + @"SELECT (to_tsquery('a & b') && to_tsquery('c & d')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void TsQueryOr() + { + using (var context = CreateContext()) + { + var tsquery = context.Customers + .Select(c => EF.Functions.ToTsQuery("a & b").Or(EF.Functions.ToTsQuery("c & d"))) + .First(); + Assert.NotNull(tsquery); + } + + AssertSql( + @"SELECT (to_tsquery('a & b') || to_tsquery('c & d')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void TsQueryToNegative() + { + using (var context = CreateContext()) + { + var tsquery = context.Customers + .Select(c => EF.Functions.ToTsQuery("a & b").ToNegative()) + .First(); + Assert.NotNull(tsquery); + } + + AssertSql( + @"SELECT (!! to_tsquery('a & b')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void TsQueryContains() + { + using (var context = CreateContext()) + { + var result = context.Customers + .Select(c => EF.Functions.ToTsQuery("a & b").Contains(EF.Functions.ToTsQuery("b"))) + .First(); + Assert.True(result); + } + + AssertSql( + @"SELECT (to_tsquery('a & b') @> to_tsquery('b')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void TsQueryIsContainedIn() + { + using (var context = CreateContext()) + { + var result = context.Customers + .Select(c => EF.Functions.ToTsQuery("b").IsContainedIn(EF.Functions.ToTsQuery("a & b"))) + .First(); + Assert.True(result); + } + + AssertSql( + @"SELECT (to_tsquery('b') <@ to_tsquery('a & b')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void GetNodeCount() + { + using (var context = CreateContext()) + { + var nodeCount = context.Customers + .Select(c => EF.Functions.ToTsQuery("b").GetNodeCount()) + .First(); + Assert.Equal(1, nodeCount); + } + + AssertSql( + @"SELECT numnode(to_tsquery('b')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void GetQueryTree() + { + using (var context = CreateContext()) + { + var queryTree = context.Customers + .Select(c => EF.Functions.ToTsQuery("b").GetQueryTree()) + .First(); + Assert.NotEmpty(queryTree); + } + + AssertSql( + @"SELECT querytree(to_tsquery('b')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void GetResultHeadline() + { + using (var context = CreateContext()) + { + var headline = context.Customers + .Select(c => EF.Functions.ToTsQuery("b").GetResultHeadline("a b c")) + .First(); + Assert.NotEmpty(headline); + } + + AssertSql( + @"SELECT ts_headline('a b c', to_tsquery('b')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void GetResultHeadline_With_Options() + { + using (var context = CreateContext()) + { + var headline = context.Customers + .Select(c => EF.Functions.ToTsQuery("b").GetResultHeadline("a b c", "MinWords=1, MaxWords=2")) + .First(); + Assert.NotEmpty(headline); + } + + AssertSql( + @"SELECT ts_headline('a b c', to_tsquery('b'), 'MinWords=1, MaxWords=2') +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void GetResultHeadline_With_Config_And_Options() + { + using (var context = CreateContext()) + { + var headline = context.Customers + .Select( + c => EF.Functions.ToTsQuery("b").GetResultHeadline( + "english", + "a b c", + "MinWords=1, MaxWords=2")) + .First(); + Assert.NotEmpty(headline); + } + + AssertSql( + @"SELECT ts_headline('english', 'a b c', to_tsquery('b'), 'MinWords=1, MaxWords=2') +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void Rewrite() + { + using (var context = CreateContext()) + { + var rewritten = context.Customers + .Select( + c => EF.Functions.ToTsQuery("a & b").Rewrite( + EF.Functions.ToTsQuery("b"), + EF.Functions.ToTsQuery("c"))) + .First(); + Assert.NotNull(rewritten); + } + + AssertSql( + @"SELECT ts_rewrite(to_tsquery('a & b'), to_tsquery('b'), to_tsquery('c')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void ToPhrase() + { + using (var context = CreateContext()) + { + var tsquery = context.Customers + .Select(c => EF.Functions.ToTsQuery("a").ToPhrase(EF.Functions.ToTsQuery("b"))) + .First(); + Assert.NotNull(tsquery); + } + + AssertSql( + @"SELECT tsquery_phrase(to_tsquery('a'), to_tsquery('b')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void ToPhrase_With_Distance() + { + using (var context = CreateContext()) + { + var tsquery = context.Customers + .Select(c => EF.Functions.ToTsQuery("a").ToPhrase(EF.Functions.ToTsQuery("b"), 10)) + .First(); + Assert.NotNull(tsquery); + } + + AssertSql( + @"SELECT tsquery_phrase(to_tsquery('a'), to_tsquery('b'), 10) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void Matches_With_String() + { + using (var context = CreateContext()) + { + var result = context.Customers + .Select(c => EF.Functions.ToTsVector("a").Matches("b")) + .First(); + Assert.False(result); + } + + AssertSql( + @"SELECT (to_tsvector('a') @@ 'b') +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void Matches_With_Tsquery() + { + using (var context = CreateContext()) + { + var result = context.Customers + .Select(c => EF.Functions.ToTsVector("a").Matches(EF.Functions.ToTsQuery("b"))) + .First(); + Assert.False(result); + } + + AssertSql( + @"SELECT (to_tsvector('a') @@ to_tsquery('b')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void Setweight_With_Enum() + { + using (var context = CreateContext()) + { + var weightedTsVector = context.Customers + .Select(c => EF.Functions.ToTsVector("a").SetWeight(NpgsqlTsVector.Lexeme.Weight.A)) + .First(); + Assert.NotNull(weightedTsVector); + } + + AssertSql( + @"SELECT setweight(to_tsvector('a'), 'A') +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void Setweight_With_Enum_And_Lexemes() + { + using (var context = CreateContext()) + { + var weightedTsVector = context.Customers + .Select(c => EF.Functions.ToTsVector("a").SetWeight(NpgsqlTsVector.Lexeme.Weight.A, new[] { "a" })) + .First(); + Assert.NotNull(weightedTsVector); + } + + AssertSql( + @"SELECT setweight(to_tsvector('a'), 'A', ARRAY['a']) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void Setweight_With_Char() + { + using (var context = CreateContext()) + { + var weightedTsVector = context.Customers + .Select(c => EF.Functions.ToTsVector("a").SetWeight('A')) + .First(); + Assert.NotNull(weightedTsVector); + } + + AssertSql( + @"SELECT setweight(to_tsvector('a'), 'A') +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void Setweight_With_Char_And_Lexemes() + { + using (var context = CreateContext()) + { + var weightedTsVector = context.Customers + .Select(c => EF.Functions.ToTsVector("a").SetWeight('A', new[] { "a" })) + .First(); + Assert.NotNull(weightedTsVector); + } + + AssertSql( + @"SELECT setweight(to_tsvector('a'), 'A', ARRAY['a']) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void GetLength() + { + using (var context = CreateContext()) + { + var length = context.Customers + .Select(c => EF.Functions.ToTsVector("c").GetLength()) + .First(); + Assert.Equal(1, length); + } + + AssertSql( + @"SELECT length(to_tsvector('c')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void ToStripped() + { + using (var context = CreateContext()) + { + var strippedTsVector = context.Customers + .Select(c => EF.Functions.ToTsVector("c:A").ToStripped()) + .First(); + Assert.Equal(NpgsqlTsVector.Parse("c").ToString(), strippedTsVector.ToString()); + } + + AssertSql( + @"SELECT strip(to_tsvector('c:A')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void Rank() + { + using (var context = CreateContext()) + { + var rank = context.Customers + .Select(c => EF.Functions.ToTsVector("a b c").Rank(EF.Functions.ToTsQuery("b"))) + .First(); + Assert.True(rank > 0); + } + + AssertSql( + @"SELECT ts_rank(to_tsvector('a b c'), to_tsquery('b')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void Rank_With_Normalization() + { + using (var context = CreateContext()) + { + var rank = context.Customers + .Select( + c => EF.Functions.ToTsVector("a b c").Rank( + EF.Functions.ToTsQuery("b"), + NpgsqlTsRankingNormalization.DivideByLength)) + .First(); + Assert.True(rank > 0); + } + + AssertSql( + @"SELECT ts_rank(to_tsvector('a b c'), to_tsquery('b'), 2) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void Rank_With_Weights() + { + using (var context = CreateContext()) + { + var rank = context.Customers + .Select( + c => EF.Functions.ToTsVector("a b c").Rank( + new float[] { 1, 1, 1, 1 }, + EF.Functions.ToTsQuery("b"))) + .First(); + Assert.True(rank > 0); + } + + AssertSql( + @"SELECT ts_rank(ARRAY[1,1,1,1], to_tsvector('a b c'), to_tsquery('b')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void Rank_With_Weights_And_Normalization() + { + using (var context = CreateContext()) + { + var rank = context.Customers + .Select( + c => EF.Functions.ToTsVector("a b c").Rank( + new float[] { 1, 1, 1, 1 }, + EF.Functions.ToTsQuery("b"), + NpgsqlTsRankingNormalization.DivideByLength)) + .First(); + Assert.True(rank > 0); + } + + AssertSql( + @"SELECT ts_rank(ARRAY[1,1,1,1], to_tsvector('a b c'), to_tsquery('b'), 2) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void RankCoverDensity() + { + using (var context = CreateContext()) + { + var rank = context.Customers + .Select(c => EF.Functions.ToTsVector("a b c").RankCoverDensity(EF.Functions.ToTsQuery("b"))) + .First(); + Assert.True(rank > 0); + } + + AssertSql( + @"SELECT ts_rank_cd(to_tsvector('a b c'), to_tsquery('b')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void RankCoverDensity_With_Normalization() + { + using (var context = CreateContext()) + { + var rank = context.Customers + .Select( + c => EF.Functions.ToTsVector("a b c").RankCoverDensity( + EF.Functions.ToTsQuery("b"), + NpgsqlTsRankingNormalization.DivideByLength)) + .First(); + Assert.True(rank > 0); + } + + AssertSql( + @"SELECT ts_rank_cd(to_tsvector('a b c'), to_tsquery('b'), 2) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void RankCoverDensity_With_Weights() + { + using (var context = CreateContext()) + { + var rank = context.Customers + .Select( + c => EF.Functions.ToTsVector("a b c").RankCoverDensity( + new float[] { 1, 1, 1, 1 }, + EF.Functions.ToTsQuery("b"))) + .First(); + Assert.True(rank > 0); + } + + AssertSql( + @"SELECT ts_rank_cd(ARRAY[1,1,1,1], to_tsvector('a b c'), to_tsquery('b')) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void RankCoverDensity_With_Weights_And_Normalization() + { + using (var context = CreateContext()) + { + var rank = context.Customers + .Select( + c => EF.Functions.ToTsVector("a b c").RankCoverDensity( + new float[] { 1, 1, 1, 1 }, + EF.Functions.ToTsQuery("b"), + NpgsqlTsRankingNormalization.DivideByLength)) + .First(); + Assert.True(rank > 0); + } + + AssertSql( + @"SELECT ts_rank_cd(ARRAY[1,1,1,1], to_tsvector('a b c'), to_tsquery('b'), 2) +FROM ""Customers"" AS c +LIMIT 1"); + } + + [Fact] + public void Basic_where() + { + using (var context = CreateContext()) + { + var count = context.Customers + .Count(c => EF.Functions.ToTsVector(c.ContactTitle).Matches(EF.Functions.ToTsQuery("owner"))); + Assert.True(count > 0); + } + } + + [Fact] + public void Complex_query() + { + using (var context = CreateContext()) + { + var headline = context.Customers + .Where( + c => EF.Functions.ToTsVector(c.ContactTitle) + .SetWeight(NpgsqlTsVector.Lexeme.Weight.A) + .Matches(EF.Functions.ToTsQuery("accounting").ToPhrase(EF.Functions.ToTsQuery("manager")))) + .Select( + c => EF.Functions.ToTsQuery("accounting").ToPhrase(EF.Functions.ToTsQuery("manager")) + .GetResultHeadline(c.ContactTitle)) + .First(); + + Assert.Equal("Accounting Manager", headline); + } + } + + void AssertSql(params string[] expected) + => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); + + protected NorthwindContext CreateContext() => Fixture.CreateContext(); + } +} diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs index cba62f9b3..4d0a7e955 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs @@ -3,8 +3,8 @@ using System.Collections.Generic; using System.Net; using System.Net.NetworkInformation; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.EntityFrameworkCore.Storage.Internal; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal; using NpgsqlTypes; @@ -250,6 +250,26 @@ public void GenerateSqlLiteral_returns_range_infinite_literal() #endregion Ranges + #region Full text search + + [Fact] + public void GenerateSqlLiteral_returns_tsquery_literal() => Assert.Equal( + @"TSQUERY '''a'' & ''b'''", + GetMapping("tsquery").GenerateSqlLiteral(NpgsqlTsQuery.Parse("a & b"))); + + [Fact] + public void GenerateSqlLiteral_returns_tsvector_literal() => Assert.Equal( + @"TSVECTOR '''a'' ''b'''", + GetMapping("tsvector").GenerateSqlLiteral(NpgsqlTsVector.Parse("a b"))); + + [Fact] + public void GenerateSqlLiteral_returns_ranking_normalization_literal() => Assert.Equal( + $"{(int)NpgsqlTsRankingNormalization.DivideByLength}", + GetMapping(typeof(NpgsqlTsRankingNormalization)) + .GenerateSqlLiteral(NpgsqlTsRankingNormalization.DivideByLength)); + + #endregion Full text search + #region Support public static RelationalTypeMapping GetMapping(string storeType)