<?php
/*
 *  $Id: DbDeployTask.php 1215 2011-07-09 19:40:04Z mrook $
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * This software consists of voluntary contributions made by many individuals
 * and is licensed under the LGPL. For more information please see
 * <http://phing.info>.
 */

require_once 'phing/Task.php';
require_once 'phing/tasks/ext/dbdeploy/DbmsSyntaxFactory.php';


/**
 * Generate SQL script for db using dbdeploy schema version table
 * and delta scripts
 *
 * <dbdeploy url="mysql:host=localhost;dbname=test"
 *     userid="dbdeploy" password="dbdeploy" dir="db" outputfile="">
 *
 * @author   Luke Crouch at SourceForge (http://sourceforge.net)
 * @version  $Revision: 1215 $
 * @package  phing.tasks.ext.dbdeploy
 */
class DbDeployTask extends Task
{
    /**
     * The tablename to use from the database for storing all changes
     * This cannot be changed
     *
     * @var string
     */
    public static $TABLE_NAME = 'changelog';

    /**
     * Connection string for the database connection
     *
     * @var string
     */
    protected $url;

    /**
     * The userid for the database connection
     *
     * @var string
     */
    protected $userid;

    /**
     * The password of the database user
     *
     * @var string
     */
    protected $password;

    /**
     * Path to the directory that holds the database patch files
     *
     * @var string
     */
    protected $dir;

    /**
     * Output file for performing all database patches of this deployment
     * Contains all the SQL statements that need to be executed
     *
     * @var string
     */
    protected $outputFile = 'dbdeploy_deploy.sql';

    /**
     * Outputfile for undoing the database patches of this deployment
     * Contains all the SQL statements that need to be executed
     *
     * @var string
     */
    protected $undoOutputFile = 'dbdeploy_undo.sql';

    /**
     * The deltaset that's being used
     *
     * @var string
     */
    protected $deltaSet = 'Main';

    /**
     * The number of the last change to apply
     *
     * @var int
     */
    protected $lastChangeToApply = 999;

    /**
     * Contains the object for the DBMS that is used
     *
     * @var object
     */
    protected $dbmsSyntax = null;

    /**
     * Array with all change numbers that are applied already
     *
     * @var array
     */
    protected $appliedChangeNumbers = array();

    /**
     * Checkall attribute
     * False means dbdeploy will only apply patches that have a higher number
     * than the last patchnumber that was applied
     * True means dbdeploy will apply all changes that aren't applied
     * already (in ascending order)
     *
     * @var int
     */
    protected $checkall = false;

    /**
     * The main function for the task
     *
     * @throws BuildException
     * @return void
     */
    public function main()
    {
        try {
            // get correct DbmsSyntax object
            $dbms = substr($this->url, 0, strpos($this->url, ':'));
            $dbmsSyntaxFactory = new DbmsSyntaxFactory($dbms);
            $this->dbmsSyntax = $dbmsSyntaxFactory->getDbmsSyntax();

            // figure out which revisions are in the db already
            $this->appliedChangeNumbers = $this->getAppliedChangeNumbers();
            $this->log('Current db revision: '.$this->getLastChangeAppliedInDb());
            $this->log('Checkall: ' . $this->checkall);

            $this->deploy();

        } catch (Exception $e) {
            throw new BuildException($e);
        }
    }

    /**
     * Get the numbers of all the patches that are already applied according to
     * the changelog table in the database
     *
     * @return array
     */
    protected function getAppliedChangeNumbers()
    {
        if (count($this->appliedChangeNumbers) == 0) {
            $this->log('Getting applied changed numbers from DB: ' . $this->url);
            $appliedChangeNumbers = array();
            $dbh = new PDO($this->url, $this->userid, $this->password);
            $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
            $sql = "SELECT *
                    FROM " . DbDeployTask::$TABLE_NAME . "
                    WHERE delta_set = '$this->deltaSet'
                    ORDER BY change_number";
            foreach ($dbh->query($sql) as $change) {
                $appliedChangeNumbers[] = $change['change_number'];
            }
            $this->appliedChangeNumbers = $appliedChangeNumbers;
        }
        return $this->appliedChangeNumbers;
    }

    /**
     * Get the number of the last patch applied to the database
     *
     * @return int|mixed The highest patch number that is applied in the db
     */
    protected function getLastChangeAppliedInDb()
    {
        return (count($this->appliedChangeNumbers) > 0)
                ? max($this->appliedChangeNumbers) : 0;
    }

    /**
     * Create the deploy and undo deploy outputfiles
     *
     * @return void
     */
    protected function deploy()
    {
        // create deploy outputfile
        $this->createOutputFile($this->outputFile, false);

        // create undo deploy outputfile
        $this->createOutputFile($this->undoOutputFile, true);
    }

    /**
     * Generate the sql for doing/undoing the deployment and write it to a file
     *
     * @param string $file
     * @param bool $undo
     * @return void
     */
    protected function createOutputFile($file, $undo = false)
    {
        $fileHandle = fopen($file, "w+");
        $sql = $this->generateSql($undo);
        fwrite($fileHandle, $sql);
    }

    /**
     * Generate the sql for doing/undoing this deployment
     *
     * @param bool $undo
     * @return string The sql
     */
    protected function generateSql($undo = false)
    {
        $sql = '';
        $lastChangeAppliedInDb = $this->getLastChangeAppliedInDb();
        $files = $this->getDeltasFilesArray();
        $this->sortFiles($files, $undo);

        foreach ($files as $fileChangeNumber => $fileName) {
            if ($this->fileNeedsToBeRead($fileChangeNumber, $lastChangeAppliedInDb)) {
                $sql .= '-- Fragment begins: ' . $fileChangeNumber . ' --' . "\n";

                if (!$undo) {
                    $sql .= 'INSERT INTO ' . DbDeployTask::$TABLE_NAME . '
                                (change_number, delta_set, start_dt, applied_by, description)' .
                            ' VALUES (' . $fileChangeNumber . ', \'' . $this->deltaSet . '\', ' .
                                $this->dbmsSyntax->generateTimestamp() .
                                ', \'dbdeploy\', \'' . $fileName . '\');' . "\n";
                }

                // read the file
                $fullFileName = $this->dir . '/' . $fileName;
                $fh = fopen($fullFileName, 'r');
                $contents = fread($fh, filesize($fullFileName));
                // allow construct with and without space added
                $split = strpos($contents, '-- //@UNDO');
                if ($split === false)
                    $split = strpos($contents, '--//@UNDO');

                if ($undo) {
                    $sql .= substr($contents, $split + 10);
                    $sql .= 'DELETE FROM ' . DbDeployTask::$TABLE_NAME . '
	                         WHERE change_number = ' . $fileChangeNumber . '
	                         AND delta_set = \'' . $this->deltaSet . '\';' . "\n";
                } else {
                    $sql .= substr($contents, 0, $split);
                    $sql .= 'UPDATE ' . DbDeployTask::$TABLE_NAME . '
	                         SET complete_dt = ' . $this->dbmsSyntax->generateTimestamp() . '
	                         WHERE change_number = ' . $fileChangeNumber . '
	                         AND delta_set = \'' . $this->deltaSet . '\';' . "\n";
                }

                $sql .= '-- Fragment ends: ' . $fileChangeNumber . ' --' . "\n";
            }
        }

        return $sql;
    }

    /**
     * Get a list of all the patch files in the patch file directory
     *
     * @return array
     */
    protected function getDeltasFilesArray()
    {
        $files = array();
        $baseDir = realpath($this->dir);
        $dh = opendir($baseDir);
        $fileChangeNumberPrefix = '';
        while (($file = readdir($dh)) !== false) {
            if (preg_match('[\d+]', $file, $fileChangeNumberPrefix)) {
                $files[intval($fileChangeNumberPrefix[0])] = $file;
            }
        }
        return $files;
    }

    /**
     * Sort files in the patch files directory (ascending or descending depending on $undo boolean)
     *
     * @param array $files
     * @param bool $undo
     * @return void
     */
    protected function sortFiles(&$files, $undo)
    {
        if ($undo) {
            krsort($files);
        } else {
            ksort($files);
        }
    }

    /**
     * Determine if this patch file need to be deployed
     * (using fileChangeNumber, lastChangeAppliedInDb and $this->checkall)
     *
     * @param int $fileChangeNumber
     * @param string $lastChangeAppliedInDb
     * @return bool True or false if patch file needs to be deployed
     */
    protected function fileNeedsToBeRead($fileChangeNumber, $lastChangeAppliedInDb)
    {
        if ($this->checkall) {
            return (!in_array($fileChangeNumber, $this->appliedChangeNumbers));
        } else {
            return ($fileChangeNumber > $lastChangeAppliedInDb && $fileChangeNumber <= $this->lastChangeToApply);
        }
    }

    /**
     * Set the url for the database connection
     *
     * @param string $url
     * @return void
     */
    public function setUrl($url)
    {
        $this->url = $url;
    }

    /**
     * Set the userid for the database connection
     *
     * @param string $userid
     * @return void
     */
    public function setUserId($userid)
    {
        $this->userid = $userid;
    }

    /**
     * Set the password for the database connection
     *
     * @param string $password
     * @return void
     */
    public function setPassword($password)
    {
        $this->password = $password;
    }

    /**
     * Set the directory where to find the patchfiles
     *
     * @param string $dir
     * @return void
     */
    public function setDir($dir)
    {
        $this->dir = $dir;
    }

    /**
     * Set the outputfile which contains all patch sql statements for this deployment
     *
     * @param string $outputFile
     * @return void
     */
    public function setOutputFile($outputFile)
    {
        $this->outputFile = $outputFile;
    }

    /**
     * Set the undo outputfile which contains all undo statements for this deployment
     *
     * @param string $undoOutputFile
     * @return void
     */
    public function setUndoOutputFile($undoOutputFile)
    {
        $this->undoOutputFile = $undoOutputFile;
    }

    /**
     * Set the lastchangetoapply property
     *
     * @param int $lastChangeToApply
     * @return void
     */
    public function setLastChangeToApply($lastChangeToApply)
    {
        $this->lastChangeToApply = $lastChangeToApply;
    }

    /**
     * Set the deltaset property
     *
     * @param string $deltaSet
     * @return void
     */
    public function setDeltaSet($deltaSet)
    {
        $this->deltaSet = $deltaSet;
    }

    /**
     * Set the checkall property
     *
     * @param bool $checkall
     * @return void
     */
    public function setCheckAll($checkall)
    {
        $this->checkall = (int)$checkall;
    }

    /**
     * Add a new fileset.
     * @return FileSet
     */
    public function createFileSet()
    {
        $this->fileset = new FileSet();
        $this->filesets[] = $this->fileset;
        return $this->fileset;
    }
}

