Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php declare(strict_types=1);
/*
* (c) shopware AG <[email protected]>
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace SwagMigrationAssistant\Core\Migration;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Migration\MigrationStep;

/**
* @internal
*/
#[Package('fundamentals@after-sales')]
class Migration1764145444AddFingerprintToConnectionTable extends MigrationStep
{
public const TABLE = 'swag_migration_connection';

public const COLUMN = 'source_system_fingerprint';

public function getCreationTimestamp(): int
{
return 1764145444;
}

public function update(Connection $connection): void
{
$this->addColumn(
connection: $connection,
table: self::TABLE,
column: self::COLUMN,
type: 'VARCHAR(255)',
);
}
}
5 changes: 5 additions & 0 deletions src/DependencyInjection/migration.xml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@
<tag name="kernel.reset" method="reset"/>
</service>

<service id="SwagMigrationAssistant\Migration\Connection\ConnectionFingerprintService">
<argument type="service" id="swag_migration_connection.repository"/>
</service>

<service id="SwagMigrationAssistant\Migration\Run\RunService">
<argument type="service" id="swag_migration_run.repository"/>
<argument type="service" id="swag_migration_connection.repository"/>
Expand All @@ -192,6 +196,7 @@
<argument type="service" id="SwagMigrationAssistant\Migration\MigrationContextFactory"/>
<argument type="service" id="SwagMigrationAssistant\Migration\Service\PremappingService"/>
<argument type="service" id="SwagMigrationAssistant\Migration\Run\RunTransitionService"/>
<argument type="service" id="SwagMigrationAssistant\Migration\Connection\ConnectionFingerprintService"/>
</service>

<service id="SwagMigrationAssistant\Migration\Run\RunTransitionService">
Expand Down
11 changes: 11 additions & 0 deletions src/Exception/MigrationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ class MigrationException extends HttpException

public const INVALID_ID = 'SWAG_MIGRATION__INVALID_ID';

public const DUPLICATE_SOURCE_CONNECTION = 'SWAG_MIGRATION__DUPLICATE_SOURCE_CONNECTION';

public static function associationEntityRequiredMissing(string $entity, string $missingEntity): self
{
return new AssociationEntityRequiredMissingException(
Expand Down Expand Up @@ -581,4 +583,13 @@ public static function invalidId(string $entityId, string $entityName): self
['entityId' => $entityId, 'entityName' => $entityName]
);
}

public static function duplicateSourceConnection(): self
{
return new self(
Response::HTTP_CONFLICT,
self::DUPLICATE_SOURCE_CONNECTION,
'A connection to this source system already exists.',
);
}
}
153 changes: 153 additions & 0 deletions src/Migration/Connection/ConnectionFingerprintService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php declare(strict_types=1);
/*
* (c) shopware AG <[email protected]>
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace SwagMigrationAssistant\Migration\Connection;

use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\MultiFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\NotFilter;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Util\Hasher;
use SwagMigrationAssistant\Profile\Shopware\Gateway\Api\ShopwareApiGateway;
use SwagMigrationAssistant\Profile\Shopware\Gateway\Local\ShopwareLocalGateway;

#[Package('fundamentals@after-sales')]
class ConnectionFingerprintService
{
public const FIELD_KEY_ENDPOINT = 'endpoint';

public const FIELD_KEY_HOST = 'dbHost';

public const FIELD_KEY_PORT = 'dbPort';

public const FIELD_KEY_NAME = 'dbName';

public const FIELD_KEY_PROFILE = 'profile';

public const DEFAULT_DB_PORT = '3306';

/**
* @param EntityRepository<SwagMigrationConnectionCollection> $connectionRepo
*
* @internal
*/
public function __construct(
private readonly EntityRepository $connectionRepo,
) {
}

