[ExpressionLanguage] Added expression language syntax validator

This commit is contained in:
Andrey Sevastianov 2020-02-21 13:17:04 +02:00 committed by Fabien Potencier
parent 0bec08f0d8
commit a5cd965494
9 changed files with 342 additions and 8 deletions

View File

@ -1,6 +1,12 @@
CHANGELOG
=========
5.1.0
-----
* added `lint` method to `ExpressionLanguage` class
* added `lint` method to `Parser` class
4.0.0
-----

View File

@ -97,6 +97,23 @@ class ExpressionLanguage
return $parsedExpression;
}
/**
* Validates the syntax of an expression.
*
* @param Expression|string $expression The expression to validate
* @param array|null $names The list of acceptable variable names in the expression, or null to accept any names
*
* @throws SyntaxError When the passed expression is invalid
*/
public function lint($expression, ?array $names): void
{
if ($expression instanceof ParsedExpression) {
return;
}
$this->getParser()->lint($this->getLexer()->tokenize((string) $expression), $names);
}
/**
* Registers a function.
*

View File

@ -31,6 +31,7 @@ class Parser
private $binaryOperators;
private $functions;
private $names;
private $lint;
public function __construct(array $functions)
{
@ -90,6 +91,30 @@ class Parser
* @throws SyntaxError
*/
public function parse(TokenStream $stream, array $names = [])
{
$this->lint = false;
return $this->doParse($stream, $names);
}
/**
* Validates the syntax of an expression.
*
* The syntax of the passed expression will be checked, but not parsed.
* If you want to skip checking dynamic variable names, pass `null` instead of the array.
*
* @throws SyntaxError When the passed expression is invalid
*/
public function lint(TokenStream $stream, ?array $names = []): void
{
$this->lint = true;
$this->doParse($stream, $names);
}
/**
* @throws SyntaxError
*/
private function doParse(TokenStream $stream, ?array $names = []): Node\Node
{
$this->stream = $stream;
$this->names = $names;
@ -197,13 +222,17 @@ class Parser
$node = new Node\FunctionNode($token->value, $this->parseArguments());
} else {
if (!\in_array($token->value, $this->names, true)) {
throw new SyntaxError(sprintf('Variable "%s" is not valid.', $token->value), $token->cursor, $this->stream->getExpression(), $token->value, $this->names);
}
if (!$this->lint || \is_array($this->names)) {
if (!\in_array($token->value, $this->names, true)) {
throw new SyntaxError(sprintf('Variable "%s" is not valid.', $token->value), $token->cursor, $this->stream->getExpression(), $token->value, $this->names);
}
// is the name used in the compiled code different
// from the name used in the expression?
if (\is_int($name = array_search($token->value, $this->names))) {
// is the name used in the compiled code different
// from the name used in the expression?
if (\is_int($name = array_search($token->value, $this->names))) {
$name = $token->value;
}
} else {
$name = $token->value;
}

View File

@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase;
use Symfony\Component\ExpressionLanguage\Lexer;
use Symfony\Component\ExpressionLanguage\Node;
use Symfony\Component\ExpressionLanguage\Parser;
use Symfony\Component\ExpressionLanguage\SyntaxError;
class ParserTest extends TestCase
{
@ -234,4 +235,98 @@ class ParserTest extends TestCase
$parser->parse($lexer->tokenize('foo > bar'), ['foo', 'baz']);
}
/**
* @dataProvider getLintData
*/
public function testLint($expression, $names, ?string $exception = null)
{
if ($exception) {
$this->expectException(SyntaxError::class);
$this->expectExceptionMessage($exception);
}
$lexer = new Lexer();
$parser = new Parser([]);
$parser->lint($lexer->tokenize($expression), $names);
// Parser does't return anything when the correct expression is passed
$this->expectNotToPerformAssertions();
}
public function getLintData(): array
{
return [
'valid expression' => [
'expression' => 'foo["some_key"].callFunction(a ? b)',
'names' => ['foo', 'a', 'b'],
],
'allow expression without names' => [
'expression' => 'foo.bar',
'names' => null,
],
'disallow expression without names' => [
'expression' => 'foo.bar',
'names' => [],
'exception' => 'Variable "foo" is not valid around position 1 for expression `foo.bar',
],
'operator collisions' => [
'expression' => 'foo.not in [bar]',
'names' => ['foo', 'bar'],
],
'incorrect expression ending' => [
'expression' => 'foo["a"] foo["b"]',
'names' => ['foo'],
'exception' => 'Unexpected token "name" of value "foo" '.
'around position 10 for expression `foo["a"] foo["b"]`.',
],
'incorrect operator' => [
'expression' => 'foo["some_key"] // 2',
'names' => ['foo'],
'exception' => 'Unexpected token "operator" of value "/" '.
'around position 18 for expression `foo["some_key"] // 2`.',
],
'incorrect array' => [
'expression' => '[value1, value2 value3]',
'names' => ['value1', 'value2', 'value3'],
'exception' => 'An array element must be followed by a comma. '.
'Unexpected token "name" of value "value3" ("punctuation" expected with value ",") '.
'around position 17 for expression `[value1, value2 value3]`.',
],
'incorrect array element' => [
'expression' => 'foo["some_key")',
'names' => ['foo'],
'exception' => 'Unclosed "[" around position 3 for expression `foo["some_key")`.',
],
'missed array key' => [
'expression' => 'foo[]',
'names' => ['foo'],
'exception' => 'Unexpected token "punctuation" of value "]" around position 5 for expression `foo[]`.',
],
'missed closing bracket in sub expression' => [
'expression' => 'foo[(bar ? bar : "default"]',
'names' => ['foo', 'bar'],
'exception' => 'Unclosed "(" around position 4 for expression `foo[(bar ? bar : "default"]`.',
],
'incorrect hash following' => [
'expression' => '{key: foo key2: bar}',
'names' => ['foo', 'bar'],
'exception' => 'A hash value must be followed by a comma. '.
'Unexpected token "name" of value "key2" ("punctuation" expected with value ",") '.
'around position 11 for expression `{key: foo key2: bar}`.',
],
'incorrect hash assign' => [
'expression' => '{key => foo}',
'names' => ['foo'],
'exception' => 'Unexpected character "=" around position 5 for expression `{key => foo}`.',
],
'incorrect array as hash using' => [
'expression' => '[foo: foo]',
'names' => ['foo'],
'exception' => 'An array element must be followed by a comma. '.
'Unexpected token "punctuation" of value ":" ("punctuation" expected with value ",") '.
'around position 5 for expression `[foo: foo]`.',
],
];
}
}

View File

@ -9,6 +9,7 @@ CHANGELOG
* allow to define a reusable set of constraints by extending the `Compound` constraint
* added `Sequentially` constraint, to sequentially validate a set of constraints (any violation raised will prevent further validation of the nested constraints)
* added the `divisibleBy` option to the `Count` constraint
* added the `ExpressionLanguageSyntax` constraint
5.0.0
-----

View File

@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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
* @Target({"PROPERTY", "METHOD", "ANNOTATION"})
*
* @author Andrey Sevastianov <mrpkmail@gmail.com>
*/
class ExpressionLanguageSyntax extends Constraint
{
const EXPRESSION_LANGUAGE_SYNTAX_ERROR = '1766a3f3-ff03-40eb-b053-ab7aa23d988a';
protected static $errorNames = [
self::EXPRESSION_LANGUAGE_SYNTAX_ERROR => 'EXPRESSION_LANGUAGE_SYNTAX_ERROR',
];
public $message = 'This value should be a valid expression.';
public $service;
public $validateNames = true;
public $names = [];
/**
* {@inheritdoc}
*/
public function validatedBy()
{
return $this->service;
}
}

View File

@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\ExpressionLanguage\SyntaxError;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* @author Andrey Sevastianov <mrpkmail@gmail.com>
*/
class ExpressionLanguageSyntaxValidator extends ConstraintValidator
{
private $expressionLanguage;
public function __construct(ExpressionLanguage $expressionLanguage)
{
$this->expressionLanguage = $expressionLanguage;
}
/**
* {@inheritdoc}
*/
public function validate($expression, Constraint $constraint): void
{
if (!$constraint instanceof ExpressionLanguageSyntax) {
throw new UnexpectedTypeException($constraint, ExpressionLanguageSyntax::class);
}
if (!\is_string($expression)) {
throw new UnexpectedTypeException($expression, 'string');
}
try {
$this->expressionLanguage->lint($expression, ($constraint->validateNames ? ($constraint->names ?? []) : null));
} catch (SyntaxError $exception) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ syntax_error }}', $this->formatValue($exception->getMessage()))
->setInvalidValue((string) $expression)
->setCode(ExpressionLanguageSyntax::EXPRESSION_LANGUAGE_SYNTAX_ERROR)
->addViolation();
}
}
}

