1<?php
2/*
3 *  $Id: Diff.php 1080 2007-02-10 18:17:08Z jwage $
4 *
5 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
6 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
7 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
8 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
9 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
10 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
11 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
12 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
13 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
14 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
15 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
16 *
17 * This software consists of voluntary contributions made by many individuals
18 * and is licensed under the LGPL. For more information, see
19 * <http://www.doctrine-project.org>.
20 */
21
22/**
23 * Doctrine_Migration_Diff - class used for generating differences and migration
24 * classes from 'from' and 'to' schema information.
25 *
26 * @package     Doctrine
27 * @subpackage  Migration
28 * @license     http://www.opensource.org/licenses/lgpl-license.php LGPL
29 * @link        www.doctrine-project.org
30 * @since       1.0
31 * @version     $Revision: 1080 $
32 * @author      Jonathan H. Wage <jonwage@gmail.com>
33 */
34class Doctrine_Migration_Diff
35{
36    protected $_from,
37              $_to,
38              $_changes = array('created_tables'      =>  array(),
39                                'dropped_tables'      =>  array(),
40                                'created_foreign_keys'=>  array(),
41                                'dropped_foreign_keys'=>  array(),
42                                'created_columns'     =>  array(),
43                                'dropped_columns'     =>  array(),
44                                'changed_columns'     =>  array(),
45                                'created_indexes'     =>  array(),
46                                'dropped_indexes'     =>  array()),
47              $_migration,
48              $_startingModelFiles = array(),
49              $_tmpPath;
50
51    protected static $_toPrefix   = 'ToPrfx',
52                     $_fromPrefix = 'FromPrfx';
53
54    /**
55     * Instantiate new Doctrine_Migration_Diff instance
56     *
57     * <code>
58     * $diff = new Doctrine_Migration_Diff('/path/to/old_models', '/path/to/new_models', '/path/to/migrations');
59     * $diff->generateMigrationClasses();
60     * </code>
61     *
62     * @param string $from      The from schema information source
63     * @param string $to        The to schema information source
64     * @param mixed  $migration Instance of Doctrine_Migration or path to migration classes
65     * @return void
66     */
67    public function __construct($from, $to, $migration)
68    {
69        $this->_from = $from;
70        $this->_to = $to;
71        $this->_startingModelFiles = Doctrine_Core::getLoadedModelFiles();
72        $this->setTmpPath(sys_get_temp_dir() . DIRECTORY_SEPARATOR . getmypid());
73
74        if ($migration instanceof Doctrine_Migration) {
75            $this->_migration = $migration;
76        } else if (is_dir($migration)) {
77            $this->_migration = new Doctrine_Migration($migration);
78        }
79    }
80
81    /**
82     * Set the temporary path to store the generated models for generating diffs
83     *
84     * @param string $tmpPath
85     * @return void
86     */
87    public function setTmpPath($tmpPath)
88    {
89        if ( ! is_dir($tmpPath)) {
90            mkdir($tmpPath, 0777, true);
91        }
92        $this->_tmpPath = $tmpPath;
93    }
94
95    /**
96     * Get unique hash id for this migration instance
97     *
98     * @return string $uniqueId
99     */
100    protected function getUniqueId()
101    {
102        return md5($this->_from . $this->_to);
103    }
104
105    /**
106     * Generate an array of changes found between the from and to schema information.
107     *
108     * @return array $changes
109     */
110    public function generateChanges()
111    {
112        $this->_cleanup();
113
114        $from = $this->_generateModels(
115            Doctrine_Manager::getInstance()->getAttribute(Doctrine_Core::ATTR_MODEL_CLASS_PREFIX) . self::$_fromPrefix,
116            $this->_from
117        );
118        $to = $this->_generateModels(
119            Doctrine_Manager::getInstance()->getAttribute(Doctrine_Core::ATTR_MODEL_CLASS_PREFIX) . self::$_toPrefix,
120            $this->_to
121        );
122
123        return $this->_diff($from, $to);
124    }
125
126    /**
127     * Generate a migration class for the changes in this diff instance
128     *
129     * @return array $changes
130     */
131    public function generateMigrationClasses()
132    {
133        $builder = new Doctrine_Migration_Builder($this->_migration);
134
135        return $builder->generateMigrationsFromDiff($this);
136    }
137
138    /**
139     * Initialize some Doctrine models at a given path.
140     *
141     * @param string $path
142     * @return array $models
143     */
144    protected function _initializeModels($path)
145    {
146        $manager = Doctrine_Manager::getInstance();
147        $modelLoading = $manager->getAttribute(Doctrine_Core::ATTR_MODEL_LOADING);
148        if ($modelLoading === Doctrine_Core::MODEL_LOADING_PEAR) {
149            $orig = Doctrine_Core::getModelsDirectory();
150            Doctrine_Core::setModelsDirectory($path);
151            $models = Doctrine_Core::initializeModels(Doctrine_Core::loadModels($path));
152            Doctrine_Core::setModelsDirectory($orig);
153        } else {
154            $models = Doctrine_Core::initializeModels(Doctrine_Core::loadModels($path));
155        }
156        return $models;
157    }
158
159    /**
160     * Generate a diff between the from and to schema information
161     *
162     * @param  string $from     Path to set of models to migrate from
163     * @param  string $to       Path to set of models to migrate to
164     * @return array  $changes
165     */
166    protected function _diff($from, $to)
167    {
168        // Load the from and to models
169        $fromModels = $this->_initializeModels($from);
170        $toModels = $this->_initializeModels($to);
171
172        // Build schema information for the models
173        $fromInfo = $this->_buildModelInformation($fromModels);
174        $toInfo = $this->_buildModelInformation($toModels);
175
176        // Build array of changes between the from and to information
177        $changes = $this->_buildChanges($fromInfo, $toInfo);
178
179        $this->_cleanup();
180
181        return $changes;
182    }
183
184    /**
185     * Build array of changes between the from and to array of schema information
186     *
187     * @param array $from  Array of schema information to generate changes from
188     * @param array $to    Array of schema information to generate changes for
189     * @return array $changes
190     */
191    protected function _buildChanges($from, $to)
192    {
193        // Loop over the to schema information and compare it to the from
194        foreach ($to as $className => $info) {
195            // If the from doesn't have this class then it is a new table
196            if ( ! isset($from[$className])) {
197                $names = array('type', 'charset', 'collate', 'indexes', 'foreignKeys', 'primary');
198                $options = array();
199                foreach ($names as $name) {
200                    if (isset($info['options'][$name]) && $info['options'][$name]) {
201                        $options[$name] = $info['options'][$name];
202                    }
203                }
204
205                $table = array('tableName' => $info['tableName'],
206                               'columns'   => $info['columns'],
207                               'options'   => $options);
208                $this->_changes['created_tables'][$info['tableName']] = $table;
209            }
210            // Check for new and changed columns
211            foreach ($info['columns'] as $name => $column) {
212                // If column doesn't exist in the from schema information then it is a new column
213                if (isset($from[$className]) && ! isset($from[$className]['columns'][$name])) {
214                    $this->_changes['created_columns'][$info['tableName']][$name] = $column;
215                }
216                // If column exists in the from schema information but is not the same then it is a changed column
217                if (isset($from[$className]['columns'][$name]) && $from[$className]['columns'][$name] != $column) {
218                    $this->_changes['changed_columns'][$info['tableName']][$name] = $column;
219                }
220            }
221            // Check for new foreign keys
222            foreach ($info['options']['foreignKeys'] as $name => $foreignKey) {
223                $foreignKey['name'] = $name;
224                // If foreign key doesn't exist in the from schema information then we need to add a index and the new fk
225                if ( ! isset($from[$className]['options']['foreignKeys'][$name])) {
226                    $this->_changes['created_foreign_keys'][$info['tableName']][$name] = $foreignKey;
227                    $indexName = Doctrine_Manager::connection()->generateUniqueIndexName($info['tableName'], $foreignKey['local']);
228                    $this->_changes['created_indexes'][$info['tableName']][$indexName] = array('fields' => array($foreignKey['local']));
229                // If foreign key does exist then lets see if anything has changed with it
230                } else if (isset($from[$className]['options']['foreignKeys'][$name])) {
231                    $oldForeignKey = $from[$className]['options']['foreignKeys'][$name];
232                    $oldForeignKey['name'] = $name;
233                    // If the foreign key has changed any then we need to drop the foreign key and readd it
234                    if ($foreignKey !== $oldForeignKey) {
235                        $this->_changes['dropped_foreign_keys'][$info['tableName']][$name] = $oldForeignKey;
236                        $this->_changes['created_foreign_keys'][$info['tableName']][$name] = $foreignKey;
237                    }
238                }
239            }
240            // Check for new indexes
241            foreach ($info['options']['indexes'] as $name => $index) {
242                // If index doesn't exist in the from schema information
243                if ( ! isset($from[$className]['options']['indexes'][$name])) {
244                    $this->_changes['created_indexes'][$info['tableName']][$name] = $index;
245                }
246            }
247        }
248        // Loop over the from schema information and compare it to the to schema information
249        foreach ($from as $className => $info) {
250            // If the class exists in the from but not in the to then it is a dropped table
251            if ( ! isset($to[$className])) {
252                $table = array('tableName' => $info['tableName'],
253                               'columns'   => $info['columns'],
254                               'options'   => array('type'        => $info['options']['type'],
255                                                    'charset'     => $info['options']['charset'],
256                                                    'collate'     => $info['options']['collate'],
257                                                    'indexes'     => $info['options']['indexes'],
258                                                    'foreignKeys' => $info['options']['foreignKeys'],
259                                                    'primary'     => $info['options']['primary']));
260                $this->_changes['dropped_tables'][$info['tableName']] = $table;
261            }
262            // Check for removed columns
263            foreach ($info['columns'] as $name => $column) {
264                // If column exists in the from but not in the to then we need to remove it
265                if (isset($to[$className]) && ! isset($to[$className]['columns'][$name])) {
266                    $this->_changes['dropped_columns'][$info['tableName']][$name] = $column;
267                }
268            }
269            // Check for dropped foreign keys
270            foreach ($info['options']['foreignKeys'] as $name => $foreignKey) {
271                // If the foreign key exists in the from but not in the to then we need to drop it
272                if ( ! isset($to[$className]['options']['foreignKeys'][$name])) {
273                    $this->_changes['dropped_foreign_keys'][$info['tableName']][$name] = $foreignKey;
274                }
275            }
276            // Check for removed indexes
277            foreach ($info['options']['indexes'] as $name => $index) {
278                // If the index exists in the from but not the to then we need to remove it
279                if ( ! isset($to[$className]['options']['indexes'][$name])) {
280                    $this->_changes['dropped_indexes'][$info['tableName']][$name] = $index;
281                }
282            }
283        }
284
285        return $this->_changes;
286    }
287
288    /**
289     * Build all the model schema information for the passed array of models
290     *
291     * @param  array $models Array of models to build the schema information for
292     * @return array $info   Array of schema information for all the passed models
293     */
294    protected function _buildModelInformation(array $models)
295    {
296        $info = array();
297        foreach ($models as $key => $model) {
298            $table = Doctrine_Core::getTable($model);
299            if ($table->getTableName() !== $this->_migration->getTableName()) {
300                $info[$model] = $table->getExportableFormat();
301            }
302        }
303
304        $info = $this->_cleanModelInformation($info);
305
306        return $info;
307    }
308
309    /**
310     * Clean the produced model information of any potential prefix text
311     *
312     * @param  mixed $info  Either array or string to clean of prefixes
313     * @return mixed $info  Cleaned value which is either an array or string
314     */
315    protected function _cleanModelInformation($info)
316    {
317        if (is_array($info)) {
318            foreach ($info as $key => $value) {
319                unset($info[$key]);
320                $key = $this->_cleanModelInformation($key);
321                $info[$key] = $this->_cleanModelInformation($value);
322            }
323            return $info;
324        } else {
325            $find = array(
326                self::$_toPrefix,
327                self::$_fromPrefix,
328                Doctrine_Inflector::tableize(self::$_toPrefix) . '_',
329                Doctrine_Inflector::tableize(self::$_fromPrefix) . '_',
330                Doctrine_Inflector::tableize(self::$_toPrefix),
331                Doctrine_Inflector::tableize(self::$_fromPrefix)
332            );
333            return str_replace($find, null, $info);
334        }
335    }
336
337    /**
338     * Get the extension of the type of file contained in a directory.
339     * Used to determine if a directory contains YAML or PHP files.
340     *
341     * @param string $item
342     * @return string $extension
343     */
344    protected function _getItemExtension($item)
345    {
346        if (is_dir($item)) {
347            $files = glob($item . DIRECTORY_SEPARATOR . '*');
348        } else {
349            $files = array($item);
350        }
351
352        $extension = null;
353        if (isset($files[0])) {
354            if (is_dir($files[0])) {
355                $extension = $this->_getItemExtension($files[0]);
356            } else {
357                $pathInfo = pathinfo($files[0]);
358                $extension = $pathInfo['extension'];
359            }
360        }
361        return $extension;
362    }
363
364    /**
365     * Generate a set of models for the schema information source
366     *
367     * @param  string $prefix  Prefix to generate the models with
368     * @param  mixed  $item    The item to generate the models from
369     * @return string $path    The path where the models were generated
370     * @throws Doctrine_Migration_Exception $e
371     */
372    protected function _generateModels($prefix, $item)
373    {
374        $path = $this->_tmpPath . DIRECTORY_SEPARATOR . strtolower($prefix) . '_doctrine_tmp_dirs';
375        $options = array(
376            'classPrefix' => $prefix,
377            'generateBaseClasses' => false
378        );
379
380        if (is_string($item) && file_exists($item)) {
381            $extension = $this->_getItemExtension($item);
382
383            if ($extension === 'yml') {
384                Doctrine_Core::generateModelsFromYaml($item, $path, $options);
385
386                return $path;
387            } else if ($extension === 'php') {
388                Doctrine_Lib::copyDirectory($item, $path);
389
390                return $path;
391            } else {
392                throw new Doctrine_Migration_Exception('No php or yml files found at path: "' . $item . '"');
393            }
394        } else {
395            try {
396                Doctrine_Core::generateModelsFromDb($path, (array) $item, $options);
397                return $path;
398            } catch (Exception $e) {
399                throw new Doctrine_Migration_Exception('Could not generate models from connection: ' . $e->getMessage());
400            }
401        }
402    }
403
404    /**
405     * Cleanup temporary generated models after a diff is performed
406     *
407     * @return void
408     */
409    protected function _cleanup()
410    {
411        $modelFiles = Doctrine_Core::getLoadedModelFiles();
412        $filesToClean = array_diff($modelFiles, $this->_startingModelFiles);
413
414        foreach ($filesToClean as $file) {
415            if (file_exists($file)) {
416                unlink($file);
417            }
418        }
419
420        // clean up tmp directories
421        Doctrine_Lib::removeDirectories($this->_tmpPath . DIRECTORY_SEPARATOR . strtolower(self::$_fromPrefix) . '_doctrine_tmp_dirs');
422        Doctrine_Lib::removeDirectories($this->_tmpPath . DIRECTORY_SEPARATOR . strtolower(self::$_toPrefix) . '_doctrine_tmp_dirs');
423    }
424}
425