Skip to content

Commit

Permalink
NEXT-25734 - Allow foreign key resolving by different unique constrai…
Browse files Browse the repository at this point in the history
…nt in sync api calls
  • Loading branch information
OliverSkroblin committed May 30, 2023
1 parent 7ab5b44 commit cc5c28a
Show file tree
Hide file tree
Showing 14 changed files with 443 additions and 11 deletions.
5 changes: 5 additions & 0 deletions src/Core/Content/DependencyInjection/product.xml
Original file line number Diff line number Diff line change
Expand Up @@ -508,5 +508,10 @@
</service>

<service id="Shopware\Core\Content\Product\SalesChannel\ProductCloseoutFilterFactory"/>

<service id="Shopware\Core\Content\Product\Api\ProductNumberFkResolver">
<argument type="service" id="Doctrine\DBAL\Connection"/>
<tag name="shopware.sync.fk_resolver"/>
</service>
</services>
</container>
1 change: 0 additions & 1 deletion src/Core/Content/DependencyInjection/property.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,5 @@
<service id="Shopware\Core\Content\Property\Aggregate\PropertyGroupTranslation\PropertyGroupTranslationDefinition">
<tag name="shopware.entity.definition"/>
</service>

</services>
</container>
55 changes: 55 additions & 0 deletions src/Core/Content/Product/Api/ProductNumberFkResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Content\Product\Api;

use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Shopware\Core\Defaults;
use Shopware\Core\Framework\Api\Sync\AbstractFkResolver;
use Shopware\Core\Framework\Api\Sync\FkReference;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Uuid\Uuid;

/**
* @internal
*/
#[Package('core')]
class ProductNumberFkResolver extends AbstractFkResolver
{
public function __construct(private readonly Connection $connection)
{
}

public static function getName(): string
{
return 'product.number';
}

/**
* @param array<FkReference> $map
*
* @return array<FkReference>
*/
public function resolve(array $map): array
{
$numbers = \array_map(fn ($id) => $id->value, $map);

$numbers = \array_filter(\array_unique($numbers));

if (empty($numbers)) {
return $map;
}

$hash = $this->connection->fetchAllKeyValue(
'SELECT product_number, LOWER(HEX(id)) FROM product WHERE product_number IN (:numbers) AND version_id = :version',
['numbers' => $numbers, 'version' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION)],
['numbers' => ArrayParameterType::STRING]
);

foreach ($map as $reference) {
$reference->resolved = $hash[$reference->value];
}

return $map;
}
}
22 changes: 21 additions & 1 deletion src/Core/Framework/Api/ApiException.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,33 @@
class ApiException extends HttpException
{
public const API_INVALID_SYNC_CRITERIA_EXCEPTION = 'API_INVALID_SYNC_CRITERIA_EXCEPTION';
public const API_RESOLVER_NOT_FOUND_EXCEPTION = 'API_RESOLVER_NOT_FOUND_EXCEPTION';
public const API_INVALID_SYNC_OPERATION_EXCEPTION = 'FRAMEWORK__INVALID_SYNC_OPERATION';

public static function invalidSyncCriteriaException(string $operationKey): self
{
return new self(
Response::HTTP_BAD_REQUEST,
self::API_INVALID_SYNC_CRITERIA_EXCEPTION,
\sprintf('Sync operation %s, with action "delete", requires a criteria with at least one filter', $operationKey)
\sprintf('Sync operation %s, with action "delete", requires a criteria with at least one filter and can only be applied for mapping entities', $operationKey)
);
}

public static function invalidSyncOperationException(string $message): self
{
return new self(
Response::HTTP_BAD_REQUEST,
self::API_INVALID_SYNC_OPERATION_EXCEPTION,
$message
);
}

public static function resolverNotFoundException(string $key): self
{
return new self(
Response::HTTP_BAD_REQUEST,
self::API_RESOLVER_NOT_FOUND_EXCEPTION,
\sprintf('Foreign key resolver for key %s not found', $key)
);
}
}
8 changes: 7 additions & 1 deletion src/Core/Framework/Api/Controller/SyncController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Shopware\Core\Framework\Api\Controller;