View File

@ -0,0 +1,88 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\ExpressionLanguage\SyntaxError;
use Symfony\Component\Validator\Constraints\ExpressionLanguageSyntax;
use Symfony\Component\Validator\Constraints\ExpressionLanguageSyntaxValidator;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
class ExpressionLanguageSyntaxTest extends ConstraintValidatorTestCase
{
/**
* @var \PHPUnit\Framework\MockObject\MockObject|ExpressionLanguage
*/
protected $expressionLanguage;
protected function createValidator()
{
return new ExpressionLanguageSyntaxValidator($this->expressionLanguage);
}
protected function setUp(): void
{
$this->expressionLanguage = $this->createExpressionLanguage();
parent::setUp();
}
public function testExpressionValid(): void
{
$this->expressionLanguage->expects($this->once())
->method('lint')
->with($this->value, []);
$this->validator->validate($this->value, new ExpressionLanguageSyntax([
'message' => 'myMessage',
]));
$this->assertNoViolation();
}
public function testExpressionWithoutNames(): void
{
$this->expressionLanguage->expects($this->once())
->method('lint')
->with($this->value, null);
$this->validator->validate($this->value, new ExpressionLanguageSyntax([
'message' => 'myMessage',
'validateNames' => false,
]));
$this->assertNoViolation();
}
public function testExpressionIsNotValid(): void
{
$this->expressionLanguage->expects($this->once())
->method('lint')
->with($this->value, [])
->willThrowException(new SyntaxError('Test exception', 42));
$this->validator->validate($this->value, new ExpressionLanguageSyntax([
'message' => 'myMessage',
]));
$this->buildViolation('myMessage')
->setParameter('{{ syntax_error }}', '"Test exception around position 42."')
->setCode(ExpressionLanguageSyntax::EXPRESSION_LANGUAGE_SYNTAX_ERROR)
->assertRaised();
}
protected function createExpressionLanguage(): MockObject
{
return $this->getMockBuilder('\Symfony\Component\ExpressionLanguage\ExpressionLanguage')->getMock();
}
}

View File

@ -30,7 +30,7 @@
"symfony/yaml": "^4.4|^5.0",
"symfony/config": "^4.4|^5.0",
"symfony/dependency-injection": "^4.4|^5.0",
"symfony/expression-language": "^4.4|^5.0",
"symfony/expression-language": "^5.1",
"symfony/cache": "^4.4|^5.0",
"symfony/mime": "^4.4|^5.0",
"symfony/property-access": "^4.4|^5.0",
@ -44,6 +44,7 @@
"doctrine/lexer": "<1.0.2",
"phpunit/phpunit": "<5.4.3",
"symfony/dependency-injection": "<4.4",
"symfony/expression-language": "<5.1",
"symfony/http-kernel": "<4.4",
"symfony/intl": "<4.4",
"symfony/translation": "<4.4",
@ -61,7 +62,7 @@
"egulias/email-validator": "Strict (RFC compliant) email validation",
"symfony/property-access": "For accessing properties within comparison constraints",
"symfony/property-info": "To automatically add NotNull and Type constraints",
"symfony/expression-language": "For using the Expression validator"
"symfony/expression-language": "For using the Expression validator and the ExpressionLanguageSyntax constraints"
},
"autoload": {
"psr-4": { "Symfony\\Component\\Validator\\": "" },