New blog : www.erreur500.com

Vous pouvez désormais nous suivre sur notre nouveau blog : Erreur 500, en français, avec toujours des astuces de dev pour Symfony, javascript, Git, admin système etc.

You can now follow us on our new blog : Erreur 500, in french, but still with tips on Symfony dev, javascript, Git, system admin and so on.

Publicités

Symfony, MAMP, PhpMyAdmin, password changed…

I use MAMP to run all the things needed to develop web sites with symfony (php based framework). Even if I am not 100% satisfied, I did not find any other solution and I must admit it is pretty simple to install. I won’t come back with installation process and symfony compatibility, there are several good posts, the best one being this one (according to me !)

But even if this post, I was unable to set up properly virtual hosts, which is unconvenient but doesn’t prevent you from using MAMP. I relocated my web projects in a different directory from the one used by mamp (the htdocs directory). This is easy to do, you just have to go to « preferences » (when you click on the MAMP icon) then « apache » and upload the root directory.

But then I changed the sql password… and I was unabled to access to PhpMyAdmin any more !! I had a message error of the following type : « sql error, cannot find sql server on localhost » or something like that… So after hours of searches on the net and in my brain, I finally solved this problem. You just have two things to change.

First, give MAMP the new password you set up for your database.
Go to /MAMP/bin/MAMP/index.php and change line 15 to this :

//.../MAMP/bin/MAMP/index.php
$link = @mysql_connect(':/Applications/MAMP/tmp/mysql/mysql.sock', 'root', 'myNewPassword');

Then, you will still be unable to connect to phpMyAdmin, so you have another thing to do, its to change the authentification setting from ‘config’ to cookie.
For this, go to /MAMP/bin/PhpMyAdmin/config.inc.php on line 84 and change the line to this :

//.../MAMP/bin/PhpMyAdmin/config.inc.php
$cfg['Servers'][$i]['auth_type']     = 'cookie';    // Authentication method (config, http or cookie based)?

Now you will be asked to enter by hand your db name and password, and this it, you can acces to PhpMyAdmin !!!!

By the way, I faced another problem and couldn’t find the solution on the net so it might be useful for some one facing the same issue. When doing a « build-all-reload » on symfony, I suddenly had the following error message : « Couldn’t locate driver named mysql ». After several tries, I ended up solving the problem. For a reason I still ignore, the symbolic links that you create when installing MAMP for symfony (to replace by default MAC apache and sql servers) were no more existing !! So just recreate them by typing the following lines in the terminal and every thing should work well :

sudo mv /usr/bin/php /usr/bin/php-old
sudo ln -s /Applications/MAMP/bin/php5/bin/php /usr/bin/php
sudo mv /usr/bin/pear /usr/bin/pear-old
sudo ln -s /Applications/MAMP/bin/php5/bin/pear /usr/bin/pear
sudo mv /usr/bin/mysql /usr/bin/mysql-old
sudo ln -s /Applications/MAMP/Library/bin/mysql /usr/bin/mysql

I stil have trouble installing Xdebug but I will tell you as soon as it will be solved ! Hope it helped…

Quick Doctrine NestedSet reference

I discovered Doctrine’s NestedSet behavior this week. I wasn’t able to find any resource on the Internet which would summarize how to create schema and fixture all together. So let me share with you this small reference article, maybe it will spare you some minutes.

First on your schema.yml

# let's say you got an Item table, with some subitems
Item:
  actAs:
    NestedSet:
      hasManyRoots: true # it means there can be several "main" items. If set to false, there is one main item, and all others subitems are bounded to this first one
      rootColumnName: item_id # Speaks for itself, that's where the relation between items will be stored
  columns:
    name: { type: string } # let's give our item a name
# You can leave it like this, but I added some relations, so i can fetch one item's subitems easily
    item_id: { type: integer, default: null }
  relations:
    Item:
      local: item_id
      foreign: id
      foreignAlias: Subitems

This schema will build your model with 3 extra fields : rgt, lft and level. Level is 0 if the record is root. Rgt and Lft are filled according to this behavior : Nested Set Model.

Now we got this, let’s create some fixtures :

Item:
  item_1:
    name: First item
    children: [] # It means for now, it has no subitems. You have to specify it, otherwise you're doctrine:build-all commands will fail
  item_2:
    name: Second item
    children:
      item_2a:
        name: Second item, First subitem
      item_2b:
        name: Second item, Second subitem
        children:
          ...

So now, instead of calling for example a foreach($item->getSubitems() as $subitem) which will occur in an extra query to the database if no Subitems exist (even if you have leftJoined your relations), you can test : if($item->getRgt() – $item->getLft() > 1). If the difference is greater than one, the current $item got subitems children.

Hope it will help.

Pre-fill a form in the backend with symfony

Pre-fill a form in Symfony

Hi all,

I hope I didn’t miss anything evident since my issue really sounds easy at first sight… What is my problem ? I just want to re-use in the backend objects that already exist to pre-fill some forms.

