From cff582b239c8c13a0a8edaeab7d8fe881928f32c Mon Sep 17 00:00:00 2001
From: Oliver Bartsch <bo@cedev.de>
Date: Thu, 12 Sep 2024 10:03:59 +0200
Subject: [PATCH] [TASK] Consider subtype definition for sub schemata
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

In case a sub schema, e.g. `tt_content.list` makes
use of the "subtypes" feature by defining the
"subtype_value_field", corresponding configuration
("subtypes_addlist" and "subtypes_excludelist") is
now evaluated in the TCA schema and added as further
schema (third level) to the corresponding sub
schema (second level).

This will therefore look like the following:

Schema "tt_content"
    -> Sub Schema "list"
        -> Subtype Schema "tx_blog_pi1"

Via the "subtypes" feature it's only
possible to add or remove existing fields
from the corresponding sub schema.

Also note that the subtype schemata can only
be accessed using the getSubSchema() and
getSubSchemata() methods on a sub schema.
This means calling `$schemaFactory->getSchema('tt_content.list.tx_blog_pi1')`
does not work intentionally!

Resolves: #104929
Releases: main
Change-Id: I10428e1129d5d23acce91cd7756f4f255a45c29b
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/86043
Tested-by: Oliver Bartsch <bo@cedev.de>
Tested-by: Jochen Roth <rothjochen@gmail.com>
Reviewed-by: Benni Mack <benni@typo3.org>
Tested-by: Frank Nägler <frank.naegler@typo3.com>
Reviewed-by: Jochen Roth <rothjochen@gmail.com>
Tested-by: core-ci <typo3@b13.com>
Reviewed-by: Frank Nägler <frank.naegler@typo3.com>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Oliver Bartsch <bo@cedev.de>
---
 .../sysext/core/Classes/Domain/RawRecord.php  |   4 +-
 .../core/Classes/Domain/RecordFactory.php     |  14 +++
 .../sysext/core/Classes/Schema/TcaSchema.php  |  11 ++
 .../core/Classes/Schema/TcaSchemaFactory.php  |  48 ++++++++-
 .../core/Tests/Unit/Domain/RawRecordTest.php  |  66 ++++++++++++
 .../Unit/Schema/TcaSchemaFactoryTest.php      | 102 +++++++++++++++++-
 6 files changed, 239 insertions(+), 6 deletions(-)
 create mode 100644 typo3/sysext/core/Tests/Unit/Domain/RawRecordTest.php

diff --git a/typo3/sysext/core/Classes/Domain/RawRecord.php b/typo3/sysext/core/Classes/Domain/RawRecord.php
index 3f3287b5e61a..c5b9ed02cdf6 100644
--- a/typo3/sysext/core/Classes/Domain/RawRecord.php
+++ b/typo3/sysext/core/Classes/Domain/RawRecord.php
@@ -54,7 +54,7 @@ readonly class RawRecord implements RecordInterface
     public function getRecordType(): ?string
     {
         if (str_contains($this->type, '.')) {
-            return GeneralUtility::revExplode('.', $this->type, 2)[1];
+            return GeneralUtility::trimExplode('.', $this->type, true)[1] ?? null;
         }
         return null;
     }
@@ -62,7 +62,7 @@ readonly class RawRecord implements RecordInterface
     public function getMainType(): string
     {
         if (str_contains($this->type, '.')) {
-            return explode('.', $this->type)[0];
+            return explode('.', $this->type)[0] ?? '';
         }
         return $this->type;
     }
diff --git a/typo3/sysext/core/Classes/Domain/RecordFactory.php b/typo3/sysext/core/Classes/Domain/RecordFactory.php
index 554c034fde88..b3b49aca2e72 100644
--- a/typo3/sysext/core/Classes/Domain/RecordFactory.php
+++ b/typo3/sysext/core/Classes/Domain/RecordFactory.php
@@ -76,6 +76,13 @@ readonly class RecordFactory
         $subSchema = null;
         if ($schema->hasSubSchema($rawRecord->getRecordType() ?? '')) {
             $subSchema = $schema->getSubSchema($rawRecord->getRecordType());
+            // @todo Support of "subtypes" will most likely be deprecated in upcoming versions
+            if ($subSchema->getSubTypeDivisorField() !== null
+                && $rawRecord->has($subSchema->getSubTypeDivisorField()->getName())
+                && isset($subSchema->getSubSchemata()[$rawRecord->get($subSchema->getSubTypeDivisorField()->getName())])
+            ) {
+                $subSchema = $subSchema->getSubSchema($rawRecord->get($subSchema->getSubTypeDivisorField()->getName()));
+            }
         }
 
         // Only use the fields that are defined in the schema
