Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/RegexMatchValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace ipl\Validator;

use InvalidArgumentException;

/**
* Validates value with a given regex pattern
*
* Available options:
* - pattern: (string) Regex pattern
* - notMatchMessage: (string) Message to show when the value isn't valid. If not set, a default message will be used
*/
class RegexMatchValidator extends BaseValidator
{
/** @var string Regex pattern */
protected string $pattern;

/** @var ?string Message to show when the value isn't valid. If not set, a default message will be used */
protected ?string $notMatchMessage = null;

/**
* Create a RegexMatchValidator
*
* @param string|array{pattern: string, notMatchMessage?: string|null} $pattern
*
* @throws InvalidArgumentException If the notMatchMessage consists only of whitespace
* @throws InvalidArgumentException If the pattern is missing or invalid
*/
public function __construct(string|array $pattern)
{
if (is_array($pattern)) {
$this->pattern = $pattern['pattern'] ?? throw new InvalidArgumentException("Missing option 'pattern'");
$this->notMatchMessage = $pattern['notMatchMessage'] ?? null;

if ($this->notMatchMessage !== null && trim($this->notMatchMessage) === '') {
throw new InvalidArgumentException(
"Option 'notMatchMessage' must not be an empty or whitespace-only string"
);
}
} else {
$this->pattern = $pattern;
}

$syntax = new RegexSyntaxValidator();
if (! $syntax->isValid($this->pattern)) {
throw new InvalidArgumentException($syntax->getMessages()[0]);

Check failure on line 47 in src/RegexMatchValidator.php

View workflow job for this annotation

GitHub Actions / PHP / Static analysis (8.2) / PHPStan 8.2

Parameter #1 $message of class InvalidArgumentException constructor expects string, mixed given.

Check failure on line 47 in src/RegexMatchValidator.php

View workflow job for this annotation

GitHub Actions / PHP / Static analysis (8.4) / PHPStan 8.4

Parameter #1 $message of class InvalidArgumentException constructor expects string, mixed given.

Check failure on line 47 in src/RegexMatchValidator.php

View workflow job for this annotation

GitHub Actions / PHP / Static analysis (8.3) / PHPStan 8.3

Parameter #1 $message of class InvalidArgumentException constructor expects string, mixed given.

Check failure on line 47 in src/RegexMatchValidator.php

View workflow job for this annotation

GitHub Actions / PHP / Static analysis (8.5) / PHPStan 8.5

Parameter #1 $message of class InvalidArgumentException constructor expects string, mixed given.
}
}

public function isValid($value): bool
{
// Multiple isValid() calls must not stack validation messages
$this->clearMessages();

$result = preg_match($this->pattern, $value);

Check failure on line 56 in src/RegexMatchValidator.php

View workflow job for this annotation

GitHub Actions / PHP / Static analysis (8.2) / PHPStan 8.2

Parameter #2 $subject of function preg_match expects string, mixed given.

Check failure on line 56 in src/RegexMatchValidator.php

View workflow job for this annotation

GitHub Actions / PHP / Static analysis (8.4) / PHPStan 8.4

Parameter #2 $subject of function preg_match expects string, mixed given.

Check failure on line 56 in src/RegexMatchValidator.php

View workflow job for this annotation

GitHub Actions / PHP / Static analysis (8.3) / PHPStan 8.3

Parameter #2 $subject of function preg_match expects string, mixed given.

Check failure on line 56 in src/RegexMatchValidator.php

View workflow job for this annotation

GitHub Actions / PHP / Static analysis (8.5) / PHPStan 8.5

Parameter #2 $subject of function preg_match expects string, mixed given.

if ($result === false) {
$this->addMessage(preg_last_error_msg());

return false;
}

if (! $result) {
if ($this->notMatchMessage === null) {
$this->addMessage(sprintf(
$this->translate("'%s' does not match against pattern '%s'"),
$value,

Check failure on line 68 in src/RegexMatchValidator.php

View workflow job for this annotation

GitHub Actions / PHP / Static analysis (8.2) / PHPStan 8.2

Parameter #2 ...$values of function sprintf expects bool|float|int|string|null, mixed given.

Check failure on line 68 in src/RegexMatchValidator.php

View workflow job for this annotation

GitHub Actions / PHP / Static analysis (8.4) / PHPStan 8.4

Parameter #2 ...$values of function sprintf expects bool|float|int|string|null, mixed given.

Check failure on line 68 in src/RegexMatchValidator.php

View workflow job for this annotation

GitHub Actions / PHP / Static analysis (8.3) / PHPStan 8.3

Parameter #2 ...$values of function sprintf expects bool|float|int|string|null, mixed given.

Check failure on line 68 in src/RegexMatchValidator.php

View workflow job for this annotation

GitHub Actions / PHP / Static analysis (8.5) / PHPStan 8.5

Parameter #2 ...$values of function sprintf expects bool|float|int|string|null, mixed given.
$this->pattern
));
} else {
$this->addMessage($this->notMatchMessage);
}

return false;
}

return true;
}
}
155 changes: 155 additions & 0 deletions tests/RegexMatchValidatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php

namespace ipl\Tests\Validator;

use InvalidArgumentException;
use ipl\I18n\NoopTranslator;
use ipl\I18n\StaticTranslator;
use ipl\Validator\RegexMatchValidator;

class RegexMatchValidatorTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

StaticTranslator::$instance = new NoopTranslator();
}

public function testValidPatternMatch(): void
{
$validator = new RegexMatchValidator('/^[a-z]+$/');

$this->assertTrue($validator->isValid('abc'));
$this->assertTrue($validator->isValid('xyz'));
}

public function testInvalidPatternMatch(): void
{
$validator = new RegexMatchValidator('/^[a-z]+$/');

$this->assertFalse($validator->isValid('ABC'));
$this->assertFalse($validator->isValid('123'));
$this->assertFalse($validator->isValid('abc123'));
}

public function testConstructorWithStringPattern(): void
{
$validator = new RegexMatchValidator('/^\d{3}-\d{4}$/');

$this->assertTrue($validator->isValid('123-4567'));
$this->assertFalse($validator->isValid('12-4567'));
$this->assertFalse($validator->isValid('abc-defg'));
}

public function testConstructorWithArrayPattern(): void
{
$validator = new RegexMatchValidator(['pattern' => '/^test/']);

$this->assertTrue($validator->isValid('test123'));
$this->assertTrue($validator->isValid('testing'));
$this->assertFalse($validator->isValid('notest'));
}

public function testCustomNotMatchMessage(): void
{
$customMessage = 'This value does not match the required pattern';
$validator = new RegexMatchValidator([
'pattern' => '/^[0-9]+$/',
'notMatchMessage' => $customMessage
]);

$this->assertFalse($validator->isValid('abc'));

$messages = $validator->getMessages();
$this->assertCount(1, $messages);
$this->assertSame($customMessage, $messages[0]);
}

public function testDefaultNotMatchMessage(): void
{
$validator = new RegexMatchValidator('/^[0-9]+$/');

$this->assertFalse($validator->isValid('abc'));

$messages = $validator->getMessages();
$this->assertCount(1, $messages);
$this->assertStringContainsString("'abc'", $messages[0]);
$this->assertStringContainsString('/^[0-9]+$/', $messages[0]);
}

public function testWhitespaceOnlyNotMatchMessageThrowsException(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("Option 'notMatchMessage' must not be an empty or whitespace-only string");

new RegexMatchValidator(['pattern' => '/^test/', 'notMatchMessage' => " \t\n "]);
}

public function testMissingPatternOptionThrowsException(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("Missing option 'pattern'");

new RegexMatchValidator(['notMatchMessage' => 'Test message']);
}

public function testInvalidRegexPattern(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('failed to compile');

new RegexMatchValidator('/[/'); // Unclosed character class
}

public function testPregMatchErrorYieldsErrorMessage(): void
{
$validator = new RegexMatchValidator('/^.$/u');

$this->assertFalse($validator->isValid("\xFF"));

$messages = $validator->getMessages();

$this->assertCount(1, $messages);
$this->assertStringContainsString('UTF-8', $messages[0]);
}

public function testMultipleIsValidCalls(): void
{
$validator = new RegexMatchValidator('/^[a-z]+$/');

// First validation - should fail
$this->assertFalse($validator->isValid('123'));
$this->assertCount(1, $validator->getMessages());

// Second validation - should succeed and clear previous messages
$this->assertTrue($validator->isValid('abc'));
$this->assertCount(0, $validator->getMessages());

// Third validation - should fail with new message
$this->assertFalse($validator->isValid('XYZ'));
$this->assertCount(1, $validator->getMessages());
}

public function testHexColorPattern(): void
{
$validator = new RegexMatchValidator('/^#[0-9A-Fa-f]{6}$/');

$this->assertTrue($validator->isValid('#FF5733'));
$this->assertTrue($validator->isValid('#000000'));
$this->assertTrue($validator->isValid('#ffffff'));
$this->assertFalse($validator->isValid('#FFF'));
$this->assertFalse($validator->isValid('FF5733'));
$this->assertFalse($validator->isValid('#GGGGGG'));
}

public function testPhoneNumberPattern(): void
{
$validator = new RegexMatchValidator('/^\+?[1-9]\d{1,14}$/');

$this->assertTrue($validator->isValid('+1234567890'));
$this->assertTrue($validator->isValid('1234567890'));
$this->assertFalse($validator->isValid('+0123456789'));
$this->assertFalse($validator->isValid('abc'));
}
}
Loading