diff --git a/README.md b/README.md index 7d6973c417..814f04bb40 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Marten also provides .NET developers with an ACID-compliant event store with use Before getting started you will need the following in your environment: -**1. .NET Core SDK 2.0+** +**1. .NET Core SDK 2.1** Available [here](https://www.microsoft.com/net/download/core) @@ -91,13 +91,11 @@ dotnet restore src\Marten.sln dotnet build src\Marten.sln # running tests for a specific target framework -dotnet test src\Marten.Testing\Marten.Testing.csproj --framework netcoreapp2.0 +dotnet run -p martenbuild.csproj -- test # mocha tests -npm install -npm run test +dotnet run -p martenbuild.csproj -- mocha # running documentation website locally -dotnet restore docs.csproj -dotnet stdocs run +dotnet run -p martenbuild.csproj -- docs ``` diff --git a/documentation/documentation/documents/configuration/full_text.md b/documentation/documentation/documents/configuration/full_text.md new file mode 100644 index 0000000000..a8bc415d6d --- /dev/null +++ b/documentation/documentation/documents/configuration/full_text.md @@ -0,0 +1,76 @@ + + +Full Text Indexes in Marten are built based on <[linkto:documentation/documents/configuration/gin_or_gist_index;title=Gin Indexes]> utilizing [Postgres built in Text Search functions](https://www.postgresql.org/docs/10/textsearch-controls.html). This enables the possibility to do more sophisticated searching through text fields. + +
+To use this feature, you will need to use PostgreSQL version 10.0 or above, as this is the first version that support text search function on jsonb column - this is also the data type that Marten use to store it's data. +
+ +## Definining Full Text Index through Store options + +Full Text Indexes can be created using the fluent interface of `StoreOptions` like this: + + +* one index for whole document - all document properties values will be indexed + +<[sample:using_whole_document_full_text_index_through_store_options_with_default]> + +
+If you don't specify language (regConfig) - by default it will be created with 'english' value. +
+ +* single property - there is possibility to specify specific property to be indexed + +<[sample:using_a_single_property_full_text_index_through_store_options_with_default]> + +* single property with custom settings + +<[sample:using_a_single_property_full_text_index_through_store_options_with_custom_settings]> + +* multiple properties + +<[sample:using_multiple_properties_full_text_index_through_store_options_with_default]> + +* multiple properties with custom settings + +<[sample:using_multiple_properties_full_text_index_through_store_options_with_custom_settings]> + +* more than one index for document with different languages (regConfig) + +<[sample:using_more_than_one_full_text_index_through_store_options_with_different_reg_config]> + +## Defining Full Text Index through Attribute + +Full Text Indexes can be created using the `[FullTextIndex]` attribute like this: + +* one index for whole document - by setting attribute on the class all document properties values will be indexed + +<[sample:using_a_full_text_index_through_attribute_on_class_with_default]> + +* single property + +<[sample:using_a_single_property_full_text_index_through_store_options_with_default]> + +
+If you don't specify regConfig - by default it will be created with 'english' value. +
+ +* single property with custom settings + +<[sample:using_a_single_property_full_text_index_through_store_options_with_custom_settings]> + +* multiple properties + +<[sample:using_multiple_properties_full_text_index_through_store_options_with_default]> + +
+To group multiple properties into single index you need to specify the same values in `IndexName` parameters. +
+ +* one index for multiple properties with custom settings + +<[sample:using_multiple_properties_full_text_index_through_store_options_with_custom_settings]> + +* multiple indexes for multiple properties with custom settings + +<[sample:using_more_than_one_full_text_index_through_store_options_with_different_reg_config]> diff --git a/documentation/documentation/documents/configuration/unique.md b/documentation/documentation/documents/configuration/unique.md index d2f5406865..f29594c31a 100644 --- a/documentation/documentation/documents/configuration/unique.md +++ b/documentation/documentation/documents/configuration/unique.md @@ -6,7 +6,7 @@ Marten supports both <[linkto:documentation/documents/configuration/duplicated_f ## Definining Unique Index through Store options -Unique Indexes can be created using the fluent interface off of `StoreOptions` like this: +Unique Indexes can be created using the fluent interface of `StoreOptions` like this: 1. **Computed**: * single property diff --git a/documentation/documentation/documents/querying/linq.md b/documentation/documentation/documents/querying/linq.md index de88737d77..07453cba71 100644 --- a/documentation/documentation/documents/querying/linq.md +++ b/documentation/documentation/documents/querying/linq.md @@ -211,6 +211,32 @@ Query data from all tenants using `AnyTenant` method. Use `TenantIsOneOf` to query on a selected list of tenants. <[sample:tenant_is_one_of]> +## Text Search + +Postgres contains built in [Text Search functions](https://www.postgresql.org/docs/10/textsearch-controls.html). They enable the possibility to do more sophisticated searching through text fields. Marten gives possibility to define <[linkto:documentation/documents/configuration/full_text;title=Full Text Indexes]> and perform queries on them. +Currently three types of full Text Search functions are supported: + +* regular Search (to_tsquery) + +<[sample:search_in_query_sample]> + +* plain text Search (plainto_tsquery) + +<[sample:plain_search_in_query_sample]> + +* phrase Search (phraseto_tsquery) + +<[sample:phrase_search_in_query_sample]> + +All types of Text Searches can be combined with other Linq queries + +<[sample:text_search_combined_with_other_query_sample]> + +They allow also to specify language (regConfig) of the text search query (by default `english` is being used) + +<[sample:text_search_with_non_default_regConfig_sample]> + + ## Supported Types At this point, Marten's Linq support has been tested against these .Net types: diff --git a/martenbuild.cs b/martenbuild.cs index eca363da41..b8e0c3c592 100644 --- a/martenbuild.cs +++ b/martenbuild.cs @@ -8,7 +8,7 @@ namespace martenbuild { class MartenBuild { - private const string BUILD_VERSION = "3.1.0"; + private const string BUILD_VERSION = "3.3.0"; static void Main(string[] args) { diff --git a/rakefile.rb b/rakefile.rb index 841af27569..df52e330a6 100644 --- a/rakefile.rb +++ b/rakefile.rb @@ -4,7 +4,7 @@ COMPILE_TARGET = ENV['config'].nil? ? "debug" : ENV['config'] RESULTS_DIR = "results" -BUILD_VERSION = '3.1.0' +BUILD_VERSION = '3.3.0' CONNECTION = ENV['connection'] diff --git a/src/Marten.Testing/Acceptance/full_text_index.cs b/src/Marten.Testing/Acceptance/full_text_index.cs index 679baafafd..68dfb9d0a7 100644 --- a/src/Marten.Testing/Acceptance/full_text_index.cs +++ b/src/Marten.Testing/Acceptance/full_text_index.cs @@ -1,18 +1,184 @@ -using System; +using System; +using System.Collections.Generic; using System.Linq; -using Marten.Schema; +using Marten.Schema; +using Marten.Storage; using Marten.Testing.Documents; -using Shouldly; +using Shouldly; using Xunit; namespace Marten.Testing.Acceptance -{ +{ + // SAMPLE: using_a_full_text_index_through_attribute_on_class_with_default + [FullTextIndex] + public class Book + { + public Guid Id { get; set; } + + public string Title { get; set; } + + public string Author { get; set; } + + public string Information { get; set; } + } + + // ENDSAMPLE + + // SAMPLE: using_a_single_property_full_text_index_through_attribute_with_default + public class UserProfile + { + public Guid Id { get; set; } + + [FullTextIndex] + public string Information { get; set; } + } + + // ENDSAMPLE + + // SAMPLE: using_a_single_property_full_text_index_through_attribute_with_custom_settings + public class UserDetails + { + private const string FullTextIndexName = "mt_custom_user_details_fts_idx"; + + public Guid Id { get; set; } + + [FullTextIndex(IndexName = FullTextIndexName, RegConfig = "italian")] + public string Details { get; set; } + } + + // ENDSAMPLE + + // SAMPLE: using_multiple_properties_full_text_index_through_attribute_with_default + public class Article + { + public Guid Id { get; set; } + + [FullTextIndex] + public string Heading { get; set; } + + [FullTextIndex] + public string Text { get; set; } + } + + // ENDSAMPLE + + // SAMPLE: using_multiple_properties_full_text_index_through_attribute_with_custom_settings + public class BlogPost + { + public Guid Id { get; set; } + + public string Category { get; set; } + + [FullTextIndex] + public string EnglishText { get; set; } + + [FullTextIndex(RegConfig = "italian")] + public string ItalianText { get; set; } + + [FullTextIndex(RegConfig = "french")] + public string FrenchText { get; set; } + } + + // ENDSAMPLE + public class full_text_index : IntegratedFixture { + public void using_whole_document_full_text_index_through_store_options_with_default() + { + // SAMPLE: using_whole_document_full_text_index_through_store_options_with_default + var store = DocumentStore.For(_ => + { + _.Connection(ConnectionSource.ConnectionString); + + // This creates + _.Schema.For().FullTextIndex(); + }); + // ENDSAMPLE + } + + public void using_a_single_property_full_text_index_through_store_options_with_default() + { + // SAMPLE: using_a_single_property_full_text_index_through_store_options_with_default + var store = DocumentStore.For(_ => + { + _.Connection(ConnectionSource.ConnectionString); + + // This creates + _.Schema.For().FullTextIndex(d => d.FirstName); + }); + // ENDSAMPLE + } + + public void using_a_single_property_full_text_index_through_store_options_with_custom_settings() + { + // SAMPLE: using_a_single_property_full_text_index_through_store_options_with_custom_settings + var store = DocumentStore.For(_ => + { + _.Connection(ConnectionSource.ConnectionString); + + // This creates + _.Schema.For().FullTextIndex( + index => + { + index.IndexName = "mt_custom_italian_user_fts_idx"; + index.RegConfig = "italian"; + }, + d => d.FirstName); + }); + // ENDSAMPLE + } + + public void using_multiple_properties_full_text_index_through_store_options_with_default() + { + // SAMPLE: using_multiple_properties_full_text_index_through_store_options_with_default + var store = DocumentStore.For(_ => + { + _.Connection(ConnectionSource.ConnectionString); + + // This creates + _.Schema.For().FullTextIndex(d => d.FirstName, d => d.LastName); + }); + // ENDSAMPLE + } + + public void using_multiple_properties_full_text_index_through_store_options_with_custom_settings() + { + // SAMPLE: using_multiple_properties_full_text_index_through_store_options_with_custom_settings + var store = DocumentStore.For(_ => + { + _.Connection(ConnectionSource.ConnectionString); + + // This creates + _.Schema.For().FullTextIndex( + index => + { + index.IndexName = "mt_custom_italian_user_fts_idx"; + index.RegConfig = "italian"; + }, + d => d.FirstName, d => d.LastName); + }); + // ENDSAMPLE + } + + public void using_more_than_one_full_text_index_through_store_options_with_different_reg_config() + { + // SAMPLE: using_more_than_one_full_text_index_through_store_options_with_different_reg_config + var store = DocumentStore.For(_ => + { + _.Connection(ConnectionSource.ConnectionString); + + // This creates + _.Schema.For() + .FullTextIndex(d => d.FirstName) //by default it will use "english" + .FullTextIndex("italian", d => d.LastName); + }); + // ENDSAMPLE + } + [Fact] - public void example() + public void using_full_text_query_through_query_session() { - // SAMPLE: using-a-full-text-index + // SAMPLE: using_full_text_query_through_query_session var store = DocumentStore.For(_ => { _.Connection(ConnectionSource.ConnectionString); @@ -20,28 +186,284 @@ public void example() // Create the full text index _.Schema.For().FullTextIndex(); }); + IReadOnlyList result; - using(var session = store.OpenSession()) + using (var session = store.OpenSession()) { - session.Store(new User { FirstName = "Jeremy", LastName = "Miller", UserName = "jmiller"}); - session.Store(new User { FirstName = "Lindsey", LastName = "Miller", UserName = "lmiller"}); - session.Store(new User { FirstName = "Max", LastName = "Miller", UserName = "mmiller"}); - session.Store(new User { FirstName = "Frank", LastName = "Zombo", UserName = "fzombo"}); - session.Store(new User { FirstName = "Somebody", LastName = "Somewher", UserName = "somebody"}); - session.SaveChanges(); - - var somebody = session.Search("somebody"); + session.Store(new User { FirstName = "Jeremy", LastName = "Miller", UserName = "jmiller" }); + session.Store(new User { FirstName = "Lindsey", LastName = "Miller", UserName = "lmiller" }); + session.Store(new User { FirstName = "Max", LastName = "Miller", UserName = "mmiller" }); + session.Store(new User { FirstName = "Frank", LastName = "Zombo", UserName = "fzombo" }); + session.Store(new User { FirstName = "Somebody", LastName = "Somewher", UserName = "somebody" }); + session.SaveChanges(); + + result = session.Search("somebody"); } store.Dispose(); // ENDSAMPLE + + result.Count().ShouldBe(1); + } + + [Fact] + public void search_in_query_sample() + { + StoreOptions(_ => _.RegisterDocumentType()); + + var expectedId = Guid.NewGuid(); + + using (var session = theStore.OpenSession()) + { + session.Store(new BlogPost { Id = expectedId, EnglishText = "somefilter" }); + session.Store(new BlogPost { Id = Guid.NewGuid(), ItalianText = "somefilter" }); + session.SaveChanges(); + } + + using (var session = theStore.OpenSession()) + { + // SAMPLE: search_in_query_sample + var posts = session.Query() + .Where(x => x.Search("somefilter")) + .ToList(); + // ENDSAMPLE + + posts.Count.ShouldBe(1); + posts.Single().Id.ShouldBe(expectedId); + } + } + + [Fact] + public void plain_text_search_in_query_sample() + { + StoreOptions(_ => _.RegisterDocumentType()); + + var expectedId = Guid.NewGuid(); + + using (var session = theStore.OpenSession()) + { + session.Store(new BlogPost { Id = expectedId, EnglishText = "somefilter" }); + session.Store(new BlogPost { Id = Guid.NewGuid(), ItalianText = "somefilter" }); + session.SaveChanges(); + } + + using (var session = theStore.OpenSession()) + { + // SAMPLE: plain_search_in_query_sample + var posts = session.Query() + .Where(x => x.PlainTextSearch("somefilter")) + .ToList(); + // ENDSAMPLE + + posts.Count.ShouldBe(1); + posts.Single().Id.ShouldBe(expectedId); + } + } + + [Fact] + public void phrase_search_in_query_sample() + { + StoreOptions(_ => _.RegisterDocumentType()); + + var expectedId = Guid.NewGuid(); + + using (var session = theStore.OpenSession()) + { + session.Store(new BlogPost { Id = expectedId, EnglishText = "somefilter" }); + session.Store(new BlogPost { Id = Guid.NewGuid(), ItalianText = "somefilter" }); + session.SaveChanges(); + } + + using (var session = theStore.OpenSession()) + { + // SAMPLE: phrase_search_in_query_sample + var posts = session.Query() + .Where(x => x.PhraseSearch("somefilter")) + .ToList(); + // ENDSAMPLE + + posts.Count.ShouldBe(1); + posts.Single().Id.ShouldBe(expectedId); + } + } + + [Fact] + public void text_search_combined_with_other_query_sample() + { + StoreOptions(_ => _.RegisterDocumentType()); + + var expectedId = Guid.NewGuid(); + + using (var session = theStore.OpenSession()) + { + session.Store(new BlogPost { Id = expectedId, EnglishText = "somefilter", Category = "LifeStyle" }); + session.Store(new BlogPost { Id = Guid.NewGuid(), EnglishText = "somefilter", Category = "Other" }); + session.Store(new BlogPost { Id = Guid.NewGuid(), ItalianText = "somefilter", Category = "LifeStyle" }); + session.SaveChanges(); + } + + using (var session = theStore.OpenSession()) + { + // SAMPLE: text_search_combined_with_other_query_sample + var posts = session.Query() + .Where(x => x.Category == "LifeStyle") + .Where(x => x.PhraseSearch("somefilter")) + .ToList(); + // ENDSAMPLE + + posts.Count.ShouldBe(1); + posts.Single().Id.ShouldBe(expectedId); + } + } + + [Fact] + public void text_search_with_non_default_regConfig_sample() + { + StoreOptions(_ => _.RegisterDocumentType()); + + var expectedId = Guid.NewGuid(); + + using (var session = theStore.OpenSession()) + { + session.Store(new BlogPost { Id = Guid.NewGuid(), EnglishText = "somefilter" }); + session.Store(new BlogPost { Id = expectedId, ItalianText = "somefilter" }); + session.SaveChanges(); + } + + using (var session = theStore.OpenSession()) + { + // SAMPLE: text_search_with_non_default_regConfig_sample + var posts = session.Query() + .Where(x => x.PhraseSearch("somefilter", "italian")) + .ToList(); + // ENDSAMPLE + + posts.Count.ShouldBe(1); + posts.Single().Id.ShouldBe(expectedId); + } + } + + [Fact] + public void should_search_with_store_options_default_configuration() + { + SearchShouldBeSuccessfulFor(_ => _.Schema.For().FullTextIndex()); } + [Fact] + public void should_search_with_store_options_for_specific_members() + { + SearchShouldBeSuccessfulFor(_ => _.Schema.For().FullTextIndex(d => d.FirstName, d => d.LastName)); + } + + [Fact] + public void should_search_with_store_options_with_multipleIndexes() + { + const string frenchRegConfig = "french"; + const string italianRegConfig = "italian"; + + StoreOptions(_ => _.Schema.For() + .FullTextIndex(italianRegConfig, d => d.FirstName) + .FullTextIndex(frenchRegConfig, d => d.LastName)); + + const string searchFilter = "Lindsey"; + + using (var session = theStore.OpenSession()) + { + session.Store(new User { FirstName = searchFilter, LastName = "Miller", UserName = "lmiller" }); + session.Store(new User { FirstName = "Frank", LastName = searchFilter, UserName = "fzombo" }); + + session.Store(new User { FirstName = "Jeremy", LastName = "Miller", UserName = "jmiller" }); + session.Store(new User { FirstName = "Max", LastName = "Miller", UserName = "mmiller" }); + session.Store(new User { FirstName = "Somebody", LastName = "Somewher", UserName = "somebody" }); + session.SaveChanges(); + + var italianResults = session.Search(searchFilter, italianRegConfig); + + italianResults.Count.ShouldBe(1); + italianResults.ShouldContain(u => u.FirstName == searchFilter); + italianResults.ShouldNotContain(u => u.LastName == searchFilter); + + var frenchResults = session.Search(searchFilter, frenchRegConfig); + + frenchResults.Count.ShouldBe(1); + frenchResults.ShouldNotContain(u => u.FirstName == searchFilter); + frenchResults.ShouldContain(u => u.LastName == searchFilter); + } + } + + [Fact] + public void should_search_by_tenant_with_tenancy_conjoined() + { + StoreOptions(_ => + { + _.Events.TenancyStyle = TenancyStyle.Conjoined; + _.Policies.AllDocumentsAreMultiTenanted(); + + _.Schema.For().FullTextIndex(); + }); + + const string searchFilter = "Lindsey"; + + var tenants = new[] { "Tenant", "Other Tenant" }; + + foreach (var tenant in tenants) + { + using (var session = theStore.OpenSession(tenant)) + { + session.Store(new User { FirstName = searchFilter, LastName = "Miller", UserName = "lmiller" }); + session.Store(new User { FirstName = "Frank", LastName = "Zombo", UserName = "fzombo" }); + session.SaveChanges(); + } + } + + foreach (var tenant in tenants) + { + using (var session = theStore.OpenSession(tenant)) + { + var results = session.Search(searchFilter); + + results.Count.ShouldBe(1); + results.ShouldContain(u => u.FirstName == searchFilter); + results.ShouldNotContain(u => u.LastName == searchFilter); + } + } + } + + private void SearchShouldBeSuccessfulFor(Action configure) + { + StoreOptions(configure); + + const string searchFilter = "Lindsey"; + + using (var session = theStore.OpenSession()) + { + session.Store(new User { FirstName = searchFilter, LastName = "Miller", UserName = "lmiller" }); + session.Store(new User { FirstName = "Frank", LastName = searchFilter, UserName = "fzombo" }); + + session.Store(new User { FirstName = "Jeremy", LastName = "Miller", UserName = "jmiller" }); + session.Store(new User { FirstName = "Max", LastName = "Miller", UserName = "mmiller" }); + session.Store(new User { FirstName = "Somebody", LastName = "Somewher", UserName = "somebody" }); + session.SaveChanges(); + + var results = session.Search(searchFilter); + + results.Count.ShouldBe(2); + results.ShouldContain(u => u.FirstName == searchFilter); + results.ShouldContain(u => u.LastName == searchFilter); + } + } + + [Fact] + private void should_search_using_a_single_property_full_text_index_through_attribute_with_custom_settings() + { + StoreOptions(_ => _.Schema.For()); + } + [Fact] public void creating_a_full_text_index_should_create_the_index_on_the_table() { - StoreOptions(_=>_.Schema.For().FullTextIndex()); + StoreOptions(_ => _.Schema.For().FullTextIndex()); var data = Target.GenerateRandomData(100).ToArray(); theStore.BulkInsert(data); @@ -73,7 +495,7 @@ public void not_specifying_an_index_name_should_generate_default_index_name() [Fact] public void specifying_an_index_name_without_marten_prefix_should_prepend_prefix() { - StoreOptions(_ => _.Schema.For().FullTextIndex(configure: x=>x.IndexName = "doesnt_have_prefix")); + StoreOptions(_ => _.Schema.For().FullTextIndex(configure: x => x.IndexName = "doesnt_have_prefix")); var data = Target.GenerateRandomData(100).ToArray(); theStore.BulkInsert(data); @@ -87,7 +509,7 @@ public void specifying_an_index_name_without_marten_prefix_should_prepend_prefix [Fact] public void specifying_an_index_name_with_mixed_case_should_result_in_lower_case_name() { - StoreOptions(_ => _.Schema.For().FullTextIndex(configure: x=>x.IndexName = "Doesnt_Have_PreFix")); + StoreOptions(_ => _.Schema.For().FullTextIndex(configure: x => x.IndexName = "Doesnt_Have_PreFix")); var data = Target.GenerateRandomData(100).ToArray(); theStore.BulkInsert(data); @@ -101,7 +523,7 @@ public void specifying_an_index_name_with_mixed_case_should_result_in_lower_case [Fact] public void specifying_an_index_name_with_marten_prefix_remains_unchanged() { - StoreOptions(_ => _.Schema.For().FullTextIndex(configure: x=>x.IndexName = "mt_i_have_prefix")); + StoreOptions(_ => _.Schema.For().FullTextIndex(configure: x => x.IndexName = "mt_i_have_prefix")); var data = Target.GenerateRandomData(100).ToArray(); theStore.BulkInsert(data); @@ -112,11 +534,10 @@ public void specifying_an_index_name_with_marten_prefix_remains_unchanged() ddl.ShouldContain("mt_i_have_prefix"); } - [Fact] public void specifying_an_index_name_with_marten_prefix_and_mixed_case_results_in_lowercase_name() { - StoreOptions(_ => _.Schema.For().FullTextIndex(configure: x=>x.IndexName = "mT_I_hAve_preFIX")); + StoreOptions(_ => _.Schema.For().FullTextIndex(configure: x => x.IndexName = "mT_I_hAve_preFIX")); var data = Target.GenerateRandomData(100).ToArray(); theStore.BulkInsert(data); @@ -126,5 +547,290 @@ public void specifying_an_index_name_with_marten_prefix_and_mixed_case_results_i ddl.ShouldContain("mt_i_have_prefix"); } + + [Fact] + public void creating_a_full_text_index_with_custom_data_configuration_should_create_the_index_without_regConfig_in_indexname_custom_data_configuration() + { + const string DataConfig = "(data ->> 'AnotherString' || ' ' || 'test')"; + + StoreOptions(_ => _.Schema.For().FullTextIndex( + index => + { + index.DataConfig = DataConfig; + })); + + var data = Target.GenerateRandomData(100).ToArray(); + theStore.BulkInsert(data); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + indexName: $"mt_doc_target_idx_fts", + dataConfig: DataConfig + ); + } + + [Fact] + public void creating_a_full_text_index_with_custom_data_configuration_and_custom_regConfig_should_create_the_index_with_custom_regConfig_in_indexname_custom_data_configuration() + { + const string DataConfig = "(data ->> 'AnotherString' || ' ' || 'test')"; + const string RegConfig = "french"; + + StoreOptions(_ => _.Schema.For().FullTextIndex( + index => + { + index.RegConfig = RegConfig; + index.DataConfig = DataConfig; + })); + + var data = Target.GenerateRandomData(100).ToArray(); + theStore.BulkInsert(data); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + indexName: $"mt_doc_target_{RegConfig}_idx_fts", + regConfig: RegConfig, + dataConfig: DataConfig + ); + } + + [Fact] + public void creating_a_full_text_index_with_custom_data_configuration_and_custom_regConfig_custom_indexName_should_create_the_index_with_custom_indexname_custom_data_configuration() + { + const string DataConfig = "(data ->> 'AnotherString' || ' ' || 'test')"; + const string RegConfig = "french"; + const string IndexName = "custom_index_name"; + + StoreOptions(_ => _.Schema.For().FullTextIndex( + index => + { + index.DataConfig = DataConfig; + index.RegConfig = RegConfig; + index.IndexName = IndexName; + })); + + var data = Target.GenerateRandomData(100).ToArray(); + theStore.BulkInsert(data); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + indexName: $"mt_{IndexName}", + regConfig: RegConfig, + dataConfig: DataConfig + ); + } + + [Fact] + public void creating_a_full_text_index_with_single_member_should_create_the_index_without_regConfig_in_indexname_and_member_selectors() + { + StoreOptions(_ => _.Schema.For().FullTextIndex(d => d.String)); + + var data = Target.GenerateRandomData(100).ToArray(); + theStore.BulkInsert(data); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + indexName: $"mt_doc_target_idx_fts", + dataConfig: $"((data ->> '{nameof(Target.String)}'))" + ); + } + + [Fact] + public void creating_a_full_text_index_with_multiple_members_should_create_the_index_without_regConfig_in_indexname_and_members_selectors() + { + StoreOptions(_ => _.Schema.For().FullTextIndex(d => d.String, d => d.AnotherString)); + + var data = Target.GenerateRandomData(100).ToArray(); + theStore.BulkInsert(data); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + indexName: $"mt_doc_target_idx_fts", + dataConfig: $"((data ->> '{nameof(Target.String)}') || ' ' || (data ->> '{nameof(Target.AnotherString)}'))" + ); + } + + [Fact] + public void creating_a_full_text_index_with_multiple_members_and_custom_configuration_should_create_the_index_with_custom_configuration_and_members_selectors() + { + const string IndexName = "custom_index_name"; + const string RegConfig = "french"; + + StoreOptions(_ => _.Schema.For().FullTextIndex( + index => + { + index.IndexName = IndexName; + index.RegConfig = RegConfig; + }, + d => d.AnotherString)); + + var data = Target.GenerateRandomData(100).ToArray(); + theStore.BulkInsert(data); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + indexName: $"mt_{IndexName}", + regConfig: RegConfig, + dataConfig: $"((data ->> '{nameof(Target.AnotherString)}'))" + ); + } + + [Fact] + public void creating_multiple_full_text_index_with_different_regConfigs_and_custom_data_config_should_create_the_indexes_with_different_recConfigs() + { + const string frenchRegConfig = "french"; + const string italianRegConfig = "italian"; + + StoreOptions(_ => _.Schema.For() + .FullTextIndex(frenchRegConfig, d => d.String) + .FullTextIndex(italianRegConfig, d => d.AnotherString)); + + var data = Target.GenerateRandomData(100).ToArray(); + theStore.BulkInsert(data); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + indexName: $"mt_doc_target_{frenchRegConfig}_idx_fts", + regConfig: frenchRegConfig, + dataConfig: $"((data ->> '{nameof(Target.String)}'))" + ); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + indexName: $"mt_doc_target_{italianRegConfig}_idx_fts", + regConfig: italianRegConfig, + dataConfig: $"((data ->> '{nameof(Target.AnotherString)}'))" + ); + } + + [Fact] + public void using_a_full_text_index_through_attribute_on_class_with_default() + { + StoreOptions(_ => _.RegisterDocumentType()); + + theStore.BulkInsert(new[] { new Book { Id = Guid.NewGuid(), Author = "test", Information = "test", Title = "test" } }); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + tableName: "public.mt_doc_book", + indexName: $"mt_doc_book_idx_fts", + regConfig: FullTextIndex.DefaultRegConfig, + dataConfig: $"data" + ); + } + + [Fact] + public void using_a_single_property_full_text_index_through_attribute_with_default() + { + StoreOptions(_ => _.RegisterDocumentType()); + + theStore.BulkInsert(new[] { new UserProfile { Id = Guid.NewGuid(), Information = "test" } }); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + tableName: "public.mt_doc_userprofile", + indexName: $"mt_doc_userprofile_idx_fts", + regConfig: FullTextIndex.DefaultRegConfig, + dataConfig: $"((data ->> '{nameof(UserProfile.Information)}'))" + ); + } + + [Fact] + public void using_a_single_property_full_text_index_through_attribute_with_custom_settings() + { + StoreOptions(_ => _.RegisterDocumentType()); + + theStore.BulkInsert(new[] { new UserDetails { Id = Guid.NewGuid(), Details = "test" } }); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + tableName: "public.mt_doc_userdetails", + indexName: "mt_custom_user_details_fts_idx", + regConfig: "italian", + dataConfig: $"((data ->> '{nameof(UserDetails.Details)}'))" + ); + } + + [Fact] + public void using_multiple_properties_full_text_index_through_attribute_with_default() + { + StoreOptions(_ => _.RegisterDocumentType
()); + + theStore.BulkInsert(new[] { new Article { Id = Guid.NewGuid(), Heading = "test", Text = "test" } }); + + theStore.Storage + .ShouldContainIndexDefinitionFor
( + tableName: "public.mt_doc_article", + indexName: $"mt_doc_article_idx_fts", + regConfig: FullTextIndex.DefaultRegConfig, + dataConfig: $"((data ->> '{nameof(Article.Heading)}') || ' ' || (data ->> '{nameof(Article.Text)}'))" + ); + } + + [Fact] + public void using_multiple_properties_full_text_index_through_attribute_with_custom_settings() + { + const string frenchRegConfig = "french"; + const string italianRegConfig = "italian"; + + StoreOptions(_ => _.RegisterDocumentType()); + + theStore.BulkInsert(new[] { new BlogPost { Id = Guid.NewGuid(), Category = "test", EnglishText = "test", FrenchText = "test", ItalianText = "test" } }); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + tableName: "public.mt_doc_blogpost", + indexName: $"mt_doc_blogpost_idx_fts", + regConfig: FullTextIndex.DefaultRegConfig, + dataConfig: $"((data ->> '{nameof(BlogPost.EnglishText)}'))" + ); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + tableName: "public.mt_doc_blogpost", + indexName: $"mt_doc_blogpost_{frenchRegConfig}_idx_fts", + regConfig: frenchRegConfig, + dataConfig: $"((data ->> '{nameof(BlogPost.FrenchText)}'))" + ); + + theStore.Storage + .ShouldContainIndexDefinitionFor( + tableName: "public.mt_doc_blogpost", + indexName: $"mt_doc_blogpost_{italianRegConfig}_idx_fts", + regConfig: italianRegConfig, + dataConfig: $"((data ->> '{nameof(BlogPost.ItalianText)}'))" + ); + } + } + + public static class FullTextIndexTestsExtension + { + public static void ShouldContainIndexDefinitionFor( + this StorageFeatures storage, + string tableName = "public.mt_doc_target", + string indexName = "mt_doc_target_idx_fts", + string regConfig = "english", + string dataConfig = null) + { + var ddl = storage.MappingFor(typeof(TDocument)).Indexes + .Where(x => x.IndexName == indexName) + .Select(x => x.ToDDL()) + .FirstOrDefault(); + + ddl.ShouldNotBeNull(); + + ddl.ShouldContain($"CREATE INDEX {indexName}"); + ddl.ShouldContain($"ON {tableName}"); + ddl.ShouldContain($"to_tsvector('{regConfig}', {dataConfig})"); + + if (regConfig != null) + { + ddl.ShouldContain(regConfig); + } + + if (dataConfig != null) + { + ddl.ShouldContain(dataConfig); + } + } } } \ No newline at end of file diff --git a/src/Marten/FullTextIndexAttribute.cs b/src/Marten/FullTextIndexAttribute.cs new file mode 100644 index 0000000000..cfa73f2832 --- /dev/null +++ b/src/Marten/FullTextIndexAttribute.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using System.Reflection; + +namespace Marten.Schema +{ + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Class)] + public class FullTextIndexAttribute : MartenAttribute + { + public override void Modify(DocumentMapping mapping) + { + mapping.AddFullTextIndex(regConfig: RegConfig, (index) => { index.IndexName = IndexName; }); + } + + public override void Modify(DocumentMapping mapping, MemberInfo member) + { + var membersGroupedByIndexName = member.DeclaringType.GetMembers() + .Where(mi => mi.GetCustomAttributes().Any()) + .Select(mi => new + { + Member = mi, + IndexInformation = mi.GetCustomAttributes().First() + }) + .GroupBy(m => m.IndexInformation.IndexName ?? m.IndexInformation.RegConfig ?? m.Member.Name) + .Where(mg => mg.Any(m => m.Member == member)) + .Single(); + + mapping.AddFullTextIndex( + membersGroupedByIndexName.Select(mg => new[] { mg.Member }).ToArray(), + regConfig: RegConfig, + indexName: IndexName); + } + + /// + /// Specify the name of the index explicity + /// + public string IndexName { get; set; } = null; + + /// + /// Specify Index type + /// + public string RegConfig = FullTextIndex.DefaultRegConfig; + } +} \ No newline at end of file diff --git a/src/Marten/IQuerySession.cs b/src/Marten/IQuerySession.cs index 7ab67f10b6..eb8b3353de 100644 --- a/src/Marten/IQuerySession.cs +++ b/src/Marten/IQuerySession.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Marten.Linq; +using Marten.Schema; using Marten.Services.BatchQuerying; using Marten.Storage; using Npgsql; @@ -79,7 +80,6 @@ public interface IQuerySession : IDisposable /// Task LoadAsync(Guid id, CancellationToken token = default(CancellationToken)); - // SAMPLE: querying_with_linq /// /// Use Linq operators to query the documents @@ -88,6 +88,7 @@ public interface IQuerySession : IDisposable /// /// IMartenQueryable Query(); + // ENDSAMPLE /// @@ -110,7 +111,7 @@ public interface IQuerySession : IDisposable Task> QueryAsync(string sql, CancellationToken token = default(CancellationToken), params object[] parameters); /// - /// Define a batch of deferred queries and load operations to be conducted in one asynchronous request to the + /// Define a batch of deferred queries and load operations to be conducted in one asynchronous request to the /// database for potentially performance /// /// @@ -121,7 +122,6 @@ public interface IQuerySession : IDisposable /// NpgsqlConnection Connection { get; } - /// /// The session specific logger for this session. Can be set for better integration /// with custom diagnostics @@ -138,7 +138,6 @@ public interface IQuerySession : IDisposable /// IDocumentStore DocumentStore { get; } - /// /// A query that is compiled so a copy of the DbCommand can be used directly in subsequent requests. /// @@ -158,7 +157,6 @@ public interface IQuerySession : IDisposable /// A task for a single item query result Task QueryAsync(ICompiledQuery query, CancellationToken token = default(CancellationToken)); - /// /// Load or find multiple documents by id /// @@ -264,60 +262,60 @@ public interface IQuerySession : IDisposable /// Performs a full text search against /// /// The text to search for. May contain lexeme patterns used by PostgreSQL for full text searching - /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by + /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by /// /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES /// - IReadOnlyList Search(string queryText, string config = "english"); + IReadOnlyList Search(string queryText, string regConfig = FullTextIndex.DefaultRegConfig); /// /// Performs an asynchronous full text search against /// /// The text to search for. May contain lexeme patterns used by PostgreSQL for full text searching - /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by + /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by /// /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES /// - Task> SearchAsync(string queryText, string config = "english", CancellationToken token = default); + Task> SearchAsync(string queryText, string regConfig = FullTextIndex.DefaultRegConfig, CancellationToken token = default); /// /// Performs a full text search against using the 'plainto_tsquery' search function /// /// The text to search for. May contain lexeme patterns used by PostgreSQL for full text searching - /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by + /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by /// /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES /// - IReadOnlyList PlainTextSearch(string searchTerm, string config = "english"); + IReadOnlyList PlainTextSearch(string searchTerm, string regConfig = FullTextIndex.DefaultRegConfig); /// /// Performs an asynchronous full text search against using the 'plainto_tsquery' function /// /// The text to search for. May contain lexeme patterns used by PostgreSQL for full text searching - /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by + /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by /// /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES /// - Task> PlainTextSearchAsync(string searchTerm, string config = "english", CancellationToken token = default); + Task> PlainTextSearchAsync(string searchTerm, string regConfig = FullTextIndex.DefaultRegConfig, CancellationToken token = default); /// /// Performs a full text search against using the 'phraseto_tsquery' search function /// /// The text to search for. May contain lexeme patterns used by PostgreSQL for full text searching - /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by + /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by /// /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES /// - IReadOnlyList PhraseSearch(string searchTerm, string config = "english"); + IReadOnlyList PhraseSearch(string searchTerm, string regConfig = FullTextIndex.DefaultRegConfig); /// /// Performs an asynchronous full text search against using the 'phraseto_tsquery' search function /// /// The text to search for. May contain lexeme patterns used by PostgreSQL for full text searching - /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by + /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by /// /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES /// - Task> PhraseSearchAsync(string searchTerm, string config = "english", CancellationToken token = default); + Task> PhraseSearchAsync(string searchTerm, string regConfig = FullTextIndex.DefaultRegConfig, CancellationToken token = default); } } \ No newline at end of file diff --git a/src/Marten/Linq/IMartenQueryable.cs b/src/Marten/Linq/IMartenQueryable.cs index b67eae8469..778ee29af6 100644 --- a/src/Marten/Linq/IMartenQueryable.cs +++ b/src/Marten/Linq/IMartenQueryable.cs @@ -15,22 +15,33 @@ public interface IMartenQueryable { IEnumerable Includes { get; } - QueryStatistics Statistics { get; } - Task> ToListAsync(CancellationToken token); - Task AnyAsync(CancellationToken token); - Task CountAsync(CancellationToken token); - Task CountLongAsync(CancellationToken token); - Task FirstAsync(CancellationToken token); - Task FirstOrDefaultAsync(CancellationToken token); - Task SingleAsync(CancellationToken token); + QueryStatistics Statistics { get; } + + Task> ToListAsync(CancellationToken token); + + Task AnyAsync(CancellationToken token); + + Task CountAsync(CancellationToken token); + + Task CountLongAsync(CancellationToken token); + + Task FirstAsync(CancellationToken token); + + Task FirstOrDefaultAsync(CancellationToken token); + + Task SingleAsync(CancellationToken token); + Task SingleOrDefaultAsync(CancellationToken token); - Task SumAsync(CancellationToken token); - Task MinAsync(CancellationToken token); - Task MaxAsync(CancellationToken token); + Task SumAsync(CancellationToken token); + + Task MinAsync(CancellationToken token); + + Task MaxAsync(CancellationToken token); + Task AverageAsync(CancellationToken token); - /// Configure EXPLAIN options as documented in EXPLAIN documentation + /// Configure EXPLAIN options as documented in EXPLAIN documentation QueryPlan Explain(FetchType fetchType = FetchType.FetchMany, Action configureExplain = null); /// @@ -43,7 +54,6 @@ public interface IMartenQueryable IQueryable TransformTo(string transformName); } - public interface IMartenQueryable : IQueryable, IMartenQueryable { IMartenQueryable Include(Expression> idSource, Action callback, diff --git a/src/Marten/Linq/IWhereFragment.cs b/src/Marten/Linq/IWhereFragment.cs index e667167906..19ce7e5e48 100644 --- a/src/Marten/Linq/IWhereFragment.cs +++ b/src/Marten/Linq/IWhereFragment.cs @@ -8,7 +8,8 @@ namespace Marten.Linq { public interface IWhereFragment { - void Apply(CommandBuilder builder); + void Apply(CommandBuilder builder); + bool Contains(string sqlText); } @@ -28,12 +29,13 @@ public static IWhereFragment Append(this IWhereFragment fragment, IWhereFragment } return new CompoundWhereFragment("and", fragment, other); - } public static IWhereFragment Append(this IWhereFragment fragment, IWhereFragment[] others) { - if (!others.Any()) return fragment; + if (others?.Any() == false) return fragment; + + if (fragment == null) return Append(others.First(), others.Skip(1).ToArray()); foreach (var other in others) { @@ -52,7 +54,7 @@ public static IWhereFragment[] Flatten(this IWhereFragment fragment) return fragment.As().Children.ToArray(); } - return new IWhereFragment[] {fragment}; + return new IWhereFragment[] { fragment }; } public static string ToSql(this IWhereFragment fragment) @@ -70,6 +72,5 @@ public static bool SpecifiesTenant(this IWhereFragment fragment) { return fragment.Flatten().OfType().Any(); } - } } \ No newline at end of file diff --git a/src/Marten/Linq/MartenExpressionParser.cs b/src/Marten/Linq/MartenExpressionParser.cs index 12c421292f..7a72dd05fd 100644 --- a/src/Marten/Linq/MartenExpressionParser.cs +++ b/src/Marten/Linq/MartenExpressionParser.cs @@ -8,7 +8,6 @@ using Marten.Linq.Parsing; using Marten.Linq.SoftDeletes; using Marten.Schema; -using Marten.Util; using Remotion.Linq; using Remotion.Linq.Clauses.Expressions; using Remotion.Linq.Clauses.ResultOperators; @@ -66,8 +65,8 @@ public IWhereFragment ParseWhereFragment(IQueryableDocument mapping, Expression new EnumerableContains(), new StringEndsWith(), new StringStartsWith(), - new StringEquals(), - new SimpleEqualsParser(), + new StringEquals(), + new SimpleEqualsParser(), // Added new IsOneOf(), @@ -95,18 +94,23 @@ public IWhereFragment ParseWhereFragment(IQueryableDocument mapping, Expression new MatchesSqlParser(), // dictionaries - new DictionaryExpressions() + new DictionaryExpressions(), + + // full text search + new Search(), + new PhraseSearch(), + new PlainTextSearch() }; private static readonly object[] _supplementalParsers = new[] { - new SimpleBinaryComparisonExpressionParser(), + new SimpleBinaryComparisonExpressionParser(), }; private IWhereFragment buildSimpleWhereClause(IQueryableDocument mapping, BinaryExpression binary) { - var isValueExpressionOnRight = binary.Right.IsValueExpression(); - + var isValueExpressionOnRight = binary.Right.IsValueExpression(); + var isSubQuery = isValueExpressionOnRight ? binary.Left is SubQueryExpression : binary.Right is SubQueryExpression; @@ -125,8 +129,8 @@ private IWhereFragment buildSimpleWhereClause(IQueryableDocument mapping, Binary if (parser != null) { - var where = parser.Parse(mapping, _serializer, binary); - + var where = parser.Parse(mapping, _serializer, binary); + return where; } @@ -154,4 +158,4 @@ private static object moduloByValue(BinaryExpression binary) return moduloValueExpression != null ? moduloValueExpression.Value : 1; } } -} +} \ No newline at end of file diff --git a/src/Marten/Linq/MartenQueryExecutor.cs b/src/Marten/Linq/MartenQueryExecutor.cs index 35e31f9b8d..68f75192b0 100644 --- a/src/Marten/Linq/MartenQueryExecutor.cs +++ b/src/Marten/Linq/MartenQueryExecutor.cs @@ -31,7 +31,6 @@ public MartenQueryExecutor(IManagedConnection runner, DocumentStore store, IIden public ITenant Tenant { get; } public QueryStatistics Statistics { get; set; } - T IQueryExecutor.ExecuteScalar(QueryModel queryModel) { var handler = Store.HandlerFactory.HandlerForScalarQuery(queryModel, Includes.ToArray(), @@ -44,7 +43,6 @@ T IQueryExecutor.ExecuteScalar(QueryModel queryModel) return Connection.Fetch(handler, IdentityMap.ForQuery(), Statistics, Tenant); } - T IQueryExecutor.ExecuteSingle(QueryModel queryModel, bool returnDefaultWhenEmpty) { var handler = Store.HandlerFactory.HandlerForSingleQuery(queryModel, _includes.ToArray(), Statistics, diff --git a/src/Marten/Linq/MartenQueryable.cs b/src/Marten/Linq/MartenQueryable.cs index 533048e751..f10e62754e 100644 --- a/src/Marten/Linq/MartenQueryable.cs +++ b/src/Marten/Linq/MartenQueryable.cs @@ -47,7 +47,6 @@ public IQueryable TransformTo(string transformName) return this.Select(x => x.TransformTo(transformName)); } - public IEnumerable Includes { get @@ -88,7 +87,6 @@ public IMartenQueryable Include(Expression> idSourc return this; } - public IMartenQueryable Include(Expression> idSource, IList list, JoinType joinType = JoinType.Inner) { @@ -190,7 +188,6 @@ public LinqQuery ToLinqQuery() return new LinqQuery(Store, query, Includes.ToArray(), Statistics); } - private IQueryHandler toDiagnosticHandler(FetchType fetchType) { switch (fetchType) @@ -218,7 +215,6 @@ public NpgsqlCommand BuildCommand(FetchType fetchType) return CommandBuilder.ToCommand(Tenant, handler); } - private Task executeAsync(Func, IQueryHandler> source, CancellationToken token) { @@ -230,6 +226,6 @@ private Task executeAsync(Func, IQueryHandler(_store, Model, new IIncludeJoin[0], null).AppendWhere(sql); } @@ -187,7 +186,6 @@ public void ConfigureAggregate(CommandBuilder sql, string @operator) AppendWhere(sql); } - public IQueryHandler> ToList() { return new ListQueryHandler(this); @@ -207,8 +205,6 @@ private void writeOrderClause(CommandBuilder sql) } } - - private void writeOrderByFragment(CommandBuilder sql, Ordering clause) { var locator = _mapping.JsonLocator(clause.Expression); @@ -225,7 +221,8 @@ private IWhereFragment buildWhereFragment() var bodies = bodyClauses(); var wheres = bodies.OfType().ToArray(); - if (wheres.Length == 0) return _mapping.DefaultWhereFragment(); + if (wheres.Length == 0) + return _mapping.DefaultWhereFragment(); var where = wheres.Length == 1 ? _store.Parser.ParseWhereFragment(_mapping, wheres.Single().Predicate) diff --git a/src/Marten/Linq/Parsing/FullTextSearchMethodCallParser.cs b/src/Marten/Linq/Parsing/FullTextSearchMethodCallParser.cs new file mode 100644 index 0000000000..5660c78c61 --- /dev/null +++ b/src/Marten/Linq/Parsing/FullTextSearchMethodCallParser.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq.Expressions; +using Marten.Linq.WhereFragments; +using Marten.Schema; + +namespace Marten.Linq.Parsing +{ + public enum FullTextSearchFunction + { + to_tsquery, + plainto_tsquery, + phraseto_tsquery + } + + public abstract class FullTextSearchMethodCallParser : IMethodCallParser + { + private readonly string methodName; + private readonly FullTextSearchFunction searchFunction; + + protected FullTextSearchMethodCallParser(string methodName, FullTextSearchFunction searchFunction) + { + this.methodName = methodName; + this.searchFunction = searchFunction; + } + + public bool Matches(MethodCallExpression expression) + { + return expression.Method.Name == methodName + && expression.Method.DeclaringType == typeof(LinqExtensions); + } + + public IWhereFragment Parse(IQueryableDocument mapping, ISerializer serializer, MethodCallExpression expression) + { + if (expression.Arguments.Count < 2 || expression.Arguments[1].Value() == null) + throw new ArgumentException("Search Term needs to be provided", "searchTerm"); + + if (expression.Arguments[1].Type != typeof(string)) + throw new ArgumentException("Search Term needs to be string", "searchTerm"); + + if (expression.Arguments.Count > 2 && expression.Arguments[2].Type != typeof(string)) + throw new ArgumentException("Reg config needs to be string", "regConfig"); + + var searchTerm = (string)expression.Arguments[1].Value(); + + var regConfig = expression.Arguments.Count > 2 ? + expression.Arguments[2].Value() as string : FullTextIndex.DefaultRegConfig; + + return new FullTextWhereFragment( + mapping as DocumentMapping, + searchFunction, + searchTerm, + regConfig); + } + } +} \ No newline at end of file diff --git a/src/Marten/Linq/Parsing/PhraseSearch.cs b/src/Marten/Linq/Parsing/PhraseSearch.cs new file mode 100644 index 0000000000..e114be6573 --- /dev/null +++ b/src/Marten/Linq/Parsing/PhraseSearch.cs @@ -0,0 +1,9 @@ +namespace Marten.Linq.Parsing +{ + public class PhraseSearch : FullTextSearchMethodCallParser + { + public PhraseSearch() : base(nameof(LinqExtensions.PhraseSearch), FullTextSearchFunction.phraseto_tsquery) + { + } + } +} \ No newline at end of file diff --git a/src/Marten/Linq/Parsing/PlainTextSearch.cs b/src/Marten/Linq/Parsing/PlainTextSearch.cs new file mode 100644 index 0000000000..282a7adaf8 --- /dev/null +++ b/src/Marten/Linq/Parsing/PlainTextSearch.cs @@ -0,0 +1,9 @@ +namespace Marten.Linq.Parsing +{ + public class PlainTextSearch : FullTextSearchMethodCallParser + { + public PlainTextSearch() : base(nameof(LinqExtensions.PlainTextSearch), FullTextSearchFunction.plainto_tsquery) + { + } + } +} \ No newline at end of file diff --git a/src/Marten/Linq/Parsing/Search.cs b/src/Marten/Linq/Parsing/Search.cs new file mode 100644 index 0000000000..26946c4262 --- /dev/null +++ b/src/Marten/Linq/Parsing/Search.cs @@ -0,0 +1,9 @@ +namespace Marten.Linq.Parsing +{ + public class Search : FullTextSearchMethodCallParser + { + public Search() : base(nameof(LinqExtensions.Search), FullTextSearchFunction.to_tsquery) + { + } + } +} \ No newline at end of file diff --git a/src/Marten/Linq/WhereFragments/FullTextWhereFragment.cs b/src/Marten/Linq/WhereFragments/FullTextWhereFragment.cs new file mode 100644 index 0000000000..513f2560a0 --- /dev/null +++ b/src/Marten/Linq/WhereFragments/FullTextWhereFragment.cs @@ -0,0 +1,51 @@ +using System.Linq; +using Marten.Linq.Parsing; +using Marten.Schema; +using Marten.Util; + +namespace Marten.Linq.WhereFragments +{ + internal class FullTextWhereFragment : IWhereFragment + { + private readonly string _regConfig; + private readonly string _dataConfig; + private readonly FullTextSearchFunction _searchFunction; + private readonly string _searchTerm; + + private string Sql => $"to_tsvector(:argRegConfig::regconfig, {_dataConfig}) @@ {_searchFunction}(:argRegConfig::regconfig, :argSearchTerm)"; + + public FullTextWhereFragment(DocumentMapping mapping, FullTextSearchFunction searchFunction, string searchTerm, string regConfig = FullTextIndex.DefaultRegConfig) + { + _regConfig = regConfig; + _dataConfig = GetDataConfig(mapping, regConfig); + _searchFunction = searchFunction; + _searchTerm = searchTerm; + } + + public void Apply(CommandBuilder builder) + { + builder.AddNamedParameter("argRegConfig", _regConfig); + builder.AddNamedParameter("argSearchTerm", _searchTerm); + + builder.Append(Sql); + } + + public bool Contains(string sqlText) + { + return Sql.Contains(sqlText); + } + + private static string GetDataConfig(DocumentMapping mapping, string regConfig) + { + if (mapping == null) + return FullTextIndex.DefaultDataConfig; + + return mapping + .Indexes + .OfType() + .Where(i => i.RegConfig == regConfig) + .Select(i => i.DataConfig) + .FirstOrDefault() ?? FullTextIndex.DefaultDataConfig; + } + } +} \ No newline at end of file diff --git a/src/Marten/LinqExtensions.cs b/src/Marten/LinqExtensions.cs index 7ebfe857e1..3cb6ad624e 100644 --- a/src/Marten/LinqExtensions.cs +++ b/src/Marten/LinqExtensions.cs @@ -1,7 +1,8 @@ using System.Collections.Generic; using System.Linq; using Baseline; - +using Marten.Schema; + namespace Marten { public static class LinqExtensions @@ -110,5 +111,80 @@ public static bool TenantIsOneOf(this T variable, params string[] tenantIds) { return true; } + + /// + /// Performs a full text search against + /// + /// The text to search for. May contain lexeme patterns used by PostgreSQL for full text searching + /// + /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static bool Search(this T variable, string searchTerm) + { + return true; + } + + /// + /// Performs a full text search against + /// + /// The text to search for. May contain lexeme patterns used by PostgreSQL for full text searching + /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by + /// + /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static bool Search(this T variable, string searchTerm, string regConfig) + { + return true; + } + + /// + /// Performs a full text search against using the 'plainto_tsquery' search function + /// + /// The text to search for. May contain lexeme patterns used by PostgreSQL for full text searching + /// + /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static bool PlainTextSearch(this T variable, string searchTerm) + { + return true; + } + + /// + /// Performs a full text search against using the 'plainto_tsquery' search function + /// + /// The text to search for. May contain lexeme patterns used by PostgreSQL for full text searching + /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by + /// + /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static bool PlainTextSearch(this T variable, string searchTerm, string regConfig) + { + return true; + } + + /// + /// Performs a full text search against using the 'phraseto_tsquery' search function + /// + /// The text to search for. May contain lexeme patterns used by PostgreSQL for full text searching + /// + /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static bool PhraseSearch(this T variable, string searchTerm) + { + return true; + } + + /// + /// Performs a full text search against using the 'phraseto_tsquery' search function + /// + /// The text to search for. May contain lexeme patterns used by PostgreSQL for full text searching + /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used by + /// + /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static bool PhraseSearch(this T variable, string searchTerm, string regConfig) + { + return true; + } } } \ No newline at end of file diff --git a/src/Marten/Marten.csproj b/src/Marten/Marten.csproj index 2a2c33bbb5..8715ae12f8 100644 --- a/src/Marten/Marten.csproj +++ b/src/Marten/Marten.csproj @@ -68,6 +68,6 @@ snupkg - + diff --git a/src/Marten/MartenRegistry.cs b/src/Marten/MartenRegistry.cs index b728d18443..8cf88d170b 100644 --- a/src/Marten/MartenRegistry.cs +++ b/src/Marten/MartenRegistry.cs @@ -187,7 +187,20 @@ public DocumentMappingExpression UniqueIndex(params Expression /// Creates a unique index on this data member within the JSON data storage /// - /// + /// Name of the index + /// + /// + public DocumentMappingExpression UniqueIndex(string indexName, params Expression>[] expressions) + { + alter = m => m.UniqueIndex(indexName, expressions); + + return this; + } + + /// + /// Creates a unique index on this data member within the JSON data storage + /// + /// Type of the index /// /// public DocumentMappingExpression UniqueIndex(UniqueIndexType indexType, params Expression>[] expressions) @@ -197,6 +210,20 @@ public DocumentMappingExpression UniqueIndex(UniqueIndexType indexType, param return this; } + /// + /// Creates a unique index on this data member within the JSON data storage + /// + /// Type of the index + /// Name of the index + /// + /// + public DocumentMappingExpression UniqueIndex(UniqueIndexType indexType, string indexName, params Expression>[] expressions) + { + alter = m => m.UniqueIndex(indexType, indexName, expressions); + + return this; + } + /// /// Creates an index on the predefined Last Modified column /// @@ -209,9 +236,33 @@ public DocumentMappingExpression IndexLastModified(Action co return this; } - public DocumentMappingExpression FullTextIndex(string config = "english", Action configure = null) + public DocumentMappingExpression FullTextIndex(string regConfig = Schema.FullTextIndex.DefaultRegConfig, Action configure = null) + { + alter = m => m.AddFullTextIndex(regConfig, configure); + return this; + } + + public DocumentMappingExpression FullTextIndex(Action configure) + { + alter = m => m.AddFullTextIndex(Schema.FullTextIndex.DefaultRegConfig, configure); + return this; + } + + public DocumentMappingExpression FullTextIndex(params Expression>[] expressions) + { + FullTextIndex(Schema.FullTextIndex.DefaultRegConfig, expressions); + return this; + } + + public DocumentMappingExpression FullTextIndex(string regConfig, params Expression>[] expressions) + { + alter = m => m.FullTextIndex(regConfig, expressions); + return this; + } + + public DocumentMappingExpression FullTextIndex(Action configure, params Expression>[] expressions) { - alter = m => m.AddFullTextIndex(config, configure); + alter = m => m.FullTextIndex(configure, expressions); return this; } diff --git a/src/Marten/QuerySession.cs b/src/Marten/QuerySession.cs index 41c4fc7dcb..c07b6ee19f 100644 --- a/src/Marten/QuerySession.cs +++ b/src/Marten/QuerySession.cs @@ -40,6 +40,7 @@ public QuerySession(DocumentStore store, IManagedConnection connection, IQueryPa } public ISerializer Serializer { get; } + public Guid? VersionFor(TDoc entity) { var id = _store.Storage.StorageFor(typeof(TDoc)).Identity(entity); @@ -274,7 +275,6 @@ private IEnumerable fetchDocuments(TKey[] keys) var resolver = storage.As>(); var cmd = storage.LoadByArrayCommand(keys); cmd.AddTenancy(_parent.Tenant); - var list = new List(); @@ -353,7 +353,6 @@ public IMartenSessionLogger Logger public int RequestCount => _connection.RequestCount; - ~QuerySession() { Dispose(); @@ -398,50 +397,34 @@ public T Load(Guid id) return loadAsync(id, token); } - public IReadOnlyList Search(string searchTerm, string config = "english") - { - return DoFullTextSearch(searchTerm, config, "to_tsquery"); - } - - public Task> SearchAsync(string searchTerm, string config = "english", CancellationToken token = default) - { - return DoFullTextSearchAsync(searchTerm, config, "to_tsquery", token); - } - - public IReadOnlyList PlainTextSearch(string searchTerm, string config = "english") + public IReadOnlyList Search(string searchTerm, string regConfig = FullTextIndex.DefaultRegConfig) { - return DoFullTextSearch(searchTerm, config, "plainto_tsquery"); + return Query().Where(d => d.Search(searchTerm, regConfig)).ToList(); } - public Task> PlainTextSearchAsync(string searchTerm, string config = "english", CancellationToken token = default) + public Task> SearchAsync(string searchTerm, string regConfig = FullTextIndex.DefaultRegConfig, CancellationToken token = default) { - return DoFullTextSearchAsync(searchTerm, config, "plainto_tsquery", token); + return Query().Where(d => d.Search(searchTerm, regConfig)).ToListAsync(); } - public IReadOnlyList PhraseSearch(string searchTerm, string config = "english") + public IReadOnlyList PlainTextSearch(string searchTerm, string regConfig = FullTextIndex.DefaultRegConfig) { - return DoFullTextSearch(searchTerm, config, "phraseto_tsquery"); + return Query().Where(d => d.PlainTextSearch(searchTerm, regConfig)).ToList(); } - public Task> PhraseSearchAsync(string searchTerm, string config = "english", CancellationToken token = default) + public Task> PlainTextSearchAsync(string searchTerm, string regConfig = FullTextIndex.DefaultRegConfig, CancellationToken token = default) { - return DoFullTextSearchAsync(searchTerm, config, "phraseto_tsquery", token); + return Query().Where(d => d.PlainTextSearch(searchTerm, regConfig)).ToListAsync(); } - private IReadOnlyList DoFullTextSearch(string searchTerm, string config, string searchFunction) + public IReadOnlyList PhraseSearch(string searchTerm, string regConfig = FullTextIndex.DefaultRegConfig) { - assertNotDisposed(); - var sql = $"where to_tsvector('{config}', data) @@ {searchFunction}('{config}', '{searchTerm}')"; - var handler = new UserSuppliedQueryHandler(_store, sql, new object[0]); - return _connection.Fetch(handler, _identityMap.ForQuery(), null, Tenant); + return Query().Where(d => d.PhraseSearch(searchTerm, regConfig)).ToList(); } - private Task> DoFullTextSearchAsync(string searchTerm, string config, string searchFunction, CancellationToken token) + public Task> PhraseSearchAsync(string searchTerm, string regConfig = FullTextIndex.DefaultRegConfig, CancellationToken token = default) { - assertNotDisposed(); - var sql = $"where to_tsvector('{config}', data) @@ {searchFunction}('{config}', '{searchTerm}')"; - var handler = new UserSuppliedQueryHandler(_store, sql, new object[0]); - return _connection.FetchAsync(handler, _identityMap.ForQuery(), null, Tenant, token); + return Query().Where(d => d.PhraseSearch(searchTerm, regConfig)).ToListAsync(); } } } \ No newline at end of file diff --git a/src/Marten/QueryableExtensions.cs b/src/Marten/QueryableExtensions.cs index abcfca0802..4d6c1b80b3 100644 --- a/src/Marten/QueryableExtensions.cs +++ b/src/Marten/QueryableExtensions.cs @@ -26,7 +26,7 @@ public static Task> ToListAsync(this IQueryable queryable return queryable.As().ToListAsync(token); } - #endregion + #endregion ToList #region Any @@ -36,7 +36,6 @@ public static Task AnyAsync( { if (source == null) throw new ArgumentNullException(nameof(source)); - return source.As().AnyAsync(token); } @@ -51,7 +50,7 @@ public static Task AnyAsync( return source.Where(predicate).AnyAsync(token); } - #endregion + #endregion Any #region Aggregate Functions @@ -64,7 +63,6 @@ public static Task SumAsync( return source.Select(expression).As().SumAsync(token); } - public static Task MaxAsync( this IQueryable source, Expression> expression, CancellationToken token = default(CancellationToken)) @@ -74,7 +72,6 @@ public static Task MaxAsync( return source.Select(expression).As().MaxAsync(token); } - public static Task MinAsync( this IQueryable source, Expression> expression, CancellationToken token = default(CancellationToken)) @@ -93,7 +90,7 @@ public static Task AverageAsync( return source.Select(expression).As().AverageAsync(token); } - #endregion + #endregion Aggregate Functions #region Count/LongCount/Sum @@ -106,7 +103,6 @@ public static Task CountAsync( return source.As().CountAsync(token); } - public static Task CountAsync( this IQueryable source, Expression> predicate, @@ -118,7 +114,6 @@ public static Task CountAsync( return source.Where(predicate).CountAsync(token); } - public static Task LongCountAsync( this IQueryable source, CancellationToken token = default(CancellationToken)) @@ -139,7 +134,7 @@ public static Task LongCountAsync( return source.Where(predicate).LongCountAsync(token); } - #endregion + #endregion Count/LongCount/Sum #region First/FirstOrDefault @@ -183,7 +178,7 @@ public static Task FirstOrDefaultAsync( return source.Where(predicate).FirstOrDefaultAsync(token); } - #endregion + #endregion First/FirstOrDefault #region Single/SingleOrDefault @@ -227,7 +222,7 @@ public static Task SingleOrDefaultAsync( return source.Where(predicate).SingleOrDefaultAsync(token); } - #endregion + #endregion Single/SingleOrDefault #region Shared @@ -236,14 +231,14 @@ private static IMartenQueryable CastToMartenQueryable(IQueryable querya var martenQueryable = queryable as IMartenQueryable; if (martenQueryable == null) { - throw new InvalidOperationException($"{typeof (T)} is not IMartenQueryable<>"); + throw new InvalidOperationException($"{typeof(T)} is not IMartenQueryable<>"); } return martenQueryable; - } - - #endregion - + } + + #endregion Shared + public static NpgsqlCommand ToCommand(this IQueryable queryable, FetchType fetchType = FetchType.FetchMany) { var q = queryable as MartenQueryable; diff --git a/src/Marten/Schema/DocumentMapping.cs b/src/Marten/Schema/DocumentMapping.cs index e89a71b2f6..216fb869b9 100644 --- a/src/Marten/Schema/DocumentMapping.cs +++ b/src/Marten/Schema/DocumentMapping.cs @@ -448,16 +448,47 @@ public IIndexDefinition AddUniqueIndex(MemberInfo[][] members, UniqueIndexType i /// /// Adds a full text index /// - /// The dictionary to used by the 'to_tsvector' function, defaults to 'english'. + /// The dictionary to used by the 'to_tsvector' function, defaults to 'english'. /// Optional action to further configure the full text index /// /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-DOCUMENTS /// - public void AddFullTextIndex(string config = "english", Action configure = null) + public FullTextIndex AddFullTextIndex(string regConfig = FullTextIndex.DefaultRegConfig, Action configure = null) { - var index = new FullTextIndex(this, config); - configure?.Invoke(index); + var index = new FullTextIndex(this, regConfig); + configure?.Invoke(index); + + return AddFullTextIndexIfDoesNotExist(index); + } + + /// + /// Adds a full text index + /// + /// Document fields that should be use by full text index + /// The dictionary to used by the 'to_tsvector' function, defaults to 'english'. + /// + /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-DOCUMENTS + /// + public FullTextIndex AddFullTextIndex(MemberInfo[][] members, string regConfig = FullTextIndex.DefaultRegConfig, string indexName = null) + { + var index = new FullTextIndex(this, regConfig, members) + { + IndexName = indexName + }; + + return AddFullTextIndexIfDoesNotExist(index); + } + + private FullTextIndex AddFullTextIndexIfDoesNotExist(FullTextIndex index) + { + var existing = Indexes.OfType().FirstOrDefault(x => x.IndexName == index.IndexName); + if (existing != null) + { + return existing; + } Indexes.Add(index); + + return index; } public ForeignKeyDefinition AddForeignKey(string memberName, Type referenceType) @@ -755,10 +786,20 @@ public void Index(IReadOnlyCollection>> expressions, public void UniqueIndex(params Expression>[] expressions) { - UniqueIndex(UniqueIndexType.Computed, expressions); - } + UniqueIndex(UniqueIndexType.Computed, null, expressions); + } + + public void UniqueIndex(string indexName, params Expression>[] expressions) + { + UniqueIndex(UniqueIndexType.Computed, indexName, expressions); + } public void UniqueIndex(UniqueIndexType indexType, params Expression>[] expressions) + { + UniqueIndex(indexType, null, expressions); + } + + public void UniqueIndex(UniqueIndexType indexType, string indexName, params Expression>[] expressions) { AddUniqueIndex( expressions @@ -769,7 +810,56 @@ public void UniqueIndex(UniqueIndexType indexType, params Expression + /// Adds a full text index with default region config set to 'english' + /// + /// Document fields that should be use by full text index + /// + /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-DOCUMENTS + /// + public FullTextIndex FullTextIndex(params Expression>[] expressions) + { + return FullTextIndex(Schema.FullTextIndex.DefaultRegConfig, expressions); + } + + /// + /// Adds a full text index with default region config set to 'english' + /// + /// Document fields that should be use by full text index + /// + /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-DOCUMENTS + /// + public FullTextIndex FullTextIndex(Action configure, params Expression>[] expressions) + { + var index = FullTextIndex(Schema.FullTextIndex.DefaultRegConfig, expressions); + configure(index); + return index; + } + + /// + /// Adds a full text index + /// + /// The dictionary to used by the 'to_tsvector' function, defaults to 'english'. + /// Document fields that should be use by full text index + /// + /// See: https://www.postgresql.org/docs/10/static/textsearch-controls.html#TEXTSEARCH-PARSING-DOCUMENTS + /// + public FullTextIndex FullTextIndex(string regConfig, params Expression>[] expressions) + { + return AddFullTextIndex( + expressions + .Select(e => + { + var visitor = new FindMembers(); + visitor.Visit(e); + return visitor.Members.ToArray(); + }) + .ToArray(), + regConfig); } public void ForeignKey( diff --git a/src/Marten/Schema/FullTextIndex.cs b/src/Marten/Schema/FullTextIndex.cs index cf9b3d4ea2..c28dd8307a 100644 --- a/src/Marten/Schema/FullTextIndex.cs +++ b/src/Marten/Schema/FullTextIndex.cs @@ -1,4 +1,5 @@ -using System; +using System.Linq; +using System.Reflection; using Baseline; using Marten.Storage; @@ -6,41 +7,84 @@ namespace Marten.Schema { public class FullTextIndex : IIndexDefinition { - private readonly string _config; + public const string DefaultRegConfig = "english"; + public const string DefaultDataConfig = "data"; + + private string _regConfig; + private string _dataConfig; private readonly DbObjectName _table; private string _indexName; - public FullTextIndex(DocumentMapping parent, string config) + public FullTextIndex(DocumentMapping mapping, string regConfig = null, string dataConfig = null, string indexName = null) + { + _table = mapping.Table; + RegConfig = regConfig; + DataConfig = dataConfig; + IndexName = indexName; + } + + public FullTextIndex(DocumentMapping mapping, string regConfig, MemberInfo[][] members) + : this(mapping, regConfig, GetDataConfig(mapping, members)) { - _table = parent.Table; - _config = config; - _indexName = $"{_table.Name}_idx_fts"; } public string IndexName { - get => _indexName; - set + get { - var lowerValue = value.ToLowerInvariant(); - if(value.IsNotEmpty() && lowerValue.StartsWith(DocumentMapping.MartenPrefix)) - _indexName = lowerValue.ToLowerInvariant(); - else if(lowerValue.IsNotEmpty()) - _indexName = DocumentMapping.MartenPrefix + lowerValue.ToLowerInvariant(); + var lowerValue = _indexName?.ToLowerInvariant(); + if (lowerValue?.StartsWith(DocumentMapping.MartenPrefix) == true) + return lowerValue.ToLowerInvariant(); + else if (lowerValue?.IsNotEmpty() == true) + return DocumentMapping.MartenPrefix + lowerValue.ToLowerInvariant(); + else if (_regConfig != DefaultRegConfig) + return $"{_table.Name}_{_regConfig}_idx_fts"; else - _indexName = $"{_table.Name}_idx_fts"; + return $"{_table.Name}_idx_fts"; } - } + set => _indexName = value; + } + + public string RegConfig + { + get => _regConfig; + set => _regConfig = value ?? DefaultRegConfig; + } + + public string DataConfig + { + get => _dataConfig; + set => _dataConfig = value ?? DefaultDataConfig; + } + public string ToDDL() { - return $"CREATE INDEX {IndexName} ON {_table.QualifiedName} USING gin (( to_tsvector('{_config}', data) ));"; + return $"CREATE INDEX {IndexName} ON {_table.QualifiedName} USING gin (( to_tsvector('{_regConfig}', {_dataConfig}) ));"; } public bool Matches(ActualIndex index) { var ddl = index?.DDL.ToLowerInvariant(); // Check for the existence of the 'to_tsvector' function, the correct table name, and the use of the data column - return ddl?.Contains("to_tsvector") == true && ddl.Contains(_table.QualifiedName) && ddl.Contains("data"); + return ddl?.Contains("to_tsvector") == true + && ddl?.Contains(IndexName) == true + && ddl?.Contains(_table.QualifiedName) == true + && ddl?.Contains(_regConfig) == true + && ddl?.Contains(_dataConfig) == true; + } + + private static string GetDataConfig(DocumentMapping mapping, MemberInfo[][] members) + { + var dataConfig = members + .Select(m => $"({mapping.FieldFor(m).SqlLocator.Replace("d.", "")})") + .Join(" || ' ' || "); + + return $"({dataConfig})"; + } + + private void RefreshIndexName() + { + IndexName = _indexName; } } } \ No newline at end of file