Benchmarking PHP – a hands on solution

Screenshot of some PHP codeSometime you will be in a situation where benchmarking is to solve a particular performance problem. You can imagine how it goes – the system performs well on an unloaded development server with a few hundred records, but once in the wild on a production server with tens of thousands of records all of a sudden you’ve got an issue.

Finding bottlenecks in code isn’t always an easy task and it’s helpful to have some way of measuring the time it takes to run a process accurately. Sure, there’s the stopwatch method but this only scales so far. Accurately benchmarking is also great for measuring performance of code where the speed isn’t currently an issue. You can benchmark a known operation, then repeat this benchmark process in the future to ensure that no unintentional degradation of performance has occurred as a result of development.

Here’s an example piece of code to help you quickly benchmark code. It supports loading multiple tests into it and simply does a var_dump of the results. Using these results in creative ways is an exercise for the reader.

<?php
 
    class Benchmark
    {
        private $results;
 
        public function __construct()
        {
            // Our benchmarking may take some time - disable the time limit
            set_time_limit( 0 );
        }
 
        private function benchmark( $test, $callback )
        {
            $start = microtime( true );
            $callback();
            $this->results[ $test ] = microtime( true ) - $start;
        }
 
        public function runTests()
        {
            // -- Test #1 ---------------------------------------
 
            $this->benchmark( 'Fetch all contacts', function() {
 
                TwSCM::model( 'broadcast/contact' )->fetchAll();
 
            } );
 
            // -- Test #2 ---------------------------------------
 
            $this->benchmark( 'Fetch objects for tag 4', function() {
 
                TwSCM::model( 'tag' )->getObjectsForTag( 4 );
 
            } );
 
            // -- Test #3 ---------------------------------------
 
            $this->benchmark( 'Count to one million', function() {
 
                for( $a = 0; $a <= 1000000; $a++ )
                {
                    // Do nothing ...
                }
 
            } );
 
            // -- Output the results ----------------------------
 
            var_dump( $this->results );    
        }
    }
 
    $benchmark = new Benchmark();
    $benchmark->runTests();
 
?>

The above code requires at least PHP 5.3 (it uses closures to run the tests and time/store the results) and currently some tests that I’ve been running to profile Turboweb’s Simple Web Manager application. The benchmark() method takes two parameters – the first is the name of the test (this is displayed when $this->results is var_dump()ed) and the second is an anonymous function containing the test to be run.

By modifying the contents of the anonymous function and running this script multiple times you will be able to test and tweak your functions to as quickly as they can.

Good hunting!

Redshift – visual help for night owl programmers

If you’re a night owl like me the first thing you do when you crank up your laptop at night is to bust down the brightness down to minimum to save yourself some eyestrain. After coming across a page showing The Best Of Linux Software I saw mention of a program called redshift. Redshift makes your screen redder by adjusting the gamma curves, and thus making your screen easier to see.

bob@bob-laptop:~$ sudo apt-get install redshift libnotify-bin

(libnotify is not required to run redshift, but is used by the script below).

The ocular science behind this is straightforward – to quote Wikipedia:

In biological night vision, molecules of rhodopsin in the rods of the eye undergo a change in shape as they absorb light. Rhodopsin is the chemical that allows night-vision, and is extremely sensitive to light. Exposed to a spectrum of light, the pigment immediately bleaches, and it takes about 30 minutes to regenerate fully, but most of the adaptation occurs within the first five or ten minutes in the dark. Rhodopsin in the human rods is less sensitive to the longer red wavelengths of light, so many people use red light to help preserve night vision as it only slowly depletes the eye’s rhodopsin stores in the rods and instead is viewed by the cones.

Anyway, the utility is simple enough to use, but requires that it be launched from the command line. I don’t mind that at all, but I don’t want the hassle of having to do that. Instead what I ended up doing was writing a wrapper script for redshift that I placed on my Gnome panel which would effectively toggle redshift on and off when I click the icon. Here’s the script:

