diff --git a/.gitignore b/.gitignore index e358fdd..ad151ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,126 @@ -*/config/development -*/logs/log-*.php -!*/logs/index.html -*/cache/* -!*/cache/index.html -!*/cache/.htaccess +#------------------------- +# Operating Specific Junk Files +#------------------------- +# OS X +.DS_Store +.AppleDouble +.LSOverride + +# OS X Thumbnails +._* + +# Windows image file caches +Thumbs.db +ehthumbs.db +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Linux +*~ + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +#------------------------- +# Environment Files +#------------------------- +# These should never be under version control, +# as it poses a security risk. +.env +.vagrant +Vagrantfile + +#------------------------- +# Temporary Files +#------------------------- +writable/cache/* +!writable/cache/index.html + +writable/logs/* +!writable/logs/index.html + +writable/session/* +!writable/session/index.html + +writable/uploads/* +!writable/uploads/index.html + +writable/debugbar/* + +php_errors.log + +#------------------------- +# User Guide Temp Files +#------------------------- user_guide_src/build/* user_guide_src/cilexer/build/* user_guide_src/cilexer/dist/* user_guide_src/cilexer/pycilexer.egg-info/* -#codeigniter 3 -application/logs/* -!application/logs/index.html -!application/logs/.htaccess -/vendor/ -/nbproject/private/ -/nbproject/ \ No newline at end of file +#------------------------- +# Test Files +#------------------------- +tests/coverage* + +# Don't save phpunit under version control. +phpunit + +#------------------------- +# Composer +#------------------------- +vendor/ +composer.lock + +#------------------------- +# IDE / Development Files +#------------------------- + +# Modules Testing +_modules/* + +# phpenv local config +.php-version + +# Jetbrains editors (PHPStorm, etc) +.idea/ +*.iml + +# Netbeans +nbproject/ +build/ +nbbuild/ +dist/ +nbdist/ +nbactions.xml +nb-configuration.xml +.nb-gradle/ + +# Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache +*.sublime-workspace +*.sublime-project +.phpintel +/api/ + +# Visual Studio Code +.vscode/ + +/results/ +/phpunit*.xml \ No newline at end of file diff --git a/app/Common.php b/app/Common.php new file mode 100644 index 0000000..780ba3f --- /dev/null +++ b/app/Common.php @@ -0,0 +1,15 @@ +migration->current() this is the version that schema will - | be upgraded / downgraded to. + | This is the format that will be used when creating new migrations + | using the cli command: + | > php spark migrate:create + | + | Typical formats: + | YmdHis_ + | Y-m-d-His_ + | Y_m_d_His_ | */ - public $currentVersion = 0; + public $timestampFormat = 'Y-m-d-His_'; } diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index abffc56..252692f 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -1,4 +1,5 @@ -session = \Config\Services::session(); - } + } diff --git a/app/Controllers/Home.php b/app/Controllers/Home.php index 00a3572..8798cdd 100644 --- a/app/Controllers/Home.php +++ b/app/Controllers/Home.php @@ -1,7 +1,5 @@ showHeader(); -// fire off the command the main framework. -$console->run(); +// fire off the command in the main framework. +$response = $console->run(); +if ($response->getStatusCode() >= 300) +{ + exit($response->getStatusCode()); +} diff --git a/system/API/ResponseTrait.php b/system/API/ResponseTrait.php index 3539f25..f887b11 100644 --- a/system/API/ResponseTrait.php +++ b/system/API/ResponseTrait.php @@ -65,6 +65,7 @@ protected $codes = [ 'created' => 201, 'deleted' => 200, + 'no_content' => 204, 'invalid_request' => 400, 'unsupported_response_type' => 400, 'invalid_scope' => 400, @@ -191,6 +192,21 @@ //-------------------------------------------------------------------- /** + * Used after a command has been successfully executed but there is no + * meaningful reply to send back to the client. + * + * @param string $message Message. + * + * @return mixed + */ + public function respondNoContent(string $message = 'No Content') + { + return $this->respond(null, $this->codes['no_content'], $message); + } + + //-------------------------------------------------------------------- + + /** * Used when the client is either didn't send authorization information, * or had bad authorization credentials. User is encouraged to try again * with the proper information. diff --git a/system/Autoloader/Autoloader.php b/system/Autoloader/Autoloader.php index 359e5eb..598ae7b 100644 --- a/system/Autoloader/Autoloader.php +++ b/system/Autoloader/Autoloader.php @@ -422,7 +422,7 @@ unset($paths['CodeIgniter\\']); } - // Composer stores paths with trailng slash. We don't. + // Composer stores namespaces with trailing slash. We don't. $newPaths = []; foreach ($paths as $key => $value) { diff --git a/system/Autoloader/FileLocator.php b/system/Autoloader/FileLocator.php index bdbb91f..0bacd15 100644 --- a/system/Autoloader/FileLocator.php +++ b/system/Autoloader/FileLocator.php @@ -126,19 +126,19 @@ $filename = implode('/', $segments); break; } - + // if no namespaces matched then quit if (empty($paths)) { return false; } - + // Check each path in the namespace foreach ($paths as $path) { // Ensure trailing slash $path = rtrim($path, '/') . '/'; - + // If we have a folder name, then the calling function // expects this file to be within that folder, like 'Views', // or 'libraries'. @@ -153,7 +153,7 @@ return $path; } } - + return false; } @@ -408,7 +408,7 @@ // autoloader->getNamespace($prefix) returns an array of paths for that namespace foreach ($this->autoloader->getNamespace($prefix) as $namespacePath) { - $fullPath = realpath($namespacePath . $path); + $fullPath = realpath(rtrim($namespacePath, '/') . '/' . $path); if (! is_dir($fullPath)) { diff --git a/system/CLI/CommandRunner.php b/system/CLI/CommandRunner.php index 00c7874..2d81124 100644 --- a/system/CLI/CommandRunner.php +++ b/system/CLI/CommandRunner.php @@ -71,6 +71,7 @@ * @param string $method * @param array ...$params * + * @return mixed * @throws \ReflectionException */ public function _remap($method, ...$params) @@ -81,7 +82,7 @@ array_shift($params); } - $this->index($params); + return $this->index($params); } //-------------------------------------------------------------------- diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php index 974dc82..9a07cff 100644 --- a/system/Cache/Handlers/MemcachedHandler.php +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -138,7 +138,7 @@ // $stats should be an associate array with a key in the format of host:port. // If it doesn't have the key, we know the server is not working as expected. - if( !isset($stats[$this->config['host']. ':' .$this->config['port']]) ) + if (! isset($stats[$this->config['host'] . ':' . $this->config['port']])) { throw new CriticalError('Cache: Memcached connection failed.'); } @@ -154,7 +154,8 @@ ); // If we can't connect, throw a CriticalError exception - if($can_connect == false){ + if ($can_connect === false) + { throw new CriticalError('Cache: Memcache connection failed.'); } diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 14a260b..54cb02d 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -246,7 +246,7 @@ { $key = $this->prefix . $key; - return ($this->redis->delete($key) === 1); + return ($this->redis->del($key) === 1); } //-------------------------------------------------------------------- diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 4e694c6..374a4bc 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -65,7 +65,7 @@ /** * The current version of CodeIgniter Framework */ - const CI_VERSION = '4.0.0-beta.4'; + const CI_VERSION = '4.0.0-rc.1'; /** * App startup time. @@ -355,6 +355,12 @@ else { $response = $this->response; + + // Set response code for CLI command failures + if (is_numeric($returned) || $returned === false) + { + $response->setStatusCode(400); + } } if ($response instanceof Response) @@ -968,6 +974,17 @@ */ public function storePreviousURL($uri) { + // Ignore CLI requests + if (is_cli()) + { + return; + } + // Ignore AJAX requests + if (method_exists($this->request, 'isAJAX') && $this->request->isAJAX()) + { + return; + } + // This is mainly needed during testing... if (is_string($uri)) { diff --git a/system/Commands/Database/CreateMigration.php b/system/Commands/Database/CreateMigration.php index cc00665..a674a39 100644 --- a/system/Commands/Database/CreateMigration.php +++ b/system/Commands/Database/CreateMigration.php @@ -104,6 +104,7 @@ */ public function run(array $params = []) { + helper('inflector'); $name = array_shift($params); if (empty($name)) @@ -116,12 +117,13 @@ CLI::error(lang('Migrations.badCreateName')); return; } + $ns = $params['-n'] ?? CLI::getOption('n'); $homepath = APPPATH; if (! empty($ns)) { - // Get all namespaces form PSR4 paths. + // Get all namespaces from PSR4 paths. $config = new Autoload(); $namespaces = $config->psr4; @@ -139,43 +141,22 @@ $ns = 'App'; } - // Migrations Config - $config = new Migrations(); - - if ($config->type !== 'timestamp' && $config->type !== 'sequential') - { - CLI::error(lang('Migrations.invalidType', [$config->type])); - return; - } - - // migration Type - if ($config->type === 'timestamp') - { - $fileName = date('YmdHis_') . $name; - } - else if ($config->type === 'sequential') - { - // default with 001 - $sequence = $params[0] ?? '001'; - // number must be three digits - if (! is_numeric($sequence) || strlen($sequence) !== 3) - { - CLI::error(lang('Migrations.migNumberError')); - return; - } - - $fileName = $sequence . '_' . $name; - } + // Always use UTC/GMT so global teams can work together + $config = config('Migrations'); + $fileName = gmdate($config->timestampFormat) . $name; // full path $path = $homepath . '/Database/Migrations/' . $fileName . '.php'; + // Class name should be pascal case now (camel case with upper first letter) + $name = pascalize($name); + $template = << 'Set migration namespace', + '-g' => 'Set database group', + '-all' => 'Set for all namespaces, will ignore (-n) option', + ]; + + /** + * Ensures that all migrations have been run. + * + * @param array $params + */ + public function run(array $params = []) + { + $runner = Services::migrations(); + $runner->clearCliMessages(); + + CLI::write(lang('Migrations.latest'), 'yellow'); + + $namespace = $params['-n'] ?? CLI::getOption('n'); + $group = $params['-g'] ?? CLI::getOption('g'); + + try + { + // Check for 'all' namespaces + if ($this->isAllNamespace($params)) + { + $runner->setNamespace(null); + } + // Check for a specified namespace + elseif ($namespace) + { + $runner->setNamespace($namespace); + } + + if (! $runner->latest($group)) + { + CLI::write(lang('Migrations.generalFault'), 'red'); + } + + $messages = $runner->getCliMessages(); + foreach ($messages as $message) + { + CLI::write($message); + } + + CLI::write('Done'); + } + catch (\Exception $e) + { + $this->showError($e); + } + } + + /** + * To migrate all namespaces to the latest migration + * + * Demo: + * 1. command line: php spark migrate:latest -all + * 2. command file: $this->call('migrate:latest', ['-g' => 'test','-all']); + * + * @param array $params + * @return boolean + */ + private function isAllNamespace(array $params): bool + { + if (array_search('-all', $params) !== false) + { + return true; + } + + return ! is_null(CLI::getOption('all')); + } + +} diff --git a/system/Commands/Database/MigrateCurrent.php b/system/Commands/Database/MigrateCurrent.php deleted file mode 100644 index 2ca0fb6..0000000 --- a/system/Commands/Database/MigrateCurrent.php +++ /dev/null @@ -1,128 +0,0 @@ - 'Set database group', - ]; - - /** - * Migrates us up or down to the version specified as $currentVersion - * in the migrations config file. - * - * @param array $params - */ - public function run(array $params = []) - { - $runner = Services::migrations(); - - CLI::write(lang('Migrations.toVersion'), 'yellow'); - - $group = $params['-g'] ?? CLI::getOption('g'); - try - { - $runner->current($group); - $messages = $runner->getCliMessages(); - foreach ($messages as $message) - { - CLI::write($message); - } - - CLI::write('Done'); - } - catch (\Exception $e) - { - $this->showError($e); - } - } - -} diff --git a/system/Commands/Database/MigrateLatest.php b/system/Commands/Database/MigrateLatest.php deleted file mode 100644 index d04f5ac..0000000 --- a/system/Commands/Database/MigrateLatest.php +++ /dev/null @@ -1,157 +0,0 @@ - 'Set migration namespace', - '-g' => 'Set database group', - '-all' => 'Set latest for all namespace, will ignore (-n) option', - ]; - - /** - * Ensures that all migrations have been run. - * - * @param array $params - */ - public function run(array $params = []) - { - $runner = Services::migrations(); - - CLI::write(lang('Migrations.toLatest'), 'yellow'); - - $namespace = $params['-n'] ?? CLI::getOption('n'); - $group = $params['-g'] ?? CLI::getOption('g'); - - try - { - if ($this->isAllNamespace($params)) - { - $runner->latestAll($group); - } - else - { - $runner->latest($namespace, $group); - } - $messages = $runner->getCliMessages(); - foreach ($messages as $message) - { - CLI::write($message); - } - - CLI::write('Done'); - } - catch (\Exception $e) - { - $this->showError($e); - } - } - - /** - * To migrate all namespaces to the latest migration - * - * Demo: - * 1. command line: php spark migrate:latest -all - * 2. command file: $this->call('migrate:latest', ['-g' => 'test','-all']); - * - * @param array $params - * @return boolean - */ - private function isAllNamespace(array $params): bool - { - if (array_search('-all', $params) !== false) - { - return true; - } - - return ! is_null(CLI::getOption('all')); - } - -} diff --git a/system/Commands/Database/MigrateRefresh.php b/system/Commands/Database/MigrateRefresh.php index e56d563..4468f16 100644 --- a/system/Commands/Database/MigrateRefresh.php +++ b/system/Commands/Database/MigrateRefresh.php @@ -104,8 +104,8 @@ */ public function run(array $params = []) { - $this->call('migrate:rollback'); - $this->call('migrate:latest'); + $this->call('migrate:rollback', ['-b' => 0]); + $this->call('migrate'); } } diff --git a/system/Commands/Database/MigrateRollback.php b/system/Commands/Database/MigrateRollback.php index 0944ad8..515db07 100644 --- a/system/Commands/Database/MigrateRollback.php +++ b/system/Commands/Database/MigrateRollback.php @@ -72,7 +72,7 @@ * * @var string */ - protected $description = 'Runs all of the migrations in reverse order, until they have all been un-applied.'; + protected $description = 'Runs the "down" method for all migrations in the last batch.'; /** * the Command's usage @@ -94,9 +94,8 @@ * @var array */ protected $options = [ - '-n' => 'Set migration namespace', - '-g' => 'Set database group', - '-all' => 'Set latest for all namespace, will ignore (-n) option', + '-b' => 'Specify a batch to roll back to; e.g. "3" to return to batch #3 or "-2" to roll back twice', + '-g' => 'Set database group', ]; /** @@ -109,37 +108,23 @@ { $runner = Services::migrations(); - CLI::write(lang('Migrations.rollingBack'), 'yellow'); - $group = $params['-g'] ?? CLI::getOption('g'); if (! is_null($group)) { $runner->setGroup($group); } + try { - if (! $this->isAllNamespace($params)) + $batch = $params['-b'] ?? CLI::getOption('b') ?? $runner->getLastBatch() - 1; + CLI::write(lang('Migrations.rollingBack') . ' ' . $batch, 'yellow'); + + if (! $runner->regress($batch)) { - $namespace = $params['-n'] ?? CLI::getOption('n'); - $runner->version(0, $namespace); + CLI::write(lang('Migrations.generalFault'), 'red'); } - else - { - // Get all namespaces from PSR4 paths. - $config = new Autoload(); - $namespaces = $config->psr4; - foreach ($namespaces as $namespace => $path) - { - $runner->setNamespace($namespace); - $migrations = $runner->findMigrations(); - if (empty($migrations)) - { - continue; - } - $runner->version(0, $namespace, $group); - } - } + $messages = $runner->getCliMessages(); foreach ($messages as $message) { @@ -153,25 +138,4 @@ $this->showError($e); } } - - /** - * To migrate all namespaces to the latest migration - * - * Demo: - * 1. command line: php spark migrate:latest -all - * 2. command file: $this->call('migrate:latest', ['-g' => 'test','-all']); - * - * @param array $params - * @return boolean - */ - private function isAllNamespace(array $params): bool - { - if (array_search('-all', $params) !== false) - { - return true; - } - - return ! is_null(CLI::getOption('all')); - } - } diff --git a/system/Commands/Database/MigrateStatus.php b/system/Commands/Database/MigrateStatus.php index 147e738..f0bc498 100644 --- a/system/Commands/Database/MigrateStatus.php +++ b/system/Commands/Database/MigrateStatus.php @@ -160,17 +160,18 @@ CLI::write(' ' . str_pad(lang('Migrations.filename'), $max + 4) . lang('Migrations.on'), 'yellow'); - foreach ($migrations as $version => $migration) + foreach ($migrations as $uid => $migration) { $date = ''; foreach ($history as $row) { - if ($row['version'] !== $version) + + if ($runner->getObjectUid($row) !== $uid) { continue; } - $date = date('Y-m-d H:i:s', $row['time']); + $date = date('Y-m-d H:i:s', $row->time); } CLI::write(str_pad(' ' . $migration->name, $max + 6) . ($date ? $date : '---')); } diff --git a/system/Commands/Database/MigrateVersion.php b/system/Commands/Database/MigrateVersion.php deleted file mode 100644 index d781f68..0000000 --- a/system/Commands/Database/MigrateVersion.php +++ /dev/null @@ -1,140 +0,0 @@ - 'The version number to migrate', - ]; - - /** - * the Command's Options - * - * @var array - */ - protected $options = [ - '-n' => 'Set migration namespace', - '-g' => 'Set database group', - ]; - - /** - * Migrates the database up or down to get to the specified version. - * - * @param array $params - */ - public function run(array $params = []) - { - $runner = Services::migrations(); - - // Get the version number - $version = array_shift($params); - - if (is_null($version)) - { - $version = CLI::prompt(lang('Migrations.version')); - } - - if (is_null($version)) - { - CLI::error(lang('Migrations.invalidVersion')); - exit(); - } - - CLI::write(sprintf(lang('Migrations.toVersionPH'), $version), 'yellow'); - - $namespace = $params['-n'] ?? CLI::getOption('n'); - $group = $params['-g'] ?? CLI::getOption('g'); - - try - { - $runner->version($version, $namespace, $group); - CLI::write('Done'); - } - catch (\Exception $e) - { - $this->showError($e); - } - } - -} diff --git a/system/Common.php b/system/Common.php index 213c6dd..fa045d9 100644 --- a/system/Common.php +++ b/system/Common.php @@ -111,7 +111,7 @@ //-------------------------------------------------------------------- -if (! function_exists('db_connnect')) +if (! function_exists('db_connect')) { /** * Grabs a database connection and returns it to the user. @@ -815,7 +815,7 @@ $response->setHeader('Strict-Transport-Security', 'max-age=' . $duration); $response->redirect($uri); $response->sendHeaders(); - + exit(); } } diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index d297eff..5a1c662 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -90,47 +90,7 @@ foreach ($properties as $property) { - if (is_array($this->$property)) - { - foreach ($this->$property as $key => $val) - { - if ($value = $this->getEnvValue("{$property}.{$key}", $prefix, $shortPrefix)) - { - if (! is_null($value)) - { - if ($value === 'false') - { - $value = false; - } - elseif ($value === 'true') - { - $value = true; - } - - $this->$property[$key] = $value; - } - } - } - } - else - { - if (($value = $this->getEnvValue($property, $prefix, $shortPrefix)) !== false) - { - if (! is_null($value)) - { - if ($value === 'false') - { - $value = false; - } - elseif ($value === 'true') - { - $value = true; - } - - $this->$property = is_bool($value) ? $value : trim($value, '\'"'); - } - } - } + $this->initEnvValue($this->$property, $property, $prefix, $shortPrefix); } if (defined('ENVIRONMENT') && ENVIRONMENT !== 'testing') @@ -145,6 +105,49 @@ //-------------------------------------------------------------------- /** + * Initialization an environment-specific configuration setting + * + * @param mixed &$property + * @param string $name + * @param string $prefix + * @param string $shortPrefix + * + * @return mixed + */ + protected function initEnvValue(&$property, string $name, string $prefix, string $shortPrefix) + { + if (is_array($property)) + { + foreach ($property as $key => $val) + { + $this->initEnvValue($property[$key], "{$name}.{$key}", $prefix, $shortPrefix); + } + } + else + { + if (($value = $this->getEnvValue($name, $prefix, $shortPrefix)) !== false) + { + if (! is_null($value)) + { + if ($value === 'false') + { + $value = false; + } + elseif ($value === 'true') + { + $value = true; + } + + $property = is_bool($value) ? $value : trim($value, '\'"'); + } + } + } + return $property; + } + + //-------------------------------------------------------------------- + + /** * Retrieve an environment-specific configuration setting * * @param string $property diff --git a/system/Config/Config.php b/system/Config/Config.php index 1abfd05..ae60dd3 100644 --- a/system/Config/Config.php +++ b/system/Config/Config.php @@ -122,10 +122,10 @@ { return new $name(); } - + $locator = Services::locator(); $file = $locator->locateFile($name, 'Config'); - + if (empty($file)) { // No file found - check if the class was namespaced @@ -134,14 +134,14 @@ // Class was namespaced and locateFile couldn't find it return null; } - + // Check all namespaces $files = $locator->search('Config/' . $name); if (empty($files)) { return null; } - + // Get the first match (prioritizes user and framework) $file = reset($files); } diff --git a/system/Config/Services.php b/system/Config/Services.php index afbf959..109410d 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -1,5 +1,4 @@ setLogger(static::logger(true)); + return $email; + } + + /** + * The Encryption class provides two-way encryption. + * + * @param mixed $config + * @param boolean $getShared + * + * @return EncrypterInterface Encryption handler + */ + public static function encrypter($config = null, $getShared = false) + { + if ($getShared === true) + { + return static::getSharedInstance('encrypter', $config); + } + + if (empty($config)) + { + $config = new \Config\Encryption(); + } + + $encryption = new Encryption($config); + $encrypter = $encryption->initialize($config); + return $encrypter; + } + + //-------------------------------------------------------------------- + + /** * The Exceptions class holds the methods that handle: * * - set_exception_handler @@ -351,13 +403,11 @@ if ($getShared) { return static::getSharedInstance('language', $locale) - ->setLocale($locale); + ->setLocale($locale); } - $locale = ! empty($locale) - ? $locale - : static::request() - ->getLocale(); + $locale = ! empty($locale) ? $locale : static::request() + ->getLocale(); return new Language($locale); } @@ -553,10 +603,10 @@ } return new IncomingRequest( - $config, - new URI(), - 'php://input', - new UserAgent() + $config, + new URI(), + 'php://input', + new UserAgent() ); } @@ -609,7 +659,7 @@ $response = new RedirectResponse($config); $response->setProtocolVersion(static::request() - ->getProtocolVersion()); + ->getProtocolVersion()); return $response; } @@ -876,5 +926,4 @@ } //-------------------------------------------------------------------- - } diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 0306651..ea3488d 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -40,6 +40,7 @@ use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; +use Closure; /** * Class BaseBuilder @@ -384,7 +385,24 @@ //-------------------------------------------------------------------- /** - * SELECT [MAX|MIN|AVG|SUM]() + * Select Count + * + * Generates a SELECT COUNT(field) portion of a query + * + * @param string $select The field + * @param string $alias An alias + * + * @return BaseBuilder + */ + public function selectCount(string $select = '', string $alias = '') + { + return $this->maxMinAvgSum($select, $alias, 'COUNT'); + } + + //-------------------------------------------------------------------- + + /** + * SELECT [MAX|MIN|AVG|SUM|COUNT]() * * @used-by selectMax() * @used-by selectMin() @@ -413,7 +431,7 @@ $type = strtoupper($type); - if (! in_array($type, ['MAX', 'MIN', 'AVG', 'SUM'])) + if (! in_array($type, ['MAX', 'MIN', 'AVG', 'SUM', 'COUNT'])) { throw new DatabaseException('Invalid function type: ' . $type); } @@ -701,10 +719,18 @@ } else { - $k .= $op; + $k .= " $op"; } - $v = " :$bind:"; + if ($v instanceof Closure) + { + $builder = $this->cleanClone(); + $v = '(' . str_replace("\n", ' ', $v($builder)->getCompiledSelect()) . ')'; + } + else + { + $v = " :$bind:"; + } } elseif (! $this->hasOperator($k) && $qb_key !== 'QBHaving') { @@ -733,13 +759,13 @@ * Generates a WHERE field IN('item', 'item') SQL query, * joined with 'AND' if appropriate. * - * @param string $key The field to search - * @param array $values The values searched on - * @param boolean $escape + * @param string $key The field to search + * @param array|string|Closure $values The values searched on, or anonymous function with subquery + * @param boolean $escape * * @return BaseBuilder */ - public function whereIn(string $key = null, array $values = null, bool $escape = null) + public function whereIn(string $key = null, $values = null, bool $escape = null) { return $this->_whereIn($key, $values, false, 'AND ', $escape); } @@ -752,13 +778,13 @@ * Generates a WHERE field IN('item', 'item') SQL query, * joined with 'OR' if appropriate. * - * @param string $key The field to search - * @param array $values The values searched on - * @param boolean $escape + * @param string $key The field to search + * @param array|string|Closure $values The values searched on, or anonymous function with subquery + * @param boolean $escape * * @return BaseBuilder */ - public function orWhereIn(string $key = null, array $values = null, bool $escape = null) + public function orWhereIn(string $key = null, $values = null, bool $escape = null) { return $this->_whereIn($key, $values, false, 'OR ', $escape); } @@ -771,13 +797,13 @@ * Generates a WHERE field NOT IN('item', 'item') SQL query, * joined with 'AND' if appropriate. * - * @param string $key The field to search - * @param array $values The values searched on - * @param boolean $escape + * @param string $key The field to search + * @param array|string|Closure $values The values searched on, or anonymous function with subquery + * @param boolean $escape * * @return BaseBuilder */ - public function whereNotIn(string $key = null, array $values = null, bool $escape = null) + public function whereNotIn(string $key = null, $values = null, bool $escape = null) { return $this->_whereIn($key, $values, true, 'AND ', $escape); } @@ -790,13 +816,13 @@ * Generates a WHERE field NOT IN('item', 'item') SQL query, * joined with 'OR' if appropriate. * - * @param string $key The field to search - * @param array $values The values searched on - * @param boolean $escape + * @param string $key The field to search + * @param array|string|Closure $values The values searched on, or anonymous function with subquery + * @param boolean $escape * * @return BaseBuilder */ - public function orWhereNotIn(string $key = null, array $values = null, bool $escape = null) + public function orWhereNotIn(string $key = null, $values = null, bool $escape = null) { return $this->_whereIn($key, $values, true, 'OR ', $escape); } @@ -811,17 +837,17 @@ * @used-by whereNotIn() * @used-by orWhereNotIn() * - * @param string $key The field to search - * @param array $values The values searched on - * @param boolean $not If the statement would be IN or NOT IN - * @param string $type - * @param boolean $escape + * @param string $key The field to search + * @param array|Closure $values The values searched on, or anonymous function with subquery + * @param boolean $not If the statement would be IN or NOT IN + * @param string $type + * @param boolean $escape * * @return BaseBuilder */ - protected function _whereIn(string $key = null, array $values = null, bool $not = false, string $type = 'AND ', bool $escape = null) + protected function _whereIn(string $key = null, $values = null, bool $not = false, string $type = 'AND ', bool $escape = null) { - if ($key === null || $values === null) + if ($key === null || $values === null || (! is_array($values) && ! ($values instanceof Closure))) { return $this; } @@ -837,17 +863,25 @@ $not = ($not) ? ' NOT' : ''; - $where_in = array_values($values); - $ok = $this->setBind($ok, $where_in, $escape); + if ($values instanceof Closure) + { + $builder = $this->cleanClone(); + $ok = str_replace("\n", ' ', $values($builder)->getCompiledSelect()); + } + else + { + $whereIn = is_array($values) ? array_values($values) : $values; + $ok = $this->setBind($ok, $whereIn, $escape); + } $prefix = empty($this->QBWhere) ? $this->groupGetType('') : $this->groupGetType($type); - $where_in = [ - 'condition' => $prefix . $key . $not . " IN :{$ok}:", + $whereIn = [ + 'condition' => $prefix . $key . $not . ($values instanceof Closure ? " IN ($ok)" : " IN :{$ok}:"), 'escape' => false, ]; - $this->QBWhere[] = $where_in; + $this->QBWhere[] = $whereIn; return $this; } @@ -1293,7 +1327,7 @@ * * @return BaseBuilder */ - public function limit(int $value = null, int $offset = 0) + public function limit(int $value = null, ?int $offset = 0) { if (! is_null($value)) { @@ -1472,7 +1506,7 @@ } $result = $returnSQL - ? $this->getCompiledSelect() + ? $this->getCompiledSelect($reset) : $this->db->query($this->compileSelect(), $this->binds, false); if ($reset === true) @@ -1552,6 +1586,10 @@ $this->QBOrderBy = null; } + // We cannot use a LIMIT when getting the single row COUNT(*) result + $limit = $this->QBLimit; + $this->QBLimit = false; + $sql = ($this->QBDistinct === true) ? $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results" @@ -1575,6 +1613,9 @@ $this->QBOrderBy = $orderBy ?? []; } + // Restore the LIMIT setting + $this->QBLimit = $limit; + $row = (! $result instanceof ResultInterface) ? null : $result->getRow(); @@ -1588,6 +1629,18 @@ } //-------------------------------------------------------------------- + /** + * Get compiled 'where' condition string + * + * Compiles the set conditions and returns the sql statement + * + * @return string + */ + public function getCompiledQBWhere() + { + return $this->QBWhere; + } + //-------------------------------------------------------------------- /** * Get_Where @@ -2640,7 +2693,6 @@ { continue; } - // $matches = array( // 0 => '(test <= foo)', /* the whole thing */ // 1 => '(', /* optional */ @@ -2968,7 +3020,7 @@ ]; } - return preg_match_all('/' . implode('|', $_operators) . '/i', $str, $match) ? ($list ? $match[0] : $match[0][count($match[0]) - 1]) : false; + return preg_match_all('/' . implode('|', $_operators) . '/i', $str, $match) ? ($list ? $match[0] : $match[0][0]) : false; } // -------------------------------------------------------------------- @@ -3013,4 +3065,16 @@ } //-------------------------------------------------------------------- + + /** + * Returns a clone of a Base Builder with reset query builder values. + * + * @return BaseBuilder + */ + protected function cleanClone() + { + return (clone $this)->from([], true)->resetQuery(); + } + + //-------------------------------------------------------------------- } diff --git a/system/Database/MigrationRunner.php b/system/Database/MigrationRunner.php index a371060..76331c8 100644 --- a/system/Database/MigrationRunner.php +++ b/system/Database/MigrationRunner.php @@ -56,13 +56,6 @@ protected $enabled = false; /** - * The type of migrations to use (sequential or timestamp) - * - * @var string - */ - protected $type; - - /** * Name of table to store meta information * * @var string @@ -70,13 +63,6 @@ protected $table; /** - * The version that current() will take us to. - * - * @var integer - */ - protected $currentVersion = 0; - - /** * The Namespace where migrations can be found. * * @var string @@ -102,7 +88,7 @@ * * @var string */ - protected $regex; + protected $regex = '/^\d{4}[_-]?\d{2}[_-]?\d{2}[_-]?\d{6}_(\w+)$/'; /** * The main database connection. Used to store @@ -159,10 +145,8 @@ */ public function __construct(BaseConfig $config, $db = null) { - $this->enabled = $config->enabled ?? false; - $this->type = $config->type ?? 'timestamp'; - $this->table = $config->table ?? 'migrations'; - $this->currentVersion = $config->currentVersion ?? 0; + $this->enabled = $config->enabled ?? false; + $this->table = $config->table ?? 'migrations'; // Default name space is the app namespace $this->namespace = APP_NAMESPACE; @@ -172,14 +156,6 @@ $this->group = $config->defaultGroup; unset($config); - if (! in_array($this->type, ['sequential', 'timestamp'])) - { - throw ConfigException::forInvalidMigrationType($this->type); - } - - // Migration basename regex - $this->regex = ($this->type === 'timestamp') ? '/^\d{14}_(\w+)$/' : '/^\d{3}_(\w+)$/'; - // If no db connection passed in, use // default database group. $this->db = db_connect($db); @@ -188,19 +164,11 @@ //-------------------------------------------------------------------- /** - * Migrate to a schema version + * Locate and run all new migrations * - * Calls each migration step required to get to the schema version of - * choice - * - * @param string $targetVersion Target schema version - * @param string|null $namespace * @param string|null $group - * - * @return mixed Current version string on success, FALSE on failure or no migrations are found - * @throws ConfigException */ - public function version(string $targetVersion, string $namespace = null, string $group = null) + public function latest(string $group = null) { if (! $this->enabled) { @@ -209,172 +177,51 @@ $this->ensureTable(); - // Set Namespace if not null - if (! is_null($namespace)) - { - $this->setNamespace($namespace); - } - // Set database group if not null if (! is_null($group)) { $this->setGroup($group); } - // Sequential versions need adjusting to 3 places so they can be found later. - if ($this->type === 'sequential') - { - $targetVersion = str_pad($targetVersion, 3, '0', STR_PAD_LEFT); - } - + // Locate the migrations $migrations = $this->findMigrations(); + // If nothing was found then we're done if (empty($migrations)) { return true; } - // Get Namespace current version - // Note: We use strings, so that timestamp versions work on 32-bit systems - $currentVersion = $this->getVersion(); - if ($targetVersion > $currentVersion) + // Remove any migrations already in the history + foreach ($this->getHistory($this->group) as $history) { - // Moving Up - $method = 'up'; - ksort($migrations); - } - else - { - // Moving Down, apply in reverse order - $method = 'down'; - krsort($migrations); + unset($migrations[$this->getObjectUid($history)]); } - // Check Migration consistency - $this->checkMigrations($migrations, $method, $targetVersion); + // Start a new batch + $batch = $this->getLastBatch() + 1; - // loop migration for each namespace (module) - $migrationStatus = false; - foreach ($migrations as $version => $migration) + // Run each migration + foreach ($migrations as $migration) { - // Only include migrations within the scope - if (($method === 'up' && $version > $currentVersion && $version <= $targetVersion) || ( $method === 'down' && $version <= $currentVersion && $version > $targetVersion)) + if ($this->migrate('up', $migration)) { - $migrationStatus = false; - include_once $migration->path; - - // Get namespaced class name - $class = $this->namespace . '\Database\Migrations\Migration_' . ($migration->name); - - $this->setName($migration->name); - - // Validate the migration file structure - if (! class_exists($class, false)) - { - throw new \RuntimeException(sprintf(lang('Migrations.classNotFound'), $class)); - } - - // Forcing migration to selected database group - $instance = new $class(\Config\Database::forge($this->group)); - - if (! is_callable([$instance, $method])) - { - throw new \RuntimeException(sprintf(lang('Migrations.missingMethod'), $method)); - } - - $instance->{$method}(); - if ($method === 'up') - { - $this->addHistory($migration->version); - } - elseif ($method === 'down') - { - $this->removeHistory($migration->version); - } - - $migrationStatus = true; + $this->addHistory($migration, $batch); } - } - - return ($migrationStatus) ? $targetVersion : false; - } - - //-------------------------------------------------------------------- - - /** - * Sets the schema to the latest migration - * - * @param string|null $namespace - * @param string|null $group - * - * @return mixed Current version string on success, FALSE on failure - */ - public function latest(string $namespace = null, string $group = null) - { - $this->ensureTable(); - - // Set Namespace if not null - if (! is_null($namespace)) - { - $this->setNamespace($namespace); - } - // Set database group if not null - if (! is_null($group)) - { - $this->setGroup($group); - } - - $migrations = $this->findMigrations(); - - $lastMigration = end($migrations)->version ?? 0; - - // Calculate the last migration step from existing migration - // filenames and proceed to the standard version migration - return $this->version($lastMigration); - } - - //-------------------------------------------------------------------- - - /** - * Sets the schema to the latest migration for all namespaces - * - * @param string|null $group - * - * @return boolean - */ - public function latestAll(string $group = null): bool - { - $this->ensureTable(); - - // Set database group if not null - if (! is_null($group)) - { - $this->setGroup($group); - } - - // Get all namespaces from the autoloader - $namespaces = Services::autoloader()->getNamespace(); - - foreach ($namespaces as $namespace => $paths) - { - $this->setNamespace($namespace); - $migrations = $this->findMigrations(); - - if (empty($migrations)) + // If a migration failed then try to back out what was done + else { - continue; - } + $this->regress(-1); - $lastMigration = end($migrations)->version; - // No New migrations to add - if ($lastMigration === $this->getVersion()) - { - continue; - } + $message = lang('Migrations.generalFault'); - // Calculate the last migration step from existing migration - // filenames and proceed to the standard version migration - $this->version($lastMigration); + if ($this->silent) + { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + return false; + } + throw new \RuntimeException($message); + } } return true; @@ -383,14 +230,141 @@ //-------------------------------------------------------------------- /** - * Sets the (APP_NAMESPACE) schema to $currentVersion in migration config file + * Migrate down to a previous batch * + * Calls each migration step required to get to the provided batch + * + * @param integer $targetBatch Target batch number, or negative for a relative batch, 0 for all * @param string|null $group * - * @return mixed Current version string on success, FALSE on failure or no migrations are found + * @return mixed Current batch number on success, FALSE on failure or no migrations are found + * @throws ConfigException */ - public function current(string $group = null) + public function regress(int $targetBatch = 0, string $group = null) { + if (! $this->enabled) + { + throw ConfigException::forDisabledMigrations(); + } + + // Set database group if not null + if (! is_null($group)) + { + $this->setGroup($group); + } + + $this->ensureTable(); + + // Get all the batches + $batches = $this->getBatches(); + + // Convert a relative batch to its absolute + if ($targetBatch < 0) + { + $targetBatch = $batches[count($batches) - 1 + $targetBatch] ?? 0; + } + + // If the goal was rollback then check if it is done + if (empty($batches) && $targetBatch === 0) + { + return true; + } + + // Make sure $targetBatch is found + if ($targetBatch !== 0 && ! in_array($targetBatch, $batches)) + { + $message = lang('Migrations.batchNotFound') . $targetBatch; + + if ($this->silent) + { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + return false; + } + throw new \RuntimeException($message); + } + + // Get all migrations + $this->namespace = null; + $allMigrations = $this->findMigrations(); + + // Gather migrations down through each batch until reaching the target + $migrations = []; + while ($batch = array_pop($batches)) + { + // Check if reached target + if ($batch <= $targetBatch) + { + break; + } + + // Get the migrations from each history + foreach ($this->getBatchHistory($batch) as $history) + { + // Create a UID from the history to match its migration + $uid = $this->getObjectUid($history); + + // Make sure the migration is still available + if (! isset($allMigrations[$uid])) + { + $message = lang('Migrations.gap') . ' ' . $history->version; + + if ($this->silent) + { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + return false; + } + throw new \RuntimeException($message); + } + + // Add the history and put it on the list + $migration = $allMigrations[$uid]; + $migration->history = $history; + $migrations[] = $migration; + } + } + + // Run each migration + foreach ($migrations as $migration) + { + if ($this->migrate('down', $migration)) + { + $this->removeHistory($migration->history); + } + // If a migration failed then quit so as not to ruin the whole batch + else + { + $message = lang('Migrations.generalFault'); + + if ($this->silent) + { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + return false; + } + throw new \RuntimeException($message); + } + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Migrate a single file regardless of order or batches. + * Method "up" or "down" determined by presence in history. + * NOTE: This is not recommended and provided mostly for testing. + * + * @param string $path Full path to a valid migration file + * @param string $path Namespace of the target migration + * @param string|null $group + */ + public function force(string $path, string $namespace, string $group = null) + { + if (! $this->enabled) + { + throw ConfigException::forDisabledMigrations(); + } + $this->ensureTable(); // Set database group if not null @@ -399,19 +373,105 @@ $this->setGroup($group); } - return $this->version($this->currentVersion); + // Create and validate the migration + $migration = $this->migrationFromFile($path, $namespace); + if (empty($migration)) + { + $message = lang('Migrations.notFound'); + + if ($this->silent) + { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + return false; + } + throw new \RuntimeException($message); + } + + // Check the history for a match + $method = 'up'; + $this->setNamespace($migration->namespace); + foreach ($this->getHistory($this->group) as $history) + { + if ($this->getObjectUid($history) === $migration->uid) + { + $method = 'down'; + $migration->history = $history; + break; + } + } + + // up + if ($method === 'up') + { + // Start a new batch + $batch = $this->getLastBatch() + 1; + + if ($this->migrate('up', $migration)) + { + $this->addHistory($migration, $batch); + return true; + } + } + + // down + elseif ($this->migrate('down', $migration)) + { + $this->removeHistory($migration->history); + return true; + } + + // If it came this far the migration failed + $message = lang('Migrations.generalFault'); + + if ($this->silent) + { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + return false; + } + throw new \RuntimeException($message); } //-------------------------------------------------------------------- /** - * Retrieves list of available migration scripts for one namespace + * Retrieves list of available migration scripts * - * @return array list of migrations as $version for one namespace + * @return array List of all located migrations by their UID */ public function findMigrations(): array { + // If a namespace is set then use it, otherwise load all namespaces from the autoloader + $namespaces = $this->namespace ? [$this->namespace] : array_keys(Services::autoloader()->getNamespace()); + + // Collect the migrations to run by their sortable UID $migrations = []; + foreach ($namespaces as $namespace) + { + foreach ($this->findNamespaceMigrations($namespace) as $migration) + { + $migrations[$migration->uid] = $migration; + } + } + + // Sort migrations ascending by their UID (version) + ksort($migrations); + + return $migrations; + } + + //-------------------------------------------------------------------- + + /** + * Retrieves a list of available migration scripts for one namespace + * + * @param string $namespace The namespace to search for migrations + * + * @return array List of unsorted migrations from the namespace + */ + public function findNamespaceMigrations(string $namespace): array + { + $migrations = []; + $locator = Services::locator(true); // If $this->path contains a valid directory use it. if (! empty($this->path)) @@ -423,136 +483,79 @@ // Otherwise use FileLocator to search files in the subdirectory of the namespace else { - $locator = Services::locator(true); - $files = $locator->listNamespaceFiles($this->namespace, '/Database/Migrations/'); + $files = $locator->listNamespaceFiles($namespace, '/Database/Migrations/'); } // Load all *_*.php files in the migrations path // We can't use glob if we want it to be testable.... foreach ($files as $file) { - if (substr($file, -4) !== '.php') + // Clean up the file path + $file = empty($this->path) ? $file : $this->path . str_replace($this->path, '', $file); + + // Create the migration object from the file and save it + if ($migration = $this->migrationFromFile($file, $namespace)) { - continue; - } - - // Remove the extension - $name = basename($file, '.php'); - - // Filter out non-migration files - if (preg_match($this->regex, $name)) - { - // Create migration object using stdClass - $migration = new \stdClass(); - - // Get migration version number - $migration->version = $this->getMigrationNumber($name); - $migration->name = $this->getMigrationName($name); - $migration->path = ! empty($this->path) && strpos($file, $this->path) !== 0 - ? $this->path . $file - : $file; - - // Add to migrations[version] - $migrations[$migration->version] = $migration; + $migrations[] = $migration; } } - ksort($migrations); - return $migrations; } //-------------------------------------------------------------------- /** - * checks if the list of available migration scripts list are consistent - * if sequential check if no gaps and check if all consistent with migrations table if downgrading - * if timestamp check if consistent with migrations table if downgrading + * Create a migration object from a file path. * - * @param array $migrations - * @param string $method - * @param string $targetVersion + * @param string $path The path to the file + * @param string $path The namespace of the target migration * - * @return boolean + * @return object|false Returns the migration object, or false on failure */ - protected function checkMigrations(array $migrations, string $method, string $targetVersion): bool + protected function migrationFromFile(string $path, string $namespace) { - // Check if no migrations found - if (empty($migrations)) + if (substr($path, -4) !== '.php') { - if ($this->silent) - { - return false; - } - throw new \RuntimeException(lang('Migrations.empty')); + return false; } - // Check if $targetVersion file is found - if ((int)$targetVersion !== 0 && ! array_key_exists($targetVersion, $migrations)) + // Remove the extension + $name = basename($path, '.php'); + + // Filter out non-migration files + if (! preg_match($this->regex, $name)) { - if ($this->silent) - { - return false; - } - throw new \RuntimeException(lang('Migrations.notFound') . $targetVersion); + return false; } - ksort($migrations); + $locator = Services::locator(true); - if ($method === 'down') - { - $history_migrations = $this->getHistory($this->group); - $history_size = count($history_migrations) - 1; - } - // Check for sequence gaps - $loop = 0; - foreach ($migrations as $migration) - { - if ($this->type === 'sequential' && abs($migration->version - $loop) > 1) - { - throw new \RuntimeException(lang('Migrations.gap') . ' ' . $migration->version); - } - // Check if all old migration files are all available to do downgrading - if ($method === 'down') - { - if ($loop <= $history_size && $history_migrations[$loop]['version'] !== $migration->version) - { - throw new \RuntimeException(lang('Migrations.gap') . ' ' . $migration->version); - } - } - $loop ++; - } + // Create migration object using stdClass + $migration = new \stdClass(); - return true; + // Get migration version number + $migration->version = $this->getMigrationNumber($name); + $migration->name = $this->getMigrationName($name); + $migration->path = $path; + $migration->class = $locator->getClassname($path); + $migration->namespace = $namespace; + $migration->uid = $this->getObjectUid($migration); + + return $migration; } //-------------------------------------------------------------------- /** - * Sets the path to the base directory that will be used - * when locating migrations. If left null, the value will - * be chosen from $this->namespace's directory. - * - * @param string|null $path - * - * @return $this - */ - public function setPath(string $path = null) - { - $this->path = $path; - - return $this; - } - - /** * Set namespace. * Allows other scripts to modify on the fly as needed. * - * @param string $namespace + * @param string $namespace or null for "all" * * @return MigrationRunner */ - public function setNamespace(string $namespace) + public function setNamespace(?string $namespace) { $this->namespace = $namespace; @@ -595,33 +598,6 @@ //-------------------------------------------------------------------- /** - * Grabs the full migration history from the database. - * - * @param string $group - * - * @return array - */ - public function getHistory(string $group = 'default'): array - { - $this->ensureTable(); - - $query = $this->db->table($this->table) - ->where('group', $group) - ->where('namespace', $this->namespace) - ->orderBy('version', 'ASC') - ->get(); - - if (! $query) - { - return []; - } - - return $query->getResultArray(); - } - - //-------------------------------------------------------------------- - - /** * If $silent == true, then will not throw exceptions and will * attempt to continue gracefully. * @@ -647,7 +623,9 @@ */ protected function getMigrationNumber(string $migration): string { - return sscanf($migration, '%[0-9]+', $number) ? $number : '0'; + preg_match('/^\d{4}[_-]?\d{2}[_-]?\d{2}[_-]?\d{6}/', $migration, $matches); + + return count($matches) ? $matches[0] : '0'; } //-------------------------------------------------------------------- @@ -670,28 +648,22 @@ //-------------------------------------------------------------------- /** - * Retrieves current schema version + * Uses the non-repeatable portions of a migration or history + * to create a sortable unique key * - * @return string Current migration version + * @param object $migration or $history + * + * @return string */ - protected function getVersion(): string + public function getObjectUid($object): string { - $this->ensureTable(); - - $row = $this->db->table($this->table) - ->select('version') - ->where('group', $this->group) - ->where('namespace', $this->namespace) - ->orderBy('version', 'DESC') - ->get(); - - return $row && ! is_null($row->getRow()) ? $row->getRow()->version : '0'; + return $object->version . $object->class; } //-------------------------------------------------------------------- /** - * Retrieves current schema version + * Retrieves messages formatted for CLI output * * @return array Current migration version */ @@ -703,27 +675,59 @@ //-------------------------------------------------------------------- /** - * Stores the current schema version. + * Clears any CLI messages. * - * @param string $version + * @return MigrationRunner + */ + public function clearCliMessages() + { + $this->cliMessages = []; + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Truncates the history table. * - * @internal param string $migration Migration reached + * @return boolean + */ + public function clearHistory() + { + if ($this->db->tableExists($this->table)) + { + $this->db->table($this->table) + ->truncate(); + } + } + + //-------------------------------------------------------------------- + + /** + * Add a history to the table. + * + * @param object $migration + * @param integer $batch * * @return void */ - protected function addHistory(string $version) + protected function addHistory($migration, int $batch) { $this->db->table($this->table) - ->insert([ - 'version' => $version, - 'name' => $this->name, - 'group' => $this->group, - 'namespace' => $this->namespace, - 'time' => time(), - ]); + ->insert([ + 'version' => $migration->version, + 'class' => $migration->class, + 'group' => $this->group, + 'namespace' => $migration->namespace, + 'time' => time(), + 'batch' => $batch, + ]); + if (is_cli()) { - $this->cliMessages[] = "\t" . CLI::color(lang('Migrations.added'), 'yellow') . "($this->namespace) " . $version . '_' . $this->name; + $this->cliMessages[] = "\t" . CLI::color(lang('Migrations.added'), + 'yellow') . "($migration->namespace) " . $migration->version . '_' . $migration->class; } } @@ -732,25 +736,176 @@ /** * Removes a single history * - * @param string $version + * @param string $version + * * @return void */ - protected function removeHistory(string $version) + protected function removeHistory($history) { - $this->db->table($this->table) - ->where('version', $version) - ->where('group', $this->group) - ->where('namespace', $this->namespace) - ->delete(); + $this->db->table($this->table)->where('id', $history->id)->delete(); + if (is_cli()) { - $this->cliMessages[] = "\t" . CLI::color(lang('Migrations.removed'), 'yellow') . "($this->namespace) " . $version . '_' . $this->name; + $this->cliMessages[] = "\t" . CLI::color(lang('Migrations.removed'), + 'yellow') . "($history->namespace) " . $history->version . '_' . $this->getMigrationName($history->class); } } //-------------------------------------------------------------------- /** + * Grabs the full migration history from the database for a group + * + * @param string $group + * + * @return array + */ + public function getHistory(string $group = 'default'): array + { + $this->ensureTable(); + + $criteria = ['group' => $group]; + + // If a namespace was specified then use it + if ($this->namespace) + { + $criteria['namespace'] = $this->namespace; + } + + $query = $this->db->table($this->table) + ->where($criteria) + ->orderBy('id', 'ASC') + ->get(); + + return $query ? $query->getResultObject() : []; + } + + //-------------------------------------------------------------------- + + /** + * Returns the migration history for a single batch. + * + * @param integer $batch + * + * @return array + */ + public function getBatchHistory(int $batch): array + { + $this->ensureTable(); + + $query = $this->db->table($this->table) + ->where('batch', $batch) + ->orderBy('id', 'asc') + ->get(); + + return $query ? $query->getResultObject() : []; + } + + //-------------------------------------------------------------------- + + /** + * Returns all the batches from the database history in order + * + * @return array + */ + public function getBatches(): array + { + $this->ensureTable(); + + $batches = $this->db->table($this->table) + ->select('batch') + ->distinct() + ->orderBy('batch', 'asc') + ->get() + ->getResultArray(); + + return array_column($batches, 'batch'); + } + + //-------------------------------------------------------------------- + + /** + * Returns the value of the last batch in the database. + * + * @return integer + */ + public function getLastBatch(): int + { + $this->ensureTable(); + + $batch = $this->db->table($this->table) + ->selectMax('batch') + ->get() + ->getResultObject(); + + $batch = is_array($batch) && count($batch) + ? end($batch)->batch + : 0; + + return (int)$batch; + } + + //-------------------------------------------------------------------- + + /** + * Returns the version number of the first migration for a batch. + * Mostly just for tests. + * + * @param integer $batch + * + * @return string + */ + public function getBatchStart(int $batch): string + { + // Convert a relative batch to its absolute + if ($batch < 0) + { + $batches = $this->getBatches(); + $batch = $batches[count($batches) - 1 + $targetBatch] ?? 0; + } + + $migration = $this->db->table($this->table) + ->where('batch', $batch) + ->orderBy('id', 'asc') + ->limit(1) + ->get() + ->getResultObject(); + + return count($migration) ? $migration[0]->version : '0'; + } + + //-------------------------------------------------------------------- + + /** + * Returns the version number of the last migration for a batch. + * Mostly just for tests. + * + * @param integer $batch + * + * @return string + */ + public function getBatchEnd(int $batch): string + { + // Convert a relative batch to its absolute + if ($batch < 0) + { + $batches = $this->getBatches(); + $batch = $batches[count($batches) - 1 + $targetBatch] ?? 0; + } + + $migration = $this->db->table($this->table) + ->where('batch', $batch) + ->orderBy('id', 'desc') + ->limit(1) + ->get() + ->getResultObject(); + + return count($migration) ? $migration[0]->version : 0; + } + + //-------------------------------------------------------------------- + + /** * Ensures that we have created our migrations table * in the database. */ @@ -764,15 +919,20 @@ $forge = \Config\Database::forge($this->db); $forge->addField([ + 'id' => [ + 'type' => 'INTEGER', + 'constraint' => 255, + 'unsigned' => true, + 'auto_increment' => true, + ], 'version' => [ 'type' => 'VARCHAR', 'constraint' => 255, 'null' => false, ], - 'name' => [ - 'type' => 'VARCHAR', - 'constraint' => 255, - 'null' => false, + 'class' => [ + 'type' => 'TEXT', + 'null' => false, ], 'group' => [ 'type' => 'VARCHAR', @@ -789,12 +949,65 @@ 'constraint' => 11, 'null' => false, ], + 'batch' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => false, + ], ]); + $forge->addPrimaryKey('id'); $forge->createTable($this->table, true); $this->tableChecked = true; } - //-------------------------------------------------------------------- + /** + * Handles the actual running of a migration. + * + * @param $direction "up" or "down" + * @param $migration The migration to run + * + * @return boolean + */ + protected function migrate($direction, $migration): bool + { + include_once $migration->path; + + $class = $migration->class; + $this->setName($migration->name); + + // Validate the migration file structure + if (! class_exists($class, false)) + { + $message = sprintf(lang('Migrations.classNotFound'), $class); + + if ($this->silent) + { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + return false; + } + throw new \RuntimeException($message); + } + + // Forcing migration to selected database group + $instance = new $class(\Config\Database::forge($this->group)); + + if (! is_callable([$instance, $direction])) + { + $message = sprintf(lang('Migrations.missingMethod'), $direction); + + if ($this->silent) + { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + return false; + } + throw new \RuntimeException($message); + } + + $instance->{$direction}(); + + return true; + } } diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index 5b1066c..6994684 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -41,6 +41,7 @@ use CodeIgniter\Debug\Toolbar\Collectors\History; use CodeIgniter\Format\JSONFormatter; use CodeIgniter\Format\XMLFormatter; +use CodeIgniter\HTTP\DownloadResponse; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use Config\Services; @@ -325,17 +326,25 @@ /** * Prepare for debugging.. * + * @param RequestInterface $request + * @param ResponseInterface $response * @global type $app * @return type */ - public function prepare() + public function prepare(RequestInterface $request = null, ResponseInterface $response = null) { if (CI_DEBUG && ! is_cli()) { global $app; - $request = Services::request(); - $response = Services::response(); + $request = $request ?? Services::request(); + $response = $response ?? Services::response(); + + // Disable the toolbar for downloads + if ($response instanceof DownloadResponse) + { + return; + } $toolbar = Services::toolbar(config(Toolbar::class)); $stats = $app->getPerformanceStats(); diff --git a/system/Debug/Toolbar/Views/toolbarloader.js.php b/system/Debug/Toolbar/Views/toolbarloader.js.php index 539490b..236ea01 100644 --- a/system/Debug/Toolbar/Views/toolbarloader.js.php +++ b/system/Debug/Toolbar/Views/toolbarloader.js.php @@ -62,9 +62,9 @@ // Track all AJAX requests if (window.ActiveXObject) { - var oldXHR = new ActiveXObject('Microsoft.XMLHTTP'); + var oldXHR = new ActiveXObject('Microsoft.XMLHTTP'); } else { - var oldXHR = window.XMLHttpRequest; + var oldXHR = window.XMLHttpRequest; } function newXHR() { diff --git a/system/Email/Email.php b/system/Email/Email.php new file mode 100644 index 0000000..0c72b95 --- /dev/null +++ b/system/Email/Email.php @@ -0,0 +1,2142 @@ + '1 (Highest)', + 2 => '2 (High)', + 3 => '3 (Normal)', + 4 => '4 (Low)', + 5 => '5 (Lowest)', + ]; + /** + * mbstring.func_overload flag + * + * @var boolean + */ + protected static $func_overload; + /** + * Logger instance to record error messages and awarnings. + * + * @var \PSR\Log\LoggerInterface + */ + protected $logger; + //-------------------------------------------------------------------- + /** + * Constructor - Sets Email Preferences + * + * The constructor can be passed an array of config values + * + * @param array|null $config + */ + public function __construct($config = null) + { + $this->initialize($config); + isset(static::$func_overload) || static::$func_overload = (extension_loaded('mbstring') && ini_get('mbstring.func_overload')); + log_message('info', 'Email Class Initialized'); + } + //-------------------------------------------------------------------- + /** + * Initialize preferences + * + * @param array|\Config\Email $config + * + * @return Email + */ + public function initialize($config) + { + $this->clear(); + if ($config instanceof \Config\Email) + { + $config = get_object_vars($config); + } + foreach (get_class_vars(get_class($this)) as $key => $value) + { + if (property_exists($this, $key) && isset($config[$key])) + { + $method = 'set' . ucfirst($key); + if (method_exists($this, $method)) + { + $this->$method($config[$key]); + } + else + { + $this->$key = $config[$key]; + } + } + } + $this->charset = strtoupper($this->charset); + $this->SMTPAuth = isset($this->SMTPUser[0], $this->SMTPPass[0]); + return $this; + } + //-------------------------------------------------------------------- + /** + * Initialize the Email Data + * + * @param boolean $clearAttachments + * + * @return Email + */ + public function clear($clearAttachments = false) + { + $this->subject = ''; + $this->body = ''; + $this->finalBody = ''; + $this->headerStr = ''; + $this->replyToFlag = false; + $this->recipients = []; + $this->CCArray = []; + $this->BCCArray = []; + $this->headers = []; + $this->debugMessage = []; + $this->setHeader('Date', $this->setDate()); + if ($clearAttachments !== false) + { + $this->attachments = []; + } + return $this; + } + //-------------------------------------------------------------------- + /** + * Set FROM + * + * @param string $from + * @param string $name + * @param string|null $returnPath Return-Path + * + * @return Email + */ + public function setFrom($from, $name = '', $returnPath = null) + { + if (preg_match('/\<(.*)\>/', $from, $match)) + { + $from = $match[1]; + } + if ($this->validate) + { + $this->validateEmail($this->stringToArray($from)); + if ($returnPath) + { + $this->validateEmail($this->stringToArray($returnPath)); + } + } + // prepare the display name + if ($name !== '') + { + // only use Q encoding if there are characters that would require it + if (! preg_match('/[\200-\377]/', $name)) + { + // add slashes for non-printing characters, slashes, and double quotes, and surround it in double quotes + $name = '"' . addcslashes($name, "\0..\37\177'\"\\") . '"'; + } + else + { + $name = $this->prepQEncoding($name); + } + } + $this->setHeader('From', $name . ' <' . $from . '>'); + isset($returnPath) || $returnPath = $from; + $this->setHeader('Return-Path', '<' . $returnPath . '>'); + return $this; + } + //-------------------------------------------------------------------- + /** + * Set Reply-to + * + * @param string $replyto + * @param string $name + * + * @return Email + */ + public function setReplyTo($replyto, $name = '') + { + if (preg_match('/\<(.*)\>/', $replyto, $match)) + { + $replyto = $match[1]; + } + if ($this->validate) + { + $this->validateEmail($this->stringToArray($replyto)); + } + if ($name !== '') + { + // only use Q encoding if there are characters that would require it + if (! preg_match('/[\200-\377]/', $name)) + { + // add slashes for non-printing characters, slashes, and double quotes, and surround it in double quotes + $name = '"' . addcslashes($name, "\0..\37\177'\"\\") . '"'; + } + else + { + $name = $this->prepQEncoding($name); + } + } + $this->setHeader('Reply-To', $name . ' <' . $replyto . '>'); + $this->replyToFlag = true; + return $this; + } + //-------------------------------------------------------------------- + /** + * Set Recipients + * + * @param string $to + * + * @return Email + */ + public function setTo($to) + { + $to = $this->stringToArray($to); + $to = $this->cleanEmail($to); + if ($this->validate) + { + $this->validateEmail($to); + } + if ($this->getProtocol() !== 'mail') + { + $this->setHeader('To', implode(', ', $to)); + } + $this->recipients = $to; + return $this; + } + //-------------------------------------------------------------------- + /** + * Set CC + * + * @param string $cc + * + * @return Email + */ + public function setCC($cc) + { + $cc = $this->cleanEmail($this->stringToArray($cc)); + if ($this->validate) + { + $this->validateEmail($cc); + } + $this->setHeader('Cc', implode(', ', $cc)); + if ($this->getProtocol() === 'smtp') + { + $this->CCArray = $cc; + } + return $this; + } + //-------------------------------------------------------------------- + /** + * Set BCC + * + * @param string $bcc + * @param string $limit + * + * @return Email + */ + public function setBCC($bcc, $limit = '') + { + if ($limit !== '' && is_numeric($limit)) + { + $this->BCCBatchMode = true; + $this->BCCBatchSize = $limit; + } + $bcc = $this->cleanEmail($this->stringToArray($bcc)); + if ($this->validate) + { + $this->validateEmail($bcc); + } + if ($this->getProtocol() === 'smtp' || ($this->BCCBatchMode && count($bcc) > $this->BCCBatchSize)) + { + $this->BCCArray = $bcc; + } + else + { + $this->setHeader('Bcc', implode(', ', $bcc)); + } + return $this; + } + //-------------------------------------------------------------------- + /** + * Set Email Subject + * + * @param string $subject + * + * @return Email + */ + public function setSubject($subject) + { + $subject = $this->prepQEncoding($subject); + $this->setHeader('Subject', $subject); + return $this; + } + //-------------------------------------------------------------------- + /** + * Set Body + * + * @param string $body + * + * @return Email + */ + public function setMessage($body) + { + $this->body = rtrim(str_replace("\r", '', $body)); + return $this; + } + //-------------------------------------------------------------------- + /** + * Assign file attachments + * + * @param string $file Can be local path, URL or buffered content + * @param string $disposition 'attachment' + * @param string|null $newname + * @param string $mime + * + * @return Email + */ + public function attach($file, $disposition = '', $newname = null, $mime = '') + { + if ($mime === '') + { + if (strpos($file, '://') === false && ! is_file($file)) + { + $this->setErrorMessage(lang('Email.attachmentMissing', [$file])); + return false; + } + if (! $fp = @fopen($file, 'rb')) + { + $this->setErrorMessage(lang('Email.attachmentUnreadable', [$file])); + return false; + } + $fileContent = stream_get_contents($fp); + $mime = $this->mimeTypes(pathinfo($file, PATHINFO_EXTENSION)); + fclose($fp); + } + else + { + $fileContent = & $file; // buffered file + } + // declare names on their own, to make phpcbf happy + $namesAttached = [ + $file, + $newname, + ]; + $this->attachments[] = [ + 'name' => $namesAttached, + 'disposition' => empty($disposition) ? 'attachment' : $disposition, + // Can also be 'inline' Not sure if it matters + 'type' => $mime, + 'content' => chunk_split(base64_encode($fileContent)), + 'multipart' => 'mixed', + ]; + return $this; + } + //-------------------------------------------------------------------- + /** + * Set and return attachment Content-ID + * + * Useful for attached inline pictures + * + * @param string $filename + * + * @return string + */ + public function setAttachmentCID($filename) + { + for ($i = 0, $c = count($this->attachments); $i < $c; $i ++) + { + if ($this->attachments[$i]['name'][0] === $filename) + { + $this->attachments[$i]['multipart'] = 'related'; + $this->attachments[$i]['cid'] = uniqid(basename($this->attachments[$i]['name'][0]) . '@', true); + return $this->attachments[$i]['cid']; + } + } + return false; + } + //-------------------------------------------------------------------- + /** + * Add a Header Item + * + * @param string $header + * @param string $value + * + * @return Email + */ + public function setHeader($header, $value) + { + $this->headers[$header] = str_replace(["\n", "\r"], '', $value); + return $this; + } + //-------------------------------------------------------------------- + /** + * Convert a String to an Array + * + * @param string $email + * + * @return array + */ + protected function stringToArray($email) + { + if (! is_array($email)) + { + return (strpos($email, ',') !== false) ? preg_split('/[\s,]/', $email, -1, PREG_SPLIT_NO_EMPTY) : (array) trim($email); + } + return $email; + } + //-------------------------------------------------------------------- + /** + * Set Multipart Value + * + * @param string $str + * + * @return Email + */ + public function setAltMessage($str) + { + $this->altMessage = (string) $str; + return $this; + } + //-------------------------------------------------------------------- + /** + * Set Mailtype + * + * @param string $type + * + * @return Email + */ + public function setMailType($type = 'text') + { + $this->mailType = ($type === 'html') ? 'html' : 'text'; + return $this; + } + //-------------------------------------------------------------------- + /** + * Set Wordwrap + * + * @param boolean $wordWrap + * + * @return Email + */ + public function setWordWrap($wordWrap = true) + { + $this->wordWrap = (bool) $wordWrap; + return $this; + } + //-------------------------------------------------------------------- + /** + * Set Protocol + * + * @param string $protocol + * + * @return Email + */ + public function setProtocol($protocol = 'mail') + { + $this->protocol = in_array($protocol, $this->protocols, true) ? strtolower($protocol) : 'mail'; + return $this; + } + //-------------------------------------------------------------------- + /** + * Set Priority + * + * @param integer $n + * + * @return Email + */ + public function setPriority($n = 3) + { + $this->priority = preg_match('/^[1-5]$/', $n) ? (int) $n : 3; + return $this; + } + //-------------------------------------------------------------------- + /** + * Set Newline Character + * + * @param string $newline + * + * @return Email + */ + public function setNewline($newline = "\n") + { + $this->newline = in_array($newline, ["\n", "\r\n", "\r"]) ? $newline : "\n"; + return $this; + } + //-------------------------------------------------------------------- + /** + * Set CRLF + * + * @param string $CRLF + * + * @return Email + */ + public function setCRLF($CRLF = "\n") + { + $this->CRLF = ($CRLF !== "\n" && $CRLF !== "\r\n" && $CRLF !== "\r") ? "\n" : $CRLF; + return $this; + } + //-------------------------------------------------------------------- + /** + * Get the Message ID + * + * @return string + */ + protected function getMessageID() + { + $from = str_replace(['>', '<'], '', $this->headers['Return-Path']); + return '<' . uniqid('', true) . strstr($from, '@') . '>'; + } + //-------------------------------------------------------------------- + /** + * Get Mail Protocol + * + * @return string + */ + protected function getProtocol() + { + $this->protocol = strtolower($this->protocol); + in_array($this->protocol, $this->protocols, true) || $this->protocol = 'mail'; + return $this->protocol; + } + //-------------------------------------------------------------------- + /** + * Get Mail Encoding + * + * @return string + */ + protected function getEncoding() + { + in_array($this->encoding, $this->bitDepths) || $this->encoding = '8bit'; + foreach ($this->baseCharsets as $charset) + { + if (strpos($this->charset, $charset) === 0) + { + $this->encoding = '7bit'; + break; + } + } + return $this->encoding; + } + //-------------------------------------------------------------------- + /** + * Get content type (text/html/attachment) + * + * @return string + */ + protected function getContentType() + { + if ($this->mailType === 'html') + { + return empty($this->attachments) ? 'html' : 'html-attach'; + } + elseif ($this->mailType === 'text' && ! empty($this->attachments)) + { + return 'plain-attach'; + } + else + { + return 'plain'; + } + } + //-------------------------------------------------------------------- + /** + * Set RFC 822 Date + * + * @return string + */ + protected function setDate() + { + $timezone = date('Z'); + $operator = ($timezone[0] === '-') ? '-' : '+'; + $timezone = abs($timezone); + $timezone = floor($timezone / 3600) * 100 + ($timezone % 3600) / 60; + return sprintf('%s %s%04d', date('D, j M Y H:i:s'), $operator, $timezone); + } + //-------------------------------------------------------------------- + /** + * Mime message + * + * @return string + */ + protected function getMimeMessage() + { + return 'This is a multi-part message in MIME format.' . $this->newline . 'Your email application may not support this format.'; + } + //-------------------------------------------------------------------- + /** + * Validate Email Address + * + * @param string $email + * + * @return boolean + */ + public function validateEmail($email) + { + if (! is_array($email)) + { + $this->setErrorMessage(lang('Email.mustBeArray')); + return false; + } + foreach ($email as $val) + { + if (! $this->isValidEmail($val)) + { + $this->setErrorMessage(lang('Email.invalidAddress', $val)); + return false; + } + } + return true; + } + //-------------------------------------------------------------------- + /** + * Email Validation + * + * @param string $email + * + * @return boolean + */ + public function isValidEmail($email) + { + if (function_exists('idn_to_ascii') && defined('INTL_IDNA_VARIANT_UTS46') && $atpos = strpos($email, '@')) + { + $email = static::substr($email, 0, ++ $atpos) . idn_to_ascii( + static::substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46 + ); + } + return (bool) filter_var($email, FILTER_VALIDATE_EMAIL); + } + //-------------------------------------------------------------------- + /** + * Clean Extended Email Address: Joe Smith + * + * @param string $email + * + * @return string + */ + public function cleanEmail($email) + { + if (! is_array($email)) + { + return preg_match('/\<(.*)\>/', $email, $match) ? $match[1] : $email; + } + $cleanEmail = []; + foreach ($email as $addy) + { + $cleanEmail[] = preg_match('/\<(.*)\>/', $addy, $match) ? $match[1] : $addy; + } + return $cleanEmail; + } + //-------------------------------------------------------------------- + /** + * Build alternative plain text message + * + * Provides the raw message for use in plain-text headers of + * HTML-formatted emails. + * If the user hasn't specified his own alternative message + * it creates one by stripping the HTML + * + * @return string + */ + protected function getAltMessage() + { + if (! empty($this->altMessage)) + { + return ($this->wordWrap) ? $this->wordWrap($this->altMessage, 76) : $this->altMessage; + } + $body = preg_match('/\(.*)\<\/body\>/si', $this->body, $match) ? $match[1] : $this->body; + $body = str_replace("\t", '', preg_replace('#