From f9bbaa782b262bf16cdb75e79655c378be67aa28 Mon Sep 17 00:00:00 2001 From: tito10047 Date: Fri, 5 Dec 2025 12:29:02 +0100 Subject: [PATCH 01/13] some refactoring --- README.md | 79 +++++--- config/definition.php | 78 +++----- config/services.php | 23 ++- src/Command/DebugPreferenceCommand.php | 18 +- src/DataCollector/PreferenceDataCollector.php | 4 +- .../AutoTagContextKeyResolverPass.php | 5 +- .../Compiler/TraceableManagersPass.php | 4 +- src/Enum/SelectionMode.php | 19 ++ src/PersistentPreferenceBundle.php | 50 +---- src/Resolver/ObjectContextResolver.php | 4 +- .../StorableObjectConverterInterface.php | 14 ++ ...renceManager.php => PersistentManager.php} | 23 ++- src/Service/PersistentManagerInterface.php | 14 ++ .../PreconfiguredPreferenceInterface.php | 8 + .../PreconfiguredSelectionInterface.php | 8 + src/Service/Preference.php | 10 +- src/Service/PreferenceManagerInterface.php | 12 -- src/Service/SelectionInterface.php | 75 +++++++ src/Service/TraceablePersistentManager.php | 30 +++ src/Service/TraceablePreferenceManager.php | 25 --- ...rage.php => DoctrinePreferenceStorage.php} | 2 +- ...orage.php => PreferenceSessionStorage.php} | 2 +- ...ace.php => PreferenceStorageInterface.php} | 2 +- src/Storage/SelectionSessionStorage.php | 188 ++++++++++++++++++ src/Storage/SelectionStorageInterface.php | 82 ++++++++ src/Storage/StorableEnvelope.php | 45 +++++ src/Twig/PreferenceExtension.php | 2 +- src/Twig/PreferenceRuntime.php | 4 +- .../packages/persistent_preference.yaml | 44 ++-- .../DebugPreferenceCommandIntegrationTest.php | 4 +- ...PreferenceDataCollectorIntegrationTest.php | 8 +- .../Resolver/ObjectContextResolverTest.php | 4 +- .../Service/PreferenceManagerTest.php | 8 +- .../Storage/DoctrineStorageTest.php | 8 +- .../Twig/PreferenceExtensionTest.php | 4 +- tests/Trait/SessionInterfaceTrait.php | 30 +++ .../Command/DebugPreferenceCommandTest.php | 24 +-- .../PreferenceDataCollectorTest.php | 8 +- tests/Unit/Service/PreferenceTest.php | 10 +- ...t.php => PreferenceSessionStorageTest.php} | 8 +- .../Storage/SelectionSessionStorageTest.php | 143 +++++++++++++ 41 files changed, 864 insertions(+), 269 deletions(-) create mode 100644 src/Enum/SelectionMode.php create mode 100644 src/Resolver/StorableObjectConverterInterface.php rename src/Service/{PreferenceManager.php => PersistentManager.php} (59%) create mode 100644 src/Service/PersistentManagerInterface.php create mode 100644 src/Service/PreconfiguredPreferenceInterface.php create mode 100644 src/Service/PreconfiguredSelectionInterface.php delete mode 100644 src/Service/PreferenceManagerInterface.php create mode 100644 src/Service/SelectionInterface.php create mode 100644 src/Service/TraceablePersistentManager.php delete mode 100644 src/Service/TraceablePreferenceManager.php rename src/Storage/{DoctrineStorage.php => DoctrinePreferenceStorage.php} (97%) rename src/Storage/{SessionStorage.php => PreferenceSessionStorage.php} (97%) rename src/Storage/{StorageInterface.php => PreferenceStorageInterface.php} (98%) create mode 100644 src/Storage/SelectionSessionStorage.php create mode 100644 src/Storage/SelectionStorageInterface.php create mode 100644 src/Storage/StorableEnvelope.php create mode 100644 tests/Trait/SessionInterfaceTrait.php rename tests/Unit/Storage/{SessionStorageTest.php => PreferenceSessionStorageTest.php} (95%) create mode 100644 tests/Unit/Storage/SelectionSessionStorageTest.php diff --git a/README.md b/README.md index 296cd68..0ed8a2f 100644 --- a/README.md +++ b/README.md @@ -3,27 +3,35 @@ # 🛒 Persistent Preference Bundle ```yaml -persistent_preference: - managers: - default: - storage: 'persistent_preference.storage.session' - my_pref_manager: - storage: 'app.persistent_preference.storage.doctrine' - storage: - doctrine: - id: 'app.persistent_preference.storage.doctrine' - enabled: true - preference_class: App\Entity\UserPreference - entity_manager: 'default' - - context_providers: - users: - class: App\Entity\User - prefix: 'user' - companies: - class: App\Entity\Company - prefix: 'company' - identifier_method: 'getUuid' +services: + app.users_resolver: + class: Tito10047\PersistentPreferenceBundle\Resolver\ObjectContextResolver + arguments: + $targetClass: App\Entity\User + $identifierMethod: 'getName' + app.companies_resolver: + class: Tito10047\PersistentPreferenceBundle\Resolver\ObjectContextResolver + arguments: + $targetClass: App\Entity\Company + app.storage.doctrine: + class: Tito10047\PersistentPreferenceBundle\Storage\DoctrinePreferenceStorage + arguments: + - '@doctrine.orm.entity_manager' + - Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\UserPreference + +persistent: + preference: + managers: + default: + storage: '@persistent_preference.storage.session' + my_pref_manager: + storage: '@app.storage.doctrine' + selection: + managers: + default: + storage: 'persistent.selection.storage.session' + simple: + storage: 'persistent.selection.storage.doctrine' ``` ```php @@ -32,20 +40,34 @@ namespace ; use \Symfony\Component\DependencyInjection\Attribute\Autowire; use \App\Entity\User; use \App\Entity\Company; +use \App\Entity\Product; class Foo{ public function __construct( - private readonly PreferenceManagerInterface $sessionPrefManager - #[Autowire('persistent_preference.manager.my_pref_manager')] - private readonly PreferenceManagerInterface $doctrinePrefManager, + private readonly PreconfiguredPreferenceInterface $sessionPrefManager + #[Autowire('persistent.preference.my_pref_manager')] + private readonly PreconfiguredPreferenceInterface $doctrinePrefManager, + private readonly PreconfiguredSelectionInterface $sessionPrefManager, + #[Autowire('persistent.selection.my_sel_manager')] + private readonly PreconfiguredSelectionInterface $doctrinePrefManager, private readonly EntityManagerInterface $em ) {} - public function bar(User $user, Company $company){ + public function bar(User $user, Company $company, Product $product){ $userPref = $this->sessionPrefManager->getPreference($user); $companyPref = $this->doctrinePrefManager->getPreference($company); + + $cartSelection = $this->sessionPrefManager->getSelection($user,"cart"); + $companySelection = $this->doctrinePrefManager->getSelection($company, "products"); + + $cartSelection->select($product, [ + 'quantity' => $request->get('qty', 1), + 'added_at' => new \DateTime() + ]); + + $companySelection->select($product); $userPref->set('foo', 'bar'); $userPref->set('baz', [1,2,3]); @@ -60,8 +82,13 @@ class Foo{ $foo2 = $companyPref->get('foo2'); $baz2 = $companyPref->get('baz2'); + + $selectedItems = $cartSelection->getSelectedObjects(); + $selectedProducts = $companySelection->getSelectedObjects(); + + + $cart->destroy(); } - } ``` diff --git a/config/definition.php b/config/definition.php index 8a23593..e0ac4ee 100644 --- a/config/definition.php +++ b/config/definition.php @@ -6,58 +6,34 @@ * @link https://symfony.com/doc/current/bundles/best_practices.html#configuration */ return static function (DefinitionConfigurator $definition): void { - $definition - ->rootNode() - ->children() - // Sekcia 1: Resolvery (Ako z objektu spraviť kľúč) - ->arrayNode('context_providers') - ->info('Map entities to context prefixes (e.g. User -> "user_123")') - ->useAttributeAsKey('name') - ->arrayPrototype() - ->children() - ->scalarNode('class')->isRequired()->cannotBeEmpty()->end() - ->scalarNode('prefix')->isRequired()->cannotBeEmpty()->end() - ->scalarNode('identifier_method')->defaultValue('getId')->end() - ->end() - ->end() - ->end() - - // Sekcia 2: Storage (Kde to uložiť) - ->arrayNode('storage') - ->addDefaultsIfNotSet() - ->children() - ->arrayNode('doctrine') - ->canBeEnabled() // Creates an 'enabled' boolean key - ->children() - ->scalarNode('id') - ->info('Service ID to register the Doctrine storage under') - ->isRequired() - ->end() - ->scalarNode('preference_class') - ->info('The Entity class that implements PreferenceEntityInterface') - ->isRequired() - ->end() - ->scalarNode('entity_manager') - ->defaultValue('default') - ->info('The Doctrine Entity Manager to use') - ->end() - ->end() - ->end() - // Tu môžeme v budúcnosti pridať ->arrayNode('redis')... - ->end() - ->end() - ->arrayNode('managers') - ->useAttributeAsKey('name') - ->arrayPrototype() - ->children() - - // ID storage služby; ak nie je zadané, použije sa defaultná storage aliasovaná na StorageInterface - ->scalarNode('storage')->defaultNull()->end() - + $addManagerSection = static function (\Symfony\Component\Config\Definition\Builder\NodeBuilder $nodeBuilder, string $sectionName): void + { + $nodeBuilder + ->arrayNode($sectionName) + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('managers') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('storage') + ->isRequired() + ->cannotBeEmpty() + ->info('The service ID of the storage backend to use (e.g. "@app.storage.doctrine").') + ->end() + ->end() ->end() ->end() ->end() - ->end() - ->end() - ; + ->end(); + }; + $rootNode = $definition->rootNode(); + $children = $rootNode->children(); + + $addManagerSection($children, 'preference'); + + $addManagerSection($children, 'selection'); + + $children->end(); + }; diff --git a/config/services.php b/config/services.php index 93b248a..e2f1220 100644 --- a/config/services.php +++ b/config/services.php @@ -8,11 +8,11 @@ use Tito10047\PersistentPreferenceBundle\Converter\ObjectVarsConverter; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagContextKeyResolverPass; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagValueTransformerPass; -use Tito10047\PersistentPreferenceBundle\Service\PreferenceManager; -use Tito10047\PersistentPreferenceBundle\Service\PreferenceManagerInterface; -use Tito10047\PersistentPreferenceBundle\Service\TraceablePreferenceManager; -use Tito10047\PersistentPreferenceBundle\Storage\SessionStorage; -use Tito10047\PersistentPreferenceBundle\Storage\StorageInterface; +use Tito10047\PersistentPreferenceBundle\Service\PersistentManager; +use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; +use Tito10047\PersistentPreferenceBundle\Service\TraceablePersistentManager; +use Tito10047\PersistentPreferenceBundle\Storage\PreferenceSessionStorage; +use Tito10047\PersistentPreferenceBundle\Storage\PreferenceStorageInterface; use Tito10047\PersistentPreferenceBundle\Resolver\PersistentContextResolver; use Tito10047\PersistentPreferenceBundle\Transformer\ScalarValueTransformer; use Tito10047\PersistentPreferenceBundle\Twig\PreferenceExtension; @@ -32,11 +32,12 @@ // --- Storage --- $services - ->set('persistent_preference.storage.session',SessionStorage::class) + ->set('persistent_preference.storage.session',PreferenceSessionStorage::class) ->arg('$requestStack', service(RequestStack::class)) + ->public() ; // Alias the interface to our concrete storage service id - $services->alias(StorageInterface::class, 'persistent_preference.storage.session'); + $services->alias(PreferenceStorageInterface::class, 'persistent_preference.storage.session'); // --- Built-in Resolvers --- $services @@ -59,14 +60,14 @@ // --- PreferenceManager --- $services - ->set('persistent_preference.manager.default', PreferenceManager::class) + ->set('persistent_preference.manager.default', PersistentManager::class) ->public() ->arg('$resolvers', tagged_iterator(AutoTagContextKeyResolverPass::TAG)) ->arg('$transformers', tagged_iterator(AutoTagValueTransformerPass::TAG)) ->arg('$storage', service('persistent_preference.storage.session')) ->tag('persistent_preference.manager', ['name' => 'default']) ; - $services->alias(PreferenceManagerInterface::class, 'persistent_preference.manager.default'); + $services->alias(PersistentManagerInterface::class, 'persistent_preference.manager.default'); // --- Twig Extension --- $services @@ -79,7 +80,7 @@ $services ->set(PreferenceRuntime::class) ->public() - ->arg('$preferenceManager', service(PreferenceManagerInterface::class)) + ->arg('$preferenceManager', service(PersistentManagerInterface::class)) ->tag('twig.runtime') ; @@ -99,7 +100,7 @@ $services ->set(PreferenceDataCollector::class) ->public() - ->arg('$storage', service(StorageInterface::class)) + ->arg('$storage', service(PreferenceStorageInterface::class)) ->tag('data_collector', [ 'id' => 'app.preference_collector', 'template' => 'data_collector/panel.html.twig', diff --git a/src/Command/DebugPreferenceCommand.php b/src/Command/DebugPreferenceCommand.php index 4eb0eb9..a65d166 100644 --- a/src/Command/DebugPreferenceCommand.php +++ b/src/Command/DebugPreferenceCommand.php @@ -10,10 +10,10 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Tito10047\PersistentPreferenceBundle\Service\PreferenceManagerInterface; -use Tito10047\PersistentPreferenceBundle\Storage\DoctrineStorage; -use Tito10047\PersistentPreferenceBundle\Storage\SessionStorage; -use Tito10047\PersistentPreferenceBundle\Storage\StorageInterface; +use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; +use Tito10047\PersistentPreferenceBundle\Storage\DoctrinePreferenceStorage; +use Tito10047\PersistentPreferenceBundle\Storage\PreferenceSessionStorage; +use Tito10047\PersistentPreferenceBundle\Storage\PreferenceStorageInterface; #[AsCommand(name: 'debug:preference', description: 'Print preferences for a given context and manager')] final class DebugPreferenceCommand extends Command @@ -44,12 +44,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $manager = $this->container->get($serviceId); - if (!$manager instanceof PreferenceManagerInterface) { + if (!$manager instanceof PersistentManagerInterface) { $io->error(sprintf('Service "%s" is not a PreferenceManagerInterface.', $serviceId)); return Command::FAILURE; } - $storage = $manager->getStorage(); + $storage = $manager->getPreferenceStorage(); $storageName = $this->detectStorageName($storage); $preference = $manager->getPreference($context); @@ -77,11 +77,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - private function detectStorageName(StorageInterface $storage): string + private function detectStorageName(PreferenceStorageInterface $storage): string { return match (true) { - $storage instanceof DoctrineStorage => 'doctrine', - $storage instanceof SessionStorage => 'session', + $storage instanceof DoctrinePreferenceStorage => 'doctrine', + $storage instanceof PreferenceSessionStorage => 'session', default => (new \ReflectionClass($storage))->getShortName(), }; } diff --git a/src/DataCollector/PreferenceDataCollector.php b/src/DataCollector/PreferenceDataCollector.php index 62a4356..de51d46 100644 --- a/src/DataCollector/PreferenceDataCollector.php +++ b/src/DataCollector/PreferenceDataCollector.php @@ -7,11 +7,11 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; -use Tito10047\PersistentPreferenceBundle\Storage\StorageInterface; +use Tito10047\PersistentPreferenceBundle\Storage\PreferenceStorageInterface; final class PreferenceDataCollector extends DataCollector { - public function __construct(private readonly StorageInterface $storage) + public function __construct(private readonly PreferenceStorageInterface $storage) { $this->reset(); } diff --git a/src/DependencyInjection/Compiler/AutoTagContextKeyResolverPass.php b/src/DependencyInjection/Compiler/AutoTagContextKeyResolverPass.php index c677c72..904a4ec 100644 --- a/src/DependencyInjection/Compiler/AutoTagContextKeyResolverPass.php +++ b/src/DependencyInjection/Compiler/AutoTagContextKeyResolverPass.php @@ -6,7 +6,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Tito10047\PersistentPreferenceBundle\Resolver\ContextKeyResolverInterface; -use Tito10047\PersistentPreferenceBundle\Resolver\ObjectContextResolver; +// no need to reference specific resolver classes here /** * Automatically adds the persistent_selection.identifier_normalizer tag to any @@ -50,9 +50,6 @@ public function process(ContainerBuilder $container): void if (!is_string($class)) { continue; } - if ($class == ObjectContextResolver::class){ - continue; - } // Use ContainerBuilder's reflection helper to avoid triggering // autoload errors for vendor/dev classes that may not be present. diff --git a/src/DependencyInjection/Compiler/TraceableManagersPass.php b/src/DependencyInjection/Compiler/TraceableManagersPass.php index 5dde472..40931ae 100644 --- a/src/DependencyInjection/Compiler/TraceableManagersPass.php +++ b/src/DependencyInjection/Compiler/TraceableManagersPass.php @@ -8,7 +8,7 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; -use Tito10047\PersistentPreferenceBundle\Service\TraceablePreferenceManager; +use Tito10047\PersistentPreferenceBundle\Service\TraceablePersistentManager; /** * Decorates all preference managers with TraceablePreferenceManager in debug mode @@ -42,7 +42,7 @@ public function process(ContainerBuilder $container): void continue; // already decorated } - $definition = new Definition(TraceablePreferenceManager::class); + $definition = new Definition(TraceablePersistentManager::class); $definition->setPublic(true); $definition->setDecoratedService($serviceId); $definition->setArguments([ diff --git a/src/Enum/SelectionMode.php b/src/Enum/SelectionMode.php new file mode 100644 index 0000000..eea2a7d --- /dev/null +++ b/src/Enum/SelectionMode.php @@ -0,0 +1,19 @@ +import('../config/definition.php'); @@ -38,51 +38,17 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $services->set('persistent_preference.converter.object_vars', ObjectVarsConverter::class) ->alias(MetadataConverterInterface::class, 'persistent_preference.converter.object_vars'); - // Optional Doctrine storage service from configuration - $doctrineCfg = $config['storage']['doctrine'] ?? null; - if (is_array($doctrineCfg) && ($doctrineCfg['enabled'] ?? false)) { - $serviceId = $doctrineCfg['id'] ?? 'persistent_preference.storage.doctrine'; - $entityClass = $doctrineCfg['preference_class'] ?? null; - $emName = $doctrineCfg['entity_manager'] ?? 'default'; - $emServiceId = sprintf('doctrine.orm.%s_entity_manager', $emName); - /** @var ServiceConfigurator $def */ - $def = $services - ->set($serviceId, DoctrineStorage::class) - ->public() - ; - $def->arg('$em', service($emServiceId)); - $def->arg('$entityClass', $entityClass); - } - - - // 1) Register configured context providers as services - $contextProviders = $config['context_providers'] ?? []; - foreach ($contextProviders as $name => $providerCfg) { - $serviceId = 'persistent_preference.context_resolver.' . $name; - /** @var ServiceConfigurator $def */ - $def = $services - ->set($serviceId, ObjectContextResolver::class) - ->public() - ; - $def->arg('$class', $providerCfg['class']); - $def->arg('$prefix', $providerCfg['prefix']); - $def->arg('$identifierMethod', $providerCfg['identifier_method'] ?? 'getId'); - - // Manually tag as a context key resolver so it's injected into managers - $def->tag(AutoTagContextKeyResolverPass::TAG); - } - - // 2) Register managers - $configManagers = $config['managers'] ?? []; + $configManagers = $config['preference']['managers'] ?? []; foreach ($configManagers as $name => $subConfig) { - $storage = service($subConfig['storage'] ?? 'persistent_preference.storage.session'); + $storage = service($subConfig['storage'] ?? '@persistent_preference.storage.session'); + $storage = ltrim($storage, '@'); $services - ->set('persistent_preference.manager.' . $name, PreferenceManager::class) + ->set('persistent_preference.manager.' . $name, PersistentManager::class) ->public() ->arg('$resolvers', tagged_iterator(AutoTagContextKeyResolverPass::TAG)) ->arg('$transformers', tagged_iterator(AutoTagValueTransformerPass::TAG)) - ->arg('$storage', $storage) + ->arg('$storage', service($storage)) ->arg('$dispatcher', service('event_dispatcher')) ->tag('persistent_preference.manager', ['name' => $name]); } diff --git a/src/Resolver/ObjectContextResolver.php b/src/Resolver/ObjectContextResolver.php index a55373d..03c266f 100644 --- a/src/Resolver/ObjectContextResolver.php +++ b/src/Resolver/ObjectContextResolver.php @@ -10,7 +10,7 @@ class ObjectContextResolver implements ContextKeyResolverInterface{ public function __construct( private readonly string $class, private readonly string $prefix, - private readonly string $identifierMethod, + private readonly string $identifierMethod = 'getId', ) { } @@ -22,6 +22,6 @@ public function resolve(object $context): string { if (!method_exists($context, $this->identifierMethod)){ throw new \LogicException('Method ' . $this->identifierMethod . ' not found in ' . get_class($context)); } - return $this->prefix . '_' . $context->{$this->identifierMethod}(); + return $this->prefix . $context->{$this->identifierMethod}(); } } \ No newline at end of file diff --git a/src/Resolver/StorableObjectConverterInterface.php b/src/Resolver/StorableObjectConverterInterface.php new file mode 100644 index 0000000..61b6da5 --- /dev/null +++ b/src/Resolver/StorableObjectConverterInterface.php @@ -0,0 +1,14 @@ + $resolvers * @param iterable $transformers */ public function __construct( - private readonly iterable $resolvers, - private readonly iterable $transformers, - private readonly StorageInterface $storage, - private readonly EventDispatcherInterface $dispatcher, + private readonly iterable $resolvers, + private readonly iterable $transformers, + private readonly PreferenceStorageInterface $storage, + private readonly EventDispatcherInterface $dispatcher, ) {} - public function getPreference(object|string $context): PreferenceInterface + public function getPreference(object|string $owner): PreferenceInterface { - $contextKey = $this->resolveContextKey($context); + $contextKey = $this->resolveContextKey($owner); return new Preference($this->transformers, $contextKey, $this->storage, $this->dispatcher); } - public function getStorage(): StorageInterface + public function getPreferenceStorage(): PreferenceStorageInterface { return $this->storage; } @@ -51,4 +52,8 @@ private function resolveContextKey(object|string $context): string get_class($context) )); } + + public function getSelection(string $namespace, mixed $owner = null): SelectionInterface { + // TODO: Implement getSelection() method. + } } \ No newline at end of file diff --git a/src/Service/PersistentManagerInterface.php b/src/Service/PersistentManagerInterface.php new file mode 100644 index 0000000..f1d2886 --- /dev/null +++ b/src/Service/PersistentManagerInterface.php @@ -0,0 +1,14 @@ + $transformers */ public function __construct( - private readonly iterable $transformers, - private readonly string $context, - private readonly StorageInterface $storage, - private readonly EventDispatcherInterface $dispatcher, + private readonly iterable $transformers, + private readonly string $context, + private readonly PreferenceStorageInterface $storage, + private readonly EventDispatcherInterface $dispatcher, ) {} public function getContext(): string diff --git a/src/Service/PreferenceManagerInterface.php b/src/Service/PreferenceManagerInterface.php deleted file mode 100644 index d7c87e1..0000000 --- a/src/Service/PreferenceManagerInterface.php +++ /dev/null @@ -1,12 +0,0 @@ - $metadataMap + * Asociatívne pole, kde KĽÚČ je ID položky (získané normalizáciou) + * a HODNOTA sú metadata pre danú položku. + * Príklad: [101 => ['qty' => 5], 102 => ['qty' => 1]] + */ + public function selectMultiple(array $items, null|array $metadata=null):static; + public function unselectMultiple(array $items):static; + + public function selectAll():static; + + public function unselectAll():static; + + /** + * @return array + */ + public function getSelectedIdentifiers(): array; + /** + * Vráti mapu vybraných položiek. Ak je zadaná $metadataClass, metadáta sa hydratujú. + * + * @param class-string $metadataClass FQCN pre hydratáciu metadát (napr. MyDomainConfig::class). + * @return array + * @template T of object + * @phpstan-param class-string|null $metadataClass + * @phpstan-return array|array + */ + public function getSelected(?string $metadataClass = null): array; + + /** + * Vráti mapu vybraných položiek. Ak je zadaná $metadataClass, metadáta sa hydratujú. + * + * @param class-string|null $metadataClass FQCN pre hydratáciu metadát (napr. MyDomainConfig::class). + * @return T|array|null + * @template T of object + * @phpstan-param class-string|null $metadataClass + * @phpstan-return T|array + */ + public function getMetadata(mixed $item, ?string $metadataClass = null): null|array|object; + + public function getTotal():int; + + public function normalize(mixed $item):int|string; +} \ No newline at end of file diff --git a/src/Service/TraceablePersistentManager.php b/src/Service/TraceablePersistentManager.php new file mode 100644 index 0000000..e6ca2c1 --- /dev/null +++ b/src/Service/TraceablePersistentManager.php @@ -0,0 +1,30 @@ +inner->getPreference($owner); + return new TraceablePreference($this->managerName, $preference, $this->collector); + } + + public function getPreferenceStorage(): \Tito10047\PersistentPreferenceBundle\Storage\PreferenceStorageInterface + { + return $this->inner->getPreferenceStorage(); + } + + public function getSelection(string $namespace, mixed $owner = null): SelectionInterface { + // TODO: Implement getSelection() method. + } +} diff --git a/src/Service/TraceablePreferenceManager.php b/src/Service/TraceablePreferenceManager.php deleted file mode 100644 index b577882..0000000 --- a/src/Service/TraceablePreferenceManager.php +++ /dev/null @@ -1,25 +0,0 @@ -inner->getPreference($context); - return new TraceablePreference($this->managerName, $preference, $this->collector); - } - - public function getStorage(): \Tito10047\PersistentPreferenceBundle\Storage\StorageInterface - { - return $this->inner->getStorage(); - } -} diff --git a/src/Storage/DoctrineStorage.php b/src/Storage/DoctrinePreferenceStorage.php similarity index 97% rename from src/Storage/DoctrineStorage.php rename to src/Storage/DoctrinePreferenceStorage.php index eb96e04..e48f0d2 100644 --- a/src/Storage/DoctrineStorage.php +++ b/src/Storage/DoctrinePreferenceStorage.php @@ -6,7 +6,7 @@ use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\QueryBuilder; -final class DoctrineStorage implements StorageInterface +final class DoctrinePreferenceStorage implements PreferenceStorageInterface { public function __construct( private readonly EntityManagerInterface $em, diff --git a/src/Storage/SessionStorage.php b/src/Storage/PreferenceSessionStorage.php similarity index 97% rename from src/Storage/SessionStorage.php rename to src/Storage/PreferenceSessionStorage.php index 019953a..935184d 100644 --- a/src/Storage/SessionStorage.php +++ b/src/Storage/PreferenceSessionStorage.php @@ -5,7 +5,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\SessionInterface; -final class SessionStorage implements StorageInterface +final class PreferenceSessionStorage implements PreferenceStorageInterface { private const SESSION_PREFIX = '_persistent_preference_'; diff --git a/src/Storage/StorageInterface.php b/src/Storage/PreferenceStorageInterface.php similarity index 98% rename from src/Storage/StorageInterface.php rename to src/Storage/PreferenceStorageInterface.php index c955d19..52b490d 100644 --- a/src/Storage/StorageInterface.php +++ b/src/Storage/PreferenceStorageInterface.php @@ -9,7 +9,7 @@ * type casting. It simply stores and retrieves serialized data (scalars/arrays) * associated with a specific context string. */ -interface StorageInterface +interface PreferenceStorageInterface { /** * Retrieves a raw value from storage. diff --git a/src/Storage/SelectionSessionStorage.php b/src/Storage/SelectionSessionStorage.php new file mode 100644 index 0000000..8ffb5cf --- /dev/null +++ b/src/Storage/SelectionSessionStorage.php @@ -0,0 +1,188 @@ + [ + * 'mode'=> 'include', // value of SelectionMode enum + * 'ids'=> [1, 2, 5], + * 'meta'=> [ + * 1=> [ ... metadata array ... ], + * 2=> [ ... metadata array ... ] + * ] + * ] + * ] + */ +final class SelectionSessionStorage implements SelectionStorageInterface +{ + private const SESSION_PREFIX = '_persistent_selection_'; + + /** + * Fallback session used when there is no active HTTP session available (e.g. CLI/tests). + */ + private ?SessionInterface $fallbackSession = null; + + public function __construct( + private readonly RequestStack $requestStack + ) {} + + public function add(string $context, array $ids, ?array $idMetadataMap): void + { + $data = $this->loadData($context); + + $mergedIds = array_merge($data['ids'], $ids); + + $data['ids'] = array_values(array_unique($mergedIds)); + + if ($idMetadataMap) { + foreach ($idMetadataMap as $id => $meta) { + // Pre istotu castujeme kľúč na string, aby bol v JSONe konzistentný + $data['meta'][(string)$id] = $meta; + } + } + + $this->saveData($context, $data); + } + + + public function remove(string $context, array $ids): void + { + $data = $this->loadData($context); + + // Remove specified IDs from the stored list + $diff = array_diff($data['ids'], $ids); + + // Re-index array after removal + $data['ids'] = array_values($diff); + + // Remove corresponding metadata entries for removed IDs + foreach ($ids as $id) { + unset($data['meta'][(string)$id]); + } + + $this->saveData($context, $data); + } + + public function clear(string $context): void + { + $this->getSession()->remove($this->getKey($context)); + } + + public function getStored(string $context): array + { + return $this->loadData($context)['ids']; + } + + public function hasIdentifier(string $context, int|string $id): bool + { + // Strict check might fail if mixing int/string types, + // but usually, we rely on PHP's loose comparison for in_array here + // or we should enforce type consistency in the add() method. + return in_array($id, $this->loadData($context)['ids']); + } + + public function setMode(string $context, SelectionMode $mode): void + { + $data = $this->loadData($context); + $data['mode'] = $mode->value; + + $this->saveData($context, $data); + } + + public function getMode(string $context): SelectionMode + { + $value = $this->loadData($context)['mode']; + + return SelectionMode::tryFrom($value) ?? SelectionMode::INCLUDE; + } + + + /** + * Helper to retrieve the session service. + * Using RequestStack allows usage in services where the session might not be started yet. + */ + private function getSession(): SessionInterface + { + try { + return $this->requestStack->getSession(); + } catch (SessionNotFoundException $e) { + // No HTTP session available (likely CLI/tests). Use in-memory fallback session. + if ($this->fallbackSession === null) { + $this->fallbackSession = new Session(new MockArraySessionStorage()); + } + + return $this->fallbackSession; + } + } + + /** + * Generates a namespaced key for the session. + */ + private function getKey(string $context): string + { + return self::SESSION_PREFIX . $context; + } + + /** + * Loads raw data from session or returns default structure. + * Ensures presence of newly added keys for backward compatibility. + * + * @return array{mode: string, ids: array, meta: array} + */ + private function loadData(string $context): array + { + $data = $this->getSession()->get($this->getKey($context), [ + 'mode' => SelectionMode::INCLUDE->value, + 'ids' => [], + 'meta' => [], + ]); + + // Backward compatibility: add missing keys if old structure is present + if (!isset($data['meta']) || !is_array($data['meta'])) { + $data['meta'] = []; + } + if (!isset($data['ids']) || !is_array($data['ids'])) { + $data['ids'] = []; + } + if (!isset($data['mode']) || !is_string($data['mode'])) { + $data['mode'] = SelectionMode::INCLUDE->value; + } + + return $data; + } + + /** + * Persists the data structure back to the session. + * @param array{mode: string, ids: array, meta: array} $data + */ + private function saveData(string $context, array $data): void + { + $this->getSession()->set($this->getKey($context), $data); + } + + public function getStoredWithMetadata(string $context): array { + $data = $this->loadData($context); + $result = []; + foreach ($data['ids'] as $id) { + $key = (string)$id; + $result[$id] = $data['meta'][$key] ?? []; + } + return $result; + } + + public function getMetadata(string $context, int|string $id): array { + $data = $this->loadData($context); + return $data['meta'][(string)$id] ?? []; + } +} \ No newline at end of file diff --git a/src/Storage/SelectionStorageInterface.php b/src/Storage/SelectionStorageInterface.php new file mode 100644 index 0000000..a61a94c --- /dev/null +++ b/src/Storage/SelectionStorageInterface.php @@ -0,0 +1,82 @@ + $ids + * @param array $idMetadataMap Mapa: ID => Konvertované pole metadát + */ + public function add(string $context, array $ids, ?array $idMetadataMap): void; + + /** + * Removes identifiers from the storage for a specific context. + * + * @param string $context The unique context key + * @param array $ids List of identifiers to remove + */ + public function remove(string $context, array $ids): void; + + /** + * Clears all data for the given context and resets the mode to INCLUDE. + * + * @param string $context The unique context key + */ + public function clear(string $context): void; + + /** + * Returns the raw identifiers currently stored. + * + * NOTE: The meaning of these IDs depends on the current SelectionMode. + * - If Mode is INCLUDE: These are the selected items. + * - If Mode is EXCLUDE: These are the unselected items (exceptions). + * + * @param string $context The unique context key + * @return array + */ + public function getStored(string $context): array; + + public function getStoredWithMetadata(string $context):array; + + public function getMetadata(string $context, string|int $id):array; + + /** + * Checks if a specific identifier is present in the storage. + * This checks the raw storage, ignoring the current Mode logic. + * + * @param string $context The unique context key + * @param string|int $id The identifier to check + */ + public function hasIdentifier(string $context, string|int $id): bool; + + /** + * Sets the selection mode (Include vs Exclude). + * + * @param string $context The unique context key + * @param SelectionMode $mode The target mode + */ + public function setMode(string $context, SelectionMode $mode): void; + + /** + * Retrieves the current selection mode. + * + * @param string $context The unique context key + */ + public function getMode(string $context): SelectionMode; +} \ No newline at end of file diff --git a/src/Storage/StorableEnvelope.php b/src/Storage/StorableEnvelope.php new file mode 100644 index 0000000..f1c1f69 --- /dev/null +++ b/src/Storage/StorableEnvelope.php @@ -0,0 +1,45 @@ + 'dark']) + */ + public readonly array $data + ) {} + + /** + * Pomocná metóda pre konverziu na pole (pre finálne úložisko) + */ + public function toArray(): array + { + return [ + '__class__' => $this->className, + 'data' => $this->data, + ]; + } + + /** + * Pomocná metóda pre vytvorenie z poľa (pri načítaní z úložiska) + */ + public static function fromArray(array $data): self + { + if (!isset($data['__class__']) || !isset($data['data'])) { + throw new \InvalidArgumentException('Invalid storable structure definition.'); + } + return new self($data['__class__'], $data['data']); + } +} \ No newline at end of file diff --git a/src/Twig/PreferenceExtension.php b/src/Twig/PreferenceExtension.php index a49cb61..7e95404 100644 --- a/src/Twig/PreferenceExtension.php +++ b/src/Twig/PreferenceExtension.php @@ -2,7 +2,7 @@ namespace Tito10047\PersistentPreferenceBundle\Twig; -use Tito10047\PersistentPreferenceBundle\Service\PreferenceManagerInterface; +use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Twig\Extension\AbstractExtension; use Twig\TwigFilter; use Twig\TwigFunction; diff --git a/src/Twig/PreferenceRuntime.php b/src/Twig/PreferenceRuntime.php index 8024207..51d5eb2 100644 --- a/src/Twig/PreferenceRuntime.php +++ b/src/Twig/PreferenceRuntime.php @@ -2,12 +2,12 @@ namespace Tito10047\PersistentPreferenceBundle\Twig; -use Tito10047\PersistentPreferenceBundle\Service\PreferenceManagerInterface; +use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Twig\Extension\RuntimeExtensionInterface; final class PreferenceRuntime implements RuntimeExtensionInterface { - public function __construct(private readonly PreferenceManagerInterface $preferenceManager) + public function __construct(private readonly PersistentManagerInterface $preferenceManager) { } diff --git a/tests/App/AssetMapper/config/packages/persistent_preference.yaml b/tests/App/AssetMapper/config/packages/persistent_preference.yaml index d96668f..9d1e8b8 100644 --- a/tests/App/AssetMapper/config/packages/persistent_preference.yaml +++ b/tests/App/AssetMapper/config/packages/persistent_preference.yaml @@ -1,21 +1,25 @@ -persistent_preference: - managers: - default: - storage: 'persistent_preference.storage.session' - my_pref_manager: - storage: 'app.persistent_preference.storage.doctrine' - storage: - doctrine: - id: 'app.persistent_preference.storage.doctrine' - enabled: true - preference_class: Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\UserPreference - entity_manager: 'default' +services: + app.users_resolver: + class: Tito10047\PersistentPreferenceBundle\Resolver\ObjectContextResolver + arguments: + $class: Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\User + $prefix: 'user_' + app.companies_resolver: + class: Tito10047\PersistentPreferenceBundle\Resolver\ObjectContextResolver + arguments: + $class: Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\Company + $prefix: 'company_' + $identifierMethod: 'getUuid' + app.storage.doctrine: + class: Tito10047\PersistentPreferenceBundle\Storage\DoctrinePreferenceStorage + arguments: + - '@doctrine.orm.entity_manager' + - Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\UserPreference - context_providers: - users: - class: Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\User - prefix: 'user' - companies: - class: Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\Company - prefix: 'company' - identifier_method: 'getUuid' \ No newline at end of file +persistent: + preference: + managers: + default: + storage: '@persistent_preference.storage.session' + my_pref_manager: + storage: '@app.storage.doctrine' \ No newline at end of file diff --git a/tests/Integration/Command/DebugPreferenceCommandIntegrationTest.php b/tests/Integration/Command/DebugPreferenceCommandIntegrationTest.php index e08f195..246b108 100644 --- a/tests/Integration/Command/DebugPreferenceCommandIntegrationTest.php +++ b/tests/Integration/Command/DebugPreferenceCommandIntegrationTest.php @@ -4,7 +4,7 @@ use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Tester\CommandTester; -use Tito10047\PersistentPreferenceBundle\Service\PreferenceManagerInterface; +use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; class DebugPreferenceCommandIntegrationTest extends AssetMapperKernelTestCase @@ -14,7 +14,7 @@ public function testRunsThroughContainerAndPrintsDoctrineStorage(): void static::bootKernel(); // Seed some preferences into doctrine-backed manager - /** @var PreferenceManagerInterface $pmDoctrine */ + /** @var PersistentManagerInterface $pmDoctrine */ $pmDoctrine = static::getContainer()->get('persistent_preference.manager.my_pref_manager'); $pmDoctrine->getPreference('user_15')->import([ 'theme' => 'dark', diff --git a/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php b/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php index 750ada3..13c8b51 100644 --- a/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php +++ b/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php @@ -11,7 +11,7 @@ use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; -use Tito10047\PersistentPreferenceBundle\Service\PreferenceManagerInterface; +use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; final class PreferenceDataCollectorIntegrationTest extends AssetMapperKernelTestCase @@ -22,8 +22,8 @@ public function testCollectorCountsPersistedPreferencesForContext(): void $container = self::getContainer(); // Write some preferences using the real manager & session-backed storage - /** @var PreferenceManagerInterface $manager */ - $manager = $container->get(PreferenceManagerInterface::class); + /** @var PersistentManagerInterface $manager */ + $manager = $container->get(PersistentManagerInterface::class); $contextKey = 'integration_test_ctx'; @@ -42,7 +42,7 @@ public function testCollectorCountsPersistedPreferencesForContext(): void ]); // Create collector (service may not be registered when profiler is absent) - $storage = $container->get(\Tito10047\PersistentPreferenceBundle\Storage\StorageInterface::class); + $storage = $container->get(\Tito10047\PersistentPreferenceBundle\Storage\PreferenceStorageInterface::class); $collector = new PreferenceDataCollector($storage); self::assertInstanceOf(DataCollectorInterface::class, $collector); diff --git a/tests/Integration/Resolver/ObjectContextResolverTest.php b/tests/Integration/Resolver/ObjectContextResolverTest.php index a5f8ad3..f472bb2 100644 --- a/tests/Integration/Resolver/ObjectContextResolverTest.php +++ b/tests/Integration/Resolver/ObjectContextResolverTest.php @@ -6,7 +6,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; -use Tito10047\PersistentPreferenceBundle\Service\PreferenceManagerInterface; +use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\Company; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\User; use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; @@ -32,7 +32,7 @@ public function testResolvesConfiguredUserAndCompanyContexts(): void static::bootKernel(); $this->ensureSession(); - $pm = static::getContainer()->get(PreferenceManagerInterface::class); + $pm = static::getContainer()->get(PersistentManagerInterface::class); $user = (new User())->setId(10); $company = (new Company())->setUuid(77); diff --git a/tests/Integration/Service/PreferenceManagerTest.php b/tests/Integration/Service/PreferenceManagerTest.php index 0d02170..e16b690 100644 --- a/tests/Integration/Service/PreferenceManagerTest.php +++ b/tests/Integration/Service/PreferenceManagerTest.php @@ -5,7 +5,7 @@ use PHPUnit\Framework\Attributes\TestWith; use stdClass; use Tito10047\PersistentPreferenceBundle\Service\PreferenceInterface; -use Tito10047\PersistentPreferenceBundle\Service\PreferenceManagerInterface; +use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Tito10047\PersistentPreferenceBundle\Service\PersistentContextInterface; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\ServiceHelper; use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; @@ -38,7 +38,7 @@ public function testReturnsPreferenceForStringContextAndPersists(): void { static::bootKernel(); $this->ensureSession(); - $pm = static::getContainer()->get(PreferenceManagerInterface::class); + $pm = static::getContainer()->get(PersistentManagerInterface::class); $pref = $pm->getPreference('user_1'); $this->assertInstanceOf(PreferenceInterface::class, $pref); @@ -59,7 +59,7 @@ public function testResolvesObjectContextViaPersistentContextInterface(): void { static::bootKernel(); $this->ensureSession(); - $pm = static::getContainer()->get(PreferenceManagerInterface::class); + $pm = static::getContainer()->get(PersistentManagerInterface::class); $obj = new class implements PersistentContextInterface { public function getPersistentContext(): string { return 'ctx_object_1'; } @@ -80,7 +80,7 @@ public function testThrowsForUnsupportedObject(): void { static::bootKernel(); $this->ensureSession(); - $pm = static::getContainer()->get(PreferenceManagerInterface::class); + $pm = static::getContainer()->get(PersistentManagerInterface::class); $this->expectException(\InvalidArgumentException::class); $pm->getPreference(new stdClass()); diff --git a/tests/Integration/Storage/DoctrineStorageTest.php b/tests/Integration/Storage/DoctrineStorageTest.php index 58ef2bf..4a4a13f 100644 --- a/tests/Integration/Storage/DoctrineStorageTest.php +++ b/tests/Integration/Storage/DoctrineStorageTest.php @@ -2,7 +2,7 @@ namespace Tito10047\PersistentPreferenceBundle\Tests\Integration\Storage; -use Tito10047\PersistentPreferenceBundle\Storage\DoctrineStorage; +use Tito10047\PersistentPreferenceBundle\Storage\DoctrinePreferenceStorage; use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; class DoctrineStorageTest extends AssetMapperKernelTestCase @@ -12,12 +12,12 @@ public function testServiceIsRegisteredFromConfigAndPersistsData(): void static::bootKernel(); $container = static::getContainer(); - $serviceId = 'app.persistent_preference.storage.doctrine'; + $serviceId = 'app.storage.doctrine'; $this->assertTrue($container->has($serviceId), 'Doctrine storage service should be registered with configured id'); - /** @var DoctrineStorage $storage */ + /** @var DoctrinePreferenceStorage $storage */ $storage = $container->get($serviceId); - $this->assertInstanceOf(DoctrineStorage::class, $storage); + $this->assertInstanceOf(DoctrinePreferenceStorage::class, $storage); // Basic CRUD $ctx = 'user_99'; diff --git a/tests/Integration/Twig/PreferenceExtensionTest.php b/tests/Integration/Twig/PreferenceExtensionTest.php index befae7e..3640d98 100644 --- a/tests/Integration/Twig/PreferenceExtensionTest.php +++ b/tests/Integration/Twig/PreferenceExtensionTest.php @@ -6,7 +6,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; -use Tito10047\PersistentPreferenceBundle\Service\PreferenceManagerInterface; +use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\Company; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\User; use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; @@ -33,7 +33,7 @@ public function testTwigFunctionAndFilterReturnStoredValues(): void static::bootKernel(); $this->ensureSession(); - $pm = static::getContainer()->get(PreferenceManagerInterface::class); + $pm = static::getContainer()->get(PersistentManagerInterface::class); $user = (new User())->setId(5)->setName('Alice'); $company = (new Company())->setUuid(10)->setName('ACME'); diff --git a/tests/Trait/SessionInterfaceTrait.php b/tests/Trait/SessionInterfaceTrait.php new file mode 100644 index 0000000..bb15719 --- /dev/null +++ b/tests/Trait/SessionInterfaceTrait.php @@ -0,0 +1,30 @@ +createMock(SessionInterface::class); + + $session->method('get') + ->willReturnCallback(function (string $key, mixed $default = null) use (&$sessionStore) { + return $sessionStore[$key] ?? $default; + }); + + $session->method('set') + ->willReturnCallback(function (string $key, mixed $value) use (&$sessionStore): void { + $sessionStore[$key] = $value; + }); + + $session->method('remove') + ->willReturnCallback(function (string $key) use (&$sessionStore): void { + unset($sessionStore[$key]); + }); + return $session; + } +} \ No newline at end of file diff --git a/tests/Unit/Command/DebugPreferenceCommandTest.php b/tests/Unit/Command/DebugPreferenceCommandTest.php index e27733b..5f37c9d 100644 --- a/tests/Unit/Command/DebugPreferenceCommandTest.php +++ b/tests/Unit/Command/DebugPreferenceCommandTest.php @@ -8,9 +8,9 @@ use Symfony\Component\HttpFoundation\RequestStack; use Tito10047\PersistentPreferenceBundle\Command\DebugPreferenceCommand; use Tito10047\PersistentPreferenceBundle\Service\PreferenceInterface; -use Tito10047\PersistentPreferenceBundle\Service\PreferenceManagerInterface; -use Tito10047\PersistentPreferenceBundle\Storage\SessionStorage; -use Tito10047\PersistentPreferenceBundle\Storage\StorageInterface; +use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; +use Tito10047\PersistentPreferenceBundle\Storage\PreferenceSessionStorage; +use Tito10047\PersistentPreferenceBundle\Storage\PreferenceStorageInterface; class DebugPreferenceCommandTest extends TestCase { @@ -51,11 +51,11 @@ public function testSuccessWithRowsAndSessionStorageLabel(): void 'nothing' => null, ]); - $storage = new SessionStorage(new RequestStack()); + $storage = new PreferenceSessionStorage(new RequestStack()); - $manager = $this->createMock(PreferenceManagerInterface::class); + $manager = $this->createMock(PersistentManagerInterface::class); $manager->method('getPreference')->with($context)->willReturn($preference); - $manager->method('getStorage')->willReturn($storage); + $manager->method('getPreferenceStorage')->willReturn($storage); $container = $this->makeContainerMock([ $serviceId => $manager, @@ -91,11 +91,11 @@ public function testEmptyPreferencesShowsMessage(): void $preference = $this->createMock(PreferenceInterface::class); $preference->method('all')->willReturn([]); - $storage = $this->createMock(StorageInterface::class); + $storage = $this->createMock(PreferenceStorageInterface::class); - $manager = $this->createMock(PreferenceManagerInterface::class); + $manager = $this->createMock(PersistentManagerInterface::class); $manager->method('getPreference')->with($context)->willReturn($preference); - $manager->method('getStorage')->willReturn($storage); + $manager->method('getPreferenceStorage')->willReturn($storage); $container = $this->makeContainerMock([ $serviceId => $manager, @@ -159,7 +159,7 @@ public function testFallbackStorageNameUsesShortClassName(): void $preference->method('all')->willReturn(['a' => 1]); // Custom storage implementing interface to trigger fallback name - $customStorage = new class() implements StorageInterface { + $customStorage = new class() implements PreferenceStorageInterface { public function get(string $context, string $key, mixed $default = null): mixed { return null; } public function set(string $context, string $key, mixed $value): void {} public function setMultiple(string $context, array $values): void {} @@ -168,9 +168,9 @@ public function has(string $context, string $key): bool { return false; } public function all(string $context): array { return []; } }; - $manager = $this->createMock(PreferenceManagerInterface::class); + $manager = $this->createMock(PersistentManagerInterface::class); $manager->method('getPreference')->with($context)->willReturn($preference); - $manager->method('getStorage')->willReturn($customStorage); + $manager->method('getPreferenceStorage')->willReturn($customStorage); $container = $this->makeContainerMock([ $serviceId => $manager, diff --git a/tests/Unit/DataCollector/PreferenceDataCollectorTest.php b/tests/Unit/DataCollector/PreferenceDataCollectorTest.php index c4ad001..2e8d500 100644 --- a/tests/Unit/DataCollector/PreferenceDataCollectorTest.php +++ b/tests/Unit/DataCollector/PreferenceDataCollectorTest.php @@ -8,14 +8,14 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; -use Tito10047\PersistentPreferenceBundle\Service\PreferenceManagerInterface; -use Tito10047\PersistentPreferenceBundle\Storage\StorageInterface; +use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; +use Tito10047\PersistentPreferenceBundle\Storage\PreferenceStorageInterface; final class PreferenceDataCollectorTest extends TestCase { public function testResetSetsDefaults(): void { - $storage = $this->createMock(StorageInterface::class); + $storage = $this->createMock(PreferenceStorageInterface::class); $collector = new PreferenceDataCollector($storage); $collector->reset(); @@ -28,7 +28,7 @@ public function testResetSetsDefaults(): void public function testCollectPopulatesData(): void { - $storage = $this->createMock(StorageInterface::class); + $storage = $this->createMock(PreferenceStorageInterface::class); $collector = new PreferenceDataCollector($storage); $request = new Request([], [], ['_route' => 'test_route']); diff --git a/tests/Unit/Service/PreferenceTest.php b/tests/Unit/Service/PreferenceTest.php index 48bf226..3ca908f 100644 --- a/tests/Unit/Service/PreferenceTest.php +++ b/tests/Unit/Service/PreferenceTest.php @@ -7,7 +7,7 @@ use Tito10047\PersistentPreferenceBundle\Event\PreferenceEvent; use Tito10047\PersistentPreferenceBundle\Event\PreferenceEvents; use Tito10047\PersistentPreferenceBundle\Service\Preference; -use Tito10047\PersistentPreferenceBundle\Storage\StorageInterface; +use Tito10047\PersistentPreferenceBundle\Storage\PreferenceStorageInterface; use Tito10047\PersistentPreferenceBundle\Transformer\ValueTransformerInterface; class PreferenceTest extends TestCase @@ -29,7 +29,7 @@ public function testSetDispatchesPreThenStoresThenDispatchesPost(): void $input = 'dark'; $transformed = 'dark_trans'; - $storage = $this->createMock(StorageInterface::class); + $storage = $this->createMock(PreferenceStorageInterface::class); $storage->expects(self::once()) ->method('set') ->with($context, $key, $transformed); @@ -71,7 +71,7 @@ public function testSetStopsOnPreEventPropagationStop(): void $key = 'k'; $value = 123; - $storage = $this->createMock(StorageInterface::class); + $storage = $this->createMock(PreferenceStorageInterface::class); $storage->expects(self::never())->method('set'); $dispatcher = $this->createMock(EventDispatcherInterface::class); @@ -93,7 +93,7 @@ public function testImportDispatchesPreForEachStoresOnceThenDispatchesPostForEac $values = ['a' => 1, 'b' => 2]; $transformed = ['a' => '1t', 'b' => '2t']; - $storage = $this->createMock(StorageInterface::class); + $storage = $this->createMock(PreferenceStorageInterface::class); $storage->expects(self::once()) ->method('setMultiple') ->with($context, $transformed); @@ -119,7 +119,7 @@ public function testImportStopsOnAnyPreEvent(): void $context = 'ctx4'; $values = ['x' => 10, 'y' => 20]; - $storage = $this->createMock(StorageInterface::class); + $storage = $this->createMock(PreferenceStorageInterface::class); $storage->expects(self::never())->method('setMultiple'); $dispatcher = $this->createMock(EventDispatcherInterface::class); diff --git a/tests/Unit/Storage/SessionStorageTest.php b/tests/Unit/Storage/PreferenceSessionStorageTest.php similarity index 95% rename from tests/Unit/Storage/SessionStorageTest.php rename to tests/Unit/Storage/PreferenceSessionStorageTest.php index 8c5ec80..708892f 100644 --- a/tests/Unit/Storage/SessionStorageTest.php +++ b/tests/Unit/Storage/PreferenceSessionStorageTest.php @@ -7,11 +7,11 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\SessionInterface; -use Tito10047\PersistentPreferenceBundle\Storage\SessionStorage; +use Tito10047\PersistentPreferenceBundle\Storage\PreferenceSessionStorage; -class SessionStorageTest extends TestCase +class PreferenceSessionStorageTest extends TestCase { - private function createStorageWithSession(?SessionInterface $session): SessionStorage + private function createStorageWithSession(?SessionInterface $session): PreferenceSessionStorage { $stack = $this->createMock(RequestStack::class); @@ -23,7 +23,7 @@ private function createStorageWithSession(?SessionInterface $session): SessionSt $stack->method('getCurrentRequest')->willReturn($request); } - return new SessionStorage($stack); + return new PreferenceSessionStorage($stack); } /** @return SessionInterface&MockObject */ diff --git a/tests/Unit/Storage/SelectionSessionStorageTest.php b/tests/Unit/Storage/SelectionSessionStorageTest.php new file mode 100644 index 0000000..dfa1c42 --- /dev/null +++ b/tests/Unit/Storage/SelectionSessionStorageTest.php @@ -0,0 +1,143 @@ +createMock(RequestStack::class); + $requestStack->method('getSession')->willReturn($this->mockSessionInterface()); + + $this->storage = new SelectionSessionStorage($requestStack); + } + + public function testAddMergesAndDeduplicates(): void + { + $ctx = 'ctx_add'; + + $this->storage->add($ctx, [1, 2, 3], null); + $this->storage->add($ctx, [2, 3, 4, '5'], null); + + $this->assertSame([1, 2, 3, 4, '5'], $this->storage->getStored($ctx)); + } + + public function testRemoveRemovesAndReindexes(): void + { + $ctx = 'ctx_remove'; + + $this->storage->add($ctx, [1, 2, 3, 4], null); + $this->storage->remove($ctx, [2, 4]); + + $this->assertSame([1, 3], $this->storage->getStored($ctx)); + } + + public function testClearResetsContext(): void + { + $ctx = 'ctx_clear'; + + $this->storage->add($ctx, [7], null); + $this->storage->setMode($ctx, SelectionMode::EXCLUDE); + + $this->storage->clear($ctx); + + $this->assertSame([], $this->storage->getStored($ctx)); + $this->assertSame(SelectionMode::INCLUDE, $this->storage->getMode($ctx)); + } + + public function testGetStoredIdentifiersReturnsCurrentIds(): void + { + $ctx = 'ctx_ids'; + $this->storage->add($ctx, [9, 10], null); + + $this->assertSame([9, 10], $this->storage->getStored($ctx)); + } + + public function testHasIdentifierUsesLooseComparison(): void + { + $ctx = 'ctx_has'; + $this->storage->add($ctx, [5], null); + + // uses in_array with loose comparison in the implementation + $this->assertTrue($this->storage->hasIdentifier($ctx, '5')); + $this->assertTrue($this->storage->hasIdentifier($ctx, 5)); + $this->assertFalse($this->storage->hasIdentifier($ctx, '6')); + + // metadata not set returns empty array + $this->assertSame([], $this->storage->getMetadata($ctx, 5)); + } + + public function testDefaultModeIsInclude(): void + { + $ctx = 'ctx_default_mode'; + $this->assertSame(SelectionMode::INCLUDE, $this->storage->getMode($ctx)); + } + + public function testSetAndGetModePersistsValue(): void + { + $ctx = 'ctx_mode'; + $this->storage->setMode($ctx, SelectionMode::EXCLUDE); + $this->assertSame(SelectionMode::EXCLUDE, $this->storage->getMode($ctx)); + } + + public function testAddWithMetadataAndGetStored(): void + { + $ctx = 'ctx_meta'; + + $meta = ['foo' => 'bar', 'n' => 1]; + // New API: third parameter is an associative map id => metadata + $this->storage->add($ctx, [1, 2], [ + 1 => $meta, + 2 => $meta, + ]); + + // Non-overwritten metadata persists per id + $this->assertSame($meta, $this->storage->getMetadata($ctx, 1)); + $this->assertSame($meta, $this->storage->getMetadata($ctx, 2)); + + // getStored returns id=>metadata map for stored ids + $this->assertSame([ + 1 => $meta, + 2 => $meta, + ], $this->storage->getStoredWithMetadata($ctx)); + + // Add another id without metadata, should not override others + $this->storage->add($ctx, [3], null); + $this->assertSame([], $this->storage->getMetadata($ctx, 3)); + + $this->assertSame([ + 1 => $meta, + 2 => $meta, + 3 => [], + ], $this->storage->getStoredWithMetadata($ctx)); + } + + public function testRemoveAlsoRemovesMetadata(): void + { + $ctx = 'ctx_remove_meta'; + $meta = ['x' => 10]; + // New API: provide map for both ids + $this->storage->add($ctx, [10, 11], [ + 10 => $meta, + 11 => $meta, + ]); + $this->storage->remove($ctx, [10]); + + $this->assertSame([11], $this->storage->getStored($ctx)); + $this->assertSame([], $this->storage->getMetadata($ctx, 10)); + $this->assertSame([11 => $meta], $this->storage->getStoredWithMetadata($ctx)); + } +} From 62095b4a20b959c1887f3901ba4622ec22268468 Mon Sep 17 00:00:00 2001 From: tito10047 Date: Fri, 5 Dec 2025 12:39:44 +0100 Subject: [PATCH 02/13] wip --- config/services.php | 11 +- src/Command/DebugPreferenceCommand.php | 4 +- src/DataCollector/PreferenceDataCollector.php | 2 +- .../Compiler/TraceableManagersPass.php | 2 +- .../PreconfiguredPreferenceInterface.php | 2 +- src/{ => Preference}/Service/Preference.php | 4 +- .../Service/PreferenceInterface.php | 2 +- .../Service/TraceablePersistentManager.php | 7 +- .../Service/TraceablePreference.php | 2 +- .../Storage/BasePreference.php | 2 +- .../Storage/PreferenceEntityInterface.php | 2 +- .../Storage/PreferenceSessionStorage.php | 2 +- .../Storage/PreferenceStorageInterface.php | 2 +- src/Selection/Loader/ArrayLoader.php | 45 ++++ .../Loader/DoctrineCollectionLoader.php | 102 +++++++++ .../Loader/DoctrineQueryBuilderLoader.php | 203 +++++++++++++++++ src/Selection/Loader/DoctrineQueryLoader.php | 206 ++++++++++++++++++ .../Loader/IdentityLoaderInterface.php | 19 ++ src/Selection/Normalizer/ArrayNormalizer.php | 24 ++ .../IdentifierNormalizerInterface.php | 27 +++ src/Selection/Normalizer/ObjectNormalizer.php | 38 ++++ src/Selection/Normalizer/ScalarNormalizer.php | 20 ++ .../PreconfiguredSelectionInterface.php | 2 +- .../Service/SelectionInterface.php | 2 +- .../Storage/SelectionSessionStorage.php | 2 +- .../Storage/SelectionStorageInterface.php | 2 +- src/Service/PersistentManager.php | 6 +- src/Service/PersistentManagerInterface.php | 7 +- src/Storage/DoctrinePreferenceStorage.php | 4 +- .../AssetMapper/Src/Entity/UserPreference.php | 3 +- ...PreferenceDataCollectorIntegrationTest.php | 4 +- .../Service/PreferenceManagerTest.php | 17 +- .../Storage/DoctrineStorageTest.php | 2 +- .../Command/DebugPreferenceCommandTest.php | 6 +- .../PreferenceDataCollectorTest.php | 3 +- .../Service/PreferenceTest.php | 6 +- .../Storage/PreferenceSessionStorageTest.php | 4 +- .../Storage/SelectionSessionStorageTest.php | 4 +- 38 files changed, 744 insertions(+), 58 deletions(-) rename src/{ => Preference}/Service/PreconfiguredPreferenceInterface.php (65%) rename src/{ => Preference}/Service/Preference.php (96%) rename src/{ => Preference}/Service/PreferenceInterface.php (97%) rename src/{ => Preference}/Service/TraceablePersistentManager.php (76%) rename src/{ => Preference}/Service/TraceablePreference.php (96%) rename src/{ => Preference}/Storage/BasePreference.php (94%) rename src/{ => Preference}/Storage/PreferenceEntityInterface.php (93%) rename src/{ => Preference}/Storage/PreferenceSessionStorage.php (97%) rename src/{ => Preference}/Storage/PreferenceStorageInterface.php (96%) create mode 100644 src/Selection/Loader/ArrayLoader.php create mode 100644 src/Selection/Loader/DoctrineCollectionLoader.php create mode 100644 src/Selection/Loader/DoctrineQueryBuilderLoader.php create mode 100644 src/Selection/Loader/DoctrineQueryLoader.php create mode 100644 src/Selection/Loader/IdentityLoaderInterface.php create mode 100644 src/Selection/Normalizer/ArrayNormalizer.php create mode 100644 src/Selection/Normalizer/IdentifierNormalizerInterface.php create mode 100644 src/Selection/Normalizer/ObjectNormalizer.php create mode 100644 src/Selection/Normalizer/ScalarNormalizer.php rename src/{ => Selection}/Service/PreconfiguredSelectionInterface.php (68%) rename src/{ => Selection}/Service/SelectionInterface.php (97%) rename src/{ => Selection}/Storage/SelectionSessionStorage.php (98%) rename src/{ => Selection}/Storage/SelectionStorageInterface.php (97%) rename tests/Integration/{ => Preference}/Service/PreferenceManagerTest.php (93%) rename tests/Integration/{ => Preference}/Storage/DoctrineStorageTest.php (96%) rename tests/Unit/{ => Preference}/Service/PreferenceTest.php (95%) rename tests/Unit/{ => Preference}/Storage/PreferenceSessionStorageTest.php (96%) rename tests/Unit/{ => Selection}/Storage/SelectionSessionStorageTest.php (96%) diff --git a/config/services.php b/config/services.php index e2f1220..b3bc35e 100644 --- a/config/services.php +++ b/config/services.php @@ -3,21 +3,20 @@ use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpFoundation\RequestStack; -use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; +use Tito10047\PersistentPreferenceBundle\Command\DebugPreferenceCommand; use Tito10047\PersistentPreferenceBundle\Converter\MetadataConverterInterface; use Tito10047\PersistentPreferenceBundle\Converter\ObjectVarsConverter; +use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagContextKeyResolverPass; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagValueTransformerPass; +use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceSessionStorage; +use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; +use Tito10047\PersistentPreferenceBundle\Resolver\PersistentContextResolver; use Tito10047\PersistentPreferenceBundle\Service\PersistentManager; use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; -use Tito10047\PersistentPreferenceBundle\Service\TraceablePersistentManager; -use Tito10047\PersistentPreferenceBundle\Storage\PreferenceSessionStorage; -use Tito10047\PersistentPreferenceBundle\Storage\PreferenceStorageInterface; -use Tito10047\PersistentPreferenceBundle\Resolver\PersistentContextResolver; use Tito10047\PersistentPreferenceBundle\Transformer\ScalarValueTransformer; use Tito10047\PersistentPreferenceBundle\Twig\PreferenceExtension; use Tito10047\PersistentPreferenceBundle\Twig\PreferenceRuntime; -use Tito10047\PersistentPreferenceBundle\Command\DebugPreferenceCommand; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; diff --git a/src/Command/DebugPreferenceCommand.php b/src/Command/DebugPreferenceCommand.php index a65d166..a115d0e 100644 --- a/src/Command/DebugPreferenceCommand.php +++ b/src/Command/DebugPreferenceCommand.php @@ -10,10 +10,10 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceSessionStorage; +use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Tito10047\PersistentPreferenceBundle\Storage\DoctrinePreferenceStorage; -use Tito10047\PersistentPreferenceBundle\Storage\PreferenceSessionStorage; -use Tito10047\PersistentPreferenceBundle\Storage\PreferenceStorageInterface; #[AsCommand(name: 'debug:preference', description: 'Print preferences for a given context and manager')] final class DebugPreferenceCommand extends Command diff --git a/src/DataCollector/PreferenceDataCollector.php b/src/DataCollector/PreferenceDataCollector.php index de51d46..7139a42 100644 --- a/src/DataCollector/PreferenceDataCollector.php +++ b/src/DataCollector/PreferenceDataCollector.php @@ -7,7 +7,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; -use Tito10047\PersistentPreferenceBundle\Storage\PreferenceStorageInterface; +use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; final class PreferenceDataCollector extends DataCollector { diff --git a/src/DependencyInjection/Compiler/TraceableManagersPass.php b/src/DependencyInjection/Compiler/TraceableManagersPass.php index 40931ae..5f050be 100644 --- a/src/DependencyInjection/Compiler/TraceableManagersPass.php +++ b/src/DependencyInjection/Compiler/TraceableManagersPass.php @@ -8,7 +8,7 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; -use Tito10047\PersistentPreferenceBundle\Service\TraceablePersistentManager; +use Tito10047\PersistentPreferenceBundle\Preference\Service\TraceablePersistentManager; /** * Decorates all preference managers with TraceablePreferenceManager in debug mode diff --git a/src/Service/PreconfiguredPreferenceInterface.php b/src/Preference/Service/PreconfiguredPreferenceInterface.php similarity index 65% rename from src/Service/PreconfiguredPreferenceInterface.php rename to src/Preference/Service/PreconfiguredPreferenceInterface.php index 72acf09..1c71cba 100644 --- a/src/Service/PreconfiguredPreferenceInterface.php +++ b/src/Preference/Service/PreconfiguredPreferenceInterface.php @@ -1,6 +1,6 @@ managerName, $preference, $this->collector); } - public function getPreferenceStorage(): \Tito10047\PersistentPreferenceBundle\Storage\PreferenceStorageInterface + public function getPreferenceStorage(): \Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface { return $this->inner->getPreferenceStorage(); } diff --git a/src/Service/TraceablePreference.php b/src/Preference/Service/TraceablePreference.php similarity index 96% rename from src/Service/TraceablePreference.php rename to src/Preference/Service/TraceablePreference.php index 9655d4f..9ca67cc 100644 --- a/src/Service/TraceablePreference.php +++ b/src/Preference/Service/TraceablePreference.php @@ -1,6 +1,6 @@ normalize($item, $identifierPath); + } + + return $identifiers; + } + + + public function getTotalCount(mixed $source): int { + return count($source); + } + + public function getCacheKey(mixed $source): string { + if (!is_array($source)) { + throw new InvalidArgumentException('Source must be an array.'); + } + // Use a deterministic hash of the full source structure. serialize() preserves + // structure and values for arrays/objects commonly used in tests. + return 'array:' . md5(serialize($source)); + } +} \ No newline at end of file diff --git a/src/Selection/Loader/DoctrineCollectionLoader.php b/src/Selection/Loader/DoctrineCollectionLoader.php new file mode 100644 index 0000000..9bf7892 --- /dev/null +++ b/src/Selection/Loader/DoctrineCollectionLoader.php @@ -0,0 +1,102 @@ +supports($source)) { + throw new InvalidArgumentException('Source must be a Doctrine Collection.'); + } + + /** @var Collection $source */ + return $source->count(); + } + + /** + * @param string $identifierPath * + * + * @inheritDoc + */ + public function loadAllIdentifiers(?IdentifierNormalizerInterface $resolver, mixed $source, ?string $identifierPath): array + { + if (!$this->supports($source)) { + throw new InvalidArgumentException('Source must be a Doctrine Collection.'); + } + + /** @var Collection $source */ + $identifiers = []; + + foreach ($source as $item) { + $identifiers[] = $resolver->normalize($item, $identifierPath); + } + + return $identifiers; + } + + public function getCacheKey(mixed $source): string { + if (!$this->supports($source)) { + throw new InvalidArgumentException('Source must be a Doctrine Collection.'); + } + + /** @var Collection $source */ + $items = $source->toArray(); + + $normalized = array_map(function ($item) { + return self::normalizeValue($item); + }, $items); + + return 'doctrine_collection:' . md5(serialize($normalized)); + } + + /** + * Produce a deterministic scalar/array representation for hashing. + */ + private static function normalizeValue(mixed $value): mixed + { + if (is_scalar($value) || $value === null) { + return $value; + } + if ($value instanceof \DateTimeInterface) { + return ['__dt__' => true, 'v' => $value->format(DATE_ATOM)]; + } + if (is_array($value)) { + $normalized = []; + foreach ($value as $k => $v) { + $normalized[$k] = self::normalizeValue($v); + } + if (!array_is_list($normalized)) { + ksort($normalized); + } + return $normalized; + } + if (is_object($value)) { + $vars = get_object_vars($value); + ksort($vars); + return ['__class__' => get_class($value), 'props' => self::normalizeValue($vars)]; + } + // Fallback – stringify + return (string)$value; + } +} \ No newline at end of file diff --git a/src/Selection/Loader/DoctrineQueryBuilderLoader.php b/src/Selection/Loader/DoctrineQueryBuilderLoader.php new file mode 100644 index 0000000..4aa18cf --- /dev/null +++ b/src/Selection/Loader/DoctrineQueryBuilderLoader.php @@ -0,0 +1,203 @@ + + */ + public function loadAllIdentifiers(?IdentifierNormalizerInterface $resolver, mixed $source, ?string $identifierPath): array + { + if (!$this->supports($source)) { + throw new InvalidArgumentException('Source must be a Doctrine QueryBuilder instance.'); + } + + $baseQb = clone $source; + + $em = $baseQb->getEntityManager(); + + $rootAliases = $baseQb->getRootAliases(); + $rootEntities = $baseQb->getRootEntities(); + if (empty($rootAliases) || empty($rootEntities)) { + // fallback – vytiahni z DQL + $query = $baseQb->getQuery(); + [$rootEntity, $rootAlias] = $this->resolveRootFromDql($query); + } else { + $rootAlias = $rootAliases[0]; + $rootEntity = $rootEntities[0]; + } + + $metadata = $em->getClassMetadata($rootEntity); + $identifierFields = $metadata->getIdentifierFieldNames(); + if (count($identifierFields) !== 1) { + throw new RuntimeException('Composite alebo neštandardný identifikátor nie je podporovaný pre loadAllIdentifiers().'); + } + + $defaultIdField = $identifierFields[0]; + $identifierField = ($identifierPath !== null && $identifierPath !== '') ? $identifierPath : $defaultIdField; + + // prepis SELECT, ostatné časti dotazu (WHERE, JOIN, GROUP BY, HAVING, ORDER BY) ponechaj + $baseQb->resetDQLPart('select'); + $baseQb->select($rootAlias . '.' . $identifierField); + + // ignoruj stránkovanie – chceme všetky identifikátory z danej filtrácie + $baseQb->setFirstResult(null); + $baseQb->setMaxResults(null); + + $rows = $baseQb->getQuery()->getResult(); + return array_map(function($item)use($identifierPath){ + return $this->arrayNormalizer->normalize($item, $identifierPath); + }, $rows); + } + + /** + * @param QueryBuilder $source + */ + public function getTotalCount(mixed $source): int + { + if (!$this->supports($source)) { + throw new InvalidArgumentException('Source must be a Doctrine QueryBuilder instance.'); + } + + $baseQb = clone $source; + + $em = $baseQb->getEntityManager(); + + $rootAliases = $baseQb->getRootAliases(); + $rootEntities = $baseQb->getRootEntities(); + if (empty($rootAliases) || empty($rootEntities)) { + // fallback – vytiahni z DQL + $query = $baseQb->getQuery(); + [$rootEntity, $rootAlias] = $this->resolveRootFromDql($query); + } else { + $rootAlias = $rootAliases[0]; + $rootEntity = $rootEntities[0]; + } + + $metadata = $em->getClassMetadata($rootEntity); + $identifierFields = $metadata->getIdentifierFieldNames(); + + // COUNT výraz + if (count($identifierFields) === 1) { + $countExpr = 'COUNT(DISTINCT ' . $rootAlias . '.' . $identifierFields[0] . ')'; + } else { + $countExpr = 'COUNT(' . $rootAlias . ')'; // fallback + } + + // uprav SELECT na COUNT, odstráň orderBy, ignoruj stránkovanie + $baseQb->resetDQLPart('select'); + $baseQb->resetDQLPart('orderBy'); + $baseQb->select($countExpr); + $baseQb->setFirstResult(null); + $baseQb->setMaxResults(null); + + try { + return (int) $baseQb->getQuery()->getSingleScalarResult(); + } catch (Exception $e) { + throw new RuntimeException('Failed to execute count query.', 0, $e); + } + } + + /** + * Vytiahne root entitu a alias z Query DQL pomocou Doctrine Parsera. + * + * @return array{0:string,1:string} [entityClass, alias] + */ + private function resolveRootFromDql(Query $query): array + { + $AST = $query->getAST(); + + /** @var Query\AST\IdentificationVariableDeclaration $from */ + $from = $AST->fromClause->identificationVariableDeclarations[0] ?? null; + if ($from === null || $from->rangeVariableDeclaration === null) { + throw new RuntimeException('Nepodarilo sa zistiť root entitu z DQL dotazu.'); + } + + $rootEntity = $from->rangeVariableDeclaration->abstractSchemaName; + $rootAlias = $from->rangeVariableDeclaration->aliasIdentificationVariable; + + if (!is_string($rootEntity) || !is_string($rootAlias)) { + throw new RuntimeException('Neplatný FROM klauzula v DQL dotaze.'); + } + + return [$rootEntity, $rootAlias]; + } + + public function getCacheKey(mixed $source): string { + if (!$this->supports($source)) { + throw new InvalidArgumentException('Source must be a Doctrine QueryBuilder instance.'); + } + + /** @var QueryBuilder $source */ + // Use the generated DQL from the QB for a stable representation of structure + $dql = $source->getQuery()->getDQL(); + $params = $source->getParameters(); + $normParams = []; + foreach ($params as $p) { + $name = method_exists($p, 'getName') ? $p->getName() : null; + $value = method_exists($p, 'getValue') ? $p->getValue() : null; + $normParams[] = [ + 'name' => $name, + 'value' => self::normalizeValue($value), + ]; + } + usort($normParams, function($a, $b){ + return strcmp((string)$a['name'], (string)$b['name']); + }); + + return 'doctrine_qb:' . md5(serialize([$dql, $normParams])); + } + + /** + * Normalize values for a deterministic cache key. + */ + private static function normalizeValue(mixed $value): mixed + { + if (is_scalar($value) || $value === null) { + return $value; + } + if ($value instanceof \DateTimeInterface) { + return ['__dt__' => true, 'v' => $value->format(DATE_ATOM)]; + } + if (is_array($value)) { + $normalized = []; + foreach ($value as $k => $v) { + $normalized[$k] = self::normalizeValue($v); + } + if (!array_is_list($normalized)) { + ksort($normalized); + } + return $normalized; + } + if (is_object($value)) { + $vars = get_object_vars($value); + ksort($vars); + return ['__class__' => get_class($value), 'props' => self::normalizeValue($vars)]; + } + return (string)$value; + } +} diff --git a/src/Selection/Loader/DoctrineQueryLoader.php b/src/Selection/Loader/DoctrineQueryLoader.php new file mode 100644 index 0000000..401d35b --- /dev/null +++ b/src/Selection/Loader/DoctrineQueryLoader.php @@ -0,0 +1,206 @@ + + */ + public function loadAllIdentifiers(?IdentifierNormalizerInterface $resolver, mixed $source, ?string $identifierPath): array { + if (!$this->supports($source)) { + throw new InvalidArgumentException('Source must be a Doctrine Query instance.'); + } + + /** @var Query $baseQuery */ + $baseQuery = clone $source; + $entityManager = $baseQuery->getEntityManager(); + // Parametre si vezmeme z pôvodného zdroja (klon môže prísť o väzbu na parametre) + $sourceParameters = $source->getParameters(); + + [$rootEntity, $rootAlias] = $this->resolveRootFromDql($baseQuery); + + $metadata = $entityManager->getClassMetadata($rootEntity); + $identifierFields = $metadata->getIdentifierFieldNames(); + if (count($identifierFields) !== 1) { + throw new RuntimeException('Composite alebo neštandardný identifikátor nie je podporovaný pre loadAllIdentifiers().'); + } + + $defaultIdField = $identifierFields[0]; + $identifierField = ($identifierPath !== null && $identifierPath !== '') ? $identifierPath : $defaultIdField; + + $dql = $baseQuery->getDQL(); + $posFrom = stripos($dql, ' from '); + if ($posFrom === false) { + throw new RuntimeException('Neplatný DQL – chýba FROM klauzula.'); + } + $newDql = 'SELECT ' . $rootAlias . '.' . $identifierField . substr($dql, $posFrom); + + $idQuery = $entityManager->createQuery($newDql); + // prenes parametre z pôvodného dotazu (aby WHERE ostal funkčný) + $idQuery->setParameters($sourceParameters); + + $rows = $idQuery->getScalarResult(); + return array_map('current', $rows); + } + + /** + * @inheritDoc + * + * @param Query $source The Doctrine Query instance. + */ + public function getTotalCount(mixed $source): int { + if (!$this->supports($source)) { + throw new InvalidArgumentException('Source must be a Doctrine Query instance.'); + } + + // očakáva sa iba Query + + /** @var Query $baseQuery */ + $baseQuery = clone $source; + $entityManager = $baseQuery->getEntityManager(); + + [$rootEntity, $rootAlias] = $this->resolveRootFromDql($baseQuery); + + // ak je k dispozícii jednoduché ID pole, rátaj COUNT(DISTINCT alias.id), inak COUNT(alias) + $metadata = $entityManager->getClassMetadata($rootEntity); + $identifierFields = $metadata->getIdentifierFieldNames(); + $countExpr = null; + if (count($identifierFields) === 1) { + $countExpr = 'COUNT(DISTINCT ' . $rootAlias . '.' . $identifierFields[0] . ')'; + } else { + $countExpr = 'COUNT(' . $rootAlias . ')'; // fallback + } + + // poskladaj COUNT dopyt z pôvodného DQL: vymeniť SELECT časť a odstrániť ORDER BY + $dql = $baseQuery->getDQL(); + $posFrom = stripos($dql, ' from '); + if ($posFrom === false) { + throw new RuntimeException('Neplatný DQL – chýba FROM klauzula.'); + } + + // odstráň ORDER BY (ak je) + $dqlTail = substr($dql, $posFrom); + $posOrderBy = stripos($dqlTail, ' order by '); + if ($posOrderBy !== false) { + $dqlTail = substr($dqlTail, 0, $posOrderBy); + } + + $newDql = 'SELECT ' . $countExpr . $dqlTail; + + $countQuery = $entityManager->createQuery($newDql); + // použijeme parametre z pôvodného Query (nie z klonu) + $countQuery->setParameters($source->getParameters()); + + try { + return (int) $countQuery->getSingleScalarResult(); + } catch (Exception $e) { + throw new RuntimeException('Failed to execute optimized count query.', 0, $e); + } + } + + /** + * Vytiahne root entitu a alias z Query DQL pomocou Doctrine Parsera. + * + * @return array{0:string,1:string} [entityClass, alias] + */ + private function resolveRootFromDql(Query $query): array { + $AST = $query->getAST(); + + /** @var Query\AST\IdentificationVariableDeclaration $from */ + $from = $AST->fromClause->identificationVariableDeclarations[0] ?? null; + if ($from === null || $from->rangeVariableDeclaration === null) { + throw new RuntimeException('Nepodarilo sa zistiť root entitu z DQL dotazu.'); + } + + $rootEntity = $from->rangeVariableDeclaration->abstractSchemaName; + $rootAlias = $from->rangeVariableDeclaration->aliasIdentificationVariable; + + if (!is_string($rootEntity) || !is_string($rootAlias)) { + throw new RuntimeException('Neplatný FROM klauzula v DQL dotaze.'); + } + + return [$rootEntity, $rootAlias]; + } + + + public function getCacheKey(mixed $source): string { + if (!$this->supports($source)) { + throw new InvalidArgumentException('Source must be a Doctrine Query instance.'); + } + + /** @var Query $source */ + $dql = $source->getDQL(); + $params = $source->getParameters(); // Doctrine\Common\Collections\Collection of Parameter + $normParams = []; + foreach ($params as $p) { + $name = method_exists($p, 'getName') ? $p->getName() : null; + $value = method_exists($p, 'getValue') ? $p->getValue() : null; + $normParams[] = [ + 'name' => $name, + 'value' => self::normalizeValue($value), + ]; + } + // ensure deterministic order + usort($normParams, function($a, $b){ + return strcmp((string)$a['name'], (string)$b['name']); + }); + + return 'doctrine_query:' . md5(serialize([$dql, $normParams])); + } + + /** + * Normalize values for a deterministic cache key. + */ + private static function normalizeValue(mixed $value): mixed + { + if (is_scalar($value) || $value === null) { + return $value; + } + if ($value instanceof \DateTimeInterface) { + return ['__dt__' => true, 'v' => $value->format(DATE_ATOM)]; + } + if (is_array($value)) { + $normalized = []; + foreach ($value as $k => $v) { + $normalized[$k] = self::normalizeValue($v); + } + if (!array_is_list($normalized)) { + ksort($normalized); + } + return $normalized; + } + if (is_object($value)) { + // try to reduce to public props for stability + $vars = get_object_vars($value); + ksort($vars); + return ['__class__' => get_class($value), 'props' => self::normalizeValue($vars)]; + } + return (string)$value; + } +} \ No newline at end of file diff --git a/src/Selection/Loader/IdentityLoaderInterface.php b/src/Selection/Loader/IdentityLoaderInterface.php new file mode 100644 index 0000000..c805ef1 --- /dev/null +++ b/src/Selection/Loader/IdentityLoaderInterface.php @@ -0,0 +1,19 @@ +isReadable($item, $identifierPath)) { + throw new RuntimeException(sprintf( + 'Cannot read identifier "%s" from object of type "%s".', + $identifierPath, get_debug_type($item) + )); + } + + $value = $accessor->getValue($item, $identifierPath); + + if (is_object($value) && method_exists($value, '__toString')) { + return (string) $value; + } + + if (is_scalar($value)) { + return $value; + } + + throw new RuntimeException('Extracted value is not a scalar.'); + } +} \ No newline at end of file diff --git a/src/Selection/Normalizer/ScalarNormalizer.php b/src/Selection/Normalizer/ScalarNormalizer.php new file mode 100644 index 0000000..994c826 --- /dev/null +++ b/src/Selection/Normalizer/ScalarNormalizer.php @@ -0,0 +1,20 @@ +get(\Tito10047\PersistentPreferenceBundle\Storage\PreferenceStorageInterface::class); + $storage = $container->get(\Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface::class); $collector = new PreferenceDataCollector($storage); self::assertInstanceOf(DataCollectorInterface::class, $collector); diff --git a/tests/Integration/Service/PreferenceManagerTest.php b/tests/Integration/Preference/Service/PreferenceManagerTest.php similarity index 93% rename from tests/Integration/Service/PreferenceManagerTest.php rename to tests/Integration/Preference/Service/PreferenceManagerTest.php index e16b690..b1b2aa4 100644 --- a/tests/Integration/Service/PreferenceManagerTest.php +++ b/tests/Integration/Preference/Service/PreferenceManagerTest.php @@ -2,20 +2,17 @@ namespace Tito10047\PersistentPreferenceBundle\Tests\Integration\Service; -use PHPUnit\Framework\Attributes\TestWith; use stdClass; -use Tito10047\PersistentPreferenceBundle\Service\PreferenceInterface; -use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; -use Tito10047\PersistentPreferenceBundle\Service\PersistentContextInterface; -use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\ServiceHelper; -use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; -use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Support\TestList; -use Tito10047\PersistentPreferenceBundle\Enum\PreferenceMode; -use function Zenstruck\Foundry\object; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; +use Tito10047\PersistentPreferenceBundle\Enum\PreferenceMode; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceInterface; +use Tito10047\PersistentPreferenceBundle\Service\PersistentContextInterface; +use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; +use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Support\TestList; +use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; class PreferenceManagerTest extends AssetMapperKernelTestCase { diff --git a/tests/Integration/Storage/DoctrineStorageTest.php b/tests/Integration/Preference/Storage/DoctrineStorageTest.php similarity index 96% rename from tests/Integration/Storage/DoctrineStorageTest.php rename to tests/Integration/Preference/Storage/DoctrineStorageTest.php index 4a4a13f..9bd08b9 100644 --- a/tests/Integration/Storage/DoctrineStorageTest.php +++ b/tests/Integration/Preference/Storage/DoctrineStorageTest.php @@ -1,6 +1,6 @@ Date: Fri, 5 Dec 2025 12:47:17 +0100 Subject: [PATCH 03/13] wip --- .../AssetMapper/Src/Entity/RecordInteger.php | 49 ++++++ .../App/AssetMapper/Src/Entity/RecordUuid.php | 37 +++++ .../AssetMapper/Src/Entity/TestCategory.php | 33 ++++ .../Unit/Selection/Loader/ArrayLoaderTest.php | 30 ++++ .../Loader/DoctrineCollectionLoaderTest.php | 33 ++++ .../Loader/DoctrineQueryBuilderLoaderTest.php | 148 +++++++++++++++++ .../Loader/DoctrineQueryLoaderTest.php | 151 ++++++++++++++++++ .../Normalizer/ObjectNormalizerTest.php | 79 +++++++++ .../Normalizer/ScalarNormalizerTest.php | 48 ++++++ 9 files changed, 608 insertions(+) create mode 100644 tests/App/AssetMapper/Src/Entity/RecordInteger.php create mode 100644 tests/App/AssetMapper/Src/Entity/RecordUuid.php create mode 100644 tests/App/AssetMapper/Src/Entity/TestCategory.php create mode 100644 tests/Unit/Selection/Loader/ArrayLoaderTest.php create mode 100644 tests/Unit/Selection/Loader/DoctrineCollectionLoaderTest.php create mode 100644 tests/Unit/Selection/Loader/DoctrineQueryBuilderLoaderTest.php create mode 100644 tests/Unit/Selection/Loader/DoctrineQueryLoaderTest.php create mode 100644 tests/Unit/Selection/Normalizer/ObjectNormalizerTest.php create mode 100644 tests/Unit/Selection/Normalizer/ScalarNormalizerTest.php diff --git a/tests/App/AssetMapper/Src/Entity/RecordInteger.php b/tests/App/AssetMapper/Src/Entity/RecordInteger.php new file mode 100644 index 0000000..cbcdf21 --- /dev/null +++ b/tests/App/AssetMapper/Src/Entity/RecordInteger.php @@ -0,0 +1,49 @@ +id; + } + + public function setId(?int $id): self { + $this->id = $id; + return $this; + } + + public function getName(): ?string { + return $this->name; + } + + public function setName(?string $name): self { + $this->name = $name; + return $this; + } + + public function getCategory(): ?TestCategory + { + return $this->category; + } + + public function setCategory(?TestCategory $category): self + { + $this->category = $category; + return $this; + } + +} \ No newline at end of file diff --git a/tests/App/AssetMapper/Src/Entity/RecordUuid.php b/tests/App/AssetMapper/Src/Entity/RecordUuid.php new file mode 100644 index 0000000..2931052 --- /dev/null +++ b/tests/App/AssetMapper/Src/Entity/RecordUuid.php @@ -0,0 +1,37 @@ +id; + } + + public function setId(?Uuid $id): self { + $this->id = $id; + return $this; + } + + public function getName(): ?string { + return $this->name; + } + + public function setName(?string $name): self { + $this->name = $name; + return $this; + } + +} \ No newline at end of file diff --git a/tests/App/AssetMapper/Src/Entity/TestCategory.php b/tests/App/AssetMapper/Src/Entity/TestCategory.php new file mode 100644 index 0000000..0c6210c --- /dev/null +++ b/tests/App/AssetMapper/Src/Entity/TestCategory.php @@ -0,0 +1,33 @@ +id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + return $this; + } +} diff --git a/tests/Unit/Selection/Loader/ArrayLoaderTest.php b/tests/Unit/Selection/Loader/ArrayLoaderTest.php new file mode 100644 index 0000000..730e7e5 --- /dev/null +++ b/tests/Unit/Selection/Loader/ArrayLoaderTest.php @@ -0,0 +1,30 @@ +assertTrue($loader->supports($records)); + $this->assertSame(10, $loader->getTotalCount($records)); + + $ids = array_map(fn(RecordInteger $record) => $record->getId(), $records); + $foundIds = $loader->loadAllIdentifiers($resolver, $records, "id"); + + $this->assertEquals($ids, $foundIds); + } +} diff --git a/tests/Unit/Selection/Loader/DoctrineCollectionLoaderTest.php b/tests/Unit/Selection/Loader/DoctrineCollectionLoaderTest.php new file mode 100644 index 0000000..f9bc077 --- /dev/null +++ b/tests/Unit/Selection/Loader/DoctrineCollectionLoaderTest.php @@ -0,0 +1,33 @@ + 1, 'name' => 'A']; + $o2 = (object)['id' => 2, 'name' => 'B']; + + $c1 = new ArrayCollection([$o1, $o2]); + $c2 = new ArrayCollection([(object)['id' => 1, 'name' => 'A'], (object)['id' => 2, 'name' => 'B']]); + + // Rovnaký obsah -> rovnaký cache key + $k1a = $loader->getCacheKey($c1); + $k1b = $loader->getCacheKey($c1); + $k2 = $loader->getCacheKey($c2); + $this->assertSame($k1a, $k1b); + $this->assertSame($k1a, $k2); + + // Po zmene obsahu -> iný cache key + $c2->add((object)['id' => 3, 'name' => 'C']); + $k3 = $loader->getCacheKey($c2); + $this->assertNotSame($k2, $k3); + } +} diff --git a/tests/Unit/Selection/Loader/DoctrineQueryBuilderLoaderTest.php b/tests/Unit/Selection/Loader/DoctrineQueryBuilderLoaderTest.php new file mode 100644 index 0000000..3146178 --- /dev/null +++ b/tests/Unit/Selection/Loader/DoctrineQueryBuilderLoaderTest.php @@ -0,0 +1,148 @@ +get('doctrine')->getManager(); + + $qb = $em->createQueryBuilder() + ->select('i') + ->from(RecordInteger::class, 'i') + ->orderBy('i.id', 'ASC') + ->setMaxResults(5); + + $this->assertTrue($loader->supports($qb)); + $this->assertEquals(10, $loader->getTotalCount($qb)); + + $ids = array_map(fn(RecordInteger $record) => $record->getId(), $records); + sort($ids); + $foundIds = $loader->loadAllIdentifiers(null, $qb, 'id'); + sort($foundIds); + + $this->assertEquals($ids, $foundIds); + } + + public function testGetCacheKeyStableAndDistinct(): void + { + RecordIntegerFactory::createMany(3); + + $loader = new DoctrineQueryBuilderLoader(new ArrayNormalizer()); + + /** @var EntityManagerInterface $em */ + $em = self::getContainer()->get('doctrine')->getManager(); + + $qb1 = $em->createQueryBuilder() + ->select('i') + ->from(RecordInteger::class, 'i') + ->where('i.name = :name') + ->setParameter('name', 'keep'); + + $qb2 = $em->createQueryBuilder() + ->select('i') + ->from(RecordInteger::class, 'i') + ->where('i.name = :name') + ->setParameter('name', 'keep'); + + // Rovnaká filtrácia → rovnaký cache key + $k1a = $loader->getCacheKey($qb1); + $k1b = $loader->getCacheKey($qb1); + $k2 = $loader->getCacheKey($qb2); + $this->assertSame($k1a, $k1b); + $this->assertSame($k1a, $k2); + + // Zmena parametra → iný cache key + $qb2->setParameter('name', 'drop'); + $k3 = $loader->getCacheKey($qb2); + $this->assertNotSame($k2, $k3); + } + + public function testWithWhere(): void + { + $records = RecordIntegerFactory::createMany(10); + + /** @var EntityManagerInterface $em */ + $em = self::getContainer()->get('doctrine')->getManager(); + + $expectedIds = array_values(array_map( + fn(RecordInteger $r) => $r->getId(), + array_filter($records, fn(RecordInteger $r) => $r->getName()=='keep', ARRAY_FILTER_USE_BOTH) + )); + + $loader = new DoctrineQueryBuilderLoader(new ArrayNormalizer()); + + $qb = $em->createQueryBuilder() + ->select('i') + ->from(RecordInteger::class, 'i') + ->where('i.name = :name') + ->setParameter('name', 'keep') + ->orderBy('i.id', 'DESC') + ->setFirstResult(2) + ->setMaxResults(3); + + $this->assertTrue($loader->supports($qb)); + $this->assertEquals(count($expectedIds), $loader->getTotalCount($qb)); + + + sort($expectedIds); + + $foundIds = $loader->loadAllIdentifiers(null, $qb, 'id'); + sort($foundIds); + + $this->assertEquals($expectedIds, $foundIds); + } + + public function testWithJoin(): void + { + /** @var EntityManagerInterface $em */ + $em = self::getContainer()->get('doctrine')->getManager(); + + TestCategoryFactory::createOne([ + "name"=>"A" + ]); + TestCategoryFactory::createOne([ + "name"=>"A" + ]); + + $records = RecordIntegerFactory::createMany(10); + $loader = new DoctrineQueryBuilderLoader(new ArrayNormalizer()); + + $expectedIds = array_values(array_map( + fn(RecordInteger $r) => $r->getId(), + array_filter($records, fn(RecordInteger $r) => $r->getCategory()->getName()=='A', ARRAY_FILTER_USE_BOTH) + )); + + $qb = $em->createQueryBuilder() + ->select('i') + ->from(RecordInteger::class, 'i') + ->join('i.category', 'c') + ->where('c.name = :name') + ->setParameter('name', 'A') + ->orderBy('i.id', 'DESC') + ->setFirstResult(1) + ->setMaxResults(2); + + $this->assertTrue($loader->supports($qb)); + $this->assertEquals(count($expectedIds), $loader->getTotalCount($qb)); + + $foundIds = $loader->loadAllIdentifiers(null, $qb, 'id'); + sort($foundIds); + + $this->assertEquals($expectedIds, $foundIds); + } +} diff --git a/tests/Unit/Selection/Loader/DoctrineQueryLoaderTest.php b/tests/Unit/Selection/Loader/DoctrineQueryLoaderTest.php new file mode 100644 index 0000000..e67bbb7 --- /dev/null +++ b/tests/Unit/Selection/Loader/DoctrineQueryLoaderTest.php @@ -0,0 +1,151 @@ +get('doctrine')->getManager(); + $query = $em->createQueryBuilder() + ->select('i') + ->from(RecordInteger::class, 'i') + ->orderBy('i.id', 'ASC') + ->setMaxResults(5) + ->getQuery(); + + $this->assertTrue($loader->supports($query)); + $this->assertEquals(10, $loader->getTotalCount($query)); + + $ids = array_map(fn(RecordInteger $record) => $record->getId(), $records); + sort($ids); + $foundIds = $loader->loadAllIdentifiers(null, $query, "id"); + sort($foundIds); + + $this->assertEquals($ids, $foundIds); + + + } + + public function testWithWhere(): void + { + $records = RecordIntegerFactory::createMany(10); + + /** @var EntityManagerInterface $em */ + $em = self::getContainer()->get('doctrine')->getManager(); + + // očakávané ID podľa vygenerovaného mena z factory + $expectedIds = array_values(array_map( + fn(RecordInteger $r) => $r->getId(), + array_filter($records, fn(RecordInteger $r) => $r->getName() === 'keep', ARRAY_FILTER_USE_BOTH) + )); + + $loader = new DoctrineQueryLoader(); + + $qb = $em->createQueryBuilder() + ->select('i') + ->from(RecordInteger::class, 'i') + ->where('i.name = :name') + ->setParameter('name', 'keep') + ->orderBy('i.id', 'DESC') + ->setFirstResult(2) + ->setMaxResults(3); + + $query = $qb->getQuery(); + + $this->assertTrue($loader->supports($query)); + $this->assertEquals(count($expectedIds), $loader->getTotalCount($query)); + sort($expectedIds); + + $foundIds = $loader->loadAllIdentifiers(null, $query, 'id'); + sort($foundIds); + + $this->assertEquals($expectedIds, $foundIds); + } + + public function testWithJoin(): void + { /** @var EntityManagerInterface $em */ + $em = self::getContainer()->get('doctrine')->getManager(); + + // vytvor pár kategórií s názvom "A" (nemusia byť priradené žiadnemu záznamu) + TestCategoryFactory::createOne(['name' => 'A']); + TestCategoryFactory::createOne(['name' => 'A']); + + $records = RecordIntegerFactory::createMany(10); + $loader = new DoctrineQueryLoader(); + + // očakávané ID podľa kategórie s názvom A + $expectedIds = array_values(array_map( + fn(RecordInteger $r) => $r->getId(), + array_filter($records, fn(RecordInteger $r) => $r->getCategory() && $r->getCategory()->getName() === 'A', ARRAY_FILTER_USE_BOTH) + )); + + $qb = $em->createQueryBuilder() + ->select('i') + ->from(RecordInteger::class, 'i') + ->join('i.category', 'c') + ->where('c.name = :name') + ->setParameter('name', 'A') + ->orderBy('i.id', 'DESC') + ->setFirstResult(1) + ->setMaxResults(2); + + $query = $qb->getQuery(); + + $this->assertTrue($loader->supports($query)); + $this->assertEquals(count($expectedIds), $loader->getTotalCount($query)); + + $foundIds = $loader->loadAllIdentifiers(null, $query, 'id'); + sort($foundIds); + + $this->assertEquals($expectedIds, $foundIds); + } + + public function testGetCacheKeyStableAndDistinct(): void + { + RecordIntegerFactory::createMany(3); + + $loader = new DoctrineQueryLoader(); + + /** @var EntityManagerInterface $em */ + $em = self::getContainer()->get('doctrine')->getManager(); + + $qb1 = $em->createQueryBuilder() + ->select('i') + ->from(RecordInteger::class, 'i') + ->where('i.name = :name') + ->setParameter('name', 'keep'); + $q1 = $qb1->getQuery(); + + $qb2 = $em->createQueryBuilder() + ->select('i') + ->from(RecordInteger::class, 'i') + ->where('i.name = :name') + ->setParameter('name', 'keep'); + $q2 = $qb2->getQuery(); + + // Rovnaká filtrácia → rovnaký cache key + $k1a = $loader->getCacheKey($q1); + $k1b = $loader->getCacheKey($q1); + $k2 = $loader->getCacheKey($q2); + $this->assertSame($k1a, $k1b); + $this->assertSame($k1a, $k2); + + // Zmena parametra → iný cache key + $qb2->setParameter('name', 'drop'); + $q3 = $qb2->getQuery(); + $k3 = $loader->getCacheKey($q3); + $this->assertNotSame($k2, $k3); + } +} \ No newline at end of file diff --git a/tests/Unit/Selection/Normalizer/ObjectNormalizerTest.php b/tests/Unit/Selection/Normalizer/ObjectNormalizerTest.php new file mode 100644 index 0000000..6e94910 --- /dev/null +++ b/tests/Unit/Selection/Normalizer/ObjectNormalizerTest.php @@ -0,0 +1,79 @@ +assertTrue($normalizer->supports(new \stdClass())); + $this->assertTrue($normalizer->supports(new RecordInteger())); + + $this->assertFalse($normalizer->supports(1)); + $this->assertFalse($normalizer->supports('a')); + $this->assertFalse($normalizer->supports(1.23)); + $this->assertFalse($normalizer->supports(true)); + $this->assertFalse($normalizer->supports(null)); + $this->assertFalse($normalizer->supports([])); + } + + public function testNormalizeReadsScalarViaGetter(): void + { + $normalizer = new ObjectNormalizer(); + + $entity = (new RecordInteger())->setId(123)->setName('Foo'); + + $this->assertSame(123, $normalizer->normalize($entity, 'id')); + $this->assertSame('Foo', $normalizer->normalize($entity, 'name')); + } + + public function testNormalizeReadsStringFromStringableProperty(): void + { + $normalizer = new ObjectNormalizer(); + + // object with public property that is stringable + $obj = new class { + public $value; + public function __construct() + { + $this->value = new class { + public function __toString(): string { return 'STRINGABLE'; } + }; + } + }; + + $this->assertSame('STRINGABLE', $normalizer->normalize($obj, 'value')); + } + + public function testNormalizeThrowsWhenPathNotReadable(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot read identifier'); + + $normalizer = new ObjectNormalizer(); + $obj = new \stdClass(); + // property does not exist + $normalizer->normalize($obj, 'unknown'); + } + + public function testNormalizeThrowsWhenExtractedValueIsNotScalarOrStringable(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Extracted value is not a scalar'); + + $normalizer = new ObjectNormalizer(); + + $obj = new class { + public $value; + public function __construct() { $this->value = new \stdClass(); } + }; + + $normalizer->normalize($obj, 'value'); + } +} diff --git a/tests/Unit/Selection/Normalizer/ScalarNormalizerTest.php b/tests/Unit/Selection/Normalizer/ScalarNormalizerTest.php new file mode 100644 index 0000000..acd0177 --- /dev/null +++ b/tests/Unit/Selection/Normalizer/ScalarNormalizerTest.php @@ -0,0 +1,48 @@ +assertTrue($normalizer->supports(1)); + $this->assertTrue($normalizer->supports('id')); + $this->assertTrue($normalizer->supports(1.5)); + $this->assertTrue($normalizer->supports(false)); + + $this->assertFalse($normalizer->supports(null)); + $this->assertFalse($normalizer->supports([])); + $this->assertFalse($normalizer->supports(new \stdClass())); + } + + public function testNormalizeReturnsIntOrString(): void + { + $normalizer = new ScalarNormalizer(); + + $this->assertSame(123, $normalizer->normalize(123, 'ignored')); + $this->assertSame('abc', $normalizer->normalize('abc', 'ignored')); + } + + public function testNormalizeThrowsForFloatOrBool(): void + { + $normalizer = new ScalarNormalizer(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Item is not a valid scalar type'); + $normalizer->normalize(12.34, 'ignored'); + } + + public function testNormalizeThrowsForBool(): void + { + $normalizer = new ScalarNormalizer(); + + $this->expectException(\RuntimeException::class); + $normalizer->normalize(true, 'ignored'); + } +} From 13cf2b469d652b38062c102e03f1dc2cb625da7c Mon Sep 17 00:00:00 2001 From: tito10047 Date: Fri, 5 Dec 2025 12:53:06 +0100 Subject: [PATCH 04/13] wip --- config/services.php | 5 +++-- .../PreferenceDataCollectorIntegrationTest.php | 3 ++- .../Preference/Service/PreferenceManagerTest.php | 11 +++++------ .../Preference/Storage/DoctrineStorageTest.php | 2 +- .../Resolver/ObjectContextResolverTest.php | 3 ++- tests/Integration/Twig/PreferenceExtensionTest.php | 3 ++- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/config/services.php b/config/services.php index b3bc35e..4a4d9b6 100644 --- a/config/services.php +++ b/config/services.php @@ -9,6 +9,7 @@ use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagContextKeyResolverPass; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagValueTransformerPass; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreconfiguredPreferenceInterface; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceSessionStorage; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; use Tito10047\PersistentPreferenceBundle\Resolver\PersistentContextResolver; @@ -66,7 +67,7 @@ ->arg('$storage', service('persistent_preference.storage.session')) ->tag('persistent_preference.manager', ['name' => 'default']) ; - $services->alias(PersistentManagerInterface::class, 'persistent_preference.manager.default'); + $services->alias(PreconfiguredPreferenceInterface::class, 'persistent_preference.manager.default'); // --- Twig Extension --- $services @@ -79,7 +80,7 @@ $services ->set(PreferenceRuntime::class) ->public() - ->arg('$preferenceManager', service(PersistentManagerInterface::class)) + ->arg('$preferenceManager', service(PreconfiguredPreferenceInterface::class)) ->tag('twig.runtime') ; diff --git a/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php b/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php index f4f4d67..c21048a 100644 --- a/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php +++ b/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php @@ -11,6 +11,7 @@ use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreconfiguredPreferenceInterface; use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; @@ -23,7 +24,7 @@ public function testCollectorCountsPersistedPreferencesForContext(): void // Write some preferences using the real manager & session-backed storage /** @var PersistentManagerInterface $manager */ - $manager = $container->get(PersistentManagerInterface::class); + $manager = $container->get(PreconfiguredPreferenceInterface::class); $contextKey = 'integration_test_ctx'; diff --git a/tests/Integration/Preference/Service/PreferenceManagerTest.php b/tests/Integration/Preference/Service/PreferenceManagerTest.php index b1b2aa4..8fbcb2f 100644 --- a/tests/Integration/Preference/Service/PreferenceManagerTest.php +++ b/tests/Integration/Preference/Service/PreferenceManagerTest.php @@ -1,17 +1,16 @@ ensureSession(); - $pm = static::getContainer()->get(PersistentManagerInterface::class); + $pm = static::getContainer()->get(PreconfiguredPreferenceInterface::class); $pref = $pm->getPreference('user_1'); $this->assertInstanceOf(PreferenceInterface::class, $pref); @@ -56,7 +55,7 @@ public function testResolvesObjectContextViaPersistentContextInterface(): void { static::bootKernel(); $this->ensureSession(); - $pm = static::getContainer()->get(PersistentManagerInterface::class); + $pm = static::getContainer()->get(PreconfiguredPreferenceInterface::class); $obj = new class implements PersistentContextInterface { public function getPersistentContext(): string { return 'ctx_object_1'; } @@ -77,7 +76,7 @@ public function testThrowsForUnsupportedObject(): void { static::bootKernel(); $this->ensureSession(); - $pm = static::getContainer()->get(PersistentManagerInterface::class); + $pm = static::getContainer()->get(PreconfiguredPreferenceInterface::class); $this->expectException(\InvalidArgumentException::class); $pm->getPreference(new stdClass()); diff --git a/tests/Integration/Preference/Storage/DoctrineStorageTest.php b/tests/Integration/Preference/Storage/DoctrineStorageTest.php index 9bd08b9..ae47a1a 100644 --- a/tests/Integration/Preference/Storage/DoctrineStorageTest.php +++ b/tests/Integration/Preference/Storage/DoctrineStorageTest.php @@ -1,6 +1,6 @@ ensureSession(); - $pm = static::getContainer()->get(PersistentManagerInterface::class); + $pm = static::getContainer()->get(PreconfiguredPreferenceInterface::class); $user = (new User())->setId(10); $company = (new Company())->setUuid(77); diff --git a/tests/Integration/Twig/PreferenceExtensionTest.php b/tests/Integration/Twig/PreferenceExtensionTest.php index 3640d98..7732b83 100644 --- a/tests/Integration/Twig/PreferenceExtensionTest.php +++ b/tests/Integration/Twig/PreferenceExtensionTest.php @@ -6,6 +6,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreconfiguredPreferenceInterface; use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\Company; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\User; @@ -33,7 +34,7 @@ public function testTwigFunctionAndFilterReturnStoredValues(): void static::bootKernel(); $this->ensureSession(); - $pm = static::getContainer()->get(PersistentManagerInterface::class); + $pm = static::getContainer()->get(PreconfiguredPreferenceInterface::class); $user = (new User())->setId(5)->setName('Alice'); $company = (new Company())->setUuid(10)->setName('ACME'); From 69723d7ba41d3e3a5bc3445fd5e697fd4ab9e6d9 Mon Sep 17 00:00:00 2001 From: tito10047 Date: Fri, 5 Dec 2025 14:37:58 +0100 Subject: [PATCH 05/13] wip --- config/definition.php | 37 ++- config/services.php | 23 +- src/Command/DebugPreferenceCommand.php | 6 +- .../Compiler/AutoTagValueTransformerPass.php | 44 +-- src/PersistentPreferenceBundle.php | 8 +- .../PreconfiguredPreferenceInterface.php | 8 - src/Preference/Service/Preference.php | 2 +- .../Service/PreferenceManager.php} | 10 +- .../Service/PreferenceManagerInterface.php | 11 + .../Service/TraceablePersistentManager.php | 4 +- src/Selection/Service/HasModeInterface.php | 11 + .../PreconfiguredSelectionInterface.php | 2 + .../Service/RegisterSelectionInterface.php | 10 + src/Selection/Service/Selection.php | 280 ++++++++++++++++++ src/Selection/Service/SelectionInterface.php | 6 +- src/Service/PersistentManagerInterface.php | 19 -- src/Storage/StorableEnvelope.php | 2 +- src/Transformer/ArrayValueTransformer.php | 27 ++ src/Transformer/ObjectIdValueTransformer.php | 32 ++ src/Transformer/ScalarValueTransformer.php | 14 +- .../SerializableObjectTransformer.php | 27 ++ src/Transformer/ValueTransformerInterface.php | 8 +- src/Twig/PreferenceRuntime.php | 4 +- ...istent_preference.yaml => persistent.yaml} | 17 +- tests/App/AssetMapper/config/services.yaml | 4 +- .../DebugPreferenceCommandIntegrationTest.php | 2 +- ...PreferenceDataCollectorIntegrationTest.php | 4 +- .../Service/PreferenceManagerTest.php | 8 +- .../Storage/DoctrineStorageTest.php | 2 +- .../Resolver/ObjectContextResolverTest.php | 4 +- .../Twig/PreferenceExtensionTest.php | 5 +- .../Command/DebugPreferenceCommandTest.php | 17 +- .../Preference/Service/PreferenceTest.php | 12 +- .../Service/SelectionInterfaceTest.php | 268 +++++++++++++++++ .../ScalarValueTransformerTest.php | 15 +- 35 files changed, 791 insertions(+), 162 deletions(-) delete mode 100644 src/Preference/Service/PreconfiguredPreferenceInterface.php rename src/{Service/PersistentManager.php => Preference/Service/PreferenceManager.php} (80%) create mode 100644 src/Preference/Service/PreferenceManagerInterface.php create mode 100644 src/Selection/Service/HasModeInterface.php create mode 100644 src/Selection/Service/RegisterSelectionInterface.php create mode 100644 src/Selection/Service/Selection.php delete mode 100644 src/Service/PersistentManagerInterface.php create mode 100644 src/Transformer/ArrayValueTransformer.php create mode 100644 src/Transformer/ObjectIdValueTransformer.php create mode 100644 src/Transformer/SerializableObjectTransformer.php rename tests/App/AssetMapper/config/packages/{persistent_preference.yaml => persistent.yaml} (57%) create mode 100644 tests/Unit/Selection/Service/SelectionInterfaceTest.php diff --git a/config/definition.php b/config/definition.php index e0ac4ee..ff7cb21 100644 --- a/config/definition.php +++ b/config/definition.php @@ -6,10 +6,28 @@ * @link https://symfony.com/doc/current/bundles/best_practices.html#configuration */ return static function (DefinitionConfigurator $definition): void { - $addManagerSection = static function (\Symfony\Component\Config\Definition\Builder\NodeBuilder $nodeBuilder, string $sectionName): void - { - $nodeBuilder - ->arrayNode($sectionName) + $rootNode = $definition->rootNode(); + $children = $rootNode->children(); + + $children + ->arrayNode('preference') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('managers') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('storage') + ->isRequired() + ->cannotBeEmpty() + ->info('The service ID of the storage backend to use (e.g. "@app.storage.doctrine").') + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('selection') ->addDefaultsIfNotSet() ->children() ->arrayNode('managers') @@ -21,18 +39,17 @@ ->cannotBeEmpty() ->info('The service ID of the storage backend to use (e.g. "@app.storage.doctrine").') ->end() + ->scalarNode('resolver') + ->isRequired() + ->cannotBeEmpty() + ->info('') + ->end() ->end() ->end() ->end() ->end() ->end(); - }; - $rootNode = $definition->rootNode(); - $children = $rootNode->children(); - - $addManagerSection($children, 'preference'); - $addManagerSection($children, 'selection'); $children->end(); diff --git a/config/services.php b/config/services.php index 4a4d9b6..bb85a17 100644 --- a/config/services.php +++ b/config/services.php @@ -9,12 +9,11 @@ use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagContextKeyResolverPass; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagValueTransformerPass; -use Tito10047\PersistentPreferenceBundle\Preference\Service\PreconfiguredPreferenceInterface; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManager; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManagerInterface; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceSessionStorage; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; use Tito10047\PersistentPreferenceBundle\Resolver\PersistentContextResolver; -use Tito10047\PersistentPreferenceBundle\Service\PersistentManager; -use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Tito10047\PersistentPreferenceBundle\Transformer\ScalarValueTransformer; use Tito10047\PersistentPreferenceBundle\Twig\PreferenceExtension; use Tito10047\PersistentPreferenceBundle\Twig\PreferenceRuntime; @@ -32,12 +31,12 @@ // --- Storage --- $services - ->set('persistent_preference.storage.session',PreferenceSessionStorage::class) + ->set('persistent.preference.storage.session',PreferenceSessionStorage::class) ->arg('$requestStack', service(RequestStack::class)) ->public() ; // Alias the interface to our concrete storage service id - $services->alias(PreferenceStorageInterface::class, 'persistent_preference.storage.session'); + $services->alias(PreferenceStorageInterface::class, 'persistent.preference.storage.session'); // --- Built-in Resolvers --- $services @@ -53,21 +52,21 @@ // --- Metadata Converters --- $services - ->set('persistent_preference.converter.object_vars', ObjectVarsConverter::class) + ->set('persistent.preference.converter.object_vars', ObjectVarsConverter::class) ->public() ; - $services->alias(MetadataConverterInterface::class, 'persistent_preference.converter.object_vars'); + $services->alias(MetadataConverterInterface::class, 'persistent.preference.converter.object_vars'); // --- PreferenceManager --- $services - ->set('persistent_preference.manager.default', PersistentManager::class) + ->set('persistent.preference.manager.default', PreferenceManager::class) ->public() ->arg('$resolvers', tagged_iterator(AutoTagContextKeyResolverPass::TAG)) ->arg('$transformers', tagged_iterator(AutoTagValueTransformerPass::TAG)) - ->arg('$storage', service('persistent_preference.storage.session')) - ->tag('persistent_preference.manager', ['name' => 'default']) + ->arg('$storage', service('persistent.preference.storage.session')) + ->tag('persistent.preference.manager', ['name' => 'default']) ; - $services->alias(PreconfiguredPreferenceInterface::class, 'persistent_preference.manager.default'); + $services->alias(PreferenceManagerInterface::class, 'persistent.preference.manager.default'); // --- Twig Extension --- $services @@ -80,7 +79,7 @@ $services ->set(PreferenceRuntime::class) ->public() - ->arg('$preferenceManager', service(PreconfiguredPreferenceInterface::class)) + ->arg('$preferenceManager', service(PreferenceManagerInterface::class)) ->tag('twig.runtime') ; diff --git a/src/Command/DebugPreferenceCommand.php b/src/Command/DebugPreferenceCommand.php index a115d0e..37211b6 100644 --- a/src/Command/DebugPreferenceCommand.php +++ b/src/Command/DebugPreferenceCommand.php @@ -10,9 +10,9 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManagerInterface; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceSessionStorage; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; -use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Tito10047\PersistentPreferenceBundle\Storage\DoctrinePreferenceStorage; #[AsCommand(name: 'debug:preference', description: 'Print preferences for a given context and manager')] @@ -37,14 +37,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $context = (string) $input->getArgument('context'); $managerName = (string) $input->getOption('manager'); - $serviceId = 'persistent_preference.manager.' . $managerName; + $serviceId = 'persistent.preference.manager.' . $managerName; if (!$this->container->has($serviceId)) { $io->error(sprintf('Preference manager "%s" not found (service id "%s").', $managerName, $serviceId)); return Command::FAILURE; } $manager = $this->container->get($serviceId); - if (!$manager instanceof PersistentManagerInterface) { + if (!$manager instanceof PreferenceManagerInterface) { $io->error(sprintf('Service "%s" is not a PreferenceManagerInterface.', $serviceId)); return Command::FAILURE; } diff --git a/src/DependencyInjection/Compiler/AutoTagValueTransformerPass.php b/src/DependencyInjection/Compiler/AutoTagValueTransformerPass.php index 5539324..4c88fc9 100644 --- a/src/DependencyInjection/Compiler/AutoTagValueTransformerPass.php +++ b/src/DependencyInjection/Compiler/AutoTagValueTransformerPass.php @@ -8,50 +8,8 @@ use Tito10047\PersistentPreferenceBundle\Resolver\ContextKeyResolverInterface; use Tito10047\PersistentPreferenceBundle\Transformer\ValueTransformerInterface; -final class AutoTagValueTransformerPass implements CompilerPassInterface +final class AutoTagValueTransformerPass { public const TAG = 'persistent_preference.value_transformer'; - public function process(ContainerBuilder $container): void - { - $parameterBag = $container->getParameterBag(); - - /** @var array $definitions */ - $definitions = $container->getDefinitions(); - - foreach ($definitions as $id => $definition) { - // Skip non-instantiable or special definitions - if ($definition->isAbstract() || $definition->isSynthetic()) { - continue; - } - - // If it already has the tag, skip (idempotent) - if ($definition->hasTag(self::TAG)) { - continue; - } - - // Try to resolve the class name - $class = $definition->getClass() ?: $id; // Fallback: service id can be FQCN - if (!is_string($class) || $class === '') { - continue; - } - - // Resolve parameters like "%foo.class%" - $class = $parameterBag->resolveValue($class); - if (!is_string($class)) { - continue; - } - - // Use ContainerBuilder's reflection helper to avoid triggering - // autoload errors for vendor/dev classes that may not be present. - $reflection = $container->getReflectionClass($class, false); - if (!$reflection) { - continue; // cannot reflect, skip silently - } - - if ($reflection->implementsInterface(ValueTransformerInterface::class)) { - $definition->addTag(self::TAG)->setPublic(true); - } - } - } } diff --git a/src/PersistentPreferenceBundle.php b/src/PersistentPreferenceBundle.php index 5f90e9a..ce72d57 100644 --- a/src/PersistentPreferenceBundle.php +++ b/src/PersistentPreferenceBundle.php @@ -13,10 +13,7 @@ use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagIdentityLoadersPass; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagValueTransformerPass; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\TraceableManagersPass; -use Tito10047\PersistentPreferenceBundle\Resolver\ObjectContextResolver; -use Tito10047\PersistentPreferenceBundle\Service\PersistentManager; -use Tito10047\PersistentPreferenceBundle\Storage\DoctrinePreferenceStorage; -use Symfony\Component\DependencyInjection\Loader\Configurator\ServiceConfigurator; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManager; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; @@ -44,7 +41,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $storage = service($subConfig['storage'] ?? '@persistent_preference.storage.session'); $storage = ltrim($storage, '@'); $services - ->set('persistent_preference.manager.' . $name, PersistentManager::class) + ->set('persistent.preference.manager.' . $name, PreferenceManager::class) ->public() ->arg('$resolvers', tagged_iterator(AutoTagContextKeyResolverPass::TAG)) ->arg('$transformers', tagged_iterator(AutoTagValueTransformerPass::TAG)) @@ -57,7 +54,6 @@ public function loadExtension(array $config, ContainerConfigurator $container, C public function build(ContainerBuilder $container): void { parent::build($container); $container->addCompilerPass(new AutoTagContextKeyResolverPass()); - $container->addCompilerPass(new AutoTagValueTransformerPass()); $container->addCompilerPass(new TraceableManagersPass()); } } \ No newline at end of file diff --git a/src/Preference/Service/PreconfiguredPreferenceInterface.php b/src/Preference/Service/PreconfiguredPreferenceInterface.php deleted file mode 100644 index 1c71cba..0000000 --- a/src/Preference/Service/PreconfiguredPreferenceInterface.php +++ /dev/null @@ -1,8 +0,0 @@ -transformers as $transformer) { if ($transformer->supports($value)) { - return $transformer->transform($value); + return $transformer->transform($value)->toArray(); } } diff --git a/src/Service/PersistentManager.php b/src/Preference/Service/PreferenceManager.php similarity index 80% rename from src/Service/PersistentManager.php rename to src/Preference/Service/PreferenceManager.php index fff3d1a..58b311a 100644 --- a/src/Service/PersistentManager.php +++ b/src/Preference/Service/PreferenceManager.php @@ -1,16 +1,14 @@ $resolvers @@ -54,8 +52,4 @@ private function resolveContextKey(object|string $context): string get_class($context) )); } - - public function getSelection(string $namespace, mixed $owner = null): SelectionInterface { - // TODO: Implement getSelection() method. - } } \ No newline at end of file diff --git a/src/Preference/Service/PreferenceManagerInterface.php b/src/Preference/Service/PreferenceManagerInterface.php new file mode 100644 index 0000000..fda334d --- /dev/null +++ b/src/Preference/Service/PreferenceManagerInterface.php @@ -0,0 +1,11 @@ +storage->hasIdentifier($this->key, $item); + return $this->storage->getMode($this->key) === SelectionMode::INCLUDE ? $has : !$has; + } + + public function select(mixed $item, null|array|object $metadata = null): static { + $id = is_scalar($item) ? $item : $this->normalizer->transform($item); + $mode = $this->storage->getMode($this->key); + $metaArray = null; + if ($metadata !== null) { + $metaArray = $this->metadataConverter->transform($metadata)->toArray(); + } + if ($mode === SelectionMode::INCLUDE) { + // SessionStorage::add now expects a map [id => metadata] + $this->storage->add($this->key, [$id], $metaArray !== null ? [$id => $metaArray] : null); + } else { + // In EXCLUDE mode, selecting means removing the id from the exclusion list + $this->storage->remove($this->key, [$id]); + } + return $this; + } + + public function unselect(mixed $item): static { + $id = is_scalar($item) ? $item : $this->normalizer->transform($item); + if ($this->storage->getMode($this->key) === SelectionMode::INCLUDE) { + $this->storage->remove($this->key, [$id]); + } else { + $this->storage->add($this->key, [$id], null); + } + return $this; + } + + public function selectMultiple(array $items, null|array $metadata = null): static { + $mode = $this->storage->getMode($this->key); + // If metadata is provided as a map per-id, we need to process per item. + // When metadata is null, we can batch add in INCLUDE mode. + if ($mode === SelectionMode::EXCLUDE) { + throw new \LogicException('Cannot select multiple items in EXCLUDE mode.'); + } + if ($metadata === null) { + $ids = []; + foreach ($items as $item) { + $ids[] = is_scalar($item) ? $item : $this->normalizer->transform($item); + } + $this->storage->add($this->key, $ids, null); + return $this; + } + foreach ($items as $item) { + $id = is_scalar($item) ? $item : $this->normalizer->transform($item); + + $metaForId = null; + if (array_key_exists($id, $metadata) || array_key_exists((string) $id, $metadata)) { + $metaForId = $metadata[$id] ?? $metadata[(string) $id]; + } else { + throw new \LogicException("No metadata found for id $id"); + } + + // Convert object metadata and pass as [id => meta] + $metaForId = $this->metadataConverter->transform($metaForId)->toArray(); + $this->storage->add($this->key, [$id], [$id => $metaForId]); + } + return $this; + } + + public function unselectMultiple(array $items): static { + $ids = []; + foreach ($items as $item) { + $ids[] = is_scalar($item) ? $item : $this->normalizer->transform($item); + } + $this->storage->remove($this->key, $ids); + return $this; + } + + public function selectAll(): static { + $this->storage->clear($this->key); + $this->storage->setMode($this->key, SelectionMode::EXCLUDE); + return $this; + } + + public function unselectAll(): static { + $this->storage->clear($this->key); + $this->storage->setMode($this->key, SelectionMode::INCLUDE); + return $this; + } + + /** + * Prepne stav položky a vráti nový stav (true = vybraný, false = nevybraný). + */ + public function toggle(mixed $item, null|array|object $metadata = null): bool { + if ($this->isSelected($item)) { + $this->unselect($item); + return false; + } + $this->select($item, $metadata); + return true; + } + + public function getSelectedIdentifiers(): array { + if ($this->storage->getMode($this->key) === SelectionMode::INCLUDE) { + return $this->storage->getStored($this->key); + } else { + $excluded = $this->storage->getStored($this->key); + $all = $this->storage->getStored($this->getAllContext()); + return array_diff($all, $excluded); + } + } + + public function update(mixed $item, object|array|null $metadata = null): static { + $id = is_scalar($item) ? $item : $this->normalizer->transform($item); + if ($metadata === null) { + return $this; // nothing to update + } + $metaArray = $this->metadataConverter->transform($metadata)->toArray(); + + $mode = $this->storage->getMode($this->key); + if ($mode === SelectionMode::INCLUDE) { + // Ensure metadata is persisted for this id (and id is included) + $this->storage->add($this->key, [$id], [$id => $metaArray]); + return $this; + } + // In EXCLUDE mode, metadata can only be stored for explicitly excluded ids + if ($this->storage->hasIdentifier($this->key, $id)) { + $this->storage->add($this->key, [$id], [$id => $metaArray]); + } + return $this; + } + + public function getSelected(): array { + $mode = $this->storage->getMode($this->key); + if ($mode === SelectionMode::INCLUDE) { + $map = $this->storage->getStoredWithMetadata($this->key); + $hydrated = []; + foreach ($map as $id => $meta) { + $hydrated[$id] = $this->metadataConverter->reverseTransform(StorableEnvelope::fromArray($meta)); + } + return $hydrated; + } + // EXCLUDE mode + $excluded = $this->storage->getStored($this->key); + $all = $this->storage->getStored($this->getAllContext()); + $selected = array_values(array_diff($all, $excluded)); + $result = []; + foreach ($selected as $id) { + $meta = $this->storage->getMetadata($this->key, $id); + if ($metadataClass !== null) { + $result[$id] = $this->metadataConverter->reverseTransform($meta); + } else { + $result[$id] = $meta; + } + } + return $result; + } + + public function getMetadata(mixed $item): null|array|object { + $id = is_scalar($item) ? $item : $this->normalizer->transform($item); + $meta = $this->storage->getMetadata($this->key, $id); + if ($meta === [] || $meta === null) { + return null; + } + $meta = StorableEnvelope::fromArray($meta); + if ($this->metadataConverter->supportsReverse($meta)) { + return $this->metadataConverter->reverseTransform($meta); + } + return $meta; + } + + public function rememberAll(array $ids): static { + $this->storage->add($this->getAllContext(), $ids, null); + return $this; + } + + public function setMode(SelectionMode $mode): void { + $this->storage->setMode($this->key, $mode); + } + + public function getMode(): SelectionMode { + return $this->storage->getMode($this->key); + } + + private function getAllContext(): string { + return $this->key . '__ALL__'; + } + + private function getAllMetaContext(): string { + return $this->key . '__ALL_META__'; + } + + public function destroy(): static { + $this->storage->clear($this->key); + $this->storage->clear($this->getAllContext()); + $this->storage->clear($this->getAllMetaContext()); + return $this; + } + + public function isSelectedAll(): bool { + return $this->getMode() == SelectionMode::EXCLUDE && count($this->getSelectedIdentifiers()) == 0; + } + + public function getTotal(): int { + return count($this->storage->getStored($this->getAllContext())); + } + + public function normalize(mixed $item): int|string { + return $this->normalizer->transform($item); + } + + + public function hasSource(string $cacheKey): bool { + // First ensure we have a marker for this source + if (!$this->storage->hasIdentifier($this->getAllMetaContext(), $cacheKey)) { + return false; + } + + // Check TTL metadata if present + $meta = $this->storage->getMetadata($this->getAllMetaContext(), $cacheKey); + if (!is_array($meta) || $meta === []) { + return true; // no TTL -> considered present + } + + if (isset($meta['expiresAt'])) { + $expiresAt = (int) $meta['expiresAt']; + if ($expiresAt !== 0 && time() >= $expiresAt) { + // expired + return false; + } + } + return true; + } + + public function registerSource(string $cacheKey, mixed $source, int|\DateInterval|null $ttl = null): static { + // If source already registered, do nothing + if ($this->hasSource($cacheKey)) { + return $this; + } + + // Expecting $source to be an array of scalar identifiers already normalized + $ids = is_array($source) ? array_values($source) : []; + if (!empty($ids)) { + $this->rememberAll($ids); + } + + // Mark the source as registered in ALL_META context, optionally with TTL metadata + $meta = null; + if ($ttl !== null) { + $expiresAt = 0; // 0 == never expire + if ($ttl instanceof \DateInterval) { + $expiresAt = (new \DateTimeImmutable('now')) + ->add($ttl) + ->getTimestamp(); + } else { + // int seconds (can be zero or negative -> already expired) + $expiresAt = time() + (int) $ttl; + } + $meta = ['expiresAt' => $expiresAt]; + } + + $this->storage->add($this->getAllMetaContext(), [$cacheKey], $meta !== null ? [$cacheKey => $meta] : null); + + return $this; + } +} \ No newline at end of file diff --git a/src/Selection/Service/SelectionInterface.php b/src/Selection/Service/SelectionInterface.php index fc99e54..1ea71b7 100644 --- a/src/Selection/Service/SelectionInterface.php +++ b/src/Selection/Service/SelectionInterface.php @@ -50,24 +50,22 @@ public function getSelectedIdentifiers(): array; /** * Vráti mapu vybraných položiek. Ak je zadaná $metadataClass, metadáta sa hydratujú. * - * @param class-string $metadataClass FQCN pre hydratáciu metadát (napr. MyDomainConfig::class). * @return array * @template T of object * @phpstan-param class-string|null $metadataClass * @phpstan-return array|array */ - public function getSelected(?string $metadataClass = null): array; + public function getSelected(): array; /** * Vráti mapu vybraných položiek. Ak je zadaná $metadataClass, metadáta sa hydratujú. * - * @param class-string|null $metadataClass FQCN pre hydratáciu metadát (napr. MyDomainConfig::class). * @return T|array|null * @template T of object * @phpstan-param class-string|null $metadataClass * @phpstan-return T|array */ - public function getMetadata(mixed $item, ?string $metadataClass = null): null|array|object; + public function getMetadata(mixed $item): null|array|object; public function getTotal():int; diff --git a/src/Service/PersistentManagerInterface.php b/src/Service/PersistentManagerInterface.php deleted file mode 100644 index 51b1c23..0000000 --- a/src/Service/PersistentManagerInterface.php +++ /dev/null @@ -1,19 +0,0 @@ - 'dark']) */ - public readonly array $data + public readonly array|string|null|int|float $data ) {} /** diff --git a/src/Transformer/ArrayValueTransformer.php b/src/Transformer/ArrayValueTransformer.php new file mode 100644 index 0000000..fae08e9 --- /dev/null +++ b/src/Transformer/ArrayValueTransformer.php @@ -0,0 +1,27 @@ +className === "array"; + } + + public function reverseTransform(StorableEnvelope $value): mixed { + return $value->data; + } +} \ No newline at end of file diff --git a/src/Transformer/ObjectIdValueTransformer.php b/src/Transformer/ObjectIdValueTransformer.php new file mode 100644 index 0000000..ac4dd2c --- /dev/null +++ b/src/Transformer/ObjectIdValueTransformer.php @@ -0,0 +1,32 @@ +class; + } + + public function transform(mixed $value): StorableEnvelope { + if (!$value instanceof $this->class) { + throw new \InvalidArgumentException('Expected instance of ' . $this->class); + } + return new StorableEnvelope($this->class, $value->{$this->identifierMethod}()); + } + + public function supportsReverse(StorableEnvelope $value): bool { + return $value->className === $this->class; + } + + public function reverseTransform(StorableEnvelope $value): mixed { + return $value->data; + } +} \ No newline at end of file diff --git a/src/Transformer/ScalarValueTransformer.php b/src/Transformer/ScalarValueTransformer.php index cbb9bea..4f60cd7 100644 --- a/src/Transformer/ScalarValueTransformer.php +++ b/src/Transformer/ScalarValueTransformer.php @@ -2,6 +2,8 @@ namespace Tito10047\PersistentPreferenceBundle\Transformer; +use Tito10047\PersistentPreferenceBundle\Storage\StorableEnvelope; + /** * Support all basic types. Int, String, Bool, Float, Null */ @@ -11,15 +13,15 @@ public function supports(mixed $value): bool { return is_scalar($value) || $value === null; } - public function transform(mixed $value): mixed { - return $value; + public function transform(mixed $value): StorableEnvelope { + return new StorableEnvelope("scalar",$value); } - public function supportsReverse(mixed $value): bool { - return is_scalar($value) || $value === null; + public function supportsReverse(StorableEnvelope $value): bool { + return $value->className === "scalar"; } - public function reverseTransform(mixed $value): mixed { - return $value; + public function reverseTransform(StorableEnvelope $value): mixed { + return $value->data; } } \ No newline at end of file diff --git a/src/Transformer/SerializableObjectTransformer.php b/src/Transformer/SerializableObjectTransformer.php new file mode 100644 index 0000000..8a5c9dd --- /dev/null +++ b/src/Transformer/SerializableObjectTransformer.php @@ -0,0 +1,27 @@ +className === "serializable"; + } + + public function reverseTransform(StorableEnvelope $value): mixed { + return unserialize($value->data); + } +} \ No newline at end of file diff --git a/src/Transformer/ValueTransformerInterface.php b/src/Transformer/ValueTransformerInterface.php index 04e6973..2c81efd 100644 --- a/src/Transformer/ValueTransformerInterface.php +++ b/src/Transformer/ValueTransformerInterface.php @@ -2,6 +2,8 @@ namespace Tito10047\PersistentPreferenceBundle\Transformer; +use Tito10047\PersistentPreferenceBundle\Storage\StorableEnvelope; + /** * Responsibility: Handles two-way conversion between PHP Objects and Storage Data. * @@ -20,7 +22,7 @@ public function supports(mixed $value): bool; * Converts PHP value to a storage-friendly format (scalar/array). * Example: Object -> ['__type' => 'MyClass', 'data' => {...}] */ - public function transform(mixed $value): mixed; + public function transform(mixed $value): StorableEnvelope; /** * Checks if the raw value from storage looks like something this transformer created. @@ -28,11 +30,11 @@ public function transform(mixed $value): mixed; * * Example: Checks if array has '__type' key. */ - public function supportsReverse(mixed $value): bool; + public function supportsReverse(StorableEnvelope $value): bool; /** * Converts storage format back to PHP value. * Example: ['__type' => 'MyClass', ...] -> Object */ - public function reverseTransform(mixed $value): mixed; + public function reverseTransform(StorableEnvelope $value): mixed; } \ No newline at end of file diff --git a/src/Twig/PreferenceRuntime.php b/src/Twig/PreferenceRuntime.php index 51d5eb2..dd8f464 100644 --- a/src/Twig/PreferenceRuntime.php +++ b/src/Twig/PreferenceRuntime.php @@ -2,12 +2,12 @@ namespace Tito10047\PersistentPreferenceBundle\Twig; -use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManagerInterface; use Twig\Extension\RuntimeExtensionInterface; final class PreferenceRuntime implements RuntimeExtensionInterface { - public function __construct(private readonly PersistentManagerInterface $preferenceManager) + public function __construct(private readonly PreferenceManagerInterface $preferenceManager) { } diff --git a/tests/App/AssetMapper/config/packages/persistent_preference.yaml b/tests/App/AssetMapper/config/packages/persistent.yaml similarity index 57% rename from tests/App/AssetMapper/config/packages/persistent_preference.yaml rename to tests/App/AssetMapper/config/packages/persistent.yaml index 9d1e8b8..bfdcafd 100644 --- a/tests/App/AssetMapper/config/packages/persistent_preference.yaml +++ b/tests/App/AssetMapper/config/packages/persistent.yaml @@ -15,11 +15,24 @@ services: arguments: - '@doctrine.orm.entity_manager' - Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\UserPreference + app.transformer.id: + class: Tito10047\PersistentPreferenceBundle\Transformer\ObjectIdValueTransformer + arguments: + $class: Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\User + $identifierMethod: 'getId' persistent: preference: managers: default: - storage: '@persistent_preference.storage.session' + storage: '@persistent.preference.storage.session' my_pref_manager: - storage: '@app.storage.doctrine' \ No newline at end of file + storage: '@app.storage.doctrine' + selection: + managers: + default: + storage: '@persistent.selection.storage.session' + resolver: '@app.transformer.id' + simple: + storage: '@persistent.selection.storage.doctrine' + resolver: '@app.transformer.id' diff --git a/tests/App/AssetMapper/config/services.yaml b/tests/App/AssetMapper/config/services.yaml index 8eb91a8..7020fea 100644 --- a/tests/App/AssetMapper/config/services.yaml +++ b/tests/App/AssetMapper/config/services.yaml @@ -21,7 +21,7 @@ services: Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\ServiceHelper: arguments: - $resolvers: !tagged_iterator persistent_preference.context_key_resolver - $transformers: !tagged_iterator persistent_preference.value_transformer + $resolvers: !tagged_iterator persistent.preference.context_key_resolver + $transformers: !tagged_iterator persistent.preference.value_transformer public: true diff --git a/tests/Integration/Command/DebugPreferenceCommandIntegrationTest.php b/tests/Integration/Command/DebugPreferenceCommandIntegrationTest.php index 246b108..3e33b9a 100644 --- a/tests/Integration/Command/DebugPreferenceCommandIntegrationTest.php +++ b/tests/Integration/Command/DebugPreferenceCommandIntegrationTest.php @@ -15,7 +15,7 @@ public function testRunsThroughContainerAndPrintsDoctrineStorage(): void // Seed some preferences into doctrine-backed manager /** @var PersistentManagerInterface $pmDoctrine */ - $pmDoctrine = static::getContainer()->get('persistent_preference.manager.my_pref_manager'); + $pmDoctrine = static::getContainer()->get('persistent.preference.manager.my_pref_manager'); $pmDoctrine->getPreference('user_15')->import([ 'theme' => 'dark', 'limit' => 50, diff --git a/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php b/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php index c21048a..396762c 100644 --- a/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php +++ b/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php @@ -11,7 +11,7 @@ use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; -use Tito10047\PersistentPreferenceBundle\Preference\Service\PreconfiguredPreferenceInterface; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManagerInterface; use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; @@ -24,7 +24,7 @@ public function testCollectorCountsPersistedPreferencesForContext(): void // Write some preferences using the real manager & session-backed storage /** @var PersistentManagerInterface $manager */ - $manager = $container->get(PreconfiguredPreferenceInterface::class); + $manager = $container->get(PreferenceManagerInterface::class); $contextKey = 'integration_test_ctx'; diff --git a/tests/Integration/Preference/Service/PreferenceManagerTest.php b/tests/Integration/Preference/Service/PreferenceManagerTest.php index 8fbcb2f..f47719b 100644 --- a/tests/Integration/Preference/Service/PreferenceManagerTest.php +++ b/tests/Integration/Preference/Service/PreferenceManagerTest.php @@ -7,7 +7,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; -use Tito10047\PersistentPreferenceBundle\Preference\Service\PreconfiguredPreferenceInterface; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManagerInterface; use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceInterface; use Tito10047\PersistentPreferenceBundle\Service\PersistentContextInterface; use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; @@ -34,7 +34,7 @@ public function testReturnsPreferenceForStringContextAndPersists(): void { static::bootKernel(); $this->ensureSession(); - $pm = static::getContainer()->get(PreconfiguredPreferenceInterface::class); + $pm = static::getContainer()->get(PreferenceManagerInterface::class); $pref = $pm->getPreference('user_1'); $this->assertInstanceOf(PreferenceInterface::class, $pref); @@ -55,7 +55,7 @@ public function testResolvesObjectContextViaPersistentContextInterface(): void { static::bootKernel(); $this->ensureSession(); - $pm = static::getContainer()->get(PreconfiguredPreferenceInterface::class); + $pm = static::getContainer()->get(PreferenceManagerInterface::class); $obj = new class implements PersistentContextInterface { public function getPersistentContext(): string { return 'ctx_object_1'; } @@ -76,7 +76,7 @@ public function testThrowsForUnsupportedObject(): void { static::bootKernel(); $this->ensureSession(); - $pm = static::getContainer()->get(PreconfiguredPreferenceInterface::class); + $pm = static::getContainer()->get(PreferenceManagerInterface::class); $this->expectException(\InvalidArgumentException::class); $pm->getPreference(new stdClass()); diff --git a/tests/Integration/Preference/Storage/DoctrineStorageTest.php b/tests/Integration/Preference/Storage/DoctrineStorageTest.php index ae47a1a..ec8a907 100644 --- a/tests/Integration/Preference/Storage/DoctrineStorageTest.php +++ b/tests/Integration/Preference/Storage/DoctrineStorageTest.php @@ -42,7 +42,7 @@ public function testManagerUsesConfiguredDoctrineStorage(): void static::bootKernel(); $container = static::getContainer(); - $manager = $container->get('persistent_preference.manager.my_pref_manager'); + $manager = $container->get('persistent.preference.manager.my_pref_manager'); $pref = $manager->getPreference('user_5'); $pref->set('x', 1); $this->assertSame(1, $pref->getInt('x')); diff --git a/tests/Integration/Resolver/ObjectContextResolverTest.php b/tests/Integration/Resolver/ObjectContextResolverTest.php index 6473376..22b627a 100644 --- a/tests/Integration/Resolver/ObjectContextResolverTest.php +++ b/tests/Integration/Resolver/ObjectContextResolverTest.php @@ -6,7 +6,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; -use Tito10047\PersistentPreferenceBundle\Preference\Service\PreconfiguredPreferenceInterface; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManagerInterface; use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\Company; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\User; @@ -33,7 +33,7 @@ public function testResolvesConfiguredUserAndCompanyContexts(): void static::bootKernel(); $this->ensureSession(); - $pm = static::getContainer()->get(PreconfiguredPreferenceInterface::class); + $pm = static::getContainer()->get(PreferenceManagerInterface::class); $user = (new User())->setId(10); $company = (new Company())->setUuid(77); diff --git a/tests/Integration/Twig/PreferenceExtensionTest.php b/tests/Integration/Twig/PreferenceExtensionTest.php index 7732b83..83fb106 100644 --- a/tests/Integration/Twig/PreferenceExtensionTest.php +++ b/tests/Integration/Twig/PreferenceExtensionTest.php @@ -6,8 +6,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; -use Tito10047\PersistentPreferenceBundle\Preference\Service\PreconfiguredPreferenceInterface; -use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManagerInterface; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\Company; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\User; use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; @@ -34,7 +33,7 @@ public function testTwigFunctionAndFilterReturnStoredValues(): void static::bootKernel(); $this->ensureSession(); - $pm = static::getContainer()->get(PreconfiguredPreferenceInterface::class); + $pm = static::getContainer()->get(PreferenceManagerInterface::class); $user = (new User())->setId(5)->setName('Alice'); $company = (new Company())->setUuid(10)->setName('ACME'); diff --git a/tests/Unit/Command/DebugPreferenceCommandTest.php b/tests/Unit/Command/DebugPreferenceCommandTest.php index 596739f..bb8cd3f 100644 --- a/tests/Unit/Command/DebugPreferenceCommandTest.php +++ b/tests/Unit/Command/DebugPreferenceCommandTest.php @@ -8,6 +8,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Tito10047\PersistentPreferenceBundle\Command\DebugPreferenceCommand; use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceInterface; +use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManagerInterface; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceSessionStorage; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; @@ -40,7 +41,7 @@ private function makeContainerMock(array $services, array $hasMap = []): Contain public function testSuccessWithRowsAndSessionStorageLabel(): void { $context = 'user_15'; - $serviceId = 'persistent_preference.manager.default'; + $serviceId = 'persistent.preference.manager.default'; $preference = $this->createMock(PreferenceInterface::class); $preference->method('all')->willReturn([ @@ -53,7 +54,7 @@ public function testSuccessWithRowsAndSessionStorageLabel(): void $storage = new PreferenceSessionStorage(new RequestStack()); - $manager = $this->createMock(PersistentManagerInterface::class); + $manager = $this->createMock(PreferenceManagerInterface::class); $manager->method('getPreference')->with($context)->willReturn($preference); $manager->method('getPreferenceStorage')->willReturn($storage); @@ -86,14 +87,14 @@ public function testSuccessWithRowsAndSessionStorageLabel(): void public function testEmptyPreferencesShowsMessage(): void { $context = 'ctx'; - $serviceId = 'persistent_preference.manager.default'; + $serviceId = 'persistent.preference.manager.default'; $preference = $this->createMock(PreferenceInterface::class); $preference->method('all')->willReturn([]); $storage = $this->createMock(PreferenceStorageInterface::class); - $manager = $this->createMock(PersistentManagerInterface::class); + $manager = $this->createMock(PreferenceManagerInterface::class); $manager->method('getPreference')->with($context)->willReturn($preference); $manager->method('getPreferenceStorage')->willReturn($storage); @@ -115,7 +116,7 @@ public function testEmptyPreferencesShowsMessage(): void public function testMissingManagerServiceFails(): void { $container = $this->makeContainerMock([], [ - 'persistent_preference.manager.missing' => false, + 'persistent.preference.manager.missing' => false, ]); $command = new DebugPreferenceCommand($container); @@ -132,7 +133,7 @@ public function testMissingManagerServiceFails(): void public function testWrongServiceTypeFails(): void { - $serviceId = 'persistent_preference.manager.weird'; + $serviceId = 'persistent.preference.manager.weird'; $container = $this->makeContainerMock([ $serviceId => new \stdClass(), ]); @@ -153,7 +154,7 @@ public function testWrongServiceTypeFails(): void public function testFallbackStorageNameUsesShortClassName(): void { $context = 'c'; - $serviceId = 'persistent_preference.manager.default'; + $serviceId = 'persistent.preference.manager.default'; $preference = $this->createMock(PreferenceInterface::class); $preference->method('all')->willReturn(['a' => 1]); @@ -168,7 +169,7 @@ public function has(string $context, string $key): bool { return false; } public function all(string $context): array { return []; } }; - $manager = $this->createMock(PersistentManagerInterface::class); + $manager = $this->createMock(PreferenceManagerInterface::class); $manager->method('getPreference')->with($context)->willReturn($preference); $manager->method('getPreferenceStorage')->willReturn($customStorage); diff --git a/tests/Unit/Preference/Service/PreferenceTest.php b/tests/Unit/Preference/Service/PreferenceTest.php index c6c55cf..de05c3d 100644 --- a/tests/Unit/Preference/Service/PreferenceTest.php +++ b/tests/Unit/Preference/Service/PreferenceTest.php @@ -8,6 +8,7 @@ use Tito10047\PersistentPreferenceBundle\Event\PreferenceEvents; use Tito10047\PersistentPreferenceBundle\Preference\Service\Preference; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; +use Tito10047\PersistentPreferenceBundle\Storage\StorableEnvelope; use Tito10047\PersistentPreferenceBundle\Transformer\ValueTransformerInterface; class PreferenceTest extends TestCase @@ -27,12 +28,12 @@ public function testSetDispatchesPreThenStoresThenDispatchesPost(): void $context = 'ctx1'; $key = 'theme'; $input = 'dark'; - $transformed = 'dark_trans'; + $transformed = new StorableEnvelope("scalar",'dark_trans'); $storage = $this->createMock(PreferenceStorageInterface::class); $storage->expects(self::once()) ->method('set') - ->with($context, $key, $transformed); + ->with($context, $key, $transformed->toArray()); $dispatcher = $this->createMock(EventDispatcherInterface::class); $order = 0; @@ -91,7 +92,10 @@ public function testImportDispatchesPreForEachStoresOnceThenDispatchesPostForEac { $context = 'ctx3'; $values = ['a' => 1, 'b' => 2]; - $transformed = ['a' => '1t', 'b' => '2t']; + $transformed = [ + 'a' => (new StorableEnvelope("scalar",'1t'))->toArray(), + 'b' => (new StorableEnvelope("scalar",'2t'))->toArray() + ]; $storage = $this->createMock(PreferenceStorageInterface::class); $storage->expects(self::once()) @@ -107,7 +111,7 @@ public function testImportDispatchesPreForEachStoresOnceThenDispatchesPostForEac $transformer = $this->makeTransformer( supports: static fn($v) => true, - transform: static function ($v) { return $v . 't'; } + transform: static function ($v) { return new StorableEnvelope('scalar', $v . 't'); } ); $service = new Preference([$transformer], $context, $storage, $dispatcher); diff --git a/tests/Unit/Selection/Service/SelectionInterfaceTest.php b/tests/Unit/Selection/Service/SelectionInterfaceTest.php new file mode 100644 index 0000000..fed32d0 --- /dev/null +++ b/tests/Unit/Selection/Service/SelectionInterfaceTest.php @@ -0,0 +1,268 @@ +createMock(RequestStack::class); + $requestStack->method('getSession')->willReturn($this->mockSessionInterface()); + + $this->storage = new SelectionSessionStorage($requestStack); + + $this->normalizer = new ScalarValueTransformer(); + $this->converter = new ArrayValueTransformer(); + } + + public function testGetSelectedIdentifiersWithExcludeModeRemembersAll():void { + $selection = new Selection('test', $this->storage, $this->normalizer, $this->converter); + $selection->rememberAll([1, 2, 3]); + $selection->setMode(SelectionMode::EXCLUDE); + + /** @var SelectionInterface $selection */ + $ids = $selection->getSelectedIdentifiers(); + + $this->assertSame([1, 2, 3], $ids); + } + + public function testSelectAndIsSelected(): void + { + $selection = new Selection('ctx_select', $this->storage, $this->normalizer, $this->converter); + + // selection methods should be fluent + $chain = $selection->select(5)->select(6); + $this->assertSame($selection, $chain); + + $this->assertTrue($selection->isSelected(5)); + $this->assertTrue($selection->isSelected(6)); + $this->assertFalse($selection->isSelected(7)); + } + + public function testUnselect(): void + { + $selection = new Selection('ctx_unselect', $this->storage, $this->normalizer, $this->converter); + $selection->select(10)->select(11); + + $this->assertTrue($selection->isSelected(10)); + $this->assertTrue($selection->isSelected(11)); + + $chain = $selection->unselect(10); + $this->assertSame($selection, $chain); + + $this->assertFalse($selection->isSelected(10)); + $this->assertTrue($selection->isSelected(11)); + } + + public function testSelectMultiple(): void + { + $selection = new Selection('ctx_multi', $this->storage, $this->normalizer, $this->converter); + // intentionally pass mixed types, storage supports loose comparisons + $selection->selectMultiple([1, '2', 3]); + + /** @var SelectionInterface $selection */ + $this->assertSame([1, '2', 3], $selection->getSelectedIdentifiers()); + } + + public function testClearSelected(): void + { + $selection = new Selection('ctx_clear', $this->storage, $this->normalizer, $this->converter); + $selection->select(1)->select(2); + $this->assertSame([1, 2], $selection->getSelectedIdentifiers()); + + $chain = $selection->unselectAll(); + $this->assertSame($selection, $chain); + $this->assertSame([], $selection->getSelectedIdentifiers()); + $this->assertFalse($selection->isSelected(1)); + } + + public function testDestroyClearsAllContexts(): void + { + $selection = new Selection('ctx_destroy', $this->storage, $this->normalizer, $this->converter); + // Setup some state in both primary and __ALL__ contexts (using helper methods only for setup) + $selection->rememberAll([100, 200, 300]); + $selection->select(200)->select(400); + + // Sanity before destroy + $this->assertNotSame([], $selection->getSelectedIdentifiers()); + + $chain = $selection->destroy(); + $this->assertSame($selection, $chain); + + // After destroy, include-mode default with no identifiers + $this->assertSame([], $selection->getSelectedIdentifiers()); + $this->assertFalse($selection->isSelected(200)); + } + + public function testGetSelectedIdentifiersInIncludeMode(): void + { + $selection = new Selection('ctx_ids', $this->storage, $this->normalizer, $this->converter); + $selection->select(1)->select(2)->select(2); // duplicate should be deduped by storage + + /** @var SelectionInterface $selection */ + $this->assertSame([1, 2], $selection->getSelectedIdentifiers()); + } + + public function testSelectWithArrayMetadataAndRetrieve(): void + { + $selection = new Selection('ctx_meta_array', $this->storage, $this->normalizer, $this->converter); + $meta = ['foo' => 'bar', 'n' => 42]; + $selection->select(7, $meta); + + // getMetadata returns array when no class is requested + $this->assertSame($meta, $selection->getMetadata(7)); + + // getSelected returns id=>metadata map + $this->assertSame([7 => $meta], $selection->getSelected()); + } + + public function testSelectWithObjectMetadataAndHydration(): void + { + $selection = new Selection('ctx_meta_object', $this->storage, $this->normalizer, new SerializableObjectTransformer()); + $obj = new stdClass(); + $obj->foo = 'baz'; + $obj->num = 13; + + $selection->select(8, $obj); + + + // With class, we get hydrated stdClass + $hydrated = $selection->getMetadata(8); + $this->assertInstanceOf(stdClass::class, $hydrated); + $this->assertSame('baz', $hydrated->foo); + $this->assertSame(13, $hydrated->num); + } + + public function testSelectMultipleWithPerIdMetadata(): void + { + $selection = new Selection('ctx_meta_multi', $this->storage, $this->normalizer, $this->converter); + $items = [1, 2, 3]; + $metadataMap = [ + 1 => ['x' => 1], + 2 => ['x' => 2], + 3 => ['x' => 0] + // 3 will fallback to sharing same array if provided, here we provide a default + ]; + $selection->selectMultiple($items, $metadataMap); + + $selected = $selection->getSelected(); + $this->assertSame(['x' => 1], $selected[1]); + $this->assertSame(['x' => 2], $selected[2]); + $this->assertSame(['x' => 0], $selected[3]); + } + + public function testUpdateMetadataOverwrites(): void + { + $selection = new Selection('ctx_update', $this->storage, $this->normalizer, $this->converter); + $selection->select(55, ['a' => 1]); + + $selection->update(55, ['a' => 2, 'b' => 3]); + $this->assertSame(['a' => 2, 'b' => 3], $selection->getMetadata(55)); + } + + + public function testHasSelectionWithCacheKeyAndTtl(): void + { + $selection = new Selection('ctx_meta', $this->storage, $this->normalizer, $this->converter); + + // Initially no selection cached + $this->assertFalse($selection->hasSource('abc')); + + // Set with cache key without ttl + $selection->registerSource("abc", [10, 20]); + $this->assertTrue($selection->hasSource('abc')); + $this->assertFalse($selection->hasSource('other')); + + } + + public function testHasSourceExpiresWithIntTtl(): void + { + $selection = new Selection('ctx_meta_ttl_int', $this->storage, $this->normalizer, $this->converter); + + $this->assertFalse($selection->hasSource('src1')); + + // ttl 1 second + $selection->registerSource('src1', [1, 2, 3], 1); + $this->assertTrue($selection->hasSource('src1')); + + // wait for expiry + sleep(2); + $this->assertFalse($selection->hasSource('src1')); + } + + public function testHasSourceExpiresWithDateIntervalTtl(): void + { + $selection = new Selection('ctx_meta_ttl_interval', $this->storage, $this->normalizer, $this->converter); + + $this->assertFalse($selection->hasSource('src2')); + + $interval = new \DateInterval('PT1S'); + $selection->registerSource('src2', [4, 5], $interval); + $this->assertTrue($selection->hasSource('src2')); + + sleep(2); + $this->assertFalse($selection->hasSource('src2')); + } + public function testToggleInIncludeModeWithMetadata(): void + { + $selection = new Selection('ctx_toggle_include', $this->storage, $this->normalizer, $this->converter); + + // Initially not selected + $this->assertFalse($selection->isSelected(101)); + + // Toggle to select with metadata + $newState = $selection->toggle(101, ['qty' => 5]); + $this->assertTrue($newState); + $this->assertTrue($selection->isSelected(101)); + $this->assertSame(['qty' => 5], $selection->getMetadata(101)); + + // Toggle again to unselect + $newState = $selection->toggle(101); + $this->assertFalse($newState); + $this->assertFalse($selection->isSelected(101)); + $this->assertNull($selection->getMetadata(101)); + } + + public function testToggleInExcludeMode(): void + { + $selection = new Selection('ctx_toggle_exclude', $this->storage, $this->normalizer, $this->converter); + + // Define universe and switch to EXCLUDE => everything remembered is selected by default + $selection->rememberAll([1, 2, 3]); + $selection->setMode(SelectionMode::EXCLUDE); + + // Initially selected (not excluded yet) + $this->assertTrue($selection->isSelected(1)); + + // Toggle should unselect (i.e., add to exclusion list) + $state = $selection->toggle(1); + $this->assertFalse($state); + $this->assertFalse($selection->isSelected(1)); + + // Toggle again should select (remove from exclusion list) + $state = $selection->toggle(1); + $this->assertTrue($state); + $this->assertTrue($selection->isSelected(1)); + } +} \ No newline at end of file diff --git a/tests/Unit/Transformer/ScalarValueTransformerTest.php b/tests/Unit/Transformer/ScalarValueTransformerTest.php index 27d1622..2eca4f1 100644 --- a/tests/Unit/Transformer/ScalarValueTransformerTest.php +++ b/tests/Unit/Transformer/ScalarValueTransformerTest.php @@ -3,6 +3,7 @@ namespace Tito10047\PersistentPreferenceBundle\Tests\Unit\Transformer; use PHPUnit\Framework\TestCase; +use Tito10047\PersistentPreferenceBundle\Storage\StorableEnvelope; use Tito10047\PersistentPreferenceBundle\Transformer\ScalarValueTransformer; class ScalarValueTransformerTest extends TestCase @@ -38,7 +39,7 @@ public function testTransformIsIdentityForSupportedValues(): void { $inputs = [0, 123, 1.5, '', 'hello', true, false, null]; foreach ($inputs as $in) { - $this->assertSame($in, $this->transformer->transform($in)); + $this->assertEquals($this->getEnvelope($in), $this->transformer->transform($in)); } } @@ -46,12 +47,12 @@ public function testSupportsReverseMatchesSupports(): void { $supported = [0, 123, 1.5, '', 'hello', true, false, null]; foreach ($supported as $val) { - $this->assertTrue($this->transformer->supportsReverse($val)); + $this->assertTrue($this->transformer->supportsReverse($this->getEnvelope($val))); } - $unsupported = [[], ['a' => 1], new \stdClass(), function () {}]; + $unsupported = [[], ['a' => 1]]; foreach ($unsupported as $val) { - $this->assertFalse($this->transformer->supportsReverse($val)); + $this->assertFalse($this->transformer->supportsReverse($this->getEnvelope($val, "array"))); } } @@ -59,11 +60,15 @@ public function testReverseTransformIsIdentityForSupportedValues(): void { $inputs = [0, 123, 1.5, '', 'hello', true, false, null]; foreach ($inputs as $in) { - $this->assertSame($in, + $this->assertEquals($in, $this->transformer->reverseTransform( $this->transformer->transform($in) ) ); } } + + private function getEnvelope(mixed $data, string $className = "scalar"): StorableEnvelope { + return new StorableEnvelope($className,$data); + } } From aa029fd6c6ac99075fd90bc07c06e456d5dbc225 Mon Sep 17 00:00:00 2001 From: tito10047 Date: Fri, 5 Dec 2025 15:37:03 +0100 Subject: [PATCH 06/13] wip --- config/services.php | 10 +- .../Compiler/AutoTagValueTransformerPass.php | 15 -- src/PersistentPreferenceBundle.php | 6 +- src/Preference/Service/Preference.php | 8 +- .../config/packages/persistent.yaml | 1 + .../Service/PreferenceManagerTest.php | 1 - .../Preference/Service/PreferenceTest.php | 165 ++++++++++++++++++ 7 files changed, 182 insertions(+), 24 deletions(-) delete mode 100644 src/DependencyInjection/Compiler/AutoTagValueTransformerPass.php diff --git a/config/services.php b/config/services.php index bb85a17..6e11ab5 100644 --- a/config/services.php +++ b/config/services.php @@ -9,11 +9,14 @@ use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagContextKeyResolverPass; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagValueTransformerPass; +use Tito10047\PersistentPreferenceBundle\PersistentPreferenceBundle; use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManager; use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManagerInterface; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceSessionStorage; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; use Tito10047\PersistentPreferenceBundle\Resolver\PersistentContextResolver; +use Tito10047\PersistentPreferenceBundle\Transformer\ArrayValueTransformer; +use Tito10047\PersistentPreferenceBundle\Transformer\ObjectIdValueTransformer; use Tito10047\PersistentPreferenceBundle\Transformer\ScalarValueTransformer; use Tito10047\PersistentPreferenceBundle\Twig\PreferenceExtension; use Tito10047\PersistentPreferenceBundle\Twig\PreferenceRuntime; @@ -62,7 +65,7 @@ ->set('persistent.preference.manager.default', PreferenceManager::class) ->public() ->arg('$resolvers', tagged_iterator(AutoTagContextKeyResolverPass::TAG)) - ->arg('$transformers', tagged_iterator(AutoTagValueTransformerPass::TAG)) + ->arg('$transformers', tagged_iterator(PersistentPreferenceBundle::TRANSFORMER_TAG)) ->arg('$storage', service('persistent.preference.storage.session')) ->tag('persistent.preference.manager', ['name' => 'default']) ; @@ -74,6 +77,11 @@ ->public() ->tag('twig.extension') ; + foreach([ArrayValueTransformer::class, ScalarValueTransformer::class] as $class){ + $services->set($class) + ->public() + ->tag(PersistentPreferenceBundle::TRANSFORMER_TAG); + } // --- Twig Runtime --- $services diff --git a/src/DependencyInjection/Compiler/AutoTagValueTransformerPass.php b/src/DependencyInjection/Compiler/AutoTagValueTransformerPass.php deleted file mode 100644 index 4c88fc9..0000000 --- a/src/DependencyInjection/Compiler/AutoTagValueTransformerPass.php +++ /dev/null @@ -1,15 +0,0 @@ -import('../config/definition.php'); @@ -44,7 +42,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C ->set('persistent.preference.manager.' . $name, PreferenceManager::class) ->public() ->arg('$resolvers', tagged_iterator(AutoTagContextKeyResolverPass::TAG)) - ->arg('$transformers', tagged_iterator(AutoTagValueTransformerPass::TAG)) + ->arg('$transformers', tagged_iterator(self::TRANSFORMER_TAG)) ->arg('$storage', service($storage)) ->arg('$dispatcher', service('event_dispatcher')) ->tag('persistent_preference.manager', ['name' => $name]); diff --git a/src/Preference/Service/Preference.php b/src/Preference/Service/Preference.php index cb8d677..0058edd 100644 --- a/src/Preference/Service/Preference.php +++ b/src/Preference/Service/Preference.php @@ -6,6 +6,7 @@ use Tito10047\PersistentPreferenceBundle\Event\PreferenceEvent; use Tito10047\PersistentPreferenceBundle\Event\PreferenceEvents; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; +use Tito10047\PersistentPreferenceBundle\Storage\StorableEnvelope; use Tito10047\PersistentPreferenceBundle\Transformer\ValueTransformerInterface; /** @@ -157,17 +158,18 @@ private function applyTransform(mixed $value): mixed } } - return $value; // fallback: store as-is + throw new \RuntimeException("No transformer found for value of type " . gettype($value)); } - private function applyReverseTransform(mixed $value): mixed + private function applyReverseTransform(array $value): mixed { + $value = StorableEnvelope::fromArray($value); foreach ($this->transformers as $transformer) { if ($transformer->supportsReverse($value)) { return $transformer->reverseTransform($value); } } - return $value; // fallback: return as-is + throw new \RuntimeException("No reverse transformer found for value of type " . $value->className); } } diff --git a/tests/App/AssetMapper/config/packages/persistent.yaml b/tests/App/AssetMapper/config/packages/persistent.yaml index bfdcafd..42ce1ba 100644 --- a/tests/App/AssetMapper/config/packages/persistent.yaml +++ b/tests/App/AssetMapper/config/packages/persistent.yaml @@ -28,6 +28,7 @@ persistent: storage: '@persistent.preference.storage.session' my_pref_manager: storage: '@app.storage.doctrine' + selection: managers: default: diff --git a/tests/Integration/Preference/Service/PreferenceManagerTest.php b/tests/Integration/Preference/Service/PreferenceManagerTest.php index f47719b..fc054bb 100644 --- a/tests/Integration/Preference/Service/PreferenceManagerTest.php +++ b/tests/Integration/Preference/Service/PreferenceManagerTest.php @@ -10,7 +10,6 @@ use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManagerInterface; use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceInterface; use Tito10047\PersistentPreferenceBundle\Service\PersistentContextInterface; -use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; class PreferenceManagerTest extends AssetMapperKernelTestCase diff --git a/tests/Unit/Preference/Service/PreferenceTest.php b/tests/Unit/Preference/Service/PreferenceTest.php index de05c3d..de2e06d 100644 --- a/tests/Unit/Preference/Service/PreferenceTest.php +++ b/tests/Unit/Preference/Service/PreferenceTest.php @@ -9,6 +9,7 @@ use Tito10047\PersistentPreferenceBundle\Preference\Service\Preference; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; use Tito10047\PersistentPreferenceBundle\Storage\StorableEnvelope; +use Tito10047\PersistentPreferenceBundle\Transformer\ScalarValueTransformer; use Tito10047\PersistentPreferenceBundle\Transformer\ValueTransformerInterface; class PreferenceTest extends TestCase @@ -23,6 +24,16 @@ private function makeTransformer(callable $supports, callable $transform, ?calla return $tr; } + public function testGetContextReturnsProvidedContext(): void + { + $context = 'user_42'; + $storage = $this->createMock(PreferenceStorageInterface::class); + $dispatcher = $this->createMock(EventDispatcherInterface::class); + + $service = new Preference([], $context, $storage, $dispatcher); + self::assertSame($context, $service->getContext()); + } + public function testSetDispatchesPreThenStoresThenDispatchesPost(): void { $context = 'ctx1'; @@ -141,4 +152,158 @@ public function testImportStopsOnAnyPreEvent(): void $service = new Preference([], $context, $storage, $dispatcher); $service->import($values); } + + public function testGetReturnsDefaultWithoutReverseTransform(): void + { + $context = 'ctx5'; + $key = 'missing'; + $default = 'DEF'; + + $storage = $this->createMock(PreferenceStorageInterface::class); + // storage returns exactly the default value + $storage->expects(self::once()) + ->method('get') + ->with($context, $key, $default) + ->willReturn($default); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $service = new Preference([], $context, $storage, $dispatcher); + + self::assertSame($default, $service->get($key, $default)); + } + + public function testGetAppliesReverseTransformWhenSupported(): void + { + $context = 'ctx6'; + $key = 'obj'; + $stored = (new StorableEnvelope('scalar', 'raw'))->toArray(); + + $storage = $this->createMock(PreferenceStorageInterface::class); + $storage->expects(self::once()) + ->method('get') + ->with($context, $key, null) + ->willReturn($stored); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $transformer = $this->makeTransformer( + supports: static fn($v) => false, + transform: static fn($v) => new StorableEnvelope('scalar', $v), + supportsReverse: static fn(StorableEnvelope $v) => $v->className=='scalar', + reverseTransform: static fn(StorableEnvelope $v) => $v->data.'_rt', + ); + + $service = new Preference([$transformer], $context, $storage, $dispatcher); + self::assertSame('raw_rt', $service->get($key)); + } + + public function testGetIntCastsValuesProperly(): void + { + $context = 'ctx7'; + $storage = $this->createMock(PreferenceStorageInterface::class); + + $transformer = new ScalarValueTransformer(); + $map = [ + // context, key, default => return + [$context, 'i1', 0, $transformer->transform(7)->toArray()], + [$context, 'i2', 0, $transformer->transform('15')->toArray()], + [$context, 'i3', 5, $transformer->transform('no-number')->toArray()], + ]; + $storage->method('get')->willReturnMap($map); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $service = new Preference([$transformer], $context, $storage, $dispatcher); + + self::assertSame(7, $service->getInt('i1')); + self::assertSame(15, $service->getInt('i2')); + self::assertSame(5, $service->getInt('i3', 5)); + } + + public function testHasDelegatesToStorage(): void + { + $context = 'ctx9'; + $key = 'exists'; + $storage = $this->createMock(PreferenceStorageInterface::class); + $storage->expects(self::once()) + ->method('has') + ->with($context, $key) + ->willReturn(true); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $service = new Preference([], $context, $storage, $dispatcher); + self::assertTrue($service->has($key)); + } + + public function testRemoveDelegatesToStorageAndIsFluent(): void + { + $context = 'ctx10'; + $key = 'to_remove'; + $storage = $this->createMock(PreferenceStorageInterface::class); + $storage->expects(self::once()) + ->method('remove') + ->with($context, $key); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $service = new Preference([], $context, $storage, $dispatcher); + + self::assertSame($service, $service->remove($key)); + } + + public function testAllAppliesReverseTransformToEachValue(): void + { + $context = 'ctx11'; + $raw = [ + 'a' => (new StorableEnvelope('scalar', '1'))->toArray(), + 'b' => (new StorableEnvelope('scalar', '2'))->toArray(), + ]; + + $storage = $this->createMock(PreferenceStorageInterface::class); + $storage->expects(self::once()) + ->method('all') + ->with($context) + ->willReturn($raw); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $transformer = $this->makeTransformer( + supports: static fn($v) => false, + transform: static fn($v) => new StorableEnvelope('scalar', $v), + supportsReverse: static fn(StorableEnvelope $v) => $v->className === 'scalar', + reverseTransform: static fn(StorableEnvelope $v) => 'rt-'.$v->data, + ); + + $service = new Preference([$transformer], $context, $storage, $dispatcher); + self::assertSame(['a' => 'rt-1', 'b' => 'rt-2'], $service->all()); + } + + public function testSetThenGetRoundtripReturnsSameLogicalValue(): void + { + $context = 'ctx12'; + $key = 'round'; + $input = ['x' => 1]; + + $storedValue = null; + $storage = $this->createMock(PreferenceStorageInterface::class); + $storage->expects(self::once()) + ->method('set') + ->with($context, $key, self::isType('array')) + ->willReturnCallback(function ($ctx, $k, $val) use (&$storedValue) { $storedValue = $val; }); + $storage->expects(self::once()) + ->method('get') + ->with($context, $key, null) + ->willReturnCallback(function () use (&$storedValue) { return $storedValue; }); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + // Identity forward transform into envelope and exact reverse + $transformer = $this->makeTransformer( + supports: static fn($v) => is_array($v), + transform: static fn($v) => new StorableEnvelope('json', json_encode($v)), + supportsReverse: static fn(StorableEnvelope $v) => $v->className=='json', + reverseTransform: static fn(StorableEnvelope $v) => json_decode($v->data, true), + ); + + $service = new Preference([$transformer], $context, $storage, $dispatcher); + $service->set($key, $input); + $out = $service->get($key); + + self::assertSame($input, $out); + } } From 8a2ca8f5790449dd3cfbc6d140f518d6f9d89d0e Mon Sep 17 00:00:00 2001 From: tito10047 Date: Fri, 5 Dec 2025 16:45:13 +0100 Subject: [PATCH 07/13] wip --- config/definition.php | 10 ++- config/services.php | 50 ++++++++--- .../Compiler/AutoTagIdentityLoadersPass.php | 62 +++++++++++++ src/PersistentPreferenceBundle.php | 20 +++++ src/Selection/Loader/ArrayLoader.php | 6 +- .../Loader/DoctrineCollectionLoader.php | 6 +- .../Loader/DoctrineQueryBuilderLoader.php | 17 ++-- src/Selection/Loader/DoctrineQueryLoader.php | 5 +- .../Loader/IdentityLoaderInterface.php | 5 +- src/Selection/Normalizer/ArrayNormalizer.php | 24 ----- .../IdentifierNormalizerInterface.php | 27 ------ src/Selection/Normalizer/ObjectNormalizer.php | 38 -------- src/Selection/Normalizer/ScalarNormalizer.php | 20 ----- src/Selection/Service/Selection.php | 34 +++---- src/Selection/Service/SelectionManager.php | 65 ++++++++++++++ ...face.php => SelectionManagerInterface.php} | 2 +- src/Service/PersistentContextInterface.php | 8 -- tests/App/AssetMapper/Src/ServiceHelper.php | 9 +- .../App/AssetMapper/Src/Support/TestList.php | 28 ++++++ .../config/packages/persistent.yaml | 13 ++- .../Service/PreferenceManagerTest.php | 20 ----- .../Selection/SelectionManagerTest.php | 88 +++++++++++++++++++ .../Unit/Selection/Loader/ArrayLoaderTest.php | 30 ------- .../Loader/DoctrineQueryBuilderLoaderTest.php | 15 ++-- .../Loader/DoctrineQueryLoaderTest.php | 6 +- .../Normalizer/ObjectNormalizerTest.php | 79 ----------------- .../Normalizer/ScalarNormalizerTest.php | 48 ---------- .../Service/SelectionInterfaceTest.php | 4 - 28 files changed, 372 insertions(+), 367 deletions(-) create mode 100644 src/DependencyInjection/Compiler/AutoTagIdentityLoadersPass.php delete mode 100644 src/Selection/Normalizer/ArrayNormalizer.php delete mode 100644 src/Selection/Normalizer/IdentifierNormalizerInterface.php delete mode 100644 src/Selection/Normalizer/ObjectNormalizer.php delete mode 100644 src/Selection/Normalizer/ScalarNormalizer.php create mode 100644 src/Selection/Service/SelectionManager.php rename src/Selection/Service/{PreconfiguredSelectionInterface.php => SelectionManagerInterface.php} (87%) delete mode 100644 src/Service/PersistentContextInterface.php create mode 100644 tests/App/AssetMapper/Src/Support/TestList.php create mode 100644 tests/Integration/Selection/SelectionManagerTest.php delete mode 100644 tests/Unit/Selection/Loader/ArrayLoaderTest.php delete mode 100644 tests/Unit/Selection/Normalizer/ObjectNormalizerTest.php delete mode 100644 tests/Unit/Selection/Normalizer/ScalarNormalizerTest.php diff --git a/config/definition.php b/config/definition.php index ff7cb21..63acf84 100644 --- a/config/definition.php +++ b/config/definition.php @@ -35,15 +35,21 @@ ->arrayPrototype() ->children() ->scalarNode('storage') - ->isRequired() + ->defaultValue('@persistent.selection.storage.session') ->cannotBeEmpty() ->info('The service ID of the storage backend to use (e.g. "@app.storage.doctrine").') ->end() - ->scalarNode('resolver') + ->scalarNode('transformer') + ->isRequired() + ->cannotBeEmpty() + ->info('') + ->end() + ->scalarNode('metadata_transformer') ->isRequired() ->cannotBeEmpty() ->info('') ->end() + ->integerNode('ttl')->defaultNull()->min(0)->end() ->end() ->end() ->end() diff --git a/config/services.php b/config/services.php index 6e11ab5..e2a8848 100644 --- a/config/services.php +++ b/config/services.php @@ -8,15 +8,20 @@ use Tito10047\PersistentPreferenceBundle\Converter\ObjectVarsConverter; use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagContextKeyResolverPass; +use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagIdentityLoadersPass; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagValueTransformerPass; use Tito10047\PersistentPreferenceBundle\PersistentPreferenceBundle; use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManager; use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManagerInterface; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceSessionStorage; use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; -use Tito10047\PersistentPreferenceBundle\Resolver\PersistentContextResolver; +use Tito10047\PersistentPreferenceBundle\Selection\Loader\ArrayLoader; +use Tito10047\PersistentPreferenceBundle\Selection\Loader\DoctrineCollectionLoader; +use Tito10047\PersistentPreferenceBundle\Selection\Loader\DoctrineQueryBuilderLoader; +use Tito10047\PersistentPreferenceBundle\Selection\Loader\DoctrineQueryLoader; +use Tito10047\PersistentPreferenceBundle\Selection\Storage\SelectionSessionStorage; +use Tito10047\PersistentPreferenceBundle\Selection\Storage\SelectionStorageInterface; use Tito10047\PersistentPreferenceBundle\Transformer\ArrayValueTransformer; -use Tito10047\PersistentPreferenceBundle\Transformer\ObjectIdValueTransformer; use Tito10047\PersistentPreferenceBundle\Transformer\ScalarValueTransformer; use Tito10047\PersistentPreferenceBundle\Twig\PreferenceExtension; use Tito10047\PersistentPreferenceBundle\Twig\PreferenceRuntime; @@ -41,11 +46,6 @@ // Alias the interface to our concrete storage service id $services->alias(PreferenceStorageInterface::class, 'persistent.preference.storage.session'); - // --- Built-in Resolvers --- - $services - ->set(PersistentContextResolver::class) - ->public() - ; // --- Built-in Value Transformers --- $services @@ -77,11 +77,13 @@ ->public() ->tag('twig.extension') ; - foreach([ArrayValueTransformer::class, ScalarValueTransformer::class] as $class){ - $services->set($class) - ->public() - ->tag(PersistentPreferenceBundle::TRANSFORMER_TAG); - } + + $services->set(ArrayValueTransformer::class) + ->public() + ->tag(PersistentPreferenceBundle::TRANSFORMER_TAG); + $services->set("persistent.transformer.scalar",ScalarValueTransformer::class) + ->public() + ->tag(PersistentPreferenceBundle::TRANSFORMER_TAG); // --- Twig Runtime --- $services @@ -90,7 +92,26 @@ ->arg('$preferenceManager', service(PreferenceManagerInterface::class)) ->tag('twig.runtime') ; +// --- Loadery --- + $services + ->set(ArrayLoader::class) + ->tag(AutoTagIdentityLoadersPass::TAG) + ; + + $services + ->set(DoctrineCollectionLoader::class) + ->tag(AutoTagIdentityLoadersPass::TAG) + ; + + $services + ->set(DoctrineQueryLoader::class) + ->tag(AutoTagIdentityLoadersPass::TAG) + ; + $services + ->set(DoctrineQueryBuilderLoader::class) + ->tag(AutoTagIdentityLoadersPass::TAG) + ; // --- Console Command --- $services ->set(DebugPreferenceCommand::class) @@ -98,6 +119,11 @@ ->arg('$container', service('service_container')) ->tag('console.command') ; + $services + ->set('persistent.selection.storage.session', SelectionSessionStorage::class) + ->arg('$requestStack', service(RequestStack::class)) + ; + $services->alias(SelectionStorageInterface::class, SelectionSessionStorage::class); // --- Data Collector --- // Register only when WebProfiler is installed AND Symfony debug is enabled diff --git a/src/DependencyInjection/Compiler/AutoTagIdentityLoadersPass.php b/src/DependencyInjection/Compiler/AutoTagIdentityLoadersPass.php new file mode 100644 index 0000000..88404e2 --- /dev/null +++ b/src/DependencyInjection/Compiler/AutoTagIdentityLoadersPass.php @@ -0,0 +1,62 @@ +getParameterBag(); + + /** @var array $definitions */ + $definitions = $container->getDefinitions(); + + foreach ($definitions as $id => $definition) { + // Skip non-instantiable or special definitions + if ($definition->isAbstract() || $definition->isSynthetic()) { + continue; + } + + // If it already has the tag, skip (idempotent) + if ($definition->hasTag(self::TAG)) { + continue; + } + + // Try to resolve the class name + $class = $definition->getClass() ?: $id; // FQCN service id fallback + if (!is_string($class) || $class === '') { + continue; + } + + // Resolve parameters like "%foo.class%" + $class = $parameterBag->resolveValue($class); + if (!is_string($class)) { + continue; + } + + // Safe reflection via container helper + $reflection = $container->getReflectionClass($class, false); + if (!$reflection) { + continue; + } + + if ($reflection->implementsInterface(IdentityLoaderInterface::class)) { + $definition->addTag(self::TAG)->setPublic(true); + } + } + } +} diff --git a/src/PersistentPreferenceBundle.php b/src/PersistentPreferenceBundle.php index ad02d95..3a89b18 100644 --- a/src/PersistentPreferenceBundle.php +++ b/src/PersistentPreferenceBundle.php @@ -9,8 +9,10 @@ use Tito10047\PersistentPreferenceBundle\Converter\MetadataConverterInterface; use Tito10047\PersistentPreferenceBundle\Converter\ObjectVarsConverter; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagContextKeyResolverPass; +use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagIdentityLoadersPass; use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\TraceableManagersPass; use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManager; +use Tito10047\PersistentPreferenceBundle\Selection\Service\SelectionManager; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; @@ -47,11 +49,29 @@ public function loadExtension(array $config, ContainerConfigurator $container, C ->arg('$dispatcher', service('event_dispatcher')) ->tag('persistent_preference.manager', ['name' => $name]); } + + $configManagers = $config['selection']['managers'] ?? []; + foreach($configManagers as $name=>$subConfig){ + $storage = service(ltrim($subConfig['storage'], '@')); + $transformer = service(ltrim($subConfig['transformer'], '@')??null); + $metadataTransformer = service(ltrim($subConfig['metadata_transformer'], '@')??null); + $ttl = $subConfig['ttl']??null; + $services + ->set('persistent.selection.manager.'.$name,SelectionManager::class) + ->public() + ->arg('$storage', $storage) + ->arg('$transformer', $transformer) + ->arg('$metadataTransformer', $metadataTransformer) + ->arg('$loaders', tagged_iterator(AutoTagIdentityLoadersPass::TAG)) + ->arg('$ttl', $ttl) + ; + } } public function build(ContainerBuilder $container): void { parent::build($container); $container->addCompilerPass(new AutoTagContextKeyResolverPass()); + $container->addCompilerPass(new AutoTagIdentityLoadersPass()); $container->addCompilerPass(new TraceableManagersPass()); } } \ No newline at end of file diff --git a/src/Selection/Loader/ArrayLoader.php b/src/Selection/Loader/ArrayLoader.php index 02384b8..a6901e7 100644 --- a/src/Selection/Loader/ArrayLoader.php +++ b/src/Selection/Loader/ArrayLoader.php @@ -4,7 +4,7 @@ use InvalidArgumentException; -use Tito10047\PersistentPreferenceBundle\Selection\Normalizer\IdentifierNormalizerInterface; +use Tito10047\PersistentPreferenceBundle\Transformer\ValueTransformerInterface; final class ArrayLoader implements IdentityLoaderInterface { @@ -14,7 +14,7 @@ public function supports(mixed $source): bool return is_array($source); } - public function loadAllIdentifiers(?IdentifierNormalizerInterface $resolver, mixed $source, ?string $identifierPath): array + public function loadAllIdentifiers(?ValueTransformerInterface $transformer, mixed $source): array { if (!is_array($source)) { throw new InvalidArgumentException('Source must be an array.'); @@ -23,7 +23,7 @@ public function loadAllIdentifiers(?IdentifierNormalizerInterface $resolver, mix $identifiers = []; foreach ($source as $item) { - $identifiers[] = $resolver->normalize($item, $identifierPath); + $identifiers[] = $transformer->transform($item)->data; } return $identifiers; diff --git a/src/Selection/Loader/DoctrineCollectionLoader.php b/src/Selection/Loader/DoctrineCollectionLoader.php index 9bf7892..e3cf8c2 100644 --- a/src/Selection/Loader/DoctrineCollectionLoader.php +++ b/src/Selection/Loader/DoctrineCollectionLoader.php @@ -5,6 +5,7 @@ use Doctrine\Common\Collections\Collection; use InvalidArgumentException; use Tito10047\PersistentPreferenceBundle\Selection\Normalizer\IdentifierNormalizerInterface; +use Tito10047\PersistentPreferenceBundle\Transformer\ValueTransformerInterface; /** * Loader responsible for extracting identifiers from Doctrine Collection objects. @@ -35,11 +36,10 @@ public function getTotalCount(mixed $source): int } /** - * @param string $identifierPath * * * @inheritDoc */ - public function loadAllIdentifiers(?IdentifierNormalizerInterface $resolver, mixed $source, ?string $identifierPath): array + public function loadAllIdentifiers(?ValueTransformerInterface $transformer, mixed $source): array { if (!$this->supports($source)) { throw new InvalidArgumentException('Source must be a Doctrine Collection.'); @@ -49,7 +49,7 @@ public function loadAllIdentifiers(?IdentifierNormalizerInterface $resolver, mix $identifiers = []; foreach ($source as $item) { - $identifiers[] = $resolver->normalize($item, $identifierPath); + $identifiers[] = $transformer->transform($item)->data; } return $identifiers; diff --git a/src/Selection/Loader/DoctrineQueryBuilderLoader.php b/src/Selection/Loader/DoctrineQueryBuilderLoader.php index 4aa18cf..56e1724 100644 --- a/src/Selection/Loader/DoctrineQueryBuilderLoader.php +++ b/src/Selection/Loader/DoctrineQueryBuilderLoader.php @@ -9,7 +9,10 @@ use Exception; use InvalidArgumentException; use RuntimeException; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Tito10047\PersistentPreferenceBundle\Selection\Normalizer\IdentifierNormalizerInterface; +use Tito10047\PersistentPreferenceBundle\Transformer\ObjectIdValueTransformer; +use Tito10047\PersistentPreferenceBundle\Transformer\ValueTransformerInterface; /** * Loader pre Doctrine QueryBuilder. @@ -17,9 +20,6 @@ */ final class DoctrineQueryBuilderLoader implements IdentityLoaderInterface { - public function __construct( - private IdentifierNormalizerInterface $arrayNormalizer - ) { } public function supports(mixed $source): bool { @@ -28,9 +28,10 @@ public function supports(mixed $source): bool /** * @param QueryBuilder $source + * * @return array */ - public function loadAllIdentifiers(?IdentifierNormalizerInterface $resolver, mixed $source, ?string $identifierPath): array + public function loadAllIdentifiers(?ValueTransformerInterface $transformer, mixed $source): array { if (!$this->supports($source)) { throw new InvalidArgumentException('Source must be a Doctrine QueryBuilder instance.'); @@ -58,7 +59,7 @@ public function loadAllIdentifiers(?IdentifierNormalizerInterface $resolver, mix } $defaultIdField = $identifierFields[0]; - $identifierField = ($identifierPath !== null && $identifierPath !== '') ? $identifierPath : $defaultIdField; + $identifierField = $defaultIdField; // prepis SELECT, ostatné časti dotazu (WHERE, JOIN, GROUP BY, HAVING, ORDER BY) ponechaj $baseQb->resetDQLPart('select'); @@ -69,8 +70,10 @@ public function loadAllIdentifiers(?IdentifierNormalizerInterface $resolver, mix $baseQb->setMaxResults(null); $rows = $baseQb->getQuery()->getResult(); - return array_map(function($item)use($identifierPath){ - return $this->arrayNormalizer->normalize($item, $identifierPath); + + + return array_map(function($item) use($identifierField){ + return $item[$identifierField]; }, $rows); } diff --git a/src/Selection/Loader/DoctrineQueryLoader.php b/src/Selection/Loader/DoctrineQueryLoader.php index 401d35b..88b7731 100644 --- a/src/Selection/Loader/DoctrineQueryLoader.php +++ b/src/Selection/Loader/DoctrineQueryLoader.php @@ -10,6 +10,7 @@ use InvalidArgumentException; use RuntimeException; use Tito10047\PersistentPreferenceBundle\Selection\Normalizer\IdentifierNormalizerInterface; +use Tito10047\PersistentPreferenceBundle\Transformer\ValueTransformerInterface; /** * Loader responsible for extracting identifiers and counts from a Doctrine ORM Query object. @@ -32,7 +33,7 @@ public function supports(mixed $source): bool { * * @return array */ - public function loadAllIdentifiers(?IdentifierNormalizerInterface $resolver, mixed $source, ?string $identifierPath): array { + public function loadAllIdentifiers(?ValueTransformerInterface $transformer, mixed $source): array { if (!$this->supports($source)) { throw new InvalidArgumentException('Source must be a Doctrine Query instance.'); } @@ -52,7 +53,7 @@ public function loadAllIdentifiers(?IdentifierNormalizerInterface $resolver, mix } $defaultIdField = $identifierFields[0]; - $identifierField = ($identifierPath !== null && $identifierPath !== '') ? $identifierPath : $defaultIdField; + $identifierField = $defaultIdField; $dql = $baseQuery->getDQL(); $posFrom = stripos($dql, ' from '); diff --git a/src/Selection/Loader/IdentityLoaderInterface.php b/src/Selection/Loader/IdentityLoaderInterface.php index c805ef1..fd8af90 100644 --- a/src/Selection/Loader/IdentityLoaderInterface.php +++ b/src/Selection/Loader/IdentityLoaderInterface.php @@ -3,12 +3,11 @@ namespace Tito10047\PersistentPreferenceBundle\Selection\Loader; - -use Tito10047\PersistentPreferenceBundle\Selection\Normalizer\IdentifierNormalizerInterface; +use Tito10047\PersistentPreferenceBundle\Transformer\ValueTransformerInterface; interface IdentityLoaderInterface { - public function loadAllIdentifiers(?IdentifierNormalizerInterface $resolver, mixed $source, ?string $identifierPath): array; + public function loadAllIdentifiers(?ValueTransformerInterface $transformer, mixed $source): array; public function getTotalCount(mixed $source): int; diff --git a/src/Selection/Normalizer/ArrayNormalizer.php b/src/Selection/Normalizer/ArrayNormalizer.php deleted file mode 100644 index f768f69..0000000 --- a/src/Selection/Normalizer/ArrayNormalizer.php +++ /dev/null @@ -1,24 +0,0 @@ -isReadable($item, $identifierPath)) { - throw new RuntimeException(sprintf( - 'Cannot read identifier "%s" from object of type "%s".', - $identifierPath, get_debug_type($item) - )); - } - - $value = $accessor->getValue($item, $identifierPath); - - if (is_object($value) && method_exists($value, '__toString')) { - return (string) $value; - } - - if (is_scalar($value)) { - return $value; - } - - throw new RuntimeException('Extracted value is not a scalar.'); - } -} \ No newline at end of file diff --git a/src/Selection/Normalizer/ScalarNormalizer.php b/src/Selection/Normalizer/ScalarNormalizer.php deleted file mode 100644 index 994c826..0000000 --- a/src/Selection/Normalizer/ScalarNormalizer.php +++ /dev/null @@ -1,20 +0,0 @@ -normalizer->transform($item); + $id = is_scalar($item) ? $item : $this->transformer->transform($item); $mode = $this->storage->getMode($this->key); $metaArray = null; if ($metadata !== null) { - $metaArray = $this->metadataConverter->transform($metadata)->toArray(); + $metaArray = $this->metadataTransformer->transform($metadata)->toArray(); } if ($mode === SelectionMode::INCLUDE) { // SessionStorage::add now expects a map [id => metadata] @@ -40,7 +40,7 @@ public function select(mixed $item, null|array|object $metadata = null): static } public function unselect(mixed $item): static { - $id = is_scalar($item) ? $item : $this->normalizer->transform($item); + $id = is_scalar($item) ? $item : $this->transformer->transform($item); if ($this->storage->getMode($this->key) === SelectionMode::INCLUDE) { $this->storage->remove($this->key, [$id]); } else { @@ -59,13 +59,13 @@ public function selectMultiple(array $items, null|array $metadata = null): stati if ($metadata === null) { $ids = []; foreach ($items as $item) { - $ids[] = is_scalar($item) ? $item : $this->normalizer->transform($item); + $ids[] = is_scalar($item) ? $item : $this->transformer->transform($item); } $this->storage->add($this->key, $ids, null); return $this; } foreach ($items as $item) { - $id = is_scalar($item) ? $item : $this->normalizer->transform($item); + $id = is_scalar($item) ? $item : $this->transformer->transform($item); $metaForId = null; if (array_key_exists($id, $metadata) || array_key_exists((string) $id, $metadata)) { @@ -75,7 +75,7 @@ public function selectMultiple(array $items, null|array $metadata = null): stati } // Convert object metadata and pass as [id => meta] - $metaForId = $this->metadataConverter->transform($metaForId)->toArray(); + $metaForId = $this->metadataTransformer->transform($metaForId)->toArray(); $this->storage->add($this->key, [$id], [$id => $metaForId]); } return $this; @@ -84,7 +84,7 @@ public function selectMultiple(array $items, null|array $metadata = null): stati public function unselectMultiple(array $items): static { $ids = []; foreach ($items as $item) { - $ids[] = is_scalar($item) ? $item : $this->normalizer->transform($item); + $ids[] = is_scalar($item) ? $item : $this->transformer->transform($item); } $this->storage->remove($this->key, $ids); return $this; @@ -125,11 +125,11 @@ public function getSelectedIdentifiers(): array { } public function update(mixed $item, object|array|null $metadata = null): static { - $id = is_scalar($item) ? $item : $this->normalizer->transform($item); + $id = is_scalar($item) ? $item : $this->transformer->transform($item); if ($metadata === null) { return $this; // nothing to update } - $metaArray = $this->metadataConverter->transform($metadata)->toArray(); + $metaArray = $this->metadataTransformer->transform($metadata)->toArray(); $mode = $this->storage->getMode($this->key); if ($mode === SelectionMode::INCLUDE) { @@ -150,7 +150,7 @@ public function getSelected(): array { $map = $this->storage->getStoredWithMetadata($this->key); $hydrated = []; foreach ($map as $id => $meta) { - $hydrated[$id] = $this->metadataConverter->reverseTransform(StorableEnvelope::fromArray($meta)); + $hydrated[$id] = $this->metadataTransformer->reverseTransform(StorableEnvelope::fromArray($meta)); } return $hydrated; } @@ -162,7 +162,7 @@ public function getSelected(): array { foreach ($selected as $id) { $meta = $this->storage->getMetadata($this->key, $id); if ($metadataClass !== null) { - $result[$id] = $this->metadataConverter->reverseTransform($meta); + $result[$id] = $this->metadataTransformer->reverseTransform($meta); } else { $result[$id] = $meta; } @@ -171,14 +171,14 @@ public function getSelected(): array { } public function getMetadata(mixed $item): null|array|object { - $id = is_scalar($item) ? $item : $this->normalizer->transform($item); + $id = is_scalar($item) ? $item : $this->transformer->transform($item); $meta = $this->storage->getMetadata($this->key, $id); if ($meta === [] || $meta === null) { return null; } $meta = StorableEnvelope::fromArray($meta); - if ($this->metadataConverter->supportsReverse($meta)) { - return $this->metadataConverter->reverseTransform($meta); + if ($this->metadataTransformer->supportsReverse($meta)) { + return $this->metadataTransformer->reverseTransform($meta); } return $meta; } @@ -220,7 +220,7 @@ public function getTotal(): int { } public function normalize(mixed $item): int|string { - return $this->normalizer->transform($item); + return $this->transformer->transform($item); } diff --git a/src/Selection/Service/SelectionManager.php b/src/Selection/Service/SelectionManager.php new file mode 100644 index 0000000..571e409 --- /dev/null +++ b/src/Selection/Service/SelectionManager.php @@ -0,0 +1,65 @@ +findLoader($source); + + $selection = new Selection( + $namespace, + $this->storage, + $this->transformer, + $this->metadataTransformer, + ); + + foreach ($source as $item) { + if (!$this->transformer->supports($item)) { + throw new \InvalidArgumentException(sprintf('Item of type "%s" is not supported.', gettype($item))); + } + } + $cacheKey = $loader->getCacheKey($source); + if (!$selection->hasSource($cacheKey)) { + $selection->registerSource($cacheKey, + $loader->loadAllIdentifiers($this->transformer, $source), + $ttl?? $this->ttl + ); + } + + return $selection; + } + + public function getSelection(string $namespace, mixed $owner = null): SelectionInterface { + return new Selection($namespace, $this->storage, $this->transformer, $this->metadataTransformer); + } + + private function findLoader(mixed $source): IdentityLoaderInterface { + $loader = null; + foreach ($this->loaders as $_loader) { + if ($_loader->supports($source)) { + $loader = $_loader; + break; + } + } + if ($loader === null) { + throw new \InvalidArgumentException('No suitable loader found for the given source.'); + } + return $loader; + } + + +} \ No newline at end of file diff --git a/src/Selection/Service/PreconfiguredSelectionInterface.php b/src/Selection/Service/SelectionManagerInterface.php similarity index 87% rename from src/Selection/Service/PreconfiguredSelectionInterface.php rename to src/Selection/Service/SelectionManagerInterface.php index b1fd493..cd5226b 100644 --- a/src/Selection/Service/PreconfiguredSelectionInterface.php +++ b/src/Selection/Service/SelectionManagerInterface.php @@ -2,7 +2,7 @@ namespace Tito10047\PersistentPreferenceBundle\Selection\Service; -interface PreconfiguredSelectionInterface +interface SelectionManagerInterface { public function registerSelection(string $namespace, mixed $source, int|\DateInterval|null $ttl = null): SelectionInterface; diff --git a/src/Service/PersistentContextInterface.php b/src/Service/PersistentContextInterface.php deleted file mode 100644 index 03c4ee4..0000000 --- a/src/Service/PersistentContextInterface.php +++ /dev/null @@ -1,8 +0,0 @@ -> */ + private array $items; + + /** + * @param array> $items + */ + public function __construct(array $items) + { + $this->items = $items; + } + + /** + * @return array> + */ + public function all(): array + { + return $this->items; + } +} diff --git a/tests/App/AssetMapper/config/packages/persistent.yaml b/tests/App/AssetMapper/config/packages/persistent.yaml index 42ce1ba..34de229 100644 --- a/tests/App/AssetMapper/config/packages/persistent.yaml +++ b/tests/App/AssetMapper/config/packages/persistent.yaml @@ -33,7 +33,12 @@ persistent: managers: default: storage: '@persistent.selection.storage.session' - resolver: '@app.transformer.id' - simple: - storage: '@persistent.selection.storage.doctrine' - resolver: '@app.transformer.id' + transformer: '@app.transformer.id' + metadata_transformer: '@app.transformer.id' + ttl: 3600 + scalar: + transformer: '@persistent.transformer.scalar' + metadata_transformer: '@app.transformer.id' + array: + transformer: '@persistent.transformer.scalar' + metadata_transformer: '@app.transformer.id' diff --git a/tests/Integration/Preference/Service/PreferenceManagerTest.php b/tests/Integration/Preference/Service/PreferenceManagerTest.php index fc054bb..95dae99 100644 --- a/tests/Integration/Preference/Service/PreferenceManagerTest.php +++ b/tests/Integration/Preference/Service/PreferenceManagerTest.php @@ -50,26 +50,6 @@ public function testReturnsPreferenceForStringContextAndPersists(): void $this->assertFalse($pref2->has('limit')); } - public function testResolvesObjectContextViaPersistentContextInterface(): void - { - static::bootKernel(); - $this->ensureSession(); - $pm = static::getContainer()->get(PreferenceManagerInterface::class); - - $obj = new class implements PersistentContextInterface { - public function getPersistentContext(): string { return 'ctx_object_1'; } - }; - - $pref = $pm->getPreference($obj); - $pref->import(['enabled' => 'true', 'count' => '10']); - - $this->assertTrue($pref->getBool('enabled')); - $this->assertSame(10, $pref->getInt('count')); - $this->assertSame(['enabled' => true, 'count' => 10], [ - 'enabled' => $pref->getBool('enabled'), - 'count' => $pref->getInt('count'), - ]); - } public function testThrowsForUnsupportedObject(): void { diff --git a/tests/Integration/Selection/SelectionManagerTest.php b/tests/Integration/Selection/SelectionManagerTest.php new file mode 100644 index 0000000..3e90286 --- /dev/null +++ b/tests/Integration/Selection/SelectionManagerTest.php @@ -0,0 +1,88 @@ +get('persistent.selection.manager.scalar'); + $this->assertInstanceOf(SelectionManagerInterface::class, $manager); + + // Use the test normalizer that supports type "array" and requires identifierPath + $selection = $manager->getSelection('test_key'); + $this->assertInstanceOf(SelectionInterface::class, $selection); + + // Initially nothing selected + $this->assertFalse($selection->isSelected( 1)); + + // Select single item and verify + $selection->select( 1); + $this->assertTrue($selection->isSelected(1)); + + // Select multiple + $selection->selectMultiple([ + 2, + 3, + ]); + + $this->assertTrue($selection->isSelected( 2)); + $this->assertTrue($selection->isSelected( 3)); + + $ids = $selection->getSelectedIdentifiers(); + sort($ids); + $this->assertSame([1, 2, 3], $ids); + + // Unselect one and verify + $selection->unselect(2); + $this->assertFalse($selection->isSelected( 2)); + + $ids = $selection->getSelectedIdentifiers(); + sort($ids); + $this->assertSame([1, 3], $ids); + } + + public function testRegisterSourceThrowsWhenNoLoader(): void + { + $container = self::getContainer(); + + /** @var SelectionManagerInterface $manager */ + $manager = $container->get('persistent.selection.manager.default'); + $this->assertInstanceOf(SelectionManagerInterface::class, $manager); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No suitable loader found'); + + // stdClass is not supported by any IdentityLoader in tests/app + $manager->registerSelection('no_loader_key', new \stdClass()); + } + + + #[TestWith(['default',[['id' => 1, 'name' => 'A']]])] + #[TestWith(['scalar',[['id' => 1, 'name' => 'A']]])] + #[TestWith(['array',[new stdClass()]])] + public function testThrowExceptionOnBadNormalizer($service,$data):void { + + $container = self::getContainer(); + + /** @var SelectionManagerInterface $manager */ + $manager = $container->get('persistent.selection.manager.'.$service); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('is not supported'); + $manager->registerSelection("array_key_2", $data); + } + +} diff --git a/tests/Unit/Selection/Loader/ArrayLoaderTest.php b/tests/Unit/Selection/Loader/ArrayLoaderTest.php deleted file mode 100644 index 730e7e5..0000000 --- a/tests/Unit/Selection/Loader/ArrayLoaderTest.php +++ /dev/null @@ -1,30 +0,0 @@ -assertTrue($loader->supports($records)); - $this->assertSame(10, $loader->getTotalCount($records)); - - $ids = array_map(fn(RecordInteger $record) => $record->getId(), $records); - $foundIds = $loader->loadAllIdentifiers($resolver, $records, "id"); - - $this->assertEquals($ids, $foundIds); - } -} diff --git a/tests/Unit/Selection/Loader/DoctrineQueryBuilderLoaderTest.php b/tests/Unit/Selection/Loader/DoctrineQueryBuilderLoaderTest.php index 3146178..4d49294 100644 --- a/tests/Unit/Selection/Loader/DoctrineQueryBuilderLoaderTest.php +++ b/tests/Unit/Selection/Loader/DoctrineQueryBuilderLoaderTest.php @@ -4,7 +4,6 @@ use Doctrine\ORM\EntityManagerInterface; use Tito10047\PersistentPreferenceBundle\Selection\Loader\DoctrineQueryBuilderLoader; -use Tito10047\PersistentPreferenceBundle\Selection\Normalizer\ArrayNormalizer; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\RecordInteger; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Factory\RecordIntegerFactory; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Factory\TestCategoryFactory; @@ -16,7 +15,7 @@ public function testBasic(): void { $records = RecordIntegerFactory::createMany(10); - $loader = new DoctrineQueryBuilderLoader(new ArrayNormalizer()); + $loader = new DoctrineQueryBuilderLoader(); /** @var EntityManagerInterface $em */ $em = self::getContainer()->get('doctrine')->getManager(); @@ -32,7 +31,7 @@ public function testBasic(): void $ids = array_map(fn(RecordInteger $record) => $record->getId(), $records); sort($ids); - $foundIds = $loader->loadAllIdentifiers(null, $qb, 'id'); + $foundIds = $loader->loadAllIdentifiers(null, $qb); sort($foundIds); $this->assertEquals($ids, $foundIds); @@ -42,7 +41,7 @@ public function testGetCacheKeyStableAndDistinct(): void { RecordIntegerFactory::createMany(3); - $loader = new DoctrineQueryBuilderLoader(new ArrayNormalizer()); + $loader = new DoctrineQueryBuilderLoader(); /** @var EntityManagerInterface $em */ $em = self::getContainer()->get('doctrine')->getManager(); @@ -84,7 +83,7 @@ public function testWithWhere(): void array_filter($records, fn(RecordInteger $r) => $r->getName()=='keep', ARRAY_FILTER_USE_BOTH) )); - $loader = new DoctrineQueryBuilderLoader(new ArrayNormalizer()); + $loader = new DoctrineQueryBuilderLoader(); $qb = $em->createQueryBuilder() ->select('i') @@ -101,7 +100,7 @@ public function testWithWhere(): void sort($expectedIds); - $foundIds = $loader->loadAllIdentifiers(null, $qb, 'id'); + $foundIds = $loader->loadAllIdentifiers(null, $qb); sort($foundIds); $this->assertEquals($expectedIds, $foundIds); @@ -120,7 +119,7 @@ public function testWithJoin(): void ]); $records = RecordIntegerFactory::createMany(10); - $loader = new DoctrineQueryBuilderLoader(new ArrayNormalizer()); + $loader = new DoctrineQueryBuilderLoader(); $expectedIds = array_values(array_map( fn(RecordInteger $r) => $r->getId(), @@ -140,7 +139,7 @@ public function testWithJoin(): void $this->assertTrue($loader->supports($qb)); $this->assertEquals(count($expectedIds), $loader->getTotalCount($qb)); - $foundIds = $loader->loadAllIdentifiers(null, $qb, 'id'); + $foundIds = $loader->loadAllIdentifiers(null, $qb); sort($foundIds); $this->assertEquals($expectedIds, $foundIds); diff --git a/tests/Unit/Selection/Loader/DoctrineQueryLoaderTest.php b/tests/Unit/Selection/Loader/DoctrineQueryLoaderTest.php index e67bbb7..27d99e4 100644 --- a/tests/Unit/Selection/Loader/DoctrineQueryLoaderTest.php +++ b/tests/Unit/Selection/Loader/DoctrineQueryLoaderTest.php @@ -30,7 +30,7 @@ public function testBasic() { $ids = array_map(fn(RecordInteger $record) => $record->getId(), $records); sort($ids); - $foundIds = $loader->loadAllIdentifiers(null, $query, "id"); + $foundIds = $loader->loadAllIdentifiers(null, $query); sort($foundIds); $this->assertEquals($ids, $foundIds); @@ -68,7 +68,7 @@ public function testWithWhere(): void $this->assertEquals(count($expectedIds), $loader->getTotalCount($query)); sort($expectedIds); - $foundIds = $loader->loadAllIdentifiers(null, $query, 'id'); + $foundIds = $loader->loadAllIdentifiers(null, $query); sort($foundIds); $this->assertEquals($expectedIds, $foundIds); @@ -106,7 +106,7 @@ public function testWithJoin(): void $this->assertTrue($loader->supports($query)); $this->assertEquals(count($expectedIds), $loader->getTotalCount($query)); - $foundIds = $loader->loadAllIdentifiers(null, $query, 'id'); + $foundIds = $loader->loadAllIdentifiers(null, $query); sort($foundIds); $this->assertEquals($expectedIds, $foundIds); diff --git a/tests/Unit/Selection/Normalizer/ObjectNormalizerTest.php b/tests/Unit/Selection/Normalizer/ObjectNormalizerTest.php deleted file mode 100644 index 6e94910..0000000 --- a/tests/Unit/Selection/Normalizer/ObjectNormalizerTest.php +++ /dev/null @@ -1,79 +0,0 @@ -assertTrue($normalizer->supports(new \stdClass())); - $this->assertTrue($normalizer->supports(new RecordInteger())); - - $this->assertFalse($normalizer->supports(1)); - $this->assertFalse($normalizer->supports('a')); - $this->assertFalse($normalizer->supports(1.23)); - $this->assertFalse($normalizer->supports(true)); - $this->assertFalse($normalizer->supports(null)); - $this->assertFalse($normalizer->supports([])); - } - - public function testNormalizeReadsScalarViaGetter(): void - { - $normalizer = new ObjectNormalizer(); - - $entity = (new RecordInteger())->setId(123)->setName('Foo'); - - $this->assertSame(123, $normalizer->normalize($entity, 'id')); - $this->assertSame('Foo', $normalizer->normalize($entity, 'name')); - } - - public function testNormalizeReadsStringFromStringableProperty(): void - { - $normalizer = new ObjectNormalizer(); - - // object with public property that is stringable - $obj = new class { - public $value; - public function __construct() - { - $this->value = new class { - public function __toString(): string { return 'STRINGABLE'; } - }; - } - }; - - $this->assertSame('STRINGABLE', $normalizer->normalize($obj, 'value')); - } - - public function testNormalizeThrowsWhenPathNotReadable(): void - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Cannot read identifier'); - - $normalizer = new ObjectNormalizer(); - $obj = new \stdClass(); - // property does not exist - $normalizer->normalize($obj, 'unknown'); - } - - public function testNormalizeThrowsWhenExtractedValueIsNotScalarOrStringable(): void - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Extracted value is not a scalar'); - - $normalizer = new ObjectNormalizer(); - - $obj = new class { - public $value; - public function __construct() { $this->value = new \stdClass(); } - }; - - $normalizer->normalize($obj, 'value'); - } -} diff --git a/tests/Unit/Selection/Normalizer/ScalarNormalizerTest.php b/tests/Unit/Selection/Normalizer/ScalarNormalizerTest.php deleted file mode 100644 index acd0177..0000000 --- a/tests/Unit/Selection/Normalizer/ScalarNormalizerTest.php +++ /dev/null @@ -1,48 +0,0 @@ -assertTrue($normalizer->supports(1)); - $this->assertTrue($normalizer->supports('id')); - $this->assertTrue($normalizer->supports(1.5)); - $this->assertTrue($normalizer->supports(false)); - - $this->assertFalse($normalizer->supports(null)); - $this->assertFalse($normalizer->supports([])); - $this->assertFalse($normalizer->supports(new \stdClass())); - } - - public function testNormalizeReturnsIntOrString(): void - { - $normalizer = new ScalarNormalizer(); - - $this->assertSame(123, $normalizer->normalize(123, 'ignored')); - $this->assertSame('abc', $normalizer->normalize('abc', 'ignored')); - } - - public function testNormalizeThrowsForFloatOrBool(): void - { - $normalizer = new ScalarNormalizer(); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Item is not a valid scalar type'); - $normalizer->normalize(12.34, 'ignored'); - } - - public function testNormalizeThrowsForBool(): void - { - $normalizer = new ScalarNormalizer(); - - $this->expectException(\RuntimeException::class); - $normalizer->normalize(true, 'ignored'); - } -} diff --git a/tests/Unit/Selection/Service/SelectionInterfaceTest.php b/tests/Unit/Selection/Service/SelectionInterfaceTest.php index fed32d0..b4fa1d0 100644 --- a/tests/Unit/Selection/Service/SelectionInterfaceTest.php +++ b/tests/Unit/Selection/Service/SelectionInterfaceTest.php @@ -5,13 +5,9 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\RequestStack; use stdClass; -use Tito10047\PersistentPreferenceBundle\Converter\ObjectVarsConverter; use Tito10047\PersistentPreferenceBundle\Enum\SelectionMode; -use Tito10047\PersistentPreferenceBundle\Selection\Normalizer\IdentifierNormalizerInterface; -use Tito10047\PersistentPreferenceBundle\Selection\Normalizer\ScalarNormalizer; use Tito10047\PersistentPreferenceBundle\Selection\Service\Selection; use Tito10047\PersistentPreferenceBundle\Selection\Storage\SelectionSessionStorage; -use Tito10047\PersistentPreferenceBundle\Storage\StorableEnvelope; use Tito10047\PersistentPreferenceBundle\Tests\Trait\SessionInterfaceTrait; use Tito10047\PersistentPreferenceBundle\Transformer\ArrayValueTransformer; use Tito10047\PersistentPreferenceBundle\Transformer\ScalarValueTransformer; From 59f0f5371b743dda6def66eb1f5c64e9e410be73 Mon Sep 17 00:00:00 2001 From: tito10047 Date: Mon, 8 Dec 2025 05:30:32 +0100 Subject: [PATCH 08/13] added some tests --- src/Selection/Service/Selection.php | 26 ++++++--- .../Storage/SelectionSessionStorage.php | 4 ++ src/Storage/StorableEnvelope.php | 8 +++ .../Service/SelectionInterfaceTest.php | 58 +++++++++++++++++++ 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/Selection/Service/Selection.php b/src/Selection/Service/Selection.php index 9b8a484..df007a8 100644 --- a/src/Selection/Service/Selection.php +++ b/src/Selection/Service/Selection.php @@ -23,7 +23,7 @@ public function isSelected(mixed $item): bool { } public function select(mixed $item, null|array|object $metadata = null): static { - $id = is_scalar($item) ? $item : $this->transformer->transform($item); + $id = is_scalar($item) ? $item : $this->transformer->transform($item)->toArray(); $mode = $this->storage->getMode($this->key); $metaArray = null; if ($metadata !== null) { @@ -116,12 +116,20 @@ public function toggle(mixed $item, null|array|object $metadata = null): bool { public function getSelectedIdentifiers(): array { if ($this->storage->getMode($this->key) === SelectionMode::INCLUDE) { - return $this->storage->getStored($this->key); + $data = $this->storage->getStored($this->key); } else { $excluded = $this->storage->getStored($this->key); $all = $this->storage->getStored($this->getAllContext()); - return array_diff($all, $excluded); + $data = array_diff($all, $excluded); } + foreach ($data as $key => $value) { + if (is_array($value) && + ($envelope = StorableEnvelope::tryFromArray($value)) && + $this->transformer->supportsReverse($envelope)) { + $data[$key] = $this->transformer->reverseTransform($envelope); + } + } + return $data; } public function update(mixed $item, object|array|null $metadata = null): static { @@ -147,10 +155,14 @@ public function update(mixed $item, object|array|null $metadata = null): static public function getSelected(): array { $mode = $this->storage->getMode($this->key); if ($mode === SelectionMode::INCLUDE) { - $map = $this->storage->getStoredWithMetadata($this->key); + $map = $this->storage->getStoredWithMetadata($this->key); $hydrated = []; foreach ($map as $id => $meta) { - $hydrated[$id] = $this->metadataTransformer->reverseTransform(StorableEnvelope::fromArray($meta)); + if ($meta = StorableEnvelope::tryFromArray($meta)) { + $hydrated[$id] = $this->metadataTransformer->reverseTransform($meta); + } else { + $hydrated[$id] = []; + } } return $hydrated; } @@ -161,10 +173,10 @@ public function getSelected(): array { $result = []; foreach ($selected as $id) { $meta = $this->storage->getMetadata($this->key, $id); - if ($metadataClass !== null) { + if ($meta = StorableEnvelope::tryFromArray($meta) !== null) { $result[$id] = $this->metadataTransformer->reverseTransform($meta); } else { - $result[$id] = $meta; + $result[$id] = []; } } return $result; diff --git a/src/Selection/Storage/SelectionSessionStorage.php b/src/Selection/Storage/SelectionSessionStorage.php index 52008b7..60ae6c5 100644 --- a/src/Selection/Storage/SelectionSessionStorage.php +++ b/src/Selection/Storage/SelectionSessionStorage.php @@ -8,6 +8,7 @@ use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Tito10047\PersistentPreferenceBundle\Enum\SelectionMode; +use Tito10047\PersistentPreferenceBundle\Storage\StorableEnvelope; /** * Default storage implementation using Symfony Session. @@ -175,6 +176,9 @@ public function getStoredWithMetadata(string $context): array { $data = $this->loadData($context); $result = []; foreach ($data['ids'] as $id) { + if ($id instanceof StorableEnvelope){ + $id = $id->data; + } $key = (string)$id; $result[$id] = $data['meta'][$key] ?? []; } diff --git a/src/Storage/StorableEnvelope.php b/src/Storage/StorableEnvelope.php index 5c7ea73..6ce532a 100644 --- a/src/Storage/StorableEnvelope.php +++ b/src/Storage/StorableEnvelope.php @@ -21,6 +21,14 @@ public function __construct( public readonly array|string|null|int|float $data ) {} + public static function tryFromArray(mixed $meta): ?StorableEnvelope { + try{ + return self::fromArray($meta); + }catch (\InvalidArgumentException){ + return null; + } + } + /** * Pomocná metóda pre konverziu na pole (pre finálne úložisko) */ diff --git a/tests/Unit/Selection/Service/SelectionInterfaceTest.php b/tests/Unit/Selection/Service/SelectionInterfaceTest.php index b4fa1d0..2349e13 100644 --- a/tests/Unit/Selection/Service/SelectionInterfaceTest.php +++ b/tests/Unit/Selection/Service/SelectionInterfaceTest.php @@ -12,6 +12,7 @@ use Tito10047\PersistentPreferenceBundle\Transformer\ArrayValueTransformer; use Tito10047\PersistentPreferenceBundle\Transformer\ScalarValueTransformer; use Tito10047\PersistentPreferenceBundle\Transformer\SerializableObjectTransformer; +use Tito10047\PersistentPreferenceBundle\Transformer\ObjectIdValueTransformer; use Tito10047\PersistentPreferenceBundle\Transformer\ValueTransformerInterface; class SelectionInterfaceTest extends TestCase{ @@ -261,4 +262,61 @@ public function testToggleInExcludeMode(): void $this->assertTrue($state); $this->assertTrue($selection->isSelected(1)); } + + public function testCustomMetadataTransformerIsApplied(): void + { + $selection = new Selection('ctx_custom_meta', $this->storage, $this->normalizer, $this->converter); + + // Create a custom metadata transformer mock + $custom = $this->createMock(ValueTransformerInterface::class); + $custom->method('supports')->willReturnCallback(static fn($v) => is_array($v)); + $custom->method('transform')->willReturnCallback(static fn($v) => new \Tito10047\PersistentPreferenceBundle\Storage\StorableEnvelope('custom', $v)); + $custom->method('supportsReverse')->willReturnCallback(static fn($env) => $env->className === 'custom'); + $custom->method('reverseTransform')->willReturnCallback(static function ($env) { + $o = new \stdClass(); + foreach ((array)$env->data as $k => $v) { $o->{$k} = $v; } + return $o; + }); + + // Re-create selection with the custom metadata transformer + $selection = new Selection('ctx_custom_meta', $this->storage, $this->normalizer, $custom); + + $meta = ['foo' => 'bar', 'n' => 9]; + $selection->select(123, $meta); + + // getSelected should hydrate metadata through reverseTransform + $selected = $selection->getSelected(); + $this->assertArrayHasKey(123, $selected); + $this->assertInstanceOf(\stdClass::class, $selected[123]); + $this->assertSame('bar', $selected[123]->foo); + $this->assertSame(9, $selected[123]->n); + + // getMetadata() should return hydrated object as well + $m = $selection->getMetadata(123); + $this->assertInstanceOf(\stdClass::class, $m); + $this->assertSame('bar', $m->foo); + $this->assertSame(9, $m->n); + } + + public function testObjectIdValueTransformerAsMetadata(): void + { + // Dummy object with getId() + $fooClass = new class(77) { + public function __construct(private int $id) {} + public function getId(): int { return $this->id; } + }; + + $transformer = new ObjectIdValueTransformer($fooClass::class); + // Sanity on transformer itself + $this->assertTrue($transformer->supports($fooClass)); + + $selection = new Selection('ctx_obj_id_meta', $this->storage, $transformer, $this->normalizer); + + // Select scalar id with object metadata -> should store envelope and read back id (77) + $selection->select($fooClass); + + // getSelected returns map id => metadata (reverse transformed) + $selected = $selection->getSelectedIdentifiers(); + $this->assertSame([77], $selected); + } } \ No newline at end of file From fa3e5206e34ba8436acd762e819cd1ef2a586bc0 Mon Sep 17 00:00:00 2001 From: tito10047 Date: Mon, 8 Dec 2025 12:50:14 +0100 Subject: [PATCH 09/13] wip --- .../persistent-selection_controller.js | 131 +++++++ composer.json | 3 +- config/definition.php | 4 +- config/routes.php | 30 ++ config/services.php | 48 ++- src/Controller/SelectController.php | 103 ++++++ src/PersistentPreferenceBundle.php | 6 +- src/Selection/Service/Selection.php | 5 +- src/Selection/Service/SelectionInterface.php | 1 - src/Twig/SelectionExtension.php | 31 ++ src/Twig/SelectionRuntime.php | 177 ++++++++++ .../config/packages/persistent.yaml | 2 +- .../Twig/SelectionExtensionTest.php | 200 +++++++++++ .../Unit/Controller/SelectControllerTest.php | 332 ++++++++++++++++++ .../Service/SelectionInterfaceTest.php | 5 + 15 files changed, 1058 insertions(+), 20 deletions(-) create mode 100644 assets/controllers/persistent-selection_controller.js create mode 100644 config/routes.php create mode 100644 src/Controller/SelectController.php create mode 100644 src/Twig/SelectionExtension.php create mode 100644 src/Twig/SelectionRuntime.php create mode 100644 tests/Integration/Twig/SelectionExtensionTest.php create mode 100644 tests/Unit/Controller/SelectControllerTest.php diff --git a/assets/controllers/persistent-selection_controller.js b/assets/controllers/persistent-selection_controller.js new file mode 100644 index 0000000..23783c0 --- /dev/null +++ b/assets/controllers/persistent-selection_controller.js @@ -0,0 +1,131 @@ +import {Controller} from '@hotwired/stimulus'; + +export default class extends Controller { + + static targets = ["checkbox", "selectAll"] + + static values = { + urlToggle: String, + urlSelectAll: String, + urlSelectRange: String, + key: String, + manager: String, + selectAllClass: String, + unselectAllClass: String + } + + toggle({params: {id}, target}) { + let checked = target.checked; + fetch(this._getUrl(this.urlToggleValue, id, checked)).then((response) => { + if (!response.ok) { + target.checked = !checked; + console.error("can't select item #" + id); + } + }).catch((error) => { + target.checked = !checked; + console.error(error); + }) + } + + selectAll({target, params: {checked}}) { + const isCheckbox = target.matches('[type="checkbox"]'); + if (isCheckbox) { + checked = target.checked; + }else{ + if (this.hasSelectAllClassValue) { + checked = !target.classList.contains(this.selectAllClassValue) + } + } + fetch(this._getUrl(this.urlSelectAllValue, null, checked)).then((response) => { + if (!response.ok) { + if (isCheckbox) { + target.checked = !checked; + } + console.error("can't select all items") + } else { + if (this.hasSelectAllClassValue) { + if (checked) { + target.classList.add(this.selectAllClassValue); + } else { + target.classList.remove(this.selectAllClassValue); + } + } + if (this.hasUnselectAllClassValue) { + if (checked) { + target.classList.remove(this.unselectAllClassValue); + } else { + target.classList.add(this.unselectAllClassValue); + } + } + this.checkboxTargets.forEach((checkbox) => { + checkbox.checked = checked; + }) + } + }).catch((error) => { + if (isCheckbox) { + target.value = !checked; + } + console.error(error); + }) + } + + selectCurrentPage({target, params: {checked}}) { + const isCheckbox = target.matches('[type="checkbox"]'); + if (isCheckbox) { + checked = target.checked; + }else{ + if (this.hasSelectAllClassValue) { + checked = !target.classList.contains(this.selectAllClassValue) + } + } + let ids = []; + this.checkboxTargets.forEach((checkbox) => { + ids.push(checkbox.value); + }) + fetch(this._getUrl(this.urlSelectRangeValue, null, checked), { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(ids) + }).then((response) => { + if (!response.ok) { + console.error("can't select all items") + } else { + if (this.hasSelectAllClassValue) { + if (checked) { + target.classList.add(this.selectAllClassValue); + } else { + target.classList.remove(this.selectAllClassValue); + } + } + if (this.hasUnselectAllClassValue) { + if (checked) { + target.classList.remove(this.unselectAllClassValue); + } else { + target.classList.add(this.unselectAllClassValue); + } + } + + this.checkboxTargets.forEach((checkbox) => { + checkbox.checked = checked; + }) + } + }).catch((error) => { + console.error(error); + }) + } + + _getUrl(url, id, selected) { + let params = { + key: this.keyValue, + manager: this.managerValue, + selected: selected ? "1" : "0", + }; + if (id) { + params["id"] = id; + } + return url + '?' + new URLSearchParams(params).toString() + } +} diff --git a/composer.json b/composer.json index 9d9924f..e73fccd 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ "preference", "batch", "pagination", - "ui" + "ui", + "symfony-ux" ], "minimum-stability": "stable", "authors": [ diff --git a/config/definition.php b/config/definition.php index 63acf84..755f43b 100644 --- a/config/definition.php +++ b/config/definition.php @@ -40,12 +40,12 @@ ->info('The service ID of the storage backend to use (e.g. "@app.storage.doctrine").') ->end() ->scalarNode('transformer') - ->isRequired() + ->defaultValue('@persistent.transformer.scalar') ->cannotBeEmpty() ->info('') ->end() ->scalarNode('metadata_transformer') - ->isRequired() + ->defaultValue('@persistent.transformer.array') ->cannotBeEmpty() ->info('') ->end() diff --git a/config/routes.php b/config/routes.php new file mode 100644 index 0000000..63237aa --- /dev/null +++ b/config/routes.php @@ -0,0 +1,30 @@ +add('persistent_selection_toggle', '/_persistent-selection/toggle') + ->controller([SelectController::class, 'rowSelectorToggle']) + ->methods(['GET']) + ; + + // Označiť/odznačiť všetky riadky podľa kľúča + $routes + ->add('persistent_selection_select_all', '/_persistent-selection/select-all') + ->controller([SelectController::class, 'rowSelectorSelectAll']) + ->methods(['GET']) + ; + + $routes + ->add('persistent_selection_select_range', '/_persistent-selection/select-range') + ->controller([SelectController::class, 'rowSelectorSelectRange']) + ->methods(['POST']) + ; +}; diff --git a/config/services.php b/config/services.php index e2a8848..a51bd28 100644 --- a/config/services.php +++ b/config/services.php @@ -3,7 +3,9 @@ use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Tito10047\PersistentPreferenceBundle\Command\DebugPreferenceCommand; +use Tito10047\PersistentPreferenceBundle\Controller\SelectController; use Tito10047\PersistentPreferenceBundle\Converter\MetadataConverterInterface; use Tito10047\PersistentPreferenceBundle\Converter\ObjectVarsConverter; use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; @@ -19,12 +21,16 @@ use Tito10047\PersistentPreferenceBundle\Selection\Loader\DoctrineCollectionLoader; use Tito10047\PersistentPreferenceBundle\Selection\Loader\DoctrineQueryBuilderLoader; use Tito10047\PersistentPreferenceBundle\Selection\Loader\DoctrineQueryLoader; +use Tito10047\PersistentPreferenceBundle\Selection\Service\SelectionManager; +use Tito10047\PersistentPreferenceBundle\Selection\Service\SelectionManagerInterface; use Tito10047\PersistentPreferenceBundle\Selection\Storage\SelectionSessionStorage; use Tito10047\PersistentPreferenceBundle\Selection\Storage\SelectionStorageInterface; use Tito10047\PersistentPreferenceBundle\Transformer\ArrayValueTransformer; use Tito10047\PersistentPreferenceBundle\Transformer\ScalarValueTransformer; use Tito10047\PersistentPreferenceBundle\Twig\PreferenceExtension; use Tito10047\PersistentPreferenceBundle\Twig\PreferenceRuntime; +use Tito10047\PersistentPreferenceBundle\Twig\SelectionExtension; +use Tito10047\PersistentPreferenceBundle\Twig\SelectionRuntime; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; @@ -46,13 +52,6 @@ // Alias the interface to our concrete storage service id $services->alias(PreferenceStorageInterface::class, 'persistent.preference.storage.session'); - - // --- Built-in Value Transformers --- - $services - ->set(ScalarValueTransformer::class) - ->public() - ; - // --- Metadata Converters --- $services ->set('persistent.preference.converter.object_vars', ObjectVarsConverter::class) @@ -71,14 +70,28 @@ ; $services->alias(PreferenceManagerInterface::class, 'persistent.preference.manager.default'); - // --- Twig Extension --- + // --- SelectionManager --- + $services + ->set('persistent.selection.manager.default', SelectionManager::class) + ->public() + ->arg('$storage', service('persistent.selection.storage.session')) + ->arg('$transformer', service('persistent.transformer.scalar')) + ->arg('$metadataTransformer', service('persistent.transformer.array')) + ->arg('$loaders', tagged_iterator(AutoTagIdentityLoadersPass::TAG)) + ->arg('$ttl', null) + ->tag('persistent.selection.manager', ['name' => 'default']); + $services->alias(SelectionManagerInterface::class, 'persistent.selection.manager.default'); + + + // --- Twig Extension --- $services ->set(PreferenceExtension::class) ->public() ->tag('twig.extension') ; - $services->set(ArrayValueTransformer::class) + // --- Built-in Value Transformers --- + $services->set("persistent.transformer.array",ArrayValueTransformer::class) ->public() ->tag(PersistentPreferenceBundle::TRANSFORMER_TAG); $services->set("persistent.transformer.scalar",ScalarValueTransformer::class) @@ -143,5 +156,22 @@ // Note: decoration of all managers is handled by a CompilerPass (TraceableManagersPass) } } + // --- Controllers --- + $services + ->set(SelectController::class) + ->public() + ->arg('$selectionManagers', tagged_iterator('persistent.selection.manager', 'name')); + // --- Twig integration --- + $services + ->set(SelectionExtension::class) + ->tag('twig.extension') + ; + + $services + ->set(SelectionRuntime::class) + ->arg('$selectionManagers', tagged_iterator('persistent.selection.manager', 'name')) + ->arg('$router', service(UrlGeneratorInterface::class)) + ->tag('twig.runtime') + ; }; diff --git a/src/Controller/SelectController.php b/src/Controller/SelectController.php new file mode 100644 index 0000000..5141a0f --- /dev/null +++ b/src/Controller/SelectController.php @@ -0,0 +1,103 @@ + $selectionManagers + */ + public function __construct( + private readonly iterable $selectionManagers, + ) { + } + + + public function rowSelectorToggle(Request $request): Response { + $key = $request->query->getString("key", ""); + $manager = $request->query->getString("manager", ""); + $id = $request->query->get("id", null); + $selected = $request->query->getBoolean("selected", true); + + if (!$manager || !$key || !$id) { + throw new BadRequestHttpException("Missing key or value or id"); + } + + $selector = $this->getRowsSelector($manager)->getSelection($key); + + if ($selected) { + $selector->select($id); + } else { + $selector->unselect($id); + } + + return new Response(null, 202); + } + + public function rowSelectorSelectRange(Request $request): Response { + $key = $request->query->getString("key", ""); + $manager = $request->query->getString("manager", ""); + $ids = json_decode($request->getContent(), true); + if (json_last_error() !== JSON_ERROR_NONE || $ids === null) { + throw new BadRequestHttpException("Body is not valid json"); + } + if (!is_array($ids)) { + $ids = [$ids]; + } + $selected = $request->query->getBoolean("selected", true); + + $onlyScalar = count(array_filter($ids, fn($value) => !is_scalar($value))) === 0; + + if (!$manager || !$key) { + throw new BadRequestHttpException("missing key or value"); + } + if (!$onlyScalar) { + throw new BadRequestHttpException("Id variables can be only scalar values"); + } + + $selector = $this->getRowsSelector((string) $manager)->getSelection((string) $key); + + if ($selected) { + $selector->selectMultiple($ids); + } else { + $selector->unselectMultiple($ids); + } + + return new Response(null, 202); + } + + public function rowSelectorSelectAll(Request $request): Response { + $key = $request->query->getString("key", ""); + $manager = $request->query->getString("manager", ""); + $selected = $request->query->getBoolean("selected", true); + + if (!$manager || !$key) { + throw new BadRequestHttpException("missing key or value"); + } + + $selector = $this->getRowsSelector((string) $manager)->getSelection((string) $key); + + if ($selected) { + $selector->selectAll(); + } else { + $selector->unselectAll(); + } + + + return new Response(null, 202); + } + + private function getRowsSelector(string $manager): SelectionManagerInterface { + foreach ($this->selectionManagers as $id => $selectionManager) { + if ($id === $manager) { + return $selectionManager; + } + } + throw new BadRequestHttpException(sprintf('No selection manager found for manager "%s".', $manager)); + } +} diff --git a/src/PersistentPreferenceBundle.php b/src/PersistentPreferenceBundle.php index 3a89b18..12a7f41 100644 --- a/src/PersistentPreferenceBundle.php +++ b/src/PersistentPreferenceBundle.php @@ -20,6 +20,7 @@ * @link https://symfony.com/doc/current/bundles/best_practices.html */ class PersistentPreferenceBundle extends AbstractBundle { + public const STIMULUS_CONTROLLER='tito10047--persistent-preference-bundle--persistent-selection'; protected string $extensionAlias = 'persistent'; const TRANSFORMER_TAG = 'persistent_preference.value_transformer'; @@ -53,8 +54,8 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $configManagers = $config['selection']['managers'] ?? []; foreach($configManagers as $name=>$subConfig){ $storage = service(ltrim($subConfig['storage'], '@')); - $transformer = service(ltrim($subConfig['transformer'], '@')??null); - $metadataTransformer = service(ltrim($subConfig['metadata_transformer'], '@')??null); + $transformer = service(ltrim($subConfig['transformer'], '@')); + $metadataTransformer = service(ltrim($subConfig['metadata_transformer'], '@')); $ttl = $subConfig['ttl']??null; $services ->set('persistent.selection.manager.'.$name,SelectionManager::class) @@ -64,6 +65,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C ->arg('$metadataTransformer', $metadataTransformer) ->arg('$loaders', tagged_iterator(AutoTagIdentityLoadersPass::TAG)) ->arg('$ttl', $ttl) + ->tag('persistent.selection.manager', ['name' => $name]) ; } } diff --git a/src/Selection/Service/Selection.php b/src/Selection/Service/Selection.php index df007a8..533e6ce 100644 --- a/src/Selection/Service/Selection.php +++ b/src/Selection/Service/Selection.php @@ -18,6 +18,7 @@ public function __construct( } public function isSelected(mixed $item): bool { + $item = is_scalar($item) ? $item : $this->transformer->transform($item); $has = $this->storage->hasIdentifier($this->key, $item); return $this->storage->getMode($this->key) === SelectionMode::INCLUDE ? $has : !$has; } @@ -231,10 +232,6 @@ public function getTotal(): int { return count($this->storage->getStored($this->getAllContext())); } - public function normalize(mixed $item): int|string { - return $this->transformer->transform($item); - } - public function hasSource(string $cacheKey): bool { // First ensure we have a marker for this source diff --git a/src/Selection/Service/SelectionInterface.php b/src/Selection/Service/SelectionInterface.php index 1ea71b7..85b328a 100644 --- a/src/Selection/Service/SelectionInterface.php +++ b/src/Selection/Service/SelectionInterface.php @@ -69,5 +69,4 @@ public function getMetadata(mixed $item): null|array|object; public function getTotal():int; - public function normalize(mixed $item):int|string; } \ No newline at end of file diff --git a/src/Twig/SelectionExtension.php b/src/Twig/SelectionExtension.php new file mode 100644 index 0000000..a370a1b --- /dev/null +++ b/src/Twig/SelectionExtension.php @@ -0,0 +1,31 @@ + ['html']]), + new TwigFunction('persistent_selection_row_selector_all', [SelectionRuntime::class, 'rowSelectorAll'], ['is_safe' => ['html']]), + new TwigFunction('persistent_selection_total', [SelectionRuntime::class, 'getTotal']), + new TwigFunction('persistent_selection_count', [SelectionRuntime::class, 'getSelectedCount']), + new TwigFunction('persistent_selection_stimulus_controller', [SelectionRuntime::class, 'getStimulusController'], ['is_safe' => ['html']]), + ]; + } + + + public function getGlobals(): array { + return [ + 'persistent_selection_stimulus_controller_name'=> PersistentPreferenceBundle::STIMULUS_CONTROLLER, + ]; + } +} diff --git a/src/Twig/SelectionRuntime.php b/src/Twig/SelectionRuntime.php new file mode 100644 index 0000000..beac9f2 --- /dev/null +++ b/src/Twig/SelectionRuntime.php @@ -0,0 +1,177 @@ + $selectionManagers + */ + public function __construct( + private readonly iterable $selectionManagers, + private readonly UrlGeneratorInterface $router + ) { + } + + public function getStimulusController(string $key, ?string $controller = null, array $variables = [], string $manager = 'default', bool $asArray = false): string|array { + $toggleUrl = $this->router->generate('persistent_selection_toggle'); + $selectAllUrl = $this->router->generate('persistent_selection_select_all'); + $selectRange = $this->router->generate('persistent_selection_select_range'); + + + $myAttributes = [ + "data-controller" => $this->controllerName, + ]; + $variables = $variables + [ + "urlToggle" => $toggleUrl, + "urlSelectAll" => $selectAllUrl, + "urlSelectRange" => $selectRange, + "key" => $key, + "manager" => $manager + ]; + foreach ($variables as $name => $value) { + $name = strtolower(preg_replace('/([a-zA-Z])(?=[A-Z])/', '$1-', $name)); + $attributes["data-{$this->controllerName}-{$name}-value"] = $value; + } + if ($controller){ + $attributes["data-controller"]=$controller; + } + $attributes = $this->mergeAttributes($myAttributes, $attributes); + if ($asArray) { + return $attributes; + } + return $this->renderAttributes($attributes); + } + + public function isSelected(string $key, mixed $item, string $manager = 'default'): bool { + $manager = $this->getRowsSelector($manager); + $selection = $manager->getSelection($key); + return $selection->isSelected($item); + } + + public function isSelectedAll(string $key, string $manager = 'default'): bool { + $manager = $this->getRowsSelector($manager); + $selection = $manager->getSelection($key); + return $selection->isSelectedAll(); + } + + public function rowSelector(string $key, mixed $item, array $attributes = [], string $manager = 'default'): string { + $selected = ""; + $manager = $this->getRowsSelector($manager); + $selection = $manager->getSelection($key); + + $myAttributes = [ + "name" => "row-selector[]", + "class" => "row-selector" + ]; + + $id = $selection->normalize($item); + if ($selection->isSelected($id)) { + $myAttributes["checked"] = 'checked'; + } + + $attributes = $this->renderAttributes($this->mergeAttributes($myAttributes, $attributes)); + + return "controllerName}-target=\"checkbox\" data-action='{$this->controllerName}#toggle' data-{$this->controllerName}-id-param='{$id}' value='{$id}'>"; + } + + public function mergeAttributes(array $default, array $custom): array { + + // Merge default and custom attributes with simple rules: + // - Start from defaults + // - "class" values are concatenated (default first, then custom) and de-duplicated + // - Boolean-like false/null removes the attribute + // - For other keys, custom overrides default + $attrs = $default; + + foreach ($custom as $key => $value) { + if ($value === false || $value === null) { + unset($attrs[$key]); + continue; + } + + // Merge token-list attributes: class and data-controller + if ($key === 'class' || $key === 'data-controller') { + $existing = isset($attrs[$key]) ? trim((string) $attrs[$key]) : ''; + $incoming = trim((string) $value); + + $tokens = array_merge( + $existing !== '' ? preg_split('/\s+/', $existing, -1, PREG_SPLIT_NO_EMPTY) : [], + $incoming !== '' ? preg_split('/\s+/', $incoming, -1, PREG_SPLIT_NO_EMPTY) : [] + ); + + $attrs[$key] = implode(' ', array_unique($tokens)); + continue; + } + + $attrs[$key] = $value; + } + return $attrs; + } + + public function renderAttributes(array $attributes): string { + // Render attributes: for boolean-like true use key="key", for strings escape + foreach ($attributes as $key => $value) { + if ($value === true || ($key === 'checked' && $value === 'checked')) { + $out[] = sprintf('%s="%s"', $key, $key); + continue; + } + if ($value === '') { + $out[] = sprintf('%s=""', $key); + continue; + } + $out[] = sprintf('%s="%s"', $key, htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + } + + return implode(' ', $out); + } + + public function rowSelectorAll(string $key, array $attributes = [], string $manager = 'default'): string { + $selected = ""; + + $manager = $this->getRowsSelector($manager); + $selection = $manager->getSelection($key); + + $myAttributes = [ + "name" => "row-selector-all", + "class" => "row-selector" + ]; + + if ($selection->isSelectedAll()) { + $myAttributes["checked"] = 'checked'; + } + + $attributes = $this->renderAttributes($this->mergeAttributes($myAttributes, $attributes)); + + return "controllerName}-target=\"selectAll\" data-action='{$this->controllerName}#selectAll'>"; + } + + public function getTotal(string $key, string $manager = 'default'): int { + $manager = $this->getRowsSelector($manager); + $selection = $manager->getSelection($key); + return $selection->getTotal(); + } + + public function getSelectedCount(string $key, string $manager = 'default'): int { + $manager = $this->getRowsSelector($manager); + $selection = $manager->getSelection($key); + return count($selection->getSelectedIdentifiers()); + } + + private function getRowsSelector(string $manager): SelectionManagerInterface { + foreach ($this->selectionManagers as $id => $selectionManager) { + if ($id === $manager) { + return $selectionManager; + } + } + throw new InvalidArgumentException(sprintf('No selection manager found for manager "%s".', $manager)); + } +} \ No newline at end of file diff --git a/tests/App/AssetMapper/config/packages/persistent.yaml b/tests/App/AssetMapper/config/packages/persistent.yaml index 34de229..29bfd73 100644 --- a/tests/App/AssetMapper/config/packages/persistent.yaml +++ b/tests/App/AssetMapper/config/packages/persistent.yaml @@ -41,4 +41,4 @@ persistent: metadata_transformer: '@app.transformer.id' array: transformer: '@persistent.transformer.scalar' - metadata_transformer: '@app.transformer.id' + metadata_transformer: '@app.transformer.id' \ No newline at end of file diff --git a/tests/Integration/Twig/SelectionExtensionTest.php b/tests/Integration/Twig/SelectionExtensionTest.php new file mode 100644 index 0000000..8383227 --- /dev/null +++ b/tests/Integration/Twig/SelectionExtensionTest.php @@ -0,0 +1,200 @@ +get('twig'); + + $this->assertInstanceOf(Environment::class, $twig); + + foreach ([ + 'persistent_selection_is_selected', + 'persistent_selection_is_selected_all', + 'persistent_selection_row_selector', + 'persistent_selection_row_selector_all', + 'persistent_selection_total', + 'persistent_selection_count', + 'persistent_selection_stimulus_controller', + ] as $functionName) { + $this->assertNotNull($twig->getFunction($functionName), sprintf('Twig function %s should be registered.', $functionName)); + } + $this->assertArrayHasKey('persistent_selection_stimulus_controller_name',$twig->getGlobals()); + } + + public function testTwigFunctionsBehavior(): void + { + $this->markTestSkipped("TODO: fix this test"); + $controllerName = PersistentPreferenceBundle::STIMULUS_CONTROLLER; + $container = self::getContainer(); + + /** @var SelectionManagerInterface $manager */ + $manager = $container->get(SelectionManagerInterface::class); + + // Prepare simple object list with "id" property + $items = []; + for ($i = 1; $i <= 3; $i++) { + $o = new User(); + $o->setId($i); + $o->setName('Item '.$i); + $items[] = $o; + } + + // Register source under a key + $selection = $manager->registerSelection('twig_key', $items); + $this->assertInstanceOf(SelectionInterface::class, $selection); + $this->assertSame(3, $selection->getTotal(), 'Total after registerSource should reflect all items.'); + + // Select one item (object with id=2) + $selection->select($items[1]); + + /** @var Environment $twig */ + $twig = $container->get('twig'); + + // persistent_is_selected for id=2 should be true, for id=1 should be false + $tpl = $twig->createTemplate( + "{{ persistent_selection_is_selected('twig_key', item) ? 'YES' : 'NO' }}" + ); + $outSelected = $tpl->render(['item' => $items[1]]); // id=2 + $outNotSelected = $tpl->render(['item' => $items[0]]); // id=1 + $this->assertSame('YES', $outSelected); + $this->assertSame('NO', $outNotSelected); + + // persistent_row_selector should include checked attribute only for selected item + $tpl = $twig->createTemplate("{{ persistent_selection_row_selector('twig_key', item) }}"); + $htmlSelected = $tpl->render(['item' => $items[1]]); + $htmlNotSelected = $tpl->render(['item' => $items[0]]); + $this->assertStringContainsString('type=\'checkbox\'', $htmlSelected); + $this->assertStringContainsString("data-{$controllerName}-target=\"checkbox\"", $htmlSelected); + $this->assertStringContainsString('checked="checked"', $htmlSelected); + $this->assertStringNotContainsString('checked="checked"', $htmlNotSelected); + + // persistent_selection_total and persistent_selection_count + $tplTotal = $twig->createTemplate("{{ persistent_selection_total('twig_key') }}"); + $tplCount = $twig->createTemplate("{{ persistent_selection_count('twig_key') }}"); + $this->assertSame('3', trim($tplTotal->render())); + $this->assertSame('1', trim($tplCount->render())); + + // check controller name + $name = $twig->createTemplate("{{ persistent_selection_stimulus_controller_name }}"); + $this->assertSame($controllerName, trim($name->render())); + + // persistent_row_selector_all should not be checked in default INCLUDE mode + $tplAll = $twig->createTemplate("{{ persistent_selection_row_selector_all('twig_key') }}"); + $htmlAll = $tplAll->render(); + $this->assertStringNotContainsString('checked="checked"', $htmlAll); + + // Switch to EXCLUDE mode; with current Selection::isSelectedAll() implementation, + // the checkbox remains unchecked unless all items are excluded (which we don't do here) + $selection->setMode(SelectionMode::EXCLUDE); + $htmlAllExclude = $tplAll->render(); + $this->assertStringNotContainsString('checked="checked"', $htmlAllExclude); + + // persistent_stimulus_controller should contain controller name and required URLs + $tplStimulus = $twig->createTemplate("{{ persistent_selection_stimulus_controller('twig_key') }}"); + $attrs = $tplStimulus->render(); + $this->assertStringContainsString("data-controller=\"{$controllerName}\"", $attrs); + $this->assertStringContainsString("data-{$controllerName}-url-toggle-value=\"", $attrs); + $this->assertStringContainsString('/_persistent-selection/toggle', $attrs); + $this->assertStringContainsString("data-{$controllerName}-url-select-all-value=\"", $attrs); + $this->assertStringContainsString('/_persistent-selection/select-all', $attrs); + $this->assertStringContainsString("data-{$controllerName}-url-select-range-value=\"", $attrs); + $this->assertStringContainsString('/_persistent-selection/select-range', $attrs); + $this->assertStringContainsString("data-{$controllerName}-key-value=\"twig_key\"", $attrs); + $this->assertStringContainsString("data-{$controllerName}-manager-value=\"default\"", $attrs); + } + + public function testRowSelectorCustomAttributesAreMergedAndEscaped(): void + { + $this->markTestSkipped("TODO: fix this test"); + $container = self::getContainer(); + + /** @var SelectionManagerInterface $manager */ + $manager = $container->get(SelectionManagerInterface::class); + + // Items + $items = []; + for ($i = 1; $i <= 1; $i++) { + $o = new \stdClass(); + $o->id = $i; + $items[] = $o; + } + + // Register & select item to ensure 'checked' attribute appears + $selection = $manager->registerSelection('twig_attr_key', $items); + $selection->select($items[0]); + + /** @var Environment $twig */ + $twig = $container->get('twig'); + + // Custom attributes: override name, add class, data-foo, boolean, and escaping + $tpl = $twig->createTemplate( + "{{ persistent_selection_row_selector('twig_attr_key', item, {\n". + " 'class': 'extra extra',\n". + " 'name': 'custom[]',\n". + " 'data-foo': 'bar',\n". + " 'disabled': true,\n". + " 'data-title': 'A \"quote\" & more'\n". + "}) }}" + ); + + $html = $tpl->render(['item' => $items[0]]); + + // name overridden + $this->assertStringContainsString('name="custom[]"', $html); + // class merged and de-duplicated: default 'row-selector' + custom 'extra' + $this->assertStringContainsString('class="row-selector extra"', $html); + // checked present because item selected + $this->assertStringContainsString('checked="checked"', $html); + // data-foo propagated + $this->assertStringContainsString('data-foo="bar"', $html); + // boolean attribute rendered as disabled="disabled" + $this->assertStringContainsString('disabled="disabled"', $html); + // escaped value + $this->assertStringContainsString('data-title="A "quote" & more"', $html); + } + + public function testStimulusControllerMergesDataController(): void + { + $this->markTestSkipped("TODO: fix this test"); + $container = self::getContainer(); + /** @var Environment $twig */ + $twig = $container->get('twig'); + $controllerName = PersistentPreferenceBundle::STIMULUS_CONTROLLER; + + // Provide custom data-controller to be concatenated after the bundle's default + $tpl = $twig->createTemplate( + "{{ persistent_selection_stimulus_controller('twig_key', 'extra-controller' ) }}" + ); + + $attrs = $tpl->render(); + + // Expect both controllers, default first then custom + $this->assertStringContainsString( + sprintf('data-controller="%s extra-controller"', $controllerName), + $attrs + ); + + // If custom includes duplicate default, it should be de-duplicated + $tplDedupe = $twig->createTemplate( + "{{ persistent_selection_stimulus_controller('twig_key', 'extra-controller') }}" + ); + $attrsDedupe = $tplDedupe->render(); + $this->assertStringContainsString( + sprintf('data-controller="%s extra-controller"', $controllerName), + $attrsDedupe + ); + } +} diff --git a/tests/Unit/Controller/SelectControllerTest.php b/tests/Unit/Controller/SelectControllerTest.php new file mode 100644 index 0000000..6574185 --- /dev/null +++ b/tests/Unit/Controller/SelectControllerTest.php @@ -0,0 +1,332 @@ +createMock(SelectionInterface::class); + } + + /** @var SelectionManagerInterface&MockObject $manager */ + $manager = $this->createMock(SelectionManagerInterface::class); + $manager->expects($this->once()) + ->method('getSelection') + ->with($key) + ->willReturn($selection); + // Controller accepts iterable keyed by manager id + return new SelectController([$id => $manager]); + } + + public function testRowSelectorToggleSelectsWhenSelectedTrue(): void { + $key = 'orders'; + $managerId = 'default'; + $itemId = 123; + + /** @var SelectionInterface&MockObject $selection */ + $selection = $this->createMock(SelectionInterface::class); + $selection->expects($this->once())->method('select')->with($itemId); + $selection->expects($this->never())->method('unselect'); + + $controller = $this->createControllerWithManager($managerId, $key, $selection); + + $request = new Request( + query: [ + 'key' => $key, + 'manager' => $managerId, + 'id' => $itemId, + 'selected' => '1', + ] + ); + + $response = $controller->rowSelectorToggle($request); + $this->assertSame(202, $response->getStatusCode()); + } + + public function testRowSelectorToggleUnselectsWhenSelectedFalse(): void { + $key = 'orders'; + $managerId = 'default'; + $itemId = 987; + + /** @var SelectionInterface&MockObject $selection */ + $selection = $this->createMock(SelectionInterface::class); + $selection->expects($this->once())->method('unselect')->with($itemId); + $selection->expects($this->never())->method('select'); + + $controller = $this->createControllerWithManager($managerId, $key, $selection); + + $request = new Request( + query: [ + 'key' => $key, + 'manager' => $managerId, + 'id' => $itemId, + 'selected' => '0', + ] + ); + + $response = $controller->rowSelectorToggle($request); + $this->assertSame(202, $response->getStatusCode()); + } + + public function testRowSelectorToggleThrowsOnMissingKeyOrManager(): void { + $controller = new SelectController([]); + + $this->expectException(BadRequestHttpException::class); + $request = new Request(query: ['key' => 'k']); + $controller->rowSelectorToggle($request); + } + + public function testRowSelectorSelectRangeSelectsMultiple(): void { + $key = 'products'; + $managerId = 'array'; + $ids = [1, '2', 3]; + + /** @var SelectionInterface&MockObject $selection */ + $selection = $this->createMock(SelectionInterface::class); + $selection->expects($this->once())->method('selectMultiple')->with($ids); + $selection->expects($this->never())->method('unselectMultiple'); + + $controller = $this->createControllerWithManager($managerId, $key, $selection); + + $request = new Request( + query: [ + 'key' => $key, + 'manager' => $managerId, + 'selected' => '1', + ], + content: json_encode($ids), + + ); + + $response = $controller->rowSelectorSelectRange($request); + $this->assertSame(202, $response->getStatusCode()); + } + + public function testRowSelectorSelectRangeUnselectsMultipleWhenSelectedFalse(): void { + $key = 'products'; + $managerId = 'array'; + $ids = [10, 11, 12]; + + /** @var SelectionInterface&MockObject $selection */ + $selection = $this->createMock(SelectionInterface::class); + $selection->expects($this->once())->method('unselectMultiple')->with($ids); + $selection->expects($this->never())->method('selectMultiple'); + + + $controller = $this->createControllerWithManager($managerId, $key, $selection); + + $request = new Request( + query: [ + 'key' => $key, + 'manager' => $managerId, + 'selected' => '0', + ], + content: json_encode($ids) + ); + + $response = $controller->rowSelectorSelectRange($request); + $this->assertSame(202, $response->getStatusCode()); + } + + public function testRowSelectorSelectRangeThrowsOnNonScalarIds(): void { + // For invalid (non-scalar) IDs, the controller validates input before + // calling manager->getSelection(). Therefore, we must not set an + // expectation that getSelection() will be invoked here. + /** @var SelectionManagerInterface&MockObject $manager */ + $manager = $this->createMock(SelectionManagerInterface::class); + $controller = new SelectController(['any' => $manager]); + + $this->expectException(BadRequestHttpException::class); + + $request = new Request( + query: [ + 'key' => 'k', + 'manager' => 'any', + ], + request: [ + 'id' => [1, ['nested' => 'x']], + ] + ); + + $controller->rowSelectorSelectRange($request); + } + + public function testRowSelectorToggleThrowsOnUnknownManager(): void { + $knownManager = $this->createMock(SelectionManagerInterface::class); + $controller = new SelectController(['known' => $knownManager]); + + $this->expectException(BadRequestHttpException::class); + + $request = new Request(query: [ + 'key' => 'k', + 'manager' => 'unknown', + 'id' => 1, + 'selected' => '1', + ]); + + $controller->rowSelectorToggle($request); + } + + public function testRowSelectorSelectRangeThrowsOnUnknownManager(): void { + $knownManager = $this->createMock(SelectionManagerInterface::class); + $controller = new SelectController(['known' => $knownManager]); + + $this->expectException(BadRequestHttpException::class); + + $request = new Request( + query: [ + 'key' => 'k', + 'manager' => 'unknown', + 'selected' => '1', + ], + request: [ + 'id' => [1, 2], + ] + ); + + $controller->rowSelectorSelectRange($request); + } + + public function testRowSelectorSelectRangeWrapsSingleScalarId(): void { + $key = 'products'; + $managerId = 'array'; + $singleId = 42; + + /** @var SelectionInterface&MockObject $selection */ + $selection = $this->createMock(SelectionInterface::class); + $selection->expects($this->once())->method('selectMultiple')->with([$singleId]); + $selection->expects($this->never())->method('unselectMultiple'); + + $controller = $this->createControllerWithManager($managerId, $key, $selection); + + $request = new Request( + query: [ + 'key' => $key, + 'manager' => $managerId, + 'selected' => '1', + ], + content: json_encode($singleId) + ); + + $response = $controller->rowSelectorSelectRange($request); + $this->assertSame(202, $response->getStatusCode()); + } + + public function testRowSelectorSelectRangeHandlesEmptyIdsArray(): void { + $key = 'products'; + $managerId = 'array'; + + /** @var SelectionInterface&MockObject $selection */ + $selection = $this->createMock(SelectionInterface::class); + $selection->expects($this->once())->method('selectMultiple')->with([]); + $selection->expects($this->never())->method('unselectMultiple'); + + $controller = $this->createControllerWithManager($managerId, $key, $selection); + + $request = new Request( + query: [ + 'key' => $key, + 'manager' => $managerId, + 'selected' => '1', + ], + content: "[]" + ); + + $response = $controller->rowSelectorSelectRange($request); + $this->assertSame(202, $response->getStatusCode()); + } + + public function testRowSelectorSelectRangeThrowsOnMissingKeyOrManager(): void { + $controller = new SelectController([]); + + $this->expectException(BadRequestHttpException::class); + + $request = new Request( + query: [ + // missing 'manager' + 'key' => 'k', + ], + request: [ + 'id' => [1, 2, 3], + ] + ); + + $controller->rowSelectorSelectRange($request); + } + + public function testRowSelectorSelectAllThrowsOnMissingKeyOrManager(): void { + $controller = new SelectController([]); + + $this->expectException(BadRequestHttpException::class); + + $request = new Request(query: [ + // missing 'manager' + 'key' => 'abc', + ]); + + $controller->rowSelectorSelectAll($request); + } + + public function testRowSelectorSelectAllSelectsAll(): void { + $key = 'customers'; + $managerId = 'default'; + + /** @var SelectionInterface&MockObject $selection */ + $selection = $this->createMock(SelectionInterface::class); + $selection->expects($this->once())->method('selectAll'); + $selection->expects($this->never())->method('unselectAll'); + + $controller = $this->createControllerWithManager($managerId, $key, $selection); + + $request = new Request(query: [ + 'key' => $key, + 'manager' => $managerId, + 'selected' => '1', + ]); + + $response = $controller->rowSelectorSelectAll($request); + $this->assertSame(202, $response->getStatusCode()); + } + + public function testRowSelectorSelectAllUnselectsAllWhenSelectedFalse(): void { + $key = 'customers'; + $managerId = 'default'; + + /** @var SelectionInterface&MockObject $selection */ + $selection = $this->createMock(SelectionInterface::class); + $selection->expects($this->once())->method('unselectAll'); + $selection->expects($this->never())->method('selectAll'); + + + $controller = $this->createControllerWithManager($managerId, $key, $selection); + + $request = new Request(query: [ + 'key' => $key, + 'manager' => $managerId, + 'selected' => '0', + ]); + + $response = $controller->rowSelectorSelectAll($request); + $this->assertSame(202, $response->getStatusCode()); + } + + public function testThrowsWhenManagerNotFound(): void { + $controller = new SelectController(['known' => $this->createMock(SelectionManagerInterface::class)]); + + $this->expectException(BadRequestHttpException::class); + $controller->rowSelectorSelectAll(new Request(query: [ + 'key' => 'abc', + 'manager' => 'unknown', + ])); + } +} diff --git a/tests/Unit/Selection/Service/SelectionInterfaceTest.php b/tests/Unit/Selection/Service/SelectionInterfaceTest.php index 2349e13..1e99c62 100644 --- a/tests/Unit/Selection/Service/SelectionInterfaceTest.php +++ b/tests/Unit/Selection/Service/SelectionInterfaceTest.php @@ -318,5 +318,10 @@ public function getId(): int { return $this->id; } // getSelected returns map id => metadata (reverse transformed) $selected = $selection->getSelectedIdentifiers(); $this->assertSame([77], $selected); + + $selected=$selection->getSelected(); + $this->assertSame($fooClass, $selected[77]); + + $this->assertTrue($selection->isSelected($fooClass));; } } \ No newline at end of file From 59e5c5640a0f578758b2fbebccdcc460b390e343 Mon Sep 17 00:00:00 2001 From: tito10047 Date: Mon, 8 Dec 2025 14:23:44 +0100 Subject: [PATCH 10/13] wip --- config/routes.php | 1 - src/Selection/Service/Selection.php | 72 ++++-- .../Storage/SelectionSessionStorage.php | 232 +++++++----------- .../Storage/SelectionStorageInterface.php | 47 ++-- src/Storage/StorableEnvelope.php | 5 +- src/Twig/SelectionRuntime.php | 3 +- tests/App/AssetMapper/Src/Controller/.keep | 0 .../AssetMapper/config/routes/persisten.yaml | 2 + .../Selection/SelectionManagerTest.php | 12 +- .../Twig/SelectionExtensionTest.php | 15 +- tests/Trait/SessionInterfaceTrait.php | 14 ++ .../Service/SelectionInterfaceTest.php | 4 +- .../Storage/SelectionSessionStorageTest.php | 49 ++-- 13 files changed, 237 insertions(+), 219 deletions(-) create mode 100644 tests/App/AssetMapper/Src/Controller/.keep create mode 100644 tests/App/AssetMapper/config/routes/persisten.yaml diff --git a/config/routes.php b/config/routes.php index 63237aa..70e92d1 100644 --- a/config/routes.php +++ b/config/routes.php @@ -1,7 +1,6 @@ transformer->transform($item); - $has = $this->storage->hasIdentifier($this->key, $item); + $id = $this->normalizeIdentifier($item); + $has = $this->storage->hasIdentifier($this->key, $id); return $this->storage->getMode($this->key) === SelectionMode::INCLUDE ? $has : !$has; } public function select(mixed $item, null|array|object $metadata = null): static { - $id = is_scalar($item) ? $item : $this->transformer->transform($item)->toArray(); + $id = $this->normalizeIdentifier($item); $mode = $this->storage->getMode($this->key); $metaArray = null; if ($metadata !== null) { $metaArray = $this->metadataTransformer->transform($metadata)->toArray(); } if ($mode === SelectionMode::INCLUDE) { - // SessionStorage::add now expects a map [id => metadata] - $this->storage->add($this->key, [$id], $metaArray !== null ? [$id => $metaArray] : null); + $this->storage->set($this->key, $id, $metaArray); } else { // In EXCLUDE mode, selecting means removing the id from the exclusion list $this->storage->remove($this->key, [$id]); @@ -41,11 +40,11 @@ public function select(mixed $item, null|array|object $metadata = null): static } public function unselect(mixed $item): static { - $id = is_scalar($item) ? $item : $this->transformer->transform($item); + $id = $this->normalizeIdentifier($item); if ($this->storage->getMode($this->key) === SelectionMode::INCLUDE) { $this->storage->remove($this->key, [$id]); } else { - $this->storage->add($this->key, [$id], null); + $this->storage->set($this->key, $id, null); } return $this; } @@ -60,13 +59,13 @@ public function selectMultiple(array $items, null|array $metadata = null): stati if ($metadata === null) { $ids = []; foreach ($items as $item) { - $ids[] = is_scalar($item) ? $item : $this->transformer->transform($item); + $ids[] = $this->normalizeIdentifier($item); } - $this->storage->add($this->key, $ids, null); + $this->storage->setMultiple($this->key, $ids); return $this; } foreach ($items as $item) { - $id = is_scalar($item) ? $item : $this->transformer->transform($item); + $id = $this->normalizeIdentifier($item); $metaForId = null; if (array_key_exists($id, $metadata) || array_key_exists((string) $id, $metadata)) { @@ -75,9 +74,8 @@ public function selectMultiple(array $items, null|array $metadata = null): stati throw new \LogicException("No metadata found for id $id"); } - // Convert object metadata and pass as [id => meta] $metaForId = $this->metadataTransformer->transform($metaForId)->toArray(); - $this->storage->add($this->key, [$id], [$id => $metaForId]); + $this->storage->set($this->key, $id, $metaForId); } return $this; } @@ -85,7 +83,7 @@ public function selectMultiple(array $items, null|array $metadata = null): stati public function unselectMultiple(array $items): static { $ids = []; foreach ($items as $item) { - $ids[] = is_scalar($item) ? $item : $this->transformer->transform($item); + $ids[] = $this->normalizeIdentifier($item); } $this->storage->remove($this->key, $ids); return $this; @@ -125,7 +123,7 @@ public function getSelectedIdentifiers(): array { } foreach ($data as $key => $value) { if (is_array($value) && - ($envelope = StorableEnvelope::tryFromArray($value)) && + ($envelope = StorableEnvelope::tryFrom($value)) && $this->transformer->supportsReverse($envelope)) { $data[$key] = $this->transformer->reverseTransform($envelope); } @@ -134,7 +132,7 @@ public function getSelectedIdentifiers(): array { } public function update(mixed $item, object|array|null $metadata = null): static { - $id = is_scalar($item) ? $item : $this->transformer->transform($item); + $id = $this->normalizeIdentifier($item); if ($metadata === null) { return $this; // nothing to update } @@ -143,12 +141,12 @@ public function update(mixed $item, object|array|null $metadata = null): static $mode = $this->storage->getMode($this->key); if ($mode === SelectionMode::INCLUDE) { // Ensure metadata is persisted for this id (and id is included) - $this->storage->add($this->key, [$id], [$id => $metaArray]); + $this->storage->set($this->key, $id, $metaArray); return $this; } // In EXCLUDE mode, metadata can only be stored for explicitly excluded ids if ($this->storage->hasIdentifier($this->key, $id)) { - $this->storage->add($this->key, [$id], [$id => $metaArray]); + $this->storage->set($this->key, $id, $metaArray); } return $this; } @@ -156,10 +154,11 @@ public function update(mixed $item, object|array|null $metadata = null): static public function getSelected(): array { $mode = $this->storage->getMode($this->key); if ($mode === SelectionMode::INCLUDE) { - $map = $this->storage->getStoredWithMetadata($this->key); + $ids = $this->storage->getStored($this->key); $hydrated = []; - foreach ($map as $id => $meta) { - if ($meta = StorableEnvelope::tryFromArray($meta)) { + foreach ($ids as $id) { + $meta = $this->storage->getMetadata($this->key, $id); + if ($meta = StorableEnvelope::tryFrom($meta)) { $hydrated[$id] = $this->metadataTransformer->reverseTransform($meta); } else { $hydrated[$id] = []; @@ -174,8 +173,9 @@ public function getSelected(): array { $result = []; foreach ($selected as $id) { $meta = $this->storage->getMetadata($this->key, $id); - if ($meta = StorableEnvelope::tryFromArray($meta) !== null) { - $result[$id] = $this->metadataTransformer->reverseTransform($meta); + $envelope = StorableEnvelope::tryFrom($meta); + if ($envelope !== null) { + $result[$id] = $this->metadataTransformer->reverseTransform($envelope); } else { $result[$id] = []; } @@ -184,7 +184,7 @@ public function getSelected(): array { } public function getMetadata(mixed $item): null|array|object { - $id = is_scalar($item) ? $item : $this->transformer->transform($item); + $id = $this->normalizeIdentifier($item); $meta = $this->storage->getMetadata($this->key, $id); if ($meta === [] || $meta === null) { return null; @@ -197,7 +197,7 @@ public function getMetadata(mixed $item): null|array|object { } public function rememberAll(array $ids): static { - $this->storage->add($this->getAllContext(), $ids, null); + $this->storage->setMultiple($this->getAllContext(), $ids); return $this; } @@ -217,6 +217,28 @@ private function getAllMetaContext(): string { return $this->key . '__ALL_META__'; } + /** + * Normalize an item into a storage identifier (int|string|array). + */ + private function normalizeIdentifier(mixed $item): int|string|array { + if (is_scalar($item)) { + return $item; + } + $transformed = $this->transformer->transform($item); + if ($transformed instanceof StorableEnvelope) { + // If transformer can reverse, prefer scalar identifier (e.g., ObjectId -> int) + if ($this->transformer->supportsReverse($transformed)) { + $reversed = $this->transformer->reverseTransform($transformed); + if (is_scalar($reversed)) { + return $reversed; + } + } + // Fallback to serializable array form + return $transformed->toArray(); + } + return $transformed; // assume transformer returns scalar or array + } + public function destroy(): static { $this->storage->clear($this->key); $this->storage->clear($this->getAllContext()); @@ -282,7 +304,7 @@ public function registerSource(string $cacheKey, mixed $source, int|\DateInterva $meta = ['expiresAt' => $expiresAt]; } - $this->storage->add($this->getAllMetaContext(), [$cacheKey], $meta !== null ? [$cacheKey => $meta] : null); + $this->storage->set($this->getAllMetaContext(), $cacheKey, $meta); return $this; } diff --git a/src/Selection/Storage/SelectionSessionStorage.php b/src/Selection/Storage/SelectionSessionStorage.php index 60ae6c5..fba35d2 100644 --- a/src/Selection/Storage/SelectionSessionStorage.php +++ b/src/Selection/Storage/SelectionSessionStorage.php @@ -8,185 +8,133 @@ use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Tito10047\PersistentPreferenceBundle\Enum\SelectionMode; -use Tito10047\PersistentPreferenceBundle\Storage\StorableEnvelope; - -/** - * Default storage implementation using Symfony Session. - * - * Data structure in session: - * [ - * '_persistent_selection_{context}' => [ - * 'mode'=> 'include', // value of SelectionMode enum - * 'ids'=> [1, 2, 5], - * 'meta'=> [ - * 1=> [ ... metadata array ... ], - * 2=> [ ... metadata array ... ] - * ] - * ] - * ] - */ + + final class SelectionSessionStorage implements SelectionStorageInterface { private const SESSION_PREFIX = '_persistent_selection_'; - /** - * Fallback session used when there is no active HTTP session available (e.g. CLI/tests). - */ - private ?SessionInterface $fallbackSession = null; public function __construct( private readonly RequestStack $requestStack ) {} - public function add(string $context, array $ids, ?array $idMetadataMap): void - { - $data = $this->loadData($context); + public function set(string $context, int|array|string $identifier, ?array $metadata): void { + [$ids, $meta, $mode] = $this->loadContext($context); - $mergedIds = array_merge($data['ids'], $ids); + // add identifier if not present (loose check) + if (!in_array($identifier, $ids, false)) { + $ids[] = $identifier; + } + // persist metadata if provided + if ($metadata !== null) { + $key = $this->metaKey($identifier); + $meta[$key] = $metadata; + } - $data['ids'] = array_values(array_unique($mergedIds)); + $this->saveContext($context, $ids, $meta, $mode); + } - if ($idMetadataMap) { - foreach ($idMetadataMap as $id => $meta) { - // Pre istotu castujeme kľúč na string, aby bol v JSONe konzistentný - $data['meta'][(string)$id] = $meta; + public function setMultiple(string $context, array $identifiers): void { + [$ids, $meta, $mode] = $this->loadContext($context); + foreach ($identifiers as $id) { + if (!in_array($id, $ids, false)) { + $ids[] = $id; } } - - $this->saveData($context, $data); + $this->saveContext($context, $ids, $meta, $mode); } + public function remove(string $context, array $identifier): void { + $this->removeMultiple($context, $identifier); + } - public function remove(string $context, array $ids): void - { - $data = $this->loadData($context); - - // Remove specified IDs from the stored list - $diff = array_diff($data['ids'], $ids); - - // Re-index array after removal - $data['ids'] = array_values($diff); - - // Remove corresponding metadata entries for removed IDs - foreach ($ids as $id) { - unset($data['meta'][(string)$id]); + public function removeMultiple(string $context, array $identifiers): void { + [$ids, $meta, $mode] = $this->loadContext($context); + if ($ids === []) { + return; } - - $this->saveData($context, $data); + // remove ids and related metadata + $remaining = []; + foreach ($ids as $existing) { + if (!in_array($existing, $identifiers, false)) { + $remaining[] = $existing; + } else { + unset($meta[$this->metaKey($existing)]); + } + } + $this->saveContext($context, array_values($remaining), $meta, $mode); } - public function clear(string $context): void - { - $this->getSession()->remove($this->getKey($context)); + public function clear(string $context): void { + $this->saveContext($context, [], [], SelectionMode::INCLUDE); } - public function getStored(string $context): array - { - return $this->loadData($context)['ids']; + public function getStored(string $context): array { + [$ids] = $this->loadContext($context); + return $ids; } - public function hasIdentifier(string $context, int|string $id): bool - { - // Strict check might fail if mixing int/string types, - // but usually, we rely on PHP's loose comparison for in_array here - // or we should enforce type consistency in the add() method. - return in_array($id, $this->loadData($context)['ids']); + public function getMetadata(string $context, int|array|string $identifiers): array { + [, $meta] = $this->loadContext($context); + $key = $this->metaKey($identifiers); + return $meta[$key] ?? []; } - public function setMode(string $context, SelectionMode $mode): void - { - $data = $this->loadData($context); - $data['mode'] = $mode->value; - - $this->saveData($context, $data); + public function hasIdentifier(string $context, int|array|string $identifiers): bool { + [$ids] = $this->loadContext($context); + return in_array($identifiers, $ids, false); } - public function getMode(string $context): SelectionMode - { - $value = $this->loadData($context)['mode']; - - return SelectionMode::tryFrom($value) ?? SelectionMode::INCLUDE; + public function setMode(string $context, SelectionMode $mode): void { + [$ids, $meta] = $this->loadContext($context); + $this->saveContext($context, $ids, $meta, $mode); } - - /** - * Helper to retrieve the session service. - * Using RequestStack allows usage in services where the session might not be started yet. - */ - private function getSession(): SessionInterface - { - try { - return $this->requestStack->getSession(); - } catch (SessionNotFoundException $e) { - // No HTTP session available (likely CLI/tests). Use in-memory fallback session. - if ($this->fallbackSession === null) { - $this->fallbackSession = new Session(new MockArraySessionStorage()); - } - - return $this->fallbackSession; - } - } + public function getMode(string $context): SelectionMode { + [, , $mode] = $this->loadContext($context); + return $mode; + } /** - * Generates a namespaced key for the session. + * Internal helpers */ - private function getKey(string $context): string - { - return self::SESSION_PREFIX . $context; + private function loadContext(string $context): array { + $session = $this->getSession(); + $key = self::SESSION_PREFIX . $context; + $bag = $session->get($key, null); + if (!is_array($bag) || !isset($bag['ids'], $bag['meta'], $bag['mode'])) { + return [[], [], SelectionMode::INCLUDE]; + } + $mode = $bag['mode']; + if (!$mode instanceof SelectionMode) { + $mode = SelectionMode::tryFrom((string) $mode) ?? SelectionMode::INCLUDE; + } + return [$bag['ids'], $bag['meta'], $mode]; } - /** - * Loads raw data from session or returns default structure. - * Ensures presence of newly added keys for backward compatibility. - * - * @return array{mode: string, ids: array, meta: array} - */ - private function loadData(string $context): array - { - $data = $this->getSession()->get($this->getKey($context), [ - 'mode' => SelectionMode::INCLUDE->value, - 'ids' => [], - 'meta' => [], - ]); - - // Backward compatibility: add missing keys if old structure is present - if (!isset($data['meta']) || !is_array($data['meta'])) { - $data['meta'] = []; - } - if (!isset($data['ids']) || !is_array($data['ids'])) { - $data['ids'] = []; - } - if (!isset($data['mode']) || !is_string($data['mode'])) { - $data['mode'] = SelectionMode::INCLUDE->value; - } - - return $data; - } - - /** - * Persists the data structure back to the session. - * @param array{mode: string, ids: array, meta: array} $data - */ - private function saveData(string $context, array $data): void - { - $this->getSession()->set($this->getKey($context), $data); - } + private function saveContext(string $context, array $ids, array $meta, SelectionMode $mode): void { + $session = $this->getSession(); + $key = self::SESSION_PREFIX . $context; + $session->set($key, [ + 'ids' => array_values($ids), + 'meta' => $meta, + 'mode' => $mode, + ]); + } - public function getStoredWithMetadata(string $context): array { - $data = $this->loadData($context); - $result = []; - foreach ($data['ids'] as $id) { - if ($id instanceof StorableEnvelope){ - $id = $id->data; - } - $key = (string)$id; - $result[$id] = $data['meta'][$key] ?? []; + private function metaKey(int|array|string $identifier): string { + if (is_array($identifier)) { + return 'arr:' . json_encode($identifier, JSON_THROW_ON_ERROR); } - return $result; + return (string) $identifier; } - public function getMetadata(string $context, int|string $id): array { - $data = $this->loadData($context); - return $data['meta'][(string)$id] ?? []; - } + private function getSession(): SessionInterface { + try { + return $this->requestStack->getSession(); + } catch (SessionNotFoundException) { + throw new SessionNotFoundException('No session found. Make sure to start a session before using the selection service.'); + } + } } \ No newline at end of file diff --git a/src/Selection/Storage/SelectionStorageInterface.php b/src/Selection/Storage/SelectionStorageInterface.php index 998c82b..7101365 100644 --- a/src/Selection/Storage/SelectionStorageInterface.php +++ b/src/Selection/Storage/SelectionStorageInterface.php @@ -5,7 +5,6 @@ namespace Tito10047\PersistentPreferenceBundle\Selection\Storage; - use Tito10047\PersistentPreferenceBundle\Enum\SelectionMode; /** @@ -15,23 +14,38 @@ * or if they exist in the database. It only persists scalar values (int/string). * Complex logic regarding objects/UUIDs must be handled by the Manager layer. */ -interface SelectionStorageInterface -{ +interface SelectionStorageInterface { + + /** + * Pridá alebo aktualizuje identifikátor a ich pridružené dáta. + * + * @param int|string|array $identifier + * @param array|null $metadata + */ + public function set(string $context, int|string|array $identifier, ?array $metadata): void; + /** * Pridá alebo aktualizuje identifikátory a ich pridružené dáta. * - * @param array $ids - * @param array $idMetadataMap Mapa: ID => Konvertované pole metadát + * @param array $identifiers */ - public function add(string $context, array $ids, ?array $idMetadataMap): void; + public function setMultiple(string $context, array $identifiers): void; /** * Removes identifiers from the storage for a specific context. * - * @param string $context The unique context key - * @param array $ids List of identifiers to remove + * @param string $context The unique context key + * @param string|int|array $identifier List of identifiers to remove + */ + public function remove(string $context, array $identifier): void; + + /** + * Removes identifiers from the storage for a specific context. + * + * @param string $context The unique context key + * @param array $identifiers List of identifiers to remove */ - public function remove(string $context, array $ids): void; + public function removeMultiple(string $context, array $identifiers): void; /** * Clears all data for the given context and resets the mode to INCLUDE. @@ -48,28 +62,27 @@ public function clear(string $context): void; * - If Mode is EXCLUDE: These are the unselected items (exceptions). * * @param string $context The unique context key + * * @return array */ public function getStored(string $context): array; - public function getStoredWithMetadata(string $context):array; - - public function getMetadata(string $context, string|int $id):array; + public function getMetadata(string $context, string|int|array $identifiers): array; /** * Checks if a specific identifier is present in the storage. * This checks the raw storage, ignoring the current Mode logic. * - * @param string $context The unique context key - * @param string|int $id The identifier to check + * @param string $context The unique context key + * @param string|int $id The identifier to check */ - public function hasIdentifier(string $context, string|int $id): bool; + public function hasIdentifier(string $context, string|int|array $identifiers): bool; /** * Sets the selection mode (Include vs Exclude). * - * @param string $context The unique context key - * @param SelectionMode $mode The target mode + * @param string $context The unique context key + * @param SelectionMode $mode The target mode */ public function setMode(string $context, SelectionMode $mode): void; diff --git a/src/Storage/StorableEnvelope.php b/src/Storage/StorableEnvelope.php index 6ce532a..dc31272 100644 --- a/src/Storage/StorableEnvelope.php +++ b/src/Storage/StorableEnvelope.php @@ -21,7 +21,10 @@ public function __construct( public readonly array|string|null|int|float $data ) {} - public static function tryFromArray(mixed $meta): ?StorableEnvelope { + public static function tryFrom(mixed $meta): ?StorableEnvelope { + if (!is_array($meta)) { + return null; + } try{ return self::fromArray($meta); }catch (\InvalidArgumentException){ diff --git a/src/Twig/SelectionRuntime.php b/src/Twig/SelectionRuntime.php index beac9f2..fe9c3d6 100644 --- a/src/Twig/SelectionRuntime.php +++ b/src/Twig/SelectionRuntime.php @@ -73,8 +73,7 @@ public function rowSelector(string $key, mixed $item, array $attributes = [], st "class" => "row-selector" ]; - $id = $selection->normalize($item); - if ($selection->isSelected($id)) { + if ($selection->isSelected($item)) { $myAttributes["checked"] = 'checked'; } diff --git a/tests/App/AssetMapper/Src/Controller/.keep b/tests/App/AssetMapper/Src/Controller/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tests/App/AssetMapper/config/routes/persisten.yaml b/tests/App/AssetMapper/config/routes/persisten.yaml new file mode 100644 index 0000000..83cda2a --- /dev/null +++ b/tests/App/AssetMapper/config/routes/persisten.yaml @@ -0,0 +1,2 @@ +persistent_selection: + resource: '@PersistentPreferenceBundle/config/routes.php' diff --git a/tests/Integration/Selection/SelectionManagerTest.php b/tests/Integration/Selection/SelectionManagerTest.php index 3e90286..7483e91 100644 --- a/tests/Integration/Selection/SelectionManagerTest.php +++ b/tests/Integration/Selection/SelectionManagerTest.php @@ -4,20 +4,28 @@ use PHPUnit\Framework\Attributes\TestWith; use stdClass; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Tito10047\PersistentPreferenceBundle\Enum\SelectionMode; use Tito10047\PersistentPreferenceBundle\Selection\Service\SelectionManagerInterface; use Tito10047\PersistentPreferenceBundle\Selection\Service\SelectionInterface; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\ServiceHelper; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Support\TestList; use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; +use Tito10047\PersistentPreferenceBundle\Tests\Trait\SessionInterfaceTrait; class SelectionManagerTest extends AssetMapperKernelTestCase { + use SessionInterfaceTrait; + public function testGetSelectionAndSelectFlow(): void { - $container = self::getContainer(); + $this->initSession(); + $container = self::getContainer(); - /** @var SelectionInterface $manager */ + /** @var SelectionInterface $manager */ $manager = $container->get('persistent.selection.manager.scalar'); $this->assertInstanceOf(SelectionManagerInterface::class, $manager); diff --git a/tests/Integration/Twig/SelectionExtensionTest.php b/tests/Integration/Twig/SelectionExtensionTest.php index 8383227..ae3f143 100644 --- a/tests/Integration/Twig/SelectionExtensionTest.php +++ b/tests/Integration/Twig/SelectionExtensionTest.php @@ -8,10 +8,12 @@ use Tito10047\PersistentPreferenceBundle\Selection\Service\SelectionManagerInterface; use Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\User; use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; +use Tito10047\PersistentPreferenceBundle\Tests\Trait\SessionInterfaceTrait; use Twig\Environment; class SelectionExtensionTest extends AssetMapperKernelTestCase { + use SessionInterfaceTrait; public function testTwigFunctionsAreRegistered(): void { $container = self::getContainer(); @@ -36,7 +38,7 @@ public function testTwigFunctionsAreRegistered(): void public function testTwigFunctionsBehavior(): void { - $this->markTestSkipped("TODO: fix this test"); + $this->initSession(); $controllerName = PersistentPreferenceBundle::STIMULUS_CONTROLLER; $container = self::getContainer(); @@ -118,7 +120,7 @@ public function testTwigFunctionsBehavior(): void public function testRowSelectorCustomAttributesAreMergedAndEscaped(): void { - $this->markTestSkipped("TODO: fix this test"); + $this->initSession(); $container = self::getContainer(); /** @var SelectionManagerInterface $manager */ @@ -127,9 +129,10 @@ public function testRowSelectorCustomAttributesAreMergedAndEscaped(): void // Items $items = []; for ($i = 1; $i <= 1; $i++) { - $o = new \stdClass(); - $o->id = $i; - $items[] = $o; + $o = new User(); + $o->setId($i); + $o->setName('Item '.$i); + $items[] = $o; } // Register & select item to ensure 'checked' attribute appears @@ -168,7 +171,7 @@ public function testRowSelectorCustomAttributesAreMergedAndEscaped(): void public function testStimulusControllerMergesDataController(): void { - $this->markTestSkipped("TODO: fix this test"); + $this->initSession(); $container = self::getContainer(); /** @var Environment $twig */ $twig = $container->get('twig'); diff --git a/tests/Trait/SessionInterfaceTrait.php b/tests/Trait/SessionInterfaceTrait.php index bb15719..3e05555 100644 --- a/tests/Trait/SessionInterfaceTrait.php +++ b/tests/Trait/SessionInterfaceTrait.php @@ -2,7 +2,11 @@ namespace Tito10047\PersistentPreferenceBundle\Tests\Trait; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\SessionInterface; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; trait SessionInterfaceTrait { @@ -27,4 +31,14 @@ public function mockSessionInterface() { }); return $session; } + + public function initSession(): void { + $container = self::getContainer(); + /** @var RequestStack $requestStack */ + $requestStack = $container->get('request_stack'); + $session = new Session(new MockArraySessionStorage()); + $request = new Request(); + $request->setSession($session); + $requestStack->push($request); + } } \ No newline at end of file diff --git a/tests/Unit/Selection/Service/SelectionInterfaceTest.php b/tests/Unit/Selection/Service/SelectionInterfaceTest.php index 1e99c62..12a6939 100644 --- a/tests/Unit/Selection/Service/SelectionInterfaceTest.php +++ b/tests/Unit/Selection/Service/SelectionInterfaceTest.php @@ -320,8 +320,8 @@ public function getId(): int { return $this->id; } $this->assertSame([77], $selected); $selected=$selection->getSelected(); - $this->assertSame($fooClass, $selected[77]); + $this->assertSame([77=>[]], $selected); - $this->assertTrue($selection->isSelected($fooClass));; + $this->assertTrue($selection->isSelected(77));; } } \ No newline at end of file diff --git a/tests/Unit/Selection/Storage/SelectionSessionStorageTest.php b/tests/Unit/Selection/Storage/SelectionSessionStorageTest.php index 855e53b..4973ef3 100644 --- a/tests/Unit/Selection/Storage/SelectionSessionStorageTest.php +++ b/tests/Unit/Selection/Storage/SelectionSessionStorageTest.php @@ -29,8 +29,8 @@ public function testAddMergesAndDeduplicates(): void { $ctx = 'ctx_add'; - $this->storage->add($ctx, [1, 2, 3], null); - $this->storage->add($ctx, [2, 3, 4, '5'], null); + $this->storage->setMultiple($ctx, [1, 2, 3]); + $this->storage->setMultiple($ctx, [2, 3, 4, '5']); $this->assertSame([1, 2, 3, 4, '5'], $this->storage->getStored($ctx)); } @@ -39,7 +39,7 @@ public function testRemoveRemovesAndReindexes(): void { $ctx = 'ctx_remove'; - $this->storage->add($ctx, [1, 2, 3, 4], null); + $this->storage->setMultiple($ctx, [1, 2, 3, 4]); $this->storage->remove($ctx, [2, 4]); $this->assertSame([1, 3], $this->storage->getStored($ctx)); @@ -49,7 +49,7 @@ public function testClearResetsContext(): void { $ctx = 'ctx_clear'; - $this->storage->add($ctx, [7], null); + $this->storage->setMultiple($ctx, [7]); $this->storage->setMode($ctx, SelectionMode::EXCLUDE); $this->storage->clear($ctx); @@ -61,7 +61,7 @@ public function testClearResetsContext(): void public function testGetStoredIdentifiersReturnsCurrentIds(): void { $ctx = 'ctx_ids'; - $this->storage->add($ctx, [9, 10], null); + $this->storage->setMultiple($ctx, [9, 10]); $this->assertSame([9, 10], $this->storage->getStored($ctx)); } @@ -69,7 +69,7 @@ public function testGetStoredIdentifiersReturnsCurrentIds(): void public function testHasIdentifierUsesLooseComparison(): void { $ctx = 'ctx_has'; - $this->storage->add($ctx, [5], null); + $this->storage->setMultiple($ctx, [5]); // uses in_array with loose comparison in the implementation $this->assertTrue($this->storage->hasIdentifier($ctx, '5')); @@ -98,46 +98,53 @@ public function testAddWithMetadataAndGetStored(): void $ctx = 'ctx_meta'; $meta = ['foo' => 'bar', 'n' => 1]; - // New API: third parameter is an associative map id => metadata - $this->storage->add($ctx, [1, 2], [ - 1 => $meta, - 2 => $meta, - ]); + // New API: set per id with optional metadata + $this->storage->set($ctx, 1, $meta); + $this->storage->set($ctx, 2, $meta); // Non-overwritten metadata persists per id $this->assertSame($meta, $this->storage->getMetadata($ctx, 1)); $this->assertSame($meta, $this->storage->getMetadata($ctx, 2)); - // getStored returns id=>metadata map for stored ids + // Reconstruct map id=>metadata from API + $map = []; + foreach ($this->storage->getStored($ctx) as $id) { + $map[$id] = $this->storage->getMetadata($ctx, $id); + } $this->assertSame([ 1 => $meta, 2 => $meta, - ], $this->storage->getStoredWithMetadata($ctx)); + ], $map); // Add another id without metadata, should not override others - $this->storage->add($ctx, [3], null); + $this->storage->set($ctx, 3, null); $this->assertSame([], $this->storage->getMetadata($ctx, 3)); + $map = []; + foreach ($this->storage->getStored($ctx) as $id) { + $map[$id] = $this->storage->getMetadata($ctx, $id); + } $this->assertSame([ 1 => $meta, 2 => $meta, 3 => [], - ], $this->storage->getStoredWithMetadata($ctx)); + ], $map); } public function testRemoveAlsoRemovesMetadata(): void { $ctx = 'ctx_remove_meta'; $meta = ['x' => 10]; - // New API: provide map for both ids - $this->storage->add($ctx, [10, 11], [ - 10 => $meta, - 11 => $meta, - ]); + $this->storage->set($ctx, 10, $meta); + $this->storage->set($ctx, 11, $meta); $this->storage->remove($ctx, [10]); $this->assertSame([11], $this->storage->getStored($ctx)); $this->assertSame([], $this->storage->getMetadata($ctx, 10)); - $this->assertSame([11 => $meta], $this->storage->getStoredWithMetadata($ctx)); + $map = []; + foreach ($this->storage->getStored($ctx) as $id) { + $map[$id] = $this->storage->getMetadata($ctx, $id); + } + $this->assertSame([11 => $meta], $map); } } From 0a03b3d2d3e21a2e37bd4084c0cb43d25a767110 Mon Sep 17 00:00:00 2001 From: tito10047 Date: Mon, 8 Dec 2025 14:28:54 +0100 Subject: [PATCH 11/13] removed 8.1 version --- .github/workflows/symfony.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/symfony.yml b/.github/workflows/symfony.yml index 2897e74..8c7b0fa 100644 --- a/.github/workflows/symfony.yml +++ b/.github/workflows/symfony.yml @@ -29,10 +29,10 @@ jobs: fail-fast: false matrix: php-version: - - "8.1" - "8.2" - "8.3" - "8.4" + - "8.5" symfony: - '6.4.*' - '7.3.*' @@ -42,9 +42,6 @@ jobs: - "ubuntu-latest" exclude: # Symfony 7.3 does not support PHP 8.1 - - php-version: "8.1" - symfony: '7.3.*' - operating-system: "ubuntu-latest" - php-version: "8.1" symfony: '8.0.*' operating-system: "ubuntu-latest" From a24e3bd132a1f3b4b052d3aa444f5f535d200a2b Mon Sep 17 00:00:00 2001 From: tito10047 Date: Mon, 8 Dec 2025 21:17:50 +0100 Subject: [PATCH 12/13] added extensive PHPDoc comments across multiple interfaces --- src/Converter/MetadataConverterInterface.php | 23 ++- .../Service/PreferenceManagerInterface.php | 15 +- src/Resolver/ContextKeyResolverInterface.php | 18 +- .../Loader/IdentityLoaderInterface.php | 37 +++- src/Selection/Service/HasModeInterface.php | 14 +- .../Service/RegisterSelectionInterface.php | 18 +- src/Selection/Service/SelectionInterface.php | 165 +++++++++++------- .../Service/SelectionManagerInterface.php | 22 ++- .../Storage/SelectionStorageInterface.php | 43 +++-- 9 files changed, 246 insertions(+), 109 deletions(-) diff --git a/src/Converter/MetadataConverterInterface.php b/src/Converter/MetadataConverterInterface.php index f58bac7..c494d92 100644 --- a/src/Converter/MetadataConverterInterface.php +++ b/src/Converter/MetadataConverterInterface.php @@ -3,26 +3,31 @@ namespace Tito10047\PersistentPreferenceBundle\Converter; /** - * Definuje kontrakt pre extrahovanie a hydratáciu komplexných metadát (payload) - * na uložiteľné a čitateľné dáta. + * Defines the contract for extracting and hydrating complex metadata (payload) + * into a storable/readable representation and back. */ interface MetadataConverterInterface { /** - * Konvertuje objekt metadát na bezpečne uložiteľné pole. + * Converts a metadata object into a safely storable array representation. * - * @param object $metadataObject Objekt (napr. DomainConfig). - * @return array The resulting serializable array. + * The result should contain only scalar/array values suitable for storage + * (e.g. JSON column). Any nested objects must be normalized here. + * + * @param object $metadataObject Arbitrary metadata object (e.g. DomainConfig). + * @return array Serializable array to be persisted. */ public function convertToStorable(object $metadataObject): array; /** - * Konvertuje uložené pole späť na pôvodný objekt metadát. + * Converts previously stored array data back into the original metadata object. * - * @param array $storedData Pole s metadátami. - * @param string $targetClass FQCN cieľovej triedy. + * If the data cannot be hydrated to the target class, implementors may return + * null to indicate absence/invalid payload. * - * @return object|null + * @param array $storedData Raw metadata as read from storage. + * @param string $targetClass Fully-qualified class name to hydrate. + * @return object|null The hydrated metadata instance or null if not possible. */ public function convertFromStorable(array $storedData, string $targetClass): ?object; } \ No newline at end of file diff --git a/src/Preference/Service/PreferenceManagerInterface.php b/src/Preference/Service/PreferenceManagerInterface.php index fda334d..8bfaa72 100644 --- a/src/Preference/Service/PreferenceManagerInterface.php +++ b/src/Preference/Service/PreferenceManagerInterface.php @@ -5,7 +5,18 @@ use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; interface PreferenceManagerInterface { - public function getPreference(string|object $owner): PreferenceInterface; - public function getPreferenceStorage(): PreferenceStorageInterface; + /** + * Returns a Preference API instance bound to a resolved context for the given owner. + * + * The $owner may be a string context directly (e.g. "user_123") or an object + * that will be resolved to a context using registered ContextKeyResolver(s). + */ + public function getPreference(string|object $owner): PreferenceInterface; + + /** + * Exposes the low-level storage used by the manager. + * Useful for advanced scenarios or debugging. + */ + public function getPreferenceStorage(): PreferenceStorageInterface; } \ No newline at end of file diff --git a/src/Resolver/ContextKeyResolverInterface.php b/src/Resolver/ContextKeyResolverInterface.php index 5783e1f..925a0cf 100644 --- a/src/Resolver/ContextKeyResolverInterface.php +++ b/src/Resolver/ContextKeyResolverInterface.php @@ -3,11 +3,23 @@ namespace Tito10047\PersistentPreferenceBundle\Resolver; /** - * Strategy interface to resolve a context string from an arbitrary object. + * Strategy interface to resolve a persistent context string from an arbitrary object. + * + * Implementations are used by the PreferenceManager to turn domain objects + * (e.g. User, Tenant) into a unique context identifier string like + * "user_123" or "tenant:acme". */ interface ContextKeyResolverInterface { - public function supports(object $context): bool; + /** + * Whether this resolver can handle the given context object. + */ + public function supports(object $context): bool; - public function resolve(object $context): string; + /** + * Resolves a unique, stable context identifier for the given object. + * + * Should only be called if {@see supports()} returned true. + */ + public function resolve(object $context): string; } \ No newline at end of file diff --git a/src/Selection/Loader/IdentityLoaderInterface.php b/src/Selection/Loader/IdentityLoaderInterface.php index fd8af90..00b0bde 100644 --- a/src/Selection/Loader/IdentityLoaderInterface.php +++ b/src/Selection/Loader/IdentityLoaderInterface.php @@ -7,12 +7,33 @@ interface IdentityLoaderInterface { - public function loadAllIdentifiers(?ValueTransformerInterface $transformer, mixed $source): array; - - - public function getTotalCount(mixed $source): int; - - public function supports(mixed $source):bool; - - public function getCacheKey(mixed $source):string; + /** + * Extracts all stable item identifiers from a given source. + * + * The optional $transformer can be used to normalize values and/or metadata + * into a storage-friendly representation before returning identifiers. + * + * @param ValueTransformerInterface|null $transformer Optional transformer for normalization + * @param mixed $source Supported data source (e.g. array, Doctrine query) + * @return array List of identifiers + */ + public function loadAllIdentifiers(?ValueTransformerInterface $transformer, mixed $source): array; + + + /** + * Returns the total number of items available in the given source. + * This can be used for UI counters and pagination. + */ + public function getTotalCount(mixed $source): int; + + /** + * Whether this loader supports the provided source type/value. + */ + public function supports(mixed $source):bool; + + /** + * Produces a stable cache key representing the source configuration. + * Implementations should ensure the key changes when the source changes. + */ + public function getCacheKey(mixed $source):string; } \ No newline at end of file diff --git a/src/Selection/Service/HasModeInterface.php b/src/Selection/Service/HasModeInterface.php index 8572482..c05993b 100644 --- a/src/Selection/Service/HasModeInterface.php +++ b/src/Selection/Service/HasModeInterface.php @@ -6,6 +6,16 @@ use Tito10047\PersistentPreferenceBundle\Enum\SelectionMode; interface HasModeInterface { - public function setMode(SelectionMode $mode): void; - public function getMode(): SelectionMode; + /** + * Sets how the selection interprets stored identifiers. + * + * INCLUDE: stored IDs are the selected ones. + * EXCLUDE: stored IDs are exclusions (everything else is considered selected). + */ + public function setMode(SelectionMode $mode): void; + + /** + * Returns the current selection interpretation mode. + */ + public function getMode(): SelectionMode; } \ No newline at end of file diff --git a/src/Selection/Service/RegisterSelectionInterface.php b/src/Selection/Service/RegisterSelectionInterface.php index af5cc48..7ee57df 100644 --- a/src/Selection/Service/RegisterSelectionInterface.php +++ b/src/Selection/Service/RegisterSelectionInterface.php @@ -4,7 +4,21 @@ interface RegisterSelectionInterface { - public function registerSource(string $cacheKey, mixed $source, int|\DateInterval|null $ttl = null): static; + /** + * Registers a source for later reuse under a stable cache key. + * + * Useful when the same namespace needs to operate on multiple sources over + * time, or when you want to decouple source discovery from selection usage. + * + * @param string $cacheKey Stable key representing the source + * @param mixed $source Data source supported by an IdentityLoader + * @param int|\DateInterval|null $ttl Optional TTL for identifier caching + * @return static + */ + public function registerSource(string $cacheKey, mixed $source, int|\DateInterval|null $ttl = null): static; - public function hasSource(string $cacheKey): bool; + /** + * Whether a source with the given cache key was already registered. + */ + public function hasSource(string $cacheKey): bool; } \ No newline at end of file diff --git a/src/Selection/Service/SelectionInterface.php b/src/Selection/Service/SelectionInterface.php index 85b328a..074ba3b 100644 --- a/src/Selection/Service/SelectionInterface.php +++ b/src/Selection/Service/SelectionInterface.php @@ -5,68 +5,107 @@ interface SelectionInterface { - public function destroy(): static; - - public function isSelected(mixed $item): bool; - - public function isSelectedAll():bool; - - public function select(mixed $item, null|array|object $metadata=null): static; - public function update(mixed $item, null|array|object $metadata=null): static; - - public function unselect(mixed $item): static; - - - /** - * Prepína stav položky (napríklad aktivovanie/deaktivovanie) s možnosťou pripojiť metadata. - * - * @param mixed $item Položka na prepnutie stavu (Entity, Objekt alebo ID). - * @param null|array|object $metadata Dodatočné informácie alebo nastavenia priradené k položke. - * - * @return bool Vráti nový stav. - */ - public function toggle(mixed $item, null|array|object $metadata=null): bool; - - /** - * Zvolí viacero položiek naraz, pričom každej môže priradiť špecifické metadata. - * - * @param array $items Zoznam položiek (Entity, Objekty alebo ID). - * @param array $metadataMap - * Asociatívne pole, kde KĽÚČ je ID položky (získané normalizáciou) - * a HODNOTA sú metadata pre danú položku. - * Príklad: [101 => ['qty' => 5], 102 => ['qty' => 1]] - */ - public function selectMultiple(array $items, null|array $metadata=null):static; - public function unselectMultiple(array $items):static; - - public function selectAll():static; - - public function unselectAll():static; - - /** - * @return array - */ - public function getSelectedIdentifiers(): array; - /** - * Vráti mapu vybraných položiek. Ak je zadaná $metadataClass, metadáta sa hydratujú. - * - * @return array - * @template T of object - * @phpstan-param class-string|null $metadataClass - * @phpstan-return array|array - */ - public function getSelected(): array; - - /** - * Vráti mapu vybraných položiek. Ak je zadaná $metadataClass, metadáta sa hydratujú. - * - * @return T|array|null - * @template T of object - * @phpstan-param class-string|null $metadataClass - * @phpstan-return T|array - */ - public function getMetadata(mixed $item): null|array|object; - - public function getTotal():int; + /** + * Destroys all data for this selection namespace and resets to default state. + * Typically clears identifiers and sets mode back to INCLUDE. + */ + public function destroy(): static; + + /** + * Checks whether a given item is currently considered selected. + * Accepts an entity/object or a raw identifier. + */ + public function isSelected(mixed $item): bool; + + /** + * Returns true if the selection is in "select all" state. + * This is usually when mode is EXCLUDE and no exclusions are stored. + */ + public function isSelectedAll():bool; + + /** + * Marks an item as selected, optionally attaching metadata. + * In EXCLUDE mode this removes the item from the exclusion list. + */ + public function select(mixed $item, null|array|object $metadata=null): static; + + /** + * Updates metadata for a previously selected item. + * If the item was not selected yet, implementations may select it. + */ + public function update(mixed $item, null|array|object $metadata=null): static; + + /** + * Marks an item as unselected. + * In EXCLUDE mode this adds the item to the exclusion list. + */ + public function unselect(mixed $item): static; + + + /** + * Toggles the selection state of an item and returns the new state. + * Optional metadata is attached when toggling into the selected state. + */ + public function toggle(mixed $item, null|array|object $metadata=null): bool; + + /** + * Selects many items at once. + * When providing metadata, pass an associative map keyed by normalized ID. + * Example: [101 => ['qty' => 5], 102 => ['qty' => 1]] + * + * @param array $items List of items (entities/objects or identifiers) + * @param array|null $metadata Map of id => metadata + */ + public function selectMultiple(array $items, null|array $metadata=null):static; + + /** + * Unselects many items at once. + */ + public function unselectMultiple(array $items):static; + + /** + * Puts the selection into "select all" state (EXCLUDE mode with empty exclusions). + */ + public function selectAll():static; + + /** + * Clears the selection and returns to INCLUDE mode (nothing selected). + */ + public function unselectAll():static; + + /** + * Returns a list of normalized identifiers representing the raw storage. + * Note: In INCLUDE mode these are selected; in EXCLUDE mode these are exclusions. + * + * @return array + */ + public function getSelectedIdentifiers(): array; + + /** + * Returns a map of id => metadata for currently stored items. + * Implementations may hydrate metadata into objects. + * + * @return array + * @template T of object + * @phpstan-param class-string|null $metadataClass + * @phpstan-return array|array + */ + public function getSelected(): array; + + /** + * Returns metadata for a specific item, if present, optionally hydrated. + * + * @return T|array|null + * @template T of object + * @phpstan-param class-string|null $metadataClass + * @phpstan-return T|array + */ + public function getMetadata(mixed $item): null|array|object; + + /** + * Returns the total number of items in the registered source. + * Useful for UI displaying "X of Y selected". + */ + public function getTotal():int; } \ No newline at end of file diff --git a/src/Selection/Service/SelectionManagerInterface.php b/src/Selection/Service/SelectionManagerInterface.php index cd5226b..9a5ecb5 100644 --- a/src/Selection/Service/SelectionManagerInterface.php +++ b/src/Selection/Service/SelectionManagerInterface.php @@ -4,7 +4,25 @@ interface SelectionManagerInterface { + /** + * Registers a selectable source under a namespace and returns its Selection API. + * + * The $source can be any type supported by a configured IdentityLoader + * (e.g. Doctrine Query/QueryBuilder/Collection, arrays, scalars, etc.). + * The loader is responsible for extracting stable identifiers from the source. + * + * @param string $namespace A logical key for this selection (used for storage/session) + * @param mixed $source Data source to derive item identifiers from + * @param int|\DateInterval|null $ttl Optional time-to-live for cached identifier lists + * @return SelectionInterface Fluent API to manipulate selection state + */ + public function registerSelection(string $namespace, mixed $source, int|\DateInterval|null $ttl = null): SelectionInterface; - public function registerSelection(string $namespace, mixed $source, int|\DateInterval|null $ttl = null): SelectionInterface; - public function getSelection(string $namespace, mixed $owner = null): SelectionInterface; + /** + * Returns the Selection API for an already registered namespace. + * + * If $owner is provided, its context may be used by the storage layer + * (e.g. to scope selection to a user/tenant). + */ + public function getSelection(string $namespace, mixed $owner = null): SelectionInterface; } \ No newline at end of file diff --git a/src/Selection/Storage/SelectionStorageInterface.php b/src/Selection/Storage/SelectionStorageInterface.php index 7101365..9a345e2 100644 --- a/src/Selection/Storage/SelectionStorageInterface.php +++ b/src/Selection/Storage/SelectionStorageInterface.php @@ -16,20 +16,20 @@ */ interface SelectionStorageInterface { - /** - * Pridá alebo aktualizuje identifikátor a ich pridružené dáta. - * - * @param int|string|array $identifier - * @param array|null $metadata - */ - public function set(string $context, int|string|array $identifier, ?array $metadata): void; + /** + * Adds or updates a single identifier and its associated metadata. + * + * @param int|string|array $identifier Normalized identifier + * @param array|null $metadata Optional metadata to persist + */ + public function set(string $context, int|string|array $identifier, ?array $metadata): void; - /** - * Pridá alebo aktualizuje identifikátory a ich pridružené dáta. - * - * @param array $identifiers - */ - public function setMultiple(string $context, array $identifiers): void; + /** + * Adds or updates multiple identifiers (without metadata). + * + * @param array $identifiers List of normalized identifiers + */ + public function setMultiple(string $context, array $identifiers): void; /** * Removes identifiers from the storage for a specific context. @@ -67,16 +67,23 @@ public function clear(string $context): void; */ public function getStored(string $context): array; - public function getMetadata(string $context, string|int|array $identifiers): array; + /** + * Returns metadata stored for the given identifier(s). + * + * @param string $context The unique context key + * @param string|int|array $identifiers Single id or list of ids + * @return array Map of id => metadata (or null) + */ + public function getMetadata(string $context, string|int|array $identifiers): array; /** * Checks if a specific identifier is present in the storage. * This checks the raw storage, ignoring the current Mode logic. * - * @param string $context The unique context key - * @param string|int $id The identifier to check - */ - public function hasIdentifier(string $context, string|int|array $identifiers): bool; + * @param string $context The unique context key + * @param string|int|array $identifiers The identifier to check + */ + public function hasIdentifier(string $context, string|int|array $identifiers): bool; /** * Sets the selection mode (Include vs Exclude). From de52d2a07e6eecb171626526a2626def775abdec Mon Sep 17 00:00:00 2001 From: tito10047 Date: Tue, 9 Dec 2025 16:07:05 +0100 Subject: [PATCH 13/13] renamed bundle --- README.md | 16 ++--- composer.json | 6 +- config/routes.php | 6 +- config/services.php | 62 +++++++++---------- src/Command/DebugPreferenceCommand.php | 10 +-- src/Controller/SelectController.php | 4 +- src/Converter/MetadataConverterInterface.php | 2 +- src/Converter/ObjectVarsConverter.php | 2 +- src/Converter/SymfonySerializerConverter.php | 2 +- src/DataCollector/PreferenceDataCollector.php | 4 +- .../AutoTagContextKeyResolverPass.php | 6 +- .../Compiler/AutoTagIdentityLoadersPass.php | 4 +- .../Compiler/TraceableManagersPass.php | 8 +-- src/Enum/SelectionMode.php | 2 +- src/Event/PreferenceEvent.php | 2 +- src/Event/PreferenceEvents.php | 10 +-- ...ceBundle.php => PersistentStateBundle.php} | 30 ++++----- src/Preference/Service/Preference.php | 12 ++-- .../Service/PreferenceInterface.php | 2 +- src/Preference/Service/PreferenceManager.php | 10 +-- .../Service/PreferenceManagerInterface.php | 4 +- .../Service/TraceablePersistentManager.php | 10 +-- .../Service/TraceablePreference.php | 4 +- src/Preference/Storage/BasePreference.php | 2 +- .../Storage/PreferenceEntityInterface.php | 2 +- .../Storage/PreferenceSessionStorage.php | 4 +- .../Storage/PreferenceStorageInterface.php | 2 +- src/Resolver/ContextKeyResolverInterface.php | 2 +- src/Resolver/ObjectContextResolver.php | 4 +- src/Resolver/PersistentContextResolver.php | 4 +- .../StorableObjectConverterInterface.php | 4 +- src/Selection/Loader/ArrayLoader.php | 4 +- .../Loader/DoctrineCollectionLoader.php | 6 +- .../Loader/DoctrineQueryBuilderLoader.php | 8 +-- src/Selection/Loader/DoctrineQueryLoader.php | 6 +- .../Loader/IdentityLoaderInterface.php | 4 +- src/Selection/Service/HasModeInterface.php | 4 +- .../Service/RegisterSelectionInterface.php | 2 +- src/Selection/Service/Selection.php | 10 +-- src/Selection/Service/SelectionInterface.php | 2 +- src/Selection/Service/SelectionManager.php | 8 +-- .../Service/SelectionManagerInterface.php | 2 +- .../Storage/SelectionSessionStorage.php | 4 +- .../Storage/SelectionStorageInterface.php | 4 +- src/Storage/DoctrinePreferenceStorage.php | 6 +- src/Storage/StorableEnvelope.php | 2 +- src/Transformer/ArrayValueTransformer.php | 4 +- src/Transformer/ObjectIdValueTransformer.php | 4 +- src/Transformer/ScalarValueTransformer.php | 4 +- .../SerializableObjectTransformer.php | 4 +- src/Transformer/ValueTransformerInterface.php | 4 +- src/Twig/PreferenceExtension.php | 4 +- src/Twig/PreferenceRuntime.php | 4 +- src/Twig/SelectionExtension.php | 6 +- src/Twig/SelectionRuntime.php | 8 +-- templates/data_collector/panel.html.twig | 4 +- templates/hello.html.twig | 2 +- tests/App/AssetMapper/Src/Entity/Company.php | 2 +- .../AssetMapper/Src/Entity/RecordInteger.php | 2 +- .../App/AssetMapper/Src/Entity/RecordUuid.php | 2 +- .../AssetMapper/Src/Entity/TestCategory.php | 2 +- tests/App/AssetMapper/Src/Entity/User.php | 2 +- .../AssetMapper/Src/Entity/UserPreference.php | 4 +- .../Src/Factory/RecordIntegerFactory.php | 4 +- .../Src/Factory/RecordUuidFactory.php | 4 +- .../Src/Factory/TestCategoryFactory.php | 4 +- tests/App/AssetMapper/Src/ServiceHelper.php | 6 +- .../App/AssetMapper/Src/Support/TestList.php | 2 +- tests/App/AssetMapper/bin/console | 2 +- tests/App/AssetMapper/config/bundles.php | 2 +- .../AssetMapper/config/packages/doctrine.php | 2 +- .../AssetMapper/config/packages/maker.yaml | 2 +- .../config/packages/persistent.yaml | 16 ++--- tests/App/AssetMapper/config/routes.yaml | 2 +- .../config/routes/batch_selection.yaml | 4 +- .../AssetMapper/config/routes/persisten.yaml | 2 +- tests/App/AssetMapper/config/services.yaml | 4 +- tests/App/AssetMapper/public/index.php | 2 +- tests/App/Kernel.php | 2 +- tests/App/KernelTestCase.php | 2 +- .../DebugPreferenceCommandIntegrationTest.php | 8 +-- ...PreferenceDataCollectorIntegrationTest.php | 12 ++-- .../AutoTagContextKeyResolverPassTest.php | 6 +- .../Kernel/AssetMapperKernelTestCase.php | 6 +- .../Service/PreferenceManagerTest.php | 10 +-- .../Storage/DoctrineStorageTest.php | 6 +- .../Resolver/ObjectContextResolverTest.php | 12 ++-- .../Selection/SelectionManagerTest.php | 16 ++--- .../Twig/PreferenceExtensionTest.php | 10 +-- .../Twig/SelectionExtensionTest.php | 28 ++++----- tests/Trait/SessionInterfaceTrait.php | 2 +- .../Command/DebugPreferenceCommandTest.php | 14 ++--- .../Unit/Controller/SelectControllerTest.php | 8 +-- .../PreferenceDataCollectorTest.php | 6 +- .../Preference/Service/PreferenceTest.php | 16 ++--- .../Storage/PreferenceSessionStorageTest.php | 30 ++++----- .../Loader/DoctrineCollectionLoaderTest.php | 6 +- .../Loader/DoctrineQueryBuilderLoaderTest.php | 12 ++-- .../Loader/DoctrineQueryLoaderTest.php | 12 ++-- .../Service/SelectionInterfaceTest.php | 22 +++---- .../Storage/SelectionSessionStorageTest.php | 8 +-- .../ScalarValueTransformerTest.php | 6 +- tests/bootstrap.php | 2 +- 103 files changed, 348 insertions(+), 348 deletions(-) rename src/{PersistentPreferenceBundle.php => PersistentStateBundle.php} (69%) diff --git a/README.md b/README.md index 0ed8a2f..30c2551 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,29 @@ -![Tests](https://github.com//tito10047/persistent-preference-bundle/actions/workflows/symfony.yml/badge.svg) +![Tests](https://github.com//tito10047/persistent-state-bundle/actions/workflows/symfony.yml/badge.svg) -# 🛒 Persistent Preference Bundle +# 🛒 Persistent State Bundle ```yaml services: app.users_resolver: - class: Tito10047\PersistentPreferenceBundle\Resolver\ObjectContextResolver + class: Tito10047\PersistentStateBundle\Resolver\ObjectContextResolver arguments: $targetClass: App\Entity\User $identifierMethod: 'getName' app.companies_resolver: - class: Tito10047\PersistentPreferenceBundle\Resolver\ObjectContextResolver + class: Tito10047\PersistentStateBundle\Resolver\ObjectContextResolver arguments: $targetClass: App\Entity\Company app.storage.doctrine: - class: Tito10047\PersistentPreferenceBundle\Storage\DoctrinePreferenceStorage + class: Tito10047\PersistentStateBundle\Storage\DoctrinePreferenceStorage arguments: - '@doctrine.orm.entity_manager' - - Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\UserPreference + - Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\Entity\UserPreference persistent: preference: managers: default: - storage: '@persistent_preference.storage.session' + storage: '@persistent.preference.storage.session' my_pref_manager: storage: '@app.storage.doctrine' selection: @@ -125,6 +125,6 @@ Storage: doctrine Notes: - The `context` argument accepts either a pre-resolved key like `user_15` or any object supported by your configured context resolvers. -- The `--manager` option selects which preference manager to use. It maps to the service id `persistent_preference.manager.{name}` and defaults to `default` when omitted. +- The `--manager` option selects which preference manager to use. It maps to the service id `persistent.manager.{name}` and defaults to `default` when omitted. - The Storage line reflects the underlying storage: `session`, `doctrine`, or the short class name for custom storages. - Non-scalar values are JSON-encoded for readability; `null` and booleans are rendered as `null`, `true`/`false`. \ No newline at end of file diff --git a/composer.json b/composer.json index e73fccd..c725e13 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "tito10047/persistent-preference-bundle", + "name": "tito10047/persistent-state-bundle", "type": "symfony-bundle", "license": "MIT", "description": "Provides a unified API for handling persistent user preferences, UI states, and settings across different storage adapters (Doctrine, Session, Redis).", @@ -32,12 +32,12 @@ }, "autoload": { "psr-4": { - "Tito10047\\PersistentPreferenceBundle\\": "src/" + "Tito10047\\PersistentStateBundle\\": "src/" } }, "autoload-dev": { "psr-4": { - "Tito10047\\PersistentPreferenceBundle\\Tests\\": "tests/" + "Tito10047\\PersistentStateBundle\\Tests\\": "tests/" } }, "extra": { diff --git a/config/routes.php b/config/routes.php index 70e92d1..d245626 100644 --- a/config/routes.php +++ b/config/routes.php @@ -9,20 +9,20 @@ */ return static function (RoutingConfigurator $routes): void { $routes - ->add('persistent_selection_toggle', '/_persistent-selection/toggle') + ->add('persistent_selection_toggle', '/_persistent-state-selection/toggle') ->controller([SelectController::class, 'rowSelectorToggle']) ->methods(['GET']) ; // Označiť/odznačiť všetky riadky podľa kľúča $routes - ->add('persistent_selection_select_all', '/_persistent-selection/select-all') + ->add('persistent_selection_select_all', '/_persistent-state-selection/select-all') ->controller([SelectController::class, 'rowSelectorSelectAll']) ->methods(['GET']) ; $routes - ->add('persistent_selection_select_range', '/_persistent-selection/select-range') + ->add('persistent_selection_select_range', '/_persistent-state-selection/select-range') ->controller([SelectController::class, 'rowSelectorSelectRange']) ->methods(['POST']) ; diff --git a/config/services.php b/config/services.php index a51bd28..9b68efe 100644 --- a/config/services.php +++ b/config/services.php @@ -4,38 +4,38 @@ use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -use Tito10047\PersistentPreferenceBundle\Command\DebugPreferenceCommand; -use Tito10047\PersistentPreferenceBundle\Controller\SelectController; -use Tito10047\PersistentPreferenceBundle\Converter\MetadataConverterInterface; -use Tito10047\PersistentPreferenceBundle\Converter\ObjectVarsConverter; -use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; -use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagContextKeyResolverPass; -use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagIdentityLoadersPass; -use Tito10047\PersistentPreferenceBundle\DependencyInjection\Compiler\AutoTagValueTransformerPass; -use Tito10047\PersistentPreferenceBundle\PersistentPreferenceBundle; -use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManager; -use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManagerInterface; -use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceSessionStorage; -use Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface; -use Tito10047\PersistentPreferenceBundle\Selection\Loader\ArrayLoader; -use Tito10047\PersistentPreferenceBundle\Selection\Loader\DoctrineCollectionLoader; -use Tito10047\PersistentPreferenceBundle\Selection\Loader\DoctrineQueryBuilderLoader; -use Tito10047\PersistentPreferenceBundle\Selection\Loader\DoctrineQueryLoader; -use Tito10047\PersistentPreferenceBundle\Selection\Service\SelectionManager; -use Tito10047\PersistentPreferenceBundle\Selection\Service\SelectionManagerInterface; -use Tito10047\PersistentPreferenceBundle\Selection\Storage\SelectionSessionStorage; -use Tito10047\PersistentPreferenceBundle\Selection\Storage\SelectionStorageInterface; -use Tito10047\PersistentPreferenceBundle\Transformer\ArrayValueTransformer; -use Tito10047\PersistentPreferenceBundle\Transformer\ScalarValueTransformer; -use Tito10047\PersistentPreferenceBundle\Twig\PreferenceExtension; -use Tito10047\PersistentPreferenceBundle\Twig\PreferenceRuntime; -use Tito10047\PersistentPreferenceBundle\Twig\SelectionExtension; -use Tito10047\PersistentPreferenceBundle\Twig\SelectionRuntime; +use Tito10047\PersistentStateBundle\Command\DebugPreferenceCommand; +use Tito10047\PersistentStateBundle\Controller\SelectController; +use Tito10047\PersistentStateBundle\Converter\MetadataConverterInterface; +use Tito10047\PersistentStateBundle\Converter\ObjectVarsConverter; +use Tito10047\PersistentStateBundle\DataCollector\PreferenceDataCollector; +use Tito10047\PersistentStateBundle\DependencyInjection\Compiler\AutoTagContextKeyResolverPass; +use Tito10047\PersistentStateBundle\DependencyInjection\Compiler\AutoTagIdentityLoadersPass; +use Tito10047\PersistentStateBundle\DependencyInjection\Compiler\AutoTagValueTransformerPass; +use Tito10047\PersistentStateBundle\PersistentStateBundle; +use Tito10047\PersistentStateBundle\Preference\Service\PreferenceManager; +use Tito10047\PersistentStateBundle\Preference\Service\PreferenceManagerInterface; +use Tito10047\PersistentStateBundle\Preference\Storage\PreferenceSessionStorage; +use Tito10047\PersistentStateBundle\Preference\Storage\PreferenceStorageInterface; +use Tito10047\PersistentStateBundle\Selection\Loader\ArrayLoader; +use Tito10047\PersistentStateBundle\Selection\Loader\DoctrineCollectionLoader; +use Tito10047\PersistentStateBundle\Selection\Loader\DoctrineQueryBuilderLoader; +use Tito10047\PersistentStateBundle\Selection\Loader\DoctrineQueryLoader; +use Tito10047\PersistentStateBundle\Selection\Service\SelectionManager; +use Tito10047\PersistentStateBundle\Selection\Service\SelectionManagerInterface; +use Tito10047\PersistentStateBundle\Selection\Storage\SelectionSessionStorage; +use Tito10047\PersistentStateBundle\Selection\Storage\SelectionStorageInterface; +use Tito10047\PersistentStateBundle\Transformer\ArrayValueTransformer; +use Tito10047\PersistentStateBundle\Transformer\ScalarValueTransformer; +use Tito10047\PersistentStateBundle\Twig\PreferenceExtension; +use Tito10047\PersistentStateBundle\Twig\PreferenceRuntime; +use Tito10047\PersistentStateBundle\Twig\SelectionExtension; +use Tito10047\PersistentStateBundle\Twig\SelectionRuntime; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; /** - * Konfigurácia služieb pre PersistentPreferenceBundle – bez autowire/autoconfigure. + * Konfigurácia služieb pre PersistentStateBundle – bez autowire/autoconfigure. * Všetko je definované manuálne. */ return static function (ContainerConfigurator $container): void { @@ -64,7 +64,7 @@ ->set('persistent.preference.manager.default', PreferenceManager::class) ->public() ->arg('$resolvers', tagged_iterator(AutoTagContextKeyResolverPass::TAG)) - ->arg('$transformers', tagged_iterator(PersistentPreferenceBundle::TRANSFORMER_TAG)) + ->arg('$transformers', tagged_iterator(PersistentStateBundle::TRANSFORMER_TAG)) ->arg('$storage', service('persistent.preference.storage.session')) ->tag('persistent.preference.manager', ['name' => 'default']) ; @@ -93,10 +93,10 @@ // --- Built-in Value Transformers --- $services->set("persistent.transformer.array",ArrayValueTransformer::class) ->public() - ->tag(PersistentPreferenceBundle::TRANSFORMER_TAG); + ->tag(PersistentStateBundle::TRANSFORMER_TAG); $services->set("persistent.transformer.scalar",ScalarValueTransformer::class) ->public() - ->tag(PersistentPreferenceBundle::TRANSFORMER_TAG); + ->tag(PersistentStateBundle::TRANSFORMER_TAG); // --- Twig Runtime --- $services diff --git a/src/Command/DebugPreferenceCommand.php b/src/Command/DebugPreferenceCommand.php index 37211b6..c194002 100644 --- a/src/Command/DebugPreferenceCommand.php +++ b/src/Command/DebugPreferenceCommand.php @@ -1,6 +1,6 @@ findTaggedServiceIds($tagName, true) as $serviceId => $tagAttrsList) { // Determine manager name from tag attribute 'name' if available $managerName = $tagAttrsList[0]['name'] ?? $serviceId; diff --git a/src/Enum/SelectionMode.php b/src/Enum/SelectionMode.php index eea2a7d..4c8bf96 100644 --- a/src/Enum/SelectionMode.php +++ b/src/Enum/SelectionMode.php @@ -1,6 +1,6 @@ import('../config/definition.php'); @@ -33,13 +33,13 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $container->import('../config/services.php'); $services = $container->services(); // Default metadata converter service - $services->set('persistent_preference.converter.object_vars', ObjectVarsConverter::class) - ->alias(MetadataConverterInterface::class, 'persistent_preference.converter.object_vars'); + $services->set('persistent.preference.converter.object_vars', ObjectVarsConverter::class) + ->alias(MetadataConverterInterface::class, 'persistent.preference.converter.object_vars'); $configManagers = $config['preference']['managers'] ?? []; foreach ($configManagers as $name => $subConfig) { - $storage = service($subConfig['storage'] ?? '@persistent_preference.storage.session'); + $storage = service($subConfig['storage'] ?? '@persistent.preference.storage.session'); $storage = ltrim($storage, '@'); $services ->set('persistent.preference.manager.' . $name, PreferenceManager::class) @@ -48,7 +48,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C ->arg('$transformers', tagged_iterator(self::TRANSFORMER_TAG)) ->arg('$storage', service($storage)) ->arg('$dispatcher', service('event_dispatcher')) - ->tag('persistent_preference.manager', ['name' => $name]); + ->tag('persistent.preference.manager', ['name' => $name]); } $configManagers = $config['selection']['managers'] ?? []; diff --git a/src/Preference/Service/Preference.php b/src/Preference/Service/Preference.php index 0058edd..eee3100 100644 --- a/src/Preference/Service/Preference.php +++ b/src/Preference/Service/Preference.php @@ -1,13 +1,13 @@ managerName, $preference, $this->collector); } - public function getPreferenceStorage(): \Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface + public function getPreferenceStorage(): \Tito10047\PersistentStateBundle\Preference\Storage\PreferenceStorageInterface { return $this->inner->getPreferenceStorage(); } diff --git a/src/Preference/Service/TraceablePreference.php b/src/Preference/Service/TraceablePreference.php index 9ca67cc..0d6b3c1 100644 --- a/src/Preference/Service/TraceablePreference.php +++ b/src/Preference/Service/TraceablePreference.php @@ -1,8 +1,8 @@ PersistentPreferenceBundle::STIMULUS_CONTROLLER, + 'persistent_selection_stimulus_controller_name'=> PersistentStateBundle::STIMULUS_CONTROLLER, ]; } } diff --git a/src/Twig/SelectionRuntime.php b/src/Twig/SelectionRuntime.php index fe9c3d6..8f96f2f 100644 --- a/src/Twig/SelectionRuntime.php +++ b/src/Twig/SelectionRuntime.php @@ -1,16 +1,16 @@ $selectionManagers diff --git a/templates/data_collector/panel.html.twig b/templates/data_collector/panel.html.twig index 35f9adb..3fdf3d0 100644 --- a/templates/data_collector/panel.html.twig +++ b/templates/data_collector/panel.html.twig @@ -8,7 +8,7 @@ {% endset %} {% set text %}
- Persistent Preferences + Persistents {{ collector.preferencesCount }} položiek
{% endset %} @@ -24,7 +24,7 @@ {% endblock %} {% block panel %} -

Persistent Preferences

+

Persistents

{{ collector.preferencesCount }} diff --git a/templates/hello.html.twig b/templates/hello.html.twig index b7c7931..4373a6f 100644 --- a/templates/hello.html.twig +++ b/templates/hello.html.twig @@ -1 +1 @@ -

Hello PersistentPreferenceBundle!

+

Hello PersistentStateBundle!

diff --git a/tests/App/AssetMapper/Src/Entity/Company.php b/tests/App/AssetMapper/Src/Entity/Company.php index e8cb2f2..361a717 100644 --- a/tests/App/AssetMapper/Src/Entity/Company.php +++ b/tests/App/AssetMapper/Src/Entity/Company.php @@ -1,6 +1,6 @@ ['all' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], - Tito10047\PersistentPreferenceBundle\PersistentPreferenceBundle::class => ['all' => true], + Tito10047\PersistentStateBundle\PersistentStateBundle::class => ['all' => true], DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['all' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], diff --git a/tests/App/AssetMapper/config/packages/doctrine.php b/tests/App/AssetMapper/config/packages/doctrine.php index 31ed4af..bb1f179 100644 --- a/tests/App/AssetMapper/config/packages/doctrine.php +++ b/tests/App/AssetMapper/config/packages/doctrine.php @@ -21,7 +21,7 @@ 'type' => 'attribute', 'is_bundle' => false, 'dir' => '%kernel.project_dir%/tests/App/AssetMapper/Src/Entity', - 'prefix' => 'Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity', + 'prefix' => 'Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\Entity', 'alias' => 'App', ], ], diff --git a/tests/App/AssetMapper/config/packages/maker.yaml b/tests/App/AssetMapper/config/packages/maker.yaml index 07fc84a..470eaea 100644 --- a/tests/App/AssetMapper/config/packages/maker.yaml +++ b/tests/App/AssetMapper/config/packages/maker.yaml @@ -1,2 +1,2 @@ maker: - root_namespace: 'Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\' + root_namespace: 'Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\' diff --git a/tests/App/AssetMapper/config/packages/persistent.yaml b/tests/App/AssetMapper/config/packages/persistent.yaml index 29bfd73..13bf4fc 100644 --- a/tests/App/AssetMapper/config/packages/persistent.yaml +++ b/tests/App/AssetMapper/config/packages/persistent.yaml @@ -1,24 +1,24 @@ services: app.users_resolver: - class: Tito10047\PersistentPreferenceBundle\Resolver\ObjectContextResolver + class: Tito10047\PersistentStateBundle\Resolver\ObjectContextResolver arguments: - $class: Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\User + $class: Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\Entity\User $prefix: 'user_' app.companies_resolver: - class: Tito10047\PersistentPreferenceBundle\Resolver\ObjectContextResolver + class: Tito10047\PersistentStateBundle\Resolver\ObjectContextResolver arguments: - $class: Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\Company + $class: Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\Entity\Company $prefix: 'company_' $identifierMethod: 'getUuid' app.storage.doctrine: - class: Tito10047\PersistentPreferenceBundle\Storage\DoctrinePreferenceStorage + class: Tito10047\PersistentStateBundle\Storage\DoctrinePreferenceStorage arguments: - '@doctrine.orm.entity_manager' - - Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\UserPreference + - Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\Entity\UserPreference app.transformer.id: - class: Tito10047\PersistentPreferenceBundle\Transformer\ObjectIdValueTransformer + class: Tito10047\PersistentStateBundle\Transformer\ObjectIdValueTransformer arguments: - $class: Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Entity\User + $class: Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\Entity\User $identifierMethod: 'getId' persistent: diff --git a/tests/App/AssetMapper/config/routes.yaml b/tests/App/AssetMapper/config/routes.yaml index 3da74e1..9532b6a 100644 --- a/tests/App/AssetMapper/config/routes.yaml +++ b/tests/App/AssetMapper/config/routes.yaml @@ -1,5 +1,5 @@ controllers: resource: path: ../Src/Controller/ - namespace: Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\Controller + namespace: Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\Controller type: attribute diff --git a/tests/App/AssetMapper/config/routes/batch_selection.yaml b/tests/App/AssetMapper/config/routes/batch_selection.yaml index 2a377ec..5a7d6e2 100644 --- a/tests/App/AssetMapper/config/routes/batch_selection.yaml +++ b/tests/App/AssetMapper/config/routes/batch_selection.yaml @@ -1,2 +1,2 @@ -persistent_preference: - resource: '@PersistentPreferenceBundle/config/routes.php' +persistent: + resource: '@PersistentStateBundle/config/routes.php' diff --git a/tests/App/AssetMapper/config/routes/persisten.yaml b/tests/App/AssetMapper/config/routes/persisten.yaml index 83cda2a..3a472c8 100644 --- a/tests/App/AssetMapper/config/routes/persisten.yaml +++ b/tests/App/AssetMapper/config/routes/persisten.yaml @@ -1,2 +1,2 @@ persistent_selection: - resource: '@PersistentPreferenceBundle/config/routes.php' + resource: '@PersistentStateBundle/config/routes.php' diff --git a/tests/App/AssetMapper/config/services.yaml b/tests/App/AssetMapper/config/services.yaml index 7020fea..39f588f 100644 --- a/tests/App/AssetMapper/config/services.yaml +++ b/tests/App/AssetMapper/config/services.yaml @@ -13,13 +13,13 @@ services: # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name - Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\: + Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\: resource: '../Src/' # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones - Tito10047\PersistentPreferenceBundle\Tests\App\AssetMapper\Src\ServiceHelper: + Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\ServiceHelper: arguments: $resolvers: !tagged_iterator persistent.preference.context_key_resolver $transformers: !tagged_iterator persistent.preference.value_transformer diff --git a/tests/App/AssetMapper/public/index.php b/tests/App/AssetMapper/public/index.php index b9a8a1a..c0185c6 100644 --- a/tests/App/AssetMapper/public/index.php +++ b/tests/App/AssetMapper/public/index.php @@ -10,5 +10,5 @@ require_once dirname(__DIR__).'/../../../vendor/autoload_runtime.php'; return function (array $context) { - return new \Tito10047\PersistentPreferenceBundle\Tests\App\Kernel("dev","AssetMapper/config"); + return new \Tito10047\PersistentStateBundle\Tests\App\Kernel("dev","AssetMapper/config"); }; diff --git a/tests/App/Kernel.php b/tests/App/Kernel.php index 7bbca3c..e6e9e17 100644 --- a/tests/App/Kernel.php +++ b/tests/App/Kernel.php @@ -1,6 +1,6 @@ get('persistent.preference.manager.my_pref_manager'); $pmDoctrine->getPreference('user_15')->import([ 'theme' => 'dark', diff --git a/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php b/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php index 396762c..9fbddba 100644 --- a/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php +++ b/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tito10047\PersistentPreferenceBundle\Tests\Integration\DataCollector; +namespace Tito10047\PersistentStateBundle\Tests\Integration\DataCollector; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -10,10 +10,10 @@ use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; -use Tito10047\PersistentPreferenceBundle\DataCollector\PreferenceDataCollector; -use Tito10047\PersistentPreferenceBundle\Preference\Service\PreferenceManagerInterface; -use Tito10047\PersistentPreferenceBundle\Service\PersistentManagerInterface; -use Tito10047\PersistentPreferenceBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; +use Tito10047\PersistentStateBundle\DataCollector\PreferenceDataCollector; +use Tito10047\PersistentStateBundle\Preference\Service\PreferenceManagerInterface; +use Tito10047\PersistentStateBundle\Service\PersistentManagerInterface; +use Tito10047\PersistentStateBundle\Tests\Integration\Kernel\AssetMapperKernelTestCase; final class PreferenceDataCollectorIntegrationTest extends AssetMapperKernelTestCase { @@ -43,7 +43,7 @@ public function testCollectorCountsPersistedPreferencesForContext(): void ]); // Create collector (service may not be registered when profiler is absent) - $storage = $container->get(\Tito10047\PersistentPreferenceBundle\Preference\Storage\PreferenceStorageInterface::class); + $storage = $container->get(\Tito10047\PersistentStateBundle\Preference\Storage\PreferenceStorageInterface::class); $collector = new PreferenceDataCollector($storage); self::assertInstanceOf(DataCollectorInterface::class, $collector); diff --git a/tests/Integration/DependencyInjection/Compiler/AutoTagContextKeyResolverPassTest.php b/tests/Integration/DependencyInjection/Compiler/AutoTagContextKeyResolverPassTest.php index e676b8d..a67fad8 100644 --- a/tests/Integration/DependencyInjection/Compiler/AutoTagContextKeyResolverPassTest.php +++ b/tests/Integration/DependencyInjection/Compiler/AutoTagContextKeyResolverPassTest.php @@ -1,9 +1,9 @@ initSession(); - $controllerName = PersistentPreferenceBundle::STIMULUS_CONTROLLER; + $controllerName = PersistentStateBundle::STIMULUS_CONTROLLER; $container = self::getContainer(); /** @var SelectionManagerInterface $manager */ @@ -109,11 +109,11 @@ public function testTwigFunctionsBehavior(): void $attrs = $tplStimulus->render(); $this->assertStringContainsString("data-controller=\"{$controllerName}\"", $attrs); $this->assertStringContainsString("data-{$controllerName}-url-toggle-value=\"", $attrs); - $this->assertStringContainsString('/_persistent-selection/toggle', $attrs); + $this->assertStringContainsString('/_persistent-state-selection/toggle', $attrs); $this->assertStringContainsString("data-{$controllerName}-url-select-all-value=\"", $attrs); - $this->assertStringContainsString('/_persistent-selection/select-all', $attrs); + $this->assertStringContainsString('/_persistent-state-selection/select-all', $attrs); $this->assertStringContainsString("data-{$controllerName}-url-select-range-value=\"", $attrs); - $this->assertStringContainsString('/_persistent-selection/select-range', $attrs); + $this->assertStringContainsString('/_persistent-state-selection/select-range', $attrs); $this->assertStringContainsString("data-{$controllerName}-key-value=\"twig_key\"", $attrs); $this->assertStringContainsString("data-{$controllerName}-manager-value=\"default\"", $attrs); } @@ -175,7 +175,7 @@ public function testStimulusControllerMergesDataController(): void $container = self::getContainer(); /** @var Environment $twig */ $twig = $container->get('twig'); - $controllerName = PersistentPreferenceBundle::STIMULUS_CONTROLLER; + $controllerName = PersistentStateBundle::STIMULUS_CONTROLLER; // Provide custom data-controller to be concatenated after the bundle's default $tpl = $twig->createTemplate( diff --git a/tests/Trait/SessionInterfaceTrait.php b/tests/Trait/SessionInterfaceTrait.php index 3e05555..777f6cf 100644 --- a/tests/Trait/SessionInterfaceTrait.php +++ b/tests/Trait/SessionInterfaceTrait.php @@ -1,6 +1,6 @@ expects($this->atLeastOnce()) ->method('get') - ->with('_persistent_preference_ctx1', $this->isType('array')) + ->with('_persistent_ctx1', $this->isType('array')) ->willReturnOnConsecutiveCalls([], ['a' => 123]); $session->expects($this->once()) ->method('set') - ->with('_persistent_preference_ctx1', ['a' => 123]); + ->with('_persistent_ctx1', ['a' => 123]); $storage = $this->createStorageWithSession($session); $storage->set('ctx1', 'a', 123); @@ -75,12 +75,12 @@ public function testSetMultipleAndAll(): void // Start with existing bucket $session->expects($this->exactly(2)) ->method('get') - ->with('_persistent_preference_ctx2', $this->isType('array')) + ->with('_persistent_ctx2', $this->isType('array')) ->willReturnOnConsecutiveCalls(['x' => 1], ['x' => 1, 'a' => 2, 'b' => 3]); $session->expects($this->once()) ->method('set') - ->with('_persistent_preference_ctx2', ['x' => 1, 'a' => 2, 'b' => 3]); + ->with('_persistent_ctx2', ['x' => 1, 'a' => 2, 'b' => 3]); $storage = $this->createStorageWithSession($session); $storage->setMultiple('ctx2', ['a' => 2, 'b' => 3]); @@ -93,12 +93,12 @@ public function testHasAndRemove(): void $session->expects($this->exactly(3)) ->method('get') - ->with('_persistent_preference_ctx3', $this->isType('array')) + ->with('_persistent_ctx3', $this->isType('array')) ->willReturnOnConsecutiveCalls(['k' => null], ['k' => null], []); $session->expects($this->once()) ->method('set') - ->with('_persistent_preference_ctx3', []); + ->with('_persistent_ctx3', []); $storage = $this->createStorageWithSession($session); $this->assertTrue($storage->has('ctx3', 'k')); // exists even if null @@ -113,20 +113,20 @@ public function testDifferentContextsAreIsolated(): void $session->expects($this->exactly(4)) ->method('get') ->with($this->logicalOr( - $this->equalTo('_persistent_preference_A'), - $this->equalTo('_persistent_preference_B') + $this->equalTo('_persistent_A'), + $this->equalTo('_persistent_B') ), $this->isType('array')) ->willReturnMap([ - ['_persistent_preference_A', [], ['foo' => 1]], - ['_persistent_preference_A', [], ['foo' => 1]], - ['_persistent_preference_B', [], []], - ['_persistent_preference_B', [], []], + ['_persistent_A', [], ['foo' => 1]], + ['_persistent_A', [], ['foo' => 1]], + ['_persistent_B', [], []], + ['_persistent_B', [], []], ]); // one set call to put value into A $session->expects($this->once()) ->method('set') - ->with('_persistent_preference_A', ['foo' => 1]); + ->with('_persistent_A', ['foo' => 1]); $storage = $this->createStorageWithSession($session); $storage->set('A', 'foo', 1); diff --git a/tests/Unit/Selection/Loader/DoctrineCollectionLoaderTest.php b/tests/Unit/Selection/Loader/DoctrineCollectionLoaderTest.php index f9bc077..dec4115 100644 --- a/tests/Unit/Selection/Loader/DoctrineCollectionLoaderTest.php +++ b/tests/Unit/Selection/Loader/DoctrineCollectionLoaderTest.php @@ -1,10 +1,10 @@ createMock(ValueTransformerInterface::class); $custom->method('supports')->willReturnCallback(static fn($v) => is_array($v)); - $custom->method('transform')->willReturnCallback(static fn($v) => new \Tito10047\PersistentPreferenceBundle\Storage\StorableEnvelope('custom', $v)); + $custom->method('transform')->willReturnCallback(static fn($v) => new \Tito10047\PersistentStateBundle\Storage\StorableEnvelope('custom', $v)); $custom->method('supportsReverse')->willReturnCallback(static fn($env) => $env->className === 'custom'); $custom->method('reverseTransform')->willReturnCallback(static function ($env) { $o = new \stdClass(); diff --git a/tests/Unit/Selection/Storage/SelectionSessionStorageTest.php b/tests/Unit/Selection/Storage/SelectionSessionStorageTest.php index 4973ef3..c53afda 100644 --- a/tests/Unit/Selection/Storage/SelectionSessionStorageTest.php +++ b/tests/Unit/Selection/Storage/SelectionSessionStorageTest.php @@ -1,12 +1,12 @@