Skip to content
Merged
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"doctrine/orm": "^2.7",
"doctrine/persistence": "^1.3 || ^2.0 || ^3.0",
"knplabs/knp-menu": "^3.1",
"league/csv": "^9.0",
"league/csv": "^9.6",
"league/flysystem": "^2.1 || ^3.0",
"league/flysystem-bundle": "^2.4 || ^3.0",
"liip/imagine-bundle": "^2.4",
Expand Down
46 changes: 46 additions & 0 deletions src/Controller/Admin/RefreshLookupTableAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusFeedPlugin\Controller\Admin;

use Setono\SyliusFeedPlugin\Lookup\LookupTableRefresherInterface;
use Setono\SyliusFeedPlugin\Model\LookupTableInterface;
use Setono\SyliusFeedPlugin\Repository\LookupTableRepositoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\RouterInterface;

/**
* Admin "Refresh" action (§10.1): re-imports a LookupTable's rows from its source and redirects back
* to the grid with a flash. A failed import keeps the last-good rows (handled by the refresher).
*/
final class RefreshLookupTableAction
{
public function __construct(
private readonly LookupTableRepositoryInterface $repository,
private readonly LookupTableRefresherInterface $refresher,
private readonly RouterInterface $router,
) {
}

public function __invoke(Request $request, int|string $id): Response
{
$table = $this->repository->find($id);
if (!$table instanceof LookupTableInterface) {
throw new NotFoundHttpException(sprintf('There is no lookup table with id "%s"', $id));
}

$this->refresher->refresh($table);

$session = $request->getSession();
if ($session instanceof Session) {
$session->getFlashBag()->add('success', 'setono_sylius_feed.lookup_table.refreshed');
}

return new RedirectResponse($this->router->generate('setono_sylius_feed_admin_lookup_table_index'));
}
}
78 changes: 78 additions & 0 deletions src/DataSource/ProductDataSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusFeedPlugin\DataSource;

use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Setono\Doctrine\ORMTrait;
use Setono\SyliusFeedPlugin\Context\FeedContext;
use Setono\SyliusFeedPlugin\Doctrine\BatchIterator;
use Setono\SyliusFeedPlugin\Filter\FilterSet;
use Sylius\Component\Core\Model\ChannelInterface;

/**
* Streams enabled, channel-assigned products — one row per product (§8.7). Iterates in
* bounded-memory batches via {@see BatchIterator} (clears the entity manager every batch), so a
* large catalog stays within budget even though associations are lazy-loaded per row (§6.3).
* Query-pushable filters land in M6; for now only the enabled/channel constraints are applied at
* the query level.
*/
final class ProductDataSource implements DataSourceInterface
{
use ORMTrait;

private const BATCH_SIZE = 1000;

/**
* @param class-string $resourceClass
*/
public function __construct(
ManagerRegistry $managerRegistry,
private readonly string $resourceClass,
) {
$this->managerRegistry = $managerRegistry;
}

public function getResourceClass(): string
{
return $this->resourceClass;
}

public function getItems(FeedContext $context, FilterSet $filters): iterable
{
return BatchIterator::iterate(
$this->createQueryBuilder($context)->getQuery(),
$this->getManager($this->resourceClass),
self::BATCH_SIZE,
);
}

public function count(FeedContext $context, FilterSet $filters): int
{
return (int) $this->createQueryBuilder($context)
->select('COUNT(product.id)')
->getQuery()
->getSingleScalarResult();
}

private function createQueryBuilder(FeedContext $context): QueryBuilder
{
$queryBuilder = $this->getManager($this->resourceClass)
->getRepository($this->resourceClass)
->createQueryBuilder('product')
->andWhere('product.enabled = true');

$channel = $context->getChannel();
if ($channel instanceof ChannelInterface) {
// MEMBER OF (an EXISTS subquery) rather than a join, since Doctrine's toIterable() does
// not allow iterating over a query that joins a to-many association.
$queryBuilder
->andWhere(':channel MEMBER OF product.channels')
->setParameter('channel', $channel);
}

return $queryBuilder;
}
}
21 changes: 21 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@
namespace Setono\SyliusFeedPlugin\DependencyInjection;

