diff --git a/.github/workflows/docfx-gh-pages.yml b/.github/workflows/docfx-gh-pages.yml index 1321b4ba9..9ec09b6d9 100644 --- a/.github/workflows/docfx-gh-pages.yml +++ b/.github/workflows/docfx-gh-pages.yml @@ -33,7 +33,7 @@ jobs: - name: Setup Github Pages uses: actions/configure-pages@v3 - name: Build DocFX Site - uses: VakuWare/docfx-pdf-action@v1.4.0 + uses: nunit/docfx-action@v2.10.0 with: args: docs/docfx.json - name: Upload docfx built site artifact diff --git a/docs/api/index.md b/docs/api/index.md index 6b256ce8a..680c23fcc 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,7 +1,7 @@ --- uid: apidocsindex --- -# Examine V3 API Documentation +# Examine V4 API Documentation API documentation is automatically generated. diff --git a/docs/articles/configuration.md b/docs/articles/configuration.md index 8873dd988..ff2d43170 100644 --- a/docs/articles/configuration.md +++ b/docs/articles/configuration.md @@ -1,5 +1,4 @@ --- -layout: page title: Configuration permalink: /configuration uid: configuration diff --git a/docs/articles/indexing.md b/docs/articles/indexing.md index 946328376..7ad7ca846 100644 --- a/docs/articles/indexing.md +++ b/docs/articles/indexing.md @@ -1,5 +1,4 @@ --- -layout: page title: Indexing permalink: /indexing uid: indexing diff --git a/docs/articles/searching.md b/docs/articles/searching.md index 18dd2d5c1..6b2fd0250 100644 --- a/docs/articles/searching.md +++ b/docs/articles/searching.md @@ -1,5 +1,4 @@ --- -layout: page title: Searching permalink: /searching uid: searching diff --git a/docs/articles/sorting.md b/docs/articles/sorting.md index 66d062ee1..4baf84adc 100644 --- a/docs/articles/sorting.md +++ b/docs/articles/sorting.md @@ -1,5 +1,4 @@ --- -layout: page title: Sorting permalink: /sorting uid: sorting diff --git a/docs/articles/suggesting.md b/docs/articles/suggesting.md new file mode 100644 index 000000000..bddf413ed --- /dev/null +++ b/docs/articles/suggesting.md @@ -0,0 +1,87 @@ +--- +layout: page +title: Suggesting +permalink: /suggesting +ref: suggesting +order: 2 +--- +Suggesting and Spell Checking +=== + +_**Tip**: There are many examples of searching in the [`SuggesterApiTests` source code](https://github.com/Shazwazza/Examine/blob/master/src/Examine.Test/Lucene/Suggest/SuggesterApiTests.cs) to use as examples/reference._ + + +## Registering Suggesters + +On the index to register the Suggesters, create a SuggesterDefinitionCollection and set it on IndexOptions.SuggesterDefinitions + +Examples for suggesting or spellchecking based on the "fullName" index field. + +```cs + var suggesters = new SuggesterDefinitionCollection(); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.AnalyzingInfixSuggester, ExamineLuceneSuggesterNames.AnalyzingInfixSuggester, new string[] { "fullName" })); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.AnalyzingSuggester, ExamineLuceneSuggesterNames.AnalyzingSuggester, new string[] { "fullName" })); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker, ExamineLuceneSuggesterNames.DirectSpellChecker, new string[] { "fullName" })); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker_LevensteinDistance, ExamineLuceneSuggesterNames.DirectSpellChecker_LevensteinDistance, new string[] { "fullName" })); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker_JaroWinklerDistance, ExamineLuceneSuggesterNames.DirectSpellChecker_JaroWinklerDistance, new string[] { "fullName" })); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker_NGramDistance, ExamineLuceneSuggesterNames.DirectSpellChecker_NGramDistance, new string[] { "fullName" })); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.FuzzySuggester, ExamineLuceneSuggesterNames.FuzzySuggester, new string[] { "fullName" })); +``` + +## Suggester API + +```cs +var suggester = index.Suggester; +var query = suggester.CreateSuggestionQuery(); +var results = query.Execute("Sam", new SuggestionOptions(5,ExamineLuceneSuggesterNames.AnalyzingSuggester)); +``` + +This code will run a suggestion for the input text "Sam", returning up to 5 suggestions. + +## Lucene Suggesters + +To generate suggestions for input text, retreive the ISuggester from the index IIndex.Suggester. + +### Analyzing Suggester + +```cs +var suggester = index.Suggester; +var query = suggester.CreateSuggestionQuery(); +var results = query.Execute("Sam", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.AnalyzingInfixSuggester)); +``` + +This code will run a suggestion for the input text "Sam", returning up to 5 suggestions, and will highlight the result text which matches the input text.. + +### Analyzing Suggester + +```cs +var suggester = index.Suggester; +var query = suggester.CreateSuggestionQuery(); +var results = query.Execute("Sam", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.AnalyzingSuggester)); +``` + +This code will run a suggestion for the input text "Sam", returning up to 5 suggestions. + +### Fuzzy Suggester + +```cs +var suggester = index.Suggester; +var query = suggester.CreateSuggestionQuery(); +var results = query.Execute("Sam", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.FuzzySuggester)); +``` + +This code will run a Fuzzy suggestion for the input text "Sam", returning up to 5 suggestions. + +## Lucene Spellcheckers + +To generate spellchecker suggestions for input text, retreive the ISuggester from the index IIndex.Suggester. + +### Direct SpellChecker Suggester + +```cs +var suggester = index.Suggester; +var query = suggester.CreateSuggestionQuery(); +var results = query.Execute("Sam", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.DirectSpellChecker)); +``` + +This code will run a spellchecker suggestion for the input text "Sam" returning up to 5 suggestions. diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index 2c385e520..3b84d20d2 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -7,4 +7,8 @@ - name: Sorting href: sorting.md - name: Paging - href: sorting.md#paging-and-limiting-results \ No newline at end of file + href: sorting.md#paging-and-limiting-results +- name: Suggesters + href: suggesting.md +- name: Spellchecking + href: suggesting.md#lucene-spellcheckers \ No newline at end of file diff --git a/docs/developerguides/buildtest.md b/docs/developerguides/buildtest.md index 7659e04cd..bdf2f5395 100644 --- a/docs/developerguides/buildtest.md +++ b/docs/developerguides/buildtest.md @@ -1,5 +1,4 @@ --- -layout: page title: Build and Test permalink: /devbuildtest uid: devbuildtest diff --git a/docs/developerguides/codestructure.md b/docs/developerguides/codestructure.md index 31198efe8..cf04cd18a 100644 --- a/docs/developerguides/codestructure.md +++ b/docs/developerguides/codestructure.md @@ -1,9 +1,8 @@ --- -layout: page title: Code Structure permalink: /devcodestructure uid: devcodestructure -order: 1 +order: 2 --- Code Structure diff --git a/docs/developerguides/docsite.md b/docs/developerguides/docsite.md index 782a68199..683160466 100644 --- a/docs/developerguides/docsite.md +++ b/docs/developerguides/docsite.md @@ -1,9 +1,8 @@ --- -layout: page title: Documentation Site permalink: /devdocsite uid: devdocsite -order: 1 +order: 3 --- Documentation Site @@ -16,7 +15,7 @@ The easiest way to get started is to edit an existing page by clicking the Impro ## Building documentation -1. Download [docfx](https://github.com/dotnet/docfx/releases). +1. Install [docfx](https://github.com/dotnet/docfx/releases) dotnet tool update -g docfx. 2. Unzip the release and add the folder to your system path variables. 3. Open a terminal, for example PowerShell or the VS Code terminal. 4. Change directory to /docs diff --git a/docs/developerguides/roadmap.md b/docs/developerguides/roadmap.md index b177be593..a9df191b6 100644 --- a/docs/developerguides/roadmap.md +++ b/docs/developerguides/roadmap.md @@ -1,9 +1,8 @@ --- -layout: page title: Roadmap permalink: /devroadmap uid: devroadmap -order: 1 +order: 4 --- Examine.Lucene Roadmap @@ -13,8 +12,6 @@ This page covers the roadmap for the core Examine abstractions and the Examine.L ## Ongoing and future work -- [ ] [Facet Deep Paging](https://github.com/Shazwazza/Examine/pull/321) -- [ ] [Hierarchical Faceting API (Taxonomy)](https://github.com/Shazwazza/Examine/pull/323) - [ ] [Suggestions API and Spellchecking API](https://github.com/Shazwazza/Examine/pull/326) - [ ] [Similarity API (BM25)](https://github.com/Shazwazza/Examine/pull/327) - [ ] [GeoSpatial API](https://github.com/Shazwazza/Examine/pull/328) @@ -22,6 +19,8 @@ This page covers the roadmap for the core Examine abstractions and the Examine.L ## Past and completed work +- [x] [Facet Deep Paging](https://github.com/Shazwazza/Examine/pull/321) +- [x] [Hierarchical Faceting API (Taxonomy)](https://github.com/Shazwazza/Examine/pull/323) - [x] [Nullable Support](https://github.com/Shazwazza/Examine/pull/313) - [x] [Faceting API (SortedDocValues)](https://github.com/Shazwazza/Examine/pull/311) - [x] [Docfx website](https://github.com/Shazwazza/Examine/pull/322) @@ -29,5 +28,11 @@ This page covers the roadmap for the core Examine abstractions and the Examine.L ## Release Notes -### Examine 3.1 -[Examine 3.1 Release Notes](https://github.com/Shazwazza/Examine/releases/tag/v3.1.0) \ No newline at end of file +### Examine 4.0.0 beta 1 +[Examine 4.0.0 beta 1 Release Notes](https://github.com/Shazwazza/Examine/releases/tag/v4.0.0-beta.1) + +### Examine v3.2.0 beta 9 +[Examine v3.2.0 beta 9 Release Notes](https://github.com/Shazwazza/Examine/releases/tag/v3.2.0-beta.9) + +### Examine 3.1.0 +[Examine 3.1.0 Release Notes](https://github.com/Shazwazza/Examine/releases/tag/v3.1.0) \ No newline at end of file diff --git a/docs/docfx.json b/docs/docfx.json index 445db277a..194bb9328 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -66,8 +66,14 @@ "fileMetadataFiles": [], "template": [ "default", - "templates/material" + "modern", + "templates/examinemodern" ], + "sitemap": { + "baseUrl": "https://shazwazza.github.io/Examine", + "priority": 0.1, + "changefreq": "monthly" + }, "postProcessors": ["ExtractSearchIndex"], "markdownEngineName": "markdig", "noLangKeyword": false, @@ -76,6 +82,7 @@ "disableGitFeatures": false, "globalMetadata": { "_appTitle": "Examine", + "_appName": "Examine", "_appFooter": "Examine", "_enableSearch": true, "_gitContribute": { @@ -85,6 +92,8 @@ }, "_appLogoPath": "images/headerlogo.png", "_appFaviconPath": "images/favicon.ico" + }, + "fileMetadata": { } } } diff --git a/docs/docs-v1-v2/configuration.md b/docs/docs-v1-v2/configuration.md index 70b09967e..ad8c4a897 100644 --- a/docs/docs-v1-v2/configuration.md +++ b/docs/docs-v1-v2/configuration.md @@ -1,5 +1,4 @@ --- -layout: page title: V1/V2 Configuration permalink: /configuration uid: v2configuration diff --git a/docs/docs-v1-v2/indexing.md b/docs/docs-v1-v2/indexing.md index b439a0749..4387c161a 100644 --- a/docs/docs-v1-v2/indexing.md +++ b/docs/docs-v1-v2/indexing.md @@ -1,5 +1,4 @@ --- -layout: page title: V1/V2 Indexing permalink: /indexing uid: v2indexing diff --git a/docs/docs-v1-v2/searching.md b/docs/docs-v1-v2/searching.md index bca9a0a37..2905d7804 100644 --- a/docs/docs-v1-v2/searching.md +++ b/docs/docs-v1-v2/searching.md @@ -1,5 +1,4 @@ --- -layout: page title: V1/V2 Searching permalink: /searching uid: v2searching diff --git a/docs/docs-v1-v2/sorting.md b/docs/docs-v1-v2/sorting.md index 9382d79cc..4d21aa7de 100644 --- a/docs/docs-v1-v2/sorting.md +++ b/docs/docs-v1-v2/sorting.md @@ -1,5 +1,4 @@ --- -layout: page title: V1/V2 Sorting permalink: /sorting uid: v2sorting diff --git a/docs/index.md b/docs/index.md index ef03772e4..f55119ebb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,6 +36,7 @@ Releases are available [here](https://github.com/Shazwazza/Examine/releases) and | Examine Version | .NET | | --------------- | ---- | +| V4 | .NET Standard 2.0 | | V3 | .NET Standard 2.0 | | V2 | .NET Standard 2.0 | | V1 | .NET Framework 4.5.2 | @@ -47,7 +48,7 @@ Releases are available [here](https://github.com/Shazwazza/Examine/releases) and 1. Install ```powershell - > dotnet add package Examine --version 3.0.1 + > dotnet add package Examine --version 4.0.0-beta1 ``` 1. Configure Services and create an index diff --git a/docs/suggesting.md b/docs/suggesting.md new file mode 100644 index 000000000..f25dade17 --- /dev/null +++ b/docs/suggesting.md @@ -0,0 +1,83 @@ +--- +layout: page +title: Suggesting +permalink: /suggesting +ref: suggesting +order: 2 +--- +Suggesting and Spell Checking +=== + +_**Tip**: There are many examples of searching in the [`SuggesterApiTests` source code](https://github.com/Shazwazza/Examine/blob/master/src/Examine.Test/Lucene/Suggest/SuggesterApiTests.cs) to use as examples/reference._ + + +## Registering Suggesters + +On the index to register the Suggesters, create a SuggesterDefinitionCollection and set it on IndexOptions.SuggesterDefinitions + +Example + +```cs + var suggesters = new SuggesterDefinitionCollection(); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.AnalyzingInfixSuggester, ExamineLuceneSuggesterNames.AnalyzingInfixSuggester, new string[] { "fullName" })); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.AnalyzingSuggester, ExamineLuceneSuggesterNames.AnalyzingSuggester, new string[] { "fullName" })); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker, ExamineLuceneSuggesterNames.DirectSpellChecker, new string[] { "fullName" })); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker_LevensteinDistance, ExamineLuceneSuggesterNames.DirectSpellChecker_LevensteinDistance, new string[] { "fullName" })); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker_JaroWinklerDistance, ExamineLuceneSuggesterNames.DirectSpellChecker_JaroWinklerDistance, new string[] { "fullName" })); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker_NGramDistance, ExamineLuceneSuggesterNames.DirectSpellChecker_NGramDistance, new string[] { "fullName" })); + suggesters.AddOrUpdate(new SuggesterDefinition(ExamineLuceneSuggesterNames.FuzzySuggester, ExamineLuceneSuggesterNames.FuzzySuggester, new string[] { "fullName" })); +``` + +## Suggester API + +```cs +var suggester = index.Suggester; +var query = suggester.CreateSuggestionQuery(); +var results = query.Execute("Sam", new SuggestionOptions(5,ExamineLuceneSuggesterNames.AnalyzingSuggester)); +``` + +This code will run a suggestion for the input text "Sam", returning up to 5 suggestions. + +## Lucene Suggesters + +To generate suggestions for input text, retreive the ISuggester from the index IIndex.Suggester. + +### Analyzing Suggester + +```cs +var suggester = index.Suggester; +var query = suggester.CreateSuggestionQuery(); +var results = query.Execute("Sam", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.AnalyzingInfixSuggester)); +``` + +This code will run a suggestion for the input text "Sam", returning up to 5 suggestions, and will highlight the result text which matches the input text.. + +### Analyzing Suggester + +```cs +var suggester = index.Suggester; +var query = suggester.CreateSuggestionQuery(); +var results = query.Execute("Sam", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.AnalyzingSuggester)); +``` + +This code will run a suggestion for the input text "Sam", returning up to 5 suggestions. + +### Fuzzy Suggester + +```cs +var suggester = index.Suggester; +var query = suggester.CreateSuggestionQuery(); +var results = query.Execute("Sam", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.FuzzySuggester)); +``` + +This code will run a Fuzzy suggestion for the input text "Sam", returning up to 5 suggestions. + +### Direct SpellChecker Suggester + +```cs +var suggester = index.Suggester; +var query = suggester.CreateSuggestionQuery(); +var results = query.Execute("Sam", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.DirectSpellChecker)); +``` + +This code will run a spellchecker suggestion for the input text "Sam" returning up to 5 suggestions. diff --git a/docs/templates/examinemodern/public/main.css b/docs/templates/examinemodern/public/main.css new file mode 100644 index 000000000..98636c8b6 --- /dev/null +++ b/docs/templates/examinemodern/public/main.css @@ -0,0 +1,41 @@ +/* COLOR VARIABLES*/ +:root { + --header-bg-color: #159957; + --header-ft-color: #fff; + --highlight-light: #5e92f3; + --highlight-dark: #003c8f; + --accent-dim: #e0e0e0; + --accent-super-dim: #f3f3f3; + --font-color: #34393e; + --card-box-shadow: 0 1px 2px 0 rgba(61, 65, 68, 0.06), 0 1px 3px 1px rgba(61, 65, 68, 0.16); + --search-box-shadow: 0 1px 2px 0 rgba(41, 45, 48, 0.36), 0 1px 3px 1px rgba(41, 45, 48, 0.46); + --transition: 350ms; +} +/* Examine Docs Overrides */ + +/* Logo */ +#logo { + vertical-align: middle; + width: 40px; + height: 40px; + background-color: #fff; + border-radius: 20px; + margin:5px; +} +/* +.navbar { + color: var(--header-ft-color); + --bs-navbar-active-color: #fff; + --bs-nav-link-color: #fff; + background-color: var(--hergb(2, 2, 2)-bg-color); + background-image: linear-gradient(120deg, #155799, var(--header-bg-color)); +} + +.navbar-inverse .navbar-nav > li > a, +.navbar-inverse .navbar-text { + background-color: transparent; +} + +h1,h2,h3,h4,h5{ + color: var(--highlight-dark); +} */ \ No newline at end of file diff --git a/docs/templates/material/partials/head.tmpl.partial b/docs/templates/material/partials/head.tmpl.partial deleted file mode 100644 index 6ff01af7f..000000000 --- a/docs/templates/material/partials/head.tmpl.partial +++ /dev/null @@ -1,29 +0,0 @@ -{{!Copyright (c) Oscar Vasquez. All rights reserved. Licensed under the MIT license. See LICENSE file in the project root for full license information.}} - - - - - {{#title}}{{title}}{{/title}}{{^title}}{{>partials/title}}{{/title}} {{#_appTitle}}| {{_appTitle}} {{/_appTitle}} - - - - {{#_description}}{{/_description}} - - - - - - - - {{#_noindex}}{{/_noindex}} - {{#_enableSearch}}{{/_enableSearch}} - {{#_enableNewTab}}{{/_enableNewTab}} - - - - - - - - - \ No newline at end of file diff --git a/docs/templates/material/styles/main.css b/docs/templates/material/styles/main.css deleted file mode 100644 index 77d29effb..000000000 --- a/docs/templates/material/styles/main.css +++ /dev/null @@ -1,337 +0,0 @@ -/* COLOR VARIABLES*/ -:root { - --header-bg-color: #159957; - --header-ft-color: #fff; - --highlight-light: #5e92f3; - --highlight-dark: #003c8f; - --accent-dim: #e0e0e0; - --accent-super-dim: #f3f3f3; - --font-color: #34393e; - --card-box-shadow: 0 1px 2px 0 rgba(61, 65, 68, 0.06), 0 1px 3px 1px rgba(61, 65, 68, 0.16); - --search-box-shadow: 0 1px 2px 0 rgba(41, 45, 48, 0.36), 0 1px 3px 1px rgba(41, 45, 48, 0.46); - --transition: 350ms; -} - -body { - color: var(--font-color); - font-family: "Roboto", sans-serif; - line-height: 1.5; - font-size: 16px; - -ms-text-size-adjust: 100%; - -webkit-text-size-adjust: 100%; - word-wrap: break-word; -} - -/* HIGHLIGHT COLOR */ - -button, -a { - color: var(--highlight-dark); - cursor: pointer; -} - -button:hover, -button:focus, -a:hover, -a:focus { - color: var(--highlight-light); - text-decoration: none; -} - -.toc .nav > li.active > a { - color: var(--highlight-dark); -} - -.toc .nav > li.active > a:hover, -.toc .nav > li.active > a:focus { - color: var(--highlight-light); -} - -.pagination > .active > a { - background-color: var(--header-bg-color); - border-color: var(--header-bg-color); -} - -.pagination > .active > a, -.pagination > .active > a:focus, -.pagination > .active > a:hover, -.pagination > .active > span, -.pagination > .active > span:focus, -.pagination > .active > span:hover { - background-color: var(--highlight-light); - border-color: var(--highlight-light); -} - -/* HEADINGS */ - -h1 { - font-weight: 600; - font-size: 32px; -} - -h2 { - font-weight: 600; - font-size: 24px; - line-height: 1.8; -} - -h3 { - font-weight: 600; - font-size: 20px; - line-height: 1.8; -} - -h5 { - font-size: 14px; - padding: 10px 0px; -} - -article h1, -article h2, -article h3, -article h4 { - margin-top: 35px; - margin-bottom: 15px; -} - -article h4 { - padding-bottom: 8px; - border-bottom: 2px solid #ddd; -} - -/* NAVBAR */ - -.navbar-brand > img { - color: var(--header-ft-color); -} - -.navbar { - border: none; - /* Both navbars use box-shadow */ - -webkit-box-shadow: var(--card-box-shadow); - -moz-box-shadow: var(--card-box-shadow); - box-shadow: var(--card-box-shadow); -} - -.subnav { - border-top: 1px solid #ddd; - background-color: #fff; -} - -.navbar-inverse { - background-color: var(--header-bg-color); - z-index: 100; -} - -.navbar-inverse .navbar-nav > li > a, -.navbar-inverse .navbar-text { - color: var(--header-ft-color); - background-color: var(--header-bg-color); - border-bottom: 3px solid transparent; - padding-bottom: 12px; - transition: 350ms; -} - -.navbar-inverse .navbar-nav > li > a:focus, -.navbar-inverse .navbar-nav > li > a:hover { - color: var(--header-ft-color); - background-color: var(--header-bg-color); - border-bottom: 3px solid white; -} - -.navbar-inverse .navbar-nav > .active > a, -.navbar-inverse .navbar-nav > .active > a:focus, -.navbar-inverse .navbar-nav > .active > a:hover { - color: var(--header-ft-color); - background-color: var(--header-bg-color); - border-bottom: 3px solid white; -} - -.navbar-form .form-control { - border: 0; - border-radius: 4px; - box-shadow: var(--search-box-shadow); - transition:var(--transition); -} - -.navbar-form .form-control:hover { - background-color: var(--accent-dim); -} - -/* NAVBAR TOGGLED (small screens) */ - -.navbar-inverse .navbar-collapse, .navbar-inverse .navbar-form { - border: none; -} -.navbar-inverse .navbar-toggle { - box-shadow: var(--card-box-shadow); - border: none; -} - -.navbar-inverse .navbar-toggle:focus, -.navbar-inverse .navbar-toggle:hover { - background-color: var(--highlight-dark); -} - -/* SIDEBAR */ - -.toc .level1 > li { - font-weight: 400; -} - -.toc .nav > li > a { - color: var(--font-color); -} - -.sidefilter { - background-color: #fff; - border-left: none; - border-right: none; -} - -.sidefilter { - background-color: #fff; - border-left: none; - border-right: none; -} - -.toc-filter { - padding: 5px; - margin: 0; - box-shadow: var(--card-box-shadow); - transition:var(--transition); -} - -.toc-filter:hover { - background-color: var(--accent-super-dim); -} - -.toc-filter > input { - border: none; - background-color: inherit; - transition: inherit; -} - -.toc-filter > .filter-icon { - display: none; -} - -.sidetoc > .toc { - background-color: #fff; - overflow-x: hidden; -} - -.sidetoc { - background-color: #fff; - border: none; -} - -/* ALERTS */ - -.alert { - padding: 0px 0px 5px 0px; - color: inherit; - background-color: inherit; - border: none; - box-shadow: var(--card-box-shadow); -} - -.alert > p { - margin-bottom: 0; - padding: 5px 10px; -} - -.alert > ul { - margin-bottom: 0; - padding: 5px 40px; -} - -.alert > h5 { - padding: 10px 15px; - margin-top: 0; - text-transform: uppercase; - font-weight: bold; - border-radius: 4px 4px 0 0; -} - -.alert-info > h5 { - color: #1976d2; - border-bottom: 4px solid #1976d2; - background-color: #e3f2fd; -} - -.alert-warning > h5 { - color: #f57f17; - border-bottom: 4px solid #f57f17; - background-color: #fff3e0; -} - -.alert-danger > h5 { - color: #d32f2f; - border-bottom: 4px solid #d32f2f; - background-color: #ffebee; -} - -/* CODE HIGHLIGHT */ -pre { - padding: 9.5px; - margin: 0 0 10px; - font-size: 13px; - word-break: break-all; - word-wrap: break-word; - background-color: #fffaef; - border-radius: 4px; - border: none; - box-shadow: var(--card-box-shadow); -} - -/* STYLE FOR IMAGES */ - -.article .small-image { - margin-top: 15px; - box-shadow: var(--card-box-shadow); - max-width: 350px; -} - -.article .medium-image { - margin-top: 15px; - box-shadow: var(--card-box-shadow); - max-width: 550px; -} - -.article .large-image { - margin-top: 15px; - box-shadow: var(--card-box-shadow); - max-width: 700px; -} - -/* Examine Docs Overrides */ - -/* Logo */ -#logo { - vertical-align: middle; - width: 40px; - height: 40px; - background-color: #fff; - border-radius: 20px; - margin:5px; -} - -.navbar { - color: var(--header-ft-color); - background-color: var(--header-bg-color); - background-image: linear-gradient(120deg, #155799, var(--header-bg-color)); -} - -.navbar-inverse .navbar-nav > li > a, -.navbar-inverse .navbar-text { - background-color: transparent; -} - -.subnav.navbar * { - color: var(--header-ft-color); -} - -h1,h2,h3,h4,h5{ - color: var(--highlight-dark); -} \ No newline at end of file diff --git a/docs/toc.yml b/docs/toc.yml index f060c3a02..26c2e3d9b 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1,9 +1,9 @@ - name: User Guide - href: articles/ + href: articles/toc.yml - name: Api Documentation href: api/ homepage: api/index.md - name: Developer Guide - href: developerguides/ + href: developerguides/toc.yml - name: Source Code href: https://github.com/Shazwazza/Examine diff --git a/src/Examine.Core/BaseIndexProvider.cs b/src/Examine.Core/BaseIndexProvider.cs index f952396c4..2140aefea 100644 --- a/src/Examine.Core/BaseIndexProvider.cs +++ b/src/Examine.Core/BaseIndexProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Examine.Suggest; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -82,6 +83,10 @@ protected abstract void PerformDeleteFromIndex(IEnumerable itemIds, /// public abstract ISearcher Searcher { get; } + /// + public abstract ISuggester Suggester { get; } + + /// /// /// Validates the items and calls /// diff --git a/src/Examine.Core/ExamineManager.cs b/src/Examine.Core/ExamineManager.cs index a057ea101..de185be21 100644 --- a/src/Examine.Core/ExamineManager.cs +++ b/src/Examine.Core/ExamineManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using Examine.Suggest; namespace Examine { @@ -14,7 +15,26 @@ public class ExamineManager : IDisposable, IExamineManager /// public ExamineManager(IEnumerable indexes, IEnumerable searchers) { - foreach(var i in indexes) + foreach (var i in indexes) + { + AddIndex(i); + } + + foreach (var s in searchers) + { + AddSearcher(s); + } + } + + /// + /// Constructor + /// + /// Examine Indexes + /// Examine Searchers + /// Examine Suggesters + public ExamineManager(IEnumerable indexes, IEnumerable searchers, IEnumerable suggesters) + { + foreach(IIndex i in indexes) { AddIndex(i); } @@ -23,10 +43,16 @@ public ExamineManager(IEnumerable indexes, IEnumerable search { AddSearcher(s); } + + foreach (ISuggester s in suggesters) + { + AddSuggester(s); + } } private readonly ConcurrentDictionary _indexers = new ConcurrentDictionary(StringComparer.InvariantCultureIgnoreCase); private readonly ConcurrentDictionary _searchers = new ConcurrentDictionary(StringComparer.InvariantCultureIgnoreCase); + private readonly ConcurrentDictionary _suggesters = new ConcurrentDictionary(StringComparer.InvariantCultureIgnoreCase); /// public bool TryGetSearcher(string searcherName, @@ -44,12 +70,20 @@ public bool TryGetIndex(string indexName, out IIndex index) => (index = _indexers.TryGetValue(indexName, out var i) ? i : null) != null; + + /// + public bool TryGetSuggester(string suggesterName, out ISuggester suggester) => + (suggester = _suggesters.TryGetValue(suggesterName, out var s) ? s : null) != null; + /// public IEnumerable RegisteredSearchers => _searchers.Values; /// public IEnumerable Indexes => _indexers.Values; - + + /// + public IEnumerable RegisteredSuggesters => _suggesters.Values; + private IIndex AddIndex(IIndex index) { //make sure this name doesn't exist in @@ -72,6 +106,17 @@ private ISearcher AddSearcher(ISearcher searcher) return searcher; } + private ISuggester AddSuggester(ISuggester suggester) + { + //make sure this name doesn't exist in + if (!_suggesters.TryAdd(suggester.Name, suggester)) + { + throw new InvalidOperationException("The suggester with name " + suggester.Name + " already exists"); + } + + return suggester; + } + /// /// Call this in Application_End. /// @@ -122,7 +167,18 @@ public virtual void Stop(bool immediate) { // we don't want to kill the app or anything, even though it is terminating, best to just ensure that // no strange lucene background thread stuff causes issues here. - } + } + try + { + foreach (var suggester in RegisteredSuggesters.OfType()) + { + suggester.Dispose(); + } + } + catch { + // we don't want to kill the app or anything, even though it is terminating, best to just ensure that + // no strange lucene background thread stuff causes issues here. + } } else { diff --git a/src/Examine.Core/IExamineManager.cs b/src/Examine.Core/IExamineManager.cs index c2583f246..5663aa8b0 100644 --- a/src/Examine.Core/IExamineManager.cs +++ b/src/Examine.Core/IExamineManager.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using Examine.Suggest; namespace Examine { @@ -25,6 +26,14 @@ public interface IExamineManager /// IEnumerable RegisteredSearchers { get; } + /// + /// Gets a list of all manually configured suggester providers + /// + /// + /// This returns only those suggesters explicitly registered with AddSuggester or config based suggesters + /// + IEnumerable RegisteredSuggesters { get; } + /// /// Disposes the /// @@ -56,5 +65,15 @@ bool TryGetSearcher(string searcherName, #endif out ISearcher searcher); + /// + /// Returns a sugesster that was registered with AddSuggester or via config + /// + /// + /// + /// + /// true if the suggester was found by name + /// + bool TryGetSuggester(string suggesterName, out ISuggester suggester); + } } diff --git a/src/Examine.Core/IIndex.cs b/src/Examine.Core/IIndex.cs index b584abead..bc7c9c278 100644 --- a/src/Examine.Core/IIndex.cs +++ b/src/Examine.Core/IIndex.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Examine.Suggest; namespace Examine { @@ -61,5 +62,11 @@ public interface IIndex /// Occurs for an Indexing Error /// event EventHandler IndexingError; + + /// + /// Returns a suggester for the index + /// + /// + ISuggester Suggester { get; } } } diff --git a/src/Examine.Core/IndexOptions.cs b/src/Examine.Core/IndexOptions.cs index fc1bf4693..ddc5b7d88 100644 --- a/src/Examine.Core/IndexOptions.cs +++ b/src/Examine.Core/IndexOptions.cs @@ -5,10 +5,12 @@ namespace Examine /// public class IndexOptions { + /// public IndexOptions() { FieldDefinitions = new FieldDefinitionCollection(); + SuggesterDefinitions = new SuggesterDefinitionCollection(); } /// @@ -20,5 +22,10 @@ public IndexOptions() /// The validator for the /// public IValueSetValidator? Validator { get; set; } + + /// + /// The suggester definitions for the + /// + public SuggesterDefinitionCollection SuggesterDefinitions { get; set; } } } diff --git a/src/Examine.Core/ReadOnlySuggesterDefinitionCollection.cs b/src/Examine.Core/ReadOnlySuggesterDefinitionCollection.cs new file mode 100644 index 000000000..36a9aa6e2 --- /dev/null +++ b/src/Examine.Core/ReadOnlySuggesterDefinitionCollection.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Concurrent; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace Examine +{ + /// + /// Manages the mappings between a suggester name and it's index type + /// + public class ReadOnlySuggesterDefinitionCollection : IEnumerable + { + /// + /// Constructor + /// + public ReadOnlySuggesterDefinitionCollection() + : this(Enumerable.Empty()) + { + } + + /// + /// Constructor + /// + /// Suggester Definitions + public ReadOnlySuggesterDefinitionCollection(params SuggesterDefinition[] definitions) + : this((IEnumerable)definitions) + { + + } + + /// + /// Constructor + /// + /// Suggester Definitions + public ReadOnlySuggesterDefinitionCollection(IEnumerable definitions) + { + if (definitions == null) + return; + + foreach (var s in definitions.GroupBy(x => x.Name)) + { + var suggester = s.FirstOrDefault(); + if (suggester != default) + { + Definitions.TryAdd(s.Key, suggester); + } + } + } + + /// + /// Tries to get a by suggester name + /// + /// + /// + /// + /// returns true if one was found otherwise false + /// + /// + /// Marked as virtual so developers can inherit this class and override this method in case + /// suggester definitions are dynamic. + /// + public virtual bool TryGetValue(string suggesterName, out SuggesterDefinition suggesterDefinition) => Definitions.TryGetValue(suggesterName, out suggesterDefinition); + + /// + /// Count of Suggesters + /// + public int Count => Definitions.Count; + + /// + /// Suggester Definitions + /// + protected ConcurrentDictionary Definitions { get; } = new ConcurrentDictionary(StringComparer.InvariantCultureIgnoreCase); + + /// + public IEnumerator GetEnumerator() => Definitions.Values.GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/Examine.Core/Suggest/BaseSuggesterProvider.cs b/src/Examine.Core/Suggest/BaseSuggesterProvider.cs new file mode 100644 index 000000000..e6a551736 --- /dev/null +++ b/src/Examine.Core/Suggest/BaseSuggesterProvider.cs @@ -0,0 +1,24 @@ +using System; + +namespace Examine.Suggest +{ + /// + public abstract class BaseSuggesterProvider : ISuggester + { + /// + protected BaseSuggesterProvider(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)); + Name = name; + } + /// + public string Name { get; } + + /// + public abstract ISuggestionQuery CreateSuggestionQuery(); + + /// + public abstract ISuggestionResults Suggest(string searchText, SuggestionOptions? options = null); + } +} diff --git a/src/Examine.Core/Suggest/ISuggester.cs b/src/Examine.Core/Suggest/ISuggester.cs new file mode 100644 index 000000000..706689a0d --- /dev/null +++ b/src/Examine.Core/Suggest/ISuggester.cs @@ -0,0 +1,28 @@ +namespace Examine.Suggest +{ + /// + /// An interface representing an Examine Suggester. + /// + public interface ISuggester + { + /// + /// Suggester Name. + /// + string Name { get; } + + /// + /// Suggest query terms for the given search Text + /// + /// Text to suggest on + /// Options + /// + ISuggestionResults Suggest(string searchText, SuggestionOptions? options = null); + + /// + /// Creates a Suggestion Query + /// + /// Query + ISuggestionQuery CreateSuggestionQuery(); + + } +} diff --git a/src/Examine.Core/Suggest/ISuggestionExecutor.cs b/src/Examine.Core/Suggest/ISuggestionExecutor.cs new file mode 100644 index 000000000..510d3b584 --- /dev/null +++ b/src/Examine.Core/Suggest/ISuggestionExecutor.cs @@ -0,0 +1,13 @@ +namespace Examine.Suggest +{ + /// + /// Executes a Suggester + /// + public interface ISuggestionExecutor + { + /// + /// Executes the query + /// + ISuggestionResults Execute(string searchText, SuggestionOptions? options = null); + } +} diff --git a/src/Examine.Core/Suggest/ISuggestionQuery.cs b/src/Examine.Core/Suggest/ISuggestionQuery.cs new file mode 100644 index 000000000..71038db78 --- /dev/null +++ b/src/Examine.Core/Suggest/ISuggestionQuery.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Examine.Suggest +{ + /// + /// Search Suggestion Query + /// + public interface ISuggestionQuery : ISuggestionExecutor + { + } +} diff --git a/src/Examine.Core/Suggest/ISuggestionResult.cs b/src/Examine.Core/Suggest/ISuggestionResult.cs new file mode 100644 index 000000000..14af7baac --- /dev/null +++ b/src/Examine.Core/Suggest/ISuggestionResult.cs @@ -0,0 +1,23 @@ +namespace Examine.Suggest +{ + /// + /// Suggestion Result + /// + public interface ISuggestionResult + { + /// + /// Suggestion Text + /// + string Text { get; } + + /// + /// Suggestion Weight + /// + float? Weight { get; } + + /// + /// Frequency of Suggestion text occurance + /// + int? Frequency { get; } + } +} diff --git a/src/Examine.Core/Suggest/ISuggestionResults.cs b/src/Examine.Core/Suggest/ISuggestionResults.cs new file mode 100644 index 000000000..395f043cf --- /dev/null +++ b/src/Examine.Core/Suggest/ISuggestionResults.cs @@ -0,0 +1,12 @@ +using System.Collections; +using System.Collections.Generic; + +namespace Examine.Suggest +{ + /// + /// Suggestion Results + /// + public interface ISuggestionResults : IEnumerable + { + } +} diff --git a/src/Examine.Core/Suggest/SuggestionOptions.cs b/src/Examine.Core/Suggest/SuggestionOptions.cs new file mode 100644 index 000000000..e83976902 --- /dev/null +++ b/src/Examine.Core/Suggest/SuggestionOptions.cs @@ -0,0 +1,33 @@ +namespace Examine.Suggest +{ + /// + /// Suggester Options + /// + public class SuggestionOptions + { + /// + /// Constructor + /// + /// Clamp number of results + /// The name of the Suggester to use + public SuggestionOptions(int top = 5, string suggesterName = null) + { + Top = top; + if (top < 0) + { + top = 0; + } + SuggesterName = suggesterName; + } + + /// + /// Clamp number of results. + /// + public int Top { get; } + + /// + /// The name of the Suggester to use + /// + public string SuggesterName { get; } + } +} diff --git a/src/Examine.Core/Suggest/SuggestionResult.cs b/src/Examine.Core/Suggest/SuggestionResult.cs new file mode 100644 index 000000000..edfb61834 --- /dev/null +++ b/src/Examine.Core/Suggest/SuggestionResult.cs @@ -0,0 +1,30 @@ +namespace Examine.Suggest +{ + /// + /// Suggestion Result + /// + public class SuggestionResult : ISuggestionResult + { + /// + /// Constructor + /// + /// Suggestion Text + /// Suggestion Weight + /// Suggestion Text frequency + public SuggestionResult(string text, float? weight = null, int? frequency = null) + { + Text = text; + Weight = weight; + Frequency = frequency; + } + + /// + public string Text { get; } + + /// + public float? Weight { get; } + + /// + public int? Frequency { get; } + } +} diff --git a/src/Examine.Core/SuggesterDefinition.cs b/src/Examine.Core/SuggesterDefinition.cs new file mode 100644 index 000000000..38c69f407 --- /dev/null +++ b/src/Examine.Core/SuggesterDefinition.cs @@ -0,0 +1,37 @@ +using System; + +namespace Examine +{ + /// + /// Defines a suggester for an Index + /// + public class SuggesterDefinition + { + /// + /// Constructor + /// + /// Name of the suggester + /// Source Index Fields for the Suggester + public SuggesterDefinition(string name, string[]? sourceFields = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)); + } + + Name = name; + SourceFields = sourceFields; + } + + /// + /// The name of the Suggester + /// + public string Name { get; } + + /// + /// Suggester Source Fields + /// + public string[]? SourceFields { get; } + + } +} diff --git a/src/Examine.Core/SuggesterDefinitionCollection.cs b/src/Examine.Core/SuggesterDefinitionCollection.cs new file mode 100644 index 000000000..db255ed1c --- /dev/null +++ b/src/Examine.Core/SuggesterDefinitionCollection.cs @@ -0,0 +1,46 @@ +using System; + +namespace Examine +{ + /// + /// Collection of Suggester Definitions on an Index + /// + public class SuggesterDefinitionCollection : ReadOnlySuggesterDefinitionCollection + { + /// + /// Constructor + /// + /// Suggester Definitions + public SuggesterDefinitionCollection(params SuggesterDefinition[] definitions) : base(definitions) + { + } + + /// + /// Constructor + /// + public SuggesterDefinitionCollection() + { + } + + /// + /// Get or Add a Suggester Definition + /// + /// Name of Suggester + /// Function to add Suggester + /// + public SuggesterDefinition GetOrAdd(string suggesterName, Func add) => Definitions.GetOrAdd(suggesterName, add); + + /// + /// Replace any definition with the specified one, if one doesn't exist then it is added + /// + /// + public void AddOrUpdate(SuggesterDefinition definition) => Definitions.AddOrUpdate(definition.Name, definition, (s, factory) => definition); + + /// + /// Try Add a Suggester Definition + /// + /// Suggester Defintion + /// Whether the Suggester was added + public bool TryAdd(SuggesterDefinition definition) => Definitions.TryAdd(definition.Name, definition); + } +} diff --git a/src/Examine.Host/ServicesCollectionExtensions.cs b/src/Examine.Host/ServicesCollectionExtensions.cs index 4d7c72614..bc87c198e 100644 --- a/src/Examine.Host/ServicesCollectionExtensions.cs +++ b/src/Examine.Host/ServicesCollectionExtensions.cs @@ -5,6 +5,8 @@ using Examine.Lucene; using Examine.Lucene.Directories; using Examine.Lucene.Providers; +using Examine.Lucene.Suggest; +using Examine.Suggest; using Lucene.Net.Analysis; using Lucene.Net.Facet; using Microsoft.Extensions.DependencyInjection; @@ -148,7 +150,7 @@ public static IServiceCollection AddExamineLuceneIndex( /// /// /// - /// A factory to fullfill the custom searcher construction parameters excluding the name that are not already registerd in DI. + /// A factory to fullfill the custom searcher construction parameters excluding the name that are not already registered in DI. /// /// public static IServiceCollection AddExamineSearcher( @@ -261,5 +263,32 @@ public static IServiceCollection AddExamine(this IServiceCollection services, Di return services; } + + /// + /// Registers a standalone Examine suggester + /// + /// + /// + /// + /// + /// A factory to fullfill the custom suggester construction parameters excluding the name that are not already registerd in DI. + /// + /// + public static IServiceCollection AddExamineSuggester( + this IServiceCollection serviceCollection, + string name, + Func> parameterFactory) + where TSuggester : ISuggester + => serviceCollection.AddTransient(services => + { + IList parameters = parameterFactory(services); + parameters.Insert(0, name); + + ISuggester suggester = ActivatorUtilities.CreateInstance( + services, + parameters.ToArray()); + + return suggester; + }); } } diff --git a/src/Examine.Lucene/Examine.Lucene.csproj b/src/Examine.Lucene/Examine.Lucene.csproj index 0ddf6cadd..69e63195b 100644 --- a/src/Examine.Lucene/Examine.Lucene.csproj +++ b/src/Examine.Lucene/Examine.Lucene.csproj @@ -30,6 +30,7 @@ 4.8.0-beta00016 + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Examine.Lucene/Indexing/FullTextType.cs b/src/Examine.Lucene/Indexing/FullTextType.cs index afa50ef73..7a6f841be 100644 --- a/src/Examine.Lucene/Indexing/FullTextType.cs +++ b/src/Examine.Lucene/Indexing/FullTextType.cs @@ -1,10 +1,13 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using Examine.Lucene.Analyzers; using Examine.Lucene.Providers; using Examine.Lucene.Search; using Examine.Search; +using Examine.Lucene.Suggest; +using Examine.Suggest; using Lucene.Net.Analysis; using Lucene.Net.Analysis.Miscellaneous; using Lucene.Net.Analysis.TokenAttributes; @@ -13,7 +16,13 @@ using Lucene.Net.Facet.SortedSet; using Lucene.Net.Index; using Lucene.Net.Search; +using Lucene.Net.Search.Spell; +using Lucene.Net.Search.Suggest; +using Lucene.Net.Search.Suggest.Analyzing; +using Lucene.Net.Util; using Microsoft.Extensions.Logging; +using static Lucene.Net.Search.Suggest.Lookup; +using LuceneDirectory = Lucene.Net.Store.Directory; namespace Examine.Lucene.Indexing { @@ -28,6 +37,8 @@ namespace Examine.Lucene.Indexing public class FullTextType : IndexFieldValueTypeBase, IIndexFacetValueType { private readonly bool _sortable; + private readonly Analyzer _searchAnalyzer; + private readonly Func _lookup; private readonly Analyzer _analyzer; private readonly bool _isFacetable; #pragma warning disable IDE0032 // Use auto property @@ -68,10 +79,32 @@ public FullTextType(string fieldName, ILoggerFactory logger, bool isFacetable, b public FullTextType(string fieldName, ILoggerFactory logger, Analyzer? analyzer = null, bool sortable = false) #pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads : base(fieldName, logger, true) +#pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads { _sortable = sortable; _analyzer = analyzer ?? new CultureInvariantStandardAnalyzer(); _isFacetable = false; + _searchAnalyzer = _analyzer; + } + + /// + /// Constructor + /// + /// + /// + /// Defaults to + /// + /// + /// + /// + /// + public FullTextType(string fieldName, ILoggerFactory logger, Func lookup, Analyzer analyzer, bool sortable, Analyzer searchAnalyzer) + : base(fieldName, logger, true) + { + _sortable = sortable; + _analyzer = analyzer ?? new CultureInvariantStandardAnalyzer(); + _searchAnalyzer = searchAnalyzer ?? _analyzer; + _lookup = lookup; } /// @@ -119,6 +152,9 @@ public override void AddValue(Document doc, object? value) } /// + /// + public override Analyzer SearchAnalyzer => _searchAnalyzer; + protected override void AddSingleValue(Document doc, object value) { if (TryConvert(value, out var str)) diff --git a/src/Examine.Lucene/Indexing/IIndexFieldValueType.cs b/src/Examine.Lucene/Indexing/IIndexFieldValueType.cs index de09fa5a8..1033fcf1c 100644 --- a/src/Examine.Lucene/Indexing/IIndexFieldValueType.cs +++ b/src/Examine.Lucene/Indexing/IIndexFieldValueType.cs @@ -1,5 +1,4 @@ using Lucene.Net.Analysis; -using Lucene.Net.Analysis.Miscellaneous; using Lucene.Net.Documents; using Lucene.Net.Search; @@ -27,7 +26,7 @@ public interface IIndexFieldValueType bool Store { get; } /// - /// Returns the analyzer for this field type, or null to use the default + /// Returns the index time analyzer for this field type, or null to use the default /// Analyzer? Analyzer { get; } @@ -44,6 +43,10 @@ public interface IIndexFieldValueType /// /// Query? GetQuery(string query); + /// + /// Returns the search time analyzer for this field type, or null to use the default + /// + Analyzer SearchAnalyzer { get; } //IHighlighter GetHighlighter(Query query, Searcher searcher, FacetsLoader facetsLoader); diff --git a/src/Examine.Lucene/Indexing/IndexFieldValueTypeBase.cs b/src/Examine.Lucene/Indexing/IndexFieldValueTypeBase.cs index 9709eca46..15415fd4e 100644 --- a/src/Examine.Lucene/Indexing/IndexFieldValueTypeBase.cs +++ b/src/Examine.Lucene/Indexing/IndexFieldValueTypeBase.cs @@ -32,6 +32,8 @@ protected IndexFieldValueTypeBase(string fieldName, ILoggerFactory loggerFactory /// public virtual Analyzer? Analyzer => null; + public virtual Analyzer SearchAnalyzer => null; + /// /// The logger /// diff --git a/src/Examine.Lucene/Providers/LuceneIndex.cs b/src/Examine.Lucene/Providers/LuceneIndex.cs index 5a25b3c8a..917c10df0 100644 --- a/src/Examine.Lucene/Providers/LuceneIndex.cs +++ b/src/Examine.Lucene/Providers/LuceneIndex.cs @@ -21,6 +21,8 @@ using Lucene.Net.Facet.Taxonomy; using Lucene.Net.Facet.Taxonomy.Directory; using static Lucene.Net.Replicator.IndexAndTaxonomyRevision; +using Examine.Suggest; +using Examine.Lucene.Suggest; namespace Examine.Lucene.Providers { @@ -127,6 +129,11 @@ private LuceneIndex( _cancellationToken = _cancellationTokenSource.Token; DefaultAnalyzer = _options.Analyzer ?? new StandardAnalyzer(LuceneInfo.CurrentVersion); + + //initialize the field types + _suggesterDefinitionCollection = _options.SuggesterDefinitions; + + _suggester = new Lazy(CreateSuggesters); } /// @@ -173,6 +180,7 @@ internal LuceneIndex( private readonly LuceneIndexOptions _options; private PerFieldAnalyzerWrapper? _fieldAnalyzer; private ControlledRealTimeReopenThread? _nrtReopenThread; + private ControlledRealTimeReopenThread _nrtSuggesterReopenThread; private readonly ILogger _logger; private readonly Lazy? _directory; #if FULLDEBUG @@ -209,6 +217,15 @@ internal LuceneIndex( /// public override ISearcher Searcher => _searcher.Value; + + + private readonly Lazy _suggester; + + /// + /// Gets a suggester for the index + /// + public override ISuggester Suggester => _suggester.Value; + /// /// Gets a Taxonomy searcher for the index /// @@ -247,6 +264,13 @@ internal LuceneIndex( /// public FieldValueTypeCollection FieldValueTypeCollection => _fieldValueTypeCollection.Value; + private readonly SuggesterDefinitionCollection _suggesterDefinitionCollection; + + /// + /// Returns the configured for this index + /// + public SuggesterDefinitionCollection SuggesterDefinitionCollection => _suggesterDefinitionCollection; + /// /// The default analyzer to use when indexing content, by default, this is set to StandardAnalyzer /// @@ -1321,6 +1345,41 @@ public DirectoryTaxonomyWriter TaxonomyWriter #region Private + private LuceneSuggester CreateSuggesters() + { + var possibleSuffixes = new[] { "Index", "Indexer" }; + var name = Name; + foreach (var suffix in possibleSuffixes) + { + //trim the "Indexer" / "Index" suffix if it exists + if (!name.EndsWith(suffix)) + continue; + name = name.Substring(0, name.LastIndexOf(suffix, StringComparison.Ordinal)); + } + + TrackingIndexWriter writer = IndexWriter; + var suggesterManager = new ReaderManager(writer.IndexWriter, true); + suggesterManager.AddListener(this); + + _nrtSuggesterReopenThread = new ControlledRealTimeReopenThread(writer, suggesterManager, 5.0, 1.0) + { + Name = $"{Name} Suggester NRT Reopen Thread", + IsBackground = true + }; + + _nrtSuggesterReopenThread.Start(); + + // wait for most recent changes when first creating the suggester + WaitForChanges(); + + var suggester = new LuceneSuggester(name + "Suggester", suggesterManager, FieldValueTypeCollection, SuggesterDefinitionCollection); + + IndexCommitted += LuceneIndex_IndexCommitted_RefreshSuggesters; + return suggester; + } + + private void LuceneIndex_IndexCommitted_RefreshSuggesters(object sender, EventArgs e) => _suggester.Value.RebuildSuggesters(); + private LuceneSearcher CreateSearcher() { var possibleSuffixes = new[] { "Index", "Indexer" }; @@ -1599,6 +1658,11 @@ protected virtual void Dispose(bool disposing) _nrtReopenThread.Interrupt(); _nrtReopenThread.Dispose(); } + if (_nrtSuggesterReopenThread != null) + { + _nrtSuggesterReopenThread.Interrupt(); + _nrtSuggesterReopenThread.Dispose(); + } // The type of _taxonomyNrtReopenThread has overriden the != operator and expects a non null value to compare the references. Therefore we use is not null instead of != null. if (_taxonomyNrtReopenThread is not null) @@ -1612,6 +1676,12 @@ protected virtual void Dispose(bool disposing) _searcher.Value.Dispose(); } + if (_suggester.IsValueCreated) + { + IndexCommitted -= LuceneIndex_IndexCommitted_RefreshSuggesters; + _suggester.Value.Dispose(); + } + //cancel any operation currently in place _cancellationTokenSource.Cancel(); diff --git a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs index 053a8e89b..e44a3542e 100644 --- a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs +++ b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs @@ -299,7 +299,7 @@ private IReadOnlyDictionary ExtractFacets(FacetsCollector? /// /// The doc to convert. /// The score. - /// + /// The index(number) of the shard(Examine Index) the search result executed on /// A populated search result object private LuceneSearchResult CreateSearchResult(Document doc, float score, int shardIndex) { diff --git a/src/Examine.Lucene/Search/LuceneSearchResults.cs b/src/Examine.Lucene/Search/LuceneSearchResults.cs index 04e3df326..eeeeeeed9 100644 --- a/src/Examine.Lucene/Search/LuceneSearchResults.cs +++ b/src/Examine.Lucene/Search/LuceneSearchResults.cs @@ -27,6 +27,7 @@ public LuceneSearchResults(IReadOnlyCollection results, int total TotalItemCount = totalItemCount; MaxScore = float.NaN; Facets = _noFacets; + SearchAfter = null; } /// diff --git a/src/Examine.Lucene/Suggest/AnalyzingInfixSuggesterDefinition.cs b/src/Examine.Lucene/Suggest/AnalyzingInfixSuggesterDefinition.cs new file mode 100644 index 000000000..bf98b3221 --- /dev/null +++ b/src/Examine.Lucene/Suggest/AnalyzingInfixSuggesterDefinition.cs @@ -0,0 +1,127 @@ +using Lucene.Net.Analysis; +using Lucene.Net.Index; +using Lucene.Net.Search.Spell; +using System.Linq; +using Lucene.Net.Search.Suggest; +using Lucene.Net.Search.Suggest.Analyzing; +using Lucene.Net.Util; +using Examine.Suggest; +using static Lucene.Net.Search.Suggest.Lookup; +using System.Collections.Generic; +using System; + +namespace Examine.Lucene.Suggest +{ + /// + /// Lucene.NET AnalyzingInfixSuggester Definition + /// + public class AnalyzingInfixSuggesterDefinition : LuceneSuggesterDefinition, ILookupExecutor + { + /// + /// Constructor + /// + /// + /// + /// + /// + public AnalyzingInfixSuggesterDefinition(string name, string[] sourceFields, ISuggesterDirectoryFactory directoryFactory, Analyzer? queryTimeAnalyzer = null) + : base(name, sourceFields, directoryFactory, queryTimeAnalyzer) + { + if (directoryFactory is null) + { + throw new ArgumentNullException(nameof(directoryFactory)); + } + if (sourceFields is null) + { + throw new ArgumentNullException(nameof(sourceFields)); + } + if (sourceFields.Length == 0) + { + throw new ArgumentOutOfRangeException(nameof(sourceFields)); + } + } + + /// + public Lookup? Lookup { get; internal set; } + + /// + public override ILookupExecutor BuildSuggester(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + => BuildAnalyzingInfixSuggesterLookup(fieldValueTypeCollection, readerManager, rebuild); + + /// + public override ISuggestionResults ExecuteSuggester(string searchText, ISuggestionExecutionContext suggestionExecutionContext) => ExecuteAnalyzingInfixSuggester(searchText, suggestionExecutionContext); + + /// + protected ILookupExecutor BuildAnalyzingInfixSuggesterLookup(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + { + string field = (SourceFields?.FirstOrDefault()) ?? throw new InvalidOperationException("SourceFields can not be empty"); + + var fieldValue = GetFieldValueType(fieldValueTypeCollection, field); + var indexTimeAnalyzer = fieldValue.Analyzer; + + + AnalyzingInfixSuggester? suggester = null; + Analyzer? queryTimeAnalyzer = QueryTimeAnalyzer; + + if (SuggesterDirectoryFactory is null) + { + throw new InvalidOperationException("SuggesterDirectoryFactory must be passed to constructor"); + } + + var luceneDictionary = SuggesterDirectoryFactory.CreateDirectory(Name.Replace(".", "_"), false); + var luceneVersion = LuceneVersion.LUCENE_48; + + if (rebuild) + { + suggester = Lookup as AnalyzingInfixSuggester; + } + else if (queryTimeAnalyzer != null) + { + suggester = new AnalyzingInfixSuggester(luceneVersion, luceneDictionary, indexTimeAnalyzer, queryTimeAnalyzer, AnalyzingInfixSuggester.DEFAULT_MIN_PREFIX_CHARS); + } + else + { + suggester = new AnalyzingInfixSuggester(luceneVersion, luceneDictionary, indexTimeAnalyzer); + } + if (suggester is null) + { + throw new NullReferenceException("Lookup or Analyzer not set"); + } + using (var readerReference = new IndexReaderReference(readerManager)) + { + var lookupDictionary = new LuceneDictionary(readerReference.IndexReader, field); + suggester.Build(lookupDictionary); + } + Lookup = suggester; + return this; + } + + /// + /// Analyzing Infix Suggester Lookup + /// + private LuceneSuggestionResults ExecuteAnalyzingInfixSuggester(string searchText, ISuggestionExecutionContext suggestionExecutionContext) + { + AnalyzingInfixSuggester? suggester = Lookup as AnalyzingInfixSuggester ?? throw new InvalidCastException("Lookup is not AnalyzingInfixSuggester"); + + var onlyMorePopular = false; + if (suggestionExecutionContext.Options is LuceneSuggestionOptions luceneSuggestionOptions && luceneSuggestionOptions.SuggestionMode == LuceneSuggestionOptions.SuggestMode.SUGGEST_MORE_POPULAR) + { + onlyMorePopular = true; + } + + IList lookupResults; + bool highlight = true; + if (highlight) + { + lookupResults = suggester.DoLookup(searchText, null, suggestionExecutionContext.Options.Top, false, true); + } + else + { + lookupResults = suggester.DoLookup(searchText, onlyMorePopular, suggestionExecutionContext.Options.Top); + } + var results = lookupResults.Select(x => new SuggestionResult(x.Key, x.Value)); + LuceneSuggestionResults suggestionResults = new LuceneSuggestionResults(results.ToArray()); + return suggestionResults; + } + } +} diff --git a/src/Examine.Lucene/Suggest/AnalyzingSuggesterDefinition.cs b/src/Examine.Lucene/Suggest/AnalyzingSuggesterDefinition.cs new file mode 100644 index 000000000..29b89b260 --- /dev/null +++ b/src/Examine.Lucene/Suggest/AnalyzingSuggesterDefinition.cs @@ -0,0 +1,104 @@ +using Lucene.Net.Analysis; +using Lucene.Net.Index; +using Lucene.Net.Search.Spell; +using System.Linq; +using Lucene.Net.Search.Suggest; +using Lucene.Net.Search.Suggest.Analyzing; +using Examine.Suggest; +using System; + +namespace Examine.Lucene.Suggest +{ + /// + /// Lucene.Net AnalyzingSuggester + /// + public class AnalyzingSuggesterDefinition : LuceneSuggesterDefinition, ILookupExecutor + { + /// + /// Constructor + /// + /// + /// + /// + /// + public AnalyzingSuggesterDefinition(string name, string[] sourceFields, ISuggesterDirectoryFactory? directoryFactory = null, Analyzer? queryTimeAnalyzer = null) + : base(name, sourceFields, directoryFactory, queryTimeAnalyzer) + { + if (sourceFields is null) + { + throw new ArgumentNullException(nameof(sourceFields)); + } + if (sourceFields.Length == 0) + { + throw new ArgumentOutOfRangeException(nameof(sourceFields)); + } + } + + /// + public Lookup? Lookup { get; internal set; } + + /// + public override ILookupExecutor BuildSuggester(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + => BuildAnalyzingSuggesterLookup(fieldValueTypeCollection, readerManager, rebuild); + + /// + public override ISuggestionResults ExecuteSuggester(string searchText, ISuggestionExecutionContext suggestionExecutionContext) => ExecuteAnalyzingSuggester(searchText, suggestionExecutionContext); + + /// + protected ILookupExecutor BuildAnalyzingSuggesterLookup(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + { + string field = (SourceFields?.FirstOrDefault()) ?? throw new InvalidOperationException("SourceFields can not be empty"); + var fieldValue = GetFieldValueType(fieldValueTypeCollection, field); + var indexTimeAnalyzer = fieldValue.Analyzer; + + AnalyzingSuggester? suggester = null; + Analyzer? queryTimeAnalyzer = QueryTimeAnalyzer; + + if (rebuild) + { + suggester = Lookup as AnalyzingSuggester; + } + else if (queryTimeAnalyzer != null) + { + suggester = new AnalyzingSuggester(indexTimeAnalyzer, queryTimeAnalyzer); + } + else + { + suggester = new AnalyzingSuggester(indexTimeAnalyzer); + } + + if (suggester is null) + { + throw new NullReferenceException("Lookup or Analyzer not set"); + } + + using (var readerReference = new IndexReaderReference(readerManager)) + { + var lookupDictionary = new LuceneDictionary(readerReference.IndexReader, field); + suggester.Build(lookupDictionary); + } + + Lookup = suggester; + return this; + } + + /// + protected ISuggestionResults ExecuteAnalyzingSuggester(string searchText, ISuggestionExecutionContext suggestionExecutionContext) + { + AnalyzingSuggester? analyzingSuggester = Lookup as AnalyzingSuggester ?? throw new InvalidCastException("Lookup is not AnalyzingSuggester"); + + var onlyMorePopular = false; + if (suggestionExecutionContext.Options is LuceneSuggestionOptions luceneSuggestionOptions + && luceneSuggestionOptions.SuggestionMode == LuceneSuggestionOptions.SuggestMode.SUGGEST_MORE_POPULAR) + { + onlyMorePopular = true; + } + + var lookupResults = analyzingSuggester.DoLookup(searchText, onlyMorePopular, suggestionExecutionContext.Options.Top); + var results = lookupResults.Select(x => new SuggestionResult(x.Key, x.Value)); + LuceneSuggestionResults suggestionResults = new LuceneSuggestionResults(results.ToArray()); + return suggestionResults; + } + } +} + diff --git a/src/Examine.Lucene/Suggest/DirectSpellCheckerDefinition.cs b/src/Examine.Lucene/Suggest/DirectSpellCheckerDefinition.cs new file mode 100644 index 000000000..2911d5213 --- /dev/null +++ b/src/Examine.Lucene/Suggest/DirectSpellCheckerDefinition.cs @@ -0,0 +1,78 @@ +using Lucene.Net.Index; +using Lucene.Net.Search.Spell; +using System.Linq; +using Examine.Suggest; +using System; + +namespace Examine.Lucene.Suggest +{ + /// + /// Lucene.NET DirectSpellChecker Suggester Defintion + /// + public class DirectSpellCheckerDefinition : LuceneSuggesterDefinition, ILookupExecutor + { + /// + /// Constructor + /// + /// + /// + /// + public DirectSpellCheckerDefinition(string name, string[] sourceFields, ISuggesterDirectoryFactory? directoryFactory = null) + : base(name, sourceFields, directoryFactory, default) + { + if (sourceFields is null) + { + throw new ArgumentNullException(nameof(sourceFields)); + } + if (sourceFields.Length == 0) + { + throw new ArgumentOutOfRangeException(nameof(sourceFields)); + } + } + + /// + /// Spell Checker + /// + public DirectSpellChecker? Spellchecker { get; set; } + + /// + public override ILookupExecutor BuildSuggester(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + => BuildDirectSpellCheckerSuggester(fieldValueTypeCollection, readerManager, rebuild); + + /// + public override ISuggestionResults ExecuteSuggester(string searchText, ISuggestionExecutionContext suggestionExecutionContext) => ExecuteDirectSpellChecker(searchText, suggestionExecutionContext); + + /// + protected ILookupExecutor BuildDirectSpellCheckerSuggester(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + { + Spellchecker = new DirectSpellChecker(); + return this; + } + + private ISuggestionResults ExecuteDirectSpellChecker(string searchText, ISuggestionExecutionContext suggestionExecutionContext) + { + string field = (SourceFields?.FirstOrDefault()) ?? throw new InvalidOperationException("SourceFields can not be empty"); + var suggestMode = SuggestMode.SUGGEST_WHEN_NOT_IN_INDEX; + if (suggestionExecutionContext.Options is LuceneSuggestionOptions luceneSuggestionOptions) + { + suggestMode = (SuggestMode)luceneSuggestionOptions.SuggestionMode; + } + + if (Spellchecker is null) + { + throw new NullReferenceException("Spellchecker not set"); + } + + using (var readerReference = suggestionExecutionContext.GetIndexReader()) + { + var lookupResults = Spellchecker.SuggestSimilar(new Term(field, searchText), + suggestionExecutionContext.Options.Top, + readerReference.IndexReader, + suggestMode); + var results = lookupResults.Select(x => new SuggestionResult(x.String, x.Score, x.Freq)); + LuceneSuggestionResults suggestionResults = new LuceneSuggestionResults(results.ToArray()); + return suggestionResults; + } + } + } +} diff --git a/src/Examine.Lucene/Suggest/Directories/FileSystemSuggesterDirectoryFactory.cs b/src/Examine.Lucene/Suggest/Directories/FileSystemSuggesterDirectoryFactory.cs new file mode 100644 index 000000000..ac4e91741 --- /dev/null +++ b/src/Examine.Lucene/Suggest/Directories/FileSystemSuggesterDirectoryFactory.cs @@ -0,0 +1,46 @@ +using System.IO; +using Examine.Lucene.Directories; +using Lucene.Net.Index; +using Lucene.Net.Store; +using Directory = Lucene.Net.Store.Directory; + +namespace Examine.Lucene.Suggest.Directories +{ + /// + /// File System Suggester Directory Factory + /// + public class FileSystemSuggesterDirectoryFactory : SuggesterDirectoryFactoryBase + { + private readonly DirectoryInfo _baseDir; + + /// + /// Constructor + /// + /// Base Directory + /// Lucene Lock Factory + public FileSystemSuggesterDirectoryFactory(DirectoryInfo baseDir, ILockFactory lockFactory) + { + _baseDir = baseDir; + LockFactory = lockFactory; + } + + /// + /// Lock Factory + /// + public ILockFactory LockFactory { get; } + + /// + protected override Directory CreateDirectory(string name, bool forceUnlock) + { + var path = Path.Combine(_baseDir.FullName, name); + var luceneIndexFolder = new DirectoryInfo(path); + + var dir = FSDirectory.Open(luceneIndexFolder, LockFactory.GetLockFactory(luceneIndexFolder)); + if (forceUnlock) + { + IndexWriter.Unlock(dir); + } + return dir; + } + } +} diff --git a/src/Examine.Lucene/Suggest/Directories/RAMSuggesterDirectoryFactory.cs b/src/Examine.Lucene/Suggest/Directories/RAMSuggesterDirectoryFactory.cs new file mode 100644 index 000000000..d8f013174 --- /dev/null +++ b/src/Examine.Lucene/Suggest/Directories/RAMSuggesterDirectoryFactory.cs @@ -0,0 +1,13 @@ +using Lucene.Net.Store; + +namespace Examine.Lucene.Suggest.Directories +{ + /// + /// RAMDirectory Suggester Directory Factory + /// + public class RAMSuggesterDirectoryFactory : SuggesterDirectoryFactoryBase + { + /// + protected override Directory CreateDirectory(string name, bool forceUnlock) => new RAMDirectory(); + } +} diff --git a/src/Examine.Lucene/Suggest/Directories/SuggesterDirectoryFactoryBase.cs b/src/Examine.Lucene/Suggest/Directories/SuggesterDirectoryFactoryBase.cs new file mode 100644 index 000000000..7c7265ab6 --- /dev/null +++ b/src/Examine.Lucene/Suggest/Directories/SuggesterDirectoryFactoryBase.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Concurrent; +using Lucene.Net.Store; + +namespace Examine.Lucene.Suggest.Directories +{ + /// + /// Base class for Lucene Suggester Directory Factory + /// + public abstract class SuggesterDirectoryFactoryBase : ISuggesterDirectoryFactory + { + private readonly ConcurrentDictionary _createdDirectories = new ConcurrentDictionary(); + private bool _disposedValue; + + /// + Directory ISuggesterDirectoryFactory.CreateDirectory(string name, bool forceUnlock) + => _createdDirectories.GetOrAdd( + name, + s => CreateDirectory(name, forceUnlock)); + + /// + protected abstract Directory CreateDirectory(string name, bool forceUnlock); + + /// + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + foreach (Directory d in _createdDirectories.Values) + { + d.Dispose(); + } + } + + _disposedValue = true; + } + } + + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + } + } +} diff --git a/src/Examine.Lucene/Suggest/ExamineLuceneSuggesterNames.cs b/src/Examine.Lucene/Suggest/ExamineLuceneSuggesterNames.cs new file mode 100644 index 000000000..fa8de2e61 --- /dev/null +++ b/src/Examine.Lucene/Suggest/ExamineLuceneSuggesterNames.cs @@ -0,0 +1,44 @@ +namespace Examine +{ + /// + /// Built in Suggester names for Examine Lucene + /// + public class ExamineLuceneSuggesterNames + { + /// + /// Lucene.NET AnalyzingInfixSuggester Suggester + /// + public const string AnalyzingInfixSuggester = "Lucene.AnalyzingInfixSuggester"; + + /// + /// Lucene.NET AnalyzingSuggester Suggester + /// + public const string AnalyzingSuggester = "Lucene.AnalyzingSuggester"; + + /// + /// Lucene.NET FuzzySuggester Suggester + /// + public const string FuzzySuggester = "Lucene.FuzzySuggester"; + + /// + /// Lucene.NET DirectSpellChecker with default string distance implementation. + /// + public const string DirectSpellChecker = "Lucene.DirectSpellChecker"; + + /// + /// Lucene.NET DirectSpellChecker with JaroWinklerDistance string distance implementation. + /// + public const string DirectSpellChecker_JaroWinklerDistance = "Lucene.DirectSpellChecker.JaroWinklerDistance"; + + /// + /// Lucene.NET DirectSpellChecker with LevensteinDistance string distance implementation. + /// + public const string DirectSpellChecker_LevensteinDistance = "Lucene.DirectSpellChecker.LevensteinDistance"; + + /// + /// Lucene.NET DirectSpellChecker with NGramDistance string distance implementation. + /// + public const string DirectSpellChecker_NGramDistance = "Lucene.DirectSpellChecker.NGramDistance"; + + } +} diff --git a/src/Examine.Lucene/Suggest/FuzzySuggesterDefinition.cs b/src/Examine.Lucene/Suggest/FuzzySuggesterDefinition.cs new file mode 100644 index 000000000..6f67d93eb --- /dev/null +++ b/src/Examine.Lucene/Suggest/FuzzySuggesterDefinition.cs @@ -0,0 +1,96 @@ +using Lucene.Net.Analysis; +using Lucene.Net.Index; +using Lucene.Net.Search.Spell; +using System.Linq; +using Lucene.Net.Search.Suggest; +using Lucene.Net.Search.Suggest.Analyzing; +using Examine.Suggest; +using System; + +namespace Examine.Lucene.Suggest +{ + /// + /// Lucene.NET FuzzySuggester Definition + /// + public class FuzzySuggesterDefinition : LuceneSuggesterDefinition, ILookupExecutor + { + /// + /// Constructor + /// + /// Suggester Name + /// Source fields for suggestions + /// Suggester Directory Factory + /// Query Time Analyzer + public FuzzySuggesterDefinition(string name, string[]? sourceFields, ISuggesterDirectoryFactory? directoryFactory = null, Analyzer? queryTimeAnalyzer = null) + : base(name, sourceFields, directoryFactory, queryTimeAnalyzer) + { + if (sourceFields is null) + { + throw new ArgumentNullException(nameof(sourceFields)); + } + if (sourceFields.Length == 0) + { + throw new ArgumentOutOfRangeException(nameof(sourceFields)); + } + } + + /// + public Lookup? Lookup { get; internal set; } + + /// + public override ILookupExecutor BuildSuggester(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + => BuildFuzzySuggesterLookup(fieldValueTypeCollection, readerManager, rebuild); + + /// + public override ISuggestionResults ExecuteSuggester(string searchText, ISuggestionExecutionContext suggestionExecutionContext) => ExecuteFuzzySuggester(searchText, suggestionExecutionContext); + + /// + protected ILookupExecutor BuildFuzzySuggesterLookup(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + { + string field = (SourceFields?.FirstOrDefault()) ?? throw new InvalidOperationException("SourceFields can not be empty"); + var fieldValue = GetFieldValueType(fieldValueTypeCollection, field); + var indexTimeAnalyzer = fieldValue.Analyzer; + + + FuzzySuggester? suggester = null; + Analyzer? queryTimeAnalyzer = QueryTimeAnalyzer; + + if (rebuild) + { + suggester = Lookup as FuzzySuggester; + } + else if (queryTimeAnalyzer != null) + { + suggester = new FuzzySuggester(indexTimeAnalyzer, queryTimeAnalyzer); + } + else + { + suggester = new FuzzySuggester(indexTimeAnalyzer); + } + using (var readerReference = new IndexReaderReference(readerManager)) + { + LuceneDictionary luceneDictionary = new LuceneDictionary(readerReference.IndexReader, field); + suggester.Build(luceneDictionary); + } + Lookup = suggester; + + return this; + } + + private ISuggestionResults ExecuteFuzzySuggester(string searchText, ISuggestionExecutionContext suggestionExecutionContext) + { + FuzzySuggester? suggester = Lookup as FuzzySuggester; + + var onlyMorePopular = false; + if (suggestionExecutionContext.Options is LuceneSuggestionOptions luceneSuggestionOptions + && luceneSuggestionOptions.SuggestionMode == LuceneSuggestionOptions.SuggestMode.SUGGEST_MORE_POPULAR) + { + onlyMorePopular = true; + } + var lookupResults = suggester.DoLookup(searchText, onlyMorePopular, suggestionExecutionContext.Options.Top); + var results = lookupResults.Select(x => new SuggestionResult(x.Key, x.Value)); + LuceneSuggestionResults suggestionResults = new LuceneSuggestionResults(results.ToArray()); + return suggestionResults; + } + } +} diff --git a/src/Examine.Lucene/Suggest/IIndexReaderReference.cs b/src/Examine.Lucene/Suggest/IIndexReaderReference.cs new file mode 100644 index 000000000..02aa76d3a --- /dev/null +++ b/src/Examine.Lucene/Suggest/IIndexReaderReference.cs @@ -0,0 +1,16 @@ +using System; +using Lucene.Net.Index; + +namespace Examine.Lucene.Suggest +{ + /// + /// Reference to an instance of an IndexReader on a Lucene Index + /// + public interface IIndexReaderReference : IDisposable + { + /// + /// Index Reader + /// + DirectoryReader IndexReader { get; } + } +} diff --git a/src/Examine.Lucene/Suggest/ILookupExecutor.cs b/src/Examine.Lucene/Suggest/ILookupExecutor.cs new file mode 100644 index 000000000..b3dff6278 --- /dev/null +++ b/src/Examine.Lucene/Suggest/ILookupExecutor.cs @@ -0,0 +1,18 @@ +using Examine.Suggest; + +namespace Examine.Lucene.Suggest +{ + /// + /// Lookup (Suggestion) Executor + /// + public interface ILookupExecutor + { + /// + /// Executes a Suggester + /// + /// Search Text input + /// Suggestion Context + /// Suggestion Results + ISuggestionResults ExecuteSuggester(string searchText, ISuggestionExecutionContext suggestionExecutionContext); + } +} diff --git a/src/Examine.Lucene/Suggest/ISuggesterContext.cs b/src/Examine.Lucene/Suggest/ISuggesterContext.cs new file mode 100644 index 000000000..4408cf3ed --- /dev/null +++ b/src/Examine.Lucene/Suggest/ISuggesterContext.cs @@ -0,0 +1,44 @@ +using Examine.Lucene.Indexing; +using Lucene.Net.Search.Suggest; +using Lucene.Net.Util; + +namespace Examine.Lucene.Suggest +{ + /// + /// Suggester Context for LuceneSuggester + /// + public interface ISuggesterContext + { + /// + /// Retrieves a IndexReaderReference for the index the Suggester is for + /// + /// + IIndexReaderReference GetIndexReader(); + + /// + /// Gets the IIndexFieldValueType for a given Field Name in the Index. + /// + /// Index Field Name + /// + IIndexFieldValueType GetFieldValueType(string fieldName); + + /// + /// Gets the Suggester Definitions + /// + /// + SuggesterDefinitionCollection GetSuggesterDefinitions(); + + /// + /// Gets the Version of the Lucene Index + /// + /// + LuceneVersion GetLuceneVersion(); + + /// + /// Get the Suggester + /// + /// Suggester Name + /// Suggester + TLookup GetSuggester(string name) where TLookup : Lookup; + } +} diff --git a/src/Examine.Lucene/Suggest/ISuggesterDirectoryFactory.cs b/src/Examine.Lucene/Suggest/ISuggesterDirectoryFactory.cs new file mode 100644 index 000000000..cc23529a2 --- /dev/null +++ b/src/Examine.Lucene/Suggest/ISuggesterDirectoryFactory.cs @@ -0,0 +1,25 @@ +using System; +using Lucene.Net.Store; + +namespace Examine.Lucene.Suggest +{ + /// + /// Creates a Lucene for a suggester + /// + /// + /// The directory created must only be created ONCE per suggester and disposed when the factory is disposed. + /// + public interface ISuggesterDirectoryFactory : IDisposable + { + /// + /// Creates the directory instance + /// + /// If true, will force unlock the directory when created + /// Directory Name + /// + /// + /// Any subsequent calls for the same index will return the same directory instance + /// + Directory CreateDirectory(string name, bool forceUnlock); + } +} diff --git a/src/Examine.Lucene/Suggest/ISuggestionExecutionContext.cs b/src/Examine.Lucene/Suggest/ISuggestionExecutionContext.cs new file mode 100644 index 000000000..e6509731b --- /dev/null +++ b/src/Examine.Lucene/Suggest/ISuggestionExecutionContext.cs @@ -0,0 +1,21 @@ +using Examine.Suggest; + +namespace Examine.Lucene.Suggest +{ + /// + /// Context for Suggester Execution + /// + public interface ISuggestionExecutionContext + { + /// + /// Suggestion Options + /// + SuggestionOptions Options { get; } + + /// + /// Retrieves a IndexReaderReference for the index the Suggester is for + /// + /// + IIndexReaderReference GetIndexReader(); + } +} diff --git a/src/Examine.Lucene/Suggest/IndexReaderReference.cs b/src/Examine.Lucene/Suggest/IndexReaderReference.cs new file mode 100644 index 000000000..bb7881d00 --- /dev/null +++ b/src/Examine.Lucene/Suggest/IndexReaderReference.cs @@ -0,0 +1,57 @@ +using System; +using Lucene.Net.Index; + +namespace Examine.Lucene.Suggest +{ + /// + /// Reference to an instance of an IndexReader on a Lucene Index + /// + public class IndexReaderReference : IIndexReaderReference + { + private readonly ReaderManager _readerManager; + private bool _disposedValue; + private DirectoryReader _indexReader; + + public IndexReaderReference(ReaderManager readerManager) + { + _readerManager = readerManager; + } + + /// + public DirectoryReader IndexReader + { + get + { + if (_disposedValue) + { + throw new ObjectDisposedException($"{nameof(IndexReaderReference)} is disposed"); + } + return _indexReader ?? (_indexReader = _readerManager.Acquire()); + } + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + if (_indexReader != null) + { + _readerManager.Release(_indexReader); + } + } + + _disposedValue = true; + } + } + + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + System.GC.SuppressFinalize(this); + } + } +} diff --git a/src/Examine.Lucene/Suggest/JaroWinklerDistanceDefinition.cs b/src/Examine.Lucene/Suggest/JaroWinklerDistanceDefinition.cs new file mode 100644 index 000000000..fcf3f090d --- /dev/null +++ b/src/Examine.Lucene/Suggest/JaroWinklerDistanceDefinition.cs @@ -0,0 +1,73 @@ +using Lucene.Net.Index; +using Lucene.Net.Search.Spell; +using System.Linq; +using Examine.Suggest; +using System; + +namespace Examine.Lucene.Suggest +{ + /// + /// Lucene.NET JaroWinklerDistance Suggester Definition + /// + public class JaroWinklerDistanceDefinition : LuceneSuggesterDefinition, ILookupExecutor + { + /// + /// Constructor + /// + /// Suggester Name + /// Index Source Fields + /// Suggester Directory Factory + public JaroWinklerDistanceDefinition(string name, string[]? sourceFields, ISuggesterDirectoryFactory? directoryFactory = null) + : base(name, sourceFields, directoryFactory, default) + { + if (sourceFields is null) + { + throw new ArgumentNullException(nameof(sourceFields)); + } + if (sourceFields.Length == 0) + { + throw new ArgumentOutOfRangeException(nameof(sourceFields)); + } + } + + /// + /// Spell Checker + /// + public DirectSpellChecker? Spellchecker { get; set; } + + /// + public override ILookupExecutor BuildSuggester(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + => BuildDirectSpellCheckerSuggester(fieldValueTypeCollection, readerManager, rebuild); + + /// + public override ISuggestionResults ExecuteSuggester(string searchText, ISuggestionExecutionContext suggestionExecutionContext) => ExecuteDirectSpellChecker(searchText, suggestionExecutionContext); + + /// + protected ILookupExecutor BuildDirectSpellCheckerSuggester(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + { + Spellchecker = new DirectSpellChecker(); + Spellchecker.Distance = new JaroWinklerDistance(); + return this; + } + + private ISuggestionResults ExecuteDirectSpellChecker(string searchText, ISuggestionExecutionContext suggestionExecutionContext) + { + string field = (SourceFields?.FirstOrDefault()) ?? throw new InvalidOperationException("SourceFields can not be empty"); + var suggestMode = SuggestMode.SUGGEST_WHEN_NOT_IN_INDEX; + if (suggestionExecutionContext.Options is LuceneSuggestionOptions luceneSuggestionOptions) + { + suggestMode = (SuggestMode)luceneSuggestionOptions.SuggestionMode; + } + using (var readerReference = suggestionExecutionContext.GetIndexReader()) + { + var lookupResults = Spellchecker.SuggestSimilar(new Term(field, searchText), + suggestionExecutionContext.Options.Top, + readerReference.IndexReader, + suggestMode); + var results = lookupResults.Select(x => new SuggestionResult(x.String, x.Score, x.Freq)); + LuceneSuggestionResults suggestionResults = new LuceneSuggestionResults(results.ToArray()); + return suggestionResults; + } + } + } +} diff --git a/src/Examine.Lucene/Suggest/LevensteinDistanceDefinition.cs b/src/Examine.Lucene/Suggest/LevensteinDistanceDefinition.cs new file mode 100644 index 000000000..0d420470b --- /dev/null +++ b/src/Examine.Lucene/Suggest/LevensteinDistanceDefinition.cs @@ -0,0 +1,79 @@ +using Lucene.Net.Index; +using Lucene.Net.Search.Spell; +using System.Linq; +using Examine.Suggest; +using System; + +namespace Examine.Lucene.Suggest +{ + /// + /// Lucene.NET LevensteinDistance Suggester Definition + /// + public class LevensteinDistanceSuggesterDefinition : LuceneSuggesterDefinition, ILookupExecutor + { + /// + /// Constructor + /// + /// + /// + /// + public LevensteinDistanceSuggesterDefinition(string name, string[] sourceFields, ISuggesterDirectoryFactory directoryFactory = null) + : base(name, sourceFields, directoryFactory, default) + { + if (sourceFields is null) + { + throw new ArgumentNullException(nameof(sourceFields)); + } + if (sourceFields.Length == 0) + { + throw new ArgumentOutOfRangeException(nameof(sourceFields)); + } + } + + /// + /// Spell Checker + /// + public DirectSpellChecker? Spellchecker { get; set; } + + /// + public override ILookupExecutor BuildSuggester(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + => BuildDirectSpellCheckerSuggester(fieldValueTypeCollection, readerManager, rebuild); + + /// + public override ISuggestionResults ExecuteSuggester(string searchText, ISuggestionExecutionContext suggestionExecutionContext) => ExecuteDirectSpellChecker(searchText, suggestionExecutionContext); + + /// + protected ILookupExecutor BuildDirectSpellCheckerSuggester(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + { + Spellchecker = new DirectSpellChecker(); + Spellchecker.Distance = new LevensteinDistance(); + return this; + } + + private ISuggestionResults ExecuteDirectSpellChecker(string searchText, ISuggestionExecutionContext suggestionExecutionContext) + { + string field = (SourceFields?.FirstOrDefault()) ?? throw new InvalidOperationException("SourceFields can not be empty"); + var suggestMode = SuggestMode.SUGGEST_WHEN_NOT_IN_INDEX; + if (suggestionExecutionContext.Options is LuceneSuggestionOptions luceneSuggestionOptions) + { + suggestMode = (SuggestMode)luceneSuggestionOptions.SuggestionMode; + } + + if (Spellchecker is null) + { + throw new NullReferenceException("Spellchecker not set"); + } + + using (var readerReference = suggestionExecutionContext.GetIndexReader()) + { + var lookupResults = Spellchecker.SuggestSimilar(new Term(field, searchText), + suggestionExecutionContext.Options.Top, + readerReference.IndexReader, + suggestMode); + var results = lookupResults.Select(x => new SuggestionResult(x.String, x.Score, x.Freq)); + LuceneSuggestionResults suggestionResults = new LuceneSuggestionResults(results.ToArray()); + return suggestionResults; + } + } + } +} diff --git a/src/Examine.Lucene/Suggest/LuceneSuggester.cs b/src/Examine.Lucene/Suggest/LuceneSuggester.cs new file mode 100644 index 000000000..3d02d7758 --- /dev/null +++ b/src/Examine.Lucene/Suggest/LuceneSuggester.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Lucene.Net.Index; + +namespace Examine.Lucene.Suggest +{ + /// + /// Lucene Index based Suggester + /// + public class LuceneSuggester : BaseLuceneSuggester, IDisposable + { + private readonly ReaderManager _readerManager; + private readonly FieldValueTypeCollection _fieldValueTypeCollection; + private readonly SuggesterDefinitionCollection _suggesterDefinitions; + private bool _disposedValue; + + private Dictionary _suggesters = new Dictionary(); + + /// + /// Constructor + /// + /// Name of the Suggester + /// Retrieves a IndexReaderReference for the index the Suggester is for + /// Index Field Types + /// Defintions of the Suggesters on an Index + public LuceneSuggester(string name, ReaderManager readerManager, FieldValueTypeCollection fieldValueTypeCollection, SuggesterDefinitionCollection suggesterDefinitions) + : base(name) + { + _readerManager = readerManager; + _fieldValueTypeCollection = fieldValueTypeCollection; + _suggesterDefinitions = suggesterDefinitions; + BuildSuggesters(); + } + + /// + public void RebuildSuggesters() => BuildSuggesters(true); + + /// + protected virtual void BuildSuggesters(bool rebuild = false) + { + foreach (var suggesterDefintion in _suggesterDefinitions) + { + if (!rebuild && _suggesters.ContainsKey(suggesterDefintion.Name)) + { + throw new InvalidOperationException("Can not register more than one Suggester with the same name"); + } + var luceneSuggesterDefinition = suggesterDefintion as LuceneSuggesterDefinition; + if (luceneSuggesterDefinition != null) + { + var lookup = luceneSuggesterDefinition.BuildSuggester(_fieldValueTypeCollection, _readerManager, rebuild); + if (_suggesters.ContainsKey(suggesterDefintion.Name)) + { + _suggesters[suggesterDefintion.Name] = lookup; + } + else + { + _suggesters.Add(suggesterDefintion.Name, lookup); + } + } + } + } + + /// + public override ISuggesterContext GetSuggesterContext() => new SuggesterContext(_readerManager, _fieldValueTypeCollection, _suggesterDefinitions, _suggesters); + + /// + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + foreach (var suggester in _suggesters.OfType()) + { + suggester?.Dispose(); + } + _readerManager.Dispose(); + } + + _disposedValue = true; + } + } + + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/Examine.Lucene/Suggest/LuceneSuggesterDefinition.cs b/src/Examine.Lucene/Suggest/LuceneSuggesterDefinition.cs new file mode 100644 index 000000000..4e088d029 --- /dev/null +++ b/src/Examine.Lucene/Suggest/LuceneSuggesterDefinition.cs @@ -0,0 +1,69 @@ +using Lucene.Net.Index; +using Examine.Lucene.Indexing; +using Examine.Suggest; +using Lucene.Net.Analysis; + +namespace Examine.Lucene.Suggest +{ + /// + /// Defines a suggester for a Lucene Index + /// + public abstract class LuceneSuggesterDefinition : SuggesterDefinition + { + /// + /// Constructor + /// + /// Name of the suggester + /// Source Index Fields for the Suggester + /// Directory Factory for Lucene Suggesters (Required by AnalyzingInfixSuggester) + /// Analyzer to use at Query time + public LuceneSuggesterDefinition(string name, string[]? sourceFields, ISuggesterDirectoryFactory? directoryFactory, Analyzer? queryTimeAnalyzer) + : base(name, sourceFields) + { + SuggesterDirectoryFactory = directoryFactory; + QueryTimeAnalyzer = queryTimeAnalyzer; + } + + /// + /// Directory Factory for Lucene Suggesters + /// + public ISuggesterDirectoryFactory? SuggesterDirectoryFactory { get; } + + /// + /// Query Time Analyzer + /// + public Analyzer? QueryTimeAnalyzer { get; } + + /// + /// Build the Suggester Lookup + /// + /// Index fields + /// Index Reader Manager + /// Whether the lookup is being rebuilt + /// Lookup Executor + public abstract ILookupExecutor BuildSuggester(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild); + + /// + /// Executes the Suggester + /// + /// Search Text + /// Suggestion Context + /// + public abstract ISuggestionResults ExecuteSuggester(string searchText, ISuggestionExecutionContext suggestionExecutionContext); + + + /// + /// Gets the field value type of a field name + /// + /// + /// + /// + protected IIndexFieldValueType GetFieldValueType(FieldValueTypeCollection fieldValueTypeCollection, string fieldName) + { + //Get the value type for the field, or use the default if not defined + return fieldValueTypeCollection.GetValueType( + fieldName, + fieldValueTypeCollection.ValueTypeFactories.GetRequiredFactory(FieldDefinitionTypes.FullText)); + } + } +} diff --git a/src/Examine.Lucene/Suggest/LuceneSuggesterExecutor.cs b/src/Examine.Lucene/Suggest/LuceneSuggesterExecutor.cs new file mode 100644 index 000000000..a8ec56af1 --- /dev/null +++ b/src/Examine.Lucene/Suggest/LuceneSuggesterExecutor.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using Examine.Suggest; + +namespace Examine.Lucene.Suggest +{ + /// + /// Suggester Executor for a Lucene Index + /// + internal class LuceneSuggesterExecutor + { + private readonly string _searchText; + private readonly SuggestionOptions _options; + private readonly ISuggesterContext _suggesterContext; + private readonly ISuggestionResults _emptySuggestionResults = new LuceneSuggestionResults(Array.Empty()); + + public LuceneSuggesterExecutor(string searchText, SuggestionOptions options, ISuggesterContext suggesterContext) + { + _searchText = searchText; + _options = options; + _suggesterContext = suggesterContext; + } + + /// + /// Execute the Suggester + /// + /// Suggestion Results + public ISuggestionResults Execute() + { + if (_options.SuggesterName == null) + { + return _emptySuggestionResults; + } + + var suggesters = _suggesterContext.GetSuggesterDefinitions(); + var suggester = suggesters.FirstOrDefault(x => x.Name == _options.SuggesterName); + if (suggester?.Name == null || suggester?.SourceFields == null) + { + return _emptySuggestionResults; + } + var luceneSuggesterDefinition = suggester as LuceneSuggesterDefinition; + if(luceneSuggesterDefinition == null) + { + return _emptySuggestionResults; + } + ISuggestionExecutionContext ctx = new LuceneSuggestionExecutionContext(_options,_suggesterContext); + return luceneSuggesterDefinition.ExecuteSuggester(_searchText, ctx); + } + } +} diff --git a/src/Examine.Lucene/Suggest/LuceneSuggestionExecutionContext.cs b/src/Examine.Lucene/Suggest/LuceneSuggestionExecutionContext.cs new file mode 100644 index 000000000..f13c21b43 --- /dev/null +++ b/src/Examine.Lucene/Suggest/LuceneSuggestionExecutionContext.cs @@ -0,0 +1,18 @@ +using Examine.Suggest; + +namespace Examine.Lucene.Suggest +{ + internal class LuceneSuggestionExecutionContext : ISuggestionExecutionContext + { + private readonly ISuggesterContext _suggesterContext; + + public LuceneSuggestionExecutionContext(SuggestionOptions options, ISuggesterContext suggesterContext) + { + Options = options; + _suggesterContext = suggesterContext; + } + public SuggestionOptions Options { get; } + + public IIndexReaderReference GetIndexReader() => _suggesterContext.GetIndexReader(); + } +} diff --git a/src/Examine.Lucene/Suggest/LuceneSuggestionOptions.cs b/src/Examine.Lucene/Suggest/LuceneSuggestionOptions.cs new file mode 100644 index 000000000..5b88e52ab --- /dev/null +++ b/src/Examine.Lucene/Suggest/LuceneSuggestionOptions.cs @@ -0,0 +1,51 @@ +using Examine.Suggest; +using Lucene.Net.Search.Spell; + +namespace Examine.Lucene.Suggest +{ + /// + /// Lucene Suggester query time options + /// + public class LuceneSuggestionOptions : SuggestionOptions + { + /// + /// Constructor + /// + /// Clamp number of results + /// The name of the Suggester to use + /// Suggestion Mode + public LuceneSuggestionOptions(int top = 5, string suggesterName = null, SuggestMode suggestionMode = default) : base(top, suggesterName) + { + SuggestionMode = suggestionMode; + } + + /// + /// Strategy for suggesting related terms + /// + public SuggestMode SuggestionMode { get; } + + /// + /// Set of strategies for suggesting related terms + /// + public enum SuggestMode + { + /// + /// Generate suggestions only for terms not in the index (default) + /// + SUGGEST_WHEN_NOT_IN_INDEX = 0, + + /// + /// Return only suggested words that are as frequent or more frequent than the + /// searched word + /// + SUGGEST_MORE_POPULAR, + + /// + /// Always attempt to offer suggestions (however, other parameters may limit + /// suggestions. For example, see + /// ). + /// + SUGGEST_ALWAYS + } + } +} diff --git a/src/Examine.Lucene/Suggest/LuceneSuggestionQuery.cs b/src/Examine.Lucene/Suggest/LuceneSuggestionQuery.cs new file mode 100644 index 000000000..c030c765c --- /dev/null +++ b/src/Examine.Lucene/Suggest/LuceneSuggestionQuery.cs @@ -0,0 +1,30 @@ +using Examine.Suggest; + +namespace Examine.Lucene.Suggest +{ + /// + /// Lucene Search Suggestion Query + /// + public class LuceneSuggestionQuery : ISuggestionQuery, Examine.Suggest.ISuggestionExecutor + { + private readonly ISuggesterContext _suggesterContext; + + /// + /// Constructor + /// + /// Lucene Suggestion Query Context + /// Query time suggester options + public LuceneSuggestionQuery(ISuggesterContext suggesterContext, SuggestionOptions? options) + { + _suggesterContext = suggesterContext; + } + + /// + public ISuggestionResults Execute(string searchText, SuggestionOptions? options = null) + { + var executor = new LuceneSuggesterExecutor(searchText, options, _suggesterContext); + var results = executor.Execute(); + return results; + } + } +} diff --git a/src/Examine.Lucene/Suggest/LuceneSuggestionQueryBase.cs b/src/Examine.Lucene/Suggest/LuceneSuggestionQueryBase.cs new file mode 100644 index 000000000..7f6dd0eee --- /dev/null +++ b/src/Examine.Lucene/Suggest/LuceneSuggestionQueryBase.cs @@ -0,0 +1,47 @@ +using Examine.Suggest; + +namespace Examine.Lucene.Suggest +{ + /// + /// Base class for Lucene Suggesters + /// + public abstract class BaseLuceneSuggester : BaseSuggesterProvider + { + /// + /// Constructor + /// + /// Suggester Name + protected BaseLuceneSuggester(string name) : base(name) + { + } + + /// + /// Gets the Lucene Suggester Context + /// + /// Context + public abstract ISuggesterContext GetSuggesterContext(); + + /// + public override ISuggestionQuery CreateSuggestionQuery() + { + return CreateSuggestionQuery(new SuggestionOptions()); + } + + /// + /// Create a Suggestion Query + /// + /// + /// + public virtual ISuggestionQuery CreateSuggestionQuery(SuggestionOptions? options = null) + { + return new LuceneSuggestionQuery(GetSuggesterContext(), options); + } + + /// + public override ISuggestionResults Suggest(string searchText, SuggestionOptions? options = null) + { + var suggestionExecutor = CreateSuggestionQuery(); + return suggestionExecutor.Execute(searchText, options); + } + } +} diff --git a/src/Examine.Lucene/Suggest/LuceneSuggestionResults.cs b/src/Examine.Lucene/Suggest/LuceneSuggestionResults.cs new file mode 100644 index 000000000..7398db9be --- /dev/null +++ b/src/Examine.Lucene/Suggest/LuceneSuggestionResults.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using Examine.Suggest; + +namespace Examine.Lucene.Suggest +{ + /// + /// Lucene Suggestion results + /// + public class LuceneSuggestionResults : ISuggestionResults + { + /// + /// Empty Suggestion Results + /// + public static LuceneSuggestionResults Empty { get; } = new LuceneSuggestionResults(Array.Empty()); + + private readonly IReadOnlyCollection _results; + + /// + /// Constructor + /// + /// Suggestion Results + public LuceneSuggestionResults(IReadOnlyCollection results) + { + _results = results; + } + + /// + public IEnumerator GetEnumerator() => _results.GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/Examine.Lucene/Suggest/NGramDistanceDefinition.cs b/src/Examine.Lucene/Suggest/NGramDistanceDefinition.cs new file mode 100644 index 000000000..f080ba17a --- /dev/null +++ b/src/Examine.Lucene/Suggest/NGramDistanceDefinition.cs @@ -0,0 +1,79 @@ +using Lucene.Net.Index; +using Lucene.Net.Search.Spell; +using System.Linq; +using Examine.Suggest; +using System; + +namespace Examine.Lucene.Suggest +{ + /// + /// Lucene.NET NGramDistanceSuggester Definition + /// + public class NGramDistanceSuggesterDefinition : LuceneSuggesterDefinition, ILookupExecutor + { + /// + /// Constructor + /// + /// Suggester Name + /// Source fields for suggestions + /// Suggester Directory Factory + public NGramDistanceSuggesterDefinition(string name, string[]? sourceFields, ISuggesterDirectoryFactory? directoryFactory = null) + : base(name, sourceFields, directoryFactory, default) + { + if (sourceFields is null) + { + throw new ArgumentNullException(nameof(sourceFields)); + } + if (sourceFields.Length == 0) + { + throw new ArgumentOutOfRangeException(nameof(sourceFields)); + } + } + + /// + /// Spellchecker + /// + public DirectSpellChecker? Spellchecker { get; set; } + + /// + public override ILookupExecutor BuildSuggester(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + => BuildDirectSpellCheckerSuggester(fieldValueTypeCollection, readerManager, rebuild); + + /// + public override ISuggestionResults ExecuteSuggester(string searchText, ISuggestionExecutionContext suggestionExecutionContext) => ExecuteDirectSpellChecker(searchText, suggestionExecutionContext); + + /// + protected ILookupExecutor BuildDirectSpellCheckerSuggester(FieldValueTypeCollection fieldValueTypeCollection, ReaderManager readerManager, bool rebuild) + { + Spellchecker = new DirectSpellChecker(); + Spellchecker.Distance = new NGramDistance(); + return this; + } + + private ISuggestionResults ExecuteDirectSpellChecker(string searchText, ISuggestionExecutionContext suggestionExecutionContext) + { + string field = (SourceFields?.FirstOrDefault()) ?? throw new InvalidOperationException("SourceFields can not be empty"); + var suggestMode = SuggestMode.SUGGEST_WHEN_NOT_IN_INDEX; + if (suggestionExecutionContext.Options is LuceneSuggestionOptions luceneSuggestionOptions) + { + suggestMode = (SuggestMode)luceneSuggestionOptions.SuggestionMode; + } + + if (Spellchecker is null) + { + throw new NullReferenceException("Spellchecker not set"); + } + + using (var readerReference = suggestionExecutionContext.GetIndexReader()) + { + var lookupResults = Spellchecker.SuggestSimilar(new Term(field, searchText), + suggestionExecutionContext.Options.Top, + readerReference.IndexReader, + suggestMode); + var results = lookupResults.Select(x => new SuggestionResult(x.String, x.Score, x.Freq)); + LuceneSuggestionResults suggestionResults = new LuceneSuggestionResults(results.ToArray()); + return suggestionResults; + } + } + } +} diff --git a/src/Examine.Lucene/Suggest/SuggesterContext.cs b/src/Examine.Lucene/Suggest/SuggesterContext.cs new file mode 100644 index 000000000..e601111ed --- /dev/null +++ b/src/Examine.Lucene/Suggest/SuggesterContext.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using Examine.Lucene.Indexing; +using Lucene.Net.Index; +using Lucene.Net.Search.Suggest; +using Lucene.Net.Util; + +namespace Examine.Lucene.Suggest +{ + /// + /// Suggester Context for LuceneSuggester + /// + public class SuggesterContext : ISuggesterContext + { + private ReaderManager _readerManager; + private FieldValueTypeCollection _fieldValueTypeCollection; + private readonly SuggesterDefinitionCollection _suggesterDefinitions; + private readonly Dictionary _suggesters; + + /// + /// Constructor + /// + /// Reader Manager for IndexReader on the Suggester Index + /// Fields of Suggester Index + /// Suggester definitions for the index + /// Suggester implementations + public SuggesterContext(ReaderManager readerManager, FieldValueTypeCollection fieldValueTypeCollection, SuggesterDefinitionCollection suggesterDefinitions, + Dictionary suggesters) + { + _readerManager = readerManager; + _fieldValueTypeCollection = fieldValueTypeCollection; + _suggesterDefinitions = suggesterDefinitions; + _suggesters = suggesters; + } + + /// + public IIndexReaderReference GetIndexReader() => new IndexReaderReference(_readerManager); + + /// + public SuggesterDefinitionCollection GetSuggesterDefinitions() => _suggesterDefinitions; + + /// + public TLookup GetSuggester(string name) where TLookup : Lookup + { + if(_suggesters.TryGetValue(name, out var suggester)) + { + return suggester as TLookup; + } + throw new ArgumentException(name, nameof(name)); + } + + /// + public IIndexFieldValueType GetFieldValueType(string fieldName) + { + //Get the value type for the field, or use the default if not defined + return _fieldValueTypeCollection.GetValueType( + fieldName, + _fieldValueTypeCollection.ValueTypeFactories.GetRequiredFactory(FieldDefinitionTypes.FullText)); + } + + /// + public LuceneVersion GetLuceneVersion() => LuceneVersion.LUCENE_48; + } +} diff --git a/src/Examine.Test/Examine.Lucene/Suggest/SuggesterApiTests.cs b/src/Examine.Test/Examine.Lucene/Suggest/SuggesterApiTests.cs new file mode 100644 index 000000000..1b177ce1a --- /dev/null +++ b/src/Examine.Test/Examine.Lucene/Suggest/SuggesterApiTests.cs @@ -0,0 +1,284 @@ +using System.Linq; +using Examine.Lucene.Suggest; +using Examine.Lucene.Suggest.Directories; +using Examine.Suggest; +using Lucene.Net.Analysis.Standard; +using NUnit.Framework; + +namespace Examine.Test.Examine.Lucene.Suggest +{ + [TestFixture] + public class SuggesterApiTests : ExamineBaseTest + { + FieldDefinitionCollection _fieldDefinitionCollection; + SuggesterDefinitionCollection _suggesters; + + [SetUp] + public void Setup() + { + _fieldDefinitionCollection = new FieldDefinitionCollection(); + _fieldDefinitionCollection.AddOrUpdate(new FieldDefinition("nodeName", FieldDefinitionTypes.FullText)); + _fieldDefinitionCollection.AddOrUpdate(new FieldDefinition("bodyText", FieldDefinitionTypes.FullText)); + + _suggesters = new SuggesterDefinitionCollection(); + _suggesters.AddOrUpdate(new AnalyzingInfixSuggesterDefinition(ExamineLuceneSuggesterNames.AnalyzingInfixSuggester, new string[] { "nodeName" }, new RAMSuggesterDirectoryFactory())); + _suggesters.AddOrUpdate(new AnalyzingSuggesterDefinition(ExamineLuceneSuggesterNames.AnalyzingSuggester, new string[] { "nodeName" })); + _suggesters.AddOrUpdate(new DirectSpellCheckerDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker, new string[] { "nodeName" })); + _suggesters.AddOrUpdate(new LevensteinDistanceSuggesterDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker_LevensteinDistance, new string[] { "nodeName" })); + _suggesters.AddOrUpdate(new JaroWinklerDistanceDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker_JaroWinklerDistance, new string[] { "nodeName" })); + _suggesters.AddOrUpdate(new NGramDistanceSuggesterDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker_NGramDistance, new string[] { "nodeName" })); + _suggesters.AddOrUpdate(new FuzzySuggesterDefinition(ExamineLuceneSuggesterNames.FuzzySuggester, new string[] { "nodeName" })); + } + + [Test] + public void Suggest_Text_AnalyzingInfixSuggester() + { + var analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion); + using (var luceneDir = new RandomIdRAMDirectory()) + using (var indexer = GetTestIndex(luceneDir, analyzer, _fieldDefinitionCollection, suggesterDefinitions: _suggesters)) + { + indexer.IndexItems(new[] { + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "location 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "location 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "location 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "locksmiths 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "locksmiths 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "locksmiths 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "locomotive 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "locomotive 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "locomotive 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "vehicle 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "vehicle 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "vehicle 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "content localization 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "content localization 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "content localization 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + + }); + + ISuggester suggester = indexer.Suggester; + + var query = suggester.CreateSuggestionQuery(); + + var results = query.Execute("loc", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.AnalyzingInfixSuggester)); + Assert.IsTrue(results.Count() == 4); + Assert.IsTrue(results.Any(x => x.Text.Equals("location"))); + Assert.IsTrue(results.Any(x => x.Text.Equals("locksmiths"))); + Assert.IsTrue(results.Any(x => x.Text.Equals("locomotive"))); + Assert.IsTrue(results.Any(x => x.Text.Equals("localization"))); + + var results2 = query.Execute("loco", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.AnalyzingInfixSuggester)); + Assert.IsTrue(results2.Count() == 1); + Assert.IsTrue(results2.Any(x => x.Text.Equals("locomotive"))); + } + } + + [Test] + public void Suggest_Text_AnalyzingSuggester() + { + var analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion); + using (var luceneDir = new RandomIdRAMDirectory()) + using (var indexer = GetTestIndex(luceneDir, analyzer, _fieldDefinitionCollection, suggesterDefinitions: _suggesters)) + { + indexer.IndexItems(new[] { + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "location 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "location 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "location 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "locksmiths 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "locksmiths 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "locksmiths 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "locomotive 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "locomotive 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "locomotive 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "vehicle 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "vehicle 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "vehicle 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "content localization 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "content localization 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "content localization 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + + }); + + ISuggester suggester = indexer.Suggester; + + var query = suggester.CreateSuggestionQuery(); + + var results = query.Execute("loc", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.AnalyzingSuggester)); + Assert.IsTrue(results.Count() == 4); + Assert.IsTrue(results.Any(x => x.Text.Equals("location"))); + Assert.IsTrue(results.Any(x => x.Text.Equals("locksmiths"))); + Assert.IsTrue(results.Any(x => x.Text.Equals("locomotive"))); + Assert.IsTrue(results.Any(x => x.Text.Equals("localization"))); + + var results2 = query.Execute("loco", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.AnalyzingSuggester)); + Assert.IsTrue(results2.Count() == 1); + Assert.IsTrue(results2.Any(x => x.Text.Equals("locomotive"))); + } + } + + [Test] + public void Suggest_Text_FuzzySuggester() + { + var analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion); + using (var luceneDir = new RandomIdRAMDirectory()) + using (var indexer = GetTestIndex(luceneDir, analyzer, _fieldDefinitionCollection, suggesterDefinitions: _suggesters)) + { + indexer.IndexItems(new[] { + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "location 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "location 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "location 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "locksmiths 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "locksmiths 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "locksmiths 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "locomotive 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "locomotive 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "locomotive 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "vehicle 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "vehicle 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "vehicle 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "content localization 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "content localization 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "content localization 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + + }); + + ISuggester suggester = indexer.Suggester; + var query = suggester.CreateSuggestionQuery(); + + var results = query.Execute("loc", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.FuzzySuggester)); + Assert.IsTrue(results.Count() == 4); + Assert.IsTrue(results.Any(x => x.Text.Equals("location"))); + Assert.IsTrue(results.Any(x => x.Text.Equals("locksmiths"))); + Assert.IsTrue(results.Any(x => x.Text.Equals("locomotive"))); + Assert.IsTrue(results.Any(x => x.Text.Equals("localization"))); + + var results2 = query.Execute("loco", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.FuzzySuggester)); + Assert.IsTrue(results.Count() == 4); + Assert.IsTrue(results.Any(x => x.Text.Equals("location"))); + Assert.IsTrue(results.Any(x => x.Text.Equals("locksmiths"))); + Assert.IsTrue(results.Any(x => x.Text.Equals("locomotive"))); + Assert.IsTrue(results.Any(x => x.Text.Equals("localization"))); + } + } + + + [Test] + public void Suggest_Text_DirectSpellChecker() + { + var analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion); + using (var luceneDir = new RandomIdRAMDirectory()) + using (var indexer = GetTestIndex(luceneDir, analyzer, _fieldDefinitionCollection, suggesterDefinitions: _suggesters)) + { + indexer.IndexItems(new[] { + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "location 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "location 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "location 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "locksmiths 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "locksmiths 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "locksmiths 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "locomotive 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "locomotive 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "locomotive 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "vehicle 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "vehicle 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "vehicle 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "content localization 1", bodyText = "Zanzibar is in Africa"}), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "content localization 2", bodyText = "In Canada there is a town called Sydney in Nova Scotia"}), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "content localization 3", bodyText = "Sydney is the capital of NSW in Australia"}), + + + }); + + ISuggester suggester = indexer.Suggester; + var query = suggester.CreateSuggestionQuery(); + + var results = query.Execute("logomotave", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.DirectSpellChecker)); + Assert.IsTrue(results.Count() == 1); + Assert.IsTrue(results.Any(x => x.Text.Equals("locomotive"))); + + var results2 = query.Execute("localisation", new LuceneSuggestionOptions(5, ExamineLuceneSuggesterNames.DirectSpellChecker)); + Assert.IsTrue(results2.Count() == 1); + Assert.IsTrue(results2.Any(x => x.Text.Equals("localization"))); + } + } + } +} diff --git a/src/Examine.Test/ExamineBaseTest.cs b/src/Examine.Test/ExamineBaseTest.cs index a75b67417..d0f858c2a 100644 --- a/src/Examine.Test/ExamineBaseTest.cs +++ b/src/Examine.Test/ExamineBaseTest.cs @@ -21,7 +21,7 @@ public virtual void Setup() loggerFactory.CreateLogger(typeof(ExamineBaseTest)).LogDebug("Initializing test"); } - public TestIndex GetTestIndex(Directory d, Analyzer analyzer, FieldDefinitionCollection fieldDefinitions = null, IndexDeletionPolicy indexDeletionPolicy = null, IReadOnlyDictionary indexValueTypesFactory = null, FacetsConfig facetsConfig = null) + public TestIndex GetTestIndex(Directory d, Analyzer analyzer, FieldDefinitionCollection fieldDefinitions = null, IndexDeletionPolicy indexDeletionPolicy = null, IReadOnlyDictionary indexValueTypesFactory = null, FacetsConfig facetsConfig = null,SuggesterDefinitionCollection suggesterDefinitions = null) { var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Debug)); return new TestIndex( @@ -33,7 +33,8 @@ public TestIndex GetTestIndex(Directory d, Analyzer analyzer, FieldDefinitionCol Analyzer = analyzer, IndexDeletionPolicy = indexDeletionPolicy, IndexValueTypesFactory = indexValueTypesFactory, - FacetsConfig = facetsConfig ?? new FacetsConfig() + FacetsConfig = facetsConfig ?? new FacetsConfig(), + SuggesterDefinitions = suggesterDefinitions })); } diff --git a/src/Examine.Web.Demo/ConfigureIndexOptions.cs b/src/Examine.Web.Demo/ConfigureIndexOptions.cs index 3466d8f72..5dbf59feb 100644 --- a/src/Examine.Web.Demo/ConfigureIndexOptions.cs +++ b/src/Examine.Web.Demo/ConfigureIndexOptions.cs @@ -1,6 +1,8 @@ using Examine.Lucene; using Examine.Lucene.Analyzers; using Examine.Lucene.Indexing; +using Examine.Lucene.Suggest; +using Examine.Lucene.Suggest.Directories; using Microsoft.Extensions.Options; namespace Examine.Web.Demo @@ -39,6 +41,15 @@ public void Configure(string name, LuceneDirectoryIndexOptions options) // Add the field definition for a field called "phone" which maps // to a Value Type called "phone" defined above. options.FieldDefinitions.AddOrUpdate(new FieldDefinition("phone", "phone")); + + options.FieldDefinitions.AddOrUpdate(new FieldDefinition("FullName", FieldDefinitionTypes.FullText)); + options.SuggesterDefinitions.AddOrUpdate(new AnalyzingInfixSuggesterDefinition(ExamineLuceneSuggesterNames.AnalyzingInfixSuggester, new string[] { "fullName" }, new RAMSuggesterDirectoryFactory())); + options.SuggesterDefinitions.AddOrUpdate(new AnalyzingSuggesterDefinition(ExamineLuceneSuggesterNames.AnalyzingSuggester, new string[] { "fullName" })); + options.SuggesterDefinitions.AddOrUpdate(new DirectSpellCheckerDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker, new string[] { "fullName" })); + options.SuggesterDefinitions.AddOrUpdate(new LevensteinDistanceSuggesterDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker_LevensteinDistance, new string[] { "fullName" })); + options.SuggesterDefinitions.AddOrUpdate(new JaroWinklerDistanceDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker_JaroWinklerDistance, new string[] { "fullName" })); + options.SuggesterDefinitions.AddOrUpdate(new NGramDistanceSuggesterDefinition(ExamineLuceneSuggesterNames.DirectSpellChecker_NGramDistance,new string[] { "fullName" })); + options.SuggesterDefinitions.AddOrUpdate(new FuzzySuggesterDefinition(ExamineLuceneSuggesterNames.FuzzySuggester, new string[] { "fullName" })); break; case "TaxonomyFacetIndex": options.UseTaxonomyIndex = true; diff --git a/src/Examine.Web.Demo/Data/IndexService.cs b/src/Examine.Web.Demo/Data/IndexService.cs index 961801675..5dca0ee7b 100644 --- a/src/Examine.Web.Demo/Data/IndexService.cs +++ b/src/Examine.Web.Demo/Data/IndexService.cs @@ -2,6 +2,7 @@ using Examine.Lucene.Providers; using Examine.Lucene.Search; using Examine.Search; +using Examine.Suggest; using Examine.Web.Demo.Controllers; using Examine.Web.Demo.Data.Models; using Lucene.Net.Search; @@ -45,9 +46,20 @@ public IndexInformation GetIndexInformation(string indexName) if (index is IIndexStats indexStats) { var fields = indexStats.GetFieldNames(); + var searchers = new List(); + var suggesters = new List(); + if (!string.IsNullOrWhiteSpace(index.Searcher?.Name)) + { + searchers.Add(index.Searcher?.Name); + } + if (!string.IsNullOrWhiteSpace(index.Suggester?.Name)) + { + suggesters.Add(index.Suggester?.Name); + } + return new IndexInformation( indexStats.GetDocumentCount(), - fields.ToList()); + fields.ToList(), searchers, suggesters); } else { @@ -116,6 +128,15 @@ public ILuceneSearchResults SearchLucene(string indexName, Func fields) + public IndexInformation(long documentCount, List fields, List searchers, List suggesters) { DocumentCount = documentCount; Fields = fields; FieldCount = fields.Count; + Searchers = searchers; + Suggesters = suggesters; } public long DocumentCount { get; } public List Fields { get; } public int FieldCount { get; set; } + + public List Searchers { get; } + + public List Suggesters { get; } } } diff --git a/src/Examine.Web.Demo/IndexFactoryExtensions.cs b/src/Examine.Web.Demo/IndexFactoryExtensions.cs index 91729169a..cb9c21ad8 100644 --- a/src/Examine.Web.Demo/IndexFactoryExtensions.cs +++ b/src/Examine.Web.Demo/IndexFactoryExtensions.cs @@ -1,4 +1,7 @@ using Lucene.Net.Facet; +using Examine.Lucene.Suggest; +using Lucene.Net.Analysis.Standard; +using Lucene.Net.Util; using Microsoft.Extensions.DependencyInjection; namespace Examine.Web.Demo @@ -42,6 +45,7 @@ public static IServiceCollection CreateIndexes(this IServiceCollection services) services.ConfigureOptions(); + return services; } } diff --git a/src/Examine.Web.Demo/Pages/Search.razor b/src/Examine.Web.Demo/Pages/Search.razor index 3e635115a..4d8881acc 100644 --- a/src/Examine.Web.Demo/Pages/Search.razor +++ b/src/Examine.Web.Demo/Pages/Search.razor @@ -1,4 +1,5 @@ @page "/search" +@using Examine.Suggest; @using Examine.Web.Demo.Data @using System.Diagnostics @inject IndexService IndexService @@ -27,6 +28,21 @@ +
+ @if (_suggesterResults != null) + { +

@_suggesterResults.Count() Suggestions found (Showing @_suggesterResults.Count()) - Found in: @_suggesterTime

+ @foreach (var suggesterResult in _suggesterResults) + { +

Suggestion Text:@suggesterResult.Text, Weight:@suggesterResult.Weight, Frequency:@suggesterResult.Frequency

+ } + } + else + { +

No results

+ } +
+
@if (_searchResults != null) { @@ -47,6 +63,8 @@ private string _query = string.Empty; private ISearchResults? _searchResults; private string _searchTime = string.Empty; + private ISuggestionResults? _suggesterResults; + private string _suggesterTime = string.Empty; protected override void OnInitialized() { @@ -60,6 +78,18 @@ _searchResults = IndexService.SearchNativeQuery(_selectedIndex, _query.Trim()); stopwatch.Stop(); _searchTime = $"{stopwatch.ElapsedMilliseconds} Milliseconds ({stopwatch.Elapsed:g})"; + + try + { + var stopwatchSuggester = Stopwatch.StartNew(); + _suggesterResults = IndexService.Suggest(_selectedIndex, _query.Trim()); + stopwatchSuggester.Stop(); + _suggesterTime = $"{stopwatchSuggester.ElapsedMilliseconds} Milliseconds ({stopwatch.Elapsed:g})"; + } + catch + { + + } } private void GetFirst100Items(){ diff --git a/src/Examine.Web.Demo/Shared/Components/IndexData.razor b/src/Examine.Web.Demo/Shared/Components/IndexData.razor index c885f1d09..8e996b8d0 100644 --- a/src/Examine.Web.Demo/Shared/Components/IndexData.razor +++ b/src/Examine.Web.Demo/Shared/Components/IndexData.razor @@ -61,6 +61,26 @@ } }
+
+

Searchers

+ @if (Information != null) + { + foreach (var field in Information.Searchers) + { +

@field

+ } + } +
+
+

Suggesters

+ @if (Information != null) + { + foreach (var field in Information.Suggesters) + { +

@field

+ } + } +
@code {