├── .editorconfig ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── build.yml ├── .gitignore ├── .idea └── .idea.Foundatio.Parsers │ └── .idea │ ├── encodings.xml │ ├── indexLayout.xml │ ├── projectSettingsUpdater.xml │ └── vcs.xml ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── Foundatio.Parsers.sln ├── LICENSE.txt ├── NuGet.config ├── README.md ├── build ├── Foundatio.snk ├── common.props └── foundatio-icon.png ├── docker-compose.yml ├── docs ├── aggregations.md └── query.md ├── global.json ├── src ├── Foundatio.Parsers.ElasticQueries │ ├── ElasticMappingResolver.cs │ ├── ElasticQueryParser.cs │ ├── ElasticQueryParserConfiguration.cs │ ├── Extensions │ │ ├── DefaultAggregationNodeExtensions.cs │ │ ├── DefaultQueryNodeExtensions.cs │ │ ├── DefaultSortNodeExtensions.cs │ │ ├── ElasticExtensions.cs │ │ ├── ElasticMappingExtensions.cs │ │ ├── QueryNodeExtensions.cs │ │ ├── QueryVisitorContextExtensions.cs │ │ └── SearchDescriptorExtensions.cs │ ├── Foundatio.Parsers.ElasticQueries.csproj │ └── Visitors │ │ ├── CombineAggregationsVisitor.cs │ │ ├── CombineQueriesVisitor.cs │ │ ├── ElasticQueryVisitorContext.cs │ │ ├── GeoVisitor.cs │ │ ├── GetSortFieldsVisitor.cs │ │ ├── IElasticQueryVisitorContext.cs │ │ └── NestedVisitor.cs ├── Foundatio.Parsers.LuceneQueries │ ├── Extensions │ │ ├── QueryNodeExtensions.cs │ │ ├── QueryVisitorContextExtensions.cs │ │ ├── StringExtensions.cs │ │ └── TextWriterExtensions.cs │ ├── Foundatio.Parsers.LuceneQueries.csproj │ ├── LuceneQueryParser.cs │ ├── LuceneQueryParser.peg │ ├── Nodes │ │ ├── ExistsNode.cs │ │ ├── GroupNode.cs │ │ ├── IQueryNode.cs │ │ ├── MissingNode.cs │ │ ├── QueryNodeBase.cs │ │ ├── TermNode.cs │ │ └── TermRangeNode.cs │ ├── QueryTypes.cs │ ├── QueryValidation.cs │ ├── QueryValidator.cs │ └── Visitors │ │ ├── AssignOperationTypeVisitor.cs │ │ ├── ChainedQueryVisitor.cs │ │ ├── CleanupQueryVisitor.cs │ │ ├── DebugQueryVisitor.cs │ │ ├── FieldResolverQueryVisitor.cs │ │ ├── GenerateQueryVisitor.cs │ │ ├── GetReferencedFieldsQueryVisitor.cs │ │ ├── IChainableQueryVisitor.cs │ │ ├── IQueryNodeVisitor.cs │ │ ├── IQueryNodeVisitorWithResult.cs │ │ ├── IQueryVisitorContext.cs │ │ ├── IQueryVisitorContextWithFieldResolver.cs │ │ ├── IQueryVisitorContextWithIncludeResolver.cs │ │ ├── IQueryVisitorContextWithValidation.cs │ │ ├── IncludeVisitor.cs │ │ ├── InvertQueryVisitor.cs │ │ ├── QueryNodeVisitorBase.cs │ │ ├── QueryNodeVisitorWithResultBase.cs │ │ ├── QueryVisitorContext.cs │ │ ├── RemoveFieldsQueryVisitor.cs │ │ ├── TermToFieldVisitor.cs │ │ ├── ValidationVisitor.cs │ │ └── VisitorWithPriority.cs └── Foundatio.Parsers.SqlQueries │ ├── Extensions │ ├── EnumerableExtensions.cs │ ├── SqlNodeExtensions.cs │ └── TypeExtensions.cs │ ├── Foundatio.Parsers.SqlQueries.csproj │ ├── SqlQueryParser.cs │ ├── SqlQueryParserConfiguration.cs │ └── Visitors │ ├── GenerateSqlVisitor.cs │ ├── ISqlQueryVisitorContext.cs │ └── SqlQueryVisitorContext.cs └── tests ├── Directory.Build.props ├── Foundatio.Parsers.ElasticQueries.Tests ├── AggregationParserTests.cs ├── CustomVisitorTests.cs ├── ElasticMappingResolverTests.cs ├── ElasticQueryParserTests.cs ├── Foundatio.Parsers.ElasticQueries.Tests.csproj ├── InvertQueryTests.cs └── Utility │ └── ElasticsearchTestBase.cs ├── Foundatio.Parsers.LuceneQueries.Tests ├── CleanupQueryVisitorTests.cs ├── FieldResolverVisitorTests.cs ├── Foundatio.Parsers.LuceneQueries.Tests.csproj ├── GenerateQueryVisitorTests.cs ├── IncludeQueryVisitorTests.cs ├── InvertQueryVisitorTests.cs ├── QueryParserTests.cs ├── QueryParserValidationTests.cs ├── QueryValidatorTests.cs ├── ReferencedFieldsTests.cs ├── RemoveFieldsQueryVisitor.cs ├── UnescapeTests.cs └── Utility │ └── LoggingTracer.cs └── Foundatio.Parsers.SqlQueries.Tests ├── DynamicFieldVisitor.cs ├── Foundatio.Parsers.SqlQueries.Tests.csproj ├── SampleContext.cs └── SqlQueryParserTests.cs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: exceptionless 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | - package-ecosystem: nuget 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | 9 | - package-ecosystem: "docker-compose" 10 | directory: "/" 11 | schedule: 12 | interval: quarterly 13 | ignore: 14 | - dependency-name: "elasticsearch/elasticsearch" 15 | versions: 16 | - "<8.0.0" 17 | - ">=9.0.0" 18 | - dependency-name: "kibana/kibana" 19 | versions: 20 | - "<8.0.0" 21 | - ">=9.0.0" 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | uses: FoundatioFx/Foundatio/.github/workflows/build-workflow.yml@main 7 | secrets: inherit 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # User-specific files 2 | *.suo 3 | *.user 4 | 5 | # Build results 6 | [Bb]in/ 7 | [Oo]bj/ 8 | artifacts 9 | .vs/ 10 | 11 | # MSTest test Results 12 | [Tt]est[Rr]esult*/ 13 | 14 | # ReSharper is a .NET coding add-in 15 | _ReSharper*/ 16 | *.[Rr]e[Ss]harper 17 | *.DotSettings.user 18 | 19 | # JustCode is a .NET coding addin-in 20 | .JustCode 21 | 22 | # DotCover is a Code Coverage Tool 23 | *.dotCover 24 | 25 | # NCrunch 26 | _NCrunch_* 27 | .*crunch*.local.xml 28 | 29 | # NuGet Packages 30 | *.nupkg 31 | 32 | .DS_Store 33 | 34 | # Rider 35 | 36 | # User specific 37 | **/.idea/**/workspace.xml 38 | **/.idea/**/tasks.xml 39 | **/.idea/shelf/* 40 | **/.idea/dictionaries 41 | 42 | # Sensitive or high-churn files 43 | **/.idea/**/dataSources/ 44 | **/.idea/**/dataSources.ids 45 | **/.idea/**/dataSources.xml 46 | **/.idea/**/dataSources.local.xml 47 | **/.idea/**/sqlDataSources.xml 48 | **/.idea/**/dynamic.xml 49 | 50 | # Rider 51 | # Rider auto-generates .iml files, and contentModel.xml 52 | **/.idea/**/*.iml 53 | **/.idea/**/contentModel.xml 54 | **/.idea/**/modules.xml 55 | **/.idea/copilot/chatSessions/ 56 | -------------------------------------------------------------------------------- /.idea/.idea.Foundatio.Parsers/.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/.idea.Foundatio.Parsers/.idea/indexLayout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/.idea.Foundatio.Parsers/.idea/projectSettingsUpdater.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/.idea.Foundatio.Parsers/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "theaustinseven.pegs", 4 | "ms-dotnettools.csharp", 5 | "streetsidesoftware.code-spell-checker", 6 | "editorconfig.editorconfig", 7 | "tintoy.msbuild-project-tools" 8 | ] 9 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to find out which attributes exist for C# debugging 3 | // Use hover for the description of the existing attributes 4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": ".NET Core Launch (console)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/test/Foundatio.Parsers.LuceneQueries.Tests/bin/Debug/net8.0/Foundatio.Parsers.LuceneQueries.Tests.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}/test/Foundatio.Parsers.LuceneQueries.Tests", 16 | // For more information about the 'console' field, see https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#console-terminal-window 17 | "console": "internalConsole", 18 | "stopAtEntry": false, 19 | "internalConsoleOptions": "openOnSessionStart" 20 | }, 21 | { 22 | "name": ".NET Core Attach", 23 | "type": "coreclr", 24 | "request": "attach", 25 | "processId": "${command:pickProcess}" 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Lucene", 4 | "Niemyjski", 5 | "Xunit", 6 | "aggs" 7 | ], 8 | "msbuildProjectTools.nuget.includePreRelease": true 9 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "group": { 9 | "kind": "build", 10 | "isDefault": true 11 | }, 12 | "args": [ 13 | "build", 14 | "${workspaceFolder}", 15 | "/p:GenerateFullPaths=true" 16 | ], 17 | "problemMatcher": "$msCompile" 18 | }, 19 | { 20 | "label": "test", 21 | "command": "dotnet", 22 | "type": "process", 23 | "group": { 24 | "kind": "test", 25 | "isDefault": true 26 | }, 27 | "args": [ 28 | "test", 29 | "${workspaceFolder}/tests/Foundatio.Parsers.LuceneQueries.Tests/Foundatio.Parsers.LuceneQueries.Tests.csproj", 30 | "/p:GenerateFullPaths=true" 31 | ], 32 | "problemMatcher": "$msCompile" 33 | }, 34 | { 35 | "label": "pack", 36 | "command": "dotnet pack -c Release -o ${workspaceFolder}/artifacts", 37 | "type": "shell", 38 | "problemMatcher": [] 39 | }, 40 | { 41 | "label": "docker: elasticsearch", 42 | "command": "docker compose up", 43 | "type": "shell", 44 | "isBackground": true, 45 | "group": "test", 46 | "problemMatcher": [] 47 | } 48 | ] 49 | } -------------------------------------------------------------------------------- /Foundatio.Parsers.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30404.54 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Foundatio.Parsers.LuceneQueries", "src\Foundatio.Parsers.LuceneQueries\Foundatio.Parsers.LuceneQueries.csproj", "{F759E45D-C914-4546-947D-B9F0854D22E9}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Foundatio.Parsers.ElasticQueries", "src\Foundatio.Parsers.ElasticQueries\Foundatio.Parsers.ElasticQueries.csproj", "{9F6F9CC3-2544-44EB-8829-209187271A4E}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Foundatio.Parsers.LuceneQueries.Tests", "tests\Foundatio.Parsers.LuceneQueries.Tests\Foundatio.Parsers.LuceneQueries.Tests.csproj", "{DDFFC095-C821-41DA-8DFF-2AD37A56106F}" 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{400D08AA-50A4-4D74-A3AD-F74AAB8FC706}" 13 | ProjectSection(SolutionItems) = preProject 14 | .editorconfig = .editorconfig 15 | .github\workflows\build.yml = .github\workflows\build.yml 16 | build\common.props = build\common.props 17 | tests\Directory.Build.props = tests\Directory.Build.props 18 | docker-compose.yml = docker-compose.yml 19 | NuGet.config = NuGet.config 20 | README.md = README.md 21 | EndProjectSection 22 | EndProject 23 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Foundatio.Parsers.ElasticQueries.Tests", "tests\Foundatio.Parsers.ElasticQueries.Tests\Foundatio.Parsers.ElasticQueries.Tests.csproj", "{C353E601-874C-4855-9B01-2980BC624BC4}" 24 | EndProject 25 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Foundatio.Parsers.SqlQueries", "src\Foundatio.Parsers.SqlQueries\Foundatio.Parsers.SqlQueries.csproj", "{53C0B9D8-6398-437C-AE15-43BD4CB7AD8D}" 26 | EndProject 27 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Foundatio.Parsers.SqlQueries.Tests", "tests\Foundatio.Parsers.SqlQueries.Tests\Foundatio.Parsers.SqlQueries.Tests.csproj", "{B30934E7-C69F-465B-BACF-61AAEC7DA775}" 28 | EndProject 29 | Global 30 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 31 | Debug|Any CPU = Debug|Any CPU 32 | Release|Any CPU = Release|Any CPU 33 | EndGlobalSection 34 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 35 | {F759E45D-C914-4546-947D-B9F0854D22E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {F759E45D-C914-4546-947D-B9F0854D22E9}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {F759E45D-C914-4546-947D-B9F0854D22E9}.Release|Any CPU.ActiveCfg = Release|Any CPU 38 | {F759E45D-C914-4546-947D-B9F0854D22E9}.Release|Any CPU.Build.0 = Release|Any CPU 39 | {9F6F9CC3-2544-44EB-8829-209187271A4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 40 | {9F6F9CC3-2544-44EB-8829-209187271A4E}.Debug|Any CPU.Build.0 = Debug|Any CPU 41 | {9F6F9CC3-2544-44EB-8829-209187271A4E}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {9F6F9CC3-2544-44EB-8829-209187271A4E}.Release|Any CPU.Build.0 = Release|Any CPU 43 | {DDFFC095-C821-41DA-8DFF-2AD37A56106F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 44 | {DDFFC095-C821-41DA-8DFF-2AD37A56106F}.Debug|Any CPU.Build.0 = Debug|Any CPU 45 | {DDFFC095-C821-41DA-8DFF-2AD37A56106F}.Release|Any CPU.ActiveCfg = Release|Any CPU 46 | {DDFFC095-C821-41DA-8DFF-2AD37A56106F}.Release|Any CPU.Build.0 = Release|Any CPU 47 | {C353E601-874C-4855-9B01-2980BC624BC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 48 | {C353E601-874C-4855-9B01-2980BC624BC4}.Debug|Any CPU.Build.0 = Debug|Any CPU 49 | {C353E601-874C-4855-9B01-2980BC624BC4}.Release|Any CPU.ActiveCfg = Release|Any CPU 50 | {C353E601-874C-4855-9B01-2980BC624BC4}.Release|Any CPU.Build.0 = Release|Any CPU 51 | {53C0B9D8-6398-437C-AE15-43BD4CB7AD8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 52 | {53C0B9D8-6398-437C-AE15-43BD4CB7AD8D}.Debug|Any CPU.Build.0 = Debug|Any CPU 53 | {53C0B9D8-6398-437C-AE15-43BD4CB7AD8D}.Release|Any CPU.ActiveCfg = Release|Any CPU 54 | {53C0B9D8-6398-437C-AE15-43BD4CB7AD8D}.Release|Any CPU.Build.0 = Release|Any CPU 55 | {B30934E7-C69F-465B-BACF-61AAEC7DA775}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 56 | {B30934E7-C69F-465B-BACF-61AAEC7DA775}.Debug|Any CPU.Build.0 = Debug|Any CPU 57 | {B30934E7-C69F-465B-BACF-61AAEC7DA775}.Release|Any CPU.ActiveCfg = Release|Any CPU 58 | {B30934E7-C69F-465B-BACF-61AAEC7DA775}.Release|Any CPU.Build.0 = Release|Any CPU 59 | EndGlobalSection 60 | GlobalSection(SolutionProperties) = preSolution 61 | HideSolutionNode = FALSE 62 | EndGlobalSection 63 | GlobalSection(ExtensibilityGlobals) = postSolution 64 | SolutionGuid = {37B59D3F-7772-4404-9BF3-F2FFE79C47D0} 65 | EndGlobalSection 66 | EndGlobal 67 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Foundatio](https://raw.githubusercontent.com/FoundatioFx/Foundatio/master/media/foundatio-dark-bg.svg#gh-dark-mode-only "Foundatio")![Foundatio](https://raw.githubusercontent.com/FoundatioFx/Foundatio/master/media/foundatio.svg#gh-light-mode-only "Foundatio") 2 | 3 | [![Build status](https://github.com/FoundatioFx/Foundatio.Parsers/workflows/Build/badge.svg)](https://github.com/FoundatioFx/Foundatio.Parsers/actions) 4 | [![NuGet Version](http://img.shields.io/nuget/v/Foundatio.Parsers.LuceneQueries.svg?style=flat)](https://www.nuget.org/packages/Foundatio.Parsers.LuceneQueries/) 5 | [![feedz.io](https://img.shields.io/badge/endpoint.svg?url=https%3A%2F%2Ff.feedz.io%2Ffoundatio%2Ffoundatio%2Fshield%2FFoundatio.Parsers.LuceneQueries%2Flatest)](https://f.feedz.io/foundatio/foundatio/packages/Foundatio.Parsers.LuceneQueries/latest/download) 6 | [![Discord](https://img.shields.io/discord/715744504891703319)](https://discord.gg/6HxgFCx) 7 | 8 | A lucene style query parser that is extensible and allows additional syntax features. Also includes an Elasticsearch query_string query replacement that greatly enhances its capabilities for dynamic queries. 9 | 10 | ## Getting Started (Development) 11 | 12 | [This package](https://www.nuget.org/packages/Foundatio.Parsers.LuceneQueries/) can be installed via the [NuGet package manager](https://docs.nuget.org/consume/Package-Manager-Dialog). If you need help, please contact us via in-app support or [open an issue](https://github.com/exceptionless/Foundatio.Parsers/issues/new). We’re always here to help if you have any questions! 13 | 14 | 1. You will need to have [Visual Studio Code](https://code.visualstudio.com) installed. 15 | 2. Open the `Foundatio.Parsers.sln` Visual Studio solution file. 16 | 17 | ## Using LuceneQueryParser 18 | 19 | Below is a small sampling of the things you can accomplish with LuceneQueryParser, so check it out! We use this library extensively in [Exceptionless](https://github.com/exceptionless/Exceptionless)! 20 | 21 | In the sample below we will parse a query and output it's structure using the `DebugQueryVisitor` and then generate the same exact query using the parse result. 22 | 23 | ```csharp 24 | using Foundatio.Parsers.LuceneQueries; 25 | using Foundatio.Parsers.LuceneQueries.Visitors; 26 | 27 | var parser = new LuceneQueryParser(); 28 | var result = parser.Parse("field:[1 TO 2]"); 29 | Debug.WriteLine(DebugQueryVisitor.Run(result)); 30 | ``` 31 | 32 | Here is the parse result as shown from the `DebugQueryVisitor` 33 | ``` 34 | Group: 35 | Left - Term: 36 | TermMax: 2 37 | TermMin: 1 38 | MinInclusive: True 39 | MaxInclusive: True 40 | Field: 41 | Name: field 42 | ``` 43 | 44 | Finally, lets translate the parse result back into the original query. 45 | ```csharp 46 | var generatedQuery = GenerateQueryVisitor.Run(result); 47 | System.Diagnostics.Debug.Assert(query == generatedQuery); 48 | ``` 49 | ## [Query Syntax](docs/query.md) 50 | 51 | ## [Aggregation Syntax](docs/aggregations.md) 52 | 53 | ## Features 54 | - Lucene Query Syntax Parser 55 | - Parsers fairly standardized syntax from [Lucene](https://lucene.apache.org/core/2_9_4/queryparsersyntax.html) and [Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html). 56 | - Visitors for extensibility 57 | - Field Aliases (static and dynamic) 58 | - Query Includes 59 | - Define stored queries that can be included inside other queries as macros that will be expanded 60 | - Validation 61 | - Validate query syntax 62 | - Restrict access to specific fields 63 | - Restrict the number of operations allowed 64 | - Restrict nesting depth 65 | - Elasticsearch 66 | - Elastic query string query replacement on steriods 67 | - Dynamic search and filter expressions 68 | - Dynamic aggregation expressions 69 | - Supported bucket aggregations: terms, geo grid, date histogram, numeric histogram 70 | - Bucket aggregations allow nesting other dynamic aggregations inside 71 | - Supported metric aggregations: min, max, avg, sum, stats, extended stats, cardinality, missing, percentiles 72 | - Dynamic sort expressions 73 | - Dynamic expressions can be exposed to end users to allow for custom searches, filters, sorting and aggregations 74 | - Enables allowing users to build custom views, charts and dashboards 75 | - Enables powerful APIs that allow users to do things you never thought of 76 | - Supports geo queries (proximity and radius) 77 | - mygeo:75044~75mi 78 | - Returns all documents that have a value in the mygeo field that is within a 75 mile radius of the 75044 zip code 79 | - Supports nested document mappings 80 | - Automatically resolves non-analyzed keyword sub-fields for sorting and aggregations 81 | - Aliases can be defined right on your NEST mappings 82 | - Supports both root and inner field name aliases 83 | 84 | ## Thanks to all the people who have contributed 85 | 86 | [![contributors](https://contributors-img.web.app/image?repo=FoundatioFx/Foundatio.Parsers)](https://github.com/FoundatioFx/Foundatio.Parsers/graphs/contributors) 87 | -------------------------------------------------------------------------------- /build/Foundatio.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FoundatioFx/Foundatio.Parsers/d75eb61dffc1e5e3ed65bfa519fc4854102adeed/build/Foundatio.snk -------------------------------------------------------------------------------- /build/common.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0;net8.0 5 | Foundatio.Parsers 6 | A lucene style query parser that is extensible and allows additional syntax features. 7 | https://github.com/FoundatioFx/Foundatio.Parsers 8 | https://github.com/FoundatioFx/Foundatio.Parsers/releases 9 | true 10 | v 11 | true 12 | 13 | Copyright (c) 2025 Foundatio. All rights reserved. 14 | FoundatioFx 15 | $(NoWarn);CS1591 16 | true 17 | latest 18 | true 19 | $(SolutionDir)artifacts 20 | foundatio-icon.png 21 | README.md 22 | Apache-2.0 23 | $(PackageProjectUrl) 24 | true 25 | true 26 | embedded 27 | 28 | 29 | 30 | true 31 | 32 | 33 | 34 | true 35 | $(MSBuildThisFileDirectory)Foundatio.snk 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /build/foundatio-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FoundatioFx/Foundatio.Parsers/d75eb61dffc1e5e3ed65bfa519fc4854102adeed/build/foundatio-icon.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | elasticsearch: 3 | image: docker.elastic.co/elasticsearch/elasticsearch:8.18.1 4 | environment: 5 | discovery.type: single-node 6 | xpack.security.enabled: 'false' 7 | ES_JAVA_OPTS: -Xms512m -Xmx512m 8 | ports: 9 | - 9200:9200 10 | - 9300:9300 11 | networks: 12 | - foundatio 13 | healthcheck: 14 | interval: 2s 15 | retries: 10 16 | test: curl -s http://localhost:9200/_cluster/health | grep -vq '"status":"red"' 17 | 18 | kibana: 19 | depends_on: 20 | elasticsearch: 21 | condition: service_healthy 22 | image: docker.elastic.co/kibana/kibana:8.18.1 23 | ports: 24 | - 5601:5601 25 | networks: 26 | - foundatio 27 | healthcheck: 28 | interval: 2s 29 | retries: 20 30 | test: curl --write-out 'HTTP %{http_code}' --fail --silent --output /dev/null http://localhost:5601/api/status 31 | 32 | sqlserver: 33 | image: mcr.microsoft.com/mssql/server:2022-latest 34 | ports: 35 | - "1433:1433" # login with sa:P@ssword1 36 | environment: 37 | - "ACCEPT_EULA=Y" 38 | - "MSSQL_SA_PASSWORD=P@ssword1" 39 | - "MSSQL_PID=Developer" 40 | user: root 41 | networks: 42 | - foundatio 43 | healthcheck: 44 | test: 45 | [ 46 | "CMD", 47 | "/opt/mssql-tools/bin/sqlcmd", 48 | "-Usa", 49 | "-PP@ssword1", 50 | "-Q", 51 | "select 1", 52 | ] 53 | interval: 1s 54 | retries: 20 55 | 56 | ready: 57 | image: andrewlock/wait-for-dependencies 58 | command: elasticsearch:9200 59 | depends_on: 60 | - elasticsearch 61 | - sqlserver 62 | networks: 63 | - foundatio 64 | 65 | networks: 66 | foundatio: 67 | driver: bridge 68 | name: foundatio 69 | -------------------------------------------------------------------------------- /docs/query.md: -------------------------------------------------------------------------------- 1 | # Query Syntax 2 | 3 | The query syntax is based on Lucene syntax. 4 | 5 | ## Basic 6 | - `field:value` exact match 7 | - `field:"Eric Smith"` exact match with quoted string value 8 | - `_exists_:field` matches if there is any value for the field 9 | - `_missing_:field` matches if there is not a value for the field 10 | 11 | ## Ranges 12 | Ranges can be specified for date or numeric fields. Inclusive ranges are specified with square brackets `[min TO max]` and exclusive ranges with curly brackets `{min TO max}`. 13 | 14 | - `datefield:[2012-01-01 TO 2012-12-31]` matches all days in 2012 for `datefield` 15 | - `numberfield:[1 TO 5]` matches any number between 1 and 5 on `numberfield` 16 | - `numberfield:1..5` shorthand for above query 17 | - `numberfield:1..5` shorthand for above query 18 | - `datefield:{* TO 2012-01-01}` matches dates before 2012 19 | - `numberfield:[10 TO *]` matches values 10 and above 20 | - `numberfield:[1 TO 5}` matches numbers from 1 up to but not including 5 21 | 22 | Ranges with one side unbounded can use the following syntax: 23 | 24 | - `age:>10` matches age greater than 10 25 | - `age:>=10` matches age greater or equal to 10 26 | - `age:<10` matches age less than 10 27 | - `age:<=10` matches age less than or equal to 10 28 | 29 | ## Boolean operators 30 | 31 | `AND` and `OR` are used to combine filter criteria. `NOT` can be used to negate filter criteria 32 | 33 | `((field:quick AND otherfield:fox) OR (field:brown AND otherfield:fox) OR otherfield:fox) AND NOT thirdfield:news` 34 | 35 | ## Grouping 36 | 37 | Multiple terms or clauses can be grouped together with parentheses, to form sub-queries: 38 | 39 | `(field:quick OR field:brown) AND otherfield:fox` 40 | 41 | ## Date Math 42 | 43 | Parameters which accept a formatted date value understand date math. 44 | 45 | The expression starts with an anchor date, which can either be `now`, or a date string ending with ||. This anchor date can optionally be followed by one or more maths expressions: 46 | 47 | `+1h` Add one hour 48 | `-1d` Subtract one day 49 | 50 | Supported units are: 51 | 52 | - `y` Years 53 | - `M` Months 54 | - `w` Weeks 55 | - `d` Days 56 | - `h` Hours 57 | - `H` Hours 58 | - `m` Minutes 59 | - `s` Seconds 60 | 61 | Assuming now is `2001-01-01 12:00:00`, some examples are: 62 | 63 | - `now+1h` now in milliseconds plus one hour. Resolves to: 2001-01-01 13:00:00 64 | - `now-1h` now in milliseconds minus one hour. Resolves to: 2001-01-01 11:00:00 65 | 66 | ## Geo Proximity Queries 67 | 68 | Geo proximity queries allow filtering data by documents that have a geo field value located within a given proximity of a geo coordinate. Locations can be resolved using a provided geo coding function that can translate something like "Dallas, TX" into a geo coordinate. 69 | 70 | Examples: 71 | - Within 75 miles of abc geohash: geofield:abc~75mi 72 | - Within 75 miles of 75044: geofield:75044~75mi 73 | 74 | ## Geo Range Queries 75 | 76 | Geo range queries allow filtering data by documents that have a geo field value located within a bounding box. It uses the same syntax as other Lucene range queries, but the range is the top left and bottom right geo coordinates. 77 | 78 | Examples: 79 | - Within coordinates rectangle: geofield:[geohash1..geohash2] 80 | 81 | ## Nested Document Queries 82 | 83 | Elasticsearch does not support querying nested documents using the query_string query, but when using this library queries on those fields should work automatically. 84 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "8.0.100", 4 | "rollForward": "latestMinor", 5 | "allowPrerelease": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Extensions/DefaultQueryNodeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Foundatio.Parsers.ElasticQueries.Visitors; 4 | using Foundatio.Parsers.LuceneQueries.Extensions; 5 | using Foundatio.Parsers.LuceneQueries.Nodes; 6 | using Foundatio.Parsers.LuceneQueries.Visitors; 7 | using Nest; 8 | 9 | namespace Foundatio.Parsers.ElasticQueries.Extensions; 10 | 11 | public static class DefaultQueryNodeExtensions 12 | { 13 | public static async Task GetDefaultQueryAsync(this IQueryNode node, IQueryVisitorContext context) 14 | { 15 | if (node is TermNode termNode) 16 | return termNode.GetDefaultQuery(context); 17 | 18 | if (node is TermRangeNode termRangeNode) 19 | return await termRangeNode.GetDefaultQueryAsync(context); 20 | 21 | if (node is ExistsNode existsNode) 22 | return existsNode.GetDefaultQuery(context); 23 | 24 | if (node is MissingNode missingNode) 25 | return missingNode.GetDefaultQuery(context); 26 | 27 | return null; 28 | } 29 | 30 | public static QueryBase GetDefaultQuery(this TermNode node, IQueryVisitorContext context) 31 | { 32 | if (context is not IElasticQueryVisitorContext elasticContext) 33 | throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); 34 | 35 | QueryBase query; 36 | string field = node.UnescapedField; 37 | string[] defaultFields = node.GetDefaultFields(elasticContext.DefaultFields); 38 | if (field == null && defaultFields != null && defaultFields.Length == 1) 39 | field = defaultFields[0]; 40 | 41 | if (elasticContext.MappingResolver.IsPropertyAnalyzed(field)) 42 | { 43 | string[] fields = !String.IsNullOrEmpty(field) ? [field] : defaultFields; 44 | 45 | if (!node.IsQuotedTerm && node.UnescapedTerm.EndsWith("*")) 46 | { 47 | query = new QueryStringQuery 48 | { 49 | Fields = fields, 50 | AllowLeadingWildcard = false, 51 | AnalyzeWildcard = true, 52 | Query = node.UnescapedTerm 53 | }; 54 | } 55 | else 56 | { 57 | if (fields != null && fields.Length == 1) 58 | { 59 | if (node.IsQuotedTerm) 60 | { 61 | query = new MatchPhraseQuery 62 | { 63 | Field = fields[0], 64 | Query = node.UnescapedTerm 65 | }; 66 | } 67 | else 68 | { 69 | query = new MatchQuery 70 | { 71 | Field = fields[0], 72 | Query = node.UnescapedTerm 73 | }; 74 | } 75 | } 76 | else 77 | { 78 | query = new MultiMatchQuery 79 | { 80 | Fields = fields, 81 | Query = node.UnescapedTerm 82 | }; 83 | if (node.IsQuotedTerm) 84 | ((MultiMatchQuery)query).Type = TextQueryType.Phrase; 85 | } 86 | } 87 | } 88 | else 89 | { 90 | if (!node.IsQuotedTerm && node.UnescapedTerm.EndsWith("*")) 91 | { 92 | query = new PrefixQuery 93 | { 94 | Field = field, 95 | Value = node.UnescapedTerm.TrimEnd('*') 96 | }; 97 | } 98 | else 99 | { 100 | query = new TermQuery 101 | { 102 | Field = field, 103 | Value = node.UnescapedTerm 104 | }; 105 | } 106 | } 107 | 108 | return query; 109 | } 110 | 111 | public static async Task GetDefaultQueryAsync(this TermRangeNode node, IQueryVisitorContext context) 112 | { 113 | if (context is not IElasticQueryVisitorContext elasticContext) 114 | throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); 115 | 116 | string field = node.UnescapedField; 117 | if (elasticContext.MappingResolver.IsDatePropertyType(field)) 118 | { 119 | var range = new DateRangeQuery { Field = field, TimeZone = node.Boost ?? node.GetTimeZone(await elasticContext.GetTimeZoneAsync()) }; 120 | if (!String.IsNullOrWhiteSpace(node.UnescapedMin) && node.UnescapedMin != "*") 121 | { 122 | if (node.MinInclusive.HasValue && !node.MinInclusive.Value) 123 | range.GreaterThan = node.UnescapedMin; 124 | else 125 | range.GreaterThanOrEqualTo = node.UnescapedMin; 126 | } 127 | 128 | if (!String.IsNullOrWhiteSpace(node.UnescapedMax) && node.UnescapedMax != "*") 129 | { 130 | if (node.MaxInclusive.HasValue && !node.MaxInclusive.Value) 131 | range.LessThan = node.UnescapedMax; 132 | else 133 | range.LessThanOrEqualTo = node.UnescapedMax; 134 | } 135 | 136 | return range; 137 | } 138 | else 139 | { 140 | var range = new TermRangeQuery { Field = field }; 141 | if (!String.IsNullOrWhiteSpace(node.UnescapedMin) && node.UnescapedMin != "*") 142 | { 143 | if (node.MinInclusive.HasValue && !node.MinInclusive.Value) 144 | range.GreaterThan = node.UnescapedMin; 145 | else 146 | range.GreaterThanOrEqualTo = node.UnescapedMin; 147 | } 148 | 149 | if (!String.IsNullOrWhiteSpace(node.UnescapedMax) && node.UnescapedMax != "*") 150 | { 151 | if (node.MaxInclusive.HasValue && !node.MaxInclusive.Value) 152 | range.LessThan = node.UnescapedMax; 153 | else 154 | range.LessThanOrEqualTo = node.UnescapedMax; 155 | } 156 | 157 | return range; 158 | } 159 | } 160 | 161 | public static QueryBase GetDefaultQuery(this ExistsNode node, IQueryVisitorContext context) 162 | { 163 | return new ExistsQuery { Field = node.UnescapedField }; 164 | } 165 | 166 | public static QueryBase GetDefaultQuery(this MissingNode node, IQueryVisitorContext context) 167 | { 168 | return new BoolQuery 169 | { 170 | MustNot = 171 | [ 172 | new ExistsQuery 173 | { 174 | Field = node.UnescapedField 175 | } 176 | ] 177 | }; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Extensions/DefaultSortNodeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Foundatio.Parsers.ElasticQueries.Visitors; 3 | using Foundatio.Parsers.LuceneQueries.Extensions; 4 | using Foundatio.Parsers.LuceneQueries.Nodes; 5 | using Foundatio.Parsers.LuceneQueries.Visitors; 6 | using Nest; 7 | 8 | namespace Foundatio.Parsers.ElasticQueries.Extensions; 9 | 10 | public static class DefaultSortNodeExtensions 11 | { 12 | public static IFieldSort GetDefaultSort(this TermNode node, IQueryVisitorContext context) 13 | { 14 | if (context is not IElasticQueryVisitorContext elasticContext) 15 | throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); 16 | 17 | string field = elasticContext.MappingResolver.GetSortFieldName(node.UnescapedField); 18 | var fieldType = elasticContext.MappingResolver.GetFieldType(field); 19 | 20 | var sort = new FieldSort 21 | { 22 | Field = field, 23 | UnmappedType = fieldType == FieldType.None ? FieldType.Keyword : fieldType, 24 | Order = node.IsNodeOrGroupNegated() ? SortOrder.Descending : SortOrder.Ascending 25 | }; 26 | 27 | return sort; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Extensions/ElasticMappingExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Nest; 2 | 3 | public static class ElasticMapping 4 | { 5 | /// 6 | /// Not chainable with AddSortField. Use AddKeywordAndSortFields to add both. 7 | /// 8 | public static TextPropertyDescriptor AddKeywordField(this TextPropertyDescriptor descriptor) where T : class 9 | { 10 | return descriptor.AddKeywordField(null); 11 | } 12 | 13 | /// 14 | /// Not chainable with AddSortField. Use AddKeywordAndSortFields to add both. 15 | /// 16 | public static TextPropertyDescriptor AddKeywordField(this TextPropertyDescriptor descriptor, string normalizer) where T : class 17 | { 18 | return descriptor.Fields(f => f.Keyword(s => s.Name(KeywordFieldName).Normalizer(normalizer).IgnoreAbove(256))); 19 | } 20 | 21 | /// 22 | /// Not chainable with AddSortField. Use AddKeywordAndSortFields to add both. 23 | /// 24 | public static TextPropertyDescriptor AddKeywordField(this TextPropertyDescriptor descriptor, bool lowercase) where T : class 25 | { 26 | return descriptor.AddKeywordField(lowercase ? "lowercase" : null); 27 | } 28 | 29 | /// 30 | /// Not chainable with AddKeywordField. Use AddKeywordAndSortFields to add both. 31 | /// 32 | public static TextPropertyDescriptor AddSortField(this TextPropertyDescriptor descriptor, string normalizer = "sort") where T : class 33 | { 34 | return descriptor.Fields(f => f.Keyword(s => s.Name(SortFieldName).Normalizer(normalizer).IgnoreAbove(256))); 35 | } 36 | 37 | public static TextPropertyDescriptor AddKeywordAndSortFields(this TextPropertyDescriptor descriptor, string sortNormalizer = "sort", string keywordNormalizer = null) where T : class 38 | { 39 | return descriptor.Fields(f => f 40 | .Keyword(s => s.Name(KeywordFieldName).Normalizer(keywordNormalizer).IgnoreAbove(256)) 41 | .Keyword(s => s.Name(SortFieldName).Normalizer(sortNormalizer).IgnoreAbove(256))); 42 | } 43 | 44 | public static TextPropertyDescriptor AddKeywordAndSortFields(this TextPropertyDescriptor descriptor, bool keywordLowercase) where T : class 45 | { 46 | return descriptor.AddKeywordAndSortFields(keywordNormalizer: keywordLowercase ? "lowercase" : null); 47 | } 48 | 49 | public static AnalysisDescriptor AddSortNormalizer(this AnalysisDescriptor descriptor) 50 | { 51 | return descriptor.Normalizers(d => d.Custom("sort", n => n.Filters("lowercase", "asciifolding"))); 52 | } 53 | 54 | public static string KeywordFieldName = "keyword"; 55 | public static string SortFieldName = "sort"; 56 | } 57 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Extensions/QueryNodeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Foundatio.Parsers.LuceneQueries.Nodes; 4 | using Nest; 5 | 6 | namespace Foundatio.Parsers.ElasticQueries.Extensions; 7 | 8 | public static class QueryNodeExtensions 9 | { 10 | private const string QueryKey = "@Query"; 11 | public static Task GetQueryAsync(this IQueryNode node, Func> getDefaultValue = null) 12 | { 13 | if (!node.Data.TryGetValue(QueryKey, out object value)) 14 | { 15 | if (getDefaultValue == null) 16 | return Task.FromResult(null); 17 | 18 | return getDefaultValue?.Invoke(); 19 | } 20 | 21 | return Task.FromResult(value as QueryBase); 22 | } 23 | 24 | public static void SetQuery(this IQueryNode node, QueryBase container) 25 | { 26 | node.Data[QueryKey] = container; 27 | } 28 | 29 | public static void RemoveQuery(this IQueryNode node) 30 | { 31 | if (node.Data.ContainsKey(QueryKey)) 32 | node.Data.Remove(QueryKey); 33 | } 34 | 35 | private const string SourceFilterKey = "@SourceFilter"; 36 | public static SourceFilter GetSourceFilter(this IQueryNode node, Func getDefaultValue = null) 37 | { 38 | if (!node.Data.TryGetValue(SourceFilterKey, out object value)) 39 | return getDefaultValue?.Invoke(); 40 | 41 | return value as SourceFilter; 42 | } 43 | 44 | private const string AggregationKey = "@Aggregation"; 45 | public static Task GetAggregationAsync(this IQueryNode node, Func> getDefaultValue = null) 46 | { 47 | if (!node.Data.TryGetValue(AggregationKey, out object value)) 48 | { 49 | if (getDefaultValue == null) 50 | return Task.FromResult(null); 51 | 52 | return getDefaultValue?.Invoke(); 53 | } 54 | 55 | return Task.FromResult(value as AggregationBase); 56 | } 57 | 58 | public static void SetAggregation(this IQueryNode node, AggregationBase aggregation) 59 | { 60 | node.Data[AggregationKey] = aggregation; 61 | } 62 | 63 | public static void RemoveAggregation(this IQueryNode node) 64 | { 65 | node.Data.Remove(AggregationKey); 66 | } 67 | 68 | private const string SortKey = "@Sort"; 69 | public static IFieldSort GetSort(this IQueryNode node, Func getDefaultValue = null) 70 | { 71 | if (!node.Data.TryGetValue(SortKey, out object value)) 72 | return getDefaultValue?.Invoke(); 73 | 74 | return value as IFieldSort; 75 | } 76 | 77 | public static void SetSort(this IQueryNode node, IFieldSort sort) 78 | { 79 | node.Data[SortKey] = sort; 80 | } 81 | 82 | public static void RemoveSort(this IQueryNode node) 83 | { 84 | node.Data.Remove(SortKey); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Extensions/QueryVisitorContextExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Foundatio.Parsers.ElasticQueries.Visitors; 4 | using Foundatio.Parsers.LuceneQueries.Extensions; 5 | using Foundatio.Parsers.LuceneQueries.Nodes; 6 | using Foundatio.Parsers.LuceneQueries.Visitors; 7 | using Nest; 8 | 9 | namespace Foundatio.Parsers.ElasticQueries.Extensions; 10 | 11 | public static class QueryVisitorContextExtensions 12 | { 13 | public static bool? IsRuntimeFieldResolverEnabled(this T context) where T : IQueryVisitorContext 14 | { 15 | if (context is not IElasticQueryVisitorContext elasticContext) 16 | throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); 17 | 18 | return elasticContext.EnableRuntimeFieldResolver; 19 | } 20 | 21 | public static RuntimeFieldResolver GetRuntimeFieldResolver(this IQueryVisitorContext context) 22 | { 23 | if (context is not IElasticQueryVisitorContext elasticContext) 24 | throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); 25 | 26 | return elasticContext.RuntimeFieldResolver; 27 | } 28 | 29 | public static T EnableRuntimeFieldResolver(this T context, bool enabled = true) where T : IQueryVisitorContext 30 | { 31 | if (context is not IElasticQueryVisitorContext elasticContext) 32 | throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); 33 | 34 | elasticContext.EnableRuntimeFieldResolver = enabled; 35 | 36 | return context; 37 | } 38 | 39 | public static T SetRuntimeFieldResolver(this T context, RuntimeFieldResolver resolver) where T : IQueryVisitorContext 40 | { 41 | if (context is not IElasticQueryVisitorContext elasticContext) 42 | throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); 43 | 44 | elasticContext.RuntimeFieldResolver = resolver; 45 | 46 | return context; 47 | } 48 | 49 | public static Task GetTimeZoneAsync(this IQueryVisitorContext context) 50 | { 51 | var elasticContext = context as IElasticQueryVisitorContext; 52 | if (elasticContext?.DefaultTimeZone != null) 53 | return elasticContext.DefaultTimeZone.Invoke(); 54 | 55 | return Task.FromResult(null); 56 | } 57 | 58 | public static T SetMappingResolver(this T context, ElasticMappingResolver mappingResolver) where T : IQueryVisitorContext 59 | { 60 | if (context is not IElasticQueryVisitorContext elasticContext) 61 | throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); 62 | 63 | elasticContext.MappingResolver = mappingResolver ?? ElasticMappingResolver.NullInstance; 64 | 65 | return context; 66 | } 67 | 68 | public static ElasticMappingResolver GetMappingResolver(this T context) where T : IQueryVisitorContext 69 | { 70 | if (context is not IElasticQueryVisitorContext elasticContext) 71 | throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); 72 | 73 | return elasticContext.MappingResolver ?? ElasticMappingResolver.NullInstance; 74 | } 75 | 76 | public static T UseSearchMode(this T context) where T : IQueryVisitorContext 77 | { 78 | context.SetDefaultOperator(GroupOperator.Or); 79 | context.UseScoring(); 80 | 81 | return context; 82 | } 83 | 84 | public static T SetDefaultOperator(this T context, Operator defaultOperator) where T : IQueryVisitorContext 85 | { 86 | if (defaultOperator == Operator.And) 87 | context.DefaultOperator = GroupOperator.And; 88 | else if (defaultOperator == Operator.Or) 89 | context.DefaultOperator = GroupOperator.Or; 90 | 91 | return context; 92 | } 93 | 94 | public static T UseScoring(this T context) where T : IQueryVisitorContext 95 | { 96 | if (context is not IElasticQueryVisitorContext elasticContext) 97 | throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); 98 | 99 | elasticContext.UseScoring = true; 100 | 101 | return context; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Extensions/SearchDescriptorExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Nest; 3 | 4 | namespace Foundatio.Parsers.ElasticQueries.Extensions; 5 | 6 | public static class SearchDescriptorExtensions 7 | { 8 | public static SearchDescriptor Aggregations(this SearchDescriptor descriptor, AggregationContainer aggregations) where T : class 9 | { 10 | descriptor.Aggregations(f => 11 | { 12 | ((IAggregationContainer)f).Aggregations = aggregations.Aggregations; 13 | return f; 14 | }); 15 | 16 | return descriptor; 17 | } 18 | 19 | public static SearchDescriptor Sort(this SearchDescriptor descriptor, IEnumerable sorts) where T : class 20 | { 21 | var searchRequest = descriptor as ISearchRequest; 22 | 23 | foreach (var sort in sorts) 24 | { 25 | if (searchRequest.Sort == null) 26 | searchRequest.Sort = new List(); 27 | 28 | searchRequest.Sort.Add(sort); 29 | } 30 | 31 | return descriptor; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Foundatio.Parsers.ElasticQueries.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Visitors/CombineAggregationsVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Foundatio.Parsers.ElasticQueries.Extensions; 6 | using Foundatio.Parsers.LuceneQueries.Extensions; 7 | using Foundatio.Parsers.LuceneQueries.Nodes; 8 | using Foundatio.Parsers.LuceneQueries.Visitors; 9 | using Nest; 10 | 11 | namespace Foundatio.Parsers.ElasticQueries.Visitors; 12 | 13 | public class CombineAggregationsVisitor : ChainableQueryVisitor 14 | { 15 | public override async Task VisitAsync(GroupNode node, IQueryVisitorContext context) 16 | { 17 | await base.VisitAsync(node, context).ConfigureAwait(false); 18 | 19 | if (context is not IElasticQueryVisitorContext elasticContext) 20 | throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); 21 | 22 | var container = await GetParentContainerAsync(node, context); 23 | var termsAggregation = container as ITermsAggregation; 24 | 25 | foreach (var child in node.Children.OfType()) 26 | { 27 | var aggregation = await child.GetAggregationAsync(() => child.GetDefaultAggregationAsync(context)); 28 | if (aggregation == null) 29 | { 30 | var termNode = child as TermNode; 31 | if (termNode != null && termsAggregation != null) 32 | { 33 | // TODO: Move these to the default aggs method using a visitor to walk down the tree to gather them but not going into any sub groups 34 | if (termNode.Field == "@exclude") 35 | { 36 | termsAggregation.Exclude = termsAggregation.Exclude.AddValue(termNode.UnescapedTerm); 37 | } 38 | else if (termNode.Field == "@include") 39 | { 40 | termsAggregation.Include = termsAggregation.Include.AddValue(termNode.UnescapedTerm); 41 | } 42 | else if (termNode.Field == "@missing") 43 | { 44 | termsAggregation.Missing = termNode.UnescapedTerm; 45 | } 46 | else if (termNode.Field == "@min") 47 | { 48 | int? minCount = null; 49 | if (!String.IsNullOrEmpty(termNode.Term) && Int32.TryParse(termNode.UnescapedTerm, out int parsedMinCount)) 50 | minCount = parsedMinCount; 51 | 52 | termsAggregation.MinimumDocumentCount = minCount; 53 | } 54 | } 55 | 56 | if (termNode != null && container is ITopHitsAggregation topHitsAggregation) 57 | { 58 | var filter = node.GetSourceFilter(() => new SourceFilter()); 59 | if (termNode.Field == "@exclude") 60 | { 61 | if (filter.Excludes == null) 62 | filter.Excludes = termNode.UnescapedTerm; 63 | else 64 | filter.Excludes.And(termNode.UnescapedTerm); 65 | } 66 | else if (termNode.Field == "@include") 67 | { 68 | if (filter.Includes == null) 69 | filter.Includes = termNode.UnescapedTerm; 70 | else 71 | filter.Includes.And(termNode.UnescapedTerm); 72 | } 73 | topHitsAggregation.Source = filter; 74 | } 75 | 76 | if (termNode != null && container is IDateHistogramAggregation dateHistogramAggregation) 77 | { 78 | if (termNode.Field == "@missing") 79 | { 80 | DateTime? missingValue = null; 81 | if (!String.IsNullOrEmpty(termNode.Term) && DateTime.TryParse(termNode.Term, out var parsedMissingDate)) 82 | missingValue = parsedMissingDate; 83 | 84 | dateHistogramAggregation.Missing = missingValue; 85 | } 86 | else if (termNode.Field == "@offset") 87 | { 88 | dateHistogramAggregation.Offset = termNode.IsExcluded() ? "-" + termNode.Term : termNode.Term; 89 | } 90 | } 91 | 92 | continue; 93 | } 94 | 95 | if (container is BucketAggregationBase bucketContainer) 96 | { 97 | if (bucketContainer.Aggregations == null) 98 | bucketContainer.Aggregations = new AggregationDictionary(); 99 | 100 | bucketContainer.Aggregations[((IAggregation)aggregation).Name] = (AggregationContainer)aggregation; 101 | } 102 | 103 | if (termsAggregation != null && (child.Prefix == "-" || child.Prefix == "+")) 104 | { 105 | if (termsAggregation.Order == null) 106 | termsAggregation.Order = new List(); 107 | 108 | termsAggregation.Order.Add(new TermsOrder 109 | { 110 | Key = ((IAggregation)aggregation).Name, 111 | Order = child.Prefix == "-" ? SortOrder.Descending : SortOrder.Ascending 112 | }); 113 | } 114 | } 115 | 116 | if (node.Parent == null) 117 | node.SetAggregation(container); 118 | } 119 | 120 | private async Task GetParentContainerAsync(IQueryNode node, IQueryVisitorContext context) 121 | { 122 | AggregationBase container = null; 123 | var currentNode = node; 124 | while (container == null && currentNode != null) 125 | { 126 | IQueryNode n = currentNode; 127 | container = await n.GetAggregationAsync(async () => 128 | { 129 | var result = await n.GetDefaultAggregationAsync(context); 130 | if (result != null) 131 | n.SetAggregation(result); 132 | 133 | return result; 134 | }); 135 | 136 | if (currentNode.Parent != null) 137 | currentNode = currentNode.Parent; 138 | else 139 | break; 140 | } 141 | 142 | if (container == null) 143 | { 144 | container = new ChildrenAggregation(null, null); 145 | currentNode.SetAggregation(container); 146 | } 147 | 148 | return container; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Visitors/CombineQueriesVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using Foundatio.Parsers.ElasticQueries.Extensions; 5 | using Foundatio.Parsers.LuceneQueries.Extensions; 6 | using Foundatio.Parsers.LuceneQueries.Nodes; 7 | using Foundatio.Parsers.LuceneQueries.Visitors; 8 | using Nest; 9 | 10 | namespace Foundatio.Parsers.ElasticQueries.Visitors; 11 | 12 | public class CombineQueriesVisitor : ChainableQueryVisitor 13 | { 14 | 15 | public override async Task VisitAsync(GroupNode node, IQueryVisitorContext context) 16 | { 17 | await base.VisitAsync(node, context).ConfigureAwait(false); 18 | 19 | // Only stop on scoped group nodes (parens). Gather all child queries (including scoped groups) and then combine them. 20 | // Combining only happens at the scoped group level though. 21 | // Merge all non-field terms together into a single match or multi-match query 22 | // Merge all nested queries for the same nested field together 23 | 24 | if (context is not IElasticQueryVisitorContext elasticContext) 25 | throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); 26 | 27 | QueryBase query = await node.GetQueryAsync(() => node.GetDefaultQueryAsync(context)).ConfigureAwait(false); 28 | QueryBase container = query; 29 | var nested = query as NestedQuery; 30 | if (nested != null && node.Parent != null) 31 | container = null; 32 | 33 | foreach (var child in node.Children.OfType()) 34 | { 35 | var childQuery = await child.GetQueryAsync(() => child.GetDefaultQueryAsync(context)).ConfigureAwait(false); 36 | if (childQuery == null) continue; 37 | 38 | var op = node.GetOperator(elasticContext); 39 | if (child.IsExcluded()) 40 | childQuery = !childQuery; 41 | 42 | if (op == GroupOperator.Or && node.IsRequired()) 43 | op = GroupOperator.And; 44 | 45 | if (op == GroupOperator.And) 46 | { 47 | container &= childQuery; 48 | } 49 | else if (op == GroupOperator.Or) 50 | { 51 | container |= childQuery; 52 | } 53 | } 54 | 55 | if (nested != null) 56 | { 57 | nested.Query = container; 58 | node.SetQuery(nested); 59 | } 60 | else 61 | { 62 | node.SetQuery(container); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Visitors/ElasticQueryVisitorContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Foundatio.Parsers.LuceneQueries.Visitors; 5 | 6 | namespace Foundatio.Parsers.ElasticQueries.Visitors; 7 | 8 | public class ElasticQueryVisitorContext : QueryVisitorContext, IElasticQueryVisitorContext 9 | { 10 | public Func> DefaultTimeZone { get; set; } 11 | public bool UseScoring { get; set; } 12 | public ElasticMappingResolver MappingResolver { get; set; } 13 | public ICollection RuntimeFields { get; } = new List(); 14 | public bool? EnableRuntimeFieldResolver { get; set; } 15 | public RuntimeFieldResolver RuntimeFieldResolver { get; set; } 16 | } 17 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Visitors/GeoVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Foundatio.Parsers.ElasticQueries.Extensions; 4 | using Foundatio.Parsers.LuceneQueries; 5 | using Foundatio.Parsers.LuceneQueries.Nodes; 6 | using Foundatio.Parsers.LuceneQueries.Visitors; 7 | using Nest; 8 | 9 | namespace Foundatio.Parsers.ElasticQueries.Visitors; 10 | 11 | public class GeoVisitor : ChainableQueryVisitor 12 | { 13 | private readonly Func> _resolveGeoLocation; 14 | 15 | public GeoVisitor(Func> resolveGeoLocation = null) 16 | { 17 | _resolveGeoLocation = resolveGeoLocation; 18 | } 19 | 20 | public override async Task VisitAsync(TermNode node, IQueryVisitorContext context) 21 | { 22 | if (context.QueryType != QueryTypes.Query || context is not IElasticQueryVisitorContext elasticContext || !elasticContext.MappingResolver.IsGeoPropertyType(node.Field)) 23 | return; 24 | 25 | string location = _resolveGeoLocation != null ? await _resolveGeoLocation(node.Term).ConfigureAwait(false) ?? node.Term : node.Term; 26 | var query = new GeoDistanceQuery { Field = node.Field, Location = location, Distance = node.Proximity ?? Distance.Miles(10) }; 27 | node.SetQuery(query); 28 | } 29 | 30 | public override void Visit(TermRangeNode node, IQueryVisitorContext context) 31 | { 32 | if (context is not IElasticQueryVisitorContext elasticContext || !elasticContext.MappingResolver.IsGeoPropertyType(node.Field)) 33 | return; 34 | 35 | var box = new BoundingBox { TopLeft = node.Min, BottomRight = node.Max }; 36 | var query = new GeoBoundingBoxQuery { BoundingBox = box, Field = node.Field }; 37 | node.SetQuery(query); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Visitors/GetSortFieldsVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Foundatio.Parsers.ElasticQueries.Extensions; 5 | using Foundatio.Parsers.LuceneQueries.Nodes; 6 | using Foundatio.Parsers.LuceneQueries.Visitors; 7 | using Nest; 8 | 9 | namespace Foundatio.Parsers.ElasticQueries.Visitors; 10 | 11 | public class GetSortFieldsVisitor : QueryNodeVisitorWithResultBase> 12 | { 13 | private readonly List _fields = new(); 14 | 15 | public override void Visit(TermNode node, IQueryVisitorContext context) 16 | { 17 | if (String.IsNullOrEmpty(node.Field)) 18 | return; 19 | 20 | var sort = node.GetSort(() => node.GetDefaultSort(context)); 21 | if (sort.SortKey == null) 22 | return; 23 | 24 | _fields.Add(sort); 25 | } 26 | 27 | public override async Task> AcceptAsync(IQueryNode node, IQueryVisitorContext context) 28 | { 29 | await node.AcceptAsync(this, context).ConfigureAwait(false); 30 | return _fields; 31 | } 32 | 33 | public static Task> RunAsync(IQueryNode node, IQueryVisitorContext context = null) 34 | { 35 | return new GetSortFieldsVisitor().AcceptAsync(node, context); 36 | } 37 | 38 | public static IEnumerable Run(IQueryNode node, IQueryVisitorContext context = null) 39 | { 40 | return RunAsync(node, context).GetAwaiter().GetResult(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Visitors/IElasticQueryVisitorContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Foundatio.Parsers.LuceneQueries.Visitors; 5 | 6 | namespace Foundatio.Parsers.ElasticQueries.Visitors 7 | { 8 | public interface IElasticQueryVisitorContext : IQueryVisitorContext 9 | { 10 | Func> DefaultTimeZone { get; set; } 11 | bool UseScoring { get; set; } 12 | ElasticMappingResolver MappingResolver { get; set; } 13 | ICollection RuntimeFields { get; } 14 | bool? EnableRuntimeFieldResolver { get; set; } 15 | RuntimeFieldResolver RuntimeFieldResolver { get; set; } 16 | } 17 | } 18 | 19 | namespace Foundatio.Parsers 20 | { 21 | public delegate Task RuntimeFieldResolver(string field); 22 | 23 | public class ElasticRuntimeField 24 | { 25 | public string Name { get; set; } 26 | public ElasticRuntimeFieldType FieldType { get; set; } = ElasticRuntimeFieldType.Keyword; 27 | public string Script { get; set; } 28 | } 29 | 30 | // This is the list of supported field types for runtime fields: 31 | // https://www.elastic.co/guide/en/elasticsearch/reference/master/runtime-mapping-fields.html 32 | public enum ElasticRuntimeFieldType 33 | { 34 | Boolean, 35 | Date, 36 | Double, 37 | GeoPoint, 38 | Ip, 39 | Keyword, 40 | Long 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.ElasticQueries/Visitors/NestedVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using Foundatio.Parsers.ElasticQueries.Extensions; 5 | using Foundatio.Parsers.LuceneQueries.Nodes; 6 | using Foundatio.Parsers.LuceneQueries.Visitors; 7 | using Nest; 8 | 9 | namespace Foundatio.Parsers.ElasticQueries.Visitors; 10 | 11 | public class NestedVisitor : ChainableQueryVisitor 12 | { 13 | public override Task VisitAsync(GroupNode node, IQueryVisitorContext context) 14 | { 15 | if (String.IsNullOrEmpty(node.Field)) 16 | return base.VisitAsync(node, context); 17 | 18 | string nestedProperty = GetNestedProperty(node.Field, context); 19 | if (nestedProperty == null) 20 | return base.VisitAsync(node, context); 21 | 22 | node.SetQuery(new NestedQuery { Path = nestedProperty }); 23 | 24 | return base.VisitAsync(node, context); 25 | } 26 | 27 | private string GetNestedProperty(string fullName, IQueryVisitorContext context) 28 | { 29 | string[] nameParts = fullName?.Split('.').ToArray(); 30 | 31 | if (nameParts == null || context is not IElasticQueryVisitorContext elasticContext || nameParts.Length == 0) 32 | return null; 33 | 34 | string fieldName = String.Empty; 35 | for (int i = 0; i < nameParts.Length; i++) 36 | { 37 | if (i > 0) 38 | fieldName += "."; 39 | 40 | fieldName += nameParts[i]; 41 | 42 | if (elasticContext.MappingResolver.IsNestedPropertyType(fieldName)) 43 | return fieldName; 44 | } 45 | 46 | return null; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace Foundatio.Parsers.LuceneQueries.Extensions; 4 | 5 | public static class StringExtensions 6 | { 7 | public static string Unescape(this string input) 8 | { 9 | if (input == null) 10 | return null; 11 | 12 | var sb = new StringBuilder(); 13 | bool escaped = false; 14 | foreach (char ch in input) 15 | { 16 | if (escaped) 17 | { 18 | sb.Append(ch); 19 | escaped = false; 20 | } 21 | else if (ch == '\\') 22 | { 23 | escaped = true; 24 | } 25 | else 26 | { 27 | sb.Append(ch); 28 | } 29 | } 30 | 31 | if (escaped) 32 | sb.Append('\\'); 33 | 34 | return sb.ToString(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Extensions/TextWriterExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | 3 | namespace Foundatio.Parsers.LuceneQueries.Extensions; 4 | 5 | public static class TextWriterExtensions 6 | { 7 | public static void WriteIf(this TextWriter writer, bool condition, string content, params object[] arguments) 8 | { 9 | if (!condition) 10 | return; 11 | 12 | if (arguments != null && arguments.Length > 0) 13 | writer.Write(content, arguments); 14 | else 15 | writer.Write(content); 16 | } 17 | 18 | public static void WriteLineIf(this TextWriter writer, bool condition, string content, params object[] arguments) 19 | { 20 | if (!condition) 21 | return; 22 | 23 | if (arguments != null && arguments.Length > 0) 24 | writer.WriteLine(content, arguments); 25 | else 26 | writer.WriteLine(content); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Foundatio.Parsers.LuceneQueries.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/LuceneQueryParser.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Foundatio.Parsers.LuceneQueries.Nodes; 3 | using Foundatio.Parsers.LuceneQueries.Visitors; 4 | 5 | namespace Foundatio.Parsers.LuceneQueries; 6 | 7 | public partial class LuceneQueryParser : IQueryParser 8 | { 9 | public virtual Task ParseAsync(string query, IQueryVisitorContext context = null) 10 | { 11 | var result = Parse(query); 12 | return Task.FromResult(result); 13 | } 14 | 15 | public IQueryNode Parse(string query, IQueryVisitorContext context) 16 | { 17 | return ParseAsync(query, context).GetAwaiter().GetResult(); 18 | } 19 | } 20 | 21 | public interface IQueryParser 22 | { 23 | Task ParseAsync(string query, IQueryVisitorContext context = null); 24 | } 25 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Nodes/ExistsNode.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text; 3 | using Foundatio.Parsers.LuceneQueries.Extensions; 4 | 5 | namespace Foundatio.Parsers.LuceneQueries.Nodes; 6 | 7 | public class ExistsNode : QueryNodeBase, IFieldQueryNode 8 | { 9 | public bool? IsNegated { get; set; } 10 | public string Prefix { get; set; } 11 | public string Field { get; set; } 12 | public string UnescapedField => Field?.Unescape(); 13 | 14 | public ExistsNode CopyTo(ExistsNode target) 15 | { 16 | if (IsNegated.HasValue) 17 | target.IsNegated = IsNegated; 18 | 19 | if (Prefix != null) 20 | target.Prefix = Prefix; 21 | 22 | if (Field != null) 23 | target.Field = Field; 24 | 25 | foreach (var kvp in Data) 26 | target.Data.Add(kvp.Key, kvp.Value); 27 | 28 | return target; 29 | } 30 | 31 | public override IQueryNode Clone() 32 | { 33 | var clone = new ExistsNode(); 34 | CopyTo(clone); 35 | return clone; 36 | } 37 | 38 | public override string ToString() 39 | { 40 | var builder = new StringBuilder(); 41 | 42 | if (IsNegated.HasValue && IsNegated.Value) 43 | builder.Append("NOT "); 44 | 45 | builder.Append(Prefix); 46 | builder.Append("_exists_"); 47 | builder.Append(":"); 48 | builder.Append(Field); 49 | 50 | return builder.ToString(); 51 | } 52 | 53 | public override IEnumerable Children => EmptyNodeList; 54 | } 55 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Nodes/GroupNode.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Foundatio.Parsers.LuceneQueries.Extensions; 5 | 6 | namespace Foundatio.Parsers.LuceneQueries.Nodes; 7 | 8 | public class GroupNode : QueryNodeBase, IFieldQueryWithProximityAndBoostNode 9 | { 10 | private IQueryNode _left; 11 | public IQueryNode Left 12 | { 13 | get => _left; 14 | set 15 | { 16 | _left = value; 17 | if (_left != null) 18 | _left.Parent = this; 19 | } 20 | } 21 | 22 | private IQueryNode _right; 23 | public IQueryNode Right 24 | { 25 | get => _right; 26 | set 27 | { 28 | _right = value; 29 | if (_right != null) 30 | _right.Parent = this; 31 | } 32 | } 33 | 34 | public GroupOperator Operator { get; set; } = GroupOperator.Default; 35 | public bool HasParens { get; set; } 36 | public string Field { get; set; } 37 | public string UnescapedField => Field?.Unescape(); 38 | public bool? IsNegated { get; set; } 39 | public string Prefix { get; set; } 40 | public string Boost { get; set; } 41 | public string UnescapedBoost => Boost?.Unescape(); 42 | public string Proximity { get; set; } 43 | public string UnescapedProximity => Proximity?.Unescape(); 44 | 45 | public GroupNode CopyTo(GroupNode target) 46 | { 47 | if (Left != null) 48 | target.Left = Left.Clone(); 49 | 50 | if (Right != null) 51 | target.Right = Right.Clone(); 52 | 53 | target.Operator = Operator; 54 | target.HasParens = HasParens; 55 | 56 | if (Field != null) 57 | target.Field = Field; 58 | 59 | if (IsNegated.HasValue) 60 | target.IsNegated = IsNegated; 61 | 62 | if (Boost != null) 63 | target.Boost = Boost; 64 | 65 | if (Proximity != null) 66 | target.Proximity = Proximity; 67 | 68 | if (Prefix != null) 69 | target.Prefix = Prefix; 70 | 71 | foreach (var kvp in Data) 72 | target.Data.Add(kvp.Key, kvp.Value); 73 | 74 | return target; 75 | } 76 | 77 | public override string ToString() 78 | { 79 | return ToString(GroupOperator.Default); 80 | } 81 | 82 | public string ToString(GroupOperator defaultOperator) 83 | { 84 | if (Left == null && Right == null) 85 | return String.Empty; 86 | 87 | var builder = new StringBuilder(); 88 | var op = Operator != GroupOperator.Default ? Operator : defaultOperator; 89 | 90 | if (IsNegated.HasValue && IsNegated.Value) 91 | builder.Append("NOT "); 92 | 93 | builder.Append(Prefix); 94 | 95 | if (!String.IsNullOrEmpty(Field)) 96 | builder.Append(Field).Append(':'); 97 | 98 | if (HasParens) 99 | builder.Append("("); 100 | 101 | if (Left != null) 102 | builder.Append(Left is GroupNode ? ((GroupNode)Left).ToString(defaultOperator) : Left.ToString()); 103 | 104 | if (Left != null && Right != null) 105 | { 106 | if (op == GroupOperator.And) 107 | builder.Append(" AND "); 108 | else if (op == GroupOperator.Or) 109 | builder.Append(" OR "); 110 | else if (Right != null) 111 | builder.Append(" "); 112 | } 113 | 114 | if (Right != null) 115 | builder.Append(Right is GroupNode ? ((GroupNode)Right).ToString(defaultOperator) : Right.ToString()); 116 | 117 | if (HasParens) 118 | builder.Append(")"); 119 | 120 | if (Proximity != null) 121 | builder.Append("~" + Proximity); 122 | 123 | if (Boost != null) 124 | builder.Append("^" + Boost); 125 | 126 | return builder.ToString(); 127 | } 128 | 129 | public override IQueryNode Clone() 130 | { 131 | var clone = new GroupNode(); 132 | CopyTo(clone); 133 | 134 | return clone; 135 | } 136 | 137 | public override IEnumerable Children 138 | { 139 | get 140 | { 141 | var children = new List(); 142 | if (Left != null) 143 | children.Add(Left); 144 | if (Right != null) 145 | children.Add(Right); 146 | 147 | return children; 148 | } 149 | } 150 | } 151 | 152 | public enum GroupOperator 153 | { 154 | Default, 155 | And, 156 | Or 157 | } 158 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Nodes/IQueryNode.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using Foundatio.Parsers.LuceneQueries.Visitors; 4 | 5 | namespace Foundatio.Parsers.LuceneQueries.Nodes; 6 | 7 | public interface IQueryNode 8 | { 9 | IQueryNode Parent { get; set; } 10 | IEnumerable Children { get; } 11 | IDictionary Data { get; } 12 | Task AcceptAsync(IQueryNodeVisitor visitor, IQueryVisitorContext context); 13 | string ToString(); 14 | IQueryNode Clone(); 15 | } 16 | 17 | public interface IFieldQueryNode : IQueryNode 18 | { 19 | bool? IsNegated { get; set; } 20 | string Prefix { get; set; } 21 | string Field { get; set; } 22 | string UnescapedField { get; } 23 | } 24 | 25 | public interface IFieldQueryWithProximityAndBoostNode : IFieldQueryNode 26 | { 27 | string Boost { get; set; } 28 | string UnescapedBoost { get; } 29 | string Proximity { get; set; } 30 | string UnescapedProximity { get; } 31 | } 32 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Nodes/MissingNode.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text; 3 | using Foundatio.Parsers.LuceneQueries.Extensions; 4 | 5 | namespace Foundatio.Parsers.LuceneQueries.Nodes; 6 | 7 | public class MissingNode : QueryNodeBase, IFieldQueryNode 8 | { 9 | public bool? IsNegated { get; set; } 10 | public string Prefix { get; set; } 11 | public string Field { get; set; } 12 | public string UnescapedField => Field?.Unescape(); 13 | 14 | public MissingNode CopyTo(MissingNode target) 15 | { 16 | if (IsNegated.HasValue) 17 | target.IsNegated = IsNegated; 18 | 19 | if (Prefix != null) 20 | target.Prefix = Prefix; 21 | 22 | if (Field != null) 23 | target.Field = Field; 24 | 25 | foreach (var kvp in Data) 26 | target.Data.Add(kvp.Key, kvp.Value); 27 | 28 | return target; 29 | } 30 | 31 | public override IQueryNode Clone() 32 | { 33 | var clone = new MissingNode(); 34 | CopyTo(clone); 35 | return clone; 36 | } 37 | 38 | public override string ToString() 39 | { 40 | var builder = new StringBuilder(); 41 | 42 | if (IsNegated.HasValue && IsNegated.Value) 43 | builder.Append("NOT "); 44 | 45 | builder.Append(Prefix); 46 | builder.Append("_missing_"); 47 | builder.Append(":"); 48 | builder.Append(Field); 49 | 50 | return builder.ToString(); 51 | } 52 | 53 | public override IEnumerable Children => EmptyNodeList; 54 | } 55 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Nodes/QueryNodeBase.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using Foundatio.Parsers.LuceneQueries.Visitors; 4 | 5 | namespace Foundatio.Parsers.LuceneQueries.Nodes; 6 | 7 | public abstract class QueryNodeBase : IQueryNode 8 | { 9 | public virtual Task AcceptAsync(IQueryNodeVisitor visitor, IQueryVisitorContext context) 10 | { 11 | if (this is GroupNode groupNode) 12 | return visitor.VisitAsync(groupNode, context); 13 | 14 | if (this is TermNode termNode) 15 | return visitor.VisitAsync(termNode, context); 16 | 17 | if (this is TermRangeNode rangeNode) 18 | return visitor.VisitAsync(rangeNode, context); 19 | 20 | if (this is MissingNode missingNode) 21 | return visitor.VisitAsync(missingNode, context); 22 | 23 | if (this is ExistsNode existsNode) 24 | return visitor.VisitAsync(existsNode, context); 25 | 26 | return Task.FromResult(this); 27 | } 28 | 29 | public IDictionary Data { get; } = new Dictionary(); 30 | public abstract IEnumerable Children { get; } 31 | public IQueryNode Parent { get; set; } 32 | public static readonly IList EmptyNodeList = new List().AsReadOnly(); 33 | 34 | public abstract IQueryNode Clone(); 35 | public abstract override string ToString(); 36 | } 37 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Nodes/TermNode.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Foundatio.Parsers.LuceneQueries.Extensions; 5 | 6 | namespace Foundatio.Parsers.LuceneQueries.Nodes; 7 | 8 | public class TermNode : QueryNodeBase, IFieldQueryWithProximityAndBoostNode 9 | { 10 | public bool? IsNegated { get; set; } 11 | public string Prefix { get; set; } 12 | public string Field { get; set; } 13 | public string UnescapedField => Field?.Unescape(); 14 | public string Term { get; set; } 15 | public string UnescapedTerm => Term?.Unescape(); 16 | public bool IsQuotedTerm { get; set; } 17 | public bool IsRegexTerm { get; set; } 18 | public string Boost { get; set; } 19 | public string UnescapedBoost => Boost?.Unescape(); 20 | public string Proximity { get; set; } 21 | public string UnescapedProximity => Proximity?.Unescape(); 22 | 23 | public TermNode CopyTo(TermNode target) 24 | { 25 | if (IsNegated.HasValue) 26 | target.IsNegated = IsNegated; 27 | 28 | if (Prefix != null) 29 | target.Prefix = Prefix; 30 | 31 | if (Field != null) 32 | target.Field = Field; 33 | 34 | if (Term != null) 35 | target.Term = Term; 36 | 37 | target.IsQuotedTerm = IsQuotedTerm; 38 | target.IsRegexTerm = IsRegexTerm; 39 | 40 | if (Boost != null) 41 | target.Boost = Boost; 42 | 43 | if (Proximity != null) 44 | target.Proximity = Proximity; 45 | 46 | foreach (var kvp in Data) 47 | target.Data.Add(kvp.Key, kvp.Value); 48 | 49 | return target; 50 | } 51 | 52 | public override string ToString() 53 | { 54 | var builder = new StringBuilder(); 55 | 56 | if (IsNegated.HasValue && IsNegated.Value) 57 | builder.Append("NOT "); 58 | 59 | builder.Append(Prefix); 60 | 61 | if (!String.IsNullOrEmpty(Field)) 62 | { 63 | builder.Append(Field); 64 | builder.Append(":"); 65 | } 66 | 67 | if (IsQuotedTerm) 68 | builder.Append("\"" + Term + "\""); 69 | else if (IsRegexTerm) 70 | builder.Append("/" + Term + "/"); 71 | else 72 | builder.Append(Term); 73 | 74 | if (Proximity != null) 75 | builder.Append("~" + Proximity); 76 | 77 | if (Boost != null) 78 | builder.Append("^" + Boost); 79 | 80 | return builder.ToString(); 81 | } 82 | 83 | public override IQueryNode Clone() 84 | { 85 | var clone = new TermNode(); 86 | CopyTo(clone); 87 | return clone; 88 | } 89 | 90 | public override IEnumerable Children => EmptyNodeList; 91 | } 92 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Nodes/TermRangeNode.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Foundatio.Parsers.LuceneQueries.Extensions; 5 | 6 | namespace Foundatio.Parsers.LuceneQueries.Nodes; 7 | 8 | public class TermRangeNode : QueryNodeBase, IFieldQueryNode 9 | { 10 | public bool? IsNegated { get; set; } 11 | public string Field { get; set; } 12 | public string UnescapedField => Field?.Unescape(); 13 | public string Prefix { get; set; } 14 | public string Min { get; set; } 15 | public string UnescapedMin => Min?.Unescape(); 16 | public bool IsMinQuotedTerm { get; set; } 17 | public string Max { get; set; } 18 | public string UnescapedMax => Max?.Unescape(); 19 | public bool IsMaxQuotedTerm { get; set; } 20 | public string Operator { get; set; } 21 | public string Delimiter { get; set; } 22 | public bool? MinInclusive { get; set; } 23 | public bool? MaxInclusive { get; set; } 24 | public string Boost { get; set; } 25 | public string UnescapedBoost => Boost?.Unescape(); 26 | public string Proximity { get; set; } 27 | 28 | public TermRangeNode CopyTo(TermRangeNode target) 29 | { 30 | if (Field != null) 31 | target.Field = Field; 32 | 33 | if (IsNegated.HasValue) 34 | target.IsNegated = IsNegated; 35 | 36 | if (Prefix != null) 37 | target.Prefix = Prefix; 38 | 39 | if (Min != null) 40 | target.Min = Min; 41 | 42 | target.IsMinQuotedTerm = IsMinQuotedTerm; 43 | 44 | if (Max != null) 45 | target.Max = Max; 46 | 47 | target.IsMaxQuotedTerm = IsMaxQuotedTerm; 48 | 49 | if (Operator != null) 50 | target.Operator = Operator; 51 | 52 | if (Delimiter != null) 53 | target.Delimiter = Delimiter; 54 | 55 | if (MinInclusive.HasValue) 56 | target.MinInclusive = MinInclusive; 57 | 58 | if (MaxInclusive.HasValue) 59 | target.MaxInclusive = MaxInclusive; 60 | 61 | if (Boost != null) 62 | target.Boost = Boost; 63 | 64 | if (Proximity != null) 65 | target.Proximity = Proximity; 66 | 67 | foreach (var kvp in Data) 68 | target.Data.Add(kvp.Key, kvp.Value); 69 | 70 | return target; 71 | } 72 | 73 | public override IQueryNode Clone() 74 | { 75 | var clone = new TermRangeNode(); 76 | CopyTo(clone); 77 | return clone; 78 | } 79 | 80 | public override string ToString() 81 | { 82 | var builder = new StringBuilder(); 83 | 84 | if (IsNegated.HasValue && IsNegated.Value) 85 | builder.Append("NOT "); 86 | 87 | builder.Append(Prefix); 88 | 89 | if (!String.IsNullOrEmpty(Field)) 90 | { 91 | builder.Append(Field); 92 | builder.Append(":"); 93 | } 94 | 95 | if (!String.IsNullOrEmpty(Operator)) 96 | builder.Append(Operator); 97 | 98 | if (MinInclusive.HasValue && String.IsNullOrEmpty(Operator)) 99 | builder.Append(MinInclusive.Value ? "[" : "{"); 100 | 101 | if (IsMinQuotedTerm) 102 | builder.Append("\"" + Min + "\""); 103 | else 104 | builder.Append(Min); 105 | 106 | if (!String.IsNullOrEmpty(Min) && !String.IsNullOrEmpty(Max) && String.IsNullOrEmpty(Operator)) 107 | builder.Append(Delimiter ?? " TO "); 108 | 109 | if (IsMaxQuotedTerm) 110 | builder.Append("\"" + Max + "\""); 111 | else 112 | builder.Append(Max); 113 | 114 | if (MaxInclusive.HasValue && String.IsNullOrEmpty(Operator)) 115 | builder.Append(MaxInclusive.Value ? "]" : "}"); 116 | 117 | if (Proximity != null) 118 | builder.Append("~" + Proximity); 119 | 120 | if (Boost != null) 121 | builder.Append("^" + Boost); 122 | 123 | return builder.ToString(); 124 | } 125 | 126 | public override IEnumerable Children => EmptyNodeList; 127 | } 128 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/QueryTypes.cs: -------------------------------------------------------------------------------- 1 | namespace Foundatio.Parsers.LuceneQueries; 2 | 3 | public static class QueryTypes 4 | { 5 | public const string Query = "query"; 6 | public const string Aggregation = "aggregation"; 7 | public const string Sort = "sort"; 8 | public const string Unknown = "unknown"; 9 | } 10 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/QueryValidation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Diagnostics; 5 | using System.Linq; 6 | 7 | namespace Foundatio.Parsers.LuceneQueries; 8 | 9 | public class QueryValidationOptions 10 | { 11 | public bool ShouldThrow { get; set; } 12 | public ICollection AllowedFields { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); 13 | public ICollection RestrictedFields { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); 14 | public bool AllowLeadingWildcards { get; set; } = true; 15 | public bool AllowUnresolvedFields { get; set; } = true; 16 | public bool AllowUnresolvedIncludes { get; set; } = false; 17 | public ICollection AllowedOperations { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); 18 | public ICollection RestrictedOperations { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); 19 | public int AllowedMaxNodeDepth { get; set; } 20 | } 21 | 22 | [DebuggerDisplay("IsValid: {IsValid} Message: {Message} Type: {QueryType}")] 23 | public class QueryValidationResult 24 | { 25 | private ConcurrentDictionary> _operations = new(StringComparer.OrdinalIgnoreCase); 26 | 27 | public string QueryType { get; set; } 28 | public bool IsValid => ValidationErrors.Count == 0; 29 | public ICollection ValidationErrors { get; } = new List(); 30 | public string Message 31 | { 32 | get 33 | { 34 | if (ValidationErrors.Count == 0) 35 | return String.Empty; 36 | 37 | if (ValidationErrors.Count > 1) 38 | return String.Join("\r\n", ValidationErrors.Select(e => e.ToString())); 39 | 40 | return ValidationErrors.Single().Message; 41 | } 42 | } 43 | public ICollection ReferencedFields { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); 44 | public ICollection ReferencedIncludes { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); 45 | public ICollection UnresolvedFields { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); 46 | public ICollection UnresolvedIncludes { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); 47 | public int MaxNodeDepth { get; set; } = 1; 48 | public IDictionary> Operations => _operations; 49 | 50 | public static implicit operator bool(QueryValidationResult info) => info.IsValid; 51 | 52 | private int _currentNodeDepth = 1; 53 | internal int CurrentNodeDepth 54 | { 55 | get => _currentNodeDepth; 56 | set 57 | { 58 | _currentNodeDepth = value; 59 | if (_currentNodeDepth > MaxNodeDepth) 60 | MaxNodeDepth = _currentNodeDepth; 61 | } 62 | } 63 | 64 | internal void AddOperation(string operation, string field) 65 | { 66 | _operations.AddOrUpdate(operation, 67 | _ => new HashSet(StringComparer.OrdinalIgnoreCase) { field }, 68 | (_, collection) => 69 | { 70 | collection.Add(field); 71 | return collection; 72 | } 73 | ); 74 | } 75 | } 76 | 77 | public class QueryValidationError 78 | { 79 | public QueryValidationError(string message, int index = -1) 80 | { 81 | Message = message; 82 | Index = index; 83 | } 84 | 85 | /// 86 | /// The validation error message. 87 | /// 88 | public string Message { get; } 89 | 90 | /// 91 | /// Index where the validation error occurs in the query string. 92 | /// 93 | public int Index { get; } = -1; 94 | 95 | public override string ToString() 96 | { 97 | if (Index > 0) 98 | return $"[{Index}] {Message}"; 99 | 100 | return Message; 101 | } 102 | } 103 | 104 | public class QueryValidationException : Exception 105 | { 106 | public QueryValidationException(string message, QueryValidationResult result = null, 107 | Exception inner = null) : base(message, inner) 108 | { 109 | Result = result; 110 | } 111 | 112 | public QueryValidationResult Result { get; } 113 | public ICollection Errors => Result.ValidationErrors; 114 | } 115 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/QueryValidator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Foundatio.Parsers.LuceneQueries.Extensions; 4 | using Foundatio.Parsers.LuceneQueries.Visitors; 5 | using Pegasus.Common; 6 | 7 | namespace Foundatio.Parsers.LuceneQueries; 8 | 9 | public class QueryValidator 10 | { 11 | public static Task ValidateQueryAsync(string query, QueryValidationOptions options = null, IQueryVisitorContextWithValidation context = null) 12 | { 13 | if (context == null) 14 | context = new QueryVisitorContext(); 15 | 16 | context.QueryType = QueryTypes.Query; 17 | 18 | return InternalValidateAsync(query, context, options); 19 | } 20 | 21 | public static Task ValidateAggregationsAsync(string aggregations, QueryValidationOptions options = null, IQueryVisitorContextWithValidation context = null) 22 | { 23 | if (context == null) 24 | context = new QueryVisitorContext(); 25 | 26 | context.QueryType = QueryTypes.Aggregation; 27 | 28 | return InternalValidateAsync(aggregations, context, options); 29 | } 30 | 31 | public static Task ValidateSortAsync(string sort, QueryValidationOptions options = null, IQueryVisitorContextWithValidation context = null) 32 | { 33 | if (context == null) 34 | context = new QueryVisitorContext(); 35 | 36 | context.QueryType = QueryTypes.Sort; 37 | 38 | return InternalValidateAsync(sort, context, options); 39 | } 40 | 41 | private static async Task InternalValidateAsync(string query, IQueryVisitorContextWithValidation context, QueryValidationOptions options = null) 42 | { 43 | var parser = new LuceneQueryParser(); 44 | try 45 | { 46 | var node = await parser.ParseAsync(query); 47 | if (context == null) 48 | context = new QueryVisitorContext(); 49 | 50 | if (options != null) 51 | context.SetValidationOptions(options); 52 | 53 | var fieldResolver = context.GetFieldResolver(); 54 | if (fieldResolver != null) 55 | node = await FieldResolverQueryVisitor.RunAsync(node, fieldResolver, context as IQueryVisitorContextWithFieldResolver); 56 | 57 | var includeResolver = context.GetIncludeResolver(); 58 | if (includeResolver != null) 59 | node = await IncludeVisitor.RunAsync(node, includeResolver, context as IQueryVisitorContextWithIncludeResolver); 60 | 61 | return await ValidationVisitor.RunAsync(node, context); 62 | } 63 | catch (FormatException ex) 64 | { 65 | var cursor = ex.Data["cursor"] as Cursor; 66 | context.AddValidationError(ex.Message, cursor.Column); 67 | 68 | return context.GetValidationResult(); 69 | } 70 | } 71 | 72 | public static Task ValidateQueryAndThrowAsync(string query, QueryValidationOptions options = null, IQueryVisitorContextWithValidation context = null) 73 | { 74 | if (context == null) 75 | context = new QueryVisitorContext(); 76 | 77 | context.QueryType = QueryTypes.Query; 78 | 79 | return InternalValidateAndThrowAsync(query, context, options); 80 | } 81 | 82 | public static Task ValidateAggregationsAndThrowAsync(string aggregations, QueryValidationOptions options = null, IQueryVisitorContextWithValidation context = null) 83 | { 84 | if (context == null) 85 | context = new QueryVisitorContext(); 86 | 87 | context.QueryType = QueryTypes.Aggregation; 88 | 89 | return InternalValidateAndThrowAsync(aggregations, context, options); 90 | } 91 | 92 | public static Task ValidateSortAndThrowAsync(string sort, QueryValidationOptions options = null, IQueryVisitorContextWithValidation context = null) 93 | { 94 | if (context == null) 95 | context = new QueryVisitorContext(); 96 | 97 | context.QueryType = QueryTypes.Sort; 98 | 99 | return InternalValidateAndThrowAsync(sort, context, options); 100 | } 101 | 102 | private static async Task InternalValidateAndThrowAsync(string query, IQueryVisitorContextWithValidation context, QueryValidationOptions options = null) 103 | { 104 | var parser = new LuceneQueryParser(); 105 | try 106 | { 107 | var node = await parser.ParseAsync(query); 108 | if (context == null) 109 | context = new QueryVisitorContext(); 110 | 111 | options ??= new QueryValidationOptions(); 112 | options.ShouldThrow = true; 113 | context.SetValidationOptions(options); 114 | 115 | var fieldResolver = context.GetFieldResolver(); 116 | if (fieldResolver != null) 117 | node = await FieldResolverQueryVisitor.RunAsync(node, fieldResolver, context as IQueryVisitorContextWithFieldResolver); 118 | 119 | var includeResolver = context.GetIncludeResolver(); 120 | if (includeResolver != null) 121 | node = await IncludeVisitor.RunAsync(node, includeResolver, context as IQueryVisitorContextWithIncludeResolver); 122 | 123 | return await ValidationVisitor.RunAsync(node, context); 124 | } 125 | catch (FormatException ex) 126 | { 127 | var cursor = ex.Data["cursor"] as Cursor; 128 | context.AddValidationError(ex.Message, cursor.Column); 129 | 130 | throw new QueryValidationException(ex.Message, context.GetValidationResult(), ex); 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/AssignOperationTypeVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Foundatio.Parsers.LuceneQueries.Extensions; 4 | using Foundatio.Parsers.LuceneQueries.Nodes; 5 | 6 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 7 | 8 | public class AssignOperationTypeVisitor : ChainableQueryVisitor 9 | { 10 | public override Task VisitAsync(GroupNode node, IQueryVisitorContext context) 11 | { 12 | if (node.HasOperationType()) 13 | return Task.CompletedTask; 14 | 15 | if (String.IsNullOrEmpty(node.Field)) 16 | return base.VisitAsync(node, context); 17 | 18 | if (node.Left is not TermNode leftTerm) 19 | { 20 | context.AddValidationError($"Aggregations ({node.Field}) must specify a field."); 21 | return Task.CompletedTask; 22 | } 23 | 24 | if (String.IsNullOrEmpty(leftTerm.Term)) 25 | context.AddValidationError($"Aggregations ({node.Field}) must specify a field."); 26 | 27 | if (node.Field.StartsWith("@")) 28 | return base.VisitAsync(node, context); 29 | 30 | node.SetOperationType(node.Field); 31 | node.Field = leftTerm.Term; 32 | node.Boost = leftTerm.Boost; 33 | node.Proximity = leftTerm.Proximity; 34 | node.Left = null; 35 | 36 | return base.VisitAsync(node, context); 37 | } 38 | 39 | public override void Visit(TermNode node, IQueryVisitorContext context) 40 | { 41 | if (node.HasOperationType()) 42 | return; 43 | 44 | if (String.IsNullOrEmpty(node.Field) && !String.IsNullOrEmpty(node.Term)) 45 | { 46 | context.AddValidationError($"Aggregations ({node.Term}) must specify a field."); 47 | node.SetOperationType(node.Term); 48 | return; 49 | } 50 | 51 | if (String.IsNullOrEmpty(node.Term)) 52 | context.AddValidationError($"Aggregations ({node.Field}) must specify a field."); 53 | 54 | if (node.Field != null && node.Field.StartsWith("@")) 55 | return; 56 | 57 | node.SetOperationType(node.Field); 58 | node.Field = node.Term; 59 | node.Term = null; 60 | } 61 | } 62 | 63 | public static class AggregationType 64 | { 65 | public const string Min = "min"; 66 | public const string Max = "max"; 67 | public const string Avg = "avg"; 68 | public const string Sum = "sum"; 69 | public const string Cardinality = "cardinality"; 70 | public const string Missing = "missing"; 71 | public const string DateHistogram = "date"; 72 | public const string Histogram = "histogram"; 73 | public const string Percentiles = "percentiles"; 74 | public const string GeoHashGrid = "geogrid"; 75 | public const string Terms = "terms"; 76 | public const string Stats = "stats"; 77 | public const string TopHits = "tophits"; 78 | public const string ExtendedStats = "exstats"; 79 | } 80 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/ChainedQueryVisitor.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using Foundatio.Parsers.LuceneQueries.Nodes; 5 | 6 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 7 | 8 | public class ChainedQueryVisitor : QueryNodeVisitorWithResultBase, IChainableQueryVisitor 9 | { 10 | private readonly List _visitors = new(); 11 | private QueryVisitorWithPriority[] _frozenVisitors; 12 | private bool _isDirty = true; 13 | 14 | public void AddVisitor(IQueryNodeVisitorWithResult visitor, int priority = 0) 15 | { 16 | AddVisitor(new QueryVisitorWithPriority 17 | { 18 | Priority = priority, 19 | Visitor = visitor 20 | }); 21 | } 22 | 23 | public void AddVisitor(QueryVisitorWithPriority visitor) 24 | { 25 | _visitors.Add(visitor); 26 | _isDirty = true; 27 | } 28 | 29 | public void RemoveVisitor() where T : IChainableQueryVisitor 30 | { 31 | var visitor = _visitors.FirstOrDefault(v => typeof(T) == v.Visitor.GetType()); 32 | if (visitor == null) 33 | return; 34 | 35 | _visitors.Remove(visitor); 36 | _isDirty = true; 37 | } 38 | 39 | public void ReplaceVisitor(IChainableQueryVisitor visitor, int? newPriority = null) where T : IChainableQueryVisitor 40 | { 41 | int priority = newPriority.GetValueOrDefault(0); 42 | 43 | var referenceVisitor = _visitors.FirstOrDefault(v => typeof(T) == v.Visitor.GetType()); 44 | if (referenceVisitor != null) 45 | { 46 | if (!newPriority.HasValue) 47 | priority = referenceVisitor.Priority - 1; 48 | 49 | _visitors.Remove(referenceVisitor); 50 | } 51 | 52 | _visitors.Add(new QueryVisitorWithPriority { Visitor = visitor, Priority = priority }); 53 | _isDirty = true; 54 | } 55 | 56 | public void AddVisitorBefore(IChainableQueryVisitor visitor) 57 | { 58 | int priority = 0; 59 | var referenceVisitor = _visitors.FirstOrDefault(v => typeof(T) == v.Visitor.GetType()); 60 | if (referenceVisitor != null) 61 | priority = referenceVisitor.Priority - 1; 62 | 63 | _visitors.Add(new QueryVisitorWithPriority { Visitor = visitor, Priority = priority }); 64 | _isDirty = true; 65 | } 66 | 67 | public void AddVisitorAfter(IChainableQueryVisitor visitor) 68 | { 69 | int priority = 0; 70 | var referenceVisitor = _visitors.FirstOrDefault(v => typeof(T) == v.Visitor.GetType()); 71 | if (referenceVisitor != null) 72 | priority = referenceVisitor.Priority + 1; 73 | 74 | _visitors.Add(new QueryVisitorWithPriority { Visitor = visitor, Priority = priority }); 75 | _isDirty = true; 76 | } 77 | 78 | public override async Task AcceptAsync(IQueryNode node, IQueryVisitorContext context) 79 | { 80 | if (_isDirty) 81 | _frozenVisitors = _visitors.OrderBy(v => v.Priority).ToArray(); 82 | 83 | foreach (var visitor in _frozenVisitors) 84 | node = await visitor.AcceptAsync(node, context).ConfigureAwait(false); 85 | 86 | return node; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/CleanupQueryVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Foundatio.Parsers.LuceneQueries.Extensions; 4 | using Foundatio.Parsers.LuceneQueries.Nodes; 5 | 6 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 7 | 8 | public class CleanupQueryVisitor : ChainableQueryVisitor 9 | { 10 | public override async Task VisitAsync(IQueryNode node, IQueryVisitorContext context) 11 | { 12 | var result = CleanNode(node); 13 | if (result == null) 14 | return null; 15 | 16 | result = await base.VisitAsync(result, context); 17 | 18 | return CleanNode(result); 19 | } 20 | 21 | private IQueryNode CleanNode(IQueryNode node) 22 | { 23 | if (node is GroupNode groupNode) 24 | { 25 | if (groupNode.Left != null && groupNode.Right != null) 26 | return groupNode; 27 | 28 | // remove non-root empty groups 29 | if (groupNode.Left == null && groupNode.Right == null && groupNode.Parent != null) 30 | { 31 | groupNode.RemoveSelf(); 32 | return groupNode.Parent; 33 | } 34 | 35 | // don't alter groups with fields 36 | if (!String.IsNullOrEmpty(groupNode.Field)) 37 | { 38 | return groupNode; 39 | } 40 | 41 | if (groupNode.Left is GroupNode leftGroupNode && groupNode.Right == null && leftGroupNode.Field == null) 42 | { 43 | leftGroupNode.Field = groupNode.Field; 44 | groupNode.ReplaceSelf(leftGroupNode); 45 | 46 | // no value is set, use parent value 47 | if (!leftGroupNode.IsNegated.HasValue) 48 | { 49 | leftGroupNode.IsNegated = groupNode.IsNegated; 50 | } 51 | else if (groupNode.IsNegated.HasValue) 52 | { 53 | // both values set 54 | 55 | // double negative 56 | if (groupNode.IsNegated.Value && leftGroupNode.IsNegated.Value) 57 | leftGroupNode.IsNegated = false; 58 | else if (groupNode.IsNegated.Value || leftGroupNode.IsNegated.Value) 59 | leftGroupNode.IsNegated = true; 60 | } 61 | 62 | groupNode = leftGroupNode; 63 | node = groupNode; 64 | } 65 | 66 | if (groupNode.Right is GroupNode rightGroupNode && groupNode.Left == null && rightGroupNode.Field == null) 67 | { 68 | rightGroupNode.Field = groupNode.Field; 69 | groupNode.ReplaceSelf(rightGroupNode); 70 | 71 | // no value is set, use parent value 72 | if (!rightGroupNode.IsNegated.HasValue) 73 | { 74 | rightGroupNode.IsNegated = groupNode.IsNegated; 75 | } 76 | else if (groupNode.IsNegated.HasValue) 77 | { 78 | // both values set 79 | 80 | // double negative 81 | if (groupNode.IsNegated.Value && rightGroupNode.IsNegated.Value) 82 | rightGroupNode.IsNegated = false; 83 | else if (groupNode.IsNegated.Value || rightGroupNode.IsNegated.Value) 84 | rightGroupNode.IsNegated = true; 85 | } 86 | 87 | groupNode = rightGroupNode; 88 | node = groupNode; 89 | } 90 | 91 | // don't need parens on single term 92 | if (groupNode.HasParens 93 | && groupNode.Left is TermNode leftTermNode 94 | && groupNode.Right == null) 95 | { 96 | 97 | if (groupNode.IsNegated.HasValue && groupNode.IsNegated.Value) 98 | { 99 | groupNode.HasParens = false; 100 | } 101 | else 102 | { 103 | groupNode.ReplaceSelf(leftTermNode); 104 | node = leftTermNode; 105 | } 106 | } 107 | 108 | // don't need parens on single term 109 | if (groupNode.HasParens 110 | && groupNode.Right is TermNode rightTermNode 111 | && groupNode.Left == null) 112 | { 113 | 114 | if (groupNode.IsNegated.HasValue && groupNode.IsNegated.Value) 115 | { 116 | groupNode.HasParens = false; 117 | } 118 | else 119 | { 120 | groupNode.ReplaceSelf(rightTermNode); 121 | node = rightTermNode; 122 | } 123 | } 124 | } 125 | 126 | return node; 127 | } 128 | 129 | public static async Task RunAsync(IQueryNode node, IQueryVisitorContext context = null) 130 | { 131 | var result = await new CleanupQueryVisitor().AcceptAsync(node, context); 132 | return result.ToString(); 133 | } 134 | 135 | public static string Run(IQueryNode node, IQueryVisitorContext context = null) 136 | { 137 | return RunAsync(node, context).GetAwaiter().GetResult(); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/DebugQueryVisitor.cs: -------------------------------------------------------------------------------- 1 | using System.CodeDom.Compiler; 2 | using System.IO; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using Foundatio.Parsers.LuceneQueries.Extensions; 6 | using Foundatio.Parsers.LuceneQueries.Nodes; 7 | 8 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 9 | 10 | public class DebugQueryVisitor : QueryNodeVisitorWithResultBase 11 | { 12 | private readonly StringBuilder _builder = new(); 13 | private readonly IndentedTextWriter _writer; 14 | 15 | public DebugQueryVisitor() 16 | { 17 | _writer = new IndentedTextWriter(new StringWriter(_builder)); 18 | } 19 | 20 | public override async Task VisitAsync(GroupNode node, IQueryVisitorContext context) 21 | { 22 | await _writer.WriteLineAsync("Group:").ConfigureAwait(false); 23 | _writer.Indent++; 24 | _writer.WriteLineIf(node.IsNegated.HasValue, "IsNegated: {0}", node.IsNegated); 25 | _writer.WriteLineIf(node.Field != null, "Field: {0}", node.Field); 26 | _writer.WriteLineIf(node.GetOriginalField() != null, "Original Field: {0}", node.GetOriginalField()); 27 | _writer.WriteLineIf(node.Prefix != null, "Prefix: {0}", node.Prefix); 28 | _writer.WriteLineIf(node.Boost != null, "Boost: {0}", node.Boost); 29 | _writer.WriteLineIf(node.Proximity != null, "Proximity: {0}", node.Proximity); 30 | 31 | _writer.WriteIf(node.Left != null, "Left - "); 32 | if (node.Left != null) 33 | await node.Left.AcceptAsync(this, context).ConfigureAwait(false); 34 | 35 | _writer.WriteIf(node.Right != null, "Right - "); 36 | if (node.Right != null) 37 | await node.Right.AcceptAsync(this, context).ConfigureAwait(false); 38 | 39 | _writer.WriteLineIf(node.Operator != GroupOperator.Default, "Operator: {0}", node.Operator); 40 | _writer.WriteLineIf(node.HasParens, "Parens: true"); 41 | 42 | WriteData(node); 43 | 44 | _writer.Indent--; 45 | } 46 | 47 | public override void Visit(TermNode node, IQueryVisitorContext context) 48 | { 49 | _writer.WriteLine("Term: "); 50 | _writer.Indent++; 51 | _writer.WriteLineIf(node.Field != null, "Field: {0}", node.Field); 52 | _writer.WriteLineIf(node.GetOriginalField() != null, "Original Field: {0}", node.GetOriginalField()); 53 | _writer.WriteLineIf(node.IsNegated.HasValue, "IsNegated: {0}", node.IsNegated); 54 | _writer.WriteLineIf(node.Prefix != null, "Prefix: {0}", node.Prefix); 55 | _writer.WriteLine("IsQuoted: {0}", node.IsQuotedTerm); 56 | _writer.WriteLine("IsRegex: {0}", node.IsRegexTerm); 57 | _writer.WriteLineIf(node.Term != null, "Term: {0}", node.Term); 58 | _writer.WriteLineIf(node.Boost != null, "Boost: {0}", node.Boost); 59 | _writer.WriteLineIf(node.Proximity != null, "Proximity: {0}", node.Proximity); 60 | 61 | WriteData(node); 62 | 63 | _writer.Indent--; 64 | } 65 | 66 | public override void Visit(TermRangeNode node, IQueryVisitorContext context) 67 | { 68 | _writer.WriteLine("Term Range: "); 69 | _writer.Indent++; 70 | _writer.WriteLineIf(node.Field != null, "Field: {0}", node.Field); 71 | _writer.WriteLineIf(node.GetOriginalField() != null, "Original Field: {0}", node.GetOriginalField()); 72 | _writer.WriteLineIf(node.IsNegated.HasValue, "IsNegated: {0}", node.IsNegated); 73 | _writer.WriteLineIf(node.Prefix != null, "Prefix: {0}", node.Prefix); 74 | _writer.WriteLineIf(node.Operator != null, "Operator: {0}", node.Operator); 75 | _writer.WriteLineIf(node.Max != null, "Max: {0}", node.Max); 76 | _writer.WriteLineIf(node.Min != null, "Min: {0}", node.Min); 77 | _writer.WriteLineIf(node.MinInclusive.HasValue, "MinInclusive: {0}", node.MinInclusive); 78 | _writer.WriteLineIf(node.MaxInclusive.HasValue, "MaxInclusive: {0}", node.MaxInclusive); 79 | 80 | WriteData(node); 81 | 82 | _writer.Indent--; 83 | } 84 | 85 | public override void Visit(ExistsNode node, IQueryVisitorContext context) 86 | { 87 | _writer.WriteLine("Exists: "); 88 | _writer.Indent++; 89 | _writer.WriteLineIf(node.Field != null, "Field: {0}", node.Field); 90 | _writer.WriteLineIf(node.GetOriginalField() != null, "Original Field: {0}", node.GetOriginalField()); 91 | _writer.WriteLineIf(node.IsNegated.HasValue, "IsNegated: {0}", node.IsNegated); 92 | _writer.WriteLineIf(node.Prefix != null, "Prefix: {0}", node.Prefix); 93 | 94 | WriteData(node); 95 | 96 | _writer.Indent--; 97 | } 98 | 99 | public override void Visit(MissingNode node, IQueryVisitorContext context) 100 | { 101 | _writer.WriteLine("Missing: "); 102 | _writer.Indent++; 103 | _writer.WriteLineIf(node.Field != null, "Field: {0}", node.Field); 104 | _writer.WriteLineIf(node.GetOriginalField() != null, "Original Field: {0}", node.GetOriginalField()); 105 | _writer.WriteLineIf(node.IsNegated.HasValue, "IsNegated: {0}", node.IsNegated); 106 | _writer.WriteLineIf(node.Prefix != null, "Prefix: {0}", node.Prefix); 107 | 108 | WriteData(node); 109 | 110 | _writer.Indent--; 111 | } 112 | 113 | private void WriteData(QueryNodeBase node) 114 | { 115 | if (node.Data.Count <= 0) 116 | return; 117 | 118 | _writer.WriteLine("Data:"); 119 | _writer.Indent++; 120 | foreach (var kvp in node.Data) 121 | { 122 | _writer.Write(kvp.Key); 123 | if (kvp.Value != null) 124 | { 125 | _writer.Write(": "); 126 | _writer.WriteLine(kvp.Value.ToString()); 127 | } 128 | } 129 | _writer.Indent--; 130 | } 131 | 132 | public override async Task AcceptAsync(IQueryNode node, IQueryVisitorContext context) 133 | { 134 | await node.AcceptAsync(this, context); 135 | return _builder.ToString(); 136 | } 137 | 138 | public static Task RunAsync(IQueryNode node, IQueryVisitorContext context = null) 139 | { 140 | return new DebugQueryVisitor().AcceptAsync(node, context); 141 | } 142 | 143 | public static string Run(IQueryNode node, IQueryVisitorContext context = null) 144 | { 145 | return RunAsync(node, context).GetAwaiter().GetResult(); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/FieldResolverQueryVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Foundatio.Parsers.LuceneQueries.Extensions; 5 | using Foundatio.Parsers.LuceneQueries.Nodes; 6 | 7 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 8 | 9 | public class FieldResolverQueryVisitor : ChainableQueryVisitor 10 | { 11 | private readonly QueryFieldResolver _globalResolver; 12 | 13 | public FieldResolverQueryVisitor(QueryFieldResolver globalResolver = null) 14 | { 15 | _globalResolver = globalResolver; 16 | } 17 | 18 | public override async Task VisitAsync(GroupNode node, IQueryVisitorContext context) 19 | { 20 | await ResolveField(node, context); 21 | await base.VisitAsync(node, context); 22 | } 23 | 24 | public override Task VisitAsync(TermNode node, IQueryVisitorContext context) 25 | { 26 | return ResolveField(node, context); 27 | } 28 | 29 | public override Task VisitAsync(TermRangeNode node, IQueryVisitorContext context) 30 | { 31 | return ResolveField(node, context); 32 | } 33 | 34 | public override Task VisitAsync(ExistsNode node, IQueryVisitorContext context) 35 | { 36 | return ResolveField(node, context); 37 | } 38 | 39 | public override Task VisitAsync(MissingNode node, IQueryVisitorContext context) 40 | { 41 | return ResolveField(node, context); 42 | } 43 | 44 | private async Task ResolveField(IFieldQueryNode node, IQueryVisitorContext context) 45 | { 46 | if (node.Parent == null || node.Field == null) 47 | return; 48 | 49 | var contextResolver = context.GetFieldResolver(); 50 | if (_globalResolver == null && contextResolver == null) 51 | return; 52 | 53 | try 54 | { 55 | string resolvedField = null; 56 | if (contextResolver != null) 57 | resolvedField = await contextResolver(node.Field, context).ConfigureAwait(false); 58 | if (resolvedField == null && _globalResolver != null) 59 | resolvedField = await _globalResolver(node.Field, context).ConfigureAwait(false); 60 | 61 | if (resolvedField == null) 62 | { 63 | if (context.QueryType is QueryTypes.Aggregation && node.Field.StartsWith("@")) 64 | return; 65 | 66 | // add field to unresolved fields list 67 | context.GetValidationResult().UnresolvedFields.Add(node.Field); 68 | return; 69 | } 70 | 71 | if (!resolvedField.Equals(node.Field)) 72 | { 73 | node.SetOriginalField(node.Field); 74 | node.Field = resolvedField; 75 | } 76 | } 77 | catch (Exception ex) 78 | { 79 | context.AddValidationError($"Error in field resolver callback when resolving field ({node.Field}): {ex.Message}"); 80 | } 81 | } 82 | 83 | public override async Task AcceptAsync(IQueryNode node, IQueryVisitorContext context) 84 | { 85 | await node.AcceptAsync(this, context).ConfigureAwait(false); 86 | return node; 87 | } 88 | 89 | public static Task RunAsync(IQueryNode node, QueryFieldResolver resolver, IQueryVisitorContextWithFieldResolver context = null) 90 | { 91 | context ??= new QueryVisitorContext(); 92 | context.SetFieldResolver(resolver); 93 | return new FieldResolverQueryVisitor().AcceptAsync(node, context); 94 | } 95 | 96 | public static Task RunAsync(IQueryNode node, Func resolver, IQueryVisitorContextWithFieldResolver context = null) 97 | { 98 | context ??= new QueryVisitorContext(); 99 | context.SetFieldResolver((field, _) => Task.FromResult(resolver(field))); 100 | return new FieldResolverQueryVisitor().AcceptAsync(node, context); 101 | } 102 | 103 | public static IQueryNode Run(IQueryNode node, QueryFieldResolver resolver, IQueryVisitorContextWithFieldResolver context = null) 104 | { 105 | return RunAsync(node, resolver, context).GetAwaiter().GetResult(); 106 | } 107 | 108 | public static IQueryNode Run(IQueryNode node, Func resolver, IQueryVisitorContextWithFieldResolver context = null) 109 | { 110 | return RunAsync(node, resolver, context).GetAwaiter().GetResult(); 111 | } 112 | 113 | public static Task RunAsync(IQueryNode node, IDictionary map, IQueryVisitorContextWithFieldResolver context = null) 114 | { 115 | context ??= new QueryVisitorContext(); 116 | context.SetFieldResolver(map.ToHierarchicalFieldResolver()); 117 | return new FieldResolverQueryVisitor().AcceptAsync(node, context); 118 | } 119 | 120 | public static IQueryNode Run(IQueryNode node, IDictionary map, IQueryVisitorContextWithFieldResolver context = null) 121 | { 122 | return RunAsync(node, map, context).GetAwaiter().GetResult(); 123 | } 124 | } 125 | 126 | public delegate Task QueryFieldResolver(string field, IQueryVisitorContext context); 127 | 128 | public class FieldMap : Dictionary { } 129 | 130 | public static class FieldMapExtensions 131 | { 132 | public static string GetValueOrNull(this IDictionary map, string field) 133 | { 134 | if (map == null || field == null) 135 | return null; 136 | 137 | if (map.TryGetValue(field, out string value)) 138 | return value; 139 | 140 | return null; 141 | } 142 | 143 | public static QueryFieldResolver ToHierarchicalFieldResolver(this IDictionary map, string resultPrefix = null) 144 | { 145 | return (field, _) => 146 | { 147 | if (field == null) 148 | return null; 149 | 150 | if (map.TryGetValue(field, out string result)) 151 | return Task.FromResult($"{resultPrefix}{result}"); 152 | 153 | // start at the longest path and go backwards until we find a match in the map 154 | int currentPart = field.LastIndexOf('.'); 155 | while (currentPart > 0) 156 | { 157 | string currentName = field.Substring(0, currentPart); 158 | if (map.TryGetValue(currentName, out string currentResult)) 159 | return Task.FromResult($"{resultPrefix}{currentResult}{field.Substring(currentPart)}"); 160 | 161 | currentPart = field.LastIndexOf('.', currentPart - 1); 162 | } 163 | 164 | return Task.FromResult(field); 165 | }; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/GenerateQueryVisitor.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using System.Threading.Tasks; 3 | using Foundatio.Parsers.LuceneQueries.Nodes; 4 | 5 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 6 | 7 | public class GenerateQueryVisitor : QueryNodeVisitorWithResultBase 8 | { 9 | private readonly StringBuilder _builder = new(); 10 | 11 | public override Task VisitAsync(GroupNode node, IQueryVisitorContext context) 12 | { 13 | _builder.Append(node.ToString(context != null ? context.DefaultOperator : GroupOperator.Default)); 14 | 15 | return Task.CompletedTask; 16 | } 17 | 18 | public override void Visit(TermNode node, IQueryVisitorContext context) 19 | { 20 | _builder.Append(node); 21 | } 22 | 23 | public override void Visit(TermRangeNode node, IQueryVisitorContext context) 24 | { 25 | _builder.Append(node); 26 | } 27 | 28 | public override void Visit(ExistsNode node, IQueryVisitorContext context) 29 | { 30 | _builder.Append(node); 31 | } 32 | 33 | public override void Visit(MissingNode node, IQueryVisitorContext context) 34 | { 35 | _builder.Append(node); 36 | } 37 | 38 | public override async Task AcceptAsync(IQueryNode node, IQueryVisitorContext context) 39 | { 40 | await node.AcceptAsync(this, context).ConfigureAwait(false); 41 | return _builder.ToString(); 42 | } 43 | 44 | public static Task RunAsync(IQueryNode node, IQueryVisitorContext context = null) 45 | { 46 | return new GenerateQueryVisitor().AcceptAsync(node, context); 47 | } 48 | 49 | public static string Run(IQueryNode node, IQueryVisitorContext context = null) 50 | { 51 | return RunAsync(node, context).GetAwaiter().GetResult(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/GetReferencedFieldsQueryVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Foundatio.Parsers.LuceneQueries.Extensions; 5 | using Foundatio.Parsers.LuceneQueries.Nodes; 6 | 7 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 8 | 9 | [Obsolete("Use QueryNodeExtensions.GetReferencedFields() extension method instead.")] 10 | public class GetReferencedFieldsQueryVisitor : QueryNodeVisitorWithResultBase> 11 | { 12 | public override Task> AcceptAsync(IQueryNode node, IQueryVisitorContext context) 13 | { 14 | return Task.FromResult(node.GetReferencedFields(context)); 15 | } 16 | 17 | public static Task> RunAsync(IQueryNode node, IQueryVisitorContext context = null) 18 | { 19 | return new GetReferencedFieldsQueryVisitor().AcceptAsync(node, context); 20 | } 21 | 22 | public static ISet Run(IQueryNode node, IQueryVisitorContext context = null) 23 | { 24 | return RunAsync(node, context).GetAwaiter().GetResult(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/IChainableQueryVisitor.cs: -------------------------------------------------------------------------------- 1 | using Foundatio.Parsers.LuceneQueries.Nodes; 2 | 3 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 4 | 5 | public interface IChainableQueryVisitor : IQueryNodeVisitorWithResult { } 6 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/IQueryNodeVisitor.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Foundatio.Parsers.LuceneQueries.Nodes; 3 | 4 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 5 | 6 | public interface IQueryNodeVisitor 7 | { 8 | Task VisitAsync(IQueryNode node, IQueryVisitorContext context); 9 | } 10 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/IQueryNodeVisitorWithResult.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Foundatio.Parsers.LuceneQueries.Nodes; 3 | 4 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 5 | 6 | public interface IQueryNodeVisitorWithResult : IQueryNodeVisitor 7 | { 8 | Task AcceptAsync(IQueryNode node, IQueryVisitorContext context); 9 | } 10 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/IQueryVisitorContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Foundatio.Parsers.LuceneQueries.Nodes; 3 | 4 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 5 | 6 | public interface IQueryVisitorContext 7 | { 8 | GroupOperator DefaultOperator { get; set; } 9 | string[] DefaultFields { get; set; } 10 | string QueryType { get; set; } 11 | IDictionary Data { get; } 12 | } 13 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/IQueryVisitorContextWithFieldResolver.cs: -------------------------------------------------------------------------------- 1 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 2 | 3 | public interface IQueryVisitorContextWithFieldResolver : IQueryVisitorContext 4 | { 5 | QueryFieldResolver FieldResolver { get; set; } 6 | } 7 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/IQueryVisitorContextWithIncludeResolver.cs: -------------------------------------------------------------------------------- 1 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 2 | 3 | public interface IQueryVisitorContextWithIncludeResolver : IQueryVisitorContext 4 | { 5 | IncludeResolver IncludeResolver { get; set; } 6 | } 7 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/IQueryVisitorContextWithValidation.cs: -------------------------------------------------------------------------------- 1 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 2 | 3 | public interface IQueryVisitorContextWithValidation : IQueryVisitorContext 4 | { 5 | QueryValidationOptions ValidationOptions { get; set; } 6 | QueryValidationResult ValidationResult { get; set; } 7 | } 8 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/IncludeVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Foundatio.Parsers.LuceneQueries.Extensions; 5 | using Foundatio.Parsers.LuceneQueries.Nodes; 6 | 7 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 8 | 9 | public delegate bool ShouldSkipIncludeFunc(TermNode node, IQueryVisitorContext context); 10 | public delegate Task IncludeResolver(string name); 11 | 12 | public class IncludeVisitor : ChainableMutatingQueryVisitor 13 | { 14 | private readonly LuceneQueryParser _parser = new(); 15 | private readonly ShouldSkipIncludeFunc _shouldSkipInclude; 16 | private readonly string _includeName; 17 | 18 | public IncludeVisitor(ShouldSkipIncludeFunc shouldSkipInclude = null, string includeName = "include") 19 | { 20 | _shouldSkipInclude = shouldSkipInclude; 21 | _includeName = includeName; 22 | } 23 | 24 | public override async Task VisitAsync(TermNode node, IQueryVisitorContext context) 25 | { 26 | if (node.Field != "@" + _includeName || (_shouldSkipInclude != null && _shouldSkipInclude(node, context))) 27 | return node; 28 | 29 | var includeResolver = context.GetIncludeResolver(); 30 | if (includeResolver == null) 31 | return node; 32 | 33 | var includes = context.GetValidationResult().ReferencedIncludes; 34 | includes.Add(node.Term); 35 | 36 | var includeStack = context.GetIncludeStack(); 37 | if (includeStack.Contains(node.Term)) 38 | { 39 | context.AddValidationError($"Recursive {_includeName} ({node.Term})"); 40 | return node; 41 | } 42 | 43 | try 44 | { 45 | string includedQuery = await includeResolver(node.Term).ConfigureAwait(false); 46 | if (includedQuery == null) 47 | { 48 | context.AddValidationError($"Unresolved {_includeName} ({node.Term})"); 49 | context.GetValidationResult().UnresolvedIncludes.Add(node.Term); 50 | } 51 | 52 | if (String.IsNullOrEmpty(includedQuery)) 53 | return node; 54 | 55 | includeStack.Push(node.Term); 56 | 57 | var result = (GroupNode)await _parser.ParseAsync(includedQuery).ConfigureAwait(false); 58 | result.HasParens = true; 59 | await VisitAsync(result, context).ConfigureAwait(false); 60 | 61 | includeStack.Pop(); 62 | 63 | return node.ReplaceSelf(result); 64 | } 65 | catch (Exception ex) 66 | { 67 | context.AddValidationError($"Error in {_includeName} resolver callback when resolving {_includeName} ({node.Term}): {ex.Message}"); 68 | context.GetValidationResult().UnresolvedIncludes.Add(node.Term); 69 | 70 | return node; 71 | } 72 | } 73 | 74 | public static Task RunAsync(IQueryNode node, IncludeResolver includeResolver, IQueryVisitorContextWithIncludeResolver context = null, ShouldSkipIncludeFunc shouldSkipInclude = null) 75 | { 76 | context ??= new QueryVisitorContext(); 77 | context.SetIncludeResolver(includeResolver); 78 | 79 | return new IncludeVisitor(shouldSkipInclude).AcceptAsync(node, context); 80 | } 81 | 82 | public static IQueryNode Run(IQueryNode node, IncludeResolver includeResolver, IQueryVisitorContextWithIncludeResolver context = null, ShouldSkipIncludeFunc shouldSkipInclude = null) 83 | { 84 | return RunAsync(node, includeResolver, context, shouldSkipInclude).GetAwaiter().GetResult(); 85 | } 86 | 87 | public static IQueryNode Run(IQueryNode node, Func includeResolver, IQueryVisitorContextWithIncludeResolver context = null, ShouldSkipIncludeFunc shouldSkipInclude = null) 88 | { 89 | return RunAsync(node, name => Task.FromResult(includeResolver(name)), context, shouldSkipInclude).GetAwaiter().GetResult(); 90 | } 91 | 92 | public static Task RunAsync(IQueryNode node, IDictionary includes, IQueryVisitorContextWithIncludeResolver context = null, ShouldSkipIncludeFunc shouldSkipInclude = null) 93 | { 94 | return RunAsync(node, name => Task.FromResult(includes.ContainsKey(name) ? includes[name] : null), context, shouldSkipInclude); 95 | } 96 | 97 | public static IQueryNode Run(IQueryNode node, IDictionary includes, IQueryVisitorContextWithIncludeResolver context = null, ShouldSkipIncludeFunc shouldSkipInclude = null) 98 | { 99 | return RunAsync(node, includes, context, shouldSkipInclude).GetAwaiter().GetResult(); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/InvertQueryVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Foundatio.Parsers.LuceneQueries.Extensions; 6 | using Foundatio.Parsers.LuceneQueries.Nodes; 7 | 8 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 9 | 10 | public class InvertQueryVisitor : ChainableMutatingQueryVisitor 11 | { 12 | public InvertQueryVisitor(IEnumerable nonInvertedFields = null) 13 | { 14 | NonInvertedFields.AddRange(nonInvertedFields); 15 | } 16 | 17 | public List NonInvertedFields { get; } = new List(); 18 | 19 | public override Task VisitAsync(GroupNode node, IQueryVisitorContext context) 20 | { 21 | bool onlyNonInvertedFields = node.GetReferencedFields().All(f => NonInvertedFields.Contains(f, StringComparer.OrdinalIgnoreCase)); 22 | bool hasNonInvertedFields = node.GetReferencedFields().Any(f => NonInvertedFields.Contains(f, StringComparer.OrdinalIgnoreCase)); 23 | bool onlyInvertedFields = !hasNonInvertedFields; 24 | 25 | // don't invert groups that only contain non-inverted fields 26 | if (onlyNonInvertedFields) 27 | return Task.FromResult(node); 28 | 29 | if (onlyInvertedFields) 30 | { 31 | // invert, don't visit children 32 | node = node.InvertNegation() as GroupNode; 33 | 34 | var alternateInvertedCriteria = context.GetAlternateInvertedCriteria(); 35 | if (alternateInvertedCriteria != null) 36 | node = node.ReplaceSelf(new GroupNode 37 | { 38 | Left = alternateInvertedCriteria, 39 | Right = node.Clone(), 40 | Operator = GroupOperator.Or, 41 | HasParens = true 42 | }); 43 | 44 | return Task.FromResult(node); 45 | } 46 | 47 | // otherwise, continue visiting children and invert them 48 | return base.VisitAsync(node, context); 49 | } 50 | 51 | public override Task VisitAsync(IQueryNode node, IQueryVisitorContext context) 52 | { 53 | if (context.DefaultOperator == GroupOperator.Or) 54 | throw new ArgumentException("Queries using OR as the default operator can not be inverted."); 55 | 56 | if (node is GroupNode groupNode) 57 | return VisitAsync(groupNode, context); 58 | 59 | if (node is IFieldQueryNode fieldNode && NonInvertedFields.Contains(fieldNode.Field, StringComparer.OrdinalIgnoreCase)) 60 | return Task.FromResult(node); 61 | 62 | node = node.InvertNegation(); 63 | 64 | var alternateInvertedCriteria = context.GetAlternateInvertedCriteria(); 65 | if (alternateInvertedCriteria != null) 66 | node = node.ReplaceSelf(new GroupNode 67 | { 68 | Left = alternateInvertedCriteria, 69 | Right = node.Clone(), 70 | Operator = GroupOperator.Or, 71 | HasParens = true 72 | }); 73 | 74 | return Task.FromResult(node); 75 | } 76 | 77 | public override Task AcceptAsync(IQueryNode node, IQueryVisitorContext context) 78 | { 79 | return node.AcceptAsync(this, context); 80 | } 81 | 82 | public static async Task RunAsync(IQueryNode node, IEnumerable nonInvertedFields = null, IQueryVisitorContext context = null) 83 | { 84 | var result = await new InvertQueryVisitor(nonInvertedFields).AcceptAsync(node, context); 85 | return result.ToString(); 86 | } 87 | 88 | public static string Run(IQueryNode node, IEnumerable nonInvertedFields = null, IQueryVisitorContext context = null) 89 | { 90 | return RunAsync(node, nonInvertedFields, context).GetAwaiter().GetResult(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/QueryNodeVisitorBase.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Foundatio.Parsers.LuceneQueries.Nodes; 3 | 4 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 5 | 6 | public abstract class QueryNodeVisitorBase : IQueryNodeVisitor 7 | { 8 | public virtual async Task VisitAsync(GroupNode node, IQueryVisitorContext context) 9 | { 10 | foreach (var child in node.Children) 11 | await VisitAsync(child, context).ConfigureAwait(false); 12 | } 13 | 14 | public virtual void Visit(TermNode node, IQueryVisitorContext context) { } 15 | 16 | public virtual Task VisitAsync(TermNode node, IQueryVisitorContext context) 17 | { 18 | Visit(node, context); 19 | return Task.CompletedTask; 20 | } 21 | 22 | public virtual void Visit(TermRangeNode node, IQueryVisitorContext context) { } 23 | 24 | public virtual Task VisitAsync(TermRangeNode node, IQueryVisitorContext context) 25 | { 26 | Visit(node, context); 27 | return Task.CompletedTask; 28 | } 29 | 30 | public virtual void Visit(ExistsNode node, IQueryVisitorContext context) { } 31 | 32 | public virtual Task VisitAsync(ExistsNode node, IQueryVisitorContext context) 33 | { 34 | Visit(node, context); 35 | return Task.CompletedTask; 36 | } 37 | 38 | public virtual void Visit(MissingNode node, IQueryVisitorContext context) { } 39 | 40 | public virtual Task VisitAsync(MissingNode node, IQueryVisitorContext context) 41 | { 42 | Visit(node, context); 43 | return Task.CompletedTask; 44 | } 45 | 46 | public virtual async Task VisitAsync(IQueryNode node, IQueryVisitorContext context) 47 | { 48 | if (node is GroupNode groupNode) 49 | { 50 | await VisitAsync(groupNode, context); 51 | return node; 52 | } 53 | 54 | if (node is TermNode termNode) 55 | { 56 | await VisitAsync(termNode, context); 57 | return node; 58 | } 59 | 60 | if (node is TermRangeNode termRangeNode) 61 | { 62 | await VisitAsync(termRangeNode, context); 63 | return node; 64 | } 65 | 66 | if (node is MissingNode missingNode) 67 | { 68 | await VisitAsync(missingNode, context); 69 | return node; 70 | } 71 | 72 | if (node is ExistsNode existsNode) 73 | { 74 | await VisitAsync(existsNode, context); 75 | return node; 76 | } 77 | 78 | return node; 79 | } 80 | } 81 | 82 | public abstract class MutatingQueryNodeVisitorBase : IQueryNodeVisitor 83 | { 84 | public virtual async Task VisitAsync(GroupNode node, IQueryVisitorContext context) 85 | { 86 | foreach (var child in node.Children) 87 | await VisitAsync(child, context).ConfigureAwait(false); 88 | 89 | return node; 90 | } 91 | 92 | public virtual IQueryNode Visit(TermNode node, IQueryVisitorContext context) => node; 93 | 94 | public virtual Task VisitAsync(TermNode node, IQueryVisitorContext context) 95 | { 96 | return Task.FromResult(Visit(node, context)); 97 | } 98 | 99 | public virtual IQueryNode Visit(TermRangeNode node, IQueryVisitorContext context) => node; 100 | 101 | public virtual Task VisitAsync(TermRangeNode node, IQueryVisitorContext context) 102 | { 103 | return Task.FromResult(Visit(node, context)); 104 | } 105 | 106 | public virtual IQueryNode Visit(ExistsNode node, IQueryVisitorContext context) => node; 107 | 108 | public virtual Task VisitAsync(ExistsNode node, IQueryVisitorContext context) 109 | { 110 | return Task.FromResult(Visit(node, context)); 111 | } 112 | 113 | public virtual IQueryNode Visit(MissingNode node, IQueryVisitorContext context) => node; 114 | 115 | public virtual Task VisitAsync(MissingNode node, IQueryVisitorContext context) 116 | { 117 | return Task.FromResult(Visit(node, context)); 118 | } 119 | 120 | public virtual Task VisitAsync(IQueryNode node, IQueryVisitorContext context) 121 | { 122 | if (node is GroupNode groupNode) 123 | return VisitAsync(groupNode, context); 124 | 125 | if (node is TermNode termNode) 126 | return VisitAsync(termNode, context); 127 | 128 | if (node is TermRangeNode termRangeNode) 129 | return VisitAsync(termRangeNode, context); 130 | 131 | if (node is MissingNode missingNode) 132 | return VisitAsync(missingNode, context); 133 | 134 | if (node is ExistsNode existsNode) 135 | return VisitAsync(existsNode, context); 136 | 137 | return Task.FromResult(node); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/QueryNodeVisitorWithResultBase.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Foundatio.Parsers.LuceneQueries.Nodes; 3 | 4 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 5 | 6 | public abstract class QueryNodeVisitorWithResultBase : QueryNodeVisitorBase, IQueryNodeVisitorWithResult 7 | { 8 | public abstract Task AcceptAsync(IQueryNode node, IQueryVisitorContext context); 9 | } 10 | 11 | public abstract class MutatingQueryNodeVisitorWithResultBase : MutatingQueryNodeVisitorBase, IQueryNodeVisitorWithResult 12 | { 13 | public abstract Task AcceptAsync(IQueryNode node, IQueryVisitorContext context); 14 | } 15 | 16 | public abstract class ChainableQueryVisitor : QueryNodeVisitorWithResultBase, IChainableQueryVisitor 17 | { 18 | public override async Task AcceptAsync(IQueryNode node, IQueryVisitorContext context) 19 | { 20 | var result = await node.AcceptAsync(this, context).ConfigureAwait(false); 21 | return result; 22 | } 23 | } 24 | 25 | public abstract class ChainableMutatingQueryVisitor : MutatingQueryNodeVisitorWithResultBase, IChainableQueryVisitor 26 | { 27 | public override async Task AcceptAsync(IQueryNode node, IQueryVisitorContext context) 28 | { 29 | var result = await node.AcceptAsync(this, context).ConfigureAwait(false); 30 | return result; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/QueryVisitorContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Foundatio.Parsers.LuceneQueries.Nodes; 3 | 4 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 5 | 6 | public class QueryVisitorContext : IQueryVisitorContext, IQueryVisitorContextWithFieldResolver, IQueryVisitorContextWithIncludeResolver, IQueryVisitorContextWithValidation 7 | { 8 | public GroupOperator DefaultOperator { get; set; } = GroupOperator.And; 9 | public string[] DefaultFields { get; set; } 10 | public string QueryType { get; set; } = QueryTypes.Query; 11 | public IDictionary Data { get; } = new Dictionary(); 12 | public QueryFieldResolver FieldResolver { get; set; } 13 | public IncludeResolver IncludeResolver { get; set; } 14 | public QueryValidationOptions ValidationOptions { get; set; } 15 | public QueryValidationResult ValidationResult { get; set; } 16 | } 17 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/RemoveFieldsQueryVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Foundatio.Parsers.LuceneQueries.Extensions; 6 | using Foundatio.Parsers.LuceneQueries.Nodes; 7 | 8 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 9 | 10 | public class RemoveFieldsQueryVisitor : ChainableQueryVisitor 11 | { 12 | public RemoveFieldsQueryVisitor(IEnumerable fieldsToRemove) 13 | { 14 | if (fieldsToRemove == null) 15 | throw new ArgumentNullException(nameof(fieldsToRemove)); 16 | 17 | string[] fieldsToRemoveList = fieldsToRemove.ToArray(); 18 | ShouldRemoveField = f => fieldsToRemoveList.Contains(f, StringComparer.OrdinalIgnoreCase); 19 | } 20 | 21 | public RemoveFieldsQueryVisitor(Func shouldRemoveFieldFunc) 22 | { 23 | if (shouldRemoveFieldFunc == null) 24 | throw new ArgumentNullException(nameof(shouldRemoveFieldFunc)); 25 | 26 | ShouldRemoveField = shouldRemoveFieldFunc; 27 | } 28 | 29 | public Func ShouldRemoveField { get; } 30 | 31 | public override Task VisitAsync(IQueryNode node, IQueryVisitorContext context) 32 | { 33 | if (node is IFieldQueryNode fieldNode && fieldNode.Field != null && ShouldRemoveField(fieldNode.Field)) 34 | { 35 | node.RemoveSelf(); 36 | return Task.FromResult(null); 37 | } 38 | 39 | return base.VisitAsync(node, context); 40 | } 41 | 42 | public override async Task AcceptAsync(IQueryNode node, IQueryVisitorContext context) 43 | { 44 | await node.AcceptAsync(this, context); 45 | return node; 46 | } 47 | 48 | public static async Task RunAsync(IQueryNode node, IEnumerable nonInvertedFields = null, IQueryVisitorContext context = null) 49 | { 50 | var result = await new RemoveFieldsQueryVisitor(nonInvertedFields).AcceptAsync(node, context); 51 | return result.ToString(); 52 | } 53 | 54 | public static async Task RunAsync(IQueryNode node, Func shouldRemoveFieldFunc, IQueryVisitorContext context = null) 55 | { 56 | var result = await new RemoveFieldsQueryVisitor(shouldRemoveFieldFunc).AcceptAsync(node, context); 57 | return result.ToString(); 58 | } 59 | 60 | public static string Run(IQueryNode node, IEnumerable nonInvertedFields = null, IQueryVisitorContext context = null) 61 | { 62 | return RunAsync(node, nonInvertedFields, context).GetAwaiter().GetResult(); 63 | } 64 | 65 | public static string Run(IQueryNode node, Func shouldRemoveFieldFunc, IQueryVisitorContext context = null) 66 | { 67 | return RunAsync(node, shouldRemoveFieldFunc, context).GetAwaiter().GetResult(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/TermToFieldVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Foundatio.Parsers.LuceneQueries.Nodes; 4 | 5 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 6 | 7 | public class TermToFieldVisitor : ChainableQueryVisitor 8 | { 9 | public override void Visit(TermNode node, IQueryVisitorContext context) 10 | { 11 | if (String.IsNullOrEmpty(node.Term) || (node.Field != null && node.Field.StartsWith("@"))) 12 | return; 13 | 14 | node.Field = node.Term; 15 | node.Term = null; 16 | } 17 | 18 | public static Task RunAsync(IQueryNode node, IQueryVisitorContext context = null) 19 | { 20 | return new TermToFieldVisitor().AcceptAsync(node, context); 21 | } 22 | 23 | public static void Run(IQueryNode node, IQueryVisitorContext context = null) 24 | { 25 | RunAsync(node, context).GetAwaiter().GetResult(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.LuceneQueries/Visitors/VisitorWithPriority.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics; 3 | using System.Threading.Tasks; 4 | using Foundatio.Parsers.LuceneQueries.Nodes; 5 | 6 | namespace Foundatio.Parsers.LuceneQueries.Visitors; 7 | 8 | [DebuggerDisplay("{Priority}, {Visitor}")] 9 | public class QueryVisitorWithPriority : IChainableQueryVisitor 10 | { 11 | public int Priority { get; set; } 12 | public IQueryNodeVisitorWithResult Visitor { get; set; } 13 | 14 | public Task AcceptAsync(IQueryNode node, IQueryVisitorContext context) 15 | { 16 | return Visitor.AcceptAsync(node, context); 17 | } 18 | 19 | public Task VisitAsync(IQueryNode node, IQueryVisitorContext context) 20 | { 21 | return Visitor.VisitAsync(node, context); 22 | } 23 | 24 | public class PriorityComparer : IComparer 25 | { 26 | public int Compare(QueryVisitorWithPriority x, QueryVisitorWithPriority y) 27 | { 28 | return x.Priority.CompareTo(y.Priority); 29 | } 30 | 31 | public static readonly PriorityComparer Instance = new(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.SqlQueries/Extensions/EnumerableExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Foundatio.Parsers.SqlQueries.Extensions; 4 | 5 | internal static class EnumerableExtensions 6 | { 7 | public delegate void ElementAction(T element, ElementInfo info); 8 | 9 | public static void ForEach(this IEnumerable elements, ElementAction action) 10 | { 11 | using IEnumerator enumerator = elements.GetEnumerator(); 12 | bool isFirst = true; 13 | bool hasNext = enumerator.MoveNext(); 14 | int index = 0; 15 | 16 | while (hasNext) 17 | { 18 | T current = enumerator.Current; 19 | hasNext = enumerator.MoveNext(); 20 | action(current, new ElementInfo(index, isFirst, !hasNext)); 21 | isFirst = false; 22 | index++; 23 | } 24 | } 25 | 26 | public struct ElementInfo 27 | { 28 | public ElementInfo(int index, bool isFirst, bool isLast) 29 | : this() 30 | { 31 | Index = index; 32 | IsFirst = isFirst; 33 | IsLast = isLast; 34 | } 35 | 36 | public int Index { get; private set; } 37 | public bool IsFirst { get; private set; } 38 | public bool IsLast { get; private set; } 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.SqlQueries/Extensions/TypeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace Foundatio.Parsers.SqlQueries.Extensions; 5 | 6 | public static class TypeExtensions 7 | { 8 | private static readonly IList _integerTypes = new List() 9 | { 10 | typeof (byte), 11 | typeof (short), 12 | typeof (int), 13 | typeof (long), 14 | typeof (sbyte), 15 | typeof (ushort), 16 | typeof (uint), 17 | typeof (ulong), 18 | typeof (byte?), 19 | typeof (short?), 20 | typeof (int?), 21 | typeof (long?), 22 | typeof (sbyte?), 23 | typeof (ushort?), 24 | typeof (uint?), 25 | typeof (ulong?) 26 | }; 27 | 28 | public static Type UnwrapNullable(this Type type) 29 | { 30 | if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) 31 | return Nullable.GetUnderlyingType(type); 32 | 33 | return type; 34 | } 35 | 36 | public static bool IsString(this Type type) => type == typeof(string); 37 | public static bool IsDateTime(this Type typeToCheck) => typeToCheck == typeof(DateTime) || typeToCheck == typeof(DateTime?); 38 | public static bool IsDateOnly(this Type typeToCheck) => typeToCheck == typeof(DateOnly) || typeToCheck == typeof(DateOnly?); 39 | public static bool IsBoolean(this Type typeToCheck) => typeToCheck == typeof(bool) || typeToCheck == typeof(bool?); 40 | public static bool IsNumeric(this Type type) => type.IsFloatingPoint() || type.IsIntegerBased(); 41 | public static bool IsIntegerBased(this Type type) => _integerTypes.Contains(type); 42 | public static bool IsFloatingPoint(this Type type) => type == typeof(decimal) || type == typeof(float) || type == typeof(double); 43 | } 44 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.SqlQueries/Foundatio.Parsers.SqlQueries.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0; 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.SqlQueries/Visitors/GenerateSqlVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Threading.Tasks; 4 | using Foundatio.Parsers.LuceneQueries.Nodes; 5 | using Foundatio.Parsers.LuceneQueries.Visitors; 6 | using Foundatio.Parsers.SqlQueries.Extensions; 7 | 8 | namespace Foundatio.Parsers.SqlQueries.Visitors; 9 | 10 | public class GenerateSqlVisitor : QueryNodeVisitorWithResultBase 11 | { 12 | private readonly StringBuilder _builder = new(); 13 | 14 | public override Task VisitAsync(GroupNode node, IQueryVisitorContext context) 15 | { 16 | if (context is not ISqlQueryVisitorContext sqlContext) 17 | throw new InvalidOperationException("The context must be an ISqlQueryVisitorContext."); 18 | 19 | _builder.Append(node.ToDynamicLinqString(sqlContext)); 20 | 21 | return Task.CompletedTask; 22 | } 23 | 24 | public override void Visit(TermNode node, IQueryVisitorContext context) 25 | { 26 | if (context is not ISqlQueryVisitorContext sqlContext) 27 | throw new InvalidOperationException("The context must be an ISqlQueryVisitorContext."); 28 | 29 | _builder.Append(node.ToDynamicLinqString(sqlContext)); 30 | } 31 | 32 | public override void Visit(TermRangeNode node, IQueryVisitorContext context) 33 | { 34 | if (context is not ISqlQueryVisitorContext sqlContext) 35 | throw new InvalidOperationException("The context must be an ISqlQueryVisitorContext."); 36 | 37 | _builder.Append(node.ToDynamicLinqString(sqlContext)); 38 | } 39 | 40 | public override void Visit(ExistsNode node, IQueryVisitorContext context) 41 | { 42 | if (context is not ISqlQueryVisitorContext sqlContext) 43 | throw new InvalidOperationException("The context must be an ISqlQueryVisitorContext."); 44 | 45 | _builder.Append(node.ToDynamicLinqString(sqlContext)); 46 | } 47 | 48 | public override void Visit(MissingNode node, IQueryVisitorContext context) 49 | { 50 | if (context is not ISqlQueryVisitorContext sqlContext) 51 | throw new InvalidOperationException("The context must be an ISqlQueryVisitorContext."); 52 | 53 | _builder.Append(node.ToDynamicLinqString(sqlContext)); 54 | } 55 | 56 | public override async Task AcceptAsync(IQueryNode node, IQueryVisitorContext context) 57 | { 58 | await node.AcceptAsync(this, context).ConfigureAwait(false); 59 | return _builder.ToString(); 60 | } 61 | 62 | public static Task RunAsync(IQueryNode node, IQueryVisitorContext context = null) 63 | { 64 | return new GenerateSqlVisitor().AcceptAsync(node, context); 65 | } 66 | 67 | public static string Run(IQueryNode node, IQueryVisitorContext context = null) 68 | { 69 | return RunAsync(node, context).GetAwaiter().GetResult(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.SqlQueries/Visitors/ISqlQueryVisitorContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Foundatio.Parsers.LuceneQueries.Visitors; 4 | 5 | namespace Foundatio.Parsers.SqlQueries.Visitors; 6 | 7 | public interface ISqlQueryVisitorContext : IQueryVisitorContext 8 | { 9 | List Fields { get; set; } 10 | Action SearchTokenizer { get; set; } 11 | Func DateTimeParser { get; set; } 12 | Func DateOnlyParser { get; set; } 13 | } 14 | -------------------------------------------------------------------------------- /src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Text; 5 | using Foundatio.Parsers.LuceneQueries.Visitors; 6 | using Microsoft.EntityFrameworkCore.Metadata; 7 | 8 | namespace Foundatio.Parsers.SqlQueries.Visitors; 9 | 10 | public class SqlQueryVisitorContext : QueryVisitorContext, ISqlQueryVisitorContext 11 | { 12 | public List Fields { get; set; } 13 | public Action SearchTokenizer { get; set; } = static _ => { }; 14 | public Func DateTimeParser { get; set; } 15 | public Func DateOnlyParser { get; set; } 16 | public IEntityType EntityType { get; set; } 17 | } 18 | 19 | [DebuggerDisplay("{FullName} IsNumber: {IsNumber} IsMoney: {IsMoney} IsDate: {IsDate} IsBoolean: {IsBoolean} IsCollection: {IsCollection}")] 20 | public class EntityFieldInfo 21 | { 22 | public required string Name { get; init; } 23 | public required string FullName { get; init; } 24 | public bool IsNumber { get; set; } 25 | public bool IsMoney { get; set; } 26 | public bool IsDate { get; set; } 27 | public bool IsDateOnly { get; set; } 28 | public bool IsBoolean { get; set; } 29 | public bool IsCollection { get; set; } 30 | public bool IsNavigation { get; set; } 31 | public EntityFieldInfo Parent { get; set; } 32 | public IDictionary Data { get; set; } = new Dictionary(); 33 | 34 | protected bool Equals(EntityFieldInfo other) => Name == other.Name; 35 | 36 | public override bool Equals(object obj) 37 | { 38 | if (obj is null) 39 | { 40 | return false; 41 | } 42 | 43 | if (ReferenceEquals(this, obj)) 44 | { 45 | return true; 46 | } 47 | 48 | if (obj.GetType() != GetType()) 49 | { 50 | return false; 51 | } 52 | 53 | return Equals((EntityFieldInfo)obj); 54 | } 55 | 56 | public override int GetHashCode() => (Name != null ? Name.GetHashCode() : 0); 57 | 58 | public (string fieldPrefix, string fieldSuffix) GetFieldPrefixAndSuffix() 59 | { 60 | var fieldTree = new List(); 61 | EntityFieldInfo current = Parent; 62 | while (current != null) 63 | { 64 | fieldTree.Add(current); 65 | current = current.Parent; 66 | } 67 | 68 | fieldTree.Reverse(); 69 | 70 | var prefix = new StringBuilder(); 71 | var suffix = new StringBuilder(); 72 | foreach (var field in fieldTree) 73 | { 74 | if (field.IsCollection) 75 | { 76 | prefix.Append($"{field.Name}.Any("); 77 | suffix.Append(")"); 78 | } 79 | else 80 | { 81 | prefix.Append(field.Name).Append("."); 82 | } 83 | }; 84 | 85 | return (prefix.ToString(), suffix.ToString()); 86 | } 87 | } 88 | 89 | public class SearchTerm 90 | { 91 | public EntityFieldInfo FieldInfo { get; set; } 92 | public string Term { get; set; } 93 | public List Tokens { get; set; } 94 | public SqlSearchOperator Operator { get; set; } = SqlSearchOperator.Contains; 95 | } 96 | 97 | public enum SqlSearchOperator { Equals, Contains, StartsWith } 98 | -------------------------------------------------------------------------------- /tests/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0; 5 | False 6 | $(NoWarn);CS1591;NU1701 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticMappingResolverTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq.Expressions; 3 | using Nest; 4 | using Xunit; 5 | using Xunit.Abstractions; 6 | 7 | namespace Foundatio.Parsers.ElasticQueries.Tests; 8 | 9 | public class ElasticMappingResolverTests : ElasticsearchTestBase 10 | { 11 | public ElasticMappingResolverTests(ITestOutputHelper output, ElasticsearchFixture fixture) : base(output, fixture) 12 | { 13 | Log.DefaultMinimumLevel = Microsoft.Extensions.Logging.LogLevel.Trace; 14 | } 15 | 16 | private ITypeMapping MapMyNestedType(TypeMappingDescriptor m) 17 | { 18 | return m 19 | .AutoMap() 20 | .Dynamic() 21 | .DynamicTemplates(t => t.DynamicTemplate("idx_text", t => t.Match("text*").Mapping(m => m.Text(mp => mp.AddKeywordAndSortFields())))) 22 | .Properties(p => p 23 | .Text(p1 => p1.Name(n => n.Field1).AddKeywordAndSortFields()) 24 | .Text(p1 => p1.Name(n => n.Field4).AddKeywordAndSortFields()) 25 | .FieldAlias(a => a.Path(n => n.Field4).Name("field4alias")) 26 | .Text(p1 => p1.Name(n => n.Field5).AddKeywordAndSortFields(true)) 27 | ); 28 | } 29 | 30 | [Fact] 31 | public void CanResolveCodedProperty() 32 | { 33 | string index = CreateRandomIndex(MapMyNestedType); 34 | 35 | Client.IndexMany([ 36 | new MyNestedType 37 | { 38 | Field1 = "value1", 39 | Field2 = "value2", 40 | Nested = 41 | [ 42 | new MyType 43 | { 44 | Field1 = "banana", 45 | Data = { 46 | { "number-0001", 23 }, 47 | { "text-0001", "Hey" }, 48 | { "spaced field", "hey" } 49 | } 50 | } 51 | ] 52 | }, 53 | new MyNestedType { Field1 = "value2", Field2 = "value2" }, 54 | new MyNestedType { Field1 = "value1", Field2 = "value4" } 55 | ], index); 56 | Client.Indices.Refresh(index); 57 | 58 | var resolver = ElasticMappingResolver.Create(MapMyNestedType, Client, index, _logger); 59 | 60 | var payloadProperty = resolver.GetMappingProperty("payload"); 61 | Assert.IsType(payloadProperty); 62 | Assert.NotNull(payloadProperty.Name); 63 | } 64 | 65 | [Fact] 66 | public void CanResolveProperties() 67 | { 68 | string index = CreateRandomIndex(MapMyNestedType); 69 | 70 | Client.IndexMany([ 71 | new MyNestedType 72 | { 73 | Field1 = "value1", 74 | Field2 = "value2", 75 | Nested = 76 | [ 77 | new MyType 78 | { 79 | Field1 = "banana", 80 | Data = { 81 | { "number-0001", 23 }, 82 | { "text-0001", "Hey" }, 83 | { "spaced field", "hey" } 84 | } 85 | } 86 | ] 87 | }, 88 | new MyNestedType { Field1 = "value2", Field2 = "value2" }, 89 | new MyNestedType { Field1 = "value1", Field2 = "value4" } 90 | ], index); 91 | Client.Indices.Refresh(index); 92 | 93 | var resolver = ElasticMappingResolver.Create(MapMyNestedType, Client, index, _logger); 94 | 95 | string dynamicTextAggregation = resolver.GetAggregationsFieldName("nested.data.text-0001"); 96 | Assert.Equal("nested.data.text-0001.keyword", dynamicTextAggregation); 97 | 98 | string dynamicSpacedAggregation = resolver.GetAggregationsFieldName("nested.data.spaced field"); 99 | Assert.Equal("nested.data.spaced field.keyword", dynamicSpacedAggregation); 100 | 101 | string dynamicSpacedSort = resolver.GetSortFieldName("nested.data.spaced field"); 102 | Assert.Equal("nested.data.spaced field.keyword", dynamicSpacedSort); 103 | 104 | string dynamicSpacedField = resolver.GetResolvedField("nested.data.spaced field"); 105 | Assert.Equal("nested.data.spaced field", dynamicSpacedField); 106 | 107 | var field1Property = resolver.GetMappingProperty("Field1"); 108 | Assert.IsType(field1Property); 109 | 110 | string field5Property = resolver.GetAggregationsFieldName("Field5"); 111 | Assert.Equal("field5.keyword", field5Property); 112 | 113 | var unknownProperty = resolver.GetMappingProperty("UnknowN.test.doesNotExist"); 114 | Assert.Null(unknownProperty); 115 | 116 | string field1 = resolver.GetResolvedField("FielD1"); 117 | Assert.Equal("field1", field1); 118 | 119 | string emptyField = resolver.GetResolvedField(" "); 120 | Assert.Equal(" ", emptyField); 121 | 122 | string unknownField = resolver.GetResolvedField("UnknowN.test.doesNotExist"); 123 | Assert.Equal("UnknowN.test.doesNotExist", unknownField); 124 | 125 | string unknownField2 = resolver.GetResolvedField("unknown.test.doesnotexist"); 126 | Assert.Equal("unknown.test.doesnotexist", unknownField2); 127 | 128 | string unknownField3 = resolver.GetResolvedField("unknown"); 129 | Assert.Equal("unknown", unknownField3); 130 | 131 | var field4Property = resolver.GetMappingProperty("Field4"); 132 | Assert.IsType(field4Property); 133 | 134 | var field4ReflectionProperty = resolver.GetMappingProperty(new Field(typeof(MyNestedType).GetProperty("Field4"))); 135 | Assert.IsType(field4ReflectionProperty); 136 | 137 | var field4ExpressionProperty = resolver.GetMappingProperty(new Field(GetObjectPath(p => p.Field4))); 138 | Assert.IsType(field4ExpressionProperty); 139 | 140 | var field4AliasMapping = resolver.GetMapping("Field4Alias", true); 141 | Assert.IsType(field4AliasMapping.Property); 142 | Assert.Same(field4Property, field4AliasMapping.Property); 143 | 144 | string field4sort = resolver.GetSortFieldName("Field4Alias"); 145 | Assert.Equal("field4.sort", field4sort); 146 | 147 | string field4aggs = resolver.GetAggregationsFieldName("Field4Alias"); 148 | Assert.Equal("field4.keyword", field4aggs); 149 | 150 | var nestedIdProperty = resolver.GetMappingProperty("Nested.Id"); 151 | Assert.IsType(nestedIdProperty); 152 | 153 | string nestedId = resolver.GetResolvedField("Nested.Id"); 154 | Assert.Equal("nested.id", nestedId); 155 | 156 | nestedIdProperty = resolver.GetMappingProperty("nested.id"); 157 | Assert.IsType(nestedIdProperty); 158 | 159 | var nestedField1Property = resolver.GetMappingProperty("Nested.Field1"); 160 | Assert.IsType(nestedField1Property); 161 | 162 | nestedField1Property = resolver.GetMappingProperty("nEsted.fieLD1"); 163 | Assert.IsType(nestedField1Property); 164 | 165 | var nestedField2Property = resolver.GetMappingProperty("Nested.Field4"); 166 | Assert.IsType(nestedField2Property); 167 | 168 | var nestedField5Property = resolver.GetMappingProperty("Nested.Field5"); 169 | Assert.IsType(nestedField5Property); 170 | 171 | var nestedDataProperty = resolver.GetMappingProperty("Nested.Data"); 172 | Assert.IsType(nestedDataProperty); 173 | } 174 | 175 | private static Expression GetObjectPath(Expression> objectPath) 176 | { 177 | return objectPath; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.ElasticQueries.Tests/Foundatio.Parsers.ElasticQueries.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | docker-compose.yml 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.ElasticQueries.Tests/InvertQueryTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Foundatio.Parsers.LuceneQueries; 5 | using Foundatio.Parsers.LuceneQueries.Extensions; 6 | using Foundatio.Parsers.LuceneQueries.Nodes; 7 | using Foundatio.Parsers.LuceneQueries.Visitors; 8 | using Microsoft.Extensions.Logging; 9 | using Nest; 10 | using Xunit; 11 | using Xunit.Abstractions; 12 | 13 | namespace Foundatio.Parsers.ElasticQueries.Tests; 14 | 15 | public class InvertQueryTests : ElasticsearchTestBase 16 | { 17 | public const string OrgId = "1"; 18 | public const string AltOrgId = "2"; 19 | 20 | public InvertQueryTests(ITestOutputHelper output, SampleDataFixture fixture) : base(output, fixture) { } 21 | 22 | [Fact] 23 | public Task CanInvertTermQuery() 24 | { 25 | return InvertAndValidateQuery("deleted", "(NOT deleted)", null, true); 26 | } 27 | 28 | [Fact] 29 | public Task CanInvertFieldQuery() 30 | { 31 | return InvertAndValidateQuery("status:open", "(NOT status:open)", null, true); 32 | } 33 | 34 | [Fact] 35 | public Task CanInvertNotFieldQuery() 36 | { 37 | return InvertAndValidateQuery("NOT status:open", "status:open", null, true); 38 | } 39 | 40 | [Fact] 41 | public Task CanInvertMultipleTermsQuery() 42 | { 43 | return InvertAndValidateQuery("field1:value field2:value field3:value", "(NOT (field1:value field2:value field3:value))", null, true); 44 | } 45 | 46 | [Fact] 47 | public Task CanInvertOrGroupQuery() 48 | { 49 | return InvertAndValidateQuery("(field1:value OR field2:value)", "(NOT (field1:value OR field2:value))", null, true); 50 | } 51 | 52 | [Fact] 53 | public Task CanInvertFieldWithNonInvertedFieldQuery() 54 | { 55 | return InvertAndValidateQuery("field:value organizationId:value", "(NOT field:value) organizationId:value", null, true); 56 | } 57 | 58 | [Fact] 59 | public Task CanInvertAlternateCriteria() 60 | { 61 | return InvertAndValidateQuery("value", "(is_deleted:true OR (NOT value))", "is_deleted:true", true); 62 | } 63 | 64 | [Fact] 65 | public Task CanInvertAlternateCriteriaAndNonInvertedField() 66 | { 67 | return InvertAndValidateQuery("organizationId:value field1:value", "organizationId:value (is_deleted:true OR (NOT field1:value))", "is_deleted:true", true); 68 | } 69 | 70 | [Fact] 71 | public Task CanInvertNonInvertedFieldAndOrGroup() 72 | { 73 | return InvertAndValidateQuery("organizationId:value (field1:value OR field2:value)", "organizationId:value (NOT (field1:value OR field2:value))", null, true); 74 | } 75 | 76 | [Fact] 77 | public Task CanInvertAlternateCriteriaAndNonInvertedFieldAndOrGroup() 78 | { 79 | return InvertAndValidateQuery("organizationId:value (field1:value OR field2:value)", "organizationId:value (is_deleted:true OR (NOT (field1:value OR field2:value)))", "is_deleted:true", true); 80 | } 81 | 82 | [Fact] 83 | public Task CanInvertGroupNonInvertedField() 84 | { 85 | return InvertAndValidateQuery("(field1:value organizationId:value) field2:value", "((NOT field1:value) organizationId:value) (NOT field2:value)", null, true); 86 | } 87 | 88 | private async Task InvertAndValidateQuery(string query, string expected, string alternateInvertedCriteria, bool isValid) 89 | { 90 | var parser = new LuceneQueryParser(); 91 | 92 | IQueryNode result; 93 | try 94 | { 95 | result = await parser.ParseAsync(query); 96 | } 97 | catch (FormatException ex) 98 | { 99 | Assert.False(isValid, ex.Message); 100 | return; 101 | } 102 | 103 | var invertQueryVisitor = new InvertQueryVisitor(["organizationId"]); 104 | var context = new QueryVisitorContext(); 105 | 106 | if (!String.IsNullOrWhiteSpace(alternateInvertedCriteria)) 107 | { 108 | var invertedAlternate = await parser.ParseAsync(alternateInvertedCriteria); 109 | context.SetAlternateInvertedCriteria(invertedAlternate); 110 | } 111 | 112 | result = await invertQueryVisitor.AcceptAsync(result, context); 113 | string invertedQuery = result.ToString(); 114 | string nodes = await DebugQueryVisitor.RunAsync(result); 115 | _logger.LogInformation("{Result}", nodes); 116 | Assert.Equal(expected, invertedQuery); 117 | 118 | var total = await Client.CountAsync(); 119 | var results = await Client.SearchAsync(s => s.QueryOnQueryString(query).TrackTotalHits(true)); 120 | var invertedResults = await Client.SearchAsync(s => s.QueryOnQueryString(invertedQuery).TrackTotalHits(true)); 121 | 122 | Assert.Equal(total.Count, results.Total + invertedResults.Total); 123 | } 124 | } 125 | 126 | public class InvertTest 127 | { 128 | public const string OrgId = "1"; 129 | public const string AltOrgId = "2"; 130 | 131 | public string Id { get; set; } 132 | public string OrganizationId { get; set; } 133 | public string Description { get; set; } 134 | public string Status { get; set; } = "open"; 135 | public bool IsDeleted { get; set; } 136 | } 137 | 138 | public class SampleDataFixture : ElasticsearchFixture 139 | { 140 | public override async Task InitializeAsync() 141 | { 142 | await base.InitializeAsync(); 143 | 144 | const string indexName = "test_invert"; 145 | CreateNamedIndex(indexName, m => m 146 | .Properties(p => p 147 | .Keyword(p1 => p1.Name(n => n.Id)) 148 | .Keyword(p1 => p1.Name(n => n.OrganizationId)) 149 | .Text(p1 => p1.Name(n => n.Description)) 150 | .Keyword(p1 => p1.Name(n => n.Status)) 151 | .Boolean(p1 => p1.Name(n => n.IsDeleted)) 152 | )); 153 | 154 | var records = new List(); 155 | int id = 1; 156 | 157 | for (int i = 0; i < 10000; i++, id++) 158 | { 159 | records.Add(new InvertTest { Id = id.ToString(), OrganizationId = InvertTest.OrgId, Description = $"Description {i}", Status = "open", IsDeleted = false }); 160 | } 161 | for (int i = 0; i < 1000; i++, id++) 162 | { 163 | records.Add(new InvertTest { Id = id.ToString(), OrganizationId = InvertTest.OrgId, Description = $"Deleted Description {i}", Status = "open", IsDeleted = true }); 164 | } 165 | for (int i = 0; i < 100; i++, id++) 166 | { 167 | records.Add(new InvertTest { Id = id.ToString(), OrganizationId = InvertTest.OrgId, Description = $"Regressed Description {i}", Status = "regressed", IsDeleted = false }); 168 | } 169 | for (int i = 0; i < 100; i++, id++) 170 | { 171 | records.Add(new InvertTest { Id = id.ToString(), OrganizationId = InvertTest.OrgId, Description = $"Ignored Description {i}", Status = "ignored", IsDeleted = false }); 172 | } 173 | 174 | for (int i = 0; i < 10000; i++, id++) 175 | { 176 | records.Add(new InvertTest { Id = id.ToString(), OrganizationId = InvertTest.AltOrgId, Description = $"Alt Description {i}", Status = "open", IsDeleted = false }); 177 | } 178 | for (int i = 0; i < 1000; i++, id++) 179 | { 180 | records.Add(new InvertTest { Id = id.ToString(), OrganizationId = InvertTest.AltOrgId, Description = $"Deleted Alt Description {i}", Status = "open", IsDeleted = true }); 181 | } 182 | for (int i = 0; i < 100; i++, id++) 183 | { 184 | records.Add(new InvertTest { Id = id.ToString(), OrganizationId = InvertTest.AltOrgId, Description = $"Regressed Alt Description {i}", Status = "regressed", IsDeleted = false }); 185 | } 186 | for (int i = 0; i < 100; i++, id++) 187 | { 188 | records.Add(new InvertTest { Id = id.ToString(), OrganizationId = InvertTest.AltOrgId, Description = $"Ignored Alt Description {i}", Status = "ignored", IsDeleted = false }); 189 | } 190 | 191 | await Client.IndexManyAsync(records, indexName); 192 | 193 | await Client.Indices.RefreshAsync(indexName); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.ElasticQueries.Tests/Utility/ElasticsearchTestBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Foundatio.Parsers.ElasticQueries.Extensions; 6 | using Foundatio.Xunit; 7 | using Microsoft.Extensions.Logging; 8 | using Nest; 9 | using Xunit; 10 | using Xunit.Abstractions; 11 | 12 | namespace Foundatio.Parsers.ElasticQueries.Tests; 13 | 14 | public abstract class ElasticsearchTestBase : ElasticsearchTestBase 15 | { 16 | protected ElasticsearchTestBase(ITestOutputHelper output, ElasticsearchFixture fixture) : base(output, fixture) { } 17 | } 18 | 19 | public abstract class ElasticsearchTestBase : TestWithLoggingBase, IAsyncLifetime, IClassFixture where T : ElasticsearchFixture 20 | { 21 | private readonly T _fixture; 22 | 23 | public ElasticsearchTestBase(ITestOutputHelper output, T fixture) : base(output) 24 | { 25 | _fixture = fixture; 26 | _fixture.Log = Log; 27 | } 28 | 29 | protected IElasticClient Client => _fixture.Client; 30 | 31 | protected void CreateNamedIndex(string index, Func, ITypeMapping> configureMappings = null, Func> configureIndex = null) where TModel : class 32 | { 33 | _fixture.CreateNamedIndex(index, configureMappings, configureIndex); 34 | } 35 | 36 | protected string CreateRandomIndex(Func, ITypeMapping> configureMappings = null, Func> configureIndex = null) where TModel : class 37 | { 38 | return _fixture.CreateRandomIndex(configureMappings, configureIndex); 39 | } 40 | 41 | protected CreateIndexResponse CreateIndex(IndexName index, Func configureIndex = null) 42 | { 43 | return _fixture.CreateIndex(index, configureIndex); 44 | } 45 | 46 | /// 47 | /// per test setup 48 | /// 49 | public virtual Task InitializeAsync() 50 | { 51 | return Task.CompletedTask; 52 | } 53 | 54 | /// 55 | /// per test tear down 56 | /// 57 | public virtual Task DisposeAsync() 58 | { 59 | return Task.CompletedTask; 60 | } 61 | } 62 | 63 | public class ElasticsearchFixture : IAsyncLifetime 64 | { 65 | private readonly List _createdIndexes = new(); 66 | private static bool _elaticsearchReady; 67 | protected readonly ILogger _logger; 68 | private readonly Lazy _client; 69 | 70 | public ElasticsearchFixture() 71 | { 72 | _client = new Lazy(() => GetClient(ConfigureConnectionSettings)); 73 | } 74 | 75 | public TestLogger Log { get; set; } 76 | public IElasticClient Client => _client.Value; 77 | 78 | protected IElasticClient GetClient(Action configure = null) 79 | { 80 | string elasticsearchUrl = Environment.GetEnvironmentVariable("ELASTICSEARCH_URL") ?? "http://localhost:9200"; 81 | var settings = new ConnectionSettings(new Uri(elasticsearchUrl)); 82 | configure?.Invoke(settings); 83 | 84 | var client = new ElasticClient(settings.DisableDirectStreaming().PrettyJson()); 85 | 86 | if (!_elaticsearchReady) 87 | { 88 | if (!client.WaitForReady(new CancellationTokenSource(TimeSpan.FromMinutes(1)).Token, _logger)) 89 | throw new ApplicationException("Unable to connect to Elasticsearch."); 90 | 91 | _elaticsearchReady = true; 92 | } 93 | 94 | return client; 95 | } 96 | 97 | protected virtual void ConfigureConnectionSettings(ConnectionSettings settings) { } 98 | 99 | public void CreateNamedIndex(string index, Func, ITypeMapping> configureMappings = null, Func> configureIndex = null) where T : class 100 | { 101 | if (configureMappings == null) 102 | configureMappings = m => m.AutoMap().Dynamic(); 103 | if (configureIndex == null) 104 | configureIndex = i => i.NumberOfReplicas(0).Analysis(a => a.AddSortNormalizer()); 105 | 106 | CreateIndex(index, i => i.Settings(configureIndex).Map(configureMappings)); 107 | Client.ConnectionSettings.DefaultIndices[typeof(T)] = index; 108 | } 109 | 110 | public string CreateRandomIndex(Func, ITypeMapping> configureMappings = null, Func> configureIndex = null) where T : class 111 | { 112 | string index = "test_" + Guid.NewGuid().ToString("N"); 113 | if (configureMappings == null) 114 | configureMappings = m => m.AutoMap().Dynamic(); 115 | if (configureIndex == null) 116 | configureIndex = i => i.NumberOfReplicas(0).Analysis(a => a.AddSortNormalizer()); 117 | 118 | CreateIndex(index, i => i.Settings(configureIndex).Map(configureMappings)); 119 | Client.ConnectionSettings.DefaultIndices[typeof(T)] = index; 120 | 121 | return index; 122 | } 123 | 124 | public CreateIndexResponse CreateIndex(IndexName index, Func configureIndex = null) 125 | { 126 | _createdIndexes.Add(index); 127 | 128 | if (configureIndex == null) 129 | configureIndex = d => d.Settings(s => s.NumberOfReplicas(0)); 130 | 131 | var result = Client.Indices.Create(index, configureIndex); 132 | if (!result.IsValid) 133 | throw new ApplicationException($"Unable to create index {index}: " + result.DebugInformation); 134 | 135 | return result; 136 | } 137 | 138 | protected virtual void CleanupTestIndexes(IElasticClient client) 139 | { 140 | if (_createdIndexes.Count > 0) 141 | client.Indices.Delete(Indices.Index(_createdIndexes)); 142 | } 143 | 144 | public virtual Task InitializeAsync() 145 | { 146 | return Task.CompletedTask; 147 | } 148 | 149 | public virtual Task DisposeAsync() 150 | { 151 | CleanupTestIndexes(Client); 152 | return Task.CompletedTask; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.LuceneQueries.Tests/CleanupQueryVisitorTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Foundatio.Parsers.LuceneQueries.Nodes; 4 | using Foundatio.Parsers.LuceneQueries.Visitors; 5 | using Foundatio.Xunit; 6 | using Microsoft.Extensions.Logging; 7 | using Pegasus.Common.Tracing; 8 | using Xunit; 9 | using Xunit.Abstractions; 10 | 11 | namespace Foundatio.Parsers.LuceneQueries.Tests; 12 | 13 | public class CleanupQueryVisitorTests : TestWithLoggingBase 14 | { 15 | public CleanupQueryVisitorTests(ITestOutputHelper output) : base(output) 16 | { 17 | Log.DefaultMinimumLevel = LogLevel.Trace; 18 | } 19 | 20 | [Theory] 21 | [InlineData("value", "value")] 22 | [InlineData("(value)", "value")] 23 | [InlineData("((value))", "value")] 24 | [InlineData("(((value)))", "value")] 25 | [InlineData("((value ) )", "value")] 26 | [InlineData("test:(value)", "test:(value)")] 27 | [InlineData("test:((value))", "test:(value)")] 28 | [InlineData("NOT value", "NOT value")] 29 | [InlineData("NOT (value)", "NOT value")] 30 | [InlineData("NOT (status:fixed)", "NOT status:fixed")] 31 | [InlineData("project:123 NOT (status:open OR status:regressed)", "project:123 NOT (status:open OR status:regressed)")] 32 | public Task CanCleanupQuery(string query, string expected) 33 | { 34 | return CleanupAndValidateQuery(query, expected, true); 35 | } 36 | 37 | private async Task CleanupAndValidateQuery(string query, string expected, bool isValid) 38 | { 39 | #if ENABLE_TRACING 40 | var tracer = new LoggingTracer(_logger, reportPerformance: true); 41 | #else 42 | var tracer = NullTracer.Instance; 43 | #endif 44 | var parser = new LuceneQueryParser 45 | { 46 | Tracer = tracer 47 | }; 48 | 49 | IQueryNode result; 50 | try 51 | { 52 | result = await parser.ParseAsync(query); 53 | } 54 | catch (FormatException ex) 55 | { 56 | Assert.False(isValid, ex.Message); 57 | return; 58 | } 59 | 60 | string cleanedQuery = await CleanupQueryVisitor.RunAsync(result); 61 | string nodes = await DebugQueryVisitor.RunAsync(result); 62 | _logger.LogInformation("{Result}", nodes); 63 | Assert.Equal(expected, cleanedQuery); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.LuceneQueries.Tests/Foundatio.Parsers.LuceneQueries.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.LuceneQueries.Tests/GenerateQueryVisitorTests.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Foundatio.Parsers.LuceneQueries.Nodes; 3 | using Foundatio.Parsers.LuceneQueries.Visitors; 4 | using Foundatio.Xunit; 5 | using Microsoft.Extensions.Logging; 6 | using Pegasus.Common.Tracing; 7 | using Xunit; 8 | using Xunit.Abstractions; 9 | 10 | namespace Foundatio.Parsers.LuceneQueries.Tests; 11 | 12 | public class GenerateQueryVisitorTests : TestWithLoggingBase 13 | { 14 | public GenerateQueryVisitorTests(ITestOutputHelper output) : base(output) 15 | { 16 | Log.DefaultMinimumLevel = LogLevel.Trace; 17 | } 18 | 19 | [Theory] 20 | [InlineData("value1 value2", GroupOperator.Default, "value1 value2")] 21 | [InlineData("value1 value2", GroupOperator.And, "value1 AND value2")] 22 | [InlineData("value1 value2", GroupOperator.Or, "value1 OR value2")] 23 | [InlineData("value1 value2 value3", GroupOperator.Default, "value1 value2 value3")] 24 | [InlineData("value1 value2 value3", GroupOperator.And, "value1 AND value2 AND value3")] 25 | [InlineData("value1 value2 value3", GroupOperator.Or, "value1 OR value2 OR value3")] 26 | [InlineData("value1 value2 value3 value4", GroupOperator.And, "value1 AND value2 AND value3 AND value4")] 27 | [InlineData("(value1 value2) OR (value3 value4)", GroupOperator.And, "(value1 AND value2) OR (value3 AND value4)")] 28 | public Task DefaultOperatorApplied(string query, GroupOperator groupOperator, string expected) 29 | { 30 | return GenerateQuery(query, groupOperator, expected); 31 | } 32 | 33 | private async Task GenerateQuery(string query, GroupOperator defaultOperator, string expected) 34 | { 35 | #if ENABLE_TRACING 36 | var tracer = new LoggingTracer(_logger, reportPerformance: true); 37 | #else 38 | var tracer = NullTracer.Instance; 39 | #endif 40 | var parser = new LuceneQueryParser 41 | { 42 | Tracer = tracer 43 | }; 44 | 45 | IQueryNode parsedQuery = await parser.ParseAsync(query); 46 | 47 | var context = new QueryVisitorContext { DefaultOperator = defaultOperator }; 48 | string result = await GenerateQueryVisitor.RunAsync(parsedQuery, context); 49 | string nodes = await DebugQueryVisitor.RunAsync(parsedQuery); 50 | _logger.LogInformation("{Result}", nodes); 51 | Assert.Equal(expected, result); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.LuceneQueries.Tests/IncludeQueryVisitorTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Foundatio.Parsers.LuceneQueries.Extensions; 5 | using Foundatio.Parsers.LuceneQueries.Nodes; 6 | using Foundatio.Parsers.LuceneQueries.Visitors; 7 | using Foundatio.Xunit; 8 | using Xunit; 9 | using Xunit.Abstractions; 10 | 11 | namespace Foundatio.Parsers.LuceneQueries.Tests; 12 | 13 | public class IncludeQueryVisitorTests : TestWithLoggingBase 14 | { 15 | public IncludeQueryVisitorTests(ITestOutputHelper output) : base(output) 16 | { 17 | Log.DefaultMinimumLevel = Microsoft.Extensions.Logging.LogLevel.Trace; 18 | } 19 | 20 | [Fact] 21 | public async Task CanExpandIncludesAsync() 22 | { 23 | var parser = new LuceneQueryParser(); 24 | var result = await parser.ParseAsync("@include:other"); 25 | var includes = new Dictionary { { "other", "field:value" } }; 26 | var resolved = await IncludeVisitor.RunAsync(result, includes); 27 | Assert.Equal("(field:value)", resolved.ToString()); 28 | } 29 | 30 | [Fact] 31 | public async Task CanSkipIncludes() 32 | { 33 | var parser = new LuceneQueryParser(); 34 | var result = await parser.ParseAsync("outter @include:other @skipped:(other stuff @include:other)"); 35 | var includes = new Dictionary { 36 | { "other", "field:value" }, 37 | { "nested", "field:value @include:other" } 38 | }; 39 | var resolved = await IncludeVisitor.RunAsync(result, includes, shouldSkipInclude: (_, _) => true); 40 | Assert.Equal("outter @include:other @skipped:(other stuff @include:other)", resolved.ToString()); 41 | 42 | resolved = await IncludeVisitor.RunAsync(result, includes, shouldSkipInclude: ShouldSkipInclude); 43 | Assert.Equal("outter (field:value) @skipped:(other stuff @include:other)", resolved.ToString()); 44 | 45 | resolved = await IncludeVisitor.RunAsync(result, includes, shouldSkipInclude: (n, ctx) => !ShouldSkipInclude(n, ctx)); 46 | Assert.Equal("outter (field:value) @skipped:(other stuff (field:value))", resolved.ToString()); 47 | 48 | var nestedResult = await parser.ParseAsync("outter @skipped:(other stuff @include:nested)"); 49 | resolved = await IncludeVisitor.RunAsync(nestedResult, includes, shouldSkipInclude: ShouldSkipInclude); 50 | Assert.Equal("outter @skipped:(other stuff @include:nested)", resolved.ToString()); 51 | } 52 | 53 | private bool ShouldSkipInclude(TermNode node, IQueryVisitorContext context) 54 | { 55 | var current = node.Parent; 56 | while (current != null) 57 | { 58 | if (current is GroupNode groupNode && groupNode.Field == "@skipped") 59 | return true; 60 | 61 | current = current.Parent; 62 | } 63 | 64 | return false; 65 | } 66 | 67 | [Fact] 68 | public async Task CanHandleRecursiveInclude() 69 | { 70 | var parser = new LuceneQueryParser(); 71 | var result = await parser.ParseAsync("field1:value1 @include:include1"); 72 | var includes = new Dictionary { 73 | { "include1", "@include:include2" }, 74 | { "include2", "@include:include1" } 75 | }; 76 | 77 | var context = new QueryVisitorContext(); 78 | var resolved = await IncludeVisitor.RunAsync(result, includes, context); 79 | var validationResult = context.GetValidationResult(); 80 | Assert.False(validationResult.IsValid); 81 | Assert.Contains("Recursive", validationResult.Message); 82 | Assert.Contains("include1", validationResult.Message); 83 | } 84 | 85 | [Fact] 86 | public async Task CanHandleUnresolvedIncludes() 87 | { 88 | var parser = new LuceneQueryParser(); 89 | var result = await parser.ParseAsync("field1:value1 @include:include1"); 90 | var includes = new Dictionary { 91 | { "include2", "field1:value1" } 92 | }; 93 | 94 | var context = new QueryVisitorContext(); 95 | var resolved = await IncludeVisitor.RunAsync(result, includes, context); 96 | var validationResult = context.GetValidationResult(); 97 | Assert.Contains("include1", validationResult.UnresolvedIncludes); 98 | Assert.False(validationResult.IsValid); 99 | Assert.Contains("Unresolved", validationResult.Message); 100 | Assert.Contains("include1", validationResult.Message); 101 | } 102 | 103 | [Fact] 104 | public async Task CanHandleIncludeResolverError() 105 | { 106 | var parser = new LuceneQueryParser(); 107 | var result = await parser.ParseAsync("field1:value1 @include:include1"); 108 | 109 | var context = new QueryVisitorContext(); 110 | var resolved = await IncludeVisitor.RunAsync(result, _ => throw new ApplicationException("Bam"), context); 111 | var validationResult = context.GetValidationResult(); 112 | Assert.Contains("include1", validationResult.UnresolvedIncludes); 113 | Assert.False(validationResult.IsValid); 114 | Assert.Contains("Error in include resolver", validationResult.Message); 115 | Assert.Contains("include1", validationResult.Message); 116 | } 117 | 118 | [Fact] 119 | public async Task CanHandleIncludeUsedMultipleTimes() 120 | { 121 | var parser = new LuceneQueryParser(); 122 | var result = await parser.ParseAsync("field1:value1 @include:include1 @include:include1"); 123 | var includes = new Dictionary { 124 | { "include1", "field1:value2" } 125 | }; 126 | 127 | var context = new QueryVisitorContext(); 128 | var resolved = await IncludeVisitor.RunAsync(result, includes, context); 129 | Assert.True(context.IsValid()); 130 | } 131 | 132 | [Fact] 133 | public async Task CanExpandIncludesWithOtherCriteriaAsync() 134 | { 135 | var parser = new LuceneQueryParser(); 136 | var result = await parser.ParseAsync("field1:value1 @include:other"); 137 | var includes = new Dictionary { { "other", "field:value" } }; 138 | var resolved = await IncludeVisitor.RunAsync(result, includes); 139 | Assert.Equal("field1:value1 (field:value)", resolved.ToString()); 140 | } 141 | 142 | [Fact] 143 | public async Task CanExpandIncludesWithOtherCriteriaAndGroupingAsync() 144 | { 145 | var parser = new LuceneQueryParser(); 146 | var result = await parser.ParseAsync("field1:value1 OR (@include:other field2:value2)"); 147 | var includes = new Dictionary { { "other", "field:value" } }; 148 | var resolved = await IncludeVisitor.RunAsync(result, includes); 149 | Assert.Equal("field1:value1 OR ((field:value) field2:value2)", resolved.ToString()); 150 | } 151 | 152 | [Fact] 153 | public async Task CanExpandNestedIncludesAsync() 154 | { 155 | var parser = new LuceneQueryParser(); 156 | var result = await parser.ParseAsync("@include:other"); 157 | var includes = new Dictionary { 158 | { "other", "@include:other2" }, 159 | { "other2", "field2:value2" } 160 | }; 161 | var resolved = await IncludeVisitor.RunAsync(result, includes); 162 | Assert.Equal("((field2:value2))", resolved.ToString()); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.LuceneQueries.Tests/InvertQueryVisitorTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Foundatio.Parsers.LuceneQueries.Extensions; 4 | using Foundatio.Parsers.LuceneQueries.Nodes; 5 | using Foundatio.Parsers.LuceneQueries.Visitors; 6 | using Foundatio.Xunit; 7 | using Microsoft.Extensions.Logging; 8 | using Pegasus.Common.Tracing; 9 | using Xunit; 10 | using Xunit.Abstractions; 11 | 12 | namespace Foundatio.Parsers.LuceneQueries.Tests; 13 | 14 | public class InvertQueryVisitorTests : TestWithLoggingBase 15 | { 16 | public InvertQueryVisitorTests(ITestOutputHelper output) : base(output) 17 | { 18 | Log.DefaultMinimumLevel = LogLevel.Trace; 19 | } 20 | 21 | [Fact] 22 | public Task CanInvertTermQuery() 23 | { 24 | return InvertAndValidateQuery("value", "(NOT value)", null, true); 25 | } 26 | 27 | [Fact] 28 | public Task CanInvertFieldQuery() 29 | { 30 | return InvertAndValidateQuery("field:value", "(NOT field:value)", null, true); 31 | } 32 | 33 | [Fact] 34 | public Task CanInvertNotFieldQuery() 35 | { 36 | return InvertAndValidateQuery("NOT field:value", "field:value", null, true); 37 | } 38 | 39 | [Fact] 40 | public Task CanInvertMultipleTermsQuery() 41 | { 42 | return InvertAndValidateQuery("field1:value field2:value field3:value", "(NOT (field1:value field2:value field3:value))", null, true); 43 | } 44 | 45 | [Fact] 46 | public Task CanInvertOrGroupQuery() 47 | { 48 | return InvertAndValidateQuery("(field1:value OR field2:value)", "(NOT (field1:value OR field2:value))", null, true); 49 | } 50 | 51 | [Fact] 52 | public Task CanInvertFieldWithNonInvertedFieldQuery() 53 | { 54 | return InvertAndValidateQuery("field:value noninvertedfield1:value", "(NOT field:value) noninvertedfield1:value", null, true); 55 | } 56 | 57 | [Fact] 58 | public Task CanInvertAlternateCriteria() 59 | { 60 | return InvertAndValidateQuery("value", "(is_deleted:true OR (NOT value))", "is_deleted:true", true); 61 | } 62 | 63 | [Fact] 64 | public Task CanInvertAlternateCriteriaAndNonInvertedField() 65 | { 66 | return InvertAndValidateQuery("noninvertedfield1:value field1:value", "noninvertedfield1:value (is_deleted:true OR (NOT field1:value))", "is_deleted:true", true); 67 | } 68 | 69 | [Fact] 70 | public Task CanInvertNonInvertedFieldAndOrGroup() 71 | { 72 | return InvertAndValidateQuery("noninvertedfield1:value (field1:value OR field2:value)", "noninvertedfield1:value (NOT (field1:value OR field2:value))", null, true); 73 | } 74 | 75 | [Fact] 76 | public Task CanInvertAlternateCriteriaAndNonInvertedFieldAndOrGroup() 77 | { 78 | return InvertAndValidateQuery("noninvertedfield1:value (field1:value OR field2:value)", "noninvertedfield1:value (is_deleted:true OR (NOT (field1:value OR field2:value)))", "is_deleted:true", true); 79 | } 80 | 81 | [Fact] 82 | public Task CanInvertGroupNonInvertedField() 83 | { 84 | return InvertAndValidateQuery("(field1:value noninvertedfield1:value) field2:value", "((NOT field1:value) noninvertedfield1:value) (NOT field2:value)", null, true); 85 | } 86 | 87 | [Theory] 88 | [InlineData("noninvertedfield1:value", "noninvertedfield1:value", "is_deleted:true")] 89 | [InlineData("noninvertedfield1:value1 OR noninvertedfield1:value2", "noninvertedfield1:value1 OR noninvertedfield1:value2", "is_deleted:true")] 90 | [InlineData("(noninvertedfield1:value)", "(noninvertedfield1:value)", "is_deleted:true")] 91 | [InlineData("NOT status:fixed", "status:fixed")] 92 | [InlineData("field:value", "(NOT field:value)")] 93 | [InlineData("-field:value", "field:value")] 94 | [InlineData("status:open OR status:regressed", "(NOT (status:open OR status:regressed))")] 95 | [InlineData("(noninvertedfield1:value AND (noninvertedfield2:value)) field1:value", "(noninvertedfield1:value AND (noninvertedfield2:value)) (NOT field1:value)")] 96 | [InlineData("field1:value noninvertedfield1:value", "(NOT field1:value) noninvertedfield1:value")] 97 | [InlineData("field1:value noninvertedfield1:value field2:value", "(NOT field1:value) noninvertedfield1:value (NOT field2:value)")] 98 | [InlineData("field1:value field2:value field3:value", "(NOT (field1:value field2:value field3:value))")] 99 | [InlineData("noninvertedfield1:value field1:value field2:value field3:value", "noninvertedfield1:value (NOT (field1:value field2:value field3:value))")] 100 | [InlineData("noninvertedfield1:value field1:value field2:value field3:value", "noninvertedfield1:value (is_deleted:true OR (NOT (field1:value field2:value field3:value)))", "is_deleted:true")] 101 | [InlineData("noninvertedfield1:123 (status:open OR status:regressed) noninvertedfield1:234", "noninvertedfield1:123 (NOT (status:open OR status:regressed)) noninvertedfield1:234")] 102 | [InlineData("first_occurrence:[1609459200000 TO 1609730450521] (noninvertedfield1:537650f3b77efe23a47914f4 (status:open OR status:regressed))", "(NOT first_occurrence:[1609459200000 TO 1609730450521]) (noninvertedfield1:537650f3b77efe23a47914f4 (NOT (status:open OR status:regressed)))")] 103 | public Task CanInvertQuery(string query, string expected, string alternateInvertedCriteria = null) 104 | { 105 | return InvertAndValidateQuery(query, expected, alternateInvertedCriteria, true); 106 | } 107 | 108 | private async Task InvertAndValidateQuery(string query, string expected, string alternateInvertedCriteria, bool isValid) 109 | { 110 | #if ENABLE_TRACING 111 | var tracer = new LoggingTracer(_logger, reportPerformance: true); 112 | #else 113 | var tracer = NullTracer.Instance; 114 | #endif 115 | var parser = new LuceneQueryParser 116 | { 117 | Tracer = tracer 118 | }; 119 | 120 | IQueryNode result; 121 | try 122 | { 123 | result = await parser.ParseAsync(query); 124 | } 125 | catch (FormatException ex) 126 | { 127 | Assert.False(isValid, ex.Message); 128 | return; 129 | } 130 | 131 | var invertQueryVisitor = new InvertQueryVisitor(["noninvertedfield1", "noninvertedfield2"]); 132 | var context = new QueryVisitorContext(); 133 | 134 | if (!String.IsNullOrWhiteSpace(alternateInvertedCriteria)) 135 | { 136 | var invertedAlternate = await parser.ParseAsync(alternateInvertedCriteria); 137 | context.SetAlternateInvertedCriteria(invertedAlternate); 138 | } 139 | 140 | result = await invertQueryVisitor.AcceptAsync(result, context); 141 | string invertedQuery = result.ToString(); 142 | string nodes = await DebugQueryVisitor.RunAsync(result); 143 | _logger.LogInformation("{Result}", nodes); 144 | Assert.Equal(expected, invertedQuery); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.LuceneQueries.Tests/QueryValidatorTests.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Foundatio.Parsers.LuceneQueries.Extensions; 3 | using Foundatio.Parsers.LuceneQueries.Visitors; 4 | using Foundatio.Xunit; 5 | using Microsoft.Extensions.Logging; 6 | using Xunit; 7 | using Xunit.Abstractions; 8 | 9 | namespace Foundatio.Parsers.LuceneQueries.Tests; 10 | 11 | [Trait("TestType", "Unit")] 12 | public class QueryValidatorTests : TestWithLoggingBase 13 | { 14 | public QueryValidatorTests(ITestOutputHelper output) : base(output) 15 | { 16 | Log.DefaultMinimumLevel = LogLevel.Trace; 17 | } 18 | 19 | [Fact] 20 | public async Task InvalidSyntax() 21 | { 22 | var info = await QueryValidator.ValidateQueryAsync(@":"); 23 | Assert.False(info.IsValid); 24 | Assert.NotNull(info.Message); 25 | Assert.Contains("Unexpected", info.Message); 26 | } 27 | 28 | [Fact] 29 | public async Task ThrowInvalidSyntax() 30 | { 31 | var ex = await Assert.ThrowsAsync(() => QueryValidator.ValidateQueryAndThrowAsync(@":")); 32 | Assert.Contains("Unexpected", ex.Message); 33 | Assert.False(ex.Result.IsValid); 34 | Assert.NotNull(ex.Result.Message); 35 | Assert.Contains("Unexpected", ex.Result.Message); 36 | } 37 | 38 | [Fact] 39 | public async Task AllowedFields() 40 | { 41 | var options = new QueryValidationOptions(); 42 | options.AllowedFields.Add("allowedfield"); 43 | var info = await QueryValidator.ValidateQueryAsync(@"blah allowedfield:value", options); 44 | Assert.True(info.IsValid); 45 | } 46 | 47 | [Fact] 48 | public async Task AllowedFieldsWithAliases() 49 | { 50 | var options = new QueryValidationOptions(); 51 | options.AllowedFields.Add("allowedfield"); 52 | var context = new QueryVisitorContext(); 53 | var aliasMap = new FieldMap { { "allowedfield", "idx1" } }; 54 | context.SetFieldResolver(aliasMap.ToHierarchicalFieldResolver()); 55 | 56 | var info = await QueryValidator.ValidateQueryAsync(@"allowedfield:test", options, context); 57 | Assert.True(info.IsValid); 58 | 59 | // do not allow the resolved version in the query; allowed is an explicit list of fields 60 | info = await QueryValidator.ValidateQueryAsync(@"idx1:test", options, context); 61 | Assert.False(info.IsValid); 62 | } 63 | 64 | [Fact] 65 | public async Task RestrictedFields() 66 | { 67 | var options = new QueryValidationOptions(); 68 | options.RestrictedFields.Add("restrictedfield"); 69 | var info = await QueryValidator.ValidateQueryAsync(@"blah restrictedfield:value", options); 70 | Assert.False(info.IsValid); 71 | } 72 | 73 | [Fact] 74 | public async Task RestrictedFieldsWithAliases() 75 | { 76 | var options = new QueryValidationOptions(); 77 | options.RestrictedFields.Add("restrictedField"); 78 | var context = new QueryVisitorContext(); 79 | var aliasMap = new FieldMap { { "restrictedField", "idx1" } }; 80 | context.SetFieldResolver(aliasMap.ToHierarchicalFieldResolver()); 81 | var info = await QueryValidator.ValidateQueryAsync(@"restrictedField:test", options, context); 82 | Assert.False(info.IsValid); 83 | 84 | info = await QueryValidator.ValidateQueryAsync(@"idx1:test", options, context); 85 | Assert.False(info.IsValid); 86 | } 87 | 88 | [Fact] 89 | public async Task AllowLeadingWildcards() 90 | { 91 | var options = new QueryValidationOptions(); 92 | options.AllowLeadingWildcards = false; 93 | var info = await QueryValidator.ValidateQueryAsync(@"blah allowedfield:*alue", options); 94 | Assert.False(info.IsValid); 95 | Assert.Contains("wildcard", info.Message); 96 | 97 | options.AllowLeadingWildcards = true; 98 | info = await QueryValidator.ValidateQueryAsync(@"blah allowedfield:*alue", options); 99 | Assert.True(info.IsValid); 100 | } 101 | 102 | [Fact] 103 | public async Task AllowedOperations() 104 | { 105 | var options = new QueryValidationOptions(); 106 | options.AllowedOperations.Add("terms"); 107 | var info = await QueryValidator.ValidateAggregationsAsync(@"terms:blah", options); 108 | Assert.True(info.IsValid); 109 | } 110 | 111 | [Fact] 112 | public async Task NonAllowedOperations() 113 | { 114 | var options = new QueryValidationOptions(); 115 | options.AllowedOperations.Add("terms"); 116 | var info = await QueryValidator.ValidateAggregationsAsync(@"terms:blah notallowed:blah", options); 117 | Assert.False(info.IsValid); 118 | } 119 | 120 | [Fact] 121 | public async Task RestrictedOperations() 122 | { 123 | var options = new QueryValidationOptions(); 124 | options.RestrictedOperations.Add("terms"); 125 | var info = await QueryValidator.ValidateAggregationsAsync(@"terms:blah", options); 126 | Assert.False(info.IsValid); 127 | } 128 | 129 | [Fact] 130 | public async Task NonRestrictedOperations() 131 | { 132 | var options = new QueryValidationOptions(); 133 | options.RestrictedOperations.Add("terms"); 134 | var info = await QueryValidator.ValidateAggregationsAsync(@"sum:blah", options); 135 | Assert.True(info.IsValid); 136 | } 137 | 138 | [Fact] 139 | public async Task ResolvedFields() 140 | { 141 | var options = new QueryValidationOptions 142 | { 143 | AllowUnresolvedFields = false 144 | }; 145 | var context = new QueryVisitorContext(); 146 | context.SetFieldResolver(f => f == "field1" ? f : null); 147 | var info = await QueryValidator.ValidateQueryAsync(@"field1:blah", options, context); 148 | Assert.True(info.IsValid); 149 | } 150 | 151 | [Fact] 152 | public async Task NonResolvedFields() 153 | { 154 | var options = new QueryValidationOptions 155 | { 156 | AllowUnresolvedFields = false 157 | }; 158 | var context = new QueryVisitorContext(); 159 | context.SetFieldResolver(f => f == "field1" ? f : null); 160 | var info = await QueryValidator.ValidateQueryAsync(@"field1:blah field2:blah", options, context); 161 | Assert.False(info.IsValid); 162 | Assert.Contains("field2", info.UnresolvedFields); 163 | } 164 | 165 | [Fact] 166 | public async Task NonResolvedThrowsFields() 167 | { 168 | var options = new QueryValidationOptions 169 | { 170 | AllowUnresolvedFields = false 171 | }; 172 | var context = new QueryVisitorContext(); 173 | context.SetFieldResolver(f => f == "field1" ? f : null); 174 | var ex = await Assert.ThrowsAsync(() => QueryValidator.ValidateQueryAndThrowAsync(@"field1:blah field2:blah", options, context)); 175 | Assert.Contains("resolved", ex.Message); 176 | Assert.Contains("field2", ex.Result.UnresolvedFields); 177 | Assert.False(ex.Result.IsValid); 178 | Assert.NotNull(ex.Result.Message); 179 | Assert.Contains("resolved", ex.Result.Message); 180 | } 181 | 182 | [Fact] 183 | public void CanParseWildcardQuery() 184 | { 185 | var sut = new LuceneQueryParser(); 186 | var node = sut.Parse("*"); 187 | var result = ValidationVisitor.Run(node); 188 | Assert.True(result.IsValid); 189 | } 190 | 191 | // allowed fields 192 | // allowed operations 193 | // 194 | // as part of a chain of other visitors 195 | // add tests to elastic that make use of a resolver 196 | } 197 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.LuceneQueries.Tests/ReferencedFieldsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Foundatio.Parsers.LuceneQueries.Extensions; 3 | using Foundatio.Xunit; 4 | using Xunit; 5 | using Xunit.Abstractions; 6 | 7 | namespace Foundatio.Parsers.LuceneQueries.Tests; 8 | 9 | public class ReferencedFieldsTests : TestWithLoggingBase 10 | { 11 | public ReferencedFieldsTests(ITestOutputHelper output) : base(output) 12 | { 13 | Log.DefaultMinimumLevel = Microsoft.Extensions.Logging.LogLevel.Trace; 14 | } 15 | 16 | [Fact] 17 | public async Task CanGetReferencedFields() 18 | { 19 | var parser = new LuceneQueryParser(); 20 | var result = await parser.ParseAsync("field1:value field2:value (field3:value OR field4:value (field5:value)) field6:value"); 21 | var fields = result.GetReferencedFields(); 22 | 23 | Assert.Equal(6, fields.Count); 24 | Assert.Contains("field1", fields); 25 | Assert.Contains("field2", fields); 26 | Assert.Contains("field3", fields); 27 | Assert.Contains("field4", fields); 28 | Assert.Contains("field5", fields); 29 | Assert.Contains("field6", fields); 30 | 31 | // make sure caching works 32 | fields = result.GetReferencedFields(); 33 | 34 | Assert.Equal(6, fields.Count); 35 | Assert.Contains("field1", fields); 36 | Assert.Contains("field2", fields); 37 | Assert.Contains("field3", fields); 38 | Assert.Contains("field4", fields); 39 | Assert.Contains("field5", fields); 40 | Assert.Contains("field6", fields); 41 | } 42 | 43 | [Fact] 44 | public async Task CanGetTopLevelReferencedFields() 45 | { 46 | var parser = new LuceneQueryParser(); 47 | var result = await parser.ParseAsync("field1:value field2:value (field3:value OR field4:value (field5:value)) field6:value"); 48 | var fields = result.GetReferencedFields(currentGroupOnly: true); 49 | 50 | Assert.Equal(3, fields.Count); 51 | Assert.Contains("field1", fields); 52 | Assert.Contains("field2", fields); 53 | Assert.Contains("field6", fields); 54 | 55 | // make sure caching works 56 | fields = result.GetReferencedFields(currentGroupOnly: true); 57 | 58 | Assert.Equal(3, fields.Count); 59 | Assert.Contains("field1", fields); 60 | Assert.Contains("field2", fields); 61 | Assert.Contains("field6", fields); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.LuceneQueries.Tests/RemoveFieldsQueryVisitor.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Foundatio.Parsers.LuceneQueries.Visitors; 3 | using Foundatio.Xunit; 4 | using Xunit; 5 | using Xunit.Abstractions; 6 | 7 | namespace Foundatio.Parsers.LuceneQueries.Tests; 8 | 9 | public class RemoveFieldsQueryVisitorTests : TestWithLoggingBase 10 | { 11 | public RemoveFieldsQueryVisitorTests(ITestOutputHelper output) : base(output) 12 | { 13 | Log.DefaultMinimumLevel = Microsoft.Extensions.Logging.LogLevel.Trace; 14 | } 15 | 16 | [Fact] 17 | public async Task CanRemoveField() 18 | { 19 | var parser = new LuceneQueryParser(); 20 | var result = await parser.ParseAsync("field1:value field2:value (field3:value OR field4:value (field5:value)) field6:value"); 21 | string queryResult = await RemoveFieldsQueryVisitor.RunAsync(result, ["field1"]); 22 | 23 | Assert.Equal("field2:value (field3:value OR field4:value (field5:value)) field6:value", queryResult); 24 | } 25 | 26 | [Fact] 27 | public async Task CanRemoveFieldWithFunc() 28 | { 29 | var parser = new LuceneQueryParser(); 30 | var result = await parser.ParseAsync("field1:value field2:value (field3:value OR field4:value (field5:value)) field6:value"); 31 | string queryResult = await RemoveFieldsQueryVisitor.RunAsync(result, f => f == "field3"); 32 | 33 | Assert.Equal("field1:value field2:value (field4:value (field5:value)) field6:value", queryResult); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.LuceneQueries.Tests/UnescapeTests.cs: -------------------------------------------------------------------------------- 1 | using Foundatio.Parsers.LuceneQueries.Extensions; 2 | using Foundatio.Xunit; 3 | using Microsoft.Extensions.Logging; 4 | using Xunit; 5 | using Xunit.Abstractions; 6 | 7 | namespace Foundatio.Parsers.LuceneQueries.Tests; 8 | 9 | public sealed class UnescapeTests : TestWithLoggingBase 10 | { 11 | public UnescapeTests(ITestOutputHelper output) : base(output) 12 | { 13 | Log.DefaultMinimumLevel = LogLevel.Trace; 14 | } 15 | 16 | [Theory] 17 | [InlineData("", "")] 18 | [InlineData("none", "none")] 19 | [InlineData(@"Escaped \. in the code", "Escaped . in the code")] 20 | [InlineData(@"Escap\e", "Escape")] 21 | [InlineData(@"Double \\ backslash", @"Double \ backslash")] 22 | [InlineData(@"At end \", @"At end \")] 23 | public void UnescapingWorks(string test, string expected) 24 | { 25 | string result = test.Unescape(); 26 | Assert.Equal(expected, result); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.SqlQueries.Tests/DynamicFieldVisitor.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using Foundatio.Parsers.LuceneQueries.Nodes; 3 | using Foundatio.Parsers.LuceneQueries.Visitors; 4 | using Foundatio.Parsers.SqlQueries.Extensions; 5 | using Foundatio.Parsers.SqlQueries.Visitors; 6 | 7 | namespace Foundatio.Parsers.SqlQueries.Tests; 8 | 9 | public class DynamicFieldVisitor : ChainableMutatingQueryVisitor 10 | { 11 | public override IQueryNode Visit(TermNode node, IQueryVisitorContext context) 12 | { 13 | if (context is not SqlQueryVisitorContext sqlContext) 14 | return node; 15 | 16 | var field = SqlNodeExtensions.GetFieldInfo(sqlContext.Fields, node.Field); 17 | 18 | if (field == null || !field.Data.TryGetValue("DataDefinitionId", out object value) || 19 | value is not int dataDefinitionId) 20 | { 21 | return node; 22 | } 23 | 24 | var customFieldBuilder = new StringBuilder(); 25 | 26 | customFieldBuilder.Append("DataValues.Any(DataDefinitionId = "); 27 | customFieldBuilder.Append(dataDefinitionId); 28 | customFieldBuilder.Append(" AND "); 29 | switch (field) 30 | { 31 | case { IsMoney: true }: 32 | customFieldBuilder.Append("MoneyValue"); 33 | break; 34 | case { IsNumber: true }: 35 | customFieldBuilder.Append("NumberValue"); 36 | break; 37 | case { IsBoolean: true }: 38 | customFieldBuilder.Append("BooleanValue"); 39 | break; 40 | case { IsDate: true }: 41 | customFieldBuilder.Append("DateValue"); 42 | break; 43 | case { IsDateOnly: true }: 44 | customFieldBuilder.Append("DateOnlyValue"); 45 | break; 46 | default: 47 | customFieldBuilder.Append("StringValue"); 48 | break; 49 | } 50 | 51 | customFieldBuilder.Append(" = "); 52 | if (field is { IsNumber: true } or { IsBoolean: true }) 53 | { 54 | customFieldBuilder.Append(node.Term); 55 | } 56 | else 57 | { 58 | customFieldBuilder.Append("\""); 59 | customFieldBuilder.Append(node.Term); 60 | customFieldBuilder.Append("\""); 61 | } 62 | customFieldBuilder.Append(")"); 63 | 64 | node.SetQuery(customFieldBuilder.ToString()); 65 | 66 | return node; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.SqlQueries.Tests/Foundatio.Parsers.SqlQueries.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/Foundatio.Parsers.SqlQueries.Tests/SampleContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace Foundatio.Parsers.SqlQueries.Tests; 6 | 7 | public class SampleContext : DbContext 8 | { 9 | public SampleContext(DbContextOptions options) : base(options) { } 10 | public DbSet Employees => Set(); 11 | public DbSet Companies => Set(); 12 | public DbSet DataDefinitions => Set(); 13 | public DbSet DataValues => Set(); 14 | 15 | protected override void OnModelCreating(ModelBuilder modelBuilder) 16 | { 17 | base.OnModelCreating(modelBuilder); 18 | 19 | // Employee 20 | modelBuilder.Entity().HasIndex(e => new { e.FullName, e.Title }); 21 | 22 | // Company 23 | modelBuilder.Entity().HasIndex(e => new { e.Name }); 24 | 25 | // DataDefinition 26 | modelBuilder.Entity().Property(c => c.DataType).IsRequired(); 27 | modelBuilder.Entity().HasIndex(c => new { c.CompanyId, c.Key }).IsUnique(); 28 | 29 | // DataValue 30 | modelBuilder.Entity().HasIndex(c => new { c.DataDefinitionId, c.CompanyId, c.EmployeeId }).HasFilter(null).IsUnique(); 31 | modelBuilder.Entity().Property(e => e.StringValue).HasMaxLength(4000).IsSparse(); 32 | modelBuilder.Entity().Property(e => e.DateValue).IsSparse(); 33 | modelBuilder.Entity().Property(e => e.MoneyValue).IsSparse().HasColumnType("money").HasPrecision(2); 34 | modelBuilder.Entity().Property(e => e.BooleanValue).IsSparse(); 35 | modelBuilder.Entity().Property(e => e.NumberValue).HasColumnType("decimal").HasPrecision(15, 3).IsSparse(); 36 | modelBuilder.Entity().HasIndex(e => new { e.StringValue, e.DateValue, e.MoneyValue, e.BooleanValue, e.NumberValue }); 37 | } 38 | } 39 | 40 | public class Employee 41 | { 42 | public int Id { get; set; } 43 | public string FullName { get; set; } 44 | public string PhoneNumber { get; set; } 45 | public string NationalPhoneNumber { get; set; } 46 | public string Title { get; set; } 47 | public int Salary { get; set; } 48 | public List Companies { get; set; } 49 | public List DataValues { get; set; } 50 | public TimeOnly HappyHour { get; set; } 51 | public DateOnly Birthday { get; set; } 52 | 53 | public DateTime Created { get; set; } = DateTime.Now; 54 | } 55 | 56 | public class Company 57 | { 58 | public int Id { get; set; } 59 | public string Name { get; set; } 60 | public string Description { get; set; } 61 | public List Employees { get; set; } 62 | public List DataDefinitions { get; set; } 63 | } 64 | 65 | public class DataValue 66 | { 67 | public int Id { get; set; } 68 | public int DataDefinitionId { get; set; } 69 | public int CompanyId { get; set; } 70 | public int EmployeeId { get; set; } 71 | 72 | // store the values separately as sparse columns for querying purposes 73 | public string StringValue { get; set; } 74 | public DateTime? DateValue { get; set; } 75 | public DateOnly? DateOnlyValue { get; set; } 76 | public decimal? MoneyValue { get; set; } 77 | public bool? BooleanValue { get; set; } 78 | public decimal? NumberValue { get; set; } 79 | 80 | public DataDefinition Definition { get; set; } = null; 81 | 82 | public object GetValue(DataType? dataType = null) 83 | { 84 | if (!dataType.HasValue && Definition != null) 85 | dataType = Definition.DataType; 86 | 87 | if (dataType.HasValue) 88 | { 89 | return dataType switch 90 | { 91 | DataType.String => StringValue, 92 | DataType.Date => DateValue, 93 | DataType.DateOnly => DateOnlyValue, 94 | DataType.Number => NumberValue, 95 | DataType.Boolean => BooleanValue, 96 | DataType.Money => MoneyValue, 97 | DataType.Percent => NumberValue, 98 | _ => null 99 | }; 100 | } 101 | 102 | if (MoneyValue.HasValue) 103 | return MoneyValue.Value; 104 | if (BooleanValue.HasValue) 105 | return BooleanValue.Value; 106 | if (NumberValue.HasValue) 107 | return NumberValue.Value; 108 | if (DateValue.HasValue) 109 | return DateValue.Value; 110 | 111 | return StringValue ?? null; 112 | } 113 | 114 | public void ClearValue() 115 | { 116 | StringValue = null; 117 | DateValue = null; 118 | NumberValue = null; 119 | BooleanValue = null; 120 | MoneyValue = null; 121 | } 122 | 123 | public void SetValue(object value, DataType? dataType = null) 124 | { 125 | ClearValue(); 126 | 127 | if (value == null) 128 | return; 129 | 130 | switch (dataType ?? Definition!.DataType) 131 | { 132 | case DataType.String: 133 | StringValue = value.ToString(); 134 | break; 135 | case DataType.Date: 136 | if (DateTime.TryParse(value.ToString(), out DateTime dateResult)) 137 | DateValue = dateResult; 138 | break; 139 | case DataType.Number: 140 | case DataType.Percent: 141 | if (Decimal.TryParse(value.ToString(), out decimal numberResult)) 142 | NumberValue = numberResult; 143 | break; 144 | case DataType.Boolean: 145 | if (Boolean.TryParse(value.ToString(), out bool boolResult)) 146 | BooleanValue = boolResult; 147 | break; 148 | case DataType.Money: 149 | if (Decimal.TryParse(value.ToString(), out decimal decimalResult)) 150 | MoneyValue = decimalResult; 151 | break; 152 | } 153 | } 154 | 155 | // relationships 156 | [DeleteBehavior(DeleteBehavior.NoAction)] 157 | public Employee Employee { get; set; } = null; 158 | } 159 | 160 | public class DataDefinition 161 | { 162 | public int Id { get; set; } 163 | public int CompanyId { get; set; } 164 | 165 | public DataType DataType { get; set; } 166 | public string Key { get; set; } = String.Empty; 167 | 168 | // relationships 169 | [DeleteBehavior(DeleteBehavior.Cascade)] 170 | public Company Company { get; set; } = null; 171 | } 172 | 173 | public enum DataType 174 | { 175 | String, 176 | Number, 177 | Boolean, 178 | Date, 179 | DateOnly, 180 | Money, 181 | Percent 182 | } 183 | --------------------------------------------------------------------------------