Magento: Proper way to create category objects - php

I am writing a module that will extract objects, such as websites, group, categories, and products from one Magento instance, serialize their properties and write everything to a text file, to be de-serialized and on another server. These properties are then used to programatically re-create these objects on the new server. The idea is that we will be able to extract all of the objects that make up a Magento web store, and move them to another server. (No, we don't want to move the whole instance to another server. We just want to be able to move a store and it's related objects.)
Obviously, since we are creating categories on a new server, their entity_id's will change. I have worked that part out, as well as making sure that sub-categories have the proper parent id. This project has been mostly straightforward until I tried to recreate category and sub-category object. I am having all manner of problems. The new category objects save t the database. However, sometimes they don't show up in the category tree, sometimes their parent_id's change to 0, sometimes the whole category tree goes away. I been working on this for about a week. I have read that you have to set the 'path' property to the path of the parent before saving. I have read that you have to use the 'move' method to set the category to be a child of it's parent. There is lots of theory, but nobody seems to have an answer.
So my question: How do you create category and sub-category records that actually work, are properly linked to their parent categories, show up in the category tree, and don't beak things?? I have the following attributes from the original source category stored in an array called $aryData().
[entity_id] => 127 //This usually changes on new server
[parent_id] => 1 //Lookup NEW entity_id of parent and use it
[path] => //Not sure how to properly set this. Tried a few things
[position] => 8 //Leave this alone, hope for the best
[children_count] => 0 //Have to zero this out when you create new category object
[name] => Best Test
[url_key] => best-test
[is_active] => 1
[include_in_menu] => 1
And here is generally what I am doing, in a simplified fashion:
$objNewCat = Mage::getModel('catalog/category'); //Create new object to populate
$parent_id = getNewParent($data['name'], $data['url-key']; //Get new parent id by name and URL key. (This works)
$objParentCat = Mage::getModel('catalog/category')->load($parent_id);
$aryData(['parent_id']) = $parent_id; //Update parent ID in data array
$aryData(['children_count']) = 0; //Must set to 0. Updated as children are added
$objNewCat->setData($data); //Set all data parameters from our save array
$objNewCat->setPath($objParentCat->getPath()); //Is this correct? Read you have to do this
$objNewCat->save(); //Save object to populate entity_id field
//--- Now assign object to be child of the parent.
$objCat = Mage::getModel('catalog/category')->load($newCat->getId()); //reloading to set 'current category'
Mage::unregister('category');
Mage::unregister('current_category');
Mage::register('category', $objCat);
Mage::register('current_category', $objCat);
$objCat->move($parent_id);
$objCat->save();
Yes, some of this code is kind of rough. It is a simplified, and I have been trying many things to get it to work. It's very frustrating. Help me Obi-Wan Knobi. Your my only hope.

Don!, I have spent many hours researching and struggling with this problem. There are lots of references to the problem on the Internet, but nobody has come up with a reliable solution, until now.
Magento uses an object model to handle all database I/O. This is supposed to insulate you from having to do tedious work, such as updating related fields. For example, if you add a new child category, the Magento object model will automatically update the 'children_count' in the parent record. The Magento object model basically lets you concern yourself with the data that you wish to write, while taking care of the housekeeping for you. However, when the object model does not do what it is supposed to, things really suck. That is the situation with the 'parent_id' field, and the 'path' field. Magento just does not update them the way it should.
The solution is to make the changes that you need to via the object model, save the object, and then FORCE the correct values for 'parent_id' and 'path' via a database query. I have found that the 'parent_id', and 'path' fields seem to be saved properly when the record is first created, but then they get messed up if the object is updated. This code presumes you are updating an existing category object.
$catId = 127; //ID of category record you wish to edit
$objCat = Mage::getModel('catalog/category')->load($catId);
if(!is_object($objCat)) {
throw new Exception("ERROR: Could not find category id: " .$catId);
}
//--- Make sure we have proper parent_id and path values
$parentId = $objCatParent->getId();
$path = $objCatParent->getPath() ."/" .$objCat->getId();
$objCatParent = $objCat->getParentCategory(); //We will need this parent category
$data = array('name' => 'My Cool Category',
'parent_id => $parentId,
'path' => $path;
$objCat->addData($data); //Use 'addData' rather than 'setData'
$objCat->save(); //Save to update record and break parent_id, path fields
//--- Now we get a connection to the database, build a query, and update record
$resource = Mage::getSingleton('core/resource');
$objCon = $resource->getConnection('core_write');
$tableName = "catalog_category_entity"; //Well, it's the name of the Category table
$query = "UPDATE {$table} SET parent_id = {$parentId}, path = '{$path}' "
. " WHERE entity_id = " .$catId;
$objCon->query($query); //Force proper values directly into table
Yes, yes, yes, you have to be very careful when you bypass the object model and make changes directly to database records. However, the Magento object model just does not seem to work here, so we have little choice. And since we are saving the record via the object's 'save()' method before it is doing whatever 'behind the scenes' work it is supposed to do. We are just correcting an error.
I hope this helps you out. This was a sticky problem. I wish there was a better solution, but when they fix the API I will stop playing with the database. And to those of you who would say that answering your own question in the third person is akward, I say "HA!"

Related

Softdelete with Yii and relations

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",
)
);

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

Create a recursive tree when source data is not organized in a hierarchy at all (PHP + SQL Server)

