Saturday, November 26, 2011

Mapping embedded documents in Doctrine MongoDB ODM

If you have a MongoDB document that has another document embedded in it, you still have to create a separate XML mapping document for the embedded one. So if you have a parent document called Activity that has an embedded document called Coordinates, the configuration would look something like this:

src/Acme/ActivityBundle/Resources/config/doctrine/Activity.mongodb.xml

<doctrine-mongo-mapping xmlns="http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping
                    http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping.xsd">

    <document name="Acme\ActivityBundle\Document\Activity" db="acme" collection="activity" customId="true">
        
        <field fieldName="id" id="true" strategy="INCREMENT" />

        <embed-one target-document="Acme\ActivityBundle\Document\Coordinates" field="locationCoordinates" />

        <lifecycle-callbacks>
            <lifecycle-callback method="callbackPrePersist" type="prePersist" />
            <lifecycle-callback method="callbackPreUpdate" type="preUpdate" />
        </lifecycle-callbacks>
        
        <indexes>
            <index>
                <key name="locationCoordinates" order="2d" />
            </index>
        </indexes>
        
    </document>
    
</doctrine-mongo-mapping>

src/Acme/ActivityBundle/Resources/config/doctrine/Coordinates.mongodb.xml

<doctrine-mongo-mapping xmlns="http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping
                    http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping.xsd">

    <embedded-document name="Acme\ActivityBundle\Document\Coordinates">
        <field fieldName="latitude" type="float" />
        <field fieldName="longitude" type="float" />
    </embedded-document>
    
</doctrine-mongo-mapping>

The XML mapping format is not included in the Doctrine MongoDB ODM documentation. After a bunch of tests, I realized the @EmbeddedDocument in the annotation format example and simply replaced the document tag with embedded-document in the mapping file.

Friday, November 25, 2011

Creating a Symfony2 helper

While working on a Symfony2 project yesterday, I realized I needed a better way to format dates in my templates. The reason behind this was the fact that my MongoDB document entities contained date values that were either null or DateTime instances. This entailed using a bunch of if statements for extra checks that made template code unnecessarily verbose.

My solution was to create a template helper in an application specific framework bundle that I had created previously to contain all common assets and other necessary common resources.

Create a Helper directory in the framework bundle directory and create a DateHelper class:

src/Acme/FrameworkBundle/Helper/DateHelper.php

namespace Acme\FrameworkBundle\Helper;

use Symfony\Component\Templating\Helper\Helper;

class DateHelper extends Helper
{
    protected $options;
    
    public function __construct(array $options)
    {
        $this->options = $options;
    }
    
    public function getName()
    {
        return 'date';
    }
    
    public function format($date, $detailed = false)
    {
        if (!empty($date)) {
            if ($date instanceof \DateTime) {
                if ($detailed === true) {
                    return $date->format($this->getOption('detailed_format'));
                } else {
                    return $date->format($this->getOption('default_format'));
                }
            }
        }
        
        return null;
    }
    
    protected function getOption($name)
    {
        if (array_key_exists($name, $this->options)) {
            return $this->options[$name];
        }
        
        throw new Exception('Options does not exist');
    }
}

Define the configuration options for your helper:

src/Acme/FrameworkBundle/DependencyInjection/Configuration.php

namespace Acme\FrameworkBundle\DependencyInjection;

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

class Configuration implements ConfigurationInterface
{
    public function getConfigTreeBuilder()
    {
        $treeBuilder = new TreeBuilder();
        $rootNode = $treeBuilder->root('acme_framework');

        $rootNode->children()
            ->arrayNode('helper')
                ->isRequired()
                ->children()
                    ->arrayNode('date')
                        ->isRequired()
                        ->children()
                            ->scalarNode('default_format')->defaultValue('Y-m-d')->cannotBeEmpty()->end()
                            ->scalarNode('detailed_format')->defaultValue('Y-m-d H:i:s')->cannotBeEmpty()->end()
                        ->end()
                    ->end()
                ->end()
            ->end()
        ->end();

        return $treeBuilder;
    }
}

Add the new configuration section that you have defined for your helper to your config.yml:

app/config/config.yml

acme_framework:
    helper:
        date:
            default_format: "Y-m-d"
            detailed_format: "Y-m-d H:i:s"

The above approach is a very simplistic approach and would not be the correct one if you are dealing with localization. However, in my case, this is more than enough.

Add the necessary code to your extension class to load your new configuration:

src/Acme/FrameworkBundle/DependencyInjection/AcmeFrameworkExtension.php

namespace Acme\FrameworkBundle\DependencyInjection;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\DependencyInjection\Loader;

class AcmeFrameworkExtension extends Extension
{
    public function load(array $configs, ContainerBuilder $container)
    {
        $configuration = new Configuration();
        $config = $this->processConfiguration($configuration, $configs);
                
        $container->setParameter('acme_framework.helper.date', $config['helper']['date']);
        
        $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
        $loader->load('services.xml');
    }
}

The next step is to define our service and tag it as a helper:

src/Acme/FrameworkBundle/Resources/config/services.xml

<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        
        <service id="acme_framework.helper.date" class="Acme\FrameworkBundle\Helper\DateHelper">
            <tag name="templating.helper" alias="date" />
            <argument>%acme_framework.helper.date%</argument>
        </service>
        
    </services>

</container>

And that is pretty much it. You can now call your helper in your templates like this:

echo $view['date']->format($activity->getStartedAt());

Wednesday, November 2, 2011

Adding post-login logic to FOSUserBundle

Having finally figured out how to use FOSUserBundle in my project, I decided to keep track of all logins next. The implementation turned out to be a breeze thanks to Symfony2's security listener mechanism.

As usual, the first step is to create a MongoDB document for this purpose. This is a very simple document that contains a user's id, session id, IP address, and login date.

src/Acme/UserBundle/Document/LoginHistory.php

namespace Acme\UserBundle\Document;

class LoginHistory
{
    protected $id;
    protected $userId;
    protected $sessionId;
    protected $ip;
    protected $createdAt;
   
    /**
     * Get id
     *
     * @return custom_id $id
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set userId
     *
     * @param int $userId
     */
    public function setUserId($userId)
    {
        $this->userId = $userId;
    }

    /**
     * Get userId
     *
     * @return int $userId
     */
    public function getUserId()
    {
        return $this->userId;
    }

    /**
     * Set sessionId
     *
     * @param string $sessionId
     */
    public function setSessionId($sessionId)
    {
        $this->sessionId = $sessionId;
    }

    /**
     * Get sessionId
     *
     * @return string $sessionId
     */
    public function getSessionId()
    {
        return $this->sessionId;
    }

    /**
     * Set ip
     *
     * @param string $ip
     */
    public function setIp($ip)
    {
        $this->ip = $ip;
    }

    /**
     * Get ip
     *
     * @return string $ip
     */
    public function getIp()
    {
        return $this->ip;
    }

    /**
     * Set createdAt
     *
     * @param date $createdAt
     */
    public function setCreatedAt($createdAt)
    {
        $this->createdAt = $createdAt;
    }

    /**
     * Get createdAt
     *
     * @return date $createdAt
     */
    public function getCreatedAt()
    {
        return $this->createdAt;
    }

    
    public function callbackPrePersist()
    {
        $this->setCreatedAt(new \DateTime());
    }
}

Second step is to map this document to a MongoDB collection:

src/Acme/UserBundle/Resources/config/doctrine/LoginHistory.mongodb.xml

<doctrine-mongo-mapping xmlns="http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping
                    http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping.xsd">

    <document name="Acme\UserBundle\Document\LoginHistory" db="acme" collection="loginHistory" customId="true">
        
        <field fieldName="id" id="true" strategy="INCREMENT" />
        <field fieldName="userId" type="int" />
        <field fieldName="sessionId" type="string" />
        <field fieldName="ip" type="string" />
        <field fieldName="createdAt" type="date" />
        
        <lifecycle-callbacks>
            <lifecycle-callback method="callbackPrePersist" type="prePersist" />
        </lifecycle-callbacks>
        
        <indexes>
            <index>
                <key name="userId" order="asc" />
            </index>
            <index>
                <key name="createdAt" order="desc" />
            </index>
        </indexes>
        
    </document>
    
</doctrine-mongo-mapping>

Next step is to create a new listener to do the work for us when a user successfully logs in to the application:

src/Acme/UserBundle/Resources/Core/Listenere/InteractiveLogin.php

namespace Acme\UserBundle\Core\Listener;

use Symfony\Component\DependencyInjection\ContainerInterface;
use Doctrine\ODM\MongoDB\DocumentManager;
use FOS\UserBundle\Model\UserInterface;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use DateTime;
use Acme\UserBundle\Document\LoginHistory;

class InteractiveLogin
{
    protected $container;
    protected $dm;

    public function __construct(ContainerInterface $container, DocumentManager $dm)
    {
        $this->container = $container;
        $this->dm = $dm;
    }
    
    public function onSecurityInteractiveLogin(InteractiveLoginEvent $event)
    {
        $user = $event->getAuthenticationToken()->getUser();

        if ($user instanceof UserInterface) {
            
            $loginHistory = new LoginHistory();
            $loginHistory->setUserId($user->getId());
            $loginHistory->setSessionId($this->container->get('session')->getId());
            $loginHistory->setIp($this->container->get('request')->getClientIp());
            
            $this->dm->persist($loginHistory);
            $this->dm->flush();
            
        }
    }
}

The last step is to attach this listener to Symfony's security.interactive_login event:

src/Acme/UserBundle/Resources/config/doctrine/LoginHistory.mongodb.xml

<service id="acme.security.login_listener" class="Acme\UserBundle\Core\Listener\InteractiveLogin">
    <argument type="service" id="service_container" />
    <argument type="service" id="fos_user.document_manager" />
    <tag name="kernel.event_listener" event="security.interactive_login" method="onSecurityInteractiveLogin" />
</service>

That's it!