diff --git a/composer.json b/composer.json index 674051a..be8d648 100644 --- a/composer.json +++ b/composer.json @@ -21,13 +21,14 @@ ], "require": { "php": "^7.2", + "doctrine/inflector": "^1.4 || ^2.0", "symfony/config": "^3.4 || ^4.4 || ^5.0", "symfony/dependency-injection": "^3.4 || ^4.4 || ^5.0", "symfony/form": "^3.4 || ^4.4 || ^5.0", "symfony/http-kernel": "^3.4 || ^4.4 || ^5.0" }, "require-dev": { - "doctrine/orm": "^2.4", + "doctrine/orm": "^2.7", "symfony/phpunit-bridge": "^5.0" }, "config": { diff --git a/src/Exception/NoValueException.php b/src/Exception/NoValueException.php new file mode 100644 index 0000000..658df12 --- /dev/null +++ b/src/Exception/NoValueException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\DatagridBundle\Exception; + +/** + * @author Thomas Rabaix + */ +class NoValueException extends \Exception +{ +} diff --git a/src/Field/BaseFieldDescription.php b/src/Field/BaseFieldDescription.php new file mode 100644 index 0000000..c58abe2 --- /dev/null +++ b/src/Field/BaseFieldDescription.php @@ -0,0 +1,332 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\DatagridBundle\Field; + +use Doctrine\Inflector\InflectorFactory; +use Sonata\DatagridBundle\Exception\NoValueException; + +/** + * @author Thomas Rabaix + */ +abstract class BaseFieldDescription implements FieldDescriptionInterface +{ + /** + * @var string|null the field name + */ + protected $name; + + /** + * @var string|null the field name (of the form) + */ + protected $fieldName; + + /** + * @var string|int|null the type + */ + protected $type; + + /** + * @var string|int|null the original mapping type + */ + protected $mappingType; + + /** + * @var array the ORM association mapping + */ + protected $associationMapping = []; + + /** + * @var array the ORM field information + */ + protected $fieldMapping = []; + + /** + * @var array the ORM parent mapping association + */ + protected $parentAssociationMappings = []; + + /** + * @var string the template name + */ + protected $template; + + /** + * @var array the option collection + */ + protected $options = []; + + /** + * @var array[] cached object field getters + */ + private static $fieldGetters = []; + + public function setFieldName(string $fieldName): void + { + $this->fieldName = $fieldName; + } + + public function getFieldName(): ?string + { + return $this->fieldName; + } + + public function setName(string $name): void + { + $this->name = $name; + + if (!$this->getFieldName()) { + $this->setFieldName(substr(strrchr('.'.$name, '.'), 1)); + } + } + + public function getName(): ?string + { + return $this->name; + } + + public function setTemplate(string $template): void + { + $this->template = $template; + } + + public function getTemplate(): ?string + { + return $this->template; + } + + /** + * @param int|string $type + */ + public function setType($type): void + { + $this->type = $type; + } + + public function getType() + { + return $this->type; + } + + /** + * @param mixed $default + * + * @return mixed + */ + public function getOption(string $name, $default = null) + { + return isset($this->options[$name]) ? $this->options[$name] : $default; + } + + /** + * @param mixed $value + */ + public function setOption(string $name, $value): void + { + $this->options[$name] = $value; + } + + public function setOptions(array $options): void + { + // set the type if provided + if (isset($options['type'])) { + $this->setType($options['type']); + unset($options['type']); + } + + // set the template if provided + if (isset($options['template'])) { + $this->setTemplate($options['template']); + unset($options['template']); + } + + $this->options = $options; + } + + public function getOptions(): array + { + return $this->options; + } + + public function mergeOption(string $name, array $options = []): void + { + if (!isset($this->options[$name])) { + $this->options[$name] = []; + } + + if (!\is_array($this->options[$name])) { + throw new \RuntimeException(sprintf('The key `%s` does not point to an array value', $name)); + } + + $this->options[$name] = array_merge($this->options[$name], $options); + } + + public function mergeOptions(array $options = []): void + { + foreach ($options as $name => $option) { + if (\is_array($option)) { + $this->mergeOption($name, $option); + } else { + $this->setOption($name, $option); + } + } + } + + public function getAssociationMapping(): array + { + return $this->associationMapping; + } + + public function getFieldMapping(): array + { + return $this->fieldMapping; + } + + public function getParentAssociationMappings(): array + { + return $this->parentAssociationMappings; + } + + /** + * @param int|string $mappingType + */ + public function setMappingType($mappingType): void + { + $this->mappingType = $mappingType; + } + + public function getMappingType() + { + return $this->mappingType; + } + + public function isVirtual(): bool + { + return false !== $this->getOption('virtual_field', false); + } + + /** + * @throws NoValueException + * + * @return mixed + */ + public function getFieldValue(?object $object, ?string $fieldName) + { + if ($this->isVirtual() || null === $object) { + return null; + } + + $getters = []; + $parameters = []; + + // prefer method name given in the code option + if ($this->getOption('code')) { + $getters[] = $this->getOption('code'); + } + // parameters for the method given in the code option + if ($this->getOption('parameters')) { + $parameters = $this->getOption('parameters'); + } + + if (null !== $fieldName && '' !== $fieldName) { + if ($this->hasCachedFieldGetter($object, $fieldName)) { + return $this->callCachedGetter($object, $fieldName, $parameters); + } + + $camelizedFieldName = InflectorFactory::create()->build()->classify($fieldName); + + $getters[] = 'get'.$camelizedFieldName; + $getters[] = 'is'.$camelizedFieldName; + $getters[] = 'has'.$camelizedFieldName; + } + + foreach ($getters as $getter) { + if (method_exists($object, $getter) && \is_callable([$object, $getter])) { + $this->cacheFieldGetter($object, $fieldName, 'getter', $getter); + + return $object->{$getter}(...$parameters); + } + } + + if (null !== $fieldName) { + if (method_exists($object, '__call')) { + $this->cacheFieldGetter($object, $fieldName, 'call'); + + return $object->{$fieldName}(...$parameters); + } + + if ('' !== $fieldName && isset($object->{$fieldName})) { + $this->cacheFieldGetter($object, $fieldName, 'var'); + + return $object->{$fieldName}; + } + } + + throw new NoValueException(sprintf( + 'Neither the property "%s" nor one of the methods "%s()" exist and have public access in class "%s".', + $this->getName(), + implode('()", "', $getters), + \get_class($object) + )); + } + + private function getFieldGetterKey(object $object, ?string $fieldName): ?string + { + if (null === $fieldName) { + return null; + } + + $components = [\get_class($object), $fieldName]; + + $code = $this->getOption('code'); + if (\is_string($code) && '' !== $code) { + $components[] = $code; + } + + return implode('-', $components); + } + + private function hasCachedFieldGetter(object $object, string $fieldName): bool + { + return isset( + self::$fieldGetters[$this->getFieldGetterKey($object, $fieldName)] + ); + } + + private function callCachedGetter(object $object, string $fieldName, array $parameters = []) + { + $getterKey = $this->getFieldGetterKey($object, $fieldName); + + if ('getter' === self::$fieldGetters[$getterKey]['method']) { + return $object->{self::$fieldGetters[$getterKey]['getter']}(...$parameters); + } + + if ('call' === self::$fieldGetters[$getterKey]['method']) { + return $object->__call($fieldName, $parameters); + } + + return $object->{$fieldName}; + } + + private function cacheFieldGetter(object $object, ?string $fieldName, string $method, ?string $getter = null): void + { + $getterKey = $this->getFieldGetterKey($object, $fieldName); + + if (null !== $getterKey) { + self::$fieldGetters[$getterKey] = ['method' => $method]; + if (null !== $getter) { + self::$fieldGetters[$getterKey]['getter'] = $getter; + } + } + } +} diff --git a/src/Field/FieldDescriptionInterface.php b/src/Field/FieldDescriptionInterface.php new file mode 100644 index 0000000..0f31c06 --- /dev/null +++ b/src/Field/FieldDescriptionInterface.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\DatagridBundle\Field; + +use Sonata\DatagridBundle\Exception\NoValueException; + +/** + * @author Thomas Rabaix + */ +interface FieldDescriptionInterface +{ + public function setFieldName(string $fieldName): void; + + public function getFieldName(): ?string; + + /** + * Set the name, used as a form label or table header. + */ + public function setName(string $name): void; + + public function getName(): ?string; + + /** + * Set the template, used to render the field. + */ + public function setTemplate(string $template): void; + + public function getTemplate(): ?string; + + /** + * Set the type, this is a mandatory field as it used to select the correct template + * or the logic associated to the current FieldDescription object. + * + * This can be a string (text, array, datetime, boolean) or an int (ClassMetadata::ONE_TO_MANY) + * + * @param int|string $type + */ + public function setType($type): void; + + /** + * @return int|string|null + */ + public function getType(); + + /** + * @param mixed $default + * + * @return mixed + */ + public function getOption(string $name, $default = null); + + /** + * @param mixed $value + */ + public function setOption(string $name, $value): void; + + /** + * Define the options value, if the options array contains the reserved keywords + * - type + * - template. + * + * Then the value are copied across to the related property value + */ + public function setOptions(array $options): void; + + public function getOptions(): array; + + /** + * @throws \RuntimeException + */ + public function mergeOption(string $name, array $options = []): void; + + public function mergeOptions(array $options = []): void; + + public function setAssociationMapping(array $associationMapping): void; + + public function getAssociationMapping(): array; + + public function getTargetModel(): ?string; + + public function setFieldMapping(array $fieldMapping): void; + + public function getFieldMapping(): array; + + public function setParentAssociationMappings(array $parentAssociationMappings): void; + + public function getParentAssociationMappings(): array; + + /** + * Set the original mapping type (only used if the field is linked to an entity). + * + * This can be a string (text, array, datetime, boolean) or an int (ClassMetadata::ONE_TO_MANY) + * + * @param int|string $mappingType + */ + public function setMappingType($mappingType): void; + + /** + * @return int|string|null + */ + public function getMappingType(); + + public function isIdentifier(): bool; + + /** + * @throws NoValueException + * + * @return mixed + */ + public function getValue(?object $object); + + /** + * @throws NoValueException + * + * @return mixed + */ + public function getFieldValue(?object $object, string $fieldName); +} diff --git a/tests/Field/FieldDescriptionTest.php b/tests/Field/FieldDescriptionTest.php new file mode 100644 index 0000000..dbce1aa --- /dev/null +++ b/tests/Field/FieldDescriptionTest.php @@ -0,0 +1,250 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\DatagridBundle\Tests\Field; + +use PHPUnit\Framework\TestCase; +use Sonata\DatagridBundle\Exception\NoValueException; +use Sonata\DatagridBundle\Tests\Fixtures\Entity\Foo; +use Sonata\DatagridBundle\Tests\Fixtures\Entity\FooBoolean; +use Sonata\DatagridBundle\Tests\Fixtures\Entity\FooCall; +use Sonata\DatagridBundle\Tests\Fixtures\Field\FieldDescription; + +class FieldDescriptionTest extends TestCase +{ + public function testSetName(): void + { + $description = new FieldDescription(); + $description->setName('foo'); + + $this->assertSame('foo', $description->getFieldName()); + $this->assertSame('foo', $description->getName()); + } + + public function testOptions(): void + { + $description = new FieldDescription(); + $description->setOption('foo', 'bar'); + + $this->assertNull($description->getOption('bar')); + $this->assertSame('bar', $description->getOption('foo')); + + $description->mergeOptions(['settings' => ['value_1', 'value_2']]); + $description->mergeOptions(['settings' => ['value_1', 'value_3']]); + + $this->assertSame(['value_1', 'value_2', 'value_1', 'value_3'], $description->getOption('settings')); + + $description->mergeOption('settings', ['value_4']); + $this->assertSame(['value_1', 'value_2', 'value_1', 'value_3', 'value_4'], $description->getOption('settings')); + + $description->mergeOption('bar', ['hello']); + + $this->assertCount(1, $description->getOption('bar')); + + $description->setOptions(['boolean' => true]); + $description->mergeOptions(['boolean' => false]); + + $this->assertFalse($description->getOption('boolean')); + + $this->assertNull($description->getTemplate()); + $description->setOptions(['type' => 'integer', 'template' => 'foo.twig.html']); + + $this->assertSame('integer', $description->getType()); + $this->assertSame('foo.twig.html', $description->getTemplate()); + + $this->assertCount(0, $description->getOptions()); + + $description->setMappingType('int'); + $this->assertSame('int', $description->getMappingType()); + } + + public function testGetValue(): void + { + $description = new FieldDescription(); + $description->setOption('code', 'getFoo'); + + $mock = $this->getMockBuilder(\stdClass::class) + ->setMethods(['getFoo']) + ->getMock(); + $mock->expects($this->once())->method('getFoo')->willReturn(42); + + $this->assertSame(42, $description->getFieldValue($mock, 'fake')); + + /* + * Test with One parameter int + */ + $arg1 = 38; + $oneParameter = [$arg1]; + $description1 = new FieldDescription(); + $description1->setOption('code', 'getWithOneParameter'); + $description1->setOption('parameters', $oneParameter); + + $mock1 = $this->getMockBuilder(\stdClass::class) + ->setMethods(['getWithOneParameter']) + ->getMock(); + $returnValue1 = $arg1 + 2; + $mock1->expects($this->once())->method('getWithOneParameter')->with($this->equalTo($arg1))->willReturn($returnValue1); + + $this->assertSame(40, $description1->getFieldValue($mock1, 'fake')); + + /* + * Test with Two parameters int + */ + $arg2 = 4; + $twoParameters = [$arg1, $arg2]; + $description2 = new FieldDescription(); + $description2->setOption('code', 'getWithTwoParameters'); + $description2->setOption('parameters', $twoParameters); + + $mock2 = $this->getMockBuilder(\stdClass::class) + ->setMethods(['getWithTwoParameters']) + ->getMock(); + $returnValue2 = $arg1 + $arg2; + $mock2->method('getWithTwoParameters')->with($this->equalTo($arg1), $this->equalTo($arg2))->willReturn($returnValue2); + $this->assertSame(42, $description2->getFieldValue($mock2, 'fake')); + + /* + * Test with underscored attribute name + */ + foreach (['getFake', 'isFake', 'hasFake'] as $method) { + $description3 = new FieldDescription(); + $mock3 = $this->getMockBuilder(\stdClass::class) + ->setMethods([$method]) + ->getMock(); + + $mock3->expects($this->once())->method($method)->willReturn(42); + $this->assertSame(42, $description3->getFieldValue($mock3, '_fake')); + } + + $mock4 = $this->getMockBuilder('MockedTestObject') + ->setMethods(['myMethod']) + ->getMock(); + $mock4->expects($this->once()) + ->method('myMethod') + ->willReturn('myMethodValue'); + + $description4 = new FieldDescription(); + $description4->setOption('code', 'myMethod'); + + $this->assertSame($description4->getFieldValue($mock4, null), 'myMethodValue'); + } + + public function testGetValueNoValueException(): void + { + $this->expectException(NoValueException::class); + + $description = new FieldDescription(); + $mock = $this->getMockBuilder(\stdClass::class)->setMethods(['getFoo'])->getMock(); + + $description->getFieldValue($mock, 'fake'); + } + + /** + * @doesNotPerformAssertions + */ + public function testGetVirtualValue(): void + { + $description = new FieldDescription(); + $mock = $this->getMockBuilder(\stdClass::class)->setMethods(['getFoo'])->getMock(); + + $description->setOption('virtual_field', true); + $description->getFieldValue($mock, 'fake'); + } + + public function testExceptionOnNonArrayOption(): void + { + $this->expectException(\RuntimeException::class); + + $description = new FieldDescription(); + $description->setOption('bar', 'hello'); + $description->mergeOption('bar', ['exception']); + } + + public function testGetInaccessibleValue(): void + { + $quux = 'quuX'; + $foo = new Foo(); + $foo->setQuux($quux); + $ro = new \ReflectionObject($foo); + $rm = $ro->getMethod('getQuux'); + $rm->setAccessible(true); + $this->assertSame($quux, $rm->invokeArgs($foo, [])); + + $description = new FieldDescription(); + + $this->expectException(NoValueException::class); + $description->getFieldValue($foo, 'quux'); + } + + public function testGetFieldValue(): void + { + $foo = new Foo(); + $foo->setBar('Bar'); + + $description = new FieldDescription(); + $this->assertSame('Bar', $description->getFieldValue($foo, 'bar')); + $foo->setBar('baR'); + $this->assertSame('baR', $description->getFieldValue($foo, 'bar')); + + $foo->qux = 'Qux'; + $this->assertSame('Qux', $description->getFieldValue($foo, 'qux')); + $foo->qux = 'quX'; + $this->assertSame('quX', $description->getFieldValue($foo, 'qux')); + + $foo = new FooBoolean(); + $foo->setBar(true); + $foo->setBaz(false); + + $description = new FieldDescription(); + $this->assertTrue($description->getFieldValue($foo, 'bar')); + $this->assertFalse($description->getFieldValue($foo, 'baz')); + + $this->expectException(NoValueException::class); + $description->getFieldValue($foo, 'inexistantMethod'); + } + + public function testGetFieldValueWithCodeOption(): void + { + $foo = new Foo(); + $foo->setBaz('Baz'); + + $description = new FieldDescription(); + + $description->setOption('code', 'getBaz'); + $this->assertSame('Baz', $description->getFieldValue($foo, 'inexistantMethod')); + + $description->setOption('code', 'inexistantMethod'); + $this->expectException(NoValueException::class); + $description->getFieldValue($foo, 'inexistantMethod'); + } + + public function testGetFieldValueMagicCall(): void + { + $parameters = ['foo', 'bar']; + $foo = new FooCall(); + + $description = new FieldDescription(); + $description->setOption('parameters', $parameters); + $this->assertSame(['inexistantMethod', $parameters], $description->getFieldValue($foo, 'inexistantMethod')); + + // repeating to cover retrieving cached getter + $this->assertSame(['inexistantMethod', $parameters], $description->getFieldValue($foo, 'inexistantMethod')); + } + + public function testGetFieldValueWithNullObject(): void + { + $foo = null; + $description = new FieldDescription(); + $this->assertNull($description->getFieldValue($foo, 'bar')); + } +} diff --git a/tests/Fixtures/Entity/Foo.php b/tests/Fixtures/Entity/Foo.php new file mode 100644 index 0000000..2e07af5 --- /dev/null +++ b/tests/Fixtures/Entity/Foo.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\DatagridBundle\Tests\Fixtures\Entity; + +class Foo +{ + public $qux; + private $bar; + + private $baz; + + private $quux; + + public function __toString() + { + return (string) $this->bar; + } + + public function getBar() + { + return $this->bar; + } + + public function setBar($bar): void + { + $this->bar = $bar; + } + + public function addBar($bar): void + { + $this->bar[] = $bar; + } + + public function getBaz() + { + return $this->baz; + } + + public function setBaz($baz): void + { + $this->baz = $baz; + } + + public function setQuux($quux): void + { + $this->quux = $quux; + } + + protected function getQuux() + { + return $this->quux; + } +} diff --git a/tests/Fixtures/Entity/FooBoolean.php b/tests/Fixtures/Entity/FooBoolean.php new file mode 100644 index 0000000..da7107e --- /dev/null +++ b/tests/Fixtures/Entity/FooBoolean.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\DatagridBundle\Tests\Fixtures\Entity; + +class FooBoolean +{ + private $bar; + + private $baz; + + public function hasBar() + { + return $this->bar; + } + + public function setBar($bar): void + { + $this->bar = $bar; + } + + public function isBaz() + { + return $this->baz; + } + + public function setBaz($baz): void + { + $this->baz = $baz; + } +} diff --git a/tests/Fixtures/Entity/FooCall.php b/tests/Fixtures/Entity/FooCall.php new file mode 100644 index 0000000..d0ba7dc --- /dev/null +++ b/tests/Fixtures/Entity/FooCall.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\DatagridBundle\Tests\Fixtures\Entity; + +class FooCall +{ + public function __call($method, $arguments) + { + return [$method, $arguments]; + } +} diff --git a/tests/Fixtures/Field/FieldDescription.php b/tests/Fixtures/Field/FieldDescription.php new file mode 100644 index 0000000..39a7811 --- /dev/null +++ b/tests/Fixtures/Field/FieldDescription.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\DatagridBundle\Tests\Fixtures\Field; + +use Sonata\DatagridBundle\Field\BaseFieldDescription; + +class FieldDescription extends BaseFieldDescription +{ + public function setAssociationMapping($associationMapping): void + { + throw new \BadFunctionCallException('Not implemented'); + } + + public function getTargetModel(): ?string + { + throw new \BadFunctionCallException('Not implemented'); + } + + public function setFieldMapping($fieldMapping): void + { + throw new \BadFunctionCallException('Not implemented'); + } + + public function isIdentifier(): bool + { + throw new \BadFunctionCallException('Not implemented'); + } + + public function setParentAssociationMappings(array $parentAssociationMappings): void + { + throw new \BadFunctionCallException('Not implemented'); + } + + public function getValue(?object $object) + { + throw new \BadFunctionCallException('Not implemented'); + } +}