diff --git a/src/Assets/AssetContainer.php b/src/Assets/AssetContainer.php index d20399873d..a3228f6157 100644 --- a/src/Assets/AssetContainer.php +++ b/src/Assets/AssetContainer.php @@ -20,6 +20,7 @@ use Statamic\Facades\Blueprint; use Statamic\Facades\File; use Statamic\Facades\Image; +use Statamic\Facades\Pattern; use Statamic\Facades\Search; use Statamic\Facades\Stache; use Statamic\Facades\URL; @@ -357,7 +358,7 @@ public function assets($folder = '/', $recursive = false) if ($folder !== null) { if ($recursive) { - $query->where('path', 'like', "{$folder}/%"); + $query->where('path', 'like', Pattern::sqlLikeQuote($folder).'/%'); } else { $query->where('folder', $folder); } diff --git a/src/Facades/Endpoint/Pattern.php b/src/Facades/Endpoint/Pattern.php index 19f571af8e..3697c67fb6 100644 --- a/src/Facades/Endpoint/Pattern.php +++ b/src/Facades/Endpoint/Pattern.php @@ -2,6 +2,8 @@ namespace Statamic\Facades\Endpoint; +use Statamic\Support\Str; + /** * Regular expressions, et al. */ @@ -104,4 +106,35 @@ public function isUUID($value) { return (bool) preg_match($this->uuid(), $value); } + + /** + * Quotes (escapes) SQL LIKE syntax. + * + * Similar to the preg_quote method for regular expressions. + */ + public function sqlLikeQuote(string $like): string + { + return Str::of($like) + ->replace('%', '\%') + ->replace('_', '\_'); + } + + /** + * Converts SQL LIKE syntax to a regular expression. + * + * @return string The regular expression without delimiters. + */ + public function sqlLikeToRegex(string $like): string + { + return Str::of($like) + ->replace('\_', $underscore = Str::random()) + ->replace('\%', $percent = Str::random()) + ->pipe(fn ($str) => preg_quote($str, '/')) + ->replace('%', '.*') + ->replace('_', '.') + ->replace($underscore, '_') + ->replace($percent, '%') + ->prepend('^') + ->append('$'); + } } diff --git a/src/Query/Builder.php b/src/Query/Builder.php index cefe3feead..7951112284 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -10,6 +10,7 @@ use InvalidArgumentException; use Statamic\Contracts\Query\Builder as Contract; use Statamic\Extensions\Pagination\LengthAwarePaginator; +use Statamic\Facades\Pattern; abstract class Builder implements Contract { @@ -629,13 +630,13 @@ protected function filterTestGreaterThanOrEqualTo($item, $value) protected function filterTestLike($item, $like) { - $pattern = '/^'.str_replace(['%', '_'], ['.*', '.'], preg_quote($like, '/')).'$/im'; - if (is_array($item)) { $item = json_encode($item); } - return preg_match($pattern, (string) $item); + $pattern = Pattern::sqlLikeToRegex($like); + + return preg_match('/'.$pattern.'/im', (string) $item); } protected function filterTestNotLike($item, $like) diff --git a/tests/Assets/AssetContainerTest.php b/tests/Assets/AssetContainerTest.php index 017616ca0a..780bc8c4df 100644 --- a/tests/Assets/AssetContainerTest.php +++ b/tests/Assets/AssetContainerTest.php @@ -821,6 +821,20 @@ public function it_gets_assets_in_a_folder_recursively() }); } + /** + * @test + * + * @see https://github.com/statamic/cms/issues/8825 + * @see https://github.com/statamic/cms/pull/8826 + **/ + public function it_doesnt_get_kebab_case_folder_assets_when_querying_snake_case_folder() + { + tap($this->containerWithDisk('snake-kebab')->assets('foo_bar', true), function ($assets) { + $this->assertCount(1, $assets); + $this->assertEquals('foo_bar/alfa.txt', $assets->first()->path()); + }); + } + /** * @test * @@ -954,11 +968,11 @@ public function it_is_arrayable() }); } - private function containerWithDisk() + private function containerWithDisk($fixture = 'container') { config(['filesystems.disks.test' => [ 'driver' => 'local', - 'root' => __DIR__.'/__fixtures__/container', + 'root' => __DIR__.'/__fixtures__/'.$fixture, ]]); $container = (new AssetContainer)->handle('test')->disk('test'); diff --git a/tests/Assets/__fixtures__/snake-kebab/foo-bar/bravo.txt b/tests/Assets/__fixtures__/snake-kebab/foo-bar/bravo.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/Assets/__fixtures__/snake-kebab/foo_bar/alfa.txt b/tests/Assets/__fixtures__/snake-kebab/foo_bar/alfa.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/Data/Entries/EntryQueryBuilderTest.php b/tests/Data/Entries/EntryQueryBuilderTest.php index b1149a640d..877804622b 100644 --- a/tests/Data/Entries/EntryQueryBuilderTest.php +++ b/tests/Data/Entries/EntryQueryBuilderTest.php @@ -669,4 +669,72 @@ public function entries_are_found_using_offset() $this->assertCount(2, $entries); $this->assertEquals(['Post 2', 'Post 3'], $entries->map->title->all()); } + + /** + * @test + * + * @dataProvider likeProvider + */ + public function entries_are_found_using_like($like, $expected) + { + Collection::make('posts')->save(); + + collect([ + 'on', + 'only', + 'foo', + 'food', + 'boo', + 'foo bar', + 'foo_bar', + 'foodbar', + 'hello world', + 'waterworld', + 'world of warcraft', + '20%', + '20% of the time', + '20 something', + 'Pi is 3.14159', + 'Pi is not 3x14159', + 'Use a [4.x] prefix for PRs', + '/', + '/ test', + 'test /', + 'test / test', + ])->each(function ($val, $i) { + EntryFactory::id($i) + ->slug('post-'.$i) + ->collection('posts') + ->data(['title' => $val]) + ->create(); + }); + + $this->assertEquals($expected, Entry::query()->where('title', 'like', $like)->get()->map->title->all()); + } + + public function likeProvider() + { + return collect([ + 'foo' => ['foo'], + 'foo%' => ['foo', 'food', 'foo bar', 'foo_bar', 'foodbar'], + '%world' => ['hello world', 'waterworld'], + '%world%' => ['hello world', 'waterworld', 'world of warcraft'], + '_oo' => ['foo', 'boo'], + 'o_' => ['on'], + 'foo_bar' => ['foo bar', 'foo_bar', 'foodbar'], + 'foo__bar' => [], + 'fo__bar' => ['foo bar', 'foo_bar', 'foodbar'], + 'foo\_bar' => ['foo_bar'], + '20\%' => ['20%'], + '20\%%' => ['20%', '20% of the time'], + '%3.14%' => ['Pi is 3.14159'], + '%[4%' => ['Use a [4.x] prefix for PRs'], + '/' => ['/'], + '%/' => ['/', 'test /'], + '/%' => ['/', '/ test'], + '%/%' => ['/', '/ test', 'test /', 'test / test'], + ])->mapWithKeys(function ($expected, $like) { + return [$like => [$like, $expected]]; + }); + } } diff --git a/tests/Facades/PatternTest.php b/tests/Facades/PatternTest.php new file mode 100644 index 0000000000..6e0d02051a --- /dev/null +++ b/tests/Facades/PatternTest.php @@ -0,0 +1,67 @@ +assertEquals($expected, Pattern::sqlLikeQuote($string)); + } + + public function likeProvider() + { + return collect([ + 'foo' => 'foo', + '%foo' => '\%foo', + 'foo%' => 'foo\%', + '%foo%' => '\%foo\%', + '_foo' => '\_foo', + 'foo_' => 'foo\_', + '_foo_' => '\_foo\_', + 'f_o' => 'f\_o', + ])->mapWithKeys(fn ($expected, $string) => [$string => [$string, $expected]])->all(); + } + + /** + * @test + * + * @dataProvider likeRegexProvider + */ + public function it_converts_sql_like_syntax_to_regex($string, $expected) + { + $this->assertEquals($expected, Pattern::sqlLikeToRegex($string)); + } + + public function likeRegexProvider() + { + return collect([ + 'foo' => '^foo$', + 'foo%' => '^foo.*$', + '%world' => '^.*world$', + '%world%' => '^.*world.*$', + '_oo' => '^.oo$', + 'o_' => '^o.$', + 'foo_bar' => '^foo.bar$', + 'foo__bar' => '^foo..bar$', + 'fo__bar' => '^fo..bar$', + 'foo\_bar' => '^foo_bar$', + '20\%' => '^20%$', + '20\%%' => '^20%.*$', + '%3.14%' => '^.*3\.14.*$', + '%[4%' => '^.*\[4.*$', + '/' => '^\/$', + '%/' => '^.*\/$', + '/%' => '^\/.*$', + '%/%' => '^.*\/.*$', + ])->mapWithKeys(fn ($expected, $string) => [$string => [$string, $expected]])->all(); + } +}