Skip to content
This repository has been archived by the owner on Jan 21, 2020. It is now read-only.

Implement factory creation #52

Merged

Conversation

weierophinney
Copy link
Member

@weierophinney weierophinney commented Feb 8, 2018

This patch is a work in progress, addressing addresses #42.

  • Functionality for creating a factory by reflecting a class
  • Functionality for writing a factory class file for a given class name
  • symfony/console command factory:create <class>
  • Update handler:create to also generate a factory for the created handler; command should include a --no-factory switch to disable this
  • Update middleware:create to also generate a factory for the created middleware; command should include a --no-factory switch to disable this
  • Register class/factory pairs in config/autoload/dependencies.global.php

One question I have: should the command be factory:create or factory:create-for?

The final commands are:

  • expressive factory:create <for class name> [--no-register]
  • expressive handler:create <class name> [--no-factory] [--no-register]
  • expressive middleware:create <class name> [--no-factory] [--no-register]

Creates and returns the string contents for the factory class based on
reflecting the given class. Factories:

- use strict mode
- are generic classes defining `__invoke`, which accepts a PSR-11 container
- import the PSR-11 container and any classes the class constructor depends on, even if they are in the same namespace
- pull class dependencies from the container
Composes a `FactoryClassGenerator` in order to produce a factory for the
given class name, and then writes that class in a sibling file.

If the class is not autoloadable, `Create` raises an exception.

If unable to write the file to the filesystem, `Create` also raises an
exception.

The factory method, `createForClass`, returns the filename where the
factory class was written.
@weierophinney weierophinney added this to the 1.0.0 milestone Feb 8, 2018
@geerteltink
Copy link
Member

One question I have: should the command be factory:create or factory:create-for?

factory:create sounds better and is shorter

@svycka
Copy link

svycka commented Feb 9, 2018

It would be useful if the create middleware functionality could add a skeleton factory and register it in the module's ConfigProvider.
Where should the factory be saved? I've seen people saving it in the same dir and others in a special Factory dir.

Same directory. As this is what the skeleton does for HomePageFactory. If people put it elsewhere, they can not use this tool or move it.

maybe add config option for this? or ask for the first time or even more fun it could check if exists Factory directory then create there instead

@emir
Copy link

emir commented Feb 10, 2018

factory:create can be predictable and keeps consistency to other commands like handler:create, middleware:create

@weierophinney
Copy link
Member Author

maybe add config option for this?

This hugely complicates the command, and makes errors during generation far more likely. The only way it would work reliably is if, in addition to the class name for which to generate the factory, we required both the class name for the new factory, and the path where the factory should be created. At that point, you're typing as much, if not more, than you would if you were to generate the factory and then perform a git mv operation.

`ConfigInjector` creates and/or updates a special autoloadable config
file, zend-expressive-tooling-factories.global.php. Its
`injectFactoryForClass()` method expects the factory class name and the
class name to which it maps. It slurps up the existing configuration, if
any, and then adds the new mapping, sorts the entries, and writes the
file back to its location.
Modifies the CreateFactoryCommand to register the new factory for the
class using the ConfigInjector by default. Users can opt to disable this
feature via the `--no-register` option.
When `middleware:create` is called, it now generates a factory for the
generated middleware, as a sibling class, using `factory:create`.

Users can disable this by passing the `--no-factory` class.

Users can also generate the factory, but not register it, by passing the
`--no-register` option.
Updates the `handler:create` command to trigger the `factory:create`
command with the name of the generated handler.

Users may disable generation of the factory using the option
`--no-factory`.

Users may generate the factory, but omit registration of the
handler/factory pair by passing the option `--no-register`.
When testing the `middleware:create` and `handler:create` commands, I
received exceptions during factory generation saying that the middleware
and/or handler class could not be found. Adding a `require $path`
statement addresses the issue, but introduces new problems during
testing, which I addressed by adding a private flag that, when disabled,
skips the `require` statement.
*/
class ConfigInjector
{
public const CONFIG_FILE = 'config/autoload/zend-expressive-tooling-factories.global.php';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why a new file is used and not config/autoload/dependencies.global.php?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes: to prevent issues when re-generating the file:

  • serialization of closures
  • resolution of constants
  • expressions
  • usage of import statements

It's far more complex to write to a file meant to be managed manually than to use a file that we can mark as under control of the tooling.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Thanx for the explanation.

Copy link
Member

@michalbundyra michalbundyra left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like that we are also missing definition of const HELP_ARG_MODULE in CreateHandlerCommand and CreateFactoryCommand.
For reference please see other command and class final class CommandCommonOptions (line 32)

update: oh, we are not calling addDefaultOptionsAndArguments so we don't need to define that constant there.

@@ -63,6 +89,22 @@ protected function execute(InputInterface $input, OutputInterface $output)
$path
));

