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?!?
Related
I have a simple DB with multiple tables and relationships, ie:
Article - Category
User - Group
etc...
I have implemented SoftDelete behavior where there is a Active column and if set to 0, it is considered deleted.
My question is simple.
How to i specify in as few places as possible that i only want load Articles that belong to Active categories.
I have specified relationships and default scopes (with Active = 1) condition.
However, when i do findAll(), it returns those Articles that have Active = 1, even if the category it belongs to is Active = 0....
Thank you
Implementation so far:
In base class
public function defaultScope()
{
return array('condition' => 'Active = 1');
}
in model:
'category' => array(self::BELONGS_TO, 'Category', 'CategoryID'),
'query':
$data = Article::model()->findAll();
MY SOLUTION
So i decided, that doing it in framework is:
inneficient
too much work
not good as it moves business logic away from database - this is fairly important to save work later on when working on interfaces/webservices and other customizations that should be part of the product.
Overall lesson: Try to keep all business logic as close to database as possible to prevent disrepancies.
First, i was thinking using triggers that would propagate soft delete down the hierarchy. However after thinking a bit more i decided not to do this. The reason is, that this way if I (or an interface or something) decided to reactivate the parent records, there would be no way to say which child record was chain-deleted and which one was deleted before:
CASE:
Lets say Category and Article.
First, one article is deleted.
Then the whole category is deleted.
Then you realize this was a mistake and you want to undelete the Category. How do you know which article was deleted by deleting category and which one should stay deleted? Yes there are solutions, ie timestamps but ...... too complex, too easy to break
So my solution in the end are:
VIEWS. I think i will move away from yii ORM to using views for anything more complex then basic things.
There are two advantages to this for me:
1) as a DBA i can do better SQL faster
2) logic stays in database, in case the application changes/another one is added, there is no need to implement the logic in more then one places
You need to specify condition when you are using findAll method. So You should use CDbCriteria for this purpose:
$criteria=new CDbCriteria;
$criteria->with = "category";
$criteria->condition = "category.Active = 1"; //OR $criteria->compare('category.active', 1 true);
$data = Article::model()->findAll($criteria);
You should also have a defaultScope in your Article model, condition there should add category.Active = 1 or whatever your relation is named.
public function defaultScope()
{
return array('condition' => 't.Active = 1 AND category.Active = 1');
}
I don't remember by now but it might be you have to specify the relation:
return array(
'with' => array("category" => array(
'condition'=> "t.Active = 1 AND category.Active = 1",
)
);
I'm back again for another question, i'm trying and trying. But i can't get it fixed.
This is my issue;
I have a database table, with a ProvinceID this can alter from 1 to 12. But it's an ID of the province. The provinces are stored with the value pName in the table Provinces
I have the following code to alter the table, and join it with the preferences table
$veiling = ORM::factory('veilingen')
->select('veilingvoorkeur.*')
->join('veilingvoorkeur', 'LEFT')
->on('veilingen.id', '=', 'veilingvoorkeur.vId')
->find_all();
$this->template->content = View::factory('veiling/veilingen')
->bind('veiling', $veiling);
It displays correctly, in the view i have;
echo '<div class="row">';
foreach($veiling as $ve)
{
echo $ve->provincie;
}
?>
</div>
it displays the provincie id; but i want to add a function to it; So it will be transformed to a province name. Normally i would create a functions.php file with a function getProvince($provinceId)
Do a mysql query to grab the pName value from Provinces and that is the job. But i'm new to kohana. Is there an option to turn the province id to province['pName'] during the ORM selection part, or do i have to search for another solution. Which i can't find :(
So. Please help me on the road again.
Thanx in advance.
Kind regards,
Kevin
Edit: 1:08
I've tried something, and it worked. I used ORM in the view file, for adding a function;
function provincie($id){
$provincie = ORM::factory('provincies', $id);
return $provincie->pName;
}
But i'm not glad with this way of solution, is there any other way? Or am i have to use it this way?
Check out Kohana's ORM relationships. If you use this, you could access the province name by simply calling:
$ve->provincie->name;
The relationship definition could look something like:
protected $_belongs_to = array(
'provincie' => array(
'model' => 'Provincie',
'foreign_key' => 'provincie_id'
),
);
Note that if you have a table column called provincie the relationship definition I give above will not work. You'd either have to change the table column to provincie_id or rename the relationship to e.g. provincie_model.
The output you describe when you do echo $ve->provincie; suggests that you store the ID in a column called provincie, thus the above applies to you.
I'd personally go for the first option as I prefer accessing IDs directly with an _id suffix and models without any suffix. But that's up to you.
Edit: If you use Kohanas ORM relationships you could even load the province with the initial query using with() e.g:
ORM::factory('veiling')->with('provincie')->find_all();
This could save you hundreds of extra queries.
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'),
);
i'm trying to do a complex query with Criteria in a Symfony project using Propel ORM.
the query i want to make is, in human words:
Select from the 'interface' table the registers that:
- 1 are associated with a process (with a link table)
- 2 have a name similat to $name
- 3 its destiny application's name is $apd (application accecible by foreign key)
- 4 its originapplication's name is $apo (application accecible by foreign key)
here the code i made, and not working:
$c = new Criteria();
$c->addJoin($linkPeer::CODIGO_INTERFASE,$intPeer::CODIGO_INTERFASE); //1
$c->add($linkPeer::CODIGO_PROCESONEGOCIO,$this->getCodigoProcesonegocio());//1
if($name){
$name = '%'.$name.'%'; //2
$c->add($intPeer::NOMBRE_INTERFASE,$name,Criteria::LIKE); //2
}
if($apd){
$apd = '%'.$apd.'%'; //3
$c->addJoin($appPeer::CODIGO_APLICACION,$intPeer::CODIGO_APLICACION_DESTINO);//3
$c->add($appPeer::NOMBRE_APLICACION,$apd,Criteria::LIKE); //3
}
if($apo){
$apo = '%'.$apo.'%';//4
$c->addJoin($appPeer::CODIGO_APLICACION,$intPeer::CODIGO_APLICACION_ORIGEN);//4
$c->add($appPeer::NOMBRE_APLICACION,$apo,Criteria::LIKE);//4
}
After that i did a $c->toString() to see the SQL generated and i saw that when i send only an $apd value, the SQL is correct, when i send an $apo value too. But when i send both, only the $apo AND apears on the SQL.
I guess its because the $c->add(...) call is the same with a distinct parameter, but not sure at all. Is this the error? What is the best way to generate my query correctly?
Thank you very much for your time! :D
Yes, it's overriding the previous call as the Criteria object only stores one condition per field. The solution is to create 2 or more separate Criterion objects and mix them into the Criteria object:
//something like this
$cron1 = $criteria->getNewCriterion();
$cron1->add($appPeer::NOMBRE_APLICACION,$apo,Criteria::LIKE);//4
$criteria->add($cron);
//and the same with the other criterion
However, it would be much easier to upgrade to Propel15+, where you work on the Query class level, and multiple restrictions on the same field don't override each other.
Hope this helps,
Daniel
Hi This is either a very specific or very generic quetion - I'm not sure, and I'm new to the Zend framework / oo generally. Please be patient if this is a stupid Q...
Anyway, I want to create a model which does something like:
Read all the itmes from a table 'gifts' into a row set
for each row in the table, read from a second table which shows how many have been bought, the append this as another "field" in the returned row
return the row set, with the number bought included.
Most of the simple Zend examples seem to only use one table in a model, but my reading seems to suggest that I should do most of the work there, rather than in the controller. If this is too generic a question, any example of a model that works with 2 tables and returns an array would be great!
thanks for your help in advance!
I assume second tables is something like "gift_order" or something.
In this case you need to specify tables relationships beetween "gift" and and "gift_order" via foreign keys and describe it in table class.
It will look like this
class GiftOrder extends Zend_Db_Table_Abstract
{
/** Table name */
protected $_name = 'gif_order';
protected $_referenceMap = array(
"Fileset" =>array(
"columns" => array("gifId"),
"refTableClass" => "Gift",
"refColumns" => array("id")
));
........................
You need to specify foreigh key constraint while create table with SQL
ALTER TABLE `gift_order`
ADD CONSTRAINT `order_to_gift` FOREIGN KEY (`giftId`) REFERENCES `gift` (`id`) ON DELETE CASCADE;
If this is something you looking for you could find more on this at this link link http://framework.zend.com/manual/en/zend.db.table.relationships.html
With such solution you will be able to loop gifts and get their orders without any complex SQL's
$rowSetGifts = $this->findGifts();
while($rowSetGifts->next()){
$gift = $rowSetGifts->current();
$orders = $gift->findGiftOrder();//This is magick methods, this is the same $gift->findDependentRowset('GiftOrder');
//Now you can do something with orders - count($orders), loop them or edit
}
I would recommend creating a function in your gifts model class that returns what you want. It would probably look something like:
public function getGiftWithAdditionalField($giftId) {
$select = $this->getAdapter()->select()
->from(array('g' => 'gifts'))
->joinLeft(array('table2' => 't2'), 'g.gift_id = t2.gift_id', array('field' => 'field'))
->where('g.gift_id = ?', $giftId);
return $this->getAdapter->fetchAll($select);
}
You can check out the Zend Framework Docs on Joins for more info.