diff --git a/.github/workflows/codeception.yaml b/.github/workflows/codeception.yaml
index 7f81e162..bfa374e3 100644
--- a/.github/workflows/codeception.yaml
+++ b/.github/workflows/codeception.yaml
@@ -90,17 +90,6 @@ jobs:
chmod 755 .github/ci/scripts/setup-pimcore-environment-functional-tests.sh
.github/ci/scripts/setup-pimcore-environment-functional-tests.sh
- - name: Install SSH Key # this is necessary for Composer to be able to clone source from pimcore/ee-pimcore
- uses: shimataro/ssh-key-action@v2
- with:
- key: ${{ secrets.SSH_PRIVATE_KEY_PIMCORE_DEPLOYMENTS_USER }}
- known_hosts: '.... we add this in the next step ;-)'
-
- - name: "Add authentication for private pimcore packages"
- run: |
- composer config --append repositories.private-packagist composer https://repo.pimcore.com/github-actions/
- composer config --global --auth http-basic.repo.pimcore.com github-actions ${{ secrets.COMPOSER_PIMCORE_REPO_PACKAGIST_TOKEN }}
-
- name: "Install dependencies with Composer"
uses: "ramsey/composer-install@v2"
with:
diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml
deleted file mode 100644
index 3a48681b..00000000
--- a/.github/workflows/qodana.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-name: Qodana
-on:
- schedule:
- - cron: '0 01 * * *' # Run once per day
- workflow_dispatch:
- push:
- paths:
- - '**.php'
- - '**.yml'
- - '**.yaml'
- branches:
- - '*'
- - '**'
- pull_request_target:
- types: [opened, synchronize, reopened]
- paths:
- - '**.php'
- - '**.yml'
- - '**.yaml'
- branches:
- - '*'
- - '**'
-
-jobs:
- qodana-check-workflow:
- uses: pimcore/workflows-collection-public/.github/workflows/reusable-qodana-check.yaml@main
- secrets:
- COMPOSER_PIMCORE_REPO_PACKAGIST_TOKEN: ${{ secrets.COMPOSER_PIMCORE_REPO_PACKAGIST_TOKEN }}
- QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml
new file mode 100644
index 00000000..051423e8
--- /dev/null
+++ b/.github/workflows/sonar.yaml
@@ -0,0 +1,20 @@
+name: Build
+on:
+ push:
+ branches:
+ - 1.x
+ pull_request:
+ types: [opened, synchronize, reopened]
+jobs:
+ sonarcloud:
+ name: SonarCloud
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
+ - name: SonarCloud Scan
+ uses: SonarSource/sonarcloud-github-action@master
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml
index ce860b3b..d5481c97 100644
--- a/.github/workflows/static-analysis.yaml
+++ b/.github/workflows/static-analysis.yaml
@@ -35,15 +35,6 @@ jobs:
with:
coverage: "none"
php-version: "${{ matrix.php-version }}"
- - name: Install SSH Key # this is necessary for Composer to be able to clone source from pimcore/ee-pimcore
- uses: shimataro/ssh-key-action@v2
- with:
- key: ${{ secrets.SSH_PRIVATE_KEY_PIMCORE_DEPLOYMENTS_USER }}
- known_hosts: '.... we add this in the next step ;-)'
- - name: "Add authentication for private pimcore packages"
- run: |
- composer config --append repositories.private-packagist composer https://repo.pimcore.com/github-actions/
- composer config --global --auth http-basic.repo.pimcore.com github-actions ${{ secrets.COMPOSER_PIMCORE_REPO_PACKAGIST_TOKEN }}
- name: "Setup Pimcore environment"
run: |
.github/ci/scripts/setup-pimcore-environment.sh
diff --git a/.qodana-profile.xml b/.qodana-profile.xml
deleted file mode 100644
index c1a0bf25..00000000
--- a/.qodana-profile.xml
+++ /dev/null
@@ -1,625 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/config/services/query-language.yaml b/config/services/query-language.yaml
new file mode 100644
index 00000000..8e26fca1
--- /dev/null
+++ b/config/services/query-language.yaml
@@ -0,0 +1,14 @@
+services:
+ _defaults:
+ autowire: true
+ autoconfigure: false
+ public: false
+
+ Pimcore\Bundle\GenericDataIndexBundle\QueryLanguage\LexerInterface:
+ class: Pimcore\Bundle\GenericDataIndexBundle\QueryLanguage\Pql\Lexer
+
+ Pimcore\Bundle\GenericDataIndexBundle\QueryLanguage\ParserInterface:
+ class: Pimcore\Bundle\GenericDataIndexBundle\QueryLanguage\Pql\Parser
+
+ Pimcore\Bundle\GenericDataIndexBundle\QueryLanguage\ProcessorInterface:
+ class: Pimcore\Bundle\GenericDataIndexBundle\QueryLanguage\Pql\Processor
diff --git a/config/services/search-index-adapter/open-search.yaml b/config/services/search-index-adapter/open-search.yaml
index c847ad2d..2c0eeb8a 100644
--- a/config/services/search-index-adapter/open-search.yaml
+++ b/config/services/search-index-adapter/open-search.yaml
@@ -33,6 +33,12 @@ services:
Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\Search\LocateInTreeServiceInterface:
class: Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\Search\LocateInTreeService
+ Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\Search\FetchIdsBySearchServiceInterface:
+ class: Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\Search\FetchIdsBySearchService
+
+ Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\MappingAnalyzerServiceInterface:
+ class: Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\MappingAnalyzerService
+
Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\IndexStatsServiceInterface:
class: Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\IndexStatsService
@@ -40,4 +46,18 @@ services:
class: Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\IndexMappingService
Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\Workspace\QueryServiceInterface:
- class: Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\Workspace\QueryService
\ No newline at end of file
+ class: Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\Workspace\QueryService
+
+ Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\QueryLanguage\PqlAdapterInterface:
+ class: Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\QueryLanguage\PqlAdapter
+
+ Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\QueryLanguage\SubQueriesProcessorInterface:
+ class: Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\QueryLanguage\SubQueriesProcessor
+
+ Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\QueryLanguage\FieldNameTransformer\:
+ resource: '../../../src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer'
+ tags: ['pimcore.generic_data_index.pql_field_name_transformer']
+
+ Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\QueryLanguage\FieldNameValidator\:
+ resource: '../../../src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameValidator'
+ tags: ['pimcore.generic_data_index.pql_field_name_validator']
diff --git a/config/services/search-index-adapter/open-search/modifiers/search-modifiers.yaml b/config/services/search-index-adapter/open-search/modifiers/search-modifiers.yaml
index a855a91b..5f83ab4f 100644
--- a/config/services/search-index-adapter/open-search/modifiers/search-modifiers.yaml
+++ b/config/services/search-index-adapter/open-search/modifiers/search-modifiers.yaml
@@ -16,4 +16,6 @@ services:
Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\Search\Modifier\Sort\TreeSortHandlers: ~
+ Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\Search\Modifier\QueryLanguage\QueryLanguageHandlers: ~
+
Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\Search\Modifier\Filter\Workspace\WorkspaceQueryHandler: ~
\ No newline at end of file
diff --git a/config/services/search/index.yaml b/config/services/search/index.yaml
index 0c87c8b0..538ee900 100644
--- a/config/services/search/index.yaml
+++ b/config/services/search/index.yaml
@@ -7,6 +7,9 @@ services:
Pimcore\Bundle\GenericDataIndexBundle\Service\SearchIndex\LanguageServiceInterface:
class: Pimcore\Bundle\GenericDataIndexBundle\Service\SearchIndex\LanguageService
+ Pimcore\Bundle\GenericDataIndexBundle\Service\SearchIndex\IndexEntityServiceInterface:
+ class: Pimcore\Bundle\GenericDataIndexBundle\Service\SearchIndex\IndexEntityService
+
Pimcore\Bundle\GenericDataIndexBundle\Service\SearchIndex\IndexUpdateServiceInterface:
class: Pimcore\Bundle\GenericDataIndexBundle\Service\SearchIndex\IndexUpdateService
diff --git a/doc/04_Searching_For_Data_In_Index/05_Search_Modifiers/README.md b/doc/04_Searching_For_Data_In_Index/05_Search_Modifiers/README.md
index 168526bd..5e84b924 100644
--- a/doc/04_Searching_For_Data_In_Index/05_Search_Modifiers/README.md
+++ b/doc/04_Searching_For_Data_In_Index/05_Search_Modifiers/README.md
@@ -31,6 +31,12 @@ $search->addModifier(new ParentIdFilter(1))
|--------------------------------------------------------------------------------------------|-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| [ElementKeySearch](https://github.com/pimcore/generic-data-index-bundle/blob/1.x/src/Model/Search/Modifier/FullTextSearch/ElementKeySearch.php) | Full text search | Search by element key like in the studio UI. * can be used for wildcard searches - for example "Car*" to find all items starting with "Car". |
+### Query Language
+
+| Modifier | Modifier Category | Description |
+|----------------------------------------------------------------------------------------------------------------------------------|-------------------|-------------------------------------------------------------------------------------------|
+| [PqlFilter](https://github.com/pimcore/generic-data-index-bundle/blob/1.x/src/Model/Search/Modifier/QueryLanguage/PqlFilter.php) | Query Language | Apply a [Pimcore Query Language (PQL)](../09_Pimcore_Query_Language/README.md) condition. |
+
### Sort Modifiers
| Modifier | Modifier Category | Description |
diff --git a/doc/04_Searching_For_Data_In_Index/09_Pimcore_Query_Language/03_Use_PQL_as_Developer.md b/doc/04_Searching_For_Data_In_Index/09_Pimcore_Query_Language/03_Use_PQL_as_Developer.md
new file mode 100644
index 00000000..fdba58e6
--- /dev/null
+++ b/doc/04_Searching_For_Data_In_Index/09_Pimcore_Query_Language/03_Use_PQL_as_Developer.md
@@ -0,0 +1,103 @@
+# Use Pimcore Query Language (PQL) as a Developer
+
+## Execute searches based on PQL queries
+
+If you want to use the Pimcore Query Language (PQL) as a developer to search for data in the Pimcore Generic Data Index, you can use one of the following methods:
+
+#### 1. Search Modifier for the Generic Data Index search services
+
+You can use the [PqlFilter](https://github.com/pimcore/generic-data-index-bundle/blob/1.x/src/Model/Search/Modifier/QueryLanguage/PqlFilter.php) search modifier to filter search results based on a PQL query. The `PqlFilter` search modifier can be used with the search services provided by the Generic Data Index bundle. Take a look at the [Search Services](../README.md) documentation for details.
+
+#### 2. Direct use of the PQL processor to get the search query
+
+Use the `Pimcore\Bundle\GenericDataIndexBundle\QueryLanguage\ProcessorInterface` together with the `Pimcore\Bundle\GenericDataIndexBundle\Service\SearchIndex\IndexEntityServiceInterface` to process a PQL query.
+
+```php
+// inject both services via Symfony dependency injection
+/** @var \Pimcore\Bundle\GenericDataIndexBundle\QueryLanguage\ProcessorInterface $queryLanguageProcessor */
+/** @var \Pimcore\Bundle\GenericDataIndexBundle\Service\SearchIndex\IndexEntityServiceInterface $indexEntityService */
+
+$query = $queryLanguageProcessor->process(
+ 'color = "red" or color = "blue"', // The PQL query
+ $indexEntityService->getByEntityName('Car') // 'Asset', 'Document' or the name of the data object class
+);
+
+// $query is now a valid OpenSearch query array which can be used to search in the index
+```
+
+## Exception Handling
+
+In both cases, the PQL processor will throw an exception if the PQL query is invalid. The exception message will contain detailed information about the error. Especially when you would like to allow users to enter PQL queries, you should catch the exception and provide a user-friendly error feedback.
+
+##### Example
+
+This example will produce a error message like this:
+
+![PQL Syntax Error](../../img/pql-syntax-error.png)
+
+```php
+## Catching the exception
+use Pimcore\Bundle\GenericDataIndexBundle\Exception\QueryLanguage\ParsingException;
+
+try {
+ $pqlQuery = 'series = "E-Type"
+ and color "red"';
+
+ $query = $queryLanguageProcessor->process(
+ $pqlQuery, // The PQL query
+ $indexEntityService->getByEntityName('Car') // 'Asset', 'Document' or the name of the data object class
+ );
+} catch (ParsingException $e) {
+ // Provide user-friendly error feedback
+ return $twig->render('pql-syntax-error.html.twig', [
+ 'error' => $e->getMessage(),
+ 'syntaxBeforeError' => substr($e->getQuery(), 0, $e->getPosition()),
+ 'syntaxAfterError' => substr($e->getQuery(), $e->getPosition()),
+ ]);
+}
+```
+
+
+```twig
+{# pql-syntax-error.html.twig #}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ error }}
+
+
+ {{ syntaxBeforeError|nl2br }}
+ ⇧
+ {{ syntaxAfterError|nl2br }}
+
+
+
+
+
+
+```
diff --git a/doc/04_Searching_For_Data_In_Index/09_Pimcore_Query_Language/README.md b/doc/04_Searching_For_Data_In_Index/09_Pimcore_Query_Language/README.md
new file mode 100644
index 00000000..3034367f
--- /dev/null
+++ b/doc/04_Searching_For_Data_In_Index/09_Pimcore_Query_Language/README.md
@@ -0,0 +1,117 @@
+# Pimcore Query Language
+
+Pimcore Query Language (PQL) is a query language that allows you to search for data in the Pimcore Generic Data Index. It is a simple and powerful query language that allows you to search for data using a wide range of search criteria.
+
+## Syntax
+
+Description of the PQL syntax:
+
+```
+CONDITION = EXPRESSION | CONDITION ("AND" | "OR") CONDITION
+EXPRESSION = "(" CONDITION ")" | COMPARISON | QUERY_STRING_QUERY
+COMPARISON = FIELDNAME OPERATOR VALUE | RELATION_COMPARISON
+RELATION_COMPARISON = RELATION_FIELD_NAME OPERATOR VALUE
+FIELDNAME = IDENTIFIER{.IDENTIFIER}
+RELATION_FIELD_NAME = FIELDNAME:ENTITYNAME.FIELDNAME
+IDENTIFIER = [a-zA-Z_]\w*
+ENTITYNAME = [a-zA-Z_]\w*
+OPERATOR = "="|"<"|">"|">="|"<="|"LIKE"
+VALUE = INTEGER | FLOAT | "'" STRING "'" | '"' STRING '"'
+QUERY_STRING_QUERY = "QUERY('" STRING "')"
+```
+
+### Operators
+
+| Operator | Description | Examples |
+|----------|------------------------------------------------------------------------------------------------------------------------|----------------------------------------------|
+| `=` | equal | `field = "value"` |
+| `<` | smaller than | `field < 100` |
+| `<=` | smaller or equal than | `field <= 100` |
+| `=>` | bigger or equal than | `field >= 100` |
+| `>` | bigger than | `field > 100` |
+| `LIKE` | equal with wildcard support* matches zero or more characters ? matches any single character | `field like "val*"` `field like "val?e"` |
+
+### AND / OR / Brackets
+
+You can combine multiple conditions using the `AND` and `OR` operators. You can also use brackets to group conditions.
+
+**Examples:**
+
+```
+field1 = "value1" AND field2 = "value2"
+field1 = "value1" AND (field2 = "value2" OR field3 = "value3")
+(field1 = "value1" AND (field2 = "value2" OR field3 = "value3")) or field4 = "value4"
+```
+
+
+### Relation Filters
+
+Supports filtering along relations with following notation:
+
+`:.`
+
+**Examples:**
+
+```
+main_image:Asset.type
+category:Category.name
+manufacturer:Company.country
+```
+
+The entity name can be either 'Asset', 'Document' or the name of the data object class.
+
+### Field Names
+
+The field names are named and structured the same way like in the OpenSearch index. Nested field names are supported with a dot ('.') notation.
+As described [here](../../05_Extending_Data_Index/06_Extend_Search_Index.md) the fields are separated into three sections (system_fields, standard_fields and custom_fields) and depending on the data type of a attribute the attribute value could be a nested structure with sub-attributes.
+
+
+**Examples for field names with their full path in the index:**
+
+```
+system_fields.id
+standard_fields.name
+standard_fields.my_relation_field.asset
+standard_fields.description.de
+```
+
+To simplify the usage of the PQL the field names can be used without the full path in most of the cases. The PQL will automatically search in the index structure and try to detect the correct field. So normally it's enough to use the technical field name like used for example in the data object class or asset metadata attribute.
+
+**Above examples for field names without the full path:**
+
+```
+id
+name
+my_relation_field
+description.de
+```
+
+Localized fields can be accessed in the form 'field_name.locale' (e.g. description.de).
+
+### Query String Query Filters
+
+The PQL allows passing OpenSearch [query string queries](https://opensearch.org/docs/latest/query-dsl/full-text/query-string/#query-string-syntax) directly to the index. The query string query syntax provides even more flexibility to define the search criteria. Take a look at the [OpenSearch documentation](https://opensearch.org/docs/latest/query-dsl/full-text/query-string/#query-string-syntax) for more details.
+
+**Caution**: The automatic field detection is not supported for query string queries. So you have to use the full path for the field names.
+
+### Example PQL Queries
+
+All examples are based on the `Car` data object class of the [Pimcore Demo](https://pimcore.com/en/try).
+
+| Query | Description |
+|---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|
+| `series = "E-Type" AND (color = "green" OR productionYear < 1965)` | All E-Type models which are green or produced before 1965. |
+| `manufacturer:Manufacturer.name = "Alfa" and productionYear > 1965` | All Alfa cars produced after 1965. |
+| `genericImages:Asset.fullPath like "/Car Images/vw/*"` | All cars with a image linked in the `genericImages` image gallery which is contained in the asset folder `/Car Images/vw`. |
+| `color = "red" or color = "blue"` | All red or blue cars using standard PQL syntax. |
+| `Query("standard_fields.color:(red or blue)")` | All red or blue cars using simple query string syntax. |
+
+
+## Limitations
+
+* When searching for related elements the maximum possible results amount of sub queries is 65.000, see also [terms query documentation](https://opensearch.org/docs/latest/query-dsl/term/terms/).
+* Filtering for asset metadata fields is only possible if they are defined as predefined asset metadata or via the asset metadata class definitions bundle. Custom asset metadata fields directly defined on single assets are not supported.
+
+## Further Reading
+
+- [Use PQL as a Developer](./03_Use_PQL_as_Developer.md).
diff --git a/doc/04_Searching_For_Data_In_Index/README.md b/doc/04_Searching_For_Data_In_Index/README.md
index bfcac680..a372a630 100644
--- a/doc/04_Searching_For_Data_In_Index/README.md
+++ b/doc/04_Searching_For_Data_In_Index/README.md
@@ -51,6 +51,10 @@ The search service respects the user permissions and user workspaces in connecti
Details about permissions and workspaces can be found in the [permissions and workspaces documentation](08_Permissions_Workspaces/README.md).
+## Pimcore Query Language (PQL)
+The [Pimcore Query Language (PQL)](./09_Pimcore_Query_Language/README.md) is a query language which can be used to provide the user a flexible way to define search criteria for data objects, assets and documents.
+
+
## Debug OpenSearch Queries
To debug the OpenSearch queries which are created by the search service, it is possible to use the following magic parameter in the URL (when debug mode is enabled):
diff --git a/doc/img/pql-syntax-error.png b/doc/img/pql-syntax-error.png
new file mode 100644
index 00000000..c59789d8
Binary files /dev/null and b/doc/img/pql-syntax-error.png differ
diff --git a/qodana.yaml b/qodana.yaml
deleted file mode 100644
index 68f707e4..00000000
--- a/qodana.yaml
+++ /dev/null
@@ -1,57 +0,0 @@
-version: "1.0"
-linter: jetbrains/qodana-php:latest
-profile:
- path: .qodana-profile.xml
-php:
- version: "8.2"
-failThreshold: 0
-exclude:
- - name: All
- paths:
- - vendor
- - public
- - tests
- - var
- - translations
- - .php_cs.dist
- - doc
- - .github
- - .php-cs-fixer.dist.php
- - .qodana-profile.xml
- - config
- - .editorconfig
- - composer.json
- - composer.lock
- - docker-compose.yml
- - README.md
- - SECURITY.md
- - codeception.dist.yml
- - phpstan-bootstrap.php
- - phpstan-baseline.neon
- - phpstan.neon
- - LICENSE.md
- - .env
- - .gitatributes
- - .docker
- - .gitignore
- - src/DependencyInjection/Configuration.php
- - placeholderReplace.sh
- - name: PhpUnusedParameterInspection
- paths:
- - src/SearchIndexAdapter/OpenSearch/Search/Modifier
- - name: PhpMethodNamingConventionInspection
- paths:
- - src/Migrations
- - name: PhpDqlBuilderUnknownModelInspection
- paths:
- - src/Repository/IndexQueueRepository.php
- - src/Service/SearchIndex/IndexService/ElementTypeAdapter/AssetTypeAdapter.php
- - src/Service/SearchIndex/IndexService/ElementTypeAdapter/DataObjectTypeAdapter.php
-include:
- - name: PhpTaintFunctionInspection
- - name: PhpVulnerablePathsInspection
-plugins:
- - id: de.espend.idea.php.annotation
- - id: com.kalessil.phpStorm.phpInspectionsEA
- - id: de.espend.idea.php.toolbox
- - id: fr.adrienbrault.idea.symfony2plugin
\ No newline at end of file
diff --git a/sonar-project.properties b/sonar-project.properties
index 49983ca0..0495f8c2 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -1,2 +1,15 @@
sonar.projectKey=pimcore_generic-data-index-bundle
sonar.organization=pimcore
+
+# This is the name and version displayed in the SonarCloud UI.
+#sonar.projectName=generic-data-index-bundle
+#sonar.projectVersion=1.0
+
+
+# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
+#sonar.sources=.
+
+sonar.exclusions=**/vendor/**,**/doc/**,**/tests/**,**/config/**,**/.docker/**
+
+# Encoding of the source code. Default is default system encoding
+#sonar.sourceEncoding=UTF-8
\ No newline at end of file
diff --git a/src/DependencyInjection/Compiler/SearchModifierHandlerPass.php b/src/DependencyInjection/Compiler/SearchModifierHandlerPass.php
index 0edbce92..5bc94480 100644
--- a/src/DependencyInjection/Compiler/SearchModifierHandlerPass.php
+++ b/src/DependencyInjection/Compiler/SearchModifierHandlerPass.php
@@ -17,7 +17,7 @@
namespace Pimcore\Bundle\GenericDataIndexBundle\DependencyInjection\Compiler;
use Exception;
-use Pimcore\Bundle\GenericDataIndexBundle\Enum\DependencyInjection\CompilerPassTag;
+use Pimcore\Bundle\GenericDataIndexBundle\Enum\DependencyInjection\ServiceTag;
use Pimcore\Bundle\GenericDataIndexBundle\Exception\DependencyInjection\RuntimeException;
use Pimcore\Bundle\GenericDataIndexBundle\Model\OpenSearch\Modifier\SearchModifierContextInterface;
use Pimcore\Bundle\GenericDataIndexBundle\Model\Search\Modifier\SearchModifierInterface;
@@ -39,7 +39,7 @@ class SearchModifierHandlerPass implements CompilerPassInterface
public function process(ContainerBuilder $container): void
{
$taggedServiceIds = $container->findTaggedServiceIds(
- CompilerPassTag::SEARCH_MODIFIER_HANDLER->value,
+ ServiceTag::SEARCH_MODIFIER_HANDLER->value,
true
);
diff --git a/src/DependencyInjection/Compiler/ServiceLocatorPass.php b/src/DependencyInjection/Compiler/ServiceLocatorPass.php
index 516463fc..057b7ea5 100644
--- a/src/DependencyInjection/Compiler/ServiceLocatorPass.php
+++ b/src/DependencyInjection/Compiler/ServiceLocatorPass.php
@@ -16,9 +16,11 @@
namespace Pimcore\Bundle\GenericDataIndexBundle\DependencyInjection\Compiler;
-use Pimcore\Bundle\GenericDataIndexBundle\Enum\DependencyInjection\CompilerPassTag;
+use Pimcore\Bundle\GenericDataIndexBundle\Enum\DependencyInjection\ServiceTag;
+use Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\OpenSearch\QueryLanguage\FieldNameTransformerInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Exception\AutowiringFailedException;
use Symfony\Component\DependencyInjection\Reference;
/**
@@ -33,17 +35,17 @@ public function process(ContainerBuilder $container): void
{
$definitionList = [
'pimcore.generic_data_index.object.search_index_field_definition_locator' =>
- CompilerPassTag::DATA_OBJECT_SEARCH_INDEX_FIELD_DEFINITION->value,
+ ServiceTag::DATA_OBJECT_SEARCH_INDEX_FIELD_DEFINITION->value,
'pimcore.generic_data_index.asset.search_index_field_definition_locator' =>
- CompilerPassTag::ASSET_SEARCH_INDEX_FIELD_DEFINITION->value,
+ ServiceTag::ASSET_SEARCH_INDEX_FIELD_DEFINITION->value,
'pimcore.generic_data_index.asset.type_serialization_handler_locator' =>
- CompilerPassTag::ASSET_TYPE_SERIALIZATION_HANDLER->value,
+ ServiceTag::ASSET_TYPE_SERIALIZATION_HANDLER->value,
'pimcore.generic_data_index.data_object.type_serialization_handler_locator' =>
- CompilerPassTag::DATA_OBJECT_TYPE_SERIALIZATION_HANDLER->value,
+ ServiceTag::DATA_OBJECT_TYPE_SERIALIZATION_HANDLER->value,
'pimcore.generic_data_index.asset.mapping_provider_locator' =>
- CompilerPassTag::ASSET_MAPPING_PROVIDER->value,
+ ServiceTag::ASSET_MAPPING_PROVIDER->value,
'pimcore.generic_data_index.document.type_serialization_handler_locator' =>
- CompilerPassTag::DOCUMENT_TYPE_SERIALIZATION_HANDLER->value,
+ ServiceTag::DOCUMENT_TYPE_SERIALIZATION_HANDLER->value,
];
foreach ($definitionList as $definitionId => $serviceTagName) {
@@ -62,5 +64,22 @@ public function process(ContainerBuilder $container): void
$serviceLocator = $container->getDefinition($definitionId);
$serviceLocator->setArgument(0, $arguments);
}
+
+ $definitionList = [
+ ServiceTag::PQL_FIELD_NAME_TRANSFORMER->value => FieldNameTransformerInterface::class,
+ ];
+
+ foreach($definitionList as $serviceTagName => $interfaceName) {
+ foreach ($container->findTaggedServiceIds($serviceTagName) as $taggedServiceId => $tags) {
+ $definition = $container->getDefinition($taggedServiceId);
+ if (!is_subclass_of($definition->getClass(), $interfaceName)) {
+ throw new AutowiringFailedException(
+ $taggedServiceId,
+ 'Service ID ' . $taggedServiceId . ' needs to implement ' . $interfaceName
+ );
+ }
+ }
+ }
+
}
}
diff --git a/src/Enum/DependencyInjection/CompilerPassTag.php b/src/Enum/DependencyInjection/ServiceTag.php
similarity index 86%
rename from src/Enum/DependencyInjection/CompilerPassTag.php
rename to src/Enum/DependencyInjection/ServiceTag.php
index 3b17de87..9d250c91 100644
--- a/src/Enum/DependencyInjection/CompilerPassTag.php
+++ b/src/Enum/DependencyInjection/ServiceTag.php
@@ -19,7 +19,7 @@
/**
* @internal
*/
-enum CompilerPassTag: string
+enum ServiceTag: string
{
case DATA_OBJECT_SEARCH_INDEX_FIELD_DEFINITION =
'pimcore.generic_data_index.data-object.search_index_field_definition';
@@ -29,4 +29,6 @@ enum CompilerPassTag: string
case DATA_OBJECT_TYPE_SERIALIZATION_HANDLER = 'pimcore.generic_data_index.data_object_type_serialization_handler';
case DOCUMENT_TYPE_SERIALIZATION_HANDLER = 'pimcore.generic_data_index.document_type_serialization_handler';
case ASSET_MAPPING_PROVIDER = 'pimcore.generic_data_index.asset.mapping_provider';
+ case PQL_FIELD_NAME_TRANSFORMER = 'pimcore.generic_data_index.pql_field_name_transformer';
+ case PQL_FIELD_NAME_VALIDATOR = 'pimcore.generic_data_index.pql_field_name_validator';
}
diff --git a/src/Enum/QueryLanguage/QueryTokenType.php b/src/Enum/QueryLanguage/QueryTokenType.php
new file mode 100644
index 00000000..f739de05
--- /dev/null
+++ b/src/Enum/QueryLanguage/QueryTokenType.php
@@ -0,0 +1,38 @@
+query;
+ }
+
+ public function getExpected(): string
+ {
+ return $this->expected;
+ }
+
+ public function getFound(): string
+ {
+ return $this->found;
+ }
+
+ public function getToken(): ?Token
+ {
+ return $this->token;
+ }
+
+ public function getPosition(): int
+ {
+ return $this->position ?? $this->token->position ?? strlen($this->query);
+ }
+}
diff --git a/src/Model/OpenSearch/Modifier/SearchModifierContext.php b/src/Model/OpenSearch/Modifier/SearchModifierContext.php
index 152e6de2..c771f95e 100644
--- a/src/Model/OpenSearch/Modifier/SearchModifierContext.php
+++ b/src/Model/OpenSearch/Modifier/SearchModifierContext.php
@@ -17,11 +17,13 @@
namespace Pimcore\Bundle\GenericDataIndexBundle\Model\OpenSearch\Modifier;
use Pimcore\Bundle\GenericDataIndexBundle\Model\OpenSearch\OpenSearchSearchInterface;
+use Pimcore\Bundle\GenericDataIndexBundle\Model\Search\Interfaces\SearchInterface;
readonly class SearchModifierContext implements SearchModifierContextInterface
{
public function __construct(
private OpenSearchSearchInterface $search,
+ private SearchInterface $originalSearch,
) {
}
@@ -29,4 +31,9 @@ public function getSearch(): OpenSearchSearchInterface
{
return $this->search;
}
+
+ public function getOriginalSearch(): SearchInterface
+ {
+ return $this->originalSearch;
+ }
}
diff --git a/src/Model/OpenSearch/Modifier/SearchModifierContextInterface.php b/src/Model/OpenSearch/Modifier/SearchModifierContextInterface.php
index 452400d3..d5125faa 100644
--- a/src/Model/OpenSearch/Modifier/SearchModifierContextInterface.php
+++ b/src/Model/OpenSearch/Modifier/SearchModifierContextInterface.php
@@ -17,8 +17,17 @@
namespace Pimcore\Bundle\GenericDataIndexBundle\Model\OpenSearch\Modifier;
use Pimcore\Bundle\GenericDataIndexBundle\Model\OpenSearch\OpenSearchSearchInterface;
+use Pimcore\Bundle\GenericDataIndexBundle\Model\Search\Interfaces\SearchInterface;
interface SearchModifierContextInterface
{
+ /**
+ * Returns the OpenSearch search object.
+ */
public function getSearch(): OpenSearchSearchInterface;
+
+ /**
+ * Returns the original search object.
+ */
+ public function getOriginalSearch(): SearchInterface;
}
diff --git a/src/Model/QueryLanguage/ParseResult.php b/src/Model/QueryLanguage/ParseResult.php
new file mode 100644
index 00000000..efade2a7
--- /dev/null
+++ b/src/Model/QueryLanguage/ParseResult.php
@@ -0,0 +1,42 @@
+query;
+ }
+
+ /**
+ * @return ParseResultSubQuery[]
+ */
+ public function getSubQueries(): array
+ {
+ return $this->subQueries;
+ }
+}
diff --git a/src/Model/QueryLanguage/ParseResultSubQuery.php b/src/Model/QueryLanguage/ParseResultSubQuery.php
new file mode 100644
index 00000000..b23535ce
--- /dev/null
+++ b/src/Model/QueryLanguage/ParseResultSubQuery.php
@@ -0,0 +1,57 @@
+subQueryId;
+ }
+
+ public function getRelationFieldPath(): string
+ {
+ return $this->relationFieldPath;
+ }
+
+ public function getTargetType(): string
+ {
+ return $this->targetType;
+ }
+
+ public function getTargetQuery(): string
+ {
+ return $this->targetQuery;
+ }
+
+ public function getPositionInOriginalQuery(): int
+ {
+ return $this->positionInOriginalQuery;
+ }
+}
diff --git a/src/Model/QueryLanguage/SubQueryResultList.php b/src/Model/QueryLanguage/SubQueryResultList.php
new file mode 100644
index 00000000..1783a15a
--- /dev/null
+++ b/src/Model/QueryLanguage/SubQueryResultList.php
@@ -0,0 +1,47 @@
+subQueryResults[$subQueryId] = new ArrayOfPositiveIntegers($ids);
+ }
+
+ public function getSubQueryResult(string $subQueryId): array
+ {
+ if (empty($this->subQueryResults[$subQueryId])) {
+ throw new ValueError(
+ sprintf('SubQueryResult with id "%s" not contained in result list', $subQueryId)
+ );
+ }
+
+ return $this->subQueryResults[$subQueryId]->getValue();
+ }
+}
diff --git a/src/Model/Search/Modifier/QueryLanguage/PqlFilter.php b/src/Model/Search/Modifier/QueryLanguage/PqlFilter.php
new file mode 100644
index 00000000..987ad0a7
--- /dev/null
+++ b/src/Model/Search/Modifier/QueryLanguage/PqlFilter.php
@@ -0,0 +1,32 @@
+query;
+ }
+}
diff --git a/src/Model/SearchIndex/IndexEntity.php b/src/Model/SearchIndex/IndexEntity.php
new file mode 100644
index 00000000..55879791
--- /dev/null
+++ b/src/Model/SearchIndex/IndexEntity.php
@@ -0,0 +1,44 @@
+entityName;
+ }
+
+ public function getIndexName(): string
+ {
+ return $this->indexName;
+ }
+
+ public function getIndexType(): ?IndexType
+ {
+ return $this->indexType;
+ }
+}
diff --git a/src/PimcoreGenericDataIndexBundle.php b/src/PimcoreGenericDataIndexBundle.php
index 7b225f24..09f5e277 100644
--- a/src/PimcoreGenericDataIndexBundle.php
+++ b/src/PimcoreGenericDataIndexBundle.php
@@ -19,7 +19,7 @@
use Pimcore\Bundle\GenericDataIndexBundle\Attribute\OpenSearch\AsSearchModifierHandler;
use Pimcore\Bundle\GenericDataIndexBundle\DependencyInjection\Compiler\SearchModifierHandlerPass;
use Pimcore\Bundle\GenericDataIndexBundle\DependencyInjection\Compiler\ServiceLocatorPass;
-use Pimcore\Bundle\GenericDataIndexBundle\Enum\DependencyInjection\CompilerPassTag;
+use Pimcore\Bundle\GenericDataIndexBundle\Enum\DependencyInjection\ServiceTag;
use Pimcore\Bundle\OpenSearchClientBundle\PimcoreOpenSearchClientBundle;
use Pimcore\Bundle\StaticResolverBundle\PimcoreStaticResolverBundle;
use Pimcore\Extension\Bundle\AbstractPimcoreBundle;
@@ -79,7 +79,7 @@ static function (
? $reflector->getName()
: '__invoke';
- $definition->addTag(CompilerPassTag::SEARCH_MODIFIER_HANDLER->value, [
+ $definition->addTag(ServiceTag::SEARCH_MODIFIER_HANDLER->value, [
'method' => $method,
]);
}
diff --git a/src/QueryLanguage/LexerInterface.php b/src/QueryLanguage/LexerInterface.php
new file mode 100644
index 00000000..aa8910b7
--- /dev/null
+++ b/src/QueryLanguage/LexerInterface.php
@@ -0,0 +1,24 @@
+"|">="|"<="|"LIKE"
+ *
+ * VALUE = INTEGER | FLOAT | "'" STRING "'" | '"' STRING '"'
+ *
+ * QUERY_STRING_QUERY = 'QUERY("' STRING '")'
+ */
+class Lexer extends AbstractLexer implements LexerInterface
+{
+ private const REGEX_FIELD_NAME = '[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*';
+
+ //const REGEX_RELATION_FIELD = '[a-zA-Z_]\w*(?:\:[a-zA-Z_]\w*)(?:\.[a-zA-Z_]\w*)*';
+ private const REGEX_RELATION_FIELD = self::REGEX_FIELD_NAME . '(?:\:[a-zA-Z_]\w*)(?:\.[a-zA-Z_]\w*)+';
+
+ private const REGEX_QUERY_STRING = 'query\(\"(?:.*?)\"\)';
+
+ private const REGEX_NUMBERS = '(?:[0-9]+(?:[\.][0-9]+)*)(?:e[+-]?[0-9]+)?';
+
+ private const REGEX_STRING_SINGLE_QUOTE = "'(?:[^']|'')*'";
+
+ private const REGEX_STRING_DOUBLE_QUOTE = '"(?:[^"]|"")*"';
+
+ private const REGEX_OPERATOR = '>=|<=|=|>|<|like';
+
+ private const REGEX_PARANTHESES = '\(|\)';
+
+ /**
+ * Lexical catchable patterns.
+ */
+ protected function getCatchablePatterns(): array
+ {
+ return [
+ self::REGEX_QUERY_STRING,
+ self::REGEX_RELATION_FIELD,
+ self::REGEX_FIELD_NAME,
+ self::REGEX_NUMBERS,
+ self::REGEX_STRING_SINGLE_QUOTE,
+ self::REGEX_STRING_DOUBLE_QUOTE,
+ self::REGEX_OPERATOR,
+ self::REGEX_PARANTHESES,
+ ];
+ }
+
+ /**
+ * Lexical non-catchable patterns.
+ */
+ protected function getNonCatchablePatterns(): array
+ {
+ return ['\s+', '(.)'];
+ }
+
+ /**
+ * Retrieve token type. Also processes the token value if necessary.
+ */
+ protected function getType(&$value): QueryTokenType
+ {
+ $tokenType = QueryTokenType::T_NONE;
+
+ // Check for specific words or characters to assign token types
+ switch (true) {
+ case is_numeric($value):
+ $tokenType = $this->isIntegerString($value) ? QueryTokenType::T_INTEGER : QueryTokenType::T_FLOAT;
+
+ break;
+ case strlen($value)>1 && in_array($value[0], ["'", '"']) && $value[strlen($value)-1] === $value[0]:
+ $value = substr($value, 1, -1);
+ $value = str_replace(["''", '""'], ["'", '"'], $value);
+ $tokenType = QueryTokenType::T_STRING;
+
+ break;
+ case str_starts_with(strtolower($value), 'query("'):
+ $value = substr($value, 7, -2);
+ $tokenType = QueryTokenType::T_QUERY_STRING;
+
+ break;
+ case $value === '(':
+ $tokenType = QueryTokenType::T_LPAREN;
+
+ break;
+ case $value === ')':
+ $tokenType = QueryTokenType::T_RPAREN;
+
+ break;
+ case strtolower($value) === 'and':
+ $tokenType = QueryTokenType::T_AND;
+
+ break;
+ case strtolower($value) === 'or':
+ $tokenType = QueryTokenType::T_OR;
+
+ break;
+ case $value === '=':
+ $tokenType = QueryTokenType::T_EQ;
+
+ break;
+ case $value === '>':
+ $tokenType = QueryTokenType::T_GT;
+
+ break;
+ case $value === '<':
+ $tokenType = QueryTokenType::T_LT;
+
+ break;
+ case $value === '>=':
+ $tokenType = QueryTokenType::T_GTE;
+
+ break;
+ case $value === '<=':
+ $tokenType = QueryTokenType::T_LTE;
+
+ break;
+ case strtolower($value) === 'like':
+ $tokenType = QueryTokenType::T_LIKE;
+
+ break;
+ case preg_match('#' . self::REGEX_RELATION_FIELD . '#', $value):
+ $tokenType = QueryTokenType::T_RELATION_FIELD;
+
+ break;
+ case preg_match('#' . self::REGEX_FIELD_NAME . '#', $value):
+ $tokenType = QueryTokenType::T_FIELDNAME;
+
+ break;
+ }
+
+ return $tokenType;
+ }
+
+ public function getTokens(): array
+ {
+ $tokens = [];
+ $this->moveNext();
+ while ($this->lookahead !== null) {
+ //p_r("Token: " . (string)$this->lookahead['type']->value . " - Value: " . $this->lookahead['value']);
+ $tokens[] = $this->lookahead;
+ $this->moveNext();
+ }
+
+ return $tokens;
+ }
+
+ public function setQuery(string $query): void
+ {
+ $this->setInput($query);
+ }
+
+ private function isIntegerString(string $value): bool
+ {
+ return ctype_digit($value)
+ || (strlen($value) > 1 && $value[0] === '-' && ctype_digit(substr($value, 1)));
+ }
+}
diff --git a/src/QueryLanguage/Pql/Parser.php b/src/QueryLanguage/Pql/Parser.php
new file mode 100644
index 00000000..4a4a255e
--- /dev/null
+++ b/src/QueryLanguage/Pql/Parser.php
@@ -0,0 +1,333 @@
+pqlAdapter,
+ $this->indexEntityService,
+ $query,
+ $tokens,
+ $indexMapping
+ );
+ }
+
+ private function currentToken(): ?Token
+ {
+ return $this->tokens[$this->index] ?? null;
+ }
+
+ private function advance(): void
+ {
+ ++$this->index;
+ }
+
+ /**
+ * @throws ParsingException
+ */
+ private function validateCurrentTokenNotEmpty(): void
+ {
+ if ($this->currentToken() === null) {
+ $this->throwParsingException('some token', 'end of input. Seems query is truncated');
+ }
+ }
+
+ /**
+ * @throws ParsingException
+ */
+ private function expectRightParenthesis(): void
+ {
+ $this->validateCurrentTokenNotEmpty();
+ $token = $this->currentToken();
+ if (!$token || !$token->isA(QueryTokenType::T_RPAREN)) {
+ $this->throwParsingException(
+ 'token type `' . QueryTokenType::T_RPAREN->value . '`',
+ '`' . ($token['type']->value ?? 'null') . '`'
+ );
+ }
+ $this->advance();
+ }
+
+ /**
+ * @throws ParsingException
+ */
+ private function parseCondition(array &$subQueries): array|ParseResultSubQuery
+ {
+ $expr = $this->parseExpression($subQueries);
+ while ($token = $this->currentToken()) {
+ $this->validateCurrentTokenNotEmpty(); // Ensure the loop hasn't encountered unexpected end of input
+ if ($token->isA(QueryTokenType::T_AND, QueryTokenType::T_OR)) {
+ $this->advance(); // Skip the logical operator
+ $rightExpr = $this->parseExpression($subQueries);
+ if ($token->isA(QueryTokenType::T_AND)) {
+ $expr = ['bool' => ['must' => [$expr, $rightExpr]]];
+ } else {
+ $expr = ['bool' => ['should' => [$expr, $rightExpr], 'minimum_should_match' => 1]];
+ }
+ } else {
+ break;
+ }
+ }
+
+ return $expr;
+ }
+
+ /**
+ * @throws ParsingException
+ */
+ private function parseExpression(array &$subQueries): array|ParseResultSubQuery
+ {
+ $this->validateCurrentTokenNotEmpty(); // Check before attempting to parse the expression
+ $token = $this->currentToken();
+
+ if ($token?->isA(QueryTokenType::T_LPAREN)) {
+ $this->advance(); // Skip '('
+ $expr = $this->parseCondition($subQueries);
+ $this->expectRightParenthesis(); // Ensure ')' is present
+
+ return $expr;
+ }
+
+ if ($token?->isA(QueryTokenType::T_QUERY_STRING)) {
+ $this->advance();
+
+ return $this->pqlAdapter->translateToQueryStringQuery($token?->value); //@phpstan-ignore-line
+ }
+
+ return $this->parseComparison($subQueries);
+ }
+
+ /**
+ * @throws ParsingException
+ */
+ private function parseComparison(array &$subQueries): array|ParseResultSubQuery
+ {
+ $this->validateCurrentTokenNotEmpty();
+
+ if (!$this->currentToken() || !$this->currentToken()->isA(...self::FIELD_NAME_TOKENS)) {
+ $this->throwParsingException('a field name', '`' . ($this->currentToken()['value'] ?? 'null') . '`');
+ }
+
+ /** @var Token $fieldToken */
+ $fieldToken = $this->currentToken();
+ $fieldType = $fieldToken['type'];
+ $field = $fieldToken['value'];
+ $this->advance(); // Move to operator
+ $this->validateCurrentTokenNotEmpty();
+
+ $operatorToken = $this->currentToken();
+
+ if ($operatorToken === null || !$operatorToken->isA(...self::OPERATOR_TOKENS)) {
+ $this->throwParsingException('a comparison operator', '`' . ($operatorToken['value'] ?? 'null') . '`');
+ }
+
+ $this->advance(); // Move to value
+ $this->validateCurrentTokenNotEmpty();
+
+ // Adjusting expectation for the value type to include both strings and numerics
+ $valueToken = $this->currentToken();
+ if (!$valueToken || !$valueToken->isA(QueryTokenType::T_STRING, ...self::NUMERIC_TOKENS)) {
+ $this->throwParsingException('a string or numeric value', '`' . ($valueToken['value'] ?? 'null') . '`');
+ }
+
+ $this->advance(); // Prepare for next
+
+ if($fieldType === QueryTokenType::T_RELATION_FIELD) {
+ return $this->createSubQuery($subQueries, $field, $fieldToken, $operatorToken, $valueToken);
+ }
+
+ $operatorTokenType = $operatorToken->type;
+ if (!$operatorTokenType instanceof QueryTokenType) {
+ $this->throwParsingException(QueryTokenType::class, get_debug_type($operatorTokenType));
+ }
+
+ $field = $this->handleFieldName($fieldToken, $field, $this->indexMapping, null);
+
+ /** @var QueryTokenType $operatorTokenType */
+ $value = $valueToken->isA(...self::NUMERIC_TOKENS)
+ ? $this->stringToNumber($valueToken->value)
+ : $valueToken->value;
+
+ return $this->pqlAdapter->translateOperatorToSearchQuery($operatorTokenType, $field, $value);
+ }
+
+ /**
+ * @throws ParsingException
+ */
+ private function handleFieldName(
+ Token $fieldToken,
+ string $fieldName,
+ array $indexMapping,
+ ?IndexEntity $targetEntity
+ ): string {
+ $originalFieldName = $fieldName;
+ $fieldName = $this->pqlAdapter->transformFieldName($fieldName, $indexMapping, $targetEntity);
+
+ if (empty($indexMapping)) {
+ return $fieldName;
+ }
+
+ $errorMessage = $this->pqlAdapter->validateFieldName(
+ $originalFieldName,
+ $fieldName,
+ $indexMapping,
+ $targetEntity
+ );
+
+ if ($errorMessage) {
+ $this->throwParsingException(
+ $targetEntity ? 'a valid relation field name' : 'a valid field name',
+ '`' . $originalFieldName . '`',
+ $errorMessage,
+ $fieldToken
+ );
+ }
+
+ return $fieldName;
+ }
+
+ private function stringToNumber(string $string): int|float
+ {
+ if (!is_numeric($string)) {
+ return 0;
+ }
+
+ return str_contains($string, '.') ? (float)$string : (int)$string;
+ }
+
+ /**
+ * @throws ParsingException
+ */
+ private function createSubQuery(
+ array &$subQueries,
+ string $field,
+ Token $fieldToken,
+ Token $operatorToken,
+ Token $valueToken
+ ): ParseResultSubQuery {
+
+ $subQueryId = uniqid('subquery_', true);
+ $fieldParts = explode(':', $field);
+ [$relationFieldPath, $targetPath] = $fieldParts;
+
+ $targetPathParts = explode('.', $targetPath);
+
+ $targetType = array_shift($targetPathParts);
+ $targetFieldname = implode('.', $targetPathParts);
+
+ $value = $valueToken->value;
+ if ($valueToken->type === QueryTokenType::T_STRING) {
+ $value = '"' . $value . '"';
+ }
+
+ $relationFieldPath = $this->handleFieldName(
+ $fieldToken,
+ $relationFieldPath,
+ $this->indexMapping,
+ $this->indexEntityService->getByEntityName($targetType)
+ );
+
+ $subQuery = new ParseResultSubQuery(
+ $subQueryId,
+ $relationFieldPath,
+ $targetType,
+ $targetFieldname . ' ' . $operatorToken->value . ' ' . $value,
+ $fieldToken->position + strlen($field) - strlen($targetFieldname),
+ );
+
+ $subQueries[$subQueryId] = $subQuery;
+
+ return $subQuery;
+
+ }
+
+ /**
+ * @throws ParsingException
+ */
+ public function parse(): ParseResult
+ {
+ $subQueries = [];
+ $query = $this->parseCondition($subQueries);
+
+ if($token = $this->currentToken()) {
+ $this->throwParsingException('end of input', '`' . ($token['value'] ?? 'null') . '`');
+ }
+
+ return new ParseResult($query, $subQueries);
+ }
+
+ /**
+ * @throws ParsingException
+ */
+ private function throwParsingException(
+ string $expected,
+ string $found,
+ ?string $message = null,
+ ?Token $token = null
+ ): void {
+ $token = $token ?? $this->currentToken();
+
+ throw new ParsingException($this->query, $expected, $found, $token, $message);
+ }
+}
diff --git a/src/QueryLanguage/Pql/Processor.php b/src/QueryLanguage/Pql/Processor.php
new file mode 100644
index 00000000..697b8270
--- /dev/null
+++ b/src/QueryLanguage/Pql/Processor.php
@@ -0,0 +1,70 @@
+lexer->setQuery($query);
+ $tokens = $this->lexer->getTokens();
+
+ $parseResult = $this->parser
+ ->apply($query, $tokens, $this->searchIndexService->getMapping($indexEntity->getIndexName()))
+ ->parse();
+
+ $resultQuery = $parseResult->getQuery();
+
+ $subQueryResults = $this->pqlAdapter->processSubQueries($this, $query, $parseResult->getSubQueries());
+
+ if ($resultQuery instanceof ParseResultSubQuery) {
+ return $this->pqlAdapter->transformSubQuery($resultQuery, $subQueryResults);
+ }
+
+ $pqlAdapter = $this->pqlAdapter;
+ array_walk_recursive(
+ $resultQuery,
+ static function (&$value) use ($subQueryResults, $pqlAdapter) {
+ if ($value instanceof ParseResultSubQuery) {
+ $value = $pqlAdapter->transformSubQuery($value, $subQueryResults);
+ }
+ }
+ );
+
+ return $resultQuery;
+ }
+}
diff --git a/src/QueryLanguage/ProcessorInterface.php b/src/QueryLanguage/ProcessorInterface.php
new file mode 100644
index 00000000..16caf776
--- /dev/null
+++ b/src/QueryLanguage/ProcessorInterface.php
@@ -0,0 +1,28 @@
+fieldPathExistsInMapping($fieldPath, $indexMapping['mappings'])) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function fieldPathExistsInMapping(string $fieldPath, array $mapping): bool
+ {
+ $fieldPathParts = explode('.', $fieldPath, 2);
+ $field = $fieldPathParts[0];
+ $subField = $fieldPathParts[1] ?? null;
+
+ if (array_key_exists($field, $mapping['properties'] ?? [])) {
+ return empty($subField) || $this->fieldPathExistsInMapping($subField, $mapping['properties'][$field]);
+ }
+
+ if (array_key_exists($field, $mapping['fields'] ?? [])) {
+ return empty($subField) || $this->fieldPathExistsInMapping($subField, $mapping['fields'][$field]);
+ }
+
+ return false;
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/MappingAnalyzerServiceInterface.php b/src/SearchIndexAdapter/OpenSearch/MappingAnalyzerServiceInterface.php
new file mode 100644
index 00000000..da09e9cf
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/MappingAnalyzerServiceInterface.php
@@ -0,0 +1,25 @@
+openSearchClient->indices()->putMapping($params);
}
+ public function getMapping(string $indexName): array
+ {
+ return $this->openSearchClient->indices()->getMapping(['index' => $indexName]);
+ }
+
public function countByAttributeValue(string $indexName, string $attribute, string $value): int
{
$countResult = $this->openSearchClient->search([
diff --git a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/AssetMetadataDefaultLanguageTransformer.php b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/AssetMetadataDefaultLanguageTransformer.php
new file mode 100644
index 00000000..1419a9b2
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/AssetMetadataDefaultLanguageTransformer.php
@@ -0,0 +1,62 @@
+mappingAnalyzerService->fieldPathExists(SystemField::FILE_SIZE->getPath(), $indexMapping)) {
+ return null;
+ }
+
+ if (!$this->mappingAnalyzerService->fieldPathExists($fieldName, $indexMapping)) {
+ return null;
+ }
+
+ $fullFieldName = $fieldName . '.' . MappingProperty::NOT_LOCALIZED_KEY;
+ if ($this->mappingAnalyzerService->fieldPathExists($fullFieldName, $indexMapping)) {
+ return $fullFieldName;
+ }
+
+ return null;
+ }
+
+ public function stopPropagation(): bool
+ {
+ return false;
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/FieldCategoryTransformer.php b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/FieldCategoryTransformer.php
new file mode 100644
index 00000000..0b09863c
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/FieldCategoryTransformer.php
@@ -0,0 +1,60 @@
+mappingAnalyzerService->fieldPathExists($fieldName, $indexMapping)) {
+ return null;
+ }
+
+ $fieldCategories = [FieldCategory::STANDARD_FIELDS, FieldCategory::SYSTEM_FIELDS, FieldCategory::CUSTOM_FIELDS];
+ foreach ($fieldCategories as $fieldCategory) {
+ $prefixedFieldName = $fieldCategory->value . '.' . $fieldName;
+ if ($this->mappingAnalyzerService->fieldPathExists($prefixedFieldName, $indexMapping)) {
+ return $prefixedFieldName;
+ }
+ }
+
+ return null;
+ }
+
+ public function stopPropagation(): bool
+ {
+ return false;
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/IdTransformer.php b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/IdTransformer.php
new file mode 100644
index 00000000..d2b7f3a1
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/IdTransformer.php
@@ -0,0 +1,55 @@
+mappingAnalyzerService->fieldPathExists($fieldName, $indexMapping)) {
+ return null;
+ }
+
+ $fullFieldName = $fieldName . '.id';
+ if ($this->mappingAnalyzerService->fieldPathExists($fullFieldName, $indexMapping)) {
+ return $fullFieldName;
+ }
+
+ return null;
+ }
+
+ public function stopPropagation(): bool
+ {
+ return true;
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/ImageGalleryTransformer.php b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/ImageGalleryTransformer.php
new file mode 100644
index 00000000..434d23b8
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/ImageGalleryTransformer.php
@@ -0,0 +1,60 @@
+mappingAnalyzerService->fieldPathExists($fieldName, $indexMapping)) {
+ return null;
+ }
+
+ if ($targetEntity && $targetEntity->getIndexType() !== IndexType::ASSET) {
+ return null;
+ }
+
+ $fullFieldName = $fieldName . '.assets';
+ if ($this->mappingAnalyzerService->fieldPathExists($fullFieldName, $indexMapping)) {
+ return $fullFieldName;
+ }
+
+ return null;
+ }
+
+ public function stopPropagation(): bool
+ {
+ return true;
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/KeywordTransformer.php b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/KeywordTransformer.php
new file mode 100644
index 00000000..a16310a5
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/KeywordTransformer.php
@@ -0,0 +1,55 @@
+mappingAnalyzerService->fieldPathExists($fieldName, $indexMapping)) {
+ return null;
+ }
+
+ $fullFieldName = $fieldName . '.keyword';
+ if ($this->mappingAnalyzerService->fieldPathExists($fullFieldName, $indexMapping)) {
+ return $fullFieldName;
+ }
+
+ return null;
+ }
+
+ public function stopPropagation(): bool
+ {
+ return true;
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/RelationsTransformer.php b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/RelationsTransformer.php
new file mode 100644
index 00000000..f4377637
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/RelationsTransformer.php
@@ -0,0 +1,67 @@
+mappingAnalyzerService->fieldPathExists($fieldName, $indexMapping)) {
+ return null;
+ }
+
+ $addon = match($targetEntity->getIndexType()) {
+ IndexType::DATA_OBJECT => 'object',
+ IndexType::ASSET => 'asset',
+ IndexType::DOCUMENT => 'document',
+ default => null
+ };
+
+ if ($addon === null) {
+ return null;
+ }
+
+ $fullFieldName = $fieldName . '.' . $addon;
+ if ($this->mappingAnalyzerService->fieldPathExists($fullFieldName, $indexMapping)) {
+ return $fullFieldName;
+ }
+
+ return null;
+ }
+
+ public function stopPropagation(): bool
+ {
+ return true;
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformerInterface.php b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformerInterface.php
new file mode 100644
index 00000000..9d6d107f
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformerInterface.php
@@ -0,0 +1,36 @@
+mappingAnalyzerService->fieldPathExists($fieldName, $indexMapping)) {
+ return 'Field `' . $originalFieldName . '` not found';
+ }
+
+ return null;
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameValidator/LocalizedFieldValidator.php b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameValidator/LocalizedFieldValidator.php
new file mode 100644
index 00000000..440d5bd8
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameValidator/LocalizedFieldValidator.php
@@ -0,0 +1,55 @@
+defaultLocale = $defaultLanguage ?? Tool::getDefaultLanguage();
+ }
+
+ public function validateFieldName(
+ string $originalFieldName,
+ string $fieldName,
+ array $indexMapping,
+ ?IndexEntity $targetEntity = null
+ ): ?string {
+ $defaultLocaleSubField = $fieldName . '.' . $this->defaultLocale;
+ if ($this->mappingAnalyzerService->fieldPathExists($defaultLocaleSubField, $indexMapping)) {
+ return sprintf(
+ 'Field `%s` is localized - please specify a language (e.g. `%s.%s`)',
+ $originalFieldName,
+ $originalFieldName,
+ $this->defaultLocale
+ );
+ }
+
+ return null;
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameValidator/RelationValidator.php b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameValidator/RelationValidator.php
new file mode 100644
index 00000000..8cb805a2
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameValidator/RelationValidator.php
@@ -0,0 +1,66 @@
+mappingAnalyzerService->fieldPathExists($relationField, $indexMapping)) {
+ $isValidRelationField = true;
+
+ break;
+ }
+ }
+
+ if (!$isValidRelationField) {
+ return sprintf(
+ 'Field `%s` is not a valid relation field.',
+ $originalFieldName
+ );
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameValidatorInterface.php b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameValidatorInterface.php
new file mode 100644
index 00000000..cde5c0d7
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameValidatorInterface.php
@@ -0,0 +1,35 @@
+value)]
+ private iterable $fieldNameTransformers,
+ #[TaggedIterator(ServiceTag::PQL_FIELD_NAME_VALIDATOR->value)]
+ private iterable $fieldNameValidators,
+ ) {
+ }
+
+ public function translateOperatorToSearchQuery(QueryTokenType $operator, string $field, mixed $value): array
+ {
+ // term query works for keyword fields only
+ if ($operator === QueryTokenType::T_EQ && !str_ends_with($field, '.keyword')) {
+ return ['match' => [$field => $value]];
+ }
+
+ return match($operator) {
+ QueryTokenType::T_EQ => ['term' => [$field => $value]],
+ QueryTokenType::T_GT => ['range' => [$field => ['gt' => $value]]],
+ QueryTokenType::T_LT => ['range' => [$field => ['lt' => $value]]],
+ QueryTokenType::T_GTE => ['range' => [$field => ['gte' => $value]]],
+ QueryTokenType::T_LTE => ['range' => [$field => ['lte' => $value]]],
+ QueryTokenType::T_LIKE => ['wildcard' => [$field => ['value' => $value, 'case_insensitive' => true]]],
+ default => throw new InvalidArgumentException('Unknown operator: ' . $operator->value)
+ };
+ }
+
+ public function translateToQueryStringQuery(string $query): array
+ {
+ return ['query_string' => ['query' => $query]];
+ }
+
+ public function processSubQueries(
+ ProcessorInterface $processor,
+ string $originalQuery,
+ array $subQueries
+ ): SubQueryResultList {
+ return $this->subQueriesProcessor->processSubQueries($processor, $originalQuery, $subQueries);
+ }
+
+ public function transformSubQuery(ParseResultSubQuery $subQuery, SubQueryResultList $subQueryResults): array
+ {
+ $field = $subQuery->getRelationFieldPath();
+
+ return [
+ 'terms' => [
+ $field => $subQueryResults->getSubQueryResult($subQuery->getSubQueryId()),
+ ],
+ ];
+ }
+
+ public function transformFieldName(string $fieldName, array $indexMapping, ?IndexEntity $targetEntity): string
+ {
+ /** @var FieldNameTransformerInterface $transformer */
+ foreach($this->fieldNameTransformers as $transformer) {
+ if ($transformedFieldName = $transformer->transformFieldName($fieldName, $indexMapping, $targetEntity)) {
+ $fieldName = $transformedFieldName;
+ if ($transformer->stopPropagation()) {
+ break;
+ }
+ }
+ }
+
+ return $fieldName;
+ }
+
+ public function validateFieldName(
+ string $originalFieldName,
+ string $fieldName,
+ array $indexMapping,
+ ?IndexEntity $targetEntity = null
+ ): ?string {
+ /** @var FieldNameValidatorInterface $validator */
+ foreach ($this->fieldNameValidators as $validator) {
+ $errorMessage = $validator->validateFieldName($originalFieldName, $fieldName, $indexMapping, $targetEntity);
+ if ($errorMessage) {
+ return $errorMessage;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/SubQueriesProcessor.php b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/SubQueriesProcessor.php
new file mode 100644
index 00000000..9062e28b
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/SubQueriesProcessor.php
@@ -0,0 +1,95 @@
+indexEntityService->getByEntityName($subQuery->getTargetType());
+
+ if (!$this->searchIndexService->existsAlias($indexEntity->getIndexName())) {
+ throw new ParsingException(
+ $originalQuery,
+ 'a valid entity name',
+ '`' . $subQuery->getTargetType(). '`',
+ null,
+ null,
+ $subQuery->getPositionInOriginalQuery() - strlen($subQuery->getTargetType()) - 1,
+ );
+ }
+
+ try {
+ $query = $processor->process(
+ $subQuery->getTargetQuery(),
+ $indexEntity,
+ );
+ } catch(ParsingException $e) {
+ throw new ParsingException(
+ $originalQuery,
+ $e->getExpected(),
+ $e->getFound(),
+ $e->getToken(),
+ $e->getMessage(),
+ $subQuery->getPositionInOriginalQuery() + $e->getPosition(),
+ $e
+ );
+ }
+
+ $search = new Search();
+ $search
+ ->addQuery(new BoolQuery([ConditionType::FILTER->value => $query]));
+
+ $list->addResult(
+ $subQuery->getSubQueryId(),
+ $this->fetchIdsBySearchService->fetchAllIds(
+ $search,
+ $indexEntity->getIndexName()
+ )
+ );
+ }
+
+ return $list;
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/SubQueriesProcessorInterface.php b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/SubQueriesProcessorInterface.php
new file mode 100644
index 00000000..0f0973a6
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/SubQueriesProcessorInterface.php
@@ -0,0 +1,39 @@
+setSortList(new FieldSortList([new FieldSort(SystemField::ID->getPath())]));
+ }
+
+ if ($search->getSortList()->isEmpty()) {
+ throw new InvalidArgumentException('Search must have a sort defined to be able to fetch all ids');
+ }
+
+ return $this->doFetchIds($search, $indexName);
+ }
+
+ private function doFetchIds(OpenSearchSearchInterface $search, string $indexName, ?array $searchAfter = null): array
+ {
+ $search->setFrom(0);
+ $search->setSize($this->getPageSize());
+ $search->setSource(false);
+ $search->setSearchAfter($searchAfter);
+ $searchResult = $this->searchIndexService->search($search, $indexName);
+ $ids = $searchResult->getIds();
+
+ $lastHit = $searchResult->getLastHit();
+ if ($lastHit && (count($ids) === $this->getPageSize())) {
+ return array_merge($ids, $this->doFetchIds($search, $indexName, $lastHit->getSort()));
+ }
+
+ return $ids;
+ }
+
+ private function getPageSize(): int
+ {
+ $maxResultWindow = $this->searchIndexConfigService->getIndexSettings()['max_result_window'];
+
+ return min($maxResultWindow, 10000);
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/Search/FetchIdsBySearchServiceInterface.php b/src/SearchIndexAdapter/OpenSearch/Search/FetchIdsBySearchServiceInterface.php
new file mode 100644
index 00000000..db10dc81
--- /dev/null
+++ b/src/SearchIndexAdapter/OpenSearch/Search/FetchIdsBySearchServiceInterface.php
@@ -0,0 +1,24 @@
+queryLanguageProcessor->process(
+ $pql->getQuery(),
+ $this->indexEntityService->getByIndexName(
+ $this->indexNameResolver->resolveIndexName($context->getOriginalSearch())
+ )
+ );
+
+ $context->getSearch()->addQuery(
+ new BoolQuery([ConditionType::MUST->value => $query])
+ );
+ }
+}
diff --git a/src/SearchIndexAdapter/OpenSearch/Search/Modifier/SearchModifierService.php b/src/SearchIndexAdapter/OpenSearch/Search/Modifier/SearchModifierService.php
index 81d23db5..4a2d6699 100644
--- a/src/SearchIndexAdapter/OpenSearch/Search/Modifier/SearchModifierService.php
+++ b/src/SearchIndexAdapter/OpenSearch/Search/Modifier/SearchModifierService.php
@@ -77,7 +77,7 @@ public function applyModifiersFromSearch(
SearchInterface $search,
AdapterSearchInterface $adapterSearch
): void {
- $context = new SearchModifierContext($adapterSearch);
+ $context = new SearchModifierContext($adapterSearch, $search);
foreach ($search->getModifiers() as $modifier) {
$this->applyModifier($modifier, $context);
diff --git a/src/SearchIndexAdapter/QueryLanguage/PqlAdapterInterface.php b/src/SearchIndexAdapter/QueryLanguage/PqlAdapterInterface.php
new file mode 100644
index 00000000..53486aeb
--- /dev/null
+++ b/src/SearchIndexAdapter/QueryLanguage/PqlAdapterInterface.php
@@ -0,0 +1,63 @@
+ throw new InvalidElementTypeException('Invalid element type: ' . $type)
};
}
+
+ public function classDefinitionExists(string $name): bool
+ {
+ try {
+ if ($this->connection->fetchOne('SELECT id FROM classes where name=?', [$name])) {
+ return true;
+ }
+ } catch (Exception) {
+ // do nothing
+ }
+
+ return false;
+ }
}
diff --git a/src/Service/ElementServiceInterface.php b/src/Service/ElementServiceInterface.php
index 00fef529..a4d3cecb 100644
--- a/src/Service/ElementServiceInterface.php
+++ b/src/Service/ElementServiceInterface.php
@@ -32,4 +32,6 @@ interface ElementServiceInterface
* @throws InvalidElementTypeException
*/
public function getElementByType(int $id, string $type): Asset|AbstractObject|Document|null;
+
+ public function classDefinitionExists(string $name): bool;
}
diff --git a/src/Service/SearchIndex/IndexEntityService.php b/src/Service/SearchIndex/IndexEntityService.php
new file mode 100644
index 00000000..12889777
--- /dev/null
+++ b/src/Service/SearchIndex/IndexEntityService.php
@@ -0,0 +1,68 @@
+searchIndexConfigService->getIndexName($entityName),
+ $this->getIndexType($entityName)
+ );
+ }
+
+ public function getByIndexName(string $indexName): IndexEntity
+ {
+ return $this->getByEntityName(
+ str_replace($this->searchIndexConfigService->getIndexPrefix(), '', $indexName)
+ );
+ }
+
+ private function getIndexType(string $entityName): ?IndexType
+ {
+ $entityName = strtolower($entityName);
+
+ $indexType = null;
+ if (IndexName::ASSET->value === $entityName) {
+ $indexType = IndexType::ASSET;
+ } elseif (IndexName::DOCUMENT->value === $entityName) {
+ $indexType = IndexType::DOCUMENT;
+ } elseif (IndexName::DATA_OBJECT->value === $entityName) {
+ $indexType = IndexType::DATA_OBJECT;
+ } elseif ($this->elementService->classDefinitionExists($entityName)) {
+ $indexType = IndexType::DATA_OBJECT;
+ }
+
+ return $indexType;
+ }
+}
diff --git a/src/Service/SearchIndex/IndexEntityServiceInterface.php b/src/Service/SearchIndex/IndexEntityServiceInterface.php
new file mode 100644
index 00000000..03e9931e
--- /dev/null
+++ b/src/Service/SearchIndex/IndexEntityServiceInterface.php
@@ -0,0 +1,26 @@
+tester->enableSynchronousProcessing();
+ }
+
+ protected function _after()
+ {
+ TestHelper::cleanUp();
+ $this->tester->flushIndex();
+ $this->tester->cleanupIndex();
+ $this->tester->flushIndex();
+ }
+
+ // tests
+ public function testPqlFilter()
+ {
+ /** @var Unittest $object1 */
+ $object1 = TestHelper::createEmptyObject();
+ /** @var Unittest $object2 */
+ $object2 = TestHelper::createEmptyObject();
+
+ $object1
+ ->setInput('test1')
+ ->setNumber(10)
+ ->save()
+ ;
+
+ $object2
+ ->setInput('test2')
+ ->setNumber(20)
+ ->setMultihref([$object1])
+ ->save()
+ ;
+
+ /** @var DataObjectSearchServiceInterface $searchService */
+ $searchService = $this->tester->grabService('generic-data-index.test.service.data-object-search-service');
+ /** @var SearchProviderInterface $searchProvider */
+ $searchProvider = $this->tester->grabService(SearchProviderInterface::class);
+
+ $testCases = [
+ 'input = "test1"' => [$object1->getId()],
+ 'input like "test*"' => [$object1->getId(), $object2->getId()],
+ 'input like "t*1"' => [$object1->getId()],
+ 'input like "tes?1"' => [$object1->getId()],
+ 'input like "Tes?1"' => [$object1->getId()],
+ 'input like "test?1"' => [],
+ 'input like "notfound*"' => [],
+
+ 'number > 15' => [$object2->getId()],
+ 'number >= 10' => [$object1->getId(), $object2->getId()],
+ 'number < 15' => [$object1->getId()],
+ 'number <= 20' => [$object1->getId(), $object2->getId()],
+ 'number < 20.1' => [$object1->getId(), $object2->getId()],
+ 'number = 10' => [$object1->getId()],
+
+ 'number = 10 and input = "test1"' => [$object1->getId()],
+ 'number > 10 and number < 21' => [$object2->getId()],
+ 'number = 10 and input = "test2"' => [],
+ 'number = 10 or input = "test2"' => [$object1->getId(), $object2->getId()],
+ 'number = 10 or input = "test3"' => [$object1->getId()],
+ '(number = 10 and input = "test1")' => [$object1->getId()],
+ '(number = 10 and input = "test1") or number = 20' => [$object1->getId(), $object2->getId()],
+ 'input = "foo" or ((number = 10 and input = "test1") or number = 20)' => [$object1->getId(), $object2->getId()],
+
+ 'Query("standard_fields.input:(test1 or test2)")' => [$object1->getId(), $object2->getId()],
+ '(Query("standard_fields.input:(test1 or test2)") and number <=20)' => [$object1->getId(), $object2->getId()],
+ '(Query("standard_fields.input:(test1 or test2)") and number <20)' => [$object1->getId()],
+ 'Query("standard_fields.input:test1")' => [$object1->getId()],
+ 'Query("standard_fields.input:foo")' => [],
+
+ 'multihref:Unittest.input = "test1"' => [$object2->getId()],
+ 'multihref:Unittest.input = "test2"' => [],
+ '(multihref:Unittest.input = "test2" or input ="test1")' => [$object1->getId()],
+ ];
+
+ foreach ($testCases as $query => $expectedIds) {
+ $dataObjectSearch = $searchProvider
+ ->createDataObjectSearch()
+ ->addModifier(new PqlFilter($query))
+ ->setClassDefinition($object1->getClass())
+ ;
+ $searchResult = $searchService->search($dataObjectSearch);
+ $this->assertCount(count($expectedIds), $searchResult->getItems(), $query);
+ $this->assertIdArrayEquals($expectedIds, $searchResult->getIds(), $query);
+ }
+ }
+
+ private function assertIdArrayEquals(array $ids1, array $ids2, string $query)
+ {
+ sort($ids1);
+ sort($ids2);
+ $this->assertEquals($ids1, $ids2, $query);
+ }
+}
diff --git a/tests/Unit/Model/OpenSearch/Search/SearchModifierContextTest.php b/tests/Unit/Model/OpenSearch/Search/SearchModifierContextTest.php
index ede4351d..29e7a4e0 100644
--- a/tests/Unit/Model/OpenSearch/Search/SearchModifierContextTest.php
+++ b/tests/Unit/Model/OpenSearch/Search/SearchModifierContextTest.php
@@ -19,6 +19,7 @@
use Codeception\Test\Unit;
use Pimcore\Bundle\GenericDataIndexBundle\Model\OpenSearch\Modifier\SearchModifierContext;
use Pimcore\Bundle\GenericDataIndexBundle\Model\OpenSearch\OpenSearchSearchInterface;
+use Pimcore\Bundle\GenericDataIndexBundle\Model\Search\Interfaces\SearchInterface;
/**
* @internal
@@ -28,8 +29,10 @@ final class SearchModifierContextTest extends Unit
public function testGetSearch(): void
{
$searchMock = $this->makeEmpty(OpenSearchSearchInterface::class);
- $searchModifierContext = new SearchModifierContext($searchMock);
+ $assetSearchMock = $this->makeEmpty(SearchInterface::class);
+ $searchModifierContext = new SearchModifierContext($searchMock, $assetSearchMock);
$this->assertSame($searchMock, $searchModifierContext->getSearch());
+ $this->assertSame($assetSearchMock, $searchModifierContext->getOriginalSearch());
}
}
diff --git a/tests/Unit/QueryLanguage/Pql/LexerTest.php b/tests/Unit/QueryLanguage/Pql/LexerTest.php
new file mode 100644
index 00000000..ccd3f483
--- /dev/null
+++ b/tests/Unit/QueryLanguage/Pql/LexerTest.php
@@ -0,0 +1,322 @@
+ [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ],
+ 'my_field LIKE "foo*"' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_LIKE, 'value' => 'LIKE'],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo*'],
+ ],
+ 'my_field >= 42' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_GTE, 'value' => '>='],
+ ['type' => QueryTokenType::T_INTEGER, 'value' => '42'],
+ ],
+ 'my_field <= 42' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_LTE, 'value' => '<='],
+ ['type' => QueryTokenType::T_INTEGER, 'value' => '42'],
+ ],
+ 'my_field > 42' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_GT, 'value' => '>'],
+ ['type' => QueryTokenType::T_INTEGER, 'value' => '42'],
+ ],
+ 'my_field < 42' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_LT, 'value' => '<'],
+ ['type' => QueryTokenType::T_INTEGER, 'value' => '42'],
+ ],
+ ];
+
+ foreach ($testCases as $testCase => $expected) {
+ $lexer->setQuery($testCase);
+ $tokens = $lexer->getTokens();
+ $this->assertTokens($expected, $tokens, $testCase);
+ }
+ }
+
+ public function testGetTokensValueTypes(): void
+ {
+ $lexer = new Lexer();
+
+ $testCases = [
+ 'my_field = "foo"' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ],
+ 'my_field = 42' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_INTEGER, 'value' => '42'],
+ ],
+ 'my_field = 42.42' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_FLOAT, 'value' => '42.42'],
+ ],
+ ];
+
+ foreach ($testCases as $testCase => $expected) {
+ $lexer->setQuery($testCase);
+ $tokens = $lexer->getTokens();
+ $this->assertTokens($expected, $tokens, $testCase);
+ }
+ }
+
+ public function testGetTokensStringValue(): void
+ {
+ $lexer = new Lexer();
+
+ $testCases = [
+ 'my_field = "foo"' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ],
+ "my_field = 'foo'" => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ],
+ ];
+
+ foreach ($testCases as $testCase => $expected) {
+ $lexer->setQuery($testCase);
+ $tokens = $lexer->getTokens();
+ $this->assertTokens($expected, $tokens, $testCase);
+ }
+ }
+
+ public function testGetTokensQueryString(): void
+ {
+ $lexer = new Lexer();
+
+ $testCases = [
+ 'Query("standard_fields.color:(red or blue)")' => [
+ ['type' => QueryTokenType::T_QUERY_STRING, 'value' => 'standard_fields.color:(red or blue)'],
+ ],
+ 'price > 100 and Query("standard_fields.color:(red or blue)")' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'price'],
+ ['type' => QueryTokenType::T_GT, 'value' => '>'],
+ ['type' => QueryTokenType::T_INTEGER, 'value' => '100'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_QUERY_STRING, 'value' => 'standard_fields.color:(red or blue)'],
+ ],
+ 'Query("standard_fields.color:(red or blue)") and age < 1970' => [
+ ['type' => QueryTokenType::T_QUERY_STRING, 'value' => 'standard_fields.color:(red or blue)'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'age'],
+ ['type' => QueryTokenType::T_LT, 'value' => '<'],
+ ['type' => QueryTokenType::T_INTEGER, 'value' => '1970'],
+ ],
+ ];
+
+ foreach ($testCases as $testCase => $expected) {
+ $lexer->setQuery($testCase);
+ $tokens = $lexer->getTokens();
+ $this->assertTokens($expected, $tokens, $testCase);
+ }
+ }
+
+ public function testGetTokensCombined(): void
+ {
+ $lexer = new Lexer();
+
+ $testCases = [
+ 'my_field = "foo"' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ],
+ "my_field = 'foo'" => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ],
+ '(my_field = "foo" or name = "bar")' => [
+ ['type' => QueryTokenType::T_LPAREN, 'value' => '('],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ['type' => QueryTokenType::T_OR, 'value' => 'or'],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'name'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'bar'],
+ ['type' => QueryTokenType::T_RPAREN, 'value' => ')'],
+ ],
+ 'my_field = "foo" and name = "bar"' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'name'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'bar'],
+ ],
+ 'my_field = "foo" and name = "bar" and age = 42' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'name'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'bar'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'age'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_INTEGER, 'value' => '42'],
+ ],
+ 'my_field = "foo" and name = "bar" and age = 42 and price = 42.42' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'name'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'bar'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'age'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_INTEGER, 'value' => '42'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'price'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_FLOAT, 'value' => '42.42'],
+ ],
+ 'my_field = "foo" and (name = "bar" or age = 42)' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_LPAREN, 'value' => '('],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'name'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'bar'],
+ ['type' => QueryTokenType::T_OR, 'value' => 'or'],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'age'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_INTEGER, 'value' => '42'],
+ ['type' => QueryTokenType::T_RPAREN, 'value' => ')'],
+ ],
+ 'my_field = "foo" and (name = "bar" or age = 42) and price = 42.42' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_LPAREN, 'value' => '('],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'name'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'bar'],
+ ['type' => QueryTokenType::T_OR, 'value' => 'or'],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'age'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_INTEGER, 'value' => '42'],
+ ['type' => QueryTokenType::T_RPAREN, 'value' => ')'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'price'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_FLOAT, 'value' => '42.42'],
+ ],
+ 'my_field = "foo" and (name = "bar" or (age > 42 and price > 42.42))' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_LPAREN, 'value' => '('],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'name'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'bar'],
+ ['type' => QueryTokenType::T_OR, 'value' => 'or'],
+ ['type' => QueryTokenType::T_LPAREN, 'value' => '('],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'age'],
+ ['type' => QueryTokenType::T_GT, 'value' => '>'],
+ ['type' => QueryTokenType::T_INTEGER, 'value' => '42'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'price'],
+ ['type' => QueryTokenType::T_GT, 'value' => '>'],
+ ['type' => QueryTokenType::T_FLOAT, 'value' => '42.42'],
+ ['type' => QueryTokenType::T_RPAREN, 'value' => ')'],
+ ['type' => QueryTokenType::T_RPAREN, 'value' => ')'],
+ ],
+ 'my_field LIKE "foo"' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_LIKE, 'value' => 'LIKE'],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ],
+ 'my_field LIKE "foo" and name = "bar"' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_LIKE, 'value' => 'LIKE'],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo'],
+ ['type' => QueryTokenType::T_AND, 'value' => 'and'],
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'name'],
+ ['type' => QueryTokenType::T_EQ, 'value' => '='],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'bar'],
+ ],
+ 'my_field LIKE "foo*"' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'],
+ ['type' => QueryTokenType::T_LIKE, 'value' => 'LIKE'],
+ ['type' => QueryTokenType::T_STRING, 'value' => 'foo*'],
+ ],
+ 'age >= 42' => [
+ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'age'],
+ ['type' => QueryTokenType::T_GTE, 'value' => '>='],
+ ['type' => QueryTokenType::T_INTEGER, 'value' => '42'],
+ ],
+ ];
+
+ foreach ($testCases as $testCase => $expected) {
+ $lexer->setQuery($testCase);
+ $tokens = $lexer->getTokens();
+ $this->assertTokens($expected, $tokens, $testCase);
+ }
+ }
+
+ /**
+ * @param Token[] $tokens
+ */
+ private function assertTokens(array $expected, array $tokens, string $query): void
+ {
+ $this->assertCount(count($expected), $tokens, $query);
+
+ foreach ($expected as $index => $expect) {
+ $this->assertSame($expect['type'], $tokens[$index]->type, $query);
+ $this->assertSame($expect['value'], $tokens[$index]->value, $query);
+ }
+ }
+}
diff --git a/tests/Unit/QueryLanguage/Pql/ParserTest.php b/tests/Unit/QueryLanguage/Pql/ParserTest.php
new file mode 100644
index 00000000..2d266653
--- /dev/null
+++ b/tests/Unit/QueryLanguage/Pql/ParserTest.php
@@ -0,0 +1,519 @@
+assertQueryResult(
+ 'color = "red"',
+ [
+ 'match' => ['color' => 'red'],
+ ]
+ );
+
+ $this->assertQueryResult(
+ 'price > 27',
+ [
+ 'range' => ['price' => ['gt' => 27]],
+ ]
+ );
+
+ $this->assertQueryResult(
+ 'price < 30',
+ [
+ 'range' => ['price' => ['lt' => 30]],
+ ]
+ );
+
+ $this->assertQueryResult(
+ 'price >= 27',
+ [
+ 'range' => ['price' => ['gte' => 27]],
+ ]
+ );
+
+ $this->assertQueryResult(
+ 'price <= 30',
+ [
+ 'range' => ['price' => ['lte' => 30]],
+ ]
+ );
+
+ $this->assertQueryResult(
+ 'name like "Jaguar*"',
+ [
+ 'wildcard' => ['name' => ['value' => 'Jaguar*', 'case_insensitive' => true]],
+ ]
+ );
+
+ $this->assertQueryResult(
+ 'name like "Jag*ar*"',
+ [
+ 'wildcard' => ['name' => ['value' => 'Jag*ar*', 'case_insensitive' => true]],
+ ]
+ );
+ $this->assertQueryResult(
+ 'name like "Jag?ar"',
+ [
+ 'wildcard' => ['name' => ['value' => 'Jag?ar', 'case_insensitive' => true]],
+ ]
+ );
+
+ $this->assertQueryResult(
+ 'name like "Jaguar"',
+ [
+ 'wildcard' => ['name' => ['value' => 'Jaguar', 'case_insensitive' => true]],
+ ]
+ );
+ }
+
+ public function testParseCondition(): void
+ {
+ $this->assertQueryResult(
+ 'color = "red" or series = "E-Type"',
+ [
+ 'bool' => [
+ 'should' => [
+ ['match' => ['color' => 'red']],
+ ['match' => ['series' => 'E-Type']],
+ ],
+ 'minimum_should_match' => 1,
+ ],
+ ]
+ );
+
+ $this->assertQueryResult(
+ 'color = "red" and series = "E-Type"',
+ [
+ 'bool' => [
+ 'must' => [
+ ['match' => ['color' => 'red']],
+ ['match' => ['series' => 'E-Type']],
+ ],
+ ],
+ ]
+ );
+ }
+
+ public function testParseExpression(): void
+ {
+
+ $this->assertQueryResult(
+ '(color = "red" or series = "E-Type")',
+ [
+ 'bool' => [
+ 'should' => [
+ ['match' => ['color' => 'red']],
+ ['match' => ['series' => 'E-Type']],
+ ],
+ 'minimum_should_match' => 1,
+ ],
+ ]
+ );
+
+ $this->assertQueryResult(
+ '(color = "red" or series = "E-Type") and name = "Jaguar"',
+
+ [
+ 'bool' => [
+ 'must' => [
+ [
+ 'bool' => [
+ 'should' => [
+ ['match' => ['color' => 'red']],
+ ['match' => ['series' => 'E-Type']],
+ ],
+ 'minimum_should_match' => 1,
+ ],
+ ],
+ ['match' => ['name' => 'Jaguar']],
+ ],
+ ],
+ ]
+ );
+ $this->assertQueryResult(
+ 'color = "red" or series = "E-Type" and name = "Jaguar"',
+
+ [
+ 'bool' => [
+ 'must' => [
+ [
+ 'bool' => [
+ 'should' => [
+ ['match' => ['color' => 'red']],
+ ['match' => ['series' => 'E-Type']],
+ ],
+ 'minimum_should_match' => 1,
+ ],
+ ],
+ ['match' => ['name' => 'Jaguar']],
+ ],
+ ],
+ ]
+ );
+
+ $this->assertQueryResult(
+ 'color = "red" or (series = "E-Type" and name = "Jaguar")',
+ [
+ 'bool' => [
+ 'should' => [
+ ['match' => ['color' => 'red']],
+ [
+ 'bool' => [
+ 'must' => [
+ ['match' => ['series' => 'E-Type']],
+ ['match' => ['name' => 'Jaguar']],
+ ],
+ ],
+ ],
+ ],
+ 'minimum_should_match' => 1,
+ ],
+ ]
+ );
+
+ $this->assertQueryResult(
+ 'color = "red" or ((series = "E-Type" and name = "Jaguar") or price > 100)',
+ [
+ 'bool' => [
+ 'should' => [
+ ['match' => ['color' => 'red']],
+ [
+ 'bool' => [
+ 'should' => [
+ [
+ 'bool' => [
+ 'must' => [
+ ['match' => ['series' => 'E-Type']],
+ ['match' => ['name' => 'Jaguar']],
+ ],
+ ],
+ ],
+ ['range' => ['price' => ['gt' => 100]]],
+ ],
+ 'minimum_should_match' => 1,
+ ],
+ ],
+ ],
+ 'minimum_should_match' => 1,
+ ],
+ ]
+ );
+ }
+
+ public function testQueryString(): void
+ {
+ $this->assertQueryResult(
+ 'Query("color:(red or blue)")',
+ [
+ 'query_string' => [
+ 'query' => 'color:(red or blue)',
+ ],
+ ]
+ );
+
+ $this->assertQueryResult(
+ 'series="Jaguar" and Query("color:(red or blue)")',
+ [
+ 'bool' => [
+ 'must' => [
+ ['match' => ['series' => 'Jaguar']],
+ [
+ 'query_string' => [
+ 'query' => 'color:(red or blue)',
+ ],
+ ],
+ ],
+ ],
+ ]
+ );
+
+ $this->assertQueryResult(
+ '(Query("color:(red or blue)") and price>1000.23)',
+ [
+ 'bool' => [
+ 'must' => [
+ [
+ 'query_string' => [
+ 'query' => 'color:(red or blue)',
+ ],
+ ],
+ ['range' => ['price' => ['gt' => 1000.23]]],
+ ],
+ ],
+ ]
+ );
+ }
+
+ public function testCreateSubQuery(): void
+ {
+ $this->assertSubQueryResult(
+ 'manufactorer:Manufactorer.name = "Jaguar"',
+ [
+ 'relationFieldPath' => 'manufactorer',
+ 'targetType' => 'Manufactorer',
+ 'targetQuery' => 'name = "Jaguar"',
+ ]
+ );
+
+ $this->assertSubQueryResult(
+ 'mainImage:Asset.id > 17',
+ [
+ 'relationFieldPath' => 'mainImage',
+ 'targetType' => 'Asset',
+ 'targetQuery' => 'id > 17',
+ ]
+ );
+
+ $this->assertSubQueriesResult(
+ 'manufactorer:Manufactorer.name = "Jaguar" or mainImage:Asset.id > 17',
+ [
+ 'bool' => [
+ 'should' => [
+ [
+ 'relationFieldPath' => 'manufactorer',
+ 'targetType' => 'Manufactorer',
+ 'targetQuery' => 'name = "Jaguar"',
+ ],
+ [
+ 'relationFieldPath' => 'mainImage',
+ 'targetType' => 'Asset',
+ 'targetQuery' => 'id > 17',
+ ],
+ ],
+ 'minimum_should_match' => 1,
+ ],
+ ],
+ [
+ [
+ 'relationFieldPath' => 'manufactorer',
+ 'targetType' => 'Manufactorer',
+ 'targetQuery' => 'name = "Jaguar"',
+ ],
+ [
+ 'relationFieldPath' => 'mainImage',
+ 'targetType' => 'Asset',
+ 'targetQuery' => 'id > 17',
+ ],
+ ]
+ );
+
+ $this->assertSubQueriesResult(
+ 'age < 1980 and ((manufactorer:Manufactorer.name = "Jaguar" or age < 1970) and mainImage:Asset.id > 17)',
+ [
+ 'bool' => [
+ 'must' => [
+ ['range' => ['age' => ['lt' => 1980]]],
+ [
+ 'bool' => [
+ 'must' => [
+ [
+ 'bool' => [
+ 'should' => [
+ [
+ 'relationFieldPath' => 'manufactorer',
+ 'targetType' => 'Manufactorer',
+ 'targetQuery' => 'name = "Jaguar"',
+ ],
+ ['range' => ['age' => ['lt' => 1970]]],
+ ],
+ 'minimum_should_match' => 1,
+ ],
+ ],
+ [
+ 'relationFieldPath' => 'mainImage',
+ 'targetType' => 'Asset',
+ 'targetQuery' => 'id > 17',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ [
+ [
+ 'relationFieldPath' => 'manufactorer',
+ 'targetType' => 'Manufactorer',
+ 'targetQuery' => 'name = "Jaguar"',
+ ],
+ [
+ 'relationFieldPath' => 'mainImage',
+ 'targetType' => 'Asset',
+ 'targetQuery' => 'id > 17',
+ ],
+ ]
+ );
+ }
+
+ public function testParseError1(): void
+ {
+ $this->expectException(ParsingException::class);
+ $this->expectExceptionMessage('end of input. Seems query is truncated');
+ $this->parseQuery('color = "red" and');
+ }
+
+ public function testParseError2(): void
+ {
+ $this->expectException(ParsingException::class);
+ $this->expectExceptionMessage('Expected a field name, found `or`');
+ $this->parseQuery('color = "red" and or');
+ }
+
+ public function testParseError3(): void
+ {
+ $this->expectException(ParsingException::class);
+ $this->expectExceptionMessage('end of input. Seems query is truncated');
+ $this->parseQuery('color = "red" and (age < 1970 or series = "E-Type"');
+ }
+
+ public function testParseError4(): void
+ {
+ $this->expectException(ParsingException::class);
+ $this->expectExceptionMessage('Expected a string or numeric value, found `red`');
+ $this->parseQuery('color = red');
+ }
+
+ public function testParseError5(): void
+ {
+ $this->expectException(ParsingException::class);
+ $this->expectExceptionMessage('Expected a string or numeric value, found `(`');
+ $this->parseQuery('color = (Color.name = red)');
+ }
+
+ public function testParseError6(): void
+ {
+ $this->expectException(ParsingException::class);
+ $this->expectExceptionMessage('Expected a comparison operator, found `:`');
+ $this->parseQuery('manufacturer:Manufactorer = "Jaguar"');
+ }
+
+ public function testParseError7(): void
+ {
+ $this->expectException(ParsingException::class);
+ $this->expectExceptionMessage('Expected a string or numeric value, found `"`');
+ $this->parseQuery('manufacturer:Manufactorer.name = "Jaguar');
+ }
+
+ private function parseQuery(string $query): void
+ {
+ $parser = $this->createParser();
+ $lexer = new Lexer();
+ $lexer->setQuery($query);
+ $tokens = $lexer->getTokens();
+ $parser = $parser->apply($query, $tokens, []);
+ $parser->parse();
+ }
+
+ private function assertQueryResult(string $query, array $result): void
+ {
+ $parser = $this->createParser();
+ $lexer = new Lexer();
+ $lexer->setQuery($query);
+ $tokens = $lexer->getTokens();
+ $parser = $parser->apply($query, $tokens, []);
+ $parseResult = $parser->parse();
+ $this->assertSame($result, $parseResult->getQuery());
+ $this->assertEmpty($parseResult->getSubQueries());
+ }
+
+ private function assertSubQueryResult(string $query, array $subQuery): void
+ {
+ $parser = $this->createParser();
+ $lexer = new Lexer();
+ $lexer->setQuery($query);
+ $tokens = $lexer->getTokens();
+ $parser = $parser->apply($query, $tokens, []);
+ $parseResult = $parser->parse();
+ $this->assertSame($this->subQueryToArray($parseResult->getQuery()), $subQuery);
+ $this->assertSame($this->subQueriesToArray($parseResult), [$subQuery]);
+ }
+
+ private function assertSubQueriesResult(string $queryString, array $query, array $subQueries): void
+ {
+ $parser = $this->createParser();
+ $lexer = new Lexer();
+ $lexer->setQuery($queryString);
+ $tokens = $lexer->getTokens();
+ $parser = $parser->apply($queryString, $tokens, []);
+ $parseResult = $parser->parse();
+ $resultQuery = $parseResult->getQuery();
+ array_walk_recursive(
+ $resultQuery,
+ function (&$value) {
+ if ($value instanceof ParseResultSubQuery) {
+ $value = $this->subQueryToArray($value);
+ }
+ }
+ );
+ $this->assertSame($query, $resultQuery);
+ $this->assertSame($subQueries, $this->subQueriesToArray($parseResult));
+ }
+
+ private function subQueriesToArray(ParseResult $parseResult): array
+ {
+ return array_values(array_map(
+ fn (ParseResultSubQuery $query) => $this->subQueryToArray($query),
+ $parseResult->getSubQueries()
+ ));
+ }
+
+ private function subQueryToArray(ParseResultSubQuery $query): array
+ {
+ return [
+ 'relationFieldPath' => $query->getRelationFieldPath(),
+ 'targetType' => $query->getTargetType(),
+ 'targetQuery' => $query->getTargetQuery(),
+ ];
+ }
+
+ private function createParser(): Parser
+ {
+ $indexEntityService = new IndexEntityService(
+ $this->makeEmpty(SearchIndexConfigServiceInterface::class),
+ $this->makeEmpty(ElementServiceInterface::class),
+ );
+
+ $pqlAdapter = new PqlAdapter(
+ $this->makeEmpty(SubQueriesProcessorInterface::class),
+ [],
+ []
+ );
+
+ return new Parser(
+ $pqlAdapter,
+ $indexEntityService,
+ );
+ }
+}
diff --git a/tests/Unit/SearchIndexAdapter/OpenSearch/MappingAnalyzerServiceTest.php b/tests/Unit/SearchIndexAdapter/OpenSearch/MappingAnalyzerServiceTest.php
new file mode 100644
index 00000000..352d0c1c
--- /dev/null
+++ b/tests/Unit/SearchIndexAdapter/OpenSearch/MappingAnalyzerServiceTest.php
@@ -0,0 +1,106 @@
+assertTrue($mappingAnalyzerService->fieldPathExists('system_fields', $this->getTestIndexMappings()));
+ $this->assertTrue($mappingAnalyzerService->fieldPathExists('system_fields.fieldA', $this->getTestIndexMappings()));
+ $this->assertTrue($mappingAnalyzerService->fieldPathExists('system_fields.fieldA.keyword', $this->getTestIndexMappings()));
+ $this->assertTrue($mappingAnalyzerService->fieldPathExists('system_fields', $this->getTestIndexMappings()));
+ $this->assertTrue($mappingAnalyzerService->fieldPathExists('system_fields.fieldB', $this->getTestIndexMappings()));
+ $this->assertTrue($mappingAnalyzerService->fieldPathExists('system_fields.fieldB.keyword', $this->getTestIndexMappings()));
+
+ $this->assertTrue($mappingAnalyzerService->fieldPathExists('standard_fields', $this->getTestIndexMappings()));
+ $this->assertTrue($mappingAnalyzerService->fieldPathExists('standard_fields.field1', $this->getTestIndexMappings()));
+ $this->assertTrue($mappingAnalyzerService->fieldPathExists('standard_fields.field1.keyword', $this->getTestIndexMappings()));
+ $this->assertTrue($mappingAnalyzerService->fieldPathExists('standard_fields', $this->getTestIndexMappings()));
+ $this->assertTrue($mappingAnalyzerService->fieldPathExists('standard_fields.field2', $this->getTestIndexMappings()));
+ $this->assertTrue($mappingAnalyzerService->fieldPathExists('standard_fields.field2.keyword', $this->getTestIndexMappings()));
+
+ $this->assertFalse($mappingAnalyzerService->fieldPathExists('test_fields', $this->getTestIndexMappings()));
+ $this->assertFalse($mappingAnalyzerService->fieldPathExists('standard_fields.field007', $this->getTestIndexMappings()));
+ $this->assertFalse($mappingAnalyzerService->fieldPathExists('standard_fields.field1.keyword.test', $this->getTestIndexMappings()));
+ $this->assertFalse($mappingAnalyzerService->fieldPathExists('standard_fields.field1.test', $this->getTestIndexMappings()));
+ }
+
+ private function getTestIndexMappings(): array
+ {
+ return [
+ 'testindex' => [
+ 'mappings' => [
+ 'properties' => [
+ 'system_fields' => [
+ 'properties' => [
+ 'fieldA' => [
+ 'type' => 'text',
+ 'fields' => [
+ 'keyword' => [
+ 'type' => 'keyword',
+ 'ignore_above' => 256,
+ ],
+ ],
+ ],
+ 'fieldB' => [
+ 'type' => 'text',
+ 'fields' => [
+ 'keyword' => [
+ 'type' => 'keyword',
+ 'ignore_above' => 256,
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'standard_fields' => [
+ 'properties' => [
+ 'field1' => [
+ 'type' => 'text',
+ 'fields' => [
+ 'keyword' => [
+ 'type' => 'keyword',
+ 'ignore_above' => 256,
+ ],
+ ],
+ ],
+ 'field2' => [
+ 'type' => 'text',
+ 'fields' => [
+ 'keyword' => [
+ 'type' => 'keyword',
+ 'ignore_above' => 256,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/AssetMetadataDefaultLanguageTransformerTest.php b/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/AssetMetadataDefaultLanguageTransformerTest.php
new file mode 100644
index 00000000..142f3ae2
--- /dev/null
+++ b/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/AssetMetadataDefaultLanguageTransformerTest.php
@@ -0,0 +1,80 @@
+makeEmpty(MappingAnalyzerServiceInterface::class, [
+ 'fieldPathExists' => function (string $fieldName, array $indexMapping) {
+ return in_array($fieldName, ['system_fields.fileSize', 'metaData', 'metaData.default', 'metaData.en', 'metaData.de']);
+ },
+ ])
+ );
+
+ $this->assertEquals(
+ 'metaData.default',
+ $transformer->transformFieldName('metaData', [], null)
+ );
+
+ $this->assertEquals(
+ null,
+ $transformer->transformFieldName('metaData.en', [], null)
+ );
+ $this->assertEquals(
+ null,
+ $transformer->transformFieldName('metaData.de', [], null)
+ );
+
+ $this->assertEquals(
+ null,
+ $transformer->transformFieldName('foo', [], null)
+ );
+
+ //test in not asset index
+ $transformer = new AssetMetadataDefaultLanguageTransformer(
+ $this->makeEmpty(MappingAnalyzerServiceInterface::class, [
+ 'fieldPathExists' => function (string $fieldName, array $indexMapping) {
+ return in_array($fieldName, ['metaData', 'metaData.default', 'metaData.en', 'metaData.de']);
+ },
+ ])
+ );
+
+ $this->assertEquals(
+ null,
+ $transformer->transformFieldName('metaData', [], null)
+ );
+ }
+
+ public function testStopPropagation(): void
+ {
+ $transformer = new AssetMetadataDefaultLanguageTransformer(
+ $this->createMock(MappingAnalyzerServiceInterface::class)
+ );
+
+ $this->assertFalse($transformer->stopPropagation());
+ }
+}
diff --git a/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/FieldCategoryTransformerTest.php b/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/FieldCategoryTransformerTest.php
new file mode 100644
index 00000000..0cab5973
--- /dev/null
+++ b/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/FieldCategoryTransformerTest.php
@@ -0,0 +1,67 @@
+makeEmpty(MappingAnalyzerServiceInterface::class, [
+ 'fieldPathExists' => function (string $fieldName, array $indexMapping) {
+ return $fieldName === 'system_fields.id' || $fieldName === 'standard_fields.series';
+ },
+ ])
+ );
+
+ $this->assertEquals(
+ 'system_fields.id',
+ $transformer->transformFieldName('id', [], null)
+ );
+
+ $this->assertEquals(
+ null,
+ $transformer->transformFieldName('system_fields.id', [], null)
+ );
+
+ $this->assertEquals(
+ 'standard_fields.series',
+ $transformer->transformFieldName('series', [], null)
+ );
+
+ $this->assertEquals(
+ null,
+ $transformer->transformFieldName('foo', [], null)
+ );
+ }
+
+ public function testStopPropagation(): void
+ {
+ $transformer = new FieldCategoryTransformer(
+ $this->createMock(MappingAnalyzerServiceInterface::class)
+ );
+
+ $this->assertFalse($transformer->stopPropagation());
+ }
+}
diff --git a/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/IdTransformerTest.php b/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/IdTransformerTest.php
new file mode 100644
index 00000000..f0072f70
--- /dev/null
+++ b/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/IdTransformerTest.php
@@ -0,0 +1,62 @@
+makeEmpty(MappingAnalyzerServiceInterface::class, [
+ 'fieldPathExists' => function (string $fieldName, array $indexMapping) {
+ return $fieldName === 'asset' || $fieldName === 'asset.id';
+ },
+ ])
+ );
+
+ $this->assertEquals(
+ 'asset.id',
+ $transformer->transformFieldName('asset', [], null)
+ );
+
+ $this->assertEquals(
+ null,
+ $transformer->transformFieldName('asset.id', [], null)
+ );
+
+ $this->assertEquals(
+ null,
+ $transformer->transformFieldName('document', [], null)
+ );
+ }
+
+ public function testStopPropagation(): void
+ {
+ $transformer = new IdTransformer(
+ $this->createMock(MappingAnalyzerServiceInterface::class)
+ );
+
+ $this->assertTrue($transformer->stopPropagation());
+ }
+}
diff --git a/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/ImageGalleryTransformerTest.php b/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/ImageGalleryTransformerTest.php
new file mode 100644
index 00000000..0a3ea59b
--- /dev/null
+++ b/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/ImageGalleryTransformerTest.php
@@ -0,0 +1,65 @@
+makeEmpty(MappingAnalyzerServiceInterface::class, [
+ 'fieldPathExists' => function (string $fieldName, array $indexMapping) {
+ return $fieldName === 'standard_fields.gallery' || $fieldName === 'standard_fields.gallery.assets';
+ },
+ ])
+ );
+
+ $assetIndexEntity = new IndexEntity('assets', 'assets', IndexType::ASSET);
+
+ $this->assertEquals(
+ 'standard_fields.gallery.assets',
+ $transformer->transformFieldName('standard_fields.gallery', [], $assetIndexEntity)
+ );
+ $this->assertEquals(
+ null,
+ $transformer->transformFieldName('standard_fields.gallery.assets', [], $assetIndexEntity)
+ );
+
+ $this->assertEquals(
+ null,
+ $transformer->transformFieldName('gallery', [], $assetIndexEntity)
+ );
+ }
+
+ public function testStopPropagation(): void
+ {
+ $transformer = new ImageGalleryTransformer(
+ $this->createMock(MappingAnalyzerServiceInterface::class)
+ );
+
+ $this->assertTrue($transformer->stopPropagation());
+ }
+}
diff --git a/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/RelationsTransformerTest.php b/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/RelationsTransformerTest.php
new file mode 100644
index 00000000..05737d3e
--- /dev/null
+++ b/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/FieldNameTransformer/RelationsTransformerTest.php
@@ -0,0 +1,73 @@
+makeEmpty(MappingAnalyzerServiceInterface::class, [
+ 'fieldPathExists' => function (string $fieldName, array $indexMapping) {
+ return in_array($fieldName, ['relation', 'relation.object', 'relation.document', 'relation.asset']);
+ },
+ ])
+ );
+
+ $assetEntity = new IndexEntity('', '', IndexType::ASSET);
+ $documentEntity = new IndexEntity('', '', IndexType::DOCUMENT);
+ $dataObjectEntity = new IndexEntity('', '', IndexType::DATA_OBJECT);
+
+ $this->assertEquals(
+ 'relation.asset',
+ $transformer->transformFieldName('relation', [], $assetEntity)
+ );
+
+ $this->assertEquals(
+ 'relation.document',
+ $transformer->transformFieldName('relation', [], $documentEntity)
+ );
+
+ $this->assertEquals(
+ 'relation.object',
+ $transformer->transformFieldName('relation', [], $dataObjectEntity)
+ );
+
+ $this->assertEquals(
+ null,
+ $transformer->transformFieldName('foo', [], $dataObjectEntity)
+ );
+ }
+
+ public function testStopPropagation(): void
+ {
+ $transformer = new RelationsTransformer(
+ $this->createMock(MappingAnalyzerServiceInterface::class)
+ );
+
+ $this->assertTrue($transformer->stopPropagation());
+ }
+}
diff --git a/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/PqlAdapterTest.php b/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/PqlAdapterTest.php
new file mode 100644
index 00000000..b333fd73
--- /dev/null
+++ b/tests/Unit/SearchIndexAdapter/OpenSearch/QueryLanguage/PqlAdapterTest.php
@@ -0,0 +1,72 @@
+makeEmpty(FieldNameTransformerInterface::class, [
+ 'stopPropagation' => false,
+ 'transformFieldName' => function () {
+ return 'transformer-1';
+ },
+ ]);
+
+ $fieldNameTransformer2 = $this->makeEmpty(FieldNameTransformerInterface::class, [
+ 'stopPropagation' => true,
+ 'transformFieldName' => function () {
+ return 'transformer-2';
+ },
+ ]);
+
+ $pqlAdapter = $this->createPqlAdapter([$fieldNameTransformer1, $fieldNameTransformer2]);
+ $this->assertEquals(
+ 'transformer-2',
+ $pqlAdapter->transformFieldName('test', [], null)
+ );
+
+ $pqlAdapter = $this->createPqlAdapter([$fieldNameTransformer2, $fieldNameTransformer1]);
+ $this->assertEquals(
+ 'transformer-2',
+ $pqlAdapter->transformFieldName('test', [], null)
+ );
+
+ $pqlAdapter = $this->createPqlAdapter([$fieldNameTransformer1]);
+ $this->assertEquals(
+ 'transformer-1',
+ $pqlAdapter->transformFieldName('test', [], null)
+ );
+ }
+
+ private function createPqlAdapter(array $fieldNameTransformers): PqlAdapter
+ {
+ return new PqlAdapter(
+ $this->makeEmpty(SubQueriesProcessorInterface::class),
+ $fieldNameTransformers,
+ []
+ );
+ }
+}