├── .github ├── FUNDING.yml └── workflows │ ├── continuous-integration.yml │ └── release-on-milestone-closed.yml ├── .gitignore ├── renovate.json ├── infection.json.dist ├── phpunit.xml.dist ├── psalm.xml ├── src └── LazyProperty │ ├── Exception │ ├── Exception.php │ ├── InvalidLazyProperty.php │ ├── MissingLazyPropertyGetter.php │ └── InvalidAccess.php │ ├── Util │ └── AccessScopeChecker.php │ └── LazyPropertiesTrait.php ├── tests ├── LazyPropertyTestAsset │ ├── InheritedPropertiesClass.php │ ├── AClass.php │ ├── BClass.php │ ├── LazyGetterClass.php │ ├── ParentClass.php │ └── MixedPropertiesClass.php └── LazyPropertyTest │ ├── Exception │ ├── InvalidLazyPropertyTest.php │ ├── InvalidAccessTest.php │ └── MissingLazyPropertyGetterTest.php │ ├── Util │ └── AccessScopeCheckerTest.php │ └── LazyPropertiesTraitTest.php ├── phpcs.xml.dist ├── LICENSE ├── CONTRIBUTING.md ├── composer.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Ocramius] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | phpunit.xml 3 | .phpunit.result.cache 4 | phpmd.xml 5 | phpdox.xml 6 | infection-log.txt 7 | infection.json 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>Ocramius/.github:renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src" 5 | ] 6 | }, 7 | "logs": { 8 | "text": "php://stderr", 9 | "stryker": { 10 | "report": "/^\\d\\.\\d\\.x$/" 11 | } 12 | }, 13 | "minMsi": 84, 14 | "minCoveredMsi": 84 15 | } 16 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | ./tests/LazyPropertyTest 9 | 10 | 11 | 12 | ./src 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/LazyProperty/Exception/Exception.php: -------------------------------------------------------------------------------- 1 | initLazyProperties($properties); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/LazyPropertyTestAsset/AClass.php: -------------------------------------------------------------------------------- 1 | initLazyProperties(['private']); 23 | } 24 | 25 | private function getPrivate(): string 26 | { 27 | return $this->private ?: $this->private = self::class; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/LazyPropertyTestAsset/BClass.php: -------------------------------------------------------------------------------- 1 | initLazyProperties(['private']); 23 | } 24 | 25 | private function getPrivate(): string 26 | { 27 | return $this->private ?: $this->private = self::class; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 4 | This project follows doctrine/coding-standard 5 | 6 | src 7 | tests 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/LazyProperty/Exception/InvalidLazyProperty.php: -------------------------------------------------------------------------------- 1 | assertStringMatchesFormat( 21 | 'The requested lazy property "foo" is not defined in "stdClass#%s"', 22 | InvalidLazyProperty::nonExistingLazyProperty(new stdClass(), 'foo')->getMessage(), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/LazyProperty/Exception/MissingLazyPropertyGetter.php: -------------------------------------------------------------------------------- 1 | assertStringMatchesFormat( 21 | 'The requested lazy property "foo" of "stdClass#%s" is not accessible from the context of in "' 22 | . self::class . '"', 23 | InvalidAccess::invalidContext($this, new stdClass(), 'foo')->getMessage(), 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/LazyPropertyTestAsset/LazyGetterClass.php: -------------------------------------------------------------------------------- 1 | initLazyProperties($properties); 24 | } 25 | 26 | public function getProperty(): string 27 | { 28 | if ($this->property === null) { 29 | return $this->property = 'property'; 30 | } 31 | 32 | return $this->property; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/LazyPropertyTest/Exception/MissingLazyPropertyGetterTest.php: -------------------------------------------------------------------------------- 1 | assertStringMatchesFormat( 21 | 'The getter "getFoo" for lazy property "foo" is not defined in "stdClass#%s"', 22 | MissingLazyPropertyGetter::fromGetter(new stdClass(), 'getFoo', 'foo')->getMessage(), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/LazyProperty/Exception/InvalidAccess.php: -------------------------------------------------------------------------------- 1 | $propertyName; 22 | } 23 | 24 | private function getPrivate1(): string 25 | { 26 | return $this->private1 = 'private1'; 27 | } 28 | 29 | private function getPrivate2(): string 30 | { 31 | return $this->private2 = 'private'; 32 | } 33 | 34 | protected function getProtected1(): string 35 | { 36 | return $this->protected1 = 'protected1'; 37 | } 38 | 39 | protected function getProtected2(): string 40 | { 41 | return $this->protected2 = 'protected2'; 42 | } 43 | 44 | public function getPublic1(): string 45 | { 46 | return $this->public1 = 'public1'; 47 | } 48 | 49 | public function getPublic2(): string 50 | { 51 | return $this->public2 = 'public2'; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | * The project will follow strict [object calisthenics](http://www.slideshare.net/guilhermeblanco/object-calisthenics-applied-to-php) 4 | * Any contribution must provide tests for additional introduced conditions 5 | * Any un-confirmed issue needs a failing test case before being accepted 6 | * Pull requests must be sent from a new hotfix/feature branch, not from `master`. 7 | 8 | ## Installation 9 | 10 | To install the project and run the tests, you need to clone it first: 11 | 12 | ```sh 13 | $ git clone git://github.com/Ocramius/LazyProperty.git 14 | ``` 15 | 16 | You will then need to run a composer installation: 17 | 18 | ```sh 19 | $ cd LazyProperty 20 | $ curl -s https://getcomposer.org/installer | php 21 | $ php composer.phar update 22 | ``` 23 | 24 | ## Testing 25 | 26 | The PHPUnit version to be used is the one installed as a dev- dependency via composer: 27 | 28 | ```sh 29 | $ ./vendor/bin/phpunit 30 | ``` 31 | 32 | Accepted coverage for new contributions is 80%. Any contribution not satisfying this requirement 33 | won't be merged. 34 | 35 | ## Code Style 36 | 37 | The project follows the [Doctrine Coding Standard](https://github.com/doctrine/coding-standard). A configuration for php_codesniffer is shipped. 38 | Please execute the checker command and make sure you meet the requirements: 39 | 40 | ```sh 41 | $ ./vendor/bin/phpcs 42 | ``` 43 | 44 | On Mac, you might need to add `--parallel=1` due to a known [bug](https://github.com/squizlabs/PHP_CodeSniffer/issues/2304). 45 | 46 | Disabling specific rules for parts of files where it's clearly a false positive is acceptable in some cases. 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ocramius/lazy-property", 3 | "description": "A library that provides lazy instantiation logic for object properties", 4 | "type": "library", 5 | "license": "MIT", 6 | "homepage": "https://github.com/Ocramius/LazyProperty", 7 | "minimum-stability": "stable", 8 | "keywords": [ 9 | "lazy", 10 | "lazy loading", 11 | "lazy instantiation", 12 | "utility" 13 | ], 14 | "authors": [ 15 | { 16 | "name": "Marco Pivetta", 17 | "email": "ocramius@gmail.com", 18 | "homepage": "http://ocramius.github.io/" 19 | }, 20 | { 21 | "name": "Niklas Schöllhorn", 22 | "email": "schoellhorn.niklas@gmail.com", 23 | "role": "Maintainer" 24 | } 25 | ], 26 | "require": { 27 | "php": "~8.1.0 || ~8.2.0" 28 | }, 29 | "require-dev": { 30 | "phpunit/phpunit": "^9.6.10", 31 | "doctrine/coding-standard": "^10.0.0", 32 | "vimeo/psalm": "^5.13.1", 33 | "roave/infection-static-analysis-plugin": "^1.32.0" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "LazyProperty\\": "src/LazyProperty" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "LazyPropertyTestAsset\\": "tests/LazyPropertyTestAsset", 43 | "LazyPropertyTest\\": "tests/LazyPropertyTest" 44 | } 45 | }, 46 | "config": { 47 | "allow-plugins": { 48 | "dealerdirect/phpcodesniffer-composer-installer": true, 49 | "infection/extension-installer": false 50 | }, 51 | "platform": { 52 | "php": "8.1.99" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/LazyPropertyTestAsset/MixedPropertiesClass.php: -------------------------------------------------------------------------------- 1 | $propertyName; 28 | } 29 | 30 | /** @param string[] $properties */ 31 | public function initProperties(array $properties): void 32 | { 33 | $this->initLazyProperties($properties); 34 | } 35 | 36 | private function getPrivate1(): string 37 | { 38 | return $this->private1 = 'private1'; 39 | } 40 | 41 | private function getPrivate2(): string 42 | { 43 | return $this->private2 = 'private2'; 44 | } 45 | 46 | protected function getProtected1(): string 47 | { 48 | return $this->protected1 = 'protected1'; 49 | } 50 | 51 | protected function getProtected2(): string 52 | { 53 | return $this->protected2 = 'protected2'; 54 | } 55 | 56 | public function getPublic1(): string 57 | { 58 | return $this->public1 = 'public1'; 59 | } 60 | 61 | public function getPublic2(): string 62 | { 63 | return $this->public2 = 'public2'; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/LazyProperty/Util/AccessScopeChecker.php: -------------------------------------------------------------------------------- 1 | isPublic()) { 34 | if (! isset($caller['object'])) { 35 | throw InvalidAccess::invalidContext(null, $instance, $property); 36 | } 37 | 38 | $caller = $caller['object']; 39 | $callerClass = $caller::class; 40 | $instanceClass = $instance::class; 41 | 42 | if ( 43 | $callerClass === $instanceClass 44 | || ($reflectionProperty->isProtected() && is_subclass_of($callerClass, $instanceClass)) 45 | || $callerClass === ReflectionProperty::class 46 | || is_subclass_of($callerClass, ReflectionProperty::class) 47 | ) { 48 | return; 49 | } 50 | 51 | throw InvalidAccess::invalidContext($caller, $instance, $property); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/LazyProperty/LazyPropertiesTrait.php: -------------------------------------------------------------------------------- 1 | indexed by property name */ 23 | private array $lazyPropertyAccessors = []; 24 | 25 | /** 26 | * Initializes lazy properties so that first access causes their initialization via a getter 27 | * 28 | * @param string[] $lazyPropertyNames 29 | * 30 | * @throws MissingLazyPropertyGetter 31 | */ 32 | private function initLazyProperties(array $lazyPropertyNames, bool $checkLazyGetters = true): void 33 | { 34 | foreach ($lazyPropertyNames as $lazyProperty) { 35 | if ($checkLazyGetters && ! method_exists($this, 'get' . $lazyProperty)) { 36 | throw MissingLazyPropertyGetter::fromGetter($this, 'get' . $lazyProperty, $lazyProperty); 37 | } 38 | 39 | $this->lazyPropertyAccessors[$lazyProperty] = false; 40 | 41 | if (isset($this->$lazyProperty)) { 42 | continue; 43 | } 44 | 45 | unset($this->$lazyProperty); 46 | } 47 | } 48 | 49 | /** 50 | * Magic getter - initializes and gets a property 51 | * 52 | * @throws ReflectionException 53 | */ 54 | public function & __get(string $name): mixed 55 | { 56 | if (! isset($this->lazyPropertyAccessors[$name])) { 57 | throw InvalidLazyProperty::nonExistingLazyProperty($this, $name); 58 | } 59 | 60 | $caller = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT)[1]; 61 | 62 | // small optimization to avoid initializing reflection out of context 63 | if (! isset($caller['object']) || $caller['object'] !== $this) { 64 | AccessScopeChecker::checkCallerScope($caller, $this, $name); 65 | } 66 | 67 | $this->$name = null; 68 | $this->$name = $this->{'get' . $name}(); 69 | 70 | return $this->$name; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /.github/workflows/release-on-milestone-closed.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/en/categories/automating-your-workflow-with-github-actions 2 | 3 | name: "Automatic Releases" 4 | 5 | on: 6 | milestone: 7 | types: 8 | - "closed" 9 | 10 | jobs: 11 | release: 12 | name: "GIT tag, release & create merge-up PR" 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: "Checkout" 17 | uses: "actions/checkout@v3" 18 | 19 | - name: "Release" 20 | uses: "laminas/automatic-releases@v1" 21 | with: 22 | command-name: "laminas:automatic-releases:release" 23 | env: 24 | "GITHUB_TOKEN": ${{ secrets.GITHUB_TOKEN }} 25 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 26 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 27 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 28 | 29 | - name: "Create Merge-Up Pull Request" 30 | uses: "laminas/automatic-releases@v1" 31 | with: 32 | command-name: "laminas:automatic-releases:create-merge-up-pull-request" 33 | env: 34 | "GITHUB_TOKEN": ${{ secrets.GITHUB_TOKEN }} 35 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 36 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 37 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 38 | 39 | - name: "Create and/or Switch to new Release Branch" 40 | uses: "laminas/automatic-releases@v1" 41 | with: 42 | command-name: "laminas:automatic-releases:switch-default-branch-to-next-minor" 43 | env: 44 | "GITHUB_TOKEN": ${{ secrets.ORGANIZATION_ADMIN_TOKEN }} 45 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 46 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 47 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 48 | 49 | - name: "Bump Changelog Version On Originating Release Branch" 50 | uses: "laminas/automatic-releases@v1" 51 | with: 52 | command-name: "laminas:automatic-releases:bump-changelog" 53 | env: 54 | "GITHUB_TOKEN": ${{ secrets.GITHUB_TOKEN }} 55 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 56 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 57 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 58 | 59 | - name: "Create new milestones" 60 | uses: "laminas/automatic-releases@v1" 61 | with: 62 | command-name: "laminas:automatic-releases:create-milestones" 63 | env: 64 | "GITHUB_TOKEN": ${{ secrets.GITHUB_TOKEN }} 65 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 66 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 67 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 68 | -------------------------------------------------------------------------------- /tests/LazyPropertyTest/Util/AccessScopeCheckerTest.php: -------------------------------------------------------------------------------- 1 | $this], $this, 'backupGlobals'); 24 | 25 | // Add to assertion count manually since we were successful when no exception was thrown and we got here. 26 | $this->addToAssertionCount(1); 27 | } 28 | 29 | public function testAllowsAccessToPublicProperties(): void 30 | { 31 | AccessScopeChecker::checkCallerScope(['object' => $this], new ParentClass(), 'public1'); 32 | 33 | // Add to assertion count manually since we were successful when no exception was thrown and we got here. 34 | $this->addToAssertionCount(1); 35 | } 36 | 37 | public function testAllowsAccessFromSubClass(): void 38 | { 39 | AccessScopeChecker::checkCallerScope( 40 | ['object' => new InheritedPropertiesClass()], 41 | new ParentClass(), 42 | 'protected1', 43 | ); 44 | 45 | // Add to assertion count manually since we were successful when no exception was thrown and we got here. 46 | $this->addToAssertionCount(1); 47 | } 48 | 49 | public function testAllowsAccessFromSameClass(): void 50 | { 51 | AccessScopeChecker::checkCallerScope( 52 | ['object' => new ParentClass()], 53 | new ParentClass(), 54 | 'private1', 55 | ); 56 | 57 | // Add to assertion count manually since we were successful when no exception was thrown and we got here. 58 | $this->addToAssertionCount(1); 59 | } 60 | 61 | public function testAllowsAccessFromReflectionProperty(): void 62 | { 63 | AccessScopeChecker::checkCallerScope( 64 | ['object' => new ReflectionProperty(new ParentClass(), 'private1')], 65 | new ParentClass(), 66 | 'private1', 67 | ); 68 | 69 | // Add to assertion count manually since we were successful when no exception was thrown and we got here. 70 | $this->addToAssertionCount(1); 71 | } 72 | 73 | public function testDisallowsAccessFromGlobalOrFunctionScope(): void 74 | { 75 | $this->expectException(InvalidAccess::class); 76 | AccessScopeChecker::checkCallerScope( 77 | [], 78 | new ParentClass(), 79 | 'private1', 80 | ); 81 | } 82 | 83 | public function testDisallowsPrivateAccessFromDifferentScope(): void 84 | { 85 | $this->expectException(InvalidAccess::class); 86 | AccessScopeChecker::checkCallerScope( 87 | ['object' => $this], 88 | new ParentClass(), 89 | 'private1', 90 | ); 91 | } 92 | 93 | public function testDisallowsProtectedAccessFromDifferentScope(): void 94 | { 95 | $this->expectException(InvalidAccess::class); 96 | AccessScopeChecker::checkCallerScope( 97 | ['object' => $this], 98 | new ParentClass(), 99 | 'private1', 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lazy Property 2 | 3 | This small library aims at providing a very simple and efficient loading of lazy properties 4 | 5 | [![Latest Stable Version](https://poser.pugx.org/ocramius/lazy-property/v/stable.png)](https://packagist.org/packages/ocramius/lazy-property) 6 | [![Latest Unstable Version](https://poser.pugx.org/ocramius/lazy-property/v/unstable.png)](https://packagist.org/packages/ocramius/lazy-property) 7 | 8 | ## Abandoned 9 | 10 | Starting with PHP 8.3, dynamic properties are [no longer allowed "out of the box"](https://wiki.php.net/rfc/deprecate_dynamic_properties). 11 | While it is still possible to have dynamic properties via explicit declaration (the `#[\AllowDynamicProperties]` attribute), the approach 12 | of this package is no longer to be considered safe nor efficient long-term. 13 | 14 | Based on that, this package is deprecated and abandoned: please use traditional PHP `array`s instead. 15 | 16 | ## Installation 17 | 18 | The suggested installation method is via [composer](https://getcomposer.org/): 19 | 20 | ```sh 21 | composer require ocramius/lazy-property 22 | ``` 23 | 24 | ## Use case 25 | 26 | In many cases where lazy-initialization of private/protected properties is necessary, 27 | many people write classes that look like following: 28 | 29 | ```php 30 | class SomeService 31 | { 32 | protected $dependency; 33 | 34 | public function doWork() 35 | { 36 | $this->getDependency()->delegateWork(); 37 | } 38 | 39 | protected function getDependency() 40 | { 41 | return $this->dependency ?: $this->dependency = get_dependency_somehow(); 42 | } 43 | } 44 | ``` 45 | 46 | This is problematic because implementors and people subclassing `SomeService` will eventually 47 | write: 48 | 49 | ```php 50 | class SomethingElse extends SomeService 51 | { 52 | public function doOtherWork() 53 | { 54 | $this->dependency->doMoreWork(); 55 | } 56 | } 57 | ``` 58 | 59 | This can work only if `SomeService#getDependency()` was called at least once upfront (which 60 | may well be under certain circumstances), and therefore is a cause of bugs/headaches/suicides/etc. 61 | 62 | In order to avoid this problem, the implementor of `SomeService` that is also exposing 63 | its protected `$dependency` property may just use `LazyProperty\LazyPropertiesTrait` to fix the problem: 64 | 65 | 66 | ```php 67 | #[\AllowDynamicProperties] 68 | class SomeService 69 | { 70 | use \LazyProperty\LazyPropertiesTrait; 71 | 72 | protected MyDependency $dependency; 73 | 74 | public function __construct() 75 | { 76 | $this->initLazyProperties(['dependency']); 77 | } 78 | 79 | public function doWork() 80 | { 81 | // look ma! no getter! 82 | $this->dependency->delegateWork(); 83 | } 84 | 85 | protected function getDependency() 86 | { 87 | return $this->dependency ?: $this->dependency = get_dependency_somehow(); 88 | } 89 | } 90 | ``` 91 | 92 | With this, any access to `SomeService#$dependency` will cause a call to 93 | `SomeService#getDependency()` if the property was not already initialized. 94 | 95 | 96 | ```php 97 | class SomethingElse extends SomeService 98 | { 99 | public function doOtherWork() 100 | { 101 | // always works 102 | $this->dependency->doMoreWork(); 103 | } 104 | } 105 | ``` 106 | 107 | Please note that a getter is *required* in order for the property to be lazy. 108 | 109 | ## Performance notes 110 | 111 | Using `LazyProperty\LazyPropertiesTrait` allows to speed up applications where a massive 112 | amount of getter calls is going on in private/protected scope. 113 | There is some minor overhead in calling `SomeService#initLazyProperties()`, as well as in 114 | the first property access, but it should be negligible. 115 | -------------------------------------------------------------------------------- /tests/LazyPropertyTest/LazyPropertiesTraitTest.php: -------------------------------------------------------------------------------- 1 | instance = new MixedPropertiesClass(); 32 | } 33 | 34 | public function testMixedLazyPropertiesAreLazilyInitialized(): void 35 | { 36 | $instance = new MixedPropertiesClass(); 37 | 38 | $this->assertNull($this->getProperty($instance, 'public1')); 39 | $this->assertNull($this->getProperty($instance, 'public2')); 40 | $this->assertNull($this->getProperty($instance, 'protected1')); 41 | $this->assertNull($this->getProperty($instance, 'protected2')); 42 | $this->assertNull($this->getProperty($instance, 'private1')); 43 | $this->assertNull($this->getProperty($instance, 'private2')); 44 | 45 | $instance->initProperties(['public1', 'protected1', 'private1']); 46 | 47 | $this->assertSame('public1', $this->getProperty($instance, 'public1')); 48 | $this->assertNull($this->getProperty($instance, 'public2')); 49 | $this->assertSame('protected1', $this->getProperty($instance, 'protected1')); 50 | $this->assertNull($this->getProperty($instance, 'protected2')); 51 | $this->assertSame('private1', $this->getProperty($instance, 'private1')); 52 | $this->assertNull($this->getProperty($instance, 'private2')); 53 | } 54 | 55 | public function testAllMixedLazyPropertiesAreLazilyInitialized(): void 56 | { 57 | $instance = new MixedPropertiesClass(); 58 | 59 | $this->assertNull($this->getProperty($instance, 'public1')); 60 | $this->assertNull($this->getProperty($instance, 'public2')); 61 | $this->assertNull($this->getProperty($instance, 'protected1')); 62 | $this->assertNull($this->getProperty($instance, 'protected2')); 63 | $this->assertNull($this->getProperty($instance, 'private1')); 64 | $this->assertNull($this->getProperty($instance, 'private2')); 65 | 66 | $instance->initProperties(['public1', 'public2', 'protected1', 'protected2', 'private1', 'private2']); 67 | 68 | $this->assertSame('public1', $this->getProperty($instance, 'public1')); 69 | $this->assertSame('public2', $this->getProperty($instance, 'public2')); 70 | $this->assertSame('protected1', $this->getProperty($instance, 'protected1')); 71 | $this->assertSame('protected2', $this->getProperty($instance, 'protected2')); 72 | $this->assertSame('private1', $this->getProperty($instance, 'private1')); 73 | $this->assertSame('private2', $this->getProperty($instance, 'private2')); 74 | } 75 | 76 | public function testMixedLazyPropertiesAreLazilyInitializedWithProtectedAccess(): void 77 | { 78 | $instance = new MixedPropertiesClass(); 79 | 80 | $this->assertNull($this->getProperty($instance, 'public1')); 81 | $this->assertNull($this->getProperty($instance, 'public2')); 82 | $this->assertNull($this->getProperty($instance, 'protected1')); 83 | $this->assertNull($this->getProperty($instance, 'protected2')); 84 | $this->assertNull($this->getProperty($instance, 'private1')); 85 | $this->assertNull($this->getProperty($instance, 'private2')); 86 | 87 | $instance->initProperties(['public1', 'protected1', 'private1']); 88 | 89 | $this->assertSame('public1', $instance->getProperty('public1')); 90 | $this->assertNull($instance->getProperty('public2')); 91 | $this->assertSame('protected1', $instance->getProperty('protected1')); 92 | $this->assertNull($instance->getProperty('protected2')); 93 | $this->assertSame('private1', $instance->getProperty('private1')); 94 | $this->assertNull($instance->getProperty('private2')); 95 | } 96 | 97 | public function testMixedInheritedLazyPropertiesAreLazilyInitialized(): void 98 | { 99 | $instance = new InheritedPropertiesClass(); 100 | 101 | $this->assertNull($this->getProperty($instance, 'public1')); 102 | $this->assertNull($this->getProperty($instance, 'public2')); 103 | $this->assertNull($this->getProperty($instance, 'protected1')); 104 | $this->assertNull($this->getProperty($instance, 'protected2')); 105 | 106 | $instance->initProperties(['public1', 'protected1']); 107 | 108 | $this->assertSame('public1', $this->getProperty($instance, 'public1')); 109 | $this->assertNull($this->getProperty($instance, 'public2')); 110 | $this->assertSame('protected1', $this->getProperty($instance, 'protected1')); 111 | $this->assertNull($this->getProperty($instance, 'protected2')); 112 | } 113 | 114 | public function testThrowsExceptionOnMissingLazyGetter(): void 115 | { 116 | $instance = new MixedPropertiesClass(); 117 | 118 | $this->expectException(MissingLazyPropertyGetter::class); 119 | $this->expectExceptionMessage('The getter "getnonExisting" for lazy property "nonExisting" is not defined'); 120 | $instance->initProperties(['nonExisting']); 121 | } 122 | 123 | public function testDoesNotRaiseWarningsForNonExistingProperties(): void 124 | { 125 | $instance = new LazyGetterClass(); 126 | 127 | $instance->initProperties(['property']); 128 | 129 | $this->assertSame('property', $instance->getProperty()); 130 | } 131 | 132 | public function testDeniesAccessToNonExistingLazyProperties(): void 133 | { 134 | $this->expectException(InvalidLazyProperty::class); 135 | (new LazyGetterClass())->nonExisting; 136 | } 137 | 138 | public function testDeniesAccessToProtectedLazyProperties(): void 139 | { 140 | $instance = new MixedPropertiesClass(); 141 | 142 | $instance->initProperties(['protected1']); 143 | $this->expectException(InvalidAccess::class); 144 | $instance->protected1; 145 | } 146 | 147 | public function testDeniesAccessToPrivateLazyProperties(): void 148 | { 149 | $instance = new MixedPropertiesClass(); 150 | 151 | $instance->initProperties(['private1']); 152 | $this->expectException(InvalidAccess::class); 153 | $instance->private1; 154 | } 155 | 156 | public function testGetMultiInheritanceProperties(): void 157 | { 158 | $instanceA = new AClass(); 159 | $instanceB = new BClass(); 160 | 161 | $instanceA->initALazyProperties(); 162 | $instanceB->initBLazyProperties(); 163 | 164 | $this->assertSame(AClass::class, $this->getProperty($instanceA, 'private')); 165 | $this->assertSame(BClass::class, $this->getProperty($instanceB, 'private')); 166 | } 167 | 168 | public function testDoesNotReInitializeDefinedProperties(): void 169 | { 170 | $instance = new MixedPropertiesClass(); 171 | 172 | $instance->public1 = 'defined'; 173 | 174 | $instance->initProperties(['public1']); 175 | 176 | $this->assertSame('defined', $instance->public1); 177 | } 178 | 179 | /** @throws ReflectionException */ 180 | private function getProperty(object $instance, string $propertyName): mixed 181 | { 182 | $reflectionClass = new ReflectionClass($instance); 183 | 184 | while ($reflectionClass && ! $reflectionClass->hasProperty($propertyName)) { 185 | $reflectionClass = $reflectionClass->getParentClass(); 186 | } 187 | 188 | if (! $reflectionClass) { 189 | throw new UnexpectedValueException('Property "' . $propertyName . '" does not exist'); 190 | } 191 | 192 | $reflectionProperty = $reflectionClass->getProperty($propertyName); 193 | 194 | $reflectionProperty->setAccessible(true); 195 | 196 | return $reflectionProperty->getValue($instance); 197 | } 198 | } 199 | --------------------------------------------------------------------------------