Eloquent model delegate to related model - laravel

I currently have two models: A→hasOne→B
B represent a base class and A is one of its extending classes, so sometimes I want to access B attributes/methods trough A as if it were its own.
An example would be the classic BaseMonster-SpecificMonster video game use case: my BaseMonster model has some attributes like color, etc. and some method like walk, etc., and I would like to access those attributes and methods from my SpecificMonster model.
How can I do that?
I looked into DelegatesToResource trait to get some hint/clue but I think it is too bounded/related to API calls and stuff.
I'm looking at how hooking into the __get magic method of my A model without breaking any of Eloquent existing logic, but without luck.

Ok, that's what I came out with:
For attributes it is a basic check on the attribute existence.
It rely on the extending model to not have attributes in common with the base model, so I just check for null value since if the attribute doesn't exists neither on the base model original behaviour would return null anyway.
For methods I had to catch a BadMethodCallException to be sure that called method doesn't exists on current model instance nor in Eloquent magic methods before calling the base model.
public function __get($key)
{
$x = $this->getAttribute($key);
if (null === $x) {
return $this->baseModel->getAttribute($key);
}
return $x;
}
public function __call($method, $parameters)
{
try {
if (in_array($method, ['increment', 'decrement'])) {
return $this->$method(...$parameters);
}
if ($resolver = (static::$relationResolvers[get_class($this)][$method] ?? null)) {
return $resolver($this);
}
return $this->forwardCallTo($this->newQuery(), $method, $parameters);
} catch (\BadMethodCallException $e) {
return $this->forwardCallTo($this->baseModel, $method, $parameters);
}
}
I remark that this solution is tied to my personal use case, so probably can be improved to be more general and became a reusable trait.

Related

Laravel Accessor on Pascal Case Attributes

I am getting data from an external db which uses PascalCase for column names eg: ClientStatus,
I did write an accessor, but due to casing issue its not working.
public function getClientStatusAttribute() {
return $this->attributes['ClientStatus']."modified value";
}
You can either access it using ClientModel->client_status or override HasAttribute trait's two methods hasGetMutator() and mutateAttribute()
Laravel Model uses HasAttribute trait.
Model uses HasAttribute trait's two methods hasGetMutator() and mutateAttribute() to check if accessor exits or not and if exsits then return its value.
hasGetMutator() which by default uses studly helper method to make snake_case attribute to PascalCase
That's why we create accessor get{AttributeName}Attribute in this manner.So,make these methods something link this
public function hasGetMutator($key)
{
return method_exists($this, 'get'.$key.'Attribute');
}
protected function mutateAttribute($key, $value)
{
return $this->{'get'.$key.'Attribute'}($value);
}
Above we removed studly helper method, so when we try access a PascalCase attribute, first it will check if accessor exists, if yes then return its value.

Overriding Laravel get and first methods

I need to override above mentioned methods to skip some database records. Using where is not an option since I would have to use it every single time as there are records in database that I do not need most of the time and I am not allowed to delete them from DB. Here is my attempt of doing this:
class SomeTable extends BaseModel {
public static function first() {
$query = static::query();
$data = $query->first();
if($data && $data->type == 'migration_type') return null;
return $data;
}
public static function get() {
$query = static::query();
$data = $query->get();
foreach($data as $key => $item) {
if($item->type == 'migration_type') unset($data[$key]);
}
return $data;
}
}
The problem with this code is that it works only when direct called on model. If I am using some other functions, like where, before get or first methods, it just skips my overridden method.
What would be the right way to do this and should I put this code within model?
My question is not duplicate as in the answer from mentioned question it is said:
all queries made from Models extending your CustomModel will get this new methods
And I need to override those two functions only for specific model, not for each one in application as not all tables have type column. That's the reason why I have written them within model class.
I need to override above mentioned methods to skip some database records.
Consider a global query scope on the model.
https://laravel.com/docs/5.8/eloquent#global-scopes
Global scopes allow you to add constraints to all queries for a given model. Laravel's own soft delete functionality utilizes global scopes to only pull "non-deleted" models from the database. Writing your own global scopes can provide a convenient, easy way to make sure every query for a given model receives certain constraints.
The issue here is that the where() method on the model returns a QueryBuilder instance where get() will return a Collection instance.
You should be able to override collection's default methods by adding a macro in it's place and can be done like so...
Collection::macro('toUpper', function () {
return $this->map(function ($value) {
return Str::upper($value);
});
});
Extending the query builder instance is not so easy but a good tutorial exists here and involves overriding the application's default connection class, which is not great when it comes to future upgrades.
Because after calling where you're dealing with the database builder and theses methods inside your model aren't being called .. about the issue you might overcome it by using select instead of first directly so will deal with the builder ..
example:
SomeTable::select('col1','col2')->take(1)->get();
another thing overriding these kind of methods is not a good idea if you're working with other developer on the same project.
good luck