if (! $input->getOption('no-factory')) {
$this->requireHandlerBeforeGeneratingFactory && require $path;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unclear, not easy readable. I know how it works, but it looks like 'js optimized code'.

What's more I don't like to include the class that way. Can't we use autoloader? If not, maybe we should use BetterReflection to read the class by reflection without autoloading the class?

If there is no other way than require please change it for more readable if statement

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a testing problem, plain and simple. require works fine in actual use, but in testing, we get an error due to the fact that no resolvable class file is created.

I can update this to be an if statement; it just feels like overkill for what will be the general use case.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And no, the autoloader does not work in this situation. I'm not entirely sure why. But I tried a number of approaches, and the only way I could get it to be able to reflect the generated class was to require it first. I am reluctant to add BetterReflection when we can accomplish what we need without the extra dependency.

@@ -63,6 +88,22 @@ protected function execute(InputInterface $input, OutputInterface $output)
$path
));

if (! $input->getOption('no-factory')) {
$this->requireMiddlewareBeforeGeneratingFactory && require $path;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same as noted above.

throw ConfigFileNotWritableException::forFile($this->configFile);
}

$config = file_exists($this->configFile) ? include($this->configFile) : [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think usually we are not using parenthesis with include. We have this check in upcoming zend-coding-standards

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had done this because originally I was calculating the filename inline; I'll remove the parens in an upcoming commit.


private function configIsWritable() : bool
{
if (! file_exists($this->configFile) && ! is_writable(dirname($this->configFile))) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can simplify this function, according to the php docs:

Returns TRUE if the filename exists and is writable.
http://php.net/is_writable

So it's not enough just return is_writable($this->configFile) ?

(it checks existence)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted; thanks!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, this only applies to the following conditional, not the one flagged. Note that this one is testing explicitly that the parent directory is writable when the file does not exist.

The one following we can rewrite to ! is_writable($this->configFile), as we're then testing that it both exists and is writable.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, interestingly: tests FAIL when I change this! I'm not sure if this is a difference with how vfsStream handles the is_writable() call, or if the documented behavior of is_writable may be incorrect. Regardless, it needs to stay as-is.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I can check it later, for now it's fine for me. 👍


use RuntimeException;

class FactoryAlreadyExistsException extends RuntimeException
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would extract all Exceptions to namespace, as we have in different project.
I can see we don't have it here in commands, but we can refactor it later on as well.
Here we have more exceptions so it will be clearer to have them in separate directory/namespace (each for command ofc)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather not. Most of these commands are:

  • A command class
  • One or more utility classes to which they delegate
  • One or more exceptions

The exceptions are specific to the specific command domain, and having a separate namespace is overkill, even when there are multiple different types. Consdiering the majority have a single exception type, forcing each to have a subnamespace is imposing hierarchy just for the sake of it.

$constructorParameters,
function (ReflectionParameter $argument) {
if ($argument->isOptional()) {
return false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I understand correctly - optional params are not going to be initialized in the factory?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. This is also how zend-servicemanager handles optional arguments in its ReflectionBasedAbstractFactory and related factory generator.


return $application;

$this->command->setApplication($application->reveal());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unreachable statement

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh! That's not good! I'll remove the return statement!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, need to remove the setApplication() statements, as they happen in the test cases that call this method.

@@ -43,6 +54,32 @@ private function reflectExecuteMethod()
return $r;
}

private function mockApplication()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we are using PHP 7.1 I would suggest to add return type (Application?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure; do we need to add RTH within a test case, though?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to, but it would be nice, especially for IDEs and writing new test.
I would add docblock with Application|ObjectProphecy as it is more accurate, and maybe we should go this way, not RTH.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is... confusing. We can't specify a RTH of Application, as we actually return a prophecy. But specifying ObjectProphecy as the RTH makes it less clear what we're returning. I'll document it via a docblock annotation instead.


return $application;

$this->command->setApplication($application->reveal());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unreachable statement

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will fix; thanks!

$className = InvokableObject::class;
$factory = file_get_contents(__DIR__ . '/TestAsset/factories/InvokableObject.php');

self::assertEquals($factory, $this->generator->createFactory($className));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for consistency, everywhere in this PR you are using $this-> ... and maybe we can make a final decision what we are gonna do: self:: or $this-> (or static:: 🤣 )

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll fix that; not sure why I used self:: here; likely copy-pasta from the original in zend-servicemanager.

- Adds a docblock with the RTH specified as `ObjectProphecy|Application`
- Removes `$this->command->setApplication()` calls within the
  `mockApplication()` methods; these were unreachable, and unwanted.
- Do not test if a `ReflectionClass` was produced; an exception is
  thrown if one is not.
- Use `! $constructorParameters` instead of `empty($constructorParameters)`
- An exception class was not imported, due to bad copy-pasta; created a
  new one with a named constructor, and used that instead.
return substr($className, strrpos($className, '\\') + 1);
}

private function getConstructorParameters(string $className) : array
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @throws annotation with the function (UnidentifiedTypeException)

/**
* Sorts entries by key, using natcase
*/
private function sort(array $config) : array
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is basically ksort with SORT_NATURAL flag :) So I would suggest to use internal function instead.

@weierophinney weierophinney merged commit 1f61691 into zendframework:release-1.0.0 Feb 12, 2018
weierophinney added a commit that referenced this pull request Feb 12, 2018
@weierophinney weierophinney deleted the feature/factory-creation branch February 12, 2018 20:13
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants