0012032: path filter with fulltext search
authorPaul Mehrer <p.mehrer@metaways.de>
Fri, 6 May 2016 09:19:17 +0000 (11:19 +0200)
committerPhilipp Schüle <p.schuele@metaways.de>
Mon, 27 Mar 2017 14:59:31 +0000 (16:59 +0200)
https://forge.tine20.org/view.php?id=12032

Change-Id: I9b60b6877485326070e241ae70a805bbd90ac450
Reviewed-on: http://gerrit.tine20.com/customers/4230
Reviewed-by: Philipp Schüle <p.schuele@metaways.de>
Tested-by: Philipp Schüle <p.schuele@metaways.de>
15 files changed:
tests/tine20/Tinebase/Record/PathTest.php
tine20/Addressbook/Controller/Contact.php
tine20/Tinebase/Config.php
tine20/Tinebase/Config/Abstract.php
tine20/Tinebase/Controller/Record/Abstract.php
tine20/Tinebase/Frontend/Cli.php
tine20/Tinebase/Model/Filter/FullText.php
tine20/Tinebase/Model/PathFilter.php
tine20/Tinebase/Path/Backend/Sql.php
tine20/Tinebase/Record/Path.php
tine20/Tinebase/Record/RecordSet.php
tine20/Tinebase/Relations.php
tine20/Tinebase/Setup/Update/Release10.php
tine20/Tinebase/Setup/setup.xml
tine20/Tinebase/TransactionManager.php

index 7b448a2..9e85c21 100644 (file)
@@ -19,9 +19,25 @@ class Tinebase_Record_PathTest extends TestCase
      */
     protected $_fatherRecord = null;
 
+    protected $_oldConfig = null;
+
     protected function setUp()
     {
+        $mysqlSetup = new Setup_Backend_Mysql();
+        if (! Tinebase_Core::getDb() instanceof Zend_Db_Adapter_Pdo_Mysql || true !== $mysqlSetup->supports('mysql >= 5.6.4')) {
+            $this->markTestSkipped('mysql 5.6.4 or higher required');
+        }
+
         $this->_uit = Tinebase_Record_Path::getInstance();
+
+        if (true !== Tinebase_Config::getInstance()->featureEnabled(Tinebase_Config::FEATURE_SEARCH_PATH)) {
+            $features = Tinebase_Cache_PerRequest::getInstance()->load('Tinebase_Config_Abstract', 'Tinebase_Config_Abstract::featureEnabled', 'Tinebase');
+            $features->{Tinebase_Config::FEATURE_SEARCH_PATH} = true;
+        }
+
+        if (true !== Tinebase_Config::getInstance()->featureEnabled(Tinebase_Config::FEATURE_SEARCH_PATH)) {
+            throw new Exception('was not able to activate the feature search path');
+        }
         
         parent::setUp();
     }
@@ -32,12 +48,12 @@ class Tinebase_Record_PathTest extends TestCase
     public function testBuildRelationPathForRecord()
     {
         $contact = $this->_createFatherMotherChild();
-        $result = $this->_uit->generatePathForRecord($contact);
+        $result = $this->_uit->generatePathForRecord($contact, true);
         $this->assertTrue($result instanceof Tinebase_Record_RecordSet);
         $this->assertEquals(2, count($result), 'should find 2 paths for record. paths:' . print_r($result->toArray(), true));
 
         // check both paths
-        $expectedPaths = array('/grandparent/father/tester', '/mother/tester');
+        $expectedPaths = array('/grandparent{t}/father{t}/tester', '/mother{t}/tester');
         foreach ($expectedPaths as $expectedPath) {
             $this->assertTrue(in_array($expectedPath, $result->path), 'could not find path ' . $expectedPath . ' in '
                 . print_r($result->toArray(), true));
@@ -45,7 +61,7 @@ class Tinebase_Record_PathTest extends TestCase
 
         $result = $this->_uit->generatePathForRecord($this->_fatherRecord);
         $this->assertEquals(1, count($result), 'should find 1 path for record. paths:' . print_r($result->toArray(), true));
-        $this->assertEquals('/grandparent/father', $result->getFirstRecord()->path);
+        $this->assertEquals('/grandparent{t}/father', $result->getFirstRecord()->path);
     }
 
     protected function _createFatherMotherChild()
@@ -93,7 +109,7 @@ class Tinebase_Record_PathTest extends TestCase
             'own_backend'            => 'Sql',
             'own_id'                 => 0,
             'related_degree'         => Tinebase_Model_Relation::DEGREE_PARENT,
-            'type'                   => '',
+            'type'                   => 't',
             'related_backend'        => 'Sql',
             'related_id'             => $record->getId(),
             'related_model'          => 'Addressbook_Model_Contact',
@@ -132,13 +148,12 @@ class Tinebase_Record_PathTest extends TestCase
             'members'               => array($contact->getId()),
             'memberroles'           => $memberroles,
             'type'                  => Addressbook_Model_List::LISTTYPE_LIST,
-            'relations'             => array($this->_getParentRelationArray($this->_getFatherWithGrandfather()))
         ));
 
-        $recordPaths = $this->_uit->generatePathForRecord($contact);
+        $recordPaths = $this->_uit->generatePathForRecord($contact, true);
         $this->assertTrue($recordPaths instanceof Tinebase_Record_RecordSet);
         $this->assertEquals(2, count($recordPaths), 'should find 2 path for record. paths:' . print_r($recordPaths->toArray(), true));
-        $expectedPaths = array('/grandparent/father/my test group/my role/tester', '/grandparent/father/my test group/my second role/tester');
+        $expectedPaths = array('/my test group/my role/tester', '/my test group/my second role/tester');
         foreach ($expectedPaths as $expectedPath) {
             $this->assertTrue(in_array($expectedPath, $recordPaths->path), 'could not find path ' . $expectedPath . ' in '
                 . print_r($recordPaths->toArray(), true));
@@ -159,7 +174,7 @@ class Tinebase_Record_PathTest extends TestCase
             'relations' => array($relation1)
         )));
 
