diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..754617f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +/tests export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/phpunit.xml export-ignore +/phpcs.xml export-ignore +/PULL_REQUEST_TEMPLATE.md export-ignore +/CODE_OF_CONDUCT.md export-ignore +/CONTRIBUTING.md export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de4a392 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor +/composer.lock diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f7c093f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: php +php: + - '7.2' + - '7.3' + +before_script: + - yes '' | pecl install yaml + - composer install + +script: + - composer validate --strict + - vendor/bin/phpunit --coverage-text + - vendor/bin/phpcs src/ tests/ + +cache: + directories: + - $HOME/.composer/cache diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a213aef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 1.0.0 - 2019-11-17 +### Added +- Created the initial implementation of the ObjectFactory. + +### Changed +- Nothing + +### Deprecated +- Nothing + +### Removed +- Nothing + +### Fixed +- Nothing + +### Security +- Nothing + +[Unreleased]: https://github.com/ulrack/object-factory/compare/1.0.0...HEAD diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..040dd3e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at info@jyxon.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7921a76 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing + +To contribute to this package, please keep to these guidelines. + +- Fork the package. +- Create a branch per feature. +- Commit your changes to these branches. +- Create a pull request per feature to the master branch of the original repository. + +## Pull requests + +Pull request should follow these rules, before they can get accepted. + +- Follow the [pull request template](PULL_REQUEST_TEMPLATE.md). +- Contains a short but complete description. +- Has passed all test command listed bellow. + +## Running Tests + +``` bash +$ vendor/bin/phpunit --coverage-text +$ vendor/bin/phpcs src/ tests/ +``` + +## Notes + +Multiple commits per feature are allowed, but please provide a good description in your pull request. +This description will be used to squash your feature into the master branch. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..457440e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Jyxon + +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/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ce6ccf9 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +# (bugfix / feature / refactor / etc.): Short descriptive title + +## Description + +Fixes # + +Changes proposed in this pull request: +- +- +- + +Make sure you can check the following boxes before submitting the pull request: +- [] The proposed changes work with the dependencies from composer. +- [] The proposed changes have been thoroughly tested. +- [] The proposed changes don't alter the code in such a manner that it conflicts with the initial purpose of the package. + +Optional checkbox +- [] Backwards incompatible diff --git a/README.md b/README.md new file mode 100644 index 0000000..320e806 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +[![Build Status](https://travis-ci.com/ulrack/object-factory.svg?branch=master)](https://travis-ci.com/ulrack/object-factory) + +# Ulrack Object Factory + +The Object Factory package contains an implementation for creating objects, +based on configuration. + +## Installation + +To install the package run the following command: + +``` +composer require ulrack/object-factory +``` + +## Usage + +The package provides a [Analyser](src/Component/Analyser/ClassAnalyser.php) +class, which retrieves an instantiation signature of a class. +This analyser is used by the [ObjectFactory](src/Factory/ObjectFactory.php) to +determine the order of the provided parameter from the configuration. +The analyser expects an implementation of the StorageInterface from the +`ulrack/storage` package. +This implementation can be used to store previous analyses and retrieve them at +a later point (caching mechanisms e.g.). + +Creating an ObjectFactory can simply be done with the following snippet: +```php +create( + ObjectStorage::class, + [ + 'data' => ['foo'] + ] +); +``` + +A key-value structure is used for the parameters provided to the ObjectFactory. +If (in this case the ObjectStorage class) expects a `$data` parameter of the +type array in the `__construct` method, then the structure of the `$parameter` +parameter will be as follows: +```php +$parameters = [ + 'data' => [/** Value of $data here. */] +]; +``` + +For variadic parameters, this structure is the same. + +### Object nesting + +Some objects require other objects in their `__construct` method. With the +ObjectFactory it is also possible to create these object, with the correct +configuration. + +There are two ways to create the objects. + +#### Configuration declaration + +It is possible to completely configure the nested objects, expected by the method. +Instead of passing along the variable as is, a array is used with one expected +and one optional node. The expected node is `class`, this array node should contain +the string representation of the expected class. The optional node is `parameters`, +this array node will contain the objects' instantiation parameters. If none are +required, then this can be left empty or undeclared. + +To create the ObjectFactory with a (full) configuration declaration, would look +like this: +```php +create( + ObjectFactory::class, + [ + 'classAnalyser' => [ + 'class' => ClassAnalyser::class, + 'parameters' => [ + 'analysisStorage' => [ + 'class' => ObjectStorage::class, + ], + ], + ], + ] +); +``` + +The configuration declaration could technically be infinitely deep. + +#### Object declaration + +It is also possible to re-use a previously generated or instantiation instance +of a class. This can be done, by simply passing along the object in the parameters. + +```php +create( + ObjectFactory::class, + [ + 'classAnalyser' => $classAnalyser + ] +); +``` + +Both of these declaration methods can be used and mixed throughout the declaration. + +## Change log + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) and [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) for details. + +## MIT License + +Copyright (c) Jyxon + +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/composer.json b/composer.json new file mode 100644 index 0000000..dfd7d34 --- /dev/null +++ b/composer.json @@ -0,0 +1,57 @@ +{ + "name": "ulrack/object-factory", + "description": "An object factory for PHP applications.", + "keywords": ["object-factory"], + "type": "library", + "license": "MIT", + "prefer-stable": true, + "minimum-stability": "stable", + "require": + { + "php": "^7.2", + "ulrack/configuration": "^1.0", + "ulrack/storage": "^1.0" + }, + "authors": [ + { + "name": "Ulrack", + "homepage": "https://www.ulrack.com/", + "role": "Developer" + }], + "config": + { + "sort-packages": true + }, + "autoload": + { + "psr-4": + { + "Ulrack\\ObjectFactory\\": "src/" + } + }, + "autoload-dev": + { + "psr-4": + { + "Ulrack\\ObjectFactory\\Tests\\": "tests/" + } + }, + "archive": + { + "exclude": [ + "/tests", + "/.gitignore", + "/.travis.yml", + "/phpunit.xml", + "/phpcs.xml", + "/PULL_REQUEST_TEMPLATE.md", + "/CODE_OF_CONDUCT.md", + "/CONTRIBUTING.md" + ] + }, + "require-dev": + { + "phpunit/phpunit": "^8.0", + "squizlabs/php_codesniffer": "^3.4" + } +} diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..60dd6ba --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e1e548a --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,15 @@ + + + + tests + + + + + src + + + diff --git a/src/Common/ClassAnalyserInterface.php b/src/Common/ClassAnalyserInterface.php new file mode 100644 index 0000000..f0becce --- /dev/null +++ b/src/Common/ClassAnalyserInterface.php @@ -0,0 +1,16 @@ +analysisStorage = $analysisStorage; + } + + /** + * Analyses the constructor of a class and returns a configuration array. + * + * @return array + * + * @throws NonInstantiableClassException When the analysed class is not instantiable. + */ + public function analyse(string $class): array + { + if (!$this->analysisStorage->has($class)) { + $this->analysisStorage->set( + $class, + $this->reflect($class) + ); + } + + return $this->analysisStorage->get($class); + } + + /** + * Retrieves a class analysis. + * + * @param string $class + * + * @return array + * + * @throws NonInstantiableClassException When the analysed class is not instantiable. + */ + private function reflect(string $class): array + { + $reflection = new ReflectionClass($class); + + if ($reflection->isInstantiable()) { + $constructor = $reflection->getConstructor(); + $parameters = []; + + if ($constructor !== null) { + foreach ($constructor->getParameters() as $parameter) { + $type = $parameter->getType(); + $parameters[$parameter->getName()] = [ + 'type' => $type->__toString(), + 'builtin' => $type->isBuiltin(), + 'allowsNull' => $parameter->allowsNull(), + 'isOptional' => $parameter->isOptional(), + 'isVariadic' => $parameter->isVariadic(), + 'default' => $parameter->isDefaultValueAvailable() + ? $parameter->getDefaultValue() + : null, + ]; + } + } + + return $parameters; + } + + throw new NonInstantiableClassException( + $class + ); + } +} diff --git a/src/Exception/CanNotCreateObjectException.php b/src/Exception/CanNotCreateObjectException.php new file mode 100644 index 0000000..cc48f13 --- /dev/null +++ b/src/Exception/CanNotCreateObjectException.php @@ -0,0 +1,38 @@ +classAnalyser = $classAnalyser; + } + + /** + * Creates an instance of an object. + * + * @param string $class + * @param array $parameters + * + * @return object + * + * @throws CanNotCreateObjectException When the object can not be created. + */ + public function create(string $class, array $parameters): object + { + try { + $config = $this->classAnalyser->analyse($class); + } catch (Throwable $exception) { + throw new CanNotCreateObjectException( + $class, + [], + $parameters, + $exception + ); + } + + $invokeParameters = []; + foreach ($config as $parameterName => $parameterConfig) { + if (!isset($parameters[$parameterName])) { + if (!$parameterConfig['allowsNull'] + && !$parameterConfig['isOptional']) { + throw new CanNotCreateObjectException( + $class, + $parameterConfig, + $parameters + ); + } + + $invokeParameters[] = $parameterConfig['default']; + + continue; + } + + if ($parameterConfig['isVariadic']) { + $variadicParameters = []; + foreach ($parameters[$parameterName] as $variadicParameter) { + try { + $invokeParameters[] = $this->parameterByType( + $parameterConfig, + $variadicParameter + ); + } catch (Throwable $exception) { + throw new CanNotCreateObjectException( + $class, + $parameterConfig, + $parameters, + $exception + ); + } + } + + continue; + } + + try { + $invokeParameters[] = $this->parameterByType($parameterConfig, $parameters[$parameterName]); + } catch (Throwable $exception) { + throw new CanNotCreateObjectException( + $class, + $parameterConfig, + $parameters, + $exception + ); + } + } + + return new $class(...$invokeParameters); + } + + /** + * Validates and creates objects by it's type. + * + * @param array $parameterConfig + * @param mixed $parameter + * + * @return mixed + * + * @throws InvalidParameterTypeException When the type can not be resolved. + */ + private function parameterByType(array $parameterConfig, $parameter) + { + if ($parameterConfig['builtin']) { + if (gettype( + $parameter + ) === $parameterConfig['type']) { + return $parameter; + } + throw new InvalidParameterTypeException($parameterConfig, $parameter); + } + + if (!is_a( + $parameter, + $parameterConfig['type'] + )) { + if (is_array($parameter)) { + try { + return $this->create( + $parameter['class'], + $parameter['parameters'] ?? [] + ); + } catch (Throwable $exception) { + throw new InvalidParameterTypeException( + $parameterConfig, + $parameter, + $exception + ); + } + } + + throw new InvalidParameterTypeException($parameterConfig, $parameter); + } + + return $parameter; + } +} diff --git a/tests/Component/Analyser/ClassAnalyserTest.php b/tests/Component/Analyser/ClassAnalyserTest.php new file mode 100644 index 0000000..a4ec340 --- /dev/null +++ b/tests/Component/Analyser/ClassAnalyserTest.php @@ -0,0 +1,50 @@ +assertInstanceOf(ClassAnalyser::class, $subject); + + $result = $subject->analyse(ObjectStorage::class); + + $this->assertEquals([ + 'data' => [ + 'type' => 'array', + 'builtin' => true, + 'allowsNull' => false, + 'isOptional' => true, + 'isVariadic' => false, + 'default' => [], + ] + ], $result); + + $this->expectException(NonInstantiableClassException::class); + + $subject->analyse(ClassAnalyserInterface::class); + } +} diff --git a/tests/Factory/ObjectFactoryTest.php b/tests/Factory/ObjectFactoryTest.php new file mode 100644 index 0000000..c9392b5 --- /dev/null +++ b/tests/Factory/ObjectFactoryTest.php @@ -0,0 +1,252 @@ +assertInstanceOf( + ObjectFactory::class, + $subject + ); + + $this->assertInstanceOf( + ObjectFactory::class, + $subject->create( + ObjectFactory::class, + [ + 'classAnalyser' => [ + 'class' => ClassAnalyser::class, + 'parameters' => [ + 'analysisStorage' => [ + 'class' => ObjectStorage::class, + ], + ], + ], + ] + ) + ); + } + + /** + * @return void + * + * @covers ::__construct + * @covers ::create + */ + public function testNotInstantiableClass(): void + { + $subject = new ObjectFactory(new ClassAnalyser(new ObjectStorage())); + + $this->expectException(CanNotCreateObjectException::class); + + $subject->create( + ClassAnalyserInterface::class, + [] + ); + } + + /** + * @return void + * + * @covers ::__construct + * @covers ::create + */ + public function testNotProvidedParameter(): void + { + $subject = new ObjectFactory(new ClassAnalyser(new ObjectStorage())); + + $this->expectException(CanNotCreateObjectException::class); + + $subject->create( + ObjectFactory::class, + [] + ); + } + + /** + * @return void + * + * @covers ::__construct + * @covers ::create + * @covers ::parameterByType + */ + public function testInvalidTypeParameter(): void + { + $subject = new ObjectFactory(new ClassAnalyser(new ObjectStorage())); + + $this->expectException(CanNotCreateObjectException::class); + + $subject->create( + ObjectStorage::class, + [ + 'data' => 'foo', + ] + ); + } + + /** + * @return void + * + * @covers ::__construct + * @covers ::create + * @covers ::parameterByType + */ + public function testValidTypeParameter(): void + { + $subject = new ObjectFactory(new ClassAnalyser(new ObjectStorage())); + + $this->assertInstanceOf( + ObjectStorage::class, + $subject->create( + ObjectStorage::class, + [ + 'data' => ['foo' => 'bar'], + ] + ) + ); + } + + /** + * @return void + * + * @covers ::__construct + * @covers ::create + * @covers ::parameterByType + */ + public function testInvalidAggregatedInstanceParameter(): void + { + $subject = new ObjectFactory(new ClassAnalyser(new ObjectStorage())); + + $this->expectException(CanNotCreateObjectException::class); + + $subject->create( + ObjectFactory::class, + [ + 'classAnalyser' => [ + 'class' => ClassAnalyser::class, + 'parameters' => [], + ], + ] + ); + } + + /** + * @return void + * + * @covers ::__construct + * @covers ::create + * @covers ::parameterByType + */ + public function testInvalidConfiguredInstanceParameter(): void + { + $subject = new ObjectFactory(new ClassAnalyser(new ObjectStorage())); + + $this->expectException(CanNotCreateObjectException::class); + + $subject->create( + ObjectFactory::class, + [ + 'classAnalyser' => 'analyser', + ] + ); + } + + /** + * @return void + * + * @covers ::__construct + * @covers ::create + * @covers ::parameterByType + */ + public function testValidPassthruInstanceParameter(): void + { + $subject = new ObjectFactory(new ClassAnalyser(new ObjectStorage())); + + $this->assertInstanceOf( + ObjectFactory::class, + $subject->create( + ObjectFactory::class, + [ + 'classAnalyser' => new ClassAnalyser(new ObjectStorage()), + ] + ) + ); + } + + /** + * @return void + * + * @covers ::__construct + * @covers ::create + * @covers ::parameterByType + */ + public function testVariadicParameter(): void + { + $subject = new ObjectFactory(new ClassAnalyser(new ObjectStorage())); + + $result = $subject->create( + VariadicTestClass::class, + [ + 'foo' => ['foo', 'bar', 'baz'], + ] + ); + + $this->assertInstanceOf( + VariadicTestClass::class, + $result + ); + + $this->assertEquals( + $result->getFoo(), + ['foo', 'bar', 'baz'] + ); + } + + /** + * @return void + * + * @covers ::__construct + * @covers ::create + * @covers ::parameterByType + */ + public function testVariadicParameterFailure(): void + { + $subject = new ObjectFactory(new ClassAnalyser(new ObjectStorage())); + + $this->expectException(CanNotCreateObjectException::class); + + $subject->create( + VariadicTestClass::class, + [ + 'foo' => ['foo', 1, 'baz'], + ] + ); + } +} diff --git a/tests/MockObjects/VariadicTestClass.php b/tests/MockObjects/VariadicTestClass.php new file mode 100644 index 0000000..3c70feb --- /dev/null +++ b/tests/MockObjects/VariadicTestClass.php @@ -0,0 +1,36 @@ +foo = $foo; + } + + /** + * Retrieves foo. + * + * @return array + */ + public function getFoo(): array + { + return $this->foo; + } +}