use Setono\SyliusFeedPlugin\Form\Type\FeedType;
use Setono\SyliusFeedPlugin\Form\Type\LookupTableType;
use Setono\SyliusFeedPlugin\Model\Feed;
use Setono\SyliusFeedPlugin\Model\FeedSource;
use Setono\SyliusFeedPlugin\Model\FeedTranslation;
use Setono\SyliusFeedPlugin\Model\LookupTable;
use Setono\SyliusFeedPlugin\Model\LookupTableInterface;
use Setono\SyliusFeedPlugin\Repository\FeedRepository;
use Setono\SyliusFeedPlugin\Repository\LookupTableRepository;
use Sylius\Bundle\ResourceBundle\Controller\ResourceController;
use Sylius\Bundle\ResourceBundle\Doctrine\ORM\EntityRepository;
use Sylius\Bundle\ResourceBundle\Form\Type\DefaultResourceType;
Expand Down Expand Up @@ -110,6 +114,23 @@ private function addResourcesSection(ArrayNodeDefinition $node): void
->end()
->end()
->end()
->arrayNode('lookup_table')
->addDefaultsIfNotSet()
->children()
->variableNode('options')->end()
->arrayNode('classes')
->addDefaultsIfNotSet()
->children()
->scalarNode('model')->defaultValue(LookupTable::class)->cannotBeEmpty()->end()
->scalarNode('interface')->defaultValue(LookupTableInterface::class)->cannotBeEmpty()->end()
->scalarNode('controller')->defaultValue(ResourceController::class)->cannotBeEmpty()->end()
->scalarNode('repository')->defaultValue(LookupTableRepository::class)->cannotBeEmpty()->end()
->scalarNode('factory')->defaultValue(Factory::class)->end()
->scalarNode('form')->defaultValue(LookupTableType::class)->cannotBeEmpty()->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
Expand Down
85 changes: 85 additions & 0 deletions src/FeedType/ProductFeedType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusFeedPlugin\FeedType;

use Setono\SyliusFeedPlugin\DataSource\DataSourceInterface;
use Setono\SyliusFeedPlugin\Mapping\FieldDefinition;
use Setono\SyliusFeedPlugin\Mapping\ScopeDimension;
use Setono\SyliusFeedPlugin\ValueResolver\ValueResolverRegistryInterface;

/**
* One row per Sylius product (§8.7), scoped over channel × locale × currency — a sibling to the
* product_variant feed type for destinations that want product-level rows (with a "from" price and
* an aggregated availability). Its available source fields are the registered product value
* resolvers.
*/
final class ProductFeedType implements FeedTypeInterface
{
/**
* The source fields this feed type exposes, resolved from the value resolver registry by name.
*
* @var list<string>
*/
private const FIELDS = [
'id',
'title',
'description',
'link',
'main_image',
'additional_images',
'from_price',
'product_availability',
'variant_count',
'is_configurable',
];

public function __construct(
private readonly DataSourceInterface $dataSource,
private readonly ValueResolverRegistryInterface $valueResolverRegistry,
) {
}

public function getCode(): string
{
return 'product';
}

public function getLabel(): string
{
return 'setono_sylius_feed.feed_type.product';
}

public function getDataSource(): DataSourceInterface
{
return $this->dataSource;
}

public function getScopeDimensions(): array
{
return [ScopeDimension::CHANNEL, ScopeDimension::LOCALE, ScopeDimension::CURRENCY];
}

public function getAvailableFields(): array
{
$fields = [];

foreach (self::FIELDS as $name) {
if (!$this->valueResolverRegistry->has($name)) {
continue;
}

$resolver = $this->valueResolverRegistry->get($name);
$fields[$name] = new FieldDefinition(
$name,
$resolver->getLabel(),
$resolver->getType(),
$resolver,
'additional_images' === $name,
);
}

return $fields;
}
}
91 changes: 91 additions & 0 deletions src/Form/Type/LookupTableType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusFeedPlugin\Form\Type;

