jessarcher / laravel-castable-data-transfer-object

Automatically cast JSON columns to rich PHP objects in Laravel using Spatie's data-transfer-object class
https://jessarcher.com/blog/casting-json-columns-to-value-objects/
MIT License
328 stars 23 forks source link

Casting different types to same underlying column #1

Closed mbryne closed 4 years ago

mbryne commented 4 years ago

Greetings,

This is a great package, thanks for this,

I was just trying to solve a similar problem earlier in the week and went down the model states path to resolve it but wasn’t happy with the solution.

How feasible would it be to set the casted type based on ‘type’ string and ‘data’ json columns using this package?

E.g. Annotation model with ‘type’:

both data classes extend an AnnotationData interface but I would imagine I would still require separate properties or accessors?

Thanks for your efforts on this, I find the model states overkill for what we do most of the time so thought I would ask.

jessarcher commented 4 years ago

Hey @mbryne,

Thanks for the kind words.

I actually had a similar need in my real-world implementation that inspired this package, but I thought it was a bit too specific/complex to include in my blog post (which then spawned this package). Maybe I was wrong!

For my case, I created a DynamicCastableDataTransferObject cast that accepts cast parameters to configure the DTO namespace, the "type" column, and a suffix.

For example, given a cast definition of:

use App\Casts\DynamicDataTransferObject;

//...

protected $casts = [
    'data' => DynamicDataTransferObject::class.':App\Values,type,data',
];

If the model has a type attribute with a value of comment, it would look for a DTO at App\Values\CommentData to cast to. My use case had several JSON columns so I needed a configurable suffix, but in your case you may be able to get away without that cast parameter.

My implementation for that cast is:

<?php

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Str;
use Spatie\DataTransferObject\DataTransferObject;

/**
 * Cast an Eloquent attribute to a DataTransferObject based on conventions.
 *
 * The DataTransferObject class is determined by taking the CamelCase version of
 * another attribute and book-ending it with the given namespace and suffix.
 *
 * This facilitates the "convention over configuration" approach.
 *
 * ## Example
 * Given a cast definition of:
 * ```
 * protected $casts = [
 *     'data' => DynamicDataTransferObject::class.':App\Values,type,data',
 * ];
 * ```
 * And a record with a type of 'comment'.
 *
 * We end up with the following parameters:
 * - $namespace = 'App\Values'
 * - $typeAttribute = 'type'
 * - $suffix = 'data'
 * - $attributes['type'] = 'comment'
 *
 * And a resulting DTO class of:
 * `App\Values\CommentData`
 */
class DynamicDataTransferObject implements CastsAttributes
{
    protected string $namespace;

    protected string $typeAttribute;

    protected string $suffix;

    /**
     * @param $namespace The namespace to use when searching for the DataTransferObject class
     * @param $typeAttribute The attribute to use to determine the dynamic class name
     * @param $suffix The suffix to apply to the dynamic class name
     */
    public function __construct(string $namespace, string $typeAttribute, string $suffix)
    {
        $this->namespace = $namespace;
        $this->typeAttribute = $typeAttribute;
        $this->suffix = $suffix;
    }

    /**
     * Cast the stored value to a DataTransferObject.
     *
     * @param string $model
     * @param string $key
     * @param ?string $value
     * @param array $key
     *
     * @return ?DataTransferObject
     */
    public function get($model, $key, $value, $attributes)
    {
        if (is_null($value)) {
            return;
        }

        return $this->castToDto(
            json_decode($value, true),
            $this->getTypeFromAttributes($attributes)
        );
    }

    /**
     * Validate and cast the value to JSON for storage.
     *
     * @param string $model
     * @param string $key
     * @param array|DataTransferObject $value
     * @param array $key
     *
     * @return ?string
     */
    public function set($model, $key, $value, $attributes)
    {
        if (is_null($value)) {
            return;
        }

        $value = $this->castToDto($value, $this->getTypeFromAttributes($attributes));

        return json_encode($value->toArray());
    }

    /**
     * @param DataTransferObject|array $value
     */
    protected function castToDto($value, string $type): DataTransferObject
    {
        $class = $this->determineDtoClass($type);

        throw_unless(class_exists($class), new \Exception("Unable to locate DataTransferObject class [$class]"));

        if ($value instanceof $class) {
            return $value;
        }

        throw_unless(is_array($value), new \Exception('Unable to cast non-array ['.gettype($value).'] to DTO'));

        return new $class($value);
    }

    protected function determineDtoClass(string $type): string
    {
        return Str::finish($this->namespace, '\\').Str::studly($type).Str::studly($this->suffix);
    }

    protected function getTypeFromAttributes(array $attributes): string
    {
        $type = $attributes[$this->typeAttribute] ?? null;

        throw_if(empty($type), new \Exception("Unable to get type [$this->typeAttribute] from attributes"));

        return $type;
    }
}

In my package, you can cast directly to the DTO class because it implements Castable. But for the above scenario you need to reference the cast class directly in the model cast definition so that the DTO is dynamic. I don't yet love the API of this and I'm not sure how/if it fits into this package at all, but hopefully the above is helpful for you :smiley:

mbryne commented 4 years ago

Thanks very much, that is very helpful, cheers