Let’s illustrate the situation with a concrete exemple before jumping into the code. A shop sells more than 100 natural and organic personal beauty products based on lets say code (butter, face-cream…). Each reference is a complicated and somehow granny-accross-centuries-receipe secret that is made of more than 30 ingredients… plus a pink-tainting product to reach girls. This shop is so successful that the owner has opened a new one just one block away. It sells the exact same range of products, he just replaced the pink-tainting color by a blue-tainting because he now wants to target a male audience. In his backend, he will have to « create » the new shop and then for each one of the 100 product to populate the ingredients list with the 30 identical ingredients, only replacing the pink with the blue tainting-product. How tedious !!! What he’d like to do is create his new shop duplicating the previous products and kind of « crediting » (mix of creating and editing) the object, replacing only one field (the tainting product).

It’s just weird that this basic functionnality took me so many time to build. Did I miss a point in my symfony learning ?? So here is how I solved the problem…

  1. Create a component to display a productToDuplicate field above the form
  2. Add a hint of AJAX to autocomplete your search field
  3. Add a PopulateProductForm action to create a pre-filled form
  4. Write a PopulateProductFormSuccess to display pre-filled form
  5. Add a CreatePopulated action to save pre-filled form as a new Object

Create a component to display a productToDuplicate field above the classic shop form

Why creating a component and not just a simple partial that you would call in the generator.yml file of your module ? Well, we need an action to create the search widget and then a template to display it above the form. Seems to match components functions…

Here is the action to write :

//apps/backend/modules/myModule/actions/components.class.php
public function executeProductToDuplicateFinder()
  {
  	$this->productForm = new sfForm();
  	$this->productForm->setWidgets(array(
	  	'duplicated_product' => new sfWidgetFormJQueryAutocompleter(
	      array(
	        'url'    => '/backend.php/product/findProduct',
	        'config' => '{ scrollHeight: 250 ,
	                       autoFill: true ,
	                       cacheLength: 1 ,
	                       delay: 50,
	                       matchSubset: false}',
	        'label' => 'Duplicate a product :'
	      ),
	      array(
	      'size'=> '70'
	      )
      )));
  }

If you want more information on the sfWidgetFormJQueryAutocompleter, just go and see this previous article about autocomplete widget

Here is the component’s template associated. Notice that you passed the productForm to the template so you can « echo » it with all the methods symfony offers you.

//apps/backend/modules/myModule/template/_productToDuplicateFinder
<?php use_helper('Form');?>
<!-- Display an autocomplete input text to find a product and duplicate it -->
<div class="sf_admin_form">
  <!-- helper indicating to call populateProductForm action, dont forget to close it... -->
  <?php echo $productForm->renderFormTag('populateProductForm')?>
	  <table>
	    <tr>
	      <th>
	      <?php echo $productForm['duplicated_product']->renderLabel();?>
	      </th>
	      <td>
	        <?php echo $productForm['duplicated_product']->renderError();?>
	        <?php echo $productForm['duplicated_product']->render();?>
	        <?php echo submit_image_tag('myFolder/tick.png'); ?>
	      </td>
	    </tr>
	  </table>
  </form>
</div>

Make sure you closed the tag that you implicitely opened with the renderFormTag method…

Add a hint of AJAX to autocomplete your search field

I will go quickly through this part since it is not the heart of the article. In a few words, you have to write an action that will first

  • Write a method that will match the objects of ProductTable with the ‘q’ string parameter
  • Put all the results you got into a result table (use a foreach loop)
  • Return your Json encoded result
    return $this->renderText(json_encode($results));

Add a PopulateProductForm action to create a pre-filled form

This is the action we called when clicking on our ‘submit’ button in the product finder. So what do we want to do ? We want to display a product form, I should even say a new product form, but pre-filled with the properties of the selected existing product. That’s easy :

//apps/backend/myModule/actions/actions.class.php
public function executePopulateProductForm(sfWebRequest $request)
  {
  	//throw exception if not POST request
  	$this->forward404Unless($request->isMethod('post'), 'not a "POST" request');

  	//collect id of duplicated_product
  	$duplicated_product_id = $request->getParameter('duplicated_product');
  	//Check if duplicated_product ID is valid
  	$this->forward404Unless($duplicated_product = Doctrine::getTable('Product')->find($duplicated_product_id) , sprintf('unknow id for a product (%s)', $duplicated_product_id));

    //set copied_product name and shop to null
  	$duplicated_product->setName(null);
  	$duplicated_product->setShopId(null);

    //create a form based on the copied_product
    $this->form = new BackendProductForm($duplicated_product);
    //in the backend the object must be passed to the template
    $this->product = $this->form->getObject();

   }

The only thing that remains is to display this form built on the existing duplicated_product. The problem will be that our form won’t be considered as a new one by symfony (and you can’t set his isNew() property to true since this method is protected…). But for now, let’s display it.

Write a PopulateProductFormSuccess to display pre-filled form

What I did was to go in the cache and then to copy the code from an editSuccess method just adapting it…

//apps/backend/modules/myModule/template/populateProductFormSuccess
//apps/backend/modules/myModule/template/populateProductFormSuccess
<?php use_helper('I18N', 'Date') ?>
<?php include_partial('product/assets') ?>