use Setono\SyliusFeedPlugin\Model\LookupTableInterface;
use Sylius\Bundle\ResourceBundle\Form\Type\AbstractResourceType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Webmozart\Assert\Assert;

/**
* A LookupTable (§10.1): its code/name, the source (`csv`/`url`) + its single location, the
* key/join columns and the refresh policy. The location is an unmapped field packed into
* `sourceConfig` as `path` (csv) or `url` (url) on submit, and unpacked on edit.
*/
final class LookupTableType extends AbstractResourceType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('code', TextType::class, [
'label' => 'sylius.ui.code',
])
->add('name', TextType::class, [
'required' => false,
'label' => 'setono_sylius_feed.form.lookup_table.name',
])
->add('sourceType', ChoiceType::class, [
'label' => 'setono_sylius_feed.form.lookup_table.source_type',
'choices' => [
'setono_sylius_feed.form.lookup_table.source.csv' => LookupTableInterface::SOURCE_TYPE_CSV,
'setono_sylius_feed.form.lookup_table.source.url' => LookupTableInterface::SOURCE_TYPE_URL,
],
])
->add('location', TextType::class, [
'mapped' => false,
'required' => false,
'label' => 'setono_sylius_feed.form.lookup_table.location',
'help' => 'setono_sylius_feed.form.lookup_table.location_help',
])
->add('keyColumn', TextType::class, [
'label' => 'setono_sylius_feed.form.lookup_table.key_column',
])
->add('joinField', TextType::class, [
'label' => 'setono_sylius_feed.form.lookup_table.join_field',
])
->add('refreshPolicy', ChoiceType::class, [
'label' => 'setono_sylius_feed.form.lookup_table.refresh_policy',
'choices' => [
'setono_sylius_feed.form.lookup_table.policy.manual' => LookupTableInterface::REFRESH_POLICY_MANUAL,
'setono_sylius_feed.form.lookup_table.policy.before_generate' => LookupTableInterface::REFRESH_POLICY_BEFORE_GENERATE,
'setono_sylius_feed.form.lookup_table.policy.scheduled' => LookupTableInterface::REFRESH_POLICY_SCHEDULED,
],
])
;

$builder->addEventListener(FormEvents::POST_SET_DATA, static function (FormEvent $event): void {
$table = $event->getData();
if (!$table instanceof LookupTableInterface) {
return;
}

$config = $table->getSourceConfig();
$location = $config['url'] ?? $config['path'] ?? null;
if (is_string($location)) {
$event->getForm()->get('location')->setData($location);
}
});

$builder->addEventListener(FormEvents::POST_SUBMIT, static function (FormEvent $event): void {
$table = $event->getData();
Assert::isInstanceOf($table, LookupTableInterface::class);

$location = $event->getForm()->get('location')->getData();
if (is_string($location) && '' !== $location) {
$key = LookupTableInterface::SOURCE_TYPE_URL === $table->getSourceType() ? 'url' : 'path';
$table->setSourceConfig([$key => $location]);
}
});
}

public function getBlockPrefix(): string
{
return 'setono_sylius_feed_lookup_table';
}
}
37 changes: 37 additions & 0 deletions src/Format/CsvFormat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusFeedPlugin\Format;

use Setono\SyliusFeedPlugin\Writer\CsvWriter;
use Setono\SyliusFeedPlugin\Writer\CsvWriterConfig;
use Setono\SyliusFeedPlugin\Writer\WriterConfigInterface;

/**
* The generic comma-delimited CSV format (§7): the `csv` writer family with a default
* comma/double-quote dialect. Destination-specific CSV dialects (Meta, …) are mapping presets over
* this format; the header is the union of the feed's output fields, injected by the generator.
*/
final class CsvFormat implements FormatInterface
{
public function getCode(): string
{
return 'csv';
}

public function getWriter(): string
{
return CsvWriter::FORMAT;
}

public function getConfig(): WriterConfigInterface
{
return new CsvWriterConfig();
}

public function getRequiredFields(): array
{
return [];
}
}
Loading
Loading