From 954db661f6c398bcd38a3e191870e3c9dcb60816 Mon Sep 17 00:00:00 2001 From: Yuta Nagamiya Date: Mon, 8 Apr 2024 02:35:37 +0900 Subject: [PATCH] Initial commit --- .env.example | 2 + .gitattributes | 16 ++ .github/FUNDING.yml | 12 + .github/workflows/lint.yml | 50 ++++ .github/workflows/test.yml | 65 +++++ .gitignore | 22 ++ .php-cs-fixer.dist.php | 21 ++ CHANGELOG.md | 5 + LICENSE | 21 ++ README.md | 154 ++++++++++++ composer.json | 94 ++++++++ config/aop.php | 36 +++ docker-compose.yml | 13 + docker/php/Dockerfile | 17 ++ phpstan.neon.dist | 14 ++ phpunit.xml.dist | 28 +++ src/Collections/AspectMap.php | 13 + src/Collections/InterceptMap.php | 13 + src/Collections/SourceMap.php | 13 + src/Commands/CompileCommand.php | 39 +++ src/Factories/AspectMapFactory.php | 65 +++++ src/ServiceProvider.php | 45 ++++ src/Services/ClassLoader.php | 39 +++ src/Services/Compiler.php | 83 +++++++ src/Services/ServiceRegistrar.php | 66 +++++ src/Services/SourceMapFileManager.php | 49 ++++ src/ValueObjects/CompiledClass.php | 41 ++++ src/ValueObjects/CompiledPath.php | 7 + src/ValueObjects/SourceMapFile.php | 26 ++ tests/Feature/AopTest.php | 225 ++++++++++++++++++ .../stubs/Attributes/TestAttribute1.php | 8 + .../stubs/Attributes/TestAttribute2.php | 8 + .../stubs/Attributes/TestAttribute3.php | 8 + .../stubs/Attributes/TestAttribute4.php | 8 + .../stubs/Interceptors/TestInterceptor1.php | 23 ++ .../stubs/Interceptors/TestInterceptor2.php | 23 ++ tests/Feature/stubs/Targets/TestTarget1.php | 35 +++ vendor-bin/php-coveralls/composer.json | 5 + vendor-bin/php-cs-fixer/composer.json | 5 + 39 files changed, 1417 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/aop.php create mode 100644 docker-compose.yml create mode 100644 docker/php/Dockerfile create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 src/Collections/AspectMap.php create mode 100644 src/Collections/InterceptMap.php create mode 100644 src/Collections/SourceMap.php create mode 100644 src/Commands/CompileCommand.php create mode 100644 src/Factories/AspectMapFactory.php create mode 100644 src/ServiceProvider.php create mode 100644 src/Services/ClassLoader.php create mode 100644 src/Services/Compiler.php create mode 100644 src/Services/ServiceRegistrar.php create mode 100644 src/Services/SourceMapFileManager.php create mode 100644 src/ValueObjects/CompiledClass.php create mode 100644 src/ValueObjects/CompiledPath.php create mode 100644 src/ValueObjects/SourceMapFile.php create mode 100644 tests/Feature/AopTest.php create mode 100644 tests/Feature/stubs/Attributes/TestAttribute1.php create mode 100644 tests/Feature/stubs/Attributes/TestAttribute2.php create mode 100644 tests/Feature/stubs/Attributes/TestAttribute3.php create mode 100644 tests/Feature/stubs/Attributes/TestAttribute4.php create mode 100644 tests/Feature/stubs/Interceptors/TestInterceptor1.php create mode 100644 tests/Feature/stubs/Interceptors/TestInterceptor2.php create mode 100644 tests/Feature/stubs/Targets/TestTarget1.php create mode 100644 vendor-bin/php-coveralls/composer.json create mode 100644 vendor-bin/php-cs-fixer/composer.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e0df247 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +APP_UID=1000 +APP_GID=1000 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..df8ee65 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.env.example export-ignore +/.gitattributes export-ignore +/.github/ export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/CHANGELOG.md export-ignore +/docker/ export-ignore +/docker-compose.yml export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/tests/ export-ignore +/vendor-bin/ export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..b894d64 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: ngmy +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: https://flattr.com/@ngmy diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..606a5b9 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,50 @@ +name: Lint + +on: + push: + pull_request: + +jobs: + lint: + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + matrix: + php: + - '8.1' + - '8.2' + - '8.3' + laravel: + - 9 + - 10 + - 11 + exclude: + - php: '8.1' + laravel: 11 + name: PHP ${{ matrix.php }} + Laravel ${{ matrix.laravel }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php }} + run: sudo update-alternatives --set php /usr/bin/php${{ matrix.php }} + + - name: Update Composer to latest version + run: sudo composer self-update + + - name: Validate composer.json + run: composer validate + + - name: Install Composer dependencies + run: | + composer install --no-interaction + if [[ "${{ matrix.laravel }}" == 9 ]]; then + composer update --no-interaction orchestra/testbench:^7.0 --with-all-dependencies + elif [[ "${{ matrix.laravel }}" == 10 ]]; then + composer update --no-interaction orchestra/testbench:^8.0 --with-all-dependencies + elif [[ "${{ matrix.laravel }}" == 11 ]]; then + composer update --no-interaction orchestra/testbench:^9.0 --with-all-dependencies + fi + + - name: Run lint + run: composer lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..eb78ec0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,65 @@ +name: Test + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + matrix: + php: + - '8.1' + - '8.2' + - '8.3' + laravel: + - 9 + - 10 + - 11 + exclude: + - php: '8.1' + laravel: 11 + name: PHP ${{ matrix.php }} + Laravel ${{ matrix.laravel }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php }} + run: sudo update-alternatives --set php /usr/bin/php${{ matrix.php }} + + - name: Update Composer to latest version + run: sudo composer self-update + + - name: Validate composer.json + run: composer validate + + - name: Install Composer dependencies + run: | + composer install --no-interaction + if [[ "${{ matrix.laravel }}" == 9 ]]; then + composer update --no-interaction orchestra/testbench:^7.0 --with-all-dependencies + elif [[ "${{ matrix.laravel }}" == 10 ]]; then + composer update --no-interaction orchestra/testbench:^8.0 --with-all-dependencies + elif [[ "${{ matrix.laravel }}" == 11 ]]; then + composer update --no-interaction orchestra/testbench:^9.0 --with-all-dependencies + fi + + - name: Run tests + run: | + if [[ "${{ matrix.php }}" == '8.1' && "${{ matrix.laravel }}" == 10 ]]; then + composer test-coverage + else + composer test + fi + + - name: Upload coverage results to Coveralls + if: matrix.php == '8.1' && matrix.laravel == 10 + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: nick-fields/retry@v3 + with: + timeout_minutes: 10 + max_attempts: 3 + command: vendor-bin/php-coveralls/vendor/bin/php-coveralls --coverage_clover=build/logs/clover.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..194819a --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Dotenv +/.env + +# Composer +/composer.lock +/vendor/ +/vendor-bin/**/composer.lock +/vendor-bin/**/vendor/ + +# Build +/build/ + +# PHPUnit +/.phpunit.cache/ +/phpunit.xml + +# PHPStan +/phpstan.neon + +# PHP CS Fixer +/.php-cs-fixer.php +/.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..4695a1d --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,21 @@ +in(__DIR__) +; + +$config = new PhpCsFixer\Config(); + +return $config->setRules([ + '@PHP80Migration:risky' => true, + '@PHP81Migration' => true, + '@PhpCsFixer' => true, + '@PhpCsFixer:risky' => true, + '@PHPUnit100Migration:risky' => true, + /** @link https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues/4157 */ + 'return_assignment' => false, +]) + ->setFinder($finder) +; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..067a2e3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Release Notes + +## 0.1.0 - 2024-04-09 + +- Initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ed9998e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Yuta Nagamiya + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b845b5d --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# Laravel.Aop + +[![Latest Stable Version](https://img.shields.io/packagist/v/ngmy/laravel.aop.svg?style=flat-square&label=stable)](https://packagist.org/packages/ngmy/laravel.aop) +[![Test Status](https://img.shields.io/github/actions/workflow/status/ngmy/laravel.aop/test.yml?style=flat-square&label=test)](https://github.com/ngmy/Laravel.aop/actions/workflows/test.yml) +[![Lint Status](https://img.shields.io/github/actions/workflow/status/ngmy/laravel.aop/lint.yml?style=flat-square&label=lint)](https://github.com/ngmy/Laravel.aop/actions/workflows/lint.yml) +[![Code Coverage](https://img.shields.io/coverallsCoverage/github/ngmy/Laravel.Aop?style=flat-square)](https://coveralls.io/github/ngmy/Laravel.Aop) +[![Total Downloads](https://img.shields.io/packagist/dt/ngmy/laravel.aop.svg?style=flat-square)](https://packagist.org/packages/ngmy/laravel.aop) + +Laravel.Aop integrates Ray.Aop with Laravel. It provides fast AOP by static weaving. + +## Installation + +First, you should install Laravel.Aop via the Composer package manager: + +```bash +composer require ngmy/laravel.aop +``` + +You will be asked if you trust the `olvlvl/composer-attribute-collector` package, so you should press `y`. + +Next, you should configure the `olvlvl/composer-attribute-collector` package. + +> [!TIP] +> Please see the [composer-attribute-collector documentation](https://github.com/olvlvl/composer-attribute-collector) +> to learn how to configure the `olvlvl/composer-attribute-collector` package. + +Then, you should publish the Laravel.Aop configuration file using the `vendor:publish` Artisan command. This command +will publish the `aop.php` configuration file to your application's `config` directory: + +```bash +php artisan vendor:publish --provider="Ngmy\LaravelAop\ServiceProvider" +``` + +Finally, you should add the `@php artisan aop:compile --ansi` script to the `post-autoload-dump` event hook of the +`composer.json` file: + +```json +{ + "scripts": { + "post-autoload-dump": [ + "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", + "@php artisan package:discover --ansi", + "@php artisan aop:compile --ansi" + ] + } +} +``` + +## Usage + +First, you should define the attribute. +For example, let's define the `Transactional` attribute: + +```php + $invocation->proceed()); + } +} +``` + +> [!TIP] +> Please see the [Ray.Aop documentation](https://github.com/ray-di/Ray.Aop?tab=readme-ov-file#interceptor) to learn more +> about the interceptor. + +Then, you should register the attribute and the interceptor in the `intercept` configuration option of the +`config/aop.php` configuration file. +For example, let's register the `Transactional` attribute and the `TransactionalInterceptor` interceptor: + +```php +use App\Attributes\Transactional; +use App\Interceptors\TransactionalInterceptor; + +'intercept' => [ + Transactional::class => [ + TransactionalInterceptor::class, + ], +], +``` + +Then, you should annotate the methods that you want to intercept with the attribute. +For example, let's annotate the `createUser` method of the `UserService` class with the `Transactional` attribute: + +```php + $name]); + } +} +``` + +Finally, you should run the `dump-autoload` Composer command to compile the AOP classes: + +```bash +composer dump-autoload +``` + +> [!IMPORTANT] +> After changing the `intercept` configuration option or changing the annotation of the methods, you should compile +> the AOP classes again. + +Now, the methods annotated with the attribute will be intercepted by the interceptor. +In this example, the `createUser` method of the `UserService` class will be intercepted by the +`TransactionalInterceptor` and will be executed in a transaction. + +> [!IMPORTANT] +> The methods annotated with the attribute are intercepted by the interceptor only when the class instance is +> dependency resolved from the service container. If a class instance is created directly, it is not intercepted. + +## Changelog + +Please see the [changelog](CHANGELOG.md). + +## License + +Laravel.Aop is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2e603d4 --- /dev/null +++ b/composer.json @@ -0,0 +1,94 @@ +{ + "name": "ngmy/laravel.aop", + "description": "Laravel.Aop integrates Ray.Aop with Laravel.", + "license": "MIT", + "type": "library", + "version": "0.1.0", + "keywords": [ + "laravel", + "aop", + "aspect" + ], + "authors": [ + { + "name": "Yuta Nagamiya", + "email": "y.nagamiya@gmail.com" + } + ], + "homepage": "https://github.com/ngmy/Laravel.Aop", + "require": { + "php": "^8.1", + "laravel/framework": "^9.0 || ^10.0 || ^11.0", + "olvlvl/composer-attribute-collector": "*", + "ray/aop": "*", + "symfony/finder": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8", + "ergebnis/composer-normalize": "^2.42", + "larastan/larastan": "^2.9", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^7.0 || ^8.0 || ^9.0", + "phpstan/extension-installer": "^1.3" + }, + "minimum-stability": "stable", + "prefer-stable": true, + "autoload": { + "psr-4": { + "Ngmy\\LaravelAop\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Ngmy\\LaravelAop\\Tests\\": "tests/" + }, + "files": [ + "vendor/attributes.php" + ] + }, + "config": { + "allow-plugins": { + "bamarni/composer-bin-plugin": true, + "ergebnis/composer-normalize": true, + "olvlvl/composer-attribute-collector": true, + "phpstan/extension-installer": true + }, + "preferred-install": "dist", + "sort-packages": true + }, + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": true, + "target-directory": "vendor-bin" + }, + "composer-attribute-collector": { + "include": [ + "tests/Feature/stubs" + ] + }, + "laravel": { + "providers": [ + "Ngmy\\LaravelAop\\ServiceProvider" + ] + } + }, + "scripts": { + "post-autoload-dump": [ + "@php vendor/bin/testbench package:discover --ansi" + ], + "fmt": [ + "@php vendor-bin/php-cs-fixer/vendor/bin/php-cs-fixer fix --allow-risky=yes" + ], + "lint": [ + "@php vendor/bin/phpstan analyse" + ], + "test": [ + "@php vendor/bin/phpunit --no-coverage" + ], + "test-coverage": [ + "@putenv XDEBUG_MODE=coverage", + "@php vendor/bin/phpunit" + ] + } +} diff --git a/config/aop.php b/config/aop.php new file mode 100644 index 0000000..d26a19e --- /dev/null +++ b/config/aop.php @@ -0,0 +1,36 @@ + env('AOP_COMPILED_PATH', storage_path('aop')), + + /* + |-------------------------------------------------------------------------- + | Intercept Mapppings + |-------------------------------------------------------------------------- + | + | This option is the attribute to the inetceptor mappings. + | + | Example: + | [ + | App\Attributes\Transactional::class => [ + | App\Interceptors\TransactionalInterceptor::class, + | ], + | ] + | + */ + + 'intercept' => [ + ], +]; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b42997e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: "3.7" + +services: + app: + build: + context: ./docker/php + args: + UID: ${APP_UID} + GID: ${APP_GID} + tty: true + volumes: + - .:/var/www + working_dir: /var/www diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 0000000..d2d29d2 --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,17 @@ +FROM php:8.1 + +ARG USERNAME=app +ARG GROUPNAME=app +ARG UID=1000 +ARG GID=1000 +RUN groupadd -g $GID $GROUPNAME \ + && useradd -m -u $UID -g $GID $USERNAME + +# Add to download Composer packages from dist. +RUN apt-get update && apt-get install -y unzip + +# Add to measure code coverage. +RUN pecl install xdebug \ + && docker-php-ext-enable xdebug + +COPY --from=composer /usr/bin/composer /usr/bin/composer diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..73e7e87 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,14 @@ +parameters: + level: max + paths: + - src + - config + - tests + - .php-cs-fixer.dist.php + excludePaths: + - vendor + - vendor-bin + bootstrapFiles: + - vendor-bin/php-cs-fixer/vendor/autoload.php + +# vim: set ft=yaml: diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..f909be7 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + ./tests + + + + + ./src + + + + + + + + + + + + + diff --git a/src/Collections/AspectMap.php b/src/Collections/AspectMap.php new file mode 100644 index 0000000..4a1059a --- /dev/null +++ b/src/Collections/AspectMap.php @@ -0,0 +1,13 @@ + + */ +final class AspectMap extends Collection {} diff --git a/src/Collections/InterceptMap.php b/src/Collections/InterceptMap.php new file mode 100644 index 0000000..cf424ab --- /dev/null +++ b/src/Collections/InterceptMap.php @@ -0,0 +1,13 @@ +[]> + */ +final class InterceptMap extends Collection {} diff --git a/src/Collections/SourceMap.php b/src/Collections/SourceMap.php new file mode 100644 index 0000000..d5fc7a6 --- /dev/null +++ b/src/Collections/SourceMap.php @@ -0,0 +1,13 @@ + + */ +final class SourceMap extends Collection {} diff --git a/src/Commands/CompileCommand.php b/src/Commands/CompileCommand.php new file mode 100644 index 0000000..861a347 --- /dev/null +++ b/src/Commands/CompileCommand.php @@ -0,0 +1,39 @@ +compile(); + + return Command::SUCCESS; + } +} diff --git a/src/Factories/AspectMapFactory.php b/src/Factories/AspectMapFactory.php new file mode 100644 index 0000000..0b6d40f --- /dev/null +++ b/src/Factories/AspectMapFactory.php @@ -0,0 +1,65 @@ +map(static fn (array $interceptorClassNames, string $attributeClassName): Pointcut => new Pointcut( + (new Matcher())->any(), + (new Matcher())->annotatedWith($attributeClassName), + array_map(static function (string $interceptorClassName): object { + /** @var MethodInterceptor $interceptor */ + $interceptor = App::make($interceptorClassName); + + return $interceptor; + }, $interceptorClassNames), + )) + ; + + $aspectMap = AspectMap::empty(); + + $targetClassNames = $intercept + ->reduce(static function (Collection $carry, array $_, string $attributeClassName): Collection { + $predicate = Attributes::predicateForAttributeInstanceOf($attributeClassName); + $targets = Attributes::filterTargetMethods($predicate); + + /** @var Collection> $carry */ + $carry = $carry->merge($targets); + + return $carry; + }, collect()) + ->reduce(static function (Collection $carry, TargetMethod $method): Collection { + /** @var Collection $carry */ + $carry = $carry->put($method->class, true); + + return $carry; + }, collect()) + ; + + foreach ($targetClassNames as $targetClassName => $_) { + $aspectMap->put($targetClassName, $pointcuts->all()); + } + + return $aspectMap; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php new file mode 100644 index 0000000..c22dde8 --- /dev/null +++ b/src/ServiceProvider.php @@ -0,0 +1,45 @@ +mergeConfigFrom(__DIR__.'/../config/aop.php', 'aop'); + + $this->app->when(CompiledPath::class) + ->needs('$filename') + ->giveConfig('aop.compiled') + ; + + $this->app->when(InterceptMap::class) + ->needs('$items') + ->giveConfig('aop.intercept') + ; + + $registrar = $this->app->make(ServiceRegistrar::class); + $registrar->bind(); + } + + public function boot(): void + { + $this->publishes([ + __DIR__.'/../config/aop.php' => config_path('aop.php'), + ]); + + if ($this->app->runningInConsole()) { + $this->commands([ + CompileCommand::class, + ]); + } + } +} diff --git a/src/Services/ClassLoader.php b/src/Services/ClassLoader.php new file mode 100644 index 0000000..8137cb6 --- /dev/null +++ b/src/Services/ClassLoader.php @@ -0,0 +1,39 @@ +compiledPath->getPathname().'/'.str_replace('\\', '_', $className).'.php'; + + if (file_exists($classPath)) { + require $classPath; + + return true; + } + + return false; + } +} diff --git a/src/Services/Compiler.php b/src/Services/Compiler.php new file mode 100644 index 0000000..84cc0ff --- /dev/null +++ b/src/Services/Compiler.php @@ -0,0 +1,83 @@ +compiledPath->getPathname())) { + File::makeDirectory($this->compiledPath->getPathname(), 0o755, true, true); + File::put($this->compiledPath->getPathname().'/.gitignore', "*\n!.gitignore\n"); + } + + $this->compiler = new RayCompiler($this->compiledPath->getPathname()); + } + + /** + * Compile AOP classes. + */ + public function compile(): void + { + $finder = new Finder(); + $finder->files()->in($this->compiledPath->getPathname()); + + foreach ($finder as $file) { + File::delete($file->getPathname()); + } + + $sourceMap = SourceMap::empty(); + $aspectMap = $this->aspectMapFactory->fromInterceptMap($this->interceptMap); + + foreach ($aspectMap as $targetClassName => $pointcuts) { + $bind = (new Bind())->bind($targetClassName, $pointcuts); + + /** @var class-string $compiledClassName */ + $compiledClassName = $this->compiler->compile($targetClassName, $bind); + + $sourceMap->put($targetClassName, new CompiledClass($compiledClassName, $bind->getBindings())); + } + + $this->sourceMapFileManager->put($this->sourceMapFile, $sourceMap); + + // Change the permission of the classes by the Ray compiler to 644 so that it can be read from the web server, + // because the Ray compiler creates the classes with the permission of 600 + $finder->files()->name('*.php'); + + foreach ($finder as $file) { + File::chmod($file->getPathname(), 0o644); + } + } +} diff --git a/src/Services/ServiceRegistrar.php b/src/Services/ServiceRegistrar.php new file mode 100644 index 0000000..407c867 --- /dev/null +++ b/src/Services/ServiceRegistrar.php @@ -0,0 +1,66 @@ +getSourceMap(); + + foreach ($sourceMap as $targetClassName => $compiledClass) { + App::bind($targetClassName, function (Application $app, array $params) use ($compiledClass): object { + $compiledClassName = $compiledClass->getClassName(); + + if (!class_exists($compiledClassName, false)) { + $result = $this->classLoader->loadClass($compiledClassName); + if (false === $result) { + throw new \RuntimeException("Failed to load class: {$compiledClassName}"); + } + } + + $instance = $app->make($compiledClassName, $params); + $instance->bindings = $compiledClass->getBindings(); + + return $instance; + }); + } + } + + /** + * Get the source map. + * + * @return SourceMap The source map + */ + private function getSourceMap(): SourceMap + { + if (!$this->sourceMapFile->isReadable()) { + return SourceMap::empty(); + } + + return $this->sourceMapFileManager->get($this->sourceMapFile); + } +} diff --git a/src/Services/SourceMapFileManager.php b/src/Services/SourceMapFileManager.php new file mode 100644 index 0000000..aa79258 --- /dev/null +++ b/src/Services/SourceMapFileManager.php @@ -0,0 +1,49 @@ +getPathname()); + + if (false === $contents) { + throw new \RuntimeException("Failed to read the source map file: {$sourceMapFile->getPathname()}"); + } + + /** @var SourceMap $sourceMap */ + $sourceMap = unserialize($contents); + + return $sourceMap; + } + + /** + * Write the source map to the source map file. + * + * @param SourceMapFile $sourceMapFile The source map file + * @param SourceMap $sourceMap The source map + */ + public function put(SourceMapFile $sourceMapFile, SourceMap $sourceMap): void + { + $contents = serialize($sourceMap); + + $result = file_put_contents($sourceMapFile->getPathname(), $contents); + + if (false === $result) { + throw new \RuntimeException("Failed to write the source map file: {$sourceMapFile->getPathname()}"); + } + } +} diff --git a/src/ValueObjects/CompiledClass.php b/src/ValueObjects/CompiledClass.php new file mode 100644 index 0000000..9141e32 --- /dev/null +++ b/src/ValueObjects/CompiledClass.php @@ -0,0 +1,41 @@ + $bindings The bindings + */ + public function __construct( + private readonly string $className, + private readonly array $bindings, + ) {} + + /** + * Get the class name. + * + * @return class-string The class name + */ + public function getClassName(): string + { + return $this->className; + } + + /** + * Get the bindings. + * + * @return array The bindings + */ + public function getBindings(): array + { + return $this->bindings; + } +} diff --git a/src/ValueObjects/CompiledPath.php b/src/ValueObjects/CompiledPath.php new file mode 100644 index 0000000..a95091b --- /dev/null +++ b/src/ValueObjects/CompiledPath.php @@ -0,0 +1,7 @@ +getPathname().'/'.self::SOURCE_MAP_FILENAME); + } +} diff --git a/tests/Feature/AopTest.php b/tests/Feature/AopTest.php new file mode 100644 index 0000000..0c85488 --- /dev/null +++ b/tests/Feature/AopTest.php @@ -0,0 +1,225 @@ +compiledPath = $compiledPath; + } + + protected function tearDown(): void + { + File::deleteDirectory($this->compiledPath); + + parent::tearDown(); + } + + /** + * @return iterable The weaving cases + */ + public static function provideWeavingCases(): iterable + { + return [ + [ + TestTarget1::class, + 'method1', + [], + ], + [ + TestTarget1::class, + 'method2', + [ + sprintf('Start %s', TestInterceptor1::class), + sprintf('End %s', TestInterceptor1::class), + ], + ], + [ + TestTarget1::class, + 'method3', + [ + sprintf('Start %s', TestInterceptor2::class), + sprintf('End %s', TestInterceptor2::class), + ], + ], + [ + TestTarget1::class, + 'method4', + [ + sprintf('Start %s', TestInterceptor1::class), + sprintf('Start %s', TestInterceptor2::class), + sprintf('End %s', TestInterceptor2::class), + sprintf('End %s', TestInterceptor1::class), + ], + ], + [ + TestTarget1::class, + 'method5', + [ + sprintf('Start %s', TestInterceptor2::class), + sprintf('Start %s', TestInterceptor1::class), + sprintf('End %s', TestInterceptor1::class), + sprintf('End %s', TestInterceptor2::class), + ], + ], + [ + TestTarget1::class, + 'method6', + [ + sprintf('Start %s', TestInterceptor1::class), + sprintf('Start %s', TestInterceptor2::class), + sprintf('End %s', TestInterceptor2::class), + sprintf('End %s', TestInterceptor1::class), + ], + ], + [ + TestTarget1::class, + 'method7', + [ + sprintf('Start %s', TestInterceptor2::class), + sprintf('Start %s', TestInterceptor1::class), + sprintf('End %s', TestInterceptor1::class), + sprintf('End %s', TestInterceptor2::class), + ], + ], + ]; + } + + /** + * @dataProvider provideWeavingCases + * + * @param class-string $targetClassName The class name of the target + * @param string $targetMethodName The method name of the target + * @param string[] $expectedLogs The expected logs + */ + public function testWeaving( + string $targetClassName, + string $targetMethodName, + array $expectedLogs, + ): void { + // Compile the AOP classes + /** @var PendingCommand $command */ + $command = $this->artisan('aop:compile'); + $command->run(); + $command->assertSuccessful(); + + // Bind the services to the container + $serviceRegistrar = $this->app->make(ServiceRegistrar::class); + $serviceRegistrar->bind(); + + // Create a spy logger + // NOTE: Create a spy logger because Log::spy() cannot check the order of logs + $spyLogger = $this->createSpyLogger(); + Log::swap($spyLogger); + + // Call the target method + $target = $this->app->make($targetClassName); + $target->{$targetMethodName}(); + + // Check that the logs are output as expected in terms of the number, order, and content + self::assertCount(\count($expectedLogs), $spyLogger->logCalls); + + foreach ($expectedLogs as $i => $expectedLog) { + self::assertSame($expectedLog, $spyLogger->logCalls[$i]['arguments'][0]); + } + } + + protected function resolveApplicationConfiguration($app): void + { + parent::resolveApplicationConfiguration($app); + + $app['config']->set('aop.intercept', [ + TestAttribute1::class => [ + TestInterceptor1::class, + ], + TestAttribute2::class => [ + TestInterceptor2::class, + ], + TestAttribute3::class => [ + TestInterceptor1::class, + TestInterceptor2::class, + ], + TestAttribute4::class => [ + TestInterceptor2::class, + TestInterceptor1::class, + ], + ]); + } + + /** + * @return object{logCalls: array|Jsonable|mixed[]|string|Stringable, 1: mixed[]}, timestamp: float}>} The spy logger + */ + private function createSpyLogger(): object + { + $spyLogger = new class() { + /** + * The log calls. + * + * @var array|Jsonable|mixed[]|string|Stringable, 1: mixed[]}, timestamp: float}> + */ + public array $logCalls = []; + + /** + * @param Arrayable|Jsonable|mixed[]|string|Stringable $message The message + * @param mixed[] $context The context + */ + public function info($message, array $context = []): void + { + $this->logCalls[] = [ + 'method' => 'info', + 'arguments' => [$message, $context], + 'timestamp' => microtime(true), + ]; + } + }; + + return $spyLogger; + } +} diff --git a/tests/Feature/stubs/Attributes/TestAttribute1.php b/tests/Feature/stubs/Attributes/TestAttribute1.php new file mode 100644 index 0000000..a2c30bc --- /dev/null +++ b/tests/Feature/stubs/Attributes/TestAttribute1.php @@ -0,0 +1,8 @@ +proceed(); + + Log::info(sprintf('End %s', __CLASS__)); + + return $result; + } +} diff --git a/tests/Feature/stubs/Interceptors/TestInterceptor2.php b/tests/Feature/stubs/Interceptors/TestInterceptor2.php new file mode 100644 index 0000000..6777ce8 --- /dev/null +++ b/tests/Feature/stubs/Interceptors/TestInterceptor2.php @@ -0,0 +1,23 @@ +proceed(); + + Log::info(sprintf('End %s', __CLASS__)); + + return $result; + } +} diff --git a/tests/Feature/stubs/Targets/TestTarget1.php b/tests/Feature/stubs/Targets/TestTarget1.php new file mode 100644 index 0000000..48ff7b3 --- /dev/null +++ b/tests/Feature/stubs/Targets/TestTarget1.php @@ -0,0 +1,35 @@ +