#!/bin/bash
# Redshift toggle script
# @author: Bob Brown, gurubob@gmail.com
# @blog: http://www.guru.net.nz/
# 
# Required packages: redshift libnotify-bin
 
# Lat/Long for Dunedin, New Zealand
LAT=-45.8
LONG=170.5
NOTIFYIMAGE=/usr/share/icons/Humanity/actions/22/object-inverse.svg
 
RUNNING=$( ps aux|grep redshift|grep -v grep|grep -v redshift.sh|wc -l )
if [ $RUNNING -gt 0 ]; then
	notify-send -i $NOTIFYIMAGE "Stopping Redshift" "Redshift is shutting down"
	killall redshift
else
	notify-send -i $NOTIFYIMAGE "Starting Redshift" "Redshift has been started with your location as per $0"
	/usr/bin/redshift -l $LAT:$LONG &
fi

You can see the script is straightforward and requires a little configuration – redshift requires your latitude and longitude as it adjusts the redshift of your screen based on the time of day, swinging between maximum redness at midnight and no redness during daylight hours (the website claims this, but I have not yet seen it as I’ve been running redshift for about an hour so far and it’s 12:13am). You can use this interactive map to find your latitude and longitude.

The shift to red is dramatic at first, especially going from the cool blue-white of maximum screen output but after a few minutes I find myself hardly noticing it at all. It will be interesting to see whether redshift finds a permanent place on my laptop.

By the way, there is a GTK front-end for redshift too and it works just as well. It allows you to toggle the application by right clicking the red lightbulb icon in the notification area of the gnome panel. Simply install it like so:

bob@bob-laptop:~$ sudo apt-get install gtk-redshift

and then place it into your startup applications in Gnome:

  1. System, Preferences, Startup Applications
  2. Click Add
  3. Enter “Redshift” for the name
  4. Enter /usr/bin/gtk-redshift -l YOURLAT:YOURLONG for the command (my command is /usr/bin/gtk-redshift -l -45:170, roughly)
  5. Click Add
    1. Presumably now redshift will start when you log in.

Magento 1.4.2.0 Onepage Checkout Save Shipping Address Not Working

One of our clients noticed that checking the save shipping address in the magento checkout does not save that address to the customeres addresses.
After some investigation I can confirm this is a 1.4.2.0 bug, but have not seen any solutions on the web, so here is my fix:

You should override the App/Code/Core/Mage/Checkout/Model/Type/Onepage.php class in the usual manner, and override the saveShipping method, to be like:

public function saveShipping($data, $customerAddressId)
{
    if (empty($data)) {
        return array('error' => -1, 'message' => $this->_helper->__('Invalid data.'));
    }
    $address = $this->getQuote()->getShippingAddress();
 
    if (!empty($customerAddressId)) {
       $customerAddress = Mage::getModel('customer/address')->load($customerAddressId);
        if ($customerAddress->getId()) {
             if ($customerAddress->getCustomerId() != $this->getQuote()->getCustomerId()) {
                 return array('error' => 1,
                     'message' => $this->_helper->__('Customer Address is not valid.')
                 );
             }
             $address->importCustomerAddress($customerAddress);
        }
    } else {
        /* @var $addressForm Mage_Customer_Model_Form */
        $addressForm    = Mage::getModel('customer/form');
        $addressForm->setFormCode('customer_address_edit')
            ->setEntity($address)
            ->setEntityType('customer_address')
            ->setIsAjaxRequest(Mage::app()->getRequest()->isAjax());
        // emulate request object
        $addressData    = $addressForm->extractData($addressForm->prepareRequest($data));
        $addressErrors  = $addressForm->validateData($addressData);
        if ($addressErrors !== true) {
            return array('error' => 1, 'message' => $addressErrors);
        }
        $addressForm->compactData($addressData);
         //the next 3 lines are the fix!!!
        if (!empty($data['save_in_address_book'])) {
             $address->setSaveInAddressBook(1);
        }
    }
 
    $address->implodeStreetAddress();
    $address->setCollectShippingRates(true);
 
    if (($validateRes = $address->validate())!==true) {
        return array('error' => 1, 'message' => $validateRes);
    }
 
     $this->getQuote()->collectTotals()->save();
 
    $this->getCheckout()
        ->setStepData('shipping', 'complete', true)
        ->setStepData('shipping_method', 'allow', true);
 
    return array();
}

This now follows a similar logic to the saveBilling address, setting a flag to save the address when the order is saved.

Magento: Default Product Listing Sort by not exists on Available Product Listing Sort By

We have a system that populates a Magento installation by reading an external database and creating, updating and deleting products and categories. Recently I had been receiving an obscure message when trying to update categories using the Magento API.

The message was simply: Default Product Listing Sort by not exists on Available Product Listing Sort By

After Googling around I hadn’t turned up much, which forced me into the bowels of Magento. This exception is thrown in the file app/code/core/Mage/Model/Category/Attribute/Backend/Sortby.php on line 81 and 85. This file is responsible for checking that the Sort By method specified for a category is valid.

Default Display Settings for a new cateogry

Default Display Settings for a new cateogry

When you create a category in Magento using the Admin UI, the values in the Display Settings tab of the category as set as per this screenshot (click to enlarge).  The default values are:

  • Available Product Listing Sort By = Use All Available Attributes
  • Default Product Listing Sort By = Use Config Settings

With this category created, I used the following test script to check to see if I could update the category.  This script simply asks for the information about the category (category 4066 in my case) and attempts to update the category with that information.

<?php
 	define('MAGENTO', getcwd() );
 	define('CATEGORY_ID', 4066 );
	require_once MAGENTO . '/app/Mage.php';
	umask(0);
	Mage::app()->setCurrentStore(Mage_Core_Model_App::ADMIN_STORE_ID);
 
	try
	{
		$info = Mage::getModel('catalog/category_api')->info( CATEGORY_ID );
		var_dump( $info );
		Mage::getModel('catalog/category_api')->update( CATEGORY_ID, $info );
 
		echo "\n\nSUCCESS\n\n";
	}
	catch( Exception $e )
	{
		var_dump( $e );
	}
?>

When I ran this with the default settings, I received a “data_invalid” exception, however tweaking it to use any of the values in the select box for the Default Product Listing Sort By resulted in the exception “Default Product Listing Sort by not exists on Available Product Listing Sort By” (tip: it was useful to pipe the output of this script to `less’). I then had a test script that I could use to verify when my problem was fixed.

I then fiddled around with the display settings in Magento (which by the way does not suffer from this problem when saving a category) and found that if I set the Available Product Listing Sort By to anything other than using the tick to use all available attributes, and set the Default Product Listing Sort By to anything other than using the tick to use the Config Settings, then things magically started working.

This eventually led to me reworking a bit of code to include specific values for the available_sort_by and default_sort_by attributes when updating the category. Here’s my bit of code – out of context, but you get the idea.

    $result = $api->update
    (
        $category_id,
        array
        (
            'image' => $image_path,
            'description' => $description,
            'meta_description' => $meta_description,
            'meta_keywords' => $meta_keywords,
            'available_sort_by' => array( 'name' ),
            'default_sort_by' => 'name'
        )
    );

Good luck!

Magento: loadParentProductIds() is deprecated

As of Magento 1.4.2.0, the loadParentProductIds() method in the product model has been deprecated and is no longer available.

Here’s an example of it being used:
http://www.magentocommerce.com/boards/viewthread/74229

Prior to Magento 1.4.2.0, this would have worked:

list( $parentId ) = $_product->loadParentProductIds()->getData('parent_product_ids');

After Magento 1.4.2.0, this works:

list( $parentId ) = Mage::getModel( 'catalog/product_type_grouped' )->getParentIdsByChild( $_product->getId() );

Note that the above fetched the parent product ID’s for a grouped product – there are also versions for configurable products in the catalog/product_type_configurable model.

Good luck!

Creating a Magento admin fully editable grid

Recently I created a custom module for a client, that required a change to the standard UI for the backend data. The standard UI creates a grid of objects (in my case freight weight and size classes) with a button to create a new one, and action column in the grid to let you edit or delete once that item is loaded.

Having a large number of simple and related values, we changed to a new custom UI, with an editable grid where the last row in the grid is to create a new entry, e.g.:

Custom editable grid

Custom editable grid

Our other requirement was that more than one of these tables could be shown in the same tab container (in our example one called weight and one called size).

Overview:

To achieve this result, we need to create the following file structure for our custom module:

/app/code/local/Namespace/Freight/

|– Block

| `– Adminhtml

|     |– Axis.php

|     `– Freight

|         `– Axis

|             |– Axis.php

|             |– Form.php

|             |– Tabs.php

|             |– Xgrid.php

|             `– Ygrid.php

|– controllers

| |– Adminhtml

| | `– FreightController.php

| `– AxisController.php

|– etc

| |– config.xml

| `– system.xml

|– Helper

| `– Data.php

|– Model

| |– Axis.php

| |– Mysql4

| | |– Axis

| | | `– Collection.php

| | `– Axis.php

| `– Shipping

|     `– Freight.php

|– sql

| `– freight_setup

|     `– mysql4-install-0.1.0.php

SQL

There is nothing special about the setup at all, in our example we assign a few fairly standard columns. (app/code/local//Freight):

<?php
$installer = $this; $installer->
startSetup();
$installer->run("
DROP TABLE IF EXISTS {$this->getTable('freight_axis')};
CREATE TABLE {$this->getTable('freight_axis')} (
`axis_id` int(11) NOT NULL auto_increment,
`name`   varchar(100) collate latin1_general_cs default NULL,
`desc`   varchar(500) collate latin1_general_cs default NULL,
`value`   decimal(15,5) default NULL,
`priority` int(11) NOT NULL,
`type`   varchar(100) collate latin1_general_cs default NULL,
`status` smallint(6) NOT NULL default '0',
PRIMARY KEY  (`axis_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_general_cs;
 
");
$installer->endSetup();

Models

The models are also very standard.

Model/Axis.php

<?php
 
class Turboweb_Freight_Model_Axis extends Mage_Core_Model_Abstract
{
    public function _construct()
    {
        parent::_construct();
        $this->_init('freight/axis');
    }
}

Model/Mysql4/Axis.php

<?php
 
class Turboweb_Freight_Model_Mysql4_Axis extends Mage_Core_Model_Mysql4_Abstract
{
    public function _construct()
    {
        $this->_init('freight/axis', 'axis_id');
    }
}

Model/Mysql4/Axis/Collection.php

<?php
class Turboweb_Freight_Model_Mysql4_Axis_Collection extends Mage_Core_Model_Mysql4_Collection_Abstract
{
    public function _construct()
    {
        //parent::__construct();
        $this->_init('freight/axis', 'freight/axis');
    }
    public function getSize()
    {
        $this->load();
        $this->_totalRecords = count($this->getItems());
        return intval($this->_totalRecords);
    }
}

The getSize() method is required as we add an item in our block, but there is a bug where the total number of items does not change.

Helpers

Again, no difference here.

Helper/Data.php

<?php
class Turboweb_Freight_Helper_Data extends Mage_Core_Helper_Abstract
{
 
}

Blocks

Here is where things get interesting. The default UI uses the following layout hierarchy:

Widget_Grid_Container - Widget_Grid

Then when edit / create is clicked:

Widget_Form_Container – Widget_Form & Widget_Tabs – and finally the individual Widget_Form classes for each tab.

The layout we want is very different:

Widget_Form_Container – Widget_Tabs -> ajax call -> widget_grid (for each tab)

Lets look at them in turn:

Block/Adminhtml/Axis.php

<?php   class Turboweb_Freight_Block_Adminhtml_Axis extends Mage_Adminhtml_Block_Widget_Form_Container\r\n {     public function __construct()     {         parent::__construct();                         $this->_objectId = 'id';
        $this->_blockGroup = 'freight';
        $this->_controller = 'adminhtml_freight';
        $this->_mode = 'axis';
 
        $this->_updateButton('save', 'label', Mage::helper('freight')->__('Save'));
        $this->_updateButton('save', 'onclick', 'var a = freight_tabsJsTabs.activeTab.id;
                  if(a == \'freight_tabs_x_section\')editForm.submit(\''.$this->getUrl('*/*/savexaxis').'\');
                  else editForm.submit(\''.$this->getUrl('*/*/saveyaxis').'\');');
        $this->_addButton('delete', array(
            'label'     => Mage::helper('adminhtml')->__('Delete Checked'),
            'class'     => 'delete',
            'onclick'   => 'if(confirm(\''. Mage::helper('adminhtml')->__('Are you sure you want to do this?')
                .'\')){
                 var a = freight_tabsJsTabs.activeTab.id;
                  if(a == \'freight_tabs_x_section\')editForm.submit(\''.$this->getUrl('*/*/deletexaxis').'\');
                  else editForm.submit(\''.$this->getUrl('*/*/deleteyaxis').'\');}',
        ));
        $this->removeButton('back');
    }
 
    public function getHeaderText()
    {
        return Mage::helper('freight')->__('Freight Classes Configuration');
    }
 
}

Here I add code to switch which controller handles the save function, remove the back button, and add a delete button.

Block/Adminhtml/Freight/Axis/Xgrid.php

<?php class Turboweb_Freight_Block_Adminhtml_Freight_Axis_Xgrid extends Mage_Adminhtml_Block_Widget_Grid {     public function __construct()     {         parent::__construct();         $this->setId('x');
        // This is the primary key of the database
        $this->setDefaultSort('table_id');
        $this->setDefaultDir('ASC');
        $this->setSaveParametersInSession(true);
        $this->setSkipGenerateContent(true);
        $this->setUseAjax(true);
        $this->setPagerVisibility(false);
        $this->setFilterVisibility(false);
    }
 
    protected function _prepareCollection()
    {
        $collection = Mage::getModel('freight/axis')->getCollection();
        $collection->addFieldToFilter('type',array('eq'=>'x'));
        $collection->setOrder('priority', 'ASC');
        $collection->load();
        $axis = Mage::getModel('freight/axis');
        $axis->setId(0);
        $collection->addItem($axis);
        $this->setCollection($collection);
        return parent::_prepareCollection();
    }
 
    protected function _prepareColumns()
    {
        $column = $this->addColumn('sizes_select', array(
            'align'     =>'left',
            'width'     => '20px',
            'field_name' => 'sizes_select',
            'values'    => $this->getSelectedSizes(),
            'index'     => 'axis_id',
            'type'		=> 'checkbox',
        ));
        $this->addColumn('xname[]', array(
            'header'    => Mage::helper('freight')->__('Name'),
            'align'     =>'left',
            'width'     => '20px',
            'index'     => 'name',
            'type'		=> 'input',
            'name'		=> 'xname[]'
        ));
        $this->addColumn('xdescription[]', array(
            'header'    => Mage::helper('freight')->__('Description'),
            'align'     =>'left',
            'width'     => '200px',
            'index'     => 'desc',
            'type'		=> 'input',
            'name'		=> 'xdescription[]'
        ));
        $this->addColumn('xvalue[]', array(
            'header'    => Mage::helper('freight')->__('Minimum Value'),
            'align'     =>'left',
            'width'     => '20px',
            'index'     => 'value',
            'type'		=> 'input',
            'name'		=> 'xvalue[]'
        ));
        $this->addColumn('xstatus[]', array(
            'header'    => Mage::helper('freight')->__('Status'),
            'align'     =>'left',
            'width'     => '50px',
            'index'     => 'status',
			'type'      => 'select',
			'options'   => array(
			    1 => Mage::helper('freight')->__('Enabled'),
			    2 => Mage::helper('freight')->__('Disabled'),
		    ),
            'editable'	=> true,
            'name'		=> 'xstatus[]'
        ));
        $this->addColumn('xpriority[]', array(
            'header'    => Mage::helper('freight')->__('Order'),
            'align'     =>'left',
            'width'     => '20px',
            'index'     => 'priority',
            'type'		=> 'input',
            'name'		=> 'xpriority[]'
        ));
    }
    public function getSelectedSizes()
    {
        $sizes = $this->getSizes();
        if (!is_array($sizes)) {
            $sizes = $this->getSelectedSizesGroup();
        }
        return $sizes;
    }
    public function getSelectedSizesGroup()
    {
    	$sizes = array();
        $collection = Mage::getModel('freight/axis')->getCollection();
        $collection->addFieldToFilter('type',array('eq'=>'x'));
        $collection->setOrder('priority', 'ASC');
        $collection->load();
 
        foreach($collection as $sizeObj)
        {
        	$sizes[] = $sizeObj->getId();
        }
        //add create new
        $sizes[] = 0;
    	return $sizes;
    }
    public function getRowUrl($row)
    {
        return '';
    }
    public function getRowClass(Varien_Object $row) {
        return $row->getId() ? '' : 'new';
    }
	public function setSizesGroup()
	{
		return $this;
	}
    public function getGridUrl()
    {
        return $this->_getData('grid_url') ? $this->_getData('grid_url') : $this->getUrl('*/*/sizesGridOnly', array('_current'=>true));
    }
}

Important to note here is the addItem() call in the _prepareCollection() method. This becomes our create new row.

To make this row distinguishable we override the getRowClass() to give a class of new if no ID is set!

The getSelectedSizes intialises our serialised grid, so when we submit we know what is checked and what is not.

The only other thing to note is the inclusion of the name (eg ‘name’ = ‘xname[]‘) variable in the add column fields. This creates a useable array of data.

Block/Adminhtml/Freight/Axis/Tabs

<?php   class Turboweb_Freight_Block_Adminhtml_Freight_Axis_Tabs extends Mage_Adminhtml_Block_Widget_Tabs {       public function __construct()     {         parent::__construct();         $this->setId('freight_tabs');
        $this->setDestElementId('edit_form');
        $this->setTitle(Mage::helper('freight')->__('Class'));
    }
 
    protected function _beforeToHtml()
    {
	    $this->addTab('x_section', array(
	        'label'     => Mage::helper('freight')->__('Size'),
	        'url'       => $this->getUrl('*/*/sizeGrid', array('_current'=>true)),
	        'class'     => 'ajax',
	    ));
	    $this->addTab('y_section', array(
	        'label'     => Mage::helper('freight')->__('Weight'),
	        'url'       => $this->getUrl('*/*/weightGrid', array('_current'=>true)),
	        'class'     => 'ajax',
	    ));
        return parent::_beforeToHtml();
    }
}

This is where we set the tabs to call the ajax requests.

Layout

To plumb this all together the admin layout is configured to know about the 2 ajax grids, while the controller handles including the tabs.

<?xml version="1.0"?>
<layout version="0.1.0">
    <freight_adminhtml_freight_axis>
        <reference name="content">
        	<block type="freight/adminhtml_axis" name="xformcontainer"/>
        	<!-- <block type="freight/adminhtml_xaxis" name="xformcontainer"/> -->
            <!-- <block type="freight/adminhtml_yaxis" name="yformcontainer"/> -->
        </reference>
    </freight_adminhtml_freight_axis>
    <freight_adminhtml_freight_sizegrid>
        <block type="core/text_list" name="root" output="toHtml">
            <block type="freight/adminhtml_freight_axis_xgrid" name="freight/adminhtml_freight_axis_xgrid" />
            <block type="adminhtml/widget_grid_serializer" name="freight_grid_x_serializer">
                <reference name="freight_grid_x_serializer">
                    <action method="initSerializerBlock">
                        <grid_block_name>freight/adminhtml_freight_axis_xgrid</grid_block_name>
                        <data_callback>getSelectedSizes</data_callback>
                        <hidden_input_name>selectedSizes</hidden_input_name>
                        <reload_param_name>freight_sizes</reload_param_name>
                    </action>
                </reference>
            </block>
        </block>
    </freight_adminhtml_freight_sizegrid>
    <freight_adminhtml_freight_sizegridonly>
        <block type="core/text_list" name="root">
            <block type="freight/adminhtml_freight_axis_xgrid" name="freight/adminhtml_freight_axis_xgrid" />
        </block>
    </freight_adminhtml_freight_sizegridonly>
    <freight_adminhtml_freight_weightgrid>
        <block type="core/text_list" name="root" output="toHtml">
            <block type="freight/adminhtml_freight_axis_ygrid" name="freight/adminhtml_freight_axis_ygrid" />
            <block type="adminhtml/widget_grid_serializer" name="freight_grid_y_serializer">
                <reference name="freight_grid_y_serializer">
                    <action method="initSerializerBlock">
                        <grid_block_name>freight/adminhtml_freight_axis_ygrid</grid_block_name>
                        <data_callback>getSelectedWeights</data_callback>
                        <hidden_input_name>selectedWeights</hidden_input_name>
                        <reload_param_name>freight_weights</reload_param_name>
                    </action>
                </reference>
            </block>
        </block>
    </freight_adminhtml_freight_weightgrid>
    <freight_adminhtml_freight_weightgridonly>
        <block type="core/text_list" name="root">
            <block type="freight/adminhtml_freight_axis_ygrid" name="freight/adminhtml_freight_axis_ygrid" />
        </block>
    </freight_adminhtml_freight_weightgridonly>
</layout>

The serialiser is set up for the weight and size grids here.

Controller

This is where the magic happens. Basically we need 2 separate save methods, 4 methods for the ajax calls (the whole table, and when filtering/sorting the grid a grid only variation).

<?php class Turboweb_Freight_Adminhtml_FreightController extends Mage_Adminhtml_Controller_action {       protected function _initAction()     {         $this->loadLayout()
            ->_setActiveMenu('freight/freight')
            ->_addBreadcrumb(Mage::helper('adminhtml')->__('Freight'), Mage::helper('adminhtml')->__('Freight'));
        return $this;
    }
 
    public function axisAction() {
        $this->_initAction();
        $this->_addLeft($this->getLayout()->createBlock('freight/adminhtml_freight_axis_tabs'));
        $this->renderLayout();
    }
    public function savexaxisAction()
    {
    	try {
			$postData = $this->getRequest()->getPost();
			//data is arranged in priority order
		    $collection = Mage::getModel('freight/axis')->getCollection();
		    $collection->addFieldToFilter('type',array('eq'=>'x'));
		    $collection->setOrder('priority', 'ASC');
		    $collection->load();
			$i = 0;
			foreach($collection as $id => $size)
			{
				$size->setName($postData['xname'][$i])
						->setDesc($postData['xdescription'][$i])
						->setValue($postData['xvalue'][$i])
						->setPriority($postData['xpriority'][$i])
						->setStatus($postData['xstatus'][$i])
						->save();
				$i ++;
			}
			//check if create new
			if($postData['xname'][$i] != "" &amp;&amp; $postData['xvalue'][$i] != "" &amp;&amp; $postData['xpriority'][$i] != "")
			{
				$size = Mage::getModel('freight/axis');
				$size->setName($postData['xname'][$i])
						->setDesc($postData['xdescription'][$i])
						->setValue($postData['xvalue'][$i])
						->setPriority($postData['xpriority'][$i])
						->setType('x')
						->setStatus($postData['xstatus'][$i])
						->save();
			}
            Mage::getSingleton('adminhtml/session')->addSuccess(Mage::helper('adminhtml')->__('Axis was successfully saved'));
        } catch (Exception $e) {
            Mage::getSingleton('adminhtml/session')->addError($e->getMessage());
            $this->_redirect('*/*/axis');
            return;
        }
        $this->_redirect('*/*/axis');
    }
    public function saveyaxisAction()
    {
    	try {
 
			$postData = $this->getRequest()->getPost();
			//data is arranged in priority order
		    $collection = Mage::getModel('freight/axis')->getCollection();
		    $collection->addFieldToFilter('type',array('eq'=>'y'));
		    $collection->setOrder('priority', 'ASC');
		    $collection->load();
			$i = 0;
			foreach($collection as $id => $weight)
			{
				$weight->setName($postData['yname'][$i])
						->setDesc($postData['ydescription'][$i])
						->setValue($postData['yvalue'][$i])
						->setPriority($postData['ypriority'][$i])
						->setStatus($postData['ystatus'][$i])
						->save();
				$i ++;
			}
			//check if create new
			if($postData['yname'][$i] != "" &amp;&amp; $postData['yvalue'][$i] != "" &amp;&amp; $postData['ypriority'][$i] != "")
			{
				$weight = Mage::getModel('freight/axis');
				$weight->setName($postData['yname'][$i])
						->setDesc($postData['ydescription'][$i])
						->setValue($postData['yvalue'][$i])
						->setPriority($postData['ypriority'][$i])
						->setType('y')
						->setStatus($postData['ystatus'][$i])
						->save();
			}
            Mage::getSingleton('adminhtml/session')->addSuccess(Mage::helper('adminhtml')->__('Axis was successfully saved'));
        } catch (Exception $e) {
            Mage::getSingleton('adminhtml/session')->addError($e->getMessage());
            Mage::getSingleton('adminhtml/session')->setRecruitmentData($this->getRequest()->getPost());
            $this->_redirect('*/*/axis');
            return;
        }
        $this->_redirect('*/*/axis');
    }
    public function deletexaxisAction()
    {
        try {
	    	$postDataIds = explode('&amp;',$this->getRequest()->getPost('selectedSizes'));
			foreach($postDataIds as $id)
			{
				//only delete non zeros
				if($id)
				{
					Mage::getModel('freight/axis')->setId($id)->delete();
				}
			}
		    Mage::getSingleton('adminhtml/session')->addSuccess(Mage::helper('adminhtml')->__('Size classes were successfully deleted'));
		    $this->_redirect('*/*/axis');
        } catch (Exception $e) {
            Mage::getSingleton('adminhtml/session')->addError($e->getMessage());
            $this->_redirect('*/*/axis');
        }
    }
    public function deleteyaxisAction()
    {
        try {
	    	$postDataIds = explode('&amp;',$this->getRequest()->getPost('selectedWeights'));
			foreach($postDataIds as $id)
			{
				//only delete non zeros
				if($id)
				{
					Mage::getModel('freight/axis')->setId($id)->delete();
				}
			}
		    Mage::getSingleton('adminhtml/session')->addSuccess(Mage::helper('adminhtml')->__('Weight Classes were successfully deleted'));
		    $this->_redirect('*/*/axis');
        } catch (Exception $e) {
            Mage::getSingleton('adminhtml/session')->addError($e->getMessage());
            $this->_redirect('*/*/axis');
        }
    }
    public function sizeGridAction()
    {
        $this->loadLayout();
        $this->getLayout()->getBlock('freight/adminhtml_freight_axis_xgrid')
            ->setSizesGroup($this->getRequest()->getPost('products_grouped', null));
        $this->renderLayout();
    }
    public function sizeGridOnlyAction()
    {
        $this->loadLayout();
        $this->getLayout()->getBlock('freight/adminhtml_freight_axis_xgrid')
            ->setSizesGroup($this->getRequest()->getPost('products_grouped', null));
        $this->renderLayout();
    }
    public function weightGridAction()
    {
        $this->loadLayout();
        $this->getLayout()->getBlock('freight/adminhtml_freight_axis_ygrid')
            ->setWeightsGroup($this->getRequest()->getPost('products_grouped', null));
        $this->renderLayout();
    }
    public function weightGridOnlyAction()
    {
        $this->loadLayout();
        $this->getLayout()->getBlock('freight/adminhtml_freight_axis_ygrid')
            ->setWeightsGroup($this->getRequest()->getPost('products_grouped', null));
        $this->renderLayout();
    }
}

Conclusions

During the development of this UI, I was constantly challenged and often ran into dead ends. I would recommend using this concept of serialised ajax calls, and remind anyone of the fix for the total records when adding an item to the collection.

WordPress Themes