use Doctrine\DBAL\ConnectionException;
use Shopware\Core\Framework\Api\ApiException;
use Shopware\Core\Framework\Api\Exception\InvalidSyncOperationException;
use Shopware\Core\Framework\Api\Sync\SyncBehavior;
use Shopware\Core\Framework\Api\Sync\SyncOperation;
Expand Down Expand Up @@ -56,13 +57,18 @@ public function sync(Request $request, Context $context): JsonResponse
if (isset($operation['key'])) {
$key = $operation['key'];
}
$key = (string) $key;
$operations[] = new SyncOperation(
(string) $key,
$key,
$operation['entity'],
$operation['action'],
$operation['payload'] ?? [],
$operation['criteria'] ?? []
);

if (empty($operation['entity'])) {
throw ApiException::invalidSyncOperationException(sprintf('Missing "entity" argument for operation with key "%s". It needs to be a non-empty string.', (string) $key));
}
}

$result = $context->scope(Context::CRUD_API_SCOPE, fn (Context $context): SyncResult => $this->syncService->sync($operations, $context, $behavior));
Expand Down
21 changes: 21 additions & 0 deletions src/Core/Framework/Api/Sync/AbstractFkResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Framework\Api\Sync;

use Shopware\Core\Framework\Log\Package;

#[Package('core')]
abstract class AbstractFkResolver
{
/**
* Returns the unique name for the resolver which is used to identify for fk resolving hash map
*/
abstract public static function getName(): string;

/**
* @param array<FkReference> $map
*
* @return array<FkReference>
*/
abstract public function resolve(array $map): array;
}
18 changes: 18 additions & 0 deletions src/Core/Framework/Api/Sync/FkReference.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Framework\Api\Sync;

use Shopware\Core\Framework\Log\Package;

/**
* @final
*/
#[Package('core')]
class FkReference
{
public ?string $resolved = null;

public function __construct(public mixed $value)
{
}
}
143 changes: 143 additions & 0 deletions src/Core/Framework/Api/Sync/SyncFkResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Framework\Api\Sync;

use Shopware\Core\Framework\Api\ApiException;
use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry;
use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
use Shopware\Core\Framework\Log\Package;

/**
* @internal
*/
#[Package('core')]
class SyncFkResolver
{
/**
* @internal
*
* @param iterable<AbstractFkResolver> $resolvers
*/
public function __construct(
private readonly DefinitionInstanceRegistry $registry,
private readonly iterable $resolvers
) {
}

/**
* @param array<int, array<string, mixed>> $payload
*
* @return array<int, array<string, mixed>>
*/
public function resolve(string $entity, array $payload): array
{
$map = $this->collect($entity, $payload);

if (empty($map)) {
return $payload;
}

foreach ($map as $key => &$values) {
$values = $this->getResolver($key)->resolve($values);
}

\array_walk_recursive($payload, function (&$value): void {
$value = $value instanceof FkReference ? $value->resolved : $value;
});

return $payload;
}

/**
* @param array<int, array<string, mixed>> $payload
*
* @return array<string, array<FkReference>>
*/
private function collect(string $entity, array &$payload): array
{
$definition = $this->registry->getByEntityName($entity);

$map = [];
foreach ($payload as &$row) {
foreach ($row as $key => &$value) {
if (\is_array($value) && isset($value['resolver']) && isset($value['value'])) {
$definition = $this->registry->getByEntityName($entity);

$field = $definition->getField($key);

$ref = match (true) {
$field instanceof FkField => $field->getReferenceDefinition()->getEntityName(),
$field instanceof IdField => $entity,
default => null
};

if ($ref === null) {
continue;
}

$resolver = (string) $value['resolver'];

$row[$key] = $reference = new FkReference($value['value']);

$map[$resolver][] = $reference;
}

if (\is_array($value)) {
$field = $definition->getField($key);

if (!$field instanceof AssociationField) {
continue;
}

$nested = [];
if ($field instanceof ManyToManyAssociationField || $field instanceof OneToManyAssociationField) {
$ref = $field instanceof ManyToManyAssociationField ? $field->getToManyReferenceDefinition()->getEntityName() : $field->getReferenceDefinition()->getEntityName();
$nested = $this->collect($ref, $value);
} elseif ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) {
$tmp = [$value];
$nested = $this->collect($field->getReferenceDefinition()->getEntityName(), $tmp);
$value = \array_shift($tmp);
}

$map = $this->merge($map, $nested);
}
}
}

return $map;
}

