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" diff --git a/README.md b/README.md index 296cd68..30c2551 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,37 @@ -![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 -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\PersistentStateBundle\Resolver\ObjectContextResolver + arguments: + $targetClass: App\Entity\User + $identifierMethod: 'getName' + app.companies_resolver: + class: Tito10047\PersistentStateBundle\Resolver\ObjectContextResolver + arguments: + $targetClass: App\Entity\Company + app.storage.doctrine: + class: Tito10047\PersistentStateBundle\Storage\DoctrinePreferenceStorage + arguments: + - '@doctrine.orm.entity_manager' + - Tito10047\PersistentStateBundle\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(); } - } ``` @@ -98,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/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..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).", @@ -8,7 +8,8 @@ "preference", "batch", "pagination", - "ui" + "ui", + "symfony-ux" ], "minimum-stability": "stable", "authors": [ @@ -31,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/definition.php b/config/definition.php index 8a23593..755f43b 100644 --- a/config/definition.php +++ b/config/definition.php @@ -6,58 +6,57 @@ * @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() + $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() - ->end() - ; + ->arrayNode('selection') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('managers') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('storage') + ->defaultValue('@persistent.selection.storage.session') + ->cannotBeEmpty() + ->info('The service ID of the storage backend to use (e.g. "@app.storage.doctrine").') + ->end() + ->scalarNode('transformer') + ->defaultValue('@persistent.transformer.scalar') + ->cannotBeEmpty() + ->info('') + ->end() + ->scalarNode('metadata_transformer') + ->defaultValue('@persistent.transformer.array') + ->cannotBeEmpty() + ->info('') + ->end() + ->integerNode('ttl')->defaultNull()->min(0)->end() + ->end() + ->end() + ->end() + ->end() + ->end(); + + + $children->end(); + }; diff --git a/config/routes.php b/config/routes.php new file mode 100644 index 0000000..d245626 --- /dev/null +++ b/config/routes.php @@ -0,0 +1,29 @@ +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-state-selection/select-all') + ->controller([SelectController::class, 'rowSelectorSelectAll']) + ->methods(['GET']) + ; + + $routes + ->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 93b248a..9b68efe 100644 --- a/config/services.php +++ b/config/services.php @@ -3,26 +3,39 @@ 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\Converter\MetadataConverterInterface; -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\Resolver\PersistentContextResolver; -use Tito10047\PersistentPreferenceBundle\Transformer\ScalarValueTransformer; -use Tito10047\PersistentPreferenceBundle\Twig\PreferenceExtension; -use Tito10047\PersistentPreferenceBundle\Twig\PreferenceRuntime; -use Tito10047\PersistentPreferenceBundle\Command\DebugPreferenceCommand; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +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 { @@ -32,49 +45,59 @@ // --- 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'); - - // --- Built-in Resolvers --- - $services - ->set(PersistentContextResolver::class) - ->public() - ; - - // --- Built-in Value Transformers --- - $services - ->set(ScalarValueTransformer::class) - ->public() - ; + $services->alias(PreferenceStorageInterface::class, 'persistent.preference.storage.session'); // --- 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', PreferenceManager::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('$transformers', tagged_iterator(PersistentStateBundle::TRANSFORMER_TAG)) + ->arg('$storage', service('persistent.preference.storage.session')) + ->tag('persistent.preference.manager', ['name' => 'default']) ; - $services->alias(PreferenceManagerInterface::class, 'persistent_preference.manager.default'); + $services->alias(PreferenceManagerInterface::class, 'persistent.preference.manager.default'); + + // --- 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 --- + + // --- Twig Extension --- $services ->set(PreferenceExtension::class) ->public() ->tag('twig.extension') ; + // --- Built-in Value Transformers --- + $services->set("persistent.transformer.array",ArrayValueTransformer::class) + ->public() + ->tag(PersistentStateBundle::TRANSFORMER_TAG); + $services->set("persistent.transformer.scalar",ScalarValueTransformer::class) + ->public() + ->tag(PersistentStateBundle::TRANSFORMER_TAG); + // --- Twig Runtime --- $services ->set(PreferenceRuntime::class) @@ -82,7 +105,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) @@ -90,6 +132,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 @@ -99,7 +146,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', @@ -109,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/Command/DebugPreferenceCommand.php b/src/Command/DebugPreferenceCommand.php index 4eb0eb9..c194002 100644 --- a/src/Command/DebugPreferenceCommand.php +++ b/src/Command/DebugPreferenceCommand.php @@ -1,6 +1,6 @@ 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; @@ -49,7 +49,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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/Controller/SelectController.php b/src/Controller/SelectController.php new file mode 100644 index 0000000..915faa5 --- /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/Converter/MetadataConverterInterface.php b/src/Converter/MetadataConverterInterface.php index f58bac7..d9796dc 100644 --- a/src/Converter/MetadataConverterInterface.php +++ b/src/Converter/MetadataConverterInterface.php @@ -1,28 +1,33 @@ 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/Converter/ObjectVarsConverter.php b/src/Converter/ObjectVarsConverter.php index 58a805b..b5a5c79 100644 --- a/src/Converter/ObjectVarsConverter.php +++ b/src/Converter/ObjectVarsConverter.php @@ -1,6 +1,6 @@ reset(); } diff --git a/src/DependencyInjection/Compiler/AutoTagContextKeyResolverPass.php b/src/DependencyInjection/Compiler/AutoTagContextKeyResolverPass.php index c677c72..2051d20 100644 --- a/src/DependencyInjection/Compiler/AutoTagContextKeyResolverPass.php +++ b/src/DependencyInjection/Compiler/AutoTagContextKeyResolverPass.php @@ -1,12 +1,12 @@ getClass() ?: $id; // Fallback: service id can be FQCN + $class = $definition->getClass() ?: $id; // FQCN service id fallback if (!is_string($class) || $class === '') { continue; } @@ -42,14 +48,13 @@ public function process(ContainerBuilder $container): void continue; } - // Use ContainerBuilder's reflection helper to avoid triggering - // autoload errors for vendor/dev classes that may not be present. + // Safe reflection via container helper $reflection = $container->getReflectionClass($class, false); if (!$reflection) { - continue; // cannot reflect, skip silently + continue; } - if ($reflection->implementsInterface(ValueTransformerInterface::class)) { + if ($reflection->implementsInterface(IdentityLoaderInterface::class)) { $definition->addTag(self::TAG)->setPublic(true); } } diff --git a/src/DependencyInjection/Compiler/TraceableManagersPass.php b/src/DependencyInjection/Compiler/TraceableManagersPass.php index 5dde472..dd407b6 100644 --- a/src/DependencyInjection/Compiler/TraceableManagersPass.php +++ b/src/DependencyInjection/Compiler/TraceableManagersPass.php @@ -1,14 +1,14 @@ findTaggedServiceIds($tagName, true) as $serviceId => $tagAttrsList) { // Determine manager name from tag attribute 'name' if available $managerName = $tagAttrsList[0]['name'] ?? $serviceId; @@ -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..4c8bf96 --- /dev/null +++ b/src/Enum/SelectionMode.php @@ -0,0 +1,19 @@ +import('../config/definition.php'); - } - - public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { - $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'); - - // 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'] ?? []; - foreach ($configManagers as $name => $subConfig) { - $storage = service($subConfig['storage'] ?? 'persistent_preference.storage.session'); - $services - ->set('persistent_preference.manager.' . $name, PreferenceManager::class) - ->public() - ->arg('$resolvers', tagged_iterator(AutoTagContextKeyResolverPass::TAG)) - ->arg('$transformers', tagged_iterator(AutoTagValueTransformerPass::TAG)) - ->arg('$storage', $storage) - ->arg('$dispatcher', service('event_dispatcher')) - ->tag('persistent_preference.manager', ['name' => $name]); - } - } - - 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/PersistentStateBundle.php b/src/PersistentStateBundle.php new file mode 100644 index 0000000..5d33778 --- /dev/null +++ b/src/PersistentStateBundle.php @@ -0,0 +1,79 @@ +import('../config/definition.php'); + } + + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { + $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'); + + + $configManagers = $config['preference']['managers'] ?? []; + foreach ($configManagers as $name => $subConfig) { + $storage = service($subConfig['storage'] ?? '@persistent.preference.storage.session'); + $storage = ltrim($storage, '@'); + $services + ->set('persistent.preference.manager.' . $name, PreferenceManager::class) + ->public() + ->arg('$resolvers', tagged_iterator(AutoTagContextKeyResolverPass::TAG)) + ->arg('$transformers', tagged_iterator(self::TRANSFORMER_TAG)) + ->arg('$storage', service($storage)) + ->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'], '@')); + $metadataTransformer = service(ltrim($subConfig['metadata_transformer'], '@')); + $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) + ->tag('persistent.selection.manager', ['name' => $name]) + ; + } + } + + 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/Service/Preference.php b/src/Preference/Service/Preference.php similarity index 79% rename from src/Service/Preference.php rename to src/Preference/Service/Preference.php index 68ccc8e..eee3100 100644 --- a/src/Service/Preference.php +++ b/src/Preference/Service/Preference.php @@ -1,12 +1,13 @@ $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 @@ -153,21 +154,22 @@ private function applyTransform(mixed $value): mixed { foreach ($this->transformers as $transformer) { if ($transformer->supports($value)) { - return $transformer->transform($value); + return $transformer->transform($value)->toArray(); } } - 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/src/Service/PreferenceInterface.php b/src/Preference/Service/PreferenceInterface.php similarity index 97% rename from src/Service/PreferenceInterface.php rename to src/Preference/Service/PreferenceInterface.php index d6375bd..7c48a90 100644 --- a/src/Service/PreferenceInterface.php +++ b/src/Preference/Service/PreferenceInterface.php @@ -1,6 +1,6 @@ $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; } diff --git a/src/Preference/Service/PreferenceManagerInterface.php b/src/Preference/Service/PreferenceManagerInterface.php new file mode 100644 index 0000000..f034121 --- /dev/null +++ b/src/Preference/Service/PreferenceManagerInterface.php @@ -0,0 +1,22 @@ +inner->getPreference($owner); + return new TraceablePreference($this->managerName, $preference, $this->collector); + } + + public function getPreferenceStorage(): \Tito10047\PersistentStateBundle\Preference\Storage\PreferenceStorageInterface + { + return $this->inner->getPreferenceStorage(); + } + + public function getSelection(string $namespace, mixed $owner = null): SelectionInterface { + // TODO: Implement getSelection() method. + } +} diff --git a/src/Service/TraceablePreference.php b/src/Preference/Service/TraceablePreference.php similarity index 92% rename from src/Service/TraceablePreference.php rename to src/Preference/Service/TraceablePreference.php index 9655d4f..0d6b3c1 100644 --- a/src/Service/TraceablePreference.php +++ b/src/Preference/Service/TraceablePreference.php @@ -1,8 +1,8 @@ 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/PersistentContextResolver.php b/src/Resolver/PersistentContextResolver.php index 5e4baf2..ce1fff1 100644 --- a/src/Resolver/PersistentContextResolver.php +++ b/src/Resolver/PersistentContextResolver.php @@ -1,8 +1,8 @@ transform($item)->data; + } + + 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..b0ea8bd --- /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(); + } + + /** + * + * @inheritDoc + */ + public function loadAllIdentifiers(?ValueTransformerInterface $transformer, mixed $source): array + { + if (!$this->supports($source)) { + throw new InvalidArgumentException('Source must be a Doctrine Collection.'); + } + + /** @var Collection $source */ + $identifiers = []; + + foreach ($source as $item) { + $identifiers[] = $transformer->transform($item)->data; + } + + 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..c49ad19 --- /dev/null +++ b/src/Selection/Loader/DoctrineQueryBuilderLoader.php @@ -0,0 +1,206 @@ + + */ + public function loadAllIdentifiers(?ValueTransformerInterface $transformer, mixed $source): 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 = $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($identifierField){ + return $item[$identifierField]; + }, $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..0c93aad --- /dev/null +++ b/src/Selection/Loader/DoctrineQueryLoader.php @@ -0,0 +1,207 @@ + + */ + public function loadAllIdentifiers(?ValueTransformerInterface $transformer, mixed $source): 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 = $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..106960a --- /dev/null +++ b/src/Selection/Loader/IdentityLoaderInterface.php @@ -0,0 +1,39 @@ + 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 new file mode 100644 index 0000000..fd03f50 --- /dev/null +++ b/src/Selection/Service/HasModeInterface.php @@ -0,0 +1,21 @@ +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 = $this->normalizeIdentifier($item); + $mode = $this->storage->getMode($this->key); + $metaArray = null; + if ($metadata !== null) { + $metaArray = $this->metadataTransformer->transform($metadata)->toArray(); + } + if ($mode === SelectionMode::INCLUDE) { + $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]); + } + return $this; + } + + public function unselect(mixed $item): static { + $id = $this->normalizeIdentifier($item); + if ($this->storage->getMode($this->key) === SelectionMode::INCLUDE) { + $this->storage->remove($this->key, [$id]); + } else { + $this->storage->set($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[] = $this->normalizeIdentifier($item); + } + $this->storage->setMultiple($this->key, $ids); + return $this; + } + foreach ($items as $item) { + $id = $this->normalizeIdentifier($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"); + } + + $metaForId = $this->metadataTransformer->transform($metaForId)->toArray(); + $this->storage->set($this->key, $id, $metaForId); + } + return $this; + } + + public function unselectMultiple(array $items): static { + $ids = []; + foreach ($items as $item) { + $ids[] = $this->normalizeIdentifier($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) { + $data = $this->storage->getStored($this->key); + } else { + $excluded = $this->storage->getStored($this->key); + $all = $this->storage->getStored($this->getAllContext()); + $data = array_diff($all, $excluded); + } + foreach ($data as $key => $value) { + if (is_array($value) && + ($envelope = StorableEnvelope::tryFrom($value)) && + $this->transformer->supportsReverse($envelope)) { + $data[$key] = $this->transformer->reverseTransform($envelope); + } + } + return $data; + } + + public function update(mixed $item, object|array|null $metadata = null): static { + $id = $this->normalizeIdentifier($item); + if ($metadata === null) { + return $this; // nothing to update + } + $metaArray = $this->metadataTransformer->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->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->set($this->key, $id, $metaArray); + } + return $this; + } + + public function getSelected(): array { + $mode = $this->storage->getMode($this->key); + if ($mode === SelectionMode::INCLUDE) { + $ids = $this->storage->getStored($this->key); + $hydrated = []; + 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] = []; + } + } + 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); + $envelope = StorableEnvelope::tryFrom($meta); + if ($envelope !== null) { + $result[$id] = $this->metadataTransformer->reverseTransform($envelope); + } else { + $result[$id] = []; + } + } + return $result; + } + + public function getMetadata(mixed $item): null|array|object { + $id = $this->normalizeIdentifier($item); + $meta = $this->storage->getMetadata($this->key, $id); + if ($meta === [] || $meta === null) { + return null; + } + $meta = StorableEnvelope::fromArray($meta); + if ($this->metadataTransformer->supportsReverse($meta)) { + return $this->metadataTransformer->reverseTransform($meta); + } + return $meta; + } + + public function rememberAll(array $ids): static { + $this->storage->setMultiple($this->getAllContext(), $ids); + 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__'; + } + + /** + * 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()); + $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 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->set($this->getAllMetaContext(), $cacheKey, $meta); + + return $this; + } +} \ No newline at end of file diff --git a/src/Selection/Service/SelectionInterface.php b/src/Selection/Service/SelectionInterface.php new file mode 100644 index 0000000..3b40303 --- /dev/null +++ b/src/Selection/Service/SelectionInterface.php @@ -0,0 +1,111 @@ + ['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/SelectionManager.php b/src/Selection/Service/SelectionManager.php new file mode 100644 index 0000000..8c0fb2c --- /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/SelectionManagerInterface.php b/src/Selection/Service/SelectionManagerInterface.php new file mode 100644 index 0000000..e212398 --- /dev/null +++ b/src/Selection/Service/SelectionManagerInterface.php @@ -0,0 +1,28 @@ +loadContext($context); + + // 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; + } + + $this->saveContext($context, $ids, $meta, $mode); + } + + 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->saveContext($context, $ids, $meta, $mode); + } + + public function remove(string $context, array $identifier): void { + $this->removeMultiple($context, $identifier); + } + + public function removeMultiple(string $context, array $identifiers): void { + [$ids, $meta, $mode] = $this->loadContext($context); + if ($ids === []) { + return; + } + // 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->saveContext($context, [], [], SelectionMode::INCLUDE); + } + + public function getStored(string $context): array { + [$ids] = $this->loadContext($context); + return $ids; + } + + public function getMetadata(string $context, int|array|string $identifiers): array { + [, $meta] = $this->loadContext($context); + $key = $this->metaKey($identifiers); + return $meta[$key] ?? []; + } + + public function hasIdentifier(string $context, int|array|string $identifiers): bool { + [$ids] = $this->loadContext($context); + return in_array($identifiers, $ids, false); + } + + public function setMode(string $context, SelectionMode $mode): void { + [$ids, $meta] = $this->loadContext($context); + $this->saveContext($context, $ids, $meta, $mode); + } + + public function getMode(string $context): SelectionMode { + [, , $mode] = $this->loadContext($context); + return $mode; + } + + /** + * Internal helpers + */ + 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]; + } + + 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, + ]); + } + + private function metaKey(int|array|string $identifier): string { + if (is_array($identifier)) { + return 'arr:' . json_encode($identifier, JSON_THROW_ON_ERROR); + } + return (string) $identifier; + } + + 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 new file mode 100644 index 0000000..4c4a936 --- /dev/null +++ b/src/Selection/Storage/SelectionStorageInterface.php @@ -0,0 +1,102 @@ + $identifiers List of normalized identifiers + */ + 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 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 removeMultiple(string $context, array $identifiers): 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; + + /** + * 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|array $identifiers The identifier to check + */ + 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 + */ + 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/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 @@ -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 89% rename from src/Storage/DoctrineStorage.php rename to src/Storage/DoctrinePreferenceStorage.php index eb96e04..1d88e3d 100644 --- a/src/Storage/DoctrineStorage.php +++ b/src/Storage/DoctrinePreferenceStorage.php @@ -1,12 +1,12 @@ 'dark']) + */ + public readonly array|string|null|int|float $data + ) {} + + public static function tryFrom(mixed $meta): ?StorableEnvelope { + if (!is_array($meta)) { + return null; + } + try{ + return self::fromArray($meta); + }catch (\InvalidArgumentException){ + return null; + } + } + + /** + * 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/Transformer/ArrayValueTransformer.php b/src/Transformer/ArrayValueTransformer.php new file mode 100644 index 0000000..f245dae --- /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..39ed3f9 --- /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..62bf331 100644 --- a/src/Transformer/ScalarValueTransformer.php +++ b/src/Transformer/ScalarValueTransformer.php @@ -1,6 +1,8 @@ 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..f9af8e4 --- /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..5935e0f 100644 --- a/src/Transformer/ValueTransformerInterface.php +++ b/src/Transformer/ValueTransformerInterface.php @@ -1,6 +1,8 @@ ['__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/PreferenceExtension.php b/src/Twig/PreferenceExtension.php index a49cb61..2aaaebd 100644 --- a/src/Twig/PreferenceExtension.php +++ b/src/Twig/PreferenceExtension.php @@ -1,8 +1,8 @@ ['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'=> PersistentStateBundle::STIMULUS_CONTROLLER, + ]; + } +} diff --git a/src/Twig/SelectionRuntime.php b/src/Twig/SelectionRuntime.php new file mode 100644 index 0000000..8f96f2f --- /dev/null +++ b/src/Twig/SelectionRuntime.php @@ -0,0 +1,176 @@ + $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" + ]; + + if ($selection->isSelected($item)) { + $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/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/Controller/.keep b/tests/App/AssetMapper/Src/Controller/.keep new file mode 100644 index 0000000..e69de29 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 @@ 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..49b8e42 --- /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..85e657e --- /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/App/AssetMapper/Src/Entity/User.php b/tests/App/AssetMapper/Src/Entity/User.php index 464ad5e..d3aa13c 100644 --- a/tests/App/AssetMapper/Src/Entity/User.php +++ b/tests/App/AssetMapper/Src/Entity/User.php @@ -1,6 +1,6 @@ > */ + 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/bin/console b/tests/App/AssetMapper/bin/console index 34c0819..2e5f06c 100644 --- a/tests/App/AssetMapper/bin/console +++ b/tests/App/AssetMapper/bin/console @@ -2,7 +2,7 @@ ['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 new file mode 100644 index 0000000..13bf4fc --- /dev/null +++ b/tests/App/AssetMapper/config/packages/persistent.yaml @@ -0,0 +1,44 @@ +services: + app.users_resolver: + class: Tito10047\PersistentStateBundle\Resolver\ObjectContextResolver + arguments: + $class: Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\Entity\User + $prefix: 'user_' + app.companies_resolver: + class: Tito10047\PersistentStateBundle\Resolver\ObjectContextResolver + arguments: + $class: Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\Entity\Company + $prefix: 'company_' + $identifierMethod: 'getUuid' + app.storage.doctrine: + class: Tito10047\PersistentStateBundle\Storage\DoctrinePreferenceStorage + arguments: + - '@doctrine.orm.entity_manager' + - Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\Entity\UserPreference + app.transformer.id: + class: Tito10047\PersistentStateBundle\Transformer\ObjectIdValueTransformer + arguments: + $class: Tito10047\PersistentStateBundle\Tests\App\AssetMapper\Src\Entity\User + $identifierMethod: 'getId' + +persistent: + preference: + managers: + default: + storage: '@persistent.preference.storage.session' + my_pref_manager: + storage: '@app.storage.doctrine' + + selection: + managers: + default: + storage: '@persistent.selection.storage.session' + 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' \ No newline at end of file diff --git a/tests/App/AssetMapper/config/packages/persistent_preference.yaml b/tests/App/AssetMapper/config/packages/persistent_preference.yaml deleted file mode 100644 index d96668f..0000000 --- a/tests/App/AssetMapper/config/packages/persistent_preference.yaml +++ /dev/null @@ -1,21 +0,0 @@ -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' - - 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 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 new file mode 100644 index 0000000..3a472c8 --- /dev/null +++ b/tests/App/AssetMapper/config/routes/persisten.yaml @@ -0,0 +1,2 @@ +persistent_selection: + resource: '@PersistentStateBundle/config/routes.php' diff --git a/tests/App/AssetMapper/config/services.yaml b/tests/App/AssetMapper/config/services.yaml index 8eb91a8..39f588f 100644 --- a/tests/App/AssetMapper/config/services.yaml +++ b/tests/App/AssetMapper/config/services.yaml @@ -13,15 +13,15 @@ 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 + $resolvers: !tagged_iterator persistent.preference.context_key_resolver + $transformers: !tagged_iterator persistent.preference.value_transformer public: true 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 = 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 750ada3..9fbddba 100644 --- a/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php +++ b/tests/Integration/DataCollector/PreferenceDataCollectorIntegrationTest.php @@ -2,17 +2,18 @@ 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\Response; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Response; 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\Service\PreferenceManagerInterface; -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 { @@ -22,7 +23,7 @@ public function testCollectorCountsPersistedPreferencesForContext(): void $container = self::getContainer(); // Write some preferences using the real manager & session-backed storage - /** @var PreferenceManagerInterface $manager */ + /** @var PersistentManagerInterface $manager */ $manager = $container->get(PreferenceManagerInterface::class); $contextKey = 'integration_test_ctx'; @@ -42,7 +43,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\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 @@ 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/Storage/DoctrineStorageTest.php b/tests/Integration/Preference/Storage/DoctrineStorageTest.php similarity index 74% rename from tests/Integration/Storage/DoctrineStorageTest.php rename to tests/Integration/Preference/Storage/DoctrineStorageTest.php index 58ef2bf..ba1065c 100644 --- a/tests/Integration/Storage/DoctrineStorageTest.php +++ b/tests/Integration/Preference/Storage/DoctrineStorageTest.php @@ -1,9 +1,9 @@ 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'; @@ -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 a5f8ad3..2b7c425 100644 --- a/tests/Integration/Resolver/ObjectContextResolverTest.php +++ b/tests/Integration/Resolver/ObjectContextResolverTest.php @@ -1,15 +1,16 @@ initSession(); + $container = self::getContainer(); + + /** @var SelectionInterface $manager */ + $manager = $container->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/Integration/Twig/PreferenceExtensionTest.php b/tests/Integration/Twig/PreferenceExtensionTest.php index befae7e..dc48428 100644 --- a/tests/Integration/Twig/PreferenceExtensionTest.php +++ b/tests/Integration/Twig/PreferenceExtensionTest.php @@ -1,15 +1,15 @@ 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->initSession(); + $controllerName = PersistentStateBundle::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-state-selection/toggle', $attrs); + $this->assertStringContainsString("data-{$controllerName}-url-select-all-value=\"", $attrs); + $this->assertStringContainsString('/_persistent-state-selection/select-all', $attrs); + $this->assertStringContainsString("data-{$controllerName}-url-select-range-value=\"", $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); + } + + public function testRowSelectorCustomAttributesAreMergedAndEscaped(): void + { + $this->initSession(); + $container = self::getContainer(); + + /** @var SelectionManagerInterface $manager */ + $manager = $container->get(SelectionManagerInterface::class); + + // Items + $items = []; + for ($i = 1; $i <= 1; $i++) { + $o = new User(); + $o->setId($i); + $o->setName('Item '.$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->initSession(); + $container = self::getContainer(); + /** @var Environment $twig */ + $twig = $container->get('twig'); + $controllerName = PersistentStateBundle::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/Trait/SessionInterfaceTrait.php b/tests/Trait/SessionInterfaceTrait.php new file mode 100644 index 0000000..777f6cf --- /dev/null +++ b/tests/Trait/SessionInterfaceTrait.php @@ -0,0 +1,44 @@ +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; + } + + 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/Command/DebugPreferenceCommandTest.php b/tests/Unit/Command/DebugPreferenceCommandTest.php index e27733b..ac53f20 100644 --- a/tests/Unit/Command/DebugPreferenceCommandTest.php +++ b/tests/Unit/Command/DebugPreferenceCommandTest.php @@ -1,16 +1,17 @@ createMock(PreferenceInterface::class); $preference->method('all')->willReturn([ @@ -51,11 +52,11 @@ public function testSuccessWithRowsAndSessionStorageLabel(): void 'nothing' => null, ]); - $storage = new SessionStorage(new RequestStack()); + $storage = new PreferenceSessionStorage(new RequestStack()); $manager = $this->createMock(PreferenceManagerInterface::class); $manager->method('getPreference')->with($context)->willReturn($preference); - $manager->method('getStorage')->willReturn($storage); + $manager->method('getPreferenceStorage')->willReturn($storage); $container = $this->makeContainerMock([ $serviceId => $manager, @@ -86,16 +87,16 @@ 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(StorageInterface::class); + $storage = $this->createMock(PreferenceStorageInterface::class); $manager = $this->createMock(PreferenceManagerInterface::class); $manager->method('getPreference')->with($context)->willReturn($preference); - $manager->method('getStorage')->willReturn($storage); + $manager->method('getPreferenceStorage')->willReturn($storage); $container = $this->makeContainerMock([ $serviceId => $manager, @@ -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,13 +154,13 @@ 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]); // 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 {} @@ -170,7 +171,7 @@ public function all(string $context): array { return []; } $manager = $this->createMock(PreferenceManagerInterface::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/Controller/SelectControllerTest.php b/tests/Unit/Controller/SelectControllerTest.php new file mode 100644 index 0000000..70e2494 --- /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/DataCollector/PreferenceDataCollectorTest.php b/tests/Unit/DataCollector/PreferenceDataCollectorTest.php index c4ad001..eb210b3 100644 --- a/tests/Unit/DataCollector/PreferenceDataCollectorTest.php +++ b/tests/Unit/DataCollector/PreferenceDataCollectorTest.php @@ -2,20 +2,19 @@ declare(strict_types=1); -namespace Tito10047\PersistentPreferenceBundle\Tests\Unit\DataCollector; +namespace Tito10047\PersistentStateBundle\Tests\Unit\DataCollector; use PHPUnit\Framework\TestCase; 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\PersistentStateBundle\DataCollector\PreferenceDataCollector; +use Tito10047\PersistentStateBundle\Preference\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 +27,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/Preference/Service/PreferenceTest.php b/tests/Unit/Preference/Service/PreferenceTest.php new file mode 100644 index 0000000..0cbf6f8 --- /dev/null +++ b/tests/Unit/Preference/Service/PreferenceTest.php @@ -0,0 +1,309 @@ +createMock(ValueTransformerInterface::class); + $tr->method('supports')->willReturnCallback($supports); + $tr->method('transform')->willReturnCallback($transform); + $tr->method('supportsReverse')->willReturnCallback($supportsReverse ?? static fn() => false); + $tr->method('reverseTransform')->willReturnCallback($reverseTransform ?? static fn($v) => $v); + 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'; + $key = 'theme'; + $input = 'dark'; + $transformed = new StorableEnvelope("scalar",'dark_trans'); + + $storage = $this->createMock(PreferenceStorageInterface::class); + $storage->expects(self::once()) + ->method('set') + ->with($context, $key, $transformed->toArray()); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $order = 0; + $dispatcher->expects(self::exactly(2)) + ->method('dispatch') + ->willReturnCallback(function ($evt, $name) use (&$order, $context, $key, $input) { + $order++; + if ($order === 1) { + TestCase::assertInstanceOf(PreferenceEvent::class, $evt); + TestCase::assertSame(PreferenceEvents::PRE_SET, $name); + TestCase::assertSame($context, $evt->context); + TestCase::assertSame($key, $evt->key); + TestCase::assertSame($input, $evt->value); + } elseif ($order === 2) { + TestCase::assertInstanceOf(PreferenceEvent::class, $evt); + TestCase::assertSame(PreferenceEvents::POST_SET, $name); + TestCase::assertSame($context, $evt->context); + TestCase::assertSame($key, $evt->key); + TestCase::assertSame($input, $evt->value); + } + return $evt; + }); + + $transformer = $this->makeTransformer( + supports: static fn($v) => true, + transform: static fn($v) => $transformed, + ); + + $service = new Preference([$transformer], $context, $storage, $dispatcher); + $service->set($key, $input); + } + + public function testSetStopsOnPreEventPropagationStop(): void + { + $context = 'ctx2'; + $key = 'k'; + $value = 123; + + $storage = $this->createMock(PreferenceStorageInterface::class); + $storage->expects(self::never())->method('set'); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::isInstanceOf(PreferenceEvent::class), PreferenceEvents::PRE_SET) + ->willReturnCallback(function (PreferenceEvent $event) { + $event->stopPropagation(); + return $event; + }); + + $service = new Preference([], $context, $storage, $dispatcher); + $service->set($key, $value); // should early-return, no post dispatch + } + + public function testImportDispatchesPreForEachStoresOnceThenDispatchesPostForEach(): void + { + $context = 'ctx3'; + $values = ['a' => 1, 'b' => 2]; + $transformed = [ + 'a' => (new StorableEnvelope("scalar",'1t'))->toArray(), + 'b' => (new StorableEnvelope("scalar",'2t'))->toArray() + ]; + + $storage = $this->createMock(PreferenceStorageInterface::class); + $storage->expects(self::once()) + ->method('setMultiple') + ->with($context, $transformed); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + // Expect 4 dispatch calls: 2x PRE_SET, then 2x POST_SET + $dispatcher->expects(self::exactly(4)) + ->method('dispatch') + ->with(self::isInstanceOf(PreferenceEvent::class), self::logicalOr(PreferenceEvents::PRE_SET, PreferenceEvents::POST_SET)) + ->willReturnArgument(0); + + $transformer = $this->makeTransformer( + supports: static fn($v) => true, + transform: static function ($v) { return new StorableEnvelope('scalar', $v . 't'); } + ); + + $service = new Preference([$transformer], $context, $storage, $dispatcher); + $service->import($values); + } + + public function testImportStopsOnAnyPreEvent(): void + { + $context = 'ctx4'; + $values = ['x' => 10, 'y' => 20]; + + $storage = $this->createMock(PreferenceStorageInterface::class); + $storage->expects(self::never())->method('setMultiple'); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $call = 0; + $dispatcher->expects(self::atLeast(1)) + ->method('dispatch') + ->willReturnCallback(function (PreferenceEvent $event, string $name) use (&$call) { + ++$call; + if ($name === PreferenceEvents::PRE_SET) { + $event->stopPropagation(); // stop on first key + } + return $event; + }); + + $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); + } +} diff --git a/tests/Unit/Storage/SessionStorageTest.php b/tests/Unit/Preference/Storage/PreferenceSessionStorageTest.php similarity index 78% rename from tests/Unit/Storage/SessionStorageTest.php rename to tests/Unit/Preference/Storage/PreferenceSessionStorageTest.php index 8c5ec80..09731ef 100644 --- a/tests/Unit/Storage/SessionStorageTest.php +++ b/tests/Unit/Preference/Storage/PreferenceSessionStorageTest.php @@ -1,17 +1,17 @@ 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 */ @@ -56,12 +56,12 @@ public function testSetAndGetValue(): void // initial bucket empty $session->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 new file mode 100644 index 0000000..dec4115 --- /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..2846de2 --- /dev/null +++ b/tests/Unit/Selection/Loader/DoctrineQueryBuilderLoaderTest.php @@ -0,0 +1,147 @@ +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); + sort($foundIds); + + $this->assertEquals($ids, $foundIds); + } + + public function testGetCacheKeyStableAndDistinct(): void + { + RecordIntegerFactory::createMany(3); + + $loader = new DoctrineQueryBuilderLoader(); + + /** @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(); + + $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); + 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(); + + $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); + 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..f36d110 --- /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); + 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); + 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); + 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/Service/SelectionInterfaceTest.php b/tests/Unit/Selection/Service/SelectionInterfaceTest.php new file mode 100644 index 0000000..f932d72 --- /dev/null +++ b/tests/Unit/Selection/Service/SelectionInterfaceTest.php @@ -0,0 +1,327 @@ +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)); + } + + 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\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(); + 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); + + $selected=$selection->getSelected(); + $this->assertSame([77=>[]], $selected); + + $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 new file mode 100644 index 0000000..c53afda --- /dev/null +++ b/tests/Unit/Selection/Storage/SelectionSessionStorageTest.php @@ -0,0 +1,150 @@ +createMock(RequestStack::class); + $requestStack->method('getSession')->willReturn($this->mockSessionInterface()); + + $this->storage = new SelectionSessionStorage($requestStack); + } + + public function testAddMergesAndDeduplicates(): void + { + $ctx = 'ctx_add'; + + $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)); + } + + public function testRemoveRemovesAndReindexes(): void + { + $ctx = 'ctx_remove'; + + $this->storage->setMultiple($ctx, [1, 2, 3, 4]); + $this->storage->remove($ctx, [2, 4]); + + $this->assertSame([1, 3], $this->storage->getStored($ctx)); + } + + public function testClearResetsContext(): void + { + $ctx = 'ctx_clear'; + + $this->storage->setMultiple($ctx, [7]); + $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->setMultiple($ctx, [9, 10]); + + $this->assertSame([9, 10], $this->storage->getStored($ctx)); + } + + public function testHasIdentifierUsesLooseComparison(): void + { + $ctx = 'ctx_has'; + $this->storage->setMultiple($ctx, [5]); + + // 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: 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)); + + // 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, + ], $map); + + // Add another id without metadata, should not override others + $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 => [], + ], $map); + } + + public function testRemoveAlsoRemovesMetadata(): void + { + $ctx = 'ctx_remove_meta'; + $meta = ['x' => 10]; + $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)); + $map = []; + foreach ($this->storage->getStored($ctx) as $id) { + $map[$id] = $this->storage->getMetadata($ctx, $id); + } + $this->assertSame([11 => $meta], $map); + } +} diff --git a/tests/Unit/Service/PreferenceTest.php b/tests/Unit/Service/PreferenceTest.php deleted file mode 100644 index 48bf226..0000000 --- a/tests/Unit/Service/PreferenceTest.php +++ /dev/null @@ -1,140 +0,0 @@ -createMock(ValueTransformerInterface::class); - $tr->method('supports')->willReturnCallback($supports); - $tr->method('transform')->willReturnCallback($transform); - $tr->method('supportsReverse')->willReturnCallback($supportsReverse ?? static fn() => false); - $tr->method('reverseTransform')->willReturnCallback($reverseTransform ?? static fn($v) => $v); - return $tr; - } - - public function testSetDispatchesPreThenStoresThenDispatchesPost(): void - { - $context = 'ctx1'; - $key = 'theme'; - $input = 'dark'; - $transformed = 'dark_trans'; - - $storage = $this->createMock(StorageInterface::class); - $storage->expects(self::once()) - ->method('set') - ->with($context, $key, $transformed); - - $dispatcher = $this->createMock(EventDispatcherInterface::class); - $order = 0; - $dispatcher->expects(self::exactly(2)) - ->method('dispatch') - ->willReturnCallback(function ($evt, $name) use (&$order, $context, $key, $input) { - $order++; - if ($order === 1) { - TestCase::assertInstanceOf(PreferenceEvent::class, $evt); - TestCase::assertSame(PreferenceEvents::PRE_SET, $name); - TestCase::assertSame($context, $evt->context); - TestCase::assertSame($key, $evt->key); - TestCase::assertSame($input, $evt->value); - } elseif ($order === 2) { - TestCase::assertInstanceOf(PreferenceEvent::class, $evt); - TestCase::assertSame(PreferenceEvents::POST_SET, $name); - TestCase::assertSame($context, $evt->context); - TestCase::assertSame($key, $evt->key); - TestCase::assertSame($input, $evt->value); - } - return $evt; - }); - - $transformer = $this->makeTransformer( - supports: static fn($v) => true, - transform: static fn($v) => $transformed, - ); - - $service = new Preference([$transformer], $context, $storage, $dispatcher); - $service->set($key, $input); - } - - public function testSetStopsOnPreEventPropagationStop(): void - { - $context = 'ctx2'; - $key = 'k'; - $value = 123; - - $storage = $this->createMock(StorageInterface::class); - $storage->expects(self::never())->method('set'); - - $dispatcher = $this->createMock(EventDispatcherInterface::class); - $dispatcher->expects(self::once()) - ->method('dispatch') - ->with(self::isInstanceOf(PreferenceEvent::class), PreferenceEvents::PRE_SET) - ->willReturnCallback(function (PreferenceEvent $event) { - $event->stopPropagation(); - return $event; - }); - - $service = new Preference([], $context, $storage, $dispatcher); - $service->set($key, $value); // should early-return, no post dispatch - } - - public function testImportDispatchesPreForEachStoresOnceThenDispatchesPostForEach(): void - { - $context = 'ctx3'; - $values = ['a' => 1, 'b' => 2]; - $transformed = ['a' => '1t', 'b' => '2t']; - - $storage = $this->createMock(StorageInterface::class); - $storage->expects(self::once()) - ->method('setMultiple') - ->with($context, $transformed); - - $dispatcher = $this->createMock(EventDispatcherInterface::class); - // Expect 4 dispatch calls: 2x PRE_SET, then 2x POST_SET - $dispatcher->expects(self::exactly(4)) - ->method('dispatch') - ->with(self::isInstanceOf(PreferenceEvent::class), self::logicalOr(PreferenceEvents::PRE_SET, PreferenceEvents::POST_SET)) - ->willReturnArgument(0); - - $transformer = $this->makeTransformer( - supports: static fn($v) => true, - transform: static function ($v) { return $v . 't'; } - ); - - $service = new Preference([$transformer], $context, $storage, $dispatcher); - $service->import($values); - } - - public function testImportStopsOnAnyPreEvent(): void - { - $context = 'ctx4'; - $values = ['x' => 10, 'y' => 20]; - - $storage = $this->createMock(StorageInterface::class); - $storage->expects(self::never())->method('setMultiple'); - - $dispatcher = $this->createMock(EventDispatcherInterface::class); - $call = 0; - $dispatcher->expects(self::atLeast(1)) - ->method('dispatch') - ->willReturnCallback(function (PreferenceEvent $event, string $name) use (&$call) { - ++$call; - if ($name === PreferenceEvents::PRE_SET) { - $event->stopPropagation(); // stop on first key - } - return $event; - }); - - $service = new Preference([], $context, $storage, $dispatcher); - $service->import($values); - } -} diff --git a/tests/Unit/Transformer/ScalarValueTransformerTest.php b/tests/Unit/Transformer/ScalarValueTransformerTest.php index 27d1622..2050966 100644 --- a/tests/Unit/Transformer/ScalarValueTransformerTest.php +++ b/tests/Unit/Transformer/ScalarValueTransformerTest.php @@ -1,9 +1,10 @@ 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); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 2cc9b56..4baeb8b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -3,7 +3,7 @@ use Symfony\Component\Dotenv\Dotenv; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Input\ArrayInput; -use Tito10047\PersistentPreferenceBundle\Tests\App\Kernel; +use Tito10047\PersistentStateBundle\Tests\App\Kernel; // needed to avoid encoding issues when running tests on different platforms setlocale(LC_ALL, 'en_US.UTF-8');