/**
* @param array<string, mixed>|null $credentialFields
*/
public function generateFingerprint(?array $credentialFields, string $gatewayName, string $profileName): ?string
{
if (empty($credentialFields)) {
return null;
}

$data = $this->extractFingerprintData($credentialFields, $gatewayName);

if (empty($data)) {
return null;
}

$data[self::FIELD_KEY_PROFILE] = $profileName;
ksort($data);

return Hasher::hash($data);
}

public function hasDuplicateConnection(string $fingerprint, Context $context, ?string $excludeConnectionId): bool
{
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('sourceSystemFingerprint', $fingerprint));

if (isset($excludeConnectionId)) {
$criteria->addFilter(new NotFilter(MultiFilter::CONNECTION_AND, [
new EqualsFilter('id', $excludeConnectionId),
]));
}

return $this->connectionRepo->searchIds($criteria, $context)->getTotal() > 0;
}

/**
* @param array<string, mixed> $credentialFields
*
* @return array<string, string>|null
*/
private function extractFingerprintData(array $credentialFields, string $gatewayName): ?array
{
if ($gatewayName === ShopwareApiGateway::GATEWAY_NAME) {
return $this->extractApiFingerprintData($credentialFields, $gatewayName);
}

if ($gatewayName === ShopwareLocalGateway::GATEWAY_NAME) {
return $this->extractLocalFingerprintData($credentialFields);
}

return null;
}

/**
* @param array<string, mixed> $credentialFields
*
* @return array<string, string>|null
*/
private function extractApiFingerprintData(array $credentialFields, string $gatewayName): ?array
{
if (!isset($credentialFields[self::FIELD_KEY_ENDPOINT])) {
return null;
}

$normalizedEndpoint = $this->normalizeEndpoint(
(string) $credentialFields[self::FIELD_KEY_ENDPOINT]
);

return [
'type' => $gatewayName,
self::FIELD_KEY_ENDPOINT => $normalizedEndpoint,
];
}

/**
* @param array<string, mixed> $credentialFields
*
* @return array<string, string>|null
*/
private function extractLocalFingerprintData(array $credentialFields): ?array
{
if (!isset($credentialFields[self::FIELD_KEY_HOST], $credentialFields[self::FIELD_KEY_NAME])) {
return null;
}

return [
'type' => ShopwareLocalGateway::GATEWAY_NAME,
self::FIELD_KEY_HOST => \strtolower(\trim((string) $credentialFields[self::FIELD_KEY_HOST])),
self::FIELD_KEY_PORT => (string) ($credentialFields[self::FIELD_KEY_PORT] ?? self::DEFAULT_DB_PORT),
self::FIELD_KEY_NAME => \trim((string) $credentialFields[self::FIELD_KEY_NAME]),
];
}

private function normalizeEndpoint(string $endpoint): string
{
// lowercase & trim
$endpoint = strtolower(trim($endpoint));

// remove ending slash
$endpoint = rtrim($endpoint, '/');

// remove protocol (http or https does not matter for fingerprint)
$endpoint = (string) preg_replace('#^https?://#', '', $endpoint);

// remove www. prefix
return (string) preg_replace('#^www\.#', '', $endpoint);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ protected function defineFields(): FieldCollection
new PremappingField('premapping', 'premapping'),
(new StringField('profile_name', 'profileName'))->addFlags(new Required()),
(new StringField('gateway_name', 'gatewayName'))->addFlags(new Required()),
new StringField('source_system_fingerprint', 'sourceSystemFingerprint'),
new CreatedAtField(),
new UpdatedAtField(),
new OneToManyAssociationField('runs', SwagMigrationRunDefinition::class, 'connection_id'),
Expand Down
12 changes: 12 additions & 0 deletions src/Migration/Connection/SwagMigrationConnectionEntity.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class SwagMigrationConnectionEntity extends Entity

protected string $gatewayName = '';

protected ?string $sourceSystemFingerprint = null;

protected ?SwagMigrationRunCollection $runs = null;

protected ?SwagMigrationMappingCollection $mappings = null;
Expand Down Expand Up @@ -104,6 +106,16 @@ public function setGatewayName(string $gatewayName): void
$this->gatewayName = $gatewayName;
}

