Rearranging scripts to reduce the hassle of updating local application whenever scrip...
[akelos.git] / lib / AkInstaller.php
blobaafff55c5ff39766ba80a3139ed541c78de92427
1 <?php
2 /* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
4 // +----------------------------------------------------------------------+
5 // | Akelos Framework - http://www.akelos.org |
6 // +----------------------------------------------------------------------+
7 // | Copyright (c) 2002-2006, Akelos Media, S.L. & Bermi Ferrer Martinez |
8 // | Released under the GNU Lesser General Public License, see LICENSE.txt|
9 // +----------------------------------------------------------------------+
11 /**
12 * @package ActiveSupport
13 * @subpackage Installer
14 * @author Bermi Ferrer <bermi a.t akelos c.om>
15 * @copyright Copyright (c) 2002-2006, Akelos Media, S.L. http://www.akelos.org
16 * @license GNU Lesser General Public License <http://www.gnu.org/copyleft/lesser.html>
19 require_once(AK_LIB_DIR.DS.'Ak.php');
20 require_once(AK_LIB_DIR.DS.'AkActiveRecord.php');
21 file_exists(AK_APP_DIR.DS.'shared_model.php') ? require_once(AK_APP_DIR.DS.'shared_model.php') : null;
22 defined('AK_APP_INSTALLERS_DIR') ? null : define('AK_APP_INSTALLERS_DIR', AK_APP_DIR.DS.'installers');
24 // Install scripts might use more RAM than normal requests.
25 @ini_set('memory_limit', -1);
27 class AkInstaller
29 var $db;
30 var $data_dictionary;
31 var $debug = false;
32 var $available_tables = array();
33 var $vervose = true;
34 var $module;
35 var $warn_if_same_version = true;
37 function AkInstaller($db_connection = null)
39 if(empty($db_connection)){
40 $this->db =& Ak::db();
41 }else {
42 $this->db =& $db_connection;
45 $this->available_tables = $this->getAvailableTables();
47 $this->db->debug =& $this->debug;
49 $this->data_dictionary = NewDataDictionary($this->db);
52 function install($version = null, $options = array())
54 $version = (is_null($version)) ? max($this->getAvailableVersions()) : $version;
55 return $this->_upgradeOrDowngrade('up', $version , $options);
58 function up($version = null, $options = array())
60 return $this->_upgradeOrDowngrade('up', $version, $options);
64 function uninstall($version = null, $options = array())
66 $version = (is_null($version)) ? 0 : $version;
67 return $this->_upgradeOrDowngrade('down', $version, $options);
70 function down($version = null, $options = array())
72 return $this->_upgradeOrDowngrade('down', $version, $options);
76 function _upgradeOrDowngrade($action, $version = null, $options = array())
78 if(in_array('quiet',$options) && AK_ENVIRONMENT == 'development'){
79 $this->vervose = false;
80 }elseif(!empty($this->vervose) && AK_ENVIRONMENT == 'development'){
81 $this->db->debug = true;
84 $current_version = $this->getInstalledVersion($options);
85 $available_versions = $this->getAvailableVersions();
87 $action = stristr($action,'down') ? 'down' : 'up';
89 if($action == 'up'){
90 $newest_version = max($available_versions);
91 $version = isset($version[0]) && is_numeric($version[0]) ? $version[0] : $newest_version;
92 $versions = range($current_version+1,$version);
94 if($current_version > $version){
95 echo Ak::t("You can't upgrade to version %version, when you are currently on version %current_version", array('%version'=>$version,'%current_version'=>$current_version));
96 return false;
98 }else{
99 $version = !empty($version[0]) && is_numeric($version[0]) ? $version[0] : 0;
100 $versions = range($current_version, empty($version) ? 1 : $version+1);
102 if($current_version == 0){
103 return true;
104 }elseif($current_version < $version){
105 echo Ak::t("You can't downgrade to version %version, when you just have installed version %current_version", array('%version'=>$version,'%current_version'=>$current_version));
106 return false;
110 if($this->warn_if_same_version && $current_version == $version){
111 echo Ak::t("Can't go $action to version %version, you're already on version %version", array('%version'=>$version));
112 return false;
115 if(AK_CLI && !empty($this->vervose) && AK_ENVIRONMENT == 'development'){
116 echo Ak::t(ucfirst($action).'grading');
119 if(!empty($versions) && is_array($versions)){
120 foreach ($versions as $version){
121 if(!$this->_runInstallerMethod($action, $version, $options)){
122 return false;
125 }else{
126 return false;
129 return true;
133 function installVersion($version, $options = array())
135 return $this->_runInstallerMethod('up', $version, $options);
138 function uninstallVersion($version, $options = array())
140 return $this->_runInstallerMethod('down', $version, $options);
144 * Runs a a dow_1, up_3 method and wraps it into a transaction.
146 function _runInstallerMethod($method_prefix, $version, $options = array(), $version_number = null)
148 $method_name = $method_prefix.'_'.$version;
149 if(!method_exists($this, $method_name)){
150 return false;
153 $version_number = empty($version_number) ? ($method_prefix=='down' ? $version-1 : $version) : $version_number;
155 $this->transactionStart();
156 if($this->$method_name($options) === false){
157 $this->transactionFail();
159 $success = !$this->transactionHasFailed();
160 $this->transactionComplete();
161 if($success){
162 $this->setInstalledVersion($version_number, $options);
164 return $success;
167 function getInstallerName()
169 return str_replace('installer','',strtolower(get_class($this)));
173 function _versionPath($options = array())
175 $mode = empty($options['mode']) ? AK_ENVIRONMENT : $options['mode'];
176 return AK_APP_INSTALLERS_DIR.DS.(empty($this->module)?'':$this->module.DS).'versions'.DS.$mode.'_'.$this->getInstallerName().'_version.txt';
180 function getInstalledVersion($options = array())
182 $version_file = $this->_versionPath($options);
184 if(!is_file($version_file)){
185 $this->setInstalledVersion(0, $options);
187 return Ak::file_get_contents($this->_versionPath($options));
191 function setInstalledVersion($version, $options = array())
193 return Ak::file_put_contents($this->_versionPath($options), $version);
197 function getAvailableVersions()
199 $versions = array();
200 foreach(get_class_methods($this) as $method_name){
201 if(preg_match('/^up_([0-9]*)$/',$method_name, $match)){
202 $versions[] = $match[1];
205 sort($versions);
206 return $versions;
210 function modifyTable($table_name, $column_options = null, $table_options = array())
212 return $this->_createOrModifyTable($table_name, $column_options, $table_options);
216 * Adds a new column to the table called $table_name
218 function addColumn($table_name, $column_details)
220 $column_details = $this->_getColumnsAsAdodbDataDictionaryString($column_details);
221 return $this->data_dictionary->ExecuteSQLArray($this->data_dictionary->AddColumnSQL($table_name, $column_details));
224 function changeColumn($table_name, $column_details)
226 $column_details = $this->_getColumnsAsAdodbDataDictionaryString($column_details);
227 return $this->data_dictionary->ExecuteSQLArray($this->data_dictionary->AlterColumnSQL($table_name, $column_details));
230 function removeColumn($table_name, $column_name)
232 return $this->data_dictionary->ExecuteSQLArray($this->data_dictionary->DropColumnSQL($table_name, $column_name));
235 function renameColumn($table_name, $old_column_name, $new_column_name)
237 if(!strstr($this->db->databaseType,'mysql')){
238 trigger_error(Ak::t('Column renaming is only supported when using MySQL databases'), E_USER_ERROR);
240 return $this->data_dictionary->ExecuteSQLArray($this->data_dictionary->RenameColumnSQL($table_name, $old_column_name, $new_column_name));
244 function createTable($table_name, $column_options = null, $table_options = array())
246 static $created_tables = array();
248 if(in_array($table_name, $created_tables)){
249 //return false;
251 if($this->tableExists($table_name)){
252 trigger_error(Ak::t('Table %table_name already exists on the database', array('%table_name'=>$table_name)), E_USER_NOTICE);
253 return false;
255 $created_tables[] = $table_name;
256 return $this->_createOrModifyTable($table_name, $column_options, $table_options);
259 function _createOrModifyTable($table_name, $column_options = null, $table_options = array())
261 if(empty($column_options) && $this->_loadDbDesignerDbSchema()){
262 $column_options = $this->db_designer_schema[$table_name];
263 }elseif(empty($column_options)){
264 trigger_error(Ak::t('You must supply details for the table you are creating.'), E_USER_ERROR);
265 return false;
268 $column_options = is_string($column_options) ? array('columns'=>$column_options) : $column_options;
270 $default_column_options = array(
271 'sequence_table' => false
273 $column_options = array_merge($default_column_options, $column_options);
275 $default_table_options = array(
276 'mysql' => 'TYPE=InnoDB',
277 //'REPLACE'
279 $table_options = array_merge($default_table_options, $table_options);
281 $column_string = $this->_getColumnsAsAdodbDataDictionaryString($column_options['columns']);
283 $result = $this->data_dictionary->ExecuteSQLArray($this->data_dictionary->ChangeTableSQL($table_name, str_replace(array(' UNIQUE', ' INDEX', ' FULLTEXT', ' HASH'), '', $column_string), $table_options));
285 if($result){
286 $this->available_tables[] = $table_name;
289 $columns_to_index = $this->_getColumnsToIndex($column_string);
291 foreach ($columns_to_index as $column_to_index => $index_type){
292 $this->addIndex($table_name, $column_to_index.($index_type != 'INDEX' ? ' '.$index_type : ''));
295 if(isset($column_options['index_columns'])){
296 $this->addIndex($table_name, $column_options['index_columns']);
299 if($column_options['sequence_table'] || $this->_requiresSequenceTable($column_string)){
300 $this->createSequence($table_name);
303 return $result;
306 function dropTable($table_name, $options = array())
308 $result = $this->tableExists($table_name) ? $this->db->Execute('DROP TABLE '.$table_name) : true;
309 if($result){
310 unset($this->available_tables[array_search($table_name, $this->available_tables)]);
311 if(!empty($options['sequence'])){
312 $this->dropSequence($table_name);
317 function dropTables()
319 $args = func_get_args();
320 if(!empty($args)){
321 $num_args = count($args);
322 $options = $num_args > 1 && is_array($args[$num_args-1]) ? array_shift($args) : array();
323 $tables = count($args) > 1 ? $args : (is_array($args[0]) ? $args[0] : Ak::toArray($args[0]));
324 foreach ($tables as $table){
325 $this->dropTable($table, $options);
330 function addIndex($table_name, $columns, $index_name = '')
332 $index_name = ($index_name == '') ? 'idx_'.$table_name.'_'.$columns : $index_name;
333 $index_options = array();
334 if(preg_match('/(UNIQUE|FULLTEXT|HASH)/',$columns,$match)){
335 $columns = trim(str_replace($match[1],'',$columns),' ');
336 $index_options[] = $match[1];
338 return $this->tableExists($table_name) ? $this->data_dictionary->ExecuteSQLArray($this->data_dictionary->CreateIndexSQL($index_name, $table_name, $columns, $index_options)) : false;
341 function removeIndex($table_name, $columns_or_index_name)
343 if(!$this->tableExists($table_name)) return false;
344 $available_indexes =& $this->db->MetaIndexes($table_name);
345 $index_name = isset($available_indexes[$columns_or_index_name]) ? $columns_or_index_name : 'idx_'.$table_name.'_'.$columns_or_index_name;
346 if(!isset($available_indexes[$index_name])){
347 trigger_error(Ak::t('Index %index_name does not exist.', array('%index_name'=>$index_name)), E_USER_NOTICE);
348 return false;
350 return $this->data_dictionary->ExecuteSQLArray($this->data_dictionary->DropIndexSQL($index_name, $table_name));
353 function dropIndex($table_name, $columns_or_index_name)
355 return $this->removeIndex($table_name,$columns_or_index_name);
358 function createSequence($table_name)
360 $result = $this->tableExists('seq_'.$table_name) ? false : $this->db->CreateSequence('seq_'.$table_name);
361 $this->available_tables[] = 'seq_'.$table_name;
362 return $result;
365 function dropSequence($table_name)
367 $result = $this->tableExists('seq_'.$table_name) ? $this->db->DropSequence('seq_'.$table_name) : true;
368 if($result){
369 unset($this->available_tables[array_search('seq_'.$table_name, $this->available_tables)]);
372 return $result;
375 function getAvailableTables()
377 if(empty($this->available_tables)){
378 $this->available_tables = array_diff((array)$this->db->MetaTables(), array(''));
380 return $this->available_tables;
383 function tableExists($table_name)
385 return in_array($table_name,$this->getAvailableTables());
388 function _getColumnsAsAdodbDataDictionaryString($columns)
390 $columns = $this->_setColumnDefaults($columns);
391 $this->_ensureColumnNameCompatibility($columns);
392 $equivalences = array(
393 '/ ((limit|max|length) ?= ?)([0-9]+)([ \n\r,]+)/'=> ' (\3) ',
394 '/([ \n\r,]+)default([ =]+)([^\'^,^\n]+)/i'=> ' DEFAULT \'\3\'',
395 '/([ \n\r,]+)(integer|int)([( \n\r,]*)/'=> '\1 I \3',
396 '/([ \n\r,]+)float([( \n\r,]+)/'=> '\1 F \2',
397 '/([ \n\r,]+)datetime([( \n\r,]*)/'=> '\1 T \2',
398 '/([ \n\r,]+)date([( \n\r,]*)/'=> '\1 D \2',
399 '/([ \n\r,]+)timestamp([( \n\r,]*)/'=> '\1 T \2',
400 '/([ \n\r,]+)time([( \n\r,]*)/'=> '\1 T \2',
401 '/([ \n\r,]+)text([( \n\r,]*)/'=> '\1 XL \2',
402 '/([ \n\r,]+)string([( \n\r,]*)/'=> '\1 C \2',
403 '/([ \n\r,]+)binary([( \n\r,]*)/'=> '\1 B \2',
404 '/([ \n\r,]+)boolean([( \n\r,]*)/'=> '\1 L(1) \2',
405 '/ NOT( |_)?NULL/i'=> ' NOTNULL',
406 '/ AUTO( |_)?INCREMENT/i'=> ' AUTO ',
407 '/ +/'=> ' ',
408 '/ ([\(,]+)/'=> '\1',
409 '/ INDEX| IDX/i'=> ' INDEX ',
410 '/ UNIQUE/i'=> ' UNIQUE ',
411 '/ HASH/i'=> ' HASH ',
412 '/ FULL_?TEXT/i'=> ' FULLTEXT ',
413 '/ ((PRIMARY( |_)?)?KEY|pk)/i'=> ' KEY',
416 return trim(preg_replace(array_keys($equivalences),array_values($equivalences), ' '.$columns.' '), ' ');
419 function _setColumnDefaults($columns)
421 $columns = str_replace("\t",' ', $columns);
422 if(is_string($columns)){
423 if(strstr($columns,"\n")){
424 $columns = explode("\n",$columns);
425 }elseif(strstr($columns,',')){
426 $columns = explode(',',$columns);
429 foreach ((array)$columns as $column){
430 $column = trim($column, "\n\r, ");
431 if(!empty($column)){
432 $single_columns[$column] = $this->_setColumnDefault($column);
435 return join(",\n", $single_columns);
438 function _setColumnDefault($column)
440 return $this->_needsDefaultAttributes($column) ? $this->_setDefaultAttributes($column) : $column;
443 function _needsDefaultAttributes($column)
445 return preg_match('/^(([A-Z0-9_\(\)]+)|(.+ string[^\(.]*)|(\*.*))$/i',$column);
448 function _setDefaultAttributes($column)
450 $rules = $this->getDefaultColumnAttributesRules();
451 foreach ($rules as $regex=>$replacement){
452 if(is_string($replacement)){
453 $column = preg_replace($regex,$replacement,$column);
454 }elseif(preg_match($regex,$column,$match)){
455 $column = call_user_func_array($replacement,$match);
458 return $column;
462 * Returns a key => value pair of regular expressions that will trigger methods
463 * to cast database columns to their respective default values or a replacement expression.
465 function getDefaultColumnAttributesRules()
467 return array(
468 '/^\*(.*)$/i' => array(&$this,'_castToMultilingualColumn'),
469 '/^(description|content|body)$/i' => '\1 text',
470 '/^(id)$/i' => 'id integer not null auto_increment primary_key',
471 '/^(.+)_(id|by)$/i' => '\1_\2 integer index',
472 '/^(position)$/i' => '\1 integer index',
473 '/^(.+_at)$/i' => '\1 datetime',
474 '/^(.+_on)$/i' => '\1 date',
475 '/^(is_|has_|do_|does_|are_)([A-Z0-9_]+)$/i' => '\1\2 boolean not null default \'0\' index', //
476 '/^([A-Z0-9_]+) *(\([0-9]+\))?$/i' => '\1 string\2', // Everything else will default to string
477 '/^((.+ )string([^\(.]*))$/i' => '\2string(255)\3', // If we don't set the string lenght it will fail, so if not present will set it to 255
481 function _castToMultilingualColumn($found, $column)
483 $columns = array();
484 foreach (Ak::langs() as $lang){
485 $columns[] = $lang.'_'.ltrim($column);
487 return $this->_setColumnDefaults($columns);
490 function _getColumnsToIndex($column_string)
492 $columns_to_index = array();
493 foreach (explode(',',$column_string.',') as $column){
494 if(preg_match('/([A-Za-z0-9_]+) (.*) (INDEX|UNIQUE|FULLTEXT|HASH) ?(.*)$/i',$column,$match)){
495 $columns_to_index[$match[1]] = $match[3];
498 return $columns_to_index;
501 function _getUniqueValueColumns($column_string)
503 $unique_columns = array();
504 foreach (explode(',',$column_string.',') as $column){
505 if(preg_match('/([A-Za-z0-9_]+) (.*) UNIQUE ?(.*)$/',$column,$match)){
506 $unique_columns[] = $match[1];
509 return $unique_columns;
512 function _requiresSequenceTable($column_string)
514 if(preg_match('/mysql|postgres/', $this->db->databaseType)){
515 return false;
517 foreach (explode(',',$column_string.',') as $column){
518 if(preg_match('/([A-Za-z0-9_]+) (.*) AUTO (.*)$/',$column)){
519 return true;
521 if(preg_match('/^id /',$column)){
522 return true;
525 return false;
530 * Transaction support for database operations
532 * Transactions are enabled automatically for Intaller objects, But you can nest transactions within models.
533 * This transactions are nested, and only the othermost will be executed
535 * $UserInstalller->transactionStart();
536 * $UserInstalller->addTable('id, name');
538 * if(!isCompatible()){
539 * $User->transactionFail();
542 * $User->transactionComplete();
544 function transactionStart()
546 return $this->db->StartTrans();
549 function transactionComplete()
551 return $this->db->CompleteTrans();
554 function transactionFail()
556 return $this->db->FailTrans();
559 function transactionHasFailed()
561 return $this->db->HasFailedTrans();
565 function _loadDbDesignerDbSchema()
567 if($path = $this->_getDbDesignerFilePath()){
568 $this->db_designer_schema = Ak::convert('DBDesigner','AkelosDatabaseDesign', Ak::file_get_contents($path));
569 return !empty($this->db_designer_schema);
571 return false;
574 function _getDbDesignerFilePath()
576 $path = AK_APP_INSTALLERS_DIR.DS.$this->getInstallerName().'.xml';
577 return file_exists($path) ? $path : false;
580 function _ensureColumnNameCompatibility($columns)
582 $columns = explode(',',$columns.',');
583 foreach ($columns as $column){
584 $column = trim($column);
585 $column = substr($column, 0, strpos($column.' ',' '));
586 $this->_canUseColumn($column);
590 function _canUseColumn($column_name)
592 $invalid_columns = $this->_getInvalidColumnNames();
593 if(in_array($column_name, $invalid_columns)){
595 $method_name_part = AkInflector::camelize($column_name);
596 require_once(AK_LIB_DIR.DS.'AkActiveRecord.php');
597 $method_name = (method_exists(new AkActiveRecord(), 'set'.$method_name_part)?'set':'get').$method_name_part;
599 trigger_error(Ak::t('A method named %method_name exists in the AkActiveRecord class'.
600 ' which will cause a recusion problem if you use the column %column_name in your database. '.
601 'You can disable automatic %type by setting the constant %constant to false '.
602 'in your configuration file.', array(
603 '%method_name'=> $method_name,
604 '%column_name' => $column_name,
605 '%type' => Ak::t($method_name[0] == 's' ? 'setters' : 'getters'),
606 '%constant' => Ak::t($method_name[0] == 's' ? 'AK_ACTIVE_RECORD_ENABLE_CALLBACK_SETTERS' : 'AK_ACTIVE_RECORD_ENABLE_CALLBACK_GETTERS'),
608 )), E_USER_ERROR);
612 function _getInvalidColumnNames()
614 return defined('AK_INVALID_ACTIVE_RECORD_COLUMNS') ? explode(',',AK_INVALID_ACTIVE_RECORD_COLUMNS) : array('sanitized_conditions_array','conditions','inheritance_column','inheritance_column',
615 'subclasses','attribute','attributes','attribute','attributes','accessible_attributes','protected_attributes',
616 'serialized_attributes','available_attributes','attribute_caption','primary_key','column_names','content_columns',
617 'attribute_names','combined_subattributes','available_combined_attributes','connection','connection','primary_key',
618 'table_name','table_name','only_available_atrributes','columns_for_atrributes','columns_with_regex_boundaries','columns',
619 'column_settings','column_settings','akelos_data_type','class_for_database_table_mapping','display_field','display_field',
620 'internationalized_columns','available_locales','current_locale','attribute_by_locale','attribute_locales',
621 'attribute_by_locale','attribute_locales','attributes_before_type_cast','attribute_before_type_cast','serialize_attribute',
622 'available_attributes_quoted','attributes_quoted','column_type','value_for_date_column','observable_state',
623 'observable_state','observers','errors','base_errors','errors_on','full_error_messages','array_from_ak_string',
624 'attribute_condition','association_handler','associated','associated_finder_sql_options','association_option',
625 'association_option','association_id','associated_ids','associated_handler_name','associated_type','association_type',
626 'collection_handler_name','model_name','model_name','parent_model_name','parent_model_name');
629 function execute($sql)
631 return $this->db->Execute($sql);
635 function usage()
637 echo Ak::t("Description:
638 Database migrations is a sort of SCM like subversion, but for database settings.
640 The migration command takes the name of an installer located on your
641 /app/installers folder and runs one of the following commands:
643 - \"install\" + (options version number): Will update to the provided version
644 number or to the latest one in no version is given.
646 - \"uninstall\" + (options version number): Will downgrade to the provided
647 version number or to the lowest one in no version is given.
649 Current version number will be sorted at app/installers/installer_name_version.txt.
651 Example:
652 >> migrate framework install
654 Will run the default database schema for the framework.
655 This generates the tables for handling database driven sessions and cache.