diff --git a/composer.json b/composer.json index a3098c8585c4a..4e758244931fb 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "symfony/doctrine-bridge": "self.version", "symfony/dom-crawler": "self.version", "symfony/event-dispatcher": "self.version", + "symfony/expression-language": "self.version", "symfony/filesystem": "self.version", "symfony/finder": "self.version", "symfony/form": "self.version", diff --git a/src/Symfony/Bridge/Twig/Extension/ExpressionExtension.php b/src/Symfony/Bridge/Twig/Extension/ExpressionExtension.php new file mode 100644 index 0000000000000..4716dbbf565bc --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/ExpressionExtension.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Symfony\Component\ExpressionLanguage\Expression; + +/** + * ExpressionExtension gives a way to create Expressions from a template. + * + * @author Fabien Potencier + */ +class ExpressionExtension extends \Twig_Extension +{ + /** + * {@inheritdoc} + */ + public function getFunctions() + { + return array( + new \Twig_SimpleFunction('expression', array($this, 'createExpression')), + ); + } + + private function createExpression($expression) + { + return new Expression($expression); + } + + /** + * Returns the name of the extension. + * + * @return string The extension name + */ + public function getName() + { + return 'expression'; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index f0460c1a85ff6..3b2f85aa2d252 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -48,6 +48,9 @@ public function load(array $configs, ContainerBuilder $container) // will be used and everything will still work as expected. $loader->load('translation.xml'); + // Property access is used by both the Form and the Validator component + $loader->load('property_access.xml'); + $loader->load('debug_prod.xml'); if ($container->getParameter('kernel.debug')) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.xml index 569100fce75fb..93fa5235871cb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.xml @@ -10,7 +10,6 @@ Symfony\Component\Form\FormFactory Symfony\Component\Form\Extension\DependencyInjection\DependencyInjectionExtension Symfony\Component\Form\Extension\Validator\ValidatorTypeGuesser - Symfony\Component\PropertyAccess\PropertyAccessor @@ -54,9 +53,6 @@ - - - diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_access.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_access.xml new file mode 100644 index 0000000000000..18026144e4573 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_access.xml @@ -0,0 +1,14 @@ + + + + + + Symfony\Component\PropertyAccess\PropertyAccessor + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.xml index 0bc70040a2068..5f8fba2b2dd17 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.xml @@ -17,6 +17,7 @@ Symfony\Bundle\FrameworkBundle\Validator\ConstraintValidatorFactory + Symfony\Component\Validator\Constraints\ExpressionValidator @@ -63,5 +64,10 @@ %validator.mapping.loader.yaml_files_loader.mapping_files% + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php index 5041479704642..e14cd119f49e4 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php @@ -169,6 +169,7 @@ private function addAccessControlSection(ArrayNodeDefinition $rootNode) ->beforeNormalization()->ifString()->then(function($v) { return preg_split('/\s*,\s*/', $v); })->end() ->prototype('scalar')->end() ->end() + ->scalarNode('allow_if')->defaultNull()->end() ->end() ->fixXmlConfig('role') ->children() diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index d87a890f5e5f1..30471362839af 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -23,6 +23,8 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\Parameter; use Symfony\Component\Config\FileLocator; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\Security\Core\Authorization\ExpressionLanguage; /** * SecurityExtension. @@ -33,10 +35,12 @@ class SecurityExtension extends Extension { private $requestMatchers = array(); + private $expressions = array(); private $contextListeners = array(); private $listenerPositions = array('pre_auth', 'form', 'http', 'remember_me'); private $factories = array(); private $userProviderFactories = array(); + private $expressionLanguage; public function __construct() { @@ -188,8 +192,13 @@ private function createAuthorization($config, ContainerBuilder $container) $access['ips'] ); + $attributes = $access['roles']; + if ($access['allow_if']) { + $attributes[] = $this->createExpression($container, $access['allow_if']); + } + $container->getDefinition('security.access_map') - ->addMethodCall('add', array($matcher, $access['roles'], $access['requires_channel'])); + ->addMethodCall('add', array($matcher, $attributes, $access['requires_channel'])); } } @@ -596,6 +605,22 @@ private function createSwitchUserListener($container, $id, $config, $defaultProv return $switchUserListenerId; } + private function createExpression($container, $expression) + { + if (isset($this->expressions[$id = 'security.expression.'.sha1($expression)])) { + return $this->expressions[$id]; + } + + $container + ->register($id, 'Symfony\Component\ExpressionLanguage\SerializedParsedExpression') + ->setPublic(false) + ->addArgument($expression) + ->addArgument(serialize($this->getExpressionLanguage()->parse($expression, array('token', 'user', 'object', 'roles', 'request')))) + ; + + return $this->expressions[$id] = new Reference($id); + } + private function createRequestMatcher($container, $path = null, $host = null, $methods = array(), $ip = null, array $attributes = array()) { $serialized = serialize(array($path, $host, $methods, $ip, $attributes)); @@ -654,4 +679,16 @@ public function getConfiguration(array $config, ContainerBuilder $container) // first assemble the factories return new MainConfiguration($this->factories, $this->userProviderFactories); } + + private function getExpressionLanguage() + { + if (null === $this->expressionLanguage) { + if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) { + throw new RuntimeException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed.'); + } + $this->expressionLanguage = new ExpressionLanguage(); + } + + return $this->expressionLanguage; + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml index dd2a7fc30d116..82c98d815a6b7 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml @@ -32,17 +32,21 @@ Symfony\Component\Security\Core\Authorization\Voter\RoleVoter Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter Symfony\Component\Security\Core\Authorization\Voter\RoleHierarchyVoter + Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter Symfony\Component\Security\Http\Firewall Symfony\Bundle\SecurityBundle\Security\FirewallMap Symfony\Bundle\SecurityBundle\Security\FirewallContext Symfony\Component\HttpFoundation\RequestMatcher + Symfony\Component\HttpFoundation\ExpressionRequestMatcher Symfony\Component\Security\Core\Role\RoleHierarchy Symfony\Component\Security\Http\HttpUtils Symfony\Component\Security\Core\Validator\Constraints\UserPasswordValidator + + Symfony\Component\Security\Core\Authorization\ExpressionLanguage @@ -78,6 +82,7 @@ + @@ -104,6 +109,13 @@ + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php index d5af681b3331d..e6e0a3dd1be41 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php @@ -17,6 +17,7 @@ use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\ExpressionLanguage\Expression; abstract class CompleteConfigurationTest extends \PHPUnit_Framework_TestCase { @@ -133,7 +134,7 @@ public function testAccess() $matcherIds = array(); foreach ($rules as $rule) { - list($matcherId, $roles, $channel) = $rule; + list($matcherId, $attributes, $channel) = $rule; $requestMatcher = $container->getDefinition($matcherId); $this->assertFalse(isset($matcherIds[$matcherId])); @@ -141,19 +142,23 @@ public function testAccess() $i = count($matcherIds); if (1 === $i) { - $this->assertEquals(array('ROLE_USER'), $roles); + $this->assertEquals(array('ROLE_USER'), $attributes); $this->assertEquals('https', $channel); $this->assertEquals( array('/blog/524', null, array('GET', 'POST')), $requestMatcher->getArguments() ); } elseif (2 === $i) { - $this->assertEquals(array('IS_AUTHENTICATED_ANONYMOUSLY'), $roles); + $this->assertEquals(array('IS_AUTHENTICATED_ANONYMOUSLY'), $attributes); $this->assertNull($channel); $this->assertEquals( array('/blog/.*'), $requestMatcher->getArguments() ); + } elseif (3 === $i) { + $this->assertEquals('IS_AUTHENTICATED_ANONYMOUSLY', $attributes[0]); + $expression = $container->getDefinition($attributes[1])->getArgument(0); + $this->assertEquals("token.getUsername() =~ '/^admin/'", $expression); } } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php index d91d604a6a12a..4a23656bdb71c 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php @@ -82,6 +82,7 @@ 'access_control' => array( array('path' => '/blog/524', 'role' => 'ROLE_USER', 'requires_channel' => 'https', 'methods' => array('get', 'POST')), array('path' => '/blog/.*', 'role' => 'IS_AUTHENTICATED_ANONYMOUSLY'), + array('path' => '/blog/524', 'role' => 'IS_AUTHENTICATED_ANONYMOUSLY', 'allow_if' => "token.getUsername() =~ '/^admin/'"), ), 'role_hierarchy' => array( diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml index 9da4ad937f48a..9f9085f8ddcff 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml @@ -68,5 +68,6 @@ + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml index 7b5bd0e3731e6..7db967d23322e 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml @@ -69,3 +69,4 @@ security: - path: /blog/.* role: IS_AUTHENTICATED_ANONYMOUSLY + - { path: /blog/524, role: IS_AUTHENTICATED_ANONYMOUSLY, allow_if: "token.getUsername() =~ '/^admin/'" } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/config/routing.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/config/routing.yml index 535df3576c2fb..6992f80a0a124 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/config/routing.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/config/routing.yml @@ -37,3 +37,6 @@ form_logout: form_secure_action: path: /secure-but-not-covered-by-access-control defaults: { _controller: FormLoginBundle:Login:secure } + +protected-via-expression: + path: /protected-via-expression diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php index bb16373c1bbf6..d059a0bff6b74 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php @@ -91,6 +91,28 @@ public function testSecurityConfigurationForMultipleIPAddresses($config) $this->assertRestricted($barredClient, '/secured-by-two-ips'); } + /** + * @dataProvider getConfigs + */ + public function testSecurityConfigurationForExpression($config) + { + $allowedClient = $this->createClient(array('test_case' => 'StandardFormLogin', 'root_config' => $config), array('HTTP_USER_AGENT' => 'Firefox 1.0')); + $this->assertAllowed($allowedClient, '/protected-via-expression'); + + $barredClient = $this->createClient(array('test_case' => 'StandardFormLogin', 'root_config' => $config), array()); + $this->assertRestricted($barredClient, '/protected-via-expression'); + + $allowedClient = $this->createClient(array('test_case' => 'StandardFormLogin', 'root_config' => $config), array()); + + $allowedClient->request('GET', '/protected-via-expression'); + $form = $allowedClient->followRedirect()->selectButton('login')->form(); + $form['_username'] = 'johannes'; + $form['_password'] = 'test'; + $allowedClient->submit($form); + $this->assertRedirect($allowedClient->getResponse(), '/protected-via-expression'); + $this->assertAllowed($allowedClient, '/protected-via-expression'); + } + private function assertAllowed($client, $path) { $client->request('GET', $path); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml index 58bd9f2d8af3c..7357335dbebc8 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml @@ -31,4 +31,5 @@ security: - { path: ^/secured-by-one-ip$, ip: 10.10.10.10, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/secured-by-two-ips$, ips: [1.1.1.1, 2.2.2.2], roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/highly_protected_resource$, roles: IS_ADMIN } + - { path: ^/protected-via-expression$, allow_if: "(is_anonymous() and request.headers.get('user-agent') =~ '/Firefox/i') or has_role('ROLE_USER')" } - { path: .*, roles: IS_AUTHENTICATED_FULLY } diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index 139a1167ecdee..dbeeb18daa3d1 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -25,7 +25,8 @@ "symfony/twig-bundle": "~2.2", "symfony/form": "~2.1", "symfony/validator": "~2.2", - "symfony/yaml": "~2.0" + "symfony/yaml": "~2.0", + "symfony/expression-language": "~2.4" }, "autoload": { "psr-0": { "Symfony\\Bundle\\SecurityBundle\\": "" } diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml index 48c0055d9cfea..5efbe9d6d4f41 100644 --- a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml @@ -19,6 +19,7 @@ Symfony\Bridge\Twig\Extension\FormExtension Symfony\Bridge\Twig\Extension\HttpKernelExtension Symfony\Bridge\Twig\Extension\StopwatchExtension + Symfony\Bridge\Twig\Extension\ExpressionExtension Symfony\Bridge\Twig\Form\TwigRendererEngine Symfony\Bridge\Twig\Form\TwigRenderer Symfony\Bridge\Twig\Translation\TwigExtractor @@ -92,6 +93,10 @@ + + + + diff --git a/src/Symfony/Component/DependencyInjection/CHANGELOG.md b/src/Symfony/Component/DependencyInjection/CHANGELOG.md index d3ca34c2bcb97..9a41e85705514 100644 --- a/src/Symfony/Component/DependencyInjection/CHANGELOG.md +++ b/src/Symfony/Component/DependencyInjection/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 2.4.0 ----- + * added support for expressions in service definitions * added ContainerAwareTrait to add default container aware behavior to a class 2.2.0 diff --git a/src/Symfony/Component/DependencyInjection/ContainerBuilder.php b/src/Symfony/Component/DependencyInjection/ContainerBuilder.php index 46d908c55111a..199eec8d2deba 100644 --- a/src/Symfony/Component/DependencyInjection/ContainerBuilder.php +++ b/src/Symfony/Component/DependencyInjection/ContainerBuilder.php @@ -24,6 +24,8 @@ use Symfony\Component\Config\Resource\ResourceInterface; use Symfony\Component\DependencyInjection\LazyProxy\Instantiator\InstantiatorInterface; use Symfony\Component\DependencyInjection\LazyProxy\Instantiator\RealServiceInstantiator; +use Symfony\Component\DependencyInjection\ExpressionLanguage; +use Symfony\Component\ExpressionLanguage\Expression; /** * ContainerBuilder is a DI container that provides an API to easily describe services. @@ -78,6 +80,11 @@ class ContainerBuilder extends Container implements TaggedContainerInterface */ private $proxyInstantiator; + /** + * @var ExpressionLanguage|null + */ + private $expressionLanguage; + /** * Sets the track resources flag. * @@ -983,11 +990,12 @@ public function createService(Definition $definition, $id, $tryProxy = true) } /** - * Replaces service references by the real service instance. + * Replaces service references by the real service instance and evaluates expressions. * * @param mixed $value A value * - * @return mixed The same value with all service references replaced by the real service instances + * @return mixed The same value with all service references replaced by + * the real service instances and all expressions evaluated */ public function resolveServices($value) { @@ -999,6 +1007,8 @@ public function resolveServices($value) $value = $this->get((string) $value, $value->getInvalidBehavior()); } elseif ($value instanceof Definition) { $value = $this->createService($value, null); + } elseif ($value instanceof Expression) { + $value = $this->getExpressionLanguage()->evaluate($value, array('container' => $this)); } return $value; @@ -1149,4 +1159,16 @@ private function shareService(Definition $definition, $service, $id) } } } + + private function getExpressionLanguage() + { + if (null === $this->expressionLanguage) { + if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) { + throw new RuntimeException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed.'); + } + $this->expressionLanguage = new ExpressionLanguage(); + } + + return $this->expressionLanguage; + } } diff --git a/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php b/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php index e92960e21ed70..684ab78079a32 100644 --- a/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php +++ b/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php @@ -23,6 +23,8 @@ use Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException; use Symfony\Component\DependencyInjection\LazyProxy\PhpDumper\DumperInterface as ProxyDumper; use Symfony\Component\DependencyInjection\LazyProxy\PhpDumper\NullDumper; +use Symfony\Component\DependencyInjection\ExpressionLanguage; +use Symfony\Component\ExpressionLanguage\Expression; /** * PhpDumper dumps a service container as a PHP class. @@ -51,6 +53,7 @@ class PhpDumper extends Dumper private $referenceVariables; private $variableCount; private $reservedVariables = array('instance', 'class'); + private $expressionLanguage; /** * @var \Symfony\Component\DependencyInjection\LazyProxy\PhpDumper\DumperInterface @@ -1197,6 +1200,8 @@ private function dumpValue($value, $interpolate = true) } return $this->getServiceCall((string) $value, $value); + } elseif ($value instanceof Expression) { + return $this->getExpressionLanguage()->compile((string) $value, array('container')); } elseif ($value instanceof Parameter) { return $this->dumpParameter($value); } elseif (true === $interpolate && is_string($value)) { @@ -1319,4 +1324,16 @@ private function getNextVariableName() return $name; } } + + private function getExpressionLanguage() + { + if (null === $this->expressionLanguage) { + if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) { + throw new RuntimeException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed.'); + } + $this->expressionLanguage = new ExpressionLanguage(); + } + + return $this->expressionLanguage; + } } diff --git a/src/Symfony/Component/DependencyInjection/Dumper/XmlDumper.php b/src/Symfony/Component/DependencyInjection/Dumper/XmlDumper.php index a311af348e348..31bec31b22456 100644 --- a/src/Symfony/Component/DependencyInjection/Dumper/XmlDumper.php +++ b/src/Symfony/Component/DependencyInjection/Dumper/XmlDumper.php @@ -17,6 +17,7 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\Exception\RuntimeException; +use Symfony\Component\ExpressionLanguage\Expression; /** * XmlDumper dumps a service container as an XML string. @@ -259,6 +260,10 @@ private function convertParameters($parameters, $type, \DOMElement $parent, $key } elseif ($value instanceof Definition) { $element->setAttribute('type', 'service'); $this->addService($value, null, $element); + } elseif ($value instanceof Expression) { + $element->setAttribute('type', 'expression'); + $text = $this->document->createTextNode(self::phpToXml((string) $value)); + $element->appendChild($text); } else { if (in_array($value, array('null', 'true', 'false'), true)) { $element->setAttribute('type', 'string'); diff --git a/src/Symfony/Component/DependencyInjection/Dumper/YamlDumper.php b/src/Symfony/Component/DependencyInjection/Dumper/YamlDumper.php index 807e656283b55..4d72e8675311d 100644 --- a/src/Symfony/Component/DependencyInjection/Dumper/YamlDumper.php +++ b/src/Symfony/Component/DependencyInjection/Dumper/YamlDumper.php @@ -19,6 +19,7 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\Exception\RuntimeException; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\ExpressionLanguage\Expression; /** * YamlDumper dumps a service container as a YAML string. @@ -231,6 +232,8 @@ private function dumpValue($value) return $this->getServiceCall((string) $value, $value); } elseif ($value instanceof Parameter) { return $this->getParameterCall((string) $value); + } elseif ($value instanceof Expression) { + return $this->getExpressionCall((string) $value); } elseif (is_object($value) || is_resource($value)) { throw new RuntimeException('Unable to dump a service container if a parameter is an object or a resource.'); } @@ -267,6 +270,11 @@ private function getParameterCall($id) return sprintf('%%%s%%', $id); } + private function getExpressionCall($expression) + { + return sprintf('@=%s', $expression); + } + /** * Prepares parameters. * diff --git a/src/Symfony/Component/DependencyInjection/ExpressionLanguage.php b/src/Symfony/Component/DependencyInjection/ExpressionLanguage.php new file mode 100644 index 0000000000000..5b97b202b7997 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/ExpressionLanguage.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection; + +use Symfony\Component\ExpressionLanguage\ExpressionLanguage as BaseExpressionLanguage; + +/** + * Adds some function to the default ExpressionLanguage. + * + * To get a service, use service('request'). + * To get a parameter, use parameter('kernel.debug'). + * + * @author Fabien Potencier + */ +class ExpressionLanguage extends BaseExpressionLanguage +{ + protected function registerFunctions() + { + parent::registerFunctions(); + + $this->addFunction('service', function ($arg) { + return sprintf('$this->get(%s)', $arg); + }, function (array $variables, $value) { + return $variables['container']->get($value); + }); + + $this->addFunction('parameter', function ($arg) { + return sprintf('$this->getParameter(%s)', $arg); + }, function (array $variables, $value) { + return $variables['container']->getParameter($value); + }); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php b/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php index ce135fa81f929..28e5a026d432e 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php +++ b/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php @@ -19,6 +19,7 @@ use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\Config\Resource\FileResource; use Symfony\Component\Yaml\Parser as YamlParser; +use Symfony\Component\ExpressionLanguage\Expression; /** * YamlFileLoader loads YAML files service definitions. @@ -311,6 +312,8 @@ private function resolveServices($value) { if (is_array($value)) { $value = array_map(array($this, 'resolveServices'), $value); + } elseif (is_string($value) && 0 === strpos($value, '@=')) { + return new Expression(substr($value, 2)); } elseif (is_string($value) && 0 === strpos($value, '@')) { if (0 === strpos($value, '@@')) { $value = substr($value, 1); diff --git a/src/Symfony/Component/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd b/src/Symfony/Component/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd index f1c2003c62258..e00bbc23c6c1f 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd +++ b/src/Symfony/Component/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd @@ -164,6 +164,7 @@ + diff --git a/src/Symfony/Component/DependencyInjection/SimpleXMLElement.php b/src/Symfony/Component/DependencyInjection/SimpleXMLElement.php index cc5e311987151..2d995e2dd081f 100644 --- a/src/Symfony/Component/DependencyInjection/SimpleXMLElement.php +++ b/src/Symfony/Component/DependencyInjection/SimpleXMLElement.php @@ -12,6 +12,7 @@ namespace Symfony\Component\DependencyInjection; use Symfony\Component\Config\Util\XmlUtils; +use Symfony\Component\ExpressionLanguage\Expression; /** * SimpleXMLElement class. @@ -77,6 +78,9 @@ public function getArgumentsAsPhp($name, $lowercase = true) $arguments[$key] = new Reference((string) $arg['id'], $invalidBehavior, $strict); break; + case 'expression': + $arguments[$key] = new Expression((string) $arg); + break; case 'collection': $arguments[$key] = $arg->getArgumentsAsPhp($name, false); break; diff --git a/src/Symfony/Component/DependencyInjection/Tests/ContainerBuilderTest.php b/src/Symfony/Component/DependencyInjection/Tests/ContainerBuilderTest.php index ff64821e2390d..87693bff009e1 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/ContainerBuilderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/ContainerBuilderTest.php @@ -25,6 +25,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\DependencyInjection\Scope; use Symfony\Component\Config\Resource\FileResource; +use Symfony\Component\ExpressionLanguage\Expression; class ContainerBuilderTest extends \PHPUnit_Framework_TestCase { @@ -377,6 +378,15 @@ public function testCreateSyntheticService() $builder->get('foo'); } + public function testCreateServiceWithExpression() + { + $builder = new ContainerBuilder(); + $builder->setParameter('bar', 'bar'); + $builder->register('bar', 'BarClass'); + $builder->register('foo', 'FooClass')->addArgument(array('foo' => new Expression('service("bar").foo ~ parameter("bar")'))); + $this->assertEquals('foobar', $builder->get('foo')->arguments['foo']); + } + /** * @covers Symfony\Component\DependencyInjection\ContainerBuilder::resolveServices */ @@ -386,6 +396,7 @@ public function testResolveServices() $builder->register('foo', 'FooClass'); $this->assertEquals($builder->get('foo'), $builder->resolveServices(new Reference('foo')), '->resolveServices() resolves service references to service instances'); $this->assertEquals(array('foo' => array('foo', $builder->get('foo'))), $builder->resolveServices(array('foo' => array('foo', new Reference('foo')))), '->resolveServices() resolves service references to service instances in nested arrays'); + $this->assertEquals($builder->get('foo'), $builder->resolveServices(new Expression('service("foo")')), '->resolveServices() resolves expressions'); } /** diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/containers/container9.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/containers/container9.php index 6abe5e2a917f2..b5e9b073082f2 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/containers/container9.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/containers/container9.php @@ -6,6 +6,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\Parameter; +use Symfony\Component\ExpressionLanguage\Expression; $container = new ContainerBuilder(); $container-> @@ -50,7 +51,8 @@ addMethodCall('setBar', array(new Reference('foo')))-> addMethodCall('setBar', array(new Reference('foo2', ContainerInterface::NULL_ON_INVALID_REFERENCE)))-> addMethodCall('setBar', array(new Reference('foo3', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)))-> - addMethodCall('setBar', array(new Reference('foobaz', ContainerInterface::IGNORE_ON_INVALID_REFERENCE))) + addMethodCall('setBar', array(new Reference('foobaz', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)))-> + addMethodCall('setBar', array(new Expression('service("foo").foo() ~ parameter("foo")'))) ; $container-> register('factory_service', 'Bar')-> diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/classes.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/classes.php index 48e1c2c84e12d..70c3d275b8898 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/classes.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/classes.php @@ -8,6 +8,7 @@ function sc_configure($instance) class BarClass { protected $baz; + public $foo = 'foo'; public function setBaz(BazClass $baz) { diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services9.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services9.php index 9361a0e87c5ad..97fba3fde2021 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services9.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services9.php @@ -198,6 +198,7 @@ protected function getMethodCall1Service() if ($this->has('foobaz')) { $instance->setBar($this->get('foobaz', ContainerInterface::NULL_ON_INVALID_REFERENCE)); } + $instance->setBar(($this->get("foo")->foo() . $this->getParameter("foo"))); return $instance; } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services9_compiled.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services9_compiled.php index b60cbff8e8bbe..2004af9aa9077 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services9_compiled.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services9_compiled.php @@ -203,6 +203,7 @@ protected function getMethodCall1Service() $instance->setBar($this->get('foo')); $instance->setBar(NULL); + $instance->setBar(($this->get("foo")->foo() . $this->getParameter("foo"))); return $instance; } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services6.xml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services6.xml index abd9fbc1529b1..ffc038234fae3 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services6.xml +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services6.xml @@ -32,6 +32,9 @@ + + service("foo").foo() ~ parameter("foo") + diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services9.xml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services9.xml index cb3a1f69ded5b..60fcfce59a97f 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services9.xml +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services9.xml @@ -49,6 +49,9 @@ + + service("foo").foo() ~ parameter("foo") + diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services6.yml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services6.yml index 7ba9453bdd6dd..d3c793f2e31c3 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services6.yml +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services6.yml @@ -15,6 +15,7 @@ services: calls: - [ setBar, [] ] - [ setBar ] + - [ setBar, ['@=service("foo").foo() ~ parameter("foo")'] ] method_call2: class: FooClass calls: diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services9.yml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services9.yml index 6a4c3d5d97a03..6744e29f3f95f 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services9.yml +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services9.yml @@ -38,6 +38,7 @@ services: - [setBar, ['@?foo2']] - [setBar, ['@?foo3']] - [setBar, ['@?foobaz']] + - [setBar, ['@=service("foo").foo() ~ parameter("foo")']] factory_service: class: Bar diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php index e6490ecc355b3..6d36e2404c157 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php @@ -22,6 +22,7 @@ use Symfony\Component\DependencyInjection\Loader\IniFileLoader; use Symfony\Component\Config\Loader\LoaderResolver; use Symfony\Component\Config\FileLocator; +use Symfony\Component\ExpressionLanguage\Expression; class XmlFileLoaderTest extends \PHPUnit_Framework_TestCase { @@ -211,7 +212,7 @@ public function testLoadServices() $this->assertEquals('sc_configure', $services['configurator1']->getConfigurator(), '->load() parses the configurator tag'); $this->assertEquals(array(new Reference('baz', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, false), 'configure'), $services['configurator2']->getConfigurator(), '->load() parses the configurator tag'); $this->assertEquals(array('BazClass', 'configureStatic'), $services['configurator3']->getConfigurator(), '->load() parses the configurator tag'); - $this->assertEquals(array(array('setBar', array())), $services['method_call1']->getMethodCalls(), '->load() parses the method_call tag'); + $this->assertEquals(array(array('setBar', array()), array('setBar', array(new Expression('service("foo").foo() ~ parameter("foo")')))), $services['method_call1']->getMethodCalls(), '->load() parses the method_call tag'); $this->assertEquals(array(array('setBar', array('foo', new Reference('foo'), array(true, false)))), $services['method_call2']->getMethodCalls(), '->load() parses the method_call tag'); $this->assertNull($services['factory_service']->getClass()); $this->assertEquals('getInstance', $services['factory_service']->getFactoryMethod()); diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php index a567bbe9cd4d2..8287a741b5b42 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php @@ -20,6 +20,7 @@ use Symfony\Component\DependencyInjection\Loader\IniFileLoader; use Symfony\Component\Config\Loader\LoaderResolver; use Symfony\Component\Config\FileLocator; +use Symfony\Component\ExpressionLanguage\Expression; class YamlFileLoaderTest extends \PHPUnit_Framework_TestCase { @@ -113,7 +114,7 @@ public function testLoadServices() $this->assertEquals('sc_configure', $services['configurator1']->getConfigurator(), '->load() parses the configurator tag'); $this->assertEquals(array(new Reference('baz'), 'configure'), $services['configurator2']->getConfigurator(), '->load() parses the configurator tag'); $this->assertEquals(array('BazClass', 'configureStatic'), $services['configurator3']->getConfigurator(), '->load() parses the configurator tag'); - $this->assertEquals(array(array('setBar', array()), array('setBar', array())), $services['method_call1']->getMethodCalls(), '->load() parses the method_call tag'); + $this->assertEquals(array(array('setBar', array()), array('setBar', array()), array('setBar', array(new Expression('service("foo").foo() ~ parameter("foo")')))), $services['method_call1']->getMethodCalls(), '->load() parses the method_call tag'); $this->assertEquals(array(array('setBar', array('foo', new Reference('foo'), array(true, false)))), $services['method_call2']->getMethodCalls(), '->load() parses the method_call tag'); $this->assertEquals('baz_factory', $services['factory_service']->getFactoryService()); diff --git a/src/Symfony/Component/DependencyInjection/composer.json b/src/Symfony/Component/DependencyInjection/composer.json index 1a3c03dcd2b7f..1a29489d2bafd 100644 --- a/src/Symfony/Component/DependencyInjection/composer.json +++ b/src/Symfony/Component/DependencyInjection/composer.json @@ -20,7 +20,8 @@ }, "require-dev": { "symfony/yaml": "~2.0", - "symfony/config": "~2.2" + "symfony/config": "~2.2", + "symfony/expression-language": "~2.4" }, "suggest": { "symfony/yaml": "", diff --git a/src/Symfony/Component/ExpressionLanguage/.gitignore b/src/Symfony/Component/ExpressionLanguage/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md b/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md new file mode 100644 index 0000000000000..eacb5a60fbdca --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +2.4.0 +----- + + * added the component diff --git a/src/Symfony/Component/ExpressionLanguage/Compiler.php b/src/Symfony/Component/ExpressionLanguage/Compiler.php new file mode 100644 index 0000000000000..d9f29696474e1 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Compiler.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Compiles a node to PHP code. + * + * @author Fabien Potencier + */ +class Compiler +{ + private $source; + private $functions; + + public function __construct(array $functions) + { + $this->functions = $functions; + } + + public function getFunction($name) + { + return $this->functions[$name]; + } + + /** + * Gets the current PHP code after compilation. + * + * @return string The PHP code + */ + public function getSource() + { + return $this->source; + } + + public function reset() + { + $this->source = ''; + + return $this; + } + + /** + * Compiles a node. + * + * @param Node\Node $node The node to compile + * + * @return Compiler The current compiler instance + */ + public function compile(Node\Node $node) + { + $node->compile($this); + + return $this; + } + + public function subcompile(Node\Node $node) + { + $current = $this->source; + $this->source = ''; + + $node->compile($this); + + $source = $this->source; + $this->source = $current; + + return $source; + } + + /** + * Adds a raw string to the compiled code. + * + * @param string $string The string + * + * @return Compiler The current compiler instance + */ + public function raw($string) + { + $this->source .= $string; + + return $this; + } + + /** + * Adds a quoted string to the compiled code. + * + * @param string $value The string + * + * @return Compiler The current compiler instance + */ + public function string($value) + { + $this->source .= sprintf('"%s"', addcslashes($value, "\0\t\"\$\\")); + + return $this; + } + + /** + * Returns a PHP representation of a given value. + * + * @param mixed $value The value to convert + * + * @return Compiler The current compiler instance + */ + public function repr($value) + { + if (is_int($value) || is_float($value)) { + if (false !== $locale = setlocale(LC_NUMERIC, 0)) { + setlocale(LC_NUMERIC, 'C'); + } + + $this->raw($value); + + if (false !== $locale) { + setlocale(LC_NUMERIC, $locale); + } + } elseif (null === $value) { + $this->raw('null'); + } elseif (is_bool($value)) { + $this->raw($value ? 'true' : 'false'); + } elseif (is_array($value)) { + $this->raw('array('); + $first = true; + foreach ($value as $key => $value) { + if (!$first) { + $this->raw(', '); + } + $first = false; + $this->repr($key); + $this->raw(' => '); + $this->repr($value); + } + $this->raw(')'); + } else { + $this->string($value); + } + + return $this; + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Expression.php b/src/Symfony/Component/ExpressionLanguage/Expression.php new file mode 100644 index 0000000000000..f5a3820309231 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Expression.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Represents an expression. + * + * @author Fabien Potencier + */ +class Expression +{ + protected $expression; + + /** + * Constructor. + * + * @param string $expression An expression + */ + public function __construct($expression) + { + $this->expression = (string) $expression; + } + + /** + * Gets the expression. + * + * @return string The expression + */ + public function __toString() + { + return $this->expression; + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/ExpressionLanguage.php b/src/Symfony/Component/ExpressionLanguage/ExpressionLanguage.php new file mode 100644 index 0000000000000..0a45ca14dd641 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/ExpressionLanguage.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +use Symfony\Component\ExpressionLanguage\Node\Node; + +/** + * Allows to compile and evaluate expressions written in your own DSL. + * + * @author Fabien Potencier + */ +class ExpressionLanguage +{ + private $lexer; + private $parser; + private $compiler; + private $cache; + + protected $functions; + + public function __construct() + { + $this->functions = array(); + $this->registerFunctions(); + } + + /** + * Compiles an expression source code. + * + * @param string $expression The expression to compile + * @param array $names An array of valid names + * + * @return string The compiled PHP source code + */ + public function compile($expression, $names = array()) + { + return $this->getCompiler()->compile($this->parse($expression, $names))->getSource(); + } + + /** + * Evaluate an expression. + * + * @param Expression|string $expression The expression to compile + * @param array $values An array of values + * + * @return string The result of the evaluation of the expression + */ + public function evaluate($expression, $values = array()) + { + if ($expression instanceof ParsedExpression) { + $expression = $expression->getNodes(); + } else { + $expression = $this->parse($expression, array_keys($values)); + } + + return $expression->evaluate($this->functions, $values); + } + + /** + * Parses an expression. + * + * @param Expression|string $expression The expression to parse + * + * @return Node A Node tree + */ + public function parse($expression, $names) + { + $key = $expression.'//'.implode('-', $names); + + if (!isset($this->cache[$key])) { + $this->cache[$key] = $this->getParser()->parse($this->getLexer()->tokenize((string) $expression), $names); + } + + return $this->cache[$key]; + } + + public function addFunction($name, $compiler, $evaluator) + { + $this->functions[$name] = array('compiler' => $compiler, 'evaluator' => $evaluator); + } + + protected function registerFunctions() + { + $this->addFunction('constant', function ($constant) { + return sprintf('constant(%s)', $constant); + }, function (array $values, $constant) { + return constant($constant); + }); + } + + private function getLexer() + { + if (null === $this->lexer) { + $this->lexer = new Lexer(); + } + + return $this->lexer; + } + + private function getParser() + { + if (null === $this->parser) { + $this->parser = new Parser($this->functions); + } + + return $this->parser; + } + + private function getCompiler() + { + if (null === $this->compiler) { + $this->compiler = new Compiler($this->functions); + } + + return $this->compiler->reset(); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/LICENSE b/src/Symfony/Component/ExpressionLanguage/LICENSE new file mode 100644 index 0000000000000..88a57f8d8da49 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-2013 Fabien Potencier + +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/src/Symfony/Component/ExpressionLanguage/Lexer.php b/src/Symfony/Component/ExpressionLanguage/Lexer.php new file mode 100644 index 0000000000000..1a12d33765234 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Lexer.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Lexes an expression. + * + * @author Fabien Potencier + */ +class Lexer +{ + /** + * Tokenizes an expression. + * + * @param string $expression The expression to tokenize + * + * @return TokenStream A token stream instance + */ + public function tokenize($expression) + { + $expression = str_replace(array("\r", "\n", "\t", "\v", "\f"), ' ', $expression); + $cursor = 0; + $tokens = array(); + $brackets = array(); + $end = strlen($expression); + + while ($cursor < $end) { + while (' ' == $expression[$cursor]) { + ++$cursor; + } + + if (preg_match('/[0-9]+(?:\.[0-9]+)?/A', $expression, $match, null, $cursor)) { + // numbers + $number = (float) $match[0]; // floats + if (ctype_digit($match[0]) && $number <= PHP_INT_MAX) { + $number = (int) $match[0]; // integers lower than the maximum + } + $tokens[] = new Token(Token::NUMBER_TYPE, $number, $cursor + 1); + $cursor += strlen($match[0]); + } elseif (false !== strpos('([{', $expression[$cursor])) { + // opening bracket + $brackets[] = array($expression[$cursor], $cursor); + + $tokens[] = new Token(Token::PUNCTUATION_TYPE, $expression[$cursor], $cursor + 1); + ++$cursor; + } elseif (false !== strpos(')]}', $expression[$cursor])) { + // closing bracket + if (empty($brackets)) { + throw new SyntaxError(sprintf('Unexpected "%s"', $expression[$cursor]), $cursor); + } + + list($expect, $cur) = array_pop($brackets); + if ($expression[$cursor] != strtr($expect, '([{', ')]}')) { + throw new SyntaxError(sprintf('Unclosed "%s"', $expect), $cur); + } + + $tokens[] = new Token(Token::PUNCTUATION_TYPE, $expression[$cursor], $cursor + 1); + ++$cursor; + } elseif (false !== strpos('.,?:', $expression[$cursor])) { + // punctuation + $tokens[] = new Token(Token::PUNCTUATION_TYPE, $expression[$cursor], $cursor + 1); + ++$cursor; + } elseif (preg_match('/"([^#"\\\\]*(?:\\\\.[^#"\\\\]*)*)"|\'([^\'\\\\]*(?:\\\\.[^\'\\\\]*)*)\'/As', $expression, $match, null, $cursor)) { + // strings + $tokens[] = new Token(Token::STRING_TYPE, stripcslashes(substr($match[0], 1, -1)), $cursor + 1); + $cursor += strlen($match[0]); + } elseif (preg_match('/not in(?=[\s(])|\!\=\=|not(?=[\s(])|and(?=[\s(])|\=\=\=|\>\=|or(?=[\s(])|\<\=|\*\*|\.\.|in(?=[\s(])|&&|\|\||\!~|\=~|\=\=|\!\=|\*|~|%|\/|\>|\||\!|\^|&|\+|\<|\-/A', $expression, $match, null, $cursor)) { + // operators + $tokens[] = new Token(Token::OPERATOR_TYPE, $match[0], $cursor + 1); + $cursor += strlen($match[0]); + } elseif (preg_match('/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/A', $expression, $match, null, $cursor)) { + // names + $tokens[] = new Token(Token::NAME_TYPE, $match[0], $cursor + 1); + $cursor += strlen($match[0]); + } else { + // unlexable + throw new SyntaxError(sprintf('Unexpected character "%s"', $expression[$cursor]), $cursor); + } + } + + $tokens[] = new Token(Token::EOF_TYPE, null, $cursor + 1); + + if (!empty($brackets)) { + list($expect, $cur) = array_pop($brackets); + throw new SyntaxError(sprintf('Unclosed "%s"', $expect), $cur); + } + + return new TokenStream($tokens); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Node/ArgumentsNode.php b/src/Symfony/Component/ExpressionLanguage/Node/ArgumentsNode.php new file mode 100644 index 0000000000000..f440101684396 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Node/ArgumentsNode.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 Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +class ArgumentsNode extends ArrayNode +{ + public function compile(Compiler $compiler) + { + $this->compileArguments($compiler, false); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Node/ArrayNode.php b/src/Symfony/Component/ExpressionLanguage/Node/ArrayNode.php new file mode 100644 index 0000000000000..e4084f5b8219d --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Node/ArrayNode.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +class ArrayNode extends Node +{ + protected $index; + + public function __construct() + { + $this->index = -1; + } + + public function addElement(Node $value, Node $key = null) + { + if (null === $key) { + $key = new ConstantNode(++$this->index); + } + + array_push($this->nodes, $key, $value); + } + + /** + * Compiles the node to PHP. + * + * @param Compiler A Compiler instance + */ + public function compile(Compiler $compiler) + { + $compiler->raw('array('); + $this->compileArguments($compiler); + $compiler->raw(')'); + } + + public function evaluate($functions, $values) + { + $result = array(); + foreach ($this->getKeyValuePairs() as $pair) { + $result[$pair['key']->evaluate($functions, $values)] = $pair['value']->evaluate($functions, $values); + } + + return $result; + } + + protected function getKeyValuePairs() + { + $pairs = array(); + foreach (array_chunk($this->nodes, 2) as $pair) { + $pairs[] = array('key' => $pair[0], 'value' => $pair[1]); + } + + return $pairs; + } + + protected function compileArguments(Compiler $compiler, $withKeys = true) + { + $first = true; + foreach ($this->getKeyValuePairs() as $pair) { + if (!$first) { + $compiler->raw(', '); + } + $first = false; + + if ($withKeys) { + $compiler + ->compile($pair['key']) + ->raw(' => ') + ; + } + + $compiler->compile($pair['value']); + } + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Node/BinaryNode.php b/src/Symfony/Component/ExpressionLanguage/Node/BinaryNode.php new file mode 100644 index 0000000000000..c2bd58df73f3e --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Node/BinaryNode.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +class BinaryNode extends Node +{ + private $operators = array( + '~' => '.', + 'and' => '&&', + 'or' => '||', + ); + + private $functions = array( + '**' => 'pow', + '..' => 'range', + 'in' => 'in_array', + 'not in' => '!in_array', + ); + + public function __construct($operator, Node $left, Node $right) + { + $this->nodes = array('left' => $left, 'right' => $right); + $this->attributes = array('operator' => $operator); + } + + public function compile(Compiler $compiler) + { + $operator = $this->attributes['operator']; + + if ('=~' == $operator || '!~' == $operator) { + $compiler + ->raw(('!~' == $operator ? '!' : '').'preg_match(') + ->compile($this->nodes['right']) + ->raw(', ') + ->compile($this->nodes['left']) + ->raw(')') + ; + + return; + } + + if (isset($this->functions[$operator])) { + $compiler + ->raw(sprintf('%s(', $this->functions[$operator])) + ->compile($this->nodes['left']) + ->raw(', ') + ->compile($this->nodes['right']) + ->raw(')') + ; + + return; + } + + if (isset($this->operators[$operator])) { + $operator = $this->operators[$operator]; + } + + $compiler + ->raw('(') + ->compile($this->nodes['left']) + ->raw(' ') + ->raw($operator) + ->raw(' ') + ->compile($this->nodes['right']) + ->raw(')') + ; + } + + public function evaluate($functions, $values) + { + $operator = $this->attributes['operator']; + $left = $this->nodes['left']->evaluate($functions, $values); + $right = $this->nodes['right']->evaluate($functions, $values); + + if (isset($this->functions[$operator])) { + if ('not in' == $operator) { + return !call_user_func('in_array', $left, $right); + } + + return call_user_func($this->functions[$operator], $left, $right); + } + + switch ($operator) { + case 'or': + case '||': + return $left || $right; + case 'and': + case '&&': + return $left && $right; + case '|': + return $left | $right; + case '^': + return $left ^ $right; + case '&': + return $left & $right; + case '==': + return $left == $right; + case '===': + return $left === $right; + case '!=': + return $left != $right; + case '!==': + return $left !== $right; + case '<': + return $left < $right; + case '>': + return $left > $right; + case '>=': + return $left >= $right; + case '<=': + return $left <= $right; + case 'not in': + return !in_array($left, $right); + case 'in': + return in_array($left, $right); + case '+': + return $left + $right; + case '-': + return $left - $right; + case '~': + return $left.$right; + case '*': + return $left * $right; + case '/': + return $left / $right; + case '%': + return $left % $right; + case '=~': + return preg_match($right, $left); + case '!~': + return !preg_match($right, $left); + } + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Node/ConditionalNode.php b/src/Symfony/Component/ExpressionLanguage/Node/ConditionalNode.php new file mode 100644 index 0000000000000..b77dcc5aada3b --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Node/ConditionalNode.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +class ConditionalNode extends Node +{ + public function __construct(Node $expr1, Node $expr2, Node $expr3) + { + $this->nodes = array('expr1' => $expr1, 'expr2' => $expr2, 'expr3' => $expr3); + } + + public function compile(Compiler $compiler) + { + $compiler + ->raw('((') + ->compile($this->nodes['expr1']) + ->raw(') ? (') + ->compile($this->nodes['expr2']) + ->raw(') : (') + ->compile($this->nodes['expr3']) + ->raw('))') + ; + } + + public function evaluate($functions, $values) + { + if ($this->nodes['expr1']->evaluate($functions, $values)) { + return $this->nodes['expr2']->evaluate($functions, $values); + } + + return $this->nodes['expr3']->evaluate($functions, $values); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Node/ConstantNode.php b/src/Symfony/Component/ExpressionLanguage/Node/ConstantNode.php new file mode 100644 index 0000000000000..fcc1ce6fb607f --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Node/ConstantNode.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +class ConstantNode extends Node +{ + public function __construct($value) + { + $this->attributes = array('value' => $value); + } + + public function compile(Compiler $compiler) + { + $compiler->repr($this->attributes['value']); + } + + public function evaluate($functions, $values) + { + return $this->attributes['value']; + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Node/FunctionNode.php b/src/Symfony/Component/ExpressionLanguage/Node/FunctionNode.php new file mode 100644 index 0000000000000..a7b090591ab8d --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Node/FunctionNode.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +class FunctionNode extends Node +{ + public function __construct($name, Node $arguments) + { + $this->nodes = array('arguments' => $arguments); + $this->attributes = array('name' => $name); + } + + public function compile(Compiler $compiler) + { + $arguments = array(); + foreach ($this->nodes['arguments']->nodes as $node) { + $arguments[] = $compiler->subcompile($node); + } + + $function = $compiler->getFunction($this->attributes['name']); + + $compiler->raw(call_user_func_array($function['compiler'], $arguments)); + } + + public function evaluate($functions, $values) + { + $arguments = array($values); + foreach ($this->nodes['arguments']->nodes as $node) { + $arguments[] = $node->evaluate($functions, $values); + } + + return call_user_func_array($functions[$this->attributes['name']]['evaluator'], $arguments); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Node/GetAttrNode.php b/src/Symfony/Component/ExpressionLanguage/Node/GetAttrNode.php new file mode 100644 index 0000000000000..2f1156ca766fb --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Node/GetAttrNode.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +class GetAttrNode extends Node +{ + const PROPERTY_CALL = 1; + const METHOD_CALL = 2; + const ARRAY_CALL = 3; + + public function __construct(Node $node, Node $attribute, ArrayNode $arguments, $type) + { + $this->nodes = array('node' => $node, 'attribute' => $attribute, 'arguments' => $arguments); + $this->attributes = array('type' => $type); + } + + public function compile(Compiler $compiler) + { + switch ($this->attributes['type']) { + case self::PROPERTY_CALL: + $compiler + ->compile($this->nodes['node']) + ->raw('->') + ->raw($this->nodes['attribute']->attributes['value']) + ; + break; + + case self::METHOD_CALL: + $compiler + ->compile($this->nodes['node']) + ->raw('->') + ->raw($this->nodes['attribute']->attributes['value']) + ->raw('(') + ->compile($this->nodes['arguments']) + ->raw(')') + ; + break; + + case self::ARRAY_CALL: + $compiler + ->compile($this->nodes['node']) + ->raw('[') + ->compile($this->nodes['attribute'])->raw(']') + ; + break; + } + } + + public function evaluate($functions, $values) + { + switch ($this->attributes['type']) { + case self::PROPERTY_CALL: + $obj = $this->nodes['node']->evaluate($functions, $values); + if (!is_object($obj)) { + throw new \RuntimeException('Unable to get a property on a non-object.'); + } + + $property = $this->nodes['attribute']->attributes['value']; + + return $obj->$property; + + case self::METHOD_CALL: + $obj = $this->nodes['node']->evaluate($functions, $values); + if (!is_object($obj)) { + throw new \RuntimeException('Unable to get a property on a non-object.'); + } + + return call_user_func_array(array($obj, $this->nodes['attribute']->evaluate($functions, $values)), $this->nodes['arguments']->evaluate($functions, $values)); + + case self::ARRAY_CALL: + $values = $this->nodes['node']->evaluate($functions, $values); + if (!is_array($values) && !$values instanceof \ArrayAccess) { + throw new \RuntimeException('Unable to get an item on a non-array.'); + } + + return $values[$this->nodes['attribute']->evaluate($functions, $values)]; + } + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Node/NameNode.php b/src/Symfony/Component/ExpressionLanguage/Node/NameNode.php new file mode 100644 index 0000000000000..dbd0c780d9022 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Node/NameNode.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +class NameNode extends Node +{ + public function __construct($name) + { + $this->attributes = array('name' => $name); + } + + public function compile(Compiler $compiler) + { + $compiler->raw('$'.$this->attributes['name']); + } + + public function evaluate($functions, $values) + { + return $values[$this->attributes['name']]; + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Node/Node.php b/src/Symfony/Component/ExpressionLanguage/Node/Node.php new file mode 100644 index 0000000000000..da49d6b4b29cc --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Node/Node.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +/** + * Represents a node in the AST. + * + * @author Fabien Potencier + */ +class Node +{ + public $nodes = array(); + public $attributes = array(); + + /** + * Constructor. + * + * @param array $nodes An array of nodes + * @param array $attributes An array of attributes + */ + public function __construct(array $nodes = array(), array $attributes = array()) + { + $this->nodes = $nodes; + $this->attributes = $attributes; + } + + public function __toString() + { + $attributes = array(); + foreach ($this->attributes as $name => $value) { + $attributes[] = sprintf('%s: %s', $name, str_replace("\n", '', var_export($value, true))); + } + + $repr = array(str_replace('Symfony\Component\ExpressionLanguage\Node\\', '', get_class($this)).'('.implode(', ', $attributes)); + + if (count($this->nodes)) { + foreach ($this->nodes as $node) { + foreach (explode("\n", (string) $node) as $line) { + $repr[] = ' '.$line; + } + } + + $repr[] = ')'; + } else { + $repr[0] .= ')'; + } + + return implode("\n", $repr); + } + + public function compile(Compiler $compiler) + { + foreach ($this->nodes as $node) { + $node->compile($compiler); + } + } + + public function evaluate($functions, $values) + { + $results = array(); + foreach ($this->nodes as $node) { + $results[] = $node->evaluate($functions, $values); + } + + return $results; + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Node/UnaryNode.php b/src/Symfony/Component/ExpressionLanguage/Node/UnaryNode.php new file mode 100644 index 0000000000000..3ec88d117903f --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Node/UnaryNode.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +class UnaryNode extends Node +{ + private $operators = array( + '!' => '!', + 'not' => '!', + '+' => '+', + '-' => '-', + ); + + public function __construct($operator, Node $node) + { + $this->nodes = array('node' => $node); + $this->attributes = array('operator' => $operator); + } + + public function compile(Compiler $compiler) + { + $compiler + ->raw('(') + ->raw($this->operators[$this->attributes['operator']]) + ->compile($this->nodes['node']) + ->raw(')') + ; + } + + public function evaluate($functions, $values) + { + $value = $this->nodes['node']->evaluate($functions, $values); + switch ($this->attributes['operator']) { + case 'not': + case '!': + return !$value; + case '-': + return -$value; + } + + return $value; + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/ParsedExpression.php b/src/Symfony/Component/ExpressionLanguage/ParsedExpression.php new file mode 100644 index 0000000000000..61bf5807c49e7 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/ParsedExpression.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +use Symfony\Component\ExpressionLanguage\Node\Node; + +/** + * Represents an already parsed expression. + * + * @author Fabien Potencier + */ +class ParsedExpression extends Expression +{ + private $nodes; + + /** + * Constructor. + * + * @param string $expression An expression + * @param Node $nodes A Node representing the expression + */ + public function __construct($expression, Node $nodes) + { + parent::__construct($expression); + + $this->nodes = $nodes; + } + + public function getNodes() + { + return $this->nodes; + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Parser.php b/src/Symfony/Component/ExpressionLanguage/Parser.php new file mode 100644 index 0000000000000..f5eae2b4550ea --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Parser.php @@ -0,0 +1,358 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Parsers a token stream. + * + * This parser implements a "Precedence climbing" algorithm. + * + * @see http://www.engr.mun.ca/~theo/Misc/exp_parsing.htm + * @see http://en.wikipedia.org/wiki/Operator-precedence_parser + * + * @author Fabien Potencier + */ +class Parser +{ + const OPERATOR_LEFT = 1; + const OPERATOR_RIGHT = 2; + + private $stream; + private $unaryOperators; + private $binaryOperators; + private $functions; + private $names; + + public function __construct(array $functions) + { + $this->functions = $functions; + + $this->unaryOperators = array( + 'not' => array('precedence' => 50), + '!' => array('precedence' => 50), + '-' => array('precedence' => 500), + '+' => array('precedence' => 500), + ); + $this->binaryOperators = array( + 'or' => array('precedence' => 10, 'associativity' => Parser::OPERATOR_LEFT), + '||' => array('precedence' => 10, 'associativity' => Parser::OPERATOR_LEFT), + 'and' => array('precedence' => 15, 'associativity' => Parser::OPERATOR_LEFT), + '&&' => array('precedence' => 15, 'associativity' => Parser::OPERATOR_LEFT), + '|' => array('precedence' => 16, 'associativity' => Parser::OPERATOR_LEFT), + '^' => array('precedence' => 17, 'associativity' => Parser::OPERATOR_LEFT), + '&' => array('precedence' => 18, 'associativity' => Parser::OPERATOR_LEFT), + '==' => array('precedence' => 20, 'associativity' => Parser::OPERATOR_LEFT), + '===' => array('precedence' => 20, 'associativity' => Parser::OPERATOR_LEFT), + '!=' => array('precedence' => 20, 'associativity' => Parser::OPERATOR_LEFT), + '!==' => array('precedence' => 20, 'associativity' => Parser::OPERATOR_LEFT), + '<' => array('precedence' => 20, 'associativity' => Parser::OPERATOR_LEFT), + '>' => array('precedence' => 20, 'associativity' => Parser::OPERATOR_LEFT), + '>=' => array('precedence' => 20, 'associativity' => Parser::OPERATOR_LEFT), + '<=' => array('precedence' => 20, 'associativity' => Parser::OPERATOR_LEFT), + 'not in' => array('precedence' => 20, 'associativity' => Parser::OPERATOR_LEFT), + 'in' => array('precedence' => 20, 'associativity' => Parser::OPERATOR_LEFT), + '..' => array('precedence' => 25, 'associativity' => Parser::OPERATOR_LEFT), + '+' => array('precedence' => 30, 'associativity' => Parser::OPERATOR_LEFT), + '-' => array('precedence' => 30, 'associativity' => Parser::OPERATOR_LEFT), + '~' => array('precedence' => 40, 'associativity' => Parser::OPERATOR_LEFT), + '*' => array('precedence' => 60, 'associativity' => Parser::OPERATOR_LEFT), + '/' => array('precedence' => 60, 'associativity' => Parser::OPERATOR_LEFT), + '%' => array('precedence' => 60, 'associativity' => Parser::OPERATOR_LEFT), + '=~' => array('precedence' => 70, 'associativity' => Parser::OPERATOR_LEFT), + '!~' => array('precedence' => 70, 'associativity' => Parser::OPERATOR_LEFT), + '**' => array('precedence' => 200, 'associativity' => Parser::OPERATOR_RIGHT), + ); + } + + /** + * Converts a token stream to a node tree. + * + * @param TokenStream $stream A token stream instance + * @param array $names An array of valid names + * + * @return Node A node tree + */ + public function parse(TokenStream $stream, $names = array()) + { + $this->stream = $stream; + $this->names = $names; + + $node = $this->parseExpression(); + if (!$stream->isEOF()) { + throw new SyntaxError(sprintf('Unexpected token "%s" of value "%s"', $stream->current->type, $stream->current->value), $stream->current->cursor); + } + + return $node; + } + + public function parseExpression($precedence = 0) + { + $expr = $this->getPrimary(); + $token = $this->stream->current; + while ($token->test(Token::OPERATOR_TYPE) && isset($this->binaryOperators[$token->value]) && $this->binaryOperators[$token->value]['precedence'] >= $precedence) { + $op = $this->binaryOperators[$token->value]; + $this->stream->next(); + + $expr1 = $this->parseExpression(self::OPERATOR_LEFT === $op['associativity'] ? $op['precedence'] + 1 : $op['precedence']); + $expr = new Node\BinaryNode($token->value, $expr, $expr1); + + $token = $this->stream->current; + } + + if (0 === $precedence) { + return $this->parseConditionalExpression($expr); + } + + return $expr; + } + + protected function getPrimary() + { + $token = $this->stream->current; + + if ($token->test(Token::OPERATOR_TYPE) && isset($this->unaryOperators[$token->value])) { + $operator = $this->unaryOperators[$token->value]; + $this->stream->next(); + $expr = $this->parseExpression($operator['precedence']); + + return $this->parsePostfixExpression(new Node\UnaryNode($token->value, $expr)); + } + + if ($token->test(Token::PUNCTUATION_TYPE, '(')) { + $this->stream->next(); + $expr = $this->parseExpression(); + $this->stream->expect(Token::PUNCTUATION_TYPE, ')', 'An opened parenthesis is not properly closed'); + + return $this->parsePostfixExpression($expr); + } + + return $this->parsePrimaryExpression(); + } + + protected function parseConditionalExpression($expr) + { + while ($this->stream->current->test(Token::PUNCTUATION_TYPE, '?')) { + $this->stream->next(); + if (!$this->stream->current->test(Token::PUNCTUATION_TYPE, ':')) { + $expr2 = $this->parseExpression(); + if ($this->stream->current->test(Token::PUNCTUATION_TYPE, ':')) { + $this->stream->next(); + $expr3 = $this->parseExpression(); + } else { + $expr3 = new Node\ConstantNode(null); + } + } else { + $this->stream->next(); + $expr2 = $expr; + $expr3 = $this->parseExpression(); + } + + $expr = new Node\ConditionalNode($expr, $expr2, $expr3); + } + + return $expr; + } + + public function parsePrimaryExpression() + { + $token = $this->stream->current; + switch ($token->type) { + case Token::NAME_TYPE: + $this->stream->next(); + switch ($token->value) { + case 'true': + case 'TRUE': + return new Node\ConstantNode(true); + + case 'false': + case 'FALSE': + return new Node\ConstantNode(false); + + case 'null': + case 'NULL': + return new Node\ConstantNode(null); + + default: + if ('(' === $this->stream->current->value) { + if (false === isset($this->functions[$token->value])) { + throw new SyntaxError(sprintf('The function "%s" does not exist', $token->value), $token->cursor); + } + + $node = new Node\FunctionNode($token->value, $this->parseArguments()); + } else { + if (!in_array($token->value, $this->names)) { + throw new SyntaxError(sprintf('Variable "%s" is not valid', $token->value), $token->cursor); + } + + $node = new Node\NameNode($token->value); + } + } + break; + + case Token::NUMBER_TYPE: + case Token::STRING_TYPE: + $this->stream->next(); + return new Node\ConstantNode($token->value); + + default: + if ($token->test(Token::PUNCTUATION_TYPE, '[')) { + $node = $this->parseArrayExpression(); + } elseif ($token->test(Token::PUNCTUATION_TYPE, '{')) { + $node = $this->parseHashExpression(); + } else { + throw new SyntaxError(sprintf('Unexpected token "%s" of value "%s"', $token->type, $token->value), $token->cursor); + } + } + + return $this->parsePostfixExpression($node); + } + + public function parseArrayExpression() + { + $this->stream->expect(Token::PUNCTUATION_TYPE, '[', 'An array element was expected'); + + $node = new Node\ArrayNode(); + $first = true; + while (!$this->stream->current->test(Token::PUNCTUATION_TYPE, ']')) { + if (!$first) { + $this->stream->expect(Token::PUNCTUATION_TYPE, ',', 'An array element must be followed by a comma'); + + // trailing ,? + if ($this->stream->current->test(Token::PUNCTUATION_TYPE, ']')) { + break; + } + } + $first = false; + + $node->addElement($this->parseExpression()); + } + $this->stream->expect(Token::PUNCTUATION_TYPE, ']', 'An opened array is not properly closed'); + + return $node; + } + + public function parseHashExpression() + { + $this->stream->expect(Token::PUNCTUATION_TYPE, '{', 'A hash element was expected'); + + $node = new Node\ArrayNode(); + $first = true; + while (!$this->stream->current->test(Token::PUNCTUATION_TYPE, '}')) { + if (!$first) { + $this->stream->expect(Token::PUNCTUATION_TYPE, ',', 'A hash value must be followed by a comma'); + + // trailing ,? + if ($this->stream->current->test(Token::PUNCTUATION_TYPE, '}')) { + break; + } + } + $first = false; + + // a hash key can be: + // + // * a number -- 12 + // * a string -- 'a' + // * a name, which is equivalent to a string -- a + // * an expression, which must be enclosed in parentheses -- (1 + 2) + if ($this->stream->current->test(Token::STRING_TYPE) || $this->stream->current->test(Token::NAME_TYPE) || $this->stream->current->test(Token::NUMBER_TYPE)) { + $key = new Node\ConstantNode($this->stream->current->value); + $this->stream->next(); + } elseif ($this->stream->current->test(Token::PUNCTUATION_TYPE, '(')) { + $key = $this->parseExpression(); + } else { + $current = $this->stream->current; + + throw new SyntaxError(sprintf('A hash key must be a quoted string, a number, a name, or an expression enclosed in parentheses (unexpected token "%s" of value "%s"', $current->type, $current->value), $current->cursor); + } + + $this->stream->expect(Token::PUNCTUATION_TYPE, ':', 'A hash key must be followed by a colon (:)'); + $value = $this->parseExpression(); + + $node->addElement($value, $key); + } + $this->stream->expect(Token::PUNCTUATION_TYPE, '}', 'An opened hash is not properly closed'); + + return $node; + } + + public function parsePostfixExpression($node) + { + $token = $this->stream->current; + while ($token->type == Token::PUNCTUATION_TYPE) { + if ('.' === $token->value) { + $this->stream->next(); + $token = $this->stream->current; + $this->stream->next(); + + if ( + $token->type !== Token::NAME_TYPE + && + $token->type !== Token::NUMBER_TYPE + && + // operators line "not" are valid method or property names + ($token->type !== Token::OPERATOR_TYPE && preg_match('/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/A', $token->value)) + ) { + throw new SyntaxError('Expected name or number', $token->cursor); + } + + $arg = new Node\ConstantNode($token->value); + + $arguments = new Node\ArgumentsNode(); + if ($this->stream->current->test(Token::PUNCTUATION_TYPE, '(')) { + $type = Node\GetAttrNode::METHOD_CALL; + foreach ($this->parseArguments()->nodes as $n) { + $arguments->addElement($n); + } + } else { + $type = Node\GetAttrNode::PROPERTY_CALL; + } + + $node = new Node\GetAttrNode($node, $arg, $arguments, $type); + } elseif ('[' === $token->value) { + if ($node instanceof Node\GetAttrNode && Node\GetAttrNode::METHOD_CALL === $node->attributes['type'] && version_compare(PHP_VERSION, '5.4.0', '<')) { + throw new SyntaxError('Array calls on a method call is only supported on PHP 5.4+', $token->cursor); + } + + $this->stream->next(); + $arg = $this->parseExpression(); + $this->stream->expect(Token::PUNCTUATION_TYPE, ']'); + + $node = new Node\GetAttrNode($node, $arg, new Node\ArgumentsNode(), Node\GetAttrNode::ARRAY_CALL); + } else { + break; + } + + $token = $this->stream->current; + } + + return $node; + } + + /** + * Parses arguments. + */ + public function parseArguments() + { + $args = array(); + $this->stream->expect(Token::PUNCTUATION_TYPE, '(', 'A list of arguments must begin with an opening parenthesis'); + while (!$this->stream->current->test(Token::PUNCTUATION_TYPE, ')')) { + if (!empty($args)) { + $this->stream->expect(Token::PUNCTUATION_TYPE, ',', 'Arguments must be separated by a comma'); + } + + $args[] = $this->parseExpression(); + } + $this->stream->expect(Token::PUNCTUATION_TYPE, ')', 'A list of arguments must be closed by a parenthesis'); + + return new Node\Node($args); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/README.md b/src/Symfony/Component/ExpressionLanguage/README.md new file mode 100644 index 0000000000000..648dedcbcfee4 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/README.md @@ -0,0 +1,43 @@ +ExpressionLanguage Component +============================ + +The ExpressionLanguage component provides an engine that can compile and +evaluate expressions: + + use Symfony\Component\ExpressionLanguage\ExpressionLanguage; + + $language = new ExpressionLanguage(); + + echo $language->evaluate('1 + foo', array('foo' => 2)); + // would output 3 + + echo $language->compile('1 + foo'); + // would output (1 + $foo) + +By default, the engine implements simple math and logic functions, method +calls, property accesses, and array accesses. + +You can extend your DSL with functions: + + $compiler = function ($arg) { + return sprintf('strtoupper(%s)', $arg); + }; + $evaluator = function (array $variables, $value) { + return strtoupper($value); + }; + $language->addFunction('upper', $compiler, $evaluator); + + echo $language->evaluate('"foo" ~ upper(foo)', array('foo' => 'bar')); + // would output fooBAR + + echo $language->compile('"foo" ~ upper(foo)'); + // would output ("foo" . strtoupper($foo)) + +Resources +--------- + +You can run the unit tests with the following command: + + $ cd path/to/Symfony/Component/ExpressionLanguage/ + $ composer.phar install --dev + $ phpunit diff --git a/src/Symfony/Component/ExpressionLanguage/Resources/bin/generate_operator_regex.php b/src/Symfony/Component/ExpressionLanguage/Resources/bin/generate_operator_regex.php new file mode 100644 index 0000000000000..cc722ea76a9b1 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Resources/bin/generate_operator_regex.php @@ -0,0 +1,14 @@ +', '>=', '<=', 'not in', 'in', '..', '+', '-', '~', '*', '/', '%', '=~', '!~', '**'); +$operators = array_combine($operators, array_map('strlen', $operators)); +arsort($operators); + +$regex = array(); +foreach ($operators as $operator => $length) { + // an operator that ends with a character must be followed by + // a whitespace or a parenthesis + $regex[] = preg_quote($operator, '/').(ctype_alpha($operator[$length - 1]) ? '(?=[\s(])' : ''); +} + +echo '/'.implode('|', $regex).'/A'; diff --git a/src/Symfony/Component/ExpressionLanguage/SerializedParsedExpression.php b/src/Symfony/Component/ExpressionLanguage/SerializedParsedExpression.php new file mode 100644 index 0000000000000..b1b09b69b9571 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/SerializedParsedExpression.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 Symfony\Component\ExpressionLanguage; + +use Symfony\Component\ExpressionLanguage\Node\Node; + +/** + * Represents an already parsed expression. + * + * @author Fabien Potencier + */ +class SerializedParsedExpression extends ParsedExpression +{ + private $nodes; + + /** + * Constructor. + * + * @param string $expression An expression + * @param string $nodes The serialized nodes for the expression + */ + public function __construct($expression, $nodes) + { + $this->expression = (string) $expression; + $this->nodes = $nodes; + } + + public function getNodes() + { + return unserialize($this->nodes); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/SyntaxError.php b/src/Symfony/Component/ExpressionLanguage/SyntaxError.php new file mode 100644 index 0000000000000..d149c00768ad8 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/SyntaxError.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +class SyntaxError extends \LogicException +{ + public function __construct($message, $cursor = 0) + { + parent::__construct(sprintf('%s around position %d.', $message, $cursor)); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/LexerTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/LexerTest.php new file mode 100644 index 0000000000000..a69eb34809735 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Tests/LexerTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Lexer; +use Symfony\Component\ExpressionLanguage\Token; +use Symfony\Component\ExpressionLanguage\TokenStream; + +class LexerTest extends \PHPUnit_Framework_TestCase +{ + /** + * @dataProvider getTokenizeData + */ + public function testTokenize($tokens, $expression) + { + $tokens[] = new Token('end of expression', null, strlen($expression) + 1); + $lexer = new Lexer(); + $this->assertEquals(new TokenStream($tokens), $lexer->tokenize($expression)); + } + + public function getTokenizeData() + { + return array( + array( + array(new Token('name', 'a', 1)), + 'a', + ), + array( + array(new Token('string', 'foo', 1)), + '"foo"', + ), + array( + array(new Token('number', '3', 1)), + '3', + ), + array( + array(new Token('operator', '+', 1)), + '+', + ), + array( + array(new Token('punctuation', '.', 1)), + '.', + ), + array( + array( + new Token('punctuation', '(', 1), + new Token('number', '3', 2), + new Token('operator', '+', 4), + new Token('number', '5', 6), + new Token('punctuation', ')', 7), + new Token('operator', '~', 9), + new Token('name', 'foo', 11), + new Token('punctuation', '(', 14), + new Token('string', 'bar', 15), + new Token('punctuation', ')', 20), + new Token('punctuation', '.', 21), + new Token('name', 'baz', 22), + new Token('punctuation', '[', 25), + new Token('number', '4', 26), + new Token('punctuation', ']', 27), + ), + '(3 + 5) ~ foo("bar").baz[4]', + ), + ); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/Node/AbstractNodeTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/Node/AbstractNodeTest.php new file mode 100644 index 0000000000000..58b0e177e8e1a --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Tests/Node/AbstractNodeTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +abstract class AbstractNodeTest extends \PHPUnit_Framework_TestCase +{ + /** + * @dataProvider getEvaluateData + */ + public function testEvaluate($expected, $node, $variables = array(), $functions = array()) + { + $this->assertSame($expected, $node->evaluate($functions, $variables)); + } + + abstract public function getEvaluateData(); + + /** + * @dataProvider getCompileData + */ + public function testCompile($expected, $node, $functions = array()) + { + $compiler = new Compiler($functions); + $node->compile($compiler); + $this->assertSame($expected, $compiler->getSource()); + } + + abstract public function getCompileData(); +} diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/Node/ArgumentsNodeTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/Node/ArgumentsNodeTest.php new file mode 100644 index 0000000000000..42475b02768da --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Tests/Node/ArgumentsNodeTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\ArgumentsNode; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; + +class ArgumentsNodeTest extends ArrayNodeTest +{ + public function getCompileData() + { + return array( + array('"a", "b"', $this->getArrayNode()), + ); + } + + protected function createArrayNode() + { + return new ArgumentsNode(); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/Node/ArrayNodeTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/Node/ArrayNodeTest.php new file mode 100644 index 0000000000000..24d1bb06fb2ec --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Tests/Node/ArrayNodeTest.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\ArrayNode; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; + +class ArrayNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + return array( + array(array('b' => 'a', 'b'), $this->getArrayNode()), + ); + } + + public function getCompileData() + { + return array( + array('array("b" => "a", 0 => "b")', $this->getArrayNode()), + ); + } + + protected function getArrayNode() + { + $array = $this->createArrayNode(); + $array->addElement(new ConstantNode('a'), new ConstantNode('b')); + $array->addElement(new ConstantNode('b')); + + return $array; + } + + protected function createArrayNode() + { + return new ArrayNode(); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/Node/BinaryNodeTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/Node/BinaryNodeTest.php new file mode 100644 index 0000000000000..fb5389cb4988b --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Tests/Node/BinaryNodeTest.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\BinaryNode; +use Symfony\Component\ExpressionLanguage\Node\ArrayNode; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; + +class BinaryNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + $array = new ArrayNode(); + $array->addElement(new ConstantNode('a')); + $array->addElement(new ConstantNode('b')); + + return array( + array(true, new BinaryNode('or', new ConstantNode(true), new ConstantNode(false))), + array(true, new BinaryNode('||', new ConstantNode(true), new ConstantNode(false))), + array(false, new BinaryNode('and', new ConstantNode(true), new ConstantNode(false))), + array(false, new BinaryNode('&&', new ConstantNode(true), new ConstantNode(false))), + + array(0, new BinaryNode('&', new ConstantNode(2), new ConstantNode(4))), + array(6, new BinaryNode('|', new ConstantNode(2), new ConstantNode(4))), + array(6, new BinaryNode('^', new ConstantNode(2), new ConstantNode(4))), + + array(true, new BinaryNode('<', new ConstantNode(1), new ConstantNode(2))), + array(true, new BinaryNode('<=', new ConstantNode(1), new ConstantNode(2))), + array(true, new BinaryNode('<=', new ConstantNode(1), new ConstantNode(1))), + + array(false, new BinaryNode('>', new ConstantNode(1), new ConstantNode(2))), + array(false, new BinaryNode('>=', new ConstantNode(1), new ConstantNode(2))), + array(true, new BinaryNode('>=', new ConstantNode(1), new ConstantNode(1))), + + array(true, new BinaryNode('===', new ConstantNode(true), new ConstantNode(true))), + array(false, new BinaryNode('!==', new ConstantNode(true), new ConstantNode(true))), + + array(false, new BinaryNode('==', new ConstantNode(2), new ConstantNode(1))), + array(true, new BinaryNode('!=', new ConstantNode(2), new ConstantNode(1))), + + array(-1, new BinaryNode('-', new ConstantNode(1), new ConstantNode(2))), + array(3, new BinaryNode('+', new ConstantNode(1), new ConstantNode(2))), + array(4, new BinaryNode('*', new ConstantNode(2), new ConstantNode(2))), + array(1, new BinaryNode('/', new ConstantNode(2), new ConstantNode(2))), + array(1, new BinaryNode('%', new ConstantNode(5), new ConstantNode(2))), + array(25, new BinaryNode('**', new ConstantNode(5), new ConstantNode(2))), + array('ab', new BinaryNode('~', new ConstantNode('a'), new ConstantNode('b'))), + + array(true, new BinaryNode('in', new ConstantNode('a'), $array)), + array(false, new BinaryNode('in', new ConstantNode('c'), $array)), + array(true, new BinaryNode('not in', new ConstantNode('c'), $array)), + array(false, new BinaryNode('not in', new ConstantNode('a'), $array)), + + array(array(1, 2, 3), new BinaryNode('..', new ConstantNode(1), new ConstantNode(3))), + + array(1, new BinaryNode('=~', new ConstantNode('abc'), new ConstantNode('/^[a-z]+$/'))), + array(false, new BinaryNode('!~', new ConstantNode('abc'), new ConstantNode('/^[a-z]+$/'))), + ); + } + + public function getCompileData() + { + $array = new ArrayNode(); + $array->addElement(new ConstantNode('a')); + $array->addElement(new ConstantNode('b')); + + return array( + array('(true || false)', new BinaryNode('or', new ConstantNode(true), new ConstantNode(false))), + array('(true || false)', new BinaryNode('||', new ConstantNode(true), new ConstantNode(false))), + array('(true && false)', new BinaryNode('and', new ConstantNode(true), new ConstantNode(false))), + array('(true && false)', new BinaryNode('&&', new ConstantNode(true), new ConstantNode(false))), + + array('(2 & 4)', new BinaryNode('&', new ConstantNode(2), new ConstantNode(4))), + array('(2 | 4)', new BinaryNode('|', new ConstantNode(2), new ConstantNode(4))), + array('(2 ^ 4)', new BinaryNode('^', new ConstantNode(2), new ConstantNode(4))), + + array('(1 < 2)', new BinaryNode('<', new ConstantNode(1), new ConstantNode(2))), + array('(1 <= 2)', new BinaryNode('<=', new ConstantNode(1), new ConstantNode(2))), + array('(1 <= 1)', new BinaryNode('<=', new ConstantNode(1), new ConstantNode(1))), + + array('(1 > 2)', new BinaryNode('>', new ConstantNode(1), new ConstantNode(2))), + array('(1 >= 2)', new BinaryNode('>=', new ConstantNode(1), new ConstantNode(2))), + array('(1 >= 1)', new BinaryNode('>=', new ConstantNode(1), new ConstantNode(1))), + + array('(true === true)', new BinaryNode('===', new ConstantNode(true), new ConstantNode(true))), + array('(true !== true)', new BinaryNode('!==', new ConstantNode(true), new ConstantNode(true))), + + array('(2 == 1)', new BinaryNode('==', new ConstantNode(2), new ConstantNode(1))), + array('(2 != 1)', new BinaryNode('!=', new ConstantNode(2), new ConstantNode(1))), + + array('(1 - 2)', new BinaryNode('-', new ConstantNode(1), new ConstantNode(2))), + array('(1 + 2)', new BinaryNode('+', new ConstantNode(1), new ConstantNode(2))), + array('(2 * 2)', new BinaryNode('*', new ConstantNode(2), new ConstantNode(2))), + array('(2 / 2)', new BinaryNode('/', new ConstantNode(2), new ConstantNode(2))), + array('(5 % 2)', new BinaryNode('%', new ConstantNode(5), new ConstantNode(2))), + array('pow(5, 2)', new BinaryNode('**', new ConstantNode(5), new ConstantNode(2))), + array('("a" . "b")', new BinaryNode('~', new ConstantNode('a'), new ConstantNode('b'))), + + array('in_array("a", array(0 => "a", 1 => "b"))', new BinaryNode('in', new ConstantNode('a'), $array)), + array('in_array("c", array(0 => "a", 1 => "b"))', new BinaryNode('in', new ConstantNode('c'), $array)), + array('!in_array("c", array(0 => "a", 1 => "b"))', new BinaryNode('not in', new ConstantNode('c'), $array)), + array('!in_array("a", array(0 => "a", 1 => "b"))', new BinaryNode('not in', new ConstantNode('a'), $array)), + + array('range(1, 3)', new BinaryNode('..', new ConstantNode(1), new ConstantNode(3))), + + array('preg_match("/^[a-z]+/i\$/", "abc")', new BinaryNode('=~', new ConstantNode('abc'), new ConstantNode('/^[a-z]+/i$/'))), + array('!preg_match("/^[a-z]+\$/", "abc")', new BinaryNode('!~', new ConstantNode('abc'), new ConstantNode('/^[a-z]+$/'))), + ); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/Node/ConditionalNodeTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/Node/ConditionalNodeTest.php new file mode 100644 index 0000000000000..9b9f7a27243e0 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Tests/Node/ConditionalNodeTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\ConditionalNode; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; + +class ConditionalNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + return array( + array(1, new ConditionalNode(new ConstantNode(true), new ConstantNode(1), new ConstantNode(2))), + array(2, new ConditionalNode(new ConstantNode(false), new ConstantNode(1), new ConstantNode(2))), + ); + } + + public function getCompileData() + { + return array( + array('((true) ? (1) : (2))', new ConditionalNode(new ConstantNode(true), new ConstantNode(1), new ConstantNode(2))), + array('((false) ? (1) : (2))', new ConditionalNode(new ConstantNode(false), new ConstantNode(1), new ConstantNode(2))), + ); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/Node/ConstantNodeTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/Node/ConstantNodeTest.php new file mode 100644 index 0000000000000..c1a67a8603829 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Tests/Node/ConstantNodeTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; + +class ConstantNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + return array( + array(false, new ConstantNode(false)), + array(true, new ConstantNode(true)), + array(null, new ConstantNode(null)), + array(3, new ConstantNode(3)), + array(3.3, new ConstantNode(3.3)), + array('foo', new ConstantNode('foo')), + array(array(1, 'b' => 'a'), new ConstantNode(array(1, 'b' => 'a'))), + ); + } + + public function getCompileData() + { + return array( + array('false', new ConstantNode(false)), + array('true', new ConstantNode(true)), + array('null', new ConstantNode(null)), + array('3', new ConstantNode(3)), + array('3.3', new ConstantNode(3.3)), + array('"foo"', new ConstantNode('foo')), + array('array(0 => 1, "b" => "a")', new ConstantNode(array(1, 'b' => 'a'))), + ); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/Node/FunctionNodeTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/Node/FunctionNodeTest.php new file mode 100644 index 0000000000000..ecdc3d63717bc --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Tests/Node/FunctionNodeTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\FunctionNode; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; +use Symfony\Component\ExpressionLanguage\Node\Node; + +class FunctionNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + return array( + array('bar', new FunctionNode('foo', new Node(array(new ConstantNode('bar')))), array(), array('foo' => $this->getCallables())), + ); + } + + public function getCompileData() + { + return array( + array('foo("bar")', new FunctionNode('foo', new Node(array(new ConstantNode('bar')))), array('foo' => $this->getCallables())), + ); + } + + protected function getCallables() + { + return array( + 'compiler' => function ($arg) { + return sprintf('foo(%s)', $arg); + }, + 'evaluator' => function ($variables, $arg) { + return $arg; + }, + ); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/Node/GetAttrNodeTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/Node/GetAttrNodeTest.php new file mode 100644 index 0000000000000..ec0b69281f58b --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Tests/Node/GetAttrNodeTest.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 Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\ArrayNode; +use Symfony\Component\ExpressionLanguage\Node\NameNode; +use Symfony\Component\ExpressionLanguage\Node\GetAttrNode; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; + +class GetAttrNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + return array( + array('b', new GetAttrNode(new NameNode('foo'), new ConstantNode(0), $this->getArrayNode(), GetAttrNode::ARRAY_CALL), array('foo' => array('b' => 'a', 'b'))), + array('a', new GetAttrNode(new NameNode('foo'), new ConstantNode('b'), $this->getArrayNode(), GetAttrNode::ARRAY_CALL), array('foo' => array('b' => 'a', 'b'))), + + array('bar', new GetAttrNode(new NameNode('foo'), new ConstantNode('foo'), $this->getArrayNode(), GetAttrNode::PROPERTY_CALL), array('foo' => new Obj())), + + array('baz', new GetAttrNode(new NameNode('foo'), new ConstantNode('foo'), $this->getArrayNode(), GetAttrNode::METHOD_CALL), array('foo' => new Obj())), + ); + } + + public function getCompileData() + { + return array( + array('$foo[0]', new GetAttrNode(new NameNode('foo'), new ConstantNode(0), $this->getArrayNode(), GetAttrNode::ARRAY_CALL)), + array('$foo["b"]', new GetAttrNode(new NameNode('foo'), new ConstantNode('b'), $this->getArrayNode(), GetAttrNode::ARRAY_CALL)), + + array('$foo->foo', new GetAttrNode(new NameNode('foo'), new ConstantNode('foo'), $this->getArrayNode(), GetAttrNode::PROPERTY_CALL), array('foo' => new Obj())), + + array('$foo->foo(array("b" => "a", 0 => "b"))', new GetAttrNode(new NameNode('foo'), new ConstantNode('foo'), $this->getArrayNode(), GetAttrNode::METHOD_CALL), array('foo' => new Obj())), + ); + } + + protected function getArrayNode() + { + $array = new ArrayNode(); + $array->addElement(new ConstantNode('a'), new ConstantNode('b')); + $array->addElement(new ConstantNode('b')); + + return $array; + } +} + +class Obj +{ + public $foo = 'bar'; + + public function foo() + { + return 'baz'; + } +} + diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/Node/NameNodeTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/Node/NameNodeTest.php new file mode 100644 index 0000000000000..b645a6bfffd42 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Tests/Node/NameNodeTest.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\NameNode; + +class NameNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + return array( + array('bar', new NameNode('foo'), array('foo' => 'bar')), + ); + } + + public function getCompileData() + { + return array( + array('$foo', new NameNode('foo')), + ); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/Node/NodeTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/Node/NodeTest.php new file mode 100644 index 0000000000000..4365e8a1f67ce --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Tests/Node/NodeTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\Node; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; + +class NodeTest extends \PHPUnit_Framework_TestCase +{ + public function testToString() + { + $node = new Node(array(new ConstantNode('foo'))); + + $this->assertEquals(<< + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\UnaryNode; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; + +class UnaryNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + return array( + array(-1, new UnaryNode('-', new ConstantNode(1))), + array(3, new UnaryNode('+', new ConstantNode(3))), + array(false, new UnaryNode('!', new ConstantNode(true))), + array(false, new UnaryNode('not', new ConstantNode(true))), + ); + } + + public function getCompileData() + { + return array( + array('(-1)', new UnaryNode('-', new ConstantNode(1))), + array('(+3)', new UnaryNode('+', new ConstantNode(3))), + array('(!true)', new UnaryNode('!', new ConstantNode(true))), + array('(!true)', new UnaryNode('not', new ConstantNode(true))), + ); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php new file mode 100644 index 0000000000000..c1c33dd426fe4 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Parser; +use Symfony\Component\ExpressionLanguage\Lexer; +use Symfony\Component\ExpressionLanguage\Node; + +class ParserTest extends \PHPUnit_Framework_TestCase +{ + /** + * @expectedException \Symfony\Component\ExpressionLanguage\SyntaxError + * @expectedExceptionMessage Variable "foo" is not valid around position 1. + */ + public function testParseWithInvalidName() + { + $lexer = new Lexer(); + $parser = new Parser(array()); + $parser->parse($lexer->tokenize('foo')); + } + + /** + * @dataProvider getParseData + */ + public function testParse($node, $expression, $names = array()) + { + $lexer = new Lexer(); + $parser = new Parser(array()); + $this->assertEquals($node, $parser->parse($lexer->tokenize($expression), $names)); + } + + public function getParseData() + { + $arguments = new Node\ArgumentsNode(); + $arguments->addElement(new Node\ConstantNode('arg1')); + $arguments->addElement(new Node\ConstantNode(2)); + $arguments->addElement(new Node\ConstantNode(true)); + + return array( + array( + new Node\NameNode('a'), + 'a', + array('a'), + ), + array( + new Node\ConstantNode('a'), + '"a"', + ), + array( + new Node\ConstantNode(3), + '3', + ), + array( + new Node\ConstantNode(false), + 'false', + ), + array( + new Node\ConstantNode(true), + 'true', + ), + array( + new Node\ConstantNode(null), + 'null', + ), + array( + new Node\UnaryNode('-', new Node\ConstantNode(3)), + '-3', + ), + array( + new Node\BinaryNode('-', new Node\ConstantNode(3), new Node\ConstantNode(3)), + '3 - 3', + ), + array( + new Node\BinaryNode('*', + new Node\BinaryNode('-', new Node\ConstantNode(3), new Node\ConstantNode(3)), + new Node\ConstantNode(2) + ), + '(3 - 3) * 2', + ), + array( + new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar'), new Node\ArgumentsNode(), Node\GetAttrNode::PROPERTY_CALL), + 'foo.bar', + array('foo'), + ), + array( + new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar'), new Node\ArgumentsNode(), Node\GetAttrNode::METHOD_CALL), + 'foo.bar()', + array('foo'), + ), + array( + new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('not'), new Node\ArgumentsNode(), Node\GetAttrNode::METHOD_CALL), + 'foo.not()', + array('foo'), + ), + array( + new Node\GetAttrNode( + new Node\NameNode('foo'), + new Node\ConstantNode('bar'), + $arguments, + Node\GetAttrNode::METHOD_CALL + ), + 'foo.bar("arg1", 2, true)', + array('foo'), + ), + array( + new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode(3), new Node\ArgumentsNode(), Node\GetAttrNode::ARRAY_CALL), + 'foo[3]', + array('foo'), + ), + array( + new Node\ConditionalNode(new Node\ConstantNode(true), new Node\ConstantNode(true), new Node\ConstantNode(false)), + 'true ? true : false', + ), + + // chained calls + array( + $this->createGetAttrNode( + $this->createGetAttrNode( + $this->createGetAttrNode( + $this->createGetAttrNode(new Node\NameNode('foo'), 'bar', Node\GetAttrNode::METHOD_CALL), + 'foo', Node\GetAttrNode::METHOD_CALL), + 'baz', Node\GetAttrNode::PROPERTY_CALL), + '3', Node\GetAttrNode::ARRAY_CALL), + 'foo.bar().foo().baz[3]', + array('foo'), + ), + ); + } + + private function createGetAttrNode($node, $item, $type) + { + return new Node\GetAttrNode($node, new Node\ConstantNode($item), new Node\ArgumentsNode(), $type); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/Token.php b/src/Symfony/Component/ExpressionLanguage/Token.php new file mode 100644 index 0000000000000..4b9e184551507 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/Token.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Represents a Token. + * + * @author Fabien Potencier + */ +class Token +{ + public $value; + public $type; + public $cursor; + + const EOF_TYPE = 'end of expression'; + const NAME_TYPE = 'name'; + const NUMBER_TYPE = 'number'; + const STRING_TYPE = 'string'; + const OPERATOR_TYPE = 'operator'; + const PUNCTUATION_TYPE = 'punctuation'; + + /** + * Constructor. + * + * @param integer $type The type of the token + * @param string $value The token value + * @param integer $cursor The cursor position in the source + */ + public function __construct($type, $value, $cursor) + { + $this->type = $type; + $this->value = $value; + $this->cursor = $cursor; + } + + /** + * Returns a string representation of the token. + * + * @return string A string representation of the token + */ + public function __toString() + { + return sprintf('%3d %-11s %s', $this->cursor, strtoupper($this->type), $this->value); + } + + /** + * Tests the current token for a type and/or a value. + * + * @param array|integer $type The type to test + * @param string|null $value The token value + * + * @return Boolean + */ + public function test($type, $value = null) + { + return $this->type === $type && (null === $value || $this->value == $value); + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/TokenStream.php b/src/Symfony/Component/ExpressionLanguage/TokenStream.php new file mode 100644 index 0000000000000..7a75f96648f02 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/TokenStream.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Represents a token stream. + * + * @author Fabien Potencier + */ +class TokenStream +{ + public $current; + + private $tokens; + private $position; + + /** + * Constructor. + * + * @param array $tokens An array of tokens + */ + public function __construct(array $tokens) + { + $this->tokens = $tokens; + $this->position = 0; + $this->current = $tokens[0]; + } + + /** + * Returns a string representation of the token stream. + * + * @return string + */ + public function __toString() + { + return implode("\n", $this->tokens); + } + + /** + * Sets the pointer to the next token and returns the old one. + */ + public function next() + { + if (!isset($this->tokens[$this->position])) { + throw new SyntaxError('Unexpected end of expression', $this->current->cursor); + } + + ++$this->position; + + $this->current = $this->tokens[$this->position]; + } + + /** + * Tests a token. + */ + public function expect($type, $value = null, $message = null) + { + $token = $this->current; + if (!$token->test($type, $value)) { + throw new SyntaxError(sprintf('%sUnexpected token "%s" of value "%s" ("%s" expected%s)', $message ? $message.'. ' : '', $token->type, $token->value, $type, $value ? sprintf(' with value "%s"', $value) : ''), $token->cursor); + } + $this->next(); + } + + /** + * Checks if end of stream was reached + * + * @return bool + */ + public function isEOF() + { + return $this->current->type === Token::EOF_TYPE; + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/composer.json b/src/Symfony/Component/ExpressionLanguage/composer.json new file mode 100644 index 0000000000000..d939a75883a43 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/composer.json @@ -0,0 +1,31 @@ +{ + "name": "symfony/expression-language", + "type": "library", + "description": "Symfony ExpressionLanguage Component", + "keywords": [], + "homepage": "http://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + } + ], + "require": { + "php": ">=5.3.3" + }, + "autoload": { + "psr-0": { "Symfony\\Component\\ExpressionLanguage\\": "" } + }, + "target-dir": "Symfony/Component/ExpressionLanguage", + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "2.4-dev" + } + } +} diff --git a/src/Symfony/Component/ExpressionLanguage/phpunit.xml.dist b/src/Symfony/Component/ExpressionLanguage/phpunit.xml.dist new file mode 100644 index 0000000000000..41d9128824d98 --- /dev/null +++ b/src/Symfony/Component/ExpressionLanguage/phpunit.xml.dist @@ -0,0 +1,29 @@ + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/HttpFoundation/ExpressionRequestMatcher.php b/src/Symfony/Component/HttpFoundation/ExpressionRequestMatcher.php new file mode 100644 index 0000000000000..e9c8441ce314b --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/ExpressionRequestMatcher.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation; + +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; + +/** + * ExpressionRequestMatcher uses an expression to match a Request. + * + * @author Fabien Potencier + */ +class ExpressionRequestMatcher extends RequestMatcher +{ + private $language; + private $expression; + + public function setExpression(ExpressionLanguage $language, $expression) + { + $this->language = $language; + $this->expression = $expression; + } + + public function matches(Request $request) + { + if (!$this->language) { + throw new \LogicException('Unable to match the request as the expression language is not available.'); + } + + return $this->language->evaluate($this->expression, array( + 'request' => $request, + 'method' => $request->getMethod(), + 'path' => rawurldecode($request->getPathInfo()), + 'host' => $request->getHost(), + 'ip' => $request->getClientIp(), + 'attributes' => $request->attributes->all(), + )) && parent::matches($request); + } +} diff --git a/src/Symfony/Component/Routing/Annotation/Route.php b/src/Symfony/Component/Routing/Annotation/Route.php index abdbea27c6996..f6100731dd51a 100644 --- a/src/Symfony/Component/Routing/Annotation/Route.php +++ b/src/Symfony/Component/Routing/Annotation/Route.php @@ -28,6 +28,7 @@ class Route private $host; private $methods; private $schemes; + private $condition; /** * Constructor. @@ -153,4 +154,14 @@ public function getMethods() { return $this->methods; } + + public function setCondition($condition) + { + $this->condition = $condition; + } + + public function getCondition() + { + return $this->condition; + } } diff --git a/src/Symfony/Component/Routing/Loader/AnnotationClassLoader.php b/src/Symfony/Component/Routing/Loader/AnnotationClassLoader.php index 7abab5a631abd..af70a888b8673 100644 --- a/src/Symfony/Component/Routing/Loader/AnnotationClassLoader.php +++ b/src/Symfony/Component/Routing/Loader/AnnotationClassLoader.php @@ -116,6 +116,7 @@ public function load($class, $type = null) 'schemes' => array(), 'methods' => array(), 'host' => '', + 'condition' => '', ); $class = new \ReflectionClass($class); @@ -154,6 +155,10 @@ public function load($class, $type = null) if (null !== $annot->getHost()) { $globals['host'] = $annot->getHost(); } + + if (null !== $annot->getCondition()) { + $globals['condition'] = $annot->getCondition(); + } } $collection = new RouteCollection(); @@ -194,7 +199,12 @@ protected function addRoute(RouteCollection $collection, $annot, $globals, \Refl $host = $globals['host']; } - $route = new Route($globals['path'].$annot->getPath(), $defaults, $requirements, $options, $host, $schemes, $methods); + $condition = $annot->getCondition(); + if (null === $condition) { + $condition = $globals['condition']; + } + + $route = new Route($globals['path'].$annot->getPath(), $defaults, $requirements, $options, $host, $schemes, $methods, $condition); $this->configureRoute($route, $class, $method, $annot); diff --git a/src/Symfony/Component/Routing/Loader/XmlFileLoader.php b/src/Symfony/Component/Routing/Loader/XmlFileLoader.php index da7b33d856ff1..e854202f7092c 100644 --- a/src/Symfony/Component/Routing/Loader/XmlFileLoader.php +++ b/src/Symfony/Component/Routing/Loader/XmlFileLoader.php @@ -129,9 +129,9 @@ protected function parseRoute(RouteCollection $collection, \DOMElement $node, $p $schemes = preg_split('/[\s,\|]++/', $node->getAttribute('schemes'), -1, PREG_SPLIT_NO_EMPTY); $methods = preg_split('/[\s,\|]++/', $node->getAttribute('methods'), -1, PREG_SPLIT_NO_EMPTY); - list($defaults, $requirements, $options) = $this->parseConfigs($node, $path); + list($defaults, $requirements, $options, $condition) = $this->parseConfigs($node, $path); - $route = new Route($node->getAttribute('path'), $defaults, $requirements, $options, $node->getAttribute('host'), $schemes, $methods); + $route = new Route($node->getAttribute('path'), $defaults, $requirements, $options, $node->getAttribute('host'), $schemes, $methods, $condition); $collection->add($id, $route); } @@ -157,7 +157,7 @@ protected function parseImport(RouteCollection $collection, \DOMElement $node, $ $schemes = $node->hasAttribute('schemes') ? preg_split('/[\s,\|]++/', $node->getAttribute('schemes'), -1, PREG_SPLIT_NO_EMPTY) : null; $methods = $node->hasAttribute('methods') ? preg_split('/[\s,\|]++/', $node->getAttribute('methods'), -1, PREG_SPLIT_NO_EMPTY) : null; - list($defaults, $requirements, $options) = $this->parseConfigs($node, $path); + list($defaults, $requirements, $options, $condition) = $this->parseConfigs($node, $path); $this->setCurrentDir(dirname($path)); @@ -211,6 +211,7 @@ private function parseConfigs(\DOMElement $node, $path) $defaults = array(); $requirements = array(); $options = array(); + $condition = null; foreach ($node->getElementsByTagNameNS(self::NAMESPACE_URI, '*') as $n) { switch ($n->localName) { @@ -228,11 +229,14 @@ private function parseConfigs(\DOMElement $node, $path) case 'option': $options[$n->getAttribute('key')] = trim($n->textContent); break; + case 'condition': + $condition = trim($n->textContent); + break; default: throw new \InvalidArgumentException(sprintf('Unknown tag "%s" used in file "%s". Expected "default", "requirement" or "option".', $n->localName, $path)); } } - return array($defaults, $requirements, $options); + return array($defaults, $requirements, $options, $condition); } } diff --git a/src/Symfony/Component/Routing/Loader/YamlFileLoader.php b/src/Symfony/Component/Routing/Loader/YamlFileLoader.php index 9deea7fe4f552..d3eaea42e6041 100644 --- a/src/Symfony/Component/Routing/Loader/YamlFileLoader.php +++ b/src/Symfony/Component/Routing/Loader/YamlFileLoader.php @@ -28,7 +28,7 @@ class YamlFileLoader extends FileLoader { private static $availableKeys = array( - 'resource', 'type', 'prefix', 'pattern', 'path', 'host', 'schemes', 'methods', 'defaults', 'requirements', 'options', + 'resource', 'type', 'prefix', 'pattern', 'path', 'host', 'schemes', 'methods', 'defaults', 'requirements', 'options', 'condition' ); private $yamlParser; @@ -123,8 +123,9 @@ protected function parseRoute(RouteCollection $collection, $name, array $config, $host = isset($config['host']) ? $config['host'] : ''; $schemes = isset($config['schemes']) ? $config['schemes'] : array(); $methods = isset($config['methods']) ? $config['methods'] : array(); + $condition = isset($config['condition']) ? $config['condition'] : null; - $route = new Route($config['path'], $defaults, $requirements, $options, $host, $schemes, $methods); + $route = new Route($config['path'], $defaults, $requirements, $options, $host, $schemes, $methods, $condition); $collection->add($name, $route); } diff --git a/src/Symfony/Component/Routing/Loader/schema/routing/routing-1.0.xsd b/src/Symfony/Component/Routing/Loader/schema/routing/routing-1.0.xsd index daea8143865c8..9ab969a41d8a2 100644 --- a/src/Symfony/Component/Routing/Loader/schema/routing/routing-1.0.xsd +++ b/src/Symfony/Component/Routing/Loader/schema/routing/routing-1.0.xsd @@ -29,6 +29,7 @@ + @@ -61,4 +62,9 @@ + + + + + diff --git a/src/Symfony/Component/Routing/Matcher/Dumper/ApacheMatcherDumper.php b/src/Symfony/Component/Routing/Matcher/Dumper/ApacheMatcherDumper.php index 01d8c03589476..5b32684876b96 100644 --- a/src/Symfony/Component/Routing/Matcher/Dumper/ApacheMatcherDumper.php +++ b/src/Symfony/Component/Routing/Matcher/Dumper/ApacheMatcherDumper.php @@ -50,6 +50,9 @@ public function dump(array $options = array()) $prevHostRegex = ''; foreach ($this->getRoutes()->all() as $name => $route) { + if ($route->getCondition()) { + throw new \LogicException(sprintf('Unable to dump the routes for Apache as route "%s" has a condition.', $name)); + } $compiledRoute = $route->compile(); $hostRegex = $compiledRoute->getHostRegex(); diff --git a/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php b/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php index dc17ffbe984a3..a412fa7806862 100644 --- a/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php +++ b/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php @@ -13,6 +13,7 @@ use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; /** * PhpMatcherDumper creates a PHP class able to match URLs for a given set of routes. @@ -23,6 +24,8 @@ */ class PhpMatcherDumper extends MatcherDumper { + private $expressionLanguage; + /** * Dumps a set of routes to a PHP class. * @@ -91,6 +94,8 @@ public function match(\$pathinfo) { \$allow = array(); \$pathinfo = rawurldecode(\$pathinfo); + \$context = \$this->context; + \$request = \$this->request; $code @@ -237,6 +242,10 @@ private function compileRoute(Route $route, $name, $supportsRedirections, $paren $hostMatches = true; } + if ($route->getCondition()) { + $conditions[] = $this->getExpressionLanguage()->compile($route->getCondition(), array('context', 'request')); + } + $conditions = implode(' && ', $conditions); $code .= <<context->getMethod() != '$methods[0]') { @@ -375,4 +383,16 @@ private function buildPrefixTree(DumperCollection $collection) return $tree; } + + private function getExpressionLanguage() + { + if (null === $this->expressionLanguage) { + if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) { + throw new RuntimeException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed.'); + } + $this->expressionLanguage = new ExpressionLanguage(); + } + + return $this->expressionLanguage; + } } diff --git a/src/Symfony/Component/Routing/Matcher/RedirectableUrlMatcher.php b/src/Symfony/Component/Routing/Matcher/RedirectableUrlMatcher.php index 51e80057cd53b..56fcb604cc6ec 100644 --- a/src/Symfony/Component/Routing/Matcher/RedirectableUrlMatcher.php +++ b/src/Symfony/Component/Routing/Matcher/RedirectableUrlMatcher.php @@ -50,6 +50,11 @@ public function match($pathinfo) */ protected function handleRouteRequirements($pathinfo, $name, Route $route) { + // expression condition + if ($route->getCondition() && !$this->getExpressionLanguage()->evaluate($route->getCondition(), array('context' => $this->context, 'request' => $this->request))) { + return array(self::REQUIREMENT_MISMATCH, null); + } + // check HTTP scheme requirement $scheme = $route->getRequirement('_scheme'); if ($scheme && $this->context->getScheme() !== $scheme) { diff --git a/src/Symfony/Component/Routing/Matcher/TraceableUrlMatcher.php b/src/Symfony/Component/Routing/Matcher/TraceableUrlMatcher.php index c09f83e86a227..e70063b7b9106 100644 --- a/src/Symfony/Component/Routing/Matcher/TraceableUrlMatcher.php +++ b/src/Symfony/Component/Routing/Matcher/TraceableUrlMatcher.php @@ -94,6 +94,15 @@ protected function matchCollection($pathinfo, RouteCollection $routes) } } + // check condition + if ($condition = $route->getCondition()) { + if (!$this->getExpressionLanguage()->evaluate($condition, array('context' => $this->context, 'request' => $this->request))) { + $this->addTrace(sprintf('Condition "%s" does not evaluate to "true"', $condition), self::ROUTE_ALMOST_MATCHES, $name, $route); + + continue; + } + } + // check HTTP scheme requirement if ($scheme = $route->getRequirement('_scheme')) { if ($this->context->getScheme() !== $scheme) { diff --git a/src/Symfony/Component/Routing/Matcher/UrlMatcher.php b/src/Symfony/Component/Routing/Matcher/UrlMatcher.php index db18ec4e7b259..b4b7055db1d9e 100644 --- a/src/Symfony/Component/Routing/Matcher/UrlMatcher.php +++ b/src/Symfony/Component/Routing/Matcher/UrlMatcher.php @@ -16,6 +16,8 @@ use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RequestContext; use Symfony\Component\Routing\Route; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; /** * UrlMatcher matches URL based on a set of routes. @@ -24,7 +26,7 @@ * * @api */ -class UrlMatcher implements UrlMatcherInterface +class UrlMatcher implements UrlMatcherInterface, RequestMatcherInterface { const REQUIREMENT_MATCH = 0; const REQUIREMENT_MISMATCH = 1; @@ -45,6 +47,9 @@ class UrlMatcher implements UrlMatcherInterface */ protected $routes; + protected $request; + protected $expressionLanguage; + /** * Constructor. * @@ -91,6 +96,20 @@ public function match($pathinfo) : new ResourceNotFoundException(); } + /** + * {@inheritdoc} + */ + public function matchRequest(Request $request) + { + $this->request = $request; + + $ret = $this->match($request->getPathInfo()); + + $this->request = null; + + return $ret; + } + /** * Tries to match a URL with a set of routes. * @@ -180,11 +199,18 @@ protected function getAttributes(Route $route, $name, array $attributes) */ protected function handleRouteRequirements($pathinfo, $name, Route $route) { + // expression condition + if ($route->getCondition() && !$this->getExpressionLanguage()->evaluate($route->getCondition(), array('context' => $this->context, 'request' => $this->request))) { + return array(self::REQUIREMENT_MISMATCH, null); + } + // check HTTP scheme requirement $scheme = $route->getRequirement('_scheme'); - $status = $scheme && $scheme !== $this->context->getScheme() ? self::REQUIREMENT_MISMATCH : self::REQUIREMENT_MATCH; + if ($scheme && $scheme !== $this->context->getScheme()) { + return array(self::REQUIREMENT_MISMATCH, null); + } - return array($status, null); + return array(self::REQUIREMENT_MATCH, null); } /** @@ -205,4 +231,16 @@ protected function mergeDefaults($params, $defaults) return $defaults; } + + protected function getExpressionLanguage() + { + if (null === $this->expressionLanguage) { + if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) { + throw new RuntimeException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed.'); + } + $this->expressionLanguage = new ExpressionLanguage(); + } + + return $this->expressionLanguage; + } } diff --git a/src/Symfony/Component/Routing/Route.php b/src/Symfony/Component/Routing/Route.php index 5bc535c683ed2..aac654b01e473 100644 --- a/src/Symfony/Component/Routing/Route.php +++ b/src/Symfony/Component/Routing/Route.php @@ -61,6 +61,8 @@ class Route implements \Serializable */ private $compiled; + private $condition; + /** * Constructor. * @@ -75,10 +77,11 @@ class Route implements \Serializable * @param string $host The host pattern to match * @param string|array $schemes A required URI scheme or an array of restricted schemes * @param string|array $methods A required HTTP method or an array of restricted methods + * @param string $condition A condition that should evaluate to true for the route to match * * @api */ - public function __construct($path, array $defaults = array(), array $requirements = array(), array $options = array(), $host = '', $schemes = array(), $methods = array()) + public function __construct($path, array $defaults = array(), array $requirements = array(), array $options = array(), $host = '', $schemes = array(), $methods = array(), $condition = null) { $this->setPath($path); $this->setDefaults($defaults); @@ -93,6 +96,7 @@ public function __construct($path, array $defaults = array(), array $requirement if ($methods) { $this->setMethods($methods); } + $this->setCondition($condition); } public function serialize() @@ -105,6 +109,7 @@ public function serialize() 'options' => $this->options, 'schemes' => $this->schemes, 'methods' => $this->methods, + 'condition' => $this->condition, )); } @@ -118,6 +123,7 @@ public function unserialize($data) $this->options = $data['options']; $this->schemes = $data['schemes']; $this->methods = $data['methods']; + $this->condition = $data['condition']; } /** @@ -543,6 +549,33 @@ public function setRequirement($key, $regex) return $this; } + /** + * Returns the condition. + * + * @return string The condition + */ + public function getCondition() + { + return $this->condition; + } + + /** + * Sets the condition. + * + * This method implements a fluent interface. + * + * @param string $condition The condition + * + * @return Route The current Route instance + */ + public function setCondition($condition) + { + $this->condition = (string) $condition; + $this->compiled = null; + + return $this; + } + /** * Compiles the route. * diff --git a/src/Symfony/Component/Routing/RouteCollection.php b/src/Symfony/Component/Routing/RouteCollection.php index 499fe0f04f83a..f02832424236d 100644 --- a/src/Symfony/Component/Routing/RouteCollection.php +++ b/src/Symfony/Component/Routing/RouteCollection.php @@ -177,6 +177,20 @@ public function setHost($pattern, array $defaults = array(), array $requirements } } + /** + * Sets a condition on all routes. + * + * Existing conditions will be overridden. + * + * @param string $condition The condition + */ + public function setCondition($condition) + { + foreach ($this->routes as $route) { + $route->setCondition($condition); + } + } + /** * Adds defaults to all routes. * diff --git a/src/Symfony/Component/Routing/Tests/Annotation/RouteTest.php b/src/Symfony/Component/Routing/Tests/Annotation/RouteTest.php index b58869f7afc69..bd0167089e05d 100644 --- a/src/Symfony/Component/Routing/Tests/Annotation/RouteTest.php +++ b/src/Symfony/Component/Routing/Tests/Annotation/RouteTest.php @@ -35,15 +35,16 @@ public function testRouteParameters($parameter, $value, $getter) public function getValidParameters() { return array( - array('value', '/Blog', 'getPattern'), - array('value', '/Blog', 'getPath'), - array('requirements', array('_method' => 'GET'), 'getRequirements'), - array('options', array('compiler_class' => 'RouteCompiler'), 'getOptions'), - array('name', 'blog_index', 'getName'), - array('defaults', array('_controller' => 'MyBlogBundle:Blog:index'), 'getDefaults'), - array('schemes', array('https'), 'getSchemes'), - array('methods', array('GET', 'POST'), 'getMethods'), - array('host', array('{locale}.example.com'), 'getHost') + array('value', '/Blog', 'getPattern'), + array('value', '/Blog', 'getPath'), + array('requirements', array('_method' => 'GET'), 'getRequirements'), + array('options', array('compiler_class' => 'RouteCompiler'), 'getOptions'), + array('name', 'blog_index', 'getName'), + array('defaults', array('_controller' => 'MyBlogBundle:Blog:index'), 'getDefaults'), + array('schemes', array('https'), 'getSchemes'), + array('methods', array('GET', 'POST'), 'getMethods'), + array('host', array('{locale}.example.com'), 'getHost'), + array('condition', array('context.getMethod() == "GET"'), 'getCondition'), ); } } diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher1.php b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher1.php index e5f7665c81bc9..248a4f153fc3e 100644 --- a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher1.php +++ b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher1.php @@ -24,6 +24,8 @@ public function match($pathinfo) { $allow = array(); $pathinfo = rawurldecode($pathinfo); + $context = $this->context; + $request = $this->request; // foo if (0 === strpos($pathinfo, '/foo') && preg_match('#^/foo/(?Pbaz|symfony)$#s', $pathinfo, $matches)) { diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher2.php b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher2.php index ad157909b8c0e..d7b99d67b91d6 100644 --- a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher2.php +++ b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher2.php @@ -24,6 +24,8 @@ public function match($pathinfo) { $allow = array(); $pathinfo = rawurldecode($pathinfo); + $context = $this->context; + $request = $this->request; // foo if (0 === strpos($pathinfo, '/foo') && preg_match('#^/foo/(?Pbaz|symfony)$#s', $pathinfo, $matches)) { diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher3.php b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher3.php index f2f642eb86691..c3463fa77208a 100644 --- a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher3.php +++ b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher3.php @@ -24,6 +24,8 @@ public function match($pathinfo) { $allow = array(); $pathinfo = rawurldecode($pathinfo); + $context = $this->context; + $request = $this->request; if (0 === strpos($pathinfo, '/rootprefix')) { // static @@ -38,6 +40,11 @@ public function match($pathinfo) } + // with-condition + if ($pathinfo === '/with-condition' && ($context->getMethod() == "GET")) { + return array('_route' => 'with-condition'); + } + throw 0 < count($allow) ? new MethodNotAllowedException(array_unique($allow)) : new ResourceNotFoundException(); } } diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/validpattern.php b/src/Symfony/Component/Routing/Tests/Fixtures/validpattern.php index b8bbbb5f8f01d..b652d94656599 100644 --- a/src/Symfony/Component/Routing/Tests/Fixtures/validpattern.php +++ b/src/Symfony/Component/Routing/Tests/Fixtures/validpattern.php @@ -10,14 +10,18 @@ array('compiler_class' => 'RouteCompiler'), '{locale}.example.com', array('https'), - array('GET','POST','put','OpTiOnS') + array('GET','POST','put','OpTiOnS'), + 'context.getMethod() == "GET"' )); $collection->add('blog_show_legacy', new Route( '/blog/{slug}', array('_controller' => 'MyBlogBundle:Blog:show'), array('_method' => 'GET|POST|put|OpTiOnS', '_scheme' => 'https', 'locale' => '\w+',), array('compiler_class' => 'RouteCompiler'), - '{locale}.example.com' + '{locale}.example.com', + array(), + array(), + 'context.getMethod() == "GET"' )); return $collection; diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/validpattern.xml b/src/Symfony/Component/Routing/Tests/Fixtures/validpattern.xml index b9f22347b40cb..cfee9d6882839 100644 --- a/src/Symfony/Component/Routing/Tests/Fixtures/validpattern.xml +++ b/src/Symfony/Component/Routing/Tests/Fixtures/validpattern.xml @@ -8,6 +8,7 @@ MyBundle:Blog:show \w+ + context.getMethod() == "GET" @@ -17,5 +18,6 @@ hTTps \w+ + context.getMethod() == "GET" diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/validpattern.yml b/src/Symfony/Component/Routing/Tests/Fixtures/validpattern.yml index 4ada8832197b8..48cf7f8817cc2 100644 --- a/src/Symfony/Component/Routing/Tests/Fixtures/validpattern.yml +++ b/src/Symfony/Component/Routing/Tests/Fixtures/validpattern.yml @@ -5,6 +5,7 @@ blog_show: requirements: { 'locale': '\w+' } methods: ['GET','POST','put','OpTiOnS'] schemes: ['https'] + condition: 'context.getMethod() == "GET"' options: compiler_class: RouteCompiler @@ -13,5 +14,6 @@ blog_show_legacy: defaults: { _controller: "MyBundle:Blog:show" } host: "{locale}.example.com" requirements: { '_method': 'GET|POST|put|OpTiOnS', _scheme: https, 'locale': '\w+' } + condition: 'context.getMethod() == "GET"' options: compiler_class: RouteCompiler diff --git a/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderTest.php b/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderTest.php index 5b7325c317c57..c60997902e0ef 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderTest.php @@ -84,7 +84,12 @@ public function getLoadTests() array( 'Symfony\Component\Routing\Tests\Fixtures\AnnotatedClasses\BarClass', array('name' => 'route1', 'defaults' => array('arg2' => 'foobar')), - array('arg2' => false, 'arg3' => 'defaultValue3') + array('arg2' => 'defaultValue2', 'arg3' =>'defaultValue3') + ), + array( + 'Symfony\Component\Routing\Tests\Fixtures\AnnotatedClasses\BarClass', + array('name' => 'route1', 'defaults' => array('arg2' => 'foo'), 'condition' => 'context.getMethod() == "GET"'), + array('arg2' => 'defaultValue2', 'arg3' =>'defaultValue3') ), ); } @@ -102,6 +107,7 @@ public function testLoad($className, $routeDatas = array(), $methodArgs = array( 'defaults' => array(), 'schemes' => array(), 'methods' => array(), + 'condition' => null, ), $routeDatas); $this->reader @@ -116,6 +122,7 @@ public function testLoad($className, $routeDatas = array(), $methodArgs = array( $this->assertSame($routeDatas['requirements'],$route->getRequirements(), '->load preserves requirements annotation'); $this->assertCount(0, array_intersect($route->getOptions(), $routeDatas['options']), '->load preserves options annotation'); $this->assertSame(array_replace($methodArgs, $routeDatas['defaults']), $route->getDefaults(), '->load preserves defaults annotation'); + $this->assertEquals($routeDatas['condition'], $route->getCondition(), '->load preserves condition annotation'); } private function getAnnotatedRoute($datas) diff --git a/src/Symfony/Component/Routing/Tests/Loader/XmlFileLoaderTest.php b/src/Symfony/Component/Routing/Tests/Loader/XmlFileLoaderTest.php index b678b3a8c84fb..c38adbd93838f 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/XmlFileLoaderTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/XmlFileLoaderTest.php @@ -45,6 +45,7 @@ public function testLoadWithRoute() $this->assertSame('RouteCompiler', $route->getOption('compiler_class')); $this->assertEquals(array('GET', 'POST', 'PUT', 'OPTIONS'), $route->getMethods()); $this->assertEquals(array('https'), $route->getSchemes()); + $this->assertEquals('context.getMethod() == "GET"', $route->getCondition()); } } diff --git a/src/Symfony/Component/Routing/Tests/Loader/YamlFileLoaderTest.php b/src/Symfony/Component/Routing/Tests/Loader/YamlFileLoaderTest.php index 1463326094616..f0301062055e8 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/YamlFileLoaderTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/YamlFileLoaderTest.php @@ -79,6 +79,7 @@ public function testLoadWithRoute() $this->assertSame('RouteCompiler', $route->getOption('compiler_class')); $this->assertEquals(array('GET', 'POST', 'PUT', 'OPTIONS'), $route->getMethods()); $this->assertEquals(array('https'), $route->getSchemes()); + $this->assertEquals('context.getMethod() == "GET"', $route->getCondition()); } } diff --git a/src/Symfony/Component/Routing/Tests/Matcher/Dumper/PhpMatcherDumperTest.php b/src/Symfony/Component/Routing/Tests/Matcher/Dumper/PhpMatcherDumperTest.php index 542ede85c0017..473af6aa5cea3 100644 --- a/src/Symfony/Component/Routing/Tests/Matcher/Dumper/PhpMatcherDumperTest.php +++ b/src/Symfony/Component/Routing/Tests/Matcher/Dumper/PhpMatcherDumperTest.php @@ -251,6 +251,9 @@ public function getRouteCollections() $rootprefixCollection->add('static', new Route('/test')); $rootprefixCollection->add('dynamic', new Route('/{var}')); $rootprefixCollection->addPrefix('rootprefix'); + $route = new Route('/with-condition'); + $route->setCondition('context.getMethod() == "GET"'); + $rootprefixCollection->add('with-condition', $route); return array( array($collection, 'url_matcher1.php', array()), diff --git a/src/Symfony/Component/Routing/Tests/Matcher/TraceableUrlMatcherTest.php b/src/Symfony/Component/Routing/Tests/Matcher/TraceableUrlMatcherTest.php index 86d8d954c008e..de6395226f414 100644 --- a/src/Symfony/Component/Routing/Tests/Matcher/TraceableUrlMatcherTest.php +++ b/src/Symfony/Component/Routing/Tests/Matcher/TraceableUrlMatcherTest.php @@ -26,13 +26,14 @@ public function test() $coll->add('bar1', new Route('/bar/{name}', array(), array('id' => '\w+', '_method' => 'POST'))); $coll->add('bar2', new Route('/foo', array(), array(), array(), 'baz')); $coll->add('bar3', new Route('/foo1', array(), array(), array(), 'baz')); + $coll->add('bar4', new Route('/foo2', array(), array(), array(), 'baz', array(), array(), 'context.getMethod() == "GET"')); $context = new RequestContext(); $context->setHost('baz'); $matcher = new TraceableUrlMatcher($coll, $context); $traces = $matcher->getTraces('/babar'); - $this->assertEquals(array(0, 0, 0, 0, 0), $this->getLevels($traces)); + $this->assertEquals(array(0, 0, 0, 0, 0, 0), $this->getLevels($traces)); $traces = $matcher->getTraces('/foo'); $this->assertEquals(array(1, 0, 0, 2), $this->getLevels($traces)); @@ -41,7 +42,7 @@ public function test() $this->assertEquals(array(0, 2), $this->getLevels($traces)); $traces = $matcher->getTraces('/bar/dd'); - $this->assertEquals(array(0, 1, 1, 0, 0), $this->getLevels($traces)); + $this->assertEquals(array(0, 1, 1, 0, 0, 0), $this->getLevels($traces)); $traces = $matcher->getTraces('/foo1'); $this->assertEquals(array(0, 0, 0, 0, 2), $this->getLevels($traces)); @@ -52,6 +53,9 @@ public function test() $traces = $matcher->getTraces('/bar/dd'); $this->assertEquals(array(0, 1, 2), $this->getLevels($traces)); + + $traces = $matcher->getTraces('/foo2'); + $this->assertEquals(array(0, 0, 0, 0, 0, 1), $this->getLevels($traces)); } public function getLevels($traces) diff --git a/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php b/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php index 8a1428f170859..b31cada8c00e8 100644 --- a/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php +++ b/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php @@ -321,6 +321,19 @@ public function testSchemeRequirement() $matcher->match('/foo'); } + /** + * @expectedException \Symfony\Component\Routing\Exception\ResourceNotFoundException + */ + public function testCondition() + { + $coll = new RouteCollection(); + $route = new Route('/foo'); + $route->setCondition('context.getMethod() == "POST"'); + $coll->add('foo', $route); + $matcher = new UrlMatcher($coll, new RequestContext()); + $matcher->match('/foo'); + } + public function testDecodeOnce() { $coll = new RouteCollection(); diff --git a/src/Symfony/Component/Routing/Tests/RouteCollectionTest.php b/src/Symfony/Component/Routing/Tests/RouteCollectionTest.php index 94f298436894e..027917bc1aef9 100644 --- a/src/Symfony/Component/Routing/Tests/RouteCollectionTest.php +++ b/src/Symfony/Component/Routing/Tests/RouteCollectionTest.php @@ -244,4 +244,18 @@ public function testSetHost() $this->assertEquals('{locale}.example.com', $routea->getHost()); $this->assertEquals('{locale}.example.com', $routeb->getHost()); } + + public function testSetCondition() + { + $collection = new RouteCollection(); + $routea = new Route('/a'); + $routeb = new Route('/b', array(), array(), array(), '{locale}.example.net', array(), array(), 'context.getMethod() == "GET"'); + $collection->add('a', $routea); + $collection->add('b', $routeb); + + $collection->setCondition('context.getMethod() == "POST"'); + + $this->assertEquals('context.getMethod() == "POST"', $routea->getCondition()); + $this->assertEquals('context.getMethod() == "POST"', $routeb->getCondition()); + } } diff --git a/src/Symfony/Component/Routing/Tests/RouteTest.php b/src/Symfony/Component/Routing/Tests/RouteTest.php index 47b87f33abed8..d63bcbacf675d 100644 --- a/src/Symfony/Component/Routing/Tests/RouteTest.php +++ b/src/Symfony/Component/Routing/Tests/RouteTest.php @@ -24,9 +24,10 @@ public function testConstructor() $this->assertEquals('bar', $route->getOption('foo'), '__construct() takes options as its fourth argument'); $this->assertEquals('{locale}.example.com', $route->getHost(), '__construct() takes a host pattern as its fifth argument'); - $route = new Route('/', array(), array(), array(), '', array('Https'), array('POST', 'put')); + $route = new Route('/', array(), array(), array(), '', array('Https'), array('POST', 'put'), 'context.getMethod() == "GET"'); $this->assertEquals(array('https'), $route->getSchemes(), '__construct() takes schemes as its sixth argument and lowercases it'); $this->assertEquals(array('POST', 'PUT'), $route->getMethods(), '__construct() takes methods as its seventh argument and uppercases it'); + $this->assertEquals('context.getMethod() == "GET"', $route->getCondition(), '__construct() takes a condition as its eight argument'); $route = new Route('/', array(), array(), array(), '', 'Https', 'Post'); $this->assertEquals(array('https'), $route->getSchemes(), '__construct() takes a single scheme as its sixth argument'); @@ -181,6 +182,14 @@ public function testMethodIsBC() $this->assertNull($route->getRequirement('_method')); } + public function testCondition() + { + $route = new Route('/'); + $this->assertEquals(null, $route->getCondition()); + $route->setCondition('context.getMethod() == "GET"'); + $this->assertEquals('context.getMethod() == "GET"', $route->getCondition()); + } + public function testCompile() { $route = new Route('/{foo}'); diff --git a/src/Symfony/Component/Routing/composer.json b/src/Symfony/Component/Routing/composer.json index 0c476b3a68fd2..67b9327834421 100644 --- a/src/Symfony/Component/Routing/composer.json +++ b/src/Symfony/Component/Routing/composer.json @@ -21,6 +21,7 @@ "require-dev": { "symfony/config": "~2.2", "symfony/yaml": "~2.0", + "symfony/expression-language": "~2.4", "doctrine/common": "~2.2", "psr/log": "~1.0" }, diff --git a/src/Symfony/Component/Security/Core/Authorization/ExpressionLanguage.php b/src/Symfony/Component/Security/Core/Authorization/ExpressionLanguage.php new file mode 100644 index 0000000000000..bdb3371c010e3 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/ExpressionLanguage.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Authorization; + +use Symfony\Component\ExpressionLanguage\ExpressionLanguage as BaseExpressionLanguage; + +/** + * Adds some function to the default ExpressionLanguage. + * + * @author Fabien Potencier + */ +class ExpressionLanguage extends BaseExpressionLanguage +{ + protected function registerFunctions() + { + parent::registerFunctions(); + + $this->addFunction('is_anonymous', function () { + return '$trust_resolver->isAnonymous($token)'; + }, function (array $variables) { + return $variables['trust_resolver']->isAnonymous($variables['token']); + }); + + $this->addFunction('is_authenticated', function () { + return '!$trust_resolver->isAnonymous($token)'; + }, function (array $variables) { + return !$variables['trust_resolver']->isAnonymous($variables['token']); + }); + + $this->addFunction('is_fully_authenticated', function () { + return '!$trust_resolver->isFullFledge($token)'; + }, function (array $variables) { + return !$variables['trust_resolver']->isFullFledge($variables['token']); + }); + + $this->addFunction('is_remember_me', function () { + return '!$trust_resolver->isRememberMe($token)'; + }, function (array $variables) { + return !$variables['trust_resolver']->isRememberMe($variables['token']); + }); + + $this->addFunction('has_role', function ($role) { + return sprintf('in_array(%s, $roles)', $role); + }, function (array $variables, $role) { + return in_array($role, $variables['roles']); + }); + } +} diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php new file mode 100644 index 0000000000000..50c8d5cfd8511 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Authorization\Voter; + +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; +use Symfony\Component\Security\Core\Authorization\ExpressionLanguage; +use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\HttpFoundation\Request; + +/** + * ExpressionVoter votes based on the evaluation of an expression. + * + * @author Fabien Potencier + */ +class ExpressionVoter implements VoterInterface +{ + private $expressionLanguage; + private $trustResolver; + private $roleHierarchy; + + /** + * Constructor. + * + * @param ExpressionLanguage $expressionLanguage + */ + public function __construct(ExpressionLanguage $expressionLanguage, AuthenticationTrustResolverInterface $trustResolver, RoleHierarchyInterface $roleHierarchy = null) + { + $this->expressionLanguage = $expressionLanguage; + $this->trustResolver = $trustResolver; + $this->roleHierarchy = $roleHierarchy; + } + + /** + * {@inheritdoc} + */ + public function supportsAttribute($attribute) + { + return $attribute instanceof Expression; + } + + /** + * {@inheritdoc} + */ + public function supportsClass($class) + { + return true; + } + + /** + * {@inheritdoc} + */ + public function vote(TokenInterface $token, $object, array $attributes) + { + if (null !== $this->roleHierarchy) { + $roles = $this->roleHierarchy->getReachableRoles($token->getRoles()); + } else { + $roles = $token->getRoles(); + } + + $variables = array( + 'token' => $token, + 'user' => $token->getUser(), + 'object' => $object, + 'roles' => array_map(function ($role) { return $role->getRole(); }, $roles), + 'trust_resolver' => $this->trustResolver, + ); + + // this is mainly to propose a better experience when the expression is used + // in an access control rule, as the developer does not know that it's going + // to be handled by this voter + if ($object instanceof Request) { + $variables['request'] = $object; + } + + $result = VoterInterface::ACCESS_ABSTAIN; + foreach ($attributes as $attribute) { + if (!$this->supportsAttribute($attribute)) { + continue; + } + + $result = VoterInterface::ACCESS_DENIED; + if ($this->expressionLanguage->evaluate($attribute, $variables)) { + return VoterInterface::ACCESS_GRANTED; + } + } + + return $result; + } +} diff --git a/src/Symfony/Component/Security/Http/AccessMap.php b/src/Symfony/Component/Security/Http/AccessMap.php index de78e15a55c0c..bf1d54081a26e 100644 --- a/src/Symfony/Component/Security/Http/AccessMap.php +++ b/src/Symfony/Component/Security/Http/AccessMap.php @@ -28,12 +28,12 @@ class AccessMap implements AccessMapInterface * Constructor. * * @param RequestMatcherInterface $requestMatcher A RequestMatcherInterface instance - * @param array $roles An array of roles needed to access the resource + * @param array $attributes An array of attributes to pass to the access decision manager (like roles) * @param string|null $channel The channel to enforce (http, https, or null) */ - public function add(RequestMatcherInterface $requestMatcher, array $roles = array(), $channel = null) + public function add(RequestMatcherInterface $requestMatcher, array $attributes = array(), $channel = null) { - $this->map[] = array($requestMatcher, $roles, $channel); + $this->map[] = array($requestMatcher, $attributes, $channel); } public function getPatterns(Request $request) diff --git a/src/Symfony/Component/Security/composer.json b/src/Symfony/Component/Security/composer.json index b6bbae4515029..64b097a21b85b 100644 --- a/src/Symfony/Component/Security/composer.json +++ b/src/Symfony/Component/Security/composer.json @@ -28,7 +28,8 @@ "doctrine/common": "~2.2", "doctrine/dbal": "~2.2", "psr/log": "~1.0", - "ircmaxell/password-compat": "1.0.*" + "ircmaxell/password-compat": "1.0.*", + "symfony/expression-language": "~2.4" }, "suggest": { "symfony/class-loader": "", diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index d874ed07deece..f5ae1ee39092a 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -4,7 +4,8 @@ CHANGELOG 2.4.0 ----- - * added `minRatio`, `maxRatio`, `allowSquare`, `allowLandscape`, and `allowPortrait` to Image validator + * added a constraint the uses the expression language + * added `minRatio`, `maxRatio`, `allowSquare`, `allowLandscape`, and `allowPortrait` to Image validator 2.3.0 ----- diff --git a/src/Symfony/Component/Validator/ConstraintValidatorFactory.php b/src/Symfony/Component/Validator/ConstraintValidatorFactory.php index 88b5cefdc2c7b..88868e5336352 100644 --- a/src/Symfony/Component/Validator/ConstraintValidatorFactory.php +++ b/src/Symfony/Component/Validator/ConstraintValidatorFactory.php @@ -11,8 +11,9 @@ namespace Symfony\Component\Validator; -use Symfony\Component\Validator\ConstraintValidatorFactoryInterface; -use Symfony\Component\Validator\Constraint; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Validator\Constraints\ExpressionValidator; /** * Default implementation of the ConstraintValidatorFactoryInterface. @@ -20,11 +21,23 @@ * This enforces the convention that the validatedBy() method on any * Constrain will return the class name of the ConstraintValidator that * should validate the Constraint. + * + * @author Bernhard Schussek */ class ConstraintValidatorFactory implements ConstraintValidatorFactoryInterface { protected $validators = array(); + /** + * @var PropertyAccessorInterface + */ + private $propertyAccessor; + + public function __construct(PropertyAccessorInterface $propertyAccessor = null) + { + $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + } + /** * {@inheritDoc} */ @@ -32,8 +45,19 @@ public function getInstance(Constraint $constraint) { $className = $constraint->validatedBy(); - if (!isset($this->validators[$className]) || $className === 'Symfony\Component\Validator\Constraints\CollectionValidator') { - $this->validators[$className] = new $className(); + // The second condition is a hack that is needed when CollectionValidator + // calls itself recursively (Collection constraints can be nested). + // Since the context of the validator is overwritten when initialize() + // is called for the nested constraint, the outer validator is + // acting on the wrong context when the nested validation terminates. + // + // A better solution - which should be approached in Symfony 3.0 - is to + // remove the initialize() method and pass the context as last argument + // to validate() instead. + if (!isset($this->validators[$className]) || 'Symfony\Component\Validator\Constraints\CollectionValidator' === $className) { + $this->validators[$className] = 'validator.expression' === $className + ? new ExpressionValidator($this->propertyAccessor) + : new $className(); } return $this->validators[$className]; diff --git a/src/Symfony/Component/Validator/Constraints/Expression.php b/src/Symfony/Component/Validator/Constraints/Expression.php new file mode 100644 index 0000000000000..b845a32392b2c --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/Expression.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; + +/** + * @Annotation + * + * @author Fabien Potencier + * @author Bernhard Schussek + */ +class Expression extends Constraint +{ + public $message = 'This value is not valid.'; + public $expression; + + /** + * {@inheritDoc} + */ + public function getDefaultOption() + { + return 'expression'; + } + + /** + * {@inheritDoc} + */ + public function getRequiredOptions() + { + return array('expression'); + } + + /** + * {@inheritDoc} + */ + public function getTargets() + { + return array(self::CLASS_CONSTRAINT, self::PROPERTY_CONSTRAINT); + } + + /** + * {@inheritDoc} + */ + public function validatedBy() + { + return 'validator.expression'; + } +} diff --git a/src/Symfony/Component/Validator/Constraints/ExpressionValidator.php b/src/Symfony/Component/Validator/Constraints/ExpressionValidator.php new file mode 100644 index 0000000000000..e27859b08c19c --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/ExpressionValidator.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyAccess\PropertyPath; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\Validator\Exception\RuntimeException; + +/** + * @author Fabien Potencier + * @author Bernhard Schussek + */ +class ExpressionValidator extends ConstraintValidator +{ + /** + * @var PropertyAccessorInterface + */ + private $propertyAccessor; + + /** + * @var ExpressionLanguage + */ + private $expressionLanguage; + + public function __construct(PropertyAccessorInterface $propertyAccessor) + { + $this->propertyAccessor = $propertyAccessor; + } + + /** + * {@inheritDoc} + */ + public function validate($value, Constraint $constraint) + { + if (null === $value || '' === $value) { + return; + } + + $variables = array(); + + if (null === $this->context->getPropertyName()) { + $variables['this'] = $value; + } else { + // Extract the object that the property belongs to from the object + // graph + $path = new PropertyPath($this->context->getPropertyPath()); + $parentPath = $path->getParent(); + $root = $this->context->getRoot(); + + $variables['value'] = $value; + $variables['this'] = $parentPath ? $this->propertyAccessor->getValue($root, $parentPath) : $root; + } + + if (!$this->getExpressionLanguage()->evaluate($constraint->expression, $variables)) { + $this->context->addViolation($constraint->message); + } + } + + private function getExpressionLanguage() + { + if (null === $this->expressionLanguage) { + if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) { + throw new RuntimeException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed.'); + } + $this->expressionLanguage = new ExpressionLanguage(); + } + + return $this->expressionLanguage; + } +} diff --git a/src/Symfony/Component/Validator/Exception/RuntimeException.php b/src/Symfony/Component/Validator/Exception/RuntimeException.php new file mode 100644 index 0000000000000..df4a50c474338 --- /dev/null +++ b/src/Symfony/Component/Validator/Exception/RuntimeException.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 Symfony\Component\Validator\Exception; + +/** + * Base RuntimeException for the Validator component. + * + * @author Bernhard Schussek + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/ExpressionValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/ExpressionValidatorTest.php new file mode 100644 index 0000000000000..b71138e5f6bbd --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/ExpressionValidatorTest.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\Validator\Constraints\Expression; +use Symfony\Component\Validator\Constraints\ExpressionValidator; + +class ExpressionValidatorTest extends \PHPUnit_Framework_TestCase +{ + protected $context; + protected $validator; + + protected function setUp() + { + $this->context = $this->getMock('Symfony\Component\Validator\ExecutionContext', array(), array(), '', false); + $this->validator = new ExpressionValidator(PropertyAccess::createPropertyAccessor()); + $this->validator->initialize($this->context); + + $this->context->expects($this->any()) + ->method('getClassName') + ->will($this->returnValue(__CLASS__)); + } + + protected function tearDown() + { + $this->context = null; + $this->validator = null; + } + + public function testNullIsValid() + { + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate(null, new Expression('value == 1')); + } + + public function testEmptyStringIsValid() + { + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate('', new Expression('value == 1')); + } + + public function testSucceedingExpressionAtObjectLevel() + { + $constraint = new Expression('this.property == 1'); + + $object = (object) array('property' => '1'); + + $this->context->expects($this->any()) + ->method('getPropertyName') + ->will($this->returnValue(null)); + + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate($object, $constraint); + } + + public function testFailingExpressionAtObjectLevel() + { + $constraint = new Expression(array( + 'expression' => 'this.property == 1', + 'message' => 'myMessage', + )); + + $object = (object) array('property' => '2'); + + $this->context->expects($this->any()) + ->method('getPropertyName') + ->will($this->returnValue(null)); + + $this->context->expects($this->once()) + ->method('addViolation') + ->with('myMessage'); + + $this->validator->validate($object, $constraint); + } + + public function testSucceedingExpressionAtPropertyLevel() + { + $constraint = new Expression('value == this.expected'); + + $object = (object) array('expected' => '1'); + + $this->context->expects($this->any()) + ->method('getPropertyName') + ->will($this->returnValue('property')); + + $this->context->expects($this->any()) + ->method('getPropertyPath') + ->will($this->returnValue('property')); + + $this->context->expects($this->any()) + ->method('getRoot') + ->will($this->returnValue($object)); + + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate('1', $constraint); + } + + public function testFailingExpressionAtPropertyLevel() + { + $constraint = new Expression(array( + 'expression' => 'value == this.expected', + 'message' => 'myMessage', + )); + + $object = (object) array('expected' => '1'); + + $this->context->expects($this->any()) + ->method('getPropertyName') + ->will($this->returnValue('property')); + + $this->context->expects($this->any()) + ->method('getPropertyPath') + ->will($this->returnValue('property')); + + $this->context->expects($this->any()) + ->method('getRoot') + ->will($this->returnValue($object)); + + $this->context->expects($this->once()) + ->method('addViolation') + ->with('myMessage'); + + $this->validator->validate('2', $constraint); + } + + public function testSucceedingExpressionAtNestedPropertyLevel() + { + $constraint = new Expression('value == this.expected'); + + $object = (object) array('expected' => '1'); + $root = (object) array('nested' => $object); + + $this->context->expects($this->any()) + ->method('getPropertyName') + ->will($this->returnValue('property')); + + $this->context->expects($this->any()) + ->method('getPropertyPath') + ->will($this->returnValue('nested.property')); + + $this->context->expects($this->any()) + ->method('getRoot') + ->will($this->returnValue($root)); + + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate('1', $constraint); + } + + public function testFailingExpressionAtNestedPropertyLevel() + { + $constraint = new Expression(array( + 'expression' => 'value == this.expected', + 'message' => 'myMessage', + )); + + $object = (object) array('expected' => '1'); + $root = (object) array('nested' => $object); + + $this->context->expects($this->any()) + ->method('getPropertyName') + ->will($this->returnValue('property')); + + $this->context->expects($this->any()) + ->method('getPropertyPath') + ->will($this->returnValue('nested.property')); + + $this->context->expects($this->any()) + ->method('getRoot') + ->will($this->returnValue($root)); + + $this->context->expects($this->once()) + ->method('addViolation') + ->with('myMessage'); + + $this->validator->validate('2', $constraint); + } +} diff --git a/src/Symfony/Component/Validator/ValidatorBuilder.php b/src/Symfony/Component/Validator/ValidatorBuilder.php index a5bfc1fdf63c5..159070be41c2b 100644 --- a/src/Symfony/Component/Validator/ValidatorBuilder.php +++ b/src/Symfony/Component/Validator/ValidatorBuilder.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Validator; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Validator\Mapping\ClassMetadataFactory; use Symfony\Component\Validator\Exception\ValidatorException; use Symfony\Component\Validator\Mapping\Loader\LoaderChain; @@ -84,6 +86,11 @@ class ValidatorBuilder implements ValidatorBuilderInterface */ private $translationDomain; + /** + * @var PropertyAccessorInterface + */ + private $propertyAccessor; + /** * {@inheritdoc} */ @@ -253,6 +260,10 @@ public function setMetadataCache(CacheInterface $cache) */ public function setConstraintValidatorFactory(ConstraintValidatorFactoryInterface $validatorFactory) { + if (null !== $this->propertyAccessor) { + throw new ValidatorException('You cannot set a validator factory after setting a custom property accessor. Remove the call to setPropertyAccessor() if you want to call setConstraintValidatorFactory().'); + } + $this->validatorFactory = $validatorFactory; return $this; @@ -278,6 +289,20 @@ public function setTranslationDomain($translationDomain) return $this; } + /** + * {@inheritdoc} + */ + public function setPropertyAccessor(PropertyAccessorInterface $propertyAccessor) + { + if (null !== $this->validatorFactory) { + throw new ValidatorException('You cannot set a property accessor after setting a custom validator factory. Configure your validator factory instead.'); + } + + $this->propertyAccessor = $propertyAccessor; + + return $this; + } + /** * {@inheritdoc} */ @@ -319,7 +344,8 @@ public function getValidator() $metadataFactory = new ClassMetadataFactory($loader, $this->metadataCache); } - $validatorFactory = $this->validatorFactory ?: new ConstraintValidatorFactory(); + $propertyAccessor = $this->propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + $validatorFactory = $this->validatorFactory ?: new ConstraintValidatorFactory($propertyAccessor); $translator = $this->translator ?: new DefaultTranslator(); return new Validator($metadataFactory, $validatorFactory, $translator, $this->translationDomain, $this->initializers); diff --git a/src/Symfony/Component/Validator/ValidatorBuilderInterface.php b/src/Symfony/Component/Validator/ValidatorBuilderInterface.php index 99f367b6aa1ae..92aaca756a3bc 100644 --- a/src/Symfony/Component/Validator/ValidatorBuilderInterface.php +++ b/src/Symfony/Component/Validator/ValidatorBuilderInterface.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Validator; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Validator\Mapping\Cache\CacheInterface; use Symfony\Component\Translation\TranslatorInterface; use Doctrine\Common\Annotations\Reader; @@ -159,6 +160,15 @@ public function setTranslator(TranslatorInterface $translator); */ public function setTranslationDomain($translationDomain); + /** + * Sets the property accessor for resolving property paths. + * + * @param PropertyAccessorInterface $propertyAccessor The property accessor. + * + * @return ValidatorBuilderInterface The builder object. + */ + public function setPropertyAccessor(PropertyAccessorInterface $propertyAccessor); + /** * Builds and returns a new validator object. * diff --git a/src/Symfony/Component/Validator/composer.json b/src/Symfony/Component/Validator/composer.json index de06e2326d1da..d6f6195825768 100644 --- a/src/Symfony/Component/Validator/composer.json +++ b/src/Symfony/Component/Validator/composer.json @@ -17,7 +17,8 @@ ], "require": { "php": ">=5.3.3", - "symfony/translation": "~2.0" + "symfony/translation": "~2.0", + "symfony/property-access": "~2.2" }, "require-dev": { "symfony/http-foundation": "~2.1",