diff --git a/src/BindsOnUlid.php b/src/BindsOnUlid.php new file mode 100644 index 0000000..34480fb --- /dev/null +++ b/src/BindsOnUlid.php @@ -0,0 +1,28 @@ +firstOrFail(); + } + + /** + * Get the route key for the model. + */ + public function getRouteKeyName(): string + { + return $this->ulidColumn(); + } +} diff --git a/src/GeneratesUlid.php b/src/GeneratesUlid.php new file mode 100644 index 0000000..ee99746 --- /dev/null +++ b/src/GeneratesUlid.php @@ -0,0 +1,127 @@ +ulidColumns() as $item) { + $ulid = new Ulid(); + + if (isset($model->attributes[$item]) && !is_null($model->attributes[$item])) { + try { + $ulid = Ulid::fromString(strtolower($model->attributes[$item])); + } catch (InvalidUuidStringException $e) { + $ulid = Ulid::fromBinary($model->attributes[$item]); + } + } + + $model->{$item} = strtolower($ulid->toBase32()); + } + }); + } + + /** + * The name of the column that should be used for the Ulid. + */ + public function ulidColumn(): string + { + return 'ulid'; + } + + /** + * The names of the columns that should be used for the Ulid. + */ + public function ulidColumns(): array + { + return [$this->ulidColumn()]; + } + + /** + * Scope queries to find by Ulid. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|array $ulid + * @param string $ulidColumn + */ + public function scopeWhereUlid($query, $ulid, $ulidColumn = null): Builder + { + $ulidColumn = !is_null($ulidColumn) && in_array($ulidColumn, $this->ulidColumns()) + ? $ulidColumn + : $this->ulidColumns()[0]; + + $ulid = $this->normaliseUuids($ulid); + + if ($this->isClassCastable($ulidColumn)) { + $ulid = $this->bytesFromUlid($ulid); + } + + return $query->whereIn( + $this->qualifyColumn($ulidColumn), + Arr::wrap($ulid) + ); + } + + /** + * Convert a single UUID or array of UUIDs to bytes. + * + * @param \Illuminate\Contracts\Support\Arrayable|array|string $ulid + */ + protected function bytesFromUlid($ulid): array + { + if (is_array($ulid) || $ulid instanceof Arrayable) { + array_walk($ulid, function (&$uuid) { + $uuid = Ulid::fromString($uuid)->toBinary(); + }); + + return $ulid; + } + + return Arr::wrap(Ulid::fromString($ulid)->toBinary()); + } + + /** + * Normalises a single or array of input Ulids, filtering any invalid Ulid. + * + * @param \Illuminate\Contracts\Support\Arrayable|array|string $ulid + */ + protected function normaliseUuids($ulid): array + { + $ulid = array_map(function ($uuid) { + return Str::lower($uuid); + }, Arr::wrap($ulid)); + + return array_filter($ulid, function ($uuid) { + return Ulid::isValid($uuid); + }); + } +} diff --git a/tests/Feature/BindUlidTest.php b/tests/Feature/BindUlidTest.php new file mode 100644 index 0000000..5492524 --- /dev/null +++ b/tests/Feature/BindUlidTest.php @@ -0,0 +1,91 @@ +create(); + + Route::middleware(SubstituteBindings::class)->get('/posts/{post}', function (UlidRouteBoundPost $post) { + return $post; + })->name('posts.show'); + + $this->get('/posts/'.$post->ulid)->assertSuccessful(); + $this->get(route('posts.show', $post))->assertSuccessful(); + } + + /** @test */ + public function it_fails_on_invalid_default_ulid_field_value() + { + $post = factory(UlidRouteBoundPost::class)->create(); + + Route::middleware(SubstituteBindings::class)->get('/posts/{post}', function (UlidRouteBoundPost $post) { + return $post; + })->name('posts.show'); + + $this->get('/posts/'.$post->custom_ulid)->assertNotFound(); + $this->get(route('posts.show', $post->custom_ulid))->assertNotFound(); + } + + /** @test */ + public function it_binds_to_custom_ulid_field() + { + $post = factory(CustomUlidRouteBoundPost::class)->create(); + + Route::middleware(SubstituteBindings::class)->get('/posts/{post}', function (CustomUlidRouteBoundPost $post) { + return $post; + })->name('posts.show'); + + $this->get('/posts/'.$post->custom_ulid)->assertSuccessful(); + $this->get(route('posts.show', $post))->assertSuccessful(); + } + + /** @test */ + public function it_fails_on_invalid_custom_ulid_field_value() + { + $post = factory(CustomUlidRouteBoundPost::class)->create(); + + Route::middleware(SubstituteBindings::class)->get('/posts/{post}', function (CustomUlidRouteBoundPost $post) { + return $post; + })->name('posts.show'); + + $this->get('/posts/'.$post->ulid)->assertNotFound(); + $this->get(route('posts.show', $post->ulid))->assertNotFound(); + } + + /** @test */ + public function it_binds_to_declared_ulid_column_instead_of_default_when_custom_key_used() + { + $post = factory(MultipleUlidRouteBoundPost::class)->create(); + + Route::middleware(SubstituteBindings::class)->get('/posts/{post:custom_ulid}', function (MultipleUlidRouteBoundPost $post) { + return $post; + })->name('posts.show'); + + $this->get('/posts/'.$post->custom_ulid)->assertSuccessful(); + $this->get(route('posts.show', $post))->assertSuccessful(); + } + + /** @test */ + public function it_fails_on_invalid_ulid_when_custom_route_key_used() + { + $post = factory(MultipleUlidRouteBoundPost::class)->create(); + + Route::middleware(SubstituteBindings::class)->get('/posts/{post:custom_ulid}', function (MultipleUlidRouteBoundPost $post) { + return $post; + })->name('posts.show'); + + $this->get('/posts/'.$post->ulid)->assertNotFound(); + $this->get(route('posts.show', $post->ulid))->assertNotFound(); + } +} diff --git a/tests/Feature/BindUuidTest.php b/tests/Feature/BindUuidTest.php index e2e55d8..92c6711 100644 --- a/tests/Feature/BindUuidTest.php +++ b/tests/Feature/BindUuidTest.php @@ -4,9 +4,9 @@ use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Support\Facades\Route; -use Tests\Fixtures\CustomUuidRouteBoundPost; -use Tests\Fixtures\MultipleUuidRouteBoundPost; -use Tests\Fixtures\UuidRouteBoundPost; +use Tests\Fixtures\Uuid\CustomUuidRouteBoundPost; +use Tests\Fixtures\Uuid\MultipleUuidRouteBoundPost; +use Tests\Fixtures\Uuid\UuidRouteBoundPost; use Tests\TestCase; class BindUuidTest extends TestCase diff --git a/tests/Feature/UlidTest.php b/tests/Feature/UlidTest.php new file mode 100644 index 0000000..6c9f812 --- /dev/null +++ b/tests/Feature/UlidTest.php @@ -0,0 +1,184 @@ + 'Test post']); + + $this->assertNotNull($post->ulid); + } + + /** @test */ + public function it_does_not_override_the_ulid_if_it_is_already_set() + { + $ulid = '01ha3jhmpz4mgvgf772wyksj9d'; + + $post = Post::create(['title' => 'Test post', 'ulid' => $ulid]); + + $this->assertSame($ulid, $post->ulid); + } + + /** @test */ + public function you_can_find_a_model_by_its_ulid() + { + $ulid = '01ha3jhmpzyhympbf0d7q34fg8'; + + Post::create(['title' => 'test post', 'ulid' => $ulid]); + + $post = Post::whereUlid($ulid)->first(); + + $this->assertInstanceOf(Post::class, $post); + $this->assertSame($ulid, $post->ulid); + } + + /** @test */ + public function you_can_find_a_model_by_custom_ulid_parameter() + { + $ulid = '01ha3jhmpztcfkbpc6he6zcfs7'; + $custom_ulid = '01ha3jhmpzs0fjja2v79cjng4j'; + + MultipleUlidPost::create(['title' => 'test post', 'ulid' => $ulid, 'custom_ulid' => $custom_ulid]); + + $post1 = MultipleUlidPost::whereUlid($ulid)->first(); + $this->assertInstanceOf(MultipleUlidPost::class, $post1); + $this->assertSame($ulid, $post1->ulid); + + $post2 = MultipleUlidPost::whereUlid($ulid, 'ulid')->first(); + $this->assertInstanceOf(MultipleUlidPost::class, $post2); + $this->assertSame($ulid, $post2->ulid); + + $post3 = MultipleUlidPost::whereUlid($custom_ulid, 'custom_ulid')->first(); + $this->assertInstanceOf(MultipleUlidPost::class, $post3); + $this->assertSame($custom_ulid, $post3->custom_ulid); + } + + /** @test */ + public function you_can_search_by_array_of_ulids() + { + $first = Post::create(['title' => 'first post', 'ulid' => '01ha3jhmpzhpf2hvycgrh1r2k7']); + $second = Post::create(['title' => 'second post', 'ulid' => '01ha3jhmpze5c2t5crycf0z1c5']); + + $this->assertEquals(2, Post::whereUlid([ + '01ha3jhmpzhpf2hvycgrh1r2k7', + '01ha3jhmpze5c2t5crycf0z1c5', + ])->count()); + } + + /** @test */ + public function you_can_search_by_array_of_ulids_for_custom_column() + { + $first = CustomCastUlidPost::create(['title' => 'first post', 'custom_ulid' => '01ha3jhmpzp9mnxrtsbq6kwcmq']); + $second = CustomCastUlidPost::create(['title' => 'second post', 'custom_ulid' => '01ha3jhmpzvyb4e2ht3y25hvxw']); + + $this->assertEquals(2, CustomCastUlidPost::whereUlid([ + '01ha3jhmpzp9mnxrtsbq6kwcmq', + '01ha3jhmpzvyb4e2ht3y25hvxw', + ], 'custom_ulid')->count()); + } + + /** @test */ + public function you_can_generate_a_ulid_without_casting() + { + $post = UncastPost::create(['title' => 'test post']); + + $this->assertNotNull($post->ulid); + } + + /** @test */ + public function you_can_generate_a_ulid_with_casting_and_a_custom_field_name() + { + $post = CustomCastUlidPost::create(['title' => 'test post']); + + $this->assertNotNull($post->custom_ulid); + } + + /** @test */ + public function you_can_specify_a_ulid_without_casting() + { + $ulid = '01ha3jhmpz90kbrmf088p7sfpb'; + + $post = UncastPost::create(['title' => 'test-post', 'ulid' => $ulid]); + + $this->assertSame($ulid, $post->ulid); + } + + /** @test */ + public function you_can_find_a_model_by_ulid_without_casting() + { + $ulid = '01ha3jhmpz42w72r19r4j740d1'; + + UncastPost::create(['title' => 'test-post', 'ulid' => $ulid]); + + $post = UncastPost::whereUlid($ulid)->first(); + + $this->assertInstanceOf(UncastPost::class, $post); + $this->assertSame($ulid, $post->ulid); + } + + /** @test */ + public function it_allows_configurable_ulid_column_names() + { + $post = CustomUlidPost::create(['title' => 'test-post']); + + $this->assertNotNull($post->custom_ulid); + } + + /** @test */ + public function it_handles_an_invalid_ulid() + { + $ulid = 'b270f651-4db8-407b-aade-8666aca2750e'; + + Post::create(['title' => 'invalid ulid', 'ulid' => $ulid]); + + $this->expectException(ModelNotFoundException::class); + + Post::whereUlid('invalid ulid')->firstOrFail(); + } + + /** @test */ + public function it_handles_a_null_ulid_column() + { + tap(Model::withoutEvents(function () { + return Post::create([ + 'title' => 'Nullable ulid', + 'ulid' => null, + ]); + }), function ($post) { + $this->assertNull($post->ulid); + }); + } + + /** @test */ + public function it_handles_queries_with_multiple_ulid_columns() + { + $post = factory(Post::class)->create([ + 'ulid' => '01ha3jhmpzt4yemas4bxb4c4b4', + ]); + $comment = $post->comments()->save(factory(Comment::class)->make([ + 'ulid' => '01ha3jhmpz95rcvhj6jrbb7qvd', + ])); + + tap($post->comments()->whereUlid($comment->ulid)->first(), function ($comment) { + $this->assertNotNull($comment); + $this->assertEquals('01ha3jhmpzt4yemas4bxb4c4b4', $comment->post->ulid); + }); + } +} diff --git a/tests/Feature/UuidTest.php b/tests/Feature/UuidTest.php index 9016a5b..933f3e1 100644 --- a/tests/Feature/UuidTest.php +++ b/tests/Feature/UuidTest.php @@ -6,18 +6,18 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Testing\WithFaker; use Ramsey\Uuid\Uuid; -use Tests\Fixtures\Comment; -use Tests\Fixtures\CustomCastUuidPost; -use Tests\Fixtures\CustomUuidPost; -use Tests\Fixtures\EfficientUuidPost; -use Tests\Fixtures\MultipleUuidPost; -use Tests\Fixtures\OrderedPost; -use Tests\Fixtures\Post; -use Tests\Fixtures\UncastPost; -use Tests\Fixtures\Uuid1Post; -use Tests\Fixtures\Uuid4Post; -use Tests\Fixtures\Uuid6Post; -use Tests\Fixtures\Uuid7Post; +use Tests\Fixtures\Uuid\Comment; +use Tests\Fixtures\Uuid\CustomCastUuidPost; +use Tests\Fixtures\Uuid\CustomUuidPost; +use Tests\Fixtures\Uuid\EfficientUuidPost; +use Tests\Fixtures\Uuid\MultipleUuidPost; +use Tests\Fixtures\Uuid\OrderedPost; +use Tests\Fixtures\Uuid\Post; +use Tests\Fixtures\Uuid\UncastPost; +use Tests\Fixtures\Uuid\Uuid1Post; +use Tests\Fixtures\Uuid\Uuid4Post; +use Tests\Fixtures\Uuid\Uuid6Post; +use Tests\Fixtures\Uuid\Uuid7Post; use Tests\TestCase; class UuidTest extends TestCase diff --git a/tests/Fixtures/Comment.php b/tests/Fixtures/Ulid/Comment.php similarity index 63% rename from tests/Fixtures/Comment.php rename to tests/Fixtures/Ulid/Comment.php index b6b27b6..2b00b07 100644 --- a/tests/Fixtures/Comment.php +++ b/tests/Fixtures/Ulid/Comment.php @@ -1,10 +1,10 @@ hasMany(Comment::class); diff --git a/tests/Fixtures/Ulid/UlidRouteBoundPost.php b/tests/Fixtures/Ulid/UlidRouteBoundPost.php new file mode 100644 index 0000000..a83785b --- /dev/null +++ b/tests/Fixtures/Ulid/UlidRouteBoundPost.php @@ -0,0 +1,15 @@ +belongsTo(Post::class); + } +} diff --git a/tests/Fixtures/CustomCastUuidPost.php b/tests/Fixtures/Uuid/CustomCastUuidPost.php similarity index 81% rename from tests/Fixtures/CustomCastUuidPost.php rename to tests/Fixtures/Uuid/CustomCastUuidPost.php index a783cb2..b9dbb0f 100644 --- a/tests/Fixtures/CustomCastUuidPost.php +++ b/tests/Fixtures/Uuid/CustomCastUuidPost.php @@ -1,6 +1,6 @@ hasMany(Comment::class); + } +} diff --git a/tests/Fixtures/UncastPost.php b/tests/Fixtures/Uuid/UncastPost.php similarity index 61% rename from tests/Fixtures/UncastPost.php rename to tests/Fixtures/Uuid/UncastPost.php index 1b29c18..579136f 100644 --- a/tests/Fixtures/UncastPost.php +++ b/tests/Fixtures/Uuid/UncastPost.php @@ -1,6 +1,6 @@ connection()->getSchemaBuilder()->create('posts', function (Blueprint $table) { + $app['db']->connection()->getSchemaBuilder()->create('uuid_posts', function (Blueprint $table) { $table->increments('id'); $table->uuid('uuid')->nullable(); $table->uuid('custom_uuid')->nullable(); @@ -37,11 +37,25 @@ protected function setupDatabase($app) $table->string('title'); }); - $app['db']->connection()->getSchemaBuilder()->create('comments', function (Blueprint $table) { + $app['db']->connection()->getSchemaBuilder()->create('uuid_comments', function (Blueprint $table) { $table->increments('id'); $table->foreignId('post_id'); $table->uuid('uuid')->nullable(); $table->text('body'); }); + + $app['db']->connection()->getSchemaBuilder()->create('ulid_posts', function (Blueprint $table) { + $table->increments('id'); + $table->ulid('ulid')->nullable(); + $table->ulid('custom_ulid')->nullable(); + $table->string('title'); + }); + + $app['db']->connection()->getSchemaBuilder()->create('ulid_comments', function (Blueprint $table) { + $table->increments('id'); + $table->foreignId('post_id'); + $table->ulid('ulid')->nullable(); + $table->text('body'); + }); } } diff --git a/tests/database/factories/CommentFactory.php b/tests/database/factories/UlidCommentFactory.php similarity index 80% rename from tests/database/factories/CommentFactory.php rename to tests/database/factories/UlidCommentFactory.php index 0ad46f0..6861628 100644 --- a/tests/database/factories/CommentFactory.php +++ b/tests/database/factories/UlidCommentFactory.php @@ -3,8 +3,8 @@ /** @var \Illuminate\Database\Eloquent\Factory $factory */ use Faker\Generator as Faker; -use Tests\Fixtures\Comment; -use Tests\Fixtures\Post; +use Tests\Fixtures\Ulid\Comment; +use Tests\Fixtures\Ulid\Post; $factory->define(Comment::class, function (Faker $faker) { return [ diff --git a/tests/database/factories/UlidPostFactory.php b/tests/database/factories/UlidPostFactory.php new file mode 100644 index 0000000..66a1758 --- /dev/null +++ b/tests/database/factories/UlidPostFactory.php @@ -0,0 +1,67 @@ +define(CustomCastUlidPost::class, function (Faker $faker) { + return [ + 'custom_ulid' => Ulid::generate(), + 'title' => $faker->sentence, + ]; +}); + +$factory->define(CustomUlidPost::class, function (Faker $faker) { + return [ + 'custom_ulid' => Ulid::generate(), + 'title' => $faker->sentence, + ]; +}); + + +$factory->define(MultipleUlidPost::class, function (Faker $faker) { + return [ + 'ulid' => Ulid::generate(), + 'custom_ulid' => Ulid::generate(), + 'title' => $faker->sentence, + ]; +}); + +$factory->define(Post::class, function (Faker $faker) { + return [ + 'ulid' => Ulid::generate(), + 'title' => $faker->sentence, + ]; +}); + +$factory->define(CustomUlidRouteBoundPost::class, function (Faker $faker) { + return [ + 'ulid' => Ulid::generate(), + 'custom_ulid' => Ulid::generate(), + 'title' => $faker->sentence, + ]; +}); + +$factory->define(UlidRouteBoundPost::class, function (Faker $faker) { + return [ + 'ulid' => Ulid::generate(), + 'custom_ulid' => Ulid::generate(), + 'title' => $faker->sentence, + ]; +}); + +$factory->define(MultipleUlidRouteBoundPost::class, function (Faker $faker) { + return [ + 'ulid' => Ulid::generate(), + 'custom_ulid' => Ulid::generate(), + 'title' => $faker->sentence, + ]; +}); diff --git a/tests/database/factories/UuidCommentFactory.php b/tests/database/factories/UuidCommentFactory.php new file mode 100644 index 0000000..b57c56b --- /dev/null +++ b/tests/database/factories/UuidCommentFactory.php @@ -0,0 +1,14 @@ +define(Comment::class, function (Faker $faker) { + return [ + 'post_id' => factory(Post::class), + 'body' => $faker->sentence, + ]; +}); diff --git a/tests/database/factories/PostFactory.php b/tests/database/factories/UuidPostFactory.php similarity index 83% rename from tests/database/factories/PostFactory.php rename to tests/database/factories/UuidPostFactory.php index 52f102e..9a495e9 100644 --- a/tests/database/factories/PostFactory.php +++ b/tests/database/factories/UuidPostFactory.php @@ -3,15 +3,15 @@ /** @var \Illuminate\Database\Eloquent\Factory $factory */ use Faker\Generator as Faker; -use Tests\Fixtures\CustomCastUuidPost; -use Tests\Fixtures\CustomUuidPost; -use Tests\Fixtures\CustomUuidRouteBoundPost; -use Tests\Fixtures\EfficientUuidPost; -use Tests\Fixtures\MultipleUuidPost; -use Tests\Fixtures\MultipleUuidRouteBoundPost; -use Tests\Fixtures\OrderedPost; -use Tests\Fixtures\Post; -use Tests\Fixtures\UuidRouteBoundPost; +use Tests\Fixtures\Uuid\CustomCastUuidPost; +use Tests\Fixtures\Uuid\CustomUuidPost; +use Tests\Fixtures\Uuid\CustomUuidRouteBoundPost; +use Tests\Fixtures\Uuid\EfficientUuidPost; +use Tests\Fixtures\Uuid\MultipleUuidPost; +use Tests\Fixtures\Uuid\MultipleUuidRouteBoundPost; +use Tests\Fixtures\Uuid\OrderedPost; +use Tests\Fixtures\Uuid\Post; +use Tests\Fixtures\Uuid\UuidRouteBoundPost; $factory->define(CustomCastUuidPost::class, function (Faker $faker) { return [