diff --git a/Classes/Hook/CreateMaps2RecordHook.php b/Classes/Hook/CreateMaps2RecordHook.php
index cf71849b1..d914a5c2a 100644
--- a/Classes/Hook/CreateMaps2RecordHook.php
+++ b/Classes/Hook/CreateMaps2RecordHook.php
@@ -20,6 +20,7 @@
use JWeiland\Maps2\Helper\StoragePidHelper;
use JWeiland\Maps2\Service\GeoCodeService;
use JWeiland\Maps2\Service\MapService;
+use JWeiland\Maps2\Tca\ForeignColumn;
use JWeiland\Maps2\Tca\Maps2Registry;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Utility\BackendUtility;
@@ -483,13 +484,14 @@ public function synchronizeColumnsFromForeignRecordWithPoiCollection(
$tableNeedsUpdate = false;
foreach ($columnOptions['synchronizeColumns'] as $synchronizeColumns) {
- if (!$this->isValidSynchronizeConfiguration($synchronizeColumns, $foreignTableName)) {
+ $foreignColumn = $this->getForeignColumnFromSynchronizeConfiguration($synchronizeColumns, $foreignTableName);
+ if (!$foreignColumn instanceof ForeignColumn) {
return false;
}
$queryBuilder = $queryBuilder->set(
$synchronizeColumns['poiCollectionColumnName'],
- $foreignLocationRecord[$synchronizeColumns['foreignColumnName']],
+ $foreignColumn->resolveValue($foreignLocationRecord),
);
$tableNeedsUpdate = true;
}
@@ -506,15 +508,17 @@ public function synchronizeColumnsFromForeignRecordWithPoiCollection(
}
/**
- * This method checks the synchronization options itself and if columns are configured in TCA
+ * Builds the ForeignColumn value object for a single `synchronizeColumns` entry and validates
+ * it against the TCA of the foreign table. `foreignColumnName` may either be a plain column name
+ * or a coalesce/concat configuration array resolving several columns of the foreign table, see
+ * ForeignColumn::createFromConfiguration().
*/
- protected function isValidSynchronizeConfiguration(array $synchronizeColumns, string $foreignTableName): bool
+ protected function getForeignColumnFromSynchronizeConfiguration(array $synchronizeColumns, string $foreignTableName): ?ForeignColumn
{
// Check options itself
if (
!array_key_exists('foreignColumnName', $synchronizeColumns)
|| !array_key_exists('poiCollectionColumnName', $synchronizeColumns)
- || !is_string($synchronizeColumns['foreignColumnName'])
|| !is_string($synchronizeColumns['poiCollectionColumnName'])
) {
$this->messageHelper->addFlashMessage(
@@ -523,26 +527,38 @@ protected function isValidSynchronizeConfiguration(array $synchronizeColumns, st
ContextualFeedbackSeverity::ERROR,
);
- return false;
+ return null;
}
- // Check, if configured foreign columnName is valid in TCA
- $foreignColumnName = $synchronizeColumns['foreignColumnName'];
- if (
- !array_key_exists($foreignTableName, $GLOBALS['TCA'])
- || !array_key_exists($foreignColumnName, $GLOBALS['TCA'][$foreignTableName]['columns'])
- || !is_array($GLOBALS['TCA'][$foreignTableName]['columns'][$foreignColumnName]['config'])
- ) {
+ $foreignColumn = ForeignColumn::createFromConfiguration($synchronizeColumns['foreignColumnName']);
+ if (!$foreignColumn instanceof ForeignColumn) {
$this->messageHelper->addFlashMessage(
- 'Error while trying to synchronize columns of your record with maps2 record. It seems that "' . $foreignTableName . '" is not registered as table or "' . $foreignColumnName . '" is not a valid column in ' . $foreignTableName,
- 'Missing table/column in TCA',
+ 'Please check your Maps registration. The key foreignColumnName has to be a column name or a valid coalesce/concat configuration.',
+ 'Missing registration keys',
ContextualFeedbackSeverity::ERROR,
);
- return false;
+ return null;
}
- return true;
+ // Check, if every configured foreign columnName is valid in TCA
+ foreach ($foreignColumn->getColumnNames() as $foreignColumnName) {
+ if (
+ !array_key_exists($foreignTableName, $GLOBALS['TCA'])
+ || !array_key_exists($foreignColumnName, $GLOBALS['TCA'][$foreignTableName]['columns'])
+ || !is_array($GLOBALS['TCA'][$foreignTableName]['columns'][$foreignColumnName]['config'])
+ ) {
+ $this->messageHelper->addFlashMessage(
+ 'Error while trying to synchronize columns of your record with maps2 record. It seems that "' . $foreignTableName . '" is not registered as table or "' . $foreignColumnName . '" is not a valid column in ' . $foreignTableName,
+ 'Missing table/column in TCA',
+ ContextualFeedbackSeverity::ERROR,
+ );
+
+ return null;
+ }
+ }
+
+ return $foreignColumn;
}
/**
diff --git a/Classes/Tca/ForeignColumn.php b/Classes/Tca/ForeignColumn.php
new file mode 100644
index 000000000..e1d453ee9
--- /dev/null
+++ b/Classes/Tca/ForeignColumn.php
@@ -0,0 +1,154 @@
+ self::createFromConfiguration($column),
+ $columns,
+ )));
+ }
+
+ public function resolveValue(array $record): string
+ {
+ return match ($this->resolveType) {
+ ForeignColumnResolveTypeEnum::SINGLE => trim((string)($record[$this->columnName] ?? '')),
+ ForeignColumnResolveTypeEnum::COALESCE => $this->resolveCoalesce($record),
+ ForeignColumnResolveTypeEnum::CONCAT => $this->resolveConcat($record),
+ };
+ }
+
+ /**
+ * Flat list of every column name involved in this (possibly nested) configuration.
+ * Used to validate against TCA of the foreign table.
+ *
+ * @return string[]
+ */
+ public function getColumnNames(): array
+ {
+ if ($this->resolveType === ForeignColumnResolveTypeEnum::SINGLE) {
+ return [$this->columnName];
+ }
+
+ $columnNames = [];
+ foreach ($this->children as $child) {
+ $columnNames[] = $child->getColumnNames();
+ }
+
+ return array_values(array_unique(array_merge([], ...$columnNames)));
+ }
+
+ private function resolveCoalesce(array $record): string
+ {
+ foreach ($this->children as $child) {
+ $value = $child->resolveValue($record);
+ if ($value !== '') {
+ return $value;
+ }
+ }
+
+ return '';
+ }
+
+ private function resolveConcat(array $record): string
+ {
+ $values = array_filter(
+ array_map(
+ static fn(self $child): string => $child->resolveValue($record),
+ $this->children,
+ ),
+ static fn(string $value): bool => $value !== '',
+ );
+
+ return implode($this->glue, $values);
+ }
+}
diff --git a/Classes/Tca/ForeignColumnResolveTypeEnum.php b/Classes/Tca/ForeignColumnResolveTypeEnum.php
new file mode 100644
index 000000000..f6274f51b
--- /dev/null
+++ b/Classes/Tca/ForeignColumnResolveTypeEnum.php
@@ -0,0 +1,19 @@
+ [
+ [
+ 'foreignColumnName' => [
+ 'type' => 'coalesce',
+ 'columns' => [
+ 'company',
+ 'name',
+ ],
+ ],
+ 'poiCollectionColumnName' => 'title',
+ ],
+ ],
+
+.. _developer-maps2registry-synchronize-columns-concat:
+
+Concat: Combine Multiple Columns
+---------------------------------
+
+Use `type` `concat` if you want maps2 to join several columns into a single
+value, separated by `glue` (defaults to a single space):
+
+.. code-block:: php
+ :caption: EXT:my_ext/Configuration/TCA/Overrides/tt_address.php
+
+ 'synchronizeColumns' => [
+ [
+ 'foreignColumnName' => [
+ 'type' => 'concat',
+ 'columns' => [
+ 'first_name',
+ 'last_name',
+ ],
+ 'glue' => ' ',
+ ],
+ 'poiCollectionColumnName' => 'title',
+ ],
+ ],
+
+Empty columns are skipped automatically, so a record with only a `last_name`
+will not end up with a leading `glue` in the synchronized value. See
+:ref:`developer-maps2registry-synchronize-columns-resolution-rules` below
+for the exact rules governing empty and blank columns.
+
+.. _developer-maps2registry-synchronize-columns-nesting:
+
+Nesting Coalesce and Concat
+----------------------------
+
+Every entry of `columns` may again be a plain column name or a nested
+`coalesce`/`concat` configuration. This allows you to combine both
+strategies, for example: prefer the `company` column, otherwise fall back to
+the concatenated `first_name` and `last_name` columns:
+
+.. code-block:: php
+ :caption: EXT:my_ext/Configuration/TCA/Overrides/tt_address.php
+
+ 'synchronizeColumns' => [
+ [
+ 'foreignColumnName' => [
+ 'type' => 'coalesce',
+ 'columns' => [
+ 'company',
+ [
+ 'type' => 'concat',
+ 'columns' => [
+ 'first_name',
+ 'last_name',
+ ],
+ 'glue' => ' ',
+ ],
+ ],
+ ],
+ 'poiCollectionColumnName' => 'title',
+ ],
+ ],
+
+Nesting works symmetrically and at any depth: a `concat` may just as well
+contain a nested `coalesce` (e.g. `concat(first_name, coalesce(company,
+last_name))`), and that combination may again be nested inside another
+`coalesce` or `concat`. How an empty nested `concat` is treated by a
+surrounding `coalesce` is described in
+:ref:`developer-maps2registry-synchronize-columns-resolution-rules` below.
+
+.. _developer-maps2registry-synchronize-columns-shorthand:
+
+Shorthand: Plain List as Coalesce
+-----------------------------------
+
+A plain, non-associative list of column names and/or nested configurations
+(i.e. without `type`/`columns`) is automatically treated as a `coalesce`:
+maps2 tries every entry in order and uses the first one that resolves to a
+non-empty value. This is handy to fall back from one combined strategy to
+another one, for example: prefer `company` or `name`, otherwise fall back to
+the concatenated `first_name` and `last_name` columns:
+
+.. code-block:: php
+ :caption: EXT:my_ext/Configuration/TCA/Overrides/tt_address.php
+
+ 'synchronizeColumns' => [
+ [
+ 'foreignColumnName' => [
+ [
+ 'type' => 'coalesce',
+ 'columns' => ['company', 'name'],
+ ],
+ [
+ 'type' => 'concat',
+ 'columns' => ['first_name', 'last_name'],
+ ],
+ ],
+ 'poiCollectionColumnName' => 'title',
+ ],
+ ],
+
+`type` also accepts a :php:`\JWeiland\Maps2\Tca\ForeignColumnResolveTypeEnum`
+case directly instead of its string value, if you prefer a typed constant
+over a magic string.
+
+.. _developer-maps2registry-synchronize-columns-resolution-rules:
+
+Value Resolution Rules
+-----------------------
+
+The following rules apply consistently to every example above, no matter
+how deeply `coalesce` and `concat` are nested:
+
+#. Every column value is trimmed before it is evaluated. A column that
+ only contains whitespace (e.g. `" "` or a tab) is treated exactly
+ like an empty column.
+
+#. `concat` filters out empty columns *before* joining them with `glue`,
+ it does not join first and clean up afterwards. An empty column can
+ therefore never produce a stray `glue` in the result, neither at the
+ start, the end, nor in the middle. If *every* column of a `concat` is
+ empty, the whole `concat` resolves to an empty string regardless of
+ `glue`: a `, ` glue with all columns empty results in `''`, not in
+ `", , ,"`.
+
+#. `coalesce` only checks whether a candidate resolved to a non-empty
+ string. A nested `concat` that resolves to an empty string (per rule 2)
+ is therefore treated like any other empty candidate: `coalesce` simply
+ moves on to the next entry of `columns`.
diff --git a/Documentation/guides.xml b/Documentation/guides.xml
index 9b426ab5e..9eb2cc5ce 100644
--- a/Documentation/guides.xml
+++ b/Documentation/guides.xml
@@ -9,5 +9,5 @@
project-issues="https://github.com/jweiland-net/maps2/issues"
edit-on-github-branch="main"
edit-on-github="jweiland/maps2" typo3-core-preferred="stable"/>
-
+
diff --git a/Tests/Functional/Hook/CreateMaps2RecordHookTest.php b/Tests/Functional/Hook/CreateMaps2RecordHookTest.php
index 8eba6cea8..cf1016db6 100644
--- a/Tests/Functional/Hook/CreateMaps2RecordHookTest.php
+++ b/Tests/Functional/Hook/CreateMaps2RecordHookTest.php
@@ -28,6 +28,7 @@
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend;
+use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\EventDispatcher\EventDispatcher;
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -369,4 +370,62 @@ public function processDatamapCreatesNewPoiCollection(): void
$this->subject->processDatamap_afterAllOperations($dataHandler);
}
+
+ #[Test]
+ public function synchronizeColumnsFromForeignRecordWithPoiCollectionResolvesComposedForeignColumn(): void
+ {
+ $this->importCSVDataSet(__DIR__ . '/../Fixtures/tx_maps2_domain_model_poicollection.csv');
+
+ $foreignLocationRecord = [
+ 'uid' => 1,
+ 'location' => '',
+ 'street' => 'Echterdinger Straße 57',
+ 'city' => 'Filderstadt',
+ 'tx_maps2_uid' => 1,
+ ];
+
+ $columnOptions = [
+ 'synchronizeColumns' => [
+ [
+ // Prefer the location title. If empty, fall back to the concatenated street and city.
+ 'foreignColumnName' => [
+ 'type' => 'coalesce',
+ 'columns' => [
+ 'location',
+ [
+ 'type' => 'concat',
+ 'columns' => ['street', 'city'],
+ 'glue' => ', ',
+ ],
+ ],
+ ],
+ 'poiCollectionColumnName' => 'title',
+ ],
+ ],
+ ];
+
+ self::assertTrue(
+ $this->subject->synchronizeColumnsFromForeignRecordWithPoiCollection(
+ $foreignLocationRecord,
+ 'tx_events2_domain_model_location',
+ 'tx_maps2_uid',
+ $columnOptions,
+ ),
+ );
+
+ $poiCollectionQueryBuilder = $this->getConnectionPool()->getQueryBuilderForTable('tx_maps2_domain_model_poicollection');
+ $poiCollectionTitle = $poiCollectionQueryBuilder
+ ->select('title')
+ ->from('tx_maps2_domain_model_poicollection')
+ ->where(
+ $poiCollectionQueryBuilder->expr()->eq(
+ 'uid',
+ $poiCollectionQueryBuilder->createNamedParameter(1, Connection::PARAM_INT),
+ ),
+ )
+ ->executeQuery()
+ ->fetchOne();
+
+ self::assertSame('Echterdinger Straße 57, Filderstadt', $poiCollectionTitle);
+ }
}
diff --git a/Tests/Unit/Tca/ForeignColumnTest.php b/Tests/Unit/Tca/ForeignColumnTest.php
new file mode 100644
index 000000000..1e41b0abb
--- /dev/null
+++ b/Tests/Unit/Tca/ForeignColumnTest.php
@@ -0,0 +1,502 @@
+getColumnNames());
+ }
+
+ #[Test]
+ public function createFromConfigurationWithEmptyStringReturnsNull(): void
+ {
+ self::assertNull(ForeignColumn::createFromConfiguration(' '));
+ }
+
+ #[Test]
+ public function createFromConfigurationWithInvalidTypeReturnsNull(): void
+ {
+ self::assertNull(ForeignColumn::createFromConfiguration([
+ 'type' => 'foo',
+ 'columns' => ['title'],
+ ]));
+ }
+
+ #[Test]
+ public function createFromConfigurationWithSingleTypeReturnsNull(): void
+ {
+ self::assertNull(ForeignColumn::createFromConfiguration([
+ 'type' => 'single',
+ 'columns' => ['title'],
+ ]));
+ }
+
+ #[Test]
+ public function createFromConfigurationWithMissingColumnsReturnsNull(): void
+ {
+ self::assertNull(ForeignColumn::createFromConfiguration([
+ 'type' => 'coalesce',
+ ]));
+ }
+
+ #[Test]
+ public function createFromConfigurationWithEmptyColumnsReturnsNull(): void
+ {
+ self::assertNull(ForeignColumn::createFromConfiguration([
+ 'type' => 'coalesce',
+ 'columns' => ['', ' '],
+ ]));
+ }
+
+ #[Test]
+ public function createFromConfigurationWithNeitherStringNorArrayReturnsNull(): void
+ {
+ self::assertNull(ForeignColumn::createFromConfiguration(123));
+ }
+
+ #[Test]
+ public function resolveValueWithSingleColumnReturnsTrimmedValue(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration('title');
+
+ self::assertSame(
+ 'jweiland.net',
+ $subject->resolveValue(['title' => ' jweiland.net ']),
+ );
+ }
+
+ #[Test]
+ public function resolveValueWithSingleColumnAndMissingKeyReturnsEmptyString(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration('title');
+
+ self::assertSame('', $subject->resolveValue([]));
+ }
+
+ #[Test]
+ public function resolveValueWithCoalesceReturnsFirstNonEmptyColumn(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'coalesce',
+ 'columns' => ['company', 'name'],
+ ]);
+
+ self::assertSame(
+ 'ACME Inc.',
+ $subject->resolveValue([
+ 'company' => 'ACME Inc.',
+ 'name' => 'Stefan',
+ ]),
+ );
+ }
+
+ #[Test]
+ public function resolveValueWithCoalesceSkipsEmptyAndBlankColumns(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'coalesce',
+ 'columns' => ['company', 'name'],
+ ]);
+
+ self::assertSame(
+ 'Stefan',
+ $subject->resolveValue([
+ 'company' => ' ',
+ 'name' => 'Stefan',
+ ]),
+ );
+ }
+
+ #[Test]
+ public function resolveValueWithCoalesceAndAllColumnsEmptyReturnsEmptyString(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'coalesce',
+ 'columns' => ['company', 'name'],
+ ]);
+
+ self::assertSame('', $subject->resolveValue(['company' => '', 'name' => '']));
+ }
+
+ #[Test]
+ public function resolveValueWithConcatJoinsColumnsWithDefaultGlue(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'concat',
+ 'columns' => ['first_name', 'last_name'],
+ ]);
+
+ self::assertSame(
+ 'Stefan Froemken',
+ $subject->resolveValue([
+ 'first_name' => 'Stefan',
+ 'last_name' => 'Froemken',
+ ]),
+ );
+ }
+
+ #[Test]
+ public function resolveValueWithConcatUsesConfiguredGlue(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'concat',
+ 'columns' => ['zip', 'city'],
+ 'glue' => ', ',
+ ]);
+
+ self::assertSame(
+ '70794, Filderstadt',
+ $subject->resolveValue([
+ 'zip' => '70794',
+ 'city' => 'Filderstadt',
+ ]),
+ );
+ }
+
+ #[Test]
+ public function resolveValueWithCommaGlueAndAllColumnsEmptyResolvesToEmptyStringWithoutStrayGlue(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'concat',
+ 'columns' => ['street', 'house_number', 'zip', 'city'],
+ 'glue' => ', ',
+ ]);
+
+ // Empty candidates are filtered out before imploding, so no leftover
+ // ", , ," can appear, regardless of how many columns are empty.
+ self::assertSame(
+ '',
+ $subject->resolveValue(['street' => '', 'house_number' => '', 'zip' => '', 'city' => '']),
+ );
+ }
+
+ #[Test]
+ public function resolveValueWithCommaGlueAndEmptyColumnsInTheMiddleSkipsThem(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'concat',
+ 'columns' => ['street', 'house_number', 'zip', 'city'],
+ 'glue' => ', ',
+ ]);
+
+ self::assertSame(
+ 'Echterdinger Str., Filderstadt',
+ $subject->resolveValue([
+ 'street' => 'Echterdinger Str.',
+ 'house_number' => '',
+ 'zip' => '',
+ 'city' => 'Filderstadt',
+ ]),
+ );
+ }
+
+ #[Test]
+ public function resolveValueWithConcatSkipsEmptyColumns(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'concat',
+ 'columns' => ['first_name', 'last_name'],
+ ]);
+
+ self::assertSame(
+ 'Froemken',
+ $subject->resolveValue([
+ 'first_name' => '',
+ 'last_name' => 'Froemken',
+ ]),
+ );
+ }
+
+ #[Test]
+ public function resolveValueWithNestedCoalesceAndConcatCombinesBothStrategies(): void
+ {
+ // company -> name -> concat(first_name, last_name)
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'coalesce',
+ 'columns' => [
+ 'company',
+ 'name',
+ [
+ 'type' => 'concat',
+ 'columns' => ['first_name', 'last_name'],
+ 'glue' => ' ',
+ ],
+ ],
+ ]);
+
+ self::assertSame(
+ 'Stefan Froemken',
+ $subject->resolveValue([
+ 'company' => '',
+ 'name' => '',
+ 'first_name' => 'Stefan',
+ 'last_name' => 'Froemken',
+ ]),
+ );
+ }
+
+ #[Test]
+ public function resolveValueWithConcatOfWhitespaceOnlyColumnsResolvesToEmptyStringWithoutGlue(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'concat',
+ 'columns' => ['first_name', 'last_name'],
+ 'glue' => ' ',
+ ]);
+
+ // Blank (whitespace-only) columns must be trimmed to '' before the
+ // emptiness check, so no glue leaks into the result.
+ self::assertSame(
+ '',
+ $subject->resolveValue([
+ 'first_name' => ' ',
+ 'last_name' => "\t\n",
+ ]),
+ );
+ }
+
+ #[Test]
+ public function resolveValueWithCoalesceSkipsConcatOfWhitespaceOnlyColumns(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'coalesce',
+ 'columns' => [
+ [
+ 'type' => 'concat',
+ 'columns' => ['first_name', 'last_name'],
+ 'glue' => ' ',
+ ],
+ 'company',
+ ],
+ ]);
+
+ self::assertSame(
+ 'ACME Inc.',
+ $subject->resolveValue([
+ 'first_name' => ' ',
+ 'last_name' => ' ',
+ 'company' => 'ACME Inc.',
+ ]),
+ );
+ }
+
+ #[Test]
+ public function resolveValueWithConcatContainingNestedCoalesceCombinesBothStrategies(): void
+ {
+ // concat(first_name, coalesce(company, last_name))
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'concat',
+ 'columns' => [
+ 'first_name',
+ [
+ 'type' => 'coalesce',
+ 'columns' => ['company', 'last_name'],
+ ],
+ ],
+ 'glue' => ' ',
+ ]);
+
+ self::assertSame(
+ 'Stefan ACME Inc.',
+ $subject->resolveValue([
+ 'first_name' => 'Stefan',
+ 'company' => 'ACME Inc.',
+ 'last_name' => 'Froemken',
+ ]),
+ );
+
+ self::assertSame(
+ 'Stefan Froemken',
+ $subject->resolveValue([
+ 'first_name' => 'Stefan',
+ 'company' => '',
+ 'last_name' => 'Froemken',
+ ]),
+ );
+ }
+
+ #[Test]
+ public function resolveValueWithDeeplyMixedNestingFallsBackWhenInnerConcatResolvesEmpty(): void
+ {
+ // coalesce( concat( coalesce(a, b), c ), d )
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'coalesce',
+ 'columns' => [
+ [
+ 'type' => 'concat',
+ 'columns' => [
+ [
+ 'type' => 'coalesce',
+ 'columns' => ['a', 'b'],
+ ],
+ 'c',
+ ],
+ 'glue' => '-',
+ ],
+ 'd',
+ ],
+ ]);
+
+ self::assertSame(
+ ['a', 'b', 'c', 'd'],
+ $subject->getColumnNames(),
+ );
+
+ self::assertSame(
+ 'A-C',
+ $subject->resolveValue(['a' => 'A', 'b' => '', 'c' => 'C', 'd' => 'D']),
+ );
+
+ // Inner concat resolves to an empty string when a, b and c are all empty,
+ // so the outer coalesce must fall back to the sibling column "d".
+ self::assertSame(
+ 'D',
+ $subject->resolveValue(['a' => '', 'b' => '', 'c' => '', 'd' => 'D']),
+ );
+ }
+
+ #[Test]
+ public function resolveValueWithNestedConfigurationPrefersOuterColumnsBeforeFallback(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'coalesce',
+ 'columns' => [
+ 'company',
+ [
+ 'type' => 'concat',
+ 'columns' => ['first_name', 'last_name'],
+ ],
+ ],
+ ]);
+
+ self::assertSame(
+ 'ACME Inc.',
+ $subject->resolveValue([
+ 'company' => 'ACME Inc.',
+ 'first_name' => 'Stefan',
+ 'last_name' => 'Froemken',
+ ]),
+ );
+ }
+
+ #[Test]
+ public function createFromConfigurationAcceptsResolveTypeEnumInstanceAsType(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => ForeignColumnResolveTypeEnum::CONCAT,
+ 'columns' => ['first_name', 'last_name'],
+ ]);
+
+ self::assertSame(
+ 'Stefan Froemken',
+ $subject->resolveValue(['first_name' => 'Stefan', 'last_name' => 'Froemken']),
+ );
+ }
+
+ #[Test]
+ public function createFromConfigurationWithPlainListTreatsItAsImplicitCoalesce(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration(['company', 'name']);
+
+ self::assertSame(
+ 'jweiland.net',
+ $subject->resolveValue(['company' => '', 'name' => 'jweiland.net']),
+ );
+ }
+
+ #[Test]
+ public function createFromConfigurationWithPlainListOfNestedConfigurationsFallsBackToNextEntry(): void
+ {
+ // Mirrors the real-world case: prefer company or name, otherwise fall back
+ // to the concatenated first and last name.
+ $subject = ForeignColumn::createFromConfiguration([
+ [
+ 'type' => ForeignColumnResolveTypeEnum::COALESCE,
+ 'columns' => ['company', 'name'],
+ ],
+ [
+ 'type' => ForeignColumnResolveTypeEnum::CONCAT,
+ 'columns' => ['first_name', 'last_name'],
+ ],
+ ]);
+
+ self::assertSame(
+ 'Stefan Froemken',
+ $subject->resolveValue([
+ 'company' => '',
+ 'name' => '',
+ 'first_name' => 'Stefan',
+ 'last_name' => 'Froemken',
+ ]),
+ );
+ }
+
+ #[Test]
+ public function createFromConfigurationWithPlainListPrefersFirstNonEmptyGroup(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ [
+ 'type' => ForeignColumnResolveTypeEnum::COALESCE,
+ 'columns' => ['company', 'name'],
+ ],
+ [
+ 'type' => ForeignColumnResolveTypeEnum::CONCAT,
+ 'columns' => ['first_name', 'last_name'],
+ ],
+ ]);
+
+ self::assertSame(
+ 'ACME Inc.',
+ $subject->resolveValue([
+ 'company' => 'ACME Inc.',
+ 'name' => '',
+ 'first_name' => 'Stefan',
+ 'last_name' => 'Froemken',
+ ]),
+ );
+ }
+
+ #[Test]
+ public function getColumnNamesReturnsFlatUniqueListForNestedConfiguration(): void
+ {
+ $subject = ForeignColumn::createFromConfiguration([
+ 'type' => 'coalesce',
+ 'columns' => [
+ 'company',
+ 'name',
+ [
+ 'type' => 'concat',
+ 'columns' => ['first_name', 'last_name'],
+ ],
+ ],
+ ]);
+
+ self::assertSame(
+ ['company', 'name', 'first_name', 'last_name'],
+ $subject->getColumnNames(),
+ );
+ }
+}
diff --git a/ext_emconf.php b/ext_emconf.php
index 00a2b2768..598a8c7c5 100644
--- a/ext_emconf.php
+++ b/ext_emconf.php
@@ -10,7 +10,7 @@
$EM_CONF[$_EXTKEY] = [
'title' => 'Maps2',
'description' => 'Create maps with Marker, Area, Routes or Radius based on Google Maps or OpenStreetMap',
- 'version' => '12.1.2',
+ 'version' => '12.2.0',
'category' => 'plugin',
'state' => 'stable',
'author' => 'Stefan Froemken',