diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile deleted file mode 100644 index a3a7de4..0000000 --- a/.docker/php/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -ARG PHP_VERSION=8.3 - -FROM php:${PHP_VERSION}-alpine - -# Install system dependencies -RUN apk update && apk add --no-cache \ - $PHPIZE_DEPS \ - linux-headers \ - zlib-dev \ - libmemcached-dev \ - cyrus-sasl-dev - -RUN pecl install xdebug redis memcached \ - && docker-php-ext-enable xdebug redis memcached - -# Copy custom PHP configuration -COPY .docker/php/kariricode-php.ini /usr/local/etc/php/conf.d/ - -# Instalação do Composer -RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - -RUN apk del --purge $PHPIZE_DEPS && rm -rf /var/cache/apk/* - -# Mantém o contêiner ativo sem fazer nada -CMD tail -f /dev/null diff --git a/.docker/php/kariricode-php.ini b/.docker/php/kariricode-php.ini deleted file mode 100644 index 9e90446..0000000 --- a/.docker/php/kariricode-php.ini +++ /dev/null @@ -1,14 +0,0 @@ -[PHP] -memory_limit = 256M -upload_max_filesize = 50M -post_max_size = 50M -date.timezone = America/Sao_Paulo - -[Xdebug] -; zend_extension=xdebug.so -xdebug.mode=debug -xdebug.start_with_request=yes -xdebug.client_host=host.docker.internal -xdebug.client_port=9003 -xdebug.log=/tmp/xdebug.log -xdebug.idekey=VSCODE diff --git a/.env.example b/.env.example deleted file mode 100644 index e461630..0000000 --- a/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -KARIRI_APP_ENV=develop -KARIRI_PHP_VERSION=8.3 -KARIRI_PHP_PORT=9003 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6bbd010 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +# ARFA 1.3 / KaririCode Spec V4.0 — Unified CI Pipeline +# Runs on every push and PR targeting main or develop. +# Full pipeline: cs-fixer → phpstan (L9) → psalm → phpunit (pcov) +# Zero tolerance: any tool failure blocks the merge. + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + workflow_dispatch: + +jobs: + quality: + name: Quality Pipeline (ARFA 1.3) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # PHP 8.4 + pcov (mandatory driver per ARFA 1.3 §Testing) + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, xml + coverage: pcov + + # Pure dependency install — no scripts to avoid environment pollution + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress --no-scripts + + # Bootstrap kcode.phar from the official KaririCode release + - name: Install kcode (KaririCode Devkit) + run: | + wget -q https://github.com/KaririCode-Framework/kariricode-devkit/releases/latest/download/kcode.phar + chmod +x kcode.phar + sudo mv kcode.phar /usr/local/bin/kcode + + # Generate .kcode/ configs: phpunit.xml.dist, phpstan.neon, psalm.xml, etc. + - name: Initialize devkit (.kcode/ generation) + run: kcode init + + # cs-fixer → phpstan (L9) → psalm → phpunit + # Exit code ≠ 0 fails the job (zero-tolerance policy) + - name: Run full quality pipeline + run: kcode quality diff --git a/.gitignore b/.gitignore index 10edc2a..35ba97c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,67 +1,52 @@ -# Arquivos de configuração do sistema +# Sistema / IDE /.idea/ *.sublime-project *.sublime-workspace -/.phpunit.result.cache -/.php_cs.cache -/.php_cs.dist.cache -/phpstan.neon.dist -/phpstan.neon.cache -/.phpstan.result.cache -/.phpcs-cache +.DS_Store +Thumbs.db +.vscode/ # Dependências /vendor/ /node_modules/ -# Arquivos específicos do sistema operacional -.DS_Store -Thumbs.db +# DevKit (gerado pelo kcode init — nunca versionar) +.kcode/ -# Arquivos de build e compilação +# Build e cobertura /build/ /dist/ +coverage/ +coverage*/ +coverage-report-html/ + +# Cache +*.cache *.log -*.tlog *.tmp *.temp +*.swp +*~ +._* -# Arquivos e pastas de ambientes virtuais -.env - -# Arquivos de cache -/cache/ -*.cache -*.class - -# Arquivos de log -*.log -*.sql -*.sqlite +# PHP caches +/.phpunit.result.cache +/.phpunit.cache +/.php_cs.cache +/.php_cs.dist.cache +/phpstan.neon.cache +/.phpstan.result.cache +/.phpcs-cache -# Pasta de testes que não devem ser incluídas no repositório -coverage/ -coverage* +# Ambiente — não necessário numa biblioteca +.env +.env.* -# Arquivos de pacotes -*.jar -*.war -*.ear -*.zip -*.tar.gz -*.rar +# Lock — gerenciado pelo CI +/composer.lock -# Outros arquivos e pastas -*.swp -*~ -._* -temp/ -tmp/ -.vscode/launch.json -.vscode/extensions.json -tests/lista_de_arquivos.php -tests/lista_de_arquivos_test.php +# Artefatos temporários lista_de_arquivos.txt lista_de_arquivos_tests.txt -add_static_to_providers.php -/composer.lock \ No newline at end of file +temp/ +tmp/ \ No newline at end of file diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php deleted file mode 100644 index c3a51bb..0000000 --- a/.php-cs-fixer.php +++ /dev/null @@ -1,69 +0,0 @@ -in(__DIR__ . '/src') - ->in(__DIR__ . '/tests') - ->exclude('var') - ->exclude('config') - ->exclude('vendor'); - -return (new PhpCsFixer\Config()) - ->setParallelConfig(new PhpCsFixer\Runner\Parallel\ParallelConfig(4, 20)) - ->setRules([ - '@PSR12' => true, - '@Symfony' => true, - 'full_opening_tag' => false, - 'phpdoc_var_without_name' => false, - 'phpdoc_to_comment' => false, - 'array_syntax' => ['syntax' => 'short'], - 'concat_space' => ['spacing' => 'one'], - 'binary_operator_spaces' => [ - 'default' => 'single_space', - 'operators' => [ - '=' => 'single_space', - '=>' => 'single_space', - ], - ], - 'blank_line_before_statement' => [ - 'statements' => ['return'] - ], - 'cast_spaces' => ['space' => 'single'], - 'class_attributes_separation' => [ - 'elements' => [ - 'const' => 'none', - 'method' => 'one', - 'property' => 'none' - ] - ], - 'declare_equal_normalize' => ['space' => 'none'], - 'function_typehint_space' => true, - 'lowercase_cast' => true, - 'no_unused_imports' => true, - 'not_operator_with_successor_space' => true, - 'ordered_imports' => true, - 'phpdoc_align' => ['align' => 'left'], - 'phpdoc_no_alias_tag' => ['replacements' => ['type' => 'var', 'link' => 'see']], - 'phpdoc_order' => true, - 'phpdoc_scalar' => true, - 'single_quote' => true, - 'standardize_not_equals' => true, - 'trailing_comma_in_multiline' => ['elements' => ['arrays']], - 'trim_array_spaces' => true, - 'space_after_semicolon' => true, - 'no_spaces_inside_parenthesis' => true, - 'no_whitespace_before_comma_in_array' => true, - 'whitespace_after_comma_in_array' => true, - 'visibility_required' => ['elements' => ['const', 'method', 'property']], - 'multiline_whitespace_before_semicolons' => [ - 'strategy' => 'no_multi_line', - ], - 'method_chaining_indentation' => true, - 'class_definition' => [ - 'single_item_single_line' => false, - 'multi_line_extends_each_single_line' => true, - ], - 'not_operator_with_successor_space' => false - ]) - ->setRiskyAllowed(true) - ->setFinder($finder) - ->setUsingCache(false); diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 38f7f80..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "[php]": { - "editor.defaultFormatter": "junstyle.php-cs-fixer" - }, - "php-cs-fixer.executablePath": "${workspaceFolder}/vendor/bin/php-cs-fixer", - "php-cs-fixer.onsave": true, - "php-cs-fixer.rules": "@PSR12", - "php-cs-fixer.config": ".php_cs.dist", - "php-cs-fixer.formatHtml": true -} diff --git a/Makefile b/Makefile deleted file mode 100644 index a1bc589..0000000 --- a/Makefile +++ /dev/null @@ -1,174 +0,0 @@ -# Initial configurations -PHP_SERVICE := kariricode-processor-pipeline -DC := docker-compose - -# Command to execute commands inside the PHP container -EXEC_PHP := $(DC) exec -T php - -# Icons -CHECK_MARK := ✅ -WARNING := ⚠️ -INFO := ℹ️ - -# Colors -RED := \033[0;31m -GREEN := \033[0;32m -YELLOW := \033[1;33m -NC := \033[0m # No Color - -# Check if Docker is installed -CHECK_DOCKER := @command -v docker > /dev/null 2>&1 || { echo >&2 "${YELLOW}${WARNING} Docker is not installed. Aborting.${NC}"; exit 1; } -# Check if Docker Compose is installed -CHECK_DOCKER_COMPOSE := @command -v docker-compose > /dev/null 2>&1 || { echo >&2 "${YELLOW}${WARNING} Docker Compose is not installed. Aborting.${NC}"; exit 1; } -# Function to check if the container is running -CHECK_CONTAINER_RUNNING := @docker ps | grep $(PHP_SERVICE) > /dev/null 2>&1 || { echo >&2 "${YELLOW}${WARNING} The container $(PHP_SERVICE) is not running. Run 'make up' to start it.${NC}"; exit 1; } -# Check if the .env file exists -CHECK_ENV := @test -f .env || { echo >&2 "${YELLOW}${WARNING} .env file not found. Run 'make setup-env' to configure.${NC}"; exit 1; } - -## setup-env: Copy .env.example to .env if the latter does not exist -setup-env: - @test -f .env || (cp .env.example .env && echo "${GREEN}${CHECK_MARK} .env file created successfully from .env.example${NC}") - -check-environment: - @echo "${GREEN}${INFO} Checking Docker, Docker Compose, and .env file...${NC}" - $(CHECK_DOCKER) - $(CHECK_DOCKER_COMPOSE) - $(CHECK_ENV) - -check-container-running: - $(CHECK_CONTAINER_RUNNING) - -## up: Start all services in the background -up: check-environment - @echo "${GREEN}${INFO} Starting services...${NC}" - @$(DC) up -d - @echo "${GREEN}${CHECK_MARK} Services are up!${NC}" - -## down: Stop and remove all containers -down: check-environment - @echo "${YELLOW}${INFO} Stopping and removing services...${NC}" - @$(DC) down - @echo "${GREEN}${CHECK_MARK} Services stopped and removed!${NC}" - -## build: Build Docker images -build: check-environment - @echo "${YELLOW}${INFO} Building services...${NC}" - @$(DC) build - @echo "${GREEN}${CHECK_MARK} Services built!${NC}" - -## logs: Show container logs -logs: check-environment - @echo "${YELLOW}${INFO} Container logs:${NC}" - @$(DC) logs - -## re-build: Rebuild and restart containers -re-build: check-environment - @echo "${YELLOW}${INFO} Stopping and removing current services...${NC}" - @$(DC) down - @echo "${GREEN}${INFO} Rebuilding services...${NC}" - @$(DC) build - @echo "${GREEN}${INFO} Restarting services...${NC}" - @$(DC) up -d - @echo "${GREEN}${CHECK_MARK} Services rebuilt and restarted successfully!${NC}" - @$(DC) logs - -## shell: Access the shell of the PHP container -shell: check-environment check-container-running - @echo "${GREEN}${INFO} Accessing the shell of the PHP container...${NC}" - @$(DC) exec php sh - -## composer-install: Install Composer dependencies. Use make composer-install [PKG="[vendor/package [version]]"] [DEV="--dev"] -composer-install: check-environment check-container-running - @echo "${GREEN}${INFO} Installing Composer dependencies...${NC}" - @if [ -z "$(PKG)" ]; then \ - $(EXEC_PHP) composer install; \ - else \ - $(EXEC_PHP) composer require $(PKG) $(DEV); \ - fi - @echo "${GREEN}${CHECK_MARK} Composer operation completed!${NC}" - -## composer-remove: Remove Composer dependencies. Usage: make composer-remove PKG="vendor/package" -composer-remove: check-environment check-container-running - @if [ -z "$(PKG)" ]; then \ - echo "${RED}${WARNING} You must specify a package to remove. Usage: make composer-remove PKG=\"vendor/package\"${NC}"; \ - else \ - $(EXEC_PHP) composer remove $(PKG); \ - echo "${GREEN}${CHECK_MARK} Package $(PKG) removed successfully!${NC}"; \ - fi - -## composer-update: Update Composer dependencies -composer-update: check-environment check-container-running - @echo "${GREEN}${INFO} Updating Composer dependencies...${NC}" - $(EXEC_PHP) composer update - @echo "${GREEN}${CHECK_MARK} Dependencies updated!${NC}" - -## test: Run tests -test: check-environment check-container-running - @echo "${GREEN}${INFO} Running tests...${NC}" - $(EXEC_PHP) ./vendor/bin/phpunit --testdox --colors=always tests - @echo "${GREEN}${CHECK_MARK} Tests completed!${NC}" - -## test-file: Run tests on a specific class. Usage: make test-file FILE=[file] -test-file: check-environment check-container-running - @echo "${GREEN}${INFO} Running test for class $(FILE)...${NC}" - $(EXEC_PHP) ./vendor/bin/phpunit --testdox --colors=always tests/$(FILE) - @echo "${GREEN}${CHECK_MARK} Test for class $(FILE) completed!${NC}" - -## coverage: Run test coverage with visual formatting -coverage: check-environment check-container-running - @echo "${GREEN}${INFO} Analyzing test coverage...${NC}" - XDEBUG_MODE=coverage $(EXEC_PHP) ./vendor/bin/phpunit --coverage-text --colors=always tests | ccze -A - -## coverage-html: Run test coverage and generate HTML report -coverage-html: check-environment check-container-running - @echo "${GREEN}${INFO} Analyzing test coverage and generating HTML report...${NC}" - XDEBUG_MODE=coverage $(EXEC_PHP) ./vendor/bin/phpunit --coverage-html ./coverage-report-html tests - @echo "${GREEN}${INFO} Test coverage report generated in ./coverage-report-html${NC}" - -## run-script: Run a PHP script. Usage: make run-script SCRIPT="path/to/script.php" -run-script: check-environment check-container-running - @echo "${GREEN}${INFO} Running script: $(SCRIPT)...${NC}" - $(EXEC_PHP) php $(SCRIPT) - @echo "${GREEN}${CHECK_MARK} Script executed!${NC}" - -## cs-check: Run PHP_CodeSniffer to check code style -cs-check: check-environment check-container-running - @echo "${GREEN}${INFO} Checking code style...${NC}" - $(EXEC_PHP) ./vendor/bin/php-cs-fixer fix --dry-run --diff - @echo "${GREEN}${CHECK_MARK} Code style check completed!${NC}" - -## cs-fix: Run PHP CS Fixer to fix code style -cs-fix: check-environment check-container-running - @echo "${GREEN}${INFO} Fixing code style with PHP CS Fixer...${NC}" - $(EXEC_PHP) ./vendor/bin/php-cs-fixer fix - @echo "${GREEN}${CHECK_MARK} Code style fixed!${NC}" - -## security-check: Check for security vulnerabilities in dependencies -security-check: check-environment check-container-running - @echo "${GREEN}${INFO} Checking for security vulnerabilities with Security Checker...${NC}" - $(EXEC_PHP) ./vendor/bin/security-checker security:check - @echo "${GREEN}${CHECK_MARK} Security check completed!${NC}" - -## quality: Run all quality commands -quality: check-environment check-container-running cs-check test security-check - @echo "${GREEN}${CHECK_MARK} All quality commands executed!${NC}" - -## help: Show initial setup steps and available commands -help: - @echo "${GREEN}Initial setup steps for configuring the project:${NC}" - @echo "1. ${YELLOW}Initial environment setup:${NC}" - @echo " ${GREEN}${CHECK_MARK} Copy the environment file:${NC} make setup-env" - @echo " ${GREEN}${CHECK_MARK} Start the Docker containers:${NC} make up" - @echo " ${GREEN}${CHECK_MARK} Install Composer dependencies:${NC} make composer-install" - @echo "2. ${YELLOW}Development:${NC}" - @echo " ${GREEN}${CHECK_MARK} Access the PHP container shell:${NC} make shell" - @echo " ${GREEN}${CHECK_MARK} Run a PHP script:${NC} make run-script SCRIPT=\"script_name.php\"" - @echo " ${GREEN}${CHECK_MARK} Run the tests:${NC} make test" - @echo "3. ${YELLOW}Maintenance:${NC}" - @echo " ${GREEN}${CHECK_MARK} Update Composer dependencies:${NC} make composer-update" - @echo " ${GREEN}${CHECK_MARK} Clear the application cache:${NC} make cache-clear" - @echo " ${RED}${WARNING} Stop and remove all Docker containers:${NC} make down" - @echo "\n${GREEN}Available commands:${NC}" - @sed -n 's/^##//p' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ": "}; {printf "${YELLOW}%-30s${NC} %s\n", $$1, $$2}' - -.PHONY: setup-env up down build logs re-build shell composer-install composer-remove composer-update test test-file coverage coverage-html run-script cs-check cs-fix security-check quality help diff --git a/README.md b/README.md index 27bbc08..0641d81 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,40 @@ -# KaririCode Framework: Processor Pipeline Component +# KaririCode Framework: ProcessorPipeline V4.0 -[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md) [![pt-br](https://img.shields.io/badge/lang-pt--br-green.svg)](README.pt-br.md) +[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md) +[![pt-br](https://img.shields.io/badge/lang-pt--br-green.svg)](README.pt-br.md) +[![PHP 8.4+](https://img.shields.io/badge/PHP-8.4+-777BB4?style=flat-square&logo=php&logoColor=white)](https://www.php.net/) +[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![ARFA 1.3](https://img.shields.io/badge/ARFA-1.3-orange.svg)](docs/specs/SPEC-001-processor-pipeline.md) -![PHP](https://img.shields.io/badge/PHP-777BB4?style=for-the-badge&logo=php&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white) ![PHPUnit](https://img.shields.io/badge/PHPUnit-3776AB?style=for-the-badge&logo=php&logoColor=white) - -A robust and flexible component for creating and managing processing pipelines in the KaririCode Framework, providing advanced features for handling complex data processing tasks in PHP applications. - -## Table of Contents - -- [Features](#features) -- [Installation](#installation) -- [Usage](#usage) - - [Basic Usage](#basic-usage) - - [Advanced Usage](#advanced-usage) -- [Integration with Other KaririCode Components](#integration-with-other-kariricode-components) -- [Development and Testing](#development-and-testing) -- [License](#license) -- [Support and Community](#support-and-community) -- [Acknowledgements](#acknowledgements) +A robust, immutable processor pipeline component for the KaririCode Framework. Enables modular, configurable processing chains for data transformation, validation, and sanitization — fully compliant with ARFA 1.3. ## Features -- Easy creation and management of processing pipelines -- Support for both simple and configurable processors -- Context-based processor registry for organized processor management -- Seamless integration with other KaririCode components (Serializer, Validator, Normalizer) -- Extensible architecture allowing custom processors -- Built on top of the KaririCode\Contract interfaces for maximum flexibility +- **Immutable pipelines** — `withProcessor()` returns new instances (ARFA P1) +- **Context-based registry** — collision-free processor management across domains +- **Flexible specification format** — simple lists, enable/disable flags, and configuration arrays +- **Configurable processors** — runtime configuration via `ConfigurableProcessor` interface +- **Error collection** — `ProcessingResultCollection` for non-halting error aggregation +- **PHP 8.4+** — `readonly class`, asymmetric visibility, constructor promotion +- **Zero external dependencies** — depends only on `kariricode/contract` +- **Structured exceptions** — all exceptions carry context arrays for observability ## Installation -The ProcessorPipeline component can be easily installed via Composer, which is the recommended dependency manager for PHP projects. - -To install the ProcessorPipeline component in your project, run the following command in your terminal: - ```bash composer require kariricode/processor-pipeline ``` -This command will automatically add ProcessorPipeline to your project and install all necessary dependencies. - -### Requirements - -- PHP 8.1 or higher -- Composer - -### Manual Installation - -If you prefer not to use Composer, you can download the source code directly from the [GitHub repository](https://github.com/KaririCode-Framework/kariricode-processor-pipeline) and include it manually in your project. However, we strongly recommend using Composer for easier dependency management and updates. - -After installation, you can start using ProcessorPipeline in your PHP project immediately. Make sure to include the Composer autoloader in your script: - -```php -require_once 'vendor/autoload.php'; -``` - -## Usage - -### Basic Usage +**Requirements:** PHP 8.4+, Composer -1. Define your processors: +## Quick Start ```php -resultCollection->addError( - self::class, - 'invalidFormat', - "Invalid email format: $input" - ); - } - return $input; - } -} - -// Function to handle pipeline execution -function executePipeline(ProcessorBuilder $builder, ProcessorRegistry $registry, array $processorSpecs, string $input): void -{ - $resultCollection = new ProcessingResultCollection(); - $context = 'example_context'; - - $registry->register($context, 'upper_case', new UpperCaseProcessor()) - ->register($context, 'trim', new TrimProcessor()) - ->register($context, 'email_transform', new EmailTransformerProcessor()) - ->register($context, 'email_validate', new EmailValidatorProcessor($resultCollection)); - - try { - $pipeline = $builder->buildPipeline($context, $processorSpecs); - $output = $pipeline->process($input); - - // Displaying the results - echo "Original Input: '$input'\n"; - echo "Pipeline Output: '$output'\n"; - - // Display errors if any - if ($resultCollection->hasErrors()) { - echo "\nProcessing Errors:\n"; - print_r($resultCollection->getErrors()); - } else { - echo "\nNo processing errors encountered.\n"; - } - } catch (\Exception $e) { - echo "Error executing the pipeline: " . $e->getMessage() . "\n"; - } -} - -// Register processors to a context in the registry. +// 2. Register $registry = new ProcessorRegistry(); -$builder = new ProcessorBuilder($registry); - -// Execute scenario 1 - Valid input -$processorSpecs = [ - 'upper_case' => false, - 'trim' => true, - 'email_transform' => true, - 'email_validate' => true, -]; -$input = " Example@Email.COM "; +$registry + ->register('sanitizer', 'trim', new TrimProcessor()) + ->register('sanitizer', 'lowercase', new LowercaseProcessor()); -echo "Scenario 1 - Valid Input\n"; -executePipeline($builder, $registry, $processorSpecs, $input); - -// Execute scenario 2 - Invalid input -$input = " InvalidEmail@@@ "; - -echo "\nScenario 2 - Invalid Input:\n"; -executePipeline($builder, $registry, $processorSpecs, $input); -``` - -### Test Output +// 3. Build & Execute +$builder = new ProcessorBuilder($registry); +$pipeline = $builder->buildPipeline('sanitizer', ['trim', 'lowercase']); -```bash -php ./tests/application.php -Scenario 1 - Valid Input -Original Input: ' Example@Email.COM ' -Pipeline Output: 'example@email.com' - -No processing errors encountered. - -Scenario 2 - Invalid Input: -Original Input: ' InvalidEmail@@@ ' -Pipeline Output: 'invalidemail@@@' - -Processing Errors: -Array -( - [EmailValidatorProcessor] => Array - ( - [0] => Array - ( - [errorKey] => invalidFormat - [message] => Invalid email format: invalidemail@@@ - ) - - ) -) +$result = $pipeline->process(' HELLO WORLD '); +// Result: 'hello world' ``` -### Advanced Usage - -#### Configurable Processors +## Usage -Create configurable processors for more flexibility: +### Configurable Processors ```php use KaririCode\Contract\Processor\ConfigurableProcessor; -class AgeValidator implements ConfigurableProcessor +class LengthValidator implements ConfigurableProcessor { - private int $minAge = 0; - private int $maxAge = 120; + private int $minLength = 0; + private int $maxLength = PHP_INT_MAX; public function configure(array $options): void { - if (isset($options['minAge'])) { - $this->minAge = $options['minAge']; - } - if (isset($options['maxAge'])) { - $this->maxAge = $options['maxAge']; - } + $this->minLength = $options['minLength'] ?? $this->minLength; + $this->maxLength = $options['maxLength'] ?? $this->maxLength; } - public function process(mixed $input): bool + public function process(mixed $input): mixed { - return is_numeric($input) && $input >= $this->minAge && $input <= $this->maxAge; + $length = mb_strlen((string) $input); + + if ($length < $this->minLength || $length > $this->maxLength) { + throw new \InvalidArgumentException( + "Length must be between {$this->minLength} and {$this->maxLength}." + ); + } + + return $input; } } -$registry->register('user', 'ageValidator', new AgeValidator()); -$pipeline = $builder->buildPipeline('user', ['ageValidator' => ['minAge' => 18, 'maxAge' => 100]]); -``` - -## Integration with Other KaririCode Components +$registry->register('validator', 'length', new LengthValidator()); -The ProcessorPipeline component is designed to work seamlessly with other KaririCode components: +$pipeline = $builder->buildPipeline('validator', [ + 'length' => ['minLength' => 3, 'maxLength' => 50], +]); +``` -- **KaririCode\Serializer**: Use processors to transform data before or after serialization. -- **KaririCode\Validator**: Create validation pipelines for complex data structures. -- **KaririCode\Normalizer**: Build normalization pipelines for data cleaning and standardization. +### Processor Specification Format -Example using ProcessorPipeline with Validator: +```php +$pipeline = $builder->buildPipeline('validator', [ + 'required', // Simple: enabled + 'trim' => true, // Explicit: enabled + 'optional' => false, // Explicit: disabled (skipped) + 'length' => ['min' => 3], // Configured +]); +``` -1. Define your data class with validation attributes: +### Immutable Pipeline Composition ```php -use KaririCode\Validator\Attribute\Validate; - -class UserProfile -{ - #[Validate( - processors: [ - 'required', - 'length' => ['minLength' => 3, 'maxLength' => 20], - ], - messages: [ - 'required' => 'Username is required', - 'length' => 'Username must be between 3 and 20 characters', - ] - )] - private string $username = ''; - - #[Validate( - processors: ['required', 'email'], - messages: [ - 'required' => 'Email is required', - 'email' => 'Invalid email format', - ] - )] - private string $email = ''; +$basePipeline = $builder->buildPipeline('sanitizer', ['trim']); +$extendedPipeline = $basePipeline->withProcessor(new LowercaseProcessor()); - // Getters and setters... -} +// $basePipeline still has 1 processor +// $extendedPipeline has 2 processors ``` -2. Set up the validator and use it: +### Error Collection ```php -use KaririCode\ProcessorPipeline\ProcessorRegistry; -use KaririCode\Validator\Validator; -use KaririCode\Validator\Processor\Logic\RequiredValidator; -use KaririCode\Validator\Processor\Input\LengthValidator; -use KaririCode\Validator\Processor\Input\EmailValidator; - -$registry = new ProcessorRegistry(); -$registry->register('validator', 'required', new RequiredValidator()) - ->register('validator', 'length', new LengthValidator()) - ->register('validator', 'email', new EmailValidator()); +use KaririCode\ProcessorPipeline\Handler\ProcessorHandler; +use KaririCode\ProcessorPipeline\Result\ProcessingResultCollection; -$validator = new Validator($registry); +$results = new ProcessingResultCollection(); -$userProfile = new UserProfile(); -$userProfile->setUsername('wa'); // Too short -$userProfile->setEmail('invalid-email'); // Invalid format +// Wrap processors with error collection +$handler = new ProcessorHandler( + processor: new EmailValidator(), + resultCollection: $results, + haltOnError: false, +); -$result = $validator->validate($userProfile); +$output = $handler->process('invalid-email'); -if ($result->hasErrors()) { - foreach ($result->getErrors() as $property => $errors) { +if ($results->hasErrors()) { + foreach ($results->getErrors() as $processor => $errors) { foreach ($errors as $error) { - echo "$property: {$error['message']}\n"; + echo "{$processor}: {$error['message']}\n"; } } } ``` -## Development and Testing - -For development and testing purposes, this package uses Docker and Docker Compose to ensure consistency across different environments. A Makefile is provided for convenience. - -### Prerequisites - -- Docker -- Docker Compose -- Make (optional, but recommended for easier command execution) - -### Development Setup +### PHP 8.4 Attributes -1. Clone the repository: +```php +use KaririCode\ProcessorPipeline\Attribute\Process; - ```bash - git clone https://github.com/KaririCode-Framework/kariricode-processor-pipeline.git - cd kariricode-processor-pipeline - ``` +class UserProfile +{ + #[Process( + processors: ['required', 'length' => ['minLength' => 3]], + messages: ['required' => 'Username is required.'], + )] + public private(set) string $username = ''; -2. Set up the environment: + #[Process( + processors: ['trim', 'lowercase', 'email'], + messages: ['email' => 'Invalid email.'], + )] + public private(set) string $email = ''; +} +``` - ```bash - make setup-env - ``` +## Architecture -3. Start the Docker containers: +``` +Consumer Code + │ + ▼ +ProcessorBuilder ──→ ProcessorRegistry + │ (context → name → Processor) + │ creates + ▼ + Pipeline (immutable, readonly) + [P₁, P₂, ..., Pₙ] + │ + ▼ + process(input) → output +``` - ```bash - make up - ``` +### ARFA 1.3 Compliance -4. Install dependencies: - ```bash - make composer-install - ``` +| Principle | Implementation | +|-----------|---------------| +| P1: Immutable State | `Pipeline` is `readonly class`; `withProcessor()` returns new instance | +| P2: Reactive Flow | Sequential composition: f₁ ∘ f₂ ∘ ... ∘ fₙ | +| P3: Adaptive Context | Context-based registry; ConfigurableProcessor | +| P4: Protocol Agnostic | No protocol coupling; works with HTTP, gRPC, CLI, etc. | +| P5: Observability | Structured exceptions; ProcessingResultCollection tracing | -### Available Make Commands +## Integration with KaririCode Components -- `make up`: Start all services in the background -- `make down`: Stop and remove all containers -- `make build`: Build Docker images -- `make shell`: Access the PHP container shell -- `make test`: Run tests -- `make coverage`: Run test coverage with visual formatting -- `make cs-fix`: Run PHP CS Fixer to fix code style -- `make quality`: Run all quality commands (cs-check, test, security-check) +| Component | Role | +|-----------|------| +| `kariricode/contract` | `Processor`, `ConfigurableProcessor` interfaces | +| `kariricode/validator` | Validation pipelines via `#[Validate]` | +| `kariricode/sanitizer` | Sanitization pipelines via `#[Sanitize]` | +| `kariricode/transformer` | Transformation pipelines via `#[Transform]` | +| `kariricode/property-inspector` | Discovers `#[Process]` attributes on properties | -For a full list of available commands, run: +## Development ```bash -make help +git clone https://github.com/KaririCode-Framework/kariricode-processor-pipeline.git +cd kariricode-processor-pipeline +make setup-env && make up && make composer-install +make test # Run tests +make coverage # Coverage report +make cs-fix # Code style +make quality # Full quality check ``` -## License +## Documentation -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +- **[Technical Specification](docs/specs/SPEC-001-processor-pipeline.md)** — algorithms, type definitions, complexity analysis +- **[ADR-001: Immutable Pipeline](docs/adrs/ADR-001-immutable-pipeline.md)** — why pipelines are readonly +- **[ADR-002: Context Registry](docs/adrs/ADR-002-context-based-registry.md)** — two-level registry design +- **[ADR-003: Specification Format](docs/adrs/ADR-003-processor-specification-format.md)** — flexible processor specs -## Support and Community +## License -- **Documentation**: [https://kariricode.org/docs/processor-pipeline](https://kariricode.org/docs/processor-pipeline) -- **Issue Tracker**: [GitHub Issues](https://github.com/KaririCode-Framework/kariricode-processor-pipeline/issues) -- **Community**: [KaririCode Club Community](https://kariricode.club) +MIT License — see [LICENSE](LICENSE) for details. -## Acknowledgements +## Support -- The KaririCode Framework team and contributors. -- Inspired by pipeline patterns and processing chains in software architecture. +- **Documentation**: [kariricode.org/docs/processor-pipeline](https://kariricode.org/docs/processor-pipeline) +- **Issues**: [GitHub Issues](https://github.com/KaririCode-Framework/kariricode-processor-pipeline/issues) +- **Community**: [KaririCode Club](https://kariricode.club) --- -Built with ❤️ by the KaririCode team. Empowering developers to create more robust and flexible PHP applications. +Built with ❤️ by the KaririCode team. Empowering developers to create robust PHP applications. diff --git a/README.pt-br.md b/README.pt-br.md deleted file mode 100644 index f18b757..0000000 --- a/README.pt-br.md +++ /dev/null @@ -1,382 +0,0 @@ -# KaririCode Framework: Processor Pipeline Component - -[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md) [![pt-br](https://img.shields.io/badge/lang-pt--br-green.svg)](README.pt-br.md) - -![PHP](https://img.shields.io/badge/PHP-777BB4?style=for-the-badge&logo=php&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white) ![PHPUnit](https://img.shields.io/badge/PHPUnit-3776AB?style=for-the-badge&logo=php&logoColor=white) - -Um componente robusto e flexível para criar e gerenciar pipelines de processamento no KaririCode Framework, oferecendo recursos avançados para lidar com tarefas complexas de processamento de dados em aplicações PHP. - -## Índice - -- [Funcionalidades](#funcionalidades) -- [Instalação](#instalação) -- [Uso](#uso) - - [Uso Básico](#uso-básico) - - [Uso Avançado](#uso-avançado) -- [Integração com Outros Componentes do KaririCode](#integração-com-outros-componentes-do-kariricode) -- [Desenvolvimento e Testes](#desenvolvimento-e-testes) -- [Licença](#licença) -- [Suporte e Comunidade](#suporte-e-comunidade) -- [Agradecimentos](#agradecimentos) - -## Funcionalidades - -- Criação e gestão fáceis de pipelines de processamento -- Suporte para processadores simples e configuráveis -- Registro de processadores baseado em contexto para uma gestão organizada -- Integração transparente com outros componentes do KaririCode (Serializer, Validator, Normalizer) -- Arquitetura extensível que permite processadores personalizados -- Construído sobre as interfaces do KaririCode\Contract para máxima flexibilidade - -## Instalação - -O componente ProcessorPipeline pode ser facilmente instalado via Composer, que é o gerenciador de dependências recomendado para projetos PHP. - -Para instalar o componente ProcessorPipeline no seu projeto, execute o seguinte comando no terminal: - -```bash -composer require kariricode/processor-pipeline -``` - -Esse comando adicionará automaticamente o ProcessorPipeline ao seu projeto e instalará todas as dependências necessárias. - -### Requisitos - -- PHP 8.1 ou superior -- Composer - -### Instalação Manual - -Se preferir não usar o Composer, você pode baixar o código-fonte diretamente do [repositório GitHub](https://github.com/KaririCode-Framework/kariricode-processor-pipeline) e incluí-lo manualmente no seu projeto. No entanto, recomendamos fortemente o uso do Composer para facilitar o gerenciamento de dependências e atualizações. - -Após a instalação, você pode começar a usar o ProcessorPipeline no seu projeto PHP imediatamente. Certifique-se de incluir o autoloader do Composer em seu script: - -```php -require_once 'vendor/autoload.php'; -``` - -## Uso - -### Uso Básico - -1. Defina seus processadores: - -```php -resultCollection->addError( - self::class, - 'invalidFormat', - "Formato de email inválido: $input" - ); - } - return $input; - } -} - -// Função para executar o pipeline -function executePipeline(ProcessorBuilder $builder, ProcessorRegistry $registry, array $processorSpecs, string $input): void -{ - $resultCollection = new ProcessingResultCollection(); - $context = 'exemplo_contexto'; - - $registry->register($context, 'upper_case', new UpperCaseProcessor()) - ->register($context, 'trim', new TrimProcessor()) - ->register($context, 'email_transform', new EmailTransformerProcessor()) - ->register($context, 'email_validate', new EmailValidatorProcessor($resultCollection)); - - try { - $pipeline = $builder->buildPipeline($context, $processorSpecs); - $output = $pipeline->process($input); - - // Exibindo os resultados - echo "Entrada Original: '$input'\n"; - echo "Saída do Pipeline: '$output'\n"; - - // Exibir erros, se houver - if ($resultCollection->hasErrors()) { - echo "\nErros de Processamento:\n"; - print_r($resultCollection->getErrors()); - } else { - echo "\nNenhum erro de processamento encontrado.\n"; - } - } catch (\Exception $e) { - echo "Erro ao executar o pipeline: " . $e->getMessage() . "\n"; - } -} - -// Registrar processadores em um contexto no registro. -$registry = new ProcessorRegistry(); -$builder = new ProcessorBuilder($registry); - -// Executar cenário 1 - Entrada válida -$processorSpecs = [ - 'upper_case' => false, - 'trim' => true, - 'email_transform' => true, - 'email_validate' => true, -]; -$input = " Exemplo@Email.COM "; - -echo "Cenário 1 - Entrada Válida\n"; -executePipeline($builder, $registry, $processorSpecs, $input); - -// Executar cenário 2 - Entrada inválida -$input = " EmailInválido@@@ "; - -echo "\nCenário 2 - Entrada Inválida:\n"; -executePipeline($builder, $registry, $processorSpecs, $input); -``` - -### Saída de Teste - -```bash -php ./tests/application.php -Cenário 1 - Entrada Válida -Entrada Original: ' Exemplo@Email.COM ' -Saída do Pipeline: 'exemplo@email.com' - -Nenhum erro de processamento encontrado. - -Cenário 2 - Entrada Inválida: -Entrada Original: ' EmailInválido@@@ ' -Saída do Pipeline: 'emailinvalido@@@' - -Erros de Processamento: -Array -( - [EmailValidatorProcessor] => Array - ( - [0] => Array - ( - [chaveErro] => formatoInvalido - [mensagem] => Formato de email inválido: emailinvalido@@@ - ) - - ) -) -``` - -### Uso Avançado - -#### Processadores Configuráveis - -Crie processadores configuráveis para mais flexibilidade: - -```php -use KaririCode\Contract\Processor\ConfigurableProcessor; - -class AgeValidator implements ConfigurableProcessor -{ - private int $minAge = 0; - private int $maxAge = 120; - - public function configure(array $options): void - { - if (isset($options['minAge'])) { - $this->minAge = $options['minAge']; - } - if (isset($options['maxAge'])) { - $this->maxAge = $options['maxAge']; - } - } - - public function process(mixed $input): bool - { - return is_numeric($input) && $input >= $this->minAge && $input <= $this->maxAge; - } -} - -$registry->register('usuario', 'ageValidator', new AgeValidator()); -$pipeline = $builder->buildPipeline('usuario', ['ageValidator' => ['minAge' => 18, 'maxAge' => 100]]); -``` - -## Integração com Outros Componentes do KaririCode - -O componente ProcessorPipeline foi projetado para funcionar perfeitamente com outros componentes do KaririCode: - -- **KaririCode\Serializer**: Use processadores para transformar dados antes ou depois da serialização. -- **KaririCode\Validator**: Crie pipelines de validação para estruturas de dados complexas. -- **KaririCode\Normalizer**: Construa pipelines de normalização para limpeza e padronização de dados. - -Exemplo de uso do ProcessorPipeline com Validator: - -1. Defina sua classe de dados com atributos de validação: - -```php -use KaririCode\Validator\Attribute\Validate; - -class UserProfile -{ - #[Validate( - processors: [ - 'required', - 'length' => ['minLength' => 3, 'maxLength' => 20], - ], - messages: [ - 'required' => 'Nome de usuário é obrigatório', - 'length' => 'Nome de usuário deve ter entre 3 e 20 caracteres', - ] - )] - private string $username = ''; - - #[Validate( - processors: ['required', 'email'], - messages: [ - 'required' => 'Email é obrigatório', - 'email' => 'Formato de email inválido', - ] - ) - -] - private string $email = ''; - - // Getters e setters... -} -``` - -2. Configure o validador e utilize-o: - -```php -use KaririCode\ProcessorPipeline\ProcessorRegistry; -use KaririCode\Validator\Validator; -use KaririCode\Validator\Processor\Logic\RequiredValidator; -use KaririCode\Validator\Processor\Input\LengthValidator; -use KaririCode\Validator\Processor\Input\EmailValidator; - -$registry = new ProcessorRegistry(); -$registry->register('validator', 'required', new RequiredValidator()) - ->register('validator', 'length', new LengthValidator()) - ->register('validator', 'email', new EmailValidator()); - -$validator = new Validator($registry); - -$userProfile = new UserProfile(); -$userProfile->setUsername('wa'); // Muito curto -$userProfile->setEmail('email-invalido'); // Formato inválido - -$result = $validator->validate($userProfile); - -if ($result->hasErrors()) { - foreach ($result->getErrors() as $property => $errors) { - foreach ($errors as $error) { - echo "$property: {$error['message']}\n"; - } - } -} -``` - -## Desenvolvimento e Testes - -Para fins de desenvolvimento e teste, este pacote usa Docker e Docker Compose para garantir a consistência entre diferentes ambientes. Um Makefile é fornecido para conveniência. - -### Pré-requisitos - -- Docker -- Docker Compose -- Make (opcional, mas recomendado para execução mais fácil de comandos) - -### Configuração de Desenvolvimento - -1. Clone o repositório: - - ```bash - git clone https://github.com/KaririCode-Framework/kariricode-processor-pipeline.git - cd kariricode-processor-pipeline - ``` - -2. Configure o ambiente: - - ```bash - make setup-env - ``` - -3. Inicie os contêineres Docker: - - ```bash - make up - ``` - -4. Instale as dependências: - ```bash - make composer-install - ``` - -### Comandos Disponíveis no Make - -- `make up`: Iniciar todos os serviços em segundo plano -- `make down`: Parar e remover todos os contêineres -- `make build`: Construir as imagens Docker -- `make shell`: Acessar o shell do contêiner PHP -- `make test`: Executar os testes -- `make coverage`: Executar cobertura de teste com formatação visual -- `make cs-fix`: Executar o PHP CS Fixer para corrigir o estilo de código -- `make quality`: Executar todos os comandos de qualidade (cs-check, test, security-check) - -Para uma lista completa dos comandos disponíveis, execute: - -```bash -make help -``` - -## Licença - -Este projeto é licenciado sob a Licença MIT - veja o arquivo [LICENSE](LICENSE) para detalhes. - -## Suporte e Comunidade - -- **Documentação**: [https://kariricode.org/docs/processor-pipeline](https://kariricode.org/docs/processor-pipeline) -- **Relatório de Problemas**: [GitHub Issues](https://github.com/KaririCode-Framework/kariricode-processor-pipeline/issues) -- **Comunidade**: [Comunidade KaririCode Club](https://kariricode.club) - -## Agradecimentos - -- Equipe e colaboradores do KaririCode Framework. -- Inspirado por padrões de pipeline e cadeias de processamento em arquitetura de software. - ---- - -Feito com ❤️ pela equipe KaririCode. Capacitando desenvolvedores a criar aplicações PHP mais robustas e flexíveis. diff --git a/composer.json b/composer.json index bd5e782..613b0e5 100644 --- a/composer.json +++ b/composer.json @@ -1,50 +1,56 @@ { - "name": "kariricode/processor-pipeline", - "description": "A flexible and extensible processor pipeline component for the KaririCode framework. Enables the creation of modular, configurable processing chains for data transformation, validation, and sanitization tasks", - "keywords": [ - "kariricode", - "processor", - "pipeline", - "data processing", - "configurable", - "modular", - "php" - ], - "homepage": "https://kariricode.org", - "type": "library", - "license": "MIT", - "authors": [ - { - "name": "Walmir Silva", - "email": "community@kariricode.org" - } - ], - "require": { - "php": "^8.3", - "kariricode/data-structure": "^1.1", - "kariricode/contract": "^2.7", - "kariricode/exception": "^1.2", - "kariricode/property-inspector": "^1.2" - }, - "autoload": { - "psr-4": { - "KaririCode\\ProcessorPipeline\\": "src" - } - }, - "autoload-dev": { - "psr-4": { - "KaririCode\\ProcessorPipeline\\Tests\\": "tests" - } - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.51", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^11.0", - "squizlabs/php_codesniffer": "^3.9", - "enlightn/security-checker": "^2.0" - }, - "support": { - "issues": "https://github.com/KaririCode-Framework/kariricode-processor-pipeline/issues", - "source": "https://github.com/KaririCode-Framework/kariricode-processor-pipeline" - } + "name": "kariricode/processor-pipeline", + "description": "A robust, immutable processor pipeline component for the KaririCode Framework — ARFA 1.3 compliant. Enables modular, configurable processing chains for data transformation, validation, and sanitization.", + "keywords": [ + "php", + "pipeline", + "processor", + "framework", + "processor-architecture", + "kariri-code", + "arfa", + "immutable", + "reactive-flow", + "php84" + ], + "homepage": "https://kariricode.org", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Walmir Silva", + "email": "community@kariricode.org" + } + ], + "require": { + "php": "^8.4", + "kariricode/contract": "^2.8", + "kariricode/exception": "^1.2" + }, + "autoload": { + "psr-4": { + "KaririCode\\ProcessorPipeline\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "KaririCode\\ProcessorPipeline\\Tests\\": "tests/" + } + }, + "require-dev": {}, + "scripts": { + "test": "phpunit", + "analyse": "phpstan analyse", + "cs-fix": "php-cs-fixer fix" + }, + "config": { + "sort-packages": true, + "optimize-autoloader": true + }, + "support": { + "issues": "https://github.com/KaririCode-Framework/kariricode-processor-pipeline/issues", + "source": "https://github.com/KaririCode-Framework/kariricode-processor-pipeline" + }, + "minimum-stability": "stable", + "prefer-stable": true } diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 1e9503d..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,15 +0,0 @@ -services: - php: - container_name: kariricode-processor-pipeline - build: - context: . - dockerfile: .docker/php/Dockerfile - args: - PHP_VERSION: ${KARIRI_PHP_VERSION} - environment: - XDEBUG_MODE: coverage - volumes: - - .:/app - working_dir: /app - ports: - - "${KARIRI_PHP_PORT}:9003" diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0641d81 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,243 @@ +# KaririCode Framework: ProcessorPipeline V4.0 + +[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md) +[![pt-br](https://img.shields.io/badge/lang-pt--br-green.svg)](README.pt-br.md) +[![PHP 8.4+](https://img.shields.io/badge/PHP-8.4+-777BB4?style=flat-square&logo=php&logoColor=white)](https://www.php.net/) +[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![ARFA 1.3](https://img.shields.io/badge/ARFA-1.3-orange.svg)](docs/specs/SPEC-001-processor-pipeline.md) + +A robust, immutable processor pipeline component for the KaririCode Framework. Enables modular, configurable processing chains for data transformation, validation, and sanitization — fully compliant with ARFA 1.3. + +## Features + +- **Immutable pipelines** — `withProcessor()` returns new instances (ARFA P1) +- **Context-based registry** — collision-free processor management across domains +- **Flexible specification format** — simple lists, enable/disable flags, and configuration arrays +- **Configurable processors** — runtime configuration via `ConfigurableProcessor` interface +- **Error collection** — `ProcessingResultCollection` for non-halting error aggregation +- **PHP 8.4+** — `readonly class`, asymmetric visibility, constructor promotion +- **Zero external dependencies** — depends only on `kariricode/contract` +- **Structured exceptions** — all exceptions carry context arrays for observability + +## Installation + +```bash +composer require kariricode/processor-pipeline +``` + +**Requirements:** PHP 8.4+, Composer + +## Quick Start + +```php +use KaririCode\Contract\Processor\Processor; +use KaririCode\ProcessorPipeline\ProcessorRegistry; +use KaririCode\ProcessorPipeline\ProcessorBuilder; + +// 1. Define processors +class TrimProcessor implements Processor +{ + public function process(mixed $input): mixed + { + return trim((string) $input); + } +} + +class LowercaseProcessor implements Processor +{ + public function process(mixed $input): mixed + { + return strtolower((string) $input); + } +} + +// 2. Register +$registry = new ProcessorRegistry(); +$registry + ->register('sanitizer', 'trim', new TrimProcessor()) + ->register('sanitizer', 'lowercase', new LowercaseProcessor()); + +// 3. Build & Execute +$builder = new ProcessorBuilder($registry); +$pipeline = $builder->buildPipeline('sanitizer', ['trim', 'lowercase']); + +$result = $pipeline->process(' HELLO WORLD '); +// Result: 'hello world' +``` + +## Usage + +### Configurable Processors + +```php +use KaririCode\Contract\Processor\ConfigurableProcessor; + +class LengthValidator implements ConfigurableProcessor +{ + private int $minLength = 0; + private int $maxLength = PHP_INT_MAX; + + public function configure(array $options): void + { + $this->minLength = $options['minLength'] ?? $this->minLength; + $this->maxLength = $options['maxLength'] ?? $this->maxLength; + } + + public function process(mixed $input): mixed + { + $length = mb_strlen((string) $input); + + if ($length < $this->minLength || $length > $this->maxLength) { + throw new \InvalidArgumentException( + "Length must be between {$this->minLength} and {$this->maxLength}." + ); + } + + return $input; + } +} + +$registry->register('validator', 'length', new LengthValidator()); + +$pipeline = $builder->buildPipeline('validator', [ + 'length' => ['minLength' => 3, 'maxLength' => 50], +]); +``` + +### Processor Specification Format + +```php +$pipeline = $builder->buildPipeline('validator', [ + 'required', // Simple: enabled + 'trim' => true, // Explicit: enabled + 'optional' => false, // Explicit: disabled (skipped) + 'length' => ['min' => 3], // Configured +]); +``` + +### Immutable Pipeline Composition + +```php +$basePipeline = $builder->buildPipeline('sanitizer', ['trim']); +$extendedPipeline = $basePipeline->withProcessor(new LowercaseProcessor()); + +// $basePipeline still has 1 processor +// $extendedPipeline has 2 processors +``` + +### Error Collection + +```php +use KaririCode\ProcessorPipeline\Handler\ProcessorHandler; +use KaririCode\ProcessorPipeline\Result\ProcessingResultCollection; + +$results = new ProcessingResultCollection(); + +// Wrap processors with error collection +$handler = new ProcessorHandler( + processor: new EmailValidator(), + resultCollection: $results, + haltOnError: false, +); + +$output = $handler->process('invalid-email'); + +if ($results->hasErrors()) { + foreach ($results->getErrors() as $processor => $errors) { + foreach ($errors as $error) { + echo "{$processor}: {$error['message']}\n"; + } + } +} +``` + +### PHP 8.4 Attributes + +```php +use KaririCode\ProcessorPipeline\Attribute\Process; + +class UserProfile +{ + #[Process( + processors: ['required', 'length' => ['minLength' => 3]], + messages: ['required' => 'Username is required.'], + )] + public private(set) string $username = ''; + + #[Process( + processors: ['trim', 'lowercase', 'email'], + messages: ['email' => 'Invalid email.'], + )] + public private(set) string $email = ''; +} +``` + +## Architecture + +``` +Consumer Code + │ + ▼ +ProcessorBuilder ──→ ProcessorRegistry + │ (context → name → Processor) + │ creates + ▼ + Pipeline (immutable, readonly) + [P₁, P₂, ..., Pₙ] + │ + ▼ + process(input) → output +``` + +### ARFA 1.3 Compliance + +| Principle | Implementation | +|-----------|---------------| +| P1: Immutable State | `Pipeline` is `readonly class`; `withProcessor()` returns new instance | +| P2: Reactive Flow | Sequential composition: f₁ ∘ f₂ ∘ ... ∘ fₙ | +| P3: Adaptive Context | Context-based registry; ConfigurableProcessor | +| P4: Protocol Agnostic | No protocol coupling; works with HTTP, gRPC, CLI, etc. | +| P5: Observability | Structured exceptions; ProcessingResultCollection tracing | + +## Integration with KaririCode Components + +| Component | Role | +|-----------|------| +| `kariricode/contract` | `Processor`, `ConfigurableProcessor` interfaces | +| `kariricode/validator` | Validation pipelines via `#[Validate]` | +| `kariricode/sanitizer` | Sanitization pipelines via `#[Sanitize]` | +| `kariricode/transformer` | Transformation pipelines via `#[Transform]` | +| `kariricode/property-inspector` | Discovers `#[Process]` attributes on properties | + +## Development + +```bash +git clone https://github.com/KaririCode-Framework/kariricode-processor-pipeline.git +cd kariricode-processor-pipeline +make setup-env && make up && make composer-install +make test # Run tests +make coverage # Coverage report +make cs-fix # Code style +make quality # Full quality check +``` + +## Documentation + +- **[Technical Specification](docs/specs/SPEC-001-processor-pipeline.md)** — algorithms, type definitions, complexity analysis +- **[ADR-001: Immutable Pipeline](docs/adrs/ADR-001-immutable-pipeline.md)** — why pipelines are readonly +- **[ADR-002: Context Registry](docs/adrs/ADR-002-context-based-registry.md)** — two-level registry design +- **[ADR-003: Specification Format](docs/adrs/ADR-003-processor-specification-format.md)** — flexible processor specs + +## License + +MIT License — see [LICENSE](LICENSE) for details. + +## Support + +- **Documentation**: [kariricode.org/docs/processor-pipeline](https://kariricode.org/docs/processor-pipeline) +- **Issues**: [GitHub Issues](https://github.com/KaririCode-Framework/kariricode-processor-pipeline/issues) +- **Community**: [KaririCode Club](https://kariricode.club) + +--- + +Built with ❤️ by the KaririCode team. Empowering developers to create robust PHP applications. diff --git a/docs/adrs/ADR-001-immutable-pipeline.md b/docs/adrs/ADR-001-immutable-pipeline.md new file mode 100644 index 0000000..e79d230 --- /dev/null +++ b/docs/adrs/ADR-001-immutable-pipeline.md @@ -0,0 +1,63 @@ +# ADR-001: Immutable Pipeline Design + +**Status:** Accepted +**Date:** 2025-11-20 +**Author:** Walmir Silva +**Supersedes:** N/A + +## Context + +The original ProcessorPipeline (v1.x–v1.3.2) used a mutable `Pipeline` class where `pipe()` modified the internal array in-place. This created several problems: + +1. **Race conditions**: Shared pipeline instances modified concurrently in async/fiber contexts produced non-deterministic processor ordering. +2. **Test isolation**: Test suites that reused pipeline fixtures experienced ordering leaks between test methods. +3. **Composition safety**: Building a "base pipeline" and extending it for specific use-cases mutated the base, breaking other consumers. + +## Decision + +Starting with v4.0.0, `Pipeline` is declared `final readonly class`. The `pipe()` method is replaced by `withProcessor()` which returns a **new** `Pipeline` instance. The original is never modified. + +```php +// v1.x (mutable) +$pipeline->pipe(new TrimProcessor()); // mutates $pipeline + +// v4.0 (immutable — ARFA 1.3 P1) +$extended = $pipeline->withProcessor(new TrimProcessor()); // new instance +``` + +## ARFA 1.3 Justification + +**Principle 1 (Immutable State Transformation):** + +> ∀s ∈ States, ∀t ∈ Transformations: t(s) → s' where s ≠ s' ∧ s.immutable = true + +The Pipeline is a state object. Adding a processor is a transformation that must produce a new pipeline, leaving the original unchanged. + +## Consequences + +### Positive + +- Zero race conditions on shared pipeline instances +- Full test isolation without `setUp()` reconstruction +- Safe composition: base pipelines can be extended freely +- Aligns with PHP 8.4+ `readonly class` language support + +### Negative + +- Marginal memory overhead from cloning processor arrays (negligible for typical pipeline sizes of 3–15 processors) +- Existing v1.x code using `pipe()` requires migration to `withProcessor()` + +### Neutral + +- `ProcessorBuilder::buildPipeline()` already produced a new pipeline per call, so builder-based code is unaffected + +## Alternatives Considered + +1. **Copy-on-write with `clone`**: Rejected — PHP's clone semantics for arrays are value-copy, which is what `readonly` already provides. No benefit over the simpler approach. +2. **Freezable pattern** (`$pipeline->freeze()`): Rejected — adds runtime state tracking, violates Principle 1, and creates a temporal coupling anti-pattern. + +## References + +- ARFA 1.3 Specification §4.4 (ProcessorPipeline Type Definition) +- Okasaki, C., "Purely Functional Data Structures", Cambridge University Press, 1999 +- KaririCode Standard Specification V4.0 §3.1 (Immutability Requirements) diff --git a/docs/adrs/ADR-002-context-based-registry.md b/docs/adrs/ADR-002-context-based-registry.md new file mode 100644 index 0000000..b48245d --- /dev/null +++ b/docs/adrs/ADR-002-context-based-registry.md @@ -0,0 +1,62 @@ +# ADR-002: Context-Based Processor Registry + +**Status:** Accepted +**Date:** 2025-11-20 +**Author:** Walmir Silva +**Supersedes:** N/A + +## Context + +A flat processor registry (name → processor) forces globally unique names. When a project uses both `KaririCode\Validator` and `KaririCode\Sanitizer`, both may register a processor named `trim` — collision. + +## Decision + +ProcessorRegistry uses a **two-level map**: `context → processorName → Processor`. + +```php +$registry->register('validator', 'trim', new TrimValidator()); +$registry->register('sanitizer', 'trim', new TrimSanitizer()); + +// No collision — different contexts +$validator = $registry->get('validator', 'trim'); +$sanitizer = $registry->get('sanitizer', 'trim'); +``` + +## ARFA 1.3 Justification + +**Principle 3 (Adaptive Context Awareness):** + +> Context(t) = {metrics(t), conditions(t), history(t)} → Adaptation(t+1) + +The processing context determines which domain the processor belongs to. The registry's context parameter is the structural expression of this principle. + +**Principle 4 (Protocol Agnosticism):** + +The same processor class can be registered under multiple contexts for different protocol handlers (e.g. `http.validator` vs `grpc.validator`). + +## Consequences + +### Positive + +- Zero naming collisions across domains +- Clean integration between Validator, Sanitizer, Transformer, and Normalizer components +- Context names serve as documentation of intent + +### Negative + +- Two strings required per `get()` call instead of one +- Slightly more verbose API + +### Neutral + +- `ProcessorBuilder` abstracts the context parameter — end users typically interact with it rather than the registry directly + +## Alternatives Considered + +1. **Prefixed names** (`validator.trim`, `sanitizer.trim`): Rejected — convention-based, no compile-time or runtime enforcement, fragile to typos. +2. **Separate registry instances per context**: Rejected — complicates DI configuration and cross-context introspection. + +## References + +- ARFA 1.3 Specification §4.3 (ProcessorRegistry Type Definition) +- KaririCode Standard Specification V4.0 §5.2 (Context Partitioning) diff --git a/docs/adrs/ADR-003-processor-specification-format.md b/docs/adrs/ADR-003-processor-specification-format.md new file mode 100644 index 0000000..6dcf16a --- /dev/null +++ b/docs/adrs/ADR-003-processor-specification-format.md @@ -0,0 +1,60 @@ +# ADR-003: Flexible Processor Specification Format + +**Status:** Accepted +**Date:** 2025-11-20 +**Author:** Walmir Silva +**Supersedes:** N/A + +## Context + +Pipeline construction requires specifying which processors to include and optionally how to configure them. Different use-cases require different levels of control: + +1. **Simple**: Just list processor names → `['trim', 'email']` +2. **Selective**: Enable/disable specific processors → `['trim' => true, 'optional' => false]` +3. **Configured**: Provide processor-specific options → `['length' => ['min' => 3]]` + +## Decision + +`ProcessorBuilder::buildPipeline()` accepts a `$processorSpecs` parameter that supports all three formats in a single array: + +```php +$pipeline = $builder->buildPipeline('validator', [ + 'required', // Simple: enabled by name + 'trim' => true, // Selective: enabled explicitly + 'optional' => false, // Selective: disabled (skipped) + 'length' => ['minLength' => 3, 'max' => 50], // Configured +]); +``` + +### Resolution Rules + +| Key Type | Value Type | Interpretation | +|----------|-----------|----------------| +| `int` | `string` | Name from sequential list — enabled, no config | +| `string` | `false` | Disabled — skip this processor | +| `string` | `true` | Enabled — no configuration | +| `string` | `array` | Enabled — configure with array | + +## ARFA 1.3 Justification + +**Principle 2 (Reactive Flow Composition):** + +The specification defines the composition: which processors, in what order, with what configuration. This is the declarative expression of `Flow = f₁ ∘ f₂ ∘ ... ∘ fₙ`. + +## Consequences + +### Positive + +- Single API serves simple and complex use-cases +- Mirrors the `#[Validate]` / `#[Sanitize]` / `#[Transform]` attribute DSL +- No separate "simple" and "advanced" builder methods + +### Negative + +- Mixed key types (`int` and `string`) require careful resolution logic +- Type-safety is limited to runtime checks (PHPStan cannot fully validate mixed arrays) + +## References + +- KaririCode\Validator `#[Validate(processors: [...], messages: [...])]` attribute format +- ARFA 1.3 Specification §4.4.2 (Pipeline Construction Algorithm) diff --git a/docs/specs/SPEC-001-processor-pipeline.md b/docs/specs/SPEC-001-processor-pipeline.md new file mode 100644 index 0000000..45d9d2f --- /dev/null +++ b/docs/specs/SPEC-001-processor-pipeline.md @@ -0,0 +1,259 @@ +# SPEC-001: KaririCode ProcessorPipeline V4.0 — Technical Specification + +**Version:** 4.0.0 +**Status:** Final Release +**Date:** November 2025 +**Author:** Walmir Silva +**Architecture:** ARFA 1.3 (Adaptive Reactive Flow Architecture) +**License:** MIT + +--- + +## 1. Overview + +KaririCode\ProcessorPipeline is the composition engine of the KaririCode Framework. It provides a context-based processor registry, a flexible builder, and an immutable execution pipeline. Every component in the DPO triad (Validator, Sanitizer, Transformer) depends on ProcessorPipeline for processor orchestration. + +### 1.1 Component Identity + +| Property | Value | +|----------|-------| +| Packagist | `kariricode/processor-pipeline` | +| Namespace | `KaririCode\ProcessorPipeline` | +| PHP Version | ≥ 8.4 | +| Dependencies | `kariricode/contract ^4.0` | +| External Dependencies | **Zero** | +| Lines of Code (src) | ~1,143 | +| Lines of Code (tests) | ~1,822 | +| Test Count | 128 (91 unit + 12 integration + 7 attribute) | +| Test Coverage Target | ≥ 95% | + +### 1.2 ARFA 1.3 Principle Mapping + +| Class | P1 | P2 | P3 | P4 | P5 | +|-------|----|----|----|----|-----| +| Pipeline | ✅ | ✅ | — | — | ✅ | +| ProcessorRegistry | — | — | ✅ | ✅ | ✅ | +| ProcessorBuilder | — | ✅ | ✅ | — | — | +| ProcessorHandler | — | — | — | — | ✅ | +| ProcessingResultCollection | ✅ | — | — | — | ✅ | + +--- + +## 2. Architecture + +### 2.1 Component Diagram + +``` +┌──────────────────────────────────────────────────────┐ +│ Consumer Code │ +│ $pipeline = $builder->buildPipeline('ctx', specs); │ +│ $result = $pipeline->process($data); │ +└──────────────┬───────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ ProcessorBuilder │──────────┐ +│ buildPipeline(ctx, spec)│ │ +└──────────┬───────────────┘ │ + │ resolves │ creates + ▼ ▼ +┌──────────────────────┐ ┌──────────────────┐ +│ ProcessorRegistry │ │ Pipeline │ +│ context → name → P │ │ [P₁, P₂, ..Pₙ] │ +└──────────────────────┘ │ process(input) │ + └──────────────────┘ +``` + +### 2.2 Data Flow + +``` +Input → P₁.process() → P₂.process() → ... → Pₙ.process() → Output + ↓ ↓ ↓ + [error?] ──────→ PipelineExecutionException (with stage index) +``` + +--- + +## 3. Type Definitions + +### 3.1 ProcessorRegistry + +``` +ProcessorRegistry = { + processors: Map> +} + +Invariants: + ∀ (ctx, name) ∈ registry: + processors[ctx][name] implements Processor +``` + +### 3.2 Pipeline + +``` +Pipeline = { + processors: List + immutable: true +} + +Invariants: + processors.length ≥ 0 + ∀i ∈ [0, processors.length): + processors[i] implements Processor +``` + +### 3.3 ProcessorBuilder + +``` +ProcessorBuilder = { + registry: ProcessorRegistry (injected) +} + +buildPipeline: (context: String, specs: ProcessorSpec[]) → Pipeline +``` + +--- + +## 4. Algorithms + +### 4.1 Pipeline Execution + +``` +Algorithm: Sequential Pipeline Execution +Input: pipeline π with processors [P₀..Pₙ₋₁], data d +Output: transformed data d' | PipelineExecutionException + +1. state ← d +2. FOR i ← 0 TO n-1 DO +3. TRY +4. state ← π.processors[i].process(state) +5. CATCH throwable +6. RAISE PipelineExecutionException(stage=i, processor=Pᵢ.class, cause=throwable) +7. END TRY +8. END FOR +9. RETURN state +``` + +**Complexity:** O(n·p) where n = |processors|, p = avg processor cost +**Space:** O(1) additional (state variable reused) + +**Theorem 4.1.1 (Termination):** +Pipeline execution terminates in bounded time if every Pᵢ terminates. + +*Proof:* The loop iterates exactly n times (finite). Each iteration executes exactly one `process()` call. If Pᵢ terminates, the iteration completes. By induction, all n iterations complete. ∎ + +### 4.2 Specification Resolution + +``` +Algorithm: Resolve Processor Specification +Input: specs = array, registry R, context C +Output: List + +1. result ← [] +2. FOR EACH (key, value) IN specs DO +3. CASE (typeof key, typeof value): +4. (int, string) → name ← value; config ← null +5. (string, false) → CONTINUE // disabled +6. (string, true) → name ← key; config ← null +7. (string, array) → name ← key; config ← value +8. OTHERWISE → CONTINUE // unknown format +9. END CASE +10. processor ← R.get(C, name) // may throw ProcessorNotFoundException +11. IF config ≠ null THEN +12. ASSERT processor instanceof ConfigurableProcessor +13. processor.configure(config) +14. END IF +15. result.append(processor) +16. END FOR +17. RETURN result +``` + +--- + +## 5. Exception Hierarchy + +``` +ProcessorPipelineException (RuntimeException) +├── ProcessorNotFoundException +│ forNameInContext(name, context) +│ forName(name) +├── PipelineExecutionException +│ atStage(processorName, stageIndex, cause) +└── InvalidProcessorConfigurationException + forProcessor(processorName, reason) +``` + +All exceptions carry a `context: array` for structured observability. + +--- + +## 6. PHP 8.4+ Features Used + +| Feature | Usage | +|---------|-------| +| `readonly class` | `Pipeline`, `ProcessorBuilder`, `ProcessorHandler`, `Process` attribute | +| Property Hooks (`get {}`) | `ProcessingResultCollection` (6 hooks), `ProcessorRegistry` (3 hooks) | +| Asymmetric Visibility | `Process::$processors` (public read) | +| Constructor Property Promotion | All value-carrying classes | +| Named Arguments | Exception factories | +| `Attribute::IS_REPEATABLE` | `#[Process]` attribute | +| `array_values()` normalization | Pipeline constructor | + +--- + +## 7. Integration Points + +### 7.1 KaririCode Ecosystem + +| Component | Integration | +|-----------|-------------| +| `kariricode/contract` | `Processor`, `ConfigurableProcessor` interfaces | +| `kariricode/validator` | `#[Validate]` → `ProcessorBuilder::buildPipeline('validator', ...)` | +| `kariricode/sanitizer` | `#[Sanitize]` → `ProcessorBuilder::buildPipeline('sanitizer', ...)` | +| `kariricode/transformer` | `#[Transform]` → `ProcessorBuilder::buildPipeline('transformer', ...)` | +| `kariricode/normalizer` | `#[Normalize]` → `ProcessorBuilder::buildPipeline('normalizer', ...)` | +| `kariricode/property-inspector` | `AttributeAnalyzer` discovers `#[Process]` attributes | +| `kariricode/serializer` | Pre/post-serialization processing chains | + +### 7.2 External Integration + +| Pattern | Approach | +|---------|----------| +| PSR-11 Container | Register processors from DI container into ProcessorRegistry | +| PSR-14 Events | Dispatch events before/after pipeline execution via ProcessorHandler | +| PSR-3 Logging | Wrap processors with logging handlers | + +--- + +## 8. Quality Metrics + +| Metric | Target | Tool | +|--------|--------|------| +| PHPStan Level | 9 (max) | phpstan.neon | +| Psalm Level | 1 (strictest) | psalm.xml | +| PHP CS Fixer | KaririCode ruleset | .php-cs-fixer.php | +| PHPUnit Coverage | ≥ 95% line | phpunit.xml | +| Antipatterns | 0 | Manual review | +| Documentation | ≥ 800 lines per major file | Spec V4.0 | + +--- + +## 9. Migration from V1.x + +| V1.x API | V4.0 API | Notes | +|-----------|----------|-------| +| `$pipeline->pipe($p)` | `$pipeline->withProcessor($p)` | Returns new instance | +| `new Pipeline()` then `pipe()` | `new Pipeline([$p1, $p2])` or builder | Immutable construction | +| `ProcessorRegistry::register(ctx, name, p)` | Same | API unchanged | +| `ProcessorBuilder::buildPipeline(ctx, specs)` | Same | API unchanged | +| PHP 8.1+ | PHP 8.4+ | Minimum version raised | + +--- + +## 10. References + +- ARFA 1.3 Specification (Patent Pending) — 87 pages, 15 algorithms, 13 theorems +- KaririCode Standard Specification V4.0 +- Framework Design Guidelines (Cwalina & Abrams, 3rd ed.) +- Clean Code (Martin, R.C.) +- A Philosophy of Software Design (Ousterhout, J.) diff --git a/phpcs.xml b/phpcs.xml deleted file mode 100644 index 07143a4..0000000 --- a/phpcs.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - src/ - tests/ - - - vendor/* - config/* - tests/bootstrap.php - tests/object-manager.php - - diff --git a/phpinsights.php b/phpinsights.php deleted file mode 100644 index 5df088e..0000000 --- a/phpinsights.php +++ /dev/null @@ -1,60 +0,0 @@ - 'symfony', - 'exclude' => [ - 'src/Migrations', - 'src/Kernel.php', - ], - 'add' => [], - 'remove' => [ - \PHP_CodeSniffer\Standards\Generic\Sniffs\Formatting\SpaceAfterNotSniff::class, - \NunoMaduro\PhpInsights\Domain\Sniffs\ForbiddenSetterSniff::class, - \SlevomatCodingStandard\Sniffs\Commenting\UselessFunctionDocCommentSniff::class, - \SlevomatCodingStandard\Sniffs\Commenting\DocCommentSpacingSniff::class, - \SlevomatCodingStandard\Sniffs\Classes\SuperfluousInterfaceNamingSniff::class, - \SlevomatCodingStandard\Sniffs\Classes\SuperfluousExceptionNamingSniff::class, - \SlevomatCodingStandard\Sniffs\ControlStructures\DisallowYodaComparisonSniff::class, - \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenTraits::class, - \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenNormalClasses::class, - \SlevomatCodingStandard\Sniffs\Classes\SuperfluousTraitNamingSniff::class, - \SlevomatCodingStandard\Sniffs\Classes\ForbiddenPublicPropertySniff::class, - \NunoMaduro\PhpInsights\Domain\Insights\CyclomaticComplexityIsHigh::class, - \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenDefineFunctions::class, - \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenFinalClasses::class, - \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenGlobals::class, - \PHP_CodeSniffer\Standards\Squiz\Sniffs\Commenting\FunctionCommentSniff::class, - \SlevomatCodingStandard\Sniffs\TypeHints\ReturnTypeHintSniff::class, - \SlevomatCodingStandard\Sniffs\Commenting\InlineDocCommentDeclarationSniff::class, - \SlevomatCodingStandard\Sniffs\Classes\ModernClassNameReferenceSniff::class, - \PHP_CodeSniffer\Standards\Generic\Sniffs\CodeAnalysis\UselessOverridingMethodSniff::class, - \SlevomatCodingStandard\Sniffs\TypeHints\DeclareStrictTypesSniff::class, - \SlevomatCodingStandard\Sniffs\TypeHints\ParameterTypeHintSniff::class, - \SlevomatCodingStandard\Sniffs\TypeHints\PropertyTypeHintSniff::class, - \SlevomatCodingStandard\Sniffs\Arrays\TrailingArrayCommaSniff::class - ], - 'config' => [ - \PHP_CodeSniffer\Standards\Generic\Sniffs\Files\LineLengthSniff::class => [ - 'lineLimit' => 120, - 'absoluteLineLimit' => 160, - ], - \SlevomatCodingStandard\Sniffs\Commenting\InlineDocCommentDeclarationSniff::class => [ - 'exclude' => [ - 'src/Exception/BaseException.php', - ], - ], - \SlevomatCodingStandard\Sniffs\ControlStructures\AssignmentInConditionSniff::class => [ - 'enabled' => false, - ], - ], - 'requirements' => [ - 'min-quality' => 80, - 'min-complexity' => 50, - 'min-architecture' => 75, - 'min-style' => 95, - 'disable-security-check' => false, - ], - 'threads' => null -]; diff --git a/phpstan.neon b/phpstan.neon deleted file mode 100644 index c3392e9..0000000 --- a/phpstan.neon +++ /dev/null @@ -1,7 +0,0 @@ -parameters: - level: max - paths: - - src - - tests - ignoreErrors: - - '#Method .* has parameter \$.* with no value type specified in iterable type array.#' diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index ba8e7af..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - tests - - - - - - src - - - diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index f0c90a3..0000000 --- a/psalm.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Attribute/Process.php b/src/Attribute/Process.php new file mode 100644 index 0000000..cf4f392 --- /dev/null +++ b/src/Attribute/Process.php @@ -0,0 +1,72 @@ + 'Invalid email format.', + * ], + * )] + * public private(set) string $email = ''; + * + * #[Process( + * processors: [ + * 'required', + * 'length' => ['minLength' => 3, 'maxLength' => 50], + * ], + * messages: [ + * 'required' => 'Username is required.', + * 'length' => 'Username must be 3-50 characters.', + * ], + * )] + * public private(set) string $username = ''; + * } + * ``` + * + * @package KaririCode\ProcessorPipeline\Attribute + * @author Walmir Silva + * @copyright 2025 KaririCode + * @license MIT + * @version 4.0.0 + * @since 2.0.0 + */ +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] +final readonly class Process +{ + /** + * @param array> $processors Processor specification + * @param array $messages Error key → message overrides + */ + public function __construct( + public array $processors = [], + public array $messages = [], + ) { + } +} diff --git a/src/Contract/ProcessorConfigBuilder.php b/src/Contract/ProcessorConfigBuilder.php deleted file mode 100644 index d0c94a7..0000000 --- a/src/Contract/ProcessorConfigBuilder.php +++ /dev/null @@ -1,19 +0,0 @@ - + * @copyright 2025 KaririCode + * @license MIT + * @version 4.0.0 + * @since 4.0.0 + * + * @see \KaririCode\Contract\Processor\ConfigurableProcessor + * @see \KaririCode\ProcessorPipeline\ProcessorBuilder::configureProcessor() + */ +final class InvalidProcessorConfigurationException extends ProcessorPipelineException +{ + public static function forProcessor(string $processorName, string $reason): self + { + return new self( + message: "Invalid configuration for processor '{$processorName}': {$reason}", + context: ['processor' => $processorName, 'reason' => $reason], + ); + } +} diff --git a/src/Exception/PipelineExecutionException.php b/src/Exception/PipelineExecutionException.php new file mode 100644 index 0000000..ca31abf --- /dev/null +++ b/src/Exception/PipelineExecutionException.php @@ -0,0 +1,37 @@ + + * @copyright 2025 KaririCode + * @license MIT + * @version 4.0.0 + * @since 4.0.0 + */ +final class PipelineExecutionException extends ProcessorPipelineException +{ + public static function atStage( + string $processorName, + int $stageIndex, + \Throwable $cause, + ): self { + return new self( + message: "Pipeline failed at stage {$stageIndex} (processor: {$processorName}): {$cause->getMessage()}", + previous: $cause, + context: [ + 'processor' => $processorName, + 'stage' => $stageIndex, + 'causeClass' => $cause::class, + ], + ); + } +} diff --git a/src/Exception/ProcessingException.php b/src/Exception/ProcessingException.php deleted file mode 100644 index 5b9512e..0000000 --- a/src/Exception/ProcessingException.php +++ /dev/null @@ -1,33 +0,0 @@ - + * @copyright 2025 KaririCode + * @license MIT + * @version 4.0.0 + * @since 2.0.0 + */ +final class ProcessorNotFoundException extends ProcessorPipelineException +{ + public static function forNameInContext(string $processorName, string $context): self + { + return new self( + message: "Processor '{$processorName}' not found in context '{$context}'.", + context: ['processor' => $processorName, 'context' => $context], + ); + } + + public static function forName(string $processorName): self + { + return new self( + message: "Processor '{$processorName}' not found.", + context: ['processor' => $processorName], + ); + } +} diff --git a/src/Exception/ProcessorPipelineException.php b/src/Exception/ProcessorPipelineException.php new file mode 100644 index 0000000..b32356d --- /dev/null +++ b/src/Exception/ProcessorPipelineException.php @@ -0,0 +1,37 @@ + + * @copyright 2025 KaririCode + * @license MIT + * @version 4.0.0 + * @since 1.0.0 + */ +class ProcessorPipelineException extends \RuntimeException +{ + /** + * @param array $context Structured context for observability + */ + public function __construct( + string $message = '', + int $code = 0, + ?\Throwable $previous = null, + public readonly array $context = [], + ) { + parent::__construct($message, $code, $previous); + } +} diff --git a/src/Exception/ProcessorRuntimeException.php b/src/Exception/ProcessorRuntimeException.php deleted file mode 100644 index 04cca4c..0000000 --- a/src/Exception/ProcessorRuntimeException.php +++ /dev/null @@ -1,44 +0,0 @@ -processAttribute($propertyName, $attribute, $value); - } catch (\Exception $e) { - $this->processingResultErrors[$propertyName][] = $e->getMessage(); - - return $value; - } - } - - private function processAttribute(string $propertyName, ProcessableAttribute $attribute, mixed $value): mixed - { - $config = $this->configBuilder->build($attribute); - $messages = []; - - if ($attribute instanceof CustomizableMessageAttribute) { - foreach ($config as $processorName => &$processorConfig) { - if ($message = $attribute->getMessage($processorName)) { - $processorConfig['customMessage'] = $message; - $messages[$processorName] = $message; - } - } - } - - $processedValue = $this->processValue($value, $config); - - if ($errors = $this->validateProcessors($config, $messages)) { - $this->processingResultErrors[$propertyName] = $errors; - } - - $this->processedPropertyValues[$propertyName] = [ - 'value' => $processedValue, - 'messages' => $messages, - ]; - - $this->processingResultMessages[$propertyName] = $messages; - - return $processedValue; - } - - private function validateProcessors(array $processorsConfig, array $messages): array - { - $errors = []; - foreach ($processorsConfig as $processorName => $config) { - // Simplify cache key to processor name - if (!isset($this->processorCache[$processorName])) { - $this->processorCache[$processorName] = $this->builder->build( - $this->processorType, - $processorName, - $config - ); - } - - $processor = $this->processorCache[$processorName]; - - if ($error = $this->validator->validate($processor, $processorName, $messages)) { - $errors[$processorName] = $error; - } - } - - return $errors; - } - - private function processValue(mixed $value, array $config): mixed - { - return $this->builder - ->buildPipeline($this->processorType, $config) - ->process($value); - } - - public function applyChanges(object $entity): void - { - foreach ($this->processedPropertyValues as $propertyName => $data) { - (new PropertyAccessor($entity, $propertyName))->setValue($data['value']); - } - } - - public function getProcessedPropertyValues(): array - { - return $this->processedPropertyValues; - } - - public function getProcessingResultErrors(): array - { - return $this->processingResultErrors; - } - - public function getProcessingResultMessages(): array - { - return $this->processingResultMessages; - } - - public function reset(): void - { - $this->processedPropertyValues = []; - $this->processingResultErrors = []; - $this->processingResultMessages = []; - $this->processorCache = []; - } -} diff --git a/src/Handler/ProcessorAttributeHandler.php b/src/Handler/ProcessorAttributeHandler.php deleted file mode 100644 index 5166594..0000000 --- a/src/Handler/ProcessorAttributeHandler.php +++ /dev/null @@ -1,128 +0,0 @@ -results = new ProcessingResultCollection(); - } - - public function handleAttribute(string $propertyName, object $attribute, mixed $value): mixed - { - $result = parent::handleAttribute($propertyName, $attribute, $value); - - if (null !== $result) { - $this->transferResults($propertyName); - } - - return $result; - } - - private function transferResults(string $propertyName): void - { - $processedValues = parent::getProcessedPropertyValues(); - $errors = parent::getProcessingResultErrors(); - - if (isset($processedValues[$propertyName])) { - $this->results->setProcessedData( - $propertyName, - $processedValues[$propertyName]['value'] - ); - } - - if (isset($errors[$propertyName])) { - foreach ($errors[$propertyName] as $processorName => $error) { - $this->results->addError( - $propertyName, - $error['errorKey'] ?? 'processing_error', - $error['message'] ?? 'Unknown error' - ); - } - } - } - - public function getProcessedPropertyValues(): array - { - return [ - 'values' => $this->results->getProcessedData(), - 'timestamp' => time(), - ]; - } - - public function getProcessingResultErrors(): array - { - return $this->results->getErrors(); - } - - public function hasErrors(): bool - { - return $this->results->hasErrors(); - } - - public function getProcessingResults(): ProcessingResultCollection - { - return $this->results; - } - - public function reset(): void - { - parent::reset(); - $this->results = new ProcessingResultCollection(); - } - - protected function validateProcessors(array $processorsConfig, array $messages): array - { - $errors = []; - - foreach ($processorsConfig as $processorName => $config) { - $processor = $this->builder->build( - $this->identifier, - $processorName, - $config - ); - - if ($processor instanceof ValidatableProcessor && !$processor->isValid()) { - $errorKey = $processor->getErrorKey(); - $message = $messages[$processorName] ?? "Validation failed for $processorName"; - - $errors[$processorName] = [ - 'errorKey' => $errorKey, - 'message' => $message, - ]; - - $this->results->addError($processorName, $errorKey, $message); - } - } - - return $errors; - } - - protected function processValue(mixed $value, array $config): mixed - { - try { - return $this->builder - ->buildPipeline($this->identifier, $config) - ->process($value); - } catch (\Exception $e) { - throw ProcessorRuntimeException::processingFailed($value); - } - } -} diff --git a/src/Handler/ProcessorHandler.php b/src/Handler/ProcessorHandler.php new file mode 100644 index 0000000..44385ac --- /dev/null +++ b/src/Handler/ProcessorHandler.php @@ -0,0 +1,69 @@ + + * @copyright 2025 KaririCode + * @license MIT + * @version 4.0.0 + * @since 4.0.0 + */ +final readonly class ProcessorHandler implements Processor +{ + public function __construct( + private Processor $processor, + private ProcessingResultCollection $resultCollection, + private bool $haltOnError = false, + ) { + } + + /** + * Execute the wrapped processor with error collection. + * + * @param mixed $input Data to process + * + * @return mixed Processed data (passthrough on error unless halt-on-error) + * + * @throws \Throwable Re-thrown if halt-on-error is enabled + */ + #[\Override] + public function process(mixed $input): mixed + { + $this->resultCollection->recordExecution($this->processor::class); + + try { + return $this->processor->process($input); + } catch (\Throwable $throwable) { + $this->resultCollection->addError( + $this->processor::class, + 'processingFailed', + $throwable->getMessage(), + ); + + if ($this->haltOnError) { + throw $throwable; + } + + return $input; + } + } +} diff --git a/src/Pipeline/Pipeline.php b/src/Pipeline/Pipeline.php new file mode 100644 index 0000000..f01f172 --- /dev/null +++ b/src/Pipeline/Pipeline.php @@ -0,0 +1,167 @@ +buildPipeline('validator', ['required', 'email']); + * $result = $pipeline->process($input); + * + * // Manual construction: + * $pipeline = new Pipeline([new TrimProcessor(), new EmailValidator()]); + * $result = $pipeline->process(' foo@bar.com '); + * + * // Immutable extension: + * $extended = $pipeline->withProcessor(new UpperCaseProcessor()); + * // $pipeline is unchanged; $extended has 3 processors + * ``` + * + * Algorithm + * ========= + * + * ``` + * Algorithm: Sequential Pipeline Execution + * Input: pipeline π (n processors), data d + * Output: transformed data d' + * + * 1. state ← d + * 2. FOR i = 0 TO n-1 DO + * 3. TRY + * 4. state ← π.processors[i].process(state) + * 5. CATCH error + * 6. THROW PipelineExecutionException(stage=i, processor=π.processors[i]) + * 7. END FOR + * 8. RETURN state + * ``` + * + * Complexity: O(n·p) where n = processor count, p = average processor cost. + * + * Theorem (Termination): + * Pipeline execution terminates if every processor terminates. + * Proof: Finite processor count n; sequential iteration; no loops. ∎ + * + * @package KaririCode\ProcessorPipeline\Pipeline + * @author Walmir Silva + * @copyright 2025 KaririCode + * @license MIT + * @version 4.0.0 + * @since 1.0.0 + * + * @see \KaririCode\ProcessorPipeline\ProcessorBuilder Construction + * @see \KaririCode\ProcessorPipeline\ProcessorRegistry Registration + */ +final readonly class Pipeline +{ + /** @var list */ + private array $processors; + + /** + * @param list $processors Ordered processor list + */ + public function __construct(array $processors = []) + { + /** @psalm-suppress RedundantFunctionCall — input may have non-sequential keys */ + $this->processors = array_values($processors); + } + + /** + * Execute all processors sequentially on the input data. + * + * @param mixed $input Data to process + * + * @return mixed Processed result after all stages + * + * @throws PipelineExecutionException When any processor throws + */ + public function process(mixed $input): mixed + { + $state = $input; + + foreach ($this->processors as $index => $processor) { + try { + $state = $processor->process($state); + } catch (PipelineExecutionException $executionException) { + throw $executionException; + } catch (\Throwable $throwable) { + throw PipelineExecutionException::atStage( + processorName: $processor::class, + stageIndex: $index, + cause: $throwable, + ); + } + } + + return $state; + } + + /** + * Create a new pipeline with an additional processor appended. + * + * IMMUTABLE — returns a new Pipeline instance. + * The current instance is never modified (ARFA 1.3 P1). + */ + public function withProcessor(Processor $processor): self + { + return new self([...$this->processors, $processor]); + } + + /** + * Create a new pipeline with another pipeline's processors appended. + * + * IMMUTABLE — returns a new Pipeline instance. + */ + public function withPipeline(self $other): self + { + return new self([...$this->processors, ...$other->processors]); + } + + /** @return list */ + public function getProcessors(): array + { + return $this->processors; + } + + public function count(): int + { + return \count($this->processors); + } + + public function isEmpty(): bool + { + return $this->processors === []; + } +} diff --git a/src/Processor/ProcessorConfigBuilder.php b/src/Processor/ProcessorConfigBuilder.php deleted file mode 100644 index 84909e6..0000000 --- a/src/Processor/ProcessorConfigBuilder.php +++ /dev/null @@ -1,55 +0,0 @@ -getProcessors(); - $processorsConfig = []; - - foreach ($processors as $key => $processor) { - if ($this->isSimpleProcessor($processor)) { - $processorsConfig[$processor] = $this->getDefaultProcessorConfig(); - } elseif ($this->isConfigurableProcessor($processor)) { - $processorName = $this->determineProcessorName($key, $processor); - $processorsConfig[$processorName] = $this->getProcessorConfig($processor); - } - } - - return $processorsConfig; - } - - private function isSimpleProcessor(mixed $processor): bool - { - return is_string($processor); - } - - private function isConfigurableProcessor(mixed $processor): bool - { - return is_array($processor); - } - - private function getDefaultProcessorConfig(): array - { - return []; - } - - private function determineProcessorName(string|int $key, array $processor): string - { - $nameNormalizer = new ProcessorNameNormalizer(); - - return $nameNormalizer->normalize($key, $processor); - } - - private function getProcessorConfig(array $processor): array - { - return $processor; - } -} diff --git a/src/Processor/ProcessorNameNormalizer.php b/src/Processor/ProcessorNameNormalizer.php deleted file mode 100644 index c5fde05..0000000 --- a/src/Processor/ProcessorNameNormalizer.php +++ /dev/null @@ -1,25 +0,0 @@ -isNamedProcessor($key) ? (string) $key : $this->extractProcessorName($processor); - } - - private function isNamedProcessor(string|int $key): bool - { - return is_string($key); - } - - private function extractProcessorName(array $processor): string - { - $firstKey = array_key_first($processor); - - return is_string($firstKey) ? $firstKey : ''; - } -} diff --git a/src/Processor/ProcessorValidator.php b/src/Processor/ProcessorValidator.php deleted file mode 100644 index b868ba4..0000000 --- a/src/Processor/ProcessorValidator.php +++ /dev/null @@ -1,26 +0,0 @@ -isValid()) { - $errorKey = $processor->getErrorKey(); - - return [ - 'errorKey' => $errorKey, - 'message' => $messages[$processorName] ?? "Validation failed for $processorName", - ]; - } - - return null; - } -} diff --git a/src/ProcessorBuilder.php b/src/ProcessorBuilder.php index 385e195..9106873 100644 --- a/src/ProcessorBuilder.php +++ b/src/ProcessorBuilder.php @@ -5,55 +5,186 @@ namespace KaririCode\ProcessorPipeline; use KaririCode\Contract\Processor\ConfigurableProcessor; -use KaririCode\Contract\Processor\Pipeline; use KaririCode\Contract\Processor\Processor; -use KaririCode\Contract\Processor\ProcessorBuilder as ProcessorBuilderContract; -use KaririCode\Contract\Processor\ProcessorRegistry; +use KaririCode\ProcessorPipeline\Exception\InvalidProcessorConfigurationException; +use KaririCode\ProcessorPipeline\Exception\ProcessorNotFoundException; +use KaririCode\ProcessorPipeline\Pipeline\Pipeline; -class ProcessorBuilder implements ProcessorBuilderContract +/** + * Builds Pipeline instances from a ProcessorRegistry and a specification. + * + * Overview + * ======== + * + * ProcessorBuilder resolves processor names against a {@see ProcessorRegistry}, + * optionally configures {@see ConfigurableProcessor} instances, and assembles + * them into an immutable {@see Pipeline}. + * + * ARFA 1.3 Compliance + * =================== + * + * P1 (Immutable State Transformation): + * The built Pipeline is immutable. ProcessorBuilder itself is stateless + * beyond its registry reference. + * + * P2 (Reactive Flow Composition): + * Pipeline construction is the composition step: f₁ ∘ f₂ ∘ ... ∘ fₙ + * where each fᵢ is resolved from the registry by name. + * + * P3 (Adaptive Context Awareness): + * ConfigurableProcessors are configured at build time, enabling + * context-dependent processor behaviour per pipeline instance. + * + * Usage + * ===== + * + * ## Simple Pipeline (name list) + * + * ```php + * $builder = new ProcessorBuilder($registry); + * $pipeline = $builder->buildPipeline('validator', ['required', 'email']); + * $result = $pipeline->process($input); + * ``` + * + * ## Configured Pipeline (name → options map) + * + * ```php + * $pipeline = $builder->buildPipeline('validator', [ + * 'required' => false, // enabled flag + * 'length' => ['minLength' => 3, 'maxLength' => 50], + * 'email' => true, // enabled, no config + * ]); + * ``` + * + * ## Processor Specification Format + * + * The `$processorSpecs` parameter accepts: + * + * - `string[]`: Simple name list. All processors are enabled with defaults. + * - `array`: Map where: + * - `bool false` → processor is **skipped** + * - `bool true` → processor is enabled with defaults + * - `array` → processor is enabled and configured with the array + * + * Design Decisions + * ================ + * + * 1. **Specification flexibility**: Both `['required', 'email']` and + * `['required' => true, 'email' => ['strict' => true]]` are supported. + * This mirrors the Attribute-based DSL used by Validator and Sanitizer. + * + * 2. **Configuration is per-build**: Calling `configure()` on a processor + * affects that processor globally if it's a shared instance. For isolation, + * register separate instances or use a factory pattern. + * + * @package KaririCode\ProcessorPipeline + * @author Walmir Silva + * @copyright 2025 KaririCode + * @license MIT + * @version 4.0.0 + * @since 1.0.0 + * + * @see \KaririCode\ProcessorPipeline\ProcessorRegistry Processor source + * @see \KaririCode\ProcessorPipeline\Pipeline\Pipeline Built product + * @see \KaririCode\Contract\Processor\ConfigurableProcessor Configuration + */ +final readonly class ProcessorBuilder { - public function __construct(private readonly ProcessorRegistry $registry) - { - } - - public function build(string $context, string $name, array $processorConfig = []): Processor - { - $processor = $this->registry->get($context, $name); - if ($processor instanceof ConfigurableProcessor && !empty($processorConfig)) { - $processor->configure($processorConfig); - } - - return $processor; + public function __construct( + private ProcessorRegistry $registry, + ) { } + /** + * Build a pipeline from a context and processor specification. + * + * @param string $context Registry context name + * @param array $processorSpecs Processor specification + * + * @return Pipeline Immutable pipeline ready for execution + * + * @throws ProcessorNotFoundException When a processor name is not in the registry + * @throws InvalidProcessorConfigurationException When configuration fails + */ public function buildPipeline(string $context, array $processorSpecs): Pipeline { - $pipeline = new ProcessorPipeline(); + $processors = []; - foreach ($processorSpecs as $name => $config) { - if (!$this->isValidProcessorSpec($config)) { + foreach ($processorSpecs as $key => $value) { + $resolved = $this->resolveSpec($key, $value); + + if ($resolved === null) { continue; } - $processorConfig = $this->normalizeProcessorConfig($config); - $processor = $this->build($context, $name, $processorConfig); - $pipeline->addProcessor($processor); + [$processorName, $configuration] = $resolved; + + $processor = $this->registry->get($context, $processorName); + + if ($configuration !== null) { + $this->configureProcessor($processor, $processorName, $configuration); + } + + $processors[] = $processor; } - return $pipeline; + return new Pipeline($processors); } - private function isValidProcessorSpec(mixed $spec): bool + /** + * Resolve a specification entry into a processor name and optional configuration. + * + * @return array{0: string, 1: array|null}|null Null means "skip this processor" + */ + private function resolveSpec(int|string $key, mixed $value): ?array { - return is_array($spec) || true === $spec; + // Format: ['required', 'email'] → sequential list + if (\is_int($key) && \is_string($value)) { + return [$value, null]; + } + + // Format: ['required' => false] → disabled + if (\is_string($key) && $value === false) { + return null; + } + + // Format: ['required' => true] → enabled, no config + if (\is_string($key) && $value === true) { + return [$key, null]; + } + + // Format: ['length' => ['minLength' => 3]] → enabled with config + if (\is_string($key) && \is_array($value)) { + /** @var array $stringKeyedValue */ + $stringKeyedValue = $value; + + return [$key, $stringKeyedValue]; + } + + return null; } - private function normalizeProcessorConfig(mixed $config): array - { - if (is_array($config)) { - return $config; + /** + * Apply configuration to a ConfigurableProcessor. + * + * @param Processor $processor Target processor + * @param string $processorName Name for error reporting + * @param array $configuration Configuration options + * + * @throws InvalidProcessorConfigurationException When processor is not configurable + */ + private function configureProcessor( + Processor $processor, + string $processorName, + array $configuration, + ): void { + if (! $processor instanceof ConfigurableProcessor) { + throw InvalidProcessorConfigurationException::forProcessor( + $processorName, + 'Processor does not implement ConfigurableProcessor.', + ); } - return []; + $processor->configure($configuration); } } diff --git a/src/ProcessorPipeline.php b/src/ProcessorPipeline.php deleted file mode 100644 index c459a6a..0000000 --- a/src/ProcessorPipeline.php +++ /dev/null @@ -1,69 +0,0 @@ -processors[] = $processor; - - return $this; - } - - public function process(mixed $input): mixed - { - try { - return array_reduce( - $this->processors, - $this->executeProcessor(...), - $input - ); - } catch (\Exception $e) { - throw ProcessingException::pipelineExecutionFailed(); - } - } - - public function getProcessors(): array - { - return $this->processors; - } - - public function hasProcessors(): bool - { - return !empty($this->processors); - } - - public function clear(): void - { - $this->processors = []; - } - - public function count(): int - { - return count($this->processors); - } - - private function executeProcessor(mixed $carry, Processor $processor): mixed - { - try { - // Reset the processor state if it's validatable - if ($processor instanceof ValidatableProcessor) { - $processor->reset(); - } - - return $processor->process($carry); - } catch (\Exception $e) { - throw ProcessingException::processorExecutionFailed($processor::class); - } - } -} diff --git a/src/ProcessorRegistry.php b/src/ProcessorRegistry.php index 6f81fc1..24d0287 100644 --- a/src/ProcessorRegistry.php +++ b/src/ProcessorRegistry.php @@ -4,50 +4,271 @@ namespace KaririCode\ProcessorPipeline; -use KaririCode\Contract\DataStructure\Map; use KaririCode\Contract\Processor\Processor; -use KaririCode\Contract\Processor\ProcessorRegistry as ProcessorRegistryContract; -use KaririCode\DataStructure\Map\HashMap; -use KaririCode\ProcessorPipeline\Exception\ProcessorRuntimeException; +use KaririCode\ProcessorPipeline\Exception\ProcessorNotFoundException; -class ProcessorRegistry implements ProcessorRegistryContract +/** + * Context-based registry for processor instances. + * + * Overview + * ======== + * + * ProcessorRegistry organises processors into named contexts (e.g. 'validator', + * 'sanitizer', 'transformer'), enabling multiple independent processing domains + * within a single application. Each processor is identified by its context + + * name pair, ensuring collision-free registration across domains. + * + * ARFA 1.3 Compliance + * =================== + * + * P1 (Immutable State Transformation): + * The registry itself is mutable (it accumulates registrations), but + * registered processor references are never replaced silently — last-write + * wins with deterministic behaviour. + * + * P3 (Adaptive Context Awareness): + * Context-based partitioning allows the same processor class to behave + * differently under separate contexts with different configurations. + * + * P4 (Protocol Agnosticism): + * The registry is protocol-agnostic — processors registered here can + * serve HTTP, gRPC, GraphQL, WebSocket, SSE, or CLI protocols. + * + * P5 (Continuous Observability): + * Registration is introspectable via {@see getContextNames()}, + * {@see getProcessorNames()}, and {@see count()}. + * + * Usage + * ===== + * + * ## Basic Registration + * + * ```php + * use KaririCode\ProcessorPipeline\ProcessorRegistry; + * + * $registry = new ProcessorRegistry(); + * + * $registry->register('validator', 'required', new RequiredValidator()); + * $registry->register('validator', 'email', new EmailValidator()); + * $registry->register('sanitizer', 'trim', new TrimSanitizer()); + * $registry->register('sanitizer', 'htmlEscape', new HtmlEscapeSanitizer()); + * ``` + * + * ## Retrieval + * + * ```php + * $processor = $registry->get('validator', 'required'); + * $allValidators = $registry->getByContext('validator'); + * ``` + * + * ## Fluent API + * + * ```php + * $registry = (new ProcessorRegistry()) + * ->register('validator', 'required', new RequiredValidator()) + * ->register('validator', 'email', new EmailValidator()) + * ->register('sanitizer', 'trim', new TrimSanitizer()); + * ``` + * + * ## Integration with ProcessorBuilder + * + * ```php + * $builder = new ProcessorBuilder($registry); + * $pipeline = $builder->buildPipeline('validator', ['required', 'email']); + * $result = $pipeline->process($input); + * ``` + * + * Design Decisions + * ================ + * + * 1. **Two-level map** (`context → name → Processor`) avoids name collisions + * across domains. A 'trim' sanitizer and a 'trim' transformer coexist. + * + * 2. **Last-write-wins** for re-registration — no exception on overwrite. + * This enables test doubles and environment-specific processor swaps. + * + * 3. **No lazy loading** — processors are registered as fully-constructed + * instances. For deferred instantiation, integrate with a PSR-11 + * container via {@see \KaririCode\Contract\DependencyInjection\Container}. + * + * Complexity + * ========== + * + * | Operation | Time | Space | + * |----------------------|---------|--------| + * | register() | O(1) | O(1) | + * | get() | O(1) | O(1) | + * | getByContext() | O(n) | O(n) | + * | has() | O(1) | O(1) | + * | count() | O(c) | O(1) | + * + * where n = processors in context, c = number of contexts. + * + * @package KaririCode\ProcessorPipeline + * @author Walmir Silva + * @copyright 2025 KaririCode + * @license MIT + * @version 4.0.0 + * @since 1.0.0 + * + * @see \KaririCode\ProcessorPipeline\ProcessorBuilder Pipeline construction + * @see \KaririCode\ProcessorPipeline\Pipeline\Pipeline Execution engine + * @see \KaririCode\Contract\Processor\Processor Processor contract + * @see \KaririCode\Contract\Processor\ConfigurableProcessor Configurable contract + */ +final class ProcessorRegistry { - public function __construct( - private readonly Map $processors = new HashMap() - ) { + /** + * Two-level map: context → processorName → Processor. + * + * @var array> + */ + private array $processors = []; + + // ── Property Hooks (PHP 8.4) ───────────────────────────────────── + + /** @var list All registered context names (computed). */ + public array $contextNames { + get => array_keys($this->processors); } - public function register(string $context, string $name, Processor $processor): static - { - if (!$this->processors->containsKey($context)) { - $this->processors->put($context, new HashMap()); + /** Total processor count across all contexts (computed). */ + public int $totalCount { + get { + $total = 0; + foreach ($this->processors as $contextProcessors) { + $total += \count($contextProcessors); + } + + return $total; } - $contextMap = $this->processors->get($context); - $contextMap->put($name, $processor); + } + + /** Whether the registry contains any processors (computed). */ + public bool $isEmpty { + get => $this->processors === []; + } + + // ── Registration ───────────────────────────────────────────────── + + /** + * Register a processor under a context and name. + * + * @param string $context Domain context (e.g. 'validator', 'sanitizer') + * @param string $processorName Unique name within the context + * @param Processor $processor Processor instance + * + * @return self Fluent interface + */ + public function register(string $context, string $processorName, Processor $processor): self + { + $this->processors[$context][$processorName] = $processor; return $this; } - public function get(string $context, string $name): Processor + // ── Retrieval ──────────────────────────────────────────────────── + + /** + * Retrieve a processor by context and name. + * + * @throws ProcessorNotFoundException When processor is not registered + */ + public function get(string $context, string $processorName): Processor { - if (!$this->processors->containsKey($context)) { - throw ProcessorRuntimeException::contextNotFound($context); + if (! $this->has($context, $processorName)) { + throw ProcessorNotFoundException::forNameInContext($processorName, $context); } - $contextMap = $this->processors->get($context); - if (!$contextMap->containsKey($name)) { - throw ProcessorRuntimeException::processorNotFound($name, $context); + return $this->processors[$context][$processorName]; + } + + /** + * Check whether a processor exists in the registry. + */ + public function has(string $context, string $processorName): bool + { + return isset($this->processors[$context][$processorName]); + } + + /** + * Retrieve all processors registered under a context. + * + * @return array + */ + public function getByContext(string $context): array + { + return $this->processors[$context] ?? []; + } + + // ── Introspection ──────────────────────────────────────────────── + + /** + * List all registered context names. + * + * @return list + */ + public function getContextNames(): array + { + return array_keys($this->processors); + } + + /** + * List all processor names within a context. + * + * @return list + */ + public function getProcessorNames(string $context): array + { + if (! isset($this->processors[$context])) { + return []; + } + + return array_keys($this->processors[$context]); + } + + /** + * Total processor count across all contexts. + */ + public function count(): int + { + $total = 0; + + foreach ($this->processors as $contextProcessors) { + $total += \count($contextProcessors); } - return $contextMap->get($name); + return $total; } - public function getContextProcessors(string $context): Map + // ── Mutation ───────────────────────────────────────────────────── + + /** + * Remove a processor from the registry. + * + * @return bool True if the processor existed and was removed + */ + public function remove(string $context, string $processorName): bool { - if (!$this->processors->containsKey($context)) { - throw ProcessorRuntimeException::contextNotFound($context); + if (! $this->has($context, $processorName)) { + return false; } - return $this->processors->get($context); + unset($this->processors[$context][$processorName]); + + // Clean up empty contexts + if ($this->processors[$context] === []) { + unset($this->processors[$context]); + } + + return true; + } + + /** + * Remove all processors from the registry. + */ + public function clear(): void + { + $this->processors = []; } } diff --git a/src/Result/ProcessedData.php b/src/Result/ProcessedData.php deleted file mode 100644 index 8061efd..0000000 --- a/src/Result/ProcessedData.php +++ /dev/null @@ -1,35 +0,0 @@ -timestamp = time(); - } - - public function getProperty(): string - { - return $this->property; - } - - public function getValue(): mixed - { - return $this->value; - } - - public function toArray(): array - { - return [ - 'value' => $this->value, - 'timestamp' => $this->timestamp, - ]; - } -} diff --git a/src/Result/ProcessingError.php b/src/Result/ProcessingError.php deleted file mode 100644 index e867917..0000000 --- a/src/Result/ProcessingError.php +++ /dev/null @@ -1,54 +0,0 @@ -hash = $this->generateHash(); - $this->timestamp = time(); - } - - private function generateHash(): string - { - return md5($this->property . $this->errorKey . $this->message); - } - - public function getHash(): string - { - return $this->hash; - } - - public function getProperty(): string - { - return $this->property; - } - - public function getErrorKey(): string - { - return $this->errorKey; - } - - public function getMessage(): string - { - return $this->message; - } - - public function toArray(): array - { - return [ - 'errorKey' => $this->errorKey, - 'message' => $this->message, - 'timestamp' => $this->timestamp, - ]; - } -} diff --git a/src/Result/ProcessingResultCollection.php b/src/Result/ProcessingResultCollection.php index 6275bf1..a47cbd4 100644 --- a/src/Result/ProcessingResultCollection.php +++ b/src/Result/ProcessingResultCollection.php @@ -4,86 +4,232 @@ namespace KaririCode\ProcessorPipeline\Result; -use KaririCode\Contract\Processor\ProcessingResult; - -class ProcessingResultCollection implements ProcessingResult +/** + * Collects processing errors and metadata during pipeline execution. + * + * ARFA 1.3 Compliance + * =================== + * + * P1 (Immutable State Transformation): + * Results accumulate via append-only operations. Once recorded, + * an error entry is never mutated. + * + * P5 (Continuous Observability): + * Every error carries processor class, error key, and human-readable + * message — enabling structured logging and metrics collection. + * Execution trace records processor ordering for distributed tracing. + * + * PHP 8.4 Features + * ================ + * + * Uses property hooks for computed read-only access to internal state: + * - `$results->hasErrors` (computed bool) + * - `$results->hasWarnings` (computed bool) + * - `$results->errorCount` (computed int) + * - `$results->errors` (read-only array) + * - `$results->warnings` (read-only array) + * - `$results->executionTrace` (read-only list) + * + * Usage + * ===== + * + * ```php + * $results = new ProcessingResultCollection(); + * + * // Inside a processor: + * $results->addError(self::class, 'invalidFormat', 'Email is not valid.'); + * + * // After pipeline — read via property hooks: + * if ($results->hasErrors) { + * foreach ($results->errors as $processorClass => $errorList) { + * // $errorList is list + * } + * } + * + * // Trace inspection: + * $trace = $results->executionTrace; // list + * $count = $results->errorCount; // int (computed) + * ``` + * + * Thread Safety + * ============= + * + * This class is NOT thread-safe. Each pipeline execution should use its + * own instance. For async processing, use per-fiber/per-promise instances. + * + * @package KaririCode\ProcessorPipeline\Result + * @author Walmir Silva + * @copyright 2025 KaririCode + * @license MIT + * @version 4.0.0 + * @since 1.0.0 + * + * @see \KaririCode\ProcessorPipeline\Pipeline\Pipeline Pipeline integration + * @see \KaririCode\ProcessorPipeline\Handler\ProcessorHandler Error collection handler + */ +final class ProcessingResultCollection { - private array $processedData = []; - private array $errors = []; + /** @var array> */ + private array $errorEntries = []; - public function addError(string $property, string $errorKey, string $message): void - { - $error = new ProcessingError($property, $errorKey, $message); + /** @var array> */ + private array $warningEntries = []; + + /** @var list Processor execution order for tracing */ + private array $traceEntries = []; - if (!isset($this->errors[$property])) { - $this->errors[$property] = []; + // ── Property Hooks (PHP 8.4) ───────────────────────────────────── + + /** + * Read-only access to collected errors. + * + * @var array> + */ + public array $errors { + get => $this->errorEntries; + } + + /** + * Read-only access to collected warnings. + * + * @var array> + */ + public array $warnings { + get => $this->warningEntries; + } + + /** @var list */ + public array $executionTrace { + get => $this->traceEntries; + } + + /** Whether any errors have been recorded (computed). */ + public bool $hasErrors { + get => $this->errorEntries !== []; + } + + /** Whether any warnings have been recorded (computed). */ + public bool $hasWarnings { + get => $this->warningEntries !== []; + } + + /** Total individual error entries across all processors (computed). */ + public int $errorCount { + get { + $count = 0; + foreach ($this->errorEntries as $entries) { + $count += \count($entries); + } + + return $count; } + } + + // ── Mutation Methods ───────────────────────────────────────────── - $this->errors[$property][$error->getHash()] = $error; + /** + * Record a processing error. + * + * @param string $processorClass Fully-qualified processor class name + * @param string $errorKey Machine-readable error identifier + * @param string $message Human-readable error description + */ + public function addError(string $processorClass, string $errorKey, string $message): void + { + $shortName = $this->extractShortName($processorClass); + $this->errorEntries[$shortName][] = ['errorKey' => $errorKey, 'message' => $message]; } - public function setProcessedData(string $property, mixed $value): void + /** + * Record a processing warning (non-fatal). + * + * @param string $processorClass Fully-qualified processor class name + * @param string $errorKey Machine-readable warning identifier + * @param string $message Human-readable warning description + */ + public function addWarning(string $processorClass, string $errorKey, string $message): void { - $this->processedData[$property] = new ProcessedData($property, $value); + $shortName = $this->extractShortName($processorClass); + $this->warningEntries[$shortName][] = ['errorKey' => $errorKey, 'message' => $message]; } - public function hasErrors(): bool + /** + * Record a processor execution for tracing (ARFA P5). + * + * @param string $processorClass Fully-qualified processor class name + */ + public function recordExecution(string $processorClass): void { - return !empty($this->errors); + $this->traceEntries[] = $processorClass; } + // ── Legacy Accessors (V1.x compat) ─────────────────────────────── + + /** @return array> */ public function getErrors(): array { - $result = []; - foreach ($this->errors as $property => $propertyErrors) { - $result[$property] = array_values(array_map( - fn (ProcessingError $error) => [ - 'errorKey' => $error->getErrorKey(), - 'message' => $error->getMessage(), - ], - $propertyErrors - )); - } + return $this->errorEntries; + } - return $result; + /** @return array> */ + public function getWarnings(): array + { + return $this->warningEntries; } - public function getProcessedData(): array + /** @return list */ + public function getExecutionTrace(): array { - $result = []; - foreach ($this->processedData as $property => $data) { - $result[$property] = $data->getValue(); - } + return $this->traceEntries; + } - return $result; + /** @deprecated Use property hook $results->hasErrors instead. */ + public function hasErrors(): bool + { + return $this->errorEntries !== []; } - public function toArray(): array + /** @deprecated Use property hook $results->hasWarnings instead. */ + public function hasWarnings(): bool { - return [ - 'isValid' => !$this->hasErrors(), - 'errors' => $this->getErrors(), - 'processedData' => $this->getProcessedData(), - ]; + return $this->warningEntries !== []; } - public function clear(): void + // ── Collection Operations ──────────────────────────────────────── + + /** + * Merge another collection into this one (for parallel pipeline results). + */ + public function merge(self $other): void { - $this->processedData = []; - $this->errors = []; + foreach ($other->errorEntries as $processor => $entries) { + foreach ($entries as $entry) { + $this->errorEntries[$processor][] = $entry; + } + } + + foreach ($other->warningEntries as $processor => $entries) { + foreach ($entries as $entry) { + $this->warningEntries[$processor][] = $entry; + } + } + + $this->traceEntries = [...$this->traceEntries, ...$other->traceEntries]; } - public function addProcessedData(ProcessedData $data): void + public function reset(): void { - $this->processedData[$data->getProperty()] = $data; + $this->errorEntries = []; + $this->warningEntries = []; + $this->traceEntries = []; } - public function addProcessingError(ProcessingError $error): void + // ── Internal ───────────────────────────────────────────────────── + + private function extractShortName(string $fullyQualifiedName): string { - if (!isset($this->errors[$error->getProperty()])) { - $this->errors[$error->getProperty()] = []; - } + $parts = explode('\\', $fullyQualifiedName); - $this->errors[$error->getProperty()][$error->getHash()] = $error; + return end($parts); } } diff --git a/tests/Exception/ProcessorRuntimeExceptionTest.php b/tests/Exception/ProcessorRuntimeExceptionTest.php deleted file mode 100644 index f2ce614..0000000 --- a/tests/Exception/ProcessorRuntimeExceptionTest.php +++ /dev/null @@ -1,123 +0,0 @@ -assertInstanceOf(ProcessorRuntimeException::class, $exception); - $this->assertEquals(2601, $exception->getCode()); - $this->assertEquals('PROCESSOR_CONTEXT_NOT_FOUND', $exception->getErrorCode()); - $this->assertEquals("Processor context 'payment' not found", $exception->getMessage()); - $this->assertNull($exception->getPrevious()); - } - - public function testProcessorNotFound(): void - { - $exception = ProcessorRuntimeException::processorNotFound('validate', 'payment'); - - $this->assertInstanceOf(ProcessorRuntimeException::class, $exception); - $this->assertEquals(2602, $exception->getCode()); - $this->assertEquals('PROCESSOR_NOT_FOUND', $exception->getErrorCode()); - $this->assertEquals("Processor 'validate' not found in context 'payment'", $exception->getMessage()); - $this->assertNull($exception->getPrevious()); - } - - public function testProcessingFailed(): void - { - $exception = ProcessorRuntimeException::processingFailed('email'); - - $this->assertInstanceOf(ProcessorRuntimeException::class, $exception); - $this->assertEquals(2606, $exception->getCode()); - $this->assertEquals('PROCESSOR_PROCESSING_FAILED', $exception->getErrorCode()); - $this->assertEquals( - "Processing failed for property 'email'", - $exception->getMessage() - ); - $this->assertNull($exception->getPrevious()); - } - - /** - * @dataProvider specialValuesProvider - */ - public function testWithSpecialValues(string $context, string $processor, string $details): void - { - $exceptionContext = ProcessorRuntimeException::contextNotFound($context); - $this->assertStringContainsString($context, $exceptionContext->getMessage()); - - $exceptionProcessor = ProcessorRuntimeException::processorNotFound($processor, $context); - $this->assertStringContainsString($processor, $exceptionProcessor->getMessage()); - $this->assertStringContainsString($context, $exceptionProcessor->getMessage()); - } - - public static function specialValuesProvider(): array - { - return [ - 'empty values' => ['', '', ''], - 'special characters' => ['payment!@#', 'validator$%^', 'error&*()'], - 'unicode characters' => ['pagaménto', 'validação', 'erro'], - 'very long values' => [ - str_repeat('a', 100), - str_repeat('b', 100), - str_repeat('c', 100), - ], - ]; - } - - public function testExceptionHierarchy(): void - { - $exception = ProcessorRuntimeException::contextNotFound('payment'); - - $this->assertInstanceOf(\Exception::class, $exception); - $this->assertInstanceOf(\Throwable::class, $exception); - } - - public function testExceptionWithPreviousException(): void - { - $previous = new \Exception('Original error'); - - $reflection = new \ReflectionClass(ProcessorRuntimeException::class); - $method = $reflection->getMethod('createException'); - $method->setAccessible(true); - - $exception = $method->invokeArgs(null, [ - 2601, - 'PROCESSOR_CONTEXT_NOT_FOUND', - 'Test message', - $previous, - ]); - - $this->assertInstanceOf(ProcessorRuntimeException::class, $exception); - $this->assertEquals(2601, $exception->getCode()); - $this->assertEquals('PROCESSOR_CONTEXT_NOT_FOUND', $exception->getErrorCode()); - $this->assertEquals('Test message', $exception->getMessage()); - $this->assertSame($previous, $exception->getPrevious()); - } - - /** - * @dataProvider invalidPropertyValuesProvider - */ - public function testProcessingFailedWithDifferentPropertyTypes($property): void - { - $exception = ProcessorRuntimeException::processingFailed($property); - $message = $exception->getMessage(); - - $this->assertIsString($message); - $this->assertStringContainsString((string) $property, $message); - } - - public static function invalidPropertyValuesProvider(): array - { - return [ - 'valid string' => ['email'], - ]; - } -} diff --git a/tests/Handler/AttributeHandlerTest.php b/tests/Handler/AttributeHandlerTest.php deleted file mode 100644 index dd3e12f..0000000 --- a/tests/Handler/AttributeHandlerTest.php +++ /dev/null @@ -1,236 +0,0 @@ -processorBuilder = $this->createMock(ProcessorBuilder::class); - $this->processorValidator = $this->createMock(ProcessorValidator::class); - $this->configBuilder = $this->createMock(ProcessorConfigBuilder::class); - $this->attributeHandler = new AttributeHandler( - 'testProcessor', - $this->processorBuilder, - $this->processorValidator, - $this->configBuilder - ); - } - - public function testHandleAttributeProcessesValue(): void - { - $mockAttribute = $this->createMock(ProcessableAttribute::class); - $mockPipeline = $this->createMock(Pipeline::class); - - $this->configBuilder->expects($this->once()) - ->method('build') - ->willReturn(['processor1' => []]); - - $mockPipeline->expects($this->once()) - ->method('process') - ->with('initialValue') - ->willReturn('processedValue'); - - $this->processorBuilder->expects($this->once()) - ->method('buildPipeline') - ->with($this->equalTo('testProcessor'), $this->equalTo(['processor1' => []])) - ->willReturn($mockPipeline); - - $this->processorValidator->expects($this->once()) - ->method('validate') - ->willReturn(null); - - $result = $this->attributeHandler->handleAttribute('testProperty', $mockAttribute, 'initialValue'); - - $this->assertSame('processedValue', $result); - } - - public function testHandleAttributeWithValidationError(): void - { - $mockAttribute = $this->createMock(ProcessableAttribute::class); - $mockPipeline = $this->createMock(Pipeline::class); - - $this->configBuilder->expects($this->once()) - ->method('build') - ->willReturn(['processor1' => []]); - - $mockPipeline->expects($this->once()) - ->method('process') - ->willReturn('processedValue'); - - $this->processorBuilder->expects($this->once()) - ->method('buildPipeline') - ->willReturn($mockPipeline); - - $this->processorValidator->expects($this->once()) - ->method('validate') - ->willReturn(['errorKey' => 'testError', 'message' => 'Test error message']); - - $result = $this->attributeHandler->handleAttribute('testProperty', $mockAttribute, 'initialValue'); - - $this->assertSame('processedValue', $result); - - $errors = $this->attributeHandler->getProcessingResultErrors(); - $this->assertArrayHasKey('testProperty', $errors); - $this->assertArrayHasKey('processor1', $errors['testProperty']); - $this->assertEquals('testError', $errors['testProperty']['processor1']['errorKey']); - $this->assertEquals('Test error message', $errors['testProperty']['processor1']['message']); - } - - public function testHandleAttributeReturnsNullWhenAttributeNotProcessable(): void - { - $nonProcessableAttribute = new \stdClass(); - $result = $this->attributeHandler->handleAttribute('testProperty', $nonProcessableAttribute, 'initialValue'); - $this->assertNull($result); - } - - public function testApplyChangesSetsProcessedValues(): void - { - $mockEntity = new class { - public string $testProperty = 'originalValue'; - }; - - $mockAttribute = $this->createMock(ProcessableAttribute::class); - $mockPipeline = $this->createMock(Pipeline::class); - - $this->configBuilder->expects($this->once()) - ->method('build') - ->willReturn(['processor1' => []]); - - $mockPipeline->expects($this->once()) - ->method('process') - ->with('initialValue') - ->willReturn('processedValue'); - - $this->processorBuilder->expects($this->once()) - ->method('buildPipeline') - ->willReturn($mockPipeline); - - $this->processorValidator->expects($this->once()) - ->method('validate') - ->willReturn(null); - - $this->attributeHandler->handleAttribute('testProperty', $mockAttribute, 'initialValue'); - $this->attributeHandler->applyChanges($mockEntity); - - $this->assertSame('processedValue', $mockEntity->testProperty); - } - - public function testGetProcessedPropertyValuesReturnsProcessedData(): void - { - $mockAttribute = $this->createMock(ProcessableAttribute::class); - $mockPipeline = $this->createMock(Pipeline::class); - - $this->configBuilder->expects($this->once()) - ->method('build') - ->willReturn(['processor1' => []]); - - $mockPipeline->expects($this->once()) - ->method('process') - ->with('initialValue') - ->willReturn('processedValue'); - - $this->processorBuilder->expects($this->once()) - ->method('buildPipeline') - ->willReturn($mockPipeline); - - $this->processorValidator->expects($this->once()) - ->method('validate') - ->willReturn(null); - - $this->attributeHandler->handleAttribute('testProperty', $mockAttribute, 'initialValue'); - $processedValues = $this->attributeHandler->getProcessedPropertyValues(); - - $this->assertArrayHasKey('testProperty', $processedValues); - $this->assertIsArray($processedValues['testProperty']); - $this->assertArrayHasKey('value', $processedValues['testProperty']); - $this->assertArrayHasKey('messages', $processedValues['testProperty']); - $this->assertSame('processedValue', $processedValues['testProperty']['value']); - $this->assertIsArray($processedValues['testProperty']['messages']); - } - - public function testHandleAttributeWithCustomizableMessageAttribute(): void - { - $mockAttribute = $this->createMock(CombinedAttribute::class); - $mockPipeline = $this->createMock(Pipeline::class); - - $this->configBuilder->expects($this->once()) - ->method('build') - ->willReturn(['processor1' => ['option' => 'value']]); - - $mockAttribute->expects($this->once()) - ->method('getMessage') - ->with('processor1') - ->willReturn('Custom message'); - - $mockPipeline->method('process')->willReturn('processedValue'); - - $this->processorBuilder->expects($this->once()) - ->method('buildPipeline') - ->willReturn($mockPipeline); - - $this->processorValidator->expects($this->once()) - ->method('validate') - ->willReturn(null); - - $result = $this->attributeHandler->handleAttribute('testProperty', $mockAttribute, 'initialValue'); - $this->assertSame('processedValue', $result); - - $processedValues = $this->attributeHandler->getProcessedPropertyValues(); - $this->assertArrayHasKey('testProperty', $processedValues); - $this->assertArrayHasKey('messages', $processedValues['testProperty']); - $this->assertArrayHasKey('processor1', $processedValues['testProperty']['messages']); - $this->assertEquals('Custom message', $processedValues['testProperty']['messages']['processor1']); - } - - public function testHandleAttributeWithProcessingException(): void - { - $mockAttribute = $this->createMock(ProcessableAttribute::class); - $mockPipeline = $this->createMock(Pipeline::class); - - $this->configBuilder->expects($this->once()) - ->method('build') - ->willReturn(['processor1' => []]); - - $mockPipeline->expects($this->once()) - ->method('process') - ->willThrowException( - ProcessorRuntimeException::contextNotFound('payment') - ); - - $this->processorBuilder->expects($this->once()) - ->method('buildPipeline') - ->willReturn($mockPipeline); - - $result = $this->attributeHandler->handleAttribute('testProperty', $mockAttribute, 'initialValue'); - $this->assertSame('initialValue', $result); - - $errors = $this->attributeHandler->getProcessingResultErrors(); - - $this->assertStringContainsString("Processor context 'payment' not found", $errors['testProperty'][0]); - } -} diff --git a/tests/Handler/ProcessorAttributeHandlerTest.php b/tests/Handler/ProcessorAttributeHandlerTest.php deleted file mode 100644 index 0109239..0000000 --- a/tests/Handler/ProcessorAttributeHandlerTest.php +++ /dev/null @@ -1,225 +0,0 @@ -builder = $this->createMock(ProcessorBuilder::class); - $this->validator = $this->createMock(ProcessorValidator::class); - $this->configBuilder = $this->createMock(ProcessorConfigBuilder::class); - $this->processor = $this->createMock(ValidatableProcessor::class); - $this->pipeline = $this->createMock(Pipeline::class); - - $this->handler = new ProcessorAttributeHandler( - 'validator', - $this->builder, - $this->validator, - $this->configBuilder - ); - } - - public function testHandleAttributeWithValidProcessor(): void - { - $attribute = $this->createMock(ProcessableAttribute::class); - - $this->configureBasicMocks(); - $this->processor->method('isValid')->willReturn(true); - $this->pipeline->method('process')->willReturn('processed'); - - $result = $this->handler->handleAttribute('property', $attribute, 'value'); - - $this->assertEquals('processed', $result); - $this->assertFalse($this->handler->hasErrors()); - } - - public function testHandleAttributeWithValidationError(): void - { - $attribute = $this->createMock(ProcessableAttribute::class); - $attribute->method('getProcessors') - ->willReturn(['processor1' => []]); - - // Configurar processador para falhar validação - $processor = $this->createMock(ValidatableProcessor::class); - $processor->method('isValid')->willReturn(false); - $processor->method('getErrorKey')->willReturn('validation_failed'); - - // Configurar pipeline - $pipeline = $this->createMock(Pipeline::class); - $pipeline->method('process')->willReturn('processed'); - - // Configurar builder - $this->builder->method('build') - ->with('validator', 'processor1') - ->willReturn($processor); - $this->builder->method('buildPipeline') - ->willReturn($pipeline); - - // Configurar config builder - $this->configBuilder->method('build') - ->willReturn(['processor1' => []]); - - // Configurar validator para retornar erro - $this->validator->method('validate') - ->willReturn([ - 'errorKey' => 'validation_failed', - 'message' => 'Validation failed', - ]); - - $this->handler->handleAttribute('property', $attribute, 'value'); - - $this->assertTrue($this->handler->hasErrors()); - $errors = $this->handler->getProcessingResultErrors(); - $this->assertArrayHasKey('property', $errors); - $this->assertIsArray($errors['property']); - $this->assertNotEmpty($errors['property']); - } - - public function testHandleAttributeWithProcessingError(): void - { - $attribute = $this->createMock(ProcessableAttribute::class); - - $this->configureBasicMocks(); - $this->pipeline->method('process') - ->willThrowException(new \Exception('Processing error')); - - $result = $this->handler->handleAttribute('property', $attribute, 'value'); - - $this->assertEquals('value', $result); - $this->assertArrayHasKey('property', $this->handler->getProcessingResultErrors()); - } - - public function testGetProcessingResults(): void - { - $attribute = $this->createMock(ProcessableAttribute::class); - - $this->configureBasicMocks(); - $this->pipeline->method('process')->willReturn('processed'); - - $this->handler->handleAttribute('property', $attribute, 'value'); - - $results = $this->handler->getProcessingResults(); - $this->assertInstanceOf(ProcessingResultCollection::class, $results); - $processedData = $results->getProcessedData(); - $this->assertArrayHasKey('property', $processedData); - $this->assertEquals('processed', $processedData['property']); - } - - public function testReset(): void - { - $attribute = $this->createMock(ProcessableAttribute::class); - - $this->configureBasicMocks(); - $this->pipeline->method('process')->willReturn('processed'); - - $this->handler->handleAttribute('property', $attribute, 'value'); - $this->handler->reset(); - - $this->assertEmpty($this->handler->getProcessedPropertyValues()['values']); - $this->assertEmpty($this->handler->getProcessingResultErrors()); - $this->assertFalse($this->handler->hasErrors()); - } - - public function testGetProcessedPropertyValues(): void - { - $attribute = $this->createMock(ProcessableAttribute::class); - - $this->configureBasicMocks(); - $this->pipeline->method('process')->willReturn('processed'); - - $this->handler->handleAttribute('property', $attribute, 'value'); - - $values = $this->handler->getProcessedPropertyValues(); - $this->assertArrayHasKey('values', $values); - $this->assertArrayHasKey('timestamp', $values); - $this->assertEquals(['property' => 'processed'], $values['values']); - } - - private function configureBasicMocks(): void - { - // ConfigBuilder setup - $this->configBuilder->method('build') - ->willReturn(['processor1' => ['config' => 'value']]); - - // Builder setup - $this->builder->method('build') - ->willReturn($this->processor); - - $this->builder->method('buildPipeline') - ->willReturn($this->pipeline); - - // Validator setup - $this->validator->method('validate') - ->willReturnCallback(function ($processor) { - if ($processor instanceof ValidatableProcessor && !$processor->isValid()) { - return [ - 'errorKey' => $processor->getErrorKey(), - 'message' => 'Validation failed', - ]; - } - - return null; - }); - } - - public function testValidateProcessorsWithInvalidProcessor(): void - { - $processorsConfig = ['processor1' => []]; - $messages = ['processor1' => 'Validation failed for processor1']; - - $processor = $this->createMock(ValidatableProcessor::class); - $processor->method('isValid')->willReturn(false); - $processor->method('getErrorKey')->willReturn('invalid_processor'); - - $this->builder->method('build')->willReturn($processor); - - // Usar Reflection para acessar validateProcessors - $reflection = new \ReflectionClass(ProcessorAttributeHandler::class); - $method = $reflection->getMethod('validateProcessors'); - $method->setAccessible(true); - - $errors = $method->invoke($this->handler, $processorsConfig, $messages); - - $this->assertArrayHasKey('processor1', $errors); - $this->assertEquals('invalid_processor', $errors['processor1']['errorKey']); - $this->assertEquals('Validation failed for processor1', $errors['processor1']['message']); - } - - public function testProcessValueWithValidPipeline(): void - { - $config = ['processor1' => []]; - $this->pipeline->method('process')->willReturn('processed_value'); - - $this->builder->method('buildPipeline')->willReturn($this->pipeline); - - // Usar Reflection para acessar processValue - $reflection = new \ReflectionClass(ProcessorAttributeHandler::class); - $method = $reflection->getMethod('processValue'); - $method->setAccessible(true); - - $result = $method->invoke($this->handler, 'input_value', $config); - - $this->assertEquals('processed_value', $result); - } -} diff --git a/tests/Integration/PipelineIntegrationTest.php b/tests/Integration/PipelineIntegrationTest.php new file mode 100644 index 0000000..3aaf4f0 --- /dev/null +++ b/tests/Integration/PipelineIntegrationTest.php @@ -0,0 +1,289 @@ +registry = new ProcessorRegistry(); + $this->builder = new ProcessorBuilder($this->registry); + } + + // ── Full Lifecycle: Registry → Builder → Pipeline → Execution ─── + + public function testFullSanitizerPipeline(): void + { + $this->registry + ->register('sanitizer', 'trim', StubProcessor::trim()) + ->register('sanitizer', 'lowercase', StubProcessor::lowercase()) + ->register('sanitizer', 'append', StubProcessor::append('!')); + + $pipeline = $this->builder->buildPipeline('sanitizer', [ + 'trim', + 'lowercase', + 'append', + ]); + + $result = $pipeline->process(' HELLO WORLD '); + + $this->assertSame('hello world!', $result); + } + + public function testFullConfiguredValidatorPipeline(): void + { + $lengthValidator = new StubConfigurableProcessor(); + + $this->registry + ->register('validator', 'trim', StubProcessor::trim()) + ->register('validator', 'length', $lengthValidator); + + $pipeline = $this->builder->buildPipeline('validator', [ + 'trim', + 'length' => ['minLength' => 3, 'maxLength' => 20], + ]); + + // Valid input + $result = $pipeline->process(' hello '); + $this->assertSame('hello', $result); + + // Invalid input — too short after trim + $this->expectException(PipelineExecutionException::class); + $pipeline->process(' ab '); + } + + // ── Multi-Context Isolation ───────────────────────────────────── + + public function testMultiContextProcessorsOperateIndependently(): void + { + // Same name, different contexts + $this->registry->register('sanitizer', 'transform', StubProcessor::lowercase()); + $this->registry->register('formatter', 'transform', StubProcessor::uppercase()); + + $sanitizerPipeline = $this->builder->buildPipeline('sanitizer', ['transform']); + $formatterPipeline = $this->builder->buildPipeline('formatter', ['transform']); + + $this->assertSame('hello', $sanitizerPipeline->process('HELLO')); + $this->assertSame('HELLO', $formatterPipeline->process('hello')); + } + + // ── Pipeline Composition (ARFA P1 — Immutability) ─────────────── + + public function testPipelineCompositionProducesCorrectResults(): void + { + $this->registry + ->register('ctx', 'trim', StubProcessor::trim()) + ->register('ctx', 'lower', StubProcessor::lowercase()) + ->register('ctx', 'upper', StubProcessor::uppercase()); + + $basePipeline = $this->builder->buildPipeline('ctx', ['trim']); + + // Extend base in two different directions + $lowerPipeline = $basePipeline->withProcessor(StubProcessor::lowercase()); + $upperPipeline = $basePipeline->withProcessor(StubProcessor::uppercase()); + + $input = ' HeLLo '; + + // Base only trims + $this->assertSame('HeLLo', $basePipeline->process($input)); + + // Lower extends with lowercase + $this->assertSame('hello', $lowerPipeline->process($input)); + + // Upper extends with uppercase + $this->assertSame('HELLO', $upperPipeline->process($input)); + + // Base is unchanged + $this->assertSame(1, $basePipeline->count()); + } + + public function testPipelineMergingPreservesOrder(): void + { + $first = new Pipeline([ + StubProcessor::trim(), + StubProcessor::lowercase(), + ]); + + $second = new Pipeline([ + StubProcessor::append('!'), + ]); + + $merged = $first->withPipeline($second); + + $this->assertSame('hello!', $merged->process(' HELLO ')); + } + + // ── Error Collection with ProcessorHandler ────────────────────── + + public function testHandlerCollectsErrorsWithoutHalting(): void + { + $results = new ProcessingResultCollection(); + + $handlers = [ + new ProcessorHandler( + processor: StubProcessor::trim(), + resultCollection: $results, + ), + new ProcessorHandler( + processor: StubFailingProcessor::withMessage('validation error'), + resultCollection: $results, + haltOnError: false, + ), + new ProcessorHandler( + processor: StubProcessor::uppercase(), + resultCollection: $results, + ), + ]; + + $pipeline = new Pipeline($handlers); + $result = $pipeline->process(' hello '); + + // Pipeline continues despite error in handler 2 + // Handler 2 returns input passthrough → 'hello' (trimmed by handler 1) + // Handler 3 uppercases → 'HELLO' + $this->assertSame('HELLO', $result); + + // Error was recorded + $this->assertTrue($results->hasErrors); + $this->assertSame(1, $results->errorCount); + + // All 3 were traced + $this->assertCount(3, $results->executionTrace); + } + + public function testHandlerHaltsOnErrorWhenConfigured(): void + { + $results = new ProcessingResultCollection(); + + $handlers = [ + new ProcessorHandler( + processor: StubProcessor::trim(), + resultCollection: $results, + ), + new ProcessorHandler( + processor: StubFailingProcessor::withMessage('halt here'), + resultCollection: $results, + haltOnError: true, + ), + new ProcessorHandler( + processor: StubProcessor::uppercase(), + resultCollection: $results, + ), + ]; + + $pipeline = new Pipeline($handlers); + + // PipelineExecutionException wraps the re-thrown RuntimeException + $this->expectException(PipelineExecutionException::class); + $pipeline->process(' hello '); + } + + // ── Merge Results from Parallel Pipelines ─────────────────────── + + public function testMergeResultsFromParallelPipelines(): void + { + $results1 = new ProcessingResultCollection(); + $results2 = new ProcessingResultCollection(); + + $pipeline1 = new Pipeline([ + new ProcessorHandler( + processor: StubFailingProcessor::withMessage('err1'), + resultCollection: $results1, + haltOnError: false, + ), + ]); + + $pipeline2 = new Pipeline([ + new ProcessorHandler( + processor: StubFailingProcessor::withMessage('err2'), + resultCollection: $results2, + haltOnError: false, + ), + ]); + + $pipeline1->process('input1'); + $pipeline2->process('input2'); + + // Merge into results1 + $results1->merge($results2); + + $this->assertSame(2, $results1->errorCount); + } + + // ── Selective Spec with Builder ───────────────────────────────── + + public function testBuilderSelectiveSpecProducesCorrectPipeline(): void + { + $this->registry + ->register('ctx', 'trim', StubProcessor::trim()) + ->register('ctx', 'lower', StubProcessor::lowercase()) + ->register('ctx', 'upper', StubProcessor::uppercase()) + ->register('ctx', 'append', StubProcessor::append('!')); + + $pipeline = $this->builder->buildPipeline('ctx', [ + 'trim' => true, + 'lower' => false, // disabled + 'upper' => true, + 'append' => false, // disabled + ]); + + $this->assertSame(2, $pipeline->count()); + $this->assertSame('HELLO', $pipeline->process(' hello ')); + } + + // ── Large Pipeline Stress ─────────────────────────────────────── + + public function testLargePipelineExecutesCorrectly(): void + { + $processors = []; + for ($i = 0; $i < 100; $i++) { + $processors[] = StubProcessor::identity(); + } + + $pipeline = new Pipeline($processors); + $result = $pipeline->process('data'); + + $this->assertSame('data', $result); + $this->assertSame(100, $pipeline->count()); + } + + // ── Empty Processing ──────────────────────────────────────────── + + public function testEmptyPipelineFromBuilderProcessesCorrectly(): void + { + $pipeline = $this->builder->buildPipeline('ctx', []); + + $this->assertSame('untouched', $pipeline->process('untouched')); + } + + // ── Registry Cleanup Doesn't Affect Built Pipelines ───────────── + + public function testRegistryClearDoesNotAffectExistingPipelines(): void + { + $this->registry->register('ctx', 'trim', StubProcessor::trim()); + + $pipeline = $this->builder->buildPipeline('ctx', ['trim']); + + // Clear the registry + $this->registry->clear(); + + // Pipeline still works — it holds processor references + $this->assertSame('hello', $pipeline->process(' hello ')); + } +} diff --git a/tests/Processor/ProcessorConfigBuilderTest.php b/tests/Processor/ProcessorConfigBuilderTest.php deleted file mode 100644 index 8a63801..0000000 --- a/tests/Processor/ProcessorConfigBuilderTest.php +++ /dev/null @@ -1,63 +0,0 @@ -configBuilder = new ProcessorConfigBuilder(); - } - - public function testBuildWithSimpleProcessors(): void - { - $attribute = $this->createMock(ProcessableAttribute::class); - $attribute->method('getProcessors')->willReturn(['processor1', 'processor2']); - - $result = $this->configBuilder->build($attribute); - - $this->assertEquals(['processor1' => [], 'processor2' => []], $result); - } - - public function testBuildWithConfigurableProcessors(): void - { - $attribute = $this->createMock(ProcessableAttribute::class); - $attribute->method('getProcessors')->willReturn([ - 'processor1' => ['option' => 'value'], - 'processor2' => ['another_option' => 'another_value'], - ]); - - $result = $this->configBuilder->build($attribute); - - $this->assertEquals([ - 'processor1' => ['option' => 'value'], - 'processor2' => ['another_option' => 'another_value'], - ], $result); - } - - public function testBuildWithMixedProcessors(): void - { - $attribute = $this->createMock(ProcessableAttribute::class); - $attribute->method('getProcessors')->willReturn([ - 'processor1', - 'processor2' => ['option' => 'value'], - ['unnamed_processor' => []], - ]); - - $result = $this->configBuilder->build($attribute); - - $this->assertEquals([ - 'processor1' => [], - 'processor2' => ['option' => 'value'], - 'unnamed_processor' => ['unnamed_processor' => []], - ], $result); - } -} diff --git a/tests/Processor/ProcessorNameNormalizerTest.php b/tests/Processor/ProcessorNameNormalizerTest.php deleted file mode 100644 index cf500b9..0000000 --- a/tests/Processor/ProcessorNameNormalizerTest.php +++ /dev/null @@ -1,39 +0,0 @@ -normalizer = new ProcessorNameNormalizer(); - } - - public function testNormalizeWithStringKey(): void - { - $result = $this->normalizer->normalize('processor_name', []); - - $this->assertEquals('processor_name', $result); - } - - public function testNormalizeWithIntegerKey(): void - { - $result = $this->normalizer->normalize(0, ['processor_name' => []]); - - $this->assertEquals('processor_name', $result); - } - - public function testNormalizeWithEmptyProcessor(): void - { - $result = $this->normalizer->normalize(0, []); - - $this->assertEquals('', $result); - } -} diff --git a/tests/Processor/ProcessorValidatorTest.php b/tests/Processor/ProcessorValidatorTest.php deleted file mode 100644 index 1255246..0000000 --- a/tests/Processor/ProcessorValidatorTest.php +++ /dev/null @@ -1,71 +0,0 @@ -processorValidator = new ProcessorValidator(); - } - - public function testValidateWithNonValidatableProcessor(): void - { - $processor = $this->createMock(Processor::class); - - $result = $this->processorValidator->validate($processor, 'testProcessor', []); - - $this->assertNull($result); - } - - public function testValidateWithValidValidatableProcessor(): void - { - $processor = $this->createMock(ValidatableProcessor::class); - $processor->method('isValid')->willReturn(true); - - $result = $this->processorValidator->validate($processor, 'testProcessor', []); - - $this->assertNull($result); - } - - public function testValidateWithInvalidValidatableProcessor(): void - { - $processor = $this->createMock(ValidatableProcessor::class); - $processor->method('isValid')->willReturn(false); - $processor->method('getErrorKey')->willReturn('testError'); - - $result = $this->processorValidator->validate($processor, 'testProcessor', []); - - $this->assertIsArray($result); - $this->assertArrayHasKey('errorKey', $result); - $this->assertArrayHasKey('message', $result); - $this->assertEquals('testError', $result['errorKey']); - $this->assertEquals('Validation failed for testProcessor', $result['message']); - } - - public function testValidateWithInvalidValidatableProcessorAndCustomMessage(): void - { - $processor = $this->createMock(ValidatableProcessor::class); - $processor->method('isValid')->willReturn(false); - $processor->method('getErrorKey')->willReturn('testError'); - - $messages = ['testProcessor' => 'Custom error message']; - - $result = $this->processorValidator->validate($processor, 'testProcessor', $messages); - - $this->assertIsArray($result); - $this->assertArrayHasKey('errorKey', $result); - $this->assertArrayHasKey('message', $result); - $this->assertEquals('testError', $result['errorKey']); - $this->assertEquals('Custom error message', $result['message']); - } -} diff --git a/tests/ProcessorBuilderTest.php b/tests/ProcessorBuilderTest.php deleted file mode 100644 index 862c98c..0000000 --- a/tests/ProcessorBuilderTest.php +++ /dev/null @@ -1,123 +0,0 @@ -registry = $this->createMock(ProcessorRegistry::class); - $this->builder = new ProcessorBuilder($this->registry); - } - - public function testBuildNonConfigurableProcessor(): void - { - $processor = $this->createMock(Processor::class); - $this->registry->expects($this->once()) - ->method('get') - ->with('context', 'name') - ->willReturn($processor); - - $result = $this->builder->build('context', 'name'); - $this->assertSame($processor, $result); - } - - public function testBuildConfigurableProcessor(): void - { - $processor = $this->createMock(ConfigurableProcessor::class); - $this->registry->expects($this->once()) - ->method('get') - ->with('context', 'name') - ->willReturn($processor); - - $processor->expects($this->once()) - ->method('configure') - ->with(['option' => 'value']); - - $result = $this->builder->build('context', 'name', ['option' => 'value']); - $this->assertSame($processor, $result); - } - - public function testBuildConfigurableProcessorWithEmptyConfig(): void - { - $processor = $this->createMock(ConfigurableProcessor::class); - $this->registry->expects($this->once()) - ->method('get') - ->with('context', 'name') - ->willReturn($processor); - - $processor->expects($this->never()) - ->method('configure'); - - $result = $this->builder->build('context', 'name', []); - $this->assertSame($processor, $result); - } - - public function testBuildPipelineWithVariousProcessorTypes(): void - { - $processor1 = $this->createMock(Processor::class); - $processor2 = $this->createMock(ConfigurableProcessor::class); - $processor3 = $this->createMock(Processor::class); - - $this->registry->expects($this->exactly(3)) - ->method('get') - ->willReturnMap([ - ['context', 'processor1', $processor1], - ['context', 'processor2', $processor2], - ['context', 'processor3', $processor3], - ]); - - $processor2->expects($this->once()) - ->method('configure') - ->with(['option' => 'value']); - - $result = $this->builder->buildPipeline('context', [ - 'processor1' => true, - 'processor2' => ['option' => 'value'], - 'processor3' => [], - ]); - - $this->assertInstanceOf(Pipeline::class, $result); - $this->assertInstanceOf(ProcessorPipeline::class, $result); - } - - public function testBuildPipelineWithInvalidProcessorSpec(): void - { - $processor = $this->createMock(Processor::class); - - $this->registry->expects($this->once()) - ->method('get') - ->with('context', 'validProcessor') - ->willReturn($processor); - - $result = $this->builder->buildPipeline('context', [ - 'validProcessor' => true, - 'invalidProcessor' => false, - 'anotherInvalidProcessor' => null, - ]); - - $this->assertInstanceOf(Pipeline::class, $result); - } - - public function testBuildPipelineWithEmptySpecs(): void - { - $result = $this->builder->buildPipeline('context', []); - - $this->assertInstanceOf(Pipeline::class, $result); - $this->assertInstanceOf(ProcessorPipeline::class, $result); - } -} diff --git a/tests/ProcessorPipelineTest.php b/tests/ProcessorPipelineTest.php deleted file mode 100644 index b88d103..0000000 --- a/tests/ProcessorPipelineTest.php +++ /dev/null @@ -1,128 +0,0 @@ -pipeline = new ProcessorPipeline(); - } - - public function testAddProcessor(): void - { - $processor = $this->createMock(ValidatableProcessor::class); - - $result = $this->pipeline->addProcessor($processor); - - $this->assertSame($this->pipeline, $result); - $this->assertTrue($this->pipeline->hasProcessors()); - $this->assertSame(1, $this->pipeline->count()); - $this->assertSame([$processor], $this->pipeline->getProcessors()); - } - - public function testProcessWithValidatableProcessor(): void - { - $processor = $this->createMock(ValidatableProcessor::class); - $processor->expects($this->once()) - ->method('reset'); - $processor->expects($this->once()) - ->method('process') - ->with('input') - ->willReturn('processed'); - - $this->pipeline->addProcessor($processor); - $result = $this->pipeline->process('input'); - - $this->assertSame('processed', $result); - } - - public function testProcessorWithException(): void - { - $processor = $this->createMock(ValidatableProcessor::class); - $processor->expects($this->once()) - ->method('process') - ->willThrowException(new \Exception('Processing failed')); - - $this->pipeline->addProcessor($processor); - - $this->expectException(ProcessingException::class); - $this->expectExceptionMessage('Pipeline processing failed'); - - $this->pipeline->process('input'); - } - - public function testClear(): void - { - $processor = $this->createMock(ValidatableProcessor::class); - $this->pipeline->addProcessor($processor); - - $this->assertTrue($this->pipeline->hasProcessors()); - - $this->pipeline->clear(); - - $this->assertFalse($this->pipeline->hasProcessors()); - $this->assertSame(0, $this->pipeline->count()); - $this->assertEmpty($this->pipeline->getProcessors()); - } - - public function testProcessWithMultipleProcessors(): void - { - $processor1 = $this->createMock(ValidatableProcessor::class); - $processor1->expects($this->once()) - ->method('process') - ->with('input') - ->willReturn('processed1'); - - $processor2 = $this->createMock(ValidatableProcessor::class); - $processor2->expects($this->once()) - ->method('process') - ->with('processed1') - ->willReturn('processed2'); - - $this->pipeline->addProcessor($processor1); - $this->pipeline->addProcessor($processor2); - - $result = $this->pipeline->process('input'); - - $this->assertSame('processed2', $result); - $this->assertSame(2, $this->pipeline->count()); - } - - public function testProcessWithNoProcessors(): void - { - $input = 'test input'; - $result = $this->pipeline->process($input); - - $this->assertSame($input, $result); - $this->assertFalse($this->pipeline->hasProcessors()); - $this->assertSame(0, $this->pipeline->count()); - } - - public function testPipelineExecutionFailure(): void - { - $processor = $this->createMock(ValidatableProcessor::class); - $processor->method('process') - ->willThrowException(new \RuntimeException('Internal error')); - - $this->pipeline->addProcessor($processor); - - try { - $this->pipeline->process('input'); - $this->fail('Expected ProcessingException was not thrown'); - } catch (ProcessingException $e) { - $this->assertSame('PIPELINE_FAILED', $e->getErrorCode()); - $this->assertSame(3001, $e->getCode()); - $this->assertSame('Pipeline processing failed', $e->getMessage()); - } - } -} diff --git a/tests/ProcessorRegistryTest.php b/tests/ProcessorRegistryTest.php deleted file mode 100644 index bf84409..0000000 --- a/tests/ProcessorRegistryTest.php +++ /dev/null @@ -1,148 +0,0 @@ -mockHashMap = $this->createMock(HashMap::class); - $this->registry = new ProcessorRegistry($this->mockHashMap); - } - - public function testRegister(): void - { - $processor = $this->createMock(Processor::class); - $contextMap = $this->createMock(HashMap::class); - - $this->mockHashMap->expects($this->once()) - ->method('containsKey') - ->with('payment') - ->willReturn(false); - - $this->mockHashMap->expects($this->once()) - ->method('put') - ->with('payment', $this->isInstanceOf(HashMap::class)); - - $this->mockHashMap->expects($this->once()) - ->method('get') - ->with('payment') - ->willReturn($contextMap); - - $contextMap->expects($this->once()) - ->method('put') - ->with('validate', $processor); - - $this->registry->register('payment', 'validate', $processor); - } - - public function testGet(): void - { - $processor = $this->createMock(Processor::class); - $contextMap = $this->createMock(HashMap::class); - - $this->mockHashMap->expects($this->once()) - ->method('containsKey') - ->with('payment') - ->willReturn(true); - - $this->mockHashMap->expects($this->once()) - ->method('get') - ->with('payment') - ->willReturn($contextMap); - - $contextMap->expects($this->once()) - ->method('containsKey') - ->with('validate') - ->willReturn(true); - - $contextMap->expects($this->once()) - ->method('get') - ->with('validate') - ->willReturn($processor); - - $result = $this->registry->get('payment', 'validate'); - $this->assertSame($processor, $result); - } - - public function testGetContextNotFound(): void - { - $this->mockHashMap->expects($this->once()) - ->method('containsKey') - ->with('payment') - ->willReturn(false); - - $this->expectException(ProcessorRuntimeException::class); - $this->expectExceptionMessage("Processor context 'payment' not found"); - - $this->registry->get('payment', 'validate'); - } - - public function testGetProcessorNotFound(): void - { - $contextMap = $this->createMock(HashMap::class); - - $this->mockHashMap->expects($this->once()) - ->method('containsKey') - ->with('payment') - ->willReturn(true); - - $this->mockHashMap->expects($this->once()) - ->method('get') - ->with('payment') - ->willReturn($contextMap); - - $contextMap->expects($this->once()) - ->method('containsKey') - ->with('validate') - ->willReturn(false); - - $this->expectException(ProcessorRuntimeException::class); - $this->expectExceptionMessage("Processor 'validate' not found in context 'payment'"); - - $this->registry->get('payment', 'validate'); - } - - public function testGetContextProcessors(): void - { - $contextMap = $this->createMock(HashMap::class); - - $this->mockHashMap->expects($this->once()) - ->method('containsKey') - ->with('payment') - ->willReturn(true); - - $this->mockHashMap->expects($this->once()) - ->method('get') - ->with('payment') - ->willReturn($contextMap); - - $result = $this->registry->getContextProcessors('payment'); - $this->assertSame($contextMap, $result); - } - - public function testGetContextProcessorsNotFound(): void - { - $this->mockHashMap->expects($this->once()) - ->method('containsKey') - ->with('payment') - ->willReturn(false); - - $this->expectException(ProcessorRuntimeException::class); - $this->expectExceptionMessage("Processor context 'payment' not found"); - - $this->registry->getContextProcessors('payment'); - } -} diff --git a/tests/Result/ProcessedDataTest.php b/tests/Result/ProcessedDataTest.php deleted file mode 100644 index 2e32e4b..0000000 --- a/tests/Result/ProcessedDataTest.php +++ /dev/null @@ -1,63 +0,0 @@ -assertEquals('email', $data->getProperty()); - } - - public function testGetValue(): void - { - $data = new ProcessedData('email', 'test@example.com'); - - $this->assertEquals('test@example.com', $data->getValue()); - } - - public function testToArray(): void - { - $data = new ProcessedData('email', 'test@example.com'); - $result = $data->toArray(); - - $this->assertIsArray($result); - $this->assertArrayHasKey('value', $result); - $this->assertArrayHasKey('timestamp', $result); - $this->assertEquals('test@example.com', $result['value']); - $this->assertIsInt($result['timestamp']); - $this->assertLessThanOrEqual(time(), $result['timestamp']); - } - - /** - * @dataProvider valueTypesProvider - */ - public function testDifferentValueTypes(mixed $value): void - { - $data = new ProcessedData('property', $value); - - $this->assertSame($value, $data->getValue()); - $array = $data->toArray(); - $this->assertSame($value, $array['value']); - } - - public static function valueTypesProvider(): array - { - return [ - 'string' => ['test'], - 'integer' => [42], - 'float' => [3.14], - 'boolean' => [true], - 'null' => [null], - 'array' => [['test' => 'value']], - 'object' => [new \stdClass()], - ]; - } -} diff --git a/tests/Result/ProcessingErrorTest.php b/tests/Result/ProcessingErrorTest.php deleted file mode 100644 index ba487a7..0000000 --- a/tests/Result/ProcessingErrorTest.php +++ /dev/null @@ -1,87 +0,0 @@ -assertEquals($expectedHash, $error->getHash()); - } - - public function testGetProperty(): void - { - $error = new ProcessingError('email', 'invalid_email', 'Invalid email format'); - - $this->assertEquals('email', $error->getProperty()); - } - - public function testGetErrorKey(): void - { - $error = new ProcessingError('email', 'invalid_email', 'Invalid email format'); - - $this->assertEquals('invalid_email', $error->getErrorKey()); - } - - public function testGetMessage(): void - { - $error = new ProcessingError('email', 'invalid_email', 'Invalid email format'); - - $this->assertEquals('Invalid email format', $error->getMessage()); - } - - public function testToArray(): void - { - $error = new ProcessingError('email', 'invalid_email', 'Invalid email format'); - $result = $error->toArray(); - - $this->assertIsArray($result); - $this->assertArrayHasKey('errorKey', $result); - $this->assertArrayHasKey('message', $result); - $this->assertArrayHasKey('timestamp', $result); - $this->assertEquals('invalid_email', $result['errorKey']); - $this->assertEquals('Invalid email format', $result['message']); - $this->assertIsInt($result['timestamp']); - $this->assertLessThanOrEqual(time(), $result['timestamp']); - } - - public function testHashUniqueness(): void - { - $error1 = new ProcessingError('email', 'invalid_email', 'Invalid email format'); - $error2 = new ProcessingError('email', 'invalid_email', 'Invalid email format'); - $error3 = new ProcessingError('email', 'different_error', 'Invalid email format'); - - $this->assertEquals($error1->getHash(), $error2->getHash()); - $this->assertNotEquals($error1->getHash(), $error3->getHash()); - } - - /** - * @dataProvider specialCharactersProvider - */ - public function testHashWithSpecialCharacters(string $property, string $errorKey, string $message): void - { - $error = new ProcessingError($property, $errorKey, $message); - - $this->assertNotEmpty($error->getHash()); - $this->assertEquals(32, strlen($error->getHash())); - } - - public static function specialCharactersProvider(): array - { - return [ - 'unicode' => ['émáil', 'error_key', 'Test message'], - 'symbols' => ['email@test', '!error_key!', 'Test message!'], - 'spaces' => ['email test', 'error key', 'Test message with spaces'], - 'empty' => ['', '', ''], - 'mixed' => ['email#123', 'error_key!@#', 'Test message 123!@#'], - ]; - } -} diff --git a/tests/Result/ProcessingResultCollection.php b/tests/Result/ProcessingResultCollection.php deleted file mode 100644 index 1455dfa..0000000 --- a/tests/Result/ProcessingResultCollection.php +++ /dev/null @@ -1,317 +0,0 @@ -collection = new ProcessingResultCollection(); - } - - public function testAddError(): void - { - $this->collection->addError('email', 'invalid_email', 'Invalid email format'); - - $errors = $this->collection->getErrors(); - $this->assertArrayHasKey('email', $errors); - $this->assertCount(1, $errors['email']); - $this->assertEquals('invalid_email', $errors['email'][0]['errorKey']); - $this->assertEquals('Invalid email format', $errors['email'][0]['message']); - } - - public function testAddMultipleErrorsForSameProperty(): void - { - $this->collection->addError('email', 'invalid_email', 'Invalid email format'); - $this->collection->addError('email', 'required', 'Email is required'); - - $errors = $this->collection->getErrors(); - $this->assertArrayHasKey('email', $errors); - $this->assertCount(2, $errors['email']); - } - - public function testAddDuplicateError(): void - { - $this->collection->addError('email', 'invalid_email', 'Invalid email format'); - $this->collection->addError('email', 'invalid_email', 'Invalid email format'); - - $errors = $this->collection->getErrors(); - $this->assertArrayHasKey('email', $errors); - $this->assertCount(1, $errors['email']); - } - - public function testSetProcessedData(): void - { - $this->collection->setProcessedData('email', 'test@example.com'); - - $data = $this->collection->getProcessedData(); - $this->assertArrayHasKey('email', $data); - $this->assertEquals('test@example.com', $data['email']); - } - - public function testOverwriteProcessedData(): void - { - $this->collection->setProcessedData('email', 'old@example.com'); - $this->collection->setProcessedData('email', 'new@example.com'); - - $data = $this->collection->getProcessedData(); - $this->assertEquals('new@example.com', $data['email']); - } - - public function testHasErrorsWithNoErrors(): void - { - $this->assertFalse($this->collection->hasErrors()); - } - - public function testHasErrorsWithErrors(): void - { - $this->collection->addError('email', 'invalid_email', 'Invalid email format'); - $this->assertTrue($this->collection->hasErrors()); - } - - public function testToArrayWithNoData(): void - { - $result = $this->collection->toArray(); - - $this->assertIsArray($result); - $this->assertArrayHasKey('isValid', $result); - $this->assertArrayHasKey('errors', $result); - $this->assertArrayHasKey('processedData', $result); - $this->assertTrue($result['isValid']); - $this->assertEmpty($result['errors']); - $this->assertEmpty($result['processedData']); - } - - public function testToArrayWithDataAndErrors(): void - { - $this->collection->setProcessedData('email', 'test@example.com'); - $this->collection->addError('name', 'required', 'Name is required'); - - $result = $this->collection->toArray(); - - $this->assertFalse($result['isValid']); - $this->assertArrayHasKey('name', $result['errors']); - $this->assertArrayHasKey('email', $result['processedData']); - $this->assertEquals('test@example.com', $result['processedData']['email']); - } - - public function testClear(): void - { - $this->collection->setProcessedData('email', 'test@example.com'); - $this->collection->addError('name', 'required', 'Name is required'); - - $this->collection->clear(); - - $this->assertFalse($this->collection->hasErrors()); - $this->assertEmpty($this->collection->getErrors()); - $this->assertEmpty($this->collection->getProcessedData()); - } - - public function testAddProcessedData(): void - { - $processedData = new ProcessedData('email', 'test@example.com'); - $this->collection->addProcessedData($processedData); - - $data = $this->collection->getProcessedData(); - $this->assertArrayHasKey('email', $data); - $this->assertEquals('test@example.com', $data['email']); - } - - public function testAddProcessingError(): void - { - $error = new ProcessingError('email', 'invalid_email', 'Invalid email format'); - $this->collection->addProcessingError($error); - - $errors = $this->collection->getErrors(); - $this->assertArrayHasKey('email', $errors); - $this->assertEquals('invalid_email', $errors['email'][0]['errorKey']); - $this->assertEquals('Invalid email format', $errors['email'][0]['message']); - } - - public function testMultiplePropertiesWithErrors(): void - { - $this->collection->addError('email', 'invalid_email', 'Invalid email format'); - $this->collection->addError('name', 'required', 'Name is required'); - $this->collection->addError('age', 'min_value', 'Age must be at least 18'); - - $errors = $this->collection->getErrors(); - $this->assertCount(3, $errors); - $this->assertArrayHasKey('email', $errors); - $this->assertArrayHasKey('name', $errors); - $this->assertArrayHasKey('age', $errors); - } - - public function testMultiplePropertiesWithProcessedData(): void - { - $this->collection->setProcessedData('email', 'test@example.com'); - $this->collection->setProcessedData('name', 'John Doe'); - $this->collection->setProcessedData('age', 25); - - $data = $this->collection->getProcessedData(); - $this->assertCount(3, $data); - $this->assertEquals('test@example.com', $data['email']); - $this->assertEquals('John Doe', $data['name']); - $this->assertEquals(25, $data['age']); - } - - public function testMixedDataTypes(): void - { - $values = [ - 'string' => 'test', - 'integer' => 42, - 'float' => 3.14, - 'boolean' => true, - 'array' => ['test' => 'value'], - 'null' => null, - 'object' => new \stdClass(), - ]; - - foreach ($values as $key => $value) { - $this->collection->setProcessedData($key, $value); - } - - $data = $this->collection->getProcessedData(); - foreach ($values as $key => $value) { - $this->assertArrayHasKey($key, $data); - $this->assertEquals($value, $data[$key]); - } - } - - public function testErrorCollectionWithSameHashButDifferentProperties(): void - { - $this->collection->addError('email1', 'invalid', 'Invalid format'); - $this->collection->addError('email2', 'invalid', 'Invalid format'); - - $errors = $this->collection->getErrors(); - $this->assertArrayHasKey('email1', $errors); - $this->assertArrayHasKey('email2', $errors); - $this->assertCount(1, $errors['email1']); - $this->assertCount(1, $errors['email2']); - } - - public function testToArrayWithAllPossibleStates(): void - { - $processedData = new ProcessedData('email', 'test@example.com'); - $this->collection->addProcessedData($processedData); - - $error = new ProcessingError('password', 'required', 'Password is required'); - $this->collection->addProcessingError($error); - - $result = $this->collection->toArray(); - - $this->assertArrayHasKey('isValid', $result); - $this->assertArrayHasKey('errors', $result); - $this->assertArrayHasKey('processedData', $result); - - $this->assertArrayHasKey('email', $result['processedData']); - $this->assertEquals('test@example.com', $result['processedData']['email']); - - $this->assertArrayHasKey('password', $result['errors']); - $this->assertCount(1, $result['errors']['password']); - $this->assertEquals('required', $result['errors']['password'][0]['errorKey']); - $this->assertEquals('Password is required', $result['errors']['password'][0]['message']); - - $this->assertFalse($result['isValid']); - } - - public function testAddProcessedDataWithExistingProperty(): void - { - $data1 = new ProcessedData('email', 'old@example.com'); - $data2 = new ProcessedData('email', 'new@example.com'); - - $this->collection->addProcessedData($data1); - $this->collection->addProcessedData($data2); - - $result = $this->collection->getProcessedData(); - $this->assertArrayHasKey('email', $result); - $this->assertEquals('new@example.com', $result['email']); - $this->assertCount(1, $result); - } - - public function testAddProcessingErrorWithExistingError(): void - { - $error1 = new ProcessingError('email', 'required', 'Email is required'); - $error2 = new ProcessingError('email', 'required', 'Email is required'); - $error3 = new ProcessingError('email', 'invalid', 'Invalid email format'); - - $this->collection->addProcessingError($error1); - $this->collection->addProcessingError($error2); - $this->collection->addProcessingError($error3); - - $errors = $this->collection->getErrors(); - $this->assertArrayHasKey('email', $errors); - $this->assertCount(2, $errors['email']); - - $errorMessages = array_column($errors['email'], 'message'); - $this->assertContains('Email is required', $errorMessages); - $this->assertContains('Invalid email format', $errorMessages); - } - - public function testClearRemovesAllDataAndErrors(): void - { - $data = new ProcessedData('email', 'test@example.com'); - $this->collection->addProcessedData($data); - - $error = new ProcessingError('email', 'required', 'Email is required'); - $this->collection->addProcessingError($error); - - $this->assertNotEmpty($this->collection->getProcessedData()); - $this->assertNotEmpty($this->collection->getErrors()); - $this->assertTrue($this->collection->hasErrors()); - - $this->collection->clear(); - - $this->assertEmpty($this->collection->getProcessedData()); - $this->assertEmpty($this->collection->getErrors()); - $this->assertFalse($this->collection->hasErrors()); - - $result = $this->collection->toArray(); - $this->assertTrue($result['isValid']); - $this->assertEmpty($result['errors']); - $this->assertEmpty($result['processedData']); - } - - public function testAddProcessedDataAndProcessingErrorWithMultipleProperties(): void - { - $data1 = new ProcessedData('email', 'test@example.com'); - $data2 = new ProcessedData('name', 'John Doe'); - $data3 = new ProcessedData('age', 25); - - $this->collection->addProcessedData($data1); - $this->collection->addProcessedData($data2); - $this->collection->addProcessedData($data3); - - $error1 = new ProcessingError('email', 'invalid', 'Invalid email'); - $error2 = new ProcessingError('email', 'required', 'Email required'); - $error3 = new ProcessingError('password', 'required', 'Password required'); - - $this->collection->addProcessingError($error1); - $this->collection->addProcessingError($error2); - $this->collection->addProcessingError($error3); - - $processedData = $this->collection->getProcessedData(); - $this->assertCount(3, $processedData); - $this->assertEquals('test@example.com', $processedData['email']); - $this->assertEquals('John Doe', $processedData['name']); - $this->assertEquals(25, $processedData['age']); - - $errors = $this->collection->getErrors(); - $this->assertCount(2, $errors); - $this->assertCount(2, $errors['email']); - $this->assertCount(1, $errors['password']); - - $result = $this->collection->toArray(); - $this->assertFalse($result['isValid']); - $this->assertCount(2, $result['errors']); - $this->assertCount(3, $result['processedData']); - } -} diff --git a/tests/Stubs/StubConfigurableProcessor.php b/tests/Stubs/StubConfigurableProcessor.php new file mode 100644 index 0000000..6f267a9 --- /dev/null +++ b/tests/Stubs/StubConfigurableProcessor.php @@ -0,0 +1,36 @@ + Last configuration received */ + public array $lastConfig = []; + + public function configure(array $options): void + { + $this->lastConfig = $options; + $this->minLength = $options['minLength'] ?? $this->minLength; + $this->maxLength = $options['maxLength'] ?? $this->maxLength; + } + + public function process(mixed $input): mixed + { + $length = mb_strlen((string) $input); + + if ($length < $this->minLength || $length > $this->maxLength) { + throw new \InvalidArgumentException( + "Length {$length} out of range [{$this->minLength}, {$this->maxLength}].", + ); + } + + return $input; + } +} diff --git a/tests/Stubs/StubFailingProcessor.php b/tests/Stubs/StubFailingProcessor.php new file mode 100644 index 0000000..bc8e1da --- /dev/null +++ b/tests/Stubs/StubFailingProcessor.php @@ -0,0 +1,30 @@ +exception; + } + + public static function withMessage(string $message): self + { + return new self(new \RuntimeException($message)); + } + + public static function withException(\Throwable $exception): self + { + return new self($exception); + } +} diff --git a/tests/Stubs/StubProcessor.php b/tests/Stubs/StubProcessor.php new file mode 100644 index 0000000..7160ae9 --- /dev/null +++ b/tests/Stubs/StubProcessor.php @@ -0,0 +1,45 @@ +transform)($input); + } + + public static function trim(): self + { + return new self(static fn (mixed $v): string => trim((string) $v)); + } + + public static function uppercase(): self + { + return new self(static fn (mixed $v): string => strtoupper((string) $v)); + } + + public static function lowercase(): self + { + return new self(static fn (mixed $v): string => strtolower((string) $v)); + } + + public static function append(string $suffix): self + { + return new self(static fn (mixed $v): string => (string) $v . $suffix); + } + + public static function identity(): self + { + return new self(static fn (mixed $v): mixed => $v); + } +} diff --git a/tests/Unit/Attribute/ProcessTest.php b/tests/Unit/Attribute/ProcessTest.php new file mode 100644 index 0000000..9cf4381 --- /dev/null +++ b/tests/Unit/Attribute/ProcessTest.php @@ -0,0 +1,107 @@ +assertSame([], $attribute->processors); + $this->assertSame([], $attribute->messages); + } + + public function testConstructWithProcessors(): void + { + $attribute = new Process( + processors: ['trim', 'email'], + ); + + $this->assertSame(['trim', 'email'], $attribute->processors); + $this->assertSame([], $attribute->messages); + } + + public function testConstructWithMessages(): void + { + $attribute = new Process( + processors: ['required'], + messages: ['required' => 'This field is required.'], + ); + + $this->assertSame(['required'], $attribute->processors); + $this->assertSame(['required' => 'This field is required.'], $attribute->messages); + } + + public function testConstructWithConfiguredProcessors(): void + { + $attribute = new Process( + processors: [ + 'trim', + 'length' => ['minLength' => 3, 'maxLength' => 50], + ], + ); + + $this->assertSame('trim', $attribute->processors[0]); + $this->assertSame(['minLength' => 3, 'maxLength' => 50], $attribute->processors['length']); + } + + public function testAttributeIsReadableViaReflection(): void + { + $class = new class () { + #[Process( + processors: ['required', 'email'], + messages: ['email' => 'Invalid email.'], + )] + public string $email = ''; + }; + + $reflection = new \ReflectionProperty($class, 'email'); + $attributes = $reflection->getAttributes(Process::class); + + $this->assertCount(1, $attributes); + + $instance = $attributes[0]->newInstance(); + $this->assertSame(['required', 'email'], $instance->processors); + $this->assertSame(['email' => 'Invalid email.'], $instance->messages); + } + + public function testAttributeIsRepeatable(): void + { + $class = new class () { + #[Process(processors: ['trim'])] + #[Process(processors: ['email'])] + public string $email = ''; + }; + + $reflection = new \ReflectionProperty($class, 'email'); + $attributes = $reflection->getAttributes(Process::class); + + $this->assertCount(2, $attributes); + + $first = $attributes[0]->newInstance(); + $second = $attributes[1]->newInstance(); + + $this->assertSame(['trim'], $first->processors); + $this->assertSame(['email'], $second->processors); + } + + public function testAttributeTargetsProperties(): void + { + $reflection = new \ReflectionClass(Process::class); + $attributes = $reflection->getAttributes(\Attribute::class); + + $this->assertCount(1, $attributes); + + $attrInstance = $attributes[0]->newInstance(); + $flags = $attrInstance->flags; + + $this->assertTrue(($flags & \Attribute::TARGET_PROPERTY) !== 0); + $this->assertTrue(($flags & \Attribute::IS_REPEATABLE) !== 0); + } +} diff --git a/tests/Unit/Exception/ExceptionTest.php b/tests/Unit/Exception/ExceptionTest.php new file mode 100644 index 0000000..057e28d --- /dev/null +++ b/tests/Unit/Exception/ExceptionTest.php @@ -0,0 +1,156 @@ + 'value'], + ); + + $this->assertSame('test', $exception->getMessage()); + $this->assertSame(['key' => 'value'], $exception->context); + } + + public function testBaseExceptionDefaultsToEmptyContext(): void + { + $exception = new ProcessorPipelineException('msg'); + + $this->assertSame([], $exception->context); + } + + public function testBaseExceptionAcceptsPreviousThrowable(): void + { + $cause = new \RuntimeException('root'); + $exception = new ProcessorPipelineException('wrapped', previous: $cause); + + $this->assertSame($cause, $exception->getPrevious()); + } + + public function testBaseExceptionExtendsRuntimeException(): void + { + $exception = new ProcessorPipelineException(); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + } + + // ── ProcessorNotFoundException ─────────────────────────────────── + + public function testForNameInContextCreatesCorrectMessage(): void + { + $exception = ProcessorNotFoundException::forNameInContext('trim', 'validator'); + + $this->assertStringContainsString('trim', $exception->getMessage()); + $this->assertStringContainsString('validator', $exception->getMessage()); + $this->assertSame('trim', $exception->context['processor']); + $this->assertSame('validator', $exception->context['context']); + } + + public function testForNameCreatesCorrectMessage(): void + { + $exception = ProcessorNotFoundException::forName('missing'); + + $this->assertStringContainsString('missing', $exception->getMessage()); + $this->assertSame('missing', $exception->context['processor']); + $this->assertArrayNotHasKey('context', $exception->context); + } + + public function testProcessorNotFoundExtendsBase(): void + { + $exception = ProcessorNotFoundException::forName('x'); + + $this->assertInstanceOf(ProcessorPipelineException::class, $exception); + } + + // ── PipelineExecutionException ────────────────────────────────── + + public function testAtStageCreatesWithStageAndProcessor(): void + { + $cause = new \InvalidArgumentException('bad input'); + $exception = PipelineExecutionException::atStage('TrimProcessor', 2, $cause); + + $this->assertStringContainsString('stage 2', $exception->getMessage()); + $this->assertStringContainsString('TrimProcessor', $exception->getMessage()); + $this->assertStringContainsString('bad input', $exception->getMessage()); + $this->assertSame($cause, $exception->getPrevious()); + } + + public function testAtStageCarriesStructuredContext(): void + { + $cause = new \RuntimeException('fail'); + $exception = PipelineExecutionException::atStage('Proc', 5, $cause); + + $this->assertSame('Proc', $exception->context['processor']); + $this->assertSame(5, $exception->context['stage']); + $this->assertSame(\RuntimeException::class, $exception->context['causeClass']); + } + + public function testAtStageWithStageZero(): void + { + $cause = new \RuntimeException('first stage fail'); + $exception = PipelineExecutionException::atStage('First', 0, $cause); + + $this->assertSame(0, $exception->context['stage']); + } + + public function testPipelineExecutionExtendsBase(): void + { + $exception = PipelineExecutionException::atStage('P', 0, new \RuntimeException()); + + $this->assertInstanceOf(ProcessorPipelineException::class, $exception); + } + + // ── InvalidProcessorConfigurationException ────────────────────── + + public function testForProcessorCreatesWithNameAndReason(): void + { + $exception = InvalidProcessorConfigurationException::forProcessor( + 'length', + 'Processor does not implement ConfigurableProcessor.', + ); + + $this->assertStringContainsString('length', $exception->getMessage()); + $this->assertStringContainsString('ConfigurableProcessor', $exception->getMessage()); + $this->assertSame('length', $exception->context['processor']); + $this->assertSame( + 'Processor does not implement ConfigurableProcessor.', + $exception->context['reason'], + ); + } + + public function testInvalidConfigExtendsBase(): void + { + $exception = InvalidProcessorConfigurationException::forProcessor('p', 'r'); + + $this->assertInstanceOf(ProcessorPipelineException::class, $exception); + } + + // ── Catchability Hierarchy ────────────────────────────────────── + + public function testAllExceptionsAreCatchableByBase(): void + { + $exceptions = [ + ProcessorNotFoundException::forName('x'), + PipelineExecutionException::atStage('P', 0, new \RuntimeException()), + InvalidProcessorConfigurationException::forProcessor('p', 'r'), + ]; + + foreach ($exceptions as $exception) { + $this->assertInstanceOf(ProcessorPipelineException::class, $exception); + $this->assertInstanceOf(\RuntimeException::class, $exception); + } + } +} diff --git a/tests/Unit/Handler/ProcessorHandlerTest.php b/tests/Unit/Handler/ProcessorHandlerTest.php new file mode 100644 index 0000000..5265749 --- /dev/null +++ b/tests/Unit/Handler/ProcessorHandlerTest.php @@ -0,0 +1,175 @@ +results = new ProcessingResultCollection(); + } + + // ── Successful Processing ─────────────────────────────────────── + + public function testProcessDelegatesToWrappedProcessor(): void + { + $handler = new ProcessorHandler( + processor: StubProcessor::uppercase(), + resultCollection: $this->results, + ); + + $result = $handler->process('hello'); + + $this->assertSame('HELLO', $result); + } + + public function testProcessRecordsExecutionTrace(): void + { + $processor = StubProcessor::identity(); + $handler = new ProcessorHandler( + processor: $processor, + resultCollection: $this->results, + ); + + $handler->process('input'); + + $trace = $this->results->getExecutionTrace(); + $this->assertCount(1, $trace); + $this->assertSame($processor::class, $trace[0]); + } + + public function testProcessDoesNotRecordErrorOnSuccess(): void + { + $handler = new ProcessorHandler( + processor: StubProcessor::identity(), + resultCollection: $this->results, + ); + + $handler->process('input'); + + $this->assertFalse($this->results->hasErrors()); + } + + // ── Error Handling (haltOnError = false) ──────────────────────── + + public function testProcessCatchesErrorAndReturnsInput(): void + { + $handler = new ProcessorHandler( + processor: StubFailingProcessor::withMessage('boom'), + resultCollection: $this->results, + haltOnError: false, + ); + + $result = $handler->process('original'); + + $this->assertSame('original', $result); + } + + public function testProcessRecordsErrorInCollection(): void + { + $handler = new ProcessorHandler( + processor: StubFailingProcessor::withMessage('validation failed'), + resultCollection: $this->results, + haltOnError: false, + ); + + $handler->process('input'); + + $this->assertTrue($this->results->hasErrors()); + + $errors = $this->results->getErrors(); + $this->assertArrayHasKey('StubFailingProcessor', $errors); + $this->assertSame('processingFailed', $errors['StubFailingProcessor'][0]['errorKey']); + $this->assertSame('validation failed', $errors['StubFailingProcessor'][0]['message']); + } + + public function testProcessRecordsTraceEvenOnError(): void + { + $handler = new ProcessorHandler( + processor: StubFailingProcessor::withMessage('fail'), + resultCollection: $this->results, + haltOnError: false, + ); + + $handler->process('input'); + + $this->assertCount(1, $this->results->getExecutionTrace()); + } + + // ── Error Handling (haltOnError = true) ────────────────────────── + + public function testProcessReThrowsWhenHaltOnError(): void + { + $handler = new ProcessorHandler( + processor: StubFailingProcessor::withMessage('halt!'), + resultCollection: $this->results, + haltOnError: true, + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('halt!'); + + $handler->process('input'); + } + + public function testProcessRecordsErrorBeforeReThrow(): void + { + $handler = new ProcessorHandler( + processor: StubFailingProcessor::withMessage('halt!'), + resultCollection: $this->results, + haltOnError: true, + ); + + try { + $handler->process('input'); + } catch (\RuntimeException) { + // Expected + } + + $this->assertTrue($this->results->hasErrors()); + } + + // ── Multiple Handlers Sharing Collection ──────────────────────── + + public function testMultipleHandlersShareResultCollection(): void + { + $h1 = new ProcessorHandler( + processor: StubProcessor::trim(), + resultCollection: $this->results, + ); + + $h2 = new ProcessorHandler( + processor: StubFailingProcessor::withMessage('oops'), + resultCollection: $this->results, + haltOnError: false, + ); + + $h1->process(' data '); + $h2->process('input'); + + $this->assertCount(2, $this->results->getExecutionTrace()); + $this->assertTrue($this->results->hasErrors()); + } + + // ── Processor Interface Compliance ────────────────────────────── + + public function testHandlerImplementsProcessorInterface(): void + { + $handler = new ProcessorHandler( + processor: StubProcessor::identity(), + resultCollection: $this->results, + ); + + $this->assertInstanceOf(\KaririCode\Contract\Processor\Processor::class, $handler); + } +} diff --git a/tests/Unit/Pipeline/PipelineTest.php b/tests/Unit/Pipeline/PipelineTest.php new file mode 100644 index 0000000..c92bc90 --- /dev/null +++ b/tests/Unit/Pipeline/PipelineTest.php @@ -0,0 +1,264 @@ +assertTrue($pipeline->isEmpty()); + $this->assertSame(0, $pipeline->count()); + $this->assertSame([], $pipeline->getProcessors()); + } + + public function testConstructWithProcessors(): void + { + $p1 = StubProcessor::trim(); + $p2 = StubProcessor::uppercase(); + + $pipeline = new Pipeline([$p1, $p2]); + + $this->assertSame(2, $pipeline->count()); + $this->assertFalse($pipeline->isEmpty()); + } + + public function testConstructNormalizesArrayKeys(): void + { + // Associative array keys should be normalized to sequential list + $p1 = StubProcessor::trim(); + $p2 = StubProcessor::uppercase(); + + $pipeline = new Pipeline([5 => $p1, 10 => $p2]); + $processors = $pipeline->getProcessors(); + + $this->assertSame(0, array_key_first($processors)); + $this->assertSame(1, array_key_last($processors)); + } + + // ── Execution ─────────────────────────────────────────────────── + + public function testProcessAppliesProcessorsSequentially(): void + { + $pipeline = new Pipeline([ + StubProcessor::trim(), + StubProcessor::uppercase(), + ]); + + $result = $pipeline->process(' hello world '); + + $this->assertSame('HELLO WORLD', $result); + } + + public function testProcessPassthroughOnEmptyPipeline(): void + { + $pipeline = new Pipeline(); + + $this->assertSame('unchanged', $pipeline->process('unchanged')); + } + + public function testProcessPreservesTypeForNonStringInput(): void + { + $pipeline = new Pipeline([StubProcessor::identity()]); + + $this->assertSame(42, $pipeline->process(42)); + $this->assertSame(['a', 'b'], $pipeline->process(['a', 'b'])); + $this->assertNull($pipeline->process(null)); + } + + public function testProcessChainsMultipleTransformations(): void + { + $pipeline = new Pipeline([ + StubProcessor::trim(), + StubProcessor::lowercase(), + StubProcessor::append('!'), + ]); + + $result = $pipeline->process(' HELLO '); + + $this->assertSame('hello!', $result); + } + + public function testProcessSingleProcessorPipeline(): void + { + $pipeline = new Pipeline([StubProcessor::uppercase()]); + + $this->assertSame('FOO', $pipeline->process('foo')); + } + + // ── Execution Errors ──────────────────────────────────────────── + + public function testProcessWrapsExceptionInPipelineExecutionException(): void + { + $pipeline = new Pipeline([ + StubProcessor::trim(), + StubFailingProcessor::withMessage('boom'), + ]); + + $this->expectException(PipelineExecutionException::class); + $this->expectExceptionMessage('Pipeline failed at stage 1'); + + $pipeline->process('input'); + } + + public function testProcessPreservesOriginalExceptionAsPrevious(): void + { + $original = new \LogicException('root cause'); + $pipeline = new Pipeline([ + StubFailingProcessor::withException($original), + ]); + + try { + $pipeline->process('input'); + $this->fail('Expected PipelineExecutionException'); + } catch (PipelineExecutionException $exception) { + $this->assertSame($original, $exception->getPrevious()); + $this->assertSame(0, $exception->context['stage']); + $this->assertStringContainsString('root cause', $exception->getMessage()); + } + } + + public function testProcessReThrowsPipelineExecutionExceptionWithoutWrapping(): void + { + // A PipelineExecutionException from a nested pipeline should not double-wrap + $inner = PipelineExecutionException::atStage('InnerProcessor', 0, new \RuntimeException('inner')); + $pipeline = new Pipeline([ + StubFailingProcessor::withException($inner), + ]); + + try { + $pipeline->process('input'); + $this->fail('Expected PipelineExecutionException'); + } catch (PipelineExecutionException $exception) { + // Should be the same exception, not a wrapper + $this->assertSame($inner, $exception); + } + } + + public function testProcessReportsCorrectStageIndex(): void + { + $pipeline = new Pipeline([ + StubProcessor::identity(), + StubProcessor::identity(), + StubFailingProcessor::withMessage('fail at stage 2'), + ]); + + try { + $pipeline->process('input'); + $this->fail('Expected PipelineExecutionException'); + } catch (PipelineExecutionException $exception) { + $this->assertSame(2, $exception->context['stage']); + } + } + + // ── Immutability (ARFA P1) ────────────────────────────────────── + + public function testWithProcessorReturnsNewInstance(): void + { + $original = new Pipeline([StubProcessor::trim()]); + $extended = $original->withProcessor(StubProcessor::uppercase()); + + $this->assertNotSame($original, $extended); + $this->assertSame(1, $original->count()); + $this->assertSame(2, $extended->count()); + } + + public function testWithProcessorDoesNotModifyOriginal(): void + { + $original = new Pipeline(); + $original->withProcessor(StubProcessor::trim()); + + $this->assertTrue($original->isEmpty()); + } + + public function testWithPipelineReturnsNewInstance(): void + { + $first = new Pipeline([StubProcessor::trim()]); + $second = new Pipeline([StubProcessor::uppercase()]); + + $combined = $first->withPipeline($second); + + $this->assertNotSame($first, $combined); + $this->assertNotSame($second, $combined); + $this->assertSame(1, $first->count()); + $this->assertSame(1, $second->count()); + $this->assertSame(2, $combined->count()); + } + + public function testWithPipelinePreservesProcessorOrder(): void + { + $first = new Pipeline([StubProcessor::trim()]); + $second = new Pipeline([StubProcessor::uppercase()]); + + $combined = $first->withPipeline($second); + $result = $combined->process(' hello '); + + // trim → uppercase + $this->assertSame('HELLO', $result); + } + + public function testImmutableChainComposition(): void + { + $base = new Pipeline(); + $step1 = $base->withProcessor(StubProcessor::trim()); + $step2 = $step1->withProcessor(StubProcessor::lowercase()); + $step3 = $step2->withProcessor(StubProcessor::append('!')); + + // Base remains empty + $this->assertTrue($base->isEmpty()); + $this->assertSame(1, $step1->count()); + $this->assertSame(2, $step2->count()); + $this->assertSame(3, $step3->count()); + + // Full chain produces correct result + $this->assertSame('hello!', $step3->process(' HELLO ')); + } + + // ── Introspection ─────────────────────────────────────────────── + + public function testGetProcessorsReturnsList(): void + { + $p1 = StubProcessor::trim(); + $p2 = StubProcessor::uppercase(); + + $pipeline = new Pipeline([$p1, $p2]); + $processors = $pipeline->getProcessors(); + + $this->assertSame($p1, $processors[0]); + $this->assertSame($p2, $processors[1]); + } + + public function testCountReturnsProcessorCount(): void + { + $pipeline = new Pipeline([ + StubProcessor::trim(), + StubProcessor::uppercase(), + StubProcessor::lowercase(), + ]); + + $this->assertSame(3, $pipeline->count()); + } + + public function testIsEmptyReturnsTrueForEmptyPipeline(): void + { + $this->assertTrue(new Pipeline()->isEmpty()); + } + + public function testIsEmptyReturnsFalseForNonEmptyPipeline(): void + { + $pipeline = new Pipeline([StubProcessor::identity()]); + + $this->assertFalse($pipeline->isEmpty()); + } +} diff --git a/tests/Unit/ProcessorBuilderTest.php b/tests/Unit/ProcessorBuilderTest.php new file mode 100644 index 0000000..a95e1d1 --- /dev/null +++ b/tests/Unit/ProcessorBuilderTest.php @@ -0,0 +1,192 @@ +registry = new ProcessorRegistry(); + $this->builder = new ProcessorBuilder($this->registry); + } + + // ── Simple Specification (name list) ──────────────────────────── + + public function testBuildPipelineFromSimpleNameList(): void + { + $this->registry->register('sanitizer', 'trim', StubProcessor::trim()); + $this->registry->register('sanitizer', 'upper', StubProcessor::uppercase()); + + $pipeline = $this->builder->buildPipeline('sanitizer', ['trim', 'upper']); + + $this->assertInstanceOf(Pipeline::class, $pipeline); + $this->assertSame(2, $pipeline->count()); + } + + public function testBuildPipelineExecutesCorrectly(): void + { + $this->registry->register('sanitizer', 'trim', StubProcessor::trim()); + $this->registry->register('sanitizer', 'upper', StubProcessor::uppercase()); + + $pipeline = $this->builder->buildPipeline('sanitizer', ['trim', 'upper']); + $result = $pipeline->process(' hello '); + + $this->assertSame('HELLO', $result); + } + + public function testBuildPipelinePreservesSpecOrder(): void + { + $this->registry->register('ctx', 'upper', StubProcessor::uppercase()); + $this->registry->register('ctx', 'append', StubProcessor::append('!')); + + $pipeline = $this->builder->buildPipeline('ctx', ['upper', 'append']); + + $this->assertSame('HELLO!', $pipeline->process('hello')); + } + + // ── Enable/Disable Specification ──────────────────────────────── + + public function testBuildPipelineSkipsDisabledProcessors(): void + { + $this->registry->register('ctx', 'trim', StubProcessor::trim()); + $this->registry->register('ctx', 'upper', StubProcessor::uppercase()); + + $pipeline = $this->builder->buildPipeline('ctx', [ + 'trim' => true, + 'upper' => false, + ]); + + $this->assertSame(1, $pipeline->count()); + $this->assertSame('hello', $pipeline->process(' hello ')); + } + + public function testBuildPipelineWithAllDisabled(): void + { + $this->registry->register('ctx', 'trim', StubProcessor::trim()); + + $pipeline = $this->builder->buildPipeline('ctx', [ + 'trim' => false, + ]); + + $this->assertTrue($pipeline->isEmpty()); + } + + // ── Configuration Specification ───────────────────────────────── + + public function testBuildPipelineConfiguresConfigurableProcessor(): void + { + $configurable = new StubConfigurableProcessor(); + $this->registry->register('validator', 'length', $configurable); + + $this->builder->buildPipeline('validator', [ + 'length' => ['minLength' => 3, 'maxLength' => 50], + ]); + + $this->assertSame(['minLength' => 3, 'maxLength' => 50], $configurable->lastConfig); + } + + public function testBuildPipelineThrowsWhenConfiguringNonConfigurableProcessor(): void + { + $this->registry->register('ctx', 'simple', StubProcessor::identity()); + + $this->expectException(InvalidProcessorConfigurationException::class); + $this->expectExceptionMessage("Invalid configuration for processor 'simple'"); + + $this->builder->buildPipeline('ctx', [ + 'simple' => ['key' => 'value'], + ]); + } + + // ── Mixed Specification ───────────────────────────────────────── + + public function testBuildPipelineMixedSpecificationFormat(): void + { + $configurable = new StubConfigurableProcessor(); + + $this->registry->register('ctx', 'trim', StubProcessor::trim()); + $this->registry->register('ctx', 'upper', StubProcessor::uppercase()); + $this->registry->register('ctx', 'length', $configurable); + $this->registry->register('ctx', 'skip', StubProcessor::identity()); + + $pipeline = $this->builder->buildPipeline('ctx', [ + 'trim', // sequential name + 'upper' => true, // enabled flag + 'length' => ['minLength' => 1], // configured + 'skip' => false, // disabled + ]); + + $this->assertSame(3, $pipeline->count()); + $this->assertSame(['minLength' => 1], $configurable->lastConfig); + } + + // ── Empty Specification ───────────────────────────────────────── + + public function testBuildPipelineFromEmptySpec(): void + { + $pipeline = $this->builder->buildPipeline('ctx', []); + + $this->assertTrue($pipeline->isEmpty()); + } + + // ── Unknown Format Handling ───────────────────────────────────── + + public function testBuildPipelineIgnoresUnknownFormats(): void + { + $this->registry->register('ctx', 'trim', StubProcessor::trim()); + + // int key + non-string value → skipped + // string key + int value → skipped + $pipeline = $this->builder->buildPipeline('ctx', [ + 'trim', + 0 => 'trim', // duplicate but valid + 'unknown' => 42, // unknown format → skipped + ]); + + // 'trim' appears once (index 0 overwritten by explicit 0 => 'trim') + $this->assertGreaterThanOrEqual(1, $pipeline->count()); + } + + // ── Error Cases ───────────────────────────────────────────────── + + public function testBuildPipelineThrowsForUnregisteredProcessor(): void + { + $this->expectException(ProcessorNotFoundException::class); + $this->expectExceptionMessage("Processor 'missing' not found in context 'ctx'."); + + $this->builder->buildPipeline('ctx', ['missing']); + } + + public function testBuildPipelineThrowsForUnregisteredContext(): void + { + $this->expectException(ProcessorNotFoundException::class); + + $this->builder->buildPipeline('nonexistent', ['anything']); + } + + // ── Immutability of Built Pipeline ────────────────────────────── + + public function testBuildPipelineReturnsImmutablePipeline(): void + { + $this->registry->register('ctx', 'trim', StubProcessor::trim()); + + $pipeline = $this->builder->buildPipeline('ctx', ['trim']); + $extended = $pipeline->withProcessor(StubProcessor::uppercase()); + + $this->assertSame(1, $pipeline->count()); + $this->assertSame(2, $extended->count()); + } +} diff --git a/tests/Unit/ProcessorRegistryTest.php b/tests/Unit/ProcessorRegistryTest.php new file mode 100644 index 0000000..a41dca3 --- /dev/null +++ b/tests/Unit/ProcessorRegistryTest.php @@ -0,0 +1,250 @@ +registry = new ProcessorRegistry(); + } + + // ── Registration ──────────────────────────────────────────────── + + public function testRegisterStoresProcessorInContext(): void + { + $processor = StubProcessor::trim(); + $this->registry->register('sanitizer', 'trim', $processor); + + $this->assertTrue($this->registry->has('sanitizer', 'trim')); + $this->assertSame($processor, $this->registry->get('sanitizer', 'trim')); + } + + public function testRegisterReturnsSelfForFluentApi(): void + { + $result = $this->registry->register('ctx', 'name', StubProcessor::identity()); + + $this->assertSame($this->registry, $result); + } + + public function testFluentChainRegistersMultipleProcessors(): void + { + $this->registry + ->register('validator', 'required', StubProcessor::identity()) + ->register('validator', 'email', StubProcessor::identity()) + ->register('sanitizer', 'trim', StubProcessor::trim()); + + $this->assertSame(3, $this->registry->count()); + } + + public function testRegisterOverwritesExistingProcessor(): void + { + $first = StubProcessor::trim(); + $second = StubProcessor::uppercase(); + + $this->registry->register('ctx', 'proc', $first); + $this->registry->register('ctx', 'proc', $second); + + $this->assertSame($second, $this->registry->get('ctx', 'proc')); + $this->assertSame(1, $this->registry->count()); + } + + public function testSameNameInDifferentContextsDoesNotCollide(): void + { + $validatorTrim = StubProcessor::identity(); + $sanitizerTrim = StubProcessor::trim(); + + $this->registry->register('validator', 'trim', $validatorTrim); + $this->registry->register('sanitizer', 'trim', $sanitizerTrim); + + $this->assertSame($validatorTrim, $this->registry->get('validator', 'trim')); + $this->assertSame($sanitizerTrim, $this->registry->get('sanitizer', 'trim')); + $this->assertNotSame( + $this->registry->get('validator', 'trim'), + $this->registry->get('sanitizer', 'trim'), + ); + } + + // ── Retrieval ─────────────────────────────────────────────────── + + public function testGetThrowsWhenProcessorNotFound(): void + { + $this->expectException(ProcessorNotFoundException::class); + $this->expectExceptionMessage("Processor 'missing' not found in context 'validator'."); + + $this->registry->get('validator', 'missing'); + } + + public function testGetThrowsWhenContextNotFound(): void + { + $this->expectException(ProcessorNotFoundException::class); + + $this->registry->get('nonexistent', 'anything'); + } + + public function testHasReturnsFalseForMissingProcessor(): void + { + $this->assertFalse($this->registry->has('ctx', 'nope')); + } + + public function testHasReturnsFalseForMissingContext(): void + { + $this->assertFalse($this->registry->has('nonexistent', 'proc')); + } + + public function testGetByContextReturnsAllProcessors(): void + { + $p1 = StubProcessor::trim(); + $p2 = StubProcessor::uppercase(); + + $this->registry->register('sanitizer', 'trim', $p1); + $this->registry->register('sanitizer', 'upper', $p2); + + $result = $this->registry->getByContext('sanitizer'); + + $this->assertCount(2, $result); + $this->assertSame($p1, $result['trim']); + $this->assertSame($p2, $result['upper']); + } + + public function testGetByContextReturnsEmptyForUnknownContext(): void + { + $this->assertSame([], $this->registry->getByContext('unknown')); + } + + // ── Introspection ─────────────────────────────────────────────── + + public function testGetContextNamesReturnsRegisteredContexts(): void + { + $this->registry->register('validator', 'required', StubProcessor::identity()); + $this->registry->register('sanitizer', 'trim', StubProcessor::trim()); + + $names = $this->registry->getContextNames(); + + $this->assertContains('validator', $names); + $this->assertContains('sanitizer', $names); + $this->assertCount(2, $names); + } + + public function testGetContextNamesReturnsEmptyWhenEmpty(): void + { + $this->assertSame([], $this->registry->getContextNames()); + } + + public function testGetProcessorNamesReturnsNamesInContext(): void + { + $this->registry->register('ctx', 'alpha', StubProcessor::identity()); + $this->registry->register('ctx', 'beta', StubProcessor::identity()); + + $names = $this->registry->getProcessorNames('ctx'); + + $this->assertContains('alpha', $names); + $this->assertContains('beta', $names); + } + + public function testGetProcessorNamesReturnsEmptyForUnknownContext(): void + { + $this->assertSame([], $this->registry->getProcessorNames('unknown')); + } + + public function testCountReturnsZeroWhenEmpty(): void + { + $this->assertSame(0, $this->registry->count()); + } + + public function testCountReturnsTotalAcrossContexts(): void + { + $this->registry->register('a', 'p1', StubProcessor::identity()); + $this->registry->register('a', 'p2', StubProcessor::identity()); + $this->registry->register('b', 'p1', StubProcessor::identity()); + + $this->assertSame(3, $this->registry->count()); + } + + // ── Property Hooks (PHP 8.4) ──────────────────────────────────── + + public function testContextNamesHookReturnsRegisteredContexts(): void + { + $this->registry->register('validator', 'req', StubProcessor::identity()); + $this->registry->register('sanitizer', 'trim', StubProcessor::trim()); + + $this->assertContains('validator', $this->registry->contextNames); + $this->assertContains('sanitizer', $this->registry->contextNames); + } + + public function testTotalCountHookMatchesMethodCount(): void + { + $this->registry->register('a', 'x', StubProcessor::identity()); + $this->registry->register('b', 'y', StubProcessor::identity()); + + $this->assertSame($this->registry->count(), $this->registry->totalCount); + $this->assertSame(2, $this->registry->totalCount); + } + + public function testIsEmptyHookReturnsTrueWhenEmpty(): void + { + $this->assertTrue($this->registry->isEmpty); + } + + public function testIsEmptyHookReturnsFalseWhenPopulated(): void + { + $this->registry->register('ctx', 'proc', StubProcessor::identity()); + + $this->assertFalse($this->registry->isEmpty); + } + + // ── Mutation ──────────────────────────────────────────────────── + + public function testRemoveDeletesExistingProcessor(): void + { + $this->registry->register('ctx', 'proc', StubProcessor::identity()); + + $this->assertTrue($this->registry->remove('ctx', 'proc')); + $this->assertFalse($this->registry->has('ctx', 'proc')); + } + + public function testRemoveReturnsFalseForMissingProcessor(): void + { + $this->assertFalse($this->registry->remove('ctx', 'missing')); + } + + public function testRemoveCleansUpEmptyContext(): void + { + $this->registry->register('ctx', 'only', StubProcessor::identity()); + $this->registry->remove('ctx', 'only'); + + $this->assertSame([], $this->registry->getContextNames()); + } + + public function testRemovePreservesOtherProcessorsInContext(): void + { + $this->registry->register('ctx', 'keep', StubProcessor::identity()); + $this->registry->register('ctx', 'drop', StubProcessor::identity()); + + $this->registry->remove('ctx', 'drop'); + + $this->assertTrue($this->registry->has('ctx', 'keep')); + $this->assertFalse($this->registry->has('ctx', 'drop')); + } + + public function testClearRemovesEverything(): void + { + $this->registry->register('a', 'p1', StubProcessor::identity()); + $this->registry->register('b', 'p2', StubProcessor::identity()); + + $this->registry->clear(); + + $this->assertSame(0, $this->registry->count()); + $this->assertTrue($this->registry->isEmpty); + $this->assertSame([], $this->registry->getContextNames()); + } +} diff --git a/tests/Unit/Result/ProcessingResultCollectionTest.php b/tests/Unit/Result/ProcessingResultCollectionTest.php new file mode 100644 index 0000000..6294482 --- /dev/null +++ b/tests/Unit/Result/ProcessingResultCollectionTest.php @@ -0,0 +1,277 @@ +collection = new ProcessingResultCollection(); + } + + // ── Initial State ─────────────────────────────────────────────── + + public function testNewCollectionHasNoErrors(): void + { + $this->assertFalse($this->collection->hasErrors()); + $this->assertSame([], $this->collection->getErrors()); + } + + public function testNewCollectionHasNoWarnings(): void + { + $this->assertFalse($this->collection->hasWarnings()); + $this->assertSame([], $this->collection->getWarnings()); + } + + public function testNewCollectionHasEmptyTrace(): void + { + $this->assertSame([], $this->collection->getExecutionTrace()); + } + + // ── Error Recording ───────────────────────────────────────────── + + public function testAddErrorRecordsEntry(): void + { + $this->collection->addError('App\\Validator\\Email', 'invalidFormat', 'Not a valid email.'); + + $this->assertTrue($this->collection->hasErrors()); + + $errors = $this->collection->getErrors(); + $this->assertArrayHasKey('Email', $errors); + $this->assertCount(1, $errors['Email']); + $this->assertSame('invalidFormat', $errors['Email'][0]['errorKey']); + $this->assertSame('Not a valid email.', $errors['Email'][0]['message']); + } + + public function testAddErrorExtractsShortClassName(): void + { + $this->collection->addError( + 'KaririCode\\Validator\\Processor\\Logic\\RequiredValidator', + 'required', + 'Field is required.', + ); + + $errors = $this->collection->getErrors(); + $this->assertArrayHasKey('RequiredValidator', $errors); + } + + public function testAddErrorAccumulatesMultipleErrorsPerProcessor(): void + { + $this->collection->addError('App\\Proc', 'err1', 'First'); + $this->collection->addError('App\\Proc', 'err2', 'Second'); + + $errors = $this->collection->getErrors(); + $this->assertCount(2, $errors['Proc']); + } + + public function testAddErrorAccumulatesAcrossProcessors(): void + { + $this->collection->addError('App\\Alpha', 'e1', 'msg1'); + $this->collection->addError('App\\Beta', 'e2', 'msg2'); + + $errors = $this->collection->getErrors(); + $this->assertCount(1, $errors['Alpha']); + $this->assertCount(1, $errors['Beta']); + } + + // ── Warning Recording ─────────────────────────────────────────── + + public function testAddWarningRecordsEntry(): void + { + $this->collection->addWarning('App\\Proc', 'deprecatedField', 'Field is deprecated.'); + + $this->assertTrue($this->collection->hasWarnings()); + + $warnings = $this->collection->getWarnings(); + $this->assertArrayHasKey('Proc', $warnings); + $this->assertSame('deprecatedField', $warnings['Proc'][0]['errorKey']); + } + + public function testWarningsDoNotAffectErrors(): void + { + $this->collection->addWarning('App\\Proc', 'warn', 'A warning.'); + + $this->assertFalse($this->collection->hasErrors()); + $this->assertTrue($this->collection->hasWarnings()); + } + + // ── Execution Trace ───────────────────────────────────────────── + + public function testRecordExecutionAddsToTrace(): void + { + $this->collection->recordExecution('App\\ProcessorA'); + $this->collection->recordExecution('App\\ProcessorB'); + + $trace = $this->collection->getExecutionTrace(); + + $this->assertSame(['App\\ProcessorA', 'App\\ProcessorB'], $trace); + } + + public function testRecordExecutionPreservesOrder(): void + { + $this->collection->recordExecution('First'); + $this->collection->recordExecution('Second'); + $this->collection->recordExecution('Third'); + + $this->assertSame(['First', 'Second', 'Third'], $this->collection->getExecutionTrace()); + } + + // ── Property Hooks (PHP 8.4) ──────────────────────────────────── + + public function testErrorsHookReturnsErrors(): void + { + $this->collection->addError('App\\X', 'key', 'msg'); + + $this->assertSame($this->collection->getErrors(), $this->collection->errors); + } + + public function testWarningsHookReturnsWarnings(): void + { + $this->collection->addWarning('App\\X', 'key', 'msg'); + + $this->assertSame($this->collection->getWarnings(), $this->collection->warnings); + } + + public function testExecutionTraceHookReturnsTrace(): void + { + $this->collection->recordExecution('App\\X'); + + $this->assertSame($this->collection->getExecutionTrace(), $this->collection->executionTrace); + } + + public function testHasErrorsHookMatchesMethod(): void + { + $this->assertSame($this->collection->hasErrors(), $this->collection->hasErrors); + + $this->collection->addError('App\\X', 'e', 'm'); + + $this->assertSame($this->collection->hasErrors(), $this->collection->hasErrors); + $this->assertTrue($this->collection->hasErrors); + } + + public function testHasWarningsHookMatchesMethod(): void + { + $this->assertFalse($this->collection->hasWarnings); + + $this->collection->addWarning('App\\X', 'w', 'm'); + + $this->assertTrue($this->collection->hasWarnings); + } + + public function testErrorCountHookReturnsTotalEntries(): void + { + $this->assertSame(0, $this->collection->errorCount); + + $this->collection->addError('App\\A', 'e1', 'm1'); + $this->collection->addError('App\\A', 'e2', 'm2'); + $this->collection->addError('App\\B', 'e3', 'm3'); + + $this->assertSame(3, $this->collection->errorCount); + } + + // ── Merge ─────────────────────────────────────────────────────── + + public function testMergeCombinesErrors(): void + { + $other = new ProcessingResultCollection(); + + $this->collection->addError('App\\A', 'e1', 'msg1'); + $other->addError('App\\B', 'e2', 'msg2'); + + $this->collection->merge($other); + + $errors = $this->collection->getErrors(); + $this->assertArrayHasKey('A', $errors); + $this->assertArrayHasKey('B', $errors); + } + + public function testMergeCombinesWarnings(): void + { + $other = new ProcessingResultCollection(); + + $this->collection->addWarning('App\\A', 'w1', 'msg1'); + $other->addWarning('App\\B', 'w2', 'msg2'); + + $this->collection->merge($other); + + $warnings = $this->collection->getWarnings(); + $this->assertArrayHasKey('A', $warnings); + $this->assertArrayHasKey('B', $warnings); + } + + public function testMergeCombinesTraces(): void + { + $other = new ProcessingResultCollection(); + + $this->collection->recordExecution('App\\First'); + $other->recordExecution('App\\Second'); + + $this->collection->merge($other); + + $this->assertSame(['App\\First', 'App\\Second'], $this->collection->getExecutionTrace()); + } + + public function testMergeAccumulatesSameProcessorErrors(): void + { + $other = new ProcessingResultCollection(); + + $this->collection->addError('App\\Proc', 'e1', 'msg1'); + $other->addError('App\\Proc', 'e2', 'msg2'); + + $this->collection->merge($other); + + $this->assertCount(2, $this->collection->getErrors()['Proc']); + } + + public function testMergeDoesNotModifySource(): void + { + $other = new ProcessingResultCollection(); + $other->addError('App\\X', 'e', 'm'); + + $this->collection->merge($other); + + // Source unchanged + $this->assertCount(1, $other->getErrors()['X']); + } + + // ── Reset ─────────────────────────────────────────────────────── + + public function testResetClearsEverything(): void + { + $this->collection->addError('App\\A', 'e', 'm'); + $this->collection->addWarning('App\\B', 'w', 'm'); + $this->collection->recordExecution('App\\C'); + + $this->collection->reset(); + + $this->assertFalse($this->collection->hasErrors()); + $this->assertFalse($this->collection->hasWarnings()); + $this->assertSame([], $this->collection->getExecutionTrace()); + $this->assertSame(0, $this->collection->errorCount); + } + + // ── Edge Cases ────────────────────────────────────────────────── + + public function testSimpleClassNameWithoutNamespace(): void + { + $this->collection->addError('SimpleProcessor', 'err', 'msg'); + + $errors = $this->collection->getErrors(); + $this->assertArrayHasKey('SimpleProcessor', $errors); + } + + public function testEmptyStringProcessorClass(): void + { + $this->collection->addError('', 'err', 'msg'); + + $errors = $this->collection->getErrors(); + $this->assertArrayHasKey('', $errors); + } +} diff --git a/tests/application.php b/tests/application.php deleted file mode 100644 index be08349..0000000 --- a/tests/application.php +++ /dev/null @@ -1,108 +0,0 @@ -resultCollection->addError( - self::class, - 'invalidFormat', - "Invalid email format: $input" - ); - } - - return $input; - } -} - -// Function to handle pipeline execution -function executePipeline(ProcessorBuilder $builder, ProcessorRegistry $registry, array $processorSpecs, string $input): void -{ - $resultCollection = new ProcessingResultCollection(); - $context = 'example_context'; - - $registry->register($context, 'upper_case', new UpperCaseProcessor()) - ->register($context, 'trim', new TrimProcessor()) - ->register($context, 'email_transform', new EmailTransformerProcessor()) - ->register($context, 'email_validate', new EmailValidatorProcessor($resultCollection)); - - try { - $pipeline = $builder->buildPipeline($context, $processorSpecs); - $output = $pipeline->process($input); - - // Displaying the results - echo "Original Input: '$input'\n"; - echo "Pipeline Output: '$output'\n"; - - // Display errors if any - if ($resultCollection->hasErrors()) { - echo "\nProcessing Errors:\n"; - print_r($resultCollection->getErrors()); - } else { - echo "\nNo processing errors encountered.\n"; - } - } catch (Exception $e) { - echo 'Error executing the pipeline: ' . $e->getMessage() . "\n"; - } -} - -// Register processors to a context in the registry. -$registry = new ProcessorRegistry(); -$builder = new ProcessorBuilder($registry); - -// Execute scenario 1 - Valid input -$processorSpecs = [ - 'upper_case' => false, - 'trim' => true, - 'email_transform' => true, - 'email_validate' => true, -]; -$input = ' Example@Email.COM '; - -echo "Scenario 1 - Valid Input\n"; -executePipeline($builder, $registry, $processorSpecs, $input); - -// Execute scenario 2 - Invalid input -$input = ' InvalidEmail@@@ '; - -echo "\nScenario 2 - Invalid Input (English)\n"; -executePipeline($builder, $registry, $processorSpecs, $input); diff --git a/tests/benchmark_attribute_handler.php b/tests/benchmark_attribute_handler.php deleted file mode 100644 index 7512592..0000000 --- a/tests/benchmark_attribute_handler.php +++ /dev/null @@ -1,536 +0,0 @@ -builder = new MockProcessorBuilder(); - } - - public function run(): void - { - echo self::ANSI_BOLD . "\nATTRIBUTE HANDLER PERFORMANCE BENCHMARK\n" . self::ANSI_RESET; - echo str_repeat('=', 60) . "\n"; - echo self::ANSI_BLUE . 'Running benchmark with ' . self::ITERATIONS . ' iterations...' . self::ANSI_RESET . "\n\n"; - - // Warm up phase - $this->warmUp(); - - // Test original handler - $originalStats = $this->benchmarkOriginalHandler(); - - // Test optimized handler - $optimizedStats = $this->benchmarkOptimizedHandler(); - - // Display results - $this->displayResults($originalStats, $optimizedStats); - } - - private function warmUp(): void - { - echo self::ANSI_YELLOW . 'Warming up JIT compiler...' . self::ANSI_RESET . "\n"; - - for ($i = 0; $i < 1000; ++$i) { - $handler = new AttributeHandler('validator', $this->builder); - $this->runTestCase($handler); - - $handler = new AttributeHandlerOtimized('validator', $this->builder); - $this->runTestCase($handler); - } - - // Clear any accumulated memory - gc_collect_cycles(); - echo self::ANSI_GREEN . "Warm-up complete!\n\n" . self::ANSI_RESET; - } - - private function benchmarkOriginalHandler(): array - { - // Reset memory state - gc_collect_cycles(); - $startMemory = memory_get_usage(true); - - $handler = new AttributeHandler('validator', $this->builder); - $start = hrtime(true); - - for ($i = 0; $i < self::ITERATIONS; ++$i) { - $this->runTestCase($handler); - } - - $time = (hrtime(true) - $start) / 1e+9; - $memoryUsed = memory_get_usage(true) - $startMemory; - - return [ - 'time' => $time, - 'memory' => $memoryUsed, - 'peak' => memory_get_peak_usage(true), - ]; - } - - private function benchmarkOptimizedHandler(): array - { - // Reset memory state - gc_collect_cycles(); - $startMemory = memory_get_usage(true); - - $handler = new AttributeHandlerOtimized('validator', $this->builder); - $start = hrtime(true); - - for ($i = 0; $i < self::ITERATIONS; ++$i) { - $this->runTestCase($handler); - } - - $time = (hrtime(true) - $start) / 1e+9; - $memoryUsed = memory_get_usage(true) - $startMemory; - - return [ - 'time' => $time, - 'memory' => $memoryUsed, - 'peak' => memory_get_peak_usage(true), - ]; - } - - private function runTestCase($handler): void - { - $attribute = new class implements ProcessableAttribute { - public function getProcessors(): array - { - return [ - 'required', - 'email' => ['pattern' => '/.+@.+/'], - 'length' => ['min' => 5, 'max' => 50], - 'trim' => true, - 'lowercase' => true, - ]; - } - }; - - $testCases = [ - ['email', 'test@example.com'], - ['name', 'John Doe'], - ['age', 25], - ['description', str_repeat('a', 100)], - ['date', new \DateTime()], - ['empty', null], - ['whitespace', ' trimmed '], - ['special', '!@#$%^&*()'], - ['unicode', 'αβγδε'], - ['number_string', '12345'], - ]; - - foreach ($testCases as [$property, $value]) { - $handler->handleAttribute($property, $attribute, $value); - } - - $handler->getProcessingResultMessages(); - $handler->getProcessedPropertyValues(); - $handler->getProcessingResultErrors(); - } - - private function displayResults(array $originalStats, array $optimizedStats): void - { - echo self::ANSI_BOLD . "Performance Results\n" . self::ANSI_RESET; - echo str_repeat('=', 60) . "\n"; - - // Time Performance - $timeDiff = $this->calculatePercentageDiff($originalStats['time'], $optimizedStats['time']); - $timeColor = $timeDiff > 0 ? self::ANSI_GREEN : self::ANSI_RED; - - echo self::ANSI_BOLD . "Execution Time\n" . self::ANSI_RESET; - echo str_repeat('-', 40) . "\n"; - echo sprintf("%sOriginal Handler: %.6f seconds%s\n", self::ANSI_YELLOW, $originalStats['time'], self::ANSI_RESET); - echo sprintf("%sOptimized Handler: %.6f seconds%s\n", self::ANSI_YELLOW, $optimizedStats['time'], self::ANSI_RESET); - echo sprintf( - "%sTime Difference: %.2f%% %s%s\n\n", - $timeColor, - abs($timeDiff), - $timeDiff > 0 ? 'faster' : 'slower', - self::ANSI_RESET - ); - - // Memory Usage - echo self::ANSI_BOLD . "Memory Usage\n" . self::ANSI_RESET; - echo str_repeat('-', 40) . "\n"; - - $originalMemoryMB = $originalStats['memory'] / 1024 / 1024; - $optimizedMemoryMB = $optimizedStats['memory'] / 1024 / 1024; - $memoryDiff = $this->calculatePercentageDiff($originalStats['memory'], $optimizedStats['memory']); - $memoryColor = $memoryDiff > 0 ? self::ANSI_GREEN : self::ANSI_RED; - - echo sprintf("%sOriginal Handler: %.2f MB%s\n", self::ANSI_YELLOW, $originalMemoryMB, self::ANSI_RESET); - echo sprintf("%sOptimized Handler: %.2f MB%s\n", self::ANSI_YELLOW, $optimizedMemoryMB, self::ANSI_RESET); - echo sprintf( - "%sMemory Difference: %.2f%% %s%s\n\n", - $memoryColor, - abs($memoryDiff), - $memoryDiff > 0 ? 'less' : 'more', - self::ANSI_RESET - ); - - // Peak Memory - echo self::ANSI_BOLD . "Peak Memory Usage\n" . self::ANSI_RESET; - echo str_repeat('-', 40) . "\n"; - - $originalPeakMB = $originalStats['peak'] / 1024 / 1024; - $optimizedPeakMB = $optimizedStats['peak'] / 1024 / 1024; - $peakDiff = $this->calculatePercentageDiff($originalStats['peak'], $optimizedStats['peak']); - $peakColor = $peakDiff > 0 ? self::ANSI_GREEN : self::ANSI_RED; - - echo sprintf("%sOriginal Peak: %.2f MB%s\n", self::ANSI_YELLOW, $originalPeakMB, self::ANSI_RESET); - echo sprintf("%sOptimized Peak: %.2f MB%s\n", self::ANSI_YELLOW, $optimizedPeakMB, self::ANSI_RESET); - echo sprintf( - "%sPeak Difference: %.2f%% %s%s\n\n", - $peakColor, - abs($peakDiff), - $peakDiff > 0 ? 'less' : 'more', - self::ANSI_RESET - ); - - // Per Iteration Stats - echo self::ANSI_BOLD . "Per Iteration Stats\n" . self::ANSI_RESET; - echo str_repeat('-', 40) . "\n"; - - $originalTimePerIteration = ($originalStats['time'] * 1000) / self::ITERATIONS; - $optimizedTimePerIteration = ($optimizedStats['time'] * 1000) / self::ITERATIONS; - - echo sprintf( - "%sOriginal Time per Iteration: %.6f ms%s\n", - self::ANSI_YELLOW, - $originalTimePerIteration, - self::ANSI_RESET - ); - echo sprintf( - "%sOptimized Time per Iteration: %.6f ms%s\n", - self::ANSI_YELLOW, - $optimizedTimePerIteration, - self::ANSI_RESET - ); - - echo "\n" . str_repeat('=', 60) . "\n"; - } - - private function calculatePercentageDiff(float $original, float $optimized): float - { - if ($original <= 0) { - return 0; - } - - return (($original - $optimized) / $original) * 100; - } -} - -final class AttributeHandlerOtimized implements PropertyAttributeHandler, PropertyChangeApplier -{ - private array $processedPropertyValues = []; - private array $processingResultErrors = []; - private array $processingResultMessages = []; - private array $processorCache = []; - - public function __construct( - private readonly string $processorType, - private readonly ProcessorBuilder $builder, - private readonly ProcessorProcessorContract $validator = new ProcessorValidator(), - private readonly ProcessorConfigBuilderContract $configBuilder = new ProcessorConfigBuilder() - ) { - } - - public function handleAttribute(string $propertyName, object $attribute, mixed $value): mixed - { - if (!$attribute instanceof ProcessableAttribute) { - return null; - } - - try { - return $this->processAttribute($propertyName, $attribute, $value); - } catch (\Exception $e) { - $this->processingResultErrors[$propertyName][] = $e->getMessage(); - - return $value; - } - } - - private function processAttribute(string $propertyName, ProcessableAttribute $attribute, mixed $value): mixed - { - $config = $this->configBuilder->build($attribute); - $messages = []; - - if ($attribute instanceof CustomizableMessageAttribute) { - foreach ($config as $processorName => &$processorConfig) { - if ($message = $attribute->getMessage($processorName)) { - $processorConfig['customMessage'] = $message; - $messages[$processorName] = $message; - } - } - } - - $processedValue = $this->processValue($value, $config); - - if ($errors = $this->validateProcessors($config, $messages)) { - $this->processingResultErrors[$propertyName] = $errors; - } - - $this->processedPropertyValues[$propertyName] = [ - 'value' => $processedValue, - 'messages' => $messages, - ]; - - $this->processingResultMessages[$propertyName] = $messages; - - return $processedValue; - } - - private function validateProcessors(array $processorsConfig, array $messages): array - { - $errors = []; - foreach ($processorsConfig as $processorName => $config) { - // Simplify cache key to processor name - if (!isset($this->processorCache[$processorName])) { - $this->processorCache[$processorName] = $this->builder->build( - $this->processorType, - $processorName, - $config - ); - } - - $processor = $this->processorCache[$processorName]; - - if ($error = $this->validator->validate($processor, $processorName, $messages)) { - $errors[$processorName] = $error; - } - } - - return $errors; - } - - private function processValue(mixed $value, array $config): mixed - { - return $this->builder - ->buildPipeline($this->processorType, $config) - ->process($value); - } - - public function applyChanges(object $entity): void - { - foreach ($this->processedPropertyValues as $propertyName => $data) { - (new PropertyAccessor($entity, $propertyName))->setValue($data['value']); - } - } - - public function getProcessedPropertyValues(): array - { - return $this->processedPropertyValues; - } - - public function getProcessingResultErrors(): array - { - return $this->processingResultErrors; - } - - public function getProcessingResultMessages(): array - { - return $this->processingResultMessages; - } -} - -// Before -// class AttributeHandler implements PropertyAttributeHandler, PropertyChangeApplier -// { -// private array $processedPropertyValues = []; -// private array $processingResultErrors = []; -// private array $processingResultMessages = []; - -// public function __construct( -// private readonly string $processorType, -// private readonly ProcessorBuilder $builder, -// private readonly ProcessorProcessorContract $validator = new ProcessorValidator(), -// private readonly ProcessorConfigBuilderContract $configBuilder = new ProcessorConfigBuilder() -// ) { -// } - -// public function handleAttribute(string $propertyName, object $attribute, mixed $value): mixed -// { -// if (!$attribute instanceof ProcessableAttribute) { -// return null; -// } - -// $processorsConfig = $this->configBuilder->build($attribute); -// $messages = $this->extractCustomMessages($attribute, $processorsConfig); - -// try { -// $processedValue = $this->processValue($value, $processorsConfig); -// $errors = $this->validateProcessors($processorsConfig, $messages); - -// $this->storeProcessedPropertyValue($propertyName, $processedValue, $messages); - -// if (!empty($errors)) { -// $this->storeProcessingResultErrors($propertyName, $errors); -// } - -// return $processedValue; -// } catch (\Exception $e) { -// $this->storeProcessingResultError($propertyName, $e->getMessage()); - -// return $value; -// } -// } - -// private function validateProcessors(array $processorsConfig, array $messages): array -// { -// $errors = []; -// foreach ($processorsConfig as $processorName => $config) { -// $processor = $this->builder->build($this->processorType, $processorName, $config); -// $validationError = $this->validator->validate( -// $processor, -// $processorName, -// $messages -// ); - -// if ($this->shouldAddValidationError($validationError, $errors, $processorName)) { -// $errors[$processorName] = $validationError; -// } -// } - -// return $errors; -// } - -// private function shouldAddValidationError(?array $validationError, array $errors, string $processorName): bool -// { -// return null !== $validationError && !isset($errors[$processorName]); -// } - -// private function storeProcessingResultErrors(string $propertyName, array $errors): void -// { -// $this->processingResultErrors[$propertyName] = $errors; -// } - -// private function extractCustomMessages(ProcessableAttribute $attribute, array &$processorsConfig): array -// { -// $messages = []; -// if ($attribute instanceof CustomizableMessageAttribute) { -// foreach ($processorsConfig as $processorName => &$config) { -// $customMessage = $attribute->getMessage($processorName); -// if (null !== $customMessage) { -// $config['customMessage'] = $customMessage; -// $messages[$processorName] = $customMessage; -// } -// } -// } - -// return $messages; -// } - -// private function processValue(mixed $value, array $processorsConfig): mixed -// { -// $pipeline = $this->builder->buildPipeline( -// $this->processorType, -// $processorsConfig -// ); - -// return $pipeline->process($value); -// } - -// private function storeProcessedPropertyValue(string $propertyName, mixed $processedValue, array $messages): void -// { -// $this->processedPropertyValues[$propertyName] = [ -// 'value' => $processedValue, -// 'messages' => $messages, -// ]; -// $this->processingResultMessages[$propertyName] = $messages; -// } - -// private function storeProcessingResultError(string $propertyName, string $errorMessage): void -// { -// $this->processingResultErrors[$propertyName][] = $errorMessage; -// } - -// public function applyChanges(object $entity): void -// { -// foreach ($this->processedPropertyValues as $propertyName => $data) { -// (new PropertyAccessor($entity, $propertyName))->setValue($data['value']); -// } -// } - -// public function getProcessedPropertyValues(): array -// { -// return $this->processedPropertyValues; -// } - -// public function getProcessingResultErrors(): array -// { -// return $this->processingResultErrors; -// } - -// public function getProcessingResultMessages(): array -// { -// return $this->processingResultMessages; -// } -// } - -$benchmark = new BenchmarkRunner(); -$benchmark->run();