public function getSourceSystemFingerprint(): ?string
{
return $this->sourceSystemFingerprint;
}

public function setSourceSystemFingerprint(string $sourceSystemFingerprint): void
{
$this->sourceSystemFingerprint = $sourceSystemFingerprint;
}

public function getRuns(): ?SwagMigrationRunCollection
{
return $this->runs;
Expand Down
33 changes: 31 additions & 2 deletions src/Migration/Run/RunService.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use Shopware\Storefront\Theme\ThemeDefinition;
use Shopware\Storefront\Theme\ThemeService;
use SwagMigrationAssistant\Exception\MigrationException;
use SwagMigrationAssistant\Migration\Connection\ConnectionFingerprintService;
use SwagMigrationAssistant\Migration\Connection\SwagMigrationConnectionCollection;
use SwagMigrationAssistant\Migration\Connection\SwagMigrationConnectionEntity;
use SwagMigrationAssistant\Migration\DataSelection\DataSelectionCollection;
Expand Down Expand Up @@ -74,6 +75,7 @@ public function __construct(
private readonly MigrationContextFactoryInterface $migrationContextFactory,
private readonly PremappingServiceInterface $premappingService,
private readonly RunTransitionServiceInterface $runTransitionService,
private readonly ConnectionFingerprintService $connectionFingerprintService,
) {
}

Expand Down Expand Up @@ -134,19 +136,46 @@ public function getRunStatus(Context $context): MigrationState
}

