source: trunk/lib/Version.inc.php

Last change on this file was 764, checked in by anonymous, 2 years ago

Add $merge parameter to exclude values from record versioning (e.g., site_add_vars)

File size: 23.6 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 */
[502]39class Version
40{
[1]41
[468]42    // A place to keep an object instance for the singleton pattern.
[484]43    protected static $instance = null;
[468]44
[1]45    // Configuration of this object.
[484]46    protected $_params = array(
[1]47        'max_qty' => 100, // Never have more than this many versions of each record.
48        'min_qty' => 25, // Keep at least this many versions of each record.
49        'min_days' => 7, // Keep ALL versions within this many days, even if MORE than min_qty.
50        'db_table' => 'version_tbl',
[468]51
[146]52        // Automatically create table and verify columns. Better set to false after site launch.
[396]53        // This value is overwritten by the $app->getParam('db_create_tables') setting if it is available.
[146]54        'create_table' => true,
[532]55
[396]56        // If true, makes an exact comparison of saved vs. live table schemas. If false, just checks that the saved columns are available.
57        'db_schema_strict' => true,
[550]58
59        // Serialization method.
60        // Legacy installations will have been using 'phpserialize' but these should migrate to use 'json' to avoid PHP object injection https://www.owasp.org/index.php/PHP_Object_Injection
61        'serialization_method' => 'phpserialize', // Or 'json'
[1]62    );
63
64    // Auth_SQL object from which to access a current user_id.
[484]65    protected $_auth;
[1]66
67    /**
68     * This method enforces the singleton pattern for this class.
69     *
[139]70     * @return  object  Reference to the global Lock object.
[1]71     * @access  public
72     * @static
73     */
[523]74    public static function &getInstance($auth_object=null)
[1]75    {
[468]76        if (self::$instance === null) {
77            self::$instance = new self($auth_object);
[1]78        }
79
[468]80        return self::$instance;
[1]81    }
82
83    /**
84     * Constructor. Pass an Auth object on which to perform user lookups.
85     *
86     * @param mixed  $auth_object  An Auth_SQL object.
87     */
[523]88    public function __construct($auth_object=null)
[1]89    {
[479]90        $app =& App::getInstance();
[136]91
[523]92        if (!is_null($auth_object) || is_null($this->_auth)) {
93            if (!method_exists($auth_object, 'get') || !method_exists($auth_object, 'getUsername')) {
94                trigger_error('Constructor not provided a valid Auth_* object.', E_USER_ERROR);
95            }
96
97            $this->_auth = $auth_object;
[19]98        }
[42]99
[1]100        // Get create tables config from global context.
[136]101        if (!is_null($app->getParam('db_create_tables'))) {
102            $this->setParam(array('create_table' => $app->getParam('db_create_tables')));
[1]103        }
104    }
[42]105
[1]106    /**
107     * Setup the database table for this class.
108     *
109     * @access  public
110     * @author  Quinn Comendant <quinn@strangecode.com>
111     * @since   26 Aug 2005 17:09:36
112     */
[468]113    public function initDB($recreate_db=false)
[1]114    {
[479]115        $app =& App::getInstance();
116        $db =& DB::getInstance();
[136]117
[1]118        static $_db_tested = false;
[42]119
[1]120        if ($recreate_db || !$_db_tested && $this->getParam('create_table')) {
121            if ($recreate_db) {
[136]122                $db->query("DROP TABLE IF EXISTS " . $this->getParam('db_table'));
[201]123                $app->logMsg(sprintf('Dropping and recreating table %s.', $this->getParam('db_table')), LOG_INFO, __FILE__, __LINE__);
[1]124            }
[601]125            $db->query(sprintf("CREATE TABLE IF NOT EXISTS %s (
[484]126                version_id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
[169]127                record_table VARCHAR(255) NOT NULL DEFAULT '',
128                record_key VARCHAR(255) NOT NULL DEFAULT '',
129                record_val VARCHAR(255) NOT NULL DEFAULT '',
130                version_data MEDIUMBLOB NOT NULL,
131                version_title VARCHAR(255) NOT NULL DEFAULT '',
132                version_number SMALLINT(11) UNSIGNED NOT NULL DEFAULT '0',
133                version_notes VARCHAR(255) NOT NULL DEFAULT '',
134                saved_by_user_id SMALLINT(11) NOT NULL DEFAULT '0',
[601]135                version_datetime DATETIME NOT NULL DEFAULT '%s 00:00:00',
[1]136                KEY record_table (record_table),
137                KEY record_key (record_key),
138                KEY record_val (record_val)
[601]139            )", $db->escapeString($this->getParam('db_table')), $db->getParam('zero_date')));
[42]140
[136]141            if (!$db->columnExists($this->getParam('db_table'), array(
[1]142                'version_id',
143                'record_table',
144                'record_key',
145                'record_val',
146                'version_data',
147                'version_title',
[169]148                'version_number',
[1]149                'version_notes',
[144]150                'saved_by_user_id',
[1]151                'version_datetime',
152            ), false, false)) {
[136]153                $app->logMsg(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_table')), LOG_ALERT, __FILE__, __LINE__);
[1]154                trigger_error(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_table')), E_USER_ERROR);
155            }
[42]156        }
[1]157        $_db_tested = true;
158    }
159
160    /**
161     * Set the params of this object.
162     *
163     * @param  array $params   Array of param keys and values to set.
164     */
[468]165    public function setParam($params=null)
[1]166    {
[550]167        $app =& App::getInstance();
168
169        if (isset($params['serialization_method']) && !in_array($params['serialization_method'], ['phpserialize', 'json'])) {
170            trigger_error(sprintf('Invalid serialization_method: %s', $params['serialization_method']), E_USER_ERROR);
171        }
[1]172        if (isset($params) && is_array($params)) {
173            // Merge new parameters with old overriding only those passed.
174            $this->_params = array_merge($this->_params, $params);
175        }
176    }
177
178    /**
[136]179     * Return the value of a parameter, if it exists.
[1]180     *
[136]181     * @access public
182     * @param string $param        Which parameter to return.
183     * @return mixed               Configured parameter value.
[1]184     */
[468]185    public function getParam($param)
[1]186    {
[479]187        $app =& App::getInstance();
[468]188
[478]189        if (array_key_exists($param, $this->_params)) {
[1]190            return $this->_params[$param];
191        } else {
[146]192            $app->logMsg(sprintf('Parameter is not set: %s', $param), LOG_DEBUG, __FILE__, __LINE__);
[1]193            return null;
194        }
195    }
196
197    /**
198     * Saves a version of the current record into the version table.
199     *
200     * @param string $record_table  The table containing the record.
201     * @param string $record_key    The key column for the record.
202     * @param string $record_val    The value of the key column for the record.
203     * @param string $title         The title of this record. Only used for human presentation.
[764]204     * @param array  $merge         Array of values to merge with the current record values (use this to force 'key' to be an empty string in a saved version, e.g., ['key' => '']).
[1]205     *
206     * @return int                  The id for the version (mysql last insert id).
207     */
[764]208    public function create($record_table, $record_key, $record_val, $title='', $notes='', Array $merge=[])
[1]209    {
[479]210        $app =& App::getInstance();
211        $db =& DB::getInstance();
[136]212
[1]213        $this->initDB();
[42]214
[1]215        // Get current record.
[764]216        if (!$record = $this->getCurrent($record_table, $record_key, $record_val, $merge)) {
[136]217            $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]218            return false;
219        }
[468]220
[169]221        // Get previous version_number.
222        $qid = $db->query("
223            SELECT MAX(version_number) FROM " . $db->escapeString($this->getParam('db_table')) . "
224            WHERE record_table = '" . $db->escapeString($record_table) . "'
225            AND record_key = '" . $db->escapeString($record_key) . "'
226            AND record_val = '" . $db->escapeString($record_val) . "'
227        ");
228        list($last_version_number) = mysql_fetch_row($qid);
[42]229
[1]230        // Clean-up old versions.
231        $this->deleteOld($record_table, $record_key, $record_val);
[42]232
[550]233        // Serialize the DB record.
234        switch ($this->getParam('serialization_method')) {
235        case 'phpserialize':
236            $data = gzcompress(serialize($record), 9);
237            break;
238
239        case 'json':
240            $data = gzcompress(json_encode($record), 9);
241            break;
242        }
243
[1]244        // Save as new version.
[159]245        // TODO: after MySQL 5.0.23 is released this query could benefit from INSERT DELAYED.
[136]246        $db->query("
[146]247            INSERT INTO " . $db->escapeString($this->getParam('db_table')) . " (
[1]248                record_table,
249                record_key,
250                record_val,
251                version_data,
252                version_title,
[169]253                version_number,
[1]254                version_notes,
[144]255                saved_by_user_id,
[1]256                version_datetime
257            ) VALUES (
[136]258                '" . $db->escapeString($record_table) . "',
259                '" . $db->escapeString($record_key) . "',
260                '" . $db->escapeString($record_val) . "',
[550]261                '" . $db->escapeString($data) . "',
[136]262                '" . $db->escapeString($title) . "',
[169]263                '" . $db->escapeString($last_version_number + 1) . "',
[136]264                '" . $db->escapeString($notes) . "',
[149]265                '" . $db->escapeString($this->_auth->get('user_id')) . "',
[1]266                NOW()
267            )
268        ");
269
[136]270        return mysql_insert_id($db->getDBH());
[1]271    }
272
273    /**
274     * Copy a version back into it's original table.
275     *
276     * @param string $version_id    The id of the version to restore.
277     *
278     * @return int                  The id for the version (mysql last insert id).
279     */
[468]280    public function restore($version_id)
[1]281    {
[479]282        $app =& App::getInstance();
283        $db =& DB::getInstance();
[136]284
[1]285        $this->initDB();
[42]286
[1]287        // Get version data.
[136]288        $qid = $db->query("
[550]289            SELECT *
290            FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]291            WHERE version_id = '" . $db->escapeString($version_id) . "'
[1]292        ");
293        if (!$record = mysql_fetch_assoc($qid)) {
[497]294            $app->raiseMsg(sprintf(_("Version %s%s not found."), $version_id, (empty($record['version_title']) ? '' : ' (' . $record['version_title'] . ')')), MSG_WARNING, __FILE__, __LINE__);
295            $app->logMsg(sprintf('Version %s%s not found.', $version_id, (empty($record['version_title']) ? '' : ' (' . $record['version_title'] . ')')), LOG_WARNING, __FILE__, __LINE__);
[1]296            return false;
297        }
298
[550]299        // Unserialize the DB record.
300        switch ($this->getParam('serialization_method')) {
301        case 'phpserialize':
302            $data = unserialize(gzuncompress($record['version_data']));
303            break;
304
305        case 'json':
306            $data = json_decode(gzuncompress($record['version_data']), true);
307            break;
308        }
309
[1]310        // Ensure saved db columns match current table schema.
[136]311        if (!$db->columnExists($record['record_table'], array_keys($data), $this->getParam('db_schema_strict'))) {
[497]312            $app->raiseMsg(sprintf(_("Version %s%s is not compatible with the current database table."), $version_id, (empty($record['version_title']) ? '' : ' (' . $record['version_title'] . ')')), MSG_ERR, __FILE__, __LINE__);
313            $app->logMsg(sprintf('Version %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]314            return false;
315        }
316
317        // SQLize the keys of the specified versioned record.
[136]318        $replace_keys = join(",\n", array_map(array($db, 'escapeString'), array_keys($data)));
[42]319
[1]320        // SQLize the keys of the values of the specified versioned record. (These are more complex because we need to account for SQL null values.)
321        $replace_values = '';
322        $comma = '';
323        foreach ($data as $v) {
[136]324            $replace_values .= is_null($v) ? "$comma\nNULL" : "$comma\n'" . $db->escapeString($v) . "'";
[1]325            $comma = ',';
326        }
[42]327
[502]328        // Disable foreign_key_checks to prevent ON DELETE triggers or restrictions.
329        $db->query("SET SESSION foreign_key_checks = 0");
330        // Replace current record with specified versioned record. Consider converting this SQL to use INSERT 
 ON DUPLICATE KEY UPDATE 

[136]331        $db->query("
[502]332        REPLACE INTO " . $record['record_table'] . " (
[1]333                $replace_keys
334            ) VALUES (
335                $replace_values
[502]336            );
[1]337        ");
[502]338        // Re-enable foreign_key_checks.
339        $db->query("SET SESSION foreign_key_checks = 1");
[42]340
[1]341        return $record;
342    }
343
344    /**
345     * Version garbage collection. Deletes versions older than min_days
346     * when quantity of versions exceeds min_qty. If quantity
[42]347     * exceeds 100 within min_days, the oldest are deleted to bring the
[1]348     * quantity back down to min_qty.
349     *
350     * @param string $record_table  The table containing the record.
351     * @param string $record_key    The key column for the record.
352     * @param string $record_val    The value of the key column for the record.
353     *
354     * @return mixed                Array of versions, or false if none.
355     */
[468]356    public function deleteOld($record_table, $record_key, $record_val)
[1]357    {
[479]358        $db =& DB::getInstance();
[468]359
[1]360        $this->initDB();
[42]361
[1]362        // Get total number of versions for this record.
[136]363        $qid = $db->query("
[146]364            SELECT COUNT(*) FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]365            WHERE record_table = '" . $db->escapeString($record_table) . "'
366            AND record_key = '" . $db->escapeString($record_key) . "'
367            AND record_val = '" . $db->escapeString($record_val) . "'
[1]368        ");
369        list($v_count) = mysql_fetch_row($qid);
[42]370
[1]371        if ($v_count > $this->getParam('min_qty')) {
372            if ($v_count > $this->getParam('max_qty')) {
373                // To prevent a record bomb, limit max number of versions to max_qty.
374                // First query for oldest records, selecting enough to bring total number down to min_qty.
[136]375                $qid = $db->query("
[550]376                    SELECT version_id
377                    FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]378                    WHERE record_table = '" . $db->escapeString($record_table) . "'
379                    AND record_key = '" . $db->escapeString($record_key) . "'
380                    AND record_val = '" . $db->escapeString($record_val) . "'
[1]381                    ORDER BY version_datetime ASC
[550]382                    LIMIT " . $db->escapeString($v_count - $this->getParam('min_qty')) . "
[1]383                ");
[550]384                $old_versions = array();
[1]385                while (list($old_id) = mysql_fetch_row($qid)) {
386                    $old_versions[] = $old_id;
387                }
[136]388                $db->query("
[146]389                    DELETE FROM " . $db->escapeString($this->getParam('db_table')) . "
[1]390                    WHERE version_id IN ('" . join("','", $old_versions) . "')
391                ");
392            } else {
[49]393                // Delete versions older than min_days, while still keeping min_qty.
[136]394                $qid = $db->query("
[550]395                    SELECT version_id
396                    FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]397                    WHERE record_table = '" . $db->escapeString($record_table) . "'
398                    AND record_key = '" . $db->escapeString($record_key) . "'
399                    AND record_val = '" . $db->escapeString($record_val) . "'
[1]400                    AND DATE_ADD(version_datetime, INTERVAL '" . $this->getParam('min_days') . "' DAY) < NOW()
401                    ORDER BY version_datetime ASC
402                    LIMIT " . ($v_count - $this->getParam('min_qty')) . "
403                ");
[550]404                $old_versions = array();
[1]405                while (list($old_id) = mysql_fetch_row($qid)) {
406                    $old_versions[] = $old_id;
407                }
408                if (sizeof($old_versions) > 0) {
[136]409                    $db->query("
[146]410                        DELETE FROM " . $db->escapeString($this->getParam('db_table')) . "
[1]411                        WHERE version_id IN ('" . join("','", $old_versions) . "')
412                    ");
413                }
414            }
415        }
416    }
417
418    /**
[637]419     * Delete all versioned history of a DB record.
420     *
421     * @param string $record_table  The table containing the record.
422     * @param string $record_key    The key column for the record.
423     * @param string $record_val    The value of the key column for the record.
424     *
425     * @return void
426     */
427    public function deleteAll($record_table, $record_key, $record_val)
428    {
429        $app =& App::getInstance();
430        $db =& DB::getInstance();
431
432        $this->initDB();
433
434        // Delete all versions for this record.
435        $qid = $db->query("
436            DELETE FROM " . $db->escapeString($this->getParam('db_table')) . "
437            WHERE record_table = '" . $db->escapeString($record_table) . "'
438            AND record_key = '" . $db->escapeString($record_key) . "'
439            AND record_val = '" . $db->escapeString($record_val) . "'
440        ");
441        $app->logMsg(sprintf('Deleted all %s rows for %s.%s=%s', mysql_affected_rows($db->getDBH()), $record_table, $record_key, $record_val), LOG_INFO, __FILE__, __LINE__);
442    }
443
444    /**
445     * Delete one version of a DB record.
446     *
447     * @param string $version_id    The ID of the version to delete.
448     *
449     * @return void
450     */
451    public function delete($version_id)
452    {
453        $app =& App::getInstance();
454        $db =& DB::getInstance();
455
456        $this->initDB();
457
458        // Delete one version.
459        $qid = $db->query("
460            DELETE FROM " . $db->escapeString($this->getParam('db_table')) . "
461            WHERE version_id = '" . $db->escapeString($version_id) . "'
462        ");
463        $app->logMsg(sprintf('Deleted version_id=%s', $version_id), LOG_INFO, __FILE__, __LINE__);
464    }
465
466    /**
[1]467     * Get a list of versions of specified record.
468     *
469     * @param string $record_table  The table containing the record.
470     * @param string $record_key    The key column for the record.
471     * @param string $record_val    The value of the key column for the record.
472     *
473     * @return mixed                Array of versions, or false if none.
474     */
[468]475    public function getList($record_table, $record_key, $record_val)
[1]476    {
[479]477        $db =& DB::getInstance();
[468]478
[1]479        $this->initDB();
[42]480
[1]481        // Get versions of this record.
[136]482        $qid = $db->query("
[468]483            SELECT
[169]484                version_id,
485                saved_by_user_id,
486                version_datetime,
487                version_title,
488                version_number,
489                version_notes
[146]490            FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]491            WHERE record_table = '" . $db->escapeString($record_table) . "'
492            AND record_key = '" . $db->escapeString($record_key) . "'
493            AND record_val = '" . $db->escapeString($record_val) . "'
[1]494            ORDER BY version_datetime DESC
[15]495        ");
[1]496        $versions = array();
497        while ($row = mysql_fetch_assoc($qid)) {
498            // Get admin usernames.
[161]499            $row['editor'] = $this->_auth->getUsername($row['saved_by_user_id']);
[1]500            $versions[] = $row;
501        }
502        return $versions;
503    }
504
505    /**
506     * Get the version record for a specified version id.
507     *
508     * @param string $version_id    The id of the version to restore.
509     *
510     * @return mixed                Array of data saved in version, or false if none.
511     */
[468]512    public function getVerson($version_id)
[1]513    {
[479]514        $db =& DB::getInstance();
[468]515
[1]516        $this->initDB();
[42]517
[1]518        // Get version data.
[136]519        $qid = $db->query("
[146]520            SELECT * FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]521            WHERE version_id = '" . $db->escapeString($version_id) . "'
[1]522        ");
523        return mysql_fetch_assoc($qid);
524    }
525
526    /**
527     * Get the data stored for a specified version id.
528     *
529     * @param string $version_id    The id of the version to restore.
530     *
531     * @return mixed                Array of data saved in version, or false if none.
532     */
[468]533    public function getData($version_id)
[1]534    {
[479]535        $db =& DB::getInstance();
[468]536
[1]537        $this->initDB();
[42]538
[1]539        // Get version data.
[136]540        $qid = $db->query("
[550]541            SELECT *
542            FROM " . $db->escapeString($this->getParam('db_table')) . "
[136]543            WHERE version_id = '" . $db->escapeString($version_id) . "'
[1]544        ");
545        $record = mysql_fetch_assoc($qid);
546        if (isset($record['version_data'])) {
[550]547            // Unserialize the DB record.
548            switch ($this->getParam('serialization_method')) {
549            case 'phpserialize':
550                return unserialize(gzuncompress($record['version_data']));
551
552            case 'json':
553                return json_decode(gzuncompress($record['version_data']));
554            }
[1]555        } else {
556            return false;
557        }
558    }
559
560    /**
561     * Get the current record data from the original table.
562     *
[764]563     * @param string $record_table  The table containing the record.
564     * @param string $record_key    The key column for the record.
565     * @param string $record_val    The value of the key column for the record.
566     * @param array  $merge         Array of values to merge with the current record values (use this to redact certain values, e.g., ['key' => '']).
[1]567     *
[764]568     * @return mixed                Array of data from the current database record.
[1]569     */
[764]570    public function getCurrent($record_table, $record_key, $record_val, Array $merge=[])
[1]571    {
[479]572        $db =& DB::getInstance();
[502]573        $app =& App::getInstance();
[468]574
[1]575        $this->initDB();
[42]576
[502]577        if (!$record_table || !$record_key || !$record_val) {
578            $app->logMsg(sprintf('Invalid current version args: %s, %s, %s.', $record_table, $record_key, $record_val), LOG_ERR, __FILE__, __LINE__);
579            return false;
580        }
581
[136]582        $qid = $db->query("
583            SELECT * FROM " . $db->escapeString($record_table) . "
584            WHERE " . $db->escapeString($record_key) . " = '" . $db->escapeString($record_val) . "'
[1]585        ");
586        if ($record = mysql_fetch_assoc($qid)) {
[764]587            return array_merge($record, $merge);
[1]588        } else {
589            return false;
590        }
591    }
592
593
594} // End of class.
Note: See TracBrowser for help on using the repository browser.