diff --git a/Classes/Hook/CreateMaps2RecordHook.php b/Classes/Hook/CreateMaps2RecordHook.php
index b2cc493a..eae5bc4f 100644
--- a/Classes/Hook/CreateMaps2RecordHook.php
+++ b/Classes/Hook/CreateMaps2RecordHook.php
@@ -460,20 +460,17 @@ public function synchronizeColumnsFromForeignRecordWithPoiCollection(
),
);
- $tableNeedsUpdate = false;
foreach ($columnRegistration->getSynchronizeColumns() as $synchronizeColumns) {
if (!$this->isValidSynchronizeConfiguration($synchronizeColumns, $foreignTableName)) {
return false;
}
$queryBuilder = $queryBuilder->set(
- $synchronizeColumns['poiCollectionColumnName'],
- $foreignLocationRecord[$synchronizeColumns['foreignColumnName']],
+ $synchronizeColumns->getPoiCollectionColumnName(),
+ $synchronizeColumns->getForeignColumn()->resolveValue($foreignLocationRecord),
);
- $tableNeedsUpdate = true;
}
- // Only execute query, if there are columns to update
$queryBuilder->executeStatement();
return true;
@@ -484,22 +481,23 @@ public function synchronizeColumnsFromForeignRecordWithPoiCollection(
*/
private function isValidSynchronizeConfiguration(SynchronizeColumn $synchronizeColumns, string $foreignTableName): bool
{
- // Check, if configured foreign columnName is valid in TCA
- $foreignColumnName = $synchronizeColumns->getForeignColumnName();
- 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,
- );
+ // Check if every configured foreign columnName is valid in TCA
+ foreach ($synchronizeColumns->getForeignColumn()->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 false;
+ return false;
+ }
}
return true;
diff --git a/Classes/Tca/ForeignColumn.php b/Classes/Tca/ForeignColumn.php
new file mode 100644
index 00000000..e1d453ee
--- /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 00000000..f6274f51
--- /dev/null
+++ b/Classes/Tca/ForeignColumnResolveTypeEnum.php
@@ -0,0 +1,19 @@
+foreignColumnName;
+ return $this->foreignColumn;
}
public function getPoiCollectionColumnName(): string
diff --git a/Documentation/ChangeLog/Index.rst b/Documentation/ChangeLog/Index.rst
index 97fa7f59..5ada5262 100644
--- a/Documentation/ChangeLog/Index.rst
+++ b/Documentation/ChangeLog/Index.rst
@@ -7,6 +7,15 @@
ChangeLog
=========
+Version 13.1.0
+==============
+
+* FEATURE: Support coalesce/concat resolution strategies for the
+ `foreignColumnName` of `synchronizeColumns`, allowing fallback columns and
+ combined columns (including arbitrary nesting of both strategies)
+* BUGFIX: Fix TypeError in CreateMaps2RecordHook when synchronizing columns
+ (SynchronizeColumn was accessed via array offset instead of its getters)
+
Version 13.0.2
==============
diff --git a/Documentation/DeveloperManual/Maps2Registry/Index.rst b/Documentation/DeveloperManual/Maps2Registry/Index.rst
index 63720de6..bda23490 100644
--- a/Documentation/DeveloperManual/Maps2Registry/Index.rst
+++ b/Documentation/DeveloperManual/Maps2Registry/Index.rst
@@ -480,9 +480,165 @@ the title column of maps2 POI collection record:
'--div--;maps2.db:tab.maps2.gm,tx_maps2_uid',
);
-You can add multiple configuration to `synchronizeColumns`. BUT: Currently,
-only 1:1 relations are allowed.
+You can add multiple configurations to `synchronizeColumns`. `foreignColumnName`
+does not have to be a plain column name. It also accepts a structured
+configuration to combine several columns of your foreign table into a single
+value, which is resolved before it is written into the POI collection record.
+.. _developer-maps2-registry-synchronize-columns-coalesce:
+
+Coalesce: Fall Back to Another Column
+-------------------------------------
+
+Use `type` `coalesce` if you want maps2 to try several columns in order and
+use the first one that is not empty. This is useful if your table stores
+either a company name or a person name, for example:
+
+.. code-block:: php
+ :caption: EXT:my_ext/Configuration/TCA/Overrides/tt_address.php
+
+ 'synchronizeColumns' => [
+ [
+ 'foreignColumnName' => [
+ 'type' => 'coalesce',
+ 'columns' => [
+ 'company',
+ 'name',
+ ],
+ ],
+ 'poiCollectionColumnName' => 'title',
+ ],
+ ],
+
+.. _developer-maps2-registry-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-maps2-registry-synchronize-columns-resolution-rules` below
+for the exact rules governing empty and blank columns.
+
+.. _developer-maps2-registry-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-maps2-registry-synchronize-columns-resolution-rules` below.
+
+.. _developer-maps2-registry-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-maps2-registry-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`.
.. _developer-maps2-registry-override:
diff --git a/Documentation/guides.xml b/Documentation/guides.xml
index bec6fc82..e88d62f7 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/Fixtures/Extensions/address/Configuration/TCA/Overrides/tx_address_domain_model_address.php b/Tests/Functional/Fixtures/Extensions/address/Configuration/TCA/Overrides/tx_address_domain_model_address.php
index 0ea33927..3381a0c3 100644
--- a/Tests/Functional/Fixtures/Extensions/address/Configuration/TCA/Overrides/tx_address_domain_model_address.php
+++ b/Tests/Functional/Fixtures/Extensions/address/Configuration/TCA/Overrides/tx_address_domain_model_address.php
@@ -28,7 +28,18 @@
'countryColumn' => 'country',
'synchronizeColumns' => [
0 => [
- 'foreignColumnName' => 'title',
+ // Prefer the company name. If empty, fall back to the concatenated first and last name.
+ 'foreignColumnName' => [
+ 'type' => 'coalesce',
+ 'columns' => [
+ 'company',
+ [
+ 'type' => 'concat',
+ 'columns' => ['first_name', 'last_name'],
+ 'glue' => ' ',
+ ],
+ ],
+ ],
'poiCollectionColumnName' => 'title',
],
1 => [
diff --git a/Tests/Functional/Fixtures/Extensions/address/Configuration/TCA/tx_address_domain_model_address.php b/Tests/Functional/Fixtures/Extensions/address/Configuration/TCA/tx_address_domain_model_address.php
index eedd580c..8baf1e4d 100644
--- a/Tests/Functional/Fixtures/Extensions/address/Configuration/TCA/tx_address_domain_model_address.php
+++ b/Tests/Functional/Fixtures/Extensions/address/Configuration/TCA/tx_address_domain_model_address.php
@@ -79,5 +79,29 @@
'default' => 'DE',
],
],
+ 'company' => [
+ 'label' => 'Company',
+ 'config' => [
+ 'type' => 'input',
+ 'size' => 30,
+ 'eval' => 'trim',
+ ],
+ ],
+ 'first_name' => [
+ 'label' => 'First name',
+ 'config' => [
+ 'type' => 'input',
+ 'size' => 30,
+ 'eval' => 'trim',
+ ],
+ ],
+ 'last_name' => [
+ 'label' => 'Last name',
+ 'config' => [
+ 'type' => 'input',
+ 'size' => 30,
+ 'eval' => 'trim',
+ ],
+ ],
],
];
diff --git a/Tests/Functional/Fixtures/tx_address_domain_model_address.csv b/Tests/Functional/Fixtures/tx_address_domain_model_address.csv
index f0a686d7..48886fe3 100644
--- a/Tests/Functional/Fixtures/tx_address_domain_model_address.csv
+++ b/Tests/Functional/Fixtures/tx_address_domain_model_address.csv
@@ -1,4 +1,4 @@
-"tx_address_domain_model_address",,,,,,,,,
-,"uid","pid","tstamp","title","street","zip","city","country","tx_maps2_uid"
-,1,12,1551266318,"Stefan","Echterdinger Straße 57","70794","Filderstadt","de",1
-,2,12,1551969830,"Petra","Echterdinger Straße 57","70794","Filderstadt","de",2
+"tx_address_domain_model_address",,,,,,,,,,,,
+,"uid","pid","tstamp","title","street","zip","city","country","tx_maps2_uid","company","first_name","last_name"
+,1,12,1551266318,"Stefan","Echterdinger Straße 57","70794","Filderstadt","de",1,"","Stefan","Froemken"
+,2,12,1551969830,"Petra","Echterdinger Straße 57","70794","Filderstadt","de",2,"jweiland.net",,
diff --git a/Tests/Functional/Hook/CreateMaps2RecordHookTest.php b/Tests/Functional/Hook/CreateMaps2RecordHookTest.php
index ad6f8ce0..386fd889 100644
--- a/Tests/Functional/Hook/CreateMaps2RecordHookTest.php
+++ b/Tests/Functional/Hook/CreateMaps2RecordHookTest.php
@@ -20,11 +20,14 @@
use JWeiland\Maps2\Service\MapService;
use JWeiland\Maps2\Tca\ColumnRegistration;
use JWeiland\Maps2\Tca\ColumnRegistrationStorage;
+use JWeiland\Maps2\Tca\ForeignColumn;
+use JWeiland\Maps2\Tca\SynchronizeColumn;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
+use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -157,4 +160,84 @@ public function processDatamapCreatesNewPoiCollection(): void
$subject->processDatamap_afterAllOperations($dataHandler);
}
+
+ #[Test]
+ public function synchronizeColumnsFromForeignRecordWithPoiCollectionResolvesComposedForeignColumn(): void
+ {
+ $this->importCSVDataSet(__DIR__ . '/../Fixtures/tx_maps2_domain_model_poicollection.csv');
+
+ $addressQueryBuilder = $this->getConnectionPool()->getQueryBuilderForTable('tx_address_domain_model_address');
+ $foreignLocationRecord = $addressQueryBuilder
+ ->select('*')
+ ->from('tx_address_domain_model_address')
+ ->where(
+ $addressQueryBuilder->expr()->eq(
+ 'uid',
+ $addressQueryBuilder->createNamedParameter(1, Connection::PARAM_INT),
+ ),
+ )
+ ->executeQuery()
+ ->fetchAssociative();
+
+ self::assertIsArray($foreignLocationRecord);
+
+ $columnRegistration = new ColumnRegistration(
+ tableName: 'tx_address_domain_model_address',
+ columnName: 'tx_maps2_uid',
+ addressColumns: ['street', 'zip', 'city'],
+ synchronizeColumns: [
+ new SynchronizeColumn(
+ foreignColumn: ForeignColumn::createFromConfiguration([
+ 'type' => 'coalesce',
+ 'columns' => [
+ 'company',
+ [
+ 'type' => 'concat',
+ 'columns' => ['first_name', 'last_name'],
+ 'glue' => ' ',
+ ],
+ ],
+ ]),
+ poiCollectionColumnName: 'title',
+ ),
+ ],
+ );
+
+ $subject = new CreateMaps2RecordHook(
+ $this->get(GeoCodeService::class),
+ $this->get(AddressHelper::class),
+ $this->get(MessageHelper::class),
+ $this->get(StoragePidHelper::class),
+ $this->get(MapService::class),
+ new ColumnRegistrationStorage(),
+ $this->get(EventDispatcherInterface::class),
+ $this->get(TcaSchemaFactory::class),
+ $this->get(CacheManager::class),
+ $this->getConnectionPool(),
+ );
+
+ self::assertTrue(
+ $subject->synchronizeColumnsFromForeignRecordWithPoiCollection(
+ $foreignLocationRecord,
+ 'tx_address_domain_model_address',
+ 'tx_maps2_uid',
+ $columnRegistration,
+ ),
+ );
+
+ $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('Stefan Froemken', $poiCollectionTitle);
+ }
}
diff --git a/Tests/Unit/Tca/ForeignColumnTest.php b/Tests/Unit/Tca/ForeignColumnTest.php
new file mode 100644
index 00000000..1e41b0ab
--- /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/composer.json b/composer.json
index 75ea1f90..52045681 100644
--- a/composer.json
+++ b/composer.json
@@ -61,7 +61,7 @@
"providesPackages": {}
},
"extension-key": "maps2",
- "version": "13.0.2",
+ "version": "13.1.0",
"web-dir": ".Build/public"
}
}
diff --git a/ext_emconf.php b/ext_emconf.php
index b990f360..7b4e08c5 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' => '13.0.2',
+ 'version' => '13.1.0',
'category' => 'plugin',
'state' => 'stable',
'author' => 'Stefan Froemken',