Source for file migrator.php

Documentation is available at migrator.php

  1. <?php
  2. /**
  3.  * This file contains the Migrator class.
  4.  *
  5.  * @package Migrator
  6.  * @author Gabriel Birke <birke@d-scribe.de>
  7.  * @copyright Copyright (C) 2007 by Gabriel Birke
  8.  * @license http://opensource.org/licenses/gpl-license.php GNU Public License
  9.  * @version 0.5
  10.  */
  11.  
  12. /**
  13.  */
  14. require_once 'MDB2.php';
  15. require_once 'File/Util.php';
  16.  
  17. /**
  18.  * The Migrator class executes migration files - PHP files with
  19.  * {@link http://pear.php.net/package/MDB2 MDB2} instructions for database schema changes.
  20.  *
  21.  * With migrations you have a versioning scheme for database schema changes
  22.  * and you can always go back and forward to a known database schema - on all of your
  23.  * database servers (development, production and test). The currect schema version number is
  24.  * stored in a special table in your database.
  25.  *
  26.  * @author Gabriel Birke <birke@d-scribe.de>
  27.  */
  28. class Migrator
  29. {
  30.     const VERSION "0.7";
  31.     
  32.     // Error codes
  33.     const ERROR_MIGRATION_DIR_MISSING     1;
  34.     const ERROR_DB_CONNECT_FAILED         2;
  35.     const ERROR_DB_QUERY_FAILED            3;
  36.     const ERROR_INVALID_FILE_NAME        4;
  37.     const ERROR_CLASS_NOT_FOUND            5;
  38.     const ERROR_NO_MIGRATION_FILES        6;
  39.     
  40.     /**
  41.      * Table name where the schema versions are stored
  42.      * @var string 
  43.      */
  44.     public $schema_version_table    = "schema_version";
  45.     
  46.     /**
  47.      * Directory where th migration files are stored
  48.      * @var string 
  49.      */
  50.     protected $migrations_dir;
  51.     
  52.     /**
  53.      * Data Source Name
  54.      *
  55.      * Example for a valid data source: mysql://my_user:my_pass@localhost/mydb
  56.      *
  57.      * @link http://pear.php.net/manual/en/package.database.mdb2.intro-dsn.php
  58.      * @var string 
  59.      */
  60.     protected $dsn;
  61.     
  62.     /**
  63.      * Filenames, sorted by number prefix
  64.      * @var array 
  65.      */
  66.     protected $migration_files;
  67.     
  68.     /**
  69.     * Highest number prefix in {@link $migration_files}
  70.      * @var int 
  71.      */
  72.     protected $migration_max_version;
  73.     
  74.     /**
  75.      * Version to up/downgrade to
  76.      * @var int 
  77.      */
  78.     protected $target_version;
  79.     
  80.     /**
  81.      * @var MDB2_Driver 
  82.      */
  83.     protected $db;
  84.     
  85.     /**
  86.      * PEAR_Log or similar object
  87.      * @var Log 
  88.      */
  89.     protected $logger;
  90.     
  91.     /**
  92.      * Initialize the internal variables.
  93.      *
  94.      * If you subclass Migrator, you must call this constructor!
  95.      *
  96.      * @param string $dsn Data Source Name
  97.      * @param int $target Version to up- or downgrade to (-1 for most recent version)
  98.      * @param string $migrations_dir Directory path where the the migration files are
  99.      * @param Log $logger PEAR_Log or similar object (must implement debug, error, notice and info methods)
  100.      */
  101.      function __construct($dsn$target_version$migrations_dir$logger)
  102.     {
  103.         
  104.         $this->migrations_dir = $migrations_dir;
  105.         // Check migrations-dir
  106.         if(!file_exists($this->migrations_dir))
  107.         {
  108.             throw new Exception"Migrations directory '{$this->migrations_dir}does not exist.",
  109.                 Migrator::ERROR_MIGRATION_DIR_MISSING);
  110.         }
  111.         
  112.         // Collect migration file names
  113.         $this->collectMigrationFiles();
  114.         if(empty($this->migration_files))
  115.         {
  116.             throw new Exception( "No migration files.", Migrator::ERROR_NO_MIGRATION_FILES);
  117.         }
  118.         
  119.         $this->dsn = $dsn;
  120.         $this->logger = $logger;
  121.         $this->target_version = ($target_version == -1)?$this->migration_max_version:$target_version;
  122.     }
  123.     
  124.     /**
  125.      * The main function that does the migration.
  126.      *
  127.      * Connects to the database, reads the current schema version, decides to up-
  128.      * or downgrade, loads the matching migrations, executes them and updates
  129.      * the schema version.
  130.      */
  131.     function run()
  132.     {
  133.         
  134.         $this->connect();
  135.         $this->initializeCurrentSchemaVersion();
  136.         if($this->current_version == $this->target_version)
  137.         {
  138.             $this->log("Database ist at selected version, nothing to be done.", "info");
  139.             return;
  140.         }
  141.         $direction = $this->getMigrationDirection();
  142.         $this->filterAndSortMigrationFiles($direction);
  143.         
  144.         // Do the migrations
  145.         $this->migrate($direction);
  146.     }
  147.     
  148. /**    
  149.      * Collects migration file names from {@link $migrations_dir} and stores them
  150.      * in the sorted array {@link $migration_files}.
  151.      * 
  152.      * {@link $migration_max_version} is set to the biggest migration file prefix number.
  153.      */
  154.     protected function collectMigrationFiles()
  155.     {
  156.         $this->migration_files = array();
  157.         $it = new DirectoryIterator($this->migrations_dir); 
  158.         foreach($it as $file)
  159.         {
  160.             if($file->isFile() && preg_match('/^\d+_?[a-zA-Z0-9_]/', $it->getFileName()))
  161.                 $this->migration_files[] = $it->getFileName();
  162.         }
  163.         sort($this->migration_files, SORT_NUMERIC);
  164.         $this->migration_max_version = $this->migrationFileNumber(end($this->migration_files));
  165.         reset($this->migration_files);
  166.     }
  167.     
  168. /**    
  169.      * Initializes {@link $db} with an instance of a MDB2 database driver.
  170.      * 
  171.      * The MDB2 "Manager" module is also loaded for the driver.
  172.      */
  173.     protected function connect()
  174.     {
  175.         $options = array();
  176.         $this->db = MDB2::connect($this->dsn, $options);
  177.         if (PEAR::isError($this->db)) {
  178.             throw new Exception("Failed to conect to '{$this->dsn}': ".$this->db->getMessage()."\n".$this->db->getUserinfo(), <a href="../Migrator/Migrator.html">Migrator</a>::ERROR_DB_CONNECT_FAILED);
  179.         }
  180.         $this->db->loadModule('Manager', null, true);
  181.     }
  182.     
  183. /**    
  184.     * Initializes {@link $current_version} with version number from
  185.     * {@link $schema_version_table}.
  186.      *
  187.      * If the table doesn't exist, initialize with 0 and create the table.
  188.      */
  189.     protected function initializeCurrentSchemaVersion()
  190.     {
  191.         $tables = $this->db->listTables();
  192.         if(!in_array($this->schema_version_table, $tables))
  193.         {
  194.             $fields = array(
  195.                 'version' => array(
  196.                     'type' => 'integer',
  197.                     'notnull' => true,
  198.                     'unsigned' => true
  199.                 )
  200.             );
  201.             $this->checkDBResult($this->db->createTable($this->schema_version_table, $fields));
  202.             $this->checkDBResult($this->db->exec("INSERT INTO {$this->schema_version_table} (versionVALUES (0)"));
  203.         }
  204.         else
  205.             $this->current_version = 0;
  206.         $res = $this->db->query("SELECT version FROM {$this->schema_version_tableLIMIT 1");
  207.         $this->checkDBResult($res);
  208.         $this->current_version = $res->fetchOne();
  209.     }
  210.     
  211.     protected function getMigrationDirection()
  212.     {
  213.         if($this->target_version > $this->current_version)
  214.         {
  215.             $direction = "up";
  216.         }
  217.         else
  218.         {
  219.             $direction = "down";
  220.         }
  221.         $this->log("{$direction}grading from version {$this->current_versionto {$this->target_version}.", "info");
  222.         return $direction;
  223.     }
  224.     
  225.     /**
  226.      * Leave only files in $this->migration_files that are needen for up/downgrade.
  227.      *
  228.      * @param string $direction "up" or "down"
  229.      */
  230.     protected function filterAndSortMigrationFiles($direction)
  231.     {
  232.         $func_name = "_filter_{$direction}grade";
  233.         $this->log("Calling function '$func_name'.");
  234.         $this->migration_files = array_filter($this->migration_filesarray($this$func_name));
  235.         if($direction == "up")
  236.             sort($this->migration_filesSORT_NUMERIC);
  237.         else
  238.             rsort($this->migration_filesSORT_NUMERIC);
  239.         $this->log("The following migration files are left after filtering: ".implode(', '$this->migration_files));
  240.     }
  241.     
  242.     /**
  243.     * Load each class in {@link $migration_files} and call its "up" or "down" method
  244.      *
  245.      * @param string $direction "up" or "down"
  246.      */
  247.     protected function migrate($direction)
  248.     {
  249.         $classes = array();
  250.         // Load the class files and check if the class we expect exists 
  251.         foreach($this->migration_files as $file)
  252.         {
  253.             if(!preg_match('/(\d+)_?([a-zA-Z0-9_]+)/', $file, $matches))
  254.             {
  255.                 // Should not happen if migrationFiles were set with collectMigrationFiles
  256.                 throw new Exception("Invalid file name$file", Migrator::ERROR_INVALID_FILE_NAME);
  257.             }
  258.             $class = $matches[2];
  259.             $migration_id = $matches[1];
  260.             $fn = File_Util::buildpath(array($this->migrations_dir$file));
  261.             $this->log("Loading File '$fn'.", 'debug');
  262.             require_once $fn;
  263.             if(!class_exists($class))
  264.             {
  265.                 throw new Exception("Class '$classnot found.", Migrator::ERROR_CLASS_NOT_FOUND);
  266.             }
  267.             
  268.             // TODO check if the class implements IMigration
  269.             $classes[$migration_id] = $class;
  270.         }
  271.         
  272.         // If we downgrade, we must subtract one from the migration id
  273.         $id_factor = $direction=="down"?-1:0; 
  274.         
  275.         // Call each classes up/down method
  276.         // This second loop ensured that no database mordifications are made 
  277.         // when there is a syntax error in one of the loaded classes.
  278.         foreach($classes as $migration_id => $class)
  279.         {
  280.             $this->log("Doing Migration '$class'.", 'info');
  281.             call_user_func(array($class, 'init'), $this->logger);
  282.             $return call_user_func(array($class$direction)$this->db);
  283.             if(PEAR::isError($return))
  284.             {
  285.                 throw new Exception($return->getMessage()."\n".$return->getUserinfo()Migrator::ERROR_DB_QUERY_FAILED);
  286.             }
  287.             // Write new migration version (create version table if it doesn't exist)
  288.             $this->writeNewMigrationVersion($migration_id $id_factor);
  289.         }
  290.         return true;
  291.     }
  292.     
  293.     /** 
  294.      * Write the new version number into {$schema_version_table}.
  295.      */
  296.     protected function writeNewMigrationVersion($newVersion)
  297.     {
  298.         $newVersion = max(0, $newVersion);
  299.         $this->log("Changing schema version to $newVersion", 'debug');
  300.         $this->checkDBResult($this->db->exec("UPDATE {$this->schema_version_tableSET version = {$newVersion}"));
  301.     }
  302.     
  303.     /**
  304.      * Return the migration file number at the start of a filename.
  305.      *
  306.      * @param string $filename
  307.      * @return int
  308.      */
  309.     protected function migrationFileNumber($filename)
  310.     {
  311.         preg_match('/^(\d+)/', $filename, $matches);
  312.         return intval($matches[1]);
  313.     }
  314.     
  315.     /**
  316.     * Callback function for {@link array_filter()} that returns true for migration files 
  317.     * between {@link $current_version} and {@link $target_version}.
  318.     *
  319.     * @param string $filename
  320.     * @return boolean
  321.     */
  322.     protected function _filter_upgrade($filename)
  323.     {
  324.         $number = $this->migrationFileNumber($filename);
  325.         return  $number <= $this->target_version && $number $this->current_version;
  326.     }
  327.     
  328.     /**
  329.     * Callback function for {@link array_filter()} that returns true for migration files 
  330.     * between {@link $target_version} and {@link $current_version}.
  331.     *
  332.     * @param string $filename
  333.     * @return boolean
  334.     */
  335.     protected function _filter_downgrade($filename)
  336.     {
  337.         $number = $this->migrationFileNumber($filename);
  338.         return  $number $this->target_version && $number <= $this->current_version;
  339.     }
  340.     
  341.     /**
  342.      * If the result given is an error, throw an exception.
  343.      *
  344.      * @param MDB2_Result $result
  345.      */
  346.     protected function checkDBResult($result)
  347.     {
  348.         if (PEAR::isError($result)) {
  349.             throw new Exception($result->getMessage()Migrator::ERROR_DB_QUERY_FAILED);
  350.         }    
  351.     }
  352.     
  353.     function log($message, $level="debug")
  354.     {
  355.         if($this->logger)
  356.             $this->logger->$level($message);
  357.     }
  358.     

Documentation generated on Sat, 09 Jun 2007 11:50:10 +0200 by phpDocumentor 1.3.2