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..dd10a1886
--- /dev/null
+++ b/src/SearchIndexAdapter/DefaultSearch/DataObject/FieldDefinitionAdapter/CalculatedValueAdapter.php
@@ -0,0 +1,110 @@
+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;
+ }
+
+ // 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
+ {
+ 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..1bef89fa5
--- /dev/null
+++ b/tests/Unit/SearchIndexAdapter/DataObject/FieldDefinitionAdapter/CalculatedValueAdapterTest.php
@@ -0,0 +1,150 @@
+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->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));
+ }
+
+ 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(
+ '
',
+ $adapter->normalize('
')
+ );
+ $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;
+ }
+}