Yii2: How to set default attribute values in ActiveRecord?

This may seem like a trivial question, however all of the obvious solutions that I can think of have their own flaws.
What we want is to be able to set any default ActiveRecord attribute value for new records only, in a way that makes it readable before and during validation and does not interfere with derived classes used for search.
The default values need to be set and ready as soon as we instantiate the class, so that (new MyModel)->attr returns the default attr value.
Here are some of the possibilities and the problems they have:
A) In MyModel override the init() method and assign default value when isNewRecord is true like so:
public function init() {
if ($this->isNewRecord) {
$this->attr = 'defaultValue';
}
parent::init();
}
Problem: Search. Unless we explicitly unset our default attribute in MySearchModel (very error-prone because it is too easy to forget), this will also set the value before calling search() in the derived MySearchModel class and interfere with searching (the attr attribute will already be set so search will be returning incorrect results). In Yii1.1 this was resolved by calling unsetAttributes() before calling search(), however no such method exists in Yii2.
B) In MyModel override the beforeSave() method like so:
public function beforeSave($insert) {
if ($insert) {
$this->attr = 'defaultValue';
}
return parent::beforeSave();
}
Problem: Attribute is not set in unsaved records. (new MyModel)->attr is null. Worse yet, even other validation rules that rely on this value will not be able to access it, because beforeSave() is called after validation.
C) To ensure the value is available during validation we can instead override the beforeValidate() method and set the default values there like so:
public function beforeValidate() {
if ($this->isNewRecord) {
$this->attr = 'defaultValue';
}
return parent::beforeValidate();
}
Problem: Attribute is still not set in unsaved (unvalidated) records. We need to at least call $model->validate() if we want to get the default value.
D) Use DefaultValidator in rules() to set a default attribute value during validation like so:
public function rules() {
return [
[
'attr', 'default',
'value' => 'defaultValue',
'on' => 'insert', // instantiate model with this scenario
],
// ...
];
}
Problem: Same as B) and C). Value is not set until we actually save or validate the record.
So what is the right way to set default attribute values? Is there any other way without the outlined problems?
There's two ways to do this.
$model => new Model();
Now $model has all the default attributes from the database table.
Or in your rules you can use:
[['field_name'], 'default', 'value'=> $defaultValue],
Now $model will always be created with the default values you specified.
You can see a full list of core validators here http://www.yiiframework.com/doc-2.0/guide-tutorial-core-validators.html
This is a hangup with Yii's bloated multi-purpose ActiveRecords
In my humble opinion the form models, active records, and search models would be better off split into separate classes/subclasses
Why not split your search models and form models?
abstract class Creature extends ActiveRecord {
...
}
class CreatureForm extends Creature {
public function init() {
parent::init();
if ($this->isNewRecord) {
$this->number_of_legs = 4;
}
}
}
class CreatureSearch extends Creature {
public function search() {
...
}
}
The benefits of this approach are
You can easily cater for different validation, set up and display cases without resorting to a bunch of ifs and switches
You can still keep common code in the parent class to avoid repetition
You can make changes to each subclass without worrying about how it will affect the other
The individual classes don't need to know about the existence of any of their siblings/children to function correctly
In fact, in our most recent project, we are using search models that don't extend from the related ActiveRecord at all
I know it is answered but I will add my approach.
I have Application and ApplicationSearch models. In Application model I add init with a check of the current instance. If its ApplicationSearch I skip initializations.
public function init()
{
if(!$this instanceof ApplicationSearch)
{
$this->id = hash('sha256', 123);
}
parent::init();
}
also as #mae commented below you can check for existence of search method in current instance, assuming you didn't add any method with name search to the non-search base model so the code becomes:
public function init()
{
// no search method is available in Gii generated Non search class
if(!method_exists($this,'search'))
{
$this->id = hash('sha256', 123);
}
parent::init();
}
I've read your question several times and I think there are some contradictions.
You want the defaults to be readable before and during validation and then you try init() or beforeSave(). So, assuming you just want to set the default values in the model so they can be present during the part of the life cycle as long as possible and not interfere with the derived classes, simply set them after initialising the object.
You can prepare separate method where all defaults are set and call it explicitly.
$model = new Model;
$model->setDefaultValues();
Or you can create static method to create model with all default values set and return the instance of it.
$model = Model::createNew();
Or you can pass default values to constructor.
$model = new Model([
'attribute1' => 'value1',
'attribute2' => 'value2',
]);
This is not much different from setting the attributes directly.
$model = new Model;
$model->attribute1 = 'value1';
$model->attribute2 = 'value2';
Everything depends on how much transparent would you like your model be to your controller.
This way attributes are set for the whole life cycle except the direct initialisation and it's not interfering with derived search model.
Just override __construct() method in your model like this:
class MyModel extends \yii\db\ActiveRecord {
function __construct(array $config = [])
{
parent::__construct($config);
$this->attr = 'defaultValue';
}
...
}
If you want to load default value from database you can put this code in your model
public function init()
{
parent::init();
if(!method_exists($this,'search')) //for checking this code is on model search or not
{
$this->loadDefaultValues();
}
}
You can prepare separate method where all defaults are set and call it explicitly.
$model = new Model;
if($model->isNewRecord())
$model->setDefaultValues();

