Explorar el Código

Sortable extension

Lukas Botsch hace 14 años
padre
commit
71459ad65b

+ 19 - 0
lib/Gedmo/Mapping/Annotation/SortableGroup.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Gedmo\Mapping\Annotation;
+
+use Doctrine\Common\Annotations\Annotation;
+
+/**
+ * Locale annotation for Translatable behavioral extension
+ *
+ * @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
+ * @package Gedmo.Mapping.Annotation
+ * @subpackage Locale
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+final class SortableGroup extends Annotation
+{
+
+}

+ 19 - 0
lib/Gedmo/Mapping/Annotation/SortablePosition.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Gedmo\Mapping\Annotation;
+
+use Doctrine\Common\Annotations\Annotation;
+
+/**
+ * Locale annotation for Translatable behavioral extension
+ *
+ * @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
+ * @package Gedmo.Mapping.Annotation
+ * @subpackage Locale
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+final class SortablePosition extends Annotation
+{
+
+}

+ 135 - 0
lib/Gedmo/Sortable/Mapping/Driver/Annotation.php

@@ -0,0 +1,135 @@
+<?php
+
+namespace Gedmo\Sortable\Mapping\Driver;
+
+use Gedmo\Mapping\Driver\AnnotationDriverInterface,
+    Gedmo\Exception\InvalidMappingException;
+
+/**
+ * This is an annotation mapping driver for Sortable
+ * behavioral extension. Used for extraction of extended
+ * metadata from Annotations specificaly for Sortable
+ * extension.
+ *
+ * @author Lukas Botsch <lukas.botsch@gmail.com>
+ * @package Gedmo.Sortable.Mapping.Driver
+ * @subpackage Annotation
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+class Annotation implements AnnotationDriverInterface
+{
+    /**
+     * Annotation to mark field as one which will store node position
+     */
+    const POSITION = 'Gedmo\\Mapping\\Annotation\\SortablePosition';
+
+    /**
+     * Annotation to mark field as sorting group 
+     */
+    const GROUP = 'Gedmo\\Mapping\\Annotation\\SortableGroup';
+
+    /**
+     * List of types which are valid for position fields
+     *
+     * @var array
+     */
+    private $validTypes = array(
+        'integer',
+        'smallint',
+        'bigint'
+    );
+
+    /**
+     * Annotation reader instance
+     *
+     * @var object
+     */
+    private $reader;
+
+    /**
+     * original driver if it is available
+     */
+    protected $_originalDriver = null;
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setAnnotationReader($reader)
+    {
+        $this->reader = $reader;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function validateFullMetadata($meta, array $config)
+    {
+        if ($config && !isset($config['position'])) {
+            throw new InvalidMappingException("Missing property: 'position' in class - {$meta->name}");
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function readExtendedMetadata($meta, array &$config) {
+        $class = $meta->getReflectionClass();
+        
+        // property annotations
+        foreach ($class->getProperties() as $property) {
+            if ($meta->isMappedSuperclass && !$property->isPrivate() ||
+                $meta->isInheritedField($property->name) ||
+                isset($meta->associationMappings[$property->name]['inherited'])
+            ) {
+                continue;
+            }
+            // position
+            if ($position = $this->reader->getPropertyAnnotation($property, self::POSITION)) {
+                $field = $property->getName();
+                if (!$meta->hasField($field)) {
+                    throw new InvalidMappingException("Unable to find 'position' - [{$field}] as mapped property in entity - {$meta->name}");
+                }
+                if (!$this->isValidField($meta, $field)) {
+                    throw new InvalidMappingException("Sortable position field - [{$field}] type is not valid and must be 'integer' in class - {$meta->name}");
+                }
+                $config['position'] = $field;
+            }
+            // group
+            if ($group = $this->reader->getPropertyAnnotation($property, self::GROUP)) {
+                $field = $property->getName();
+                if (!$meta->hasField($field) && !$meta->hasAssociation($field)) {
+                    throw new InvalidMappingException("Unable to find 'group' - [{$field}] as mapped property in entity - {$meta->name}");
+                }
+                if (!isset($config['groups'])) {
+                    $config['groups'] = array();
+                }
+                $config['groups'][] = $field;
+            }
+        }
+    }
+
+    /**
+     * Checks if $field type is valid
+     *
+     * @param ClassMetadata $meta
+     * @param string $field
+     * @return boolean
+     */
+    protected function isValidField($meta, $field)
+    {
+        $mapping = $meta->getFieldMapping($field);
+        return $mapping && in_array($mapping['type'], $this->validTypes);
+    }
+
+    /**
+     * Passes in the mapping read by original driver
+     *
+     * @param $driver
+     * @return void
+     */
+    public function setOriginalDriver($driver)
+    {
+        $this->_originalDriver = $driver;
+    }
+}

+ 90 - 0
lib/Gedmo/Sortable/Mapping/Driver/Xml.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace Gedmo\Sortable\Mapping\Driver;
+
+use Gedmo\Mapping\Driver\Xml as BaseXml,
+    Gedmo\Exception\InvalidMappingException;
+
+/**
+ * This is a xml mapping driver for Sortable
+ * behavioral extension. Used for extraction of extended
+ * metadata from xml specificaly for Sortable
+ * extension.
+ *
+ * @author Lukas Botsch <lukas.botsch@gmail.com>
+ * @package Gedmo.Sortable.Mapping.Driver
+ * @subpackage Xml
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+class Xml extends BaseXml
+{
+
+    /**
+     * List of types which are valid for position field
+     *
+     * @var array
+     */
+    private $validTypes = array(
+        'integer',
+        'smallint',
+        'bigint'
+    );
+
+    /**
+     * {@inheritDoc}
+     */
+    public function validateFullMetadata($meta, array $config)
+    {
+        if ($config && !isset($config['position'])) {
+            throw new InvalidMappingException("Missing property: 'position' in class - {$meta->name}");
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function readExtendedMetadata($meta, array &$config)
+    {
+        /**
+         * @var \SimpleXmlElement $xml
+         */
+        $xml = $this->_getMapping($meta->name);
+
+        if (isset($xml->field)) {
+            foreach ($xml->field as $mapping) {
+                $mappingDoctrine = $mapping;
+                /**
+                 * @var \SimpleXmlElement $mapping
+                 */
+                $mapping = $mapping->children(self::GEDMO_NAMESPACE_URI);
+
+                $field = $this->_getAttribute($mappingDoctrine, 'name');
+                if (isset($mapping->{'sortable-position'})) {
+                    if (!$this->isValidField($meta, $field)) {
+                        throw new InvalidMappingException("Sortable position field - [{$field}] type is not valid and must be 'integer' in class - {$meta->name}");
+                    }
+                    $config['position'] = $field;
+                } elseif (isset($mapping->{'sortable-group'})) {
+                    if (!isset($config['groups'])) {
+                        $config['groups'] = array();
+                    }
+                    $config['groups'][] = $field;
+                }
+            }
+        }
+    }
+
+    /**
+     * Checks if $field type is valid as Sluggable field
+     *
+     * @param ClassMetadata $meta
+     * @param string $field
+     * @return boolean
+     */
+    protected function isValidField($meta, $field)
+    {
+        $mapping = $meta->getFieldMapping($field);
+        return $mapping && in_array($mapping['type'], $this->validTypes);
+    }
+}

+ 97 - 0
lib/Gedmo/Sortable/Mapping/Driver/Yaml.php

@@ -0,0 +1,97 @@
+<?php
+
+namespace Gedmo\Sortable\Mapping\Driver;
+
+use Gedmo\Mapping\Driver\File,
+    Gedmo\Mapping\Driver,
+    Gedmo\Exception\InvalidMappingException;
+
+/**
+ * This is a yaml mapping driver for Sortable
+ * behavioral extension. Used for extraction of extended
+ * metadata from yaml specificaly for Sortable
+ * extension.
+ *
+ * @author Lukas Botsch <lukas.botsch@gmail.com>
+ * @package Gedmo.Sortable.Mapping.Driver
+ * @subpackage Yaml
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+class Yaml extends File implements Driver
+{
+    /**
+     * File extension
+     * @var string
+     */
+    protected $_extension = '.dcm.yml';
+
+    /**
+     * List of types which are valid for position fields
+     *
+     * @var array
+     */
+    private $validTypes = array(
+        'integer',
+        'smallint',
+        'bigint'
+    );
+
+    /**
+     * {@inheritDoc}
+     */
+    public function validateFullMetadata($meta, array $config)
+    {
+        if ($config && !isset($config['position'])) {
+            throw new InvalidMappingException("Missing property: 'position' in class - {$meta->name}");
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function readExtendedMetadata($meta, array &$config)
+    {
+        $mapping = $this->_getMapping($meta->name);
+
+        if (isset($mapping['fields'])) {
+            foreach ($mapping['fields'] as $field => $fieldMapping) {
+                if (isset($fieldMapping['gedmo'])) {
+
+                    if (in_array('sortablePosition', $fieldMapping['gedmo'])) {
+                        if (!$this->isValidField($meta, $field)) {
+                            throw new InvalidMappingException("Sortable position field - [{$field}] type is not valid and must be 'integer' in class - {$meta->name}");
+                        }
+                        $config['position'] = $field;
+                    } elseif (in_array('sortableGroup', $fieldMapping['gedmo'])) {
+                        if (!isset($config['groups'])) {
+                            $config['groups'] = array();
+                        }
+                        $config['groups'][] = $field;
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    protected function _loadMappingFile($file)
+    {
+        return \Symfony\Component\Yaml\Yaml::load($file);
+    }
+
+    /**
+     * Checks if $field type is valid as SortablePosition field
+     *
+     * @param ClassMetadata $meta
+     * @param string $field
+     * @return boolean
+     */
+    protected function isValidField($meta, $field)
+    {
+        $mapping = $meta->getFieldMapping($field);
+        return $mapping && in_array($mapping['type'], $this->validTypes);
+    }
+}

+ 22 - 0
lib/Gedmo/Sortable/Mapping/Event/Adapter/ODM.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace Gedmo\Sortable\Mapping\Event\Adapter;
+
+use Gedmo\Mapping\Event\Adapter\ODM as BaseAdapterODM;
+use Doctrine\ODM\MongoDB\Cursor;
+use Gedmo\Sluggable\Mapping\Event\SortableAdapter;
+
+/**
+ * Doctrine event adapter for ODM adapted
+ * for sortable behavior
+ *
+ * @author Lukas Botsch <lukas.botsch@gmail.com>
+ * @package Gedmo\Sortable\Mapping\Event\Adapter
+ * @subpackage ODM
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+final class ODM extends BaseAdapterODM implements SortableAdapter
+{
+    
+}

+ 22 - 0
lib/Gedmo/Sortable/Mapping/Event/Adapter/ORM.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace Gedmo\Sortable\Mapping\Event\Adapter;
+
+use Gedmo\Mapping\Event\Adapter\ORM as BaseAdapterORM;
+use Doctrine\ORM\Query;
+use Gedmo\Sortable\Mapping\Event\SortableAdapter;
+
+/**
+ * Doctrine event adapter for ORM adapted
+ * for sortable behavior
+ *
+ * @author Lukas Botsch <lukas.botsch@gmail.com>
+ * @package Gedmo\Sortable\Mapping\Event\Adapter
+ * @subpackage ORM
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+final class ORM extends BaseAdapterORM implements SortableAdapter
+{
+    
+}

+ 20 - 0
lib/Gedmo/Sortable/Mapping/Event/SortableAdapter.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace Gedmo\Sortable\Mapping\Event;
+
+use Gedmo\Mapping\Event\AdapterInterface;
+
+/**
+ * Doctrine event adapter interface
+ * for Sortable behavior
+ *
+ * @author Lukas Botsch <lukas.botsch@gmail.com>
+ * @package Gedmo\Sortable\Mapping\Event
+ * @subpackage SortableAdapter
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+interface SortableAdapter extends AdapterInterface
+{
+    
+}

+ 45 - 0
lib/Gedmo/Sortable/Sortable.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace Gedmo\Sortable;
+
+/**
+ * This interface is not necessary but can be implemented for
+ * Entities which in some cases needs to be identified as
+ * Sortable
+ * 
+ * @author Lukas Botsch <lukas.botsch@gmail.com>
+ * @package Gedmo.Sortable
+ * @subpackage Sortable
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+interface Sortable
+{
+    // use now annotations instead of predifined methods, this interface is not necessary
+    
+    /**
+     * @gedmo:SortablePosition - to mark property which will hold the item position use annotation @gedmo:SortablePosition
+     *              This property has to be numeric. The position index can be negative and will be counted from right to left.
+     * 
+     * example:
+     * 
+     * @gedmo:SortablePosition
+     * @Column(type="int")
+     * $position
+     * 
+     * @gedmo:SortableGroup
+     * @Column(type="string", length=64)
+     * $category
+     * 
+     */
+    
+    /**
+     * @gedmo:SortableGroup - to group node sorting by a property use annotation @gedmo:SortableGroup on this property
+     * 
+     * example:
+     * 
+     * @gedmo:SortableGroup
+     * @Column(type="string", length=64)
+     * $category
+     */
+}

+ 355 - 0
lib/Gedmo/Sortable/SortableListener.php

@@ -0,0 +1,355 @@
+<?php
+
+namespace Gedmo\Sortable;
+
+use Doctrine\Common\EventArgs;
+use Gedmo\Mapping\MappedEventSubscriber;
+use Gedmo\Sluggable\Mapping\Event\SortableAdapter;
+
+/**
+ * The SortableListener maintains a sort index on your entities
+ * to enable arbitrary sorting.
+ *
+ * This behavior can inpact the performance of your application
+ * since it does some additional calculations on persisted objects.
+ *
+ * @author Lukas Botsch <lukas.botsch@gmail.com>
+ * @subpackage SortableListener
+ * @package Gedmo.Sortable
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+class SortableListener extends MappedEventSubscriber
+{
+    private $relocations = array();
+    private $maxPositions = array();
+    
+    /**
+     * Specifies the list of events to listen
+     *
+     * @return array
+     */
+    public function getSubscribedEvents()
+    {
+        return array(
+            'prePersist',
+            'onFlush',
+            'loadClassMetadata'
+        );
+    }
+    
+    public function prePersist(EventArgs $eventArgs)
+    {
+        $em = $eventArgs->getEntityManager();
+        $object = $eventArgs->getEntity();
+        $meta = $em->getClassMetadata(get_class($object));
+        if ($config = $this->getConfiguration($em, $meta->name)) {
+            if (isset($config['position'])
+                    && is_null($meta->getReflectionProperty($config['position'])->getValue($object))) {
+                $meta->getReflectionProperty($config['position'])->setValue($object, -1);
+            }
+        }
+    }
+    
+    /**
+     * Mapps additional metadata
+     *
+     * @param EventArgs $eventArgs
+     * @return void
+     */
+    public function loadClassMetadata(EventArgs $eventArgs)
+    {
+        $ea = $this->getEventAdapter($eventArgs);
+        $this->loadMetadataForObjectClass($ea->getObjectManager(), $eventArgs->getClassMetadata());
+    }
+
+    /**
+     * Generate slug on objects being updated during flush
+     * if they require changing
+     *
+     * @param EventArgs $args
+     * @return void
+     */
+    public function onFlush(EventArgs $args)
+    {
+        $ea = $this->getEventAdapter($args);
+        $om = $ea->getObjectManager();
+        $uow = $om->getUnitOfWork();
+        
+        // process all objects beeing deleted
+        foreach ($ea->getScheduledObjectDeletions($uow) as $object) {
+            $meta = $om->getClassMetadata(get_class($object));
+            if ($config = $this->getConfiguration($om, $meta->name)) {
+                $this->processDeletion($om, $config, $meta, $object);
+            }
+        }
+        
+        // process all objects beeing updated
+        foreach ($ea->getScheduledObjectUpdates($uow) as $object) {
+            $meta = $om->getClassMetadata(get_class($object));
+            if ($config = $this->getConfiguration($om, $meta->name)) {
+                $this->processUpdate($om, $config, $meta, $object);
+            }
+        }
+        
+        // process all objects beeing inserted
+        foreach ($ea->getScheduledObjectInsertions($uow) as $object) {
+            $meta = $om->getClassMetadata(get_class($object));
+            if ($config = $this->getConfiguration($om, $meta->name)) {
+                $this->processInsert($om, $config, $meta, $object);
+            }
+        }
+        
+        $this->processRelocations($om);
+    }
+    
+    /**
+     * Computes node positions and updates the sort field in memory and in the db
+     * @param EntityManager $em
+     */
+    private function processInsert($em, $config, $meta, $object)
+    {
+        $uow = $em->getUnitOfWork();
+        
+        $newPosition = $meta->getReflectionProperty($config['position'])->getValue($object);
+        if (is_null($newPosition)) {
+            $newPosition = -1;
+        }
+        
+        // Get groups
+        $groups = array();
+        foreach ($config['groups'] as $group) {
+            $groups[$group] = $meta->getReflectionProperty($group)->getValue($object);
+        }
+        // Get hash
+        $hash = $this->getHash($meta, $groups, $object);
+        
+        // Get max position
+        if (!isset($this->maxPositions[$hash])) {
+            $this->maxPositions[$hash] = $this->getMaxPosition($em, $meta, $config, $object);
+        }
+        
+        // Compute position if it is negative
+        if ($newPosition < 0) {
+            $newPosition += $this->maxPositions[$hash] + 2; // position == -1 => append at end of list
+            if ($newPosition < 0) $newPosition = 0;
+        }
+        
+        // Set position to max position if it is too big
+        $newPosition = min(array($this->maxPositions[$hash] + 1, $newPosition));
+        
+        // Compute relocations
+        $relocation = array($hash, $meta, $groups, $newPosition, -1, +1);
+        
+        // Apply existing relocations
+        $applyDelta = 0;
+        if (isset($this->relocations[$hash])) {
+            foreach ($this->relocations[$hash]['deltas'] as $delta) {
+                if ($delta['start'] <= $newPosition
+                        && ($delta['stop'] > $newPosition || $delta['stop'] < 0)) {
+                    $applyDelta += $delta['delta'];
+                }
+            }
+        }
+        $newPosition += $applyDelta;
+        
+        // Add relocations
+        call_user_func_array(array($this, 'addRelocation'), $relocation);
+        
+        // Set new position
+        $meta->getReflectionProperty($config['position'])->setValue($object, $newPosition);
+        $uow->recomputeSingleEntityChangeSet($meta, $object);
+    }
+
+    /**
+     * Computes node positions and updates the sort field in memory and in the db
+     * @param EntityManager $em
+     */
+    private function processUpdate($em, $config, $meta, $object)
+    {
+        $uow = $em->getUnitOfWork();
+        
+        $oldData = $uow->getOriginalEntityData($object);
+        $oldPosition = $oldData[$config['position']];
+        $newPosition = $meta->getReflectionProperty($config['position'])->getValue($object);
+        
+        // Get groups
+        $groups = array();
+        foreach ($config['groups'] as $group) {
+            $groups[$group] = $meta->getReflectionProperty($group)->getValue($object);
+        }
+        // Get hash
+        $hash = $this->getHash($meta, $groups, $object);
+        
+        // Get max position
+        if (!isset($this->maxPositions[$hash])) {
+            $this->maxPositions[$hash] = $this->getMaxPosition($em, $meta, $config, $object);
+        }
+        
+        // Compute position if it is negative
+        if ($newPosition < 0) {
+            $newPosition += $this->maxPositions[$hash] + 2; // position == -1 => append at end of list
+            if ($newPosition < 0) $newPosition = 0;
+        }
+        
+        // Set position to max position if it is too big
+        $newPosition = min(array($this->maxPositions[$hash] + 1, $position));
+        
+        // Compute relocations
+        $relocations = array();
+        $relocations[] = array($hash, $meta, $groups, $oldPosition, $newPosition, -1);
+        $relocations[] = array($hash, $meta, $groups, $newPosition, -1, +1);
+        
+        // Apply existing relocations
+        $applyDelta = 0;
+        if (isset($this->relocations[$hash])) {
+            foreach ($this->relocations[$hash]['deltas'] as $delta) {
+                if ($delta['start'] <= $newPosition
+                        && ($delta['stop'] > $newPosition || $delta['stop'] < 0)) {
+                    $applyDelta += $delta['delta'];
+                }
+            }
+        }
+        $newPosition += $applyDelta;
+        
+        // Add relocations
+        foreach ($relocations as $relocation) {
+            call_user_func_array(array($this, 'addRelocation'), $relocation);
+        }
+        
+        // Set new position
+        $meta->getReflectionProperty($config['position'])->setValue($object, $newPosition);
+        $uow->recomputeSingleEntityChangeSet($meta, $object);
+    }
+    
+    /**
+     * Computes node positions and updates the sort field in memory and in the db
+     * @param EntityManager $em
+     */
+    private function processDeletion($em, $config, $meta, $object)
+    {
+        $position = $meta->getReflectionProperty($config['position'])->getValue($object);
+        
+        // Get groups
+        $groups = array();
+        foreach ($config['groups'] as $group) {
+            $groups[$group] = $meta->getReflectionProperty($group)->getValue($object);
+        }
+        // Get hash
+        $hash = $this->getHash($meta, $groups, $object);
+        
+        // Add relocation
+        $this->addRelocation($hash, $meta, $groups, $position, -1, -1);
+    }
+    
+    private function processRelocations($em)
+    {
+        foreach ($this->relocations as $hash => $relocation) {
+            $config = $this->getConfiguration($em, $relocation['name']);
+            foreach ($relocation['deltas'] as $delta) {
+                if ($delta['start'] > $this->maxPositions[$hash]) {
+                    continue;
+                }
+                $sign = $delta['delta'] < 0 ? "-" : "+";
+                $absDelta = abs($delta['delta']);
+                $qb = $em->createQueryBuilder();
+                $qb->update($relocation['name'], 'n')
+                   ->set("n.{$config['position']}", "n.{$config['position']} ".$sign." :delta")
+                   ->where("n.{$config['position']} >= :start")
+                   ->setParameter('delta', $absDelta)
+                   ->setParameter('start', $delta['start']);
+                if ($delta['stop'] > 0) {
+                    $qb->andWhere("n.{$config['position']} < :stop")
+                       ->setParameter('stop', $delta['stop']);
+                }
+                $i = 1;
+                foreach ($relocation['groups'] as $group => $val) {
+                    $qb->andWhere('n.'.$group." = :group".$i)
+                       ->setParameter('group'.$i, $val);
+                    $i++;
+                }
+                $qb->getQuery()->getResult();
+            }
+        }
+
+        // Clear relocations
+        $this->relocations = array();
+        $this->maxPositions = array();
+    }
+    
+    private function getHash($meta, $groups, $object)
+    {
+        $data = $meta->name;
+        foreach ($groups as $group => $val) {
+            if (is_object($val)) {
+                $val = spl_object_hash($val);
+            }
+            $data .= $group.$val;
+        }
+        return md5($data);
+    }
+    
+    private function getMaxPosition($em, $meta, $config, $object)
+    {
+        $maxPos = null;
+        $qb = $em->createQueryBuilder();
+        $qb->select('MAX(n.'.$config['position'].')')
+           ->from($meta->name, 'n');
+        $qb = $this->addGroupWhere($qb, $config["groups"], $meta, $object);
+        $query = $qb->getQuery();
+        $query->useQueryCache(false);
+        $query->useResultCache(false);
+        $res = $query->getResult();
+        $maxPos = $res[0][1];
+        if (is_null($maxPos)) $maxPos = -1;
+        return $maxPos;
+    }
+    
+    private function addGroupWhere($qb, $groups, $meta, $object)
+    {
+        $i = 1;
+        foreach ($groups as $group) {
+            //$qb->andWhere('n.'.$group." = '".$meta->getReflectionProperty($group)->getValue($object)."'");
+            $qb->andWhere('n.'.$group.' = :group'.$i);
+            $qb->setParameter('group'.$i, $meta->getReflectionProperty($group)->getValue($object));
+            $i++;
+        }
+        return $qb;
+    }
+    
+    /**
+     * Add a relocation rule
+     * @param string $hash The hash of the sorting group
+     * @param $meta The objects meta data
+     * @param array $groups The sorting groups
+     * @param int $start Inclusive index to start relocation from
+     * @param int $stop Exclusive index to stop relocation at
+     * @param int $delta The delta to add to relocated nodes
+     */
+    private function addRelocation($hash, $meta, $groups, $start, $stop, $delta)
+    {
+        if (!array_key_exists($hash, $this->relocations)) {
+            $this->relocations[$hash] = array('name' => $meta->name, 'groups' => $groups, 'deltas' => array());
+        }
+        
+        try {
+            $newDelta = array('start' => $start, 'stop' => $stop, 'delta' => $delta);
+            array_walk($this->relocations[$hash]['deltas'], function(&$val, $idx, $needle) {
+                if ($val['start'] == $needle['start'] && $val['stop'] == $needle['stop']) {
+                    $val['delta'] += $needle['delta'];
+                    throw new \Exception("Found delta. No need to add it again.");
+                }
+            }, $newDelta);
+            $this->relocations[$hash]['deltas'][] = $newDelta;
+        } catch (\Exception $e) {}
+    }
+    
+    /**
+     * {@inheritDoc}
+     */
+    protected function getNamespace()
+    {
+        return __NAMESPACE__;
+    }
+}
+    

+ 50 - 0
tests/Gedmo/Sortable/Fixture/Category.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace Sortable\Fixture;
+
+use Gedmo\Mapping\Annotation as Gedmo;
+use Doctrine\ORM\Mapping as ORM;
+use Doctrine\Common\Collections\ArrayCollection;
+
+/**
+ * @Entity
+ */
+class Category
+{
+    /**
+     * @ORM\Id
+     * @ORM\GeneratedValue
+     * @ORM\Column(type="integer")
+     */
+    private $id;
+    
+    /**
+     * @ORM\Column(type="string", length="255")
+     */
+    private $name;
+    
+    /**
+     * @ORM\OneToMany(targetEntity="Item", mappedBy="category")
+     */
+    private $items;
+    
+    public function __construct()
+    {
+        $this->items = new ArrayCollection();
+    }
+    
+    public function getId()
+    {
+        return $this->id;
+    }
+    
+    public function setName($name)
+    {
+        $this->name = $name;
+    }
+    
+    public function getName()
+    {
+        return $this->name;
+    }
+}

+ 72 - 0
tests/Gedmo/Sortable/Fixture/Item.php

@@ -0,0 +1,72 @@
+<?php
+
+namespace Sortable\Fixture;
+
+use Gedmo\Mapping\Annotation as Gedmo;
+use Doctrine\ORM\Mapping as ORM;
+
+/**
+ * @Entity
+ */
+class Item
+{
+    /**
+     * @ORM\Id
+     * @ORM\GeneratedValue
+     * @ORM\Column(type="integer")
+     */
+    private $id;
+    
+    /**
+     * @ORM\Column(type="string", length="255")
+     */
+    private $name;
+    
+    /**
+     * @Gedmo\SortablePosition
+     * @ORM\Column(type="integer")
+     */
+    private $position;
+    
+    /**
+     * @Gedmo\SortableGroup
+     * @ORM\ManyToOne(targetEntity="Category", inversedBy="items")
+     */
+    private $category;
+    
+    public function getId()
+    {
+        return $this->id;
+    }
+    
+    public function setName($name)
+    {
+        $this->name = $name;
+    }
+    
+    public function getName()
+    {
+        return $this->name;
+    }
+    
+    public function setPosition($position)
+    {
+        $this->position = $position;
+    }
+    
+    public function getPosition()
+    {
+        return $this->position;
+    }
+    
+    public function setCategory(Category $category)
+    {
+        $this->category = $category;
+    }
+    
+    public function getCategory()
+    {
+        return $this->category;
+    }
+}
+    

+ 71 - 0
tests/Gedmo/Sortable/Fixture/Node.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace Sortable\Fixture;
+
+use Gedmo\Mapping\Annotation as Gedmo;
+use Doctrine\ORM\Mapping as ORM;
+
+/**
+ * @Entity
+ */
+class Node
+{
+    /**
+     * @ORM\Id
+     * @ORM\GeneratedValue
+     * @ORM\Column(type="integer")
+     */
+    private $id;
+
+    /**
+     * @ORM\Column(type="string", length="255")
+     */
+    private $name;
+
+    /**
+     * @Gedmo\SortableGroup
+     * @ORM\Column(type="string", length="255")
+     */
+    private $path;
+    
+    /**
+     * @Gedmo\SortablePosition
+     * @ORM\Column(type="integer")
+     */
+    private $position;
+    
+    public function getId()
+    {
+        return $this->id;
+    }
+    
+    public function setName($name)
+    {
+        $this->name = $name;
+    }
+    
+    public function getName()
+    {
+        return $this->name;
+    }
+    
+    public function setPath($path)
+    {
+        $this->path = $path;
+    }
+    
+    public function getPath()
+    {
+        return $this->path;
+    }
+    
+    public function setPosition($position)
+    {
+        $this->position = $position;
+    }
+    
+    public function getPosition()
+    {
+        return $this->position;
+    }
+}

+ 175 - 0
tests/Gedmo/Sortable/SortableTest.php

@@ -0,0 +1,175 @@
+<?php
+
+namespace Gedmo\Sortable;
+
+use Doctrine\Common\EventManager;
+use Tool\BaseTestCaseORM;
+use Sortable\Fixture\Node;
+use Sortable\Fixture\Item;
+use Sortable\Fixture\Category;
+
+/**
+ * These are tests for sluggable behavior
+ *
+ * @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
+ * @package Gedmo.Sluggable
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+class SortableTest extends BaseTestCaseORM
+{
+    const NODE = 'Sortable\\Fixture\\Node';
+    const ITEM = 'Sortable\\Fixture\\Item';
+    const CATEGORY = 'Sortable\\Fixture\\Category';
+    private $nodeId;
+    
+    protected function setUp()
+    {
+        parent::setUp();
+        
+        $annotationReader = new \Doctrine\Common\Annotations\AnnotationReader();
+        $annotationReader->setAutoloadAnnotations(true);
+        $sortable = new SortableListener;
+        $sortable->setAnnotationReader($annotationReader);
+        
+        $evm = new EventManager;
+        $evm->addEventSubscriber($sortable);
+
+        $this->getMockSqliteEntityManager($evm);
+        //$this->startQueryLog();
+        
+        $this->populate();
+    }
+    
+    protected function tearDown()
+    {
+        //$this->stopQueryLog();
+    }
+    
+    public function testInsertedNewNode()
+    {
+        $node = $this->em->find(self::NODE, $this->nodeId);
+
+        //$this->assertTrue($node instanceof Sortable);
+        $this->assertEquals(0, $node->getPosition());
+    }
+    
+    public function testInsertSortedList()
+    {
+        for ($i = 2; $i <= 10; $i++) {
+            $node = new Node();
+            $node->setName("Node".$i);
+            $node->setPath("/");
+            $this->em->persist($node);
+        }
+        $this->em->flush();
+        $this->em->clear();
+        
+        $nodes = $this->em->createQuery("SELECT node FROM Sortable\Fixture\Node node "
+                                        ."WHERE node.path = :path ORDER BY node.position")
+                 ->setParameter('path', '/')
+                 ->getResult();
+        
+        $this->assertEquals(10, count($nodes));
+        $this->assertEquals('Node1', $nodes[0]->getName());
+    }
+    
+    public function testInsertUnsortedList()
+    {
+        $this->assertTrue(true);
+    }
+    
+    public function testGroupByAssociation()
+    {
+        $category1 = new Category();
+        $category1->setName("Category1");
+        $this->em->persist($category1);
+        $category2 = new Category();
+        $category2->setName("Category2");
+        $this->em->persist($category2);
+        $this->em->flush();
+        
+        $item3 = new Item();
+        $item3->setName("Item3");
+        $item3->setCategory($category1);
+        $this->em->persist($item3);
+        
+        $item4 = new Item();
+        $item4->setName("Item4");
+        $item4->setCategory($category1);
+        $this->em->persist($item4);
+        
+        $this->em->flush();
+        
+        $item1 = new Item();
+        $item1->setName("Item1");
+        $item1->setPosition(0);
+        $item1->setCategory($category1);
+        $this->em->persist($item1);
+        
+        $item2 = new Item();
+        $item2->setName("Item2");
+        $item2->setPosition(0);
+        $item2->setCategory($category1);
+        $this->em->persist($item2);
+        
+        $item2 = new Item();
+        $item2->setName("Item2_2");
+        $item2->setPosition(0);
+        $item2->setCategory($category2);
+        $this->em->persist($item2);
+        $this->em->flush();
+        
+        $item1 = new Item();
+        $item1->setName("Item1_2");
+        $item1->setPosition(0);
+        $item1->setCategory($category2);
+        $this->em->persist($item1);
+        $this->em->flush();
+        
+        $this->em->clear();
+        
+        $items = $this->em->createQuery("SELECT i, c FROM Sortable\Fixture\Item i JOIN i.category c ORDER BY c.name, i.position")
+                 ->getResult();
+        
+        $this->assertEquals("Item1", $items[0]->getName());
+        $this->assertEquals("Category1", $items[0]->getCategory()->getName());
+        
+        $this->assertEquals("Item2", $items[1]->getName());
+        $this->assertEquals("Category1", $items[1]->getCategory()->getName());
+        
+        $this->assertEquals("Item3", $items[2]->getName());
+        $this->assertEquals("Category1", $items[2]->getCategory()->getName());
+        
+        $this->assertEquals("Item4", $items[3]->getName());
+        $this->assertEquals("Category1", $items[3]->getCategory()->getName());
+        
+        $this->assertEquals("Item1_2", $items[4]->getName());
+        $this->assertEquals("Category2", $items[4]->getCategory()->getName());
+        
+        $this->assertEquals("Item2_2", $items[5]->getName());
+        $this->assertEquals("Category2", $items[5]->getCategory()->getName());
+    }
+    
+    protected function getUsedEntityFixtures()
+    {
+        return array(
+            self::NODE,
+            self::ITEM,
+            self::CATEGORY
+        );
+    }
+    
+    private function populate()
+    {
+        $node = new Node();
+        $node->setName("Node1");
+        $node->setPath("/");
+        
+        $this->em->persist($node);
+        $this->em->flush();
+        $this->em->clear();
+        $this->nodeId = $node->getId();
+    }
+}
+