<div id="sf_admin_container">
  <h1><?php echo __('New Product', array(), 'messages') ?></h1>

  <?php include_partial('product/flashes') ?>

  <div id="sf_admin_header">
    <?php include_partial('product/form_header', array('product' => $product, 'form' => $form, 'configuration' => $configuration)) ?>
  </div>

  <div id="sf_admin_content">
    <?php //include_partial('product/form', array('product' => $product, 'form' => $form, 'configuration' => $configuration, 'helper' => $helper)) ?>

    <!-- BEGINNING OF THE NEW PART -->
	  <div class="sf_admin_form">

	    <?php //echo form_tag_for($form, '@form') ?>
	    <!-- Replaced by the hand written form_tag to force a redirect to a create action -->
	    <form action="<?php echo url_for('product/createPopulated') ?>" method="post" <?php $form->isMultipart() and print 'enctype="multipart/form-data" ' ?>>

	    <?php echo $form->renderHiddenFields() ?>

	    <?php if ($form->hasGlobalErrors()): ?>
	      <?php echo $form->renderGlobalErrors() ?>
	    <?php endif; ?>

	    <?php foreach ($configuration->getFormFields($form, $form->isNew() ? 'new' : 'edit') as $fieldset => $fields): ?>
	      <?php include_partial('product/form_fieldset', array('product' => $product, 'form' => $form, 'fields' => $fields, 'fieldset' => $fieldset)) ?>
	    <?php endforeach; ?>

	    <?php include_partial('product/form_actions', array('product' => $product, 'form' => $form, 'configuration' => $configuration, 'helper' => $helper)) ?>
	  </form>
	  </div>
    <!-- END OF THE NEW PART -->

  </div>

  <div id="sf_admin_footer">
    <?php include_partial('product/form_footer', array('product' => $product, 'form' => $form, 'configuration' => $configuration)) ?>
  </div>
</div>

Now we have our pre-filled product with all the ingredients already filled. You just have to change the tainting color from pink to blue and here it is… The last step is to save our new product in the data base.… That’s the aim of the following line of code that I changed from the cache :

<form action="<?php echo url_for('product/createPopulated') ?>" method="post" <?php $form->isMultipart() and print 'enctype="multipart/form-data" ' ?>>

I force a createPopulated action when submitting the form…

Add a CreatePopulated action to save pre-filled form as a new Object

Indeed, when symfony saves an object, it first check whereas the form isNew or not. If isNew is true, a new object will be created in the database. In the edit case, it will just do an update.

So what you have to do is create a new Form in your action, but in the same time use the request object to get the tainted values. If you only do this, you will be very disappointed : symfony will update, not create, an object. A way to avoid this is to set the tainted values ‘id’ to null !! See what it looks like :

//apps/backend/myModule/actions/actions.class.php
public function executeCreatePopulated(sfWebRequest $request)
  {
  	//create a new Form so that isNew() will be true and save will
  	//trigger an insert in the database
  	$this->form = $this->configuration->getForm();
    $this->product = $this->form->getObject();

    //We have to set the id to null otherwise it will try to update the object, not insert
    $tainted_values = $request->getParameter($this->form->getName()) ;
    $tainted_values['id'] = '';

    //we modify the $request so that we can call the processForm method without overriding it
    $request->setParameter($this->form->getName(),$tainted_values);

    $this->processForm($request, $this->form);

    $this->setTemplate('edit');
  }

And here it is ! Ouf… If anybody has a simpler solution to offer, he will be welcomed. Hope this was useful.

Limit user’s access only to some records of your Model

Working on a module, I faced the need to limit user’s access only to some of the Model’s record. Imagine for example that you got a bunch of users on your website. Let’s say users can write reviews on books or movie. You want users to be able to read all the reviews, but to be able only to edit their own reviews. SfGuard is not enough, because this kind of authorization is even more precise than allowing a module or a specific action to a group or a user.

I’m working with Symfony 1.2.6 and Doctrine 1.2
What we are going to do :

  1. Add a preExecute() method. Be careful ! You need, if not done yet, to apply this patch to sfAction (see Attachments).
  2. Add some methods in myUser class to know if we have to grant or limit access.
  3. Add again some methods in model’s classes.
  4. Eventually edit the actions and the templates

Let’s assume you got your SfGuardUser table, a Review table that contains all the… reviews ! (id, title, text) and a Many-to-Many table UserHasReview linking SfGuardUser to Review. It means that a review can be edited by several users (share documents).

First, I added a preExecute method in my actions.class.php file of my current module :

apps/myApp/modules/myModule/actions/actions.class.php

public function preExecute($request)
{
  /*
   * If no review ID parameter is set, let's fetch one
   */
  if(!$request->hasParameter('id'))
  {
    $request->setParameter('id', $this->getUser()->getOneOwnedReview());
  }

  /*
   * We check if current ID is owned by current User
   */
  if(!$this->getUser()->isOwnerOfReview($request->getParameter('id')))
  {
  /*
   * User is not the review's owner, he's not granted
   * the right to edit the data
   */
    $this->isOwnerOfReview = false;
  }
  else
  {
    $this->isOwnerOfReview = true;
  }
}

Ok, what is behind the scene ? We just add the called method in myUser.class.php. The first one checks if the User is owner of the given Review :

apps/myApp/lib/myUser.class.php

