Yii2: How to set default attribute values in ActiveRecord? - 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();

Related

Eloquent model delegate to related model

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.

Best Practice - Laravel Controller Eloquent merge

I have a scope on my Supplier model that returns results where active = true.
This works great when creating new entries, as I only want the user to see active suppliers.
Current entries may have an inactive supplier; When I edit it, I want to see all active Suppliers, plus the current supplier (if it is inactive)
I have this code in my controller:
$suppliers = Supplier::active()->get();
if (!$suppliers->contains('id', $record->supplier->id))
{
$suppliers->add(Supplier::find($record->supplier->id));
}
Two questions: Is this the correct way to do this? Should this code be in my controller or should I have it somewhere else? (perhaps a scope but I wouldn't know how to code that).
Edit:
Thanks for the help guys. I have applied advice from each of the answers and refactored my code into a new scope:
public function scopeActiveIncluding($query, Model $model = null)
{
$query->where('active', 1);
if ($model && !$model->supplier->active)
{
$query->orWhere('id', $model->supplier->id);
}
}
What you've written will work, but the Collection::contains function can potentially be pretty slow if the collection is large.
Since you have the id, I would probably make the following change:
$suppliers = Supplier::active()->get();
$supplier = Supplier::find($record->supplier->id);
if (!$supplier->active) {
$suppliers->add($supplier);
}
Of course, the downside to this is that you may be making an unnecessary query on the database.
So you have to consider:
is the record's supplier more likely to be active or inactive?
is the size of the collection of active suppliers large enough to justify another (potentially wasted) call to the database?
Make the choice that makes the most sense, based on what you know of your application's data.
As for the second question, if you will only need this specific set of suppliers in this one part of your application, then the controller is a good place for this code.
If, however, you will need this particular set of suppliers in other parts of your application, you should probably move this code elsewhere. In that case, it might make sense to create a function on the the related model (whatever type $record is...) that returns that model's suppliers set. Something like:
public function getSuppliers()
{
$suppliers = Supplier::active()->get();
$supplier = $this->supplier;
if (!$supplier->active) {
$suppliers->add($supplier);
}
return $suppliers;
}
I saw #Vince's answer about 1st question, and I'm agree with him.
About 2nd question:
Write scope in Supplier model like this:
public function scopeActive($query){
$query->where('active', 1); // for boolean type
}
For good practice, you need to write the logic parts in services like "App\Services\SupplierService.php". And there write the function you want:
public function activeSuppliersWithCurrent($record) {
$suppliers = Supplier::active()->get();
$supplier = Supplier::find($record->supplier->id);
if (!$supplier->active) {
$suppliers->add($supplier);
}
}
In your SupplierController's constructor inject the instance of that service and use the function, for example:
use App\Servives\SupplierService;
protected $supplierService = null;
public function __construct(SupplierService $supplierService) {
$this->supplierService = $supplierService;
}
public function getActiveSuppliersWithCurrent(...) {
$result = $this->supplierService->activeSuppliersWithCurrent($record);
}
As you can see, later you will not need to change anything in controller. If you'll need to change for example the query of suppliers selection, you will just have to change something only in service. This way will make your code blocks separated and shorter.
Also the sense for this pattern: you don't need to access the models from controller. All logic related with models will implemented in services.
For other projects you can grab only services or only controllers, and implement another part differently. But in that case if you had all codes in controller, that will prevent you to grab the portions of necessary codes, cuz may you don't remember what doing each blocks...
You could add a where clause to the query to also find that id.
$suppliers = Supplier::active()->orWhere('id', $record->supplier->id)->get();
You could potentially slide this into the active scope by passing the 'id' as an argument.
public function scopeActive($query, $id = null)
{
$query->where('active', true);
if ($id) {
$query->orWhere('id', $id);
}
}
Supplier::active($record->supplier->id)->get();
Or make another scope that does this.

Laravel How to pass parameter to Accessor method in model via where condition while query building?

I have a Accessor method in Collection Model getSizesAttribute, which returns array of available sizes eg: ['S','L'], Now I need to get Models with have size 'S'. like:
$collections = $collections->where('sizes','S');
But sizes is array, could I manipulate this anyhow so that I could check returns only if sizes have specific size.
I tried making another method getIsSizeAttribute, like:
public function getIsSizeAttribute($size){
return in_array($size,$this->sizes);
}
Now How could I user this in Where condition like
$collections = $collections->where('is_size','S');
Mutators and Accessors only run skin-deep, after the query's already been executed. You could use Collection::filter() as Bangnokia suggests, but that wouldn't give you any performance benefit of actually applying the condition to the initial request.
I think what you're looking for here is a Query Scope. Add something like this to your Model class:
public function scopeSize(\Illuminate\Database\Eloquent\Builder $query, $size)
{
return $query->whereIn('sizes', $this->sizes[$size]);
}
And access it like this:
$collection = $model->size('S')->get();
You should use filter on collection
$collections = $collections->filter(function($item, $index) {
return in_array('S', $item->sizes);
});

Yii2 model validation message from the label assigned via ActiveForm

Is there any way to prompt for validation with the attribute's label assigned via ActiveForm?
For instance I have a model attribute amount and the label defined in its attributeLabels function is "Amount"
But while generating form I neeed the label "Fees" so:
$form->field($model, 'amount')->textInput(['maxlength' => true])->label('Fees')
After validation it prompts me "Amount cannot be blank" - it is known to me that we can write a rule to change message but according to my requirements, same attribute (from same model) is having different labels on different forms.
I know that back-end implementation of default message uses:
'message' => '{attribute} cannot be blank.' does anyone know if there is any {x} by which the assigned label in ActiveForm can be retrieved?
PS: I know that this problem can be resolved by scenarios. But it would be hectic to write rule for every field which has dual label.
There is no such way, sorry! What you are doing is overriding the label within the view. Actually within the form to be more precise. If you check out what the label()-method does, you will see that it calls activeLabel() from the Html-helper. This method in turn renders your label and returns the code.
As you can see, none of this information gets written back to the model. Therefore, during the validation process, you won't have your new label at hand, because it never makes its way into the model.
Your cleanest option is the one you mentioned already. Use scenarios to decide which validation rule (and therefore message) to use. OR you could create your own public property in which you write your temporary label like so:
class MyModel extends \yii\db\ActiveRecord
{
public $myTempLabel;
public function attributeLabels()
{
$myLabel = $this->myTempLabel === null ? Yii::t('app', 'defaultLabel') : $this->myTempLabel;
return [
//other labels...
'myField'=>$myLabel,
//other labels...
];
}
}
In your view you can then set the value back into the attribute within your model.
Sorry for not being able to help better, but that's the way it's implemented!
What you need to do is handle the label changing logic in your attributeLabels() function.
class MyModel extends \yii\base\Model {
public $amount_label = 'amount';
public function attributeLabels() {
return [
'amount' => $this->amount_label
];
}
}
Then in your controller.
$model = new MyModel;
$model->amount_label = 'fees';
...
Of course you may want to set the label in a different way. For example, if your model as a type attribute, and this is the attribute which determines the label, you could do a conditional based on that type attribute.

Yii2: Eagerly selecting calculated column and loading value into model-property

I thought I know every aspect of Yii2 in the meantime, but this one gives me headaches.
Situation
Two tables: Client and Billings. The Client-Table holds a regular list of clients. The Billing-table has several entries for each client (1:n).
Problem
I want to fetch a calculated DB-Field together with the row itself and access it via a virtual property of the model.
Key is that it gets calculated and selected together with the row itself. I know I can achieve something similliar with a regular virtual getter calculating the amount...but this is not at the same time as the select itself.
My Plan
In the query-object of the client-model i tried to add an an additional select (addSelect-Method) and give the field an alias. Then I added the alias of this select with the attributes-method of the model. Somehow this didn't work.
My Question
Does someone of you know the right way to achieve this? As this is a very common problem, I can not imagine this beeing too hard. I just somehow can't find the solution.
Sample code:
echo $client->sumOfBillings should output the contents of the corresponding property within the client-model. The contents of this property should be filled when fetching the client-row itself and not at the moment the property gets called.
I actual found the answer myself. Here is how you do it:
Query object
The fetching of all the Yii2-Models is done via their corresponding Query-Object. This object is retrieved via the models find()-Method. If you override this method, you can return your own query-object for that class. In the example above my model looks like this:
class Client extends \yii\db\ActiveRecord
{
//...
public static function find()
{
return new ClientQuery(get_called_class());
}
//...
}
Now within the Query-Objects init()-Method we can add the corresponding additional selects:
public class ClientQuery extends \yii\db\ActiveQuery
{
public function init()
{
parent::init();
//prepare subquery for calculation
$sub = (new Query())
->select('SUM(billing_amount)')
->from('billing')
->where('billing.client_id = client.id');
$this->addSelect(['client.*', 'sumBillings'=>$sub]);
}
}
We are now done with the query-Object. What have we done now? When selecting a client the sum gets calculated and loaded as well. But how do we access it? This was the hard part where I struggeled. The solution lies within the ActiveRecord-class.
Possibilities to populate the model with calculated data
There are several possibilities to load this data into the model-class. To understand what options we have, we can check out the populateRecord($record, $row)-method of the BaseActiveRecord-class:
/**
* Populates an active record object using a row of data from the database/storage.
*
* This is an internal method meant to be called to create active record objects after
* fetching data from the database. It is mainly used by [[ActiveQuery]] to populate
* the query results into active records.
*
* When calling this method manually you should call [[afterFind()]] on the created
* record to trigger the [[EVENT_AFTER_FIND|afterFind Event]].
*
* #param BaseActiveRecord $record the record to be populated. In most cases this will be an instance
* created by [[instantiate()]] beforehand.
* #param array $row attribute values (name => value)
*/
public static function populateRecord($record, $row)
{
$columns = array_flip($record->attributes());
foreach ($row as $name => $value) {
if (isset($columns[$name])) {
$record->_attributes[$name] = $value;
} elseif ($record->canSetProperty($name)) {
$record->$name = $value;
}
}
$record->_oldAttributes = $record->_attributes;
}
As you can see, the method takes the raw-data ($row) and populates the model instance ($record). If the model has either a property or a setter-method with the same name as the calculated field, it will be populated with data.
Final code of Client-Model
This is my final code of the Client-model:
class Client extends \yii\db\ActiveRecord
{
private $_sumBillings;
//...
public static function find()
{
return new ClientQuery(get_called_class());
}
public function getSumBillings()
{
return $this->_sumBillings;
}
protected function setSumBillings($val)
{
$this->_sumBillings = $val;
}
//...
}
The populateRecord()-method will find the setter-method ($record->canSetProperty($name)) and call it to fill in the calculated value. As it is protected, it is otherwise readonly.
Voilà...not that hard actually and definitely useful!

Resources