-        $recordPaths = $this->_uit->getPathsForRecords($contact);
+        $recordPaths = $this->_uit->getPathsForRecords($contact, true);
         $this->assertEquals(1, count($recordPaths));
 
         $motherRecord = Addressbook_Controller_Contact::getInstance()->create(new Addressbook_Model_Contact(array(
@@ -176,7 +191,7 @@ class Tinebase_Record_PathTest extends TestCase
         $this->assertEquals(2, count($recordPaths));
 
         // check both paths
-        $expectedPaths = array('/grandparent/father/tester', '/mother/tester');
+        $expectedPaths = array('/grandparent{t}/father{t}/tester', '/mother{t}/tester');
         foreach ($expectedPaths as $expectedPath) {
             $this->assertTrue(in_array($expectedPath, $recordPaths->path), 'could not find path ' . $expectedPath . ' in '
                 . print_r($recordPaths->toArray(), true));
@@ -192,6 +207,10 @@ class Tinebase_Record_PathTest extends TestCase
     {
         $contact = $this->testTriggerRebuildPathForRecords();
 
+        // due to full text we need to commit here!
+        //Tinebase_TransactionManager::getInstance()->commitTransaction($this->_transactionId);
+        //$this->_transactionId = null;
+
         // change contact name and check path in related records
         $this->_fatherRecord->n_family = 'stepfather';
         Addressbook_Controller_Contact::getInstance()->update($this->_fatherRecord);
@@ -200,11 +219,13 @@ class Tinebase_Record_PathTest extends TestCase
         $this->assertEquals(2, count($recordPaths));
 
         // check both paths again
-        $expectedPaths = array('/grandparent/stepfather/tester', '/mother/tester');
+        $expectedPaths = array('/grandparent{t}/stepfather{t}/tester', '/mother{t}/tester');
         foreach ($expectedPaths as $expectedPath) {
             $this->assertTrue(in_array($expectedPath, $recordPaths->path), 'could not find path ' . $expectedPath . ' in '
                 . print_r($recordPaths->toArray(), true));
         }
+
+        // TODO we should clean up here?!?
     }
 
     /**
@@ -233,7 +254,7 @@ class Tinebase_Record_PathTest extends TestCase
         $this->assertEquals(1, count($recordPaths));
 
         // check remaining path again
-        $expectedPaths = array('/mother/tester');
+        $expectedPaths = array('/mother{t}/tester');
         foreach ($expectedPaths as $expectedPath) {
             $this->assertTrue(in_array($expectedPath, $recordPaths->path), 'could not find path ' . $expectedPath . ' in '
                 . print_r($recordPaths->toArray(), true));
@@ -248,8 +269,6 @@ class Tinebase_Record_PathTest extends TestCase
         $this->testBuildGroupMemberPathForContact();
 
         $filterValues = array(
-            'father' => 2,
-            'grandparent' => 3,
             'my test group' => 1,
             'my role' => 1,
             'somemail@example.ru' => 1
@@ -282,16 +301,16 @@ class Tinebase_Record_PathTest extends TestCase
         $this->testBuildGroupMemberPathForContact();
 
         $adbJson = new Addressbook_Frontend_Json();
-        $filter = $this->_getPathFilterArray('father');
+        $filter = $this->_getPathFilterArray('my role');
 
         $result = $adbJson->searchContacts($filter, array());
 
-        $this->assertEquals(2, $result['totalcount'], print_r($result['results'], true));
+        $this->assertEquals(1, $result['totalcount'], print_r($result['results'], true));
         $firstRecord = $result['results'][0];
         $this->assertTrue(isset($firstRecord['paths']), 'paths should be set in record' . print_r($firstRecord, true));
         // sometimes only 1 path is resolved. this is a little bit strange ...
         $this->assertGreaterThan(0, count($firstRecord['paths']), print_r($firstRecord['paths'], true));
-        $this->assertContains('/grandparent', $firstRecord['paths'][0]['path'], 'could not find grandparent in paths of record' . print_r($firstRecord, true));
+        $this->assertContains('/my test group', $firstRecord['paths'][0]['path'], 'could not find my test group in paths of record' . print_r($firstRecord, true));
     }
 
     public function testPathWithDifferentTypeRelations()
@@ -314,7 +333,7 @@ class Tinebase_Record_PathTest extends TestCase
 
         // check the 3 paths
         $this->assertEquals(3, count($recordPaths), 'paths: ' . print_r($recordPaths->toArray(), true));
-        $expectedPaths = array('/grandparent/father/tester', '/mother/tester', '/grandparent/father{type}/tester');
+        $expectedPaths = array('/grandparent{t}/father{t}/tester', '/mother{t}/tester', '/grandparent{t}/father{type}/tester');
         foreach ($expectedPaths as $expectedPath) {
             $this->assertTrue(in_array($expectedPath, $recordPaths->path), 'could not find path ' . $expectedPath . ' in '
                 . print_r($recordPaths->toArray(), true));
index bf3c685..5250c5b 100644 (file)
@@ -870,6 +870,10 @@ class Addressbook_Controller_Contact extends Tinebase_Controller_Record_Abstract
      *      - lists contact is member of
      *      - we add list role memberships
      *
+     * TODO ACLs?!?
+     * * Addressbook_Controller_List::getInstance()->get() will check for ACLs
+     * * Addressbook_Controller_ListRole::getInstance()->get() will check for ACLs
+     *
      * @param Tinebase_Record_Abstract $record
      * @return Tinebase_Record_RecordSet
      */
@@ -882,6 +886,11 @@ class Addressbook_Controller_Contact extends Tinebase_Controller_Record_Abstract
         foreach ($listIds as $listId) {
             /** @var Addressbook_Model_List $list */
             $list = Addressbook_Controller_List::getInstance()->get($listId);
+
+            /**
+             * TODO
+             * what if this would return the $list->memberroles paths too? we would double create them!
+             */
             $listPaths = $this->_getPathsOfRecord($list);
             if (count($listPaths) === 0) {
                 // add self
index 83c63d0..08dac49 100644 (file)
@@ -5,7 +5,7 @@
  * @package     Tinebase
  * @subpackage  Config
  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
- * @copyright   Copyright (c) 2007-2014 Metaways Infosystems GmbH (http://www.metaways.de)
+ * @copyright   Copyright (c) 2007-2017 Metaways Infosystems GmbH (http://www.metaways.de)
  * @author      Philipp Schüle <p.schuele@metaways.de>
  * 
  */
@@ -147,6 +147,13 @@ class Tinebase_Config extends Tinebase_Config_Abstract
     const FEATURE_REMEMBER_POPUP_SIZE = 'featureRememberPopupSize';
 
     /**
+     * FEATURE_PATH
+     *
+     * @var string
+     */
+    const FEATURE_SEARCH_PATH = 'featureSearchPath';
+
+    /**
      * user defined page title postfix for browser page title
      * 
      * @var string
@@ -851,12 +858,18 @@ class Tinebase_Config extends Tinebase_Config_Abstract
                     'description'   => 'Save edit dialog size in state',
                     //_('Save edit dialog size in state')
                 ),
+                self::FEATURE_SEARCH_PATH => array(
+                    'label'         => 'Search Paths',
+                    'description'   => 'Search Paths'
+                ),
             ),
             'default'               => array(
                 self::FEATURE_SHOW_ADVANCED_SEARCH  => true,
                 self::FEATURE_CONTAINER_CUSTOM_SORT => true,
                 self::FEATURE_SHOW_ACCOUNT_EMAIL    => true,
                 self::FEATURE_REMEMBER_POPUP_SIZE   => true,
+                self::FEATURE_SEARCH_PATH           => true,
+
             ),
         ),
         self::CRONUSERID => array(
index ba21f14..a638a8f 100644 (file)
@@ -739,6 +739,10 @@ abstract class Tinebase_Config_Abstract implements Tinebase_Config_Interface
         }
 
         if (isset($features->{$featureName})) {
+            if (Tinebase_Config::FEATURE_SEARCH_PATH === $featureName && 'Tinebase' === $this->_appName &&
+                !Setup_Backend_Factory::factory()->supports('mysql >= 5.6.4')) {
+                return false;
+            }
             return $features->{$featureName};
         }
 
index c5bdf12..f4e63f9 100644 (file)
@@ -1082,7 +1082,7 @@ abstract class Tinebase_Controller_Record_Abstract
 
         // rebuild paths if relations where set or if pathPart changed
         if (true === $this->_useRecordPaths && (true === $pathPartChanged || true === $relationsTouched)) {
-            $this->_rebuildRelationPaths($updatedRecord, $record, $currentRecord, $relationsTouched);
+            $this->_rebuildRelationPaths($updatedRecord, $currentRecord, $relationsTouched);
         }
 
         if ($record->has('tags') && isset($record->tags) && (is_array($record->tags) || $record->tags instanceof Tinebase_Record_RecordSet)) {
@@ -1106,19 +1106,22 @@ abstract class Tinebase_Controller_Record_Abstract
 
     /**
      * @param Tinebase_Record_Interface $updatedRecord
-     * @param Tinebase_Record_Interface $record
-     * @param Tinebase_Record_Interface $currentRecord
+     * @param Tinebase_Record_Interface $currentRecord the record before the update including relatedData / relations (but only those visible to the current user)
      * @param boolean $relationsTouched
      */
-    protected function _rebuildRelationPaths($updatedRecord, $record, $currentRecord, $relationsTouched)
+    protected function _rebuildRelationPaths($updatedRecord, $currentRecord, $relationsTouched)
     {
+        if (true !== Tinebase_Config::getInstance()->featureEnabled(Tinebase_Config::FEATURE_SEARCH_PATH)) {
+            return;
+        }
+
         // rebuild own paths
         $this->buildPath($updatedRecord);
 
         // rebuild paths of children that have been added or removed
         if ($relationsTouched) {
             //we need to do this to reload the relations in the next line...
-            $record->relations = null;
+            $updatedRecord->relations = null;
             $newChildRelations = Tinebase_Relations::getInstance()->getRelationsOfRecordByDegree($updatedRecord, Tinebase_Model_Relation::DEGREE_CHILD);
             if (null === $currentRecord) {
                 $oldChildRelations = new Tinebase_Record_RecordSet('Tinebase_Model_Relation');
@@ -2244,8 +2247,9 @@ abstract class Tinebase_Controller_Record_Abstract
     }
 
     /**
-<<<<<<< HEAD
-     * returns path of record
+     * returns paths of record
+     *
+     * ACL check will be disabled in this function to really take all relations into account
      *
      * @param Tinebase_Record_Interface     $record
      * @param boolean|int                   $depth
@@ -2261,7 +2265,13 @@ abstract class Tinebase_Controller_Record_Abstract
 
         $result = new Tinebase_Record_RecordSet('Tinebase_Model_Path');
 
-        $parentRelations = Tinebase_Relations::getInstance()->getRelationsOfRecordByDegree($record, Tinebase_Model_Relation::DEGREE_PARENT);
+        // we want all relations / ignoreACL, so we need to force a reload of relations
+        $oldRelations = $record->relations;
+        $record->relations = null;
+        $parentRelations = Tinebase_Relations::getInstance()->getRelationsOfRecordByDegree($record, Tinebase_Model_Relation::DEGREE_PARENT, true);
+        // restore normal relations again
+        $record->relations = $oldRelations;
+
         foreach ($parentRelations as $parent) {
 
             if (!is_object($parent->related_record)) {
@@ -2313,7 +2323,13 @@ abstract class Tinebase_Controller_Record_Abstract
     {
         $type = $this->_getTypeForPathPart($relation);
 
-        return $type . '/' . mb_substr(str_replace('/', '', trim($record->getTitle())), 0, 32);
+        /**
+         * TODO: test in all dbms for full text search
+         * example: Pfarrei Altona{PASTORAL}/Lise Müller
+         * search for "PASTORAL Müller" needs to return this ... if not, we may need to add spaces around the
+         * special chars, but probably not
+         */
+        return $type . '/' . mb_substr(str_replace(array('/', '{', '}'), '', trim($record->getTitle())), 0, 1024);
     }
 
     protected function _getTypeForPathPart($relation)
index f1de51e..8eaf406 100644 (file)
@@ -72,6 +72,11 @@ class Tinebase_Frontend_Cli extends Tinebase_Frontend_Cli_Abstract
             return -1;
         }
 
+        if (true !== Tinebase_Config::getInstance()->featureEnabled(Tinebase_Config::FEATURE_SEARCH_PATH)) {
+            Tinebase_Core::getLogger()->crit(__METHOD__ . '::' . __LINE__ . ' search paths are not enabled');
+            return -1;
+        }
+
         $applications = Tinebase_Application::getInstance()->getApplications();
         foreach($applications as $application) {
             try {
index f4b5224..61994f8 100644 (file)
@@ -5,7 +5,7 @@
  * @package     Tinebase
  * @subpackage  Filter
  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
- * @copyright   Copyright (c) 2016 Metaways Infosystems GmbH (http://www.metaways.de)
+ * @copyright   Copyright (c) 2016-2017 Metaways Infosystems GmbH (http://www.metaways.de)
  * @author      Paul Mehrer <p.mehrer@metaways.de>
  */
 
index be350a0..f3b252d 100644 (file)
@@ -35,6 +35,6 @@ class Tinebase_Model_PathFilter extends Tinebase_Model_Filter_FilterGroup
         'id'             => array('filter' => 'Tinebase_Model_Filter_Id'),
         'query'          => array('filter' => 'Tinebase_Model_Filter_Query', 'options' => array('fields' => array('path'))),
         'record_id'      => array('filter' => 'Tinebase_Model_Filter_Id'),
-        'path'           => array('filter' => 'Tinebase_Model_Filter_Text'),
+        'path'           => array('filter' => 'Tinebase_Model_Filter_FullText'),
     );
 }
index d9053fc..35bb209 100644 (file)
@@ -39,29 +39,636 @@ class Tinebase_Path_Backend_Sql extends Tinebase_Backend_Sql_Abstract
      */
     protected $_modelName = 'Tinebase_Model_Path';
 
+    protected static $_modelStore = array();
+    protected static $_rawStore = array();
+    protected static $_shadowPathMapping = array();
+    protected static $_newIds = array();
+    protected static $_toDelete = array();
+
+    protected static $_registeredCallback = false;
+
+    protected static $_delayDisabled = false;
+
+
+    /***
+     ** get methods
+     **
+     ***/
+
+    /**
+     * Gets one entry (by id)
+     *
+     * @param integer|Tinebase_Record_Interface $_id
+     * @param $_getDeleted get deleted records
+     * @return Tinebase_Record_Interface
+     * @throws Tinebase_Exception_NotFound
+     */
+    public function get($_id, $_getDeleted = FALSE)
+    {
+        if (true !== static::$_delayDisabled) {
+            if (isset(static::$_modelStore[$_id])) {
+                return static::$_modelStore[$_id];
+            }
+            if (isset(static::$_rawStore[$_id])) {
+                static::$_modelStore[$_id] = $ret = new Tinebase_Model_Path(static::$_rawStore[$_id]);
+                unset(static::$_rawStore[$_id]);
+                return $ret;
+            }
+            if (isset(static::$_toDelete[$_id])) {
+                throw new Tinebase_Exception_NotFound('path ' . $_id . ' was already deleted');
+            }
+        }
+
+        return parent::get($_id, $_getDeleted);
+    }
+
+    /**
+     * Gets all entries
+     *
+     * @param string $_orderBy Order result by
+     * @param string $_orderDirection Order direction - allowed are ASC and DESC
+     * @throws Tinebase_Exception_InvalidArgument
+     * @throws Tinebase_Exception_NotImplemented
+     * @return Tinebase_Record_RecordSet
+     */
+    public function getAll($_orderBy = NULL, $_orderDirection = 'ASC')
+    {
+        if (true === static::$_delayDisabled || (count(static::$_shadowPathMapping) == 0 && count(static::$_toDelete) == 0)) {
+            return parent::getAll($_orderBy, $_orderDirection);
+        }
+
+        throw new Tinebase_Exception_NotImplemented('paths don\'t support getAll for in memory operations');
+    }
+
+    /**
+     * Gets one entry (by property)
+     *
+     * @param  mixed  $value
+     * @param  string $property
+     * @param  bool   $getDeleted
+     * @return Tinebase_Record_Interface
+     * @throws Tinebase_Exception_NotFound
+     */
+    public function getByProperty($value, $property = 'name', $getDeleted = FALSE)
+    {
+        if (true === static::$_delayDisabled || (count(static::$_shadowPathMapping) == 0 && count(static::$_toDelete) == 0)) {
+            return parent::getByProperty($value, $property, $getDeleted);
+        }
+
+        if (count(static::$_modelStore) > 0) {
+            return current(static::$_modelStore);
+        }
+        if (count(static::$_rawStore) > 0) {
+            list($id, $data) = each(static::$_rawStore);
+            $ret = static::$_modelStore[$id] = new Tinebase_Model_Path($data);
+            unset(static::$_rawStore[$id]);
+            return $ret;
+        }
+
+        $ret = parent::getByProperty($value, $property, $getDeleted);
+        if (!isset(static::$_toDelete[$ret->getId()])) {
+            return $ret;
+        }
+
+        $result = parent::getMultipleByProperty($value, $property, $getDeleted);
+        foreach($result as $ret) {
+            if (!isset(static::$_toDelete[$ret->getId()])) {
+                return $ret;
+            }
+        }
+
+        throw new Tinebase_Exception_NotFound('nothing found');
+    }
+
+    /**
+     * Get multiple entries
+     *
+     * @param string|array $_id Ids
+     * @param array $_containerIds all allowed container ids that are added to getMultiple query
+     * @return Tinebase_Record_RecordSet
+     *
+     */
+    public function getMultiple($_id, $_containerIds = NULL)
+    {
+        $parentResult = parent::getMultiple($_id, $_containerIds);
+
+        if (true === static::$_delayDisabled || (count(static::$_shadowPathMapping) == 0 && count(static::$_toDelete) == 0)) {
+            return $parentResult;
+        }
+
+        // filter out any emtpy values
+        $ids = array_filter((array) $_id, function($value) {
+            return !empty($value);
+        });
+
+        if (empty($ids)) {
+            return $parentResult;
+        }
+
+        foreach ($ids as $id) {
+            // replace objects with their id's
+            if ($id instanceof Tinebase_Record_Interface) {
+                $id = $id->getId();
+            }
+
+            if (isset(static::$_modelStore[$id])) {
+                $parentResult->removeById($id);
+                $parentResult->addRecord(static::$_modelStore[$id]);
+            } elseif (isset(static::$_rawStore[$id])) {
+                $parentResult->removeById($id);
+                static::$_modelStore[$id] = $record = new Tinebase_Model_Path(static::$_rawStore[$id]);
+                unset(static::$_rawStore[$_id]);
+                $parentResult->addRecord($record);
+            } elseif(isset(static::$_toDelete[$id])) {
+                $parentResult->removeById($id);
+            }
+        }
+
+        return $parentResult;
+    }
+
+    /**
+     * gets multiple entries (by property)
+     *
+     * @param  mixed  $_value
+     * @param  string $_property
+     * @param  bool   $_getDeleted
+     * @param  string $_orderBy        defaults to $_property
+     * @param  string $_orderDirection defaults to 'ASC'
+     * @return Tinebase_Record_RecordSet
+     */
+    public function getMultipleByProperty($_value, $_property='name', $_getDeleted = FALSE, $_orderBy = NULL, $_orderDirection = 'ASC')
+    {
+        $parentResult = parent::getMultipleByProperty($_value, $_property, $_getDeleted, $_orderBy, $_orderDirection);
+
+        if (true === static::$_delayDisabled || (count(static::$_shadowPathMapping) == 0 && count(static::$_toDelete) == 0)) {
+            return $parentResult;
+        }
+
+        if (count(static::$_toDelete) > 0) {
+            foreach (array_keys(static::$_toDelete) as $id) {
+                $parentResult->removeById($id);
+            }
+        }
+
+        if (count(static::$_shadowPathMapping) > 0) {
+            foreach((array)$_value as $value) {
+                foreach (static::$_modelStore as $id => $record) {
+                    if (strcmp((string)$value, (string)($record->{$_property})) === 0) {
+                        $parentResult->removeById($record->getId());
+                        $parentResult->addRecord($record);
+                    }
+                }
+
+                foreach (static::$_rawStore as $id => $data) {
+                    if (isset($data[$_property]) && strcmp((string)$value, (string)($data[$_property])) === 0) {
+                        $parentResult->removeById($data['id']);
+                        $parentResult->addRecord(new Tinebase_Model_Path($data));
+                    }
+                }
+            }
+        }
+
+        return $parentResult;
+    }
+
+    /**
+     * fetch a single property for all records defined in array of $ids
+     *
+     * @param array|string $ids
+     * @param string $property
+     * @return array (key = id, value = property value)
+     */
+    public function getPropertyByIds($ids, $property)
+    {
+        if (true === static::$_delayDisabled || (count(static::$_shadowPathMapping) == 0 && count(static::$_toDelete) == 0)) {
+            return parent::getPropertyByIds($ids, $property);
+        }
+        throw new Tinebase_Exception_NotImplemented('paths don\'t support getPropertyByIds for in memory operations');
+    }
+
+    /***
+     ** create, update, delete methods
+     **
+     ***/
+
+    /**
+     * Creates new entry
+     *
+     * @param   Tinebase_Record_Interface $_record
+     * @return  Tinebase_Record_Interface
+     * @throws  Tinebase_Exception_InvalidArgument
+     * @throws  Tinebase_Exception_UnexpectedValue
+     */
+    public function create(Tinebase_Record_Interface $_record)
+    {
+        if (true === static::$_delayDisabled) {
+            return parent::create($_record);
+        }
+
+        if (!$_record instanceof $this->_modelName) {
+            throw new Tinebase_Exception_InvalidArgument('invalid model type: $_record is instance of "' . get_class($_record) . '". but should be instance of ' . $this->_modelName);
+        }
+
+        $this->_registerCallBacks();
+
+        $identifier = $_record->getIdProperty();
+        // set uid if id is empty
+        if (empty($_record->$identifier)) {
+            $_record->setId($_record->generateUID());
+        }
+
+        $this->_addToModelStore($_record);
+
+        return $_record;
+    }
+
+    /**
+     * Updates existing entry
+     *
+     * @param Tinebase_Record_Interface $_record
+     * @throws Tinebase_Exception_Record_Validation|Tinebase_Exception_InvalidArgument
+     * @return Tinebase_Record_Interface Record|NULL
+     */
+    public function update(Tinebase_Record_Interface $_record)
+    {
+        if (true === static::$_delayDisabled) {
+            return parent::update($_record);
+        }
+
+        $this->_registerCallBacks();
+        $this->_addToModelStore($_record, true);
+
+        return $_record;
+    }
+
+    /**
+     * Updates multiple entries
+     *
+     * @param array $_ids to update
+     * @param array $_data
+     * @return integer number of affected rows
+     * @throws Tinebase_Exception_Record_Validation|Tinebase_Exception_InvalidArgument
+     */
+    public function updateMultiple($_ids, $_data)
+    {
+        if (true === static::$_delayDisabled) {
+            return parent::updateMultiple($_ids, $_data);
+        }
+
+        throw new Tinebase_Exception_NotImplemented('paths don\'t support updateMultiple for in memory operations');
+    }
+
+    /**
+     * Deletes entries
+     *
+     * @param string|integer|Tinebase_Record_Interface|array $_id
+     * @return void
+     * @return int The number of affected rows.
+     */
+    public function delete($_id)
+    {
+        if (true === static::$_delayDisabled) {
+            parent::delete($_id);
+            return;
+        }
+
+        $idArray = (! is_array($_id)) ? array(Tinebase_Record_Abstract::convertId($_id, $this->_modelName)) : $_id;
+
+        parent::delete($idArray);
+
+        $this->_registerCallBacks();
+
+        foreach($idArray as $id) {
+            if (isset(static::$_modelStore[$id])) {
+                unset(static::$_shadowPathMapping[static::$_modelStore[$id]->shadow_path]);
+                unset(static::$_modelStore[$id]);
+            }
+            if (isset(static::$_rawStore[$id])) {
+                unset(static::$_shadowPathMapping[static::$_rawStore[$id]['shadow_path']]);
+                unset(static::$_rawStore[$id]);
+            }
+            unset(static::$_newIds[$id]);
+            static::$_toDelete[$id] = true;
+        }
+    }
+
+    /**
+     * delete rows by property
+     *
+     * @param string|array $_value
+     * @param string $_property
+     * @param string $_operator (equals|in)
+     * @return integer The number of affected rows.
+     * @throws Tinebase_Exception_InvalidArgument
+     * @throws Tinebase_Exception_NotImplemented
+     */
+    public function deleteByProperty($_value, $_property, $_operator = 'equals')
+    {
+        if (true === static::$_delayDisabled) {
+            return parent::deleteByProperty($_value, $_property, $_operator);
+        }
+
+        parent::deleteByProperty($_value, $_property, $_operator);
+
+        if ($_operator !== 'in') {
+            $_value = array($_value);
+        }
+
+        foreach ((array)$_value as $value) {
+            $this->_deleteInStoreByProp($value, $_property);
+        }
+    }
+
+
+    /***
+     ** search methods
+     **
+     ***/
+
+    /**
+     * Gets total count of search with $_filter
+     *
+     * @param Tinebase_Model_Filter_FilterGroup $_filter
+     * @return int|array
+     */
+    public function searchCount(Tinebase_Model_Filter_FilterGroup $_filter)
+    {
+        return $this->search($_filter)->count();
+    }
+
+    /**
+     * Search for records matching given filter
+     *
+     * @param  Tinebase_Model_Filter_FilterGroup    $_filter
+     * @param  Tinebase_Model_Pagination            $_pagination
+     * @param  array|string|boolean                 $_cols columns to get, * per default / use self::IDCOL or TRUE to get only ids
+     * @return Tinebase_Record_RecordSet|array
+     */
+    public function search(Tinebase_Model_Filter_FilterGroup $_filter = NULL, Tinebase_Model_Pagination $_pagination = NULL, $_cols = '*')
+    {
+        if (true !== static::$_delayDisabled && count(static::$_shadowPathMapping) > 0) {
+            if (! $_filter instanceof Tinebase_Model_PathFilter) {
+                throw new Tinebase_Exception_NotImplemented('paths only supports Tinebase_Model_PathFilter for in memory operations');
+            }
+
+            $filters = $_filter->getFilterObjects();
+            if (count($filters) > 1) {
+                throw new Tinebase_Exception_NotImplemented('paths don\'t support complex filters for in memory operations');
+            }
+            reset($filters);
+            /**
+             * @var $filter Tinebase_Model_Filter_Abstract
+             */
+            $filter = current($filters);
+
+            $field = $filter->getField();
+            if ($field === 'query') {
+                $field = 'path';
+            }
+            $operator = $filter->getOperator();
+            $values = $filter->getValue();
+            if (!is_array($values)) {
+                $values = array($values);
+            }
+            if ($field === 'path') {
+                $searchValues = array();
+                foreach($values as $value) {
+                    //replace full text meta characters
+                    //$value = str_replace(array('+', '-', '<', '>', '~', '*', '(', ')', '"'), ' ', $value);
+                    $value = preg_replace('#[^\w\d ]|_#u', ' ', $value);
+                    // replace multiple spaces with just one
+                    $value = preg_replace('# +#u', ' ', trim($value));
+                    $searchValues = array_merge($searchValues, explode(' ', $value));
+                }
+                $values = $searchValues;
+            }
+            $values = array_filter($values);
+        }
+
+        $parentResult = parent::search($_filter, $_pagination, $_cols);
+
+        if (true === static::$_delayDisabled) {
+            return $parentResult;
+        }
+
+        if (count(static::$_toDelete) > 0) {
+            foreach (array_keys(static::$_toDelete) as $id) {
+                $parentResult->removeById($id);
+            }
+        }
+
+        if (count(static::$_shadowPathMapping) > 0 && count($values) > 0) {
+
+            $rawRemove = array();
+            if ($operator === 'equals') {
+                foreach (static::$_modelStore as $id => $record) {
+                    $parentResult->removeById($id);
+                    foreach($values as $value) {
+                        if (mb_stripos($record->{$field}, $value) === 0 && strlen($record->{$field}) === strlen($value)) {
+                            $parentResult->addRecord($record);
+                            break;
+                        }
+                    }
+                }
+                foreach (static::$_rawStore as $id => $data) {
+                    $parentResult->removeById($id);
+                    foreach($values as $value) {
+                        if (mb_stripos($data[$field], $value) === 0 && strlen($data[$field]) === strlen($value)) {
+                            static::$_modelStore[$id] = $record = new Tinebase_Model_Path($data);
+                            $rawRemove[] = $id;
+                            $parentResult->addRecord($record);
+                            break;
+                        }
+                    }
+                }
+
+            } elseif($operator === 'contains') {
+
+                // we may need to do path magic here, because of full text
+                if ($field === 'path') {
+                    foreach (static::$_modelStore as $id => $record) {
+                        $parentResult->removeById($id);
+                        $fvalue = preg_replace('# +#u', ' ', trim(preg_replace('#[^\w\d ]|_#u', ' ', $record->{$field})));
+                        $success = true;
+                        foreach($values as $value) {
+                            if (mb_stripos($fvalue, $value) === false) {
+                                $success = false;
+                                break;
+                            }
+                        }
+                        if (true === $success) {
+                            $parentResult->addRecord($record);
+                        }
+                    }
+                    foreach (static::$_rawStore as $id => $data) {
+                        $parentResult->removeById($id);
+                        $fvalue = preg_replace('# +#u', ' ', trim(preg_replace('#[^\w\d ]|_#u', ' ', $data[$field])));
+                        $success = true;
+                        foreach($values as $value) {
+                            if (mb_stripos($fvalue, $value) === false) {
+                                $success = false;
+                                break;
+                            }
+                        }
+                        if (true === $success) {
+                            static::$_modelStore[$id] = $record = new Tinebase_Model_Path($data);
+                            $rawRemove[] = $id;
+                            $parentResult->addRecord($record);
+                        }
+                    }
+
+                } else {
+                    foreach (static::$_modelStore as $id => $record) {
+                        $parentResult->removeById($id);
+                        foreach($values as $value) {
+                            if (mb_stripos($record->{$field}, $value) !== false) {
+                                $parentResult->addRecord($record);
+                                break;
+                            }
+                        }
+                    }
+                    foreach (static::$_rawStore as $id => $data) {
+                        $parentResult->removeById($id);
+                        foreach($values as $value) {
+                            if (mb_stripos($data[$field], $value) !== false) {
+                                static::$_modelStore[$id] = $record = new Tinebase_Model_Path($data);
+                                $rawRemove[] = $id;
+                                $parentResult->addRecord($record);
+                                break;
+                            }
+                        }
+                    }
+                }
+
+            } elseif($operator === 'in') {
+                foreach (static::$_modelStore as $id => $record) {
+                    $parentResult->removeById($id);
+                    foreach($values as $value) {
+                        if (mb_stripos($record->{$field}, $value) === 0 && strlen($record->{$field}) === strlen($value)) {
+                            $parentResult->addRecord($record);
+                            break;
+                        }
+                    }
+                }
+                foreach (static::$_rawStore as $id => $data) {
+                    $parentResult->removeById($id);
+                    foreach($values as $value) {
+                        if (mb_stripos($data[$field], $value) === 0 && strlen($data[$field]) === strlen($value)) {
+                            static::$_modelStore[$id] = $record = new Tinebase_Model_Path($data);
+                            $rawRemove[] = $id;
+                            $parentResult->addRecord($record);
+                            break;
+                        }
+                    }
+                }
+            }
+
+            foreach($rawRemove as $id) {
+                unset(static::$_rawStore[$id]);
+            }
+        }
+
+        return $parentResult;
+    }
+
+    /***
+     ** hook methods
+     **
+     ***/
+
+    public function executeDelayed()
+    {
+        foreach(static::$_modelStore as $id => $record) {
+            if (isset(static::$_newIds[$id])) {
+                parent::create($record);
+            } else {
+                parent::update($record);
+            }
+        }
+
+        foreach(static::$_rawStore as $id => $data) {
+            if (isset(static::$_newIds[$id])) {
+                $this->_db->insert($this->_tablePrefix . $this->_tableName, $data);
+            } else {
+                $this->_db->update($this->_tablePrefix . $this->_tableName, $data,
+                    $this->_db->quoteIdentifier('id') . $this->_db->quoteInto(' = ?', $data['id']));
+            }
+        }
+
+        if (count(static::$_toDelete) > 0) {
+            $this->_db->delete($this->_tablePrefix . $this->_tableName,
+                $this->_db->quoteIdentifier('id') . $this->_db->quoteInto(' IN (?)', array_keys(static::$_toDelete)));
+        }
+
+        $this->_resetDelay();
+    }
+
+    public function rollback()
+    {
+        $this->_resetDelay();
+    }
+
+    /***
+     ** path specific methods
+     **
+     ***/
+
     /**
      * @param string $shadowPath
-     * @param string $replace
-     * @param string $substitution
-     * @return int          The number of affected rows.
+     * @param string $newPath
+     * @param string $oldPath
+     * @return void
      */
-    public function replacePathForShadowPathTree($shadowPath, $replace, $substitution)
+    public function replacePathForShadowPathTree($shadowPath, $newPath, $oldPath)
     {
-        return $this->_db->update($this->_tablePrefix . $this->_tableName, array(
-            'path' => new Zend_Db_Expr($this->_db->quoteInto($this->_db->quoteInto('REPLACE(path, ?', $replace) . ', ?)', $substitution)),
-            ),
-            $this->_db->quoteInto($this->_db->quoteIdentifier('shadow_path') . ' like ?', $shadowPath . '/%')
-        );
+        if (true !== static::$_delayDisabled) {
+            $this->_registerCallBacks();
+            $this->_replaceInStore($shadowPath, $newPath, $oldPath);
+        }
+
+        $result = $this->_db->select()->from($this->_tablePrefix . $this->_tableName)
+            ->where('MATCH (' . $this->_db->quoteIdentifier('shadow_path') .
+                $this->_db->quoteInto(') AGAINST (? IN BOOLEAN MODE) AND ', '+' . join(' +', $this->splitPath($shadowPath))) .
+                $this->_db->quoteIdentifier('shadow_path') .
+                $this->_db->quoteInto(' like ?', $shadowPath . '_%'))->query(Zend_Db::FETCH_ASSOC);
+
+        $rows = $result->fetchAll(Zend_Db::FETCH_ASSOC);
+        foreach($rows as $row) {
+
+            if (0 !== strpos($row['path'], $oldPath)) {
+                Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . ' shadow tree / path mismatch. shadow tree: ' . $shadowPath . ' path record: ' . print_r($row, true) . PHP_EOL . 'probably all paths need to be rebuild!' . PHP_EOL);
+                continue;
+            }
+            $row['path'] = $newPath . substr($row['path'], strlen($oldPath));
+
+            if (true === static::$_delayDisabled) {
+                $this->_db->update($this->_tablePrefix . $this->_tableName, array(
+                    'path' => $row['path']
+                ), $this->_db->quoteIdentifier('id') . $this->_db->quoteInto(' = ?', $row['id']));
+
+            } else {
+                $this->_addToRawStore($row);
+            }
+        }
     }
 
     /**
-     * @param $shadowPath
-     * @return int          The number of affected rows.
+     * @param  $shadowPath
+     * @return void
      */
     public function deleteForShadowPathTree($shadowPath)
     {
-        return $this->_db->delete($this->_tablePrefix . $this->_tableName,
-            $this->_db->quoteInto($this->_db->quoteIdentifier('shadow_path') . ' like ?', $shadowPath . '/%')
+        if (true !== static::$_delayDisabled) {
+            $this->_registerCallBacks();
+            $this->_deleteInStore($shadowPath);
+        }
+
+        $this->_db->delete($this->_tablePrefix . $this->_tableName,
+            'MATCH (' . $this->_db->quoteIdentifier('shadow_path') .
+            $this->_db->quoteInto(') AGAINST (? IN BOOLEAN MODE) AND ', '+' . join(' +', $this->splitPath($shadowPath))) .
+            $this->_db->quoteIdentifier('shadow_path') .
+            $this->_db->quoteInto(' like ?', $shadowPath . '_%')
         );
     }
 
@@ -74,18 +681,279 @@ class Tinebase_Path_Backend_Sql extends Tinebase_Backend_Sql_Abstract
      */
     public function copyTreeByShadowPath($shadowPath, $newPath, $oldPath, $newShadowPath, $oldShadowPath)
     {
-        $select = $this->_db->select()->from($this->_tablePrefix . $this->_tableName, array(
-                'path'          => new Zend_Db_Expr($this->_db->quoteInto($this->_db->quoteInto('REPLACE(path, ?', $oldPath) . ', ?)', $newPath)),
-                'shadow_path'   => new Zend_Db_Expr($this->_db->quoteInto($this->_db->quoteInto('REPLACE(shadow_path, ?', $oldShadowPath) . ', ?)', $newShadowPath)),
-                'record_id'     => 'record_id',
-                'creation_time' => new Zend_Db_Expr('NOW()'),
-            ))->where($this->_db->quoteInto($this->_db->quoteIdentifier('shadow_path') . ' like ?', $shadowPath . '/%'));
+        if (true !== static::$_delayDisabled) {
+            $this->_registerCallBacks();
+            $this->_copyInStore($shadowPath, $newPath, $oldPath, $newShadowPath, $oldShadowPath);
+        }
+
+        $select = $this->_db->select()->from($this->_tablePrefix . $this->_tableName)
+            ->where('MATCH (' . $this->_db->quoteIdentifier('shadow_path') .
+                $this->_db->quoteInto(') AGAINST (? IN BOOLEAN MODE) AND ', '+' . join(' +', $this->splitPath($shadowPath))) .
+                $this->_db->quoteIdentifier('shadow_path') .
+                $this->_db->quoteInto(' like ?', $shadowPath . '_%')
+            );
         $stmt = $this->_db->query($select);
         $entries = $stmt->fetchAll(Zend_Db::FETCH_ASSOC);
 
         foreach($entries as $entry) {
-            $entry['id'] = Tinebase_Record_Abstract::generateUID();
-            $this->_db->insert($this->_tablePrefix . $this->_tableName, $entry);
+
+            if (true === static::$_delayDisabled) {
+                $this->_replacePaths($entry, $newPath, $oldPath, $newShadowPath, $oldShadowPath);
+                $entry['id'] = Tinebase_Record_Abstract::generateUID();
+                $this->_db->insert($this->_tablePrefix . $this->_tableName, $entry);
+
+            } elseif(!isset(static::$_rawStore[$entry['id']]) && !isset(static::$_modelStore[$entry['id']])) {
+                $this->_replacePaths($entry, $newPath, $oldPath, $newShadowPath, $oldShadowPath, true);
+            }
+        }
+    }
+
+    /***
+     ** protected methods
+     **
+     ***/
+
+    protected function _resetDelay()
+    {
+        static::$_registeredCallback = false;
+        static::$_modelStore = array();
+        static::$_rawStore = array();
+        static::$_shadowPathMapping = array();
+        static::$_newIds = array();
+        static::$_toDelete = array();
+    }
+
+    protected function _deleteInStoreByProp($_value, $_property)
+    {
+        $toDelete = array();
+        foreach(static::$_modelStore as $id => $record) {
+            if (strcmp((string)$_value, (string)($record->{$_property})) === 0) {
+                unset(static::$_shadowPathMapping[$record->shadow_path]);
+                unset(static::$_newIds[$id]);
+                static::$_toDelete[$id] = true;
+                $toDelete[] = $id;
+            }
+        }
+
+        foreach($toDelete as $id) {
+            unset(static::$_modelStore[$id]);
+        }
+
+        $toDelete = array();
+        foreach(static::$_rawStore as $id => $data) {
+            if (isset($data[$_property]) && strcmp((string)$_value, (string)($data[$_property])) === 0) {
+                unset(static::$_shadowPathMapping[$data['shadow_path']]);
+                unset(static::$_newIds[$id]);
+                static::$_toDelete[$id] = true;
+                $toDelete[] = $id;
+            }
+        }
+
+        foreach($toDelete as $id) {
+            unset(static::$_rawStore[$id]);
         }
     }
+
+    protected function _registerCallBacks()
+    {
+        if (true !== static::$_registeredCallback) {
+            Tinebase_TransactionManager::getInstance()->registerOnCommitCallback(array($this, 'executeDelayed'));
+            Tinebase_TransactionManager::getInstance()->registerOnRollbackCallback(array($this, 'rollback'));
+            static::$_registeredCallback = true;
+        }
+    }
+
+    protected function _addToModelStore(Tinebase_Model_Path $_record, $_replace = false)
+    {
+        $id = $_record->getId();
+        $shadow_path = $_record->shadow_path;
+
+        if (isset(static::$_shadowPathMapping[$shadow_path])) {
+            if (false === $_replace) {
+                throw new Tinebase_Exception_UnexpectedValue('shadow path already mapped');
+            } elseif(static::$_shadowPathMapping[$shadow_path] != $id) {
+                throw new Tinebase_Exception_UnexpectedValue('shadow path already mapped to different id');
+            }
+        }
+
+        if (isset(static::$_modelStore[$id])) {
+            if (false === $_replace) {
+                throw new Tinebase_Exception_UnexpectedValue('path id already in modelStore');
+            } else {
+                unset(static::$_shadowPathMapping[static::$_modelStore[$id]->shadow_path]);
+            }
+        }
+
+        if (isset(static::$_rawStore[$id])) {
+            if (false === $_replace) {
+                throw new Tinebase_Exception_UnexpectedValue('path id already in rawStore');
+            } else {
+                unset(static::$_shadowPathMapping[static::$_rawStore[$id]['shadow_path']]);
+                unset(static::$_rawStore[$id]);
+            }
+        }
+
+        if (isset(static::$_toDelete[$id])) {
+            throw new Tinebase_Exception_UnexpectedValue('id was already deleted');
+        }
+
+        static::$_modelStore[$id] = $_record;
+        if (false === $_replace) {
+            static::$_newIds[$id] = true;
+        } else {
+            unset(static::$_newIds[$id]);
+        }
+        static::$_shadowPathMapping[$shadow_path] = $id;
+    }
+
+    protected function _addToRawStore(array $_record)
+    {
+        $id = $_record['id'];
+        $shadow_path = $_record['shadow_path'];
+
+        if (isset(static::$_toDelete[$id])) {
+            return;
+            //throw new Tinebase_Exception_UnexpectedValue('id was already deleted');
+        }
+        if (isset(static::$_modelStore[$id])) {
+            return;
+            //throw new Tinebase_Exception_UnexpectedValue('path id already in modelStore');
+        }
+        if (isset(static::$_rawStore[$id])) {
+            return;
+            //throw new Tinebase_Exception_UnexpectedValue('path id already in rawStore');
+        }
+
+        //if id was not found, shadow_path must not exists
+        if (isset(static::$_shadowPathMapping[$shadow_path])) {
+            throw new Tinebase_Exception_UnexpectedValue('shadow path already mapped');
+        }
+
+
+        static::$_rawStore[$id] = $_record;
+        //static::$_newIds[$id] = true;
+        static::$_shadowPathMapping[$shadow_path] = $id;
+    }
+
+    protected function _replaceInStore($shadowTree, $newPath, $oldPath)
+    {
+        $sTreeLength = strlen($shadowTree);
+
+        foreach(static::$_shadowPathMapping AS $sPath => $id) {
+            if (0 === strpos($sPath, $shadowTree) && strlen($sPath) > $sTreeLength) {
+
+                if (isset(static::$_rawStore[$id])) {
+                    $path = &static::$_rawStore[$id]['path'];
+                } elseif(isset(static::$_modelStore[$id])) {
+                    $path = static::$_modelStore[$id]->path;
+                } else {
+                    throw new Tinebase_Exception_UnexpectedValue('shadow path mapping broken, id not found');
+                }
+
+                if (0 !== strpos($path, $oldPath)) {
+                    Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . ' shadow tree / path mismatch. shadow tree: ' . $shadowTree . ' path: ' . $path . PHP_EOL . 'probably all paths need to be rebuild!' . PHP_EOL);
+                    continue;
+                }
+                $path = $newPath . substr($path, strlen($oldPath));
+
+                if(isset(static::$_modelStore[$id])) {
+                    static::$_modelStore[$id]->path = $path;
+                }
+            }
+        }
+    }
+
+    protected function _copyInStore($shadowTree, $newPath, $oldPath, $newShadowPath, $oldShadowPath)
+    {
+        $sTreeLength = strlen($shadowTree);
+
+        foreach(static::$_shadowPathMapping AS $sPath => $id) {
+            if (0 === strpos($sPath, $shadowTree) && strlen($sPath) > $sTreeLength) {
+
+                if (isset(static::$_rawStore[$id])) {
+                    $data = static::$_rawStore[$id];
+                } elseif(isset(static::$_modelStore[$id])) {
+                    $record = static::$_modelStore[$id];
+                    $data = array(
+                        'path'          => $record->path,
+                        'shadow_path'   => $record->shadow_path,
+                        'record_id'     => $record->record_id,
+                        'creation_time' => $record->creation_time,
+                    );
+                } else {
+                    throw new Tinebase_Exception_UnexpectedValue('shadow path mapping broken, id not found');
+                }
+
+                if (0 !== strpos($sPath, $oldShadowPath)) {
+                    Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . ' shadow tree mismatch. old shadow path: ' . $oldShadowPath . ' sPath ' . $sPath . PHP_EOL . 'probably all paths need to be rebuild!' . PHP_EOL);
+                    return;
+                }
+
+                $this->_replacePaths($data, $newPath, $oldPath, $newShadowPath, $oldShadowPath, true);
+            }
+        }
+    }
+
+    protected function _replacePaths(array &$data, $newPath, $oldPath, $newShadowPath, $oldShadowPath, $addToStore = false)
+    {
+        if (0 !== strpos($data['path'], $oldPath)) {
+            Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . ' shadow tree / path mismatch. old path: ' . $oldPath . ' path record: ' . print_r($data, true) . PHP_EOL . 'probably all paths need to be rebuild!' . PHP_EOL);
+            return;
+        }
+        $data['path'] = $newPath . substr($data['path'], strlen($oldPath));
+
+        if (0 !== strpos($data['shadow_path'], $oldShadowPath)) {
+            Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . ' shadow tree mismatch. old shadow path: ' . $oldShadowPath . ' path record: ' . print_r($data, true) . PHP_EOL . 'probably all paths need to be rebuild!' . PHP_EOL);
+            return;
+        }
+        $data['shadow_path'] = $newShadowPath . substr($data['shadow_path'], strlen($oldShadowPath));
+
+        if (isset(static::$_shadowPathMapping[$data['shadow_path']])) {
+            Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . ' shadow path already exists. old shadow path: ' . $oldShadowPath . ' new shadow path: ' . $data['shadow_path'] . PHP_EOL . 'probably all paths need to be rebuild!' . PHP_EOL);
+            return;
+        }
+
+        if (true === $addToStore) {
+            $data['id'] = $newId = Tinebase_Record_Abstract::generateUID();
+            if (isset(static::$_rawStore[$newId]) || isset(static::$_modelStore[$newId])) {
+                Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . ' generated uid not unique.' . PHP_EOL . 'probably all paths need to be rebuild!' . PHP_EOL);
+                return;
+            }
+            static::$_rawStore[$newId] = $data;
+            static::$_newIds[$newId] = true;
+            static::$_shadowPathMapping[$data['shadow_path']] = $newId;
+        }
+    }
+
+    protected function _deleteInStore($shadowTree)
+    {
+        $sTreeLength = strlen($shadowTree);
+        $toDelete = array();
+
+        foreach(static::$_shadowPathMapping AS $sPath => $id) {
+            if (0 === strpos($sPath, $shadowTree) && strlen($sPath) > $sTreeLength) {
+                $toDelete[] = $sPath;
+                unset(static::$_modelStore[$id]);
+                unset(static::$_rawStore[$id]);
+                unset(static::$_newIds[$id]);
+                static::$_toDelete[$id] = true;
+            }
+        }
+
+        foreach($toDelete as $sPath) {
+            unset(static::$_shadowPathMapping[$sPath]);
+        }
+    }
+
+    /**
+     * splits a path into its pieces, treats the type parts as individual pieces too:
+     * /a{b}/c => array(a,b,c)
+     *
+     * @param  string $path
+     * @return array
+     */
+    protected function splitPath($path)
+    {
+        return array_filter(explode('/', str_replace('//', '/', str_replace(array('{', '}'), '/', $path))));
+    }
 }
\ No newline at end of file
index fee14d0..5e633d2 100644 (file)
@@ -83,17 +83,13 @@ class Tinebase_Record_Path extends Tinebase_Controller_Record_Abstract
         $recordController = Tinebase_Core::getApplicationInstance(get_class($record));
 
         // if we rebuild recursively, dont do any tree operation, just rebuild the paths for the record and be done with it
+        // so we dont need to know the old / current paths in that case
         if (false === $rebuildRecursively) {
-            // fetch full record + check acl
-            $record = $recordController->get($record->getId());
-
-            $currentPaths = Tinebase_Record_Path::getInstance()->getPathsForRecords($record);
+            $currentPaths = $this->getPathsForRecords($record);
         }
 
-        $newPaths = new Tinebase_Record_RecordSet('Tinebase_Model_Path');
-
-        // fetch all parent -> child relations and add to path
-        $newPaths->merge($this->_getPathsOfRecord($record, $rebuildRecursively));
+        // generate all paths for the current record
+        $newPaths = $this->_getPathsOfRecord($record, $rebuildRecursively);
 
         if (method_exists($recordController, 'generatePathForRecord')) {
             $newPaths->merge($recordController->generatePathForRecord($record));
@@ -131,7 +127,7 @@ class Tinebase_Record_Path extends Tinebase_Controller_Record_Abstract
                 // path changed (a title was updated or similar)
                 if ($currentPath->path !== $newPath->path) {
                     // update ... set path = REPLACE(path, $currentPath->path, $newPath->path) where shadow_path LIKE '$shadowPath/%'
-                    $this->_backend->replacePathForShadowPathTree($shadowPath, $currentPath->path, $newPath->path);
+                    $this->_backend->replacePathForShadowPathTree($shadowPath, $newPath->path, $currentPath->path);
                 }
 
                 unset($newShadowPathOffset[$shadowPath]);
@@ -180,10 +176,10 @@ class Tinebase_Record_Path extends Tinebase_Controller_Record_Abstract
     /**
      * delete all record paths
      *
+     * no acl check done in here
+     *
      * @param $record
      * @return int
-     *
-     * TODO add acl check?
      */
     public function deletePathsForRecord($record)
     {
@@ -193,6 +189,8 @@ class Tinebase_Record_Path extends Tinebase_Controller_Record_Abstract
     /**
      * getPathsForRecords
      *
+     * no acl check done in here
+     *
      * @param Tinebase_Record_Interface|Tinebase_Record_RecordSet $records
      * @return Tinebase_Record_RecordSet
      * @throws Tinebase_Exception_NotFound
@@ -201,8 +199,6 @@ class Tinebase_Record_Path extends Tinebase_Controller_Record_Abstract
     {
         $ids = $records instanceof Tinebase_Record_Interface ? array($records->getId()) : $records->getArrayOfIds();
 
-        return $this->search(new Tinebase_Model_PathFilter(array(
-            array('field' => 'record_id', 'operator' => 'in', 'value' => $ids)
-        )));
+        return $this->_backend->getMultipleByProperty($ids, 'record_id');
     }
 }
index a980666..5bf15cc 100644 (file)
@@ -881,4 +881,12 @@ class Tinebase_Record_RecordSet implements IteratorAggregate, Countable, ArrayAc
 
         return $ids;
     }
+
+    public function removeById($id)
+    {
+        if (isset($this->_idMap[$id])) {
+            unset($this->_listOfRecords[$this->_idMap[$id]]);
+            unset($this->_idMap[$id]);
+        }
+    }
 }
index 49c5b54..e19b162 100644 (file)
@@ -723,13 +723,13 @@ class Tinebase_Relations
         $this->_backend->removeApplication($applicationName);
     }
 
-    public function getRelationsOfRecordByDegree($record, $degree)
+    public function getRelationsOfRecordByDegree($record, $degree, $ignoreACL = FALSE)
     {
         // get relations if not yet present OR use relation search here
         if (empty($record->relations)) {
             $backendType = 'Sql';
             $modelName = get_class($record);
-            $record->relations = Tinebase_Relations::getInstance()->getRelations($modelName, $backendType, $record->getId());
+            $record->relations = $this->getRelations($modelName, $backendType, $record->getId(), NULL, array(), $ignoreACL);
         }
 
 
index 0d7b6fc..44f30cc 100644 (file)
@@ -359,4 +359,86 @@ class Tinebase_Setup_Update_Release10 extends Setup_Update_Abstract
 
         $this->setApplicationVersion('Tinebase', '10.9');
     }
+
+    /**
+     * update to 10.10
+     *
+     * adding path filter feature switch & structure update
+     */
+    public function update_9()
+    {
+        $this->dropTable('path');
+
+        $declaration = new Setup_Backend_Schema_Table_Xml('<table>
+            <name>path</name>
+            <version>2</version>
+            <requirements>
+                <required>mysql >= 5.6.4</required>
+            </requirements>
+            <declaration>
+                <field>
+                    <name>id</name>
+                    <type>text</type>
+                    <length>40</length>
+                    <notnull>true</notnull>
+                </field>
+                <field>
+                    <name>record_id</name>
+                    <type>text</type>
+                    <length>40</length>
+                    <notnull>true</notnull>
+                </field>
+                <field>
+                    <name>path</name>
+                    <type>text</type>
+                    <length>65535</length>
+                    <notnull>true</notnull>
+                </field>
+                <field>
+                    <name>shadow_path</name>
+                    <type>text</type>
+                    <length>65535</length>
+                    <notnull>true</notnull>
+                </field>
+                <field>
+                    <name>creation_time</name>
+                    <type>datetime</type>
+                </field>
+                <index>
+                    <name>id</name>
+                    <primary>true</primary>
+                    <field>
+                        <name>id</name>
+                    </field>
+                </index>
+                <index>
+                    <name>path</name>
+                    <fulltext>true</fulltext>
+                    <field>
+                        <name>path</name>
+                    </field>
+                </index>
+                <index>
+                    <name>shadow_path</name>
+                    <fulltext>true</fulltext>
+                    <field>
+                        <name>shadow_path</name>
+                    </field>
+                </index>
+                <index>
+                    <name>record_id</name>
+                    <field>
+                        <name>record_id</name>
+                    </field>
+                </index>
+            </declaration>
+        </table>');
+
+        $this->createTable('path', $declaration, 'Tinebase', 2);
+
+        $frontend = new Tinebase_Frontend_Cli();
+        $frontend->rebuildPaths(null);
+
+        $this->setApplicationVersion('Tinebase', '10.10');
+    }
 }
index e1aedb8..1847d24 100644 (file)
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 <application>
     <name>Tinebase</name>
-    <version>10.9</version>
+    <version>10.10</version>
     <tables>
         <table>
             <name>applications</name>
         </table>
         <table>
             <name>path</name>
-            <version>1</version>
+            <version>2</version>
+            <requirements>
+                <required>mysql >= 5.6.4</required>
+            </requirements>
             <declaration>
                 <field>
                     <name>id</name>
                 <field>
                     <name>path</name>
                     <type>text</type>
-                    <length>255</length>
+                    <length>65535</length>
                     <notnull>true</notnull>
                 </field>
                 <field>
                     <name>shadow_path</name>
                     <type>text</type>
-                    <length>255</length>
+                    <length>65535</length>
                     <notnull>true</notnull>
                 </field>
                 <field>
                 </index>
                 <index>
                     <name>path</name>
+                    <fulltext>true</fulltext>
                     <field>
                         <name>path</name>
                     </field>
                 </index>
                 <index>
                     <name>shadow_path</name>
-                    <unique>true</unique>
+                    <fulltext>true</fulltext>
                     <field>
                         <name>shadow_path</name>
                     </field>
index db08bd4..402a8f1 100644 (file)
@@ -36,6 +36,17 @@ class Tinebase_TransactionManager
      * @var array list of all open (not commited) transactions
      */
     protected $_openTransactions = array();
+
+    /**
+     * @var array list of callbacks to call just before really committing
+     */
+    protected $_onCommitCallbacks = array();
+
+    /**
+     * @var array list of callbacks to call just before rollback
+     */
+    protected $_onRollbackCallbacks = array();
+
     /**
      * @var Tinebase_TransactionManager
      */
@@ -114,6 +125,11 @@ class Tinebase_TransactionManager
          $numOpenTransactions = count($this->_openTransactions);
          if ($numOpenTransactions === 0) {
              if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . "  no more open transactions in queue commiting all transactionables");
+
+             foreach($this->_onCommitCallbacks as $callable) {
+                 call_user_func_array($callable[0], $callable[1]);
+             }
+
              foreach ($this->_openTransactionables as $transactionableIdx => $transactionable) {
                  if ($transactionable instanceof Zend_Db_Adapter_Abstract) {
                      $transactionable->commit();
@@ -121,6 +137,8 @@ class Tinebase_TransactionManager
              }
              $this->_openTransactionables = array();
              $this->_openTransactions = array();
+             $this->_onCommitCallbacks = array();
+             $this->_onRollbackCallbacks = array();
          } else {
              if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . "  commiting defered, as there are still $numOpenTransactions in the queue");
          }
@@ -134,12 +152,42 @@ class Tinebase_TransactionManager
     public function rollBack()
     {
         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . "  rollBack request, rollBack all transactionables");
+
+        foreach ($this->_onRollbackCallbacks as $callable) {
+            call_user_func_array($callable[0], $callable[1]);
+        }
+
         foreach ($this->_openTransactionables as $transactionable) {
             if ($transactionable instanceof Zend_Db_Adapter_Abstract) {
                 $transactionable->rollBack();
             }
         }
+
         $this->_openTransactionables = array();
         $this->_openTransactions = array();
+        $this->_onCommitCallbacks = array();
+        $this->_onRollbackCallbacks = array();
+    }
+
+    /**
+     * register a callable to call just before the real commit happens
+     *
+     * @param array $callable
+     * @param array $param
+     */
+    public function registerOnCommitCallback(array $callable, array $param = array())
+    {
+        $this->_onCommitCallbacks[] = array($callable, $param);
+    }
+
+    /**
+     * register a callable to call just before the rollback happens
+     *
+     * @param array $callable
+     * @param array $param
+     */
+    public function registerOnRollbackCallback(array $callable, array $param = array())
+    {
+        $this->_onRollbackCallbacks[] = array($callable, $param);
     }
 }