public function isOwnerOfReview($review_id)
{
  if($this->isSuperAdmin())
  {
    /*
     * SuperAdmin rule the world and can access the edit form
     * whatever happens. That's unfaire, but that's the way it is
     */
    return true;
  }
  elseif(Doctrine::getTable('UserHasReview')->isUserOwnerOfReview($this->getProfile(), $review_id))
  {
    /*
     * A method is called from the Many-to-Many model class. It returns
     * a boolean: true if there is a match between review and user
     */
    return true;
  }
  return false;
}

The second method added to myUser class is the method that retrieve a default Review if none has been send in the request :

apps/myApp/lib/myUser.class.php

public function getOneOwnedReview()
{
  if($this->isSuperAdmin())
  {
    /*
     * As a SuperAdmin can see any review, let's fetch him the last created review
     */
    $last = Doctrine::getTable('Review')->getLastCreated();
    return $last->getId();
  }
  else {
    /*
     * No review is set for this user, so let's fetch the first one
     */
    return Doctrine::getTable('UserHasReview')->getUserOneOwnedReview($this->getProfile());
  }
}

The next step happens in the UserHasReviewTable.class.php file, in which we will retrieve all the data we need. A first method checks if User owns the review :

lib/model/doctrine/UserHasReviewTable.class.php

public function isUserOwnerOfReview($user, $review_id)
{
  $q = Doctrine_Query::create()
    ->from('UserHasReview uhr')
    ->where('uhr.user_id = ?', $user->getId())
    ->addWhere('uhr.review_id = ?', $review_id);

  if($q->count() == 1) { return true; }
  else { return false; }
}

Another method in the same class fetches one review by the User :

lib/model/doctrine/UserHasReviewTable.class.php

public function getUserOneOwnedReview($user)
{
  $q = Doctrine_Query::create()
    ->select('uhr.review_id')
    ->from('UserHasReview uhr')
    ->where('uhr.user_id = ?', $user->getId())
    ->fetchOne();

  return $q['review_id'];
}

We are almost done. We just need an extra method, in ReviewTable.class.php, to fetch the last created review for Super Admins :

lib/model/doctrine/ReviewTable.class.php

public function getLastCreated()
{
  $q = Doctrine_Query::create()
    ->from('Review r')
    ->orderBy('r.created_at DESC')
    ->limit('1')
    ->fetchOne();

  return $q;
}

Alright ! You just have to change a bit your actions and templates, for example something like that :

apps/myApp/modules/myModule/actions/actions.class.php

public function executeEdit(sfWebRequest $request)
{
  if($this->isOwnerOfReview == true)
  {
    ...
  }
}

And in editSuccess.php :

apps/myApp/modules/myModule/templates/editSuccess.php

...
<?php if($isOwnerOfReview == true):?>

<?php include_partial('form', array('form' => $form)) ?>

<?php else:?>
  Vous n'avez pas l'autorisation d'accèder à cette page.
<?php endif;?>
...

Here we are, now your users can only edit their own reviews. And with security.yml, you can grant this right only to « reviewers » for example. Plus your SuperAdmin staff have full access to the same tool users are dealing with (can be helpful to find a bug, or a way to improve something).
I hope it will help !

Symfony forms saving process

I always had troubles understanding what really happens in the symfony form process.

Even though most of the time everything works great following tutorials, forums, etc.. I needed to understand the magic behind. I wasn’t just quite curious. I found myself one day lost in my own code, I had overriden severals methods but I didn’t know why this one was calling that one, why this one was never called, why this one didn’t work… It worked, that way, but not this way … well, shame on me, I didn’t truly understand what I was doing.

My sticking points mainly came from embedded forms, which are a truly helpful feature but not so easy to implements sometimes. For instance, I’ll try to explain why many to many relationships are not saved in embedded forms (see this ticket) and I’ll share with you what I found to solve this issue. I’ll also try to highlight why files uploads in embedded forms is bit tricky to implements and how to deeply bind forms.

But back to the Forms in general, let’s take an admin generated module and see what’s going on when you add or edit an object. It took me two days of reading and overriding symfony’s methods, but I finally came up with this little diagram. This diagram may not follow regular standards or naming conventions, simply because I don’t know what they are, I just put it my way and I hope it’s clear enough for a basic understanding. Keep in mind that I may be wrong somewhere, if so, please tell me. Doing this helped me a lot, and having a look at this diagram, when overriding forms methods still helps me.

By the way, I’m currently using symfony 1.2.6 with Doctrine 1.2.

symfony forms saving process

Now let’s talk about many to many relationships in embedded forms.

If you ever tried to embed a form that must manage many to many relationship(s), you propably noticed that the relationships are NOT saved.
Here’s why.
An object is saved to the database simply by calling $myObject->save() Doctrine method. This saves the object properties, one to one, or one to many relationships, but not many to many because the relationships are stored in a different table.
That’s why when you do « php symfony generate-admin« , Symfony overrides the doSave() method and add save***List() methods to it, to actually save the many to many relationships.
But if you look at the saving process, the doSave() methods of embedded forms are never called.
And that’s a bit more complex than what it looks like. We could just override the saveEmbeddedForms method to call doSave() on embedded forms like on the ticket but it didn’t work for me.

I’m not 100% sure, maybe I missed something, but here’s what I understood.
Calling doSave() on an embedded form implies two things:

– that the form must be bound before. This is because auto-generated save***List() methods are testing ‘if($form->isValid())’ which is nearly the same as ‘if($form->isBound)’. « So let’s Just bind the embeddedForms before calling doSave » I thought. (This is exactly what is done in the ticket by the way)

-that all the process after the doSave() method will be executed, which seems to be useless to me because updateObjectEmbeddedForms() already call updateObject() on the child objects and saveEmbeddedForms() already calls itself  on child objects too.

I tried to embed an sfGuardForm with this system. Saving a new object went fine, but for some reasons, editing didn’t work. After some a lot of debug, I found that it triggered a hidden validation error « An object with the same ‘username’ already exists ». Honestly I don’t really remember on which level it happened, but I think this is linked to the fact that some methods are executed twice and shouldn’t be. Even calling the bind() method on embedded forms seems to me to be a redundancy. When you submit a form, doClean() deeply clean the values, meaning even embedded forms values are passed to the validators, and returned cleaned. So if you call bind() on an embedded form it will call the clean method again.

And finally for those who don’t care why and just want to know how, here’s my solution.

First of all, let’s deeply bind embedded forms, i.e binding embedded forms, embedded forms in embedded forms etc … Symfony does NOT bind embedded forms by default and I wonder why, is it a mistake? By the way, this is why files uploads in embedded forms won’t work. The processUploadFile() method is using getValues() which works only on a bound form .Anyway, we can’t just call the bind() method on embedded forms as we saw before, so let’s manually bind these forms.

I created a method which I put in BaseFormDoctrine.

//lib/form/doctrine/BaseFormDoctrine.class.php
 public function bindEmbeddedForms($embedded_forms, $values)
 {
    if($this->isValid())
    {
      foreach ($embedded_forms as $name => $form)
      {
        $form->isBound = true;
	$form->values = $values[$name];

	if ($form->embeddedForms)
	{
	  $this->bindEmbeddedForms($form->embeddedForms, $values[$name]);
	}
      }
    }
  }

And I just call it at the end of the bind method.

  public function bind(array $taintedValues = null, array $taintedFiles = null) {

      parent::bind($taintedValues, $taintedFiles);
      $this->bindEmbeddedForms($this->embeddedForms, $this->getValues());

  }

Then override the saveEmbeddedForms() method to take care of the many to many relationships.

 //lib/form/doctrine/BaseFormDoctrine.class.php
 public function saveEmbeddedForms($con = null, $forms = null)
  {
    if (is_null($con))
    {
      $con = $this->getConnection();
    }

    if (is_null($forms))
    {
      $forms = $this->embeddedForms;
    }

    foreach ($forms as $key => $form)
    {
      if ($form instanceof sfFormDoctrine)
      {
      	/*
      	 * --------------------
      	 * only modification
      	 */
      	if(method_exists(new $form(), 'doSaveManyToMany'))
      	{
          $form->doSaveManyToMany($con);
      	}
      	else
      	{
          $form->getObject()->save($con);
      	}
      	/*
      	 * --------------------
      	 */
        $form->saveEmbeddedForms($con);
      }
      else
      {
        $this->saveEmbeddedForms($con, $form->getEmbeddedForms());
      }
    }
  }

This method is just a doSave() method with the calls to updateObject() and saveEmbeddedForms() removed.

//lib/form/doctrine/BaseFormDoctrine.class.php
  public function doSaveManyToMany($con = null)
  {
    if (is_null($con))
    {
      $con = $this->getConnection();
    }

    $this->object->save($con);

   /*
    * Save the many-2-many relationship
    */
    $this->save***List($con);
  }

I’m sure there’s a better way to do it, but you got the idea. Hope it can help.

Improving Autocompletion

Updated on 2nd Oct. 2009 : some code was erased in my_javascript.js snippet because of < and >

Ajax autocompletion is really easy with sfWidgetFormJQueryAutocompleter. But the input hidden (which is the value you really matter about when processing the form) that is rendered previous to the input text box (which is only there for humans 🙂 ) is not filled-in if user fill it in very quickly. It can be tedious, because when submitting the form, the user will notice (or maybe even not) that the data he thought he filled-in has not been saved. For ergonomics purpose, i’ve wrote down a little and easy piece of code that will check data in the input text and fill in the input hidden. Plus, there is a tick or delete image that will show up, warning the user as a pre-validation of its data.

We are going to create an Ajax action that retrieve a string value from an input text box, check in our model if this string match any record, and if so, fetch and send back the ID to fill it in the input hidden of the autocompleter. I’m using Symfony 1.2.6 with Doctrine 1.2, and sfFormExtraPlugin is installed. We will assume the sfWidgetFormJQueryAutocompleter setup is already done.

  • 1. The layout
  • 2. The javascript (using jQuery)
  • 3. The routing.yml
  • 4. The module’s action.class.php
  • 5. The model’s table class

This code is simplified from one of our module : originally, we got an input text, and when you fill it in, a second one appears after the first one. And then a third one when you fill in the second on, and so on. (see this previous article (in french))
First, let’s see the layout in the middle of our form :

_myLayout.php

