Yii2 relation based on attribute values instead of keys - yii2

I have 2 tables in the db (mysql), and between the 2 there is no classic relationship through keys or ids. The only way I could define relationship would be through attribute values. E.g. table wheel and car and certain wheels would match certain cars because of the size only. Can it be defined on DB level, and/or in yii2, and if yes, how?
In the relations I can add an onCondition(), but you have to define an attribute (???), too:
public function getWheels() {
return $this->hasMany(\app\models\Wheel::className(), ['???' => '???'])->onCondition(['<', 'wheelsize', $this->wheelsize]);
}
I could use a fake attribute and set it in all records like to 1, but it seems a little bit odd for me.
I find nothing on the web regarding this or maybe I'm just searching the wrong way, or maybe I'm trying something that's totally bad practice. Can you please point me to the right direction?

Hypothetically you can set an empty array as a link, but for security reasons (I think) the condition "0 = 1" is automatically added in the select.
I faced your own problem several times and the best solution I could find was to use ActiveQuery explicitly (similar to what happens for hasOne and hasMany):
public function getWheels() {
return new ActiveQuery(\app\models\Wheel::className(), [
'where' => 'my condition' // <--- inserte here your condition as string or array
'multiple' => true // true=hasMany, false=hasOne
// you can also add other configuration params (select, on condition, order by, ...
]);
}
This way you can get both the array and the ActiveQuery to add other conditions:
var_dump($model->wheels); // array of wheels objects
var_dump($model->getWheels()); // yii\db\ActiveQuery object
$model->getWheels()->andWhere(...); // customize active query

I don't think that you could achieve this through relation.
But there is a way to work around the limitation.
<?php
namespace app\models;
class Car extend \yii\db\ActiveRecord
{
/**
* #var \app\models\Wheel
*/
private $_wheels;
/**
* #return \app\models\Wheel[]
*/
public function getWheels()
{
if (!$this->_wheels) {
$this->_wheels = Wheel::find()
->where(['<', 'wheelsize', $this->wheelsize])
//->andWhere() customize your where here
->all();
}
return $this->_wheels;
}
}
Then you could access the wheels attribute just as relation does.
<?php
$car = Car::find(1);
$car->wheels;
Beware that this way does not support Eager Loading

Related

Join subquery in Laravel model

Please suggest a better title for this question. I have problems to name my question properly.
Background
I'm creating a comic database for personal use, to track my comic reads. Every comic belongs to a series. Every comics has a release date. The release date of a series is the first release of the according comic. I have a eloquent function seriesByDate() for that:
class Series extends Model
{
public $timestamps = false;
protected $primaryKey = 'series_id';
protected $fillable = ['series_name', 'publisher_id'];
public function publisher()
{
return $this->belongsTo(Publisher::class, 'publisher_id');
}
public function comics()
{
return $this->hasMany(Comic::class, 'series_id', 'series_id');
}
// instead of saving the release date of a complete series
// we look for the first comic in this series and get the
// comic's release date.
public static function seriesByDate()
{
$firstRelease = DB::table('comics')
->select('series_id', DB::raw('MIN(comic_release_date) as first_release'))
->groupBy('series_id');
$seriesByDate = DB::table('series')
->leftJoinSub($firstRelease, 'first_release', function ($join) {
$join->on('series.series_id', '=', 'first_release.series_id');
})
->join('publishers', 'publishers.publisher_id', '=', 'series.publisher_id')
->select('series.series_id', 'series.series_name', 'first_release', 'publishers.publisher_name')
->get();
return $seriesByDate;
}
What i want
I want the release_date somehow be permanent to my Series model. Meaning: When I do a App\Series::all() i already want to have the release_date as a column in my returned data. Similar to App\Series::with('publishers')->get()
With my solution above i have to eplicitly execute App\Series::seriesByDate()
Is this even possible? Can you please give me a hint?
Edit / Update
The linked video by #Musa shows how to properly do this in a model: https://stackoverflow.com/a/61558482/5754486
You can't. There is no magic for this. You might eventually write your own custom Relation but that would be unnecessarily complex, just for the sake of having a pretty related/accessor. Both solutions are not great performance-wise.
Not sure why you choose such a structure. Without any further context/explanation, I would strongly recommend you to have a release_date column directly inside your Series model as well. That will be waaaay faster than your current structure.
If you still want to stick with that structure, I would personally retrieve the release_date "php side" instead of "database side" :
$series = App\Series::query()
->with([
'publishers',
'comics' => function ($query) {
$query->orderBy('created_at');
},
])
->get();
foreach ($series as $serie) {
$serieTitle = $serie->title;
$releaseDate = $serie->comics->first()->created_at;
echo $serieTitle.' was first released '.$releaseDate->diffForHumans().'<br/>';
}
(not tested)
the only downside is that it will return a Collection of every "comics" a "serie" has. If you do not have 10k comics per serie and you do not load 1k serie per page, that should be fine. In any case, this looks more elegant and optimized/faster than your seriesByDate method.
edit: also, you should watch "Advanced Querying With Eloquent" by Jonathan Reinink, at Laracon 2018 I believe. He discusses subqueries like the one you need. I am 100% sure you will find the best and most optimal Eloquent subquery one can forge for what you are trying to achieve : https://vimeo.com/showcase/7060635/video/255049572
you can defined an accessor then append the value
class Series extends Model
{
protected $appends = ['series_date'];
public function getSeriesDateAttribute()
{
return self::seriesByDate();
//OR build 'seriesByDate' manually, returning whatever you like.
}
}

Disable Doctrine automatic queries

With Symfony 4.2 and Doctrine, I want to disable automatic queries.
If I execute this simple example :
$posts = $em->getRepository(Post::class)->findAll();
foreach ($posts as $post) {
dump($post->getCategory()->getName();
}
Doctrine will search categories by itself. But I want to disable that. to force me to join (LEFT or INNER in repository).
It's possible ? Thanks
Implicit data fetching from database by accessing linked entity properties is one of core principles of Doctrine and can't be disabled. If you want to just fetch some data explicitly - you need to construct your own partial query and hydrate data either as array or simple object so your fetched results will not became entities.
Nothing can automatically disable this behavior and force you to write JOIN clauses, except your wishes.
This behavior (which is called lazy loading) is one of the main common behavior of all ORMs.
If you are not happy with this (and you probably have good reasons), then consider writing your own DQL queries, which are limited to your selected fields. What is not in your query will not be fetched automatically afterwards.
Write this method in your custom PostRepository class :
public function findAll(){
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('p')
->from('Post', 'p');
return $qb->getQuery()->getResult();
}
Then in your controller, you can do the same as before:
$posts = $em->getRepository(Post::class)->findAll();
foreach ($posts as $post) {
dump($post->getCategory()->getName();
}
Calling the getName() method from the Category entity will now throws an error, and will not launch any hidden SQL queries behind. If you want to play with Posts and Categories together, then you can adapt your findAll() method like this :
public function findAll(){
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('p, c')
->from('Post', 'p');
->join('p.category', 'c')
return $qb->getQuery()->getResult();
}

Yii2: db access from widget

Widgets should be designed to be self-contained and they should not access a database directly. But recently I've come across the code that uses a direct access to the database to retrieve widget settings, and also caches retrieved values.
Here's the part of the widget:
class DbCarousel extends Carousel
{
// ...
public function init()
{
$cacheKey = [
WidgetCarousel::className(),
$this->key
];
$items = Yii::$app->cache->get($cacheKey);
if ($items === false) {
$items = [];
$query = WidgetCarouselItem::find()
->joinWith('carousel')
->where([
'{{%widget_carousel_item}}.status' => 1,
'{{%widget_carousel}}.status' => WidgetCarousel::STATUS_ACTIVE,
'{{%widget_carousel}}.key' => $this->key,
])
->orderBy(['order' => SORT_ASC]);
foreach ($query->all() as $k => $item) {
/** #var $item \common\models\WidgetCarouselItem */
if ($item->path) {
$items[$k]['content'] = Html::img($item->getImageUrl());
}
if ($item->caption) {
$items[$k]['caption'] = $item->caption;
}
}
Yii::$app->cache->set($cacheKey, $items, 60*60*24*365);
}
$this->items = $items;
parent::init();
}
// ...
}
Question is: can widget under any circumstances access a database, or it's a sign that refactoring is needed?
Technically querying data and representing it are two different tasks, so such widget breaks single responsibility principle. However Yii has already a abstraction for retrieving data from database (ActiveRecord and/or ActiveQuery), so it is not that simple. You don't need to create separate data provider class for calling News::find()->newestFirst()->limit(5)->all() - usually it is easier and more pragmatic to call this query directly in LatestNewsWidget.
In general you have three situations with widgets:
Widget may display different sets of data in the same way (like GridView).
The same data may be displayed in different ways by different widgets, like widget for displaying menu - there is one menu stored in database, but depending on layout different widget should be used.
Both, way of displaying and data, is unique - for example you have only one menu and only one layout (so there is only one widget for displaying menu).
As far as two first cases clearly indicates that data source and widget should be separated, it is not that clear in third case. Personally I'm often using widgets which are responsible for retrieving all necessary data and dependencies, so I can use them simply by MyWidget::widget(). I never had any problems with this, but I'm trying to avoid too complicated DB queries - complicity usually should be hidden behind ActiveQuery abstraction. Also you always need to be prepared for refactoring and extracting querying data to separate component - at some point your unique widget with unique set of data may become not unique and separating widget and data provider may be the only sane way to keep your code DRY.

Yii model: Dynamic table relations

Table.linkedIndex is related to LinkedIndex.ID. The value of the field LinkedIndex.TableName is either Linked1 or Linked2 and defines which of these tables is related to a row in Table.
Now i want to make a dynamical link with Yii models so that i can easily get from a Table row to the corresponding Linked1 or Linked2 row:
Table.linkedID = [LinkedIndex.TableName].ID
Example
Table values:
LinkedIndex values:
Now I should get the row from Linked2 where ID=2:
$model = Table::model()->findByPk(0);
$row = $model->linked;
Model
In the model Table, I tried to make the relation to the table with the name of the value of linkedIndex.TableName:
public function relations()
{
return array(
'linkedIndex' => array(self::HAS_ONE, 'LinkedIndex', array('ID' => 'linkedIndex')),
'linked' => array(
self::HAS_ONE,
'linkedIndex.TableName',
array('ID' => 'linkedID'),
)
)
}
But then I get the error:
include(linkedIndex.TableName.php) [function.include]: failed to open stream: No such file or directory
Is there any way to make a dynamic relation Table.linkedID -> [LinkedIndex.TableName].ID with Yii Models?
Per the Yii docs here:
http://www.yiiframework.com/doc/api/1.1/CActiveRecord#relations-detail
I'd suggest using self::HAS_ONE instead (unless there can be multiple rows in LinkedIndex with the same ID - although from the looks of above, I doubt that's the case).
You can link tables together that have different keys by following the schema:
foreign_key => primary_key
In case you need to specify custom PK->FK association you can define it as array('fk'=>'pk'). For composite keys it will be array('fk_c1'=>'pk_с1','fk_c2'=>'pk_c2').
so in your case:
public function relations(){
return array(
'linkedIndex' => array(self::HAS_ONE, 'LinkedIndex', array('ID' => 'linkedIndex')),
);
}
where LinkedIndex is the class name for the LinkedIndex model (relative to your Table model - i.e. same folder. You could change that, of course) and array('ID' => 'linkedIndex') specifies the relationship as LinkedIndex.ID = Table.linkedIndex.
Edit
Looking at your updated example, I think you're misunderstanding how the relations function works. You're getting the error
include(linkedIndex.TableName.php) [function.include]: failed to open stream: No such file or directory
because you're trying to create another relation here:
'linked' => array(
self::BELONGS_TO,
'linkedIndex.TableName',
array('ID' => 'linkedID'),
)
This part: linkedIndex.TableName refers to a new model class linkedIndex.TableName, so Yii attempts to load that class' file linkedIndex.TableName.php and throws an error since it doesn't exist.
I think what you're looking for is to be able to access the value TableName within the table LinkedIndex, correct? If so, that's accessible from within the Table model via:
$this->linkedIndex->TableName
This is made possible by the relation we set up above. $this refers to the Table model, linkedIndex refers to the LinkedIndex relation we made above, and TableName is an attribute of that LinkedIndex model.
Edit 2
Per your comments, it looks like you're trying to make a more complex relationship. I'll be honest that this isn't really the way you should be using linking tables (ideally you should have a linking table between two tables, not a linking table that says which 3rd table to link to) but I'll try and answer your question as best as possible within Yii.
Ideally, this relationship should be made from within the LinkedIndex model, since that's where the relationship lies.
Since you're using the table name as the linking factor, you'll need to create a way to dynamically pass in the table you want to use after the record is found.
You can use the LinkedIndex model's afterFind function to create the secondary link after the model is created within Yii, and instantiate the new linked model there.
Something like this for your LinkedIndex model:
class LinkedIndex extends CActiveRecord{
public $linked;
public static function model($className = __CLASS__){
return parent::model($className);
}
public function tableName(){
return 'LinkedIndex';
}
public function afterFind(){
$this->linked = new Linked($this->TableName);
parent::afterFind();
}
//...etc.
}
The afterFind instantiates a new Linked model, and passes in the table name to use. That allows us to do something like this from within the Linked model:
class Linked extends CActiveRecord{
private $table_name;
public function __construct($table_name){
$this->table_name = $table_name;
}
public static function model($className = __CLASS__){
return parent::model($className);
}
public function tableName(){
return $this->table_name;
}
//...etc.
}
which is how we dynamically create a class with interchangeable table names. Of course, this fails of the classes need to have separate operations done per-method, but you could check what the table_name is and act accordingly (that's pretty janky, but would work).
All of this would result in being to access a property of the linked table via (from within the Table model):
$this->linkedIndex->linked->foo;
Because the value of LinkedIndex.TableName and Table.linkedID is needed to get the values, I moved the afterFind, suggested by M Sost, directly into the Table-Class and changed its content accordingly. No more need for a virtual model.
class Table extends CActiveRecord {
public $linked; // Needs to be public, to be accessible
// ...etc.
public function afterFind() {
$model = new $this->linkedIndex->TableName;
$this->linked = $model::model()->findByPk( $this->linkedID );
parent::afterFind();
}
// ...
}
Now I get the row from Linked2 where ID=2:
$model = Table::model()->findByPk(0);
$row = $model->linked;

Magento JoinLeft() in custom orders grid causing SQL integrity constrain violation for non-admin user in multi-website setup

I have extended the Mage_Adminhtml_Block_Sales_Order_Grid class with a custom module to add several customer attributes (Magento EE 1.10) to the grid.
I added the custom attributes to the collection in my MyCompany_MyModule_Block_Adminhtml_Order_Grid class in the _prepareCollection() method using three joins like this:
protected function _prepareCollection()
{
$collection = Mage::getResourceModel($this->_getCollectionClass());
//get the table names for the customer attributes we'll need
$customerEntityVarchar = Mage::getSingleton('core/resource')
->getTableName('customer_entity_varchar');
$customerEntityInt = Mage::getSingleton('core/resource')
->getTableName('customer_entity_int');
// add left joins to display the necessary customer attribute values
$collection->getSelect()->joinLeft(array(
'customer_entity_int_table'=>$customerEntityInt),
'`main_table`.`customer_id`=`customer_entity_int_table`.`entity_id`
AND `customer_entity_int_table`.`attribute_id`=148',
array('bureau'=>'value'));
$collection->getSelect()->joinLeft(array(
'customer_entity_varchar_table'=>$customerEntityVarchar),
'`main_table`.`customer_id`=`customer_entity_varchar_table`.`entity_id`
AND `customer_entity_varchar_table`.`attribute_id`=149',
array('index_code'=>'value'));
$collection->getSelect()->joinLeft(array(
'customer_entity_varchar_2_table'=>$customerEntityVarchar),
'`main_table`.`customer_id`=`customer_entity_varchar_2_table`.`entity_id`
AND `customer_entity_varchar_2_table`.`attribute_id`=150',
array('did_number'=>'value'));
$this->setCollection($collection);
return parent::_prepareCollection();
}
UPDATE: While everything displays fine when viewing orders, things are not fine when I try to search / filter orders by any of the text join fields (index_code or did_number). The result is a SQL error: "SQLSTATE[23000]: Integrity constraint violation: 1052 Column 'store_id' in where clause is ambiguous."
This problem also exists if I remove all but one of the leftJoin() statements, so something is going wrong with both (either) of the joins with the customer_entity_varchar table.
As now there are two columns with the name store_id, you have to specify filter_index when you add the column to the grid:
$this->addColumn('store_id', array(
...
'filter_index'=>'main_table.store_id',
));
So that it knows which one you are referring while filtering.
I hope it helps!
More than likely it is because you are joining customer_entity_varchar_table twice.
$collection->getSelect()->joinLeft(array(
'customer_entity_varchar_table'=>$customerEntityVarchar),
'`main_table`.`customer_id`=`customer_entity_varchar_table`.`entity_id`
AND `customer_entity_varchar_table`.`attribute_id`=149',
array('index_code'=>'value'));
$collection->getSelect()->joinLeft(array(
'customer_entity_varchar_2_table'=>$customerEntityVarchar),
'`main_table`.`customer_id`=`customer_entity_varchar_2_table`.`entity_id`
AND `customer_entity_varchar_2_table`.`attribute_id`=150',
array('did_number'=>'value'));
You may want to combine those, you can also try and print the SQL to see what the Query looks like:
$collection->getSelect()->getSelectSql();
More info on collections: http://blog.chapagain.com.np/magento-collection-functions/
The problem appears to exist in two different places. One case is if logged in as a user with a single store, the other as a user who can filter various stores.
Single store user
The solution I went with was to override the addAttributeToFilter method on the collection class. Not knowing exactly what changing the Enterprise_AdminGws_Model_Collections::addStoreAttributeToFilter method would affect other behavior I wanted to avoid that, and I found adding a filter index in Mage_Adminhtml_Block_Sales_Order_Grid as Javier suggested did not work.
Instead I added the following method to Mage_Sales_Model_Resource_Order_Grid_Collection:
/**
* {#inheritdoc}
*/
public function addAttributeToFilter($attribute, $condition = null)
{
if (is_string($attribute) && 'store_id' == $attribute) {
$attribute = 'main_table.' . $attribute;
}
return parent::addFieldToFilter($attribute, $condition);
}
A patch can be found here: https://gist.github.com/josephdpurcell/baf93992ff2d941d02c946aeccd48853
Multi-store user
If a user can filter orders by store at admin/sales_order, the following change is also needed to Mage_Adminhtml_Block_Sales_Order_Grid around line 75:
if (!Mage::app()->isSingleStoreMode()) {
$this->addColumn('store_id', array(
'header' => Mage::helper('sales')->__('Purchased From (Store)'),
'index' => 'store_id',
'type' => 'store',
'store_view'=> true,
'display_deleted' => true,
'filter_index' => 'main_table.store_id',
));
}
A patch can be found here: https://gist.github.com/josephdpurcell/c96286a7c4d2f5d1fe92fb36ee5d0d5a
I had the same bug, after grepping the code, I finally found the troublemaker which is in the Enterprise_AdminGws_Model_Collections class at line ~235:
/**
* Add store_id attribute to filter of EAV-collection
*
* #param Mage_Eav_Model_Entity_Collection_Abstract $collection
*/
public function addStoreAttributeToFilter($collection)
{
$collection->addAttributeToFilter('store_id', array('in' => $this->_role->getStoreIds()));
}
You have to replace 'store_id' by 'main_table.store_id', of course you'll have to extend that particular method in your own rewrite to stick into Magento guidelines :p
Hope it helps!