source: tags/2.1.5/lib/Version.inc.php

Last change on this file was 377, checked in by quinn, 14 years ago

Releasing trunk as stable version 2.1.5

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