]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - lib/mysqlschema.php
Some cleanup on detecting types
[quix0rs-gnu-social.git] / lib / mysqlschema.php
1 <?php
2 /**
3  * StatusNet, the distributed open-source microblogging tool
4  *
5  * Database schema utilities
6  *
7  * PHP version 5
8  *
9  * LICENCE: This program is free software: you can redistribute it and/or modify
10  * it under the terms of the GNU Affero General Public License as published by
11  * the Free Software Foundation, either version 3 of the License, or
12  * (at your option) any later version.
13  *
14  * This program is distributed in the hope that it will be useful,
15  * but WITHOUT ANY WARRANTY; without even the implied warranty of
16  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17  * GNU Affero General Public License for more details.
18  *
19  * You should have received a copy of the GNU Affero General Public License
20  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
21  *
22  * @category  Database
23  * @package   StatusNet
24  * @author    Evan Prodromou <evan@status.net>
25  * @copyright 2009 StatusNet, Inc.
26  * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
27  * @link      http://status.net/
28  */
29
30 if (!defined('STATUSNET')) {
31     exit(1);
32 }
33
34 /**
35  * Class representing the database schema
36  *
37  * A class representing the database schema. Can be used to
38  * manipulate the schema -- especially for plugins and upgrade
39  * utilities.
40  *
41  * @category Database
42  * @package  StatusNet
43  * @author   Evan Prodromou <evan@status.net>
44  * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
45  * @link     http://status.net/
46  */
47
48 class MysqlSchema extends Schema
49 {
50     static $_single = null;
51     protected $conn = null;
52
53
54     /**
55      * Main public entry point. Use this to get
56      * the singleton object.
57      *
58      * @return Schema the (single) Schema object
59      */
60
61     static function get()
62     {
63         if (empty(self::$_single)) {
64             self::$_single = new Schema();
65         }
66         return self::$_single;
67     }
68
69     /**
70      * Returns a TableDef object for the table
71      * in the schema with the given name.
72      *
73      * Throws an exception if the table is not found.
74      *
75      * @param string $table Name of the table to get
76      *
77      * @return TableDef tabledef for that table.
78      * @throws SchemaTableMissingException
79      */
80
81     public function getTableDef($table)
82     {
83         $def = array();
84         $hasKeys = false;
85
86         // Pull column data from INFORMATION_SCHEMA
87         $columns = $this->fetchMetaInfo($table, 'COLUMNS', 'ORDINAL_POSITION');
88         if (count($columns) == 0) {
89             throw new SchemaTableMissingException("No such table: $table");
90         }
91
92         foreach ($columns as $row) {
93
94             $name = $row['COLUMN_NAME'];
95             $field = array();
96
97             // warning -- 'unsigned' attr on numbers isn't given in DATA_TYPE and friends.
98             // It is stuck in on COLUMN_TYPE though (eg 'bigint(20) unsigned')
99             list($type, $size) = $this->reverseMapType($row['DATA_TYPE']);
100             $field['type'] = $type;
101             if ($size !== null) {
102                 $field['size'] = $size;
103             }
104
105             if ($type == 'char' || $type == 'varchar') {
106                 if ($row['CHARACTER_MAXIMUM_LENGTH'] !== null) {
107                     $field['length'] = intval($row['CHARACTER_MAXIMUM_LENGTH']);
108                 }
109             }
110             if ($type == 'numeric') {
111                 // Other int types may report these values, but they're irrelevant.
112                 // Just ignore them!
113                 if ($row['NUMERIC_PRECISION'] !== null) {
114                     $field['precision'] = intval($row['NUMERIC_PRECISION']);
115                 }
116                 if ($row['NUMERIC_SCALE'] !== null) {
117                     $field['scale'] = intval($row['NUMERIC_SCALE']);
118                 }
119             }
120             if ($row['IS_NULLABLE'] == 'NO') {
121                 $field['not null'] = true;
122             }
123             if ($row['COLUMN_DEFAULT'] !== null) {
124                 $field['default'] = $row['COLUMN_DEFAULT'];
125                 if ($this->isNumericType($type)) {
126                     $field['default'] = intval($field['default']);
127                 }
128             }
129             if ($row['COLUMN_KEY'] !== null) {
130                 // We'll need to look up key info...
131                 $hasKeys = true;
132             }
133             if ($row['COLUMN_COMMENT'] !== null) {
134                 $field['description'] = $row['COLUMN_COMMENT'];
135             }
136
137             // $row['EXTRA'] may contain 'autoincrement'
138             // ^ type=serial?
139             // $row['EXTRA'] may contain 'on update CURRENT_TIMESTAMP'
140             // ^ ...... how to specify?
141             // these seem to be the only values in curent use
142             
143             if ($row['CHARACTER_SET_NAME'] !== null) {
144                 // @fixme check against defaults?
145                 //$def['charset'] = $row['CHARACTER_SET_NAME'];
146                 //$def['collate']  = $row['COLLATION_NAME'];
147             }
148
149             $def['fields'][$name] = $field;
150         }
151
152         if ($hasKeys) {
153             // INFORMATION_SCHEMA's CONSTRAINTS and KEY_COLUMN_USAGE tables give
154             // good info on primary and unique keys but don't list ANY info on
155             // multi-value keys, which is lame-o. Sigh.
156             //
157             // Let's go old school and use SHOW INDEX :D
158             //
159             $keyInfo = $this->fetchIndexInfo($table);
160             $keys = array();
161             foreach ($keyInfo as $row) {
162                 $name = $row['Key_name'];
163                 $column = $row['Column_name'];
164
165                 if (!isset($keys[$name])) {
166                     $keys[$name] = array();
167                 }
168                 $keys[$name][] = $column;
169
170                 if ($name == 'PRIMARY') {
171                     $type = 'primary key';
172                 } else if ($row['Non_unique'] == 0) {
173                     $type = 'unique keys';
174                 } else if ($row['Index_type'] == 'FULLTEXT') {
175                     $type = 'fulltext indexes';
176                 } else {
177                     $type = 'indexes';
178                 }
179                 $keyTypes[$name] = $type;
180             }
181
182             foreach ($keyTypes as $name => $type) {
183                 if ($type == 'primary key') {
184                     // there can be only one
185                     $def[$type] = $keys[$name];
186                 } else {
187                     $def[$type][$name] = $keys[$name];
188                 }
189             }
190         }
191         return $def;
192     }
193
194     /**
195      * Pull the given table properties from INFORMATION_SCHEMA.
196      * Most of the good stuff is MySQL extensions.
197      *
198      * @return array
199      * @throws Exception if table info can't be looked up
200      */
201
202     function getTableProperties($table, $props)
203     {
204         $data = $this->fetchMetaInfo($table, 'TABLES');
205         if ($data) {
206             return $data[0];
207         } else {
208             throw new SchemaTableMissingException("No such table: $table");
209         }
210     }
211
212     /**
213      * Pull some INFORMATION.SCHEMA data for the given table.
214      *
215      * @param string $table
216      * @return array of arrays
217      */
218     function fetchMetaInfo($table, $infoTable, $orderBy=null)
219     {
220         $query = "SELECT * FROM INFORMATION_SCHEMA.%s " .
221                  "WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='%s'";
222         $schema = $this->conn->dsn['database'];
223         $sql = sprintf($query, $infoTable, $schema, $table);
224         if ($orderBy) {
225             $sql .= ' ORDER BY ' . $orderBy;
226         }
227         return $this->fetchQueryData($sql);
228     }
229
230     /**
231      * Pull 'SHOW INDEX' data for the given table.
232      *
233      * @param string $table
234      * @return array of arrays
235      */
236     function fetchIndexInfo($table)
237     {
238         $query = "SHOW INDEX FROM `%s`";
239         $sql = sprintf($query, $table);
240         return $this->fetchQueryData($sql);
241     }
242
243     /**
244      * Pull info from the query into a fun-fun array of dooooom
245      *
246      * @param string $sql
247      * @return array of arrays
248      */
249     protected function fetchQueryData($sql)
250     {
251         $res = $this->conn->query($sql);
252         if (PEAR::isError($res)) {
253             throw new Exception($res->getMessage());
254         }
255
256         $out = array();
257         $row = array();
258         while ($res->fetchInto($row, DB_FETCHMODE_ASSOC)) {
259             $out[] = $row;
260         }
261         $res->free();
262
263         return $out;
264     }
265
266     /**
267      * Creates a table with the given names and columns.
268      *
269      * @param string $name    Name of the table
270      * @param array  $columns Array of ColumnDef objects
271      *                        for new table.
272      *
273      * @return boolean success flag
274      */
275
276     public function createTable($name, $columns)
277     {
278         $uniques = array();
279         $primary = array();
280         $indices = array();
281
282         $sql = "CREATE TABLE $name (\n";
283
284         for ($i = 0; $i < count($columns); $i++) {
285
286             $cd =& $columns[$i];
287
288             if ($i > 0) {
289                 $sql .= ",\n";
290             }
291
292             $sql .= $this->_columnSql($cd);
293         }
294
295         $idx = $this->_indexList($columns);
296
297         if ($idx['primary']) {
298             $sql .= ",\nconstraint primary key (" . implode(',', $idx['primary']) . ")";
299         }
300
301         foreach ($idx['uniques'] as $u) {
302             $key = $this->_uniqueKey($name, $u);
303             $sql .= ",\nunique index $key ($u)";
304         }
305
306         foreach ($idx['indices'] as $i) {
307             $key = $this->_key($name, $i);
308             $sql .= ",\nindex $key ($i)";
309         }
310
311         $sql .= ") ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_bin; ";
312
313         $res = $this->conn->query($sql);
314
315         if (PEAR::isError($res)) {
316             throw new Exception($res->getMessage());
317         }
318
319         return true;
320     }
321
322     /**
323      * Look over a list of column definitions and list up which
324      * indices will be present
325      */
326     private function _indexList(array $columns)
327     {
328         $list = array('uniques' => array(),
329                       'primary' => array(),
330                       'indices' => array());
331         foreach ($columns as $cd) {
332             switch ($cd->key) {
333             case 'UNI':
334                 $list['uniques'][] = $cd->name;
335                 break;
336             case 'PRI':
337                 $list['primary'][] = $cd->name;
338                 break;
339             case 'MUL':
340                 $list['indices'][] = $cd->name;
341                 break;
342             }
343         }
344         return $list;
345     }
346
347     /**
348      * Get the unique index key name for a given column on this table
349      */
350     function _uniqueKey($tableName, $columnName)
351     {
352         return $this->_key($tableName, $columnName);
353     }
354
355     /**
356      * Get the index key name for a given column on this table
357      */
358     function _key($tableName, $columnName)
359     {
360         return "{$tableName}_{$columnName}_idx";
361     }
362
363     /**
364      * Ensures that a table exists with the given
365      * name and the given column definitions.
366      *
367      * If the table does not yet exist, it will
368      * create the table. If it does exist, it will
369      * alter the table to match the column definitions.
370      *
371      * @param string $tableName name of the table
372      * @param array  $columns   array of ColumnDef
373      *                          objects for the table
374      *
375      * @return boolean success flag
376      */
377
378     public function ensureTable($tableName, $columns)
379     {
380         // XXX: DB engine portability -> toilet
381
382         try {
383             $td = $this->getTableDef($tableName);
384         } catch (SchemaTableMissingException $e) {
385             return $this->createTable($tableName, $columns);
386         }
387
388         $cur = $this->_names($td->columns);
389         $new = $this->_names($columns);
390
391         $dropIndex  = array();
392         $toadd      = array_diff($new, $cur);
393         $todrop     = array_diff($cur, $new);
394         $same       = array_intersect($new, $cur);
395         $tomod      = array();
396         $addIndex   = array();
397         $tableProps = array();
398
399         foreach ($same as $m) {
400             $curCol = $this->_byName($td->columns, $m);
401             $newCol = $this->_byName($columns, $m);
402
403             if (!$newCol->equals($curCol)) {
404                 $tomod[] = $newCol->name;
405                 continue;
406             }
407
408             // Earlier versions may have accidentally left tables at default
409             // charsets which might be latin1 or other freakish things.
410             if ($this->_isString($curCol)) {
411                 if ($curCol->charset != 'utf8') {
412                     $tomod[] = $newCol->name;
413                     continue;
414                 }
415             }
416         }
417
418         // Find any indices we have to change...
419         $curIdx = $this->_indexList($td->columns);
420         $newIdx = $this->_indexList($columns);
421
422         if ($curIdx['primary'] != $newIdx['primary']) {
423             if ($curIdx['primary']) {
424                 $dropIndex[] = 'drop primary key';
425             }
426             if ($newIdx['primary']) {
427                 $keys = implode(',', $newIdx['primary']);
428                 $addIndex[] = "add constraint primary key ($keys)";
429             }
430         }
431
432         $dropUnique = array_diff($curIdx['uniques'], $newIdx['uniques']);
433         $addUnique = array_diff($newIdx['uniques'], $curIdx['uniques']);
434         foreach ($dropUnique as $columnName) {
435             $dropIndex[] = 'drop key ' . $this->_uniqueKey($tableName, $columnName);
436         }
437         foreach ($addUnique as $columnName) {
438             $addIndex[] = 'add constraint unique key ' . $this->_uniqueKey($tableName, $columnName) . " ($columnName)";;
439         }
440
441         $dropMultiple = array_diff($curIdx['indices'], $newIdx['indices']);
442         $addMultiple = array_diff($newIdx['indices'], $curIdx['indices']);
443         foreach ($dropMultiple as $columnName) {
444             $dropIndex[] = 'drop key ' . $this->_key($tableName, $columnName);
445         }
446         foreach ($addMultiple as $columnName) {
447             $addIndex[] = 'add key ' . $this->_key($tableName, $columnName) . " ($columnName)";
448         }
449
450         // Check for table properties: make sure we're using a sane
451         // engine type and charset/collation.
452         // @fixme make the default engine configurable?
453         $oldProps = $this->getTableProperties($tableName, array('ENGINE', 'TABLE_COLLATION'));
454         if (strtolower($oldProps['ENGINE']) != 'innodb') {
455             $tableProps['ENGINE'] = 'InnoDB';
456         }
457         if (strtolower($oldProps['TABLE_COLLATION']) != 'utf8_bin') {
458             $tableProps['DEFAULT CHARSET'] = 'utf8';
459             $tableProps['COLLATE'] = 'utf8_bin';
460         }
461
462         if (count($dropIndex) + count($toadd) + count($todrop) + count($tomod) + count($addIndex) + count($tableProps) == 0) {
463             // nothing to do
464             return true;
465         }
466
467         // For efficiency, we want this all in one
468         // query, instead of using our methods.
469
470         $phrase = array();
471
472         foreach ($dropIndex as $indexSql) {
473             $phrase[] = $indexSql;
474         }
475
476         foreach ($toadd as $columnName) {
477             $cd = $this->_byName($columns, $columnName);
478
479             $phrase[] = 'ADD COLUMN ' . $this->_columnSql($cd);
480         }
481
482         foreach ($todrop as $columnName) {
483             $phrase[] = 'DROP COLUMN ' . $columnName;
484         }
485
486         foreach ($tomod as $columnName) {
487             $cd = $this->_byName($columns, $columnName);
488
489             $phrase[] = 'MODIFY COLUMN ' . $this->_columnSql($cd);
490         }
491
492         foreach ($addIndex as $indexSql) {
493             $phrase[] = $indexSql;
494         }
495
496         foreach ($tableProps as $key => $val) {
497             $phrase[] = "$key=$val";
498         }
499
500         $sql = 'ALTER TABLE ' . $tableName . ' ' . implode(', ', $phrase);
501
502         common_log(LOG_DEBUG, __METHOD__ . ': ' . $sql);
503         $res = $this->conn->query($sql);
504
505         if (PEAR::isError($res)) {
506             throw new Exception($res->getMessage());
507         }
508
509         return true;
510     }
511
512     /**
513      * Is this column a string type?
514      */
515     private function _isString(array $cd)
516     {
517         $strings = array('char', 'varchar', 'text');
518         return in_array(strtolower($cd['type']), $strings);
519     }
520
521     /**
522      * Return the proper SQL for creating or
523      * altering a column.
524      *
525      * Appropriate for use in CREATE TABLE or
526      * ALTER TABLE statements.
527      *
528      * @param ColumnDef $cd column to create
529      *
530      * @return string correct SQL for that column
531      */
532
533     function columnSql(array $cd)
534     {
535         $line = array();
536         $line[] = parent::_columnSql($cd);
537
538         if ($cd['type'] == 'serial') {
539             $line[] = 'auto_increment';
540         }
541
542         if (!empty($cd['extra'])) {
543             $line[] = $cd['extra']; // hisss boooo
544         }
545
546         if (!empty($cd['description'])) {
547             $line[] = 'comment';
548             $line[] = $this->quote($cd['description']);
549         }
550
551         return implode(' ', $line);
552     }
553
554     function mapType($column)
555     {
556         $map = array('serial' => 'int',
557                      'integer' => 'int',
558                      'numeric' => 'decimal');
559         
560         $type = $column['type'];
561         if (isset($map[$type])) {
562             $type = $map[$type];
563         }
564
565         if (!empty($column['size'])) {
566             $size = $column['size'];
567             if ($type == 'int' &&
568                        in_array($size, array('tiny', 'small', 'medium', 'big'))) {
569                 $type = $size . $type;
570             } else if (in_array($type, array('blob', 'text')) &&
571                        in_array($size, array('tiny', 'medium', 'long'))) {
572                 $type = $size . $type;
573             }
574         }
575
576         return $type;
577     }
578
579     /**
580      * Map a MySQL native type back to an independent type + size
581      *
582      * @param string $type
583      * @return array ($type, $size) -- $size may be null
584      */
585     protected function reverseMapType($type)
586     {
587         $type = strtolower($type);
588         $map = array(
589             'decimal' => array('numeric', null),
590             'tinyint' => array('int', 'tiny'),
591             'smallint' => array('int', 'small'),
592             'mediumint' => array('int', 'medium'),
593             'bigint' => array('int', 'big'),
594             'tinyblob' => array('blob', 'tiny'),
595             'mediumblob' => array('blob', 'medium'),
596             'longblob' => array('blob', 'long'),
597             'tinytext' => array('text', 'tiny'),
598             'mediumtext' => array('text', 'medium'),
599             'longtext' => array('text', 'long'),
600         );
601         if (isset($map[$type])) {
602             return $map[$type];
603         } else {
604             return array($type, null);
605         }
606     }
607
608     function typeAndSize($column)
609     {
610         if ($column['type'] == 'enum') {
611             $vals = array_map(array($this, 'quote'), $column['enum']);
612             return 'enum(' . implode(',', $vals) . ')';
613         } else if ($this->_isString($column)) {
614             return parent::typeAndSize($column) . ' CHARSET utf8';
615         } else {
616             return parent::typeAndSize($column);
617         }
618     }
619 }