diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba49bead..224103c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ `Logger::assertLevelIsSupported()` (@vjik) - Chg #104: Deprecate method `Logger::validateLevel()` (@vjik) - Bug #98: Fix error on formatting trace, when it doesn't contain "file" and "line" (@vjik) +- New #108: Support of nested values in message templates' variables, e. g. `{foo.bar}` (@vjik) +- Bug #89: Fix error on parse messages, that contains variables that cannot cast to a string (@vjik) ## 2.0.0 May 22, 2022 diff --git a/src/Message.php b/src/Message.php index 910f6833f..a9ce4d087 100644 --- a/src/Message.php +++ b/src/Message.php @@ -8,6 +8,8 @@ use Psr\Log\LoggerTrait; use Psr\Log\LogLevel; use Stringable; +use Yiisoft\Log\Message\ContextValueExtractor; +use Yiisoft\VarDumper\VarDumper; use function preg_replace_callback; @@ -126,17 +128,25 @@ public function context(string $name = null, mixed $default = null): mixed */ private function parse(string|Stringable $message, array $context): string { - $message = (string)$message; - - return preg_replace_callback('/{([\w.]+)}/', static function (array $matches) use ($context) { - $placeholderName = $matches[1]; - - if (isset($context[$placeholderName])) { - /** @psalm-suppress PossiblyInvalidCast */ - return (string) $context[$placeholderName]; - } - - return $matches[0]; - }, $message); + $message = (string) $message; + + return preg_replace_callback( + '/{(.*)}/', + static function (array $matches) use ($context) { + [$exist, $value] = ContextValueExtractor::extract($context, $matches[1]); + if ($exist) { + if ( + is_scalar($value) + || $value instanceof Stringable + || $value === null + ) { + return (string) $value; + } + return VarDumper::create($value)->asString(); + } + return $matches[0]; + }, + $message + ); } } diff --git a/src/Message/ContextValueExtractor.php b/src/Message/ContextValueExtractor.php new file mode 100644 index 000000000..d7e11de86 --- /dev/null +++ b/src/Message/ContextValueExtractor.php @@ -0,0 +1,80 @@ + + */ + private static function parsePath(string $path): array + { + if ($path === '') { + return []; + } + + if (!str_contains($path, '.')) { + return [str_replace('\\\\', '\\', $path)]; + } + + /** @psalm-var non-empty-list $matches */ + $matches = preg_split( + sprintf( + '/(?%1$s%1$s)*)%2$s/', + preg_quote('\\', '/'), + preg_quote('.', '/') + ), + $path, + -1, + PREG_SPLIT_OFFSET_CAPTURE + ); + $result = []; + $countResults = count($matches); + for ($i = 1; $i < $countResults; $i++) { + $l = $matches[$i][1] - $matches[$i - 1][1] - strlen($matches[$i - 1][0]) - 1; + $result[] = $matches[$i - 1][0] . ($l > 0 ? str_repeat('\\', $l) : ''); + } + $result[] = $matches[$countResults - 1][0]; + + return array_map( + static fn(string $key): string => str_replace( + [ + '\\\\', + '\\.', + ], + [ + '\\', + '.', + ], + $key + ), + $result + ); + } +} diff --git a/tests/MessageTest.php b/tests/MessageTest.php index 33c1e58de..064f16b64 100644 --- a/tests/MessageTest.php +++ b/tests/MessageTest.php @@ -7,7 +7,9 @@ use PHPUnit\Framework\TestCase; use Psr\Log\InvalidArgumentException; use Psr\Log\LogLevel; +use stdClass; use Yiisoft\Log\Message; +use Yiisoft\Log\Tests\TestAsset\StringableObject; final class MessageTest extends TestCase { @@ -52,29 +54,113 @@ public function testConstructorThrowExceptionForUnknownLevel(): void new Message('unknown', 'message'); } - public function parseMessageProvider(): array + public function dataParseMessage(): array { return [ - [ + 'no-placeholder' => [ 'no placeholder', ['foo' => 'some'], 'no placeholder', ], - [ + 'string' => [ 'has {foo} placeholder', ['foo' => 'some'], 'has some placeholder', ], - [ + 'without-value' => [ 'has {foo} placeholder', [], 'has {foo} placeholder', ], + 'null' => [ + 'has "{foo}" placeholder', + ['foo' => null], + 'has "" placeholder', + ], + 'array' => [ + 'has "{foo}" placeholder', + ['foo' => ['bar' => 7]], + << 7 + ]" placeholder + TEXT, + ], + 'nested' => [ + 'has "{foo.bar}" placeholder', + ['foo' => ['bar' => 7]], + 'has "7" placeholder', + ], + 'nested-non-exist' => [ + 'has "{foo.bar}" placeholder', + ['foo' => []], + 'has "{foo.bar}" placeholder', + ], + 'nested-non-stringable' => [ + 'has "{foo.bar}" placeholder', + ['foo' => new stdClass()], + 'has "{foo.bar}" placeholder', + ], + 'stringable' => [ + 'has "{foo}" placeholder', + ['foo' => new StringableObject('test')], + 'has "test" placeholder', + ], + 'nested-placeholder' => [ + 'has "{a{b}c}" placeholder', + ['a{b}c' => 'test'], + 'has "test" placeholder', + ], + 'nested-quoted' => [ + 'has "{foo\.ba\\\\r}" placeholder', + ['foo.ba\\r' => 'test'], + 'has "test" placeholder', + ], + 'nested-extended-1' => [ + 'has "{foo\\\.bar}" placeholder', + ['foo\\' => ['bar' => 'test']], + 'has "test" placeholder', + ], + 'nested-extended-2' => [ + 'has "{foo\\\\\\\\.bar}" placeholder', + ['foo\\\\' => ['bar' => 'test']], + 'has "test" placeholder', + ], + 'nested-extended-3' => [ + 'has "{foo\\\.}" placeholder', + ['foo\\' => ['' => 'test']], + 'has "test" placeholder', + ], + 'nested-extended-4' => [ + 'has "{foo\\\\}" placeholder', + ['foo\\' => 'test'], + 'has "test" placeholder', + ], + 'nested-extended-5' => [ + 'has "{foo\.bar.a}" placeholder', + ['foo.bar' => ['a' => 'test']], + 'has "test" placeholder', + ], + 'nested-extended-6' => [ + 'has "{key1\..\.key2\..\.key3}" placeholder', + ['key1.' => ['.key2.' => ['.key3' => 'test']]], + 'has "test" placeholder', + ], + 'nested-extended-7' => [ + 'has "{key1\..\.key2\..\.key3}" placeholder', + ['key1.' => ['.key2.' => ['.key3' => 'test']]], + 'has "test" placeholder', + ], + 'empty' => [ + 'Value — "{}"', + ['' => 'test'], + 'Value — "test"', + ], ]; } /** - * @dataProvider parseMessageProvider + * @dataProvider dataParseMessage */ public function testParseMessage(string $message, array $context, string $expected): void { diff --git a/tests/TestAsset/StringableObject.php b/tests/TestAsset/StringableObject.php new file mode 100644 index 000000000..99de541ab --- /dev/null +++ b/tests/TestAsset/StringableObject.php @@ -0,0 +1,20 @@ +string; + } +}