├── .phpstorm.meta.php ├── LICENSE ├── README.md ├── composer.json └── src ├── App.php ├── Controller.php ├── Debug ├── AppCollection.php ├── AppCollector.php ├── Views │ └── toggle-views-hints.php ├── ViewsCollection.php ├── ViewsCollector.php └── icons │ ├── app.svg │ └── views.svg ├── Entity.php ├── Languages ├── en │ └── validation.php ├── es │ └── validation.php └── pt-br │ └── validation.php ├── Model.php ├── ModelInterface.php ├── Validator.php └── View.php /.phpstorm.meta.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace PHPSTORM_META; 11 | 12 | registerArgumentsSet( 13 | 'service_names', 14 | 'antiCsrf', 15 | 'autoloader', 16 | 'cache', 17 | 'console', 18 | 'database', 19 | 'debugger', 20 | 'exceptionHandler', 21 | 'language', 22 | 'locator', 23 | 'logger', 24 | 'mailer', 25 | 'migrator', 26 | 'request', 27 | 'response', 28 | 'router', 29 | 'session', 30 | 'validation', 31 | 'view', 32 | ); 33 | registerArgumentsSet( 34 | 'rules', 35 | 'exist', 36 | 'notUnique', 37 | 'unique', 38 | ); 39 | expectedArguments( 40 | \Framework\Config\Config::get(), 41 | 0, 42 | argumentsSet('service_names') 43 | ); 44 | expectedArguments( 45 | \Framework\Config\Config::set(), 46 | 0, 47 | argumentsSet('service_names') 48 | ); 49 | expectedArguments( 50 | \Framework\Config\Config::add(), 51 | 0, 52 | argumentsSet('service_names') 53 | ); 54 | expectedArguments( 55 | \Framework\Config\Config::getInstances(), 56 | 0, 57 | argumentsSet('service_names') 58 | ); 59 | expectedArguments( 60 | \Framework\Config\Config::load(), 61 | 0, 62 | argumentsSet('service_names') 63 | ); 64 | expectedArguments( 65 | \Framework\MVC\App::getService(), 66 | 0, 67 | argumentsSet('service_names') 68 | ); 69 | expectedArguments( 70 | \Framework\MVC\App::setService(), 71 | 0, 72 | argumentsSet('service_names') 73 | ); 74 | expectedArguments( 75 | \Framework\MVC\App::removeService(), 76 | 0, 77 | argumentsSet('service_names') 78 | ); 79 | override(\Framework\MVC\App::getService(), map([ 80 | 'antiCsrf' => \Framework\HTTP\AntiCSRF::class, 81 | 'autoloader' => \Framework\Autoload\Autoloader::class, 82 | 'cache' => \Framework\Cache\Cache::class, 83 | 'console' => \Framework\CLI\Console::class, 84 | 'database' => \Framework\Database\Database::class, 85 | 'debugger' => \Framework\Debug\Debugger::class, 86 | 'exceptionHandler' => \Framework\Debug\ExceptionHandler::class, 87 | 'language' => \Framework\Language\Language::class, 88 | 'locator' => \Framework\Autoload\Locator::class, 89 | 'logger' => \Framework\Log\Logger::class, 90 | 'mailer' => \Framework\Email\Mailer::class, 91 | 'migrator' => \Framework\Database\Extra\Migrator::class, 92 | 'request' => \Framework\HTTP\Request::class, 93 | 'response' => \Framework\HTTP\Response::class, 94 | 'router' => \Framework\Routing\Router::class, 95 | 'session' => \Framework\Session\Session::class, 96 | 'validation' => \Framework\Validation\Validation::class, 97 | 'view' => \Framework\MVC\View::class, 98 | ])); 99 | expectedArguments( 100 | \Framework\Validation\Validation::getMessage(), 101 | 1, 102 | argumentsSet('rules') 103 | ); 104 | expectedArguments( 105 | \Framework\Validation\Validation::isRuleAvailable(), 106 | 0, 107 | argumentsSet('rules') 108 | ); 109 | expectedArguments( 110 | \Framework\Validation\Validation::setMessage(), 111 | 1, 112 | argumentsSet('rules') 113 | ); 114 | expectedArguments( 115 | \Framework\Validation\Validation::setRule(), 116 | 1, 117 | argumentsSet('rules') 118 | ); 119 | registerArgumentsSet( 120 | 'order_by_directions', 121 | 'asc', 122 | 'desc', 123 | ); 124 | expectedArguments( 125 | \Framework\MVC\Model::paginate(), 126 | 4, 127 | argumentsSet('order_by_directions') 128 | ); 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Natan Felles 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Aplus Framework MVC Library 2 | 3 | # Aplus Framework MVC Library 4 | 5 | - [Home](https://aplus-framework.com/packages/mvc) 6 | - [User Guide](https://docs.aplus-framework.com/guides/libraries/mvc/index.html) 7 | - [API Documentation](https://docs.aplus-framework.com/packages/mvc.html) 8 | 9 | [![tests](https://github.com/aplus-framework/mvc/actions/workflows/tests.yml/badge.svg)](https://github.com/aplus-framework/mvc/actions/workflows/tests.yml) 10 | [![coverage](https://coveralls.io/repos/github/aplus-framework/mvc/badge.svg?branch=master)](https://coveralls.io/github/aplus-framework/mvc?branch=master) 11 | [![packagist](https://img.shields.io/packagist/v/aplus/mvc)](https://packagist.org/packages/aplus/mvc) 12 | [![open-source](https://img.shields.io/badge/open--source-sponsor-magenta)](https://aplus-framework.com/sponsor) 13 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aplus/mvc", 3 | "description": "Aplus Framework MVC Library", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "app", 8 | "application", 9 | "mvc", 10 | "model", 11 | "view", 12 | "controller", 13 | "entity", 14 | "layout", 15 | "pages", 16 | "api", 17 | "resource", 18 | "presenter", 19 | "rest", 20 | "restful" 21 | ], 22 | "authors": [ 23 | { 24 | "name": "Natan Felles", 25 | "email": "natanfelles@gmail.com", 26 | "homepage": "https://natanfelles.github.io" 27 | } 28 | ], 29 | "homepage": "https://aplus-framework.com/packages/mvc", 30 | "support": { 31 | "email": "support@aplus-framework.com", 32 | "issues": "https://github.com/aplus-framework/mvc/issues", 33 | "forum": "https://aplus-framework.com/forum", 34 | "source": "https://github.com/aplus-framework/mvc", 35 | "docs": "https://docs.aplus-framework.com/guides/libraries/mvc/" 36 | }, 37 | "funding": [ 38 | { 39 | "type": "Aplus Sponsor", 40 | "url": "https://aplus-framework.com/sponsor" 41 | } 42 | ], 43 | "require": { 44 | "php": ">=8.3", 45 | "ext-json": "*", 46 | "ext-mysqli": "*", 47 | "aplus/autoload": "^3.2", 48 | "aplus/cache": "^4.2", 49 | "aplus/cli": "^3.1", 50 | "aplus/config": "^4.1", 51 | "aplus/database": "^4.2", 52 | "aplus/database-extra": "^4.0", 53 | "aplus/date": "^3.0", 54 | "aplus/debug": "^4.3", 55 | "aplus/email": "^4.1", 56 | "aplus/http": "^6.4", 57 | "aplus/language": "^4.1", 58 | "aplus/log": "^4.2", 59 | "aplus/pagination": "^4.0", 60 | "aplus/routing": "^4.0", 61 | "aplus/session": "^4.0", 62 | "aplus/validation": "^3.2" 63 | }, 64 | "require-dev": { 65 | "ext-xdebug": "*", 66 | "aplus/coding-standard": "^2.8", 67 | "ergebnis/composer-normalize": "^2.45", 68 | "jetbrains/phpstorm-attributes": "^1.2", 69 | "phpmd/phpmd": "^2.15", 70 | "phpstan/phpstan": "^1.12", 71 | "phpunit/phpunit": "^10.5" 72 | }, 73 | "minimum-stability": "dev", 74 | "prefer-stable": true, 75 | "autoload": { 76 | "psr-4": { 77 | "Framework\\MVC\\": "src/" 78 | } 79 | }, 80 | "autoload-dev": { 81 | "psr-4": { 82 | "Tests\\MVC\\": "tests/" 83 | } 84 | }, 85 | "config": { 86 | "allow-plugins": { 87 | "ergebnis/composer-normalize": true 88 | }, 89 | "optimize-autoloader": true, 90 | "preferred-install": "dist", 91 | "sort-packages": true 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/App.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\MVC; 11 | 12 | use Framework\Autoload\Autoloader; 13 | use Framework\Autoload\Debug\AutoloadCollection; 14 | use Framework\Autoload\Locator; 15 | use Framework\Cache\Cache; 16 | use Framework\Cache\Debug\CacheCollection; 17 | use Framework\Cache\Debug\CacheCollector; 18 | use Framework\Cache\Serializer; 19 | use Framework\CLI\Command; 20 | use Framework\CLI\Console; 21 | use Framework\Config\Config; 22 | use Framework\Config\Debug\ConfigCollection; 23 | use Framework\Config\Debug\ConfigCollector; 24 | use Framework\Database\Database; 25 | use Framework\Database\Debug\DatabaseCollection; 26 | use Framework\Database\Debug\DatabaseCollector; 27 | use Framework\Database\Extra\Migrator; 28 | use Framework\Debug\Debugger; 29 | use Framework\Debug\ExceptionHandler; 30 | use Framework\Email\Debug\EmailCollection; 31 | use Framework\Email\Debug\EmailCollector; 32 | use Framework\Email\Mailer; 33 | use Framework\Helpers\Isolation; 34 | use Framework\HTTP\AntiCSRF; 35 | use Framework\HTTP\CSP; 36 | use Framework\HTTP\Debug\HTTPCollection; 37 | use Framework\HTTP\Debug\HTTPCollector; 38 | use Framework\HTTP\Request; 39 | use Framework\HTTP\Response; 40 | use Framework\Language\Debug\LanguageCollection; 41 | use Framework\Language\Debug\LanguageCollector; 42 | use Framework\Language\FallbackLevel; 43 | use Framework\Language\Language; 44 | use Framework\Log\Debug\LogCollection; 45 | use Framework\Log\Debug\LogCollector; 46 | use Framework\Log\Logger; 47 | use Framework\Log\Loggers\MultiFileLogger; 48 | use Framework\Log\LogLevel; 49 | use Framework\MVC\Debug\AppCollection; 50 | use Framework\MVC\Debug\AppCollector; 51 | use Framework\MVC\Debug\ViewsCollection; 52 | use Framework\MVC\Debug\ViewsCollector; 53 | use Framework\Routing\Debug\RoutingCollection; 54 | use Framework\Routing\Debug\RoutingCollector; 55 | use Framework\Routing\Router; 56 | use Framework\Session\Debug\SessionCollection; 57 | use Framework\Session\Debug\SessionCollector; 58 | use Framework\Session\SaveHandlers\DatabaseHandler; 59 | use Framework\Session\Session; 60 | use Framework\Validation\Debug\ValidationCollection; 61 | use Framework\Validation\Debug\ValidationCollector; 62 | use Framework\Validation\FilesValidator; 63 | use Framework\Validation\Validation; 64 | use LogicException; 65 | use ReflectionClass; 66 | use ReflectionException; 67 | use SensitiveParameter; 68 | 69 | /** 70 | * Class App. 71 | * 72 | * @package mvc 73 | */ 74 | class App 75 | { 76 | /** 77 | * Array with keys with names of services and their values have arrays where 78 | * the keys are the names of the instances and the values are the objects. 79 | * 80 | * @var array> 81 | */ 82 | protected static array $services = []; 83 | /** 84 | * Tells if the App is running. 85 | * 86 | * @var bool 87 | */ 88 | protected static bool $isRunning = false; 89 | /** 90 | * The Config instance. 91 | * 92 | * @var Config|null 93 | */ 94 | protected static ?Config $config; 95 | /** 96 | * Tells if the request is by command line. Updating directly makes it 97 | * possible to run tests simulating HTTP or CLI. 98 | * 99 | * @var bool|null 100 | */ 101 | protected static ?bool $isCli = null; 102 | /** 103 | * The App collector instance that is set when in debug mode. 104 | * 105 | * @var AppCollector 106 | */ 107 | protected static AppCollector $debugCollector; 108 | /** 109 | * Variables set in the $_SERVER super-global in command-line requests. 110 | * 111 | * @var array 112 | */ 113 | protected static array $defaultServerVars = [ 114 | 'REMOTE_ADDR' => '127.0.0.1', 115 | 'REQUEST_METHOD' => 'GET', 116 | 'REQUEST_URI' => '/', 117 | 'SERVER_PROTOCOL' => 'HTTP/1.1', 118 | 'HTTP_HOST' => 'localhost', 119 | ]; 120 | 121 | /** 122 | * Initialize the App. 123 | * 124 | * @param Config|array|string|null $config The config 125 | * @param bool $debug Set true to enable debug mode. False to disable. 126 | */ 127 | public function __construct( 128 | #[SensitiveParameter] 129 | Config | array | string | null $config = null, 130 | bool $debug = false 131 | ) { 132 | if ($debug) { 133 | $this->debugStart(); 134 | } 135 | if (isset(static::$config)) { 136 | throw new LogicException('App already initialized'); 137 | } 138 | if (!$config instanceof Config) { 139 | $config = new Config($config); 140 | } 141 | static::$config = $config; 142 | if ($debug) { 143 | $collection = new AppCollection('App'); 144 | $collection->addCollector(static::$debugCollector); 145 | static::debugger()->addCollection($collection); 146 | $configCollector = new ConfigCollector(); 147 | static::$config->setDebugCollector($configCollector); 148 | $configCollection = new ConfigCollection('Config'); 149 | $configCollection->addCollector($configCollector); 150 | static::debugger()->addCollection($configCollection); 151 | } 152 | } 153 | 154 | /** 155 | * Start debugging the App. 156 | */ 157 | protected function debugStart() : void 158 | { 159 | static::$debugCollector = new AppCollector(); 160 | static::$debugCollector->setStartTime()->setStartMemory(); 161 | static::$debugCollector->setApp($this); 162 | } 163 | 164 | /** 165 | * Load service configs catching exceptions. 166 | * 167 | * @param string $name The service name 168 | * 169 | * @return array>|null The service configs or null 170 | */ 171 | protected function loadConfigs(string $name) : ?array 172 | { 173 | $config = static::config(); 174 | try { 175 | $config->load($name); 176 | } catch (LogicException) { 177 | } 178 | return $config->getInstances($name); 179 | } 180 | 181 | /** 182 | * Make sure to load the autoloader service if its default config is set. 183 | */ 184 | protected function loadAutoloader() : void 185 | { 186 | $config = static::config(); 187 | $autoloaderConfigs = $config->getInstances('autoloader'); 188 | if ($config->getDir() !== null) { 189 | $autoloaderConfigs ??= $this->loadConfigs('autoloader'); 190 | } 191 | if (isset($autoloaderConfigs['default'])) { 192 | static::autoloader(); 193 | } 194 | } 195 | 196 | /** 197 | * Make sure to load the exceptionHandler service if its default config is set. 198 | */ 199 | protected function loadExceptionHandler() : void 200 | { 201 | $config = static::config(); 202 | $exceptionHandlerConfigs = $config->getInstances('exceptionHandler'); 203 | if ($config->getDir() !== null) { 204 | $exceptionHandlerConfigs ??= $this->loadConfigs('exceptionHandler'); 205 | } 206 | if (!isset($exceptionHandlerConfigs['default'])) { 207 | $environment = static::isDebugging() 208 | ? ExceptionHandler::DEVELOPMENT 209 | : ExceptionHandler::PRODUCTION; 210 | $config->set('exceptionHandler', [ 211 | 'environment' => $environment, 212 | ]); 213 | $exceptionHandlerConfigs = $config->getInstances('exceptionHandler'); 214 | } 215 | if (isset($exceptionHandlerConfigs['default'])) { 216 | static::exceptionHandler(); 217 | } 218 | } 219 | 220 | /** 221 | * Prepare the App to run via CLI or HTTP. 222 | */ 223 | protected function prepareToRun() : void 224 | { 225 | if (static::$isRunning) { 226 | throw new LogicException('App is already running'); 227 | } 228 | static::$isRunning = true; 229 | $this->loadAutoloader(); 230 | $this->loadExceptionHandler(); 231 | } 232 | 233 | /** 234 | * Run the App on HTTP requests. 235 | */ 236 | public function runHttp() : void 237 | { 238 | $this->prepareToRun(); 239 | $router = static::router(); 240 | $response = $router->getResponse(); 241 | $router->match() 242 | ->run($response->getRequest(), $response) 243 | ->send(); 244 | if (static::isDebugging()) { 245 | $this->debugEnd(); 246 | } 247 | } 248 | 249 | /** 250 | * Ends the debugging of the App and prints the debugbar if there is no 251 | * download file, if the request is not via AJAX and the Content-Type is 252 | * text/html. 253 | */ 254 | protected function debugEnd() : void 255 | { 256 | static::$debugCollector->setEndTime()->setEndMemory(); 257 | $response = static::router()->getResponse(); 258 | if (!$response->hasDownload() 259 | && !$response->getRequest()->isAjax() 260 | && \str_contains( 261 | (string) $response->getHeader('Content-Type'), 262 | 'text/html' 263 | ) 264 | ) { 265 | echo static::debugger()->renderDebugbar(); 266 | } 267 | } 268 | 269 | /** 270 | * Detects if the request is via command-line and runs as a CLI request, 271 | * otherwise runs as HTTP. 272 | */ 273 | public function run() : void 274 | { 275 | static::isCli() ? $this->runCli() : $this->runHttp(); 276 | } 277 | 278 | /** 279 | * Run the App on CLI requests. 280 | */ 281 | public function runCli() : void 282 | { 283 | $this->prepareToRun(); 284 | static::console()->run(); 285 | } 286 | 287 | /** 288 | * Get the Config instance. 289 | * 290 | * @return Config 291 | */ 292 | public static function config() : Config 293 | { 294 | return static::$config; 295 | } 296 | 297 | /** 298 | * Get a service. 299 | * 300 | * @param string $name Service name 301 | * @param string $instance Service instance name 302 | * 303 | * @return object|null The service object or null 304 | */ 305 | public static function getService(string $name, string $instance = 'default') : ?object 306 | { 307 | return static::$services[$name][$instance] ?? null; 308 | } 309 | 310 | /** 311 | * Set a service. 312 | * 313 | * @template T of object 314 | * 315 | * @param string $name Service name 316 | * @param T $service Service object 317 | * @param string $instance Service instance name 318 | * 319 | * @return T The service object 320 | */ 321 | public static function setService( 322 | string $name, 323 | object $service, 324 | string $instance = 'default' 325 | ) : object { 326 | return static::$services[$name][$instance] = $service; 327 | } 328 | 329 | /** 330 | * Remove services. 331 | * 332 | * @param string $name Service name 333 | * @param string|null $instance Service instance name or null to remove all instances 334 | */ 335 | public static function removeService(string $name, ?string $instance = 'default') : void 336 | { 337 | if ($instance === null) { 338 | unset(static::$services[$name]); 339 | return; 340 | } 341 | unset(static::$services[$name][$instance]); 342 | } 343 | 344 | /** 345 | * Get a autoloader service. 346 | * 347 | * @param string $instance The autoloader instance name 348 | * 349 | * @return Autoloader 350 | */ 351 | public static function autoloader(string $instance = 'default') : Autoloader 352 | { 353 | $service = static::getService('autoloader', $instance); 354 | if ($service) { 355 | return $service; // @phpstan-ignore-line 356 | } 357 | if (static::isDebugging()) { 358 | $start = \microtime(true); 359 | $service = static::setAutoloader($instance); 360 | $end = \microtime(true); 361 | $service->setDebugCollector(name: $instance); 362 | $collection = static::debugger()->getCollection('Autoload') 363 | ?? new AutoloadCollection('Autoload'); 364 | $collection->addCollector($service->getDebugCollector()); 365 | static::debugger()->addCollection($collection); 366 | static::addDebugData('autoloader', $instance, $start, $end); 367 | return $service; 368 | } 369 | return static::setAutoloader($instance); 370 | } 371 | 372 | /** 373 | * Set a autoloader service. 374 | * 375 | * @param string $instance The autoloader instance name 376 | * 377 | * @return Autoloader 378 | */ 379 | protected static function setAutoloader(string $instance) : Autoloader 380 | { 381 | $config = static::config()->get('autoloader', $instance); 382 | $service = new Autoloader($config['register'] ?? true, $config['extensions'] ?? '.php'); 383 | if (isset($config['namespaces'])) { 384 | $service->setNamespaces($config['namespaces']); 385 | } 386 | if (isset($config['classes'])) { 387 | $service->setClasses($config['classes']); 388 | } 389 | return static::setService('autoloader', $service, $instance); 390 | } 391 | 392 | /** 393 | * Get a cache service. 394 | * 395 | * @param string $instance The cache instance name 396 | * 397 | * @return Cache 398 | */ 399 | public static function cache(string $instance = 'default') : Cache 400 | { 401 | $service = static::getService('cache', $instance); 402 | if ($service) { 403 | return $service; // @phpstan-ignore-line 404 | } 405 | if (static::isDebugging()) { 406 | $start = \microtime(true); 407 | $service = static::setCache($instance); 408 | $end = \microtime(true); 409 | $collector = new CacheCollector($instance); 410 | $service->setDebugCollector($collector); 411 | $collection = static::debugger()->getCollection('Cache') 412 | ?? new CacheCollection('Cache'); 413 | $collection->addCollector($collector); 414 | static::debugger()->addCollection($collection); 415 | static::addDebugData('cache', $instance, $start, $end); 416 | return $service; 417 | } 418 | return static::setCache($instance); 419 | } 420 | 421 | /** 422 | * Set a cache service. 423 | * 424 | * @param string $instance The cache instance name 425 | * 426 | * @return Cache 427 | */ 428 | protected static function setCache(string $instance) : Cache 429 | { 430 | $config = static::config()->get('cache', $instance); 431 | $logger = null; 432 | if (isset($config['logger_instance'])) { 433 | $logger = static::logger($config['logger_instance']); 434 | } 435 | $config['serializer'] ??= Serializer::PHP; 436 | if (\is_string($config['serializer'])) { 437 | $config['serializer'] = Serializer::from($config['serializer']); 438 | } 439 | /** 440 | * @var Cache $service 441 | */ 442 | $service = new $config['class']( 443 | $config['configs'] ?? [], 444 | $config['prefix'] ?? null, 445 | $config['serializer'], 446 | $logger 447 | ); 448 | if (isset($config['default_ttl'])) { 449 | $service->setDefaultTtl($config['default_ttl']); 450 | } 451 | return static::setService('cache', $service, $instance); 452 | } 453 | 454 | /** 455 | * Get a console service. 456 | * 457 | * @param string $instance The console instance name 458 | * 459 | * @throws ReflectionException 460 | * 461 | * @return Console 462 | */ 463 | public static function console(string $instance = 'default') : Console 464 | { 465 | $service = static::getService('console', $instance); 466 | if ($service) { 467 | return $service; // @phpstan-ignore-line 468 | } 469 | if (static::isDebugging()) { 470 | $start = \microtime(true); 471 | $service = static::setConsole($instance); 472 | $end = \microtime(true); 473 | static::addDebugData('console', $instance, $start, $end); 474 | return $service; 475 | } 476 | return static::setConsole($instance); 477 | } 478 | 479 | /** 480 | * Set a console service. 481 | * 482 | * @param string $instance The console instance name 483 | * 484 | * @throws ReflectionException 485 | * 486 | * @return Console 487 | */ 488 | protected static function setConsole(string $instance) : Console 489 | { 490 | $config = static::config()->get('console', $instance); 491 | $language = null; 492 | if (isset($config['language_instance'])) { 493 | $language = static::language($config['language_instance']); 494 | } 495 | $service = new Console($language); 496 | $locator = static::locator($config['locator_instance'] ?? 'default'); 497 | if (isset($config['find_in_namespaces']) && $config['find_in_namespaces'] === true) { 498 | foreach ($locator->getFiles('Commands') as $file) { 499 | static::addCommand($file, $service, $locator); 500 | } 501 | } 502 | if (isset($config['directories'])) { 503 | foreach ($config['directories'] as $dir) { 504 | foreach ((array) $locator->listFiles($dir) as $file) { 505 | static::addCommand($file, $service, $locator); 506 | } 507 | } 508 | } 509 | return static::setService('console', $service, $instance); 510 | } 511 | 512 | /** 513 | * Detects if the file has a command and adds it to the console. 514 | * 515 | * @param string $file The file to get the command class 516 | * @param Console $console The console to add the class 517 | * @param Locator $locator The locator to get the class name in the file 518 | * 519 | * @throws ReflectionException 520 | * 521 | * @return bool True if the command was added. If not, it's false. 522 | */ 523 | protected static function addCommand(string $file, Console $console, Locator $locator) : bool 524 | { 525 | $className = $locator->getClassName($file); 526 | if ($className === null) { 527 | return false; 528 | } 529 | if (!\class_exists($className)) { 530 | Isolation::require($file); 531 | } 532 | $class = new ReflectionClass($className); // @phpstan-ignore-line 533 | if ($class->isInstantiable() && $class->isSubclassOf(Command::class)) { 534 | $console->addCommand($className); // @phpstan-ignore-line 535 | return true; 536 | } 537 | return false; 538 | } 539 | 540 | /** 541 | * Get a debugger service. 542 | * 543 | * @param string $instance The debugger instance name 544 | * 545 | * @return Debugger 546 | */ 547 | public static function debugger(string $instance = 'default') : Debugger 548 | { 549 | $service = static::getService('debugger', $instance); 550 | if ($service) { 551 | return $service; // @phpstan-ignore-line 552 | } 553 | if (static::isDebugging()) { 554 | $start = \microtime(true); 555 | $service = static::setDebugger($instance); 556 | $end = \microtime(true); 557 | static::addDebugData('debugger', $instance, $start, $end); 558 | return $service; 559 | } 560 | return static::setDebugger($instance); 561 | } 562 | 563 | /** 564 | * Set a debugger service. 565 | * 566 | * @param string $instance The debugger instance name 567 | * 568 | * @return Debugger 569 | */ 570 | protected static function setDebugger(string $instance) : Debugger 571 | { 572 | $config = static::config()->get('debugger', $instance); 573 | $service = new Debugger(); 574 | if (isset($config['debugbar_view'])) { 575 | $service->setDebugbarView($config['debugbar_view']); 576 | } 577 | if (isset($config['options'])) { 578 | $service->setOptions($config['options']); 579 | } 580 | return static::setService('debugger', $service, $instance); 581 | } 582 | 583 | /** 584 | * Get a exceptionHandler service. 585 | * 586 | * @param string $instance The exceptionHandler instance name 587 | * 588 | * @return ExceptionHandler 589 | */ 590 | public static function exceptionHandler(string $instance = 'default') : ExceptionHandler 591 | { 592 | $service = static::getService('exceptionHandler', $instance); 593 | if ($service) { 594 | return $service; // @phpstan-ignore-line 595 | } 596 | if (static::isDebugging()) { 597 | $start = \microtime(true); 598 | $service = static::setExceptionHandler($instance); 599 | $end = \microtime(true); 600 | static::addDebugData('exceptionHandler', $instance, $start, $end); 601 | return $service; 602 | } 603 | return static::setExceptionHandler($instance); 604 | } 605 | 606 | /** 607 | * Set a exceptionHandler service. 608 | * 609 | * @param string $instance The exceptionHandler instance name 610 | * 611 | * @return ExceptionHandler 612 | */ 613 | protected static function setExceptionHandler(string $instance) : ExceptionHandler 614 | { 615 | $config = static::config()->get('exceptionHandler', $instance); 616 | $environment = $config['environment'] ?? ExceptionHandler::PRODUCTION; 617 | $logger = null; 618 | if (isset($config['logger_instance'])) { 619 | $logger = static::logger($config['logger_instance']); 620 | } 621 | $language = null; 622 | if (isset($config['language_instance'])) { 623 | $language = static::language($config['language_instance']); 624 | } 625 | $service = new ExceptionHandler($environment, $logger, $language); 626 | if (isset($config['development_view'])) { 627 | $service->setDevelopmentView($config['development_view']); 628 | } 629 | if (isset($config['production_view'])) { 630 | $service->setProductionView($config['production_view']); 631 | } 632 | $config['initialize'] ??= true; 633 | if ($config['initialize'] === true) { 634 | $service->initialize($config['handle_errors'] ?? true); 635 | } 636 | if (isset($config['search_engine'])) { 637 | $service->getSearchEngines()->setCurrent($config['search_engine']); 638 | } 639 | if (isset($config['show_log_id'])) { 640 | $service->setShowLogId($config['show_log_id']); 641 | } 642 | return static::setService('exceptionHandler', $service, $instance); 643 | } 644 | 645 | /** 646 | * Get a antiCsrf service. 647 | * 648 | * @param string $instance The antiCsrf instance name 649 | * 650 | * @return AntiCSRF 651 | */ 652 | public static function antiCsrf(string $instance = 'default') : AntiCSRF 653 | { 654 | $service = static::getService('antiCsrf', $instance); 655 | if ($service) { 656 | return $service; // @phpstan-ignore-line 657 | } 658 | if (static::isDebugging()) { 659 | $start = \microtime(true); 660 | $service = static::setAntiCsrf($instance); 661 | $end = \microtime(true); 662 | static::addDebugData('antiCsrf', $instance, $start, $end); 663 | return $service; 664 | } 665 | return static::setAntiCsrf($instance); 666 | } 667 | 668 | /** 669 | * Set a antiCsrf service. 670 | * 671 | * @param string $instance The antiCsrf instance name 672 | * 673 | * @return AntiCSRF 674 | */ 675 | protected static function setAntiCsrf(string $instance) : AntiCSRF 676 | { 677 | $config = static::config()->get('antiCsrf', $instance); 678 | static::session($config['session_instance'] ?? 'default'); 679 | $service = new AntiCSRF( 680 | static::request($config['request_instance'] ?? 'default'), 681 | $config['token_bytes_length'] ?? null, 682 | $config['generate_token_function'] ?? null, 683 | ); 684 | if (isset($config['token_name'])) { 685 | $service->setTokenName($config['token_name']); 686 | } 687 | if (isset($config['enabled']) && $config['enabled'] === false) { 688 | $service->disable(); 689 | } 690 | return static::setService('antiCsrf', $service, $instance); 691 | } 692 | 693 | /** 694 | * Get a database service. 695 | * 696 | * @param string $instance The database instance name 697 | * 698 | * @return Database 699 | */ 700 | public static function database(string $instance = 'default') : Database 701 | { 702 | $service = static::getService('database', $instance); 703 | if ($service) { 704 | return $service; // @phpstan-ignore-line 705 | } 706 | if (static::isDebugging()) { 707 | $collector = new DatabaseCollector($instance); 708 | $start = \microtime(true); 709 | $service = static::setDatabase($instance, $collector); 710 | $end = \microtime(true); 711 | $collection = static::debugger()->getCollection('Database') 712 | ?? new DatabaseCollection('Database'); 713 | $collection->addCollector($collector); 714 | static::debugger()->addCollection($collection); 715 | static::addDebugData('database', $instance, $start, $end); 716 | return $service; 717 | } 718 | return static::setDatabase($instance); 719 | } 720 | 721 | /** 722 | * Set a database service. 723 | * 724 | * @param string $instance The database instance name 725 | * 726 | * @return Database 727 | */ 728 | protected static function setDatabase( 729 | string $instance, 730 | ?DatabaseCollector $collector = null 731 | ) : Database { 732 | $config = static::config()->get('database', $instance); 733 | $logger = null; 734 | if (isset($config['logger_instance'])) { 735 | $logger = static::logger($config['logger_instance']); 736 | } 737 | return static::setService( 738 | 'database', 739 | new Database( 740 | $config['config'], 741 | logger: $logger, 742 | collector: $collector 743 | ), 744 | $instance 745 | ); 746 | } 747 | 748 | /** 749 | * Get a mailer service. 750 | * 751 | * @param string $instance The mailer instance name 752 | * 753 | * @return Mailer 754 | */ 755 | public static function mailer(string $instance = 'default') : Mailer 756 | { 757 | $service = static::getService('mailer', $instance); 758 | if ($service) { 759 | return $service; // @phpstan-ignore-line 760 | } 761 | if (static::isDebugging()) { 762 | $start = \microtime(true); 763 | $service = static::setMailer($instance); 764 | $end = \microtime(true); 765 | $collector = new EmailCollector($instance); 766 | $service->setDebugCollector($collector); 767 | $collection = static::debugger()->getCollection('Email') 768 | ?? new EmailCollection('Email'); 769 | $collection->addCollector($collector); 770 | static::debugger()->addCollection($collection); 771 | static::addDebugData('mailer', $instance, $start, $end); 772 | return $service; 773 | } 774 | return static::setMailer($instance); 775 | } 776 | 777 | /** 778 | * Set a mailer service. 779 | * 780 | * @param string $instance The mailer instance name 781 | * 782 | * @return Mailer 783 | */ 784 | protected static function setMailer(string $instance) : Mailer 785 | { 786 | $config = static::config()->get('mailer', $instance); 787 | return static::setService( 788 | 'mailer', 789 | new Mailer($config), 790 | $instance 791 | ); 792 | } 793 | 794 | /** 795 | * Get a migrator service. 796 | * 797 | * @param string $instance The migrator instance name 798 | * 799 | * @return Migrator 800 | */ 801 | public static function migrator(string $instance = 'default') : Migrator 802 | { 803 | $service = static::getService('migrator', $instance); 804 | if ($service) { 805 | return $service; // @phpstan-ignore-line 806 | } 807 | if (static::isDebugging()) { 808 | $start = \microtime(true); 809 | $service = static::setMigrator($instance); 810 | $end = \microtime(true); 811 | static::addDebugData('migrator', $instance, $start, $end); 812 | return $service; 813 | } 814 | return static::setMigrator($instance); 815 | } 816 | 817 | /** 818 | * Set a migrator service. 819 | * 820 | * @param string $instance The migrator instance name 821 | * 822 | * @return Migrator 823 | */ 824 | protected static function setMigrator(string $instance) : Migrator 825 | { 826 | $config = static::config()->get('migrator', $instance); 827 | return static::setService( 828 | 'migrator', 829 | new Migrator( 830 | static::database($config['database_instance'] ?? 'default'), 831 | $config['directories'], 832 | $config['table'] ?? 'Migrations', 833 | ), 834 | $instance 835 | ); 836 | } 837 | 838 | /** 839 | * Get a language service. 840 | * 841 | * @param string $instance The language instance name 842 | * 843 | * @return Language 844 | */ 845 | public static function language(string $instance = 'default') : Language 846 | { 847 | $service = static::getService('language', $instance); 848 | if ($service) { 849 | return $service; // @phpstan-ignore-line 850 | } 851 | if (static::isDebugging()) { 852 | $start = \microtime(true); 853 | $service = static::setLanguage($instance); 854 | $end = \microtime(true); 855 | $collector = new LanguageCollector($instance); 856 | $service->setDebugCollector($collector); 857 | $collection = static::debugger()->getCollection('Language') 858 | ?? new LanguageCollection('Language'); 859 | $collection->addCollector($collector); 860 | static::debugger()->addCollection($collection); 861 | static::addDebugData('language', $instance, $start, $end); 862 | return $service; 863 | } 864 | return static::setLanguage($instance); 865 | } 866 | 867 | /** 868 | * Set a language service. 869 | * 870 | * @param string $instance The language instance name 871 | * 872 | * @return Language 873 | */ 874 | protected static function setLanguage(string $instance) : Language 875 | { 876 | $config = static::config()->get('language', $instance); 877 | $service = new Language($config['default'] ?? 'en'); 878 | if (isset($config['current'])) { 879 | $service->setCurrentLocale($config['current']); 880 | } 881 | if (isset($config['supported'])) { 882 | $service->setSupportedLocales($config['supported']); 883 | } 884 | if (isset($config['negotiate']) && $config['negotiate'] === true) { 885 | $service->setCurrentLocale( 886 | static::negotiateLanguage($service, $config['request_instance'] ?? 'default') 887 | ); 888 | } 889 | if (isset($config['fallback_level'])) { 890 | if (\is_int($config['fallback_level'])) { 891 | $config['fallback_level'] = FallbackLevel::from($config['fallback_level']); 892 | } 893 | $service->setFallbackLevel($config['fallback_level']); 894 | } 895 | $config['directories'] ??= []; 896 | if (isset($config['find_in_namespaces']) && $config['find_in_namespaces'] === true) { 897 | foreach (static::autoloader($config['autoloader_instance'] ?? 'default') 898 | ->getNamespaces() as $directories) { 899 | foreach ($directories as $directory) { 900 | $directory .= 'Languages'; 901 | if (\is_dir($directory)) { 902 | $config['directories'][] = $directory; 903 | } 904 | } 905 | } 906 | } 907 | if ($config['directories']) { 908 | $service->setDirectories($config['directories']); 909 | } 910 | $service->addDirectory(__DIR__ . '/Languages'); 911 | return static::setService('language', $service, $instance); 912 | } 913 | 914 | /** 915 | * Negotiates the language either via the command line or over HTTP. 916 | * 917 | * @param Language $language The current Language instance 918 | * @param string $requestInstance The name of the Request instance to be used 919 | * 920 | * @return string The negotiated language 921 | */ 922 | protected static function negotiateLanguage(Language $language, string $requestInstance = 'default') : string 923 | { 924 | if (static::isCli()) { 925 | $supported = \array_map('\strtolower', $language->getSupportedLocales()); 926 | $lang = \getenv('LANG'); 927 | if ($lang) { 928 | $lang = \explode('.', $lang, 2); 929 | $lang = \strtolower($lang[0]); 930 | $lang = \strtr($lang, ['_' => '-']); 931 | if (\in_array($lang, $supported, true)) { 932 | return $lang; 933 | } 934 | } 935 | return $language->getDefaultLocale(); 936 | } 937 | return static::request($requestInstance)->negotiateLanguage( 938 | $language->getSupportedLocales() 939 | ); 940 | } 941 | 942 | /** 943 | * Get a locator service. 944 | * 945 | * @param string $instance The locator instance name 946 | * 947 | * @return Locator 948 | */ 949 | public static function locator(string $instance = 'default') : Locator 950 | { 951 | $service = static::getService('locator', $instance); 952 | if ($service) { 953 | return $service; // @phpstan-ignore-line 954 | } 955 | if (static::isDebugging()) { 956 | $start = \microtime(true); 957 | $service = static::setLocator($instance); 958 | $end = \microtime(true); 959 | static::addDebugData('locator', $instance, $start, $end); 960 | return $service; 961 | } 962 | return static::setLocator($instance); 963 | } 964 | 965 | /** 966 | * Set a locator service. 967 | * 968 | * @param string $instance The locator instance name 969 | * 970 | * @return Locator 971 | */ 972 | protected static function setLocator(string $instance) : Locator 973 | { 974 | $config = static::config()->get('locator', $instance); 975 | return static::setService( 976 | 'locator', 977 | new Locator(static::autoloader($config['autoloader_instance'] ?? 'default')), 978 | $instance 979 | ); 980 | } 981 | 982 | /** 983 | * Get a logger service. 984 | * 985 | * @param string $instance The logger instance name 986 | * 987 | * @return Logger 988 | */ 989 | public static function logger(string $instance = 'default') : Logger 990 | { 991 | $service = static::getService('logger', $instance); 992 | if ($service) { 993 | return $service; // @phpstan-ignore-line 994 | } 995 | if (static::isDebugging()) { 996 | $start = \microtime(true); 997 | $service = static::setLogger($instance); 998 | $end = \microtime(true); 999 | $collector = new LogCollector($instance); 1000 | $service->setDebugCollector($collector); 1001 | $collection = static::debugger()->getCollection('Log') 1002 | ?? new LogCollection('Log'); 1003 | $collection->addCollector($collector); 1004 | static::debugger()->addCollection($collection); 1005 | static::addDebugData('logger', $instance, $start, $end); 1006 | return $service; 1007 | } 1008 | return static::setLogger($instance); 1009 | } 1010 | 1011 | /** 1012 | * Set a logger service. 1013 | * 1014 | * @param string $instance The logger instance name 1015 | * 1016 | * @return Logger 1017 | */ 1018 | protected static function setLogger(string $instance) : Logger 1019 | { 1020 | $config = static::config()->get('logger', $instance); 1021 | /** 1022 | * @var class-string $class 1023 | */ 1024 | $class = $config['class'] ?? MultiFileLogger::class; 1025 | $config['level'] ??= LogLevel::DEBUG; 1026 | if (\is_int($config['level'])) { 1027 | $config['level'] = LogLevel::from($config['level']); 1028 | } 1029 | return static::setService( 1030 | 'logger', 1031 | new $class( 1032 | $config['destination'], 1033 | $config['level'], 1034 | $config['config'] ?? [], 1035 | ), 1036 | $instance 1037 | ); 1038 | } 1039 | 1040 | /** 1041 | * Get a router service. 1042 | * 1043 | * @param string $instance The router instance name 1044 | * 1045 | * @return Router 1046 | */ 1047 | public static function router(string $instance = 'default') : Router 1048 | { 1049 | $service = static::getService('router', $instance); 1050 | if ($service) { 1051 | return $service; // @phpstan-ignore-line 1052 | } 1053 | if (static::isDebugging()) { 1054 | $start = \microtime(true); 1055 | $config = (array) static::config()->get('router', $instance); 1056 | $service = static::setRouter($instance, $config); 1057 | $collector = new RoutingCollector($instance); 1058 | $service->setDebugCollector($collector); 1059 | if (isset($config['files'])) { 1060 | static::requireRouterFiles($config['files'], $service); 1061 | } 1062 | $end = \microtime(true); 1063 | $collection = static::debugger()->getCollection('Routing') 1064 | ?? new RoutingCollection('Routing'); 1065 | $collection->addCollector($collector); 1066 | static::debugger()->addCollection($collection); 1067 | static::addDebugData('router', $instance, $start, $end); 1068 | return $service; 1069 | } 1070 | return static::setRouter($instance); 1071 | } 1072 | 1073 | /** 1074 | * Set a router service. 1075 | * 1076 | * @param string $instance The router instance name 1077 | * @param array|null $config The router instance configs or null 1078 | * 1079 | * @return Router 1080 | */ 1081 | protected static function setRouter(string $instance, ?array $config = null) : Router 1082 | { 1083 | $requireFiles = $config === null; 1084 | $config ??= static::config()->get('router', $instance); 1085 | $language = null; 1086 | if (isset($config['language_instance'])) { 1087 | $language = static::language($config['language_instance']); 1088 | } 1089 | $service = static::setService('router', new Router( 1090 | static::response($config['response_instance'] ?? 'default'), 1091 | $language 1092 | ), $instance); 1093 | if (isset($config['auto_options']) && $config['auto_options'] === true) { 1094 | $service->setAutoOptions(); 1095 | } 1096 | if (isset($config['auto_methods']) && $config['auto_methods'] === true) { 1097 | $service->setAutoMethods(); 1098 | } 1099 | if (!empty($config['placeholders'])) { 1100 | $service->addPlaceholder($config['placeholders']); 1101 | } 1102 | if ($requireFiles && isset($config['files'])) { 1103 | static::requireRouterFiles($config['files'], $service); 1104 | } 1105 | if (isset($config['callback'])) { 1106 | $config['callback']($service); 1107 | } 1108 | return $service; 1109 | } 1110 | 1111 | /** 1112 | * Load files that set the routes. 1113 | * 1114 | * @param array $files The path of the router files 1115 | * @param Router $router 1116 | */ 1117 | protected static function requireRouterFiles(array $files, Router $router) : void 1118 | { 1119 | foreach ($files as $file) { 1120 | if (!\is_file($file)) { 1121 | throw new LogicException('Invalid router file: ' . $file); 1122 | } 1123 | Isolation::require($file, ['router' => $router]); 1124 | } 1125 | } 1126 | 1127 | /** 1128 | * Get a request service. 1129 | * 1130 | * @param string $instance The request instance name 1131 | * 1132 | * @return Request 1133 | */ 1134 | public static function request(string $instance = 'default') : Request 1135 | { 1136 | $service = static::getService('request', $instance); 1137 | if ($service) { 1138 | return $service; // @phpstan-ignore-line 1139 | } 1140 | if (static::isDebugging()) { 1141 | $start = \microtime(true); 1142 | $service = static::setRequest($instance); 1143 | $end = \microtime(true); 1144 | $collector = new HTTPCollector($instance); 1145 | $collector->setRequest($service); 1146 | $collection = static::debugger()->getCollection('HTTP') 1147 | ?? new HTTPCollection('HTTP'); 1148 | $collection->addCollector($collector); 1149 | static::debugger()->addCollection($collection); 1150 | static::addDebugData('request', $instance, $start, $end); 1151 | return $service; 1152 | } 1153 | return static::setRequest($instance); 1154 | } 1155 | 1156 | /** 1157 | * Overrides variables to be set in the $_SERVER super-global when the 1158 | * request is made via the command line. 1159 | * 1160 | * @param array $vars 1161 | */ 1162 | protected static function setServerVars(array $vars = []) : void 1163 | { 1164 | $vars = \array_replace(static::$defaultServerVars, $vars); 1165 | foreach ($vars as $key => $value) { 1166 | $_SERVER[$key] ??= $value; 1167 | } 1168 | } 1169 | 1170 | /** 1171 | * Set a request service. 1172 | * 1173 | * @param string $instance The request instance name 1174 | * 1175 | * @return Request 1176 | */ 1177 | protected static function setRequest(string $instance) : Request 1178 | { 1179 | $config = static::config()->get('request', $instance); 1180 | if (static::isCli()) { 1181 | static::setServerVars($config['server_vars'] ?? []); 1182 | } 1183 | $service = new Request($config['allowed_hosts'] ?? []); 1184 | if (isset($config['force_https']) && $config['force_https'] === true) { 1185 | $service->forceHttps(); 1186 | } 1187 | if (isset($config['json_flags'])) { 1188 | $service->setJsonFlags($config['json_flags']); 1189 | } 1190 | return static::setService('request', $service, $instance); 1191 | } 1192 | 1193 | /** 1194 | * Get a response service. 1195 | * 1196 | * @param string $instance The response instance name 1197 | * 1198 | * @return Response 1199 | */ 1200 | public static function response(string $instance = 'default') : Response 1201 | { 1202 | $service = static::getService('response', $instance); 1203 | if ($service) { 1204 | return $service; // @phpstan-ignore-line 1205 | } 1206 | if (static::isDebugging()) { 1207 | $start = \microtime(true); 1208 | $service = static::setResponse($instance); 1209 | $end = \microtime(true); 1210 | $collection = static::debugger()->getCollection('HTTP'); 1211 | foreach ($collection->getCollectors() as $collector) { 1212 | if ($collector->getName() === $instance) { 1213 | $service->setDebugCollector($collector); // @phpstan-ignore-line 1214 | break; 1215 | } 1216 | } 1217 | static::addDebugData('response', $instance, $start, $end); 1218 | return $service; 1219 | } 1220 | return static::setResponse($instance); 1221 | } 1222 | 1223 | /** 1224 | * Set a response service. 1225 | * 1226 | * @param string $instance The response instance name 1227 | * 1228 | * @return Response 1229 | */ 1230 | protected static function setResponse(string $instance) : Response 1231 | { 1232 | $config = static::config()->get('response', $instance); 1233 | $service = new Response(static::request($config['request_instance'] ?? 'default')); 1234 | if (!empty($config['headers'])) { 1235 | $service->setHeaders($config['headers']); 1236 | } 1237 | if (!empty($config['auto_etag'])) { 1238 | $service->setAutoEtag( 1239 | $config['auto_etag']['active'] ?? true, 1240 | $config['auto_etag']['hash_algo'] ?? null 1241 | ); 1242 | } 1243 | if (isset($config['auto_language']) && $config['auto_language'] === true) { 1244 | $service->setContentLanguage( 1245 | static::language($config['language_instance'] ?? 'default')->getCurrentLocale() 1246 | ); 1247 | } 1248 | if (isset($config['cache'])) { 1249 | $config['cache'] === false 1250 | ? $service->setNoCache() 1251 | : $service->setCache($config['cache']['seconds'], $config['cache']['public'] ?? false); 1252 | } 1253 | if (!empty($config['csp'])) { 1254 | $service->setCsp(new CSP($config['csp'])); 1255 | } 1256 | if (!empty($config['csp_report_only'])) { 1257 | $service->setCspReportOnly(new CSP($config['csp_report_only'])); 1258 | } 1259 | if (isset($config['json_flags'])) { 1260 | $service->setJsonFlags($config['json_flags']); 1261 | } 1262 | if (isset($config['replace_headers'])) { 1263 | $service->setReplaceHeaders($config['replace_headers']); 1264 | } 1265 | return static::setService('response', $service, $instance); 1266 | } 1267 | 1268 | /** 1269 | * Get a session service. 1270 | * 1271 | * @param string $instance The session instance name 1272 | * 1273 | * @return Session 1274 | */ 1275 | public static function session(string $instance = 'default') : Session 1276 | { 1277 | $service = static::getService('session', $instance); 1278 | if ($service) { 1279 | return $service; // @phpstan-ignore-line 1280 | } 1281 | if (static::isDebugging()) { 1282 | $start = \microtime(true); 1283 | $service = static::setSession($instance); 1284 | $end = \microtime(true); 1285 | $collector = new SessionCollector($instance); 1286 | $service->setDebugCollector($collector); 1287 | $collection = static::debugger()->getCollection('Session') 1288 | ?? new SessionCollection('Session'); 1289 | $collection->addCollector($collector); 1290 | static::debugger()->addCollection($collection); 1291 | static::addDebugData('session', $instance, $start, $end); 1292 | return $service; 1293 | } 1294 | return static::setSession($instance); 1295 | } 1296 | 1297 | /** 1298 | * Set a session service. 1299 | * 1300 | * @param string $instance The session instance name 1301 | * 1302 | * @return Session 1303 | */ 1304 | protected static function setSession(string $instance) : Session 1305 | { 1306 | $config = static::config()->get('session', $instance); 1307 | if (isset($config['save_handler']['class'])) { 1308 | $logger = null; 1309 | if (isset($config['logger_instance'])) { 1310 | $logger = static::logger($config['logger_instance']); 1311 | } 1312 | $saveHandler = new $config['save_handler']['class']( 1313 | $config['save_handler']['config'] ?? [], 1314 | $logger 1315 | ); 1316 | if ($saveHandler instanceof DatabaseHandler 1317 | && isset($config['save_handler']['database_instance']) 1318 | ) { 1319 | $saveHandler->setDatabase( 1320 | static::database($config['save_handler']['database_instance']) 1321 | ); 1322 | } 1323 | } 1324 | // @phpstan-ignore-next-line 1325 | $service = new Session($config['options'] ?? [], $saveHandler ?? null); 1326 | if (isset($config['auto_start']) && $config['auto_start'] === true) { 1327 | $service->start(); 1328 | } 1329 | return static::setService('session', $service, $instance); 1330 | } 1331 | 1332 | /** 1333 | * Get a validation service. 1334 | * 1335 | * @param string $instance The validation instance name 1336 | * 1337 | * @return Validation 1338 | */ 1339 | public static function validation(string $instance = 'default') : Validation 1340 | { 1341 | $service = static::getService('validation', $instance); 1342 | if ($service) { 1343 | return $service; // @phpstan-ignore-line 1344 | } 1345 | if (static::isDebugging()) { 1346 | $start = \microtime(true); 1347 | $service = static::setValidation($instance); 1348 | $end = \microtime(true); 1349 | $collector = new ValidationCollector($instance); 1350 | $service->setDebugCollector($collector); 1351 | $collection = static::debugger()->getCollection('Validation') 1352 | ?? new ValidationCollection('Validation'); 1353 | $collection->addCollector($collector); 1354 | static::debugger()->addCollection($collection); 1355 | static::addDebugData('validation', $instance, $start, $end); 1356 | return $service; 1357 | } 1358 | return static::setValidation($instance); 1359 | } 1360 | 1361 | /** 1362 | * Set a validation service. 1363 | * 1364 | * @param string $instance The validation instance name 1365 | * 1366 | * @return Validation 1367 | */ 1368 | protected static function setValidation(string $instance) : Validation 1369 | { 1370 | $config = static::config()->get('validation', $instance); 1371 | $language = null; 1372 | if (isset($config['language_instance'])) { 1373 | $language = static::language($config['language_instance']); 1374 | } 1375 | return static::setService( 1376 | 'validation', 1377 | new Validation( 1378 | $config['validators'] ?? [ 1379 | Validator::class, 1380 | FilesValidator::class, 1381 | ], 1382 | $language 1383 | ), 1384 | $instance 1385 | ); 1386 | } 1387 | 1388 | /** 1389 | * Get a view service. 1390 | * 1391 | * @param string $instance The view instance name 1392 | * 1393 | * @return View 1394 | */ 1395 | public static function view(string $instance = 'default') : View 1396 | { 1397 | $service = static::getService('view', $instance); 1398 | if ($service) { 1399 | return $service; // @phpstan-ignore-line 1400 | } 1401 | if (static::isDebugging()) { 1402 | $start = \microtime(true); 1403 | $service = static::setView($instance); 1404 | $service->setInstanceName($instance); 1405 | $end = \microtime(true); 1406 | $collector = new ViewsCollector($instance); 1407 | $service->setDebugCollector($collector); 1408 | $collection = static::debugger()->getCollection('Views') 1409 | ?? new ViewsCollection('Views'); 1410 | $collection->addCollector($collector); 1411 | static::debugger()->addCollection($collection); 1412 | static::addDebugData('view', $instance, $start, $end); 1413 | return $service; 1414 | } 1415 | return static::setView($instance); 1416 | } 1417 | 1418 | /** 1419 | * Set a view service. 1420 | * 1421 | * @param string $instance The view instance name 1422 | * 1423 | * @return View 1424 | */ 1425 | protected static function setView(string $instance) : View 1426 | { 1427 | $config = static::config()->get('view', $instance); 1428 | $service = new View($config['base_dir'] ?? null, $config['extension'] ?? '.php'); 1429 | if (isset($config['layout_prefix'])) { 1430 | $service->setLayoutPrefix($config['layout_prefix']); 1431 | } 1432 | if (isset($config['include_prefix'])) { 1433 | $service->setIncludePrefix($config['include_prefix']); 1434 | } 1435 | if (isset($config['show_debug_comments']) && $config['show_debug_comments'] === false) { 1436 | $service->disableDebugComments(); 1437 | } 1438 | if (isset($config['throw_exceptions_in_destructor'])) { 1439 | $service->setThrowExceptionsInDestructor($config['throw_exceptions_in_destructor']); 1440 | } 1441 | return static::setService('view', $service, $instance); 1442 | } 1443 | 1444 | /** 1445 | * Tell if it is a command-line request. 1446 | * 1447 | * @return bool 1448 | */ 1449 | public static function isCli() : bool 1450 | { 1451 | if (static::$isCli === null) { 1452 | static::$isCli = \PHP_SAPI === 'cli' || \defined('STDIN'); 1453 | } 1454 | return static::$isCli; 1455 | } 1456 | 1457 | /** 1458 | * Set if it is a CLI request. Used for testing. 1459 | * 1460 | * @param bool $is 1461 | */ 1462 | public static function setIsCli(bool $is) : void 1463 | { 1464 | static::$isCli = $is; 1465 | } 1466 | 1467 | /** 1468 | * Tell if the App is in debug mode. 1469 | * 1470 | * @return bool 1471 | */ 1472 | public static function isDebugging() : bool 1473 | { 1474 | return isset(static::$debugCollector); 1475 | } 1476 | 1477 | /** 1478 | * Add services data to the debug collector. 1479 | * 1480 | * @param string $service Service name 1481 | * @param string $instance Service instance name 1482 | * @param float $start Microtime right before setting up the service 1483 | * @param float $end Microtime right after setting up the service 1484 | */ 1485 | public static function addDebugData( 1486 | string $service, 1487 | string $instance, 1488 | float $start, 1489 | float $end 1490 | ) : void { 1491 | static::$debugCollector->addData([ 1492 | 'service' => $service, 1493 | 'instance' => $instance, 1494 | 'start' => $start, 1495 | 'end' => $end, 1496 | ]); 1497 | } 1498 | } 1499 | -------------------------------------------------------------------------------- /src/Controller.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\MVC; 11 | 12 | use Framework\HTTP\Request; 13 | use Framework\HTTP\Response; 14 | use Framework\Routing\RouteActions; 15 | use ReflectionClass; 16 | use ReflectionNamedType; 17 | 18 | /** 19 | * Class Controller. 20 | * 21 | * @package mvc 22 | */ 23 | abstract class Controller extends RouteActions 24 | { 25 | /** 26 | * The matched route Request. 27 | * 28 | * @var Request 29 | */ 30 | protected Request $request; 31 | /** 32 | * The matched route Response. 33 | * 34 | * @var Response 35 | */ 36 | protected Response $response; 37 | /** 38 | * Set true to load models in properties. 39 | * 40 | * @see Controller::prepareModels() 41 | * 42 | * @var bool 43 | */ 44 | protected bool $loadModels = true; 45 | 46 | /** 47 | * Controller constructor. 48 | * 49 | * @param Request $request 50 | * @param Response $response 51 | */ 52 | public function __construct(Request $request, Response $response) 53 | { 54 | $this->request = $request; 55 | $this->response = $response; 56 | if ($this->loadModels) { 57 | $this->prepareModels(); 58 | } 59 | } 60 | 61 | /** 62 | * Initialize models in properties. 63 | * 64 | * @since 4 65 | * 66 | * @return static 67 | */ 68 | protected function prepareModels() : static 69 | { 70 | $reflection = new ReflectionClass($this); 71 | foreach ($reflection->getProperties() as $property) { 72 | $type = $property->getType(); 73 | if (!$type instanceof ReflectionNamedType) { 74 | continue; 75 | } 76 | $class = $type->getName(); 77 | if (!\is_subclass_of($class, Model::class)) { 78 | continue; 79 | } 80 | $name = $property->name; 81 | if (isset($this->{$name})) { 82 | continue; 83 | } 84 | $this->{$name} = Model::get($class); 85 | } 86 | return $this; 87 | } 88 | 89 | /** 90 | * Render a view. 91 | * 92 | * @param string $view The view file 93 | * @param array $variables The variables passed to the view 94 | * @param string $instance The View service instance name 95 | * 96 | * @return string The rendered view contents 97 | */ 98 | protected function render( 99 | string $view, 100 | array $variables = [], 101 | string $instance = 'default' 102 | ) : string { 103 | return App::view($instance)->render($view, $variables); 104 | } 105 | 106 | /** 107 | * Validate data. 108 | * 109 | * @param array $data The data to be validated 110 | * @param array|string> $rules An associative array with field 111 | * as keys and values as rules 112 | * @param array $labels An associative array with fields as 113 | * keys and label as values 114 | * @param array> $messages A multi-dimensional 115 | * array with field names as keys and values as arrays where the keys are 116 | * rule names and values are the custom error message strings 117 | * @param string $instance The Validation service instance name 118 | * 119 | * @return array An empty array if validation pass or an 120 | * associative array with field names as keys and error messages as values 121 | */ 122 | protected function validate( 123 | array $data, 124 | array $rules, 125 | array $labels = [], 126 | array $messages = [], 127 | string $instance = 'default' 128 | ) : array { 129 | $validation = App::validation($instance); 130 | return $validation->setRules($rules)->setLabels($labels) 131 | ->setMessages($messages)->validate($data) 132 | ? [] 133 | : $validation->getErrors(); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Debug/AppCollection.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\MVC\Debug; 11 | 12 | use Framework\Debug\Collection; 13 | 14 | /** 15 | * Class AppCollection. 16 | * 17 | * @package mvc 18 | */ 19 | class AppCollection extends Collection 20 | { 21 | protected string $iconPath = __DIR__ . '/icons/app.svg'; 22 | } 23 | -------------------------------------------------------------------------------- /src/Debug/AppCollector.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\MVC\Debug; 11 | 12 | use Framework\Debug\Collector; 13 | use Framework\Debug\Debugger; 14 | use Framework\MVC\App; 15 | use ReflectionClass; 16 | use ReflectionMethod; 17 | 18 | /** 19 | * Class AppCollector. 20 | * 21 | * @package mvc 22 | */ 23 | class AppCollector extends Collector 24 | { 25 | protected App $app; 26 | protected float $startTime; 27 | protected float $endTime; 28 | protected int $startMemory; 29 | protected int $endMemory; 30 | 31 | public function setApp(App $app) : static 32 | { 33 | $this->app = $app; 34 | if (!isset($this->startTime)) { 35 | $this->setStartTime(); 36 | } 37 | if (!isset($this->startMemory)) { 38 | $this->setStartMemory(); 39 | } 40 | return $this; 41 | } 42 | 43 | public function setStartTime(?float $microtime = null) : static 44 | { 45 | $this->startTime = $microtime ?? \microtime(true); 46 | return $this; 47 | } 48 | 49 | public function setEndTime(?float $microtime = null) : static 50 | { 51 | $this->endTime = $microtime ?? \microtime(true); 52 | return $this; 53 | } 54 | 55 | public function setStartMemory(?int $memoryUsage = null) : static 56 | { 57 | $this->startMemory = $memoryUsage ?? \memory_get_usage(); 58 | return $this; 59 | } 60 | 61 | public function setEndMemory(?int $memoryUsage = null) : static 62 | { 63 | $this->endMemory = $memoryUsage ?? \memory_get_usage(); 64 | return $this; 65 | } 66 | 67 | public function getActivities() : array 68 | { 69 | $activities = []; 70 | $activities[] = [ 71 | 'collection' => 'App', 72 | 'collector' => $this->getName(), 73 | 'class' => static::class, 74 | 'description' => 'Initialization', 75 | 'start' => $_SERVER['REQUEST_TIME_FLOAT'], 76 | 'end' => $this->startTime, 77 | ]; 78 | $activities[] = [ 79 | 'collector' => $this->getName(), 80 | 'class' => static::class, 81 | 'description' => 'Runtime', 82 | 'start' => $this->startTime, 83 | 'end' => $this->endTime, 84 | ]; 85 | foreach ($this->getServices() as $service => $data) { 86 | foreach ($data as $item) { 87 | $activities[] = [ 88 | 'collector' => $this->getName(), 89 | 'class' => static::class, 90 | 'description' => 'Load service ' . $service . ':' . $item['name'], 91 | 'start' => $item['start'], 92 | 'end' => $item['end'], 93 | ]; 94 | } 95 | } 96 | return $activities; 97 | } 98 | 99 | public function getContents() : string 100 | { 101 | if (!isset($this->endTime)) { 102 | $this->setEndTime(\microtime(true)); 103 | } 104 | if (!isset($this->endMemory)) { 105 | $this->setEndMemory(\memory_get_usage()); 106 | } 107 | \ob_start(); ?> 108 |

Started at: startTime) ?>

109 |

Runtime: endTime - $this->startTime) ?> ms 110 |

111 |

112 | Memory: endMemory - $this->startMemory) ?> 114 |

115 |

Services

116 |

Loaded Service Instances

117 | renderLoadedServices() ?> 118 |

Available Services

119 | renderAvailableServices(); 121 | return \ob_get_clean(); // @phpstan-ignore-line 122 | } 123 | 124 | protected function renderLoadedServices() : string 125 | { 126 | $services = $this->getServices(); 127 | $total = 0; 128 | foreach ($services as $data) { 129 | $total += \count($data); 130 | } 131 | if ($total === 0) { 132 | return '

No service instance has been loaded.

'; 133 | } 134 | \ob_start(); ?> 135 |

Total of service instance loaded.

136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | $data): ?> 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 |
ServiceInstancesTime to Load
161 | 167 | */ 168 | protected function getServices() : array 169 | { 170 | $result = []; 171 | foreach ($this->getData() as $data) { 172 | if (!isset($result[$data['service']])) { 173 | $result[$data['service']] = []; 174 | } 175 | $result[$data['service']][] = [ 176 | 'name' => $data['instance'], 177 | 'start' => $data['start'], 178 | 'end' => $data['end'], 179 | ]; 180 | } 181 | return $result; 182 | } 183 | 184 | protected function renderAvailableServices() : string 185 | { 186 | \ob_start(); 187 | $services = []; 188 | $class = new ReflectionClass($this->app); 189 | $methods = $class->getMethods(ReflectionMethod::IS_STATIC); 190 | foreach ($methods as $method) { 191 | if (!$method->isPublic()) { 192 | continue; 193 | } 194 | $name = $method->getName(); 195 | if (\in_array($name, [ 196 | 'config', 197 | 'getService', 198 | 'setService', 199 | 'removeService', 200 | 'isCli', 201 | 'setIsCli', 202 | 'isDebugging', 203 | ], true)) { 204 | continue; 205 | } 206 | $param = $method->getParameters()[0] ?? null; 207 | if (!$param || $param->getName() !== 'instance') { 208 | continue; 209 | } 210 | if ($param->getType()?->getName() !== 'string') { // @phpstan-ignore-line 211 | continue; 212 | } 213 | $instances = []; 214 | if ($param->isDefaultValueAvailable()) { 215 | $instances[] = $param->getDefaultValue(); 216 | } 217 | foreach ((array) $this->app::config()->getInstances($name) as $inst => $s) { 218 | $instances[] = $inst; 219 | } 220 | $instances = \array_unique($instances); 221 | \sort($instances); 222 | $services[$name] = [ 223 | 'returnType' => $method->getReturnType()?->getName(), // @phpstan-ignore-line 224 | 'instances' => $instances, 225 | ]; 226 | } 227 | \ksort($services); 228 | $countServices = \count($services); 229 | $s = 0; ?> 230 |

There are services available.

231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | $data): ?> 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 |
#ServiceConfig InstancesReturn Type
257 | 12 | 38 | 288 | -------------------------------------------------------------------------------- /src/Debug/ViewsCollection.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\MVC\Debug; 11 | 12 | use Framework\Debug\Collection; 13 | use Framework\Helpers\Isolation; 14 | 15 | /** 16 | * Class ViewsCollection. 17 | * 18 | * @package mvc 19 | */ 20 | class ViewsCollection extends Collection 21 | { 22 | protected string $iconPath = __DIR__ . '/icons/views.svg'; 23 | 24 | protected function prepare() : void 25 | { 26 | parent::prepare(); 27 | $this->addAction($this->makeActionToggleViewsHints()); 28 | } 29 | 30 | protected function makeActionToggleViewsHints() : string 31 | { 32 | \ob_start(); 33 | Isolation::require(__DIR__ . '/Views/toggle-views-hints.php'); 34 | return \ob_get_clean(); // @phpstan-ignore-line 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Debug/ViewsCollector.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\MVC\Debug; 11 | 12 | use Framework\Debug\Collector; 13 | use Framework\Debug\Debugger; 14 | use Framework\MVC\View; 15 | 16 | /** 17 | * Class ViewsCollector. 18 | * 19 | * @package mvc 20 | */ 21 | class ViewsCollector extends Collector 22 | { 23 | protected View $view; 24 | 25 | public function setView(View $view) : static 26 | { 27 | $this->view = $view; 28 | return $this; 29 | } 30 | 31 | public function getActivities() : array 32 | { 33 | $activities = []; 34 | foreach ($this->getSortedData() as $index => $data) { 35 | $activities[] = [ 36 | 'collector' => $this->getName(), 37 | 'class' => static::class, 38 | 'description' => 'Render view ' . ($index + 1), 39 | 'start' => $data['start'], 40 | 'end' => $data['end'], 41 | ]; 42 | } 43 | return $activities; 44 | } 45 | 46 | public function getContents() : string 47 | { 48 | $baseDir = $this->view->getBaseDir(); 49 | $extension = $this->view->getExtension(); 50 | $layoutPrefix = $this->view->getLayoutPrefix(); 51 | $includePrefix = $this->view->getIncludePrefix(); 52 | \ob_start(); 53 | if (isset($baseDir)): ?> 54 |

Base Directory:

55 | 57 |

Extension:

58 | 60 |

Layout Prefix: 61 |

62 | 65 |

Include Prefix: 66 |

67 | 69 |

Rendered Views

70 | renderRenderedViews(); 72 | return \ob_get_clean(); // @phpstan-ignore-line 73 | } 74 | 75 | protected function renderRenderedViews() : string 76 | { 77 | if (!$this->hasData()) { 78 | return '

No view has been rendered.

'; 79 | } 80 | $data = $this->getSortedData(); 81 | \ob_start(); 82 | $count = \count($data); ?> 83 |

Total of rendered view file 1 ? 's' : '' ?>.

84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | $item): ?> 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
#FileTypeTime to Render
104 | 110 | */ 111 | protected function getSortedData() : array 112 | { 113 | $data = $this->getData(); 114 | \usort($data, static function ($d1, $d2) { 115 | return $d1['start'] <=> $d2['start']; 116 | }); 117 | return $data; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Debug/icons/app.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/Debug/icons/views.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/Entity.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\MVC; 11 | 12 | use DateTime; 13 | use DateTimeInterface; 14 | use DateTimeZone; 15 | use Exception; 16 | use Framework\Date\Date; 17 | use Framework\HTTP\URL; 18 | use JsonException; 19 | use OutOfBoundsException; 20 | use ReflectionProperty; 21 | use stdClass; 22 | 23 | /** 24 | * Class Entity. 25 | * 26 | * @todo In PHP 8.4 add property hooks to validate config properties. 27 | * 28 | * @package mvc 29 | */ 30 | abstract class Entity implements \JsonSerializable, \Stringable 31 | { 32 | /** 33 | * Sets the flags that will be used to encode/decode JSON in internal 34 | * methods of this Entity class. 35 | */ 36 | public int $_jsonFlags = \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE 37 | | \JSON_PRESERVE_ZERO_FRACTION | \JSON_THROW_ON_ERROR; 38 | /** 39 | * Sets the name of the properties that will be visible when this Entity is 40 | * JSON encoded. 41 | * 42 | * @var array 43 | */ 44 | public array $_jsonVars = []; 45 | /** 46 | * This timezone is used to convert times in the {@see Entity::toModel()} 47 | * method. 48 | * 49 | * Note that it must be the same timezone as the database configurations. 50 | * 51 | * @see Model::timezone() 52 | */ 53 | public string $_timezone = '+00:00'; 54 | 55 | /** 56 | * @param array $properties 57 | */ 58 | public function __construct(array $properties) 59 | { 60 | $this->populate($properties); 61 | $this->init(); 62 | } 63 | 64 | public function __isset(string $property) : bool 65 | { 66 | return isset($this->{$property}); 67 | } 68 | 69 | public function __unset(string $property) : void 70 | { 71 | unset($this->{$property}); 72 | } 73 | 74 | /** 75 | * @param string $property 76 | * @param mixed $value 77 | * 78 | * @throws OutOfBoundsException If property is not defined 79 | */ 80 | public function __set(string $property, mixed $value) : void 81 | { 82 | $method = $this->renderMethodName('set', $property); 83 | if (\method_exists($this, $method)) { 84 | $this->{$method}($value); 85 | return; 86 | } 87 | if (\property_exists($this, $property)) { 88 | $this->{$property} = $value; 89 | return; 90 | } 91 | throw $this->propertyNotDefined($property); 92 | } 93 | 94 | /** 95 | * @param string $property 96 | * 97 | * @throws OutOfBoundsException If property is not defined 98 | * 99 | * @return mixed 100 | */ 101 | public function __get(string $property) : mixed 102 | { 103 | $method = $this->renderMethodName('get', $property); 104 | if (\method_exists($this, $method)) { 105 | return $this->{$method}(); 106 | } 107 | if (\property_exists($this, $property)) { 108 | return $this->{$property}; 109 | } 110 | throw $this->propertyNotDefined($property); 111 | } 112 | 113 | /** 114 | * Converts the entity to a JSON string. 115 | * All properties will be included. 116 | * Please note that sensitive property data may be exposed! 117 | * 118 | * @return string 119 | */ 120 | public function __toString() : string 121 | { 122 | $origin = $this->_jsonVars; 123 | $all = \array_keys($this->getObjectVars()); 124 | $this->_jsonVars = $all; 125 | $json = \json_encode($this, $this->_jsonFlags); 126 | $this->_jsonVars = $origin; 127 | return $json; // @phpstan-ignore-line 128 | } 129 | 130 | protected function propertyNotDefined(string $property) : OutOfBoundsException 131 | { 132 | return new OutOfBoundsException('Property not defined: ' . $property); 133 | } 134 | 135 | /** 136 | * Used to initialize settings, set custom properties, etc. 137 | * Called in the constructor just after the properties be populated. 138 | */ 139 | protected function init() : void 140 | { 141 | } 142 | 143 | /** 144 | * @param string $type get or set 145 | * @param string $property Property name 146 | * 147 | * @return string 148 | */ 149 | protected function renderMethodName(string $type, string $property) : string 150 | { 151 | static $properties; 152 | if (isset($properties[$property])) { 153 | return $type . $properties[$property]; 154 | } 155 | $name = \ucwords($property, '_'); 156 | $name = \strtr($name, ['_' => '']); 157 | $properties[$property] = $name; 158 | return $type . $name; 159 | } 160 | 161 | /** 162 | * @param array $properties 163 | */ 164 | protected function populate(array $properties) : void 165 | { 166 | foreach ($properties as $property => $value) { 167 | $method = $this->renderMethodName('set', $property); 168 | if (\method_exists($this, $method)) { 169 | $this->{$method}($value); 170 | continue; 171 | } 172 | $this->setProperty($property, $value); 173 | } 174 | } 175 | 176 | protected function setProperty(string $name, mixed $value) : void 177 | { 178 | if (!\property_exists($this, $name)) { 179 | throw $this->propertyNotDefined($name); 180 | } 181 | if ($value !== null) { 182 | $rp = new ReflectionProperty($this, $name); 183 | $propertyType = $rp->getType()?->getName(); // @phpstan-ignore-line 184 | if ($propertyType !== null) { 185 | $value = $this->typeHint($propertyType, $value); 186 | } 187 | } 188 | $this->{$name} = $value; 189 | } 190 | 191 | /** 192 | * Tries to convert the value according to the property type. 193 | * 194 | * @param string $propertyType 195 | * @param mixed $value 196 | * 197 | * @return mixed 198 | */ 199 | protected function typeHint(string $propertyType, mixed $value) : mixed 200 | { 201 | $valueType = \get_debug_type($value); 202 | $newValue = $this->typeHintCustom($propertyType, $valueType, $value); 203 | if ($newValue === null) { 204 | $newValue = $this->typeHintNative($propertyType, $valueType, $value); 205 | } 206 | if ($newValue === null) { 207 | $newValue = $this->typeHintAplus($propertyType, $valueType, $value); 208 | } 209 | return $newValue ?? $value; 210 | } 211 | 212 | /** 213 | * Override this method to set customizable property types. 214 | * 215 | * @param string $propertyType 216 | * @param string $valueType 217 | * @param mixed $value 218 | * 219 | * @return mixed 220 | */ 221 | protected function typeHintCustom(string $propertyType, string $valueType, mixed $value) : mixed 222 | { 223 | return null; 224 | } 225 | 226 | /** 227 | * Tries to convert the property value to native PHP types. 228 | * 229 | * @param string $propertyType 230 | * @param string $valueType 231 | * @param mixed $value 232 | * 233 | * @return mixed 234 | */ 235 | protected function typeHintNative(string $propertyType, string $valueType, mixed $value) : mixed 236 | { 237 | if ($propertyType === 'array') { 238 | return $valueType === 'string' 239 | ? \json_decode($value, true, flags: $this->_jsonFlags) 240 | : (array) $value; 241 | } 242 | if ($propertyType === 'bool') { 243 | return (bool) $value; 244 | } 245 | if ($propertyType === 'float') { 246 | return (float) $value; 247 | } 248 | if ($propertyType === 'int') { 249 | return (int) $value; 250 | } 251 | if ($propertyType === 'string') { 252 | return (string) $value; 253 | } 254 | if ($propertyType === stdClass::class) { 255 | return $valueType === 'string' 256 | ? (object) \json_decode($value, flags: $this->_jsonFlags) 257 | : (object) $value; 258 | } 259 | return null; 260 | } 261 | 262 | /** 263 | * Tries to convert the property value using Aplus Framework types. 264 | * 265 | * @param string $propertyType 266 | * @param string $valueType 267 | * @param mixed $value 268 | * 269 | * @throws Exception 270 | * 271 | * @return mixed 272 | */ 273 | protected function typeHintAplus(string $propertyType, string $valueType, mixed $value) : mixed 274 | { 275 | if ($propertyType === Date::class) { 276 | return new Date((string) $value); 277 | } 278 | if ($propertyType === URL::class) { 279 | return new URL((string) $value); 280 | } 281 | return null; 282 | } 283 | 284 | /** 285 | * Convert the Entity to an associative array accepted by Model methods. 286 | * 287 | * @throws Exception in case of error creating DateTimeZone 288 | * @throws JsonException in case of error while encoding/decoding JSON 289 | * 290 | * @return array 291 | */ 292 | public function toModel() : array 293 | { 294 | $jsonVars = $this->_jsonVars; 295 | $this->_jsonVars = \array_keys($this->getObjectVars()); 296 | // @phpstan-ignore-next-line 297 | $data = \json_decode(\json_encode($this, $this->_jsonFlags), true, 512, $this->_jsonFlags); 298 | foreach ($data as $property => &$value) { 299 | if (\is_array($value)) { 300 | $value = \json_encode($value, $this->_jsonFlags); 301 | continue; 302 | } 303 | $type = \get_debug_type($this->{$property}); 304 | if (\is_subclass_of($type, DateTimeInterface::class)) { 305 | $datetime = DateTime::createFromFormat(DateTimeInterface::ATOM, $value); 306 | // @phpstan-ignore-next-line 307 | $datetime->setTimezone(new DateTimeZone($this->_timezone)); 308 | $value = $datetime->format('Y-m-d H:i:s'); // @phpstan-ignore-line 309 | } 310 | } 311 | unset($value); 312 | $this->_jsonVars = $jsonVars; 313 | return $data; 314 | } 315 | 316 | public function jsonSerialize() : stdClass 317 | { 318 | if (!$this->_jsonVars) { 319 | return new stdClass(); 320 | } 321 | $allowed = \array_flip($this->_jsonVars); 322 | $filtered = \array_intersect_key($this->getObjectVars(), $allowed); 323 | $allowed = \array_intersect_key($allowed, $filtered); 324 | $ordered = \array_replace($allowed, $filtered); 325 | return (object) $ordered; 326 | } 327 | 328 | /** 329 | * @return array 330 | */ 331 | protected function getObjectVars() : array 332 | { 333 | $result = []; 334 | foreach (\get_object_vars($this) as $key => $value) { 335 | if (!\str_starts_with($key, '_')) { 336 | $result[$key] = $value; 337 | } 338 | } 339 | return $result; 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/Languages/en/validation.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | return [ 11 | 'exist' => 'The {field} field does not exist.', 12 | 'existMany' => 'The {field} field has at least one value that does not exist.', 13 | 'notUnique' => 'The {field} field is not registered.', 14 | 'unique' => 'The {field} field has already been registered.', 15 | ]; 16 | -------------------------------------------------------------------------------- /src/Languages/es/validation.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | return [ 11 | 'exist' => 'El campo {field} no existe.', 12 | 'existMany' => 'El campo {field} tiene al menos un valor que no existe.', 13 | 'notUnique' => 'El campo {field} no está registrado.', 14 | 'unique' => 'El campo {field} ya se ha registrado. ', 15 | ]; 16 | -------------------------------------------------------------------------------- /src/Languages/pt-br/validation.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | return [ 11 | 'exist' => 'O campo {field} não existe.', 12 | 'existMany' => 'O campo {field} tem pelo menos um valor que não existe.', 13 | 'notUnique' => 'O campo {field} não está registrado.', 14 | 'unique' => 'O campo {field} já foi registrado.', 15 | ]; 16 | -------------------------------------------------------------------------------- /src/Model.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\MVC; 11 | 12 | use BadMethodCallException; 13 | use DateTime; 14 | use DateTimeZone; 15 | use Exception; 16 | use Framework\Cache\Cache; 17 | use Framework\Database\Database; 18 | use Framework\Database\Manipulation\Traits\Where; 19 | use Framework\Language\Language; 20 | use Framework\Pagination\Pager; 21 | use Framework\Validation\Debug\ValidationCollector; 22 | use Framework\Validation\FilesValidator; 23 | use Framework\Validation\Validation; 24 | use InvalidArgumentException; 25 | use JetBrains\PhpStorm\ArrayShape; 26 | use JetBrains\PhpStorm\Pure; 27 | use LogicException; 28 | use mysqli_sql_exception; 29 | use RuntimeException; 30 | use stdClass; 31 | 32 | /** 33 | * Class Model. 34 | * 35 | * @package mvc 36 | * 37 | * @method false|int|string createById(Entity|array|stdClass $data) Create a new row and return the 38 | * id. 39 | * @method Entity|array|stdClass|null readById(int|string $id) Read a row by id. 40 | * @method Entity|array|stdClass|null findById(int|string $id) Find a row by id. 41 | * @method false|int|string updateById(int|string $id, Entity|array|stdClass $data) Update rows by 42 | * id. 43 | * @method false|int|string deleteById(int|string $id) Delete rows by id. 44 | * @method false|int|string replaceById(int|string $id, Entity|array|stdClass $data) Replace rows 45 | * by id. 46 | */ 47 | abstract class Model implements ModelInterface 48 | { 49 | /** 50 | * @var array 51 | */ 52 | protected static array $models = []; 53 | /** 54 | * Database connection instance name for read operations. 55 | * 56 | * @var string 57 | */ 58 | protected string $connectionRead = 'default'; 59 | /** 60 | * Database connection instance name for write operations. 61 | * 62 | * @var string 63 | */ 64 | protected string $connectionWrite = 'default'; 65 | /** 66 | * Table name. 67 | * 68 | * @var string 69 | */ 70 | protected string $table; 71 | /** 72 | * Table Primary Key. 73 | * 74 | * @var string 75 | */ 76 | protected string $primaryKey = 'id'; 77 | /** 78 | * Prevents Primary Key changes on INSERT and UPDATE. 79 | * 80 | * @var bool 81 | */ 82 | protected bool $protectPrimaryKey = true; 83 | /** 84 | * Fetched item return type. 85 | * 86 | * Array, object or the classname of an Entity instance. 87 | * 88 | * @see Entity 89 | * 90 | * @var string 91 | */ 92 | protected string $returnType = stdClass::class; 93 | /** 94 | * Allowed columns for INSERT and UPDATE. 95 | * 96 | * @var array 97 | */ 98 | protected array $allowedFields; 99 | /** 100 | * Auto set timestamp fields. 101 | * 102 | * @var bool 103 | */ 104 | protected bool $autoTimestamps = false; 105 | /** 106 | * The timestamp field for 'created at' time when $autoTimestamps is true. 107 | * 108 | * @var string 109 | */ 110 | protected string $fieldCreated = 'createdAt'; 111 | /** 112 | * The timestamp field for 'updated at' time when $autoTimestamps is true. 113 | * 114 | * @var string 115 | */ 116 | protected string $fieldUpdated = 'updatedAt'; 117 | /** 118 | * The timestamp format used on database write operations. 119 | * 120 | * @var string 121 | */ 122 | protected string $timestampFormat = 'Y-m-d H:i:s'; 123 | /** 124 | * The Model Validation instance. 125 | */ 126 | protected Validation $validation; 127 | /** 128 | * Validation field labels. 129 | * 130 | * @var array 131 | */ 132 | protected array $validationLabels; 133 | /** 134 | * Validation error messages. 135 | * 136 | * @var array> 137 | */ 138 | protected array $validationMessages; 139 | /** 140 | * Validation rules. 141 | * 142 | * @see Validation::setRules 143 | * 144 | * @var array|string> 145 | */ 146 | protected array $validationRules; 147 | /** 148 | * Validation Validators. 149 | * 150 | * @var array 151 | */ 152 | protected array $validationValidators = [ 153 | Validator::class, 154 | FilesValidator::class, 155 | ]; 156 | /** 157 | * The Pager instance. 158 | * 159 | * Instantiated when calling the paginate method. 160 | * 161 | * @see Model::paginate 162 | * 163 | * @var Pager 164 | */ 165 | protected Pager $pager; 166 | /** 167 | * Default pager view. 168 | * 169 | * @var string 170 | */ 171 | protected string $pagerView; 172 | /** 173 | * @var string 174 | */ 175 | protected string $pagerQuery; 176 | /** 177 | * @var array|null 178 | */ 179 | protected ?array $pagerAllowedQueries = null; 180 | /** 181 | * Pager URL. 182 | * 183 | * @var string 184 | */ 185 | protected string $pagerUrl; 186 | protected bool $cacheActive = false; 187 | protected string $cacheInstance = 'default'; 188 | protected int $cacheTtl = 60; 189 | protected int | string $cacheDataNotFound = 0; 190 | protected string $languageInstance = 'default'; 191 | protected string $columnCase = 'camel'; 192 | 193 | /** 194 | * @param string $method 195 | * @param array $arguments 196 | * 197 | * @return mixed 198 | */ 199 | public function __call(string $method, array $arguments) : mixed 200 | { 201 | if (\str_starts_with($method, 'createBy')) { 202 | $method = \substr($method, 8); 203 | $method = $this->convertCase($method, $this->columnCase); 204 | return $this->createBy($method, $arguments[0]); // @phpstan-ignore-line 205 | } 206 | if (\str_starts_with($method, 'readBy')) { 207 | $method = \substr($method, 6); 208 | $method = $this->convertCase($method, $this->columnCase); 209 | return $this->readBy($method, $arguments[0]); // @phpstan-ignore-line 210 | } 211 | if (\str_starts_with($method, 'updateBy')) { 212 | $method = \substr($method, 8); 213 | $method = $this->convertCase($method, $this->columnCase); 214 | return $this->updateBy($method, $arguments[0], $arguments[1]); // @phpstan-ignore-line 215 | } 216 | if (\str_starts_with($method, 'deleteBy')) { 217 | $method = \substr($method, 8); 218 | $method = $this->convertCase($method, $this->columnCase); 219 | return $this->deleteBy($method, $arguments[0]); // @phpstan-ignore-line 220 | } 221 | if (\str_starts_with($method, 'replaceBy')) { 222 | $method = \substr($method, 9); 223 | $method = $this->convertCase($method, $this->columnCase); 224 | return $this->replaceBy($method, $arguments[0], $arguments[1]); // @phpstan-ignore-line 225 | } 226 | if (\str_starts_with($method, 'findBy')) { 227 | $method = \substr($method, 6); 228 | $method = $this->convertCase($method, $this->columnCase); 229 | return $this->findBy($method, $arguments[0]); // @phpstan-ignore-line 230 | } 231 | $class = static::class; 232 | if (\method_exists($this, $method)) { 233 | throw new BadMethodCallException( 234 | "Method not allowed: {$class}::{$method}" 235 | ); 236 | } 237 | throw new BadMethodCallException("Method not found: {$class}::{$method}"); 238 | } 239 | 240 | /** 241 | * Convert a value to specific case. 242 | * 243 | * @param string $value 244 | * @param string $case camel, pascal or snake 245 | * 246 | * @return string The converted value 247 | */ 248 | protected function convertCase(string $value, string $case) : string 249 | { 250 | if ($case === 'camel' || $case === 'pascal') { 251 | $value = \preg_replace('/([a-z])([A-Z])/', '\1 \2', $value); 252 | $value = \preg_replace('@[^a-zA-Z0-9\-_ ]+@', '', $value); 253 | $value = \str_replace(['-', '_'], ' ', $value); 254 | $value = \str_replace(' ', '', \ucwords(\strtolower($value))); 255 | $value = \strtolower($value[0]) . \substr($value, 1); 256 | return $case === 'camel' ? \lcfirst($value) : \ucfirst($value); 257 | } 258 | if ($case === 'snake') { 259 | $value = \preg_replace('/([a-z])([A-Z])/', '\1_\2', $value); 260 | return \strtolower($value); 261 | } 262 | throw new InvalidArgumentException('Invalid case: ' . $case); 263 | } 264 | 265 | #[Pure] 266 | protected function getConnectionRead() : string 267 | { 268 | return $this->connectionRead; 269 | } 270 | 271 | #[Pure] 272 | protected function getConnectionWrite() : string 273 | { 274 | return $this->connectionWrite; 275 | } 276 | 277 | protected function getTable() : string 278 | { 279 | return $this->table ??= $this->makeTableName(); 280 | } 281 | 282 | protected function makeTableName() : string 283 | { 284 | $name = static::class; 285 | $pos = \strrpos($name, '\\'); 286 | if ($pos) { 287 | $name = \substr($name, $pos + 1); 288 | } 289 | if (\str_ends_with($name, 'Model')) { 290 | $name = \substr($name, 0, -5); 291 | } 292 | return $name; 293 | } 294 | 295 | #[Pure] 296 | protected function getPrimaryKey() : string 297 | { 298 | return $this->primaryKey; 299 | } 300 | 301 | #[Pure] 302 | protected function isProtectPrimaryKey() : bool 303 | { 304 | return $this->protectPrimaryKey; 305 | } 306 | 307 | #[Pure] 308 | protected function getReturnType() : string 309 | { 310 | return $this->returnType; 311 | } 312 | 313 | /** 314 | * @return array 315 | */ 316 | protected function getAllowedFields() : array 317 | { 318 | if (empty($this->allowedFields)) { 319 | throw new LogicException( 320 | 'Allowed fields not defined for database writes' 321 | ); 322 | } 323 | return $this->allowedFields; 324 | } 325 | 326 | #[Pure] 327 | protected function isAutoTimestamps() : bool 328 | { 329 | return $this->autoTimestamps; 330 | } 331 | 332 | #[Pure] 333 | protected function getFieldCreated() : string 334 | { 335 | return $this->fieldCreated; 336 | } 337 | 338 | #[Pure] 339 | protected function getFieldUpdated() : string 340 | { 341 | return $this->fieldUpdated; 342 | } 343 | 344 | #[Pure] 345 | protected function getTimestampFormat() : string 346 | { 347 | return $this->timestampFormat; 348 | } 349 | 350 | protected function getLanguageInstance() : string 351 | { 352 | return $this->languageInstance; 353 | } 354 | 355 | protected function getLanguage() : Language 356 | { 357 | return App::language($this->getLanguageInstance()); 358 | } 359 | 360 | protected function checkPrimaryKey(int | string $id) : void 361 | { 362 | if (empty($id)) { 363 | throw new InvalidArgumentException( 364 | 'Primary Key can not be empty' 365 | ); 366 | } 367 | } 368 | 369 | /** 370 | * @template T 371 | * 372 | * @param array $data 373 | * 374 | * @return array 375 | */ 376 | protected function filterAllowedFields(array $data) : array 377 | { 378 | $fields = \array_intersect_key($data, \array_flip($this->getAllowedFields())); 379 | if ($this->isProtectPrimaryKey() !== false 380 | && \array_key_exists($this->getPrimaryKey(), $fields) 381 | ) { 382 | throw new LogicException( 383 | 'Protected Primary Key field can not be SET' 384 | ); 385 | } 386 | return $fields; 387 | } 388 | 389 | /** 390 | * @see Model::$connectionRead 391 | * 392 | * @return Database 393 | */ 394 | protected function getDatabaseToRead() : Database 395 | { 396 | return App::database($this->getConnectionRead()); 397 | } 398 | 399 | /** 400 | * @see Model::$connectionWrite 401 | * 402 | * @return Database 403 | */ 404 | protected function getDatabaseToWrite() : Database 405 | { 406 | return App::database($this->getConnectionWrite()); 407 | } 408 | 409 | /** 410 | * A basic function to count rows in the table. 411 | * 412 | * @param array> $where Array in this format: `[['id', '=', 25]]` 413 | * 414 | * @see Where 415 | * 416 | * @return int 417 | */ 418 | public function count(array $where = []) : int 419 | { 420 | $select = $this->getDatabaseToRead() 421 | ->select() 422 | ->expressions([ 423 | 'count' => static function () : string { 424 | return 'COUNT(*)'; 425 | }, 426 | ]) 427 | ->from($this->getTable()); 428 | foreach ($where as $args) { 429 | $select->where(...$args); 430 | } 431 | return $select->run()->fetch()->count; // @phpstan-ignore-line 432 | } 433 | 434 | /** 435 | * @param int $page 436 | * @param int $perPage 437 | * 438 | * @see Model::paginate 439 | * 440 | * @return array 441 | */ 442 | #[ArrayShape([0 => 'int', 1 => 'int|null'])] 443 | #[Pure] 444 | protected function makePageLimitAndOffset(int $page, int $perPage = 10) : array 445 | { 446 | $page = $this->sanitizePageNumber($page); 447 | $perPage = $this->sanitizePageNumber($perPage); 448 | $page = $page <= 1 ? null : $page * $perPage - $perPage; 449 | if ($page > \PHP_INT_MAX) { 450 | $page = \PHP_INT_MAX; 451 | } 452 | if ($perPage === \PHP_INT_MAX && $page !== null) { 453 | $page = \PHP_INT_MAX; 454 | } 455 | return [ 456 | $perPage, 457 | $page, 458 | ]; 459 | } 460 | 461 | protected function sanitizePageNumber(int $number) : int 462 | { 463 | if ($number < 0) { 464 | if ($number === \PHP_INT_MIN) { 465 | $number++; 466 | } 467 | $number *= -1; 468 | } 469 | return $number; 470 | } 471 | 472 | /** 473 | * A basic function to paginate all rows of the table. 474 | * 475 | * @param mixed $page The current page 476 | * @param mixed $perPage Items per page 477 | * @param array|string $orderBy Order by columns 478 | * @param string $orderByDirection asc or desc 479 | * @param array> $where Array in this format: `[['id', '=', 25]]` 480 | * 481 | * @see Where 482 | * 483 | * @return array|stdClass> 484 | */ 485 | public function paginate( 486 | mixed $page, 487 | mixed $perPage = 10, 488 | array $where = [], 489 | array | string | null $orderBy = null, 490 | string $orderByDirection = 'asc', 491 | ) : array { 492 | $page = Pager::sanitize($page); 493 | $perPage = Pager::sanitize($perPage); 494 | $select = $this->getDatabaseToRead() 495 | ->select() 496 | ->from($this->getTable()) 497 | ->limit(...$this->makePageLimitAndOffset($page, $perPage)); 498 | if ($where) { 499 | foreach ($where as $args) { 500 | $select->where(...$args); 501 | } 502 | } 503 | if ($orderBy !== null) { 504 | $orderBy = (array) $orderBy; 505 | $orderByDir = \strtolower($orderByDirection); 506 | if (!\in_array($orderByDir, [ 507 | 'asc', 508 | 'desc', 509 | ])) { 510 | throw new InvalidArgumentException( 511 | 'Invalid ORDER BY direction: ' . $orderByDirection 512 | ); 513 | } 514 | $orderByDir === 'asc' 515 | ? $select->orderByAsc(...$orderBy) 516 | : $select->orderByDesc(...$orderBy); 517 | } 518 | $data = $select->run()->fetchArrayAll(); 519 | foreach ($data as &$row) { 520 | $row = $this->makeEntity($row); 521 | } 522 | unset($row); 523 | $this->setPager(new Pager($page, $perPage, $this->count($where))); 524 | return $data; 525 | } 526 | 527 | /** 528 | * Set the Pager. 529 | * 530 | * @param Pager $pager 531 | * 532 | * @return static 533 | */ 534 | protected function setPager(Pager $pager) : static 535 | { 536 | $pager->setLanguage($this->getLanguage()); 537 | $temp = $this->getPagerQuery(); 538 | if (isset($temp)) { 539 | $pager->setQuery($temp); 540 | } 541 | $temp = $this->getPagerUrl(); 542 | if (isset($temp)) { 543 | $pager->setUrl($temp); 544 | } 545 | $pager->setAllowedQueries($this->getPagerAllowedQueries()); 546 | $temp = $this->getPagerView(); 547 | if (isset($temp)) { 548 | $pager->setDefaultView($temp); 549 | } 550 | $this->pager = $pager; 551 | return $this; 552 | } 553 | 554 | /** 555 | * Get the custom URL to be used in the Pager. 556 | * 557 | * @return string|null 558 | */ 559 | protected function getPagerUrl() : ?string 560 | { 561 | return $this->pagerUrl ?? null; 562 | } 563 | 564 | /** 565 | * Get the custom view to be used in the Pager. 566 | * 567 | * @return string|null 568 | */ 569 | protected function getPagerView() : ?string 570 | { 571 | return $this->pagerView ?? null; 572 | } 573 | 574 | /** 575 | * Get the custom query to be used in the Pager. 576 | * 577 | * @return string|null 578 | */ 579 | protected function getPagerQuery() : ?string 580 | { 581 | return $this->pagerQuery ?? null; 582 | } 583 | 584 | /** 585 | * Get allowed queries to be used in the Pager. 586 | * 587 | * @return array|null 588 | */ 589 | protected function getPagerAllowedQueries() : ?array 590 | { 591 | return $this->pagerAllowedQueries; 592 | } 593 | 594 | /** 595 | * Get the Pager. 596 | * 597 | * Allowed only after calling a method that sets the Pager. 598 | * 599 | * @see Model::paginate() 600 | * 601 | * @return Pager 602 | */ 603 | public function getPager() : Pager 604 | { 605 | return $this->pager; 606 | } 607 | 608 | /** 609 | * Read a row by column name and value. 610 | * 611 | * @since 3.6 612 | * 613 | * @param int|string $value 614 | * @param string $column 615 | * 616 | * @return Entity|array|stdClass|null 617 | */ 618 | public function readBy( 619 | string $column, 620 | int | string $value 621 | ) : Entity | array | stdClass | null { 622 | if ($this->isCacheActive()) { 623 | return $this->readWithCache($column, $value); 624 | } 625 | $data = $this->readRow($column, $value); 626 | return $data ? $this->makeEntity($data) : null; 627 | } 628 | 629 | /** 630 | * Alias of {@see Model::readBy()}. 631 | * 632 | * Find a row by column name and value. 633 | * 634 | * @param string $column 635 | * @param int|string $value 636 | * 637 | * @return Entity|array|stdClass|null 638 | */ 639 | public function findBy( 640 | string $column, 641 | int | string $value 642 | ) : Entity | array | stdClass | null { 643 | return $this->readBy($column, $value); 644 | } 645 | 646 | /** 647 | * Read a row based on Primary Key. 648 | * 649 | * @since 3.6 650 | * 651 | * @param int|string $id 652 | * 653 | * @return Entity|array|stdClass|null The 654 | * selected row as configured on $returnType property or null if row was 655 | * not found 656 | */ 657 | public function read(int | string $id) : Entity | array | stdClass | null 658 | { 659 | $this->checkPrimaryKey($id); 660 | return $this->readBy($this->getPrimaryKey(), $id); 661 | } 662 | 663 | /** 664 | * Alias of {@see Model::read()}. 665 | * 666 | * @param int|string $id 667 | * 668 | * @return Entity|array|float[]|int[]|null[]|stdClass|string[]|null 669 | */ 670 | public function find(int | string $id) : Entity | array | stdClass | null 671 | { 672 | return $this->read($id); 673 | } 674 | 675 | /** 676 | * @since 3.6 677 | * 678 | * @param int|string $value 679 | * @param string $column 680 | * 681 | * @return array|null 682 | */ 683 | protected function readRow(string $column, int | string $value) : ?array 684 | { 685 | return $this->getDatabaseToRead() 686 | ->select() 687 | ->from($this->getTable()) 688 | ->whereEqual($column, $value) 689 | ->limit(1) 690 | ->run() 691 | ->fetchArray(); 692 | } 693 | 694 | /** 695 | * @since 3.6 696 | * 697 | * @param int|string $value 698 | * @param string $column 699 | * 700 | * @return Entity|array|stdClass|null 701 | */ 702 | protected function readWithCache(string $column, int | string $value) : Entity | array | stdClass | null 703 | { 704 | $cacheKey = $this->getCacheKey([ 705 | $column => $value, 706 | ]); 707 | $data = $this->getCache()->get($cacheKey); 708 | if ($data === $this->getCacheDataNotFound()) { 709 | return null; 710 | } 711 | if (\is_array($data)) { 712 | return $this->makeEntity($data); 713 | } 714 | $data = $this->readRow($column, $value); 715 | if ($data === null) { 716 | $data = $this->getCacheDataNotFound(); 717 | } 718 | $this->getCache()->set($cacheKey, $data, $this->getCacheTtl()); 719 | return \is_array($data) ? $this->makeEntity($data) : null; 720 | } 721 | 722 | /** 723 | * Alias of {@see Model::list()}. 724 | * 725 | * Find all rows with limit and offset. 726 | * 727 | * @param int|null $limit 728 | * @param int|null $offset 729 | * 730 | * @return array|stdClass> 731 | */ 732 | public function findAll(?int $limit = null, ?int $offset = null) : array 733 | { 734 | return $this->list($limit, $offset); 735 | } 736 | 737 | /** 738 | * List rows, optionally with limit and offset. 739 | * 740 | * @since 3.6 741 | * 742 | * @param int|null $offset 743 | * @param int|null $limit 744 | * 745 | * @return array|stdClass> 746 | */ 747 | public function list(?int $limit = null, ?int $offset = null) : array 748 | { 749 | $data = $this->getDatabaseToRead() 750 | ->select() 751 | ->from($this->getTable()); 752 | if ($limit !== null) { 753 | $data->limit($limit, $offset); 754 | } 755 | $data = $data->run()->fetchArrayAll(); 756 | foreach ($data as &$row) { 757 | $row = $this->makeEntity($row); 758 | } 759 | unset($row); 760 | return $data; 761 | } 762 | 763 | /** 764 | * @param array $data 765 | * 766 | * @return Entity|array|stdClass 767 | */ 768 | protected function makeEntity(array $data) : Entity | array | stdClass 769 | { 770 | $returnType = $this->getReturnType(); 771 | if ($returnType === 'array') { 772 | return $data; 773 | } 774 | if ($returnType === 'object' || $returnType === stdClass::class) { 775 | return (object) $data; 776 | } 777 | return new $returnType($data); // @phpstan-ignore-line 778 | } 779 | 780 | /** 781 | * @param Entity|array|stdClass $data 782 | * 783 | * @return array 784 | */ 785 | protected function makeArray(Entity | array | stdClass $data) : array 786 | { 787 | return $data instanceof Entity 788 | ? $data->toModel() 789 | : (array) $data; 790 | } 791 | 792 | /** 793 | * Used to auto set the timestamp fields. 794 | * 795 | * @throws Exception if a DateTime error occur 796 | * 797 | * @return string The timestamp in the $timestampFormat property format 798 | */ 799 | protected function getTimestamp() : string 800 | { 801 | return (new DateTime('now', $this->timezone()))->format( 802 | $this->getTimestampFormat() 803 | ); 804 | } 805 | 806 | /** 807 | * Get the timezone from database write connection config. As fallback, uses 808 | * the UTC timezone. 809 | * 810 | * @throws Exception if database config has a bad timezone 811 | * 812 | * @return DateTimeZone 813 | */ 814 | protected function timezone() : DateTimeZone 815 | { 816 | $timezone = $this->getDatabaseToWrite()->getConfig()['timezone'] ?? '+00:00'; 817 | return new DateTimeZone($timezone); 818 | } 819 | 820 | /** 821 | * Insert a new row. 822 | * 823 | * @param Entity|array|stdClass $data 824 | * 825 | * @return false|int|string The LAST_INSERT_ID() on success or false if 826 | * validation fail 827 | */ 828 | public function create(Entity | array | stdClass $data) : false | int | string 829 | { 830 | $data = $this->makeArray($data); 831 | if ($this->getValidation()->validate($data) === false) { 832 | return false; 833 | } 834 | $data = $this->filterAllowedFields($data); 835 | if ($this->isAutoTimestamps()) { 836 | $timestamp = $this->getTimestamp(); 837 | $data[$this->getFieldCreated()] ??= $timestamp; 838 | $data[$this->getFieldUpdated()] ??= $timestamp; 839 | } 840 | $database = $this->getDatabaseToWrite(); 841 | try { 842 | $affectedRows = $database->insert() 843 | ->into($this->getTable()) 844 | ->set($data) 845 | ->run(); 846 | } catch (mysqli_sql_exception $exception) { 847 | $this->checkMysqliException($exception); 848 | return false; 849 | } 850 | $insertId = $affectedRows > 0 // $affectedRows is -1 if fail with MYSQLI_REPORT_OFF 851 | ? $database->getInsertId() 852 | : false; 853 | if ($insertId && $this->isCacheActive()) { 854 | $this->updateCachedRow($this->getPrimaryKey(), $insertId); 855 | } 856 | return $insertId; 857 | } 858 | 859 | /** 860 | * Insert a new row and return the inserted column value. 861 | * 862 | * @param string $column Column name 863 | * @param Entity|array|stdClass $data 864 | * 865 | * @return false|int|string The value from the column data or false if 866 | * validation fail 867 | */ 868 | public function createBy(string $column, Entity | array | stdClass $data) : false | int | string 869 | { 870 | $data = $this->makeArray($data); 871 | if ($this->getValidation()->validate($data) === false) { 872 | return false; 873 | } 874 | $data = $this->filterAllowedFields($data); 875 | if (!isset($data[$column])) { 876 | throw new LogicException('Value of column ' . $column . ' is not set'); 877 | } 878 | if ($this->isAutoTimestamps()) { 879 | $timestamp = $this->getTimestamp(); 880 | $data[$this->getFieldCreated()] ??= $timestamp; 881 | $data[$this->getFieldUpdated()] ??= $timestamp; 882 | } 883 | try { 884 | $this->getDatabaseToWrite()->insert() 885 | ->into($this->getTable()) 886 | ->set($data) 887 | ->run(); 888 | } catch (mysqli_sql_exception $exception) { 889 | $this->checkMysqliException($exception); 890 | return false; 891 | } 892 | if ($this->isCacheActive()) { 893 | $this->updateCachedRow($column, $data[$column]); 894 | } 895 | return $data[$column]; 896 | } 897 | 898 | /** 899 | * @param mysqli_sql_exception $exception 900 | * 901 | * @throws mysqli_sql_exception if message is not for duplicate entry 902 | */ 903 | protected function checkMysqliException(mysqli_sql_exception $exception) : void 904 | { 905 | $message = $exception->getMessage(); 906 | if (\str_starts_with($message, 'Duplicate entry')) { 907 | $this->setDuplicateEntryError($message); 908 | return; 909 | } 910 | throw $exception; 911 | } 912 | 913 | /** 914 | * Set "Duplicate entry" as 'unique' error in the Validation. 915 | * 916 | * NOTE: We will get the index key name and not the column name. Usually the 917 | * names are the same. If table have different column and index names, 918 | * override this method and get the column name from the information_schema 919 | * table. 920 | * 921 | * @param string $message The "Duplicate entry" message from the mysqli_sql_exception 922 | */ 923 | protected function setDuplicateEntryError(string $message) : void 924 | { 925 | $field = \rtrim($message, "'"); 926 | $field = \substr($field, \strrpos($field, "'") + 1); 927 | if ($field === 'PRIMARY') { 928 | $field = $this->getPrimaryKey(); 929 | } 930 | $validation = $this->getValidation(); 931 | $validation->setError($field, 'unique'); 932 | $validation->getDebugCollector() 933 | ?->setErrorInDebugData($field, $validation->getError($field)); 934 | } 935 | 936 | /** 937 | * @param string $column 938 | * @param int|string $value 939 | */ 940 | protected function updateCachedRow(string $column, int | string $value) : void 941 | { 942 | $data = $this->readRow($column, $value); 943 | if ($data === null) { 944 | $data = $this->getCacheDataNotFound(); 945 | } 946 | $this->getCache()->set( 947 | $this->getCacheKey([$column => $value]), 948 | $data, 949 | $this->getCacheTtl() 950 | ); 951 | } 952 | 953 | /** 954 | * Save a row. Update if the Primary Key is present, otherwise 955 | * insert a new row. 956 | * 957 | * @param Entity|array|stdClass $data 958 | * 959 | * @return false|int|string The number of affected rows on updates as int, the 960 | * LAST_INSERT_ID() as int on inserts or false if validation fails 961 | */ 962 | public function save(Entity | array | stdClass $data) : false | int | string 963 | { 964 | $data = $this->makeArray($data); 965 | $id = $data[$this->getPrimaryKey()] ?? null; 966 | $data = $this->filterAllowedFields($data); 967 | if ($id !== null) { 968 | return $this->update($id, $data); 969 | } 970 | return $this->create($data); 971 | } 972 | 973 | /** 974 | * Update based on Primary Key and return the number of affected rows. 975 | * 976 | * @param int|string $id 977 | * @param Entity|array|stdClass $data 978 | * 979 | * @return false|int|string The number of affected rows or false if 980 | * validation fails 981 | */ 982 | public function update(int | string $id, Entity | array | stdClass $data) : false | int | string 983 | { 984 | $this->checkPrimaryKey($id); 985 | return $this->updateBy($this->getPrimaryKey(), $id, $data); 986 | } 987 | 988 | /** 989 | * Update based on column value and return the number of affected rows. 990 | * 991 | * @param string $column 992 | * @param int|string $value 993 | * @param Entity|array|stdClass $data 994 | * 995 | * @return false|int|string The number of affected rows or false if 996 | * validation fails 997 | */ 998 | public function updateBy( 999 | string $column, 1000 | int | string $value, 1001 | Entity | array | stdClass $data 1002 | ) : false | int | string { 1003 | $data = $this->makeArray($data); 1004 | $data[$column] ??= $value; 1005 | if ($this->getValidation()->validateOnly($data) === false) { 1006 | return false; 1007 | } 1008 | $data = $this->filterAllowedFields($data); 1009 | if ($this->isAutoTimestamps()) { 1010 | $data[$this->getFieldUpdated()] ??= $this->getTimestamp(); 1011 | } 1012 | try { 1013 | $affectedRows = $this->getDatabaseToWrite() 1014 | ->update() 1015 | ->table($this->getTable()) 1016 | ->set($data) 1017 | ->whereEqual($column, $value) 1018 | ->run(); 1019 | } catch (mysqli_sql_exception $exception) { 1020 | $this->checkMysqliException($exception); 1021 | return false; 1022 | } 1023 | if ($this->isCacheActive()) { 1024 | $this->updateCachedRow($column, $value); 1025 | } 1026 | return $affectedRows; 1027 | } 1028 | 1029 | /** 1030 | * Replace based on Primary Key and return the number of affected rows. 1031 | * 1032 | * Most used with HTTP PUT method. 1033 | * 1034 | * @param int|string $id 1035 | * @param Entity|array|stdClass $data 1036 | * 1037 | * @return false|int|string The number of affected rows or false if 1038 | * validation fails 1039 | */ 1040 | public function replace(int | string $id, Entity | array | stdClass $data) : false | int | string 1041 | { 1042 | $this->checkPrimaryKey($id); 1043 | return $this->replaceBy($this->getPrimaryKey(), $id, $data); 1044 | } 1045 | 1046 | /** 1047 | * Replace based on column value and return the number of affected rows. 1048 | * 1049 | * @param string $column 1050 | * @param int|string $value 1051 | * @param Entity|array|stdClass $data 1052 | * 1053 | * @return false|int|string The number of affected rows or false if 1054 | * validation fails 1055 | */ 1056 | public function replaceBy( 1057 | string $column, 1058 | int | string $value, 1059 | Entity | array | stdClass $data 1060 | ) : false | int | string { 1061 | $data = $this->makeArray($data); 1062 | $data[$column] ??= $value; 1063 | if ($this->getValidation()->validate($data) === false) { 1064 | return false; 1065 | } 1066 | $data = $this->filterAllowedFields($data); 1067 | $data[$column] = $value; 1068 | if ($this->isAutoTimestamps()) { 1069 | $timestamp = $this->getTimestamp(); 1070 | $data[$this->getFieldCreated()] ??= $timestamp; 1071 | $data[$this->getFieldUpdated()] ??= $timestamp; 1072 | } 1073 | $affectedRows = $this->getDatabaseToWrite() 1074 | ->replace() 1075 | ->into($this->getTable()) 1076 | ->set($data) 1077 | ->run(); 1078 | if ($this->isCacheActive()) { 1079 | $this->updateCachedRow($column, $value); 1080 | } 1081 | return $affectedRows; 1082 | } 1083 | 1084 | /** 1085 | * Delete based on Primary Key. 1086 | * 1087 | * @param int|string $id 1088 | * 1089 | * @return false|int|string The number of affected rows 1090 | */ 1091 | public function delete(int | string $id) : false | int | string 1092 | { 1093 | $this->checkPrimaryKey($id); 1094 | return $this->deleteBy($this->getPrimaryKey(), $id); 1095 | } 1096 | 1097 | /** 1098 | * Delete based on column value. 1099 | * 1100 | * @param string $column 1101 | * @param int|string $value 1102 | * 1103 | * @return false|int|string The number of affected rows 1104 | */ 1105 | public function deleteBy(string $column, int | string $value) : false | int | string 1106 | { 1107 | $affectedRows = $this->getDatabaseToWrite() 1108 | ->delete() 1109 | ->from($this->getTable()) 1110 | ->whereEqual($column, $value) 1111 | ->run(); 1112 | if ($this->isCacheActive()) { 1113 | $this->getCache()->delete( 1114 | $this->getCacheKey([$column => $value]) 1115 | ); 1116 | } 1117 | return $affectedRows; 1118 | } 1119 | 1120 | protected function getValidation() : Validation 1121 | { 1122 | if (isset($this->validation)) { 1123 | return $this->validation; 1124 | } 1125 | $this->validation = new Validation( 1126 | $this->getValidationValidators(), 1127 | $this->getLanguage() 1128 | ); 1129 | $this->validation->setRules($this->getValidationRules()) 1130 | ->setLabels($this->getValidationLabels()) 1131 | ->setMessages($this->getValidationMessages()); 1132 | if (App::isDebugging()) { 1133 | $name = 'model ' . static::class; 1134 | $collector = new ValidationCollector($name); 1135 | App::debugger()->addCollector($collector, 'Validation'); 1136 | $this->validation->setDebugCollector($collector); 1137 | } 1138 | return $this->validation; 1139 | } 1140 | 1141 | /** 1142 | * @return array 1143 | */ 1144 | protected function getValidationLabels() : array 1145 | { 1146 | return $this->validationLabels ?? []; 1147 | } 1148 | 1149 | /** 1150 | * @return array> 1151 | */ 1152 | public function getValidationMessages() : array 1153 | { 1154 | return $this->validationMessages ?? []; 1155 | } 1156 | 1157 | /** 1158 | * @return array|string> 1159 | */ 1160 | protected function getValidationRules() : array 1161 | { 1162 | if (!isset($this->validationRules)) { 1163 | throw new RuntimeException('Validation rules are not set'); 1164 | } 1165 | return $this->validationRules; 1166 | } 1167 | 1168 | /** 1169 | * @return array 1170 | */ 1171 | public function getValidationValidators() : array 1172 | { 1173 | return $this->validationValidators; 1174 | } 1175 | 1176 | /** 1177 | * Get Validation errors. 1178 | * 1179 | * @return array 1180 | */ 1181 | public function getErrors() : array 1182 | { 1183 | return $this->getValidation()->getErrors(); 1184 | } 1185 | 1186 | #[Pure] 1187 | protected function isCacheActive() : bool 1188 | { 1189 | return $this->cacheActive; 1190 | } 1191 | 1192 | #[Pure] 1193 | protected function getCacheInstance() : string 1194 | { 1195 | return $this->cacheInstance; 1196 | } 1197 | 1198 | #[Pure] 1199 | protected function getCacheTtl() : int 1200 | { 1201 | return $this->cacheTtl; 1202 | } 1203 | 1204 | #[Pure] 1205 | protected function getCacheDataNotFound() : int | string 1206 | { 1207 | return $this->cacheDataNotFound; 1208 | } 1209 | 1210 | protected function getCache() : Cache 1211 | { 1212 | return App::cache($this->getCacheInstance()); 1213 | } 1214 | 1215 | /** 1216 | * @param array $fields 1217 | * 1218 | * @return string 1219 | */ 1220 | protected function getCacheKey(array $fields) : string 1221 | { 1222 | \ksort($fields); 1223 | $suffix = []; 1224 | foreach ($fields as $field => $value) { 1225 | $suffix[] = $field . '=' . $value; 1226 | } 1227 | $suffix = \implode(';', $suffix); 1228 | return 'Model:' . static::class . '::' . $suffix; 1229 | } 1230 | 1231 | /** 1232 | * Get same Model instance. 1233 | * 1234 | * @template T of Model 1235 | * 1236 | * @since 4 1237 | * 1238 | * @param class-string $class 1239 | * 1240 | * @return T 1241 | */ 1242 | public static function get(string $class) : Model 1243 | { 1244 | if (!isset(static::$models[$class])) { 1245 | static::$models[$class] = new $class(); 1246 | } 1247 | return static::$models[$class]; 1248 | } 1249 | } 1250 | -------------------------------------------------------------------------------- /src/ModelInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\MVC; 11 | 12 | use stdClass; 13 | 14 | /** 15 | * Interface ModelInterface. 16 | * 17 | * @package mvc 18 | * 19 | * @see https://en.wikipedia.org/wiki/Create,_read,_update_and_delete 20 | */ 21 | interface ModelInterface 22 | { 23 | /** 24 | * Create a new item. 25 | * 26 | * @param Entity|array|stdClass $data 27 | * 28 | * @return false|int|string The created item id on success or false if it could not 29 | * be created 30 | */ 31 | public function create(Entity | array | stdClass $data) : false | int | string; 32 | 33 | /** 34 | * Read an item based on id. 35 | * 36 | * @since 3.6 37 | * 38 | * @param int|string $id 39 | * 40 | * @return Entity|array|stdClass|null The 41 | * item as array, Entity or stdClass or null if the item was not found 42 | */ 43 | public function read(int | string $id) : Entity | array | stdClass | null; 44 | 45 | /** 46 | * Update based on id and return the number of updated items. 47 | * 48 | * @param int|string $id 49 | * @param Entity|array|stdClass $data 50 | * 51 | * @return false|int|string The number of updated items or false if it could 52 | * not be updated 53 | */ 54 | public function update(int | string $id, Entity | array | stdClass $data) : false | int | string; 55 | 56 | /** 57 | * Delete based on id. 58 | * 59 | * @param int|string $id 60 | * 61 | * @return false|int|string The number of deleted items or false if it could not be 62 | * deleted 63 | */ 64 | public function delete(int | string $id) : false | int | string; 65 | } 66 | -------------------------------------------------------------------------------- /src/Validator.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\MVC; 11 | 12 | use Framework\Helpers\ArraySimple; 13 | use LogicException; 14 | 15 | /** 16 | * Class Validator. 17 | * 18 | * @package mvc 19 | */ 20 | class Validator extends \Framework\Validation\Validator 21 | { 22 | /** 23 | * Validates database table not unique value. 24 | * 25 | * @param string $field 26 | * @param array $data 27 | * @param string $tableColumn 28 | * @param string $ignoreColumn 29 | * @param int|string $ignoreValue 30 | * @param string $connection 31 | * 32 | * @return bool 33 | */ 34 | public static function notUnique( 35 | string $field, 36 | array $data, 37 | string $tableColumn, 38 | string $ignoreColumn = '', 39 | int | string $ignoreValue = '', 40 | string $connection = 'default' 41 | ) : bool { 42 | return !static::unique( 43 | $field, 44 | $data, 45 | $tableColumn, 46 | $ignoreColumn, 47 | $ignoreValue, 48 | $connection 49 | ); 50 | } 51 | 52 | /** 53 | * Validates database table unique value. 54 | * 55 | * You can ignore rows where a column has a certain value. 56 | * Useful when updating a row in the database. 57 | * 58 | * @param string $field 59 | * @param array $data 60 | * @param string $tableColumn 61 | * @param string $ignoreColumn 62 | * @param int|string $ignoreValue 63 | * @param string $connection 64 | * 65 | * @return bool 66 | */ 67 | public static function unique( 68 | string $field, 69 | array $data, 70 | string $tableColumn, 71 | string $ignoreColumn = '', 72 | int | string $ignoreValue = '', 73 | string $connection = 'default' 74 | ) : bool { 75 | $value = static::getData($field, $data); 76 | if ($value === null) { 77 | return false; 78 | } 79 | $ignoreValue = (string) $ignoreValue; 80 | [$table, $column] = \array_pad(\explode('.', $tableColumn, 2), 2, ''); 81 | if ($column === '') { 82 | $column = $field; 83 | } 84 | if ($connection === '') { 85 | throw new LogicException( 86 | 'The connection parameter must be set to be able to connect the database' 87 | ); 88 | } 89 | $statement = App::database($connection) 90 | ->select() 91 | ->expressions(['count' => static fn () => 'COUNT(*)']) 92 | ->from($table) 93 | ->whereEqual($column, $value); 94 | if ($ignoreColumn !== '' && !\preg_match('#^{(\w+)}$#', $ignoreValue)) { 95 | $statement->whereNotEqual($ignoreColumn, $ignoreValue); 96 | } 97 | return $statement->limit(1)->run()->fetch()->count < 1; // @phpstan-ignore-line 98 | } 99 | 100 | /** 101 | * Validates value exists in database table. 102 | * 103 | * @since 3.3 104 | * 105 | * @param string $field 106 | * @param array $data 107 | * @param string $tableColumn 108 | * @param string $connection 109 | * 110 | * @return bool 111 | */ 112 | public static function exist( 113 | string $field, 114 | array $data, 115 | string $tableColumn, 116 | string $connection = 'default' 117 | ) : bool { 118 | $value = static::getData($field, $data); 119 | if ($value === null) { 120 | return false; 121 | } 122 | [$table, $column] = \array_pad(\explode('.', $tableColumn, 2), 2, ''); 123 | if ($column === '') { 124 | $column = $field; 125 | } 126 | if ($connection === '') { 127 | throw new LogicException( 128 | 'The connection parameter must be set to be able to connect the database' 129 | ); 130 | } 131 | return App::database($connection) // @phpstan-ignore-line 132 | ->select() 133 | ->expressions(['count' => static fn () => 'COUNT(*)']) 134 | ->from($table) 135 | ->whereEqual($column, $value) 136 | ->limit(1) 137 | ->run() 138 | ->fetch()->count > 0; 139 | } 140 | 141 | /** 142 | * Validates many values exists in database table. 143 | * 144 | * @since 3.10 145 | * 146 | * @param string $field 147 | * @param array $data 148 | * @param string $tableColumn 149 | * @param string $connection 150 | * 151 | * @return bool 152 | */ 153 | public static function existMany( 154 | string $field, 155 | array $data, 156 | string $tableColumn, 157 | string $connection = 'default' 158 | ) : bool { 159 | $values = ArraySimple::value($field, $data); 160 | if ($values === null) { 161 | return true; 162 | } 163 | if (!\is_array($values)) { 164 | return false; 165 | } 166 | foreach ($values as $value) { 167 | if (!\is_scalar($value)) { 168 | return false; 169 | } 170 | } 171 | [$table, $column] = \array_pad(\explode('.', $tableColumn, 2), 2, ''); 172 | if ($column === '') { 173 | $column = $field; 174 | } 175 | if ($connection === '') { 176 | throw new LogicException( 177 | 'The connection parameter must be set to be able to connect the database' 178 | ); 179 | } 180 | $database = App::database($connection); 181 | foreach ($values as $value) { 182 | $count = $database // @phpstan-ignore-line 183 | ->select() 184 | ->expressions(['count' => static fn () => 'COUNT(*)']) 185 | ->from($table) 186 | ->whereEqual($column, $value) 187 | ->limit(1) 188 | ->run() 189 | ->fetch()->count; 190 | if ($count < 1) { 191 | return false; 192 | } 193 | } 194 | return true; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/View.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\MVC; 11 | 12 | use Framework\Helpers\Isolation; 13 | use Framework\MVC\Debug\ViewsCollector; 14 | use InvalidArgumentException; 15 | use LogicException; 16 | 17 | /** 18 | * Class View. 19 | * 20 | * @package mvc 21 | */ 22 | class View 23 | { 24 | protected ?string $baseDir = null; 25 | protected string $extension; 26 | protected string $layout; 27 | protected ?string $openBlock; 28 | /** 29 | * @var array 30 | */ 31 | protected array $openBlocks = []; 32 | /** 33 | * @var array 34 | */ 35 | protected array $layoutsOpen = []; 36 | /** 37 | * @var array 38 | */ 39 | protected array $blocks; 40 | protected string $currentView; 41 | protected ViewsCollector $debugCollector; 42 | protected string $layoutPrefix = ''; 43 | protected string $includePrefix = ''; 44 | protected bool $inInclude = false; 45 | protected bool $showDebugComments = true; 46 | /** 47 | * @var array 48 | */ 49 | protected array $viewsPaths = []; 50 | protected string $instanceName; 51 | protected bool $throwExceptionsInDestructor = true; 52 | 53 | public function __construct(?string $baseDir = null, string $extension = '.php') 54 | { 55 | if ($baseDir !== null) { 56 | $this->setBaseDir($baseDir); 57 | } 58 | $this->setExtension($extension); 59 | } 60 | 61 | public function __destruct() 62 | { 63 | if ($this->isThrowExceptionsInDestructor() && $this->openBlocks) { 64 | throw new LogicException( 65 | 'Trying to destruct a View instance while the following blocks stayed open: ' 66 | . \implode(', ', \array_map(static function ($name) { 67 | return "'{$name}'"; 68 | }, $this->openBlocks)) 69 | ); 70 | } 71 | } 72 | 73 | /** 74 | * Tells whether it is able to throw exceptions in the destructor. 75 | * 76 | * @since 4.2 77 | * 78 | * @return bool 79 | */ 80 | public function isThrowExceptionsInDestructor() : bool 81 | { 82 | return $this->throwExceptionsInDestructor; 83 | } 84 | 85 | /** 86 | * Enables/disables exceptions in the destructor. 87 | * 88 | * @since 4.2 89 | * 90 | * @param bool $active True to throw exceptions, false otherwise 91 | * 92 | * @return static 93 | */ 94 | public function setThrowExceptionsInDestructor(bool $active = true) : static 95 | { 96 | $this->throwExceptionsInDestructor = $active; 97 | return $this; 98 | } 99 | 100 | /** 101 | * Sets the base directory where the views files are located. 102 | * 103 | * @param string $baseDir 104 | * 105 | * @return static 106 | */ 107 | public function setBaseDir(string $baseDir) : static 108 | { 109 | $real = \realpath($baseDir); 110 | if (!$real || !\is_dir($real)) { 111 | throw new InvalidArgumentException("View base dir is not a valid directory: {$baseDir} "); 112 | } 113 | $this->baseDir = \rtrim($real, '\/ ') . \DIRECTORY_SEPARATOR; 114 | return $this; 115 | } 116 | 117 | /** 118 | * Get the base directory. 119 | * 120 | * @return string|null 121 | */ 122 | public function getBaseDir() : ?string 123 | { 124 | return $this->baseDir; 125 | } 126 | 127 | /** 128 | * Set the extension of views files. 129 | * 130 | * @param string $extension 131 | * 132 | * @return static 133 | */ 134 | public function setExtension(string $extension) : static 135 | { 136 | $this->extension = '.' . \ltrim($extension, '.'); 137 | return $this; 138 | } 139 | 140 | /** 141 | * Get the extension of view files. 142 | * 143 | * @return string 144 | */ 145 | public function getExtension() : string 146 | { 147 | return $this->extension; 148 | } 149 | 150 | /** 151 | * Set the name of a directory for layouts within the base directory. 152 | * 153 | * @param string $prefix 154 | * 155 | * @return static 156 | */ 157 | public function setLayoutPrefix(string $prefix) : static 158 | { 159 | $this->layoutPrefix = $this->makeDirectoryPrefix($prefix); 160 | return $this; 161 | } 162 | 163 | /** 164 | * Get the name of the layouts directory. 165 | * 166 | * @return string 167 | */ 168 | public function getLayoutPrefix() : string 169 | { 170 | return $this->layoutPrefix; 171 | } 172 | 173 | protected function makeDirectoryPrefix(string $prefix) : string 174 | { 175 | return $prefix === '' 176 | ? '' 177 | : \trim($prefix, '\/') . \DIRECTORY_SEPARATOR; 178 | } 179 | 180 | /** 181 | * Set the name of a directory for includes within the base directory. 182 | * 183 | * @param string $prefix 184 | * 185 | * @return static 186 | */ 187 | public function setIncludePrefix(string $prefix) : static 188 | { 189 | $this->includePrefix = $this->makeDirectoryPrefix($prefix); 190 | return $this; 191 | } 192 | 193 | /** 194 | * Get the name of the includes directory. 195 | * 196 | * @return string 197 | */ 198 | public function getIncludePrefix() : string 199 | { 200 | return $this->includePrefix; 201 | } 202 | 203 | protected function getNamespacedFilepath(string $view) : string 204 | { 205 | $path = App::locator()->getNamespacedFilepath($view, $this->getExtension()); 206 | if ($path) { 207 | return $path; 208 | } 209 | throw new InvalidArgumentException("Namespaced view path does not match a file: {$view}"); 210 | } 211 | 212 | protected function getFilepath(string $view) : string 213 | { 214 | if (isset($view[0]) && $view[0] === '\\') { 215 | return $this->getNamespacedFilepath($view); 216 | } 217 | $view = $this->getBaseDir() . $view . $this->getExtension(); 218 | $real = \realpath($view); 219 | if (!$real || !\is_file($real)) { 220 | throw new InvalidArgumentException("View path does not match a file: {$view}"); 221 | } 222 | if ($this->getBaseDir() && !\str_starts_with($real, $this->getBaseDir())) { 223 | throw new InvalidArgumentException("View path out of base directory: {$real}"); 224 | } 225 | return $real; 226 | } 227 | 228 | /** 229 | * Render a view file. 230 | * 231 | * @param string $view View path within the base directory 232 | * @param array $data Data passed to the view. The array keys 233 | * will be variables 234 | * 235 | * @return string 236 | */ 237 | public function render(string $view, array $data = []) : string 238 | { 239 | $debug = isset($this->debugCollector); 240 | if ($debug) { 241 | $start = \microtime(true); 242 | } 243 | $this->currentView = $view; 244 | $contents = $this->getContents($view, $data); 245 | if (isset($this->layout)) { 246 | $layout = $this->layout; 247 | unset($this->layout); 248 | $this->layoutsOpen[] = $layout; 249 | $contents = $this->render($layout, $data); 250 | } 251 | if ($debug) { 252 | $type = 'render'; 253 | if ($this->layoutsOpen) { 254 | \array_shift($this->layoutsOpen); 255 | $type = 'layout'; 256 | } 257 | $this->setDebugData($view, $start, $type); 258 | if ($this->isShowingDebugComments()) { 259 | $path = $this->getCommentPath($view); 260 | $contents = '' 261 | . \PHP_EOL . $contents . \PHP_EOL 262 | . ''; 263 | } 264 | } 265 | return $contents; 266 | } 267 | 268 | protected function setDebugData(string $file, float $start, string $type) : static 269 | { 270 | $end = \microtime(true); 271 | $this->debugCollector->addData([ 272 | 'start' => $start, 273 | 'end' => $end, 274 | 'file' => $file, 275 | 'filepath' => $this->getFilepath($file), 276 | 'type' => $type, 277 | ]); 278 | return $this; 279 | } 280 | 281 | /** 282 | * Extends a layout. 283 | * 284 | * @param string $layout The name of the file within the layouts directory 285 | * @param string|null $openBlock Optionally opens and closes this block automatically 286 | * 287 | * @return static 288 | */ 289 | public function extends(string $layout, ?string $openBlock = null) : static 290 | { 291 | $this->layout = $this->getLayoutPrefix() . $layout; 292 | $this->openBlock = $openBlock; 293 | if ($openBlock !== null) { 294 | $this->block($openBlock); 295 | } 296 | return $this; 297 | } 298 | 299 | /** 300 | * Extends a layout without prefix. 301 | * 302 | * @param string $layout The name of the file within the base directory 303 | * 304 | * @return static 305 | */ 306 | public function extendsWithoutPrefix(string $layout) : static 307 | { 308 | $this->layout = $layout; 309 | return $this; 310 | } 311 | 312 | /** 313 | * Tells whether the current contents is inside a layout. 314 | * 315 | * @param string $layout 316 | * 317 | * @return bool 318 | */ 319 | public function inLayout(string $layout) : bool 320 | { 321 | return isset($this->layout) && $this->layout === $layout; 322 | } 323 | 324 | /** 325 | * Open a block. 326 | * 327 | * @param string $name Block name 328 | * 329 | * @return static 330 | */ 331 | public function block(string $name) : static 332 | { 333 | $this->openBlocks[] = $name; 334 | \ob_start(); 335 | if (isset($this->debugCollector) && $this->isShowingDebugComments()) { 336 | if (isset($this->currentView)) { 337 | $name = $this->currentView . '::' . $name; 338 | $name = $this->getInstanceNameWithPath($name); 339 | } 340 | echo \PHP_EOL . '' . \PHP_EOL; 341 | } 342 | return $this; 343 | } 344 | 345 | /** 346 | * Close an open block. 347 | * 348 | * @return static 349 | */ 350 | public function endBlock() : static 351 | { 352 | if (empty($this->openBlocks)) { 353 | throw new LogicException('Trying to end a view block when none is open'); 354 | } 355 | $name = \array_pop($this->openBlocks); 356 | if (isset($this->debugCollector) && $this->isShowingDebugComments()) { 357 | $block = $name; 358 | if (isset($this->currentView)) { 359 | $block = $this->currentView . '::' . $name; 360 | $block = $this->getInstanceNameWithPath($block); 361 | } 362 | echo \PHP_EOL . '' . \PHP_EOL; 363 | } 364 | $contents = \ob_get_clean(); 365 | if (!isset($this->blocks[$name])) { 366 | $this->blocks[$name] = $contents; // @phpstan-ignore-line 367 | } 368 | return $this; 369 | } 370 | 371 | /** 372 | * Render a block. 373 | * 374 | * @param string $name Block name 375 | * 376 | * @return string|null 377 | */ 378 | public function renderBlock(string $name) : ?string 379 | { 380 | return $this->blocks[$name] ?? null; 381 | } 382 | 383 | /** 384 | * Remove a block. 385 | * 386 | * @param string $name Block name 387 | * 388 | * @return static 389 | */ 390 | public function removeBlock(string $name) : static 391 | { 392 | unset($this->blocks[$name]); 393 | return $this; 394 | } 395 | 396 | /** 397 | * Tells whether a given block is set. 398 | * 399 | * @param string $name Block name 400 | * 401 | * @return bool 402 | */ 403 | public function hasBlock(string $name) : bool 404 | { 405 | return isset($this->blocks[$name]); 406 | } 407 | 408 | /** 409 | * Tells whether the current content is inside a block. 410 | * 411 | * @param string $name Block name 412 | * 413 | * @return bool 414 | */ 415 | public function inBlock(string $name) : bool 416 | { 417 | return $this->currentBlock() === $name; 418 | } 419 | 420 | /** 421 | * Tells the name of the current block. 422 | * 423 | * @return string|null 424 | */ 425 | public function currentBlock() : ?string 426 | { 427 | if ($this->openBlocks) { 428 | return $this->openBlocks[\array_key_last($this->openBlocks)]; 429 | } 430 | return null; 431 | } 432 | 433 | /** 434 | * Returns the contents of an include. 435 | * 436 | * @param string $view The path of the file within the includes directory 437 | * @param array $data Data passed to the view. The array keys 438 | * will be variables 439 | * 440 | * @return string 441 | */ 442 | public function include(string $view, array $data = []) : string 443 | { 444 | $view = $this->getIncludePrefix() . $view; 445 | if (isset($this->debugCollector)) { 446 | return $this->getIncludeContentsWithDebug($view, $data); 447 | } 448 | return $this->getIncludeContents($view, $data); 449 | } 450 | 451 | protected function involveInclude(string $view, string $contents) : string 452 | { 453 | $path = $this->getCommentPath($view); 454 | return \PHP_EOL . '' 455 | . \PHP_EOL . $contents . \PHP_EOL 456 | . '' . \PHP_EOL; 457 | } 458 | 459 | /** 460 | * Returns the contents of an include without prefix. 461 | * 462 | * @param string $view The path of the file within the base directory 463 | * @param array $data Data passed to the view. The array keys 464 | * will be variables 465 | * 466 | * @return string 467 | */ 468 | public function includeWithoutPrefix(string $view, array $data = []) : string 469 | { 470 | if (isset($this->debugCollector)) { 471 | return $this->getIncludeContentsWithDebug($view, $data); 472 | } 473 | return $this->getIncludeContents($view, $data); 474 | } 475 | 476 | /** 477 | * @param string $view 478 | * @param array $data 479 | * 480 | * @return string 481 | */ 482 | protected function getIncludeContentsWithDebug(string $view, array $data = []) : string 483 | { 484 | $start = \microtime(true); 485 | $this->inInclude = true; 486 | $contents = $this->getContents($view, $data); 487 | $this->inInclude = false; 488 | $this->setDebugData($view, $start, 'include'); 489 | if (!$this->isShowingDebugComments()) { 490 | return $contents; 491 | } 492 | return $this->involveInclude($view, $contents); 493 | } 494 | 495 | /** 496 | * @param string $view 497 | * @param array $data 498 | * 499 | * @return string 500 | */ 501 | protected function getIncludeContents(string $view, array $data = []) : string 502 | { 503 | $this->inInclude = true; 504 | $contents = $this->getContents($view, $data); 505 | $this->inInclude = false; 506 | return $contents; 507 | } 508 | 509 | /** 510 | * @param string $view 511 | * @param array $data 512 | * 513 | * @return string 514 | */ 515 | protected function getContents(string $view, array $data) : string 516 | { 517 | $data['view'] = $this; 518 | \ob_start(); 519 | Isolation::require($this->getFilepath($view), $data); 520 | if (isset($this->openBlock) && !$this->inInclude) { 521 | $this->openBlock = null; 522 | $this->endBlock(); 523 | } 524 | return \ob_get_clean(); // @phpstan-ignore-line 525 | } 526 | 527 | public function getInstanceName() : string 528 | { 529 | return $this->instanceName; 530 | } 531 | 532 | public function getInstanceNameWithPath(string $name) : string 533 | { 534 | return $this->getInstanceName() . ':' . $name; 535 | } 536 | 537 | public function setInstanceName(string $instanceName) : static 538 | { 539 | $this->instanceName = $instanceName; 540 | return $this; 541 | } 542 | 543 | protected function getCommentPath(string $name) : string 544 | { 545 | $count = null; 546 | foreach ($this->viewsPaths as $view) { 547 | if ($view === $name) { 548 | $count++; 549 | } 550 | } 551 | $this->viewsPaths[] = $name; 552 | if ($count) { 553 | $count = ':' . ($count + 1); 554 | } 555 | return $this->getInstanceNameWithPath($name) . $count; 556 | } 557 | 558 | public function setDebugCollector(ViewsCollector $debugCollector) : static 559 | { 560 | $this->debugCollector = $debugCollector; 561 | $this->debugCollector->setView($this); 562 | return $this; 563 | } 564 | 565 | /** 566 | * Tells if it is showing debug comments when in debug mode. 567 | * 568 | * @since 3.2 569 | * 570 | * @return bool 571 | */ 572 | public function isShowingDebugComments() : bool 573 | { 574 | return $this->showDebugComments; 575 | } 576 | 577 | /** 578 | * Enable debug comments when in debug mode. 579 | * 580 | * @since 3.2 581 | * 582 | * @return static 583 | */ 584 | public function enableDebugComments() : static 585 | { 586 | $this->showDebugComments = true; 587 | return $this; 588 | } 589 | 590 | /** 591 | * Disable debug comments when in debug mode. 592 | * 593 | * @since 3.2 594 | * 595 | * @return static 596 | */ 597 | public function disableDebugComments() : static 598 | { 599 | $this->showDebugComments = false; 600 | return $this; 601 | } 602 | } 603 | --------------------------------------------------------------------------------