Yii Framework - two relations via the same "through" table - php

Mine goal is to have possibility to search Documents via the Users Names and Surnames and also via Recrutation Year and Semester.
Documents are related only to Declarations in such a way that Document are connected to exatly one Declaration and Declaration can be connected to exatly One or none Documents.
Declarations are related to OutgoingStudent and Recrutation.
So when quering Documents I want to query also OutgoingStudent and Recrutations via the Declaration table.
My code for relations in Documents:
return array(
'declaration' => array(self::BELONGS_TO, 'Declaration', 'DeclarationID'),
'outgoingStudentUserIdUser' => array(self::HAS_ONE, 'OutgoingStudent', 'OutgoingStudent_User_idUser','through'=>'declaration',),
'Recrutation' => array(self::HAS_ONE, 'Recrutation', 'Recrutation_RecrutationID','through'=>'declaration'),
);
And now when in search() function I want to make a query ->with
'declaration','outgoingStudentUserIdUser' and 'Recrutation':
$criteria->with = array('declaration','Recrutation','outgoingStudentUserIdUser');
I'm getting this error:
CDbCommand nie zdołał wykonać instrukcji SQL: SQLSTATE[42000] [1066]
Not unique table/alias: 'declaration'. The SQL statement executed was:
SELECT COUNT(DISTINCT t.DeclarationID) FROM Documents t LEFT
OUTER JOIN Declarations declaration ON
(t.DeclarationID=declaration.idDeclarations) LEFT OUTER JOIN
Recrutation Recrutation ON
(declaration.Recrutation_RecrutationID=Recrutation.RecrutationID)
LEFT OUTER JOIN Declarations declaration ON
(t.DeclarationID=declaration.idDeclarations) LEFT OUTER JOIN
OutgoingStudent outgoingStudentUserIdUser ON
(declaration.OutgoingStudent_User_idUser=outgoingStudentUserIdUser.User_idUser)
When using only $criteria->with = array('declaration','Recrutation') or $criteria->with = array('declaration','outgoingStudentUserIdUser') there is no error only when using both.
So probably it should be done in some other way, but how?

I have so many things to tell you! Here they are:
I find your relations function declaration pretty messy, and I'm not sure if it is doing what you want it to do (in case it worked). Here are my suggestions to re-declare it:
First of all, 'outgoingStudentUserIdUser' looks like a terrible name for a relation. In the end, the relation will be to instances of outgoingStudentUser, not only to 'ids'. So allow me to name it just as outgoingStudentUser. Now, this is my code:
'outgoingStudentUser' => array(self::HAS_ONE, 'OutgoingStudent', array('idDocuments'=>'idOutgoingStudent'),'through'=>'declaration',),
where 'idDocuments' is Documents' model primary key, and idOutgoingStudent is OutgoingStudent's model primary key.
The second relation could be corrected in a very similar way:
'Recrutation' => array(self::HAS_ONE, 'Recrutation', array('idDocuments'=>'idRecrutation'),'through'=>'declaration'),
where 'idDocuments' is Documents' model primary key, and idRecrutation is Recrutation's model primary key.
You can find that this is the correct declaration here: http://www.yiiframework.com/doc/guide/1.1/en/database.arr#through-on-self
But that's not all. I have more to tell you! What you're doing with your code is senseless. 'with' is used to force eager loading on related objects. In the following code:
$criteria->with = array('declaration','Recrutation','outgoingStudentUserIdUser');
you're just specifying in $criteria that when you retrieve in DB an instance of Documents using this $criteria, it will also fetch the models linked to that instance by the relations passed as parameters to 'with'. That's eager loading. It is used to reduce the number of queries to database. Is like saying: "go to DB and get me this instance of Documents, but once you're there, bring to me once per all the instances of other tables related to this object".
Well, that's what you're declaring, but certainly that's not what you want to do. How I know? Because that declaration is useless inside a search() function. As you may see here: http://www.yiiframework.com/doc/api/1.1/CDbCriteria/#with-detail , 'with' is only useful in some functions, and search() is not one of them. Inside search(), eager loading is pointless, senseless and useless.
So I see myself forced to ask you what are you trying to do? You say: "Mine goal is to have possibility to search Documents via the Users Names and Surnames and also via Recrutation Year and Semester", but what do you mean by "search Documents via the Users Names and..."? Do you want something like this: $user->documents, to return all the documents associated with $user? I hope you could be more specific about that, but perhaps in another, more to-the-point, question.

You can also try this:
return array(
'declaration' => array(self::BELONGS_TO, 'Declaration', 'DeclarationID'),
'outgoingStudentUserIdUser' => array(self::HAS_ONE, 'OutgoingStudent', 'OutgoingStudent_User_idUser','through'=>'declaration',),
'Recrutation' => array(self::HAS_ONE, 'Recrutation', '', 'on'=>'declaration.id=Recrutation.Recrutation_RecrutationID'),
);

Related

Yii active record relation limit to one record

I am using PHP Yii framework's Active Records to model a relation between two tables. The join involves a column and a literal, and could match 2+ rows but must be limited to only ever return 1 row.
I'm using Yii version 1.1.13, and MySQL 5.1.something.
My problem isn't the SQL, but how to configure the Yii model classes to work in all cases. I can get the classes to work sometimes (simple eager loading) but not always (never for lazy loading).
First I will describe the database. Then the goal. Then I will include examples of code I've tried and why it failed.
Sorry for the length, this is complex and examples are necessary.
The database:
TABLE sites
columns:
id INT
name VARCHAR
type VARCHAR
rows:
id name type
-- ------- -----
1 Site A foo
2 Site B bar
3 Site C bar
TABLE field_options
columns:
id INT
field VARCHAR
option_value VARCHAR
option_label VARCHAR
rows:
id field option_value option_label
-- ----------- ------------- -------------
1 sites.type foo Foo Style Site
2 sites.type bar Bar-Like Site
3 sites.type bar Bar Site
So sites has an informal a reference to field_options where:
field_options.field = 'sites.type' and
field_options.option_value = sites.type
The goal:
The goal is for sites to look up the relevant field_options.option_label to go with its type value. If there happens to be more than one matching row, pick only one (any one, doesn't matter which).
Using SQL this is easy, I can do it 2 ways:
I can join using a subquery:
SELECT
sites.id,
f1.option_label AS type_label
FROM sites
LEFT JOIN field_options AS f1 ON f1.id = (
SELECT id FROM field_options
WHERE
field_options.field = 'sites.type'
AND field_options.option_value = sites.type
LIMIT 1
)
Or I can use a subquery as a column reference in the select clause:
SELECT
sites.id,
(
SELECT id FROM field_options
WHERE
field_options.field = 'sites.type'
AND field_options.option_value = sites.type
LIMIT 1
) AS type_label
FROM sites
Either way works great. So how do I model this in Yii??
What I've tried so far:
1. Use "on" array key in relation
I can get a simple eager lookup to work with this code:
class Sites extends CActiveRecord
{
...
public function relations()
{
return array(
'type_option' => array(
self::BELONGS_TO,
'FieldOptions', // that's the class for field_options
'', // no normal foreign key
'on' => "type_option.id = (SELECT id FROM field_options WHERE field = 'sites.type' AND option_value = t.type LIMIT 1)",
),
);
}
}
This works when I load a set of Sites objects and force it to eager load type_label, e.g. Sites::model()->with('type_label')->findByPk(1).
It does not work if type_label is lazy-loaded.
$site = Sites::model()->findByPk(1);
$label = $site->type_option->option_label; // ERROR: column t.type doesn't exist
2. Force eager loading always
Building on #1 above, I tried forcing Yii to always to eager loading, never lazy loading:
class Sites extends CActiveRecord
{
public function relations()
{
....
}
public function defaultScope()
{
return array(
'with' => array( 'type_option' ),
);
}
}
Now everything always works when I load Sites, but it's no good because there are other models (not pictured here) that have relations that point to Sites, and those result in errors:
$site = Sites::model()->findByPk(1);
$label = $site->type_option->option_label; // works now
$other = OtherModel::model()->with('site_relation')->findByPk(1); // ERROR: column t.type doesn't exist, because 't' refers to OtherModel now
3. Make the reference to the base table somehow relative
If there was a way that I could refer to the base table, other than "t", that was guaranteed to point to the correct alias, that would work, e.g.
'on' => "type_option.id = (SELECT id FROM field_options WHERE field = 'sites.type' AND option_value = %%BASE_TABLE%%.type LIMIT 1)",
where %%BASE_TABLE%% always refers to the correct alias for table sites. But I know of no such token.
4. Add a true virtual database column
This way would be the best, if I could convince Yii that the table has an extra column, which should be loaded just like every other column, except the SQL is a subquery -- that would be awesome. But again, I don't see any way to mess with the column list, it's all done automatically.
So, after all that... does anyone have any ideas?
EDIT Mar 21/15: I just spent a long time investigating the possibility of subclassing parts of Yii to get the job done. No luck.
I tried creating a new type of relation based on BELONGS_TO (class CBelongsToRelation), to see if I could somehow add in context sensitivity so it could react differently depending on whether it was being lazy-loaded or not. But Yii isn't built that way. There is no place where I can hook in code during query buiding from inside a relation object. And there is also no way I can tell even what the base class is, relation objects have no link back to the parent model.
All of the code that assembles these queries for active records and their relations is locked up in a separate set of classes (CActiveFinder, CJoinQuery, etc.) that cannot be extended or replaced without replacing the entire AR system pretty much. So that's out.
I then tried to see if I can create "fake" database column entries that would actually be a subquery. Answer: no. I figured out how I could add additional columns to Yii's automatically generated schema data. But,
a) there's no way to define a column in such a way that it can be a derived value, Yii assumes it's a column name in way too many places for that; and
b) there also doesn't appear to be any way to avoid having it try to insert/update to those columns on save.
So it really is looking like Yii (1.x) just does not have any way to make this happen.
Limited solution provided by #eggyal in comments: #eggyal has a suggestion that will meet my needs. He suggests creating a MySQL view table to add extra columns for each label, using a subquery to look up the value. To allow editing, the view would have to be tied to a separate Yii class, so the downside is everywhere in my code I need to be aware of whether I'm loading a record for reading only (must use the view's class) or read/write (must use the base table's class, does not have the extra columns). That said, it is a workable solution for my particular case, maybe even the only solution -- although not an answer to this question as written, so I'm not going to put it in as an answer.
OK, after a lot of attempts, I have found a solution. Thanks to #eggyal for making me think about database views.
As a quick recap, my goal was:
link one Yii model (CActiveRecord) to another using a relation()
the table join is complex and could match more than one row
the relation must never join more than one row (i.e. LIMIT 1)
I got it to work by:
creating a view from the field_options base table, using SQL GROUP BY to eliminate duplicate rows
creating a separate Yii model (CActiveRecord class) for the view
using the new model/view for the relation(), not the original table
Even then there were some wrinkles (maybe a Yii bug?) I had to work around.
Here are all the details:
The SQL view:
CREATE VIEW field_options_distinct AS
SELECT
field,
option_value,
option_label
FROM
field_options
GROUP BY
field,
option_value
;
This view contains only the columns I care about, and only ever one row per field/option_value pair.
The Yii model class:
class FieldOptionsDistinct extends CActiveRecord
{
public function tableName()
{
return 'field_options_distinct'; // the view
}
/*
I found I needed the following to override Yii's default table data.
The view doesn't have a primary key, and that confused Yii's AR finding system
and resulted in a PHP "invalid foreach()" error.
So the code below works around it by diving into the Yii table metadata object
and manually setting the primary key column list.
*/
private $bMetaDataSet = FALSE;
public function getMetaData()
{
$oMetaData = parent::getMetaData();
if (!$this->bMetaDataSet) {
$oMetaData->tableSchema->primaryKey = array( 'field', 'option_value' );
$this->bMetaDataSet = TRUE;
}
return $oMetaData;
}
}
The Yii relation():
class Sites extends CActiveRecord
{
// ...
public function relations()
{
return (
'type_option' => array(
self::BELONGS_TO,
'FieldOptionsDistinct',
array(
'type' => 'option_value',
),
'on' => "type_option.field = 'sites.type'",
),
);
}
}
And all that does the trick. Easy, right?!?

Yii CActiveRecord multi level (nested) joins

I'm fairly new to Yii and so far I've managed to get by on my own but now I'm stuck.
I have a complex relational db (implemented in MySQL). I have the models for them and it's working properly my only problem is that I can't figure out how to make complex queries with CDbCriteria
The application is like an issue tracker so a user can report some problem and someone in charge of that type of problem will contact him/her.
Main tables relevant to the problem:
roles (multiple roles can be assigned to a user, e.g. a role can be 'accountant' or 'developer' )
issuetypes (a user with a role /e.g. accountant/ can create a new issue with a set of issue types /e.g. "printer problem"/
issues (every issue can only have one issue type)
A developer can create an issue like "new PHP version needed" but the accountant can't do that so I need to query the database for all the available issuetypes for a set of roles.
If the user have multiple roles (developer, tester) then I need the union of the issuetypes available for those roles.
So far it's working but when I need to take it a step further and query all the issues submitted with these issuetypes...I'm stuck.
Roughly I need to get the following query:
SELECT DISTINCT i.* FROM `issue` i
LEFT JOIN issuetype ON issuetype.id=i.issuetype_id
RIGHT JOIN role_has_issuetype rit ON rit.issuetype_id=issuetype.id
RIGHT JOIN role ON role.id=rit.role_id
WHERE role.role IN ('developer','tester') AND i.id IS NOT NULL
I know I could use the SQL query directly but the db backend will change in the future (most probably to Oracle) so I'd like to keep the abstraction as far as I could to avoid changing any hard-coded SQL statement and be "backend-independent".
The relevant parts of the models:
class Role extends CActiveRecord
{
public function relations()
{
return array(
'issuetypes' => array(self::MANY_MANY, 'Issuetype', 'role_has_issuetype(role_id, issuetype_id)'),
'users' => array(self::MANY_MANY, 'User', 'user_has_role(role_id, user_id)'),
);
}
}
class Issuetype extends CActiveRecord
{
public function relations()
{
return array(
...
'issues' => array(self::HAS_MANY, 'Issue', 'issuetype_id'),
'roles' => array(self::MANY_MANY, 'Role', 'role_has_issuetype(issuetype_id, role_id)'),
);
}
class Issue extends CActiveRecord
{
public function relations()
{
...
'issuetype' => array(self::BELONGS_TO, 'Issuetype', 'issuetype_id'),
);
}
}
I've tried something like this:
Issue::model()->with(
array(
'issuetype'=>array(
'select'=>false,
'joinType'=>'INNER JOIN',
'condition'=>'issuetype.roles IN ("developer","tester")',
)))->findAll();
It doens't work because issuetype has no column roles it's just a relation.
I've tried to do it in two steps. First get the issuetypes associated with the roles then get the issues.
The first part is working with this code:
$crit = new CDbCriteria();
$crit->addInCondition('roles.role',array('developer','tester'));
$crit->select = array('id');
$res=Issuetype::model()->with('roles')->findAll($crit);
But I don't know how to use the $res in another criteria.
(I'm not even sure this approach will work and even if will it's far from optimal)
I've read about a dozen SO answers and read the Yii forum together with the Yii docs but the examples I've found were not sufficient to solve this (at least I couldn't adopt those codes
to my problem)
I'm quite sure I'm just overlooking some obvious stuff but unfortunately I can't figure it out on my own.
Thanks
Sleeping on the problem helped :)
I've realized my mistake. I should have written this:
return Issue::model()->with(array(
'issuetype.roles'=>array(
'select'=>false,
'joinType'=>'INNER JOIN',
'condition'=>"roles.role IN ('developer','tester')",
)))->findAll();
I hope this will help someone in the future.

Yii: Getting distinct field values from a related model using scopes

I'm working on the Admin view in Yii for my Project model.
One of the columns with a filter is user (owner) of the project.
I can do something like this:
'filter'=> CHtml::dropDownList('Project[user_id]', $model->user_id,
CHtml::listData(User::model()->orderAsc()->findAll(),
'id','username'),array('empty'=>'(select)')),
Which gives me a list of all users in the user table, but I'd like to create something that pulls the distinct users who own a project (I've got 200 users, but only a handful tend to create projects).
Project and User are related via:
'user' => array(self::BELONGS_TO, 'User', 'user_id')
I've tried a bunch of option in the findAll() method, but none worked, I'm not trying to do something with a scope.
So far I've tried this:
'filter'=> CHtml::dropDownList('Project[user_id]', $model->user_id,
CHtml::listData(Project::model()->with('user')->ownerUsernames()->
findAll(), 'id','username'),array('empty'=>'(select)')),
and my Scope is defined as:
'ownerUsernames' => array(
'with' => 'user.username',
'select' => 'user.username',
'distinct' => true,
),
I've tried so many iterations of the above I've lost count 'with'=>'username' 'select'=>'username' etc., but just can't seem to get it to work.
I've tried replace Project::model() with $model just because I thought it might have something to do with it, but no luck.
Any ideas are appreciated. Very new to Yii, but this seems like something it can do.
You have everything ready. Define for the project model a getter function like
public function getUsername()
{
return $this->user->name;
}
Now you should be able to use
CHtml::dropDownList('Project[user_id]', $model->user_id,
CHtml::listData(Project::model()->with('user')->ownerUsernames()->
findAll(), 'id','username'),array('empty'=>'(select)'))
The logic is that CHtml::listData will get the projects as a model, it will create the keys using $project->id and it will create the values using $project->username. Because you created the getted function it will know what $project->username is. Unfortunately CHtml::listData(Project::model()->with('user')->ownerUsernames()->findAll(), 'id','user.name') will not work because it cannot execute 'user.name' or anything like that.
actually you can do many things to accomplish that but some methods would be not appropriate due to time consumption. I prefer to tackle this problem as
Make a table in DB named user_project with attributes
id
user_id(fk to user table)
project_id(fk to project table)
When you create a project then also populate user_project with given fields.
Make its model and you will see relations in it. Now make a function like
public function getName()
{
return user->name;//here i have assumed that the relation name is user
}
Now query this table like
$user=UserProject::mode::findAll();
$list=CHtml::listData($user,'user_id','name');
and use this list to populate the dropDownList. The benefit of this method is that you dont need to query all users of the user table.

Laravel 4 - Eloquent way to attach a where clause to a relationship when building a collection

This may be a dupe but I've been trawling for some time looking for a proper answer to this and haven't found one yet.
So essentially all I want to do is join two tables and attach a where condition to the entire collection based on a field from the joined table.
So lets say I have two tables:
users:
-id
-name
-email
-password
-etc
user_addresses:
-address_line1
-address_line2
-town
-city
-etc
For the sake of argument (realising this may not be the best example) - lets assume a user can have multiple address entries. Now, laravel/eloquent gives us a nice way of wrapping up conditions on a collection in the form of scopes, so we'll use one of them to define the filter.
So, if I want to get all the users with an address in smallville, I may create a scope and relationships as follows:
Users.php (model)
class users extends Eloquent{
public function addresses(){
return $this->belongsToMany('Address');
}
public function scopeSmallvilleResidents($query){
return $query->join('user_addresses', function($join) {
$join->on('user.id', '=', 'user_addresses.user_id');
})->where('user_addresses.town', '=', 'Smallville');
}
}
This works but its a bit ugly and it messes up my eloquent objects, since I no longer have a nice dynamic attribute containing users addresses, everything is just crammed into the user object.
I have tried various other things to get this to work, for example using a closure on the relationship looked promising:
//this just filters at the point of attaching the relationship so will display all users but only pull in the address where it matches
User::with(array('Addresses' => function($query){
$query->where('town', '=', 'Smallville');
}));
//This doesnt work at all
User::with('Addresses')->where('user_addresses.town', '=', 'Smallville');
So is there an 'Eloquent' way of applying where clauses to relationships in a way that filters the main collection and keeps my eloquent objects in tact? Or have I like so many others been spoiled by the elegant syntax of Eloquent to the point where I'm asking too much?
Note: I am aware that you can usually get round this by defining relationships in the other direction (e.g. accessing the address table first) but this is not always ideal and not what i am asking.
Thanks in advance for any help.
At this point, there is no means by which you can filter primary model based on a constraint in the related models.
That means, you can't get only Users who have user_address.town = 'Smallwille' in one swipe.
Personally I hope that this will get implemented soon because I can see a lot of people asking for it (including myself here).
The current workaround is messy, but it works:
$products = array();
$categories = Category::where('type', 'fruit')->get();
foreach($categories as $category)
{
$products = array_merge($products, $category->products);
}
return $products;
As stated in the question there is a way to filter the adresses first and then use eager loading to load the related users object. As so:
$addressFilter = Addresses::with('Users')->where('town', $keyword)->first();
$users= $addressFilter->users;
of course bind with belongsTo in the model.
///* And in case anyone reading wants to also use pre-filtered Users data you can pass a closure to the 'with'
$usersFilter = Addresses::with(array('Users' => function($query) use ($keyword){
$query->where('somefield', $keyword);
}))->where('town', $keyword)->first();
$myUsers = $usersFilter->users;

Find a model's siblings using a relation

This is the table for the model:
CREATE TABLE IF NOT EXISTS `SomeModel` (
`id` int NOT NULL AUTO_INCREMENT,
`parent_id` int NOT NULL
)
My goal is to be able to query a model with its siblings using:
SomeModel::model()->with('siblings')->findByPk($id);
Here is my current attempt at the relation:
public function relations()
{
return array(
'siblings' => array(self::HAS_MANY, 'SomeModel', array('parent_id'=>'parent_id')),
);
}
The problem is that I can't find a way to create a condition so that the model itself isn't returned along with it's siblings in the $model->siblings array.
Any thoughts would be great.
Thanks!
Change your relation to this:
'siblings'=>array(self::HAS_MANY, 'Address', array('parent_id'=>'parent_id'),'condition'=>'siblings.id!=t.id')
Edit: Some explanation, in the documentation for relation(), we can specify extra options for the join that takes place and these additional options:
Additional options may be specified as name-value pairs in the rest array elements.
Plus the default alias for the table is t hence use t.id.
Edit: from the comments:
Implementing lazy loading, the way you want it, will be tough to accomplish(I don't know how, not sure if possible either), however i can suggest making the current code better, by
using named scopes, use a scope when you are doing eager loading, and add the condition siblings.id!=t.id in the scope:
// add this function to your model, and remove the condition from the relation
public function scopes(){
return array(
'eagerSibs'=>array(
'condition'=>'siblings.id!=t.id',
),
);
}
Do eager loading with scope:
SomeModel::model()->eagerSibs()->with('siblings')->findByPk($id);
This will remove the error with lazy loading $model->siblings
Although the error of lazy loading will be removed you will still be getting the current record, to counter that you can add and use a function of the model which will load the related records without the current one, but ofcourse you won't be using $model->siblings, and instead have something like: $model->getLazySibs();
public function getLazySibs(){
$sibs=$this->siblings;
foreach ($sibs as $asib){
if ($asib->id != $this->id)
$lazySibs[]=$asib;
}
return $lazySibs;
}

Categories