Custom Number Formatting or Decimal Separators and i18n

  1. THOUGHTS
  2. THE CODE
  3. Related Links

By default, the decimal separator in php (also in mysql) is a dot (.). So when we work with floats in Yii (in calculations, validation, sql statements etc.), the decimal separator has to be a dot. If we want to use for example a comma (,) as the decimal separator, that is if we want to display numbers and enable users to enter numbers with a comma before the decimals, we have to...

  1. format the float value before printing/echoing : handle the output : 4.45 => "4,45"
  2. transform user input into a valid float value before using in calculations etc. : handle the input : "4,45" => 4.45

This article suggests where/when and how exactly to do this handling.

In the following we'll use the model 'Product' with the decimal attribute 'price' as an example.

THOUGHTS

1. The formatting of a number
HOW

In Yii, a number can be formatted using the formatNumber() function of the class CFormatter in the following way:

Yii::app()->format->formatNumber($value);
Yii::app()->format->number($value); // is the same as above, see details to CFormatter

By default, this will format the value without decimals, and with a comma (,) between every group of thousands. To change this default behavior we have to override the numberFormat attribute of the same class, which defines the format to be used. For example:

public $numberFormat=array('decimals'=>2, 'decimalSeparator'=>',', 'thousandSeparator'=>''); 

After this, numbers will be formatted with 2 decimals, a comma (,) before the decimals and no separator between groups of thousands.

WHERE/WHEN

The question is: at what point in the application flow would it be best to format a numerical value? The answer depends...

---
If you 'always' send the price of a product directly to the output after reading it from the database, without using it in calculations, you could do the formatting in the afterFind() function of the model Product.php:

public function afterFind() {
	$this->price = Yii::app()->format->number($this->price);
	return parent::afterFind();
}

The advantage is that the formatting is done centrally in one place, and we don't need to change the views. The disatvantage is that $product->price contains the formatted number string, thus is not a valid numerical value and can not be used as such:

$product = Product::model()->findByPk($id); // ... when we select a model like this ...
echo $product->price; // "14,90" (if price stored in db is 14.90)
echo $product->price * 10; // 140 ( = "14,90" * 10) - not the expected result!

---
This wiki article suggests to do the formatting in the CHtml class, by overriding its two functions value() and resolveValue().
This seems to be a good approach. But don't forget that it does not cover the cases where CHtml is not used to generate the ouput. The following CGridView column for example will not be affected:

array(
	'name'=>'price',
	'value'=>'$data->price - $data->discount',
),

---
There doesn't seem to be a better way than to format each float value in the view file, direclty before printing/echoing it. At least none that makes sure every type of output is covered.

2. The 'unformatting' of a number
HOW

To transform the user input into a valid numerical value, we remove the thosund-seperators from the input string, and then replace the decimal-separator with a dot (.). In a way, we simply 'unformat' the number.

WHERE/WHEN

--
In this Yii extension, the 'unformatting' is done in the beforeSave() function of a model (more specifically, in the beforeSave() function of a behavior, that can be attached to any model).
This is not a good place, because the validating happens before the saving; and since the price is not a valid numerical value at that point, the validation will fail.

--
This wiki article suggests to do the 'unformatting' in the beforeValidate() function of a model.
This is a good place. But if you want to use the user input in calculations before saving the model, make sure that the price attribute is 'unformatted' beforehand, by validating the model ($product->validate()).

--
We usually assign the received user input (=POST variables) to the model before performing other operations (calculations, validaton, saving, etc.).
Therefore we sugges to do the 'unformatting' during the assignment process. This way, after one of the following lines is executed:

$product->attributes = $_POST['Product']; // or ...
$product->setAttributes(array('price'=>$_POST['Product']['price']));

$product->price will be a valid numerical value and available for further use.

THE CODE

1. The formatting of a number

Extend the CFormatter class, override its numberFormat attribute and optionally also its formatNumber() function. Add the new function unformatNumber() to the class, so it is available application wide.

For this, create the new file components/Formatter.php with the content:

class Formatter extends CFormatter
{
	/**
	 * @var array the format used to format a number with PHP number_format() function.
	 * Three elements may be specified: "decimals", "decimalSeparator" and 
	 * "thousandSeparator". They correspond to the number of digits after 
	 * the decimal point, the character displayed as the decimal point,
	 * and the thousands separator character.
	 * new: override default value: 2 decimals, a comma (,) before the decimals 
	 * and no separator between groups of thousands
	*/
	public $numberFormat=array('decimals'=>2, 'decimalSeparator'=>',', 'thousandSeparator'=>'');
		
	/**
	 * Formats the value as a number using PHP number_format() function.
	 * new: if the given $value is null/empty, return null/empty string
	 * @param mixed $value the value to be formatted
	 * @return string the formatted result
	 * @see numberFormat
	 */
	public function formatNumber($value) {
		if($value === null) return null; 	// new
		if($value === '') return '';		// new
		return number_format($value, $this->numberFormat['decimals'], $this->numberFormat['decimalSeparator'], $this->numberFormat['thousandSeparator']);
		}
		
	/*
	 * new function unformatNumber():
	 * turns the given formatted number (string) into a float
	 * @param string $formatted_number A formatted number 
	 * (usually formatted with the formatNumber() function)
	 * @return float the 'unformatted' number
	 */
	public function unformatNumber($formatted_number) {
		if($formatted_number === null) return null;
		if($formatted_number === '') return '';
		if(is_float($formatted_number)) return $formatted_number; // only 'unformat' if parameter is not float already
			
		$value = str_replace($this->numberFormat['thousandSeparator'], '', $formatted_number);
		$value = str_replace($this->numberFormat['decimalSeparator'], '.', $value);
		return (float) $value;
	}
}

Adjust main config file config/main.php to ensure this new class is used instead of the CFormatter class:

'components'=>array( 
	//...
	'format'=>array(
		'class'=>'application.components.Formatter',
	),
	//...
)

In a view file, or anywhere else for that matter, you can format a numerical value, or 'unformat' a formatted number string, simply by calling:

$formatted_number1 = Yii::app()->format->number($numerical_value); 
$formatted_number2 = Yii::app()->format->formatNumber($numerical_value);  // same as above
$numerical_value2 = Yii::app()->format->unformatNumber($formatted_number1); 

NOTE: We declared the format centrally in one file (Formatter.php), so that it can be changed easily. It can also be configured in the main config file, which will override the value declared in the class file:

'components'=>array( 
	//...
	'format'=>array(
		'class'=>'application.components.Formatter',
		'numberFormat'=>array('decimals'=>3, 'decimalSeparator'=>',', 'thousandSeparator'=>'-'),
	),
	//...
),
// ...
2. The 'unformatting' of a number

Extend the CActiveRecord class and override its function setAttributes() to add the 'unformatting' functionality.
For this, create the new file components/ActiveRecord.php with the following content:

class ActiveRecord extends CActiveRecord
{
  public function setAttributes($values,$safeOnly=true) {
    if(!is_array($values)) return;
    $attributes=array_flip($safeOnly ? $this->getSafeAttributeNames() : $this->attributeNames());
    foreach($values as $name=>$value) {
      if(isset($attributes[$name])) {
        $column = $this->getTableSchema()->getColumn($name); // new
        if (stripos($column->dbType, 'decimal') !== false) // new
          $value = Yii::app()->format->unformatNumber($value); // new
        $this->$name=$value;
      }
      else if($safeOnly)
        $this->onUnsafeAttribute($name,$value);
    }
  }
}

Derive all models from this new ActiveRecord class. For example in the Product model models/Product.php edit the following line:

class Product extends ActiveRecord 	//old version: "class Product extends CActiveRecord"

In the controller, don't use the POST variables directly, but assign them to the model before further use. If there is a POST variable that is not to be assigned to any model and that contains a formatted number string, make sure to 'unformat' it before further use. That is:

$product->attributes = $_POST['Product']; 	// This massively assigns all received values using the setAttributes() function.
$reduced_price = $product->price - 4.50;	// Correct, $product->price is valid numerical value
// DO NOT USE A POST VARIABLE DIRECTLY, SINCE IT IS NOT 'UNFORMATTED'!!
$reduced_price = $_POST['Product']['price']; // Incorrect! $_POST['Product']['price'] is not valid numerical value
// 'Unformat' a formatted number input manually if necessary:
$wish_price = Yii::app()->format->unformatNumber($_POST['wish_price']);

Related Links

That's it. If something is unclear, wrong or incomplete, please let me know. Any suggestions for improvements will be appreciated!

4 0
16 followers
Viewed: 86 479 times
Version: 1.1
Category: Tutorials
Written by: c@cba
Last updated by: c@cba
Created on: Aug 1, 2012
Last updated: 11 years ago
Update Article

Revisions

View all history

Related Articles