Associating a child model in a polymorphic relationship causes an infinite loop?

I have a base Message class for an inbox using a polymorphic relationship to attach custom message types which all implement the same interface and behave differently in views based on their type. Display of that all works swimmingly, but I hit a snag when I tried to actually add these with code.
This is the Message class:
<?php
class Message extends Eloquent {
public function author() {
$this->belongsTo("User", "author_id");
}
public function recipient() {
$this->belongsTo("User", "recipient_id");
}
public function message() {
$this->morphTo();
}
}
The model that I attach to message() implements MessageInterface, So I thought I'd be able to make a quick helper to attach this model's relationship via Message::send():
public static function send(MessageInterface $message, User $to, User $from) {
if (! $message->exists)
$message->save();
$parent = new static;
$parent->author()->associate($from);
$parent->recipient()->associate($to);
$parent->message()->associate($message); // line that errors
return $parent->save();
}
But this ends up throwing what looks to be infinite recursion at me:
FatalErrorException: Maximum function nesting level of '100' reached, aborting!
This is the studly function, and from some searching it seems to happen when two models reference each other.
The Schema for the messages table is:
$table->increments("id");
$table->integer("message_id")->unsigned();
$table->string("message_type");
$table->integer("recipient_id")->unsigned();
$table->integer("author_id")->unsigned();
$table->timestamps();
Am I just doing something really wrong, here? I've looked through the morphTo method call in the source and tried seeing if reflection is the problem here (grabbing the function name and snake casing it), but I can't seem to find what is happening. The associate method call is just setting attributes and getting the class name for message_type, then returning a relationship.
There's no useful information in the error; it's a Symfony\Component\Debug\Exception\FatalErrorException with no context.
I'm running Laravel 4.1

Inspect Model relations

I am trying to inspect my Eloquent models to find out their relations to other Models. The problem is that relations are simply defined as a single method and no central index of relations exists:
public function posts()
{
return $this->hasMany('Post');
}
In order to inspect all relations I need to extract the list of methods, take out the ones inherited from Eloquent, execute each single one and check the return type:
$all = get_class_methods($model);
$inherited = get_class_methods('Eloquent');
$unique = array_diff($all, $inherited);
foreach($unique AS $method)
{
$relation = $model->$method();
if(is_a($relation, 'Illuminate\Database\Eloquent\Relations\Relation'))
{
//... this is a relation, do something with it
}
}
Needless to say, this is very dangerous. Is there a way to do this kind of inspection in a different, more secure way?
You could add PHPDoc comments to your relationship methods and then use the PHP reflection API to extract those from the source.

Resources