@@ -103,6 +110,13 @@ readonly class RecordFactory
         $subSchema = null;
         if ($schema->hasSubSchema($rawRecord->getRecordType() ?? '')) {
             $subSchema = $schema->getSubSchema($rawRecord->getRecordType());
+            // @todo Support of "subtypes" will most likely be deprecated in upcoming versions
+            if ($subSchema->getSubTypeDivisorField() !== null
+                && $rawRecord->has($subSchema->getSubTypeDivisorField()->getName())
+                && isset($subSchema->getSubSchemata()[$rawRecord->get($subSchema->getSubTypeDivisorField()->getName())])
+            ) {
+                $subSchema = $subSchema->getSubSchema($rawRecord->get($subSchema->getSubTypeDivisorField()->getName()));
+            }
         }
 
         // Only use the fields that are defined in the schema
diff --git a/typo3/sysext/core/Classes/Schema/TcaSchema.php b/typo3/sysext/core/Classes/Schema/TcaSchema.php
index e30ae41f93da..8fa1207c6679 100644
--- a/typo3/sysext/core/Classes/Schema/TcaSchema.php
+++ b/typo3/sysext/core/Classes/Schema/TcaSchema.php
@@ -227,6 +227,17 @@ readonly class TcaSchema implements SchemaInterface
         return null;
     }
 
+    /**
+     * @internal "subtype" is not considered as API of TcaSchema since this feature will most likely be deprecated in upcoming versions
+     */
+    public function getSubTypeDivisorField(): ?FieldTypeInterface
+    {
+        if (isset($this->schemaConfiguration['subtype_value_field']) && isset($this->fields[$this->schemaConfiguration['subtype_value_field']])) {
+            return $this->fields[$this->schemaConfiguration['subtype_value_field']];
+        }
+        return null;
+    }
+
     /**
      * @return PassiveRelation[]
      */
diff --git a/typo3/sysext/core/Classes/Schema/TcaSchemaFactory.php b/typo3/sysext/core/Classes/Schema/TcaSchemaFactory.php
index 42e5b0b9423d..fcb91254383e 100644
--- a/typo3/sysext/core/Classes/Schema/TcaSchemaFactory.php
+++ b/typo3/sysext/core/Classes/Schema/TcaSchemaFactory.php
@@ -211,13 +211,55 @@ class TcaSchemaFactory
 
                     $subSchemaFields[$fieldName] = $field;
                 }