/**
* @param array<string, array<FkReference>> $map
* @param array<string, array<FkReference>> $nested
*
* @return array<string, array<FkReference>>
*/
private function merge(array $map, array $nested): array
{
foreach ($nested as $resolver => $values) {
foreach ($values as $value) {
$map[$resolver][] = $value;
}
}

return $map;
}

private function getResolver(string $key): AbstractFkResolver
{
foreach ($this->resolvers as $resolver) {
if ($resolver::getName() === $key) {
return $resolver;
}
}

throw ApiException::resolverNotFoundException($key);
}
}
14 changes: 13 additions & 1 deletion src/Core/Framework/Api/Sync/SyncService.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenContainerEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexerRegistry;
use Shopware\Core\Framework\DataAbstractionLayer\MappingEntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearcherInterface;
use Shopware\Core\Framework\DataAbstractionLayer\Search\RequestCriteriaBuilder;
Expand All @@ -32,7 +33,8 @@ public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly DefinitionInstanceRegistry $registry,
private readonly EntitySearcherInterface $searcher,
private readonly RequestCriteriaBuilder $criteriaBuilder
private readonly RequestCriteriaBuilder $criteriaBuilder,
private readonly SyncFkResolver $syncFkResolver
) {
}

Expand Down Expand Up @@ -137,13 +139,23 @@ private function loopOperations(array $operations, Context $context): void

continue;
}

if ($operation->getAction() === SyncOperation::ACTION_UPSERT) {
$resolved = $this->syncFkResolver->resolve($operation->getEntity(), $operation->getPayload());

$operation->replacePayload($resolved);
}
}
}

private function handleCriteriaDelete(SyncOperation $operation, Context $context): void
{
$definition = $this->registry->getByEntityName($operation->getEntity());

if (!$definition instanceof MappingEntityDefinition) {
throw ApiException::invalidSyncCriteriaException($operation->getKey());
}

$criteria = $this->criteriaBuilder->fromArray(['filter' => $operation->getCriteria()], new Criteria(), $definition, $context);

if (empty($criteria->getFilters())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,12 @@
<argument type="service" id="Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry"/>
<argument type="service" id="Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearcherInterface"/>
<argument type="service" id="Shopware\Core\Framework\DataAbstractionLayer\Search\RequestCriteriaBuilder"/>
<argument type="service" id="Shopware\Core\Framework\Api\Sync\SyncFkResolver"/>
</service>

<service id="Shopware\Core\Framework\Api\Sync\SyncFkResolver">
<argument type="service" id="Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry"/>
<argument type="tagged" tag="shopware.sync.fk_resolver"/>
</service>

<service id="Shopware\Core\Framework\DataAbstractionLayer\Dbal\ExceptionHandlerRegistry">
Expand Down
8 changes: 4 additions & 4 deletions src/Core/Framework/Test/Api/Controller/SyncControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,18 @@ public function testMultipleProductInsert(): void
'id' => $id1,
'productNumber' => Uuid::randomHex(),
'stock' => 1,
'manufacturer' => ['name' => 'test'],
'tax' => ['name' => 'test', 'taxRate' => 15],
'manufacturer' => ['name' => 'manufacturer'],
'tax' => ['name' => 'tax', 'taxRate' => 15],
'name' => 'CREATE-1',
'price' => [['currencyId' => Defaults::CURRENCY, 'gross' => 50, 'net' => 25, 'linked' => false]],
],
[
'id' => $id2,
'productNumber' => Uuid::randomHex(),
'stock' => 1,
'manufacturer' => ['name' => 'test'],
'manufacturer' => ['name' => 'manufacturer'],
'name' => 'CREATE-2',
'tax' => ['name' => 'test', 'taxRate' => 15],
'tax' => ['name' => 'tax', 'taxRate' => 15],
'price' => [['currencyId' => Defaults::CURRENCY, 'gross' => 50, 'net' => 25, 'linked' => false]],
],
],
Expand Down
Loading

0 comments on commit cc5c28a

Please sign in to comment.