1: <?php
2: /**
3: * This file is part of the PHPLucidFrame library.
4: * SchemaManager manages your database schema.
5: *
6: * @package PHPLucidFrame\Core
7: * @since PHPLucidFrame v 1.14.0
8: * @copyright Copyright (c), PHPLucidFrame.
9: * @link http://phplucidframe.com
10: * @license http://www.opensource.org/licenses/mit-license.php MIT License
11: *
12: * This source file is subject to the MIT license that is bundled
13: * with this source code in the file LICENSE
14: */
15:
16: namespace LucidFrame\Core;
17:
18: use LucidFrame\Console\Command;
19:
20: /**
21: * Schema Manager
22: */
23: class SchemaManager
24: {
25: /** @var array The schema definition */
26: protected $schema = array();
27: /** @var string The database driver; currently it allows "mysql" only */
28: private $driver = 'mysql';
29: /** @var array The global schema options */
30: private $defaultOptions;
31: /** @var array The data types for each db driver */
32: private static $dataTypes = array(
33: 'mysql' => array(
34: 'smallint' => 'SMALLINT',
35: 'mediumint' => 'MEDIUMINT',
36: 'int' => 'INT',
37: 'integer' => 'INT',
38: 'bigint' => 'BIGINT',
39: 'decimal' => 'NUMERIC',
40: 'float' => 'DOUBLE',
41: # For decimal and float
42: # length => array(p, s) where p is the precision and s is the scale
43: # The precision represents the number of significant digits that are stored for values, and
44: # the scale represents the number of digits that can be stored following the decimal point.
45: 'string' => 'VARCHAR',
46: 'char' => 'CHAR',
47: 'binary' => 'VARBINARY',
48: 'tinytext' => 'TINYTEXT',
49: 'text' => 'TEXT',
50: 'mediumtext'=> 'MEDIUMTEXT',
51: 'longtext' => 'LONGTEXT',
52: 'tinyblob' => 'TINYBLOB',
53: 'blob' => 'BLOB',
54: 'mediumblob'=> 'MEDIUMBLOB',
55: 'longblob' => 'LONGBLOB',
56: 'array' => 'TEXT',
57: 'json' => 'TEXT',
58: # For text, blob, array and json
59: # length => tiny, medium or long
60: # tiny for TINYTEXT, medium for MEDIUMTEXT, long for LONGTEXT
61: # if no length is specified, default to TEXT
62: 'boolean' => 'TINYINT', # TINYINT(1)
63: 'date' => 'DATE',
64: 'datetime' => 'DATETIME',
65: 'time' => 'TIME',
66: ),
67: );
68: /** @var array The relational database relationships */
69: public static $relationships = array('1:m', 'm:1', 'm:m', '1:1');
70: /** @var string The namespace for the database */
71: private $dbNamespace = 'default';
72: /** @var array The array of generated SQL statements */
73: private $sqlStatements = array();
74: /** @var string Version file name extension */
75: private $sqlExtension = '.sqlc';
76: /** @var array Dropped table names */
77: private $droppedTables = array();
78: /** @var array Added table names */
79: private $addedTables = array();
80: /** @var array Dropped field names */
81: private $droppedColumns = array();
82: /** @var array Added column names */
83: private $addedColumns = array();
84: /** @var array Renamed table names */
85: private $tablesRenamed = array();
86: /** @var array Renamed field names */
87: private $columnsRenamed = array();
88:
89: /**
90: * Constructor
91: * @param array $schema The array of schema definition
92: * @param string $dbNamespace The namespace for the database schema
93: */
94: public function __construct($schema = array(), $dbNamespace = null)
95: {
96: $this->defaultOptions = array(
97: 'timestamps' => true,
98: 'constraints' => true,
99: 'charset' => 'utf8mb4',
100: 'collate' => 'utf8mb4_general_ci',
101: 'engine' => 'InnoDB',
102: );
103:
104: $this->setSchema($schema);
105:
106: if ($dbNamespace) {
107: $this->dbNamespace = $dbNamespace;
108: }
109: }
110:
111: /**
112: * Setter for the property `schema`
113: * @param array $schema The array of schema definition
114: * @return object SchemaManager
115: */
116: public function setSchema($schema)
117: {
118: if (!is_array($schema)) {
119: $schema = array(
120: '_options' => $this->defaultOptions
121: );
122: }
123:
124: $this->schema = $schema;
125:
126: return $this;
127: }
128:
129: /**
130: * Getter for the property `schema`
131: * @return array The array of schema definition
132: */
133: public function getSchema()
134: {
135: return $this->schema;
136: }
137:
138: /**
139: * Setter for the property `driver`
140: * Currently driver allows mysql only, that's why this method is private
141: * @param string $driver Database driver
142: * @return object SchemaManager
143: */
144: private function setDriver($driver)
145: {
146: $this->driver = $driver;
147:
148: return $this;
149: }
150:
151: /**
152: * Getter for the property `driver`
153: * @return string
154: */
155: public function getDriver()
156: {
157: return $this->driver;
158: }
159:
160: /**
161: * Setter for the property `dbNamespace`
162: * @param string $namespace The namespace
163: * @return object SchemaManager
164: */
165: public function setDbNamespace($namespace)
166: {
167: $this->dbNamespace = $namespace;
168:
169: return $this;
170: }
171:
172: /**
173: * Getter for the property `dbNamespace`
174: * @return string
175: */
176: public function getDbNamespace()
177: {
178: return $this->dbNamespace;
179: }
180:
181: /**
182: * Get default field type for primary key
183: * @return array Array of field type options
184: */
185: private function getPKDefaultType()
186: {
187: return array(
188: 'type' => 'int',
189: 'autoinc' => true,
190: 'null' => false,
191: 'unsigned' => true
192: );
193: }
194:
195: /**
196: * Get relationship options with defaults
197: * @param array $relation The relationship options
198: * @param string $fkTable The FK table
199: * @return array The relationship options with defaults
200: */
201: private function getRelationOptions($relation, $fkTable = '')
202: {
203: if (empty($relation)) {
204: $relation = array('name' => $fkTable . '_id');
205: }
206:
207: if (_arrayAssoc($relation)) {
208: $relations[] = $relation;
209: } else { // when more than one relationship for the same table
210: $relations = $relation;
211: }
212:
213: foreach ($relations as $i => $rel) {
214: if (!isset($rel['name'])) {
215: $rel['name'] = $fkTable . '_id';
216: }
217:
218: $relations[$i] = $rel + array(
219: 'unique' => false,
220: 'default' => null,
221: 'cascade' => false
222: );
223: }
224:
225: return $relations;
226: }
227:
228: /**
229: * Get field statement for CREATE TABLE
230: * @param string $field The field name
231: * @param array $definition SchemaManager field definition
232: * @param string $collate The collation for the field; if it is null, db collation is used
233: * @return string The field statement
234: */
235: public function getFieldStatement($field, $definition, $collate = null)
236: {
237: $type = $this->getVendorFieldType($definition);
238: if ($type === null) {
239: return '';
240: }
241:
242: $statement = "`{$field}` {$type}";
243:
244: $length = $this->getFieldLength($definition);
245: if ($length) {
246: $statement .= "($length)";
247: }
248:
249: if (in_array($definition['type'], array('string', 'char', 'text', 'array', 'json'))) {
250: # COLLATE for text fields
251: $statement .= ' COLLATE ';
252: $statement .= $collate ? $collate : $this->schema['_options']['collate'];
253: }
254:
255: if (isset($definition['unsigned'])) {
256: # unsigned
257: $statement .= ' unsigned';
258: }
259:
260: if (isset($definition['null'])) {
261: # true: DEFAULT NULL
262: # false: NOT NULL
263: $statement .= $definition['null'] ? ' DEFAULT NULL' : ' NOT NULL';
264: }
265:
266: if (isset($definition['default'])) {
267: $statement .= sprintf(" DEFAULT '%d'", (int)$definition['default']);
268: }
269:
270: if (isset($definition['autoinc']) && $definition['autoinc']) {
271: # AUTO_INCREMENT
272: $statement .= ' AUTO_INCREMENT';
273: }
274:
275: return $statement;
276: }
277:
278: /**
279: * Get field type
280: * @param array $definition SchemaManager field definition
281: * @return string The underlying db field type
282: */
283: public function getVendorFieldType(&$definition)
284: {
285: if (!isset(self::$dataTypes[$this->driver][$definition['type']])) {
286: # if no data type is defined
287: return null;
288: }
289:
290: $type = self::$dataTypes[$this->driver][$definition['type']];
291:
292: if (in_array($definition['type'], array('text', 'blob', 'array', 'json'))) {
293: if (isset($definition['length']) && in_array($definition['length'], array('tiny', 'medium', 'long'))) {
294: return strtoupper($definition['length']) . $type;
295: } else {
296: return $definition['type'] == 'blob' ? self::$dataTypes[$this->driver]['blob'] : self::$dataTypes[$this->driver]['text'];
297: }
298: }
299:
300: if ($definition['type'] == 'boolean') {
301: # if type is boolean, force unsigned, not null and default 0
302: $definition['unsigned'] = true;
303: $definition['null'] = false;
304: if (!isset($definition['default'])) {
305: $definition['default'] = false;
306: }
307: }
308:
309: return $type;
310: }
311:
312: /**
313: * Get field length
314: * @param array $definition SchemaManager field definition
315: * @return integer The field length
316: */
317: public function getFieldLength(&$definition)
318: {
319: $type = $definition['type'];
320:
321: if ($type == 'string' || $type == 'char') {
322: $length = 255;
323: } elseif ($type == 'int' || $type == 'integer') {
324: $length = 11;
325: } elseif ($type === 'boolean') {
326: $length = 1;
327: } elseif (in_array($type, array('text', 'blob', 'array', 'json'))) {
328: $length = 0;
329: } elseif ($type == 'decimal' || $type == 'float') {
330: $length = isset($definition['length']) ? $definition['length'] : 0;
331: if (is_array($length) && count($length) == 2) {
332: $length = implode(', ', $length);
333: }
334: } else {
335: $length = 0;
336: }
337:
338: if (isset($definition['length']) && is_numeric($definition['length'])) {
339: $length = $definition['length'];
340: }
341:
342: return $length;
343: }
344:
345: /**
346: * Get foreign key schema definition
347: * @param string $fkTable The reference table name
348: * @param array $relation The relationship definition
349: * @return array Foreign key schema definition
350: */
351: protected function getFKField($fkTable, $relation)
352: {
353: $field = $relation['name'];
354: $pkFields = $this->schema['_options']['pk'];
355:
356: if (isset($pkFields[$fkTable][$field])) {
357: $fkField = $pkFields[$fkTable][$field];
358: } else {
359: $keys = array_keys($pkFields[$fkTable]);
360: $firstPKField = array_shift($keys);
361: $fkField = $pkFields[$fkTable][$firstPKField];
362: }
363:
364: if (isset($fkField['autoinc'])) {
365: unset($fkField['autoinc']);
366: }
367:
368: if ($relation['unique']) {
369: $fkField['unique'] = true;
370: }
371:
372: if ($relation['default'] === null) {
373: $fkField['null'] = true;
374: } else {
375: $fkField['default'] = $relation['default'];
376: $fkField['null'] = false;
377: }
378:
379: return $fkField;
380: }
381:
382: /**
383: * Get foreign key constraint definition
384: * @param string $fkTable The reference table name
385: * @param array $relation The relationship definition
386: * @param array $schema The whole schema definition
387: * @return array|null Foreign key constraint definition
388: */
389: protected function getFKConstraint($fkTable, $relation, $schema = array())
390: {
391: if ($this->schema['_options']['constraints']) {
392: $pkFields = $this->schema['_options']['pk'];
393: $field = $relation['name'];
394: $refField = $field;
395:
396: if (!isset($pkFields[$fkTable][$refField])) {
397: $refField = 'id';
398: }
399:
400: if ($relation['cascade'] === true) {
401: $cascade = 'CASCADE';
402: } elseif ($relation['cascade'] === null) {
403: $cascade = 'SET NULL';
404: } else {
405: $cascade = 'RESTRICT';
406: }
407:
408: return array(
409: 'name' => 'FK_' . strtoupper(_randomCode(15)),
410: 'fields' => $field,
411: 'reference_table' => $fkTable,
412: 'reference_fields' => $refField,
413: 'on_delete' => $cascade,
414: 'on_update' => 'NO ACTION'
415: );
416: }
417:
418: return null;
419: }
420:
421: /**
422: * Process schema
423: * @return boolean TRUE for success; FALSE for failure
424: */
425: private function load()
426: {
427: $schema = $this->schema;
428: unset($schema['_options']);
429:
430: if (count($schema) == 0) {
431: return false;
432: }
433:
434: # Populate primary key fields
435: $this->populatePrimaryKeys($schema);
436: # Add ManyToMany tables to the schema
437: $constraints = $this->populatePivots($schema);
438:
439: $pkFields = $this->getPrimaryKeys();
440:
441: $sql = array();
442: $sql[] = 'SET FOREIGN_KEY_CHECKS=0;';
443:
444: # Create each table
445: foreach ($schema as $table => $def) {
446: $fullTableName = db_table($table); # The full table name with prefix
447: $createSql = $this->createTableStatement($table, $schema, $pkFields, $constraints);
448: if ($createSql) {
449: $sql[] = '--';
450: $sql[] = '-- Table structure for table `' . $fullTableName . '`';
451: $sql[] = '--';
452: $sql[] = "DROP TABLE IF EXISTS `{$fullTableName}`;";
453: $sql[] = $createSql;
454: }
455: }
456:
457: # Generate FK constraints
458: $constraintSql = $this->createConstraintStatements($constraints);
459: if ($constraintSql) {
460: $sql = array_merge($sql, $constraintSql);
461: }
462:
463: $sql[] = 'SET FOREIGN_KEY_CHECKS=1;';
464:
465: $this->sqlStatements = $sql;
466:
467: # Get the current version
468: $versions = $this->checkVersions($schema);
469: if (is_array($versions) && count($versions)) {
470: $currentVersion = str_replace($this->sqlExtension, '', array_pop($versions));
471: } else {
472: $currentVersion = 0;
473: }
474:
475: $this->schema['_options']['version'] = $currentVersion;
476: $schema['_options'] = $this->schema['_options'];
477: $this->schema = $schema;
478:
479: return true;
480: }
481:
482: /**
483: * Export the built schema definition into a file
484: * @param string $dbNamespace The namespace for the database
485: * @param boolean $backup Create a backup file or not
486: * @return boolean TRUE for success; FALSE for failure
487: */
488: public function build($dbNamespace = null, $backup = false)
489: {
490: if (!$this->isLoaded()) {
491: $this->load();
492: }
493:
494: if ($dbNamespace === null) {
495: $dbNamespace = $this->dbNamespace;
496: }
497:
498: $fileName = self::getSchemaLockFileName($dbNamespace);
499: $result = file_put_contents($fileName, serialize($this->schema));
500: if ($result) {
501: if ($backup) {
502: copy($fileName, self::getSchemaLockFileName($dbNamespace, true));
503: }
504:
505: return true;
506: }
507:
508: return false;
509: }
510:
511: /**
512: * Import schema to the database
513: * @param string $dbNamespace The namespace for the database
514: * @return boolean TRUE for success; FALSE for failure
515: */
516: public function import($dbNamespace = null)
517: {
518: if ($dbNamespace === null) {
519: $dbNamespace = $this->dbNamespace;
520: }
521:
522: if (!$this->isLoaded()) {
523: $this->load();
524: }
525:
526: if ($this->executeQueries($dbNamespace, $this->sqlStatements)) {
527: $this->build($dbNamespace);
528: return true;
529: }
530:
531: return false;
532: }
533:
534: /**
535: * Export sql dump file
536: * @param string $dbNamespace The namespace for the database
537: * @return boolean TRUE for success; FALSE for failure
538: */
539: public function export($dbNamespace = null)
540: {
541: if ($dbNamespace === null) {
542: $dbNamespace = $this->dbNamespace;
543: }
544:
545: $this->build($dbNamespace);
546:
547: if (!count($this->sqlStatements)) {
548: return false;
549: }
550:
551: $dump = '--' . PHP_EOL
552: . '-- Generated by PHPLucidFrame ' . _version() . PHP_EOL
553: . '-- ' . date('r') . PHP_EOL
554: . '-- ;' . PHP_EOL . PHP_EOL
555: . implode(PHP_EOL, $this->sqlStatements);
556:
557: return file_put_contents(DB . 'generated' . _DS_ . 'schema.' . $dbNamespace . '.sql', $dump) ? true : false;
558: }
559:
560: /**
561: * Update schema to the latest version
562: * @param Command $cmd LucidFrame\Console\Command
563: * @param string $dbNamespace The namespace for the database
564: * @return boolean TRUE for success; FALSE for failure
565: */
566: public function update(Command $cmd, $dbNamespace = null)
567: {
568: if ($dbNamespace === null) {
569: $dbNamespace = $this->dbNamespace;
570: }
571:
572: $schemaFrom = self::getSchemaLockDefinition($dbNamespace);
573:
574: if (!$this->isLoaded()) {
575: $this->load();
576: }
577:
578: $schemaTo = $this->schema;
579: $isSchemaChanged = $this->isSchemaChanged($schemaFrom, $schemaTo);
580: $versions = $this->checkVersions($schemaFrom);
581:
582: if (is_array($versions) && count($versions)) {
583: # Migrate to the latest version
584: $version = $this->migrate($versions, $schemaFrom, $schemaTo);
585:
586: if ($version) {
587: # Update build version
588: $this->schema['_options']['version'] = $version;
589: $this->build($dbNamespace);
590:
591: _writeln();
592: _writeln('Your schema has been updated.');
593: }
594:
595: return true;
596: }
597:
598: if ($versions === 0 || $versions === 1) {
599: # if there is no version file or if the schema is up-to-date;
600: if ($isSchemaChanged) {
601: # but if the schema is changed, get the difference
602: $sql = $this->generateSqlFromDiff($schemaFrom, $schemaTo, $cmd);
603: } else {
604: _writeln();
605: _writeln('Your schema is up-to-date.');
606:
607: return true;
608: }
609: }
610:
611: if (!empty($sql['up'])) {
612: _writeln();
613: _writeln('##########');
614: foreach ($sql['up'] as $query) {
615: _writeln($query);
616: }
617: _writeln('##########');
618: _writeln();
619: }
620:
621: $dropConstraintSql = $this->dropConstraintStatements($this->getConstraints($schemaFrom));
622: $createConstraintSql = $this->createConstraintStatements();
623:
624: if (empty($sql['up']) && count($dropConstraintSql) == 0 && count($createConstraintSql) == 0) {
625: return false;
626: }
627:
628: # Confirm before executing the queries
629: $statements = array();
630: if ($cmd->confirm('Type "y" to execute or type "n" to abort:')) {
631: $statements = array_merge($statements, $dropConstraintSql, $sql['up'], $createConstraintSql);
632:
633: $noOfQueries = $this->executeQueries($dbNamespace, $statements);
634: if (!$noOfQueries) {
635: return false;
636: }
637: } else {
638: _writeln('Aborted.');
639: return false;
640: }
641:
642: # Export version sql file
643: if ($dbVersion = $this->exportVersionFile($sql['up'], $dbNamespace)) {
644: # Build schema
645: $this->schema['_options']['version'] = $dbVersion;
646: $this->build($dbNamespace);
647: } else {
648: return false;
649: }
650:
651: _writeln('--------------------');
652: _writeln('%d queries executed.', $noOfQueries);
653:
654: return true;
655: }
656:
657: /**
658: * Find the schema difference and generate SQL file
659: * @param Command $cmd LucidFrame\Console\Command
660: * @param string $dbNamespace The namespace for the database
661: * @return boolean TRUE for SQL file exported; FALSE for no updates
662: */
663: public function diff(Command $cmd, $dbNamespace = null)
664: {
665: if ($dbNamespace === null) {
666: $dbNamespace = $this->dbNamespace;
667: }
668:
669: $schemaFrom = self::getSchemaLockDefinition($dbNamespace);
670:
671: if (!$this->isLoaded()) {
672: $this->load();
673: }
674:
675: $schemaTo = $this->schema;
676: $isSchemaChanged = $this->isSchemaChanged($schemaFrom, $schemaTo);
677: $versions = $this->checkVersions($schemaFrom);
678:
679: if (is_array($versions) && count($versions)) {
680: return false;
681: }
682:
683: if ($versions === 0 || $versions === 1) {
684: # if there is no version file or if the schema is up-to-date;
685: if ($isSchemaChanged) {
686: # but if the schema is changed, get the difference
687: $sql = $this->generateSqlFromDiff($schemaFrom, $schemaTo, $cmd);
688: if ($dbVersion = $this->exportVersionFile($sql['up'], $dbNamespace)) {
689: $versionDir = $this->getVersionDir($dbNamespace);
690:
691: _writeln();
692: _writeln($versionDir . _DS_ . $dbVersion . $this->sqlExtension . ' is exported.');
693: _writeln('Check the file and run `php lucidframe schema:update ' . $dbNamespace . '`');
694:
695: return true;
696: }
697: }
698: }
699:
700: return false;
701: }
702:
703: /**
704: * Export the SQL file with .sqlc extension in the directory /db/version/{namespace}/
705: * @param array $sql Array of SQL statements
706: * @param string $dbNamespace The namespace for the database
707: * @return mixed The version number on success or FALSE on failure
708: */
709: private function exportVersionFile(array $sql, $dbNamespace = null)
710: {
711: if (!count($sql)) {
712: return false;
713: }
714:
715: if ($dbNamespace === null) {
716: $dbNamespace = $this->dbNamespace;
717: }
718:
719: # Export version sql file
720: $dbVersion = date('YmdHis');
721:
722: $dump = '--' . PHP_EOL
723: . '-- Version ' . $dbVersion . PHP_EOL
724: . '-- Generated by PHPLucidFrame ' . _version() . PHP_EOL
725: . '-- ' . date('r') . PHP_EOL
726: . '-- ;' . PHP_EOL . PHP_EOL
727: . implode(PHP_EOL . PHP_EOL, $sql);
728:
729: $versionDir = $this->getVersionDir($dbNamespace);
730: if (file_put_contents($versionDir . _DS_ . $dbVersion . $this->sqlExtension, $dump)) {
731: return $dbVersion;
732: }
733:
734: return false;
735: }
736:
737: /**
738: * Get schema difference and generate SQL statements
739: * @param array $schemaFrom Array of the current schema data
740: * @param array $schemaTo Array of the updated schema data
741: * @param Command $cmd LucidFrame\Console\Command
742: * @return array
743: */
744: public function generateSqlFromDiff($schemaFrom, $schemaTo, Command $cmd)
745: {
746: $fieldNamesChanged = array();
747: $this->columnsRenamed = array();
748:
749: $sql = array(
750: 'up' => array(),
751: 'down' => array(),
752: );
753:
754: # Detect table renaming
755: $this->detectTableRenamings($schemaFrom, $schemaTo);
756: if (count($this->tablesRenamed)) {
757: _writeln();
758: _writeln('Type "y" to rename or type "n" to drop/create for the following tables:');
759: _writeln();
760: }
761:
762: # Get user confirmation for table renaming
763: foreach ($this->tablesRenamed as $from => $to) {
764: if (!$cmd->confirm('Table renaming from `' . $from . '` to `' . $to . '`:')) {
765: unset($this->tablesRenamed[$from]);
766: }
767: }
768:
769: # Detect field renaming
770: $this->detectColumnRenamings($schemaFrom, $schemaTo);
771: if (count($this->columnsRenamed)) {
772: _writeln();
773: _writeln('Type "y" to rename or type "n" to drop/create for the following fields:');
774: _writeln();
775: }
776:
777: # Get user confirmation for column renaming
778: foreach ($this->columnsRenamed as $from => $to) {
779: $fieldFrom = explode('.', $from);
780: if (!$cmd->confirm('Field renaming from `' . $fieldFrom[1] . '` to `' . $fieldFrom[0] . '.' . $to . '`:')) {
781: unset($this->columnsRenamed[$from]);
782: }
783: }
784:
785: # Detect schema differences and generate SQL statements
786: foreach ($schemaFrom as $table => $tableDef) {
787: if ($table == '_options') {
788: continue;
789: }
790:
791: $fullTableName = db_table($table);
792: $renamedTable = $this->isRenamed($table, $this->tablesRenamed);
793:
794: if (isset($schemaTo[$table]) || ($renamedTable && isset($schemaTo[$renamedTable]))) {
795: # Existing table
796: if ($renamedTable) {
797: # if the table is renamed
798: $table = $renamedTable;
799: }
800:
801: foreach ($tableDef as $field => $fieldDef) {
802: $collate = $this->getTableCollation($table, $schemaTo);
803: $oldField = $field;
804: $renamedField = $this->isRenamed($table . '.' . $field, $this->columnsRenamed);
805:
806: if (isset($schemaTo[$table][$field]) || ($renamedField && isset($schemaTo[$table][$renamedField]))) {
807: # Existing field
808: if ($renamedField) {
809: $field = $renamedField;
810: }
811:
812: $diff = $fieldDef !== $schemaTo[$table][$field];
813: if ($diff) {
814: # Change field
815: if (in_array($field, self::$relationships)) {
816: continue;
817: }
818:
819: if ($field == 'options') {
820: if (!empty($fieldDef['m:m'])) {
821: # if it is many-to-many table, skip
822: continue;
823: }
824:
825: $fromFieldOptions = $fieldDef;
826: $toFieldOptions = $schemaTo[$table][$field];
827: $diffOptions = $this->diffColumns($fromFieldOptions, $toFieldOptions);
828:
829: foreach ($diffOptions['diff'] as $optName => $optValue) {
830: switch ($optName) {
831: case 'unique':
832: // Drop old composite unique indices
833: if (isset($fromFieldOptions['unique'])) {
834: foreach ($fromFieldOptions['unique'] as $keyName => $uniqueFields) {
835: $sql['up'][] = "ALTER TABLE `{$fullTableName}` DROP INDEX `IDX_$keyName`;";
836: }
837: }
838:
839: if (isset($toFieldOptions['unique'])) {
840: // Add new composite unique indices
841: foreach ($toFieldOptions['unique'] as $keyName => $uniqueFields) {
842: $sql['up'][] = "ALTER TABLE `{$fullTableName}` ADD UNIQUE `IDX_$keyName` (`" . implode('`,`', $uniqueFields) . "`);";
843: }
844: }
845: break;
846:
847: case 'engine':
848: $sql['up'][] = "ALTER TABLE `{$fullTableName}` ENGINE={$toFieldOptions['engine']};";
849: break;
850:
851: case 'charset':
852: case 'collate':
853: $sql['up'][] = "ALTER TABLE `{$fullTableName}` CONVERT TO CHARACTER SET {$toFieldOptions['charset']} COLLATE {$toFieldOptions['collate']};";
854: break;
855: }
856: }
857:
858: continue;
859: }
860:
861: $newField = $field;
862:
863: $sql['up'][] = "ALTER TABLE `{$fullTableName}` CHANGE COLUMN `{$oldField}` " .
864: $this->getFieldStatement($newField, $schemaTo[$table][$newField], $collate) . ';';
865:
866: if (isset($schemaFrom[$table][$oldField]['unique']) && !isset($schemaTo[$table][$newField]['unique'])) {
867: $sql['up'][] = "ALTER TABLE `{$fullTableName}` DROP INDEX `IDX_$oldField`;";
868: } elseif (!isset($schemaFrom[$table][$oldField]['unique']) && isset($schemaTo[$table][$newField]['unique'])) {
869: $sql['up'][] = "ALTER TABLE `{$fullTableName}` ADD UNIQUE `IDX_$newField` (`$newField`);";
870: }
871:
872: $fieldNamesChanged[] = $table . '.' . $oldField;
873: $fieldNamesChanged = array_unique($fieldNamesChanged);
874: } else {
875: if ($renamedField) {
876: $fieldNamesChanged[] = $table . '.' . $renamedField;
877: $sql['up'][] = "ALTER TABLE `{$fullTableName}` CHANGE COLUMN `{$oldField}` " .
878: $this->getFieldStatement($renamedField, $schemaTo[$table][$renamedField], $collate) . ';';
879: }
880: }
881: } else {
882: # Drop or change field
883: if (in_array($field, array('m:m', '1:m', 'm:1'))) {
884: continue;
885: }
886:
887: if (in_array($table . '.' . $field, $fieldNamesChanged)) {
888: # The field name is already changed, no need to drop it
889: continue;
890: }
891:
892: if ($field == '1:1') {
893: foreach ($fieldDef as $fkFieldInTable) {
894: $sql['up'][] = "ALTER TABLE `{$fullTableName}` DROP COLUMN `{$fkFieldInTable['name']}`;";
895: }
896:
897: continue;
898: }
899:
900: $sql['up'][] = "ALTER TABLE `{$fullTableName}` DROP COLUMN `{$field}`;";
901: }
902: }
903:
904: # Rename table
905: if ($renamedTable) {
906: $sql['up'][] = 'RENAME TABLE `' . $fullTableName . '` TO `' . db_table($renamedTable) . '`;';
907: }
908: } else {
909: # Drop table
910: $sql['up'][] = "DROP TABLE IF EXISTS `{$fullTableName}`;";
911: }
912: }
913:
914: $pkFields = $this->getPrimaryKeys();
915: $constraints = $this->getConstraints();
916: foreach ($schemaTo as $table => $tableDef) {
917: if ($table == '_options') {
918: $dbOptions = $table;
919: continue;
920: }
921:
922: $collate = $this->getTableCollation($table, $schemaTo);
923: $fullTableName = db_table($table);
924: $tableFrom = $table;
925: $fieldBefore = '';
926:
927: if (!isset($schemaFrom[$table])) {
928: $oldTable = array_search($table, $this->tablesRenamed);
929: if ($oldTable === false) {
930: # Create a new table
931: $createSql = trim($this->createTableStatement($table, $schemaTo, $pkFields, $constraints));
932: if ($createSql) {
933: $sql['up'][] = $createSql;
934: }
935: # if new table, no need to lookup field changes and then continue the next table
936: continue;
937: } else {
938: $tableFrom = $oldTable;
939: }
940: }
941:
942: # Add new fields for existing table
943: foreach ($tableDef as $field => $fieldDef) {
944: if (in_array($field, array_merge(SchemaManager::$relationships, array('options')))) {
945: continue;
946: }
947:
948: if (!isset($schemaFrom[$tableFrom][$field]) && array_search($table . '.' . $field, $fieldNamesChanged) === false) {
949: # Add a new field
950: $alterSql = "ALTER TABLE `{$fullTableName}` ADD COLUMN ";
951: $alterSql .= $this->getFieldStatement($field, $fieldDef, $collate);
952: if ($fieldBefore && $field != 'created') {
953: $alterSql .= " AFTER `{$fieldBefore}`";
954: }
955: $alterSql .= ';';
956: $sql['up'][] = $alterSql;
957: }
958:
959: $fieldBefore = $field;
960: }
961: }
962:
963: return $sql;
964: }
965:
966: /**
967: * Migrate db to the latest version
968: * @param array $versions Array of versions (older to newer)
969: * @param array $schemaFrom Array of the current schema data
970: * @param array $schemaTo Array of the updated schema data
971: * @param bool $verbose Output in console or not
972: * @return string|bool
973: */
974: public function migrate(array $versions, array $schemaFrom, array $schemaTo, $verbose = true)
975: {
976: # Drop all foreign key constraints from the old schema
977: if ($dropConstraintSql = $this->dropConstraintStatements($this->getConstraints($schemaFrom))) {
978: $this->executeQueries($this->dbNamespace, $dropConstraintSql);
979: }
980:
981: if ($verbose) {
982: _writeln();
983: }
984:
985: $version = false;
986: $noOfQueries = 0;
987: foreach ($versions as $verFile) {
988: $version = str_replace($this->sqlExtension, '', $verFile);
989:
990: if ($verbose) {
991: _writeln('Executing ' . $version);
992: }
993:
994: $sql = file_get_contents(DB . 'version' . _DS_ . $this->dbNamespace . _DS_ . $verFile);
995: if (empty($sql)) {
996: if ($verbose) {
997: _writeln('No sql statements executed.');
998: }
999:
1000: return false;
1001: }
1002:
1003: $sqls = explode(';', $sql);
1004: $sql = array_filter($sqls, function($line) {
1005: $line = trim($line);
1006: return !empty($line) && strpos($line, '--') === false;
1007: });
1008:
1009: if (empty($sql)) {
1010: if ($verbose) {
1011: _writeln('No sql statements executed.');
1012: }
1013:
1014: return false;
1015: }
1016:
1017: $executed = $this->executeQueries($this->dbNamespace, $sql);
1018: if (!$executed) {
1019: return false;
1020: }
1021:
1022: $noOfQueries += $executed;
1023: if ($verbose) {
1024: _writeln();
1025: }
1026: }
1027:
1028: # Re-create all foreign key constraints from the new schema
1029: if ($createConstraintSql = $this->createConstraintStatements($this->getConstraints($schemaTo))) {
1030: $this->executeQueries($this->dbNamespace, $createConstraintSql);
1031: }
1032:
1033: return $version;
1034: }
1035:
1036: /**
1037: * Execute batch queries
1038: *
1039: * @param string $dbNamespace The namespace for the database
1040: * @param array $queries Array of SQL statements
1041: * @return boolean TRUE for success; FALSE for failure
1042: */
1043: private function executeQueries($dbNamespace, $queries)
1044: {
1045: if (!count($queries)) {
1046: return false;
1047: }
1048:
1049: if ($this->dbNamespace !== $dbNamespace) {
1050: db_switch($dbNamespace);
1051: }
1052:
1053: db_transaction();
1054:
1055: array_unshift($queries, 'SET FOREIGN_KEY_CHECKS = 0;');
1056: array_push($queries, 'SET FOREIGN_KEY_CHECKS = 1;');
1057:
1058: $count = 0;
1059: $error = false;
1060: foreach ($queries as $sql) {
1061: $sql = trim($sql);
1062:
1063: if (empty($sql)) {
1064: continue;
1065: }
1066:
1067: if (substr($sql, 0, 2) == '--') {
1068: continue;
1069: }
1070:
1071: if (!db_query($sql)) {
1072: $error = true;
1073: break;
1074: }
1075:
1076: $count++;
1077: }
1078:
1079: if ($error) {
1080: db_rollback();
1081: } else {
1082: db_commit();
1083: }
1084:
1085: if ($this->dbNamespace !== $dbNamespace) {
1086: # back to default db
1087: db_switch($this->dbNamespace);
1088: }
1089:
1090: if ($error == true) {
1091: return false;
1092: } else {
1093: return $count;
1094: }
1095: }
1096:
1097: /**
1098: * Check if schema changed
1099: * @param array $from The last schema
1100: * @param array $to The changed schema
1101: * @return bool TRUE if the schema is changed, otherwise FALSE
1102: */
1103: public function isSchemaChanged(array $from, array $to)
1104: {
1105: if (isset($from['_options']['version'])) {
1106: unset($from['_options']['version']);
1107: }
1108:
1109: if (isset($from['_options']['fkConstraints'])) {
1110: unset($from['_options']['fkConstraints']);
1111: }
1112:
1113: if (isset($to['_options']['version'])) {
1114: unset($to['_options']['version']);
1115: }
1116:
1117: if (isset($to['_options']['fkConstraints'])) {
1118: unset($to['_options']['fkConstraints']);
1119: }
1120:
1121: return $from != $to;
1122: }
1123:
1124: /**
1125: * Get the current db version
1126: * @return integer The version number
1127: */
1128: public function getCurrentVersion()
1129: {
1130: $version = 0;
1131: if ($schema = self::getSchemaLockDefinition($this->dbNamespace)) {
1132: $version = isset($schema['_options']['version']) ? $schema['_options']['version'] : 0;
1133: }
1134:
1135: return $version;
1136: }
1137:
1138: /**
1139: * Check db version files in the version directory against the current version in $schema[_options][version]
1140: * @param array $schema The schema to check in
1141: * @return mixed
1142: * 0 if there is no version file;
1143: * 1 if the schema is up-to-date;
1144: * ARRAY if there is version file to migrate
1145: */
1146: public function checkVersions(array $schema)
1147: {
1148: # Check if there is version files in the version directory
1149: $versionDir = DB . 'version' . _DS_ . $this->dbNamespace;
1150: if (!is_dir($versionDir)) {
1151: return 0;
1152: }
1153:
1154: $files = scandir($versionDir);
1155: rsort($files); # sort file name by descending
1156:
1157: # Check if the current schema version is up-to-date
1158: $lastVersion = 0;
1159: if (isset($schema['_options']['version'])) {
1160: $lastVersion = $schema['_options']['version'];
1161: if ($lastVersion . $this->sqlExtension == $files[0]) {
1162: return 1;
1163: }
1164: }
1165:
1166: # Filter all version greater than the last version
1167: $manager = $this;
1168: $files = array_filter($files, function ($fileName) use ($lastVersion, $manager) {
1169: if (preg_match('/\d{14}\\' . $manager->sqlExtension . '/', $fileName)) {
1170: if ($lastVersion == 0) {
1171: return true;
1172: }
1173:
1174: $version = str_replace($manager->sqlExtension, '', $fileName);
1175: if ($version > $lastVersion) {
1176: return true;
1177: }
1178: }
1179:
1180: return false;
1181: });
1182:
1183: if (count($files)) {
1184: sort($files);
1185: return $files;
1186: }
1187:
1188: return 0;
1189: }
1190:
1191: /**
1192: * Check if the schema is parsed and fully loaded
1193: * @return boolean TRUE/FALSE
1194: */
1195: public function isLoaded()
1196: {
1197: return isset($this->schema['_options']['pk']);
1198: }
1199:
1200: /**
1201: * Check if a table or field is renamed
1202: *
1203: * @param string $needle The table or field name
1204: * @param array $haystack Array of renamed fields or tables
1205: * @return mixed The renamed table name or field name or false
1206: */
1207: protected function isRenamed($needle, $haystack)
1208: {
1209: if (isset($haystack[$needle])) {
1210: return $haystack[$needle];
1211: } else {
1212: return false;
1213: }
1214: }
1215:
1216: /**
1217: * Check if the table exists
1218: * @param string $table The table name
1219: * @return boolean TRUE if the table exists, otherwise FALSE
1220: */
1221: public function hasTable($table)
1222: {
1223: if (!$this->isLoaded()) {
1224: return false;
1225: }
1226:
1227: $table = db_table($table);
1228:
1229: return isset($this->schema[$table]);
1230: }
1231:
1232: /**
1233: * Check if a field exists
1234: * @param string $table The table name
1235: * @param string $field The field name
1236: * @return boolean TRUE if the table exists, otherwise FALSE
1237: */
1238: public function hasField($table, $field)
1239: {
1240: if (!$this->isLoaded()) {
1241: return false;
1242: }
1243:
1244: $table = db_table($table);
1245:
1246: return isset($this->schema[$table][$field]);
1247: }
1248:
1249: /**
1250: * Check if the table has the timestamp fields or not
1251: * @param string $table The table name without prefix
1252: * @return boolean TRUE if the table has the timestamp fields, otherwise FALSE
1253: */
1254: public function hasTimestamps($table)
1255: {
1256: if (!$this->isLoaded()) {
1257: return false;
1258: }
1259:
1260: $table = db_table($table);
1261:
1262: return (isset($this->schema[$table]['options']['timestamps']) && $this->schema[$table]['options']['timestamps']) ? true : false;
1263: }
1264:
1265: /**
1266: * Check if the table has the slug field or not
1267: * @param string $table The table name without prefix
1268: * @return boolean TRUE if the table has the slug field, otherwise FALSE
1269: */
1270: public function hasSlug($table)
1271: {
1272: if (!$this->isLoaded()) {
1273: return false;
1274: }
1275:
1276: $table = db_table($table);
1277:
1278: return isset($this->schema[$table]['slug']) ? true : false;
1279: }
1280:
1281: /**
1282: * Get data type of the field
1283: * @param string $table The table name
1284: * @param string $field The field name in the table
1285: * @return string The data type or null if there is no field
1286: */
1287: public function getFieldType($table, $field)
1288: {
1289: $table = db_table($table);
1290:
1291: if ($this->hasField($table, $field)) {
1292: return $this->schema[$table][$field]['type'];
1293: }
1294:
1295: return null;
1296: }
1297:
1298: /**
1299: * Get schema options if it is defined
1300: * otherwise return the default options
1301: *
1302: * @return array
1303: */
1304: protected function getOptions()
1305: {
1306: if (isset($this->schema['_options'])) {
1307: $options = $this->schema['_options'] + $this->defaultOptions;
1308: } else {
1309: $options = $this->defaultOptions;
1310: }
1311:
1312: return $options;
1313: }
1314:
1315: /**
1316: * Get table options if it is defined
1317: * otherwise return the default options
1318: *
1319: * @param array $tableDef The table definition
1320: * @return array
1321: */
1322: protected function getTableOptions($tableDef)
1323: {
1324: $options = $this->getOptions();
1325:
1326: if (isset($options['pk'])) {
1327: unset($options['pk']);
1328: }
1329:
1330: if (isset($options['fkConstraints'])) {
1331: unset($options['fkConstraints']);
1332: }
1333:
1334: if (isset($tableDef['options'])) {
1335: $tableDef['options'] += $options;
1336: } else {
1337: $tableDef['options'] = $options;
1338: }
1339:
1340: return $tableDef['options'];
1341: }
1342:
1343: /**
1344: * Populate primary keys acccording to the schema defined
1345: * @param array $schema The database schema
1346: * @return array
1347: */
1348: public function populatePrimaryKeys(&$schema)
1349: {
1350: # Populate primary key fields
1351: $pkFields = array();
1352: foreach ($schema as $table => $def) {
1353: $def['options'] = $this->getTableOptions($def);
1354:
1355: if ($def['options']['timestamps']) {
1356: $def['created'] = array('type' => 'datetime', 'null' => true);
1357: $def['updated'] = array('type' => 'datetime', 'null' => true);
1358: $def['deleted'] = array('type' => 'datetime', 'null' => true);
1359: }
1360:
1361: $schema[$table] = $def;
1362:
1363: # PK Field(s)
1364: $pkFields[$table] = array();
1365: if (isset($def['options']['pk'])) {
1366: foreach ($def['options']['pk'] as $pk) {
1367: if (isset($def[$pk])) {
1368: # user-defined PK field type
1369: $pkFields[$table][$pk] = $def[$pk];
1370: } else {
1371: # default PK field type
1372: $pkFields[$table][$pk] = $this->getPKDefaultType();
1373: }
1374: }
1375: } else {
1376: $pkFields[$table]['id'] = $this->getPKDefaultType();
1377: }
1378: }
1379:
1380: $this->setPrimaryKeys($pkFields);
1381:
1382: return $pkFields;
1383: }
1384:
1385: /**
1386: * Populate pivot tables (joint tables fo many-to-many relationship) into the schema
1387: * @param array $schema The database schema
1388: * @return array Array of constraints
1389: */
1390: public function populatePivots(&$schema)
1391: {
1392: $constraints = array();
1393: $pkFields = $this->getPrimaryKeys();
1394:
1395: $manyToMany = array_filter($schema, function ($def) {
1396: return isset($def['m:m']);
1397: });
1398:
1399: foreach ($manyToMany as $table => $def) {
1400: foreach ($def['m:m'] as $fkTable => $joint) {
1401: if (!empty($joint['table']) && isset($schema[$joint['table']])) {
1402: # if the joint table has already been defined
1403: continue;
1404: }
1405:
1406: if (isset($schema[$table . '_to_' . $fkTable]) || isset($schema[$fkTable . '_to_' . $table])) {
1407: # if the joint table has already been defined
1408: continue;
1409: }
1410:
1411: if (isset($schema[$fkTable]['m:m'][$table])) {
1412: if (empty($joint['table']) && !empty($schema[$fkTable]['m:m'][$table]['table'])) {
1413: $joint['table'] = $schema[$fkTable]['m:m'][$table]['table'];
1414: }
1415:
1416: # table1_to_table2
1417: $jointTable = !empty($joint['table']) ? $joint['table'] : $table . '_to_' . $fkTable;
1418: $schema[$jointTable]['options'] = array(
1419: 'pk' => array(),
1420: 'timestamps' => false, # no need timestamp fields for many-to-many table
1421: 'm:m' => true
1422: ) + $this->defaultOptions;
1423:
1424: # table1.field
1425: $relation = $this->getRelationOptions($joint, $table);
1426: foreach ($relation as $rel) {
1427: $field = $rel['name'];
1428: $schema[$jointTable][$field] = $this->getFKField($table, $rel);
1429: $schema[$jointTable][$field]['null'] = false;
1430: $schema[$jointTable]['options']['pk'][] = $field;
1431: $pkFields[$jointTable][$field] = $schema[$jointTable][$field];
1432: # Get FK constraints
1433: $constraint = $this->getFKConstraint($table, $rel, $schema);
1434: if ($constraint) {
1435: $constraints[$jointTable][$field] = $constraint;
1436: }
1437: }
1438:
1439: # table2.field
1440: $relation = $this->getRelationOptions($schema[$fkTable]['m:m'][$table], $fkTable);
1441: foreach ($relation as $rel) {
1442: $field = $rel['name'];
1443: $schema[$jointTable][$field] = $this->getFKField($fkTable, $rel);
1444: $schema[$jointTable][$field]['null'] = false;
1445: $schema[$jointTable]['options']['pk'][] = $field;
1446: $pkFields[$jointTable][$field] = $schema[$jointTable][$field];
1447: # Get FK constraints
1448: $constraint = $this->getFKConstraint($fkTable, $rel, $schema);
1449: if ($constraint) {
1450: $constraints[$jointTable][$field] = $constraint;
1451: }
1452: }
1453: }
1454: }
1455: }
1456:
1457: $this->setPrimaryKeys($pkFields);
1458: $this->setConstraints($constraints);
1459:
1460: return $constraints;
1461: }
1462:
1463: /**
1464: * Generate CREATE TABLE SQL
1465: * @param string $table The new table name
1466: * @param array $schema The database schema
1467: * @param array $pkFields Array of PK fields
1468: * @param array $constraints Array of FK constraints
1469: * @return string
1470: */
1471: public function createTableStatement($table, &$schema, &$pkFields, &$constraints)
1472: {
1473: if (!isset($schema[$table])) {
1474: return null;
1475: }
1476:
1477: $def = $schema[$table]; # The table definition
1478: $fullTableName = db_table($table); # The full table name with prefix
1479: $fkFields = array(); # Populate foreign key fields
1480:
1481: # OneToMany
1482: if (isset($def['m:1']) && is_array($def['m:1'])) {
1483: foreach ($def['m:1'] as $fkTable) {
1484: if (isset($schema[$fkTable]['1:m'][$table]) || array_search($table, $schema[$fkTable]['1:m']) !== false) {
1485: $relationOptions = array();
1486: if (isset($schema[$fkTable]['1:m'][$table])) {
1487: $relationOptions = $schema[$fkTable]['1:m'][$table];
1488: }
1489:
1490: $relation = $this->getRelationOptions($relationOptions, $fkTable);
1491: foreach ($relation as $rel) {
1492: $field = $rel['name'];
1493: # Get FK field definition
1494: $fkFields[$field] = $this->getFKField($fkTable, $rel);
1495: # Get FK constraints
1496: $constraint = $this->getFKConstraint($fkTable, $rel, $schema);
1497: if ($constraint) {
1498: $constraints[$table][$field] = $constraint;
1499: }
1500: }
1501: }
1502: }
1503: }
1504:
1505: # OneToOne
1506: if (isset($def['1:1']) && is_array($def['1:1'])) {
1507: foreach ($def['1:1'] as $fkTable => $fk) {
1508: $relationOptions = array();
1509: if (is_numeric($fkTable)) {
1510: $fkTable = $fk;
1511: } else {
1512: $relationOptions = $fk;
1513: }
1514:
1515: $relation = $this->getRelationOptions($relationOptions, $fkTable);
1516: foreach ($relation as $rel) {
1517: $field = $rel['name'];
1518: # Get FK field definition
1519: $fkFields[$field] = $this->getFKField($fkTable, $rel);
1520: # Get FK constraints
1521: $constraint = $this->getFKConstraint($fkTable, $rel, $schema);
1522: if ($constraint) {
1523: $constraints[$table][$field] = $constraint;
1524: }
1525: }
1526: }
1527: }
1528:
1529: $this->setConstraints($constraints);
1530:
1531: $def = array_merge($pkFields[$table], $fkFields, $def);
1532: $schema[$table] = $def;
1533:
1534: # ManyToMany table FK indexes
1535: if (isset($def['options']['m:m']) && $def['options']['m:m']) {
1536: $jointTable = $table;
1537: foreach ($schema[$jointTable] as $field => $rule) {
1538: if ($field == 'options') {
1539: continue;
1540: }
1541: $fkFields[$field] = $rule;
1542: }
1543: }
1544:
1545: $options = $this->getTableOptions($def);
1546: $def['options'] = $options;
1547:
1548: # CREATE TABLE Statement
1549: $sql = "CREATE TABLE IF NOT EXISTS `{$fullTableName}` (" . PHP_EOL;
1550:
1551: # loop the fields
1552: $autoinc = false;
1553: foreach ($def as $name => $rule) {
1554: # Skip for relationship and option definitions
1555: if (in_array($name, self::$relationships) || $name == 'options') {
1556: continue;
1557: }
1558:
1559: $sql .= ' ' . $this->getFieldStatement($name, $rule, $this->getTableCollation($name, $schema)) . ',' . PHP_EOL;
1560:
1561: # if there is any unique index
1562: if (isset($rule['unique']) && $rule['unique']) {
1563: $fkFields[$name] = $rule;
1564: }
1565:
1566: if (isset($rule['autoinc']) && $rule['autoinc']) {
1567: $autoinc = true;
1568: }
1569: }
1570:
1571: # Indexes
1572: if (count($fkFields)) {
1573: foreach (array_keys($fkFields) as $name) {
1574: if (isset($fkFields[$name]['unique']) && $fkFields[$name]['unique']) {
1575: $sql .= ' UNIQUE KEY';
1576: } else {
1577: $sql .= ' KEY';
1578: }
1579: $sql .= " `IDX_$name` (`$name`)," . PHP_EOL;
1580: }
1581: }
1582:
1583: // Unique indexes for composite unique fields
1584: if (isset($options['unique']) && is_array($options['unique'])) {
1585: foreach ($options['unique'] as $keyName => $uniqueFields) {
1586: $sql .= ' UNIQUE KEY';
1587: $sql .= " `IDX_$keyName` (`" . implode('`,`', $uniqueFields) . "`)," . PHP_EOL;
1588: }
1589: }
1590:
1591: # Primary key indexes
1592: if (isset($pkFields[$table])) {
1593: $sql .= ' PRIMARY KEY (`' . implode('`,`', array_keys($pkFields[$table])) . '`)' . PHP_EOL;
1594: }
1595:
1596: $sql .= ')';
1597: $sql .= ' ENGINE=' . $options['engine'];
1598: $sql .= ' DEFAULT CHARSET=' . $options['charset'];
1599: $sql .= ' COLLATE=' . $options['collate'];
1600:
1601: if ($autoinc) {
1602: $sql .= ' AUTO_INCREMENT=1';
1603: }
1604:
1605: $sql .= ';' . PHP_EOL;
1606:
1607: return $sql;
1608: }
1609:
1610: /**
1611: * Generate foreign key constraints SQL statements
1612: * @param array $constraints Array of populated constraints
1613: * @return array Array of SQL statements
1614: */
1615: public function createConstraintStatements($constraints = null)
1616: {
1617: if ($constraints === null) {
1618: $constraints = $this->getConstraints();
1619: }
1620:
1621: $options = $this->getOptions();
1622: $sql = array();
1623: # FK constraints
1624: if ($options['constraints']) {
1625: foreach ($constraints as $table => $constraint) {
1626: $fullTableName = db_table($table);
1627: $constraintSql = "ALTER TABLE `{$fullTableName}`" . PHP_EOL;
1628: $statement = array();
1629: foreach ($constraint as $field => $rule) {
1630: $statement[] = " ADD CONSTRAINT `{$rule['name']}` FOREIGN KEY (`{$rule['fields']}`)"
1631: . " REFERENCES `{$rule['reference_table']}` (`{$rule['reference_fields']}`)"
1632: . " ON DELETE {$rule['on_delete']}"
1633: . " ON UPDATE {$rule['on_update']}";
1634: }
1635: $constraintSql .= implode(',' . PHP_EOL, $statement) . ';' . PHP_EOL;
1636: $sql[] = $constraintSql;
1637: }
1638: }
1639:
1640: return count($sql) ? $sql : null;
1641: }
1642:
1643: /**
1644: * Generate DROP foreign key constraints SQL statements
1645: * @param array $constraints Array of populated constraints
1646: * @return array Array of SQL statements
1647: */
1648: public function dropConstraintStatements($constraints = null)
1649: {
1650: if ($constraints === null) {
1651: $constraints = $this->getConstraints();
1652: }
1653:
1654: $options = $this->getOptions();
1655: $sql = array();
1656: # FK constraints
1657: if ($options['constraints']) {
1658: $tables = array_keys($constraints);
1659: foreach ($tables as $table) {
1660: $fullTableName = db_table($table);
1661: $result = db_query("SHOW CREATE TABLE `{$fullTableName}`");
1662: if ($result && $row = db_fetchArray($result)) {
1663: $fKeys = array();
1664: if (preg_match_all('/CONSTRAINT `(FK_[A-Z0-9]+)` FOREIGN KEY/', $row[1], $matches)) {
1665: foreach ($matches[1] as $constraintName) {
1666: $fKeys[] = " DROP FOREIGN KEY `{$constraintName}`";
1667: }
1668: }
1669:
1670: if (count($fKeys)) {
1671: $sql[] = "ALTER TABLE `{$fullTableName}`" . PHP_EOL . implode(',' . PHP_EOL, $fKeys) . ';';
1672: }
1673: }
1674: }
1675: }
1676:
1677: return count($sql) ? $sql : null;
1678: }
1679:
1680: /**
1681: * Set the populated primary keys into the schema database options
1682: * @param array $pkFields Array of primary keys
1683: * @return void
1684: */
1685: public function setPrimaryKeys($pkFields)
1686: {
1687: $this->schema['_options']['pk'] = $pkFields;
1688: }
1689:
1690: /**
1691: * Get the populated primary keys from the schema database options
1692: * @param array $schema The schema definition
1693: * @return array Array of primary keys
1694: */
1695: public function getPrimaryKeys($schema = null)
1696: {
1697: if ($schema === null) {
1698: $schema = $this->schema;
1699: }
1700:
1701: return !empty($schema['_options']['pk']) ? $schema['_options']['pk'] : array();
1702: }
1703:
1704: /**
1705: * Set the populated foreign key constraints into the schema database options
1706: * @param array $constraints Array of FK constraints
1707: * @return void
1708: */
1709: public function setConstraints($constraints)
1710: {
1711: $this->schema['_options']['fkConstraints'] = $constraints;
1712: }
1713:
1714: /**
1715: * Get the populated foreign key constraints from the schema database options
1716: * @param array $schema The schema definition
1717: * @return array Array of FK constraints
1718: */
1719: public function getConstraints($schema = null)
1720: {
1721: if ($schema === null) {
1722: $schema = $this->schema;
1723: }
1724:
1725: return !empty($schema['_options']['fkConstraints']) ? $schema['_options']['fkConstraints'] : array();
1726: }
1727:
1728: /**
1729: * Return table collation from the schema definition
1730: * @param string $table The table name
1731: * @param array $schema The schema definition (optional)
1732: * @return string
1733: */
1734: public function getTableCollation($table, $schema = null)
1735: {
1736: if ($schema === null) {
1737: $schema = $this->schema;
1738: }
1739:
1740: return isset($schema[$table]['options']['collate']) ? $schema[$table]['options']['collate'] : null;
1741: }
1742:
1743: /**
1744: * Try to find columns that only changed their name, rename operations maybe cheaper than add/drop
1745: * however ambiguities between different possibilities should not lead to renaming at all.
1746: *
1747: * @param string $needle The table or field name
1748: * @param array $from The table or field definition to check difference against $to
1749: * @param array $to The table or field definition to check difference against $from
1750: * @param string $table The table name or null
1751: *
1752: * @return mixed The similar name or false
1753: */
1754: private function getSimilarity($needle, array $from, array $to, $table = null)
1755: {
1756: if (in_array($needle, array_merge(SchemaManager::$relationships, array('options')))) {
1757: return false;
1758: }
1759:
1760: if ($table) {
1761: $compared = 'field';
1762: if (isset($this->droppedColumns[$table]) && !in_array($needle, $this->droppedColumns[$table])) {
1763: return false;
1764: }
1765:
1766: $haystack = &$this->addedColumns[$table];
1767: } else {
1768: $compared = 'table';
1769: if (!in_array($needle, $this->droppedTables)) {
1770: return false;
1771: }
1772:
1773: $haystack = &$this->addedTables;
1774: }
1775:
1776: if (!is_array($haystack) || in_array($needle, $haystack)) {
1777: return false;
1778: }
1779:
1780: $similarity = array();
1781: $matchingText = array();
1782: $matchingMetaphone = array();
1783:
1784: foreach ($haystack as $i => $name) {
1785: if ($needle === $name) {
1786: return false;
1787: }
1788:
1789: $scores = array();
1790: $matching[$name] = array();
1791:
1792: $changes = 100;
1793: if ($compared == 'table') {
1794: # Table definition comparison
1795: $diff = $this->diffTables($from[$needle], $to[$name]);
1796: if ($diff['changes'] == 0) {
1797: unset($haystack[$i]);
1798: return $name;
1799: }
1800: $changes = $diff['changes'];
1801: } else {
1802: # Field definition comparison
1803: $diff = $this->diffColumns($from[$needle], $to[$name]);
1804: $changes = $diff['changes'];
1805: }
1806: $percentChanges = 100 - $changes;
1807:
1808: # Check similar chars
1809: similar_text(strtolower($needle), strtolower($name), $percent1);
1810: $matchingText[$name] = (int)round($percent1);
1811:
1812: # Check sound
1813: $metaphone1 = metaphone(strtolower($needle));
1814: $metaphone2 = metaphone(strtolower($name));
1815: similar_text($metaphone1, $metaphone2, $percent2);
1816: $matchingMetaphone[$name] = (int)round($percent2);
1817:
1818: $percentByTwo = round(($percent1 + $percent2) / 2);
1819: $percent1 = round($percent1);
1820:
1821: if ($percent1 < 100 && $percent2 == 100) {
1822: # not similar_text, but same sound
1823: $scores[] = $percent1 + $percentChanges;
1824: }
1825:
1826: if ($percentByTwo >= 95 && $percentByTwo <= 100) {
1827: # similar_text + metaphone
1828: $scores[] = $percentByTwo + $percentChanges;
1829: }
1830:
1831: if ($percent1 > 50 && $percent1 < 100) {
1832: # similar_text only
1833: $scores[] = $percent1 + $percentChanges;
1834: }
1835:
1836: if ($compared == 'field' && strpos(strtolower($needle), 'id') !== false && strpos(strtolower($name), 'id') !== false) {
1837: # id field
1838: $scores[] = 75 + $percentChanges;
1839: }
1840:
1841: if (count($scores)) {
1842: arsort($scores);
1843: $similarity[$name] = (int)round(array_shift($scores));
1844: }
1845: }
1846:
1847: if (count($similarity) == 0) {
1848: return false;
1849: }
1850:
1851: arsort($similarity);
1852: arsort($matchingText);
1853: arsort($matchingMetaphone);
1854:
1855: foreach (array($similarity, $matchingText, $matchingMetaphone) as $i => $matchings) {
1856: $dups = array_count_values($matchings);
1857: if (array_pop($dups) == 1 || $i == 2) {
1858: $candidate = array_keys($matchings);
1859: $topSimilarity = array_shift($candidate);
1860: break;
1861: }
1862: }
1863:
1864: unset($haystack[array_search($topSimilarity, $haystack)]);
1865:
1866: return $topSimilarity;
1867: }
1868:
1869: /**
1870: * Try to find out dropped tables
1871: * @param array $schemaFrom The schema definion from
1872: * @param array $schemaTo The schema definion to
1873: * @return void
1874: */
1875: private function detectDroppedTables(array $schemaFrom, array $schemaTo)
1876: {
1877: # Find out dropped tables and columns
1878: foreach ($schemaFrom as $table => $tableDef) {
1879: if ($table == '_options') {
1880: continue;
1881: }
1882:
1883: if (!isset($schemaTo[$table])) {
1884: $this->droppedTables[] = $table;
1885: continue;
1886: }
1887: }
1888: }
1889:
1890: /**
1891: * Try to find out possible new tables
1892: * @param array $schemaFrom The schema definion from
1893: * @param array $schemaTo The schema definion to
1894: * @return void
1895: */
1896: private function detectAddedTables(array $schemaFrom, array $schemaTo)
1897: {
1898: # Find out possible new tables and columns
1899: foreach ($schemaTo as $table => $tableDef) {
1900: if ($table == '_options') {
1901: continue;
1902: }
1903:
1904: if (!isset($schemaFrom[$table])) {
1905: $this->addedTables[] = $table;
1906: continue;
1907: }
1908: }
1909: }
1910:
1911: /**
1912: * Try to find out dropped tables
1913: * @param array $schemaFrom The schema definion from
1914: * @param array $schemaTo The schema definion to
1915: * @return void
1916: */
1917: private function detectDroppedColumns(array $schemaFrom, array $schemaTo)
1918: {
1919: # Find out dropped tables and columns
1920: foreach ($schemaFrom as $table => $tableDef) {
1921: if ($table == '_options') {
1922: continue;
1923: }
1924:
1925: # Add new fields for existing table
1926: foreach ($tableDef as $field => $fieldDef) {
1927: if (in_array($field, array_merge(SchemaManager::$relationships, array('options')))) {
1928: continue;
1929: }
1930:
1931: if (!isset($schemaTo[$table][$field])) {
1932: # Add a new field
1933: $this->droppedColumns[$table][] = $field;
1934: }
1935: }
1936: }
1937: }
1938:
1939: /**
1940: * Try to find out possible new columns
1941: * @param array $schemaFrom The schema definition from
1942: * @param array $schemaTo The schema definition to
1943: * @return void
1944: */
1945: private function detectAddedColumns(array $schemaFrom, array $schemaTo)
1946: {
1947: # Find out possible new tables and columns
1948: foreach ($schemaTo as $table => $tableDef) {
1949: if ($table == '_options') {
1950: continue;
1951: }
1952:
1953: # Add new fields for existing table
1954: foreach ($tableDef as $field => $fieldDef) {
1955: if (in_array($field, array_merge(SchemaManager::$relationships, array('options')))) {
1956: continue;
1957: }
1958:
1959: if (!isset($schemaFrom[$table][$field])) {
1960: # Add a new field
1961: $this->addedColumns[$table][] = $field;
1962: }
1963: }
1964: }
1965: }
1966:
1967:
1968: /**
1969: * Try to find tables and columns that only changed their name, rename operations maybe cheaper than add/drop
1970: * however ambiguities between different possibilities should not lead to renaming at all.
1971: *
1972: * @param array $schemaFrom The schema definition from
1973: * @param array $schemaTo The schema definition to
1974: * @return void
1975: */
1976: private function detectTableRenamings(array $schemaFrom, array $schemaTo)
1977: {
1978: $this->detectDroppedTables($schemaFrom, $schemaTo);
1979: $this->detectAddedTables($schemaFrom, $schemaTo);
1980:
1981: # Detect table and column renaming
1982: foreach ($schemaFrom as $table => $tableDef) {
1983: if ($table == '_options') {
1984: continue;
1985: }
1986:
1987: $renamedTable = $this->getSimilarity($table, $schemaFrom, $schemaTo);
1988: if ($renamedTable) {
1989: $this->tablesRenamed[$table] = $renamedTable;
1990: }
1991: }
1992: }
1993:
1994: /**
1995: * Try to find tables and columns that only changed their name, rename operations maybe cheaper than add/drop
1996: * however ambiguities between different possibilities should not lead to renaming at all.
1997: *
1998: * @param array $schemaFrom The schema definion from
1999: * @param array $schemaTo The schema definion to
2000: * @return void
2001: */
2002: private function detectColumnRenamings(array $schemaFrom, array $schemaTo)
2003: {
2004: $this->detectDroppedColumns($schemaFrom, $schemaTo);
2005: $this->detectAddedColumns($schemaFrom, $schemaTo);
2006:
2007: # Detect table and column renaming
2008: foreach ($schemaFrom as $table => $tableDef) {
2009: if ($table == '_options') {
2010: continue;
2011: }
2012:
2013: $originalTable = $table;
2014: $renamedTable = null;
2015: if (isset($this->tablesRenamed[$table])) {
2016: $renamedTable = $this->tablesRenamed[$table];
2017: }
2018:
2019: if (isset($schemaTo[$table]) || ($renamedTable && isset($schemaTo[$renamedTable]))) {
2020: if ($renamedTable) {
2021: $table = $renamedTable;
2022: }
2023:
2024: foreach ($tableDef as $field => $fieldDef) {
2025: if (in_array($field, array_merge(SchemaManager::$relationships, array('options')))) {
2026: continue;
2027: }
2028:
2029: if (!isset($schemaTo[$table][$field])) {
2030: # Check if there is similar field name
2031: $renamedCol = $this->getSimilarity($field, $tableDef, $schemaTo[$table], $table);
2032: if ($renamedCol) {
2033: $this->columnsRenamed[$table . '.' . $field] = $renamedCol;
2034: }
2035: }
2036: }
2037: }
2038: }
2039: }
2040:
2041: /**
2042: * Computes the difference of two arrays similar to the native function `array_diff`
2043: * which can't be used for multi-dimensional arrays
2044: *
2045: * @param array $from The array to compare from
2046: * @param array $to An array to compare against
2047: *
2048: * @return array The array with two keys:
2049: * `diff` - an array containing all the entries from $from that are not present in the other array $to.
2050: * `changes` - number of changes; the more differences, the higher numbers; 0 means the two arrays are identical
2051: */
2052: private function diffColumns(array $from, array $to)
2053: {
2054: $changes = 0;
2055: $diff = array();
2056: foreach ($from as $key => $value) {
2057: if (!isset($to[$key])) {
2058: $diff[$key] = $value;
2059: $changes++;
2060: continue;
2061: }
2062:
2063: if (isset($to[$key]) && $from[$key] != $to[$key]) {
2064: $diff[$key] = $to[$key];
2065: $changes++;
2066: continue;
2067: }
2068: }
2069:
2070: $fromKeys = array_keys($from);
2071: $toKeys = array_keys($to);
2072: $diffKeys = array_diff($toKeys, $fromKeys);
2073: foreach ($diffKeys as $key) {
2074: $diff[$key] = $to[$key];
2075: $changes++;
2076: }
2077:
2078: return array(
2079: 'diff' => $diff,
2080: 'changes' => $changes,
2081: );
2082: }
2083:
2084: /**
2085: * Computes the difference of two arrays similar to the native function `array_diff`
2086: * which can't be used for multi-dimensional arrays
2087: *
2088: * @param array $from The array to compare from
2089: * @param array $to An array to compare against
2090: *
2091: * @return array The array with two keys:
2092: * `diff` - an array containing all the entries from $from that are not present in the other array $to.
2093: * `changes` - number of changes; the more differences, the higher numbers; 0 means the two arrays are identical
2094: */
2095: private function diffTables(array $from, array $to)
2096: {
2097: $changes = 0;
2098: $diff = array();
2099: foreach ($from as $key => $value) {
2100: if (!isset($to[$key]) || (isset($to[$key]) && $from[$key] != $to[$key])) {
2101: $diff[$key] = $value;
2102: $changes++;
2103: }
2104: }
2105:
2106: return array(
2107: 'diff' => $diff,
2108: 'changes' => $changes,
2109: );
2110: }
2111:
2112: /**
2113: * Get the version directory path
2114: * @param string $dbNamespace The namespace for the database
2115: * @return string The full directory path
2116: */
2117: private function getVersionDir($dbNamespace = null)
2118: {
2119: if ($dbNamespace === null) {
2120: $dbNamespace = $this->dbNamespace;
2121: }
2122:
2123: $versionDir = DB . 'version' . _DS_ . $dbNamespace;
2124: if (!is_dir($versionDir)) {
2125: mkdir($versionDir, 777, true);
2126: }
2127:
2128: return $versionDir;
2129: }
2130:
2131: /**
2132: * Get schema definition from the built schema file
2133: * @param string $dbNamespace The namespace for the database
2134: * @return array The schema definition; NULL when there is no file
2135: */
2136: public static function getSchemaLockDefinition($dbNamespace = null)
2137: {
2138: $file = DB . _DS_ . 'build' . _DS_ . 'schema';
2139: if ($dbNamespace) {
2140: $file .= '.' . $dbNamespace;
2141: }
2142: $file .= '.lock';
2143:
2144: if (!(is_file($file) && file_exists($file))) {
2145: return null;
2146: }
2147:
2148: return unserialize(file_get_contents($file));
2149: }
2150:
2151: /**
2152: * Get schema lock file name
2153: * @param string $dbNamespace The namespace for the database
2154: * @param boolean $backupFileName If true, ~ will be prefixed in the file name
2155: * @return string The file name with full path
2156: */
2157: public static function getSchemaLockFileName($dbNamespace = null, $backupFileName = false)
2158: {
2159: $file = DB . _DS_ . 'build' . _DS_;
2160:
2161: if ($backupFileName) {
2162: $file .= '~';
2163: }
2164:
2165: $file .= 'schema';
2166:
2167: if ($dbNamespace) {
2168: $file .= '.' . $dbNamespace;
2169: }
2170:
2171: $file .= '.lock';
2172:
2173: return $file;
2174: }
2175: }
2176: