Skip to content

Commit

Permalink
feat: support UUIDs and ULIDs (#31)
Browse files Browse the repository at this point in the history
* feat: add support for `uuid` and `ulid` primary keys

resolves #26

* ci: reduce tests run by morph type

* fix: add support for uuid and ulid in Laravel 9

* docs: update section about uuid and ulid support

* Fix styling [skip-ci]

* fix: make sure string was passed

* ci: reduce tests run by morph type

---------

Co-authored-by: marijoo <[email protected]>
  • Loading branch information
marijoo and marijoo authored Jan 24, 2024
1 parent 4c4fbc0 commit fdaf720
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 7 deletions.
13 changes: 9 additions & 4 deletions .github/workflows/snippet-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,26 @@ jobs:
fail-fast: true
matrix:
os: [ubuntu-latest]
php: [8.0, 8.1, 8.2, 8.3]
php: [8.1, 8.2, 8.3]
laravel: [9.*, 10.*]
morphtype: [integer, uuid, ulid]
stability: [prefer-lowest, prefer-stable]
include:
- laravel: 10.*
testbench: 8.*
- laravel: 9.*
testbench: 7.*
exclude:
- laravel: 10.*
php: 8.0
- laravel: 9.*
stability: prefer-lowest
- laravel: 9.*
php: 8.3
- php: 8.2
morphtype: uuid
- php: 8.2
morphtype: ulid

name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }}
name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.morphtype }}

steps:
- name: Checkout code
Expand Down Expand Up @@ -72,3 +75,5 @@ jobs:

- name: Execute tests
run: vendor/bin/pest
env:
MULTIPLEX_MORPH_TYPE: "${{ matrix.morphtype }}"
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ And it’s low profile: If you don't like it, just [remove the `HasMeta` Trait](
- [Deleting Metadata](#deleting-metadata)
- [Performance](#performance)
- [Configuration](#configuration)
- [UUID and ULID Support](#uuid-and-ulid-support)

## Installation

Expand Down Expand Up @@ -638,6 +639,12 @@ There is no need to configure anything but if you like, you can publish the conf
php artisan vendor:publish --tag="multiplex-config"
```

## UUID and ULID Support

If your application uses UUIDs or ULIDs for the model(s) using metadata, you may set the `multiplex.morph_type` setting to `uuid` or `ulid` **before** running the migrations. You might as well set the `MULTIPLEX_MORPH_TYPE` environment variable to `uuid` or `ulid`.

This will ensure `Meta` models will also use UUID/ULID and that proper foreign keys are used.

## Credits

This package is heavily based on and inspired by [Laravel-Metable](https://github.com/plank/laravel-metable) by [Sean Fraser](https://github.com/frasmage) as well as [laravel-meta](https://github.com/kodeine/laravel-meta) by [Kodeine](https://github.com/kodeine). The [Package Skeleton](https://github.com/spatie/package-skeleton-laravel) by the great [Spatie](https://spatie.be/) was used as a starting point.
Expand Down
7 changes: 7 additions & 0 deletions config/multiplex.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
*/
'migrations' => true,

/**
* The type of primary key your models using the `HasMeta` trait are using.
* Must be one of `integer`, `uuid` or `ulid`.
* ATTENTION: This must be changed before running the database migrations.
*/
'morph_type' => env('MULTIPLEX_MORPH_TYPE', 'integer'),

/**
* List of handlers for recognized data types.
*
Expand Down
29 changes: 27 additions & 2 deletions database/migrations/2022_10_14_094240_create_meta_table.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,38 @@

return new class extends Migration
{
protected function addKeys(Blueprint &$table): void
{
if (config('multiplex.morph_type') === 'uuid') {
$table->uuid('id');
$table->uuidMorphs('metable');

return;
}

if (config('multiplex.morph_type') === 'ulid') {
$table->ulid('id');
$table->ulidMorphs('metable');

return;
}

if (config('multiplex.morph_type') === 'integer') {
$table->increments('id');
$table->morphs('metable');

return;
}

throw new Exception('Please use a valid option for `morph_type` inside the multiplex config file. Must be one of `integer`, `uuid` or `ulid`.');
}

public function up(): void
{
if (!Schema::hasTable('meta')) {
Schema::create('meta', function (Blueprint $table) {
$table->increments('id');
$this->addKeys($table);

$table->morphs('metable');
$table->string('key');
$table->longtext('value')->nullable();
$table->string('type')->nullable();
Expand Down
5 changes: 4 additions & 1 deletion phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd" backupGlobals="false" bootstrap="vendor/autoload.php" colors="true" processIsolation="false" stopOnFailure="false" executionOrder="random" failOnWarning="true" failOnRisky="true" failOnEmptyTestSuite="true" beStrictAboutOutputDuringTests="true" cacheDirectory=".phpunit.cache" backupStaticProperties="false">
<testsuites>
<testsuite name="kolossal Test Suite">
<testsuite name="Multiplex Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
Expand All @@ -20,4 +20,7 @@
<directory suffix=".php">./src</directory>
</include>
</source>
<php>
<env name="MULTIPLEX_MORPH_TYPE" value="integer"/>
</php>
</phpunit>
158 changes: 158 additions & 0 deletions src/HasConfigurableMorphType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?php

namespace Kolossal\Multiplex;

use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Str;

trait HasConfigurableMorphType
{
/**
* Initialize the trait.
*/
public function initializeHasConfigurableMorphType(): void
{
if (!$this->usesUniqueIdsInMorphType()) {
return;
}

if (property_exists($this, 'usesUniqueIds')) {
$this->usesUniqueIds = true;

return;
}

static::creating(function (self $model) {
foreach ($model->uniqueIds() as $column) {
if (empty($model->{$column})) {
$model->{$column} = $model->newUniqueId();
}
}
});
}

/**
* Get the morph key type.
*/
protected function morphType(): string
{
if (!is_string(config('multiplex.morph_type'))) {
return 'integer';
}

if (in_array(config('multiplex.morph_type'), ['uuid', 'ulid'])) {
return config('multiplex.morph_type');
}

return 'integer';
}

/**
* Determine if unique ids are used in morphTo relation.
*/
protected function usesUniqueIdsInMorphType(): bool
{
return $this->morphType() !== 'integer';
}

/**
* Determine if the given value is a valid unique id.
*/
protected function isValidUniqueMorphId(mixed $value): bool
{
if (!is_string($value)) {
return false;
}

if ($this->morphType() === 'ulid') {
return Str::isUlid($value);
}

if ($this->morphType() === 'uuid') {
return Str::isUuid($value);
}

return false;
}

/**
* Get the columns that should receive a unique identifier.
*/
public function uniqueIds(): array
{
if (!$this->usesUniqueIdsInMorphType()) {
return [];
}

return [$this->getKeyName()];
}

/**
* Generate a new UUID for the model.
*/
public function newUniqueId(): ?string
{
if (!$this->usesUniqueIdsInMorphType()) {
return null;
}

if ($this->morphType() === 'ulid') {
return strtolower((string) Str::ulid());
}

return (string) Str::orderedUuid();
}

/**
* Retrieve the model for a bound value.
*
* @param \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Relations\Relation $query
* @param mixed $value
* @param string|null $field
* @return \Illuminate\Database\Eloquent\Relations\Relation
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function resolveRouteBindingQuery($query, $value, $field = null)
{
if (!$this->usesUniqueIdsInMorphType()) {
return parent::resolveRouteBindingQuery($query, $value, $field);
}

if ($field && is_string($value) && in_array($field, $this->uniqueIds()) && !$this->isValidUniqueMorphId($value)) {
/** @var \Illuminate\Database\Eloquent\Model $this */
throw (new ModelNotFoundException)->setModel(get_class($this), $value);
}

if (!$field && is_string($value) && in_array($this->getRouteKeyName(), $this->uniqueIds()) && !$this->isValidUniqueMorphId($value)) {
/** @var \Illuminate\Database\Eloquent\Model $this */
throw (new ModelNotFoundException)->setModel(get_class($this), $value);
}

return parent::resolveRouteBindingQuery($query, $value, $field);
}

/**
* Get the auto-incrementing key type.
*/
public function getKeyType(): string
{
if (in_array($this->getKeyName(), $this->uniqueIds())) {
return 'string';
}

return $this->keyType;
}

/**
* Get the value indicating whether the IDs are incrementing.
*/
public function getIncrementing(): bool
{
if (in_array($this->getKeyName(), $this->uniqueIds())) {
return false;
}

return $this->incrementing;
}
}
1 change: 1 addition & 0 deletions src/Meta.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
*/
class Meta extends Model
{
use HasConfigurableMorphType;
use HasFactory;
use HasTimestamps;

Expand Down
56 changes: 56 additions & 0 deletions tests/PrimaryKeyTypesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace Kolossal\Multiplex\Tests;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Kolossal\Multiplex\Tests\Mocks\Post;

class PrimaryKeyTypesTest extends TestCase
{
use RefreshDatabase;

protected function refreshDatabaseWithType(string $type): void
{
config()->set('multiplex.morph_type', $type);

$this->artisan('migrate:fresh', $this->migrateFreshUsing());
$this->useDatabase();
}

/**
* @test
*
* @dataProvider morphTypes
* */
public function it_uses_the_configured_column_type(string $type, string $column_type)
{
$this->refreshDatabaseWithType($type);

if (version_compare(app()->version(), '10.0.0', '>')) {
$this->assertSame($column_type, Schema::getColumnType('meta', 'id'));
}

$this->assertSame($type, config('multiplex.morph_type'));

$meta = Post::factory()->create()->saveMeta('foo', 'bar');

if (config('multiplex.morph_type') === 'uuid') {
$this->assertTrue(Str::isUuid($meta->id));
} elseif (config('multiplex.morph_type') === 'ulid') {
$this->assertTrue(Str::isUlid($meta->id));
} else {
$this->assertIsInt($meta->id);
}
}

public static function morphTypes(): array
{
return [
'integer' => ['integer', 'integer'],
'uuid' => ['uuid', 'varchar'],
'ulid' => ['ulid', 'varchar'],
];
}
}

0 comments on commit fdaf720

Please sign in to comment.