How to use the cascade_delete config option in CodeIgniter Datamapper - php

I use MySQL using InnoDB tables with CodeIgniter Datamapper in my PHP application. Often, the user is given the option of deleting a record through the app by initiating a ->delete function call. When a record has child records (one-to-one or one-to-many), I would also like these records to be deleted along with the parent record, if it is stated by FK constraints in the database.
In this case, I have 2 tables, items and input_lines. I have confirmed that both are using InnoDB. Each item can have many input_lines, so input_lines has a field called item_id, which is set to NULL, indexed, and have FK constraints (ON CASCADE DELETE and ON CASCADE UPDATE). I have set the config element in the DM config file as
$config['cascade_delete'] = FALSE
Because in the documentation it says you should do that if you are using ON UPDATE/DELETE CASCADE. However, when the user initiates the $item->delete() method, only the item is deleted, and the item_id fields on the input_line records associated with the item are set to null.
My models look like this:
class Item extends DataMapper {
public $has_many = array('labour', 'item_type', 'input_line', 'custom_item_type');
...
}
class Input_line extends DataMapper {
public $has_one = array('item');
...
}
I have tried this with cascade_delete = false and true and it won't work. I know the constraints work because deleting the record with MySQL directly works as expected, deleting the child records.
What am I missing? Why is it setting the FK fields to null instead of deleting the record?
EDIT 1:
I decided against my better judgment to debug the delete function in datamapper.php (libraries directory).
I noticed this code in that function:
// Delete all "has many" and "has one" relations for this object first
foreach (array('has_many', 'has_one') as $type)
{
foreach ($this->{$type} as $model => $properties)
{
// do we want cascading delete's?
if ($properties['cascade_delete'])
{
....
So I var_dumped the contents of $properties, and I saw this:
array (size=8)
'class' => string 'labour' (length=6)
'other_field' => string 'item' (length=4)
'join_self_as' => string 'item' (length=4)
'join_other_as' => string 'labour' (length=6)
'join_table' => string '' (length=0)
'reciprocal' => boolean false
'auto_populate' => null
'cascade_delete' => boolean true
It appears the default for when the model doesn't have the property specifically initialized is overriding the config value. This seems like too glaring a mistake so there's definitely something I'm doing wrong somewhere...right? I really, really want to avoid hacking the DM core files...
EDIT 2:
I was thinking maybe the config file wasn't being found, but I checked the logs and there're entries stating that the Datamapper config file was successfully loaded, so that's not the issue.

Doesn't look like anyone has any answers.
My solution was to change the property in the datamapper library $cascade_delete to false, since it's set to true right now. It's unfortunate that I have to resort to hacking the core, but DM won't respect my changes in the config file for cascade_delete so I have no other choice.
If anyone comes across this question and has encountered an issue like this before, please comment.

I have come across the same problem, but finally I just did like this:
$sql = "DELETE FROM EVENT WHERE event_id=".$event_id.";";
$this->db->query ($sql );
In case we set "ON DELETE CASCADE" for the foreign key which refers to event_id, the above SQL works fine, so I just call it directly.

Related

CakePHP Unit tests are ignoring Fixtures/test databases and using application's data/databases

I have a legacy application which is running on CakePHP 4.3.8. Historically this application never had any unit tests but we're starting to add them as we work on new features.
The application uses 8 different MariaDB database connections. These are configured in config/app_local.php and all have their own key. There is a corresponding "test" database for each one prefixed with test_. As an example:
dev_notification_db: Local database for a system notifications feature of the app.
test_notification_db: Test database for the above
All database names follow this convention and have appropriate credentials and connection details to a local install of MariaDB 10.x. A schema with the appropriate name has been created locally and access has been granted to the user/pass referenced in the configuration. We have not had any connection or permissions errors.
In the dev_notification_db there are around 3400 rows of data.
I'm trying to write some unit tests following CakePHP's Testing docs. In this case I want test_notification_db to contain just 3 rows of data since for a particular test I'm writing I don't need all 3400 rows from the application database and certainly don't want them in a version controlled Fixture.
I have created a Fixture in tests/Fixture/NotificationsFixture.php which contains the 3 rows I want to add to the test database, test_notification_db. This file was created using the following command:
bin/cake bake fixture --connection dev_notification_db --conditions 1=1 --count 3400 --records Notifications
When the file was created it contained all 3400 rows of data from dev_notification_db. I manually removed all but the 3 of them that were necessary for testing purposes. The reason I did it in this way is because using the command above also reads the table structure and adds it to NotificationsFixture.php, meaning the table with the same columns/data types can be created on test_notification_db.
NotificationsFixture.php looks like this
class NotificationsFixture extends TestFixture
{
public $fields = [
// This is essentially the schema of the `notifications` table
// e.g. 'id' => ['type' => 'integer', 'length' => null, 'unsigned' => true, 'null' => false, 'default' => null, 'comment' => '', 'autoIncrement' => true, 'precision' => null]
// Other columns...
];
public function init(): void
{
$this->records = [
[
'id' => 1,
'column1' => 'foo',
'column2' => 'bar',
'column3' => 'baz',
],
// Other rows of test data...
}
}
In the init() method above, $this->records contains 3 rows of data which are the ones I want to use in my fixture.
According to the Creating Fixtures section of the CakePHP docs, it says
Fixtures defines the records that will be inserted into the test database at the beginning of each test.
Therefore my understanding is that the 3 rows in $this->records should end up in the test_notification_db when I'm running my tests.
There isn't anything else in the NotificationsFixture.php file, for example something telling it to use a different Data Source from the app_local.php config file: it has $fields and the init() method, and that's all.
My test for this is in tests/TestCase/Model/Table/NotificationsTest.php. One of my tests relies on it reading the Fixture data, i.e. the 3 rows from test_notification_db. In order to do this I've added the following to my test:
protected $fixtures = [
'app.Notifications',
// ...
];
public function setUp(): void
{
parent::setUp();
$this->Notifications = TableRegistry::getTableLocator()->get('Notifications');
}
public function testGetNotifications()
{
$userNotifications = $this->Notifications->find()->where(['user_id' => 1])->count();
debug($userNotifications);
die;
}
To explain my understanding of the code above:
The $fixtures array contains app.Notifications. This is consistent to what's shown in the CakePHP docs regarding Loading Fixtures in your Test Cases. It specficially says:
After you’ve created your fixtures, you’ll want to use them in your test cases. In each test case you should load the fixtures you will need. You should load a fixture for every model that will have a query run against it.
So my understanding is I'm loading the NotificationsFixture.php file referenced earlier.
The setUp() method gives me a reference to the Model that interacts with the notifications table, i.e. src/Model/Table/NotificationsTable.php. Given that this is being run in a Test Suite - and after reviewing the docs - this should be interacting with the test database (test_notification_db) NOT the application database (dev_notification_db).
The actual test uses the model in the point above and attempts to find notifications for a given user ID. The count in this case comes back as 400. In the application database there are 400 rows for the user ID given. However, in the fixture there are only 3 rows (all of which are for the user in question). Therefore this should come back as 3, not 400.
Upon debugging the full result set it's clear that the data is being loaded from dev_notification_db and not the test database, test_notification_db.
Why is this? If the purpose of the test suite is to be able to interact with the test database and the Fixtures are - quote - "records that will be inserted into the test database at the beginning of each test" then this is certainly not doing that.
What is missing here, or making it interact with the application's database and not the separate database for testing purposes?

How to simplify this Laravel PHP code to one Eloquent query?

I assume that this should all be in one query in order to prevent duplicate data in the database. Is this correct?
How do I simplify this code into one Eloquent query?
$user = User::where( 'id', '=', $otherID )->first();
if( $user != null )
{
if( $user->requestReceived() )
accept_friend( $otherID );
else if( !$user->requestSent() )
{
$friend = new Friend;
$friend->user_1= $myID;
$friend->user_2 = $otherID;
$friend->accepted = 0;
$friend->save();
}
}
I assume that this should all be in one query in order to prevent
duplicate data in the database. Is this correct?
It's not correct. You prevent duplication by placing unique constraints on database level.
There's literally nothing you can do in php or any other language for that matter, that will prevent duplicates, if you don't have unique keys on your table(s). That's a simple fact, and if anyone tells you anything different - that person is blatantly wrong. I can explain why, but the explanation would be a lengthy one so I'll skip it.
Your code should be quite simple - just insert the data. Since it's not exactly clear how uniqueness is handled (it appears to be user_2, accepted, but there's an edge case), without a bit more data form you - it's not possible to suggest a complete solution.
You can always disregard what I wrote and try to go with suggested solutions, but they will fail miserably and you'll end up with duplicates.
I would say if there is a relationship between User and Friend you can simply employ Laravel's model relationship, such as:
$status = User::find($id)->friends()->updateOrCreate(['user_id' => $id], $attributes_to_update));
Thats what I would do to ensure that the new data is updated or a new one is created.
PS: I have used updateOrCreate() on Laravel 5.2.* only. And also it would be nice to actually do some check on user existence before updating else some errors might be thrown for null.
UPDATE
I'm not sure what to do. Could you explain a bit more what I should do? What about $attributes_to_update ?
Okay. Depending on what fields in the friends table marks the two friends, now using your example user_1 and user_2. By the example I gave, the $attributes_to_update would be (assuming otherID is the new friend's id):
$attributes_to_update = ['user_2' => otherID, 'accepted' => 0 ];
If your relationship between User and Friend is set properly, then the user_1 would already included in the insertion.
Furthermore,on this updateOrCreate function:
updateOrCreate($attributes_to_check, $attributes_to_update);
$attributes_to_check would mean those fields you want to check if they already exists before you create/update new one so if I want to ensure, the check is made when accepted is 0 then I can pass both say `['user_1' => 1, 'accepted' => 0]
Hope this is clearer now.
I'm assuming "friends" here represents a many-to-many relation between users. Apparently friend requests from one user (myID) to another (otherId).
You can represent that with Eloquent as:
class User extends Model
{
//...
public function friends()
{
return $this->belongsToMany(User::class, 'friends', 'myId', 'otherId')->withPivot('accepted');
}
}
That is, no need for Friend model.
Then, I think this is equivalent to what you want to accomplish (if not, please update with clarification):
$me = User::find($myId);
$me->friends()->syncWithoutDetaching([$otherId => ['accepted' => 0]]);
(accepted 0 or 1, according to your business logic).
This sync method prevents duplicate inserts, and updates or creates any row for the given pair of "myId - otherId". You can set any number of additional fields in the pivot table with this method.
However, I agree with #Mjh about setting unique constraints at database level as well.
For this kind of issue, First of all, you have to enjoy the code and database if you are working in laravel. For this first you create realtionship between both table friend and user in database as well as in Models . Also you have to use unique in database .
$data= array('accepted' => 0);
User::find($otherID)->friends()->updateOrCreate(['user_id', $otherID], $data));
This is query you can work with this . Also you can pass multiple condition here. Thanks
You can use firstOrCreate/ firstOrNew methods (https://laravel.com/docs/5.3/eloquent)
Example (from docs) :
// Retrieve the flight by the attributes, or create it if it doesn't exist...
$flight = App\Flight::firstOrCreate(['name' => 'Flight 10']);
// Retrieve the flight by the attributes, or instantiate a new instance...
$flight = App\Flight::firstOrNew(['name' => 'Flight 10']);
use `firstOrCreate' it will do same as you did manually.
Definition of FirstOrCreate copied from the Laravel Manual.
The firstOrCreate method will attempt to locate a database record using the given column / value pairs. If the model can not be found in the database, a record will be inserted with the given attributes.
So according to that you should try :
$user = User::where( 'id', '=', $otherID )->first();
$friend=Friend::firstOrCreate(['user_id' => $myId], ['user_2' => $otherId]);
It will check with both IDs if not exists then create record in friends table.

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?!?

Is there a way to make saveAll() remove extraneous objects?

My host object hasMany option objects associated with it. In the edit form, users can (de)select options and save that new set of associations. This is implemented using saveAll() on the posted data. The result is that
the host (main) object is updated,
option (associated) objects that are included both in the prior and the new association are updated, and
option objects that were not included in the prior association but are included in the new one are created.
But what does not happen is
that option objects that were included in the prior association but not in the new one are deleted.
Question: Can saveAll() do that as well, and how would the data structure have to look like to achieve this effect?
Related information:
My code to handle the edit form is actually more complex (hence I haven't quoted it here) but it results in the data structure as described in the book:
( [Host] => ( ... host object fields ... ),
[Option] => ( [0] => ( ... first option object fields ... ),
...
[n] => ( ... nth option object fields ... )
)
)
Now, if the original host had an associated option that is not included in the 0..n array then saveAll() won't detect this and won't delete that associated object.
Not sure if this is relevant but I am using CakePHP 1.3 .
Not really an elegant solution but works for me.
if ($this->Main->saveAll($this->data))
{
$this->Main->query(sprintf(
'DELETE '
. 'FROM extraneous '
. 'WHERE main_id = \'%s\' AND modified < (SELECT modified FROM main WHERE id = \'%1$s\')'
, mysql_real_escape_string($this->Main->id)
));
}
Note that your tables need to have a modified field.
You can ensure that everything gets executed atomically if you manually wrap everything into a transaction.
This can be done with the begin(), rollback() and commit() methods of the datasource:
$this->Main->begin();
if ( !$this->Main->save(...) ) {
$this->Main->rollback();
return false;
}
// Perform saves in related models...
if ( !$this->Main->MainRelatedModel->save(...) ) {
$this->Main->rollback();
return false;
}
// Perform deletes in extraneous records...
if ( !$this->Main->MainRelatedModel->delete(...) ) {
$this->Main->rollback();
return false;
}
// Everything went well, commit and close the transaction
$this->Main->commit();
The main disadvantage here is that transactions cannot be nested, hence you cannot use saveAll(). You have to save/delete everything step by step, instead of doing it in a single call.
saveAll() wont delete anything from your database.
I guess the best way is to delete options related to the current host before saving, and then adding them. If however, you need to update those that already exists (do you?) for some reason (like: options being related to some other models), I guess you can try to write a piece of code, that will delete unselected options.
Looking for this, I noticed there still isn't a solution built-in CakePHP. To achieve this, I added the following code to my model:
private $oldBarIds = array();
public function beforeSave($options = array() {
parent::beforeSave($options);
$this->oldBarIds = array();
if ($this->id && $this->exists() && isset($this->data['Bar'])) {
$oldBars = $this->Bar->find('all', array(
'fields' => array('id'),
'conditions' => array(
'Bar.foo_id' => $this->id
)
));
$this->oldBarIds = Hash::extract($oldBars, '{n}.id');
}
}
This checks if Bar exists in the saving data. If it does, it'll get the current id's of the current ones, setting them to $this->oldBarIds. Then when the save succeeds, it should delete the old ones:
public function afterSave($created, $options = array()) {
parent::afterSave($created, $options);
if (!$created && $this->oldBarIds) {
$this->Bar->deleteAll(array(
'Bar' => $this->oldBarIds
));
}
}
This way the deletion is handled by the model, and only occurs when the save succeeded. Should be able to add this to a behavior, might do this some day.
HABTM deletes all associated records then recreates what is needed. As PawelMysior suggests, you could achieve this with your hasMany by manually deleting the associated records immediately before the save. The danger, though, is that the save fails you lose the previous state.
I would go with a variant of GJ's solution and delete them after a successful save, but instead loop over an array of redundant IDs and use Cake's Model->del() method. This way you retain all the built-in error handling.

Using Multiple Tables in a Zend Model and returning a combined result set

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.

Categories