<div>
<label for="my_detail[input_auto]">Item</label>
render('my_module[my_detail][input_auto]', $data->getId(), array('value' => $data->getName()))?>
<img src="edit.png" alt="No data in the input" title="No data in the input" />
</div>

We also got a Javascript code, we’re using jQuery, but i’m sure you’ll be able to find out how to do the trick with some other library. The objective is to run the code when the input text is blured:

my_javascript.js

jQuery(document).ready(function() {
  jQuery('[id^=autocomplete_my_detail]').blur(function(){

    [...]

    if(this.value.length > 0)
    {
      // ... we call an ajax method to retrieve an ID matching the current input value
      findIdForString(jQuery(object).val());
    }
  }

  var findIdForString = function(string) {
    jQuery.ajax({
      type: "GET",
      url: 'findItemIdForString',
      data: { string: string },
      dataType: 'html',
      success: function(result){
        // we check if result is a number (otherwise it's null: no ID has been found for the given string)
	var pattern = new RegExp('[0-9]+');

	if(result.match(pattern))
	{
	  // an item ID has been identified for the given string
	  // modify the hidden value with the retrieved ID
	  var hidden_html = '<input id="transit_line_stations_list_input_auto_'+counter+'" class="auto_station" type="hidden" name="transit_line[stations_list][input_auto_'+counter+']" value="'+result+'" />'
          jQuery('#my_module_my_detail_input_auto).replaceWith(hidden_html);

          // modify the icon image
          setImage('tick');
	}
	else
	{
	  // no item ID has been identified for the given string
          // Here, the input hidden is modified, because,
          // as I have wrote above, we are using this code
          // in a multi-inputs page. So if the user fill in
          // correctly the input, blur, comes back to the input
          // and fill in badly, we have to unset hidden value
          var hidden_html = '<input id="transit_line_stations_list_input_auto_'+counter+'" class="auto_station" type="hidden" name="transit_line[stations_list][input_auto_'+counter+']" />';
          jQuery('#my_module_my_detail_input_auto).replaceWith(hidden_html);

          // modify the icon image
          setImage('delete');
        }

	function setImage(icon) {
	  if(icon == 'delete')
	    var title = 'ID unknown';
	  else if(icon == 'tick')
	    var title = 'ID identified';

          var img_html = '<img src="'+icon+'.png" alt="'+title+'" title="'+title+'" />';
	  jQuery('#image_my_module_my_detail).replaceWith(img_html);
	};
      }
    });
  };
}

Then of course comes the action, named findItemIdForString.

action.class.php

public function executeFindItemIdForString(sfWebRequest $request)
{
  $string = $request->getParameter('string');

  $item_id = Doctrine::getTable('MyModel')->findOneItemIdForString($string);

  return $this->renderText($item_id);
}

Don’t forget to update your routing.yml file with something like :

routing.yml
ajax_findItemIdForString:
  url:    /*/findItemIdForString
  param:  { module: my_module, action: findItemIdForString }

And the findOneItemIdForString method in MyModelTable.class.php :

MyModelTable.class.php

public function findOneItemIdForString($string)
{
  $q = Doctrine_Query::create()
    ->select('m.id')
    ->from('MyModel m')
    ->where('m.name LIKE ?', $string)
    ->fetchOne();

  if($q instanceOf Doctrine_Collection)
  {
    return $q->getId();
  }
  else
  {
    return $q['id'];
  }
}

Let’s finish with a screenshot of the result. This example shows how to add subway stations, with little green tick icons when findOneItemIdForString($string) has returned something, red cross when nothing has been found, and the edit icon when the ajax action has not yet been run.
Screenshot

sfValidatorI18nFloat a homemade validator for internationalized input numbers

Getting a number from your model and output it in a i18n/l10n way is really easy thanks to Symfony helpers. But there is no native way to easily input a number and validate its pattern before saving in the model. As one might know, float numbers don’t always have the same format from a culture to an other. One thousand five hundred dot fifty one is written 1,500.51 in USA, but it’s 1 500,51 in France. And a PHP clean float use nor comma nor space as a thousand separator, and a dot as a decimal separator.
So, inspired by this post on the Symfony forum, I have written a sfValidatorI18nFloat validator to input such things. Hope it can help some of you. Save it in your lib directory.

<?php
/**
 * Validate a number format input depending on its culture, ie:
 * fr_FR: 19,50 ==> 19.50
 * @author Simon Hostelet
 * @version 2009-09-16
 */
class sfValidatorI18nFloat extends sfValidatorBase
{
  protected function configure($options = array(), $messages = array())
  {
    $this->addMessage('max', '"%value%" must be less than %max%.');
    $this->addMessage('min', '"%value%" must be greater than %min%.');

    $this->setMessage('invalid', '"%value%" is not a valid float.');

    $this->addOption('min');
    $this->addOption('max');
  }

  protected function doClean($value)
  {
    $clean = self::I18nNumberToPhpNumber($value, sfContext::getInstance()->getUser()->getCulture());

    if(!is_numeric($clean))
    {
      throw new sfValidatorError($this, 'invalid', array('value' => $value));
    }

    if ($this->hasOption('max') && $clean > $this->getOption('max'))
    {
      throw new sfValidatorError($this, 'max', array('value' => $value, 'max' => $this->getOption('max')));
    }

    if ($this->hasOption('min') && $clean getOption('min'))
    {
      throw new sfValidatorError($this, 'min', array('value' => $value, 'min' => $this->getOption('min')));
    }

    return $clean;
  }

  /**
   * Check what are the decimal and thousand separators for the current culture.
   * Replace these separators with PHP compatible ones.
   * @param $number float
   * @param $culture  string
   * @return float PHP-cleaned
   */
  static public function I18nNumberToPhpNumber($number, $culture = 'en')
  {
    $numberFormatInfo = sfNumberFormatInfo::getInstance($culture);
    $number = str_replace($numberFormatInfo->getDecimalSeparator(), '.', $number);
    $number = str_replace($numberFormatInfo->getGroupSeparator(), '', $number);

    return $number;
  }
}

?>

Access the form values in prevalidator

I’m using a preValidator to validate a price in a form. Say the user enters « 22,35 », I want to format this string to « 22.35 ». And I need to do this before the actual validator (sfValidatorNumber) of the field, so that the string « 22,35 » will be formatted and pass the sfValidatorNumber.

How to do this? Set a preValidator:

public function configure()
{
        $this->validatorSchema->setPreValidator(
               new sfValidatorCallback(array('callback' => array($this, 'validatePrice')))
         );
}

public function validatePrice($validator, $values)
{
        ...

    	$values['price'] = str_replace(',', '.', $values['price']);
    	return $values;
}

Easy.

But this does not work ! 😀 Contrary to postValidators, $values are not modified in preValidators.

I found a little patch to fix this (after one hour trying to fix it on my own by overriding proccessForm() method etc…), on this ticket of symfony trac.

There’s no need to complicate, our time is short (Jason Mraz – I’m Yours ♫):

//lib/vendor/symfony/lib/validator.sfValidatorSchema.class.php
-LINES-
                    // pre validator
            	    try
           	    {
                         // Replace this line
123	 	          $this->preClean($values);
                         // with this one
 		          $values = $this->preClean($values);
             	    }
        	    catch (sfValidatorErrorSchema $e)
        	    {
…	…
              	  {
        	   if (is_null($validator = $this->getPreValidator()))
	           {
                         //Replace this line
221  	                  return;
                         // with this one
 		          return $values;
	            }
	             //Replace this line
224	 	     $validator->clean($values);
                    // with this one
 		     return $validator->clean($values);
	  }

Now it works !

I think that this is an important fix to have.

I use symfony 1.2.6, i didn’t try 1.2.8 but I didn’t see the patch in the changelog.

A sfValidator to convert a date from a given pattern to a SQL friendly pattern (Symfony 1.2)

Maybe I’ve just spend a few hours for nothing (if so, please let me be aware of that by commenting 😉 ), but I wasn’t able to find a widget and/or a validator to do this bunch of things :

  • Display, in a form, a date select in a I18n manner (in my case, I need a french date display, that is to say dd/mm/yyyy
  • Display only a few years : I need only the current year and the year + 1
  • Having tomorrow’s date already selected in the form
  • Validate the date for today or past dates to be invalid
  • Last but not least, having this date clean to save in my MySQL database

In my form class, I create a widget sfWidgetFormDate(). But depending on how I display it in the template, some of the above options seem to be unreachable. I tried with :

//this is my default date, set to tomorrow
$default_date = date('Y-m-d', time() + 86400);

// the typical display
// in this one, I wasn't able to set the selected date
echo $form['date'];

// display using input_date_tag
// in this one, I wasn't able to set years. Even with sfWidgetFormDate(array('years' => array(date('Y'), date('Y') +1))), it wasn't working
echo input_date_tag('date', $default_date, array('culture' => 'fr_FR'));

// even by specifying years here : useless
echo input_date_tag('date', $default_date, array('culture' => 'fr_FR', 'years' => array(date('Y'), date('Y') + 1)));

// It was ok with the rich display, but then, I had issues : I was able to display the date with french pattern (dd/mm/yyyy), but after submitting the form, validation was not able to mix the date right to yyyy-mm-dd pattern which would eventually fit in a database...
echo input_date_tag('date', $default_date, array('culture' => 'fr', 'rich' => true, 'format' => 'dd/MM/yyyy'));

As the last one was really ergonomic for my purpose, I’ve decided to keep on this one. I only had to write down a validator which would get the french pattern date, check and clean it into a mysql-ready-to-save date.

Does such a validator already exists ? That’s my question, feel free to answer. But anyway, now that my trick is working, let me share it with you ! It doesn’t really validate ANY I18n date, but kinda a few 🙂

File : lib/sfValidatorDateI18n.class.php
/**
 * Extends sfValidatorDate
 * Convert a pattern date like d/m/Y to MySQL friendly Y-m-d or other pattern
 * @author Simon Hostelet
 *
 */
class sfValidatorDateI18n extends sfValidatorDate
{

	/**
	 * Configure the validator. Adding two more options to the regular validator
	 * input_date_format : date format from the form's input. Default is Y-m-d (ex: 2009-08-19)
	 * output_date_format : date format to get as output
	 * Allowed format masks:
	 * d : 01 to 31
	 * m : 01 to 12
	 * y : 00 to 99
	 * Y : example 2009
	 * Allowed format separators: /-_,. and space
	 * @see trunk/lib/vendor/symfony/lib/validator/sfValidatorDate#configure($options, $messages)
	 */
	protected function configure($options = array(), $messages = array())
  {

  	$this->addOption('input_date_format', 'Y-m-d');
  	$this->addOption('output_date_format', 'Y-m-d');

  	parent::configure($options, $messages);
  }

  /**
   * Override sfValidatorDate doClean. Quite strange : I had to copy/paste the original
   * code after my 'convertDateToFormat' method : a simple parent::doClean($value) would
   * not work !
   * @see trunk/lib/vendor/symfony/lib/validator/sfValidatorDate#doClean($value)
   */
	protected function doClean($value)
  {

  	$value = $this->convertDateToFormat($value);

// I had to copy/paste the rest of doClean, otherwise it wouldn't work ! I don't know why...
  	if (is_array($value))
    {
      $clean = $this->convertDateArrayToTimestamp($value);
    }
    else if ($regex = $this->getOption('date_format'))
    {
      if (!preg_match($regex, $value, $match))
      {
        throw new sfValidatorError($this, 'bad_format', array('value' => $value, 'date_format' => $this->getOption('date_format_error') ? $this->getOption('date_format_error') : $this->getOption('date_format')));
      }

      $clean = $this->convertDateArrayToTimestamp($match);
    }
    else if (!ctype_digit($value))
    {
      $clean = strtotime($value);
      if (false === $clean)
      {
        throw new sfValidatorError($this, 'invalid', array('value' => $value));
      }
    }
    else
    {
      $clean = (integer) $value;
    }

    if ($this->hasOption('max') && $clean > $this->getOption('max'))
    {
      throw new sfValidatorError($this, 'max', array('value' => $value, 'max' => date($this->getOption('date_format_range_error'), $this->getOption('max'))));
    }

    if ($this->hasOption('min') && $clean getOption('min'))
    {
      throw new sfValidatorError($this, 'min', array('value' => $value, 'min' => date($this->getOption('date_format_range_error'), $this->getOption('min'))));
    }

    return $clean === $this->getEmptyValue() ? $clean : date($this->getOption('with_time') ? $this->getOption('datetime_output') : $this->getOption('date_output'), $clean);
  }

  /**
   * converts the given date
   * $value must match the option 'input_date_format'. It will be convert to 'output_date_format'
   * For example : $value = 19/08/2009, input_date_format = DD/MM/YYYY, output_date_format = YYYY-MM-DD
   * Output will be 2009-08-19
   * @param $value
   * @return unknown_type
   * @author  Simon Hostelet
   */
  protected function convertDateToFormat($value)
  {

  	// we check if input/output_date_format are well written,
  	// we get the date separator, and the order of year, month and day in the mask
    $input_details = $this->getDateAlrightSeparatorAndOrder($this->getOption('input_date_format'));
    foreach($input_details as $key => $val)
    {
    	$key = 'input_' . $key;
    	$$key = $val;
    }
    $output_details = $this->getDateAlrightSeparatorAndOrder($this->getOption('output_date_format'));

    $input_date = explode($input_date_separator, $value);

    // is this date valid ?
    if(!checkdate(intval($input_date[$input_month_order]), intval($input_date[$input_day_order]), intval($input_date[$input_year_order])))
    {
    	throw new sfValidatorError($this, 'invalid', array('value' => $value));
    }

    // let's build the output date
    $output_date = $this->getOption('output_date_format');
    $output_date = preg_replace('/Y|y/', $input_date[$input_year_order], $output_date);
    $output_date = preg_replace('/m/', $input_date[$input_month_order], $output_date);
    $output_date = preg_replace('/d/', $input_date[$input_day_order], $output_date);

    return $output_date;
  }

  /**
   * get a date format (like d/m/Y), check the format, and returns date separator (-/_, .)
   * and date order
   * @param $format
   * @return array
   * @author  Simon Hostelet
   */
  protected function getDateAlrightSeparatorAndOrder($format)
  {
    // does the date_format looks right ?
    if(!preg_match('/(d|m|y|Y)([-\/_,\. ]{1})(d|m|y|Y)([-\/_,\. ]{1})(d|m|y|Y)/', $format, $matches))
    {
      throw new sfValidatorError($this, 'invalid', array('value' => $value));
    }

  	// what is the date separator ?
    preg_match('/[dmyY]{1}([\/\-,\._ ]{1})[dmyY]{1}/', $format, $matches);
    $return_array['date_separator'] = $matches[1];

    // what is the order of day, month and year in the format mask ?
    $date_order = explode($return_array['date_separator'], $format);
    foreach($date_order as $key => $val)
    {
      switch($val)
      {
        case 'd':
          $return_array['day_order'] = $key;
          break;
        case 'm':
          $return_array['month_order'] = $key;
          break;
        default:
          $return_array['year_order'] = $key;
      }
    }

    return $return_array;
  }
}

In the Form class configure :

$this->validatorSchema['date'] = new sfValidatorDateI18n(array(
      'date_output' => 'Y-m-d',
      'input_date_format' => 'd/m/Y',
      'output_date_format' => 'Y-m-d',
      'with_time' => false,
    ));