-                $subSchema = new TcaSchema(
+
+                // @todo Support of "subtypes" will most likely be deprecated in upcoming versions
+                $subTypeSchemata = [];
+                if (isset($subSchemaDefinition['subtype_value_field'])
+                    && ($subTypeDivisorField = $subSchemaFields[$subSchemaDefinition['subtype_value_field']] ?? null) !== null
+                ) {
+                    // Add all the sub schema fields first. Afterwards extend this list based
+                    // on "subtypes_addlist" and reduce list based on "subtypes_excludelist".
+                    $subTypeFields = $subSchemaFields;
+                    $subTypes = array_filter(array_map(static fn(array $item) => $item['value'] ?? '', $subTypeDivisorField->getConfiguration()['items'] ?? []));
+                    foreach ($subTypes as $subType) {
+                        // Add fields based on "subtypes_addlist" configuration
+                        if ($subSchemaDefinition['subtypes_addlist'][$subType] ?? false) {
+                            $subTypeAddFields = GeneralUtility::trimExplode(',', (string)$subSchemaDefinition['subtypes_addlist'][$subType], true);
+                            foreach ($subTypeAddFields as $fieldName) {
+                                // Fetch the field from either the sub schema (taking columnsOverrides into account) or
+                                // fall back to the field based on the default configuration. In case field does not
+                                // exists, don't add it since it's not possible to add fields via subtypes, which have
+                                // not been defined beforehand.
+                                $field = $subSchemaFields[$fieldName] ?? $allFields[$fieldName] ?? null;
+                                if ($field === null) {
+                                    continue;
+                                }
+                                $subTypeFields[$fieldName] = $field;
+                            }
+                        }
+                        // Remove fields based on "subtypes_excludelist" configuration
+                        if ($subSchemaDefinition['subtypes_excludelist'][$subType] ?? false) {
+                            $subTypeExcludeFields = GeneralUtility::trimExplode(',', (string)$subSchemaDefinition['subtypes_excludelist'][$subType], true);
+                            foreach ($subTypeExcludeFields as $fieldName) {
+                                unset($subTypeFields[$fieldName]);
+                            }
+                        }
+
+                        $subTypeSchemata[$subType] = new TcaSchema(
+                            $schemaName . '.' . $subSchemaName . '.' . $subType,
+                            new FieldCollection($subTypeFields),
+                            array_replace_recursive($schemaConfiguration, $subSchemaDefinition)
+                        );
+                    }
+                }
+
+                $subSchemata[$subSchemaName] = new TcaSchema(
                     $schemaName . '.' . $subSchemaName,
                     new FieldCollection($subSchemaFields),
                     // Merge parts from the "types" section into the ctrl section of the main schema
-                    array_replace_recursive($schemaConfiguration, $subSchemaDefinition)
+                    array_replace_recursive($schemaConfiguration, $subSchemaDefinition),
+                    $subTypeSchemata !== [] ? new SchemaCollection($subTypeSchemata) : null
                 );
-                $subSchemata[$subSchemaName] = $subSchema;
             }
         }
         $schema = new TcaSchema(
diff --git a/typo3/sysext/core/Tests/Unit/Domain/RawRecordTest.php b/typo3/sysext/core/Tests/Unit/Domain/RawRecordTest.php
new file mode 100644
index 000000000000..0571563dbbbd
--- /dev/null
+++ b/typo3/sysext/core/Tests/Unit/Domain/RawRecordTest.php
@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Tests\Unit\Domain;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use TYPO3\CMS\Core\Domain\RawRecord;
+use TYPO3\CMS\Core\Domain\Record\ComputedProperties;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+final class RawRecordTest extends UnitTestCase
+{
+    public static function getTypeReturnsExpectedValueDataProvider(): iterable
+    {
+        yield 'full type' => [
+            'tt_content',
+            'tt_content',
+            'tt_content',
+            null,
+        ];
+        yield 'record type' => [
+            'tt_content.list',
+            'tt_content.list',
+            'tt_content',
+            'list',
+        ];
+        yield 'sub type is ignored' => [
+            'tt_content.list.tx_blog_pi1',
+            'tt_content.list.tx_blog_pi1',
+            'tt_content',
+            'list',
+        ];
+        yield 'invalid config' => [
+            'tt_content....',
+            'tt_content....',
+            'tt_content',
+            null,
+        ];
+    }
+
+    #[DataProvider('getTypeReturnsExpectedValueDataProvider')]
+    #[Test]
+    public function getTypeReturnsExpectedParts(string $type, string $fullType, string $mainType, ?string $recordType): void
+    {
+        $record = new RawRecord(123, 456, [], $this->createMock(ComputedProperties::class), $type);
+
+        self::assertSame($fullType, $record->getFullType());
+        self::assertSame($mainType, $record->getMainType());
+        self::assertSame($recordType, $record->getRecordType());
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/Schema/TcaSchemaFactoryTest.php b/typo3/sysext/core/Tests/Unit/Schema/TcaSchemaFactoryTest.php
index 69c61191f4bb..a19d56731eb4 100644
--- a/typo3/sysext/core/Tests/Unit/Schema/TcaSchemaFactoryTest.php
+++ b/typo3/sysext/core/Tests/Unit/Schema/TcaSchemaFactoryTest.php
@@ -20,6 +20,7 @@ namespace TYPO3\CMS\Core\Tests\Unit\Schema;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\Test;
 use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend;
+use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface;
 use TYPO3\CMS\Core\Schema\FieldTypeFactory;
 use TYPO3\CMS\Core\Schema\RelationMapBuilder;
 use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
@@ -388,7 +389,7 @@ final class TcaSchemaFactoryTest extends UnitTestCase
     }
 
     #[Test]
-    public function subtypesInfoIsMergedWithMainSchemaInformation(): void
+    public function recordTypesInfoIsMergedWithMainSchemaInformation(): void
     {
         $cacheMock = $this->createMock(PhpFrontend::class);
         $cacheMock->method('has')->with(self::isType('string'))->willReturn(false);
@@ -424,4 +425,103 @@ final class TcaSchemaFactoryTest extends UnitTestCase
         self::assertSame('typeSpecificRenderer', $subSchema->getRawConfiguration()['previewRenderer']);
 
     }
+
+    public static function subtypesConfigurationIsAppliedToSubSchemaDataProvider(): iterable
+    {
+        yield 'No changes in subtype' => [
+            '',
+            '',
+            ['type', 'list_type', 'foo', 'bar'],
+        ];
+        yield 'Add fields' => [
+            'baz',
+            '',
+            ['type', 'list_type', 'foo', 'bar', 'baz'],
+        ];
+        yield 'Remove fields' => [
+            '',
+            'foo',
+            ['type', 'list_type', 'bar'],
+        ];
+        yield 'Add and remove fields' => [
+            'baz',
+            'foo',
+            ['type', 'list_type', 'bar', 'baz'],
+        ];
+        yield 'Unknown field is not added' => [
+            'unknown',
+            '',
+            ['type', 'list_type', 'foo', 'bar'],
+        ];
+    }
+
+    #[DataProvider('subtypesConfigurationIsAppliedToSubSchemaDataProvider')]
+    #[Test]
+    public function subtypesConfigurationIsAppliedToSubSchema(string $addList, string $excludeList, array $fields): void
+    {
+        $cacheMock = $this->createMock(PhpFrontend::class);
+        $cacheMock->method('has')->with(self::isType('string'))->willReturn(false);
+        $subject = new TcaSchemaFactory(
+            new RelationMapBuilder(),
+            new FieldTypeFactory(),
+            '',
+            $cacheMock
+        );
+        $subject->load([
+            'myTable' => [
+                'ctrl' => [
+                    'type' => 'type',
+                ],
+                'columns' => [
+                    'type' => [
+                        'config' => [
+                            'type' => 'select',
+                            'items' => [
+                                ['label' => 'list', 'value' => 'list'],
+                            ],
+                        ],
+                    ],
+                    'list_type' => [
+                        'config' => [
+                            'type' => 'select',
+                            'items' => [
+                                ['label' => 'Blog', 'value' => 'tx_blog_pi1'],
+                            ],
+                        ],
+                    ],
+                    'foo' => [
+                        'config' => [
+                            'type' => 'input',
+                        ],
+                    ],
+                    'bar' => [
+                        'config' => [
+                            'type' => 'input',
+                        ],
+                    ],
+                    'baz' => [
+                        'config' => [
+                            'type' => 'input',
+                        ],
+                    ],
+                ],
+                'types' => [
+                    'list' => [
+                        'showitem' => 'type,list_type,foo,bar',
+                        'subtype_value_field' => 'list_type',
+                        'subtypes_addlist' => [
+                            'tx_blog_pi1' => $addList,
+                        ],
+                        'subtypes_excludelist' => [
+                            'tx_blog_pi1' => $excludeList,
+                        ],
+                    ],
+                ],
+            ],
+        ]);
+
+        $schema = $subject->get('myTable.list');
+        self::assertTrue($schema->hasSubSchema('tx_blog_pi1'));
+        self::assertSame($fields, array_values(array_map(static fn(FieldTypeInterface $field) => $field->getName(), iterator_to_array($schema->getSubSchema('tx_blog_pi1')->getFields()->getIterator()))));
+    }
 }
-- 
GitLab