crac's avatar
Level 1

Filling a model instance including relationships

I'm writing an ecommerce system. When a basket it processed I want to create a snapshot of each basket item, so that old baskets show the data for purchased items at the time of purchase, and not reflecting any product changes that have happened since.

I want to serialize the product data to a separate table which can then be retrieved and used to reconstruct product models when old baskets are viewed. This needs to be model type agnostic - that is to say I need to be able to snapshot several different model types, so I'm storing the model_type as well as the original model_id and the serialized data.

The basic version of this is working just fine - I'm using the fill() method to repopulate the product models from the deserialized data and that's giving me an object which matches the attributes for the original.

The problem is that the product model has some relationships which I'd like to include in this process. Serializing the relationship data works fine. What I can't find the way to do is repopulate the model relationships with the deserialized data. After lots of experiments I thought I had the solution - filling a model for the relationship, putting that into a property on the parent model and then reworking the HasOne relationship to return either a HasOne or the specific model type, and to return the repopulated model instead of querying the database if it is present.

The error I'm getting states that the HasOne relationship 'must return a relationship instance' - which makes sense, obviously, but is not helping!

So, my question is this - is there a way I can 'fake' a relationship on an object, populating it from deserialized data rather than from a database query?

My current code looks (something) like this:

class Product extends Model
{

  // This is the public property which can be populated 
  // with repopulated relationship objects
  public array $filledRelations = array();

  // This is supposed to return the Extension model 
  // stored in $this->filledRelations['extension']
  // if it is present, otherwise return the relationship 
  // from the database
  public function extension() : hasOne | Extension
  {
    if (array_key_exists('extension', $this->filledRelations)) {
      return $this->filledRelations['extension'];
    } else {
      return $this->hasOne(Extension::class, 'product_id', 'id');
    }
  }

}
class BasketItemArchive extends Model
{

    // The BasketItemArchive model has the following properties:
    //  model_id        the id of the original archived model
    //  model_type    the class of the original archived model
    //  data                a JSON column containing the serialized data

    public static function retrieveBasketItemArchive(int $id) : Object | null
    {

        $basketItemArchiveItem = self::find($id);

        if ($basketItemArchiveItem) {

            // Repopulate an object from the data ...
            $object = new $basketItemArchiveItem->model_type;
            $object->fill($basketItemArchiveItem->data);

            $reflector = new \ReflectionClass($object::class);

            $relationships = collect($reflector->getMethods())
                ->filter(
                    fn($method) => !empty($method->getReturnType()) &&
                        str_contains(
                            $method->getReturnType(),
                            'Illuminate\Database\Eloquent\Relations'
                        )
                )
                ->all();

            foreach ($relationships as $relationship) {
                // Get the json property key - snake version of relationship name
                $property = Str::snake($relationship->name);
                // Check if this is in the retrieved json - skip if not
                if (!array_key_exists($property, $basketItemArchiveItem->data)) {
                    continue;
                }
                // Identify the property type
                $returnTypes = $relationship->getReturnType();
                if ($returnTypes instanceof \ReflectionUnionType) {
                    // Identify the relationship core type
                    $returnType = collect($returnTypes->getTypes())
                        ->filter(
                            fn($type) => !empty($type->getName()) 
                               && !str_contains(
                                       $type->getName(),
                                       'Illuminate\Database\Eloquent\Relations'
                               )
                        )
                        ->first();
                    // Get the class string - for some reason need to get 
                    // the class as a string rather than create a new one directly
                    $returnTypeName = $returnType->getName();
                    // Create and fill the object
                    if ($returnTypeName) {
                        // Populate an object of the correct type with the data 
                        // from the json property
                        $relation = new $returnTypeName;
                        $relation->fill($basketItemArchiveItem->data[$property]);
                        // Add the relation object to the $filledRelations array 
                        // if it exists
                        if (property_exists($object, 'filledRelations')) {
                            $object->filledRelations[$relationship->name] = $relation;
                        }
                    }
                }
            }
            return $object;
        } else {
            return null;
        }

    }

}
public function test($basketItemArchiveId) {

    $repopulatedProduct = BasketItemArchive::retrieveBasketItemArchive($basketItemArchiveId);

    dd($repopulatedProduct, $repopulatedProduct->extension);

    // Returns error:
    //  Product::extension must return a relationship instance.

}
0 likes
2 replies
LaryAI's avatar
Level 58

The issue you're encountering is due to the fact that Eloquent expects a relationship method like extension() to return an instance of a relationship class, such as Illuminate\Database\Eloquent\Relations\HasOne. However, you're trying to return an instance of the related model directly, which is not how Eloquent relationships are designed to work.

To achieve the desired functionality, you can use the setRelation method on the Eloquent model to set the loaded relationship manually. This method allows you to attach a related model or a collection of related models to the parent model without querying the database.

Here's how you can modify your BasketItemArchive::retrieveBasketItemArchive method to use setRelation:

public static function retrieveBasketItemArchive(int $id): ?Model
{
    $basketItemArchiveItem = self::find($id);

    if ($basketItemArchiveItem) {
        // Repopulate an object from the data ...
        $object = new $basketItemArchiveItem->model_type;
        $object->fill(json_decode($basketItemArchiveItem->data, true));

        // Assuming the relationships are also stored in the serialized data
        $relationshipsData = json_decode($basketItemArchiveItem->data, true)['relationships'] ?? [];

        foreach ($relationshipsData as $relationName => $relationData) {
            // Assuming that the relation data is an associative array representing the related model
            $relationModel = new $relationData['type']; // Replace with the actual model type if needed
            $relationModel->fill($relationData['attributes']);

            // Set the relation on the parent model
            $object->setRelation($relationName, $relationModel);
        }

        return $object;
    }

    return null;
}

And then, in your Product model, you can remove the custom logic from the extension() method and let it behave as a normal Eloquent relationship:

class Product extends Model
{
    // ...

    public function extension(): HasOne
    {
        return $this->hasOne(Extension::class, 'product_id', 'id');
    }

    // ...
}

Now, when you call $repopulatedProduct->extension, it should return the related Extension model that was set using setRelation, without querying the database.

Please note that the above code assumes that the serialized data contains a key relationships which holds the serialized relationship data. You will need to adjust the code to match the actual structure of your serialized data.

crac's avatar
Level 1

Thank you @laryai - the setRelation() method was exactly what I was looking for, although I have ended up rewriting the way the data is serialized in the first place ...

Please or to participate in this conversation.