/**
* @param array<int, string>|null $credentialFields
* @param array<string, mixed>|null $credentialFields
*/
public function updateConnectionCredentials(Context $context, string $connectionUuid, ?array $credentialFields): void
{
if ($this->isMigrationRunning($context)) {
throw MigrationException::migrationIsAlreadyRunning();
}

$context->scope(MigrationContext::SOURCE_CONTEXT, function (Context $context) use ($connectionUuid, $credentialFields): void {
$connection = $this->connectionRepo->search(new Criteria([$connectionUuid]), $context)->first();

if ($connection === null) {
throw MigrationException::noConnectionFound();
}

$fingerprint = $this->connectionFingerprintService->generateFingerprint(
$credentialFields,
$connection->getGatewayName(),
$connection->getProfileName(),
);

if ($fingerprint === null) {
throw MigrationException::invalidConnectionCredentials();
}

$hasDuplicates = $this->connectionFingerprintService->hasDuplicateConnection(
$fingerprint,
$context,
$connectionUuid,
);

if ($hasDuplicates) {
throw MigrationException::duplicateSourceConnection();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't doing all of this in updateConnectionCredentials only prevent the already created duplicate connection from receiving a fingerprint?

I tested this locally (SW6 → SW6) and creating a duplicate connection currently behaves like this:

  • While entering the details for the second connection, an empty connection is already created in swag_migration_connection

  • After submitting the duplicate details, I do see the new "A connection to the same source system already exists." warning, but the connection is updated nonetheless, and the swag_migration_connection row now contains the credentials, only the fingerprint is missing:

    image
  • After dismissing the modal with "Close", the duplicate connection is not only created, but also active

Image

Copy link
Member Author

@larskemper larskemper Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a general migration design issue we didn't refactor until now. I suspect it was done this way because the wizard is url based: each step lives on its "own page", so state isn't shared and nothing gets stored in vuex. That said, this shouldn't prevent existing duplicate connections from receiving a fingerprint, the service only checks for existing fingerprints and exits early when none are present. So the first connection should get a fingerprint and is valid, the second not...

but in this case it seems different, I will look into that, probably need to add a filter for null in the service 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I don't get is what this PR achieves then, because the only difference to me as a user of this is that I see the warning modal, but nothing "prevent[s] multiple connections to the same system" - what am I missing here? 😅

Copy link
Member Author

@larskemper larskemper Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i get what you mean now, it validates newly created connections: "prevent creating multiple connections to the same system". Existing connections do not have a fingerprint, that's true, so their are not validated

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

found the issue: we update the connection first and then did a connection check, so creds where written before checking. I refactored it and now we first check the connection with given creds and then update the entity & generate the fingerprint

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better 👍 but I still think there's something strange about it 🤔


  1. UI briefly shows connection success before the warning:
Kapture.2025-12-10.at.18.16.43.mp4

  1. As a user, I am still left with an empty connection when this happens (now without credentials), which is a confusing state to me. While creating an exact duplicate of my existing one was prevented, it would be nicer if this empty connection would either be cleaned up or not created at all, so I am back to my initial (working) connection when I exit this modal, instead of seeing this:
image

Feel free to move this to follow-up issues due to how all of this works, but I think this feature does not make much sense with the behavior described in 2.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, definitely strange but at least "not connected" 😅. Let's create a follow up bug ticket for that 👍

}

$context->scope(MigrationContext::SOURCE_CONTEXT, function (Context $context) use ($connectionUuid, $credentialFields, $fingerprint): void {
$this->connectionRepo->update([
[
'id' => $connectionUuid,
'credentialFields' => $credentialFields,
'sourceSystemFingerprint' => $fingerprint,
],
], $context);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@
"SWAG_MIGRATION__DATABASE_CONNECTION_ERROR": "Die Datenbank Verbindung konnte nicht hergestellt werden.",
"SWAG_MIGRATION__DATABASE_CONNECTION_ATTRIBUTES_WRONG": "Die Datenbankverbindung hat nicht die richtigen Attribute und diese können nicht gesetzt werden.",
"SWAG_MIGRATION__IS_RUNNING": "Eine Migration ist zurzeit im Gange. Du kannst deshalb die Zugangsdaten nicht bearbeiten, bis die Migration abgeschlossen ist.",
"SWAG_MIGRATION__SSL_REQUIRED": "Wir haben festgestellt, dass die angegebene Shop-Domain eine SSL-Verbindung erfordert."
"SWAG_MIGRATION__SSL_REQUIRED": "Wir haben festgestellt, dass die angegebene Shop-Domain eine SSL-Verbindung erfordert.",
"SWAG_MIGRATION__DUPLICATE_SOURCE_CONNECTION": "Es besteht bereits eine Verbindung mit denselben Zugangsdaten. Bitte verwenden Sie eine andere Verbindung oder bearbeiten Sie die bestehende."
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@
"SWAG_MIGRATION__DATABASE_CONNECTION_ERROR": "Database connection could not be established.",
"SWAG_MIGRATION__DATABASE_CONNECTION_ATTRIBUTES_WRONG": "Database connection does not have the right attributes and they can not be set.",
"SWAG_MIGRATION__IS_RUNNING": "A migration is currently in progress. Therefore, you cannot edit the credentials until the migration is complete.",
"SWAG_MIGRATION__SSL_REQUIRED": "We have determined that the given shop domain is required a SSL connection."
"SWAG_MIGRATION__SSL_REQUIRED": "We have determined that the given shop domain is required a SSL connection.",
"SWAG_MIGRATION__DUPLICATE_SOURCE_CONNECTION": "A connection with the same access data already exists. Please use another connection or edit the existing one."
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* file that was distributed with this source code.
*/

namespace Core\Migration;
namespace SwagMigrationAssistant\Test\Core\Migration;

use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\CoversClass;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* file that was distributed with this source code.
*/

namespace Core\Migration;
namespace SwagMigrationAssistant\Test\Core\Migration;

use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\CoversClass;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* file that was distributed with this source code.
*/

namespace Core\Migration;
namespace SwagMigrationAssistant\Test\Core\Migration;

use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\CoversClass;
Expand Down
Loading
Loading