From b8d8f426e69ad75490705d5d41859283f89fccc0 Mon Sep 17 00:00:00 2001 From: Andreas Wagner Date: Wed, 5 Aug 2026 11:04:55 +0200 Subject: [PATCH 1/2] Map calculated value fields according to their configured element type Calculated value fields were always mapped as text/keyword regardless of their configured element type. This made boolean, numeric and date calculated values unusable for term queries, aggregations and range filters in the search index. Add a dedicated CalculatedValueAdapter that maps the field based on the element type (boolean, numeric, date) and falls back to the existing text/keyword mapping for the text-based element types (input, textarea, html). Values are normalized accordingly, since calculators may return loosely typed values. --- .../data-object/field-definition-adapters.yml | 6 +- .../CalculatedValueAdapter.php | 107 +++++++++++++ .../CalculatedValueAdapterTest.php | 145 ++++++++++++++++++ 3 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 src/SearchIndexAdapter/DefaultSearch/DataObject/FieldDefinitionAdapter/CalculatedValueAdapter.php create mode 100644 tests/Unit/SearchIndexAdapter/DataObject/FieldDefinitionAdapter/CalculatedValueAdapterTest.php diff --git a/config/services/search/data-object/field-definition-adapters.yml b/config/services/search/data-object/field-definition-adapters.yml index 67e495000..05a0bd37a 100644 --- a/config/services/search/data-object/field-definition-adapters.yml +++ b/config/services/search/data-object/field-definition-adapters.yml @@ -12,7 +12,6 @@ services: - { name: "pimcore.generic_data_index.data-object.search_index_field_definition", type: "select" } - { name: "pimcore.generic_data_index.data-object.search_index_field_definition", type: "multiselect" } - { name: "pimcore.generic_data_index.data-object.search_index_field_definition", type: "input" } - - { name: "pimcore.generic_data_index.data-object.search_index_field_definition", type: "calculatedValue" } - { name: "pimcore.generic_data_index.data-object.search_index_field_definition", type: "country" } - { name: "pimcore.generic_data_index.data-object.search_index_field_definition", type: "countrymultiselect" } - { name: "pimcore.generic_data_index.data-object.search_index_field_definition", type: "language" } @@ -24,6 +23,11 @@ services: - { name: "pimcore.generic_data_index.data-object.search_index_field_definition", type: "email" } - { name: "pimcore.generic_data_index.data-object.search_index_field_definition", type: "gender" } + Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\DefaultSearch\DataObject\FieldDefinitionAdapter\CalculatedValueAdapter: + shared: false + tags: + - { name: "pimcore.generic_data_index.data-object.search_index_field_definition", type: "calculatedValue" } + Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\DefaultSearch\DataObject\FieldDefinitionAdapter\NumericAdapter: shared: false tags: diff --git a/src/SearchIndexAdapter/DefaultSearch/DataObject/FieldDefinitionAdapter/CalculatedValueAdapter.php b/src/SearchIndexAdapter/DefaultSearch/DataObject/FieldDefinitionAdapter/CalculatedValueAdapter.php new file mode 100644 index 000000000..7d30d9b1a --- /dev/null +++ b/src/SearchIndexAdapter/DefaultSearch/DataObject/FieldDefinitionAdapter/CalculatedValueAdapter.php @@ -0,0 +1,107 @@ +getElementType()) { + self::ELEMENT_TYPE_BOOLEAN => [ + 'type' => AttributeType::BOOLEAN->value, + ], + self::ELEMENT_TYPE_NUMERIC => [ + 'type' => AttributeType::FLOAT->value, + ], + self::ELEMENT_TYPE_DATE => [ + 'type' => AttributeType::DATE->value, + 'format' => 'strict_date_time_no_millis', + ], + default => $this->indexMappingService->getMappingForTextKeyword( + $this->searchIndexConfigService->getSearchAnalyzerAttributes() + ), + }; + } + + public function normalize(mixed $value): mixed + { + return match ($this->getElementType()) { + self::ELEMENT_TYPE_BOOLEAN => $this->normalizeBoolean($value), + self::ELEMENT_TYPE_NUMERIC => is_numeric($value) ? (float) $value : null, + self::ELEMENT_TYPE_DATE => $value instanceof DateTimeInterface + ? $value->format(DateTimeInterface::ATOM) + : null, + default => $this->normalizeText($value), + }; + } + + private function normalizeBoolean(mixed $value): ?bool + { + if ($value === null || is_bool($value)) { + return $value; + } + + return (bool) $value; + } + + private function normalizeText(mixed $value): mixed + { + if (is_string($value) && $value !== '') { + return preg_replace("/src=(['\"])data:[^;]+;base64,.+?\\1/", '', $value); + } + + return parent::normalize($value); + } + + private function getElementType(): ?string + { + $fieldDefinition = $this->getFieldDefinition(); + if (!$fieldDefinition instanceof CalculatedValue) { + return null; + } + + return $fieldDefinition->getElementType(); + } +} diff --git a/tests/Unit/SearchIndexAdapter/DataObject/FieldDefinitionAdapter/CalculatedValueAdapterTest.php b/tests/Unit/SearchIndexAdapter/DataObject/FieldDefinitionAdapter/CalculatedValueAdapterTest.php new file mode 100644 index 000000000..06985b2cc --- /dev/null +++ b/tests/Unit/SearchIndexAdapter/DataObject/FieldDefinitionAdapter/CalculatedValueAdapterTest.php @@ -0,0 +1,145 @@ +createAdapter('boolean'); + + $this->assertSame([ + 'type' => 'boolean', + ], $adapter->getIndexMapping()); + } + + public function testGetSearchIndexMappingForNumericElementType(): void + { + $adapter = $this->createAdapter('numeric'); + + $this->assertSame([ + 'type' => 'float', + ], $adapter->getIndexMapping()); + } + + public function testGetSearchIndexMappingForDateElementType(): void + { + $adapter = $this->createAdapter('date'); + + $this->assertSame([ + 'type' => 'date', + 'format' => 'strict_date_time_no_millis', + ], $adapter->getIndexMapping()); + } + + public function testGetSearchIndexMappingFallsBackToTextKeyword(): void + { + $textKeywordMapping = [ + 'type' => 'text', + 'fields' => [ + 'keyword' => [ + 'type' => 'keyword', + 'ignore_above' => 1024, + ], + ], + ]; + + foreach (['input', 'textarea', 'html'] as $elementType) { + $adapter = $this->createAdapter($elementType, $textKeywordMapping); + + $this->assertSame($textKeywordMapping, $adapter->getIndexMapping()); + } + } + + /** + * Calculators are free to return loosely typed values (e.g. '0'/'1' from + * expression results), which a boolean index mapping would reject. + */ + public function testNormalizeCastsBooleanElementTypeValues(): void + { + $adapter = $this->createAdapter('boolean'); + + $this->assertTrue($adapter->normalize(true)); + $this->assertFalse($adapter->normalize(false)); + $this->assertTrue($adapter->normalize('1')); + $this->assertFalse($adapter->normalize('0')); + $this->assertTrue($adapter->normalize(1)); + $this->assertFalse($adapter->normalize(0)); + $this->assertNull($adapter->normalize(null)); + } + + public function testNormalizeCastsNumericElementTypeValues(): void + { + $adapter = $this->createAdapter('numeric'); + + $this->assertSame(1.5, $adapter->normalize(1.5)); + $this->assertSame(3.0, $adapter->normalize(3)); + $this->assertSame(2.25, $adapter->normalize('2.25')); + $this->assertNull($adapter->normalize('not a number')); + $this->assertNull($adapter->normalize(null)); + } + + public function testNormalizeFormatsDateElementTypeValues(): void + { + $adapter = $this->createAdapter('date'); + + $this->assertSame( + '2024-06-15T10:30:00+00:00', + $adapter->normalize(Carbon::create(2024, 6, 15, 10, 30, 0, 'UTC')) + ); + $this->assertNull($adapter->normalize('2024-06-15')); + $this->assertNull($adapter->normalize(null)); + } + + public function testNormalizeKeepsTextElementTypeBehavior(): void + { + $adapter = $this->createAdapter('html'); + + $this->assertSame( + 'test', + $adapter->normalize('test') + ); + $this->assertSame('plain text', $adapter->normalize('plain text')); + } + + private function createAdapter(string $elementType, array $textKeywordMapping = []): CalculatedValueAdapter + { + $indexMappingServiceMock = $this->makeEmpty(IndexMappingServiceInterface::class, [ + 'getMappingForTextKeyword' => $textKeywordMapping, + ]); + + $adapter = new CalculatedValueAdapter( + $this->makeEmpty(SearchIndexConfigServiceInterface::class), + $this->makeEmpty(FieldDefinitionServiceInterface::class), + $indexMappingServiceMock + ); + + $fieldDefinition = new CalculatedValue(); + $fieldDefinition->setElementType($elementType); + $adapter->setFieldDefinition($fieldDefinition); + + return $adapter; + } +} From abd7cc9a499fdaa1d044d2e504d396613c47db04 Mon Sep 17 00:00:00 2001 From: Andreas Wagner Date: Wed, 5 Aug 2026 11:04:55 +0200 Subject: [PATCH 2/2] Validate recognized boolean representations instead of casting A plain bool cast would index the literal 'false' (and values such as 'off'/'no') as true, silently reversing filters. Use FILTER_VALIDATE_BOOLEAN with FILTER_NULL_ON_FAILURE so unrecognized values become null instead. --- .../FieldDefinitionAdapter/CalculatedValueAdapter.php | 5 ++++- .../FieldDefinitionAdapter/CalculatedValueAdapterTest.php | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/SearchIndexAdapter/DefaultSearch/DataObject/FieldDefinitionAdapter/CalculatedValueAdapter.php b/src/SearchIndexAdapter/DefaultSearch/DataObject/FieldDefinitionAdapter/CalculatedValueAdapter.php index 7d30d9b1a..dd10a1886 100644 --- a/src/SearchIndexAdapter/DefaultSearch/DataObject/FieldDefinitionAdapter/CalculatedValueAdapter.php +++ b/src/SearchIndexAdapter/DefaultSearch/DataObject/FieldDefinitionAdapter/CalculatedValueAdapter.php @@ -83,7 +83,10 @@ private function normalizeBoolean(mixed $value): ?bool return $value; } - return (bool) $value; + // Calculators may return loosely typed values; only recognized boolean + // representations are indexed, anything else becomes null instead of + // silently reversing filters (e.g. 'false' must not turn into true). + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); } private function normalizeText(mixed $value): mixed diff --git a/tests/Unit/SearchIndexAdapter/DataObject/FieldDefinitionAdapter/CalculatedValueAdapterTest.php b/tests/Unit/SearchIndexAdapter/DataObject/FieldDefinitionAdapter/CalculatedValueAdapterTest.php index 06985b2cc..1bef89fa5 100644 --- a/tests/Unit/SearchIndexAdapter/DataObject/FieldDefinitionAdapter/CalculatedValueAdapterTest.php +++ b/tests/Unit/SearchIndexAdapter/DataObject/FieldDefinitionAdapter/CalculatedValueAdapterTest.php @@ -87,6 +87,11 @@ public function testNormalizeCastsBooleanElementTypeValues(): void $this->assertFalse($adapter->normalize('0')); $this->assertTrue($adapter->normalize(1)); $this->assertFalse($adapter->normalize(0)); + $this->assertTrue($adapter->normalize('true')); + $this->assertFalse($adapter->normalize('false')); + $this->assertTrue($adapter->normalize('yes')); + $this->assertFalse($adapter->normalize('no')); + $this->assertNull($adapter->normalize('not a boolean')); $this->assertNull($adapter->normalize(null)); }