Skip to content

feat: ulid support #127

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
28 changes: 28 additions & 0 deletions src/BindsOnUlid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace Dyrynda\Database\Support;

use Illuminate\Database\Eloquent\Model;

trait BindsOnUlid
{
/**
* Route bind desired uuid field
* Default 'uuid' column name has been set.
*
* @param string $value
* @param null|string $field
*/
public function resolveRouteBinding($value, $field = null): Model
{
return self::whereUlid($value, $field)->firstOrFail();
}

/**
* Get the route key for the model.
*/
public function getRouteKeyName(): string
{
return $this->ulidColumn();
}
}
127 changes: 127 additions & 0 deletions src/GeneratesUlid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

namespace Dyrynda\Database\Support;

use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Symfony\Component\Uid\Ulid;

/**
* Ulid generation trait.
*
* Include this trait in any Eloquent model where you wish to automatically set
* a Ulid field. When saving, if the Ulid field has not been set, generate a
* new Ulid value, which will be set on the model and saved by Eloquent.
*
* @copyright 2023 [email protected]
* @license MIT
**
* @method static \Illuminate\Database\Eloquent\Builder whereUlid(string $ulid)
*/
trait GeneratesUlid
{
/**
* Boot the trait, adding a creating observer.
*
* When persisting a new model instance, we resolve the Ulid field, then set
* a fresh Ulid, taking into account if we need to cast to binary or not.
*/
public static function bootGeneratesUlid(): void
{
static::creating(function ($model) {
/* @var \Illuminate\Database\Eloquent\Model|static $model */

foreach ($model->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);
});
}
}
91 changes: 91 additions & 0 deletions tests/Feature/BindUlidTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace Tests\Feature;

use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Support\Facades\Route;
use Tests\Fixtures\Ulid\CustomUlidRouteBoundPost;
use Tests\Fixtures\Ulid\MultipleUlidRouteBoundPost;
use Tests\Fixtures\Ulid\UlidRouteBoundPost;
use Tests\TestCase;

class BindUlidTest extends TestCase
{
/** @test */
public function it_binds_to_default_ulid_field()
{
$post = factory(UlidRouteBoundPost::class)->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();
}
}
6 changes: 3 additions & 3 deletions tests/Feature/BindUuidTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading