source: trunk/lib/Version.inc.php @ 492

Last change on this file since 492 was 484, checked in by anonymous, 10 years ago

Changed private methods and properties to protected. A few minor bug fixes.

File size: 19.2 KB
RevLine 
[1]1<?php
2/**
[362]3 * The Strangecode Codebase - a general application development framework for PHP
4 * For details visit the project site: <http://trac.strangecode.com/codebase/>
[396]5 * Copyright 2001-2012 Strangecode, LLC
[468]6 *
[362]7 * This file is part of The Strangecode Codebase.
8 *
9 * The Strangecode Codebase is free software: you can redistribute it and/or
10 * modify it under the terms of the GNU General Public License as published by the
11 * Free Software Foundation, either version 3 of the License, or (at your option)
12 * any later version.
[468]13 *
[362]14 * The Strangecode Codebase is distributed in the hope that it will be useful, but
15 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
17 * details.
[468]18 *
[362]19 * You should have received a copy of the GNU General Public License along with
20 * The Strangecode Codebase. If not, see <http://www.gnu.org/licenses/>.
21 */
22
23/**
[137]24 * Version.inc.php
[136]25 *
[137]26 * The Version class provides a system for saving, reviewing, and
[1]27 * restoring versions of a record of any DB table. All the data in the record is
28 * serialized, compressed, and saved in a blob in the version_tbl. Restoring a
29 * version simply does a REPLACE INTO of the data. It is very simple, and works
30 * with multiple database tables, but the drawback is that relationships for
[42]31 * a record cannot be retained. For example, an article from an article_tbl can
[1]32 * be saved, but not categories associated to the record in a category_article_tbl.
33 * The restored article will simple retain the relationships that the previous
34 * current article had.
35 *
36 * @author  Quinn Comendant <quinn@strangecode.com>
37 * @version 2.1
38 */
[137]39class Version {
[1]40
[468]41    // A place to keep an object instance for the singleton pattern.
[484]42    protected static $instance = null;
[468]43
[1]44    // Configuration of this object.
[484]45    protected $_params = array(
[1]46        'max_qty' => 100, // Never have more than this many versions of each record.
47        'min_qty' => 25, // Keep at least this many versions of each record.
48        'min_days' => 7, // Keep ALL versions within this many days, even if MORE than min_qty.
49        'db_table' => 'version_tbl',
[468]50
[146]51        // Automatically create table and verify columns. Better set to false after site launch.
[396]52        // This value is overwritten by the $app->getParam('db_create_tables') setting if it is available.
[146]53        'create_table' => true,
[396]54        // If true, makes an exact comparison of saved vs. live table schemas. If false, just checks that the saved columns are available.
55        'db_schema_strict' => true,
[1]56    );
57
58    // Auth_SQL object from which to access a current user_id.
[484]59    protected $_auth;
[1]60
61    /**
62     * This method enforces the singleton pattern for this class.
63     *
[139]64     * @return  object  Reference to the global Lock object.
[1]65     * @access  public
66     * @static
67     */
[468]68    public static function &getInstance($auth_object)
[1]69    {
[468]70        if (self::$instance === null) {
71            self::$instance = new self($auth_object);
[1]72        }
73
[468]74        return self::$instance;
[1]75    }
76
77    /**
78     * Constructor. Pass an Auth object on which to perform user lookups.
79     *
80     * @param mixed  $auth_object  An Auth_SQL object.
81     */
[468]82    public function __construct($auth_object)
[1]83    {
[479]84        $app =& App::getInstance();
[136]85
[149]86        if (!method_exists($auth_object, 'get') || !method_exists($auth_object, 'getUsername')) {
[136]87            trigger_error('Constructor not provided a valid Auth_* object.', E_USER_ERROR);
[19]88        }
[42]89
[1]90        $this->_auth = $auth_object;
[42]91
[1]92        // Get create tables config from global context.
[136]93        if (!is_null($app->getParam('db_create_tables'))) {
94            $this->setParam(array('create_table' => $app->getParam('db_create_tables')));
[1]95        }
96    }
[42]97
[1]98    /**
99     * Setup the database table for this class.
100     *
101     * @access  public
102     * @author  Quinn Comendant <quinn@strangecode.com>
103     * @since   26 Aug 2005 17:09:36
104     */
[468]105    public function initDB($recreate_db=false)
[1]106    {
[479]107        $app =& App::getInstance();
108        $db =& DB::getInstance();
[136]109
[1]110        static $_db_tested = false;
[42]111
[1]112        if ($recreate_db || !$_db_tested && $this->getParam('create_table')) {
113            if ($recreate_db) {
[136]114                $db->query("DROP TABLE IF EXISTS " . $this->getParam('db_table'));
[201]115                $app->logMsg(sprintf('Dropping and recreating table %s.', $this->getParam('db_table')), LOG_INFO, __FILE__, __LINE__);
[1]116            }
[146]117            $db->query("CREATE TABLE IF NOT EXISTS " . $db->escapeString($this->getParam('db_table')) . " (
[484]118                version_id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
[169]119                record_table VARCHAR(255) NOT NULL DEFAULT '',
120                record_key VARCHAR(255) NOT NULL DEFAULT '',
121                record_val VARCHAR(255) NOT NULL DEFAULT '',
122                version_data MEDIUMBLOB NOT NULL,
123                version_title VARCHAR(255) NOT NULL DEFAULT '',
124                version_number SMALLINT(11) UNSIGNED NOT NULL DEFAULT '0',
125                version_notes VARCHAR(255) NOT NULL DEFAULT '',
126                saved_by_user_id SMALLINT(11) NOT NULL DEFAULT '0',
127                version_datetime DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
[1]128                KEY record_table (record_table),
129                KEY record_key (record_key),
130                KEY record_val (record_val)
131            )");
[42]132
[136]133            if (!$db->columnExists($this->getParam('db_table'), array(
[1]134                'version_id',
135                'record_table',
136                'record_key',
137                'record_val',
138                'version_data',
139                'version_title',
[169]140                'version_number',
[1]141                'version_notes',
[144]142                'saved_by_user_id',
[1]143                'version_datetime',
144            ), false, false)) {
[136]145                $app->logMsg(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_table')), LOG_ALERT, __FILE__, __LINE__);
[1]146                trigger_error(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_table')), E_USER_ERROR);
147            }
[42]148        }
[1]149        $_db_tested = true;
150    }
151
152    /**
153     * Set the params of this object.
154     *
155     * @param  array $params   Array of param keys and values to set.
156     */
[468]157    public function setParam($params=null)
[1]158    {
159        if (isset($params) && is_array($params)) {
160            // Merge new parameters with old overriding only those passed.
161            $this->_params = array_merge($this->_params, $params);
162        }
163    }
164
165    /**
[136]166     * Return the value of a parameter, if it exists.
[1]167     *
[136]168     * @access public
169     * @param string $param        Which parameter to return.
170     * @return mixed               Configured parameter value.
[1]171     */
[468]172    public function getParam($param)
[1]173    {
[479]174        $app =& App::getInstance();
[468]175
[478]176        if (array_key_exists($param, $this->_params)) {
[1]177            return $this->_params[$param];
178        } else {
[146]179            $app->logMsg(sprintf('Parameter is not set: %s', $param), LOG_DEBUG, __FILE__, __LINE__);
[1]180            return null;
181        }
182    }
183
184    /**
185     * Saves a version of the current record into the version table.
186     *
187     * @param string $record_table  The table containing the record.
188     * @param string $record_key    The key column for the record.
189     * @param string $record_val    The value of the key column for the record.
190     * @param string $title         The title of this record. Only used for human presentation.
191     *
192     * @return int                  The id for the version (mysql last insert id).
193     */
[468]194    public function create($record_table, $record_key, $record_val, $title='', $notes='')
[1]195    {
[479]196        $app =& App::getInstance();
197        $db =& DB::getInstance();
[136]198
[1]199        $this->initDB();
[42]200
[1]201        // Get current record.
202        if (!$record = $this->getCurrent($record_table, $record_key, $record_val)) {
[136]203            $app->logMsg(sprintf('Could not create %s version, record not found: %s, %s, %s.', $title, $record_table, $record_key, $record_val), LOG_ERR, __FILE__, __LINE__);
[1]204            return false;
205        }
[468]206
[169]207        // Get previous version_number.
208        $qid = $db->query("
209            SELECT MAX(version_number) FROM " . $db->escapeString($this->getParam('db_table')) . "
210            WHERE record_table = '" . $db->escapeString($record_table) . "'
211            AND record_key = '" . $db->escapeString($record_key) . "'
212            AND record_val = '" . $db->escapeString($record_val) . "'
213        ");
214        list($last_version_number) = mysql_fetch_row($qid);
[42]215
[1]216        // Clean-up old versions.
217        $this->deleteOld($record_table, $record_key, $record_val);
[42]218
[1]219        // Save as new version.
[159]220        // TODO: after MySQL 5.0.23 is released this query could benefit from INSERT DELAYED.
[136]221        $db->query("
[146]222            INSERT INTO " . $db->escapeString($this->getParam('db_table')) . " (
[1]223                record_table,
224                record_key,
225                record_val,
226                version_data,
227                version_title,
[169]228                version_number,
[1]229                version_notes,
[144]230                saved_by_user_id,
[1]231                version_datetime
232            ) VALUES (
[136]233                '" . $db->escapeString($record_table) . "',
234                '" . $db->escapeString($record_key) . "',
235                '" . $db->escapeString($record_val) . "',
236                '" . $db->escapeString(gzcompress(serialize($record), 9)) . "',
237                '" . $db->escapeString($title) . "',
[169]238                '" . $db->escapeString($last_version_number + 1) . "',
[136]239                '" . $db->escapeString($notes) . "',
[149]240                '" . $db->escapeString($this->_auth->get('user_id')) . "',
[1]241                NOW()
242            )
243        ");
244
[136]245        return mysql_insert_id($db->getDBH());
[1]246    }
247
248    /**
249     * Copy a version back into it's original table.
250     *
251     * @param string $version_id    The id of the version to restore.
252     *
253     * @return int                  The id for the version (mysql last insert id).
254     */
[468]255    public function restore($version_id)
[1]256    {
[479]257        $app =& App::getInstance();
258        $db =& DB::getInstance();
[136]259
[1]260        $this->initDB();
[42]261
[1]262        // Get version data.
[136]263        $qid = $db->query("
[146]264            SELECT * FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]265            WHERE version_id = '" . $db->escapeString($version_id) . "'
[1]266        ");
267        if (!$record = mysql_fetch_assoc($qid)) {
[136]268            $app->raiseMsg(sprintf(_("Version ID %s%s not found."), $version_id, (empty($record['version_title']) ? '' : ' (' . $record['version_title'] . ')')), MSG_WARNING, __FILE__, __LINE__);
[141]269            $app->logMsg(sprintf('Version ID %s%s not found.', $version_id, (empty($record['version_title']) ? '' : ' (' . $record['version_title'] . ')')), LOG_WARNING, __FILE__, __LINE__);
[1]270            return false;
271        }
272        $data = unserialize(gzuncompress($record['version_data']));
273
274        // Ensure saved db columns match current table schema.
[136]275        if (!$db->columnExists($record['record_table'], array_keys($data), $this->getParam('db_schema_strict'))) {
276            $app->raiseMsg(sprintf(_("Version ID %s%s is not compatible with the current database table."), $version_id, (empty($record['version_title']) ? '' : ' (' . $record['version_title'] . ')')), MSG_ERR, __FILE__, __LINE__);
[141]277            $app->logMsg(sprintf('Version ID %s%s restoration failed, DB schema does not match for table %s.', $version_id, (empty($record['version_title']) ? '' : ' (' . $record['version_title'] . ')'), $record['record_table']), LOG_ALERT, __FILE__, __LINE__);
[1]278            return false;
279        }
280
281        // SQLize the keys of the specified versioned record.
[136]282        $replace_keys = join(",\n", array_map(array($db, 'escapeString'), array_keys($data)));
[42]283
[1]284        // SQLize the keys of the values of the specified versioned record. (These are more complex because we need to account for SQL null values.)
285        $replace_values = '';
286        $comma = '';
287        foreach ($data as $v) {
[136]288            $replace_values .= is_null($v) ? "$comma\nNULL" : "$comma\n'" . $db->escapeString($v) . "'";
[1]289            $comma = ',';
290        }
[42]291
[1]292        // Replace current record with specified versioned record.
[136]293        $db->query("
[1]294            REPLACE INTO " . $record['record_table'] . " (
295                $replace_keys
296            ) VALUES (
297                $replace_values
298            )
299        ");
[42]300
[1]301        return $record;
302    }
303
304    /**
305     * Version garbage collection. Deletes versions older than min_days
306     * when quantity of versions exceeds min_qty. If quantity
[42]307     * exceeds 100 within min_days, the oldest are deleted to bring the
[1]308     * quantity back down to min_qty.
309     *
310     * @param string $record_table  The table containing the record.
311     * @param string $record_key    The key column for the record.
312     * @param string $record_val    The value of the key column for the record.
313     *
314     * @return mixed                Array of versions, or false if none.
315     */
[468]316    public function deleteOld($record_table, $record_key, $record_val)
[1]317    {
[479]318        $db =& DB::getInstance();
[468]319
[1]320        $this->initDB();
[42]321
[1]322        // Get total number of versions for this record.
[136]323        $qid = $db->query("
[146]324            SELECT COUNT(*) FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]325            WHERE record_table = '" . $db->escapeString($record_table) . "'
326            AND record_key = '" . $db->escapeString($record_key) . "'
327            AND record_val = '" . $db->escapeString($record_val) . "'
[1]328        ");
329        list($v_count) = mysql_fetch_row($qid);
[42]330
[1]331        if ($v_count > $this->getParam('min_qty')) {
332            if ($v_count > $this->getParam('max_qty')) {
333                // To prevent a record bomb, limit max number of versions to max_qty.
334                // First query for oldest records, selecting enough to bring total number down to min_qty.
[136]335                $qid = $db->query("
[146]336                    SELECT version_id FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]337                    WHERE record_table = '" . $db->escapeString($record_table) . "'
338                    AND record_key = '" . $db->escapeString($record_key) . "'
339                    AND record_val = '" . $db->escapeString($record_val) . "'
[1]340                    ORDER BY version_datetime ASC
341                    LIMIT " . ($v_count - $this->getParam('min_qty')) . "
342                ");
343                while (list($old_id) = mysql_fetch_row($qid)) {
344                    $old_versions[] = $old_id;
345                }
[136]346                $db->query("
[146]347                    DELETE FROM " . $db->escapeString($this->getParam('db_table')) . "
[1]348                    WHERE version_id IN ('" . join("','", $old_versions) . "')
349                ");
350            } else {
[49]351                // Delete versions older than min_days, while still keeping min_qty.
[136]352                $qid = $db->query("
[146]353                    SELECT version_id FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]354                    WHERE record_table = '" . $db->escapeString($record_table) . "'
355                    AND record_key = '" . $db->escapeString($record_key) . "'
356                    AND record_val = '" . $db->escapeString($record_val) . "'
[1]357                    AND DATE_ADD(version_datetime, INTERVAL '" . $this->getParam('min_days') . "' DAY) < NOW()
358                    ORDER BY version_datetime ASC
359                    LIMIT " . ($v_count - $this->getParam('min_qty')) . "
360                ");
361                while (list($old_id) = mysql_fetch_row($qid)) {
362                    $old_versions[] = $old_id;
363                }
364                if (sizeof($old_versions) > 0) {
[136]365                    $db->query("
[146]366                        DELETE FROM " . $db->escapeString($this->getParam('db_table')) . "
[1]367                        WHERE version_id IN ('" . join("','", $old_versions) . "')
368                    ");
369                }
370            }
371        }
372    }
373
374    /**
375     * Get a list of versions of specified record.
376     *
377     * @param string $record_table  The table containing the record.
378     * @param string $record_key    The key column for the record.
379     * @param string $record_val    The value of the key column for the record.
380     *
381     * @return mixed                Array of versions, or false if none.
382     */
[468]383    public function getList($record_table, $record_key, $record_val)
[1]384    {
[479]385        $db =& DB::getInstance();
[468]386
[1]387        $this->initDB();
[42]388
[1]389        // Get versions of this record.
[136]390        $qid = $db->query("
[468]391            SELECT
[169]392                version_id,
393                saved_by_user_id,
394                version_datetime,
395                version_title,
396                version_number,
397                version_notes
[146]398            FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]399            WHERE record_table = '" . $db->escapeString($record_table) . "'
400            AND record_key = '" . $db->escapeString($record_key) . "'
401            AND record_val = '" . $db->escapeString($record_val) . "'
[1]402            ORDER BY version_datetime DESC
[15]403        ");
[1]404        $versions = array();
405        while ($row = mysql_fetch_assoc($qid)) {
406            // Get admin usernames.
[161]407            $row['editor'] = $this->_auth->getUsername($row['saved_by_user_id']);
[1]408            $versions[] = $row;
409        }
410        return $versions;
411    }
412
413    /**
414     * Get the version record for a specified version id.
415     *
416     * @param string $version_id    The id of the version to restore.
417     *
418     * @return mixed                Array of data saved in version, or false if none.
419     */
[468]420    public function getVerson($version_id)
[1]421    {
[479]422        $db =& DB::getInstance();
[468]423
[1]424        $this->initDB();
[42]425
[1]426        // Get version data.
[136]427        $qid = $db->query("
[146]428            SELECT * FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]429            WHERE version_id = '" . $db->escapeString($version_id) . "'
[1]430        ");
431        return mysql_fetch_assoc($qid);
432    }
433
434    /**
435     * Get the data stored for a specified version id.
436     *
437     * @param string $version_id    The id of the version to restore.
438     *
439     * @return mixed                Array of data saved in version, or false if none.
440     */
[468]441    public function getData($version_id)
[1]442    {
[479]443        $db =& DB::getInstance();
[468]444
[1]445        $this->initDB();
[42]446
[1]447        // Get version data.
[136]448        $qid = $db->query("
[146]449            SELECT * FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]450            WHERE version_id = '" . $db->escapeString($version_id) . "'
[1]451        ");
452        $record = mysql_fetch_assoc($qid);
453        if (isset($record['version_data'])) {
454            return unserialize(gzuncompress($record['version_data']));
455        } else {
456            return false;
457        }
458    }
459
460    /**
461     * Get the current record data from the original table.
462     *
463     * @param string $version_id    The id of the version to restore.
464     *
465     * @return mixed                Array of data saved in version, or false if none.
466     */
[468]467    public function getCurrent($record_table, $record_key, $record_val)
[1]468    {
[479]469        $db =& DB::getInstance();
[468]470
[1]471        $this->initDB();
[42]472
[136]473        $qid = $db->query("
474            SELECT * FROM " . $db->escapeString($record_table) . "
475            WHERE " . $db->escapeString($record_key) . " = '" . $db->escapeString($record_val) . "'
[1]476        ");
477        if ($record = mysql_fetch_assoc($qid)) {
478            return $record;
479        } else {
480            return false;
481        }
482    }
483
484
485} // End of class.
Note: See TracBrowser for help on using the repository browser.