Despite I've checked a lot of StackOverflow questions, as well as Google, I can't find any solution for this. (any solution which I can understand and be able to do).
The status quo
I cannot alter source database (but I would if there's not any other way).
Items of the hierarchy are into separated tables. (I have not a master hierarchy table).
If I query one of those tables, I have a relation of an item and its parent, but between diferent tables, those items doesn't share its parenting.
Relation to achieve:
Country
Region
City
And so on ...
My starting point
Source data is in the following tables (depicted as table => columns)
country_table => id
region_table => id, country_id
city_table => id, region_id
... and so on ...
But when I query&join these tables, they don't share a "master" parent in any way. D'oh!
Solutions I've thought so far
Screw the "cannot alter source database" and build a proper hierarchy table (following the guides from an answer to this question: What is the most efficient/elegant way to parse a flat table into a tree?
Worst never-ever do solution (if it can be called a solution at all): Hardcode the tree hierarchy, parenting all I can in each table and then parenting table by table.Con: Unmaintenable code, the deeper the hierarchy goes, the longest the lines of code are. And if hierarchy changes you're screwed.
EDIT: Final decision (and solution)
I didn't thought about converting that array into an XML Object (using PHP's SimpleXMLElement).
Code:
function buildLocationTree()
{
//We retrieve raw data from model (Laravelish style)
$aLocations = WhateverModel::getLocations();
$oXML = new SimpleXMLElement('<xml/>');
//I add a root node called 'result'
$oResult = $oXML->addChild('result');
foreach ($aLocations as $oLocation)
{
if (! ($oXML->xpath("//country[#id=" . $oLocation->CountryId . "]")))
{
$oCountry = $oResult->addChild('country', $oLocation->CountryName);
$oCountry->addAttribute('id', $oLocation->CountryId );
}
if (! ($oXML->xpath("//region[#id=" . $oLocation->RegionId. "]")))
{
$oRegion = $oCountry->addChild('region', $oLocation->RegionName);
$oRegion->addAttribute('id', $oLocation->RegionId );
}
//And so on... build many structures 'search in nodes + add child' as deep levels
}
return $oXML;
}
Then do whatever you need with the XML object... jsonize it, build a file...
I'd be nice to have some feedback or listen your thoughts about this solution! Is an acceptable one? Risky or prone to errors?

Magento : Get data from Varien_Data_Collection

Hiho everybody! I hope you'll give me a clue about this because I'm still noob with Magento.
I try to display a list of products I get in an array. In Mage/Catalog/Block/Product/List.php, I created a new Varien_Data_Collection() in which I pushed my products objects (with ->addItem($product)).
Then I return my custom collection and List.php class does his work with it to display the list of products.
When I call the page in my browser, I had the right number of displayed products and when I click on it to see the product page, I get the right page.
However, all the data (like the product name, the price, etc) are empty. I guess that the methods used by List class to catch these data fail with my Varien_Data_Collection object.
To illustrate, here is my code sample :
// getting a particular product
$mainProduct = Mage::getModel('catalog/category')->load($currentCat->getId());
$mainProduct = $mainProduct->getProductCollection();
$mainProduct = $mainProduct->addAttributeToFilter('product_id', $_GET['cat_id']);
// creating a custom collection
$myCollection = new Varien_Data_Collection();
foreach ($mainProduct as $product) {
// getting my particular product's related products in an array
$related = $product->getRelatedProductIds();
if ($this->array_contains($related, $_GET['cat_id'])) {
// if it suits me, add it in my custom collection
$myCollection->addItem($product);
}
}
return $myCollection;
And this is what I get in my list page :
When I var_dump($myCollection), I can see that ['name'], ['price'], etc fields are not referenced. Only ['product_id'] and many other fields I don't care about.
My very ultimate question is : how can I return a collection containing these products data to my List class ? I know that it is poorly explained but my English is very limited and I try to do my best :(
Calling ->getProductCollection() against a category only returns skeleton data for each product in the created collection. If you want full data for each of the products, you need to then load them, so in your foreach loop you would have:
$product = Mage::getModel('catalog/product')->load($product->getId());
However the way in which you are building the collection is not the best working practice - you should never have to create your own Varien_Data_Collection object, instead you should be creating a product collection as follows:
$collection = Mage::getModel('catalog/product')->getCollection();
Then before you load the collecion (which the foreach loop or calling ->load() against the collection will do as 2 examples), you can filter the collection according to your requirements. You can either do this using native Magento methods, one of which you are already using (addAttributeToFilter()) or I prefer to pull the select object from the collection and apply filtering this way:
$select = $collection->getSelect();
You can then run all of the Zend_Db_Select class methods against this select object to filter the collection.
http://framework.zend.com/manual/1.12/en/zend.db.select.html
When the collection has been loaded, the products inside it will then contain full product data.
first of all pelase do not use $_GET variable, use Mage::app()->getRequest()->getParams();
second why not try to build your collection correctly from the start?
here is what your code does:
$mainProduct = Mage::getModel('catalog/category')->load($currentCat->getId());
$mainProduct = $mainProduct->getProductCollection();
$mainProduct = $mainProduct->addAttributeToFilter('product_id', $_GET['cat_id']);
get one product, I mean you load a category then load a product collection, then filter by product id.. why not:
$mainProduct = Mage::getModel('catalog/product')->load($yourSearchedId);
I aslo do not see why you filter products by $_GET['cat_id'] which looks like a category id...
To conclude you can get more help if you explain exactly what you are trying to find. It looks like you are trying to find all products that have a given product as related. So why not set for that given product the related product correctly and get the related products collection.
$_product->getRelatedProductCollection();
UPDATE:
now that you cleared your request try this:
$relatedIds = $product->getRelatedProductIds();
$myCollection = Mage::getModel('catalog/category')
->load($currentCat->getId())
->getProductCollection();
$myCollection->addAttributeToFilter('product_id', array("in",$relatedIds));
//also addAttributeToSelect all attributes you may need like name etc
$myCollection->load(); //maybe you don't actualy need to load here
Please bear in mind I did not test this code it was written from teh top of my head, test it. But I hope you got the idea.

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.

Categories