From 311e4299d7a123129ab0794a0b1746fdf0c6ff9e Mon Sep 17 00:00:00 2001 From: Pallavi Date: Wed, 13 Mar 2024 18:08:15 +0530 Subject: [PATCH] AC service website --- LICENSE | 22 + README.md | 59 + app/.htaccess | 6 + app/Common.php | 15 + app/Config/App.php | 179 + app/Config/Autoload.php | 99 + app/Config/Boot/development.php | 34 + app/Config/Boot/production.php | 23 + app/Config/Boot/testing.php | 38 + app/Config/CURLRequest.php | 20 + app/Config/Cache.php | 171 + app/Config/Constants.php | 94 + app/Config/ContentSecurityPolicy.php | 176 + app/Config/Cookie.php | 107 + app/Config/Database.php | 85 + app/Config/DocTypes.php | 46 + app/Config/Email.php | 121 + app/Config/Encryption.php | 92 + app/Config/Events.php | 55 + app/Config/Exceptions.php | 104 + app/Config/Feature.php | 30 + app/Config/Filters.php | 69 + app/Config/ForeignCharacters.php | 12 + app/Config/Format.php | 77 + app/Config/Generators.php | 42 + app/Config/Honeypot.php | 42 + app/Config/Images.php | 31 + app/Config/Kint.php | 66 + app/Config/Logger.php | 148 + app/Config/Migrations.php | 50 + app/Config/Mimes.php | 534 + app/Config/Modules.php | 84 + app/Config/Pager.php | 37 + app/Config/Paths.php | 80 + app/Config/Publisher.php | 28 + app/Config/Routes.php | 19 + app/Config/Routing.php | 113 + app/Config/Security.php | 101 + app/Config/Services.php | 32 + app/Config/Session.php | 102 + app/Config/Toolbar.php | 118 + app/Config/UserAgents.php | 252 + app/Config/Validation.php | 44 + app/Config/View.php | 62 + app/Controllers/BaseController.php | 58 + app/Controllers/Home.php | 11 + app/Controllers/PageController.php | 35 + app/Database/Migrations/.gitkeep | 0 app/Database/Seeds/.gitkeep | 0 app/Filters/.gitkeep | 0 app/Helpers/.gitkeep | 0 app/Language/.gitkeep | 0 app/Language/en/Validation.php | 4 + app/Libraries/.gitkeep | 0 app/Models/.gitkeep | 0 app/ThirdParty/.gitkeep | 0 app/Views/errors/cli/error_404.php | 7 + app/Views/errors/cli/error_exception.php | 74 + app/Views/errors/cli/production.php | 5 + app/Views/errors/html/debug.css | 197 + app/Views/errors/html/debug.js | 116 + app/Views/errors/html/error_404.php | 84 + app/Views/errors/html/error_exception.php | 418 + app/Views/errors/html/production.php | 25 + app/Views/pages/commonfiles/footer.php | 74 + app/Views/pages/commonfiles/header.php | 63 + app/Views/pages/commonfiles/link.php | 41 + app/Views/pages/commonfiles/script.php | 28 + app/Views/pages/commonfiles/sidebar.php | 57 + app/Views/pages/contact.php | 119 + app/Views/pages/index.php | 548 + app/Views/pages/projects.php | 176 + app/Views/pages/services.php | 153 + app/Views/welcome_message.php | 325 + app/index.html | 11 + composer.json | 67 + env | 143 + phpunit.xml.dist | 57 + preload.php | 113 + public/.htaccess | 49 + public/assets/css/animate.min.css | 3137 ++++ public/assets/css/backToTop.css | 57 + public/assets/css/bootstrap.min.css | 11584 ++++++++++++ public/assets/css/custom-animation.css | 956 + public/assets/css/default.css | 714 + public/assets/css/flaticon.css | 103 + public/assets/css/fontawesome.min.css | 5 + public/assets/css/main.css | 8645 +++++++++ public/assets/css/meanmenu.css | 263 + public/assets/css/slick.css | 119 + public/assets/css/swiper-bundle.css | 546 + public/assets/css/venobox.min.css | 1 + public/assets/favicon.png | Bin 0 -> 2562 bytes public/assets/fonts/fa-brands-400.eot | Bin 0 -> 134622 bytes public/assets/fonts/fa-brands-400.svg | 3637 ++++ public/assets/fonts/fa-brands-400.ttf | Bin 0 -> 134316 bytes public/assets/fonts/fa-brands-400.woff | Bin 0 -> 90672 bytes public/assets/fonts/fa-brands-400.woff2 | Bin 0 -> 77376 bytes public/assets/fonts/fa-brands-400d41d.eot | Bin 0 -> 134622 bytes public/assets/fonts/fa-duotone-900.eot | Bin 0 -> 566254 bytes public/assets/fonts/fa-duotone-900.svg | 15299 ++++++++++++++++ public/assets/fonts/fa-duotone-900.ttf | Bin 0 -> 565956 bytes public/assets/fonts/fa-duotone-900.woff | Bin 0 -> 264932 bytes public/assets/fonts/fa-duotone-900.woff2 | Bin 0 -> 186964 bytes public/assets/fonts/fa-duotone-900d41d.eot | Bin 0 -> 566254 bytes public/assets/fonts/fa-light-300.eot | Bin 0 -> 493182 bytes public/assets/fonts/fa-light-300.svg | 12397 +++++++++++++ public/assets/fonts/fa-light-300.ttf | Bin 0 -> 492900 bytes public/assets/fonts/fa-light-300.woff | Bin 0 -> 251516 bytes public/assets/fonts/fa-light-300.woff2 | Bin 0 -> 189452 bytes public/assets/fonts/fa-light-300d41d.eot | Bin 0 -> 493182 bytes public/assets/fonts/fa-regular-400.eot | Bin 0 -> 453734 bytes public/assets/fonts/fa-regular-400.svg | 11299 ++++++++++++ public/assets/fonts/fa-regular-400.ttf | Bin 0 -> 453440 bytes public/assets/fonts/fa-regular-400.woff | Bin 0 -> 230072 bytes public/assets/fonts/fa-regular-400.woff2 | Bin 0 -> 173220 bytes public/assets/fonts/fa-regular-400d41d.eot | Bin 0 -> 453734 bytes public/assets/fonts/fa-solid-900.eot | Bin 0 -> 387174 bytes public/assets/fonts/fa-solid-900.svg | 9634 ++++++++++ public/assets/fonts/fa-solid-900.ttf | Bin 0 -> 386892 bytes public/assets/fonts/fa-solid-900.woff | Bin 0 -> 188028 bytes public/assets/fonts/fa-solid-900.woff2 | Bin 0 -> 140704 bytes public/assets/fonts/fa-solid-900d41d.eot | Bin 0 -> 387174 bytes public/assets/fonts/flaticon4ca8.eot | Bin 0 -> 23924 bytes public/assets/fonts/flaticon4ca8.svg | 174 + public/assets/fonts/flaticon4ca8.ttf | Bin 0 -> 23756 bytes public/assets/fonts/flaticon4ca8.woff | Bin 0 -> 10368 bytes public/assets/fonts/flaticon4ca8.woff2 | Bin 0 -> 8780 bytes public/assets/img/about/about-img-1.jpg | Bin 0 -> 99794 bytes public/assets/img/about/about-img-2.jpg | Bin 0 -> 43961 bytes public/assets/img/about/about-img-3.jpg | Bin 0 -> 4705 bytes public/assets/img/about/about-img-4.jpg | Bin 0 -> 260039 bytes public/assets/img/about/video-bg.jpg | Bin 0 -> 1103764 bytes public/assets/img/about/video-bg2.jpg | Bin 0 -> 571013 bytes public/assets/img/bg/fact-bg.jpg | Bin 0 -> 948592 bytes public/assets/img/bg/fact-bg2.jpg | Bin 0 -> 210391 bytes public/assets/img/bg/feature-bg.jpg | Bin 0 -> 532845 bytes public/assets/img/bg/page-banner.jpg | Bin 0 -> 403663 bytes public/assets/img/bg/team--bg.jpg | Bin 0 -> 626884 bytes public/assets/img/bg/work--bg.jpg | Bin 0 -> 466579 bytes public/assets/img/blog/b1.jpg | Bin 0 -> 92409 bytes public/assets/img/blog/b2.jpg | Bin 0 -> 126238 bytes public/assets/img/blog/b3.jpg | Bin 0 -> 128795 bytes public/assets/img/blog/b4.jpg | Bin 0 -> 91141 bytes public/assets/img/blog/b5.jpg | Bin 0 -> 81810 bytes public/assets/img/blog/b6.jpg | Bin 0 -> 95323 bytes public/assets/img/blog/b7.jpg | Bin 0 -> 141236 bytes public/assets/img/blog/blog-img-1.jpg | Bin 0 -> 59052 bytes public/assets/img/blog/blog-img-10.jpg | Bin 0 -> 81791 bytes public/assets/img/blog/blog-img-2.jpg | Bin 0 -> 54414 bytes public/assets/img/blog/blog-img-3.jpg | Bin 0 -> 77897 bytes public/assets/img/blog/blog-img-4.jpg | Bin 0 -> 52918 bytes public/assets/img/blog/blog-img-5.jpg | Bin 0 -> 45912 bytes public/assets/img/blog/blog-img-6.jpg | Bin 0 -> 54811 bytes public/assets/img/blog/blog-img-7.jpg | Bin 0 -> 69859 bytes public/assets/img/blog/blog-img-8.jpg | Bin 0 -> 52197 bytes public/assets/img/blog/blog-img-9.jpg | Bin 0 -> 68166 bytes public/assets/img/blog/blog-page-3.jpg | Bin 0 -> 9629 bytes public/assets/img/blog/blog-sm-1.png | Bin 0 -> 7410 bytes public/assets/img/blog/blog-sm-2.png | Bin 0 -> 7993 bytes public/assets/img/blog/blog-sm-3.png | Bin 0 -> 7057 bytes public/assets/img/blog/blog-sm-4.png | Bin 0 -> 7643 bytes public/assets/img/blog/blog-sm-5.png | Bin 0 -> 6859 bytes public/assets/img/blog/blog-sm-6.png | Bin 0 -> 12303 bytes public/assets/img/blog/blog-sm-7.png | Bin 0 -> 7088 bytes public/assets/img/blog/blog-sm-8.png | Bin 0 -> 6885 bytes public/assets/img/brand/brand-1.png | Bin 0 -> 1804 bytes public/assets/img/brand/brand-10.png | Bin 0 -> 3111 bytes public/assets/img/brand/brand-2.png | Bin 0 -> 2432 bytes public/assets/img/brand/brand-3.png | Bin 0 -> 4308 bytes public/assets/img/brand/brand-4.png | Bin 0 -> 2936 bytes public/assets/img/brand/brand-5.png | Bin 0 -> 3232 bytes public/assets/img/brand/brand-6.png | Bin 0 -> 1753 bytes public/assets/img/brand/brand-7.png | Bin 0 -> 4249 bytes public/assets/img/brand/brand-8.png | Bin 0 -> 2315 bytes public/assets/img/brand/brand-9.png | Bin 0 -> 2897 bytes public/assets/img/choose/choose-bg.jpg | Bin 0 -> 281953 bytes public/assets/img/choose/choose-bg2.jpg | Bin 0 -> 68347 bytes public/assets/img/choose/choose-bg3.jpg | Bin 0 -> 600772 bytes public/assets/img/choose/choose-img-2.png | Bin 0 -> 938801 bytes public/assets/img/contact/contact-img-1.png | Bin 0 -> 133828 bytes public/assets/img/contact/contact-img-2.jpg | Bin 0 -> 193793 bytes public/assets/img/contact/contact-img-3.jpg | Bin 0 -> 154270 bytes public/assets/img/contact/contact-shape-1.png | Bin 0 -> 4434 bytes public/assets/img/contact/contact-shape-2.png | Bin 0 -> 4894 bytes public/assets/img/faq/faq-img-1.jpg | Bin 0 -> 191916 bytes public/assets/img/faq/faq-img-2.jpg | Bin 0 -> 157490 bytes public/assets/img/faq/faq-img-3.png | Bin 0 -> 109762 bytes public/assets/img/faq/faq-img-4.png | Bin 0 -> 215655 bytes public/assets/img/faq/faq-img.png | Bin 0 -> 467066 bytes public/assets/img/footer/footer-bg.jpg | Bin 0 -> 72629 bytes public/assets/img/footer/footer-gal-1.jpg | Bin 0 -> 7801 bytes public/assets/img/footer/footer-gal-2.jpg | Bin 0 -> 7882 bytes public/assets/img/footer/footer-gal-3.jpg | Bin 0 -> 6141 bytes public/assets/img/footer/footer-gal-4.jpg | Bin 0 -> 6346 bytes public/assets/img/footer/footer-gal-5.jpg | Bin 0 -> 8880 bytes public/assets/img/footer/footer-gal-6.jpg | Bin 0 -> 5171 bytes public/assets/img/footer/footer-post-1.jpg | Bin 0 -> 9331 bytes public/assets/img/footer/footer-post-2.jpg | Bin 0 -> 7073 bytes public/assets/img/gallery/gallery-img-1.jpg | Bin 0 -> 110898 bytes public/assets/img/gallery/gallery-img-2.jpg | Bin 0 -> 82052 bytes public/assets/img/gallery/gallery-img-3.jpg | Bin 0 -> 124693 bytes public/assets/img/gallery/gallery-img-4.jpg | Bin 0 -> 77962 bytes public/assets/img/hero/ddd.png | Bin 0 -> 108769 bytes public/assets/img/hero/hero-img-1.jpg | Bin 0 -> 175074 bytes public/assets/img/hero/hero-img-2.jpg | Bin 0 -> 166281 bytes public/assets/img/hero/hero-img-3.jpg | Bin 0 -> 465881 bytes public/assets/img/hero/shape.png | Bin 0 -> 5298 bytes public/assets/img/hero/slider-bg-3.jpg | Bin 0 -> 347701 bytes public/assets/img/hero/slider-bg-4.jpg | Bin 0 -> 464119 bytes public/assets/img/hero/slider-bg-5.jpg | Bin 0 -> 194234 bytes public/assets/img/icon/choose-icon-1.png | Bin 0 -> 3857 bytes public/assets/img/icon/choose-icon-2.png | Bin 0 -> 3790 bytes public/assets/img/icon/choose-icon-3.png | Bin 0 -> 4602 bytes public/assets/img/icon/contact-arrow.png | Bin 0 -> 1095 bytes public/assets/img/icon/cta-icon-1.png | Bin 0 -> 285 bytes public/assets/img/icon/cta-icon-2.png | Bin 0 -> 285 bytes public/assets/img/icon/fact-icon-1.png | Bin 0 -> 1416 bytes public/assets/img/icon/fact-icon-2.png | Bin 0 -> 2156 bytes public/assets/img/icon/fact-icon-3.png | Bin 0 -> 1040 bytes public/assets/img/icon/fact-icon-4.png | Bin 0 -> 2780 bytes public/assets/img/icon/icon-man.png | Bin 0 -> 1204 bytes public/assets/img/icon/subs-icon-1.png | Bin 0 -> 2161 bytes public/assets/img/icon/subtitle-icon.png | Bin 0 -> 1773 bytes public/assets/img/logo/aclogo.png | Bin 0 -> 16518 bytes public/assets/img/logo/logo-white.png | Bin 0 -> 3312 bytes public/assets/img/logo/logo.png | Bin 0 -> 3687 bytes .../assets/img/portfolio/portfolio-img-1.jpg | Bin 0 -> 59515 bytes .../assets/img/portfolio/portfolio-img-2.jpg | Bin 0 -> 87164 bytes .../assets/img/portfolio/portfolio-img-3.jpg | Bin 0 -> 79722 bytes .../assets/img/portfolio/portfolio-img-4.jpg | Bin 0 -> 76539 bytes .../assets/img/portfolio/portfolio-img-5.jpg | Bin 0 -> 62699 bytes .../assets/img/portfolio/portfolio-img-6.jpg | Bin 0 -> 91126 bytes .../img/portfolio/project-details-1.jpg | Bin 0 -> 157925 bytes .../img/portfolio/project-details-2.jpg | Bin 0 -> 139781 bytes .../img/portfolio/project-details-3.jpg | Bin 0 -> 212011 bytes public/assets/img/service/service-bg.jpg | Bin 0 -> 142096 bytes .../assets/img/service/service-details-2.jpg | Bin 0 -> 79602 bytes .../assets/img/service/service-details-3.jpg | Bin 0 -> 91736 bytes public/assets/img/service/service-img-1.jpg | Bin 0 -> 82145 bytes public/assets/img/service/service-img-2.jpg | Bin 0 -> 68324 bytes public/assets/img/service/service-img-3.jpg | Bin 0 -> 67377 bytes public/assets/img/service/service-img-4.jpg | Bin 0 -> 302742 bytes public/assets/img/service/service-img-5.jpg | Bin 0 -> 48279 bytes public/assets/img/service/service-img-6.jpg | Bin 0 -> 69083 bytes public/assets/img/service/service-img-7.jpg | Bin 0 -> 45966 bytes public/assets/img/service/service-img.jpg | Bin 0 -> 53941 bytes public/assets/img/service/service-tab1.jpg | Bin 0 -> 271411 bytes public/assets/img/service/service-tab3.jpg | Bin 0 -> 230645 bytes public/assets/img/service/service-tab4.jpg | Bin 0 -> 217836 bytes public/assets/img/skill/skill-img.jpg | Bin 0 -> 279516 bytes public/assets/img/team/team-details-1.jpg | Bin 0 -> 56060 bytes public/assets/img/team/team-img-1.jpg | Bin 0 -> 87633 bytes public/assets/img/team/team-img-10.jpg | Bin 0 -> 89600 bytes public/assets/img/team/team-img-11.jpg | Bin 0 -> 99262 bytes public/assets/img/team/team-img-2.jpg | Bin 0 -> 79701 bytes public/assets/img/team/team-img-3.jpg | Bin 0 -> 81466 bytes public/assets/img/team/team-img-4.jpg | Bin 0 -> 135033 bytes public/assets/img/team/team-img-5.jpg | Bin 0 -> 85299 bytes public/assets/img/team/team-img-6.jpg | Bin 0 -> 96360 bytes public/assets/img/team/team-img-7.jpg | Bin 0 -> 122722 bytes public/assets/img/team/team-img-8.jpg | Bin 0 -> 60370 bytes public/assets/img/team/team-img-9.jpg | Bin 0 -> 84674 bytes .../assets/img/testimonial/testimonial-bg.jpg | Bin 0 -> 23716 bytes .../img/testimonial/testimonial-bg2.jpg | Bin 0 -> 377358 bytes .../img/testimonial/testimonial-bg3.jpg | Bin 0 -> 684799 bytes .../img/testimonial/testimonial-img-1.jpg | Bin 0 -> 249201 bytes .../img/testimonial/testimonial-img-1.png | Bin 0 -> 592457 bytes .../img/testimonial/testimonial-img-2.png | Bin 0 -> 17955 bytes .../img/testimonial/testimonial-img-3.png | Bin 0 -> 23224 bytes .../img/testimonial/testimonial-img-4.png | Bin 0 -> 22843 bytes .../img/testimonial/testimonial-img-5.png | Bin 0 -> 16185 bytes .../img/testimonial/testimonial-img-6.png | Bin 0 -> 13702 bytes .../img/testimonial/testimonial-img-7.png | Bin 0 -> 16912 bytes .../img/testimonial/testimonial-img-8.png | Bin 0 -> 13225 bytes public/assets/img/work/dust.webp | Bin 0 -> 29872 bytes public/assets/img/work/w1.jpg | Bin 0 -> 114004 bytes public/assets/img/work/w1.png | Bin 0 -> 177564 bytes public/assets/img/work/work-bg.jpg | Bin 0 -> 441080 bytes public/assets/img/work/work-img-1.jpg | Bin 0 -> 71675 bytes public/assets/img/work/work-img-2.jpg | Bin 0 -> 61144 bytes public/assets/img/work/work-img-3.jpg | Bin 0 -> 74828 bytes public/assets/js/ajax-form.js | 48 + public/assets/js/backToTop.js | 1 + public/assets/js/bootstrap.bundle.min.js | 7 + public/assets/js/imagesloaded.pkgd.min.js | 7 + public/assets/js/isotope.pkgd.min.js | 12 + public/assets/js/jquery.meanmenu.min.js | 1 + public/assets/js/main.js | 472 + public/assets/js/slick.min.js | 1 + public/assets/js/swiper-bundle.js | 14 + public/assets/js/vendor/jquery.min.js | 2 + public/assets/js/venobox.min.js | 12 + public/assets/js/wow.min.js | 3 + public/favicon.ico | Bin 0 -> 5430 bytes public/index.php | 87 + public/robots.txt | 2 + spark | 104 + system/.htaccess | 6 + system/API/ResponseTrait.php | 359 + system/Autoloader/Autoloader.php | 488 + system/Autoloader/FileLocator.php | 386 + system/BaseModel.php | 1916 ++ system/CLI/BaseCommand.php | 227 + system/CLI/CLI.php | 1153 ++ system/CLI/Commands.php | 186 + system/CLI/Console.php | 88 + system/CLI/Exceptions/CLIException.php | 34 + system/CLI/GeneratorTrait.php | 388 + system/Cache/CacheFactory.php | 89 + system/Cache/CacheInterface.php | 108 + system/Cache/Exceptions/CacheException.php | 64 + .../Cache/Exceptions/ExceptionInterface.php | 24 + system/Cache/FactoriesCache.php | 65 + .../FactoriesCache/FileVarExportHandler.php | 44 + system/Cache/Handlers/BaseHandler.php | 111 + system/Cache/Handlers/DummyHandler.php | 119 + system/Cache/Handlers/FileHandler.php | 423 + system/Cache/Handlers/MemcachedHandler.php | 276 + system/Cache/Handlers/PredisHandler.php | 235 + system/Cache/Handlers/RedisHandler.php | 267 + system/Cache/Handlers/WincacheHandler.php | 150 + system/Cache/ResponseCache.php | 151 + system/CodeIgniter.php | 1163 ++ system/Commands/Cache/ClearCache.php | 88 + system/Commands/Cache/InfoCache.php | 89 + system/Commands/Database/CreateDatabase.php | 152 + system/Commands/Database/Migrate.php | 102 + system/Commands/Database/MigrateRefresh.php | 87 + system/Commands/Database/MigrateRollback.php | 104 + system/Commands/Database/MigrateStatus.php | 167 + system/Commands/Database/Seed.php | 82 + system/Commands/Database/ShowTableInfo.php | 285 + system/Commands/Encryption/GenerateKey.php | 199 + system/Commands/Generators/CellGenerator.php | 98 + .../Commands/Generators/CommandGenerator.php | 119 + .../Commands/Generators/ConfigGenerator.php | 98 + .../Generators/ControllerGenerator.php | 134 + .../Commands/Generators/EntityGenerator.php | 84 + .../Commands/Generators/FilterGenerator.php | 84 + system/Commands/Generators/MigrateCreate.php | 90 + .../Generators/MigrationGenerator.php | 129 + system/Commands/Generators/ModelGenerator.php | 133 + .../Commands/Generators/ScaffoldGenerator.php | 121 + .../Commands/Generators/SeederGenerator.php | 84 + .../Generators/SessionMigrationGenerator.php | 113 + .../Generators/ValidationGenerator.php | 84 + system/Commands/Generators/Views/cell.tpl.php | 10 + .../Generators/Views/cell_view.tpl.php | 3 + .../Commands/Generators/Views/command.tpl.php | 76 + .../Commands/Generators/Views/config.tpl.php | 10 + .../Generators/Views/controller.tpl.php | 178 + .../Commands/Generators/Views/entity.tpl.php | 12 + .../Commands/Generators/Views/filter.tpl.php | 47 + .../Generators/Views/migration.tpl.php | 50 + .../Commands/Generators/Views/model.tpl.php | 45 + .../Commands/Generators/Views/seeder.tpl.php | 13 + .../Generators/Views/validation.tpl.php | 11 + system/Commands/Help.php | 85 + .../Commands/Housekeeping/ClearDebugbar.php | 70 + system/Commands/Housekeeping/ClearLogs.php | 91 + system/Commands/ListCommands.php | 134 + system/Commands/Server/Serve.php | 117 + system/Commands/Server/rewrite.php | 48 + system/Commands/Utilities/Environment.php | 155 + system/Commands/Utilities/FilterCheck.php | 127 + system/Commands/Utilities/Namespaces.php | 159 + system/Commands/Utilities/Publish.php | 104 + system/Commands/Utilities/Routes.php | 206 + .../Utilities/Routes/AutoRouteCollector.php | 68 + .../AutoRouterImproved/AutoRouteCollector.php | 165 + .../ControllerMethodReader.php | 241 + .../Utilities/Routes/ControllerFinder.php | 78 + .../Routes/ControllerMethodReader.php | 177 + .../Utilities/Routes/FilterCollector.php | 82 + .../Utilities/Routes/FilterFinder.php | 81 + .../Utilities/Routes/SampleURIGenerator.php | 72 + system/Common.php | 1268 ++ system/ComposerScripts.php | 173 + system/Config/AutoloadConfig.php | 151 + system/Config/BaseConfig.php | 266 + system/Config/BaseService.php | 391 + system/Config/Config.php | 55 + system/Config/DotEnv.php | 238 + system/Config/Factories.php | 539 + system/Config/Factory.php | 48 + system/Config/ForeignCharacters.php | 115 + system/Config/Publisher.php | 42 + system/Config/Routing.php | 111 + system/Config/Services.php | 849 + system/Config/View.php | 130 + system/Controller.php | 198 + system/Cookie/CloneableCookieInterface.php | 109 + system/Cookie/Cookie.php | 787 + system/Cookie/CookieInterface.php | 168 + system/Cookie/CookieStore.php | 257 + system/Cookie/Exceptions/CookieException.php | 127 + system/Database/BaseBuilder.php | 3519 ++++ system/Database/BaseConnection.php | 1770 ++ system/Database/BasePreparedQuery.php | 260 + system/Database/BaseResult.php | 535 + system/Database/BaseUtils.php | 322 + system/Database/Config.php | 151 + system/Database/ConnectionInterface.php | 163 + system/Database/Database.php | 139 + system/Database/Exceptions/DataException.php | 85 + .../Database/Exceptions/DatabaseException.php | 23 + .../Exceptions/ExceptionInterface.php | 22 + system/Database/Forge.php | 1245 ++ system/Database/Migration.php | 72 + system/Database/MigrationRunner.php | 869 + system/Database/ModelFactory.php | 53 + system/Database/MySQLi/Builder.php | 145 + system/Database/MySQLi/Connection.php | 628 + system/Database/MySQLi/Forge.php | 264 + system/Database/MySQLi/PreparedQuery.php | 112 + system/Database/MySQLi/Result.php | 168 + system/Database/MySQLi/Utils.php | 45 + system/Database/OCI8/Builder.php | 524 + system/Database/OCI8/Connection.php | 728 + system/Database/OCI8/Forge.php | 312 + system/Database/OCI8/PreparedQuery.php | 118 + system/Database/OCI8/Result.php | 117 + system/Database/OCI8/Utils.php | 38 + system/Database/Postgre/Builder.php | 623 + system/Database/Postgre/Connection.php | 586 + system/Database/Postgre/Forge.php | 218 + system/Database/Postgre/PreparedQuery.php | 127 + system/Database/Postgre/Result.php | 134 + system/Database/Postgre/Utils.php | 45 + system/Database/PreparedQueryInterface.php | 61 + system/Database/Query.php | 427 + system/Database/QueryInterface.php | 87 + system/Database/RawSql.php | 52 + system/Database/ResultInterface.php | 174 + system/Database/SQLSRV/Builder.php | 775 + system/Database/SQLSRV/Connection.php | 563 + system/Database/SQLSRV/Forge.php | 387 + system/Database/SQLSRV/PreparedQuery.php | 135 + system/Database/SQLSRV/Result.php | 174 + system/Database/SQLSRV/Utils.php | 53 + system/Database/SQLite3/Builder.php | 278 + system/Database/SQLite3/Connection.php | 445 + system/Database/SQLite3/Forge.php | 303 + system/Database/SQLite3/PreparedQuery.php | 105 + system/Database/SQLite3/Result.php | 158 + system/Database/SQLite3/Table.php | 450 + system/Database/SQLite3/Utils.php | 38 + system/Database/Seeder.php | 191 + system/Debug/BaseExceptionHandler.php | 263 + system/Debug/ExceptionHandler.php | 156 + system/Debug/ExceptionHandlerInterface.php | 30 + system/Debug/Exceptions.php | 637 + system/Debug/Iterator.php | 132 + system/Debug/Timer.php | 149 + system/Debug/Toolbar.php | 529 + .../Toolbar/Collectors/BaseCollector.php | 236 + system/Debug/Toolbar/Collectors/Config.php | 41 + system/Debug/Toolbar/Collectors/Database.php | 258 + system/Debug/Toolbar/Collectors/Events.php | 123 + system/Debug/Toolbar/Collectors/Files.php | 102 + system/Debug/Toolbar/Collectors/History.php | 143 + system/Debug/Toolbar/Collectors/Logs.php | 95 + system/Debug/Toolbar/Collectors/Routes.php | 141 + system/Debug/Toolbar/Collectors/Timers.php | 71 + system/Debug/Toolbar/Collectors/Views.php | 149 + system/Debug/Toolbar/Views/_config.tpl | 48 + system/Debug/Toolbar/Views/_database.tpl | 26 + system/Debug/Toolbar/Views/_events.tpl | 18 + system/Debug/Toolbar/Views/_files.tpl | 16 + system/Debug/Toolbar/Views/_history.tpl | 28 + system/Debug/Toolbar/Views/_logs.tpl | 20 + system/Debug/Toolbar/Views/_routes.tpl | 52 + system/Debug/Toolbar/Views/toolbar.css | 806 + system/Debug/Toolbar/Views/toolbar.js | 782 + system/Debug/Toolbar/Views/toolbar.tpl.php | 277 + system/Debug/Toolbar/Views/toolbarloader.js | 87 + system/Email/Email.php | 2273 +++ system/Encryption/EncrypterInterface.php | 46 + system/Encryption/Encryption.php | 174 + .../Exceptions/EncryptionException.php | 84 + system/Encryption/Handlers/BaseHandler.php | 76 + system/Encryption/Handlers/OpenSSLHandler.php | 156 + system/Encryption/Handlers/SodiumHandler.php | 140 + system/Entity.php | 23 + system/Entity/Cast/ArrayCast.php | 38 + system/Entity/Cast/BaseCast.php | 44 + system/Entity/Cast/BooleanCast.php | 26 + system/Entity/Cast/CSVCast.php | 34 + system/Entity/Cast/CastInterface.php | 38 + system/Entity/Cast/DatetimeCast.php | 50 + system/Entity/Cast/FloatCast.php | 26 + system/Entity/Cast/IntBoolCast.php | 36 + system/Entity/Cast/IntegerCast.php | 26 + system/Entity/Cast/JsonCast.php | 65 + system/Entity/Cast/ObjectCast.php | 26 + system/Entity/Cast/StringCast.php | 26 + system/Entity/Cast/TimestampCast.php | 34 + system/Entity/Cast/URICast.php | 28 + system/Entity/Entity.php | 606 + system/Entity/Exceptions/CastException.php | 84 + system/Events/Events.php | 284 + system/Exceptions/AlertError.php | 21 + system/Exceptions/CastException.php | 55 + system/Exceptions/ConfigException.php | 33 + system/Exceptions/CriticalError.php | 21 + system/Exceptions/DebugTraceableTrait.php | 41 + system/Exceptions/DownloadException.php | 62 + system/Exceptions/EmergencyError.php | 21 + system/Exceptions/ExceptionInterface.php | 22 + system/Exceptions/FrameworkException.php | 92 + system/Exceptions/HTTPExceptionInterface.php | 19 + system/Exceptions/HasExitCodeInterface.php | 23 + system/Exceptions/ModelException.php | 42 + system/Exceptions/PageNotFoundException.php | 83 + system/Exceptions/TestException.php | 28 + system/Files/Exceptions/FileException.php | 53 + .../Exceptions/FileNotFoundException.php | 29 + system/Files/File.php | 194 + system/Files/FileCollection.php | 368 + system/Filters/CSRF.php | 76 + system/Filters/DebugToolbar.php | 44 + system/Filters/Exceptions/FilterException.php | 42 + system/Filters/FilterInterface.php | 49 + system/Filters/Filters.php | 662 + system/Filters/Honeypot.php | 55 + system/Filters/InvalidChars.php | 126 + system/Filters/SecureHeaders.php | 73 + system/Format/Exceptions/FormatException.php | 71 + system/Format/Format.php | 74 + system/Format/FormatterInterface.php | 27 + system/Format/JSONFormatter.php | 48 + system/Format/XMLFormatter.php | 100 + system/HTTP/CLIRequest.php | 325 + system/HTTP/CURLRequest.php | 702 + system/HTTP/ContentSecurityPolicy.php | 817 + system/HTTP/DownloadResponse.php | 358 + system/HTTP/Exceptions/HTTPException.php | 242 + system/HTTP/Exceptions/RedirectException.php | 83 + system/HTTP/Files/FileCollection.php | 260 + system/HTTP/Files/UploadedFile.php | 363 + system/HTTP/Files/UploadedFileInterface.php | 149 + system/HTTP/Header.php | 192 + system/HTTP/IncomingRequest.php | 948 + system/HTTP/Message.php | 131 + system/HTTP/MessageInterface.php | 143 + system/HTTP/MessageTrait.php | 239 + system/HTTP/Negotiate.php | 361 + system/HTTP/OutgoingRequest.php | 163 + system/HTTP/OutgoingRequestInterface.php | 94 + system/HTTP/RedirectResponse.php | 170 + system/HTTP/Request.php | 123 + system/HTTP/RequestInterface.php | 49 + system/HTTP/RequestTrait.php | 370 + system/HTTP/ResponsableInterface.php | 17 + system/HTTP/Response.php | 242 + system/HTTP/ResponseInterface.php | 416 + system/HTTP/ResponseTrait.php | 818 + system/HTTP/SiteURI.php | 431 + system/HTTP/SiteURIFactory.php | 254 + system/HTTP/URI.php | 1182 ++ system/HTTP/UserAgent.php | 371 + system/Helpers/array_helper.php | 285 + system/Helpers/cookie_helper.php | 112 + system/Helpers/date_helper.php | 77 + system/Helpers/filesystem_helper.php | 450 + system/Helpers/form_helper.php | 809 + system/Helpers/html_helper.php | 547 + system/Helpers/inflector_helper.php | 337 + system/Helpers/kint_helper.php | 92 + system/Helpers/number_helper.php | 217 + system/Helpers/security_helper.php | 51 + system/Helpers/test_helper.php | 70 + system/Helpers/text_helper.php | 745 + system/Helpers/url_helper.php | 513 + system/Helpers/xml_helper.php | 59 + .../Honeypot/Exceptions/HoneypotException.php | 58 + system/Honeypot/Honeypot.php | 123 + system/HotReloader/DirectoryHasher.php | 80 + system/HotReloader/HotReloader.php | 70 + system/HotReloader/IteratorFilter.php | 55 + system/I18n/Exceptions/I18nException.php | 97 + system/I18n/Time.php | 44 + system/I18n/TimeDifference.php | 304 + system/I18n/TimeLegacy.php | 46 + system/I18n/TimeTrait.php | 1197 ++ system/Images/Exceptions/ImageException.php | 118 + system/Images/Handlers/BaseHandler.php | 783 + system/Images/Handlers/GDHandler.php | 511 + system/Images/Handlers/ImageMagickHandler.php | 479 + system/Images/Image.php | 135 + system/Images/ImageHandlerInterface.php | 140 + system/Language/Language.php | 274 + system/Language/en/CLI.php | 52 + system/Language/en/Cache.php | 18 + system/Language/en/Cast.php | 23 + system/Language/en/Cookie.php | 24 + system/Language/en/Core.php | 21 + system/Language/en/Database.php | 31 + system/Language/en/Email.php | 33 + system/Language/en/Encryption.php | 20 + system/Language/en/Errors.php | 18 + system/Language/en/Fabricator.php | 17 + system/Language/en/Files.php | 18 + system/Language/en/Filters.php | 16 + system/Language/en/Format.php | 18 + system/Language/en/HTTP.php | 81 + system/Language/en/Images.php | 34 + system/Language/en/Language.php | 15 + system/Language/en/Log.php | 16 + system/Language/en/Migrations.php | 58 + system/Language/en/Number.php | 26 + system/Language/en/Pager.php | 23 + system/Language/en/Publisher.php | 22 + system/Language/en/RESTful.php | 15 + system/Language/en/Router.php | 18 + system/Language/en/Security.php | 18 + system/Language/en/Session.php | 22 + system/Language/en/Test.php | 15 + system/Language/en/Time.php | 33 + system/Language/en/Validation.php | 74 + system/Language/en/View.php | 21 + system/Log/Exceptions/LogException.php | 33 + system/Log/Handlers/BaseHandler.php | 59 + system/Log/Handlers/ChromeLoggerHandler.php | 167 + system/Log/Handlers/ErrorlogHandler.php | 86 + system/Log/Handlers/FileHandler.php | 124 + system/Log/Handlers/HandlerInterface.php | 42 + system/Log/Logger.php | 427 + system/Model.php | 959 + system/Modules/Modules.php | 73 + system/Pager/Exceptions/PagerException.php | 37 + system/Pager/Pager.php | 439 + system/Pager/PagerInterface.php | 118 + system/Pager/PagerRenderer.php | 431 + system/Pager/Views/default_full.php | 47 + system/Pager/Views/default_head.php | 27 + system/Pager/Views/default_simple.php | 23 + system/Publisher/ContentReplacer.php | 99 + .../Exceptions/PublisherException.php | 55 + system/Publisher/Publisher.php | 514 + system/RESTful/BaseResource.php | 75 + system/RESTful/ResourceController.php | 118 + system/RESTful/ResourcePresenter.php | 114 + system/Router/AutoRouter.php | 309 + system/Router/AutoRouterImproved.php | 491 + system/Router/AutoRouterInterface.php | 25 + system/Router/DefinedRouteCollector.php | 72 + .../Exceptions/MethodNotFoundException.php | 21 + .../Router/Exceptions/RedirectException.php | 30 + system/Router/Exceptions/RouterException.php | 81 + system/Router/RouteCollection.php | 1802 ++ system/Router/RouteCollectionInterface.php | 194 + system/Router/Router.php | 678 + system/Router/RouterInterface.php | 70 + .../Security/Exceptions/SecurityException.php | 72 + system/Security/Security.php | 628 + system/Security/SecurityInterface.php | 78 + .../Session/Exceptions/SessionException.php | 52 + system/Session/Handlers/ArrayHandler.php | 91 + system/Session/Handlers/BaseHandler.php | 175 + .../Handlers/Database/MySQLiHandler.php | 55 + .../Handlers/Database/PostgreHandler.php | 102 + system/Session/Handlers/DatabaseHandler.php | 305 + system/Session/Handlers/FileHandler.php | 338 + system/Session/Handlers/MemcachedHandler.php | 322 + system/Session/Handlers/RedisHandler.php | 348 + system/Session/Session.php | 935 + system/Session/SessionInterface.php | 184 + system/Superglobals.php | 61 + system/Test/CIDatabaseTestCase.php | 24 + system/Test/CIUnitTestCase.php | 527 + system/Test/ConfigFromArrayTrait.php | 46 + system/Test/Constraints/SeeInDatabase.php | 118 + system/Test/ControllerResponse.php | 99 + system/Test/ControllerTestTrait.php | 305 + system/Test/ControllerTester.php | 293 + system/Test/DOMParser.php | 280 + system/Test/DatabaseTestTrait.php | 344 + system/Test/Fabricator.php | 546 + system/Test/FeatureResponse.php | 25 + system/Test/FeatureTestCase.php | 395 + system/Test/FeatureTestTrait.php | 397 + system/Test/FilterTestTrait.php | 308 + system/Test/Filters/CITestStreamFilter.php | 102 + system/Test/IniTestTrait.php | 33 + system/Test/Interfaces/FabricatorModel.php | 84 + system/Test/Mock/MockAppConfig.php | 28 + system/Test/Mock/MockAutoload.php | 26 + system/Test/Mock/MockBuilder.php | 23 + system/Test/Mock/MockCLIConfig.php | 34 + system/Test/Mock/MockCURLRequest.php | 56 + system/Test/Mock/MockCache.php | 300 + system/Test/Mock/MockCodeIgniter.php | 29 + system/Test/Mock/MockCommon.php | 32 + system/Test/Mock/MockConnection.php | 246 + system/Test/Mock/MockEmail.php | 40 + system/Test/Mock/MockEvents.php | 40 + system/Test/Mock/MockFileLogger.php | 34 + system/Test/Mock/MockIncomingRequest.php | 18 + system/Test/Mock/MockLanguage.php | 56 + system/Test/Mock/MockLogger.php | 103 + system/Test/Mock/MockQuery.php | 18 + system/Test/Mock/MockResourceController.php | 32 + system/Test/Mock/MockResourcePresenter.php | 35 + system/Test/Mock/MockResponse.php | 39 + system/Test/Mock/MockResult.php | 101 + system/Test/Mock/MockSecurity.php | 30 + system/Test/Mock/MockSecurityConfig.php | 31 + system/Test/Mock/MockServices.php | 34 + system/Test/Mock/MockSession.php | 71 + system/Test/Mock/MockTable.php | 28 + system/Test/PhpStreamWrapper.php | 75 + system/Test/ReflectionHelper.php | 101 + system/Test/StreamFilterTrait.php | 40 + system/Test/TestLogger.php | 101 + system/Test/TestResponse.php | 503 + system/Test/bootstrap.php | 94 + system/ThirdParty/Escaper/Escaper.php | 412 + .../Escaper/Exception/ExceptionInterface.php | 11 + .../Exception/InvalidArgumentException.php | 13 + .../Escaper/Exception/RuntimeException.php | 13 + system/ThirdParty/Escaper/LICENSE.md | 26 + system/ThirdParty/Kint/CallFinder.php | 568 + system/ThirdParty/Kint/FacadeInterface.php | 49 + system/ThirdParty/Kint/Kint.php | 729 + system/ThirdParty/Kint/LICENSE | 20 + .../ThirdParty/Kint/Parser/AbstractPlugin.php | 45 + .../Kint/Parser/ArrayLimitPlugin.php | 144 + .../Kint/Parser/ArrayObjectPlugin.php | 65 + .../ThirdParty/Kint/Parser/Base64Plugin.php | 96 + .../ThirdParty/Kint/Parser/BinaryPlugin.php | 51 + .../Kint/Parser/BlacklistPlugin.php | 101 + .../Kint/Parser/ClassMethodsPlugin.php | 115 + .../Kint/Parser/ClassStaticsPlugin.php | 154 + .../ThirdParty/Kint/Parser/ClosurePlugin.php | 96 + system/ThirdParty/Kint/Parser/ColorPlugin.php | 65 + .../Parser/ConstructablePluginInterface.php | 33 + .../Kint/Parser/DOMDocumentPlugin.php | 356 + .../ThirdParty/Kint/Parser/DateTimePlugin.php | 57 + system/ThirdParty/Kint/Parser/EnumPlugin.php | 88 + .../ThirdParty/Kint/Parser/FsPathPlugin.php | 74 + .../ThirdParty/Kint/Parser/IteratorPlugin.php | 107 + system/ThirdParty/Kint/Parser/JsonPlugin.php | 75 + .../Kint/Parser/MicrotimePlugin.php | 107 + .../ThirdParty/Kint/Parser/MysqliPlugin.php | 193 + system/ThirdParty/Kint/Parser/Parser.php | 655 + .../Kint/Parser/PluginInterface.php | 44 + system/ThirdParty/Kint/Parser/ProxyPlugin.php | 73 + .../Kint/Parser/SerializePlugin.php | 109 + .../Kint/Parser/SimpleXMLElementPlugin.php | 221 + .../Kint/Parser/SplFileInfoPlugin.php | 57 + .../Kint/Parser/SplObjectStoragePlugin.php | 56 + .../ThirdParty/Kint/Parser/StreamPlugin.php | 83 + system/ThirdParty/Kint/Parser/TablePlugin.php | 95 + .../Kint/Parser/ThrowablePlugin.php | 61 + .../Kint/Parser/TimestampPlugin.php | 77 + .../ThirdParty/Kint/Parser/ToStringPlugin.php | 69 + system/ThirdParty/Kint/Parser/TracePlugin.php | 120 + system/ThirdParty/Kint/Parser/XmlPlugin.php | 152 + .../Kint/Renderer/AbstractRenderer.php | 175 + .../ThirdParty/Kint/Renderer/CliRenderer.php | 182 + .../Kint/Renderer/PlainRenderer.php | 237 + .../Kint/Renderer/RendererInterface.php | 57 + .../Kint/Renderer/Rich/AbstractPlugin.php | 104 + .../Kint/Renderer/Rich/ArrayLimitPlugin.php | 38 + .../Kint/Renderer/Rich/BinaryPlugin.php | 62 + .../Kint/Renderer/Rich/BlacklistPlugin.php | 38 + .../Kint/Renderer/Rich/CallablePlugin.php | 130 + .../Kint/Renderer/Rich/ClosurePlugin.php | 64 + .../Kint/Renderer/Rich/ColorPlugin.php | 102 + .../Kint/Renderer/Rich/DepthLimitPlugin.php | 38 + .../Renderer/Rich/MethodDefinitionPlugin.php | 76 + .../Kint/Renderer/Rich/MicrotimePlugin.php | 74 + .../Kint/Renderer/Rich/PluginInterface.php | 35 + .../Kint/Renderer/Rich/RecursionPlugin.php | 38 + .../Renderer/Rich/SimpleXMLElementPlugin.php | 56 + .../Kint/Renderer/Rich/SourcePlugin.php | 83 + .../Kint/Renderer/Rich/TabPluginInterface.php | 35 + .../Kint/Renderer/Rich/TablePlugin.php | 141 + .../Kint/Renderer/Rich/TimestampPlugin.php | 44 + .../Kint/Renderer/Rich/TraceFramePlugin.php | 70 + .../Renderer/Rich/ValuePluginInterface.php | 35 + .../ThirdParty/Kint/Renderer/RichRenderer.php | 677 + .../Kint/Renderer/Text/AbstractPlugin.php | 63 + .../Kint/Renderer/Text/ArrayLimitPlugin.php | 38 + .../Kint/Renderer/Text/BlacklistPlugin.php | 38 + .../Kint/Renderer/Text/DepthLimitPlugin.php | 38 + .../Kint/Renderer/Text/EnumPlugin.php | 38 + .../Kint/Renderer/Text/MicrotimePlugin.php | 130 + .../Kint/Renderer/Text/PluginInterface.php | 38 + .../Kint/Renderer/Text/RecursionPlugin.php | 38 + .../Kint/Renderer/Text/TracePlugin.php | 115 + .../ThirdParty/Kint/Renderer/TextRenderer.php | 391 + system/ThirdParty/Kint/Utils.php | 296 + system/ThirdParty/Kint/Zval/BlobValue.php | 199 + system/ThirdParty/Kint/Zval/ClosureValue.php | 58 + system/ThirdParty/Kint/Zval/DateTimeValue.php | 55 + system/ThirdParty/Kint/Zval/EnumValue.php | 74 + system/ThirdParty/Kint/Zval/InstanceValue.php | 74 + system/ThirdParty/Kint/Zval/MethodValue.php | 228 + .../Kint/Zval/ParameterHoldingTrait.php | 63 + .../ThirdParty/Kint/Zval/ParameterValue.php | 85 + .../Representation/ColorRepresentation.php | 571 + .../MethodDefinitionRepresentation.php | 76 + .../MicrotimeRepresentation.php | 73 + .../Zval/Representation/Representation.php | 73 + .../Representation/SourceRepresentation.php | 72 + .../SplFileInfoRepresentation.php | 196 + system/ThirdParty/Kint/Zval/ResourceValue.php | 51 + .../Kint/Zval/SimpleXMLElementValue.php | 54 + system/ThirdParty/Kint/Zval/StreamValue.php | 56 + .../ThirdParty/Kint/Zval/ThrowableValue.php | 52 + .../ThirdParty/Kint/Zval/TraceFrameValue.php | 107 + system/ThirdParty/Kint/Zval/TraceValue.php | 47 + system/ThirdParty/Kint/Zval/Value.php | 266 + system/ThirdParty/Kint/init.php | 73 + system/ThirdParty/Kint/init_helpers.php | 88 + .../Kint/resources/compiled/aante-dark.css | 1 + .../Kint/resources/compiled/aante-light.css | 1 + .../Kint/resources/compiled/microtime.js | 1 + .../Kint/resources/compiled/original.css | 1 + .../Kint/resources/compiled/plain.css | 1 + .../Kint/resources/compiled/plain.js | 1 + .../Kint/resources/compiled/rich.js | 1 + .../Kint/resources/compiled/shared.js | 1 + .../resources/compiled/solarized-dark.css | 1 + .../Kint/resources/compiled/solarized.css | 1 + system/ThirdParty/PSR/Log/AbstractLogger.php | 128 + .../PSR/Log/InvalidArgumentException.php | 7 + system/ThirdParty/PSR/Log/LICENSE | 19 + system/ThirdParty/PSR/Log/LogLevel.php | 18 + .../PSR/Log/LoggerAwareInterface.php | 18 + .../ThirdParty/PSR/Log/LoggerAwareTrait.php | 26 + system/ThirdParty/PSR/Log/LoggerInterface.php | 125 + system/ThirdParty/PSR/Log/LoggerTrait.php | 142 + system/ThirdParty/PSR/Log/NullLogger.php | 30 + system/Throttle/Throttler.php | 182 + system/Throttle/ThrottlerInterface.php | 44 + system/Traits/ConditionalTrait.php | 61 + system/Traits/PropertiesTrait.php | 78 + system/Typography/Typography.php | 342 + system/Validation/CreditCardRules.php | 281 + system/Validation/DotArrayFilter.php | 112 + .../Exceptions/ValidationException.php | 67 + system/Validation/FileRules.php | 256 + system/Validation/FormatRules.php | 352 + system/Validation/Rules.php | 357 + .../StrictRules/CreditCardRules.php | 55 + system/Validation/StrictRules/FileRules.php | 25 + system/Validation/StrictRules/FormatRules.php | 411 + system/Validation/StrictRules/Rules.php | 408 + system/Validation/Validation.php | 983 + system/Validation/ValidationInterface.php | 162 + system/Validation/Views/list.php | 9 + system/Validation/Views/single.php | 1 + system/View/Cell.php | 310 + system/View/Cells/Cell.php | 152 + system/View/Exceptions/ViewException.php | 73 + system/View/Filters.php | 250 + system/View/Parser.php | 722 + system/View/Plugins.php | 122 + system/View/RendererInterface.php | 73 + system/View/Table.php | 542 + system/View/View.php | 505 + system/View/ViewDecoratorInterface.php | 26 + system/View/ViewDecoratorTrait.php | 37 + system/bootstrap.php | 106 + system/index.html | 11 + tests/README.md | 122 + .../2020-02-22-222222_example_migration.php | 37 + .../_support/Database/Seeds/ExampleSeeder.php | 41 + tests/_support/Libraries/ConfigReader.php | 17 + tests/_support/Models/ExampleModel.php | 24 + tests/database/ExampleDatabaseTest.php | 46 + tests/session/ExampleSessionTest.php | 18 + tests/unit/HealthTest.php | 50 + writable/.htaccess | 6 + writable/cache/index.html | 11 + writable/debugbar/.gitkeep | 0 writable/logs/index.html | 11 + writable/logs/log-2024-03-06.log | 66 + writable/logs/log-2024-03-12.log | 10 + writable/session/index.html | 11 + writable/uploads/index.html | 11 + 885 files changed, 195591 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/.htaccess create mode 100644 app/Common.php create mode 100644 app/Config/App.php create mode 100644 app/Config/Autoload.php create mode 100644 app/Config/Boot/development.php create mode 100644 app/Config/Boot/production.php create mode 100644 app/Config/Boot/testing.php create mode 100644 app/Config/CURLRequest.php create mode 100644 app/Config/Cache.php create mode 100644 app/Config/Constants.php create mode 100644 app/Config/ContentSecurityPolicy.php create mode 100644 app/Config/Cookie.php create mode 100644 app/Config/Database.php create mode 100644 app/Config/DocTypes.php create mode 100644 app/Config/Email.php create mode 100644 app/Config/Encryption.php create mode 100644 app/Config/Events.php create mode 100644 app/Config/Exceptions.php create mode 100644 app/Config/Feature.php create mode 100644 app/Config/Filters.php create mode 100644 app/Config/ForeignCharacters.php create mode 100644 app/Config/Format.php create mode 100644 app/Config/Generators.php create mode 100644 app/Config/Honeypot.php create mode 100644 app/Config/Images.php create mode 100644 app/Config/Kint.php create mode 100644 app/Config/Logger.php create mode 100644 app/Config/Migrations.php create mode 100644 app/Config/Mimes.php create mode 100644 app/Config/Modules.php create mode 100644 app/Config/Pager.php create mode 100644 app/Config/Paths.php create mode 100644 app/Config/Publisher.php create mode 100644 app/Config/Routes.php create mode 100644 app/Config/Routing.php create mode 100644 app/Config/Security.php create mode 100644 app/Config/Services.php create mode 100644 app/Config/Session.php create mode 100644 app/Config/Toolbar.php create mode 100644 app/Config/UserAgents.php create mode 100644 app/Config/Validation.php create mode 100644 app/Config/View.php create mode 100644 app/Controllers/BaseController.php create mode 100644 app/Controllers/Home.php create mode 100644 app/Controllers/PageController.php create mode 100644 app/Database/Migrations/.gitkeep create mode 100644 app/Database/Seeds/.gitkeep create mode 100644 app/Filters/.gitkeep create mode 100644 app/Helpers/.gitkeep create mode 100644 app/Language/.gitkeep create mode 100644 app/Language/en/Validation.php create mode 100644 app/Libraries/.gitkeep create mode 100644 app/Models/.gitkeep create mode 100644 app/ThirdParty/.gitkeep create mode 100644 app/Views/errors/cli/error_404.php create mode 100644 app/Views/errors/cli/error_exception.php create mode 100644 app/Views/errors/cli/production.php create mode 100644 app/Views/errors/html/debug.css create mode 100644 app/Views/errors/html/debug.js create mode 100644 app/Views/errors/html/error_404.php create mode 100644 app/Views/errors/html/error_exception.php create mode 100644 app/Views/errors/html/production.php create mode 100644 app/Views/pages/commonfiles/footer.php create mode 100644 app/Views/pages/commonfiles/header.php create mode 100644 app/Views/pages/commonfiles/link.php create mode 100644 app/Views/pages/commonfiles/script.php create mode 100644 app/Views/pages/commonfiles/sidebar.php create mode 100644 app/Views/pages/contact.php create mode 100644 app/Views/pages/index.php create mode 100644 app/Views/pages/projects.php create mode 100644 app/Views/pages/services.php create mode 100644 app/Views/welcome_message.php create mode 100644 app/index.html create mode 100644 composer.json create mode 100644 env create mode 100644 phpunit.xml.dist create mode 100644 preload.php create mode 100644 public/.htaccess create mode 100644 public/assets/css/animate.min.css create mode 100644 public/assets/css/backToTop.css create mode 100644 public/assets/css/bootstrap.min.css create mode 100644 public/assets/css/custom-animation.css create mode 100644 public/assets/css/default.css create mode 100644 public/assets/css/flaticon.css create mode 100644 public/assets/css/fontawesome.min.css create mode 100644 public/assets/css/main.css create mode 100644 public/assets/css/meanmenu.css create mode 100644 public/assets/css/slick.css create mode 100644 public/assets/css/swiper-bundle.css create mode 100644 public/assets/css/venobox.min.css create mode 100644 public/assets/favicon.png create mode 100644 public/assets/fonts/fa-brands-400.eot create mode 100644 public/assets/fonts/fa-brands-400.svg create mode 100644 public/assets/fonts/fa-brands-400.ttf create mode 100644 public/assets/fonts/fa-brands-400.woff create mode 100644 public/assets/fonts/fa-brands-400.woff2 create mode 100644 public/assets/fonts/fa-brands-400d41d.eot create mode 100644 public/assets/fonts/fa-duotone-900.eot create mode 100644 public/assets/fonts/fa-duotone-900.svg create mode 100644 public/assets/fonts/fa-duotone-900.ttf create mode 100644 public/assets/fonts/fa-duotone-900.woff create mode 100644 public/assets/fonts/fa-duotone-900.woff2 create mode 100644 public/assets/fonts/fa-duotone-900d41d.eot create mode 100644 public/assets/fonts/fa-light-300.eot create mode 100644 public/assets/fonts/fa-light-300.svg create mode 100644 public/assets/fonts/fa-light-300.ttf create mode 100644 public/assets/fonts/fa-light-300.woff create mode 100644 public/assets/fonts/fa-light-300.woff2 create mode 100644 public/assets/fonts/fa-light-300d41d.eot create mode 100644 public/assets/fonts/fa-regular-400.eot create mode 100644 public/assets/fonts/fa-regular-400.svg create mode 100644 public/assets/fonts/fa-regular-400.ttf create mode 100644 public/assets/fonts/fa-regular-400.woff create mode 100644 public/assets/fonts/fa-regular-400.woff2 create mode 100644 public/assets/fonts/fa-regular-400d41d.eot create mode 100644 public/assets/fonts/fa-solid-900.eot create mode 100644 public/assets/fonts/fa-solid-900.svg create mode 100644 public/assets/fonts/fa-solid-900.ttf create mode 100644 public/assets/fonts/fa-solid-900.woff create mode 100644 public/assets/fonts/fa-solid-900.woff2 create mode 100644 public/assets/fonts/fa-solid-900d41d.eot create mode 100644 public/assets/fonts/flaticon4ca8.eot create mode 100644 public/assets/fonts/flaticon4ca8.svg create mode 100644 public/assets/fonts/flaticon4ca8.ttf create mode 100644 public/assets/fonts/flaticon4ca8.woff create mode 100644 public/assets/fonts/flaticon4ca8.woff2 create mode 100644 public/assets/img/about/about-img-1.jpg create mode 100644 public/assets/img/about/about-img-2.jpg create mode 100644 public/assets/img/about/about-img-3.jpg create mode 100644 public/assets/img/about/about-img-4.jpg create mode 100644 public/assets/img/about/video-bg.jpg create mode 100644 public/assets/img/about/video-bg2.jpg create mode 100644 public/assets/img/bg/fact-bg.jpg create mode 100644 public/assets/img/bg/fact-bg2.jpg create mode 100644 public/assets/img/bg/feature-bg.jpg create mode 100644 public/assets/img/bg/page-banner.jpg create mode 100644 public/assets/img/bg/team--bg.jpg create mode 100644 public/assets/img/bg/work--bg.jpg create mode 100644 public/assets/img/blog/b1.jpg create mode 100644 public/assets/img/blog/b2.jpg create mode 100644 public/assets/img/blog/b3.jpg create mode 100644 public/assets/img/blog/b4.jpg create mode 100644 public/assets/img/blog/b5.jpg create mode 100644 public/assets/img/blog/b6.jpg create mode 100644 public/assets/img/blog/b7.jpg create mode 100644 public/assets/img/blog/blog-img-1.jpg create mode 100644 public/assets/img/blog/blog-img-10.jpg create mode 100644 public/assets/img/blog/blog-img-2.jpg create mode 100644 public/assets/img/blog/blog-img-3.jpg create mode 100644 public/assets/img/blog/blog-img-4.jpg create mode 100644 public/assets/img/blog/blog-img-5.jpg create mode 100644 public/assets/img/blog/blog-img-6.jpg create mode 100644 public/assets/img/blog/blog-img-7.jpg create mode 100644 public/assets/img/blog/blog-img-8.jpg create mode 100644 public/assets/img/blog/blog-img-9.jpg create mode 100644 public/assets/img/blog/blog-page-3.jpg create mode 100644 public/assets/img/blog/blog-sm-1.png create mode 100644 public/assets/img/blog/blog-sm-2.png create mode 100644 public/assets/img/blog/blog-sm-3.png create mode 100644 public/assets/img/blog/blog-sm-4.png create mode 100644 public/assets/img/blog/blog-sm-5.png create mode 100644 public/assets/img/blog/blog-sm-6.png create mode 100644 public/assets/img/blog/blog-sm-7.png create mode 100644 public/assets/img/blog/blog-sm-8.png create mode 100644 public/assets/img/brand/brand-1.png create mode 100644 public/assets/img/brand/brand-10.png create mode 100644 public/assets/img/brand/brand-2.png create mode 100644 public/assets/img/brand/brand-3.png create mode 100644 public/assets/img/brand/brand-4.png create mode 100644 public/assets/img/brand/brand-5.png create mode 100644 public/assets/img/brand/brand-6.png create mode 100644 public/assets/img/brand/brand-7.png create mode 100644 public/assets/img/brand/brand-8.png create mode 100644 public/assets/img/brand/brand-9.png create mode 100644 public/assets/img/choose/choose-bg.jpg create mode 100644 public/assets/img/choose/choose-bg2.jpg create mode 100644 public/assets/img/choose/choose-bg3.jpg create mode 100644 public/assets/img/choose/choose-img-2.png create mode 100644 public/assets/img/contact/contact-img-1.png create mode 100644 public/assets/img/contact/contact-img-2.jpg create mode 100644 public/assets/img/contact/contact-img-3.jpg create mode 100644 public/assets/img/contact/contact-shape-1.png create mode 100644 public/assets/img/contact/contact-shape-2.png create mode 100644 public/assets/img/faq/faq-img-1.jpg create mode 100644 public/assets/img/faq/faq-img-2.jpg create mode 100644 public/assets/img/faq/faq-img-3.png create mode 100644 public/assets/img/faq/faq-img-4.png create mode 100644 public/assets/img/faq/faq-img.png create mode 100644 public/assets/img/footer/footer-bg.jpg create mode 100644 public/assets/img/footer/footer-gal-1.jpg create mode 100644 public/assets/img/footer/footer-gal-2.jpg create mode 100644 public/assets/img/footer/footer-gal-3.jpg create mode 100644 public/assets/img/footer/footer-gal-4.jpg create mode 100644 public/assets/img/footer/footer-gal-5.jpg create mode 100644 public/assets/img/footer/footer-gal-6.jpg create mode 100644 public/assets/img/footer/footer-post-1.jpg create mode 100644 public/assets/img/footer/footer-post-2.jpg create mode 100644 public/assets/img/gallery/gallery-img-1.jpg create mode 100644 public/assets/img/gallery/gallery-img-2.jpg create mode 100644 public/assets/img/gallery/gallery-img-3.jpg create mode 100644 public/assets/img/gallery/gallery-img-4.jpg create mode 100644 public/assets/img/hero/ddd.png create mode 100644 public/assets/img/hero/hero-img-1.jpg create mode 100644 public/assets/img/hero/hero-img-2.jpg create mode 100644 public/assets/img/hero/hero-img-3.jpg create mode 100644 public/assets/img/hero/shape.png create mode 100644 public/assets/img/hero/slider-bg-3.jpg create mode 100644 public/assets/img/hero/slider-bg-4.jpg create mode 100644 public/assets/img/hero/slider-bg-5.jpg create mode 100644 public/assets/img/icon/choose-icon-1.png create mode 100644 public/assets/img/icon/choose-icon-2.png create mode 100644 public/assets/img/icon/choose-icon-3.png create mode 100644 public/assets/img/icon/contact-arrow.png create mode 100644 public/assets/img/icon/cta-icon-1.png create mode 100644 public/assets/img/icon/cta-icon-2.png create mode 100644 public/assets/img/icon/fact-icon-1.png create mode 100644 public/assets/img/icon/fact-icon-2.png create mode 100644 public/assets/img/icon/fact-icon-3.png create mode 100644 public/assets/img/icon/fact-icon-4.png create mode 100644 public/assets/img/icon/icon-man.png create mode 100644 public/assets/img/icon/subs-icon-1.png create mode 100644 public/assets/img/icon/subtitle-icon.png create mode 100644 public/assets/img/logo/aclogo.png create mode 100644 public/assets/img/logo/logo-white.png create mode 100644 public/assets/img/logo/logo.png create mode 100644 public/assets/img/portfolio/portfolio-img-1.jpg create mode 100644 public/assets/img/portfolio/portfolio-img-2.jpg create mode 100644 public/assets/img/portfolio/portfolio-img-3.jpg create mode 100644 public/assets/img/portfolio/portfolio-img-4.jpg create mode 100644 public/assets/img/portfolio/portfolio-img-5.jpg create mode 100644 public/assets/img/portfolio/portfolio-img-6.jpg create mode 100644 public/assets/img/portfolio/project-details-1.jpg create mode 100644 public/assets/img/portfolio/project-details-2.jpg create mode 100644 public/assets/img/portfolio/project-details-3.jpg create mode 100644 public/assets/img/service/service-bg.jpg create mode 100644 public/assets/img/service/service-details-2.jpg create mode 100644 public/assets/img/service/service-details-3.jpg create mode 100644 public/assets/img/service/service-img-1.jpg create mode 100644 public/assets/img/service/service-img-2.jpg create mode 100644 public/assets/img/service/service-img-3.jpg create mode 100644 public/assets/img/service/service-img-4.jpg create mode 100644 public/assets/img/service/service-img-5.jpg create mode 100644 public/assets/img/service/service-img-6.jpg create mode 100644 public/assets/img/service/service-img-7.jpg create mode 100644 public/assets/img/service/service-img.jpg create mode 100644 public/assets/img/service/service-tab1.jpg create mode 100644 public/assets/img/service/service-tab3.jpg create mode 100644 public/assets/img/service/service-tab4.jpg create mode 100644 public/assets/img/skill/skill-img.jpg create mode 100644 public/assets/img/team/team-details-1.jpg create mode 100644 public/assets/img/team/team-img-1.jpg create mode 100644 public/assets/img/team/team-img-10.jpg create mode 100644 public/assets/img/team/team-img-11.jpg create mode 100644 public/assets/img/team/team-img-2.jpg create mode 100644 public/assets/img/team/team-img-3.jpg create mode 100644 public/assets/img/team/team-img-4.jpg create mode 100644 public/assets/img/team/team-img-5.jpg create mode 100644 public/assets/img/team/team-img-6.jpg create mode 100644 public/assets/img/team/team-img-7.jpg create mode 100644 public/assets/img/team/team-img-8.jpg create mode 100644 public/assets/img/team/team-img-9.jpg create mode 100644 public/assets/img/testimonial/testimonial-bg.jpg create mode 100644 public/assets/img/testimonial/testimonial-bg2.jpg create mode 100644 public/assets/img/testimonial/testimonial-bg3.jpg create mode 100644 public/assets/img/testimonial/testimonial-img-1.jpg create mode 100644 public/assets/img/testimonial/testimonial-img-1.png create mode 100644 public/assets/img/testimonial/testimonial-img-2.png create mode 100644 public/assets/img/testimonial/testimonial-img-3.png create mode 100644 public/assets/img/testimonial/testimonial-img-4.png create mode 100644 public/assets/img/testimonial/testimonial-img-5.png create mode 100644 public/assets/img/testimonial/testimonial-img-6.png create mode 100644 public/assets/img/testimonial/testimonial-img-7.png create mode 100644 public/assets/img/testimonial/testimonial-img-8.png create mode 100644 public/assets/img/work/dust.webp create mode 100644 public/assets/img/work/w1.jpg create mode 100644 public/assets/img/work/w1.png create mode 100644 public/assets/img/work/work-bg.jpg create mode 100644 public/assets/img/work/work-img-1.jpg create mode 100644 public/assets/img/work/work-img-2.jpg create mode 100644 public/assets/img/work/work-img-3.jpg create mode 100644 public/assets/js/ajax-form.js create mode 100644 public/assets/js/backToTop.js create mode 100644 public/assets/js/bootstrap.bundle.min.js create mode 100644 public/assets/js/imagesloaded.pkgd.min.js create mode 100644 public/assets/js/isotope.pkgd.min.js create mode 100644 public/assets/js/jquery.meanmenu.min.js create mode 100644 public/assets/js/main.js create mode 100644 public/assets/js/slick.min.js create mode 100644 public/assets/js/swiper-bundle.js create mode 100644 public/assets/js/vendor/jquery.min.js create mode 100644 public/assets/js/venobox.min.js create mode 100644 public/assets/js/wow.min.js create mode 100644 public/favicon.ico create mode 100644 public/index.php create mode 100644 public/robots.txt create mode 100644 spark create mode 100644 system/.htaccess create mode 100644 system/API/ResponseTrait.php create mode 100644 system/Autoloader/Autoloader.php create mode 100644 system/Autoloader/FileLocator.php create mode 100644 system/BaseModel.php create mode 100644 system/CLI/BaseCommand.php create mode 100644 system/CLI/CLI.php create mode 100644 system/CLI/Commands.php create mode 100644 system/CLI/Console.php create mode 100644 system/CLI/Exceptions/CLIException.php create mode 100644 system/CLI/GeneratorTrait.php create mode 100644 system/Cache/CacheFactory.php create mode 100644 system/Cache/CacheInterface.php create mode 100644 system/Cache/Exceptions/CacheException.php create mode 100644 system/Cache/Exceptions/ExceptionInterface.php create mode 100644 system/Cache/FactoriesCache.php create mode 100644 system/Cache/FactoriesCache/FileVarExportHandler.php create mode 100644 system/Cache/Handlers/BaseHandler.php create mode 100644 system/Cache/Handlers/DummyHandler.php create mode 100644 system/Cache/Handlers/FileHandler.php create mode 100644 system/Cache/Handlers/MemcachedHandler.php create mode 100644 system/Cache/Handlers/PredisHandler.php create mode 100644 system/Cache/Handlers/RedisHandler.php create mode 100644 system/Cache/Handlers/WincacheHandler.php create mode 100644 system/Cache/ResponseCache.php create mode 100644 system/CodeIgniter.php create mode 100644 system/Commands/Cache/ClearCache.php create mode 100644 system/Commands/Cache/InfoCache.php create mode 100644 system/Commands/Database/CreateDatabase.php create mode 100644 system/Commands/Database/Migrate.php create mode 100644 system/Commands/Database/MigrateRefresh.php create mode 100644 system/Commands/Database/MigrateRollback.php create mode 100644 system/Commands/Database/MigrateStatus.php create mode 100644 system/Commands/Database/Seed.php create mode 100644 system/Commands/Database/ShowTableInfo.php create mode 100644 system/Commands/Encryption/GenerateKey.php create mode 100644 system/Commands/Generators/CellGenerator.php create mode 100644 system/Commands/Generators/CommandGenerator.php create mode 100644 system/Commands/Generators/ConfigGenerator.php create mode 100644 system/Commands/Generators/ControllerGenerator.php create mode 100644 system/Commands/Generators/EntityGenerator.php create mode 100644 system/Commands/Generators/FilterGenerator.php create mode 100644 system/Commands/Generators/MigrateCreate.php create mode 100644 system/Commands/Generators/MigrationGenerator.php create mode 100644 system/Commands/Generators/ModelGenerator.php create mode 100644 system/Commands/Generators/ScaffoldGenerator.php create mode 100644 system/Commands/Generators/SeederGenerator.php create mode 100644 system/Commands/Generators/SessionMigrationGenerator.php create mode 100644 system/Commands/Generators/ValidationGenerator.php create mode 100644 system/Commands/Generators/Views/cell.tpl.php create mode 100644 system/Commands/Generators/Views/cell_view.tpl.php create mode 100644 system/Commands/Generators/Views/command.tpl.php create mode 100644 system/Commands/Generators/Views/config.tpl.php create mode 100644 system/Commands/Generators/Views/controller.tpl.php create mode 100644 system/Commands/Generators/Views/entity.tpl.php create mode 100644 system/Commands/Generators/Views/filter.tpl.php create mode 100644 system/Commands/Generators/Views/migration.tpl.php create mode 100644 system/Commands/Generators/Views/model.tpl.php create mode 100644 system/Commands/Generators/Views/seeder.tpl.php create mode 100644 system/Commands/Generators/Views/validation.tpl.php create mode 100644 system/Commands/Help.php create mode 100644 system/Commands/Housekeeping/ClearDebugbar.php create mode 100644 system/Commands/Housekeeping/ClearLogs.php create mode 100644 system/Commands/ListCommands.php create mode 100644 system/Commands/Server/Serve.php create mode 100644 system/Commands/Server/rewrite.php create mode 100644 system/Commands/Utilities/Environment.php create mode 100644 system/Commands/Utilities/FilterCheck.php create mode 100644 system/Commands/Utilities/Namespaces.php create mode 100644 system/Commands/Utilities/Publish.php create mode 100644 system/Commands/Utilities/Routes.php create mode 100644 system/Commands/Utilities/Routes/AutoRouteCollector.php create mode 100644 system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php create mode 100644 system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php create mode 100644 system/Commands/Utilities/Routes/ControllerFinder.php create mode 100644 system/Commands/Utilities/Routes/ControllerMethodReader.php create mode 100644 system/Commands/Utilities/Routes/FilterCollector.php create mode 100644 system/Commands/Utilities/Routes/FilterFinder.php create mode 100644 system/Commands/Utilities/Routes/SampleURIGenerator.php create mode 100644 system/Common.php create mode 100644 system/ComposerScripts.php create mode 100644 system/Config/AutoloadConfig.php create mode 100644 system/Config/BaseConfig.php create mode 100644 system/Config/BaseService.php create mode 100644 system/Config/Config.php create mode 100644 system/Config/DotEnv.php create mode 100644 system/Config/Factories.php create mode 100644 system/Config/Factory.php create mode 100644 system/Config/ForeignCharacters.php create mode 100644 system/Config/Publisher.php create mode 100644 system/Config/Routing.php create mode 100644 system/Config/Services.php create mode 100644 system/Config/View.php create mode 100644 system/Controller.php create mode 100644 system/Cookie/CloneableCookieInterface.php create mode 100644 system/Cookie/Cookie.php create mode 100644 system/Cookie/CookieInterface.php create mode 100644 system/Cookie/CookieStore.php create mode 100644 system/Cookie/Exceptions/CookieException.php create mode 100644 system/Database/BaseBuilder.php create mode 100644 system/Database/BaseConnection.php create mode 100644 system/Database/BasePreparedQuery.php create mode 100644 system/Database/BaseResult.php create mode 100644 system/Database/BaseUtils.php create mode 100644 system/Database/Config.php create mode 100644 system/Database/ConnectionInterface.php create mode 100644 system/Database/Database.php create mode 100644 system/Database/Exceptions/DataException.php create mode 100644 system/Database/Exceptions/DatabaseException.php create mode 100644 system/Database/Exceptions/ExceptionInterface.php create mode 100644 system/Database/Forge.php create mode 100644 system/Database/Migration.php create mode 100644 system/Database/MigrationRunner.php create mode 100644 system/Database/ModelFactory.php create mode 100644 system/Database/MySQLi/Builder.php create mode 100644 system/Database/MySQLi/Connection.php create mode 100644 system/Database/MySQLi/Forge.php create mode 100644 system/Database/MySQLi/PreparedQuery.php create mode 100644 system/Database/MySQLi/Result.php create mode 100644 system/Database/MySQLi/Utils.php create mode 100644 system/Database/OCI8/Builder.php create mode 100644 system/Database/OCI8/Connection.php create mode 100644 system/Database/OCI8/Forge.php create mode 100644 system/Database/OCI8/PreparedQuery.php create mode 100644 system/Database/OCI8/Result.php create mode 100644 system/Database/OCI8/Utils.php create mode 100644 system/Database/Postgre/Builder.php create mode 100644 system/Database/Postgre/Connection.php create mode 100644 system/Database/Postgre/Forge.php create mode 100644 system/Database/Postgre/PreparedQuery.php create mode 100644 system/Database/Postgre/Result.php create mode 100644 system/Database/Postgre/Utils.php create mode 100644 system/Database/PreparedQueryInterface.php create mode 100644 system/Database/Query.php create mode 100644 system/Database/QueryInterface.php create mode 100644 system/Database/RawSql.php create mode 100644 system/Database/ResultInterface.php create mode 100644 system/Database/SQLSRV/Builder.php create mode 100644 system/Database/SQLSRV/Connection.php create mode 100644 system/Database/SQLSRV/Forge.php create mode 100644 system/Database/SQLSRV/PreparedQuery.php create mode 100644 system/Database/SQLSRV/Result.php create mode 100644 system/Database/SQLSRV/Utils.php create mode 100644 system/Database/SQLite3/Builder.php create mode 100644 system/Database/SQLite3/Connection.php create mode 100644 system/Database/SQLite3/Forge.php create mode 100644 system/Database/SQLite3/PreparedQuery.php create mode 100644 system/Database/SQLite3/Result.php create mode 100644 system/Database/SQLite3/Table.php create mode 100644 system/Database/SQLite3/Utils.php create mode 100644 system/Database/Seeder.php create mode 100644 system/Debug/BaseExceptionHandler.php create mode 100644 system/Debug/ExceptionHandler.php create mode 100644 system/Debug/ExceptionHandlerInterface.php create mode 100644 system/Debug/Exceptions.php create mode 100644 system/Debug/Iterator.php create mode 100644 system/Debug/Timer.php create mode 100644 system/Debug/Toolbar.php create mode 100644 system/Debug/Toolbar/Collectors/BaseCollector.php create mode 100644 system/Debug/Toolbar/Collectors/Config.php create mode 100644 system/Debug/Toolbar/Collectors/Database.php create mode 100644 system/Debug/Toolbar/Collectors/Events.php create mode 100644 system/Debug/Toolbar/Collectors/Files.php create mode 100644 system/Debug/Toolbar/Collectors/History.php create mode 100644 system/Debug/Toolbar/Collectors/Logs.php create mode 100644 system/Debug/Toolbar/Collectors/Routes.php create mode 100644 system/Debug/Toolbar/Collectors/Timers.php create mode 100644 system/Debug/Toolbar/Collectors/Views.php create mode 100644 system/Debug/Toolbar/Views/_config.tpl create mode 100644 system/Debug/Toolbar/Views/_database.tpl create mode 100644 system/Debug/Toolbar/Views/_events.tpl create mode 100644 system/Debug/Toolbar/Views/_files.tpl create mode 100644 system/Debug/Toolbar/Views/_history.tpl create mode 100644 system/Debug/Toolbar/Views/_logs.tpl create mode 100644 system/Debug/Toolbar/Views/_routes.tpl create mode 100644 system/Debug/Toolbar/Views/toolbar.css create mode 100644 system/Debug/Toolbar/Views/toolbar.js create mode 100644 system/Debug/Toolbar/Views/toolbar.tpl.php create mode 100644 system/Debug/Toolbar/Views/toolbarloader.js create mode 100644 system/Email/Email.php create mode 100644 system/Encryption/EncrypterInterface.php create mode 100644 system/Encryption/Encryption.php create mode 100644 system/Encryption/Exceptions/EncryptionException.php create mode 100644 system/Encryption/Handlers/BaseHandler.php create mode 100644 system/Encryption/Handlers/OpenSSLHandler.php create mode 100644 system/Encryption/Handlers/SodiumHandler.php create mode 100644 system/Entity.php create mode 100644 system/Entity/Cast/ArrayCast.php create mode 100644 system/Entity/Cast/BaseCast.php create mode 100644 system/Entity/Cast/BooleanCast.php create mode 100644 system/Entity/Cast/CSVCast.php create mode 100644 system/Entity/Cast/CastInterface.php create mode 100644 system/Entity/Cast/DatetimeCast.php create mode 100644 system/Entity/Cast/FloatCast.php create mode 100644 system/Entity/Cast/IntBoolCast.php create mode 100644 system/Entity/Cast/IntegerCast.php create mode 100644 system/Entity/Cast/JsonCast.php create mode 100644 system/Entity/Cast/ObjectCast.php create mode 100644 system/Entity/Cast/StringCast.php create mode 100644 system/Entity/Cast/TimestampCast.php create mode 100644 system/Entity/Cast/URICast.php create mode 100644 system/Entity/Entity.php create mode 100644 system/Entity/Exceptions/CastException.php create mode 100644 system/Events/Events.php create mode 100644 system/Exceptions/AlertError.php create mode 100644 system/Exceptions/CastException.php create mode 100644 system/Exceptions/ConfigException.php create mode 100644 system/Exceptions/CriticalError.php create mode 100644 system/Exceptions/DebugTraceableTrait.php create mode 100644 system/Exceptions/DownloadException.php create mode 100644 system/Exceptions/EmergencyError.php create mode 100644 system/Exceptions/ExceptionInterface.php create mode 100644 system/Exceptions/FrameworkException.php create mode 100644 system/Exceptions/HTTPExceptionInterface.php create mode 100644 system/Exceptions/HasExitCodeInterface.php create mode 100644 system/Exceptions/ModelException.php create mode 100644 system/Exceptions/PageNotFoundException.php create mode 100644 system/Exceptions/TestException.php create mode 100644 system/Files/Exceptions/FileException.php create mode 100644 system/Files/Exceptions/FileNotFoundException.php create mode 100644 system/Files/File.php create mode 100644 system/Files/FileCollection.php create mode 100644 system/Filters/CSRF.php create mode 100644 system/Filters/DebugToolbar.php create mode 100644 system/Filters/Exceptions/FilterException.php create mode 100644 system/Filters/FilterInterface.php create mode 100644 system/Filters/Filters.php create mode 100644 system/Filters/Honeypot.php create mode 100644 system/Filters/InvalidChars.php create mode 100644 system/Filters/SecureHeaders.php create mode 100644 system/Format/Exceptions/FormatException.php create mode 100644 system/Format/Format.php create mode 100644 system/Format/FormatterInterface.php create mode 100644 system/Format/JSONFormatter.php create mode 100644 system/Format/XMLFormatter.php create mode 100644 system/HTTP/CLIRequest.php create mode 100644 system/HTTP/CURLRequest.php create mode 100644 system/HTTP/ContentSecurityPolicy.php create mode 100644 system/HTTP/DownloadResponse.php create mode 100644 system/HTTP/Exceptions/HTTPException.php create mode 100644 system/HTTP/Exceptions/RedirectException.php create mode 100644 system/HTTP/Files/FileCollection.php create mode 100644 system/HTTP/Files/UploadedFile.php create mode 100644 system/HTTP/Files/UploadedFileInterface.php create mode 100644 system/HTTP/Header.php create mode 100644 system/HTTP/IncomingRequest.php create mode 100644 system/HTTP/Message.php create mode 100644 system/HTTP/MessageInterface.php create mode 100644 system/HTTP/MessageTrait.php create mode 100644 system/HTTP/Negotiate.php create mode 100644 system/HTTP/OutgoingRequest.php create mode 100644 system/HTTP/OutgoingRequestInterface.php create mode 100644 system/HTTP/RedirectResponse.php create mode 100644 system/HTTP/Request.php create mode 100644 system/HTTP/RequestInterface.php create mode 100644 system/HTTP/RequestTrait.php create mode 100644 system/HTTP/ResponsableInterface.php create mode 100644 system/HTTP/Response.php create mode 100644 system/HTTP/ResponseInterface.php create mode 100644 system/HTTP/ResponseTrait.php create mode 100644 system/HTTP/SiteURI.php create mode 100644 system/HTTP/SiteURIFactory.php create mode 100644 system/HTTP/URI.php create mode 100644 system/HTTP/UserAgent.php create mode 100644 system/Helpers/array_helper.php create mode 100644 system/Helpers/cookie_helper.php create mode 100644 system/Helpers/date_helper.php create mode 100644 system/Helpers/filesystem_helper.php create mode 100644 system/Helpers/form_helper.php create mode 100644 system/Helpers/html_helper.php create mode 100644 system/Helpers/inflector_helper.php create mode 100644 system/Helpers/kint_helper.php create mode 100644 system/Helpers/number_helper.php create mode 100644 system/Helpers/security_helper.php create mode 100644 system/Helpers/test_helper.php create mode 100644 system/Helpers/text_helper.php create mode 100644 system/Helpers/url_helper.php create mode 100644 system/Helpers/xml_helper.php create mode 100644 system/Honeypot/Exceptions/HoneypotException.php create mode 100644 system/Honeypot/Honeypot.php create mode 100644 system/HotReloader/DirectoryHasher.php create mode 100644 system/HotReloader/HotReloader.php create mode 100644 system/HotReloader/IteratorFilter.php create mode 100644 system/I18n/Exceptions/I18nException.php create mode 100644 system/I18n/Time.php create mode 100644 system/I18n/TimeDifference.php create mode 100644 system/I18n/TimeLegacy.php create mode 100644 system/I18n/TimeTrait.php create mode 100644 system/Images/Exceptions/ImageException.php create mode 100644 system/Images/Handlers/BaseHandler.php create mode 100644 system/Images/Handlers/GDHandler.php create mode 100644 system/Images/Handlers/ImageMagickHandler.php create mode 100644 system/Images/Image.php create mode 100644 system/Images/ImageHandlerInterface.php create mode 100644 system/Language/Language.php create mode 100644 system/Language/en/CLI.php create mode 100644 system/Language/en/Cache.php create mode 100644 system/Language/en/Cast.php create mode 100644 system/Language/en/Cookie.php create mode 100644 system/Language/en/Core.php create mode 100644 system/Language/en/Database.php create mode 100644 system/Language/en/Email.php create mode 100644 system/Language/en/Encryption.php create mode 100644 system/Language/en/Errors.php create mode 100644 system/Language/en/Fabricator.php create mode 100644 system/Language/en/Files.php create mode 100644 system/Language/en/Filters.php create mode 100644 system/Language/en/Format.php create mode 100644 system/Language/en/HTTP.php create mode 100644 system/Language/en/Images.php create mode 100644 system/Language/en/Language.php create mode 100644 system/Language/en/Log.php create mode 100644 system/Language/en/Migrations.php create mode 100644 system/Language/en/Number.php create mode 100644 system/Language/en/Pager.php create mode 100644 system/Language/en/Publisher.php create mode 100644 system/Language/en/RESTful.php create mode 100644 system/Language/en/Router.php create mode 100644 system/Language/en/Security.php create mode 100644 system/Language/en/Session.php create mode 100644 system/Language/en/Test.php create mode 100644 system/Language/en/Time.php create mode 100644 system/Language/en/Validation.php create mode 100644 system/Language/en/View.php create mode 100644 system/Log/Exceptions/LogException.php create mode 100644 system/Log/Handlers/BaseHandler.php create mode 100644 system/Log/Handlers/ChromeLoggerHandler.php create mode 100644 system/Log/Handlers/ErrorlogHandler.php create mode 100644 system/Log/Handlers/FileHandler.php create mode 100644 system/Log/Handlers/HandlerInterface.php create mode 100644 system/Log/Logger.php create mode 100644 system/Model.php create mode 100644 system/Modules/Modules.php create mode 100644 system/Pager/Exceptions/PagerException.php create mode 100644 system/Pager/Pager.php create mode 100644 system/Pager/PagerInterface.php create mode 100644 system/Pager/PagerRenderer.php create mode 100644 system/Pager/Views/default_full.php create mode 100644 system/Pager/Views/default_head.php create mode 100644 system/Pager/Views/default_simple.php create mode 100644 system/Publisher/ContentReplacer.php create mode 100644 system/Publisher/Exceptions/PublisherException.php create mode 100644 system/Publisher/Publisher.php create mode 100644 system/RESTful/BaseResource.php create mode 100644 system/RESTful/ResourceController.php create mode 100644 system/RESTful/ResourcePresenter.php create mode 100644 system/Router/AutoRouter.php create mode 100644 system/Router/AutoRouterImproved.php create mode 100644 system/Router/AutoRouterInterface.php create mode 100644 system/Router/DefinedRouteCollector.php create mode 100644 system/Router/Exceptions/MethodNotFoundException.php create mode 100644 system/Router/Exceptions/RedirectException.php create mode 100644 system/Router/Exceptions/RouterException.php create mode 100644 system/Router/RouteCollection.php create mode 100644 system/Router/RouteCollectionInterface.php create mode 100644 system/Router/Router.php create mode 100644 system/Router/RouterInterface.php create mode 100644 system/Security/Exceptions/SecurityException.php create mode 100644 system/Security/Security.php create mode 100644 system/Security/SecurityInterface.php create mode 100644 system/Session/Exceptions/SessionException.php create mode 100644 system/Session/Handlers/ArrayHandler.php create mode 100644 system/Session/Handlers/BaseHandler.php create mode 100644 system/Session/Handlers/Database/MySQLiHandler.php create mode 100644 system/Session/Handlers/Database/PostgreHandler.php create mode 100644 system/Session/Handlers/DatabaseHandler.php create mode 100644 system/Session/Handlers/FileHandler.php create mode 100644 system/Session/Handlers/MemcachedHandler.php create mode 100644 system/Session/Handlers/RedisHandler.php create mode 100644 system/Session/Session.php create mode 100644 system/Session/SessionInterface.php create mode 100644 system/Superglobals.php create mode 100644 system/Test/CIDatabaseTestCase.php create mode 100644 system/Test/CIUnitTestCase.php create mode 100644 system/Test/ConfigFromArrayTrait.php create mode 100644 system/Test/Constraints/SeeInDatabase.php create mode 100644 system/Test/ControllerResponse.php create mode 100644 system/Test/ControllerTestTrait.php create mode 100644 system/Test/ControllerTester.php create mode 100644 system/Test/DOMParser.php create mode 100644 system/Test/DatabaseTestTrait.php create mode 100644 system/Test/Fabricator.php create mode 100644 system/Test/FeatureResponse.php create mode 100644 system/Test/FeatureTestCase.php create mode 100644 system/Test/FeatureTestTrait.php create mode 100644 system/Test/FilterTestTrait.php create mode 100644 system/Test/Filters/CITestStreamFilter.php create mode 100644 system/Test/IniTestTrait.php create mode 100644 system/Test/Interfaces/FabricatorModel.php create mode 100644 system/Test/Mock/MockAppConfig.php create mode 100644 system/Test/Mock/MockAutoload.php create mode 100644 system/Test/Mock/MockBuilder.php create mode 100644 system/Test/Mock/MockCLIConfig.php create mode 100644 system/Test/Mock/MockCURLRequest.php create mode 100644 system/Test/Mock/MockCache.php create mode 100644 system/Test/Mock/MockCodeIgniter.php create mode 100644 system/Test/Mock/MockCommon.php create mode 100644 system/Test/Mock/MockConnection.php create mode 100644 system/Test/Mock/MockEmail.php create mode 100644 system/Test/Mock/MockEvents.php create mode 100644 system/Test/Mock/MockFileLogger.php create mode 100644 system/Test/Mock/MockIncomingRequest.php create mode 100644 system/Test/Mock/MockLanguage.php create mode 100644 system/Test/Mock/MockLogger.php create mode 100644 system/Test/Mock/MockQuery.php create mode 100644 system/Test/Mock/MockResourceController.php create mode 100644 system/Test/Mock/MockResourcePresenter.php create mode 100644 system/Test/Mock/MockResponse.php create mode 100644 system/Test/Mock/MockResult.php create mode 100644 system/Test/Mock/MockSecurity.php create mode 100644 system/Test/Mock/MockSecurityConfig.php create mode 100644 system/Test/Mock/MockServices.php create mode 100644 system/Test/Mock/MockSession.php create mode 100644 system/Test/Mock/MockTable.php create mode 100644 system/Test/PhpStreamWrapper.php create mode 100644 system/Test/ReflectionHelper.php create mode 100644 system/Test/StreamFilterTrait.php create mode 100644 system/Test/TestLogger.php create mode 100644 system/Test/TestResponse.php create mode 100644 system/Test/bootstrap.php create mode 100644 system/ThirdParty/Escaper/Escaper.php create mode 100644 system/ThirdParty/Escaper/Exception/ExceptionInterface.php create mode 100644 system/ThirdParty/Escaper/Exception/InvalidArgumentException.php create mode 100644 system/ThirdParty/Escaper/Exception/RuntimeException.php create mode 100644 system/ThirdParty/Escaper/LICENSE.md create mode 100644 system/ThirdParty/Kint/CallFinder.php create mode 100644 system/ThirdParty/Kint/FacadeInterface.php create mode 100644 system/ThirdParty/Kint/Kint.php create mode 100644 system/ThirdParty/Kint/LICENSE create mode 100644 system/ThirdParty/Kint/Parser/AbstractPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/ArrayLimitPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/ArrayObjectPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/Base64Plugin.php create mode 100644 system/ThirdParty/Kint/Parser/BinaryPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/BlacklistPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/ClassMethodsPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/ClassStaticsPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/ClosurePlugin.php create mode 100644 system/ThirdParty/Kint/Parser/ColorPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/ConstructablePluginInterface.php create mode 100644 system/ThirdParty/Kint/Parser/DOMDocumentPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/DateTimePlugin.php create mode 100644 system/ThirdParty/Kint/Parser/EnumPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/FsPathPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/IteratorPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/JsonPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/MicrotimePlugin.php create mode 100644 system/ThirdParty/Kint/Parser/MysqliPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/Parser.php create mode 100644 system/ThirdParty/Kint/Parser/PluginInterface.php create mode 100644 system/ThirdParty/Kint/Parser/ProxyPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/SerializePlugin.php create mode 100644 system/ThirdParty/Kint/Parser/SimpleXMLElementPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/SplFileInfoPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/SplObjectStoragePlugin.php create mode 100644 system/ThirdParty/Kint/Parser/StreamPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/TablePlugin.php create mode 100644 system/ThirdParty/Kint/Parser/ThrowablePlugin.php create mode 100644 system/ThirdParty/Kint/Parser/TimestampPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/ToStringPlugin.php create mode 100644 system/ThirdParty/Kint/Parser/TracePlugin.php create mode 100644 system/ThirdParty/Kint/Parser/XmlPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/AbstractRenderer.php create mode 100644 system/ThirdParty/Kint/Renderer/CliRenderer.php create mode 100644 system/ThirdParty/Kint/Renderer/PlainRenderer.php create mode 100644 system/ThirdParty/Kint/Renderer/RendererInterface.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/AbstractPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/ArrayLimitPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/BinaryPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/BlacklistPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/CallablePlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/ClosurePlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/ColorPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/DepthLimitPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/MethodDefinitionPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/MicrotimePlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/PluginInterface.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/RecursionPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/SimpleXMLElementPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/SourcePlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/TabPluginInterface.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/TablePlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/TimestampPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/TraceFramePlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Rich/ValuePluginInterface.php create mode 100644 system/ThirdParty/Kint/Renderer/RichRenderer.php create mode 100644 system/ThirdParty/Kint/Renderer/Text/AbstractPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Text/ArrayLimitPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Text/BlacklistPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Text/DepthLimitPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Text/EnumPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Text/MicrotimePlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Text/PluginInterface.php create mode 100644 system/ThirdParty/Kint/Renderer/Text/RecursionPlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/Text/TracePlugin.php create mode 100644 system/ThirdParty/Kint/Renderer/TextRenderer.php create mode 100644 system/ThirdParty/Kint/Utils.php create mode 100644 system/ThirdParty/Kint/Zval/BlobValue.php create mode 100644 system/ThirdParty/Kint/Zval/ClosureValue.php create mode 100644 system/ThirdParty/Kint/Zval/DateTimeValue.php create mode 100644 system/ThirdParty/Kint/Zval/EnumValue.php create mode 100644 system/ThirdParty/Kint/Zval/InstanceValue.php create mode 100644 system/ThirdParty/Kint/Zval/MethodValue.php create mode 100644 system/ThirdParty/Kint/Zval/ParameterHoldingTrait.php create mode 100644 system/ThirdParty/Kint/Zval/ParameterValue.php create mode 100644 system/ThirdParty/Kint/Zval/Representation/ColorRepresentation.php create mode 100644 system/ThirdParty/Kint/Zval/Representation/MethodDefinitionRepresentation.php create mode 100644 system/ThirdParty/Kint/Zval/Representation/MicrotimeRepresentation.php create mode 100644 system/ThirdParty/Kint/Zval/Representation/Representation.php create mode 100644 system/ThirdParty/Kint/Zval/Representation/SourceRepresentation.php create mode 100644 system/ThirdParty/Kint/Zval/Representation/SplFileInfoRepresentation.php create mode 100644 system/ThirdParty/Kint/Zval/ResourceValue.php create mode 100644 system/ThirdParty/Kint/Zval/SimpleXMLElementValue.php create mode 100644 system/ThirdParty/Kint/Zval/StreamValue.php create mode 100644 system/ThirdParty/Kint/Zval/ThrowableValue.php create mode 100644 system/ThirdParty/Kint/Zval/TraceFrameValue.php create mode 100644 system/ThirdParty/Kint/Zval/TraceValue.php create mode 100644 system/ThirdParty/Kint/Zval/Value.php create mode 100644 system/ThirdParty/Kint/init.php create mode 100644 system/ThirdParty/Kint/init_helpers.php create mode 100644 system/ThirdParty/Kint/resources/compiled/aante-dark.css create mode 100644 system/ThirdParty/Kint/resources/compiled/aante-light.css create mode 100644 system/ThirdParty/Kint/resources/compiled/microtime.js create mode 100644 system/ThirdParty/Kint/resources/compiled/original.css create mode 100644 system/ThirdParty/Kint/resources/compiled/plain.css create mode 100644 system/ThirdParty/Kint/resources/compiled/plain.js create mode 100644 system/ThirdParty/Kint/resources/compiled/rich.js create mode 100644 system/ThirdParty/Kint/resources/compiled/shared.js create mode 100644 system/ThirdParty/Kint/resources/compiled/solarized-dark.css create mode 100644 system/ThirdParty/Kint/resources/compiled/solarized.css create mode 100644 system/ThirdParty/PSR/Log/AbstractLogger.php create mode 100644 system/ThirdParty/PSR/Log/InvalidArgumentException.php create mode 100644 system/ThirdParty/PSR/Log/LICENSE create mode 100644 system/ThirdParty/PSR/Log/LogLevel.php create mode 100644 system/ThirdParty/PSR/Log/LoggerAwareInterface.php create mode 100644 system/ThirdParty/PSR/Log/LoggerAwareTrait.php create mode 100644 system/ThirdParty/PSR/Log/LoggerInterface.php create mode 100644 system/ThirdParty/PSR/Log/LoggerTrait.php create mode 100644 system/ThirdParty/PSR/Log/NullLogger.php create mode 100644 system/Throttle/Throttler.php create mode 100644 system/Throttle/ThrottlerInterface.php create mode 100644 system/Traits/ConditionalTrait.php create mode 100644 system/Traits/PropertiesTrait.php create mode 100644 system/Typography/Typography.php create mode 100644 system/Validation/CreditCardRules.php create mode 100644 system/Validation/DotArrayFilter.php create mode 100644 system/Validation/Exceptions/ValidationException.php create mode 100644 system/Validation/FileRules.php create mode 100644 system/Validation/FormatRules.php create mode 100644 system/Validation/Rules.php create mode 100644 system/Validation/StrictRules/CreditCardRules.php create mode 100644 system/Validation/StrictRules/FileRules.php create mode 100644 system/Validation/StrictRules/FormatRules.php create mode 100644 system/Validation/StrictRules/Rules.php create mode 100644 system/Validation/Validation.php create mode 100644 system/Validation/ValidationInterface.php create mode 100644 system/Validation/Views/list.php create mode 100644 system/Validation/Views/single.php create mode 100644 system/View/Cell.php create mode 100644 system/View/Cells/Cell.php create mode 100644 system/View/Exceptions/ViewException.php create mode 100644 system/View/Filters.php create mode 100644 system/View/Parser.php create mode 100644 system/View/Plugins.php create mode 100644 system/View/RendererInterface.php create mode 100644 system/View/Table.php create mode 100644 system/View/View.php create mode 100644 system/View/ViewDecoratorInterface.php create mode 100644 system/View/ViewDecoratorTrait.php create mode 100644 system/bootstrap.php create mode 100644 system/index.html create mode 100644 tests/README.md create mode 100644 tests/_support/Database/Migrations/2020-02-22-222222_example_migration.php create mode 100644 tests/_support/Database/Seeds/ExampleSeeder.php create mode 100644 tests/_support/Libraries/ConfigReader.php create mode 100644 tests/_support/Models/ExampleModel.php create mode 100644 tests/database/ExampleDatabaseTest.php create mode 100644 tests/session/ExampleSessionTest.php create mode 100644 tests/unit/HealthTest.php create mode 100644 writable/.htaccess create mode 100644 writable/cache/index.html create mode 100644 writable/debugbar/.gitkeep create mode 100644 writable/logs/index.html create mode 100644 writable/logs/log-2024-03-06.log create mode 100644 writable/logs/log-2024-03-12.log create mode 100644 writable/session/index.html create mode 100644 writable/uploads/index.html diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0119e5f --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2014-2019 British Columbia Institute of Technology +Copyright (c) 2019-2023 CodeIgniter Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..00cdf70 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# CodeIgniter 4 Framework + +## What is CodeIgniter? + +CodeIgniter is a PHP full-stack web framework that is light, fast, flexible and secure. +More information can be found at the [official site](https://codeigniter.com). + +This repository holds the distributable version of the framework. +It has been built from the +[development repository](https://github.com/codeigniter4/CodeIgniter4). + +More information about the plans for version 4 can be found in [CodeIgniter 4](https://forum.codeigniter.com/forumdisplay.php?fid=28) on the forums. + +The user guide corresponding to the latest version of the framework can be found +[here](https://codeigniter4.github.io/userguide/). + +## Important Change with index.php + +`index.php` is no longer in the root of the project! It has been moved inside the *public* folder, +for better security and separation of components. + +This means that you should configure your web server to "point" to your project's *public* folder, and +not to the project root. A better practice would be to configure a virtual host to point there. A poor practice would be to point your web server to the project root and expect to enter *public/...*, as the rest of your logic and the +framework are exposed. + +**Please** read the user guide for a better explanation of how CI4 works! + +## Repository Management + +We use GitHub issues, in our main repository, to track **BUGS** and to track approved **DEVELOPMENT** work packages. +We use our [forum](http://forum.codeigniter.com) to provide SUPPORT and to discuss +FEATURE REQUESTS. + +This repository is a "distribution" one, built by our release preparation script. +Problems with it can be raised on our forum, or as issues in the main repository. + +## Contributing + +We welcome contributions from the community. + +Please read the [*Contributing to CodeIgniter*](https://github.com/codeigniter4/CodeIgniter4/blob/develop/CONTRIBUTING.md) section in the development repository. + +## Server Requirements + +PHP version 7.4 or higher is required, with the following extensions installed: + +- [intl](http://php.net/manual/en/intl.requirements.php) +- [mbstring](http://php.net/manual/en/mbstring.installation.php) + +> **Warning** +> The end of life date for PHP 7.4 was November 28, 2022. If you are +> still using PHP 7.4, you should upgrade immediately. The end of life date +> for PHP 8.0 will be November 26, 2023. + +Additionally, make sure that the following extensions are enabled in your PHP: + +- json (enabled by default - don't turn it off) +- [mysqlnd](http://php.net/manual/en/mysqlnd.install.php) if you plan to use MySQL +- [libcurl](http://php.net/manual/en/curl.requirements.php) if you plan to use the HTTP\CURLRequest library diff --git a/app/.htaccess b/app/.htaccess new file mode 100644 index 0000000..f24db0a --- /dev/null +++ b/app/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/app/Common.php b/app/Common.php new file mode 100644 index 0000000..95f5544 --- /dev/null +++ b/app/Common.php @@ -0,0 +1,15 @@ + + */ + public array $allowedHostnames = []; + + /** + * -------------------------------------------------------------------------- + * Index File + * -------------------------------------------------------------------------- + * + * Typically this will be your index.php file, unless you've renamed it to + * something else. If you are using mod_rewrite to remove the page set this + * variable so that it is blank. + */ + public string $indexPage = 'index.php'; + + /** + * -------------------------------------------------------------------------- + * URI PROTOCOL + * -------------------------------------------------------------------------- + * + * This item determines which server global should be used to retrieve the + * URI string. The default setting of 'REQUEST_URI' works for most servers. + * If your links do not seem to work, try one of the other delicious flavors: + * + * 'REQUEST_URI' Uses $_SERVER['REQUEST_URI'] + * 'QUERY_STRING' Uses $_SERVER['QUERY_STRING'] + * 'PATH_INFO' Uses $_SERVER['PATH_INFO'] + * + * WARNING: If you set this to 'PATH_INFO', URIs will always be URL-decoded! + */ + public string $uriProtocol = 'REQUEST_URI'; + + /** + * -------------------------------------------------------------------------- + * Default Locale + * -------------------------------------------------------------------------- + * + * The Locale roughly represents the language and location that your visitor + * is viewing the site from. It affects the language strings and other + * strings (like currency markers, numbers, etc), that your program + * should run under for this request. + */ + public string $defaultLocale = 'en'; + + /** + * -------------------------------------------------------------------------- + * Negotiate Locale + * -------------------------------------------------------------------------- + * + * If true, the current Request object will automatically determine the + * language to use based on the value of the Accept-Language header. + * + * If false, no automatic detection will be performed. + */ + public bool $negotiateLocale = false; + + /** + * -------------------------------------------------------------------------- + * Supported Locales + * -------------------------------------------------------------------------- + * + * If $negotiateLocale is true, this array lists the locales supported + * by the application in descending order of priority. If no match is + * found, the first locale will be used. + * + * IncomingRequest::setLocale() also uses this list. + * + * @var string[] + */ + public array $supportedLocales = ['en']; + + /** + * -------------------------------------------------------------------------- + * Application Timezone + * -------------------------------------------------------------------------- + * + * The default timezone that will be used in your application to display + * dates with the date helper, and can be retrieved through app_timezone() + * + * @see https://www.php.net/manual/en/timezones.php for list of timezones supported by PHP. + */ + public string $appTimezone = 'UTC'; + + /** + * -------------------------------------------------------------------------- + * Default Character Set + * -------------------------------------------------------------------------- + * + * This determines which character set is used by default in various methods + * that require a character set to be provided. + * + * @see http://php.net/htmlspecialchars for a list of supported charsets. + */ + public string $charset = 'UTF-8'; + + /** + * -------------------------------------------------------------------------- + * Force Global Secure Requests + * -------------------------------------------------------------------------- + * + * If true, this will force every request made to this application to be + * made via a secure connection (HTTPS). If the incoming request is not + * secure, the user will be redirected to a secure version of the page + * and the HTTP Strict Transport Security header will be set. + */ + public bool $forceGlobalSecureRequests = false; + + /** + * -------------------------------------------------------------------------- + * Reverse Proxy IPs + * -------------------------------------------------------------------------- + * + * If your server is behind a reverse proxy, you must whitelist the proxy + * IP addresses from which CodeIgniter should trust headers such as + * X-Forwarded-For or Client-IP in order to properly identify + * the visitor's IP address. + * + * You need to set a proxy IP address or IP address with subnets and + * the HTTP header for the client IP address. + * + * Here are some examples: + * [ + * '10.0.1.200' => 'X-Forwarded-For', + * '192.168.5.0/24' => 'X-Real-IP', + * ] + * + * @var array + */ + public array $proxyIPs = []; + + /** + * -------------------------------------------------------------------------- + * Content Security Policy + * -------------------------------------------------------------------------- + * + * Enables the Response's Content Secure Policy to restrict the sources that + * can be used for images, scripts, CSS files, audio, video, etc. If enabled, + * the Response object will populate default values for the policy from the + * `ContentSecurityPolicy.php` file. Controllers can always add to those + * restrictions at run time. + * + * For a better understanding of CSP, see these documents: + * + * @see http://www.html5rocks.com/en/tutorials/security/content-security-policy/ + * @see http://www.w3.org/TR/CSP/ + */ + public bool $CSPEnabled = false; +} \ No newline at end of file diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php new file mode 100644 index 0000000..7a4602d --- /dev/null +++ b/app/Config/Autoload.php @@ -0,0 +1,99 @@ + SYSTEMPATH, + * 'App' => APPPATH + * ]; + * + * @var array|string> + */ + public $psr4 = [ + APP_NAMESPACE => APPPATH, // For custom app namespace + 'Config' => APPPATH . 'Config', + ]; + + /** + * ------------------------------------------------------------------- + * Class Map + * ------------------------------------------------------------------- + * The class map provides a map of class names and their exact + * location on the drive. Classes loaded in this manner will have + * slightly faster performance because they will not have to be + * searched for within one or more directories as they would if they + * were being autoloaded through a namespace. + * + * Prototype: + * $classmap = [ + * 'MyClass' => '/path/to/class/file.php' + * ]; + * + * @var array + */ + public $classmap = []; + + /** + * ------------------------------------------------------------------- + * Files + * ------------------------------------------------------------------- + * The files array provides a list of paths to __non-class__ files + * that will be autoloaded. This can be useful for bootstrap operations + * or for loading functions. + * + * Prototype: + * $files = [ + * '/path/to/my/file.php', + * ]; + * + * @var list + */ + public $files = []; + + /** + * ------------------------------------------------------------------- + * Helpers + * ------------------------------------------------------------------- + * Prototype: + * $helpers = [ + * 'form', + * ]; + * + * @var list + */ + public $helpers = []; +} diff --git a/app/Config/Boot/development.php b/app/Config/Boot/development.php new file mode 100644 index 0000000..a868447 --- /dev/null +++ b/app/Config/Boot/development.php @@ -0,0 +1,34 @@ + + */ + public array $file = [ + 'storePath' => WRITEPATH . 'cache/', + 'mode' => 0640, + ]; + + /** + * ------------------------------------------------------------------------- + * Memcached settings + * ------------------------------------------------------------------------- + * Your Memcached servers can be specified below, if you are using + * the Memcached drivers. + * + * @see https://codeigniter.com/user_guide/libraries/caching.html#memcached + * + * @var array + */ + public array $memcached = [ + 'host' => '127.0.0.1', + 'port' => 11211, + 'weight' => 1, + 'raw' => false, + ]; + + /** + * ------------------------------------------------------------------------- + * Redis settings + * ------------------------------------------------------------------------- + * Your Redis server can be specified below, if you are using + * the Redis or Predis drivers. + * + * @var array + */ + public array $redis = [ + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'timeout' => 0, + 'database' => 0, + ]; + + /** + * -------------------------------------------------------------------------- + * Available Cache Handlers + * -------------------------------------------------------------------------- + * + * This is an array of cache engine alias' and class names. Only engines + * that are listed here are allowed to be used. + * + * @var array> + */ + public array $validHandlers = [ + 'dummy' => DummyHandler::class, + 'file' => FileHandler::class, + 'memcached' => MemcachedHandler::class, + 'predis' => PredisHandler::class, + 'redis' => RedisHandler::class, + 'wincache' => WincacheHandler::class, + ]; +} diff --git a/app/Config/Constants.php b/app/Config/Constants.php new file mode 100644 index 0000000..47b92f8 --- /dev/null +++ b/app/Config/Constants.php @@ -0,0 +1,94 @@ +` element. + * + * Will default to self if not overridden + * + * @var string|string[]|null + */ + public $baseURI; + + /** + * Lists the URLs for workers and embedded frame contents + * + * @var string|string[] + */ + public $childSrc = 'self'; + + /** + * Limits the origins that you can connect to (via XHR, + * WebSockets, and EventSource). + * + * @var string|string[] + */ + public $connectSrc = 'self'; + + /** + * Specifies the origins that can serve web fonts. + * + * @var string|string[] + */ + public $fontSrc; + + /** + * Lists valid endpoints for submission from `
` tags. + * + * @var string|string[] + */ + public $formAction = 'self'; + + /** + * Specifies the sources that can embed the current page. + * This directive applies to ``, `'),me()}function ke(e){var s,i=function(e){var s;e.match(/(http:|https:|)\/\/(player.|www.)?(vimeo\.com|youtu(be\.com|\.be|be\.googleapis\.com))\/(video\/|embed\/|watch\?v=|v\/)?([A-Za-z0-9._%-]*)(\&\S+)?/),RegExp.$3.indexOf("youtu")>-1?s="youtube":RegExp.$3.indexOf("vimeo")>-1&&(s="vimeo");return{type:s,id:RegExp.$6}}(v),a=(e?"?rel=0&autoplay=1":"?rel=0")+function(e){var s="",i=decodeURIComponent(e).split("?");if(void 0!==i[1]){var a,t,o=i[1].split("&");for(t=0;t'),me()}function pe(){n.html('
'+e(v).html()+"
"),me()}function ge(){(j=n.find("img")).length?j.each(function(){e(this).one("load",function(){me()})}):me()}function me(){c.html(_),n.find(">:first-child").addClass("vbox-figlio").css({width:b,height:h,padding:r,background:i}),e("img.vbox-figlio").on("dragstart",function(e){e.preventDefault()}),d.scrollTop(0),fe(),n.animate({opacity:"1"},"slow",function(){O.hide()}),se.cb_content_loaded(N,U,z,B)}function fe(){var s=n.outerHeight(),i=e(window).height();f=s+60=0}function e(a,b){for(var c in b)if(null==a[c]){var d=b[c];a[c]=d}return a}function f(a){return/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(a)}function g(a){var b=arguments.length<=1||void 0===arguments[1]?!1:arguments[1],c=arguments.length<=2||void 0===arguments[2]?!1:arguments[2],d=arguments.length<=3||void 0===arguments[3]?null:arguments[3],e=void 0;return null!=document.createEvent?(e=document.createEvent("CustomEvent"),e.initCustomEvent(a,b,c,d)):null!=document.createEventObject?(e=document.createEventObject(),e.eventType=a):e.eventName=a,e}function h(a,b){null!=a.dispatchEvent?a.dispatchEvent(b):b in(null!=a)?a[b]():"on"+b in(null!=a)&&a["on"+b]()}function i(a,b,c){null!=a.addEventListener?a.addEventListener(b,c,!1):null!=a.attachEvent?a.attachEvent("on"+b,c):a[b]=c}function j(a,b,c){null!=a.removeEventListener?a.removeEventListener(b,c,!1):null!=a.detachEvent?a.detachEvent("on"+b,c):delete a[b]}function k(){return"innerHeight"in window?window.innerHeight:document.documentElement.clientHeight}Object.defineProperty(b,"__esModule",{value:!0});var l,m,n=function(){function a(a,b){for(var c=0;c=0){var b=a.target||a.srcElement;b.className=b.className.replace(this.config.animateClass,"").trim()}}},{key:"customStyle",value:function(a,b,c,d,e){return b&&this.cacheAnimationName(a),a.style.visibility=b?"hidden":"visible",c&&this.vendorSet(a.style,{animationDuration:c}),d&&this.vendorSet(a.style,{animationDelay:d}),e&&this.vendorSet(a.style,{animationIterationCount:e}),this.vendorSet(a.style,{animationName:b?"none":this.cachedAnimationName(a)}),a}},{key:"vendorSet",value:function(a,b){for(var c in b)if(b.hasOwnProperty(c)){var d=b[c];a[""+c]=d;for(var e=0;e=e&&f>=c}},{key:"disabled",value:function(){return!this.config.mobile&&f(navigator.userAgent)}}]),a}();b["default"]=r,a.exports=b["default"]}); \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7ecfce214cbdbedc15d8348babeff5cd7e720488 GIT binary patch literal 5430 zcmeHL&npB`9Dn5Kuy>cuLGmAPauLohTgugsgTofC9NbV!aa0tgTofgxl%f=?Y?Sh& zWMxI9hibZ5LZ@~9&$;7J-7ai$2=7ByD3A3P1*hfQz|8$LL= zUz~w$;90HjJ=)Ss!V^NuI2Za%(z#lli~7u{+nbyOi;~<-#pGX{K<~tb_C6Lj^YSY9 zuHBEb(bRJ+>$(mjNuamQtlmY!MgdXJjDiJ*`fSvC!*oskp+}sjm(MWyz%r29BW!f*m(x|(w?cOiYFaD0y8pq z9eCfscMhGYa>i@!YdSYJ(-6XZ(W@wc#d9lm1l3S%FF!rC_waiB@V}AM)ez?!Gp$os wgQ-p&yk8A*^lZ5Jw#)Gj@LI_qy{H^P{^jt7C;ag)RHAYMwkN>;6;PJx7hHm1jsO4v literal 0 HcmV?d00001 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..1cc4710 --- /dev/null +++ b/public/index.php @@ -0,0 +1,87 @@ +systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'bootstrap.php'; + +// Load environment settings from .env files into $_SERVER and $_ENV +require_once SYSTEMPATH . 'Config/DotEnv.php'; +(new CodeIgniter\Config\DotEnv(ROOTPATH))->load(); + +// Define ENVIRONMENT +if (! defined('ENVIRONMENT')) { + define('ENVIRONMENT', env('CI_ENVIRONMENT', 'production')); +} + +// Load Config Cache +// $factoriesCache = new \CodeIgniter\Cache\FactoriesCache(); +// $factoriesCache->load('config'); +// ^^^ Uncomment these lines if you want to use Config Caching. + +/* + * --------------------------------------------------------------- + * GRAB OUR CODEIGNITER INSTANCE + * --------------------------------------------------------------- + * + * The CodeIgniter class contains the core functionality to make + * the application run, and does all the dirty work to get + * the pieces all working together. + */ + +$app = Config\Services::codeigniter(); +$app->initialize(); +$context = is_cli() ? 'php-cli' : 'web'; +$app->setContext($context); + +/* + *--------------------------------------------------------------- + * LAUNCH THE APPLICATION + *--------------------------------------------------------------- + * Now that everything is set up, it's time to actually fire + * up the engines and make this app do its thang. + */ + +$app->run(); + +// Save Config Cache +// $factoriesCache->save('config'); +// ^^^ Uncomment this line if you want to use Config Caching. + +// Exits the application, setting the exit code for CLI-based applications +// that might be watching. +exit(EXIT_SUCCESS); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..9e60f97 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/spark b/spark new file mode 100644 index 0000000..9daa440 --- /dev/null +++ b/spark @@ -0,0 +1,104 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +/* + * -------------------------------------------------------------------- + * CodeIgniter command-line tools + * -------------------------------------------------------------------- + * The main entry point into the CLI system and allows you to run + * commands and perform maintenance on your application. + * + * Because CodeIgniter can handle CLI requests as just another web request + * this class mainly acts as a passthru to the framework itself. + */ + +// Refuse to run when called from php-cgi +if (strpos(PHP_SAPI, 'cgi') === 0) { + exit("The cli tool is not supported when running php-cgi. It needs php-cli to function!\n\n"); +} + +// Check PHP version. +$minPhpVersion = '7.4'; // If you update this, don't forget to update `public/index.php`. +if (version_compare(PHP_VERSION, $minPhpVersion, '<')) { + $message = sprintf( + 'Your PHP version must be %s or higher to run CodeIgniter. Current version: %s', + $minPhpVersion, + PHP_VERSION + ); + + exit($message); +} + +// We want errors to be shown when using it from the CLI. +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +/** + * @var bool + * + * @deprecated No longer in use. `CodeIgniter` has `$context` property. + */ +define('SPARKED', true); + +// Path to the front controller +define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR); + +// Ensure the current directory is pointing to the front controller's directory +chdir(FCPATH); + +/* + *--------------------------------------------------------------- + * BOOTSTRAP THE APPLICATION + *--------------------------------------------------------------- + * This process sets up the path constants, loads and registers + * our autoloader, along with Composer's, loads our constants + * and fires up an environment-specific bootstrapping. + */ + +// Load our paths config file +// This is the line that might need to be changed, depending on your folder structure. +require FCPATH . '../app/Config/Paths.php'; +// ^^^ Change this line if you move your application folder + +$paths = new Config\Paths(); + +// Location of the framework bootstrap file. +require rtrim($paths->systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'bootstrap.php'; + +// Load environment settings from .env files into $_SERVER and $_ENV +require_once SYSTEMPATH . 'Config/DotEnv.php'; +(new CodeIgniter\Config\DotEnv(ROOTPATH))->load(); + +// Define ENVIRONMENT +if (! defined('ENVIRONMENT')) { + define('ENVIRONMENT', env('CI_ENVIRONMENT', 'production')); +} + +// Grab our CodeIgniter +$app = Config\Services::codeigniter(); +$app->initialize(); + +// Grab our Console +$console = new CodeIgniter\CLI\Console(); + +// Show basic information before we do anything else. +if (is_int($suppress = array_search('--no-header', $_SERVER['argv'], true))) { + unset($_SERVER['argv'][$suppress]); // @codeCoverageIgnore + $suppress = true; +} + +$console->showHeader($suppress); + +// fire off the command in the main framework. +$exit = $console->run(); + +exit(is_int($exit) ? $exit : EXIT_SUCCESS); diff --git a/system/.htaccess b/system/.htaccess new file mode 100644 index 0000000..3462048 --- /dev/null +++ b/system/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/system/API/ResponseTrait.php b/system/API/ResponseTrait.php new file mode 100644 index 0000000..3cf0535 --- /dev/null +++ b/system/API/ResponseTrait.php @@ -0,0 +1,359 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\API; + +use CodeIgniter\Format\FormatterInterface; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Services; + +/** + * Provides common, more readable, methods to provide + * consistent HTTP responses under a variety of common + * situations when working as an API. + */ +trait ResponseTrait +{ + /** + * Allows child classes to override the + * status code that is used in their API. + * + * @var array + */ + protected $codes = [ + 'created' => 201, + 'deleted' => 200, + 'updated' => 200, + 'no_content' => 204, + 'invalid_request' => 400, + 'unsupported_response_type' => 400, + 'invalid_scope' => 400, + 'temporarily_unavailable' => 400, + 'invalid_grant' => 400, + 'invalid_credentials' => 400, + 'invalid_refresh' => 400, + 'no_data' => 400, + 'invalid_data' => 400, + 'access_denied' => 401, + 'unauthorized' => 401, + 'invalid_client' => 401, + 'forbidden' => 403, + 'resource_not_found' => 404, + 'not_acceptable' => 406, + 'resource_exists' => 409, + 'conflict' => 409, + 'resource_gone' => 410, + 'payload_too_large' => 413, + 'unsupported_media_type' => 415, + 'too_many_requests' => 429, + 'server_error' => 500, + 'unsupported_grant_type' => 501, + 'not_implemented' => 501, + ]; + + /** + * How to format the response data. + * Either 'json' or 'xml'. If blank will be + * determined through content negotiation. + * + * @var string + */ + protected $format = 'json'; + + /** + * Current Formatter instance. This is usually set by ResponseTrait::format + * + * @var FormatterInterface|null + */ + protected $formatter; + + /** + * Provides a single, simple method to return an API response, formatted + * to match the requested format, with proper content-type and status code. + * + * @param array|string|null $data + * + * @return ResponseInterface + */ + protected function respond($data = null, ?int $status = null, string $message = '') + { + if ($data === null && $status === null) { + $status = 404; + $output = null; + $this->format($data); + } elseif ($data === null && is_numeric($status)) { + $output = null; + $this->format($data); + } else { + $status ??= 200; + $output = $this->format($data); + } + + if ($output !== null) { + if ($this->format === 'json') { + return $this->response->setJSON($output)->setStatusCode($status, $message); + } + + if ($this->format === 'xml') { + return $this->response->setXML($output)->setStatusCode($status, $message); + } + } + + return $this->response->setBody($output)->setStatusCode($status, $message); + } + + /** + * Used for generic failures that no custom methods exist for. + * + * @param array|string $messages + * @param int $status HTTP status code + * @param string|null $code Custom, API-specific, error code + * + * @return ResponseInterface + */ + protected function fail($messages, int $status = 400, ?string $code = null, string $customMessage = '') + { + if (! is_array($messages)) { + $messages = ['error' => $messages]; + } + + $response = [ + 'status' => $status, + 'error' => $code ?? $status, + 'messages' => $messages, + ]; + + return $this->respond($response, $status, $customMessage); + } + + // -------------------------------------------------------------------- + // Response Helpers + // -------------------------------------------------------------------- + + /** + * Used after successfully creating a new resource. + * + * @param array|string|null $data + * + * @return ResponseInterface + */ + protected function respondCreated($data = null, string $message = '') + { + return $this->respond($data, $this->codes['created'], $message); + } + + /** + * Used after a resource has been successfully deleted. + * + * @param array|string|null $data + * + * @return ResponseInterface + */ + protected function respondDeleted($data = null, string $message = '') + { + return $this->respond($data, $this->codes['deleted'], $message); + } + + /** + * Used after a resource has been successfully updated. + * + * @param array|string|null $data + * + * @return ResponseInterface + */ + protected function respondUpdated($data = null, string $message = '') + { + return $this->respond($data, $this->codes['updated'], $message); + } + + /** + * Used after a command has been successfully executed but there is no + * meaningful reply to send back to the client. + * + * @return ResponseInterface + */ + protected function respondNoContent(string $message = 'No Content') + { + return $this->respond(null, $this->codes['no_content'], $message); + } + + /** + * Used when the client is either didn't send authorization information, + * or had bad authorization credentials. User is encouraged to try again + * with the proper information. + * + * @return ResponseInterface + */ + protected function failUnauthorized(string $description = 'Unauthorized', ?string $code = null, string $message = '') + { + return $this->fail($description, $this->codes['unauthorized'], $code, $message); + } + + /** + * Used when access is always denied to this resource and no amount + * of trying again will help. + * + * @return ResponseInterface + */ + protected function failForbidden(string $description = 'Forbidden', ?string $code = null, string $message = '') + { + return $this->fail($description, $this->codes['forbidden'], $code, $message); + } + + /** + * Used when a specified resource cannot be found. + * + * @return ResponseInterface + */ + protected function failNotFound(string $description = 'Not Found', ?string $code = null, string $message = '') + { + return $this->fail($description, $this->codes['resource_not_found'], $code, $message); + } + + /** + * Used when the data provided by the client cannot be validated. + * + * @return ResponseInterface + * + * @deprecated Use failValidationErrors instead + */ + protected function failValidationError(string $description = 'Bad Request', ?string $code = null, string $message = '') + { + return $this->fail($description, $this->codes['invalid_data'], $code, $message); + } + + /** + * Used when the data provided by the client cannot be validated on one or more fields. + * + * @param string|string[] $errors + * + * @return ResponseInterface + */ + protected function failValidationErrors($errors, ?string $code = null, string $message = '') + { + return $this->fail($errors, $this->codes['invalid_data'], $code, $message); + } + + /** + * Use when trying to create a new resource and it already exists. + * + * @return ResponseInterface + */ + protected function failResourceExists(string $description = 'Conflict', ?string $code = null, string $message = '') + { + return $this->fail($description, $this->codes['resource_exists'], $code, $message); + } + + /** + * Use when a resource was previously deleted. This is different than + * Not Found, because here we know the data previously existed, but is now gone, + * where Not Found means we simply cannot find any information about it. + * + * @return ResponseInterface + */ + protected function failResourceGone(string $description = 'Gone', ?string $code = null, string $message = '') + { + return $this->fail($description, $this->codes['resource_gone'], $code, $message); + } + + /** + * Used when the user has made too many requests for the resource recently. + * + * @return ResponseInterface + */ + protected function failTooManyRequests(string $description = 'Too Many Requests', ?string $code = null, string $message = '') + { + return $this->fail($description, $this->codes['too_many_requests'], $code, $message); + } + + /** + * Used when there is a server error. + * + * @param string $description The error message to show the user. + * @param string|null $code A custom, API-specific, error code. + * @param string $message A custom "reason" message to return. + */ + protected function failServerError(string $description = 'Internal Server Error', ?string $code = null, string $message = ''): ResponseInterface + { + return $this->fail($description, $this->codes['server_error'], $code, $message); + } + + // -------------------------------------------------------------------- + // Utility Methods + // -------------------------------------------------------------------- + + /** + * Handles formatting a response. Currently makes some heavy assumptions + * and needs updating! :) + * + * @param array|string|null $data + * + * @return string|null + */ + protected function format($data = null) + { + // If the data is a string, there's not much we can do to it... + if (is_string($data)) { + // The content type should be text/... and not application/... + $contentType = $this->response->getHeaderLine('Content-Type'); + $contentType = str_replace('application/json', 'text/html', $contentType); + $contentType = str_replace('application/', 'text/', $contentType); + $this->response->setContentType($contentType); + $this->format = 'html'; + + return $data; + } + + $format = Services::format(); + $mime = "application/{$this->format}"; + + // Determine correct response type through content negotiation if not explicitly declared + if ( + ! in_array($this->format, ['json', 'xml'], true) + && $this->request instanceof IncomingRequest + ) { + $mime = $this->request->negotiate( + 'media', + $format->getConfig()->supportedResponseFormats, + false + ); + } + + $this->response->setContentType($mime); + + // if we don't have a formatter, make one + if (! isset($this->formatter)) { + // if no formatter, use the default + $this->formatter = $format->getFormatter($mime); + } + + if ($mime !== 'application/json') { + // Recursively convert objects into associative arrays + // Conversion not required for JSONFormatter + $data = json_decode(json_encode($data), true); + } + + return $this->formatter->format($data); + } + + /** + * Sets the format the response should be in. + * + * @return $this + */ + protected function setResponseFormat(?string $format = null) + { + $this->format = strtolower($format); + + return $this; + } +} diff --git a/system/Autoloader/Autoloader.php b/system/Autoloader/Autoloader.php new file mode 100644 index 0000000..ca886fb --- /dev/null +++ b/system/Autoloader/Autoloader.php @@ -0,0 +1,488 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Autoloader; + +use CodeIgniter\Exceptions\ConfigException; +use Composer\Autoload\ClassLoader; +use Composer\InstalledVersions; +use Config\Autoload; +use Config\Modules; +use InvalidArgumentException; +use RuntimeException; + +/** + * An autoloader that uses both PSR4 autoloading, and traditional classmaps. + * + * Given a foo-bar package of classes in the file system at the following paths: + * ``` + * /path/to/packages/foo-bar/ + * /src + * Baz.php # Foo\Bar\Baz + * Qux/ + * Quux.php # Foo\Bar\Qux\Quux + * ``` + * you can add the path to the configuration array that is passed in the constructor. + * The Config array consists of 2 primary keys, both of which are associative arrays: + * 'psr4', and 'classmap'. + * ``` + * $Config = [ + * 'psr4' => [ + * 'Foo\Bar' => '/path/to/packages/foo-bar' + * ], + * 'classmap' => [ + * 'MyClass' => '/path/to/class/file.php' + * ] + * ]; + * ``` + * Example: + * ``` + * register(); + * ``` + * + * @see \CodeIgniter\Autoloader\AutoloaderTest + */ +class Autoloader +{ + /** + * Stores namespaces as key, and path as values. + * + * @var array> + */ + protected $prefixes = []; + + /** + * Stores class name as key, and path as values. + * + * @var array + */ + protected $classmap = []; + + /** + * Stores files as a list. + * + * @var list + */ + protected $files = []; + + /** + * Stores helper list. + * Always load the URL helper, it should be used in most apps. + * + * @var list + */ + protected $helpers = ['url']; + + /** + * Reads in the configuration array (described above) and stores + * the valid parts that we'll need. + * + * @return $this + */ + public function initialize(Autoload $config, Modules $modules) + { + $this->prefixes = []; + $this->classmap = []; + $this->files = []; + + // We have to have one or the other, though we don't enforce the need + // to have both present in order to work. + if ($config->psr4 === [] && $config->classmap === []) { + throw new InvalidArgumentException('Config array must contain either the \'psr4\' key or the \'classmap\' key.'); + } + + if ($config->psr4 !== []) { + $this->addNamespace($config->psr4); + } + + if ($config->classmap !== []) { + $this->classmap = $config->classmap; + } + + if ($config->files !== []) { + $this->files = $config->files; + } + + if (isset($config->helpers)) { + $this->helpers = [...$this->helpers, ...$config->helpers]; + } + + if (is_file(COMPOSER_PATH)) { + $this->loadComposerAutoloader($modules); + } + + return $this; + } + + private function loadComposerAutoloader(Modules $modules): void + { + // The path to the vendor directory. + // We do not want to enforce this, so set the constant if Composer was used. + if (! defined('VENDORPATH')) { + define('VENDORPATH', dirname(COMPOSER_PATH) . DIRECTORY_SEPARATOR); + } + + /** @var ClassLoader $composer */ + $composer = include COMPOSER_PATH; + + $this->loadComposerClassmap($composer); + + // Should we load through Composer's namespaces, also? + if ($modules->discoverInComposer) { + // @phpstan-ignore-next-line + $this->loadComposerNamespaces($composer, $modules->composerPackages ?? []); + } + + unset($composer); + } + + /** + * Register the loader with the SPL autoloader stack. + * + * @return void + */ + public function register() + { + // Prepend the PSR4 autoloader for maximum performance. + spl_autoload_register([$this, 'loadClass'], true, true); + + // Now prepend another loader for the files in our class map. + spl_autoload_register([$this, 'loadClassmap'], true, true); + + // Load our non-class files + foreach ($this->files as $file) { + $this->includeFile($file); + } + } + + /** + * Unregister autoloader. + * + * This method is for testing. + */ + public function unregister(): void + { + spl_autoload_unregister([$this, 'loadClass']); + spl_autoload_unregister([$this, 'loadClassmap']); + } + + /** + * Registers namespaces with the autoloader. + * + * @param array|string>|string $namespace + * + * @return $this + */ + public function addNamespace($namespace, ?string $path = null) + { + if (is_array($namespace)) { + foreach ($namespace as $prefix => $namespacedPath) { + $prefix = trim($prefix, '\\'); + + if (is_array($namespacedPath)) { + foreach ($namespacedPath as $dir) { + $this->prefixes[$prefix][] = rtrim($dir, '\\/') . DIRECTORY_SEPARATOR; + } + + continue; + } + + $this->prefixes[$prefix][] = rtrim($namespacedPath, '\\/') . DIRECTORY_SEPARATOR; + } + } else { + $this->prefixes[trim($namespace, '\\')][] = rtrim($path, '\\/') . DIRECTORY_SEPARATOR; + } + + return $this; + } + + /** + * Get namespaces with prefixes as keys and paths as values. + * + * If a prefix param is set, returns only paths to the given prefix. + * + * @return array + */ + public function getNamespace(?string $prefix = null) + { + if ($prefix === null) { + return $this->prefixes; + } + + return $this->prefixes[trim($prefix, '\\')] ?? []; + } + + /** + * Removes a single namespace from the psr4 settings. + * + * @return $this + */ + public function removeNamespace(string $namespace) + { + if (isset($this->prefixes[trim($namespace, '\\')])) { + unset($this->prefixes[trim($namespace, '\\')]); + } + + return $this; + } + + /** + * Load a class using available class mapping. + * + * @internal For `spl_autoload_register` use. + */ + public function loadClassmap(string $class): void + { + $file = $this->classmap[$class] ?? ''; + + if (is_string($file) && $file !== '') { + $this->includeFile($file); + } + } + + /** + * Loads the class file for a given class name. + * + * @internal For `spl_autoload_register` use. + * + * @param string $class The fully qualified class name. + */ + public function loadClass(string $class): void + { + $this->loadInNamespace($class); + } + + /** + * Loads the class file for a given class name. + * + * @param string $class The fully-qualified class name + * + * @return false|string The mapped file name on success, or boolean false on fail + */ + protected function loadInNamespace(string $class) + { + if (strpos($class, '\\') === false) { + return false; + } + + foreach ($this->prefixes as $namespace => $directories) { + foreach ($directories as $directory) { + $directory = rtrim($directory, '\\/'); + + if (strpos($class, $namespace) === 0) { + $filePath = $directory . str_replace('\\', DIRECTORY_SEPARATOR, substr($class, strlen($namespace))) . '.php'; + $filename = $this->includeFile($filePath); + + if ($filename) { + return $filename; + } + } + } + } + + // never found a mapped file + return false; + } + + /** + * A central way to include a file. Split out primarily for testing purposes. + * + * @return false|string The filename on success, false if the file is not loaded + */ + protected function includeFile(string $file) + { + if (is_file($file)) { + include_once $file; + + return $file; + } + + return false; + } + + /** + * Check file path. + * + * Checks special characters that are illegal in filenames on certain + * operating systems and special characters requiring special escaping + * to manipulate at the command line. Replaces spaces and consecutive + * dashes with a single dash. Trim period, dash and underscore from beginning + * and end of filename. + * + * @return string The sanitized filename + * + * @deprecated No longer used. See https://github.com/codeigniter4/CodeIgniter4/issues/7055 + */ + public function sanitizeFilename(string $filename): string + { + // Only allow characters deemed safe for POSIX portable filenames. + // Plus the forward slash for directory separators since this might be a path. + // http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_278 + // Modified to allow backslash and colons for on Windows machines. + $result = preg_match_all('/[^0-9\p{L}\s\/\-_.:\\\\]/u', $filename, $matches); + + if ($result > 0) { + $chars = implode('', $matches[0]); + + throw new InvalidArgumentException( + 'The file path contains special characters "' . $chars + . '" that are not allowed: "' . $filename . '"' + ); + } + if ($result === false) { + if (version_compare(PHP_VERSION, '8.0.0', '>=')) { + $message = preg_last_error_msg(); + } else { + $message = 'Regex error. error code: ' . preg_last_error(); + } + + throw new RuntimeException($message . '. filename: "' . $filename . '"'); + } + + // Clean up our filename edges. + $cleanFilename = trim($filename, '.-_'); + + if ($filename !== $cleanFilename) { + throw new InvalidArgumentException('The characters ".-_" are not allowed in filename edges: "' . $filename . '"'); + } + + return $cleanFilename; + } + + private function loadComposerNamespaces(ClassLoader $composer, array $composerPackages): void + { + $namespacePaths = $composer->getPrefixesPsr4(); + + // Get rid of CodeIgniter so we don't have duplicates + if (isset($namespacePaths['CodeIgniter\\'])) { + unset($namespacePaths['CodeIgniter\\']); + } + + if (! method_exists(InstalledVersions::class, 'getAllRawData')) { + throw new RuntimeException( + 'Your Composer version is too old.' + . ' Please update Composer (run `composer self-update`) to v2.0.14 or later' + . ' and remove your vendor/ directory, and run `composer update`.' + ); + } + // This method requires Composer 2.0.14 or later. + $allData = InstalledVersions::getAllRawData(); + $packageList = []; + + foreach ($allData as $list) { + $packageList = array_merge($packageList, $list['versions']); + } + + // Check config for $composerPackages. + $only = $composerPackages['only'] ?? []; + $exclude = $composerPackages['exclude'] ?? []; + if ($only !== [] && $exclude !== []) { + throw new ConfigException('Cannot use "only" and "exclude" at the same time in "Config\Modules::$composerPackages".'); + } + + // Get install paths of packages to add namespace for auto-discovery. + $installPaths = []; + if ($only !== []) { + foreach ($packageList as $packageName => $data) { + if (in_array($packageName, $only, true) && isset($data['install_path'])) { + $installPaths[] = $data['install_path']; + } + } + } else { + foreach ($packageList as $packageName => $data) { + if (! in_array($packageName, $exclude, true) && isset($data['install_path'])) { + $installPaths[] = $data['install_path']; + } + } + } + + $newPaths = []; + + foreach ($namespacePaths as $namespace => $srcPaths) { + $add = false; + + foreach ($srcPaths as $path) { + foreach ($installPaths as $installPath) { + if ($installPath === substr($path, 0, strlen($installPath))) { + $add = true; + break 2; + } + } + } + + if ($add) { + // Composer stores namespaces with trailing slash. We don't. + $newPaths[rtrim($namespace, '\\ ')] = $srcPaths; + } + } + + $this->addNamespace($newPaths); + } + + private function loadComposerClassmap(ClassLoader $composer): void + { + $classes = $composer->getClassMap(); + + $this->classmap = array_merge($this->classmap, $classes); + } + + /** + * Locates autoload information from Composer, if available. + * + * @deprecated No longer used. + * + * @return void + */ + protected function discoverComposerNamespaces() + { + if (! is_file(COMPOSER_PATH)) { + return; + } + + /** + * @var ClassLoader $composer + */ + $composer = include COMPOSER_PATH; + $paths = $composer->getPrefixesPsr4(); + $classes = $composer->getClassMap(); + + unset($composer); + + // Get rid of CodeIgniter so we don't have duplicates + if (isset($paths['CodeIgniter\\'])) { + unset($paths['CodeIgniter\\']); + } + + $newPaths = []; + + foreach ($paths as $key => $value) { + // Composer stores namespaces with trailing slash. We don't. + $newPaths[rtrim($key, '\\ ')] = $value; + } + + $this->prefixes = array_merge($this->prefixes, $newPaths); + $this->classmap = array_merge($this->classmap, $classes); + } + + /** + * Loads helpers + */ + public function loadHelpers(): void + { + helper($this->helpers); + } +} diff --git a/system/Autoloader/FileLocator.php b/system/Autoloader/FileLocator.php new file mode 100644 index 0000000..c5cf8e1 --- /dev/null +++ b/system/Autoloader/FileLocator.php @@ -0,0 +1,386 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Autoloader; + +/** + * Allows loading non-class files in a namespaced manner. + * Works with Helpers, Views, etc. + * + * @see \CodeIgniter\Autoloader\FileLocatorTest + */ +class FileLocator +{ + /** + * The Autoloader to use. + * + * @var Autoloader + */ + protected $autoloader; + + public function __construct(Autoloader $autoloader) + { + $this->autoloader = $autoloader; + } + + /** + * Attempts to locate a file by examining the name for a namespace + * and looking through the PSR-4 namespaced files that we know about. + * + * @param string $file The relative file path or namespaced file to + * locate. If not namespaced, search in the app + * folder. + * @param non-empty-string|null $folder The folder within the namespace that we should + * look for the file. If $file does not contain + * this value, it will be appended to the namespace + * folder. + * @param string $ext The file extension the file should have. + * + * @return false|string The path to the file, or false if not found. + */ + public function locateFile(string $file, ?string $folder = null, string $ext = 'php') + { + $file = $this->ensureExt($file, $ext); + + // Clears the folder name if it is at the beginning of the filename + if ($folder !== null && strpos($file, $folder) === 0) { + $file = substr($file, strlen($folder . '/')); + } + + // Is not namespaced? Try the application folder. + if (strpos($file, '\\') === false) { + return $this->legacyLocate($file, $folder); + } + + // Standardize slashes to handle nested directories. + $file = strtr($file, '/', '\\'); + $file = ltrim($file, '\\'); + + $segments = explode('\\', $file); + + // The first segment will be empty if a slash started the filename. + if ($segments[0] === '') { + unset($segments[0]); + } + + $paths = []; + $filename = ''; + + // Namespaces always comes with arrays of paths + $namespaces = $this->autoloader->getNamespace(); + + foreach (array_keys($namespaces) as $namespace) { + if (substr($file, 0, strlen($namespace) + 1) === $namespace . '\\') { + $fileWithoutNamespace = substr($file, strlen($namespace)); + + // There may be sub-namespaces of the same vendor, + // so overwrite them with namespaces found later. + $paths = $namespaces[$namespace]; + $filename = ltrim(str_replace('\\', '/', $fileWithoutNamespace), '/'); + } + } + + // if no namespaces matched then quit + if ($paths === []) { + return false; + } + + // Check each path in the namespace + foreach ($paths as $path) { + // Ensure trailing slash + $path = rtrim($path, '/') . '/'; + + // If we have a folder name, then the calling function + // expects this file to be within that folder, like 'Views', + // or 'libraries'. + if ($folder !== null && strpos($path . $filename, '/' . $folder . '/') === false) { + $path .= trim($folder, '/') . '/'; + } + + $path .= $filename; + if (is_file($path)) { + return $path; + } + } + + return false; + } + + /** + * Examines a file and returns the fully qualified class name. + */ + public function getClassname(string $file): string + { + if (is_dir($file)) { + return ''; + } + + $php = file_get_contents($file); + $tokens = token_get_all($php); + $dlm = false; + $namespace = ''; + $className = ''; + + foreach ($tokens as $i => $token) { + if ($i < 2) { + continue; + } + + if ((isset($tokens[$i - 2][1]) && ($tokens[$i - 2][1] === 'phpnamespace' || $tokens[$i - 2][1] === 'namespace')) || ($dlm && $tokens[$i - 1][0] === T_NS_SEPARATOR && $token[0] === T_STRING)) { + if (! $dlm) { + $namespace = 0; + } + if (isset($token[1])) { + $namespace = $namespace ? $namespace . '\\' . $token[1] : $token[1]; + $dlm = true; + } + } elseif ($dlm && ($token[0] !== T_NS_SEPARATOR) && ($token[0] !== T_STRING)) { + $dlm = false; + } + + if (($tokens[$i - 2][0] === T_CLASS || (isset($tokens[$i - 2][1]) && $tokens[$i - 2][1] === 'phpclass')) + && $tokens[$i - 1][0] === T_WHITESPACE + && $token[0] === T_STRING) { + $className = $token[1]; + break; + } + } + + if ($className === '') { + return ''; + } + + return $namespace . '\\' . $className; + } + + /** + * Searches through all of the defined namespaces looking for a file. + * Returns an array of all found locations for the defined file. + * + * Example: + * + * $locator->search('Config/Routes.php'); + * // Assuming PSR4 namespaces include foo and bar, might return: + * [ + * 'app/Modules/foo/Config/Routes.php', + * 'app/Modules/bar/Config/Routes.php', + * ] + */ + public function search(string $path, string $ext = 'php', bool $prioritizeApp = true): array + { + $path = $this->ensureExt($path, $ext); + + $foundPaths = []; + $appPaths = []; + + foreach ($this->getNamespaces() as $namespace) { + if (isset($namespace['path']) && is_file($namespace['path'] . $path)) { + $fullPath = $namespace['path'] . $path; + $fullPath = realpath($fullPath) ?: $fullPath; + + if ($prioritizeApp) { + $foundPaths[] = $fullPath; + } elseif (strpos($fullPath, APPPATH) === 0) { + $appPaths[] = $fullPath; + } else { + $foundPaths[] = $fullPath; + } + } + } + + if (! $prioritizeApp && $appPaths !== []) { + $foundPaths = [...$foundPaths, ...$appPaths]; + } + + // Remove any duplicates + return array_unique($foundPaths); + } + + /** + * Ensures a extension is at the end of a filename + */ + protected function ensureExt(string $path, string $ext): string + { + if ($ext !== '') { + $ext = '.' . $ext; + + if (substr($path, -strlen($ext)) !== $ext) { + $path .= $ext; + } + } + + return $path; + } + + /** + * Return the namespace mappings we know about. + * + * @return array> + */ + protected function getNamespaces() + { + $namespaces = []; + + // Save system for last + $system = []; + + foreach ($this->autoloader->getNamespace() as $prefix => $paths) { + foreach ($paths as $path) { + if ($prefix === 'CodeIgniter') { + $system = [ + 'prefix' => $prefix, + 'path' => rtrim($path, '\\/') . DIRECTORY_SEPARATOR, + ]; + + continue; + } + + $namespaces[] = [ + 'prefix' => $prefix, + 'path' => rtrim($path, '\\/') . DIRECTORY_SEPARATOR, + ]; + } + } + + $namespaces[] = $system; + + return $namespaces; + } + + /** + * Find the qualified name of a file according to + * the namespace of the first matched namespace path. + * + * @return false|string The qualified name or false if the path is not found + */ + public function findQualifiedNameFromPath(string $path) + { + $path = realpath($path) ?: $path; + + if (! is_file($path)) { + return false; + } + + foreach ($this->getNamespaces() as $namespace) { + $namespace['path'] = realpath($namespace['path']) ?: $namespace['path']; + + if ($namespace['path'] === '') { + continue; + } + + if (mb_strpos($path, $namespace['path']) === 0) { + $className = '\\' . $namespace['prefix'] . '\\' . + ltrim(str_replace( + '/', + '\\', + mb_substr($path, mb_strlen($namespace['path'])) + ), '\\'); + + // Remove the file extension (.php) + $className = mb_substr($className, 0, -4); + + // Check if this exists + if (class_exists($className)) { + return $className; + } + } + } + + return false; + } + + /** + * Scans the defined namespaces, returning a list of all files + * that are contained within the subpath specified by $path. + * + * @return string[] List of file paths + */ + public function listFiles(string $path): array + { + if ($path === '') { + return []; + } + + $files = []; + helper('filesystem'); + + foreach ($this->getNamespaces() as $namespace) { + $fullPath = $namespace['path'] . $path; + $fullPath = realpath($fullPath) ?: $fullPath; + + if (! is_dir($fullPath)) { + continue; + } + + $tempFiles = get_filenames($fullPath, true, false, false); + + if ($tempFiles !== []) { + $files = array_merge($files, $tempFiles); + } + } + + return $files; + } + + /** + * Scans the provided namespace, returning a list of all files + * that are contained within the sub path specified by $path. + * + * @return string[] List of file paths + */ + public function listNamespaceFiles(string $prefix, string $path): array + { + if ($path === '' || ($prefix === '')) { + return []; + } + + $files = []; + helper('filesystem'); + + // autoloader->getNamespace($prefix) returns an array of paths for that namespace + foreach ($this->autoloader->getNamespace($prefix) as $namespacePath) { + $fullPath = rtrim($namespacePath, '/') . '/' . $path; + $fullPath = realpath($fullPath) ?: $fullPath; + + if (! is_dir($fullPath)) { + continue; + } + + $tempFiles = get_filenames($fullPath, true, false, false); + + if ($tempFiles !== []) { + $files = array_merge($files, $tempFiles); + } + } + + return $files; + } + + /** + * Checks the app folder to see if the file can be found. + * Only for use with filenames that DO NOT include namespacing. + * + * @param non-empty-string|null $folder + * + * @return false|string The path to the file, or false if not found. + */ + protected function legacyLocate(string $file, ?string $folder = null) + { + $path = APPPATH . ($folder === null ? $file : $folder . '/' . $file); + $path = realpath($path) ?: $path; + + if (is_file($path)) { + return $path; + } + + return false; + } +} diff --git a/system/BaseModel.php b/system/BaseModel.php new file mode 100644 index 0000000..6a6f0df --- /dev/null +++ b/system/BaseModel.php @@ -0,0 +1,1916 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter; + +use Closure; +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\BaseResult; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Database\Query; +use CodeIgniter\Exceptions\ModelException; +use CodeIgniter\I18n\Time; +use CodeIgniter\Pager\Pager; +use CodeIgniter\Validation\ValidationInterface; +use Config\Services; +use InvalidArgumentException; +use ReflectionClass; +use ReflectionException; +use ReflectionProperty; +use stdClass; + +/** + * The BaseModel class provides a number of convenient features that + * makes working with a databases less painful. Extending this class + * provide means of implementing various database systems + * + * It will: + * - simplifies pagination + * - allow specifying the return type (array, object, etc) with each call + * - automatically set and update timestamps + * - handle soft deletes + * - ensure validation is run against objects when saving items + * - process various callbacks + * - allow intermingling calls to the db connection + * + * @phpstan-type row_array array + * @phpstan-type event_data_beforeinsert array{data: row_array} + * @phpstan-type event_data_afterinsert array{id: int|string, data: row_array, result: bool} + * @phpstan-type event_data_beforefind array{id?: int|string, method: string, singleton: bool, limit?: int, offset?: int} + * @phpstan-type event_data_afterfind array{id: int|string|null|list, data: row_array|list|object|null, method: string, singleton: bool} + * @phpstan-type event_data_beforeupdate array{id: null|list, data: row_array} + * @phpstan-type event_data_afterupdate array{id: null|list, data: row_array|object, result: bool} + * @phpstan-type event_data_beforedelete array{id: null|list, purge: bool} + * @phpstan-type event_data_afterdelete array{id: null|list, data: null, purge: bool, result: bool} + */ +abstract class BaseModel +{ + /** + * Pager instance. + * Populated after calling $this->paginate() + * + * @var Pager + */ + public $pager; + + /** + * Last insert ID + * + * @var int|string + */ + protected $insertID = 0; + + /** + * The Database connection group that + * should be instantiated. + * + * @var non-empty-string|null + */ + protected $DBGroup; + + /** + * The format that the results should be returned as. + * Will be overridden if the as* methods are used. + * + * @var string + */ + protected $returnType = 'array'; + + /** + * If this model should use "softDeletes" and + * simply set a date when rows are deleted, or + * do hard deletes. + * + * @var bool + */ + protected $useSoftDeletes = false; + + /** + * An array of field names that are allowed + * to be set by the user in inserts/updates. + * + * @var array + */ + protected $allowedFields = []; + + /** + * If true, will set created_at, and updated_at + * values during insert and update routines. + * + * @var bool + */ + protected $useTimestamps = false; + + /** + * The type of column that created_at and updated_at + * are expected to. + * + * Allowed: 'datetime', 'date', 'int' + * + * @var string + */ + protected $dateFormat = 'datetime'; + + /** + * The column used for insert timestamps + * + * @var string + */ + protected $createdField = 'created_at'; + + /** + * The column used for update timestamps + * + * @var string + */ + protected $updatedField = 'updated_at'; + + /** + * Used by withDeleted to override the + * model's softDelete setting. + * + * @var bool + */ + protected $tempUseSoftDeletes; + + /** + * The column used to save soft delete state + * + * @var string + */ + protected $deletedField = 'deleted_at'; + + /** + * Used by asArray and asObject to provide + * temporary overrides of model default. + * + * @var string + */ + protected $tempReturnType; + + /** + * Whether we should limit fields in inserts + * and updates to those available in $allowedFields or not. + * + * @var bool + */ + protected $protectFields = true; + + /** + * Database Connection + * + * @var BaseConnection + */ + protected $db; + + /** + * Rules used to validate data in insert, update, and save methods. + * The array must match the format of data passed to the Validation + * library. + * + * @var array|string + */ + protected $validationRules = []; + + /** + * Contains any custom error messages to be + * used during data validation. + * + * @var array + */ + protected $validationMessages = []; + + /** + * Skip the model's validation. Used in conjunction with skipValidation() + * to skip data validation for any future calls. + * + * @var bool + */ + protected $skipValidation = false; + + /** + * Whether rules should be removed that do not exist + * in the passed data. Used in updates. + * + * @var bool + */ + protected $cleanValidationRules = true; + + /** + * Our validator instance. + * + * @var ValidationInterface + */ + protected $validation; + + /* + * Callbacks. + * + * Each array should contain the method names (within the model) + * that should be called when those events are triggered. + * + * "Update" and "delete" methods are passed the same items that + * are given to their respective method. + * + * "Find" methods receive the ID searched for (if present), and + * 'afterFind' additionally receives the results that were found. + */ + + /** + * Whether to trigger the defined callbacks + * + * @var bool + */ + protected $allowCallbacks = true; + + /** + * Used by allowCallbacks() to override the + * model's allowCallbacks setting. + * + * @var bool + */ + protected $tempAllowCallbacks; + + /** + * Callbacks for beforeInsert + * + * @var array + */ + protected $beforeInsert = []; + + /** + * Callbacks for afterInsert + * + * @var array + */ + protected $afterInsert = []; + + /** + * Callbacks for beforeUpdate + * + * @var array + */ + protected $beforeUpdate = []; + + /** + * Callbacks for afterUpdate + * + * @var array + */ + protected $afterUpdate = []; + + /** + * Callbacks for beforeInsertBatch + * + * @var array + */ + protected $beforeInsertBatch = []; + + /** + * Callbacks for afterInsertBatch + * + * @var array + */ + protected $afterInsertBatch = []; + + /** + * Callbacks for beforeUpdateBatch + * + * @var array + */ + protected $beforeUpdateBatch = []; + + /** + * Callbacks for afterUpdateBatch + * + * @var array + */ + protected $afterUpdateBatch = []; + + /** + * Callbacks for beforeFind + * + * @var array + */ + protected $beforeFind = []; + + /** + * Callbacks for afterFind + * + * @var array + */ + protected $afterFind = []; + + /** + * Callbacks for beforeDelete + * + * @var array + */ + protected $beforeDelete = []; + + /** + * Callbacks for afterDelete + * + * @var array + */ + protected $afterDelete = []; + + /** + * Whether to allow inserting empty data. + */ + protected bool $allowEmptyInserts = false; + + public function __construct(?ValidationInterface $validation = null) + { + $this->tempReturnType = $this->returnType; + $this->tempUseSoftDeletes = $this->useSoftDeletes; + $this->tempAllowCallbacks = $this->allowCallbacks; + + /** + * @var ValidationInterface|null $validation + */ + $validation ??= Services::validation(null, false); + $this->validation = $validation; + + $this->initialize(); + } + + /** + * Initializes the instance with any additional steps. + * Optionally implemented by child classes. + * + * @return void + */ + protected function initialize() + { + } + + /** + * Fetches the row of database. + * This method works only with dbCalls. + * + * @param bool $singleton Single or multiple results + * @param array|int|string|null $id One primary key or an array of primary keys + * + * @return array|object|null The resulting row of data, or null. + */ + abstract protected function doFind(bool $singleton, $id = null); + + /** + * Fetches the column of database. + * This method works only with dbCalls. + * + * @param string $columnName Column Name + * + * @return array|null The resulting row of data, or null if no data found. + * + * @throws DataException + */ + abstract protected function doFindColumn(string $columnName); + + /** + * Fetches all results, while optionally limiting them. + * This method works only with dbCalls. + * + * @param int $limit Limit + * @param int $offset Offset + * + * @return array + */ + abstract protected function doFindAll(int $limit = 0, int $offset = 0); + + /** + * Returns the first row of the result set. + * This method works only with dbCalls. + * + * @return array|object|null + */ + abstract protected function doFirst(); + + /** + * Inserts data into the current database. + * This method works only with dbCalls. + * + * @param array $row Row data + * @phpstan-param row_array $row + * + * @return bool + */ + abstract protected function doInsert(array $row); + + /** + * Compiles batch insert and runs the queries, validating each row prior. + * This method works only with dbCalls. + * + * @param array|null $set An associative array of insert values + * @param bool|null $escape Whether to escape values + * @param int $batchSize The size of the batch to run + * @param bool $testing True means only number of records is returned, false will execute the query + * + * @return bool|int Number of rows inserted or FALSE on failure + */ + abstract protected function doInsertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false); + + /** + * Updates a single record in the database. + * This method works only with dbCalls. + * + * @param array|int|string|null $id ID + * @param array|null $row Row data + * @phpstan-param row_array|null $row + */ + abstract protected function doUpdate($id = null, $row = null): bool; + + /** + * Compiles an update and runs the query. + * This method works only with dbCalls. + * + * @param array|null $set An associative array of update values + * @param string|null $index The where key + * @param int $batchSize The size of the batch to run + * @param bool $returnSQL True means SQL is returned, false will execute the query + * + * @return false|int|string[] Number of rows affected or FALSE on failure, SQL array when testMode + * + * @throws DatabaseException + */ + abstract protected function doUpdateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false); + + /** + * Deletes a single record from the database where $id matches. + * This method works only with dbCalls. + * + * @param array|int|string|null $id The rows primary key(s) + * @param bool $purge Allows overriding the soft deletes setting. + * + * @return bool|string + * + * @throws DatabaseException + */ + abstract protected function doDelete($id = null, bool $purge = false); + + /** + * Permanently deletes all rows that have been marked as deleted. + * through soft deletes (deleted = 1). + * This method works only with dbCalls. + * + * @return bool|string Returns a string if in test mode. + */ + abstract protected function doPurgeDeleted(); + + /** + * Works with the find* methods to return only the rows that + * have been deleted. + * This method works only with dbCalls. + * + * @return void + */ + abstract protected function doOnlyDeleted(); + + /** + * Compiles a replace and runs the query. + * This method works only with dbCalls. + * + * @param array|null $row Row data + * @phpstan-param row_array|null $row + * @param bool $returnSQL Set to true to return Query String + * + * @return BaseResult|false|Query|string + */ + abstract protected function doReplace(?array $row = null, bool $returnSQL = false); + + /** + * Grabs the last error(s) that occurred from the Database connection. + * This method works only with dbCalls. + * + * @return array|null + */ + abstract protected function doErrors(); + + /** + * Returns the id value for the data array or object. + * + * @param array|object $data Data + * + * @return array|int|string|null + * + * @deprecated Add an override on getIdValue() instead. Will be removed in version 5.0. + */ + abstract protected function idValue($data); + + /** + * Public getter to return the id value using the idValue() method. + * For example with SQL this will return $data->$this->primaryKey. + * + * @param array|object $row Row data + * @phpstan-param row_array|object $row + * + * @return array|int|string|null + * + * @todo: Make abstract in version 5.0 + */ + public function getIdValue($row) + { + return $this->idValue($row); + } + + /** + * Override countAllResults to account for soft deleted accounts. + * This method works only with dbCalls. + * + * @param bool $reset Reset + * @param bool $test Test + * + * @return int|string + */ + abstract public function countAllResults(bool $reset = true, bool $test = false); + + /** + * Loops over records in batches, allowing you to operate on them. + * This method works only with dbCalls. + * + * @param int $size Size + * @param Closure $userFunc Callback Function + * + * @return void + * + * @throws DataException + */ + abstract public function chunk(int $size, Closure $userFunc); + + /** + * Fetches the row of database. + * + * @param array|int|string|null $id One primary key or an array of primary keys + * + * @return array|object|null The resulting row of data, or null. + * @phpstan-return ($id is int|string ? row_array|object|null : list) + */ + public function find($id = null) + { + $singleton = is_numeric($id) || is_string($id); + + if ($this->tempAllowCallbacks) { + // Call the before event and check for a return + $eventData = $this->trigger('beforeFind', [ + 'id' => $id, + 'method' => 'find', + 'singleton' => $singleton, + ]); + + if (isset($eventData['returnData']) && $eventData['returnData'] === true) { + return $eventData['data']; + } + } + + $eventData = [ + 'id' => $id, + 'data' => $this->doFind($singleton, $id), + 'method' => 'find', + 'singleton' => $singleton, + ]; + + if ($this->tempAllowCallbacks) { + $eventData = $this->trigger('afterFind', $eventData); + } + + $this->tempReturnType = $this->returnType; + $this->tempUseSoftDeletes = $this->useSoftDeletes; + $this->tempAllowCallbacks = $this->allowCallbacks; + + return $eventData['data']; + } + + /** + * Fetches the column of database. + * + * @param string $columnName Column Name + * + * @return array|null The resulting row of data, or null if no data found. + * + * @throws DataException + */ + public function findColumn(string $columnName) + { + if (strpos($columnName, ',') !== false) { + throw DataException::forFindColumnHaveMultipleColumns(); + } + + $resultSet = $this->doFindColumn($columnName); + + return $resultSet ? array_column($resultSet, $columnName) : null; + } + + /** + * Fetches all results, while optionally limiting them. + * + * @param int $limit Limit + * @param int $offset Offset + * + * @return array + */ + public function findAll(int $limit = 0, int $offset = 0) + { + if ($this->tempAllowCallbacks) { + // Call the before event and check for a return + $eventData = $this->trigger('beforeFind', [ + 'method' => 'findAll', + 'limit' => $limit, + 'offset' => $offset, + 'singleton' => false, + ]); + + if (isset($eventData['returnData']) && $eventData['returnData'] === true) { + return $eventData['data']; + } + } + + $eventData = [ + 'data' => $this->doFindAll($limit, $offset), + 'limit' => $limit, + 'offset' => $offset, + 'method' => 'findAll', + 'singleton' => false, + ]; + + if ($this->tempAllowCallbacks) { + $eventData = $this->trigger('afterFind', $eventData); + } + + $this->tempReturnType = $this->returnType; + $this->tempUseSoftDeletes = $this->useSoftDeletes; + $this->tempAllowCallbacks = $this->allowCallbacks; + + return $eventData['data']; + } + + /** + * Returns the first row of the result set. + * + * @return array|object|null + */ + public function first() + { + if ($this->tempAllowCallbacks) { + // Call the before event and check for a return + $eventData = $this->trigger('beforeFind', [ + 'method' => 'first', + 'singleton' => true, + ]); + + if (isset($eventData['returnData']) && $eventData['returnData'] === true) { + return $eventData['data']; + } + } + + $eventData = [ + 'data' => $this->doFirst(), + 'method' => 'first', + 'singleton' => true, + ]; + + if ($this->tempAllowCallbacks) { + $eventData = $this->trigger('afterFind', $eventData); + } + + $this->tempReturnType = $this->returnType; + $this->tempUseSoftDeletes = $this->useSoftDeletes; + $this->tempAllowCallbacks = $this->allowCallbacks; + + return $eventData['data']; + } + + /** + * A convenience method that will attempt to determine whether the + * data should be inserted or updated. Will work with either + * an array or object. When using with custom class objects, + * you must ensure that the class will provide access to the class + * variables, even if through a magic method. + * + * @param array|object $row Row data + * @phpstan-param row_array|object $row + * + * @throws ReflectionException + */ + public function save($row): bool + { + if ((array) $row === []) { + return true; + } + + if ($this->shouldUpdate($row)) { + $response = $this->update($this->getIdValue($row), $row); + } else { + $response = $this->insert($row, false); + + if ($response !== false) { + $response = true; + } + } + + return $response; + } + + /** + * This method is called on save to determine if entry have to be updated. + * If this method returns false insert operation will be executed + * + * @param array|object $row Row data + * @phpstan-param row_array|object $row + */ + protected function shouldUpdate($row): bool + { + $id = $this->getIdValue($row); + + return ! ($id === null || $id === []); + } + + /** + * Returns last insert ID or 0. + * + * @return int|string + */ + public function getInsertID() + { + return is_numeric($this->insertID) ? (int) $this->insertID : $this->insertID; + } + + /** + * Inserts data into the database. If an object is provided, + * it will attempt to convert it to an array. + * + * @param array|object|null $row Row data + * @phpstan-param row_array|object|null $row + * @param bool $returnID Whether insert ID should be returned or not. + * + * @return bool|int|string insert ID or true on success. false on failure. + * @phpstan-return ($returnID is true ? int|string|false : bool) + * + * @throws ReflectionException + */ + public function insert($row = null, bool $returnID = true) + { + $this->insertID = 0; + + // Set $cleanValidationRules to false temporary. + $cleanValidationRules = $this->cleanValidationRules; + $this->cleanValidationRules = false; + + $row = $this->transformDataToArray($row, 'insert'); + + // Validate data before saving. + if (! $this->skipValidation && ! $this->validate($row)) { + // Restore $cleanValidationRules + $this->cleanValidationRules = $cleanValidationRules; + + return false; + } + + // Restore $cleanValidationRules + $this->cleanValidationRules = $cleanValidationRules; + + // Must be called first, so we don't + // strip out created_at values. + $row = $this->doProtectFieldsForInsert($row); + + // doProtectFields() can further remove elements from + // $row, so we need to check for empty dataset again + if (! $this->allowEmptyInserts && $row === []) { + throw DataException::forEmptyDataset('insert'); + } + + // Set created_at and updated_at with same time + $date = $this->setDate(); + $row = $this->setCreatedField($row, $date); + $row = $this->setUpdatedField($row, $date); + + $eventData = ['data' => $row]; + + if ($this->tempAllowCallbacks) { + $eventData = $this->trigger('beforeInsert', $eventData); + } + + $result = $this->doInsert($eventData['data']); + + $eventData = [ + 'id' => $this->insertID, + 'data' => $eventData['data'], + 'result' => $result, + ]; + + if ($this->tempAllowCallbacks) { + // Trigger afterInsert events with the inserted data and new ID + $this->trigger('afterInsert', $eventData); + } + + $this->tempAllowCallbacks = $this->allowCallbacks; + + // If insertion failed, get out of here + if (! $result) { + return $result; + } + + // otherwise return the insertID, if requested. + return $returnID ? $this->insertID : $result; + } + + /** + * Set datetime to created field. + * + * @phpstan-param row_array $row + * @param int|string $date timestamp or datetime string + */ + protected function setCreatedField(array $row, $date): array + { + if ($this->useTimestamps && $this->createdField !== '' && ! array_key_exists($this->createdField, $row)) { + $row[$this->createdField] = $date; + } + + return $row; + } + + /** + * Set datetime to updated field. + * + * @phpstan-param row_array $row + * @param int|string $date timestamp or datetime string + */ + protected function setUpdatedField(array $row, $date): array + { + if ($this->useTimestamps && $this->updatedField !== '' && ! array_key_exists($this->updatedField, $row)) { + $row[$this->updatedField] = $date; + } + + return $row; + } + + /** + * Compiles batch insert runs the queries, validating each row prior. + * + * @param list|null $set an associative array of insert values + * @phpstan-param list|null $set + * @param bool|null $escape Whether to escape values + * @param int $batchSize The size of the batch to run + * @param bool $testing True means only number of records is returned, false will execute the query + * + * @return bool|int Number of rows inserted or FALSE on failure + * + * @throws ReflectionException + */ + public function insertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false) + { + // Set $cleanValidationRules to false temporary. + $cleanValidationRules = $this->cleanValidationRules; + $this->cleanValidationRules = false; + + if (is_array($set)) { + foreach ($set as &$row) { + // If $row is using a custom class with public or protected + // properties representing the collection elements, we need to grab + // them as an array. + if (is_object($row) && ! $row instanceof stdClass) { + $row = $this->objectToArray($row, false, true); + } + + // If it's still a stdClass, go ahead and convert to + // an array so doProtectFields and other model methods + // don't have to do special checks. + if (is_object($row)) { + $row = (array) $row; + } + + // Validate every row. + if (! $this->skipValidation && ! $this->validate($row)) { + // Restore $cleanValidationRules + $this->cleanValidationRules = $cleanValidationRules; + + return false; + } + + // Must be called first so we don't + // strip out created_at values. + $row = $this->doProtectFieldsForInsert($row); + + // Set created_at and updated_at with same time + $date = $this->setDate(); + $row = $this->setCreatedField($row, $date); + $row = $this->setUpdatedField($row, $date); + } + } + + // Restore $cleanValidationRules + $this->cleanValidationRules = $cleanValidationRules; + + $eventData = ['data' => $set]; + + if ($this->tempAllowCallbacks) { + $eventData = $this->trigger('beforeInsertBatch', $eventData); + } + + $result = $this->doInsertBatch($eventData['data'], $escape, $batchSize, $testing); + + $eventData = [ + 'data' => $eventData['data'], + 'result' => $result, + ]; + + if ($this->tempAllowCallbacks) { + // Trigger afterInsert events with the inserted data and new ID + $this->trigger('afterInsertBatch', $eventData); + } + + $this->tempAllowCallbacks = $this->allowCallbacks; + + return $result; + } + + /** + * Updates a single record in the database. If an object is provided, + * it will attempt to convert it into an array. + * + * @param array|int|string|null $id + * @param array|object|null $row Row data + * @phpstan-param row_array|object|null $row + * + * @throws ReflectionException + */ + public function update($id = null, $row = null): bool + { + if (is_bool($id)) { + throw new InvalidArgumentException('update(): argument #1 ($id) should not be boolean.'); + } + + if (is_numeric($id) || is_string($id)) { + $id = [$id]; + } + + $row = $this->transformDataToArray($row, 'update'); + + // Validate data before saving. + if (! $this->skipValidation && ! $this->validate($row)) { + return false; + } + + // Must be called first, so we don't + // strip out updated_at values. + $row = $this->doProtectFields($row); + + // doProtectFields() can further remove elements from + // $row, so we need to check for empty dataset again + if ($row === []) { + throw DataException::forEmptyDataset('update'); + } + + $row = $this->setUpdatedField($row, $this->setDate()); + + $eventData = [ + 'id' => $id, + 'data' => $row, + ]; + + if ($this->tempAllowCallbacks) { + $eventData = $this->trigger('beforeUpdate', $eventData); + } + + $eventData = [ + 'id' => $id, + 'data' => $eventData['data'], + 'result' => $this->doUpdate($id, $eventData['data']), + ]; + + if ($this->tempAllowCallbacks) { + $this->trigger('afterUpdate', $eventData); + } + + $this->tempAllowCallbacks = $this->allowCallbacks; + + return $eventData['result']; + } + + /** + * Compiles an update and runs the query. + * + * @param list|null $set an associative array of insert values + * @phpstan-param list|null $set + * @param string|null $index The where key + * @param int $batchSize The size of the batch to run + * @param bool $returnSQL True means SQL is returned, false will execute the query + * + * @return false|int|string[] Number of rows affected or FALSE on failure, SQL array when testMode + * + * @throws DatabaseException + * @throws ReflectionException + */ + public function updateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false) + { + if (is_array($set)) { + foreach ($set as &$row) { + // If $row is using a custom class with public or protected + // properties representing the collection elements, we need to grab + // them as an array. + if (is_object($row) && ! $row instanceof stdClass) { + // For updates the index field is needed even if it is not changed. + // So set $onlyChanged to false. + $row = $this->objectToArray($row, false, true); + } + + // If it's still a stdClass, go ahead and convert to + // an array so doProtectFields and other model methods + // don't have to do special checks. + if (is_object($row)) { + $row = (array) $row; + } + + // Validate data before saving. + if (! $this->skipValidation && ! $this->validate($row)) { + return false; + } + + // Save updateIndex for later + $updateIndex = $row[$index] ?? null; + + if ($updateIndex === null) { + throw new InvalidArgumentException( + 'The index ("' . $index . '") for updateBatch() is missing in the data: ' + . json_encode($row) + ); + } + + // Must be called first so we don't + // strip out updated_at values. + $row = $this->doProtectFields($row); + + // Restore updateIndex value in case it was wiped out + if ($updateIndex !== null) { + $row[$index] = $updateIndex; + } + + $row = $this->setUpdatedField($row, $this->setDate()); + } + } + + $eventData = ['data' => $set]; + + if ($this->tempAllowCallbacks) { + $eventData = $this->trigger('beforeUpdateBatch', $eventData); + } + + $result = $this->doUpdateBatch($eventData['data'], $index, $batchSize, $returnSQL); + + $eventData = [ + 'data' => $eventData['data'], + 'result' => $result, + ]; + + if ($this->tempAllowCallbacks) { + // Trigger afterInsert events with the inserted data and new ID + $this->trigger('afterUpdateBatch', $eventData); + } + + $this->tempAllowCallbacks = $this->allowCallbacks; + + return $result; + } + + /** + * Deletes a single record from the database where $id matches. + * + * @param array|int|string|null $id The rows primary key(s) + * @param bool $purge Allows overriding the soft deletes setting. + * + * @return BaseResult|bool + * + * @throws DatabaseException + */ + public function delete($id = null, bool $purge = false) + { + if (is_bool($id)) { + throw new InvalidArgumentException('delete(): argument #1 ($id) should not be boolean.'); + } + + if ($id && (is_numeric($id) || is_string($id))) { + $id = [$id]; + } + + $eventData = [ + 'id' => $id, + 'purge' => $purge, + ]; + + if ($this->tempAllowCallbacks) { + $this->trigger('beforeDelete', $eventData); + } + + $eventData = [ + 'id' => $id, + 'data' => null, + 'purge' => $purge, + 'result' => $this->doDelete($id, $purge), + ]; + + if ($this->tempAllowCallbacks) { + $this->trigger('afterDelete', $eventData); + } + + $this->tempAllowCallbacks = $this->allowCallbacks; + + return $eventData['result']; + } + + /** + * Permanently deletes all rows that have been marked as deleted + * through soft deletes (deleted = 1). + * + * @return bool|string Returns a string if in test mode. + */ + public function purgeDeleted() + { + if (! $this->useSoftDeletes) { + return true; + } + + return $this->doPurgeDeleted(); + } + + /** + * Sets $useSoftDeletes value so that we can temporarily override + * the soft deletes settings. Can be used for all find* methods. + * + * @param bool $val Value + * + * @return $this + */ + public function withDeleted(bool $val = true) + { + $this->tempUseSoftDeletes = ! $val; + + return $this; + } + + /** + * Works with the find* methods to return only the rows that + * have been deleted. + * + * @return $this + */ + public function onlyDeleted() + { + $this->tempUseSoftDeletes = false; + $this->doOnlyDeleted(); + + return $this; + } + + /** + * Compiles a replace and runs the query. + * + * @param array|null $row Row data + * @phpstan-param row_array|null $row + * @param bool $returnSQL Set to true to return Query String + * + * @return BaseResult|false|Query|string + */ + public function replace(?array $row = null, bool $returnSQL = false) + { + // Validate data before saving. + if (($row !== null) && ! $this->skipValidation && ! $this->validate($row)) { + return false; + } + + $row = $this->setUpdatedField((array) $row, $this->setDate()); + + return $this->doReplace($row, $returnSQL); + } + + /** + * Grabs the last error(s) that occurred. If data was validated, + * it will first check for errors there, otherwise will try to + * grab the last error from the Database connection. + * + * The return array should be in the following format: + * ['source' => 'message'] + * + * @param bool $forceDB Always grab the db error, not validation + * + * @return array + */ + public function errors(bool $forceDB = false) + { + // Do we have validation errors? + if (! $forceDB && ! $this->skipValidation && ($errors = $this->validation->getErrors())) { + return $errors; + } + + return $this->doErrors(); + } + + /** + * Works with Pager to get the size and offset parameters. + * Expects a GET variable (?page=2) that specifies the page of results + * to display. + * + * @param int|null $perPage Items per page + * @param string $group Will be used by the pagination library to identify a unique pagination set. + * @param int|null $page Optional page number (useful when the page number is provided in different way) + * @param int $segment Optional URI segment number (if page number is provided by URI segment) + * + * @return array|null + */ + public function paginate(?int $perPage = null, string $group = 'default', ?int $page = null, int $segment = 0) + { + // Since multiple models may use the Pager, the Pager must be shared. + $pager = Services::pager(); + + if ($segment !== 0) { + $pager->setSegment($segment, $group); + } + + $page = $page >= 1 ? $page : $pager->getCurrentPage($group); + // Store it in the Pager library, so it can be paginated in the views. + $this->pager = $pager->store($group, $page, $perPage, $this->countAllResults(false), $segment); + $perPage = $this->pager->getPerPage($group); + $offset = ($pager->getCurrentPage($group) - 1) * $perPage; + + return $this->findAll($perPage, $offset); + } + + /** + * It could be used when you have to change default or override current allowed fields. + * + * @param array $allowedFields Array with names of fields + * + * @return $this + */ + public function setAllowedFields(array $allowedFields) + { + $this->allowedFields = $allowedFields; + + return $this; + } + + /** + * Sets whether or not we should whitelist data set during + * updates or inserts against $this->availableFields. + * + * @param bool $protect Value + * + * @return $this + */ + public function protect(bool $protect = true) + { + $this->protectFields = $protect; + + return $this; + } + + /** + * Ensures that only the fields that are allowed to be updated are + * in the data array. + * + * @used-by update() to protect against mass assignment vulnerabilities. + * @used-by updateBatch() to protect against mass assignment vulnerabilities. + * + * @param array $row Row data + * @phpstan-param row_array $row + * + * @throws DataException + */ + protected function doProtectFields(array $row): array + { + if (! $this->protectFields) { + return $row; + } + + if ($this->allowedFields === []) { + throw DataException::forInvalidAllowedFields(static::class); + } + + foreach (array_keys($row) as $key) { + if (! in_array($key, $this->allowedFields, true)) { + unset($row[$key]); + } + } + + return $row; + } + + /** + * Ensures that only the fields that are allowed to be inserted are in + * the data array. + * + * @used-by insert() to protect against mass assignment vulnerabilities. + * @used-by insertBatch() to protect against mass assignment vulnerabilities. + * + * @param array $row Row data + * @phpstan-param row_array $row + * + * @throws DataException + */ + protected function doProtectFieldsForInsert(array $row): array + { + return $this->doProtectFields($row); + } + + /** + * Sets the date or current date if null value is passed. + * + * @param int|null $userData An optional PHP timestamp to be converted. + * + * @return int|string + * + * @throws ModelException + */ + protected function setDate(?int $userData = null) + { + $currentDate = $userData ?? Time::now()->getTimestamp(); + + return $this->intToDate($currentDate); + } + + /** + * A utility function to allow child models to use the type of + * date/time format that they prefer. This is primarily used for + * setting created_at, updated_at and deleted_at values, but can be + * used by inheriting classes. + * + * The available time formats are: + * - 'int' - Stores the date as an integer timestamp + * - 'datetime' - Stores the data in the SQL datetime format + * - 'date' - Stores the date (only) in the SQL date format. + * + * @param int $value value + * + * @return int|string + * + * @throws ModelException + */ + protected function intToDate(int $value) + { + switch ($this->dateFormat) { + case 'int': + return $value; + + case 'datetime': + return date('Y-m-d H:i:s', $value); + + case 'date': + return date('Y-m-d', $value); + + default: + throw ModelException::forNoDateFormat(static::class); + } + } + + /** + * Converts Time value to string using $this->dateFormat. + * + * The available time formats are: + * - 'int' - Stores the date as an integer timestamp + * - 'datetime' - Stores the data in the SQL datetime format + * - 'date' - Stores the date (only) in the SQL date format. + * + * @param Time $value value + * + * @return int|string + */ + protected function timeToDate(Time $value) + { + switch ($this->dateFormat) { + case 'datetime': + return $value->format('Y-m-d H:i:s'); + + case 'date': + return $value->format('Y-m-d'); + + case 'int': + return $value->getTimestamp(); + + default: + return (string) $value; + } + } + + /** + * Set the value of the skipValidation flag. + * + * @param bool $skip Value + * + * @return $this + */ + public function skipValidation(bool $skip = true) + { + $this->skipValidation = $skip; + + return $this; + } + + /** + * Allows to set (and reset) validation messages. + * It could be used when you have to change default or override current validate messages. + * + * @param array $validationMessages Value + * + * @return $this + */ + public function setValidationMessages(array $validationMessages) + { + $this->validationMessages = $validationMessages; + + return $this; + } + + /** + * Allows to set field wise validation message. + * It could be used when you have to change default or override current validate messages. + * + * @param string $field Field Name + * @param array $fieldMessages Validation messages + * + * @return $this + */ + public function setValidationMessage(string $field, array $fieldMessages) + { + $this->validationMessages[$field] = $fieldMessages; + + return $this; + } + + /** + * Allows to set (and reset) validation rules. + * It could be used when you have to change default or override current validate rules. + * + * @param array $validationRules Value + * + * @return $this + */ + public function setValidationRules(array $validationRules) + { + $this->validationRules = $validationRules; + + return $this; + } + + /** + * Allows to set field wise validation rules. + * It could be used when you have to change default or override current validate rules. + * + * @param string $field Field Name + * @param array|string $fieldRules Validation rules + * + * @return $this + */ + public function setValidationRule(string $field, $fieldRules) + { + $rules = $this->validationRules; + + // ValidationRules can be either a string, which is the group name, + // or an array of rules. + if (is_string($rules)) { + [$rules, $customErrors] = $this->validation->loadRuleGroup($rules); + + $this->validationRules = $rules; + $this->validationMessages += $customErrors; + } + + $this->validationRules[$field] = $fieldRules; + + return $this; + } + + /** + * Should validation rules be removed before saving? + * Most handy when doing updates. + * + * @param bool $choice Value + * + * @return $this + */ + public function cleanRules(bool $choice = false) + { + $this->cleanValidationRules = $choice; + + return $this; + } + + /** + * Validate the row data against the validation rules (or the validation group) + * specified in the class property, $validationRules. + * + * @param array|object $row Row data + * @phpstan-param row_array|object $row + */ + public function validate($row): bool + { + $rules = $this->getValidationRules(); + + // Validation requires array, so cast away. + if (is_object($row)) { + $row = (array) $row; + } + + if ($this->skipValidation || $rules === [] || $row === []) { + return true; + } + + $rules = $this->cleanValidationRules ? $this->cleanValidationRules($rules, $row) : $rules; + + // If no data existed that needs validation + // our job is done here. + if ($rules === []) { + return true; + } + + $this->validation->reset()->setRules($rules, $this->validationMessages); + + return $this->validation->run($row, null, $this->DBGroup); + } + + /** + * Returns the model's defined validation rules so that they + * can be used elsewhere, if needed. + * + * @param array $options Options + */ + public function getValidationRules(array $options = []): array + { + $rules = $this->validationRules; + + // ValidationRules can be either a string, which is the group name, + // or an array of rules. + if (is_string($rules)) { + [$rules, $customErrors] = $this->validation->loadRuleGroup($rules); + + $this->validationMessages += $customErrors; + } + + if (isset($options['except'])) { + $rules = array_diff_key($rules, array_flip($options['except'])); + } elseif (isset($options['only'])) { + $rules = array_intersect_key($rules, array_flip($options['only'])); + } + + return $rules; + } + + /** + * Returns the model's validation messages, so they + * can be used elsewhere, if needed. + */ + public function getValidationMessages(): array + { + return $this->validationMessages; + } + + /** + * Removes any rules that apply to fields that have not been set + * currently so that rules don't block updating when only updating + * a partial row. + * + * @param array $rules Array containing field name and rule + * @param array $row Row data (@TODO Remove null in param type) + * @phpstan-param row_array $row + */ + protected function cleanValidationRules(array $rules, ?array $row = null): array + { + if ($row === null || $row === []) { + return []; + } + + foreach (array_keys($rules) as $field) { + if (! array_key_exists($field, $row)) { + unset($rules[$field]); + } + } + + return $rules; + } + + /** + * Sets $tempAllowCallbacks value so that we can temporarily override + * the setting. Resets after the next method that uses triggers. + * + * @param bool $val value + * + * @return $this + */ + public function allowCallbacks(bool $val = true) + { + $this->tempAllowCallbacks = $val; + + return $this; + } + + /** + * A simple event trigger for Model Events that allows additional + * data manipulation within the model. Specifically intended for + * usage by child models this can be used to format data, + * save/load related classes, etc. + * + * It is the responsibility of the callback methods to return + * the data itself. + * + * Each $eventData array MUST have a 'data' key with the relevant + * data for callback methods (like an array of key/value pairs to insert + * or update, an array of results, etc.) + * + * If callbacks are not allowed then returns $eventData immediately. + * + * @param string $event Event + * @param array $eventData Event Data + * + * @return array + * + * @throws DataException + */ + protected function trigger(string $event, array $eventData) + { + // Ensure it's a valid event + if (! isset($this->{$event}) || $this->{$event} === []) { + return $eventData; + } + + foreach ($this->{$event} as $callback) { + if (! method_exists($this, $callback)) { + throw DataException::forInvalidMethodTriggered($callback); + } + + $eventData = $this->{$callback}($eventData); + } + + return $eventData; + } + + /** + * Sets the return type of the results to be as an associative array. + * + * @return $this + */ + public function asArray() + { + $this->tempReturnType = 'array'; + + return $this; + } + + /** + * Sets the return type to be of the specified type of object. + * Defaults to a simple object, but can be any class that has + * class vars with the same name as the collection columns, + * or at least allows them to be created. + * + * @param string $class Class Name + * + * @return $this + */ + public function asObject(string $class = 'object') + { + $this->tempReturnType = $class; + + return $this; + } + + /** + * Takes a class and returns an array of its public and protected + * properties as an array suitable for use in creates and updates. + * This method uses objectToRawArray() internally and does conversion + * to string on all Time instances + * + * @param object $object Object + * @param bool $onlyChanged Only Changed Property + * @param bool $recursive If true, inner entities will be cast as array as well + * + * @return array + * + * @throws ReflectionException + */ + protected function objectToArray($object, bool $onlyChanged = true, bool $recursive = false): array + { + $properties = $this->objectToRawArray($object, $onlyChanged, $recursive); + + // Convert any Time instances to appropriate $dateFormat + return $this->timeToString($properties); + } + + /** + * Convert any Time instances to appropriate $dateFormat. + * + * @param array $properties + * + * @return array + */ + protected function timeToString(array $properties): array + { + if ($properties === []) { + return []; + } + + return array_map(function ($value) { + if ($value instanceof Time) { + return $this->timeToDate($value); + } + + return $value; + }, $properties); + } + + /** + * Takes a class and returns an array of its public and protected + * properties as an array with raw values. + * + * @param object $object Object + * @param bool $onlyChanged Only Changed Property + * @param bool $recursive If true, inner entities will be casted as array as well + * + * @return array + * + * @throws ReflectionException + */ + protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array + { + // Entity::toRawArray() returns array. + if (method_exists($object, 'toRawArray')) { + $properties = $object->toRawArray($onlyChanged, $recursive); + } else { + $mirror = new ReflectionClass($object); + $props = $mirror->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED); + + $properties = []; + + // Loop over each property, + // saving the name/value in a new array we can return. + foreach ($props as $prop) { + // Must make protected values accessible. + $prop->setAccessible(true); + $properties[$prop->getName()] = $prop->getValue($object); + } + } + + return $properties; + } + + /** + * Transform data to array. + * + * @param array|object|null $row Row data + * @phpstan-param row_array|object|null $row + * @param string $type Type of data (insert|update) + * + * @throws DataException + * @throws InvalidArgumentException + * @throws ReflectionException + */ + protected function transformDataToArray($row, string $type): array + { + if (! in_array($type, ['insert', 'update'], true)) { + throw new InvalidArgumentException(sprintf('Invalid type "%s" used upon transforming data to array.', $type)); + } + + if (! $this->allowEmptyInserts && ($row === null || (array) $row === [])) { + throw DataException::forEmptyDataset($type); + } + + // If $row is using a custom class with public or protected + // properties representing the collection elements, we need to grab + // them as an array. + if (is_object($row) && ! $row instanceof stdClass) { + // If it validates with entire rules, all fields are needed. + $onlyChanged = ($this->skipValidation === false && $this->cleanValidationRules === false) + ? false : ($type === 'update'); + + $row = $this->objectToArray($row, $onlyChanged, true); + } + + // If it's still a stdClass, go ahead and convert to + // an array so doProtectFields and other model methods + // don't have to do special checks. + if (is_object($row)) { + $row = (array) $row; + } + + // If it's still empty here, means $row is no change or is empty object + if (! $this->allowEmptyInserts && ($row === null || $row === [])) { + throw DataException::forEmptyDataset($type); + } + + return $row; + } + + /** + * Provides the db connection and model's properties. + * + * @param string $name Name + * + * @return array|bool|float|int|object|string|null + */ + public function __get(string $name) + { + if (property_exists($this, $name)) { + return $this->{$name}; + } + + return $this->db->{$name} ?? null; + } + + /** + * Checks for the existence of properties across this model, and db connection. + * + * @param string $name Name + */ + public function __isset(string $name): bool + { + if (property_exists($this, $name)) { + return true; + } + + return isset($this->db->{$name}); + } + + /** + * Provides direct access to method in the database connection. + * + * @param string $name Name + * @param array $params Params + * + * @return $this|null + */ + public function __call(string $name, array $params) + { + if (method_exists($this->db, $name)) { + return $this->db->{$name}(...$params); + } + + return null; + } + + /** + * Replace any placeholders within the rules with the values that + * match the 'key' of any properties being set. For example, if + * we had the following $data array: + * + * [ 'id' => 13 ] + * + * and the following rule: + * + * 'required|is_unique[users,email,id,{id}]' + * + * The value of {id} would be replaced with the actual id in the form data: + * + * 'required|is_unique[users,email,id,13]' + * + * @param array $rules Validation rules + * @param array $data Data + * + * @codeCoverageIgnore + * + * @deprecated use fillPlaceholders($rules, $data) from Validation instead + */ + protected function fillPlaceholders(array $rules, array $data): array + { + $replacements = []; + + foreach ($data as $key => $value) { + $replacements['{' . $key . '}'] = $value; + } + + if ($replacements !== []) { + foreach ($rules as &$rule) { + if (is_array($rule)) { + foreach ($rule as &$row) { + // Should only be an `errors` array + // which doesn't take placeholders. + if (is_array($row)) { + continue; + } + + $row = strtr($row, $replacements); + } + + continue; + } + + $rule = strtr($rule, $replacements); + } + } + + return $rules; + } + + /** + * Sets $allowEmptyInserts. + */ + public function allowEmptyInserts(bool $value = true): self + { + $this->allowEmptyInserts = $value; + + return $this; + } +} diff --git a/system/CLI/BaseCommand.php b/system/CLI/BaseCommand.php new file mode 100644 index 0000000..8101283 --- /dev/null +++ b/system/CLI/BaseCommand.php @@ -0,0 +1,227 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use Config\Exceptions; +use Psr\Log\LoggerInterface; +use ReflectionException; +use Throwable; + +/** + * BaseCommand is the base class used in creating CLI commands. + * + * @property array $arguments + * @property Commands $commands + * @property string $description + * @property string $group + * @property LoggerInterface $logger + * @property string $name + * @property array $options + * @property string $usage + */ +abstract class BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group; + + /** + * The Command's name + * + * @var string + */ + protected $name; + + /** + * the Command's usage description + * + * @var string + */ + protected $usage; + + /** + * the Command's short description + * + * @var string + */ + protected $description; + + /** + * the Command's options description + * + * @var array + */ + protected $options = []; + + /** + * the Command's Arguments description + * + * @var array + */ + protected $arguments = []; + + /** + * The Logger to use for a command + * + * @var LoggerInterface + */ + protected $logger; + + /** + * Instance of Commands so + * commands can call other commands. + * + * @var Commands + */ + protected $commands; + + public function __construct(LoggerInterface $logger, Commands $commands) + { + $this->logger = $logger; + $this->commands = $commands; + } + + /** + * Actually execute a command. + * + * @param array $params + * + * @return int|void + */ + abstract public function run(array $params); + + /** + * Can be used by a command to run other commands. + * + * @return int|void + * + * @throws ReflectionException + */ + protected function call(string $command, array $params = []) + { + return $this->commands->run($command, $params); + } + + /** + * A simple method to display an error with line/file, in child commands. + * + * @return void + */ + protected function showError(Throwable $e) + { + $exception = $e; + $message = $e->getMessage(); + $config = config(Exceptions::class); + + require $config->errorViewPath . '/cli/error_exception.php'; + } + + /** + * Show Help includes (Usage, Arguments, Description, Options). + * + * @return void + */ + public function showHelp() + { + CLI::write(lang('CLI.helpUsage'), 'yellow'); + + if (! empty($this->usage)) { + $usage = $this->usage; + } else { + $usage = $this->name; + + if ($this->arguments !== []) { + $usage .= ' [arguments]'; + } + } + + CLI::write($this->setPad($usage, 0, 0, 2)); + + if (! empty($this->description)) { + CLI::newLine(); + CLI::write(lang('CLI.helpDescription'), 'yellow'); + CLI::write($this->setPad($this->description, 0, 0, 2)); + } + + if ($this->arguments !== []) { + CLI::newLine(); + CLI::write(lang('CLI.helpArguments'), 'yellow'); + $length = max(array_map('strlen', array_keys($this->arguments))); + + foreach ($this->arguments as $argument => $description) { + CLI::write(CLI::color($this->setPad($argument, $length, 2, 2), 'green') . $description); + } + } + + if ($this->options !== []) { + CLI::newLine(); + CLI::write(lang('CLI.helpOptions'), 'yellow'); + $length = max(array_map('strlen', array_keys($this->options))); + + foreach ($this->options as $option => $description) { + CLI::write(CLI::color($this->setPad($option, $length, 2, 2), 'green') . $description); + } + } + } + + /** + * Pads our string out so that all titles are the same length to nicely line up descriptions. + * + * @param int $extra How many extra spaces to add at the end + */ + public function setPad(string $item, int $max, int $extra = 2, int $indent = 0): string + { + $max += $extra + $indent; + + return str_pad(str_repeat(' ', $indent) . $item, $max); + } + + /** + * Get pad for $key => $value array output + * + * @deprecated Use setPad() instead. + * + * @codeCoverageIgnore + */ + public function getPad(array $array, int $pad): int + { + $max = 0; + + foreach (array_keys($array) as $key) { + $max = max($max, strlen($key)); + } + + return $max + $pad; + } + + /** + * Makes it simple to access our protected properties. + * + * @return array|Commands|LoggerInterface|string|null + */ + public function __get(string $key) + { + return $this->{$key} ?? null; + } + + /** + * Makes it simple to check our protected properties. + */ + public function __isset(string $key): bool + { + return isset($this->{$key}); + } +} diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php new file mode 100644 index 0000000..9a50e40 --- /dev/null +++ b/system/CLI/CLI.php @@ -0,0 +1,1153 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\CLI\Exceptions\CLIException; +use Config\Services; +use InvalidArgumentException; +use Throwable; + +/** + * Set of static methods useful for CLI request handling. + * + * Portions of this code were initially from the FuelPHP Framework, + * version 1.7.x, and used here under the MIT license they were + * originally made available under. Reference: http://fuelphp.com + * + * Some of the code in this class is Windows-specific, and not + * possible to test using travis-ci. It has been phpunit-annotated + * to prevent messing up code coverage. + * + * Some of the methods require keyboard input, and are not unit-testable + * as a result: input() and prompt(). + * validate() is internal, and not testable if prompt() isn't. + * The wait() method is mostly testable, as long as you don't give it + * an argument of "0". + * These have been flagged to ignore for code coverage purposes. + * + * @see \CodeIgniter\CLI\CLITest + */ +class CLI +{ + /** + * Is the readline library on the system? + * + * @var bool + * + * @deprecated 4.4.2 Should be protected. + * @TODO Fix to camelCase in the next major version. + */ + public static $readline_support = false; + + /** + * The message displayed at prompts. + * + * @var string + * + * @deprecated 4.4.2 Should be protected. + * @TODO Fix to camelCase in the next major version. + */ + public static $wait_msg = 'Press any key to continue...'; + + /** + * Has the class already been initialized? + * + * @var bool + */ + protected static $initialized = false; + + /** + * Foreground color list + * + * @var array + * + * @TODO Fix to camelCase in the next major version. + */ + protected static $foreground_colors = [ + 'black' => '0;30', + 'dark_gray' => '1;30', + 'blue' => '0;34', + 'dark_blue' => '0;34', + 'light_blue' => '1;34', + 'green' => '0;32', + 'light_green' => '1;32', + 'cyan' => '0;36', + 'light_cyan' => '1;36', + 'red' => '0;31', + 'light_red' => '1;31', + 'purple' => '0;35', + 'light_purple' => '1;35', + 'yellow' => '0;33', + 'light_yellow' => '1;33', + 'light_gray' => '0;37', + 'white' => '1;37', + ]; + + /** + * Background color list + * + * @var array + * + * @TODO Fix to camelCase in the next major version. + */ + protected static $background_colors = [ + 'black' => '40', + 'red' => '41', + 'green' => '42', + 'yellow' => '43', + 'blue' => '44', + 'magenta' => '45', + 'cyan' => '46', + 'light_gray' => '47', + ]; + + /** + * List of array segments. + * + * @var array + */ + protected static $segments = []; + + /** + * @var array + */ + protected static $options = []; + + /** + * Helps track internally whether the last + * output was a "write" or a "print" to + * keep the output clean and as expected. + * + * @var string|null + */ + protected static $lastWrite; + + /** + * Height of the CLI window + * + * @var int|null + */ + protected static $height; + + /** + * Width of the CLI window + * + * @var int|null + */ + protected static $width; + + /** + * Whether the current stream supports colored output. + * + * @var bool + */ + protected static $isColored = false; + + /** + * Static "constructor". + * + * @return void + */ + public static function init() + { + if (is_cli()) { + // Readline is an extension for PHP that makes interactivity with PHP + // much more bash-like. + // http://www.php.net/manual/en/readline.installation.php + static::$readline_support = extension_loaded('readline'); + + // clear segments & options to keep testing clean + static::$segments = []; + static::$options = []; + + // Check our stream resource for color support + static::$isColored = static::hasColorSupport(STDOUT); + + static::parseCommandLine(); + + static::$initialized = true; + } elseif (! defined('STDOUT')) { + // If the command is being called from a controller + // we need to define STDOUT ourselves + // For "! defined('STDOUT')" see: https://github.com/codeigniter4/CodeIgniter4/issues/7047 + define('STDOUT', 'php://output'); // @codeCoverageIgnore + } + } + + /** + * Get input from the shell, using readline or the standard STDIN + * + * Named options must be in the following formats: + * php index.php user -v --v -name=John --name=John + * + * @param string|null $prefix You may specify a string with which to prompt the user. + */ + public static function input(?string $prefix = null): string + { + // readline() can't be tested. + if (static::$readline_support && ENVIRONMENT !== 'testing') { + return readline($prefix); // @codeCoverageIgnore + } + + echo $prefix; + + return fgets(fopen('php://stdin', 'rb')); + } + + /** + * Asks the user for input. + * + * Usage: + * + * // Takes any input + * $color = CLI::prompt('What is your favorite color?'); + * + * // Takes any input, but offers default + * $color = CLI::prompt('What is your favourite color?', 'white'); + * + * // Will validate options with the in_list rule and accept only if one of the list + * $color = CLI::prompt('What is your favourite color?', array('red','blue')); + * + * // Do not provide options but requires a valid email + * $email = CLI::prompt('What is your email?', null, 'required|valid_email'); + * + * @param string $field Output "field" question + * @param array|string $options String to a default value, array to a list of options (the first option will be the default value) + * @param array|string|null $validation Validation rules + * + * @return string The user input + * + * @codeCoverageIgnore + */ + public static function prompt(string $field, $options = null, $validation = null): string + { + $extraOutput = ''; + $default = ''; + + if ($validation && ! is_array($validation) && ! is_string($validation)) { + throw new InvalidArgumentException('$rules can only be of type string|array'); + } + + if (! is_array($validation)) { + $validation = $validation ? explode('|', $validation) : []; + } + + if (is_string($options)) { + $extraOutput = ' [' . static::color($options, 'green') . ']'; + $default = $options; + } + + if (is_array($options) && $options) { + $opts = $options; + $extraOutputDefault = static::color($opts[0], 'green'); + + unset($opts[0]); + + if ($opts === []) { + $extraOutput = $extraOutputDefault; + } else { + $extraOutput = '[' . $extraOutputDefault . ', ' . implode(', ', $opts) . ']'; + $validation[] = 'in_list[' . implode(', ', $options) . ']'; + } + + $default = $options[0]; + } + + static::fwrite(STDOUT, $field . (trim($field) ? ' ' : '') . $extraOutput . ': '); + + // Read the input from keyboard. + $input = trim(static::input()) ?: $default; + + if ($validation !== []) { + while (! static::validate('"' . trim($field) . '"', $input, $validation)) { + $input = static::prompt($field, $options, $validation); + } + } + + return $input; + } + + /** + * prompt(), but based on the option's key + * + * @param array|string $text Output "field" text or an one or two value array where the first value is the text before listing the options + * and the second value the text before asking to select one option. Provide empty string to omit + * @param array $options A list of options (array(key => description)), the first option will be the default value + * @param array|string|null $validation Validation rules + * + * @return string The selected key of $options + * + * @codeCoverageIgnore + */ + public static function promptByKey($text, array $options, $validation = null): string + { + if (is_string($text)) { + $text = [$text]; + } elseif (! is_array($text)) { + throw new InvalidArgumentException('$text can only be of type string|array'); + } + + CLI::isZeroOptions($options); + + if ($line = array_shift($text)) { + CLI::write($line); + } + + CLI::printKeysAndValues($options); + + return static::prompt(PHP_EOL . array_shift($text), array_keys($options), $validation); + } + + /** + * This method is the same as promptByKey(), but this method supports multiple keys, separated by commas. + * + * @param string $text Output "field" text or an one or two value array where the first value is the text before listing the options + * and the second value the text before asking to select one option. Provide empty string to omit + * @param array $options A list of options (array(key => description)), the first option will be the default value + * + * @return array The selected key(s) and value(s) of $options + */ + public static function promptByMultipleKeys(string $text, array $options): array + { + CLI::isZeroOptions($options); + + $extraOutputDefault = static::color('0', 'green'); + $opts = $options; + unset($opts[0]); + + if ($opts === []) { + $extraOutput = $extraOutputDefault; + } else { + $optsKey = []; + + foreach (array_keys($opts) as $key) { + $optsKey[] = $key; + } + $extraOutput = '[' . $extraOutputDefault . ', ' . implode(', ', $optsKey) . ']'; + $extraOutput = 'You can specify multiple values separated by commas.' . PHP_EOL . $extraOutput; + } + + CLI::write($text); + CLI::printKeysAndValues($options); + CLI::newLine(); + $input = static::prompt($extraOutput) ?: 0; // 0 is default + + // validation + while (true) { + $pattern = preg_match_all('/^\d+(,\d+)*$/', trim($input)); + + // separate input by comma and convert all to an int[] + $inputToArray = array_map(static fn ($value) => (int) $value, explode(',', $input)); + // find max from key of $options + $maxOptions = array_key_last($options); + // find max from input + $maxInput = max($inputToArray); + + // return the prompt again if $input contain(s) non-numeric charachter, except a comma. + // And if max from $options less than max from input + // it is mean user tried to access null value in $options + if (! $pattern || $maxOptions < $maxInput) { + static::error('Please select correctly.'); + CLI::newLine(); + $input = static::prompt($extraOutput) ?: 0; + } else { + break; + } + } + + $input = []; + + foreach ($options as $key => $description) { + foreach ($inputToArray as $inputKey) { + if ($key === $inputKey) { + $input[$key] = $description; + } + } + } + + return $input; + } + + // -------------------------------------------------------------------- + // Utility for promptBy... + // -------------------------------------------------------------------- + + /** + * Validation for $options in promptByKey() and promptByMultipleKeys(). Return an error if $options is an empty array. + */ + private static function isZeroOptions(array $options): void + { + if (! $options) { + throw new InvalidArgumentException('No options to select from were provided'); + } + } + + /** + * Print each key and value one by one + */ + private static function printKeysAndValues(array $options): void + { + // +2 for the square brackets around the key + $keyMaxLength = max(array_map('mb_strwidth', array_keys($options))) + 2; + + foreach ($options as $key => $description) { + $name = str_pad(' [' . $key . '] ', $keyMaxLength + 4, ' '); + CLI::write(CLI::color($name, 'green') . CLI::wrap($description, 125, $keyMaxLength + 4)); + } + } + + // -------------------------------------------------------------------- + // End Utility for promptBy... + // -------------------------------------------------------------------- + + /** + * Validate one prompt "field" at a time + * + * @param string $field Prompt "field" output + * @param string $value Input value + * @param array|string $rules Validation rules + * + * @codeCoverageIgnore + */ + protected static function validate(string $field, string $value, $rules): bool + { + $label = $field; + $field = 'temp'; + $validation = Services::validation(null, false); + $validation->setRules([ + $field => [ + 'label' => $label, + 'rules' => $rules, + ], + ]); + $validation->run([$field => $value]); + + if ($validation->hasError($field)) { + static::error($validation->getError($field)); + + return false; + } + + return true; + } + + /** + * Outputs a string to the CLI without any surrounding newlines. + * Useful for showing repeating elements on a single line. + * + * @return void + */ + public static function print(string $text = '', ?string $foreground = null, ?string $background = null) + { + if ($foreground || $background) { + $text = static::color($text, $foreground, $background); + } + + static::$lastWrite = null; + + static::fwrite(STDOUT, $text); + } + + /** + * Outputs a string to the cli on its own line. + * + * @return void + */ + public static function write(string $text = '', ?string $foreground = null, ?string $background = null) + { + if ($foreground || $background) { + $text = static::color($text, $foreground, $background); + } + + if (static::$lastWrite !== 'write') { + $text = PHP_EOL . $text; + static::$lastWrite = 'write'; + } + + static::fwrite(STDOUT, $text . PHP_EOL); + } + + /** + * Outputs an error to the CLI using STDERR instead of STDOUT + * + * @return void + */ + public static function error(string $text, string $foreground = 'light_red', ?string $background = null) + { + // Check color support for STDERR + $stdout = static::$isColored; + static::$isColored = static::hasColorSupport(STDERR); + + if ($foreground || $background) { + $text = static::color($text, $foreground, $background); + } + + static::fwrite(STDERR, $text . PHP_EOL); + + // return STDOUT color support + static::$isColored = $stdout; + } + + /** + * Beeps a certain number of times. + * + * @param int $num The number of times to beep + * + * @return void + */ + public static function beep(int $num = 1) + { + echo str_repeat("\x07", $num); + } + + /** + * Waits a certain number of seconds, optionally showing a wait message and + * waiting for a key press. + * + * @param int $seconds Number of seconds + * @param bool $countdown Show a countdown or not + * + * @return void + */ + public static function wait(int $seconds, bool $countdown = false) + { + if ($countdown === true) { + $time = $seconds; + + while ($time > 0) { + static::fwrite(STDOUT, $time . '... '); + sleep(1); + $time--; + } + + static::write(); + } elseif ($seconds > 0) { + sleep($seconds); + } else { + // this chunk cannot be tested because of keyboard input + // @codeCoverageIgnoreStart + static::write(static::$wait_msg); + static::input(); + // @codeCoverageIgnoreEnd + } + } + + /** + * if operating system === windows + * + * @deprecated 4.3.0 Use `is_windows()` instead + */ + public static function isWindows(): bool + { + return is_windows(); + } + + /** + * Enter a number of empty lines + * + * @return void + */ + public static function newLine(int $num = 1) + { + // Do it once or more, write with empty string gives us a new line + for ($i = 0; $i < $num; $i++) { + static::write(); + } + } + + /** + * Clears the screen of output + * + * @codeCoverageIgnore + * + * @return void + */ + public static function clearScreen() + { + // Unix systems, and Windows with VT100 Terminal support (i.e. Win10) + // can handle CSI sequences. For lower than Win10 we just shove in 40 new lines. + is_windows() && ! static::streamSupports('sapi_windows_vt100_support', STDOUT) + ? static::newLine(40) + : static::fwrite(STDOUT, "\033[H\033[2J"); + } + + /** + * Returns the given text with the correct color codes for a foreground and + * optionally a background color. + * + * @param string $text The text to color + * @param string $foreground The foreground color + * @param string|null $background The background color + * @param string|null $format Other formatting to apply. Currently only 'underline' is understood + * + * @return string The color coded string + */ + public static function color(string $text, string $foreground, ?string $background = null, ?string $format = null): string + { + if (! static::$isColored || $text === '') { + return $text; + } + + if (! array_key_exists($foreground, static::$foreground_colors)) { + throw CLIException::forInvalidColor('foreground', $foreground); + } + + if ($background !== null && ! array_key_exists($background, static::$background_colors)) { + throw CLIException::forInvalidColor('background', $background); + } + + $newText = ''; + + // Detect if color method was already in use with this text + if (strpos($text, "\033[0m") !== false) { + $pattern = '/\\033\\[0;.+?\\033\\[0m/u'; + + preg_match_all($pattern, $text, $matches); + $coloredStrings = $matches[0]; + + // No colored string found. Invalid strings with no `\033[0;??`. + if ($coloredStrings === []) { + return $newText . self::getColoredText($text, $foreground, $background, $format); + } + + $nonColoredText = preg_replace( + $pattern, + '<<__colored_string__>>', + $text + ); + $nonColoredChunks = preg_split( + '/<<__colored_string__>>/u', + $nonColoredText + ); + + foreach ($nonColoredChunks as $i => $chunk) { + if ($chunk !== '') { + $newText .= self::getColoredText($chunk, $foreground, $background, $format); + } + + if (isset($coloredStrings[$i])) { + $newText .= $coloredStrings[$i]; + } + } + } else { + $newText .= self::getColoredText($text, $foreground, $background, $format); + } + + return $newText; + } + + private static function getColoredText(string $text, string $foreground, ?string $background, ?string $format): string + { + $string = "\033[" . static::$foreground_colors[$foreground] . 'm'; + + if ($background !== null) { + $string .= "\033[" . static::$background_colors[$background] . 'm'; + } + + if ($format === 'underline') { + $string .= "\033[4m"; + } + + return $string . $text . "\033[0m"; + } + + /** + * Get the number of characters in string having encoded characters + * and ignores styles set by the color() function + */ + public static function strlen(?string $string): int + { + if ($string === null) { + return 0; + } + + foreach (static::$foreground_colors as $color) { + $string = strtr($string, ["\033[" . $color . 'm' => '']); + } + + foreach (static::$background_colors as $color) { + $string = strtr($string, ["\033[" . $color . 'm' => '']); + } + + $string = strtr($string, ["\033[4m" => '', "\033[0m" => '']); + + return mb_strwidth($string); + } + + /** + * Checks whether the current stream resource supports or + * refers to a valid terminal type device. + * + * @param resource $resource + */ + public static function streamSupports(string $function, $resource): bool + { + if (ENVIRONMENT === 'testing') { + // In the current setup of the tests we cannot fully check + // if the stream supports the function since we are using + // filtered streams. + return function_exists($function); + } + + return function_exists($function) && @$function($resource); // @codeCoverageIgnore + } + + /** + * Returns true if the stream resource supports colors. + * + * This is tricky on Windows, because Cygwin, Msys2 etc. emulate pseudo + * terminals via named pipes, so we can only check the environment. + * + * Reference: https://github.com/composer/xdebug-handler/blob/master/src/Process.php + * + * @param resource $resource + */ + public static function hasColorSupport($resource): bool + { + // Follow https://no-color.org/ + if (isset($_SERVER['NO_COLOR']) || getenv('NO_COLOR') !== false) { + return false; + } + + if (getenv('TERM_PROGRAM') === 'Hyper') { + return true; + } + + if (is_windows()) { + // @codeCoverageIgnoreStart + return static::streamSupports('sapi_windows_vt100_support', $resource) + || isset($_SERVER['ANSICON']) + || getenv('ANSICON') !== false + || getenv('ConEmuANSI') === 'ON' + || getenv('TERM') === 'xterm'; + // @codeCoverageIgnoreEnd + } + + return static::streamSupports('stream_isatty', $resource); + } + + /** + * Attempts to determine the width of the viewable CLI window. + */ + public static function getWidth(int $default = 80): int + { + if (static::$width === null) { + static::generateDimensions(); + } + + return static::$width ?: $default; + } + + /** + * Attempts to determine the height of the viewable CLI window. + */ + public static function getHeight(int $default = 32): int + { + if (static::$height === null) { + static::generateDimensions(); + } + + return static::$height ?: $default; + } + + /** + * Populates the CLI's dimensions. + * + * @codeCoverageIgnore + * + * @return void + */ + public static function generateDimensions() + { + try { + if (is_windows()) { + // Shells such as `Cygwin` and `Git bash` returns incorrect values + // when executing `mode CON`, so we use `tput` instead + if (getenv('TERM') || (($shell = getenv('SHELL')) && preg_match('/(?:bash|zsh)(?:\.exe)?$/', $shell))) { + static::$height = (int) exec('tput lines'); + static::$width = (int) exec('tput cols'); + } else { + $return = -1; + $output = []; + exec('mode CON', $output, $return); + + // Look for the next lines ending in ": " + // Searching for "Columns:" or "Lines:" will fail on non-English locales + if ($return === 0 && $output && preg_match('/:\s*(\d+)\n[^:]+:\s*(\d+)\n/', implode("\n", $output), $matches)) { + static::$height = (int) $matches[1]; + static::$width = (int) $matches[2]; + } + } + } elseif (($size = exec('stty size')) && preg_match('/(\d+)\s+(\d+)/', $size, $matches)) { + static::$height = (int) $matches[1]; + static::$width = (int) $matches[2]; + } else { + static::$height = (int) exec('tput lines'); + static::$width = (int) exec('tput cols'); + } + } catch (Throwable $e) { + // Reset the dimensions so that the default values will be returned later. + // Then let the developer know of the error. + static::$height = null; + static::$width = null; + log_message('error', (string) $e); + } + } + + /** + * Displays a progress bar on the CLI. You must call it repeatedly + * to update it. Set $thisStep = false to erase the progress bar. + * + * @param bool|int $thisStep + * + * @return void + */ + public static function showProgress($thisStep = 1, int $totalSteps = 10) + { + static $inProgress = false; + + // restore cursor position when progress is continuing. + if ($inProgress !== false && $inProgress <= $thisStep) { + static::fwrite(STDOUT, "\033[1A"); + } + $inProgress = $thisStep; + + if ($thisStep !== false) { + // Don't allow div by zero or negative numbers.... + $thisStep = abs($thisStep); + $totalSteps = $totalSteps < 1 ? 1 : $totalSteps; + + $percent = (int) (($thisStep / $totalSteps) * 100); + $step = (int) round($percent / 10); + + // Write the progress bar + static::fwrite(STDOUT, "[\033[32m" . str_repeat('#', $step) . str_repeat('.', 10 - $step) . "\033[0m]"); + // Textual representation... + static::fwrite(STDOUT, sprintf(' %3d%% Complete', $percent) . PHP_EOL); + } else { + static::fwrite(STDOUT, "\007"); + } + } + + /** + * Takes a string and writes it to the command line, wrapping to a maximum + * width. If no maximum width is specified, will wrap to the window's max + * width. + * + * If an int is passed into $pad_left, then all strings after the first + * will pad with that many spaces to the left. Useful when printing + * short descriptions that need to start on an existing line. + */ + public static function wrap(?string $string = null, int $max = 0, int $padLeft = 0): string + { + if ($string === null || $string === '') { + return ''; + } + + if ($max === 0) { + $max = self::getWidth(); + } + + if (self::getWidth() < $max) { + $max = self::getWidth(); + } + + $max -= $padLeft; + + $lines = wordwrap($string, $max, PHP_EOL); + + if ($padLeft > 0) { + $lines = explode(PHP_EOL, $lines); + + $first = true; + + array_walk($lines, static function (&$line) use ($padLeft, &$first) { + if (! $first) { + $line = str_repeat(' ', $padLeft) . $line; + } else { + $first = false; + } + }); + + $lines = implode(PHP_EOL, $lines); + } + + return $lines; + } + + // -------------------------------------------------------------------- + // Command-Line 'URI' support + // -------------------------------------------------------------------- + + /** + * Parses the command line it was called from and collects all + * options and valid segments. + * + * @return void + */ + protected static function parseCommandLine() + { + $args = $_SERVER['argv'] ?? []; + array_shift($args); // scrap invoking program + $optionValue = false; + + foreach ($args as $i => $arg) { + // If there's no "-" at the beginning, then + // this is probably an argument or an option value + if (mb_strpos($arg, '-') !== 0) { + if ($optionValue) { + // We have already included this in the previous + // iteration, so reset this flag + $optionValue = false; + } else { + // Yup, it's a segment + static::$segments[] = $arg; + } + + continue; + } + + $arg = ltrim($arg, '-'); + $value = null; + + if (isset($args[$i + 1]) && mb_strpos($args[$i + 1], '-') !== 0) { + $value = $args[$i + 1]; + $optionValue = true; + } + + static::$options[$arg] = $value; + } + } + + /** + * Returns the command line string portions of the arguments, minus + * any options, as a string. This is used to pass along to the main + * CodeIgniter application. + */ + public static function getURI(): string + { + return implode('/', static::$segments); + } + + /** + * Returns an individual segment. + * + * This ignores any options that might have been dispersed between + * valid segments in the command: + * + * // segment(3) is 'three', not '-f' or 'anOption' + * > php spark one two -f anOption three + * + * **IMPORTANT:** The index here is one-based instead of zero-based. + * + * @return string|null + */ + public static function getSegment(int $index) + { + return static::$segments[$index - 1] ?? null; + } + + /** + * Returns the raw array of segments found. + */ + public static function getSegments(): array + { + return static::$segments; + } + + /** + * Gets a single command-line option. Returns TRUE if the option + * exists, but doesn't have a value, and is simply acting as a flag. + * + * @return string|true|null + */ + public static function getOption(string $name) + { + if (! array_key_exists($name, static::$options)) { + return null; + } + + // If the option didn't have a value, simply return TRUE + // so they know it was set, otherwise return the actual value. + $val = static::$options[$name] ?? true; + + return $val; + } + + /** + * Returns the raw array of options found. + */ + public static function getOptions(): array + { + return static::$options; + } + + /** + * Returns the options as a string, suitable for passing along on + * the CLI to other commands. + * + * @param bool $useLongOpts Use '--' for long options? + * @param bool $trim Trim final string output? + */ + public static function getOptionString(bool $useLongOpts = false, bool $trim = false): string + { + if (static::$options === []) { + return ''; + } + + $out = ''; + + foreach (static::$options as $name => $value) { + if ($useLongOpts && mb_strlen($name) > 1) { + $out .= "--{$name} "; + } else { + $out .= "-{$name} "; + } + + if ($value === null) { + continue; + } + + if (mb_strpos($value, ' ') !== false) { + $out .= "\"{$value}\" "; + } elseif ($value !== null) { + $out .= "{$value} "; + } + } + + return $trim ? trim($out) : $out; + } + + /** + * Returns a well formatted table + * + * @param array $tbody List of rows + * @param array $thead List of columns + * + * @return void + */ + public static function table(array $tbody, array $thead = []) + { + // All the rows in the table will be here until the end + $tableRows = []; + + // We need only indexes and not keys + if ($thead !== []) { + $tableRows[] = array_values($thead); + } + + foreach ($tbody as $tr) { + $tableRows[] = array_values($tr); + } + + // Yes, it really is necessary to know this count + $totalRows = count($tableRows); + + // Store all columns lengths + // $all_cols_lengths[row][column] = length + $allColsLengths = []; + + // Store maximum lengths by column + // $max_cols_lengths[column] = length + $maxColsLengths = []; + + // Read row by row and define the longest columns + for ($row = 0; $row < $totalRows; $row++) { + $column = 0; // Current column index + + foreach ($tableRows[$row] as $col) { + // Sets the size of this column in the current row + $allColsLengths[$row][$column] = static::strlen($col); + + // If the current column does not have a value among the larger ones + // or the value of this is greater than the existing one + // then, now, this assumes the maximum length + if (! isset($maxColsLengths[$column]) || $allColsLengths[$row][$column] > $maxColsLengths[$column]) { + $maxColsLengths[$column] = $allColsLengths[$row][$column]; + } + + // We can go check the size of the next column... + $column++; + } + } + + // Read row by row and add spaces at the end of the columns + // to match the exact column length + for ($row = 0; $row < $totalRows; $row++) { + $column = 0; + + foreach ($tableRows[$row] as $col) { + $diff = $maxColsLengths[$column] - static::strlen($col); + + if ($diff !== 0) { + $tableRows[$row][$column] .= str_repeat(' ', $diff); + } + + $column++; + } + } + + $table = ''; + $cols = ''; + + // Joins columns and append the well formatted rows to the table + for ($row = 0; $row < $totalRows; $row++) { + // Set the table border-top + if ($row === 0) { + $cols = '+'; + + foreach ($tableRows[$row] as $col) { + $cols .= str_repeat('-', static::strlen($col) + 2) . '+'; + } + $table .= $cols . PHP_EOL; + } + + // Set the columns borders + $table .= '| ' . implode(' | ', $tableRows[$row]) . ' |' . PHP_EOL; + + // Set the thead and table borders-bottom + if (($row === 0 && $thead !== []) || ($row + 1 === $totalRows)) { + $table .= $cols . PHP_EOL; + } + } + + static::write($table); + } + + /** + * While the library is intended for use on CLI commands, + * commands can be called from controllers and elsewhere + * so we need a way to allow them to still work. + * + * For now, just echo the content, but look into a better + * solution down the road. + * + * @param resource $handle + * + * @return void + */ + protected static function fwrite($handle, string $string) + { + if (! is_cli()) { + // @codeCoverageIgnoreStart + echo $string; + + return; + // @codeCoverageIgnoreEnd + } + + fwrite($handle, $string); + } +} + +// Ensure the class is initialized. Done outside of code coverage +CLI::init(); // @codeCoverageIgnore diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php new file mode 100644 index 0000000..1de0df2 --- /dev/null +++ b/system/CLI/Commands.php @@ -0,0 +1,186 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\Autoloader\FileLocator; +use CodeIgniter\Log\Logger; +use ReflectionClass; +use ReflectionException; + +/** + * Core functionality for running, listing, etc commands. + */ +class Commands +{ + /** + * The found commands. + * + * @var array + */ + protected $commands = []; + + /** + * Logger instance. + * + * @var Logger + */ + protected $logger; + + /** + * Constructor + * + * @param Logger|null $logger + */ + public function __construct($logger = null) + { + $this->logger = $logger ?? service('logger'); + $this->discoverCommands(); + } + + /** + * Runs a command given + * + * @return int|void + */ + public function run(string $command, array $params) + { + if (! $this->verifyCommand($command, $this->commands)) { + return; + } + + // The file would have already been loaded during the + // createCommandList function... + $className = $this->commands[$command]['class']; + $class = new $className($this->logger, $this); + + return $class->run($params); + } + + /** + * Provide access to the list of commands. + * + * @return array + */ + public function getCommands() + { + return $this->commands; + } + + /** + * Discovers all commands in the framework and within user code, + * and collects instances of them to work with. + * + * @return void + */ + public function discoverCommands() + { + if ($this->commands !== []) { + return; + } + + /** @var FileLocator $locator */ + $locator = service('locator'); + $files = $locator->listFiles('Commands/'); + + // If no matching command files were found, bail + // This should never happen in unit testing. + if ($files === []) { + return; // @codeCoverageIgnore + } + + // Loop over each file checking to see if a command with that + // alias exists in the class. + foreach ($files as $file) { + $className = $locator->getClassname($file); + + if ($className === '' || ! class_exists($className)) { + continue; + } + + try { + $class = new ReflectionClass($className); + + if (! $class->isInstantiable() || ! $class->isSubclassOf(BaseCommand::class)) { + continue; + } + + /** @var BaseCommand $class */ + $class = new $className($this->logger, $this); + + if (isset($class->group)) { + $this->commands[$class->name] = [ + 'class' => $className, + 'file' => $file, + 'group' => $class->group, + 'description' => $class->description, + ]; + } + + unset($class); + } catch (ReflectionException $e) { + $this->logger->error($e->getMessage()); + } + } + + asort($this->commands); + } + + /** + * Verifies if the command being sought is found + * in the commands list. + */ + public function verifyCommand(string $command, array $commands): bool + { + if (isset($commands[$command])) { + return true; + } + + $message = lang('CLI.commandNotFound', [$command]); + + $alternatives = $this->getCommandAlternatives($command, $commands); + if ($alternatives !== []) { + if (count($alternatives) === 1) { + $message .= "\n\n" . lang('CLI.altCommandSingular') . "\n "; + } else { + $message .= "\n\n" . lang('CLI.altCommandPlural') . "\n "; + } + + $message .= implode("\n ", $alternatives); + } + + CLI::error($message); + CLI::newLine(); + + return false; + } + + /** + * Finds alternative of `$name` among collection + * of commands. + */ + protected function getCommandAlternatives(string $name, array $collection): array + { + $alternatives = []; + + foreach (array_keys($collection) as $commandName) { + $lev = levenshtein($name, $commandName); + + if ($lev <= strlen($commandName) / 3 || strpos($commandName, $name) !== false) { + $alternatives[$commandName] = $lev; + } + } + + ksort($alternatives, SORT_NATURAL | SORT_FLAG_CASE); + + return array_keys($alternatives); + } +} diff --git a/system/CLI/Console.php b/system/CLI/Console.php new file mode 100644 index 0000000..adcf9aa --- /dev/null +++ b/system/CLI/Console.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\CodeIgniter; +use Config\App; +use Config\Services; +use Exception; + +/** + * Console + * + * @see \CodeIgniter\CLI\ConsoleTest + */ +class Console +{ + /** + * Runs the current command discovered on the CLI. + * + * @return int|void + * + * @throws Exception + */ + public function run() + { + // Create CLIRequest + $appConfig = config(App::class); + Services::createRequest($appConfig, true); + // Load Routes + Services::routes()->loadRoutes(); + + $runner = Services::commands(); + $params = array_merge(CLI::getSegments(), CLI::getOptions()); + $params = $this->parseParamsForHelpOption($params); + $command = array_shift($params) ?? 'list'; + + return $runner->run($command, $params); + } + + /** + * Displays basic information about the Console. + * + * @return void + */ + public function showHeader(bool $suppress = false) + { + if ($suppress) { + return; + } + + CLI::write(sprintf( + 'CodeIgniter v%s Command Line Tool - Server Time: %s UTC%s', + CodeIgniter::CI_VERSION, + date('Y-m-d H:i:s'), + date('P') + ), 'green'); + CLI::newLine(); + } + + /** + * Introspects the `$params` passed for presence of the + * `--help` option. + * + * If present, it will be found as `['help' => null]`. + * We'll remove that as an option from `$params` and + * unshift it as argument instead. + */ + private function parseParamsForHelpOption(array $params): array + { + if (array_key_exists('help', $params)) { + unset($params['help']); + + $params = $params === [] ? ['list'] : $params; + array_unshift($params, 'help'); + } + + return $params; + } +} diff --git a/system/CLI/Exceptions/CLIException.php b/system/CLI/Exceptions/CLIException.php new file mode 100644 index 0000000..4e07cd6 --- /dev/null +++ b/system/CLI/Exceptions/CLIException.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\DebugTraceableTrait; +use RuntimeException; + +/** + * CLIException + */ +class CLIException extends RuntimeException +{ + use DebugTraceableTrait; + + /** + * Thrown when `$color` specified for `$type` is not within the + * allowed list of colors. + * + * @return CLIException + */ + public static function forInvalidColor(string $type, string $color) + { + return new static(lang('CLI.invalidColor', [$type, $color])); + } +} diff --git a/system/CLI/GeneratorTrait.php b/system/CLI/GeneratorTrait.php new file mode 100644 index 0000000..7b60663 --- /dev/null +++ b/system/CLI/GeneratorTrait.php @@ -0,0 +1,388 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use Config\Generators; +use Config\Services; +use Throwable; + +/** + * GeneratorTrait contains a collection of methods + * to build the commands that generates a file. + */ +trait GeneratorTrait +{ + /** + * Component Name + * + * @var string + */ + protected $component; + + /** + * File directory + * + * @var string + */ + protected $directory; + + /** + * View template name + * + * @var string + */ + protected $template; + + /** + * Language string key for required class names. + * + * @var string + */ + protected $classNameLang = ''; + + /** + * Whether to require class name. + * + * @internal + * + * @var bool + */ + private $hasClassName = true; + + /** + * Whether to sort class imports. + * + * @internal + * + * @var bool + */ + private $sortImports = true; + + /** + * Whether the `--suffix` option has any effect. + * + * @internal + * + * @var bool + */ + private $enabledSuffixing = true; + + /** + * The params array for easy access by other methods. + * + * @internal + * + * @var array + */ + private $params = []; + + /** + * Execute the command. + * + * @deprecated use generateClass() instead + */ + protected function execute(array $params): void + { + $this->generateClass($params); + } + + /** + * Generates a class file from an existing template. + */ + protected function generateClass(array $params) + { + $this->params = $params; + + // Get the fully qualified class name from the input. + $class = $this->qualifyClassName(); + + // Get the file path from class name. + $target = $this->buildPath($class); + + // Check if path is empty. + if (empty($target)) { + return; + } + + $this->generateFile($target, $this->buildContent($class)); + } + + /** + * Generate a view file from an existing template. + */ + protected function generateView(string $view, array $params) + { + $this->params = $params; + + $target = $this->buildPath($view); + + // Check if path is empty. + if (empty($target)) { + return; + } + + $this->generateFile($target, $this->buildContent($view)); + } + + /** + * Handles writing the file to disk, and all of the safety checks around that. + */ + private function generateFile(string $target, string $content): void + { + if ($this->getOption('namespace') === 'CodeIgniter') { + // @codeCoverageIgnoreStart + CLI::write(lang('CLI.generator.usingCINamespace'), 'yellow'); + CLI::newLine(); + + if (CLI::prompt('Are you sure you want to continue?', ['y', 'n'], 'required') === 'n') { + CLI::newLine(); + CLI::write(lang('CLI.generator.cancelOperation'), 'yellow'); + CLI::newLine(); + + return; + } + + CLI::newLine(); + // @codeCoverageIgnoreEnd + } + + $isFile = is_file($target); + + // Overwriting files unknowingly is a serious annoyance, So we'll check if + // we are duplicating things, If 'force' option is not supplied, we bail. + if (! $this->getOption('force') && $isFile) { + CLI::error(lang('CLI.generator.fileExist', [clean_path($target)]), 'light_gray', 'red'); + CLI::newLine(); + + return; + } + + // Check if the directory to save the file is existing. + $dir = dirname($target); + + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + helper('filesystem'); + + // Build the class based on the details we have, We'll be getting our file + // contents from the template, and then we'll do the necessary replacements. + if (! write_file($target, $content)) { + // @codeCoverageIgnoreStart + CLI::error(lang('CLI.generator.fileError', [clean_path($target)]), 'light_gray', 'red'); + CLI::newLine(); + + return; + // @codeCoverageIgnoreEnd + } + + if ($this->getOption('force') && $isFile) { + CLI::write(lang('CLI.generator.fileOverwrite', [clean_path($target)]), 'yellow'); + CLI::newLine(); + + return; + } + + CLI::write(lang('CLI.generator.fileCreate', [clean_path($target)]), 'green'); + CLI::newLine(); + } + + /** + * Prepare options and do the necessary replacements. + */ + protected function prepare(string $class): string + { + return $this->parseTemplate($class); + } + + /** + * Change file basename before saving. + * + * Useful for components where the file name has a date. + */ + protected function basename(string $filename): string + { + return basename($filename); + } + + /** + * Parses the class name and checks if it is already qualified. + */ + protected function qualifyClassName(): string + { + // Gets the class name from input. + $class = $this->params[0] ?? CLI::getSegment(2); + + if ($class === null && $this->hasClassName) { + // @codeCoverageIgnoreStart + $nameLang = $this->classNameLang ?: 'CLI.generator.className.default'; + $class = CLI::prompt(lang($nameLang), null, 'required'); + CLI::newLine(); + // @codeCoverageIgnoreEnd + } + + helper('inflector'); + + $component = singular($this->component); + + /** + * @see https://regex101.com/r/a5KNCR/2 + */ + $pattern = sprintf('/([a-z][a-z0-9_\/\\\\]+)(%s)$/i', $component); + + if (preg_match($pattern, $class, $matches) === 1) { + $class = $matches[1] . ucfirst($matches[2]); + } + + if ($this->enabledSuffixing && $this->getOption('suffix') && preg_match($pattern, $class) !== 1) { + $class .= ucfirst($component); + } + + // Trims input, normalize separators, and ensure that all paths are in Pascalcase. + $class = ltrim(implode('\\', array_map('pascalize', explode('\\', str_replace('/', '\\', trim($class))))), '\\/'); + + // Gets the namespace from input. Don't forget the ending backslash! + $namespace = trim(str_replace('/', '\\', $this->getOption('namespace') ?? APP_NAMESPACE), '\\') . '\\'; + + if (strncmp($class, $namespace, strlen($namespace)) === 0) { + return $class; // @codeCoverageIgnore + } + + return $namespace . $this->directory . '\\' . str_replace('/', '\\', $class); + } + + /** + * Gets the generator view as defined in the `Config\Generators::$views`, + * with fallback to `$template` when the defined view does not exist. + */ + protected function renderTemplate(array $data = []): string + { + try { + return view(config(Generators::class)->views[$this->name], $data, ['debug' => false]); + } catch (Throwable $e) { + log_message('error', (string) $e); + + return view("CodeIgniter\\Commands\\Generators\\Views\\{$this->template}", $data, ['debug' => false]); + } + } + + /** + * Performs pseudo-variables contained within view file. + */ + protected function parseTemplate(string $class, array $search = [], array $replace = [], array $data = []): string + { + // Retrieves the namespace part from the fully qualified class name. + $namespace = trim(implode('\\', array_slice(explode('\\', $class), 0, -1)), '\\'); + $search[] = '<@php'; + $search[] = '{namespace}'; + $search[] = '{class}'; + $replace[] = 'renderTemplate($data)); + } + + /** + * Builds the contents for class being generated, doing all + * the replacements necessary, and alphabetically sorts the + * imports for a given template. + */ + protected function buildContent(string $class): string + { + $template = $this->prepare($class); + + if ($this->sortImports && preg_match('/(?P(?:^use [^;]+;$\n?)+)/m', $template, $match)) { + $imports = explode("\n", trim($match['imports'])); + sort($imports); + + return str_replace(trim($match['imports']), implode("\n", $imports), $template); + } + + return $template; + } + + /** + * Builds the file path from the class name. + */ + protected function buildPath(string $class): string + { + $namespace = trim(str_replace('/', '\\', $this->getOption('namespace') ?? APP_NAMESPACE), '\\'); + + // Check if the namespace is actually defined and we are not just typing gibberish. + $base = Services::autoloader()->getNamespace($namespace); + + if (! $base = reset($base)) { + CLI::error(lang('CLI.namespaceNotDefined', [$namespace]), 'light_gray', 'red'); + CLI::newLine(); + + return ''; + } + + $base = realpath($base) ?: $base; + $file = $base . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, trim(str_replace($namespace . '\\', '', $class), '\\')) . '.php'; + + return implode(DIRECTORY_SEPARATOR, array_slice(explode(DIRECTORY_SEPARATOR, $file), 0, -1)) . DIRECTORY_SEPARATOR . $this->basename($file); + } + + /** + * Allows child generators to modify the internal `$hasClassName` flag. + * + * @return $this + */ + protected function setHasClassName(bool $hasClassName) + { + $this->hasClassName = $hasClassName; + + return $this; + } + + /** + * Allows child generators to modify the internal `$sortImports` flag. + * + * @return $this + */ + protected function setSortImports(bool $sortImports) + { + $this->sortImports = $sortImports; + + return $this; + } + + /** + * Allows child generators to modify the internal `$enabledSuffixing` flag. + * + * @return $this + */ + protected function setEnabledSuffixing(bool $enabledSuffixing) + { + $this->enabledSuffixing = $enabledSuffixing; + + return $this; + } + + /** + * Gets a single command-line option. Returns TRUE if the option exists, + * but doesn't have a value, and is simply acting as a flag. + * + * @return mixed + */ + protected function getOption(string $name) + { + if (! array_key_exists($name, $this->params)) { + return CLI::getOption($name); + } + + return $this->params[$name] ?? true; + } +} diff --git a/system/Cache/CacheFactory.php b/system/Cache/CacheFactory.php new file mode 100644 index 0000000..6402b00 --- /dev/null +++ b/system/Cache/CacheFactory.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache; + +use CodeIgniter\Cache\Exceptions\CacheException; +use CodeIgniter\Exceptions\CriticalError; +use CodeIgniter\Test\Mock\MockCache; +use Config\Cache; + +/** + * A factory for loading the desired + * + * @see \CodeIgniter\Cache\CacheFactoryTest + */ +class CacheFactory +{ + /** + * The class to use when mocking + * + * @var string + */ + public static $mockClass = MockCache::class; + + /** + * The service to inject the mock as + * + * @var string + */ + public static $mockServiceName = 'cache'; + + /** + * Attempts to create the desired cache handler, based upon the + * + * @param non-empty-string|null $handler + * @param non-empty-string|null $backup + * + * @return CacheInterface + */ + public static function getHandler(Cache $config, ?string $handler = null, ?string $backup = null) + { + if (! isset($config->validHandlers) || $config->validHandlers === []) { + throw CacheException::forInvalidHandlers(); + } + + if (! isset($config->handler) || ! isset($config->backupHandler)) { + throw CacheException::forNoBackup(); + } + + $handler ??= $config->handler; + $backup ??= $config->backupHandler; + + if (! array_key_exists($handler, $config->validHandlers) || ! array_key_exists($backup, $config->validHandlers)) { + throw CacheException::forHandlerNotFound(); + } + + $adapter = new $config->validHandlers[$handler]($config); + + if (! $adapter->isSupported()) { + $adapter = new $config->validHandlers[$backup]($config); + + if (! $adapter->isSupported()) { + // Fall back to the dummy adapter. + $adapter = new $config->validHandlers['dummy'](); + } + } + + // If $adapter->initialization throws a CriticalError exception, we will attempt to + // use the $backup handler, if that also fails, we resort to the dummy handler. + try { + $adapter->initialize(); + } catch (CriticalError $e) { + log_message('critical', $e . ' Resorting to using ' . $backup . ' handler.'); + + // get the next best cache handler (or dummy if the $backup also fails) + $adapter = self::getHandler($config, $backup, 'dummy'); + } + + return $adapter; + } +} diff --git a/system/Cache/CacheInterface.php b/system/Cache/CacheInterface.php new file mode 100644 index 0000000..23ea709 --- /dev/null +++ b/system/Cache/CacheInterface.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache; + +/** + * Cache interface + */ +interface CacheInterface +{ + /** + * Takes care of any handler-specific setup that must be done. + * + * @return void + */ + public function initialize(); + + /** + * Attempts to fetch an item from the cache store. + * + * @param string $key Cache item name + * + * @return array|bool|float|int|object|string|null + */ + public function get(string $key); + + /** + * Saves an item to the cache store. + * + * @param string $key Cache item name + * @param array|bool|float|int|object|string|null $value The data to save + * @param int $ttl Time To Live, in seconds (default 60) + * + * @return bool Success or failure + */ + public function save(string $key, $value, int $ttl = 60); + + /** + * Deletes a specific item from the cache store. + * + * @param string $key Cache item name + * + * @return bool Success or failure + */ + public function delete(string $key); + + /** + * Performs atomic incrementation of a raw stored value. + * + * @param string $key Cache ID + * @param int $offset Step/value to increase by + * + * @return bool|int + */ + public function increment(string $key, int $offset = 1); + + /** + * Performs atomic decrementation of a raw stored value. + * + * @param string $key Cache ID + * @param int $offset Step/value to increase by + * + * @return bool|int + */ + public function decrement(string $key, int $offset = 1); + + /** + * Will delete all items in the entire cache. + * + * @return bool Success or failure + */ + public function clean(); + + /** + * Returns information on the entire cache. + * + * The information returned and the structure of the data + * varies depending on the handler. + * + * @return array|false|object|null + */ + public function getCacheInfo(); + + /** + * Returns detailed information about the specific item in the cache. + * + * @param string $key Cache item name. + * + * @return array|false|null + * Returns null if the item does not exist, otherwise array + * with at least the 'expire' key for absolute epoch expiry (or null). + * Some handlers may return false when an item does not exist, which is deprecated. + */ + public function getMetaData(string $key); + + /** + * Determines if the driver is supported on this system. + */ + public function isSupported(): bool; +} diff --git a/system/Cache/Exceptions/CacheException.php b/system/Cache/Exceptions/CacheException.php new file mode 100644 index 0000000..3445590 --- /dev/null +++ b/system/Cache/Exceptions/CacheException.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Exceptions; + +use CodeIgniter\Exceptions\DebugTraceableTrait; +use CodeIgniter\Exceptions\ExceptionInterface; +use RuntimeException; + +/** + * CacheException + */ +class CacheException extends RuntimeException implements ExceptionInterface +{ + use DebugTraceableTrait; + + /** + * Thrown when handler has no permission to write cache. + * + * @return CacheException + */ + public static function forUnableToWrite(string $path) + { + return new static(lang('Cache.unableToWrite', [$path])); + } + + /** + * Thrown when an unrecognized handler is used. + * + * @return CacheException + */ + public static function forInvalidHandlers() + { + return new static(lang('Cache.invalidHandlers')); + } + + /** + * Thrown when no backup handler is setup in config. + * + * @return CacheException + */ + public static function forNoBackup() + { + return new static(lang('Cache.noBackup')); + } + + /** + * Thrown when specified handler was not found. + * + * @return CacheException + */ + public static function forHandlerNotFound() + { + return new static(lang('Cache.handlerNotFound')); + } +} diff --git a/system/Cache/Exceptions/ExceptionInterface.php b/system/Cache/Exceptions/ExceptionInterface.php new file mode 100644 index 0000000..067e8aa --- /dev/null +++ b/system/Cache/Exceptions/ExceptionInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Exceptions; + +/** + * Provides a domain-level interface for broad capture + * of all framework-related exceptions. + * + * catch (\CodeIgniter\Cache\Exceptions\ExceptionInterface) { ... } + * + * @deprecated 4.1.2 + */ +interface ExceptionInterface +{ +} diff --git a/system/Cache/FactoriesCache.php b/system/Cache/FactoriesCache.php new file mode 100644 index 0000000..d78d0b1 --- /dev/null +++ b/system/Cache/FactoriesCache.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache; + +use CodeIgniter\Cache\FactoriesCache\FileVarExportHandler; +use CodeIgniter\Config\Factories; + +final class FactoriesCache +{ + /** + * @var CacheInterface|FileVarExportHandler + */ + private $cache; + + /** + * @param CacheInterface|FileVarExportHandler|null $cache + */ + public function __construct($cache = null) + { + $this->cache = $cache ?? new FileVarExportHandler(); + } + + public function save(string $component): void + { + if (! Factories::isUpdated($component)) { + return; + } + + $data = Factories::getComponentInstances($component); + + $this->cache->save($this->getCacheKey($component), $data, 3600 * 24); + } + + private function getCacheKey(string $component): string + { + return 'FactoriesCache_' . $component; + } + + public function load(string $component): bool + { + $key = $this->getCacheKey($component); + + if (! $data = $this->cache->get($key)) { + return false; + } + + Factories::setComponentInstances($component, $data); + + return true; + } + + public function delete(string $component): void + { + $this->cache->delete($this->getCacheKey($component)); + } +} diff --git a/system/Cache/FactoriesCache/FileVarExportHandler.php b/system/Cache/FactoriesCache/FileVarExportHandler.php new file mode 100644 index 0000000..f7cee5e --- /dev/null +++ b/system/Cache/FactoriesCache/FileVarExportHandler.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\FactoriesCache; + +final class FileVarExportHandler +{ + private string $path = WRITEPATH . 'cache'; + + /** + * @param array|bool|float|int|object|string|null $val + */ + public function save(string $key, $val): void + { + $val = var_export($val, true); + + // Write to temp file first to ensure atomicity + $tmp = $this->path . "/{$key}." . uniqid('', true) . '.tmp'; + file_put_contents($tmp, 'path . "/{$key}"); + } + + public function delete(string $key): void + { + @unlink($this->path . "/{$key}"); + } + + /** + * @return array|bool|float|int|object|string|null + */ + public function get(string $key) + { + return @include $this->path . "/{$key}"; + } +} diff --git a/system/Cache/Handlers/BaseHandler.php b/system/Cache/Handlers/BaseHandler.php new file mode 100644 index 0000000..061a4a6 --- /dev/null +++ b/system/Cache/Handlers/BaseHandler.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use Closure; +use CodeIgniter\Cache\CacheInterface; +use Config\Cache; +use Exception; +use InvalidArgumentException; + +/** + * Base class for cache handling + * + * @see \CodeIgniter\Cache\Handlers\BaseHandlerTest + */ +abstract class BaseHandler implements CacheInterface +{ + /** + * Reserved characters that cannot be used in a key or tag. May be overridden by the config. + * From https://github.com/symfony/cache-contracts/blob/c0446463729b89dd4fa62e9aeecc80287323615d/ItemInterface.php#L43 + * + * @deprecated in favor of the Cache config + */ + public const RESERVED_CHARACTERS = '{}()/\@:'; + + /** + * Maximum key length. + */ + public const MAX_KEY_LENGTH = PHP_INT_MAX; + + /** + * Prefix to apply to cache keys. + * May not be used by all handlers. + * + * @var string + */ + protected $prefix; + + /** + * Validates a cache key according to PSR-6. + * Keys that exceed MAX_KEY_LENGTH are hashed. + * From https://github.com/symfony/cache/blob/7b024c6726af21fd4984ac8d1eae2b9f3d90de88/CacheItem.php#L158 + * + * @param string $key The key to validate + * @param string $prefix Optional prefix to include in length calculations + * + * @throws InvalidArgumentException When $key is not valid + */ + public static function validateKey($key, $prefix = ''): string + { + if (! is_string($key)) { + throw new InvalidArgumentException('Cache key must be a string'); + } + if ($key === '') { + throw new InvalidArgumentException('Cache key cannot be empty.'); + } + + $reserved = config(Cache::class)->reservedCharacters ?? self::RESERVED_CHARACTERS; + if ($reserved && strpbrk($key, $reserved) !== false) { + throw new InvalidArgumentException('Cache key contains reserved characters ' . $reserved); + } + + // If the key with prefix exceeds the length then return the hashed version + return strlen($prefix . $key) > static::MAX_KEY_LENGTH ? $prefix . md5($key) : $prefix . $key; + } + + /** + * Get an item from the cache, or execute the given Closure and store the result. + * + * @param string $key Cache item name + * @param int $ttl Time to live + * @param Closure(): mixed $callback Callback return value + * + * @return array|bool|float|int|object|string|null + */ + public function remember(string $key, int $ttl, Closure $callback) + { + $value = $this->get($key); + + if ($value !== null) { + return $value; + } + + $this->save($key, $value = $callback(), $ttl); + + return $value; + } + + /** + * Deletes items from the cache store matching a given pattern. + * + * @param string $pattern Cache items glob-style pattern + * + * @return int|never + * + * @throws Exception + */ + public function deleteMatching(string $pattern) + { + throw new Exception('The deleteMatching method is not implemented.'); + } +} diff --git a/system/Cache/Handlers/DummyHandler.php b/system/Cache/Handlers/DummyHandler.php new file mode 100644 index 0000000..d2d4fb0 --- /dev/null +++ b/system/Cache/Handlers/DummyHandler.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use Closure; + +/** + * Dummy cache handler + * + * @see \CodeIgniter\Cache\Handlers\DummyHandlerTest + */ +class DummyHandler extends BaseHandler +{ + /** + * {@inheritDoc} + */ + public function initialize() + { + } + + /** + * {@inheritDoc} + */ + public function get(string $key) + { + return null; + } + + /** + * {@inheritDoc} + */ + public function remember(string $key, int $ttl, Closure $callback) + { + return null; + } + + /** + * {@inheritDoc} + */ + public function save(string $key, $value, int $ttl = 60) + { + return true; + } + + /** + * {@inheritDoc} + */ + public function delete(string $key) + { + return true; + } + + /** + * {@inheritDoc} + * + * @return int + */ + public function deleteMatching(string $pattern) + { + return 0; + } + + /** + * {@inheritDoc} + */ + public function increment(string $key, int $offset = 1) + { + return true; + } + + /** + * {@inheritDoc} + */ + public function decrement(string $key, int $offset = 1) + { + return true; + } + + /** + * {@inheritDoc} + */ + public function clean() + { + return true; + } + + /** + * {@inheritDoc} + */ + public function getCacheInfo() + { + return null; + } + + /** + * {@inheritDoc} + */ + public function getMetaData(string $key) + { + return null; + } + + /** + * {@inheritDoc} + */ + public function isSupported(): bool + { + return true; + } +} diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php new file mode 100644 index 0000000..0a39c36 --- /dev/null +++ b/system/Cache/Handlers/FileHandler.php @@ -0,0 +1,423 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use CodeIgniter\Cache\Exceptions\CacheException; +use CodeIgniter\I18n\Time; +use Config\Cache; +use Throwable; + +/** + * File system cache handler + * + * @see \CodeIgniter\Cache\Handlers\FileHandlerTest + */ +class FileHandler extends BaseHandler +{ + /** + * Maximum key length. + */ + public const MAX_KEY_LENGTH = 255; + + /** + * Where to store cached files on the disk. + * + * @var string + */ + protected $path; + + /** + * Mode for the stored files. + * Must be chmod-safe (octal). + * + * @var int + * + * @see https://www.php.net/manual/en/function.chmod.php + */ + protected $mode; + + /** + * Note: Use `CacheFactory::getHandler()` to instantiate. + * + * @throws CacheException + */ + public function __construct(Cache $config) + { + if (! property_exists($config, 'file')) { + $config->file = [ + 'storePath' => $config->storePath ?? WRITEPATH . 'cache', + 'mode' => 0640, + ]; + } + + $this->path = ! empty($config->file['storePath']) ? $config->file['storePath'] : WRITEPATH . 'cache'; + $this->path = rtrim($this->path, '/') . '/'; + + if (! is_really_writable($this->path)) { + throw CacheException::forUnableToWrite($this->path); + } + + $this->mode = $config->file['mode'] ?? 0640; + $this->prefix = $config->prefix; + } + + /** + * {@inheritDoc} + */ + public function initialize() + { + } + + /** + * {@inheritDoc} + */ + public function get(string $key) + { + $key = static::validateKey($key, $this->prefix); + $data = $this->getItem($key); + + return is_array($data) ? $data['data'] : null; + } + + /** + * {@inheritDoc} + */ + public function save(string $key, $value, int $ttl = 60) + { + $key = static::validateKey($key, $this->prefix); + + $contents = [ + 'time' => Time::now()->getTimestamp(), + 'ttl' => $ttl, + 'data' => $value, + ]; + + if ($this->writeFile($this->path . $key, serialize($contents))) { + try { + chmod($this->path . $key, $this->mode); + + // @codeCoverageIgnoreStart + } catch (Throwable $e) { + log_message('debug', 'Failed to set mode on cache file: ' . $e); + // @codeCoverageIgnoreEnd + } + + return true; + } + + return false; + } + + /** + * {@inheritDoc} + */ + public function delete(string $key) + { + $key = static::validateKey($key, $this->prefix); + + return is_file($this->path . $key) && unlink($this->path . $key); + } + + /** + * {@inheritDoc} + * + * @return int + */ + public function deleteMatching(string $pattern) + { + $deleted = 0; + + foreach (glob($this->path . $pattern, GLOB_NOSORT) as $filename) { + if (is_file($filename) && @unlink($filename)) { + $deleted++; + } + } + + return $deleted; + } + + /** + * {@inheritDoc} + */ + public function increment(string $key, int $offset = 1) + { + $prefixedKey = static::validateKey($key, $this->prefix); + $tmp = $this->getItem($prefixedKey); + + if ($tmp === false) { + $tmp = ['data' => 0, 'ttl' => 60]; + } + + ['data' => $value, 'ttl' => $ttl] = $tmp; + + if (! is_int($value)) { + return false; + } + + $value += $offset; + + return $this->save($key, $value, $ttl) ? $value : false; + } + + /** + * {@inheritDoc} + */ + public function decrement(string $key, int $offset = 1) + { + return $this->increment($key, -$offset); + } + + /** + * {@inheritDoc} + */ + public function clean() + { + return $this->deleteFiles($this->path, false, true); + } + + /** + * {@inheritDoc} + */ + public function getCacheInfo() + { + return $this->getDirFileInfo($this->path); + } + + /** + * {@inheritDoc} + */ + public function getMetaData(string $key) + { + $key = static::validateKey($key, $this->prefix); + + if (false === $data = $this->getItem($key)) { + return false; // @TODO This will return null in a future release + } + + return [ + 'expire' => $data['ttl'] > 0 ? $data['time'] + $data['ttl'] : null, + 'mtime' => filemtime($this->path . $key), + 'data' => $data['data'], + ]; + } + + /** + * {@inheritDoc} + */ + public function isSupported(): bool + { + return is_writable($this->path); + } + + /** + * Does the heavy lifting of actually retrieving the file and + * verifying it's age. + * + * @return array{data: mixed, ttl: int, time: int}|false + */ + protected function getItem(string $filename) + { + if (! is_file($this->path . $filename)) { + return false; + } + + $data = @unserialize(file_get_contents($this->path . $filename)); + + if (! is_array($data)) { + return false; + } + + if (! isset($data['ttl']) || ! is_int($data['ttl'])) { + return false; + } + + if (! isset($data['time']) || ! is_int($data['time'])) { + return false; + } + + if ($data['ttl'] > 0 && Time::now()->getTimestamp() > $data['time'] + $data['ttl']) { + @unlink($this->path . $filename); + + return false; + } + + return $data; + } + + /** + * Writes a file to disk, or returns false if not successful. + * + * @param string $path + * @param string $data + * @param string $mode + * + * @return bool + */ + protected function writeFile($path, $data, $mode = 'wb') + { + if (($fp = @fopen($path, $mode)) === false) { + return false; + } + + flock($fp, LOCK_EX); + + for ($result = $written = 0, $length = strlen($data); $written < $length; $written += $result) { + if (($result = fwrite($fp, substr($data, $written))) === false) { + break; + } + } + + flock($fp, LOCK_UN); + fclose($fp); + + return is_int($result); + } + + /** + * Deletes all files contained in the supplied directory path. + * Files must be writable or owned by the system in order to be deleted. + * If the second parameter is set to TRUE, any directories contained + * within the supplied base directory will be nuked as well. + * + * @param string $path File path + * @param bool $delDir Whether to delete any directories found in the path + * @param bool $htdocs Whether to skip deleting .htaccess and index page files + * @param int $_level Current directory depth level (default: 0; internal use only) + */ + protected function deleteFiles(string $path, bool $delDir = false, bool $htdocs = false, int $_level = 0): bool + { + // Trim the trailing slash + $path = rtrim($path, '/\\'); + + if (! $currentDir = @opendir($path)) { + return false; + } + + while (false !== ($filename = @readdir($currentDir))) { + if ($filename !== '.' && $filename !== '..') { + if (is_dir($path . DIRECTORY_SEPARATOR . $filename) && $filename[0] !== '.') { + $this->deleteFiles($path . DIRECTORY_SEPARATOR . $filename, $delDir, $htdocs, $_level + 1); + } elseif ($htdocs !== true || ! preg_match('/^(\.htaccess|index\.(html|htm|php)|web\.config)$/i', $filename)) { + @unlink($path . DIRECTORY_SEPARATOR . $filename); + } + } + } + + closedir($currentDir); + + return ($delDir === true && $_level > 0) ? @rmdir($path) : true; + } + + /** + * Reads the specified directory and builds an array containing the filenames, + * filesize, dates, and permissions + * + * Any sub-folders contained within the specified path are read as well. + * + * @param string $sourceDir Path to source + * @param bool $topLevelOnly Look only at the top level directory specified? + * @param bool $_recursion Internal variable to determine recursion status - do not use in calls + * + * @return array|false + */ + protected function getDirFileInfo(string $sourceDir, bool $topLevelOnly = true, bool $_recursion = false) + { + static $_filedata = []; + $relativePath = $sourceDir; + + if ($fp = @opendir($sourceDir)) { + // reset the array and make sure $source_dir has a trailing slash on the initial call + if ($_recursion === false) { + $_filedata = []; + $sourceDir = rtrim(realpath($sourceDir) ?: $sourceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + } + + // Used to be foreach (scandir($source_dir, 1) as $file), but scandir() is simply not as fast + while (false !== ($file = readdir($fp))) { + if (is_dir($sourceDir . $file) && $file[0] !== '.' && $topLevelOnly === false) { + $this->getDirFileInfo($sourceDir . $file . DIRECTORY_SEPARATOR, $topLevelOnly, true); + } elseif (! is_dir($sourceDir . $file) && $file[0] !== '.') { + $_filedata[$file] = $this->getFileInfo($sourceDir . $file); + $_filedata[$file]['relative_path'] = $relativePath; + } + } + + closedir($fp); + + return $_filedata; + } + + return false; + } + + /** + * Given a file and path, returns the name, path, size, date modified + * Second parameter allows you to explicitly declare what information you want returned + * Options are: name, server_path, size, date, readable, writable, executable, fileperms + * Returns FALSE if the file cannot be found. + * + * @param string $file Path to file + * @param array|string $returnedValues Array or comma separated string of information returned + * + * @return array|false + */ + protected function getFileInfo(string $file, $returnedValues = ['name', 'server_path', 'size', 'date']) + { + if (! is_file($file)) { + return false; + } + + if (is_string($returnedValues)) { + $returnedValues = explode(',', $returnedValues); + } + + $fileInfo = []; + + foreach ($returnedValues as $key) { + switch ($key) { + case 'name': + $fileInfo['name'] = basename($file); + break; + + case 'server_path': + $fileInfo['server_path'] = $file; + break; + + case 'size': + $fileInfo['size'] = filesize($file); + break; + + case 'date': + $fileInfo['date'] = filemtime($file); + break; + + case 'readable': + $fileInfo['readable'] = is_readable($file); + break; + + case 'writable': + $fileInfo['writable'] = is_writable($file); + break; + + case 'executable': + $fileInfo['executable'] = is_executable($file); + break; + + case 'fileperms': + $fileInfo['fileperms'] = fileperms($file); + break; + } + } + + return $fileInfo; + } +} diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php new file mode 100644 index 0000000..e865890 --- /dev/null +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -0,0 +1,276 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use CodeIgniter\Exceptions\CriticalError; +use CodeIgniter\I18n\Time; +use Config\Cache; +use Exception; +use Memcache; +use Memcached; + +/** + * Mamcached cache handler + * + * @see \CodeIgniter\Cache\Handlers\MemcachedHandlerTest + */ +class MemcachedHandler extends BaseHandler +{ + /** + * The memcached object + * + * @var Memcache|Memcached + */ + protected $memcached; + + /** + * Memcached Configuration + * + * @var array + */ + protected $config = [ + 'host' => '127.0.0.1', + 'port' => 11211, + 'weight' => 1, + 'raw' => false, + ]; + + /** + * Note: Use `CacheFactory::getHandler()` to instantiate. + */ + public function __construct(Cache $config) + { + $this->prefix = $config->prefix; + + $this->config = array_merge($this->config, $config->memcached); + } + + /** + * Closes the connection to Memcache(d) if present. + */ + public function __destruct() + { + if ($this->memcached instanceof Memcached) { + $this->memcached->quit(); + } elseif ($this->memcached instanceof Memcache) { + $this->memcached->close(); + } + } + + /** + * {@inheritDoc} + */ + public function initialize() + { + try { + if (class_exists(Memcached::class)) { + // Create new instance of Memcached + $this->memcached = new Memcached(); + if ($this->config['raw']) { + $this->memcached->setOption(Memcached::OPT_BINARY_PROTOCOL, true); + } + + // Add server + $this->memcached->addServer( + $this->config['host'], + $this->config['port'], + $this->config['weight'] + ); + + // attempt to get status of servers + $stats = $this->memcached->getStats(); + + // $stats should be an associate array with a key in the format of host:port. + // If it doesn't have the key, we know the server is not working as expected. + if (! isset($stats[$this->config['host'] . ':' . $this->config['port']])) { + throw new CriticalError('Cache: Memcached connection failed.'); + } + } elseif (class_exists(Memcache::class)) { + // Create new instance of Memcache + $this->memcached = new Memcache(); + + // Check if we can connect to the server + $canConnect = $this->memcached->connect( + $this->config['host'], + $this->config['port'] + ); + + // If we can't connect, throw a CriticalError exception + if ($canConnect === false) { + throw new CriticalError('Cache: Memcache connection failed.'); + } + + // Add server, third parameter is persistence and defaults to TRUE. + $this->memcached->addServer( + $this->config['host'], + $this->config['port'], + true, + $this->config['weight'] + ); + } else { + throw new CriticalError('Cache: Not support Memcache(d) extension.'); + } + } catch (Exception $e) { + throw new CriticalError('Cache: Memcache(d) connection refused (' . $e->getMessage() . ').'); + } + } + + /** + * {@inheritDoc} + */ + public function get(string $key) + { + $data = []; + $key = static::validateKey($key, $this->prefix); + + if ($this->memcached instanceof Memcached) { + $data = $this->memcached->get($key); + + // check for unmatched key + if ($this->memcached->getResultCode() === Memcached::RES_NOTFOUND) { + return null; + } + } elseif ($this->memcached instanceof Memcache) { + $flags = false; + $data = $this->memcached->get($key, $flags); + + // check for unmatched key (i.e. $flags is untouched) + if ($flags === false) { + return null; + } + } + + return is_array($data) ? $data[0] : $data; + } + + /** + * {@inheritDoc} + */ + public function save(string $key, $value, int $ttl = 60) + { + $key = static::validateKey($key, $this->prefix); + + if (! $this->config['raw']) { + $value = [ + $value, + Time::now()->getTimestamp(), + $ttl, + ]; + } + + if ($this->memcached instanceof Memcached) { + return $this->memcached->set($key, $value, $ttl); + } + + if ($this->memcached instanceof Memcache) { + return $this->memcached->set($key, $value, 0, $ttl); + } + + return false; + } + + /** + * {@inheritDoc} + */ + public function delete(string $key) + { + $key = static::validateKey($key, $this->prefix); + + return $this->memcached->delete($key); + } + + /** + * {@inheritDoc} + * + * @return never + */ + public function deleteMatching(string $pattern) + { + throw new Exception('The deleteMatching method is not implemented for Memcached. You must select File, Redis or Predis handlers to use it.'); + } + + /** + * {@inheritDoc} + */ + public function increment(string $key, int $offset = 1) + { + if (! $this->config['raw']) { + return false; + } + + $key = static::validateKey($key, $this->prefix); + + return $this->memcached->increment($key, $offset, $offset, 60); + } + + /** + * {@inheritDoc} + */ + public function decrement(string $key, int $offset = 1) + { + if (! $this->config['raw']) { + return false; + } + + $key = static::validateKey($key, $this->prefix); + + // FIXME: third parameter isn't other handler actions. + + return $this->memcached->decrement($key, $offset, $offset, 60); + } + + /** + * {@inheritDoc} + */ + public function clean() + { + return $this->memcached->flush(); + } + + /** + * {@inheritDoc} + */ + public function getCacheInfo() + { + return $this->memcached->getStats(); + } + + /** + * {@inheritDoc} + */ + public function getMetaData(string $key) + { + $key = static::validateKey($key, $this->prefix); + $stored = $this->memcached->get($key); + + // if not an array, don't try to count for PHP7.2 + if (! is_array($stored) || count($stored) !== 3) { + return false; // @TODO This will return null in a future release + } + + [$data, $time, $limit] = $stored; + + return [ + 'expire' => $limit > 0 ? $time + $limit : null, + 'mtime' => $time, + 'data' => $data, + ]; + } + + /** + * {@inheritDoc} + */ + public function isSupported(): bool + { + return extension_loaded('memcached') || extension_loaded('memcache'); + } +} diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php new file mode 100644 index 0000000..986d083 --- /dev/null +++ b/system/Cache/Handlers/PredisHandler.php @@ -0,0 +1,235 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use CodeIgniter\Exceptions\CriticalError; +use CodeIgniter\I18n\Time; +use Config\Cache; +use Exception; +use Predis\Client; +use Predis\Collection\Iterator\Keyspace; + +/** + * Predis cache handler + * + * @see \CodeIgniter\Cache\Handlers\PredisHandlerTest + */ +class PredisHandler extends BaseHandler +{ + /** + * Default config + * + * @var array + */ + protected $config = [ + 'scheme' => 'tcp', + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'timeout' => 0, + ]; + + /** + * Predis connection + * + * @var Client + */ + protected $redis; + + /** + * Note: Use `CacheFactory::getHandler()` to instantiate. + */ + public function __construct(Cache $config) + { + $this->prefix = $config->prefix; + + if (isset($config->redis)) { + $this->config = array_merge($this->config, $config->redis); + } + } + + /** + * {@inheritDoc} + */ + public function initialize() + { + try { + $this->redis = new Client($this->config, ['prefix' => $this->prefix]); + $this->redis->time(); + } catch (Exception $e) { + throw new CriticalError('Cache: Predis connection refused (' . $e->getMessage() . ').'); + } + } + + /** + * {@inheritDoc} + */ + public function get(string $key) + { + $key = static::validateKey($key); + + $data = array_combine( + ['__ci_type', '__ci_value'], + $this->redis->hmget($key, ['__ci_type', '__ci_value']) + ); + + if (! isset($data['__ci_type'], $data['__ci_value']) || $data['__ci_value'] === false) { + return null; + } + + switch ($data['__ci_type']) { + case 'array': + case 'object': + return unserialize($data['__ci_value']); + + case 'boolean': + case 'integer': + case 'double': // Yes, 'double' is returned and NOT 'float' + case 'string': + case 'NULL': + return settype($data['__ci_value'], $data['__ci_type']) ? $data['__ci_value'] : null; + + case 'resource': + default: + return null; + } + } + + /** + * {@inheritDoc} + */ + public function save(string $key, $value, int $ttl = 60) + { + $key = static::validateKey($key); + + switch ($dataType = gettype($value)) { + case 'array': + case 'object': + $value = serialize($value); + break; + + case 'boolean': + case 'integer': + case 'double': // Yes, 'double' is returned and NOT 'float' + case 'string': + case 'NULL': + break; + + case 'resource': + default: + return false; + } + + if (! $this->redis->hmset($key, ['__ci_type' => $dataType, '__ci_value' => $value])) { + return false; + } + + if ($ttl !== 0) { + $this->redis->expireat($key, Time::now()->getTimestamp() + $ttl); + } + + return true; + } + + /** + * {@inheritDoc} + */ + public function delete(string $key) + { + $key = static::validateKey($key); + + return $this->redis->del($key) === 1; + } + + /** + * {@inheritDoc} + * + * @return int + */ + public function deleteMatching(string $pattern) + { + $matchedKeys = []; + + foreach (new Keyspace($this->redis, $pattern) as $key) { + $matchedKeys[] = $key; + } + + return $this->redis->del($matchedKeys); + } + + /** + * {@inheritDoc} + */ + public function increment(string $key, int $offset = 1) + { + $key = static::validateKey($key); + + return $this->redis->hincrby($key, 'data', $offset); + } + + /** + * {@inheritDoc} + */ + public function decrement(string $key, int $offset = 1) + { + $key = static::validateKey($key); + + return $this->redis->hincrby($key, 'data', -$offset); + } + + /** + * {@inheritDoc} + */ + public function clean() + { + return $this->redis->flushdb()->getPayload() === 'OK'; + } + + /** + * {@inheritDoc} + */ + public function getCacheInfo() + { + return $this->redis->info(); + } + + /** + * {@inheritDoc} + */ + public function getMetaData(string $key) + { + $key = static::validateKey($key); + + $data = array_combine(['__ci_value'], $this->redis->hmget($key, ['__ci_value'])); + + if (isset($data['__ci_value']) && $data['__ci_value'] !== false) { + $time = Time::now()->getTimestamp(); + $ttl = $this->redis->ttl($key); + + return [ + 'expire' => $ttl > 0 ? $time + $ttl : null, + 'mtime' => $time, + 'data' => $data['__ci_value'], + ]; + } + + return null; + } + + /** + * {@inheritDoc} + */ + public function isSupported(): bool + { + return class_exists(Client::class); + } +} diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php new file mode 100644 index 0000000..953de2d --- /dev/null +++ b/system/Cache/Handlers/RedisHandler.php @@ -0,0 +1,267 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use CodeIgniter\Exceptions\CriticalError; +use CodeIgniter\I18n\Time; +use Config\Cache; +use Redis; +use RedisException; + +/** + * Redis cache handler + * + * @see \CodeIgniter\Cache\Handlers\RedisHandlerTest + */ +class RedisHandler extends BaseHandler +{ + /** + * Default config + * + * @var array + */ + protected $config = [ + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'timeout' => 0, + 'database' => 0, + ]; + + /** + * Redis connection + * + * @var Redis|null + */ + protected $redis; + + /** + * Note: Use `CacheFactory::getHandler()` to instantiate. + */ + public function __construct(Cache $config) + { + $this->prefix = $config->prefix; + + $this->config = array_merge($this->config, $config->redis); + } + + /** + * Closes the connection to Redis if present. + */ + public function __destruct() + { + if (isset($this->redis)) { + $this->redis->close(); + } + } + + /** + * {@inheritDoc} + */ + public function initialize() + { + $config = $this->config; + + $this->redis = new Redis(); + + try { + // Note:: If Redis is your primary cache choice, and it is "offline", every page load will end up been delayed by the timeout duration. + // I feel like some sort of temporary flag should be set, to indicate that we think Redis is "offline", allowing us to bypass the timeout for a set period of time. + + if (! $this->redis->connect($config['host'], ($config['host'][0] === '/' ? 0 : $config['port']), $config['timeout'])) { + // Note:: I'm unsure if log_message() is necessary, however I'm not 100% comfortable removing it. + log_message('error', 'Cache: Redis connection failed. Check your configuration.'); + + throw new CriticalError('Cache: Redis connection failed. Check your configuration.'); + } + + if (isset($config['password']) && ! $this->redis->auth($config['password'])) { + log_message('error', 'Cache: Redis authentication failed.'); + + throw new CriticalError('Cache: Redis authentication failed.'); + } + + if (isset($config['database']) && ! $this->redis->select($config['database'])) { + log_message('error', 'Cache: Redis select database failed.'); + + throw new CriticalError('Cache: Redis select database failed.'); + } + } catch (RedisException $e) { + throw new CriticalError('Cache: RedisException occurred with message (' . $e->getMessage() . ').'); + } + } + + /** + * {@inheritDoc} + */ + public function get(string $key) + { + $key = static::validateKey($key, $this->prefix); + $data = $this->redis->hMGet($key, ['__ci_type', '__ci_value']); + + if (! isset($data['__ci_type'], $data['__ci_value']) || $data['__ci_value'] === false) { + return null; + } + + switch ($data['__ci_type']) { + case 'array': + case 'object': + return unserialize($data['__ci_value']); + + case 'boolean': + case 'integer': + case 'double': // Yes, 'double' is returned and NOT 'float' + case 'string': + case 'NULL': + return settype($data['__ci_value'], $data['__ci_type']) ? $data['__ci_value'] : null; + + case 'resource': + default: + return null; + } + } + + /** + * {@inheritDoc} + */ + public function save(string $key, $value, int $ttl = 60) + { + $key = static::validateKey($key, $this->prefix); + + switch ($dataType = gettype($value)) { + case 'array': + case 'object': + $value = serialize($value); + break; + + case 'boolean': + case 'integer': + case 'double': // Yes, 'double' is returned and NOT 'float' + case 'string': + case 'NULL': + break; + + case 'resource': + default: + return false; + } + + if (! $this->redis->hMSet($key, ['__ci_type' => $dataType, '__ci_value' => $value])) { + return false; + } + + if ($ttl !== 0) { + $this->redis->expireAt($key, Time::now()->getTimestamp() + $ttl); + } + + return true; + } + + /** + * {@inheritDoc} + */ + public function delete(string $key) + { + $key = static::validateKey($key, $this->prefix); + + return $this->redis->del($key) === 1; + } + + /** + * {@inheritDoc} + * + * @return int + */ + public function deleteMatching(string $pattern) + { + $matchedKeys = []; + $iterator = null; + + do { + // Scan for some keys + $keys = $this->redis->scan($iterator, $pattern); + + // Redis may return empty results, so protect against that + if ($keys !== false) { + foreach ($keys as $key) { + $matchedKeys[] = $key; + } + } + } while ($iterator > 0); + + return $this->redis->del($matchedKeys); + } + + /** + * {@inheritDoc} + */ + public function increment(string $key, int $offset = 1) + { + $key = static::validateKey($key, $this->prefix); + + return $this->redis->hIncrBy($key, '__ci_value', $offset); + } + + /** + * {@inheritDoc} + */ + public function decrement(string $key, int $offset = 1) + { + return $this->increment($key, -$offset); + } + + /** + * {@inheritDoc} + */ + public function clean() + { + return $this->redis->flushDB(); + } + + /** + * {@inheritDoc} + */ + public function getCacheInfo() + { + return $this->redis->info(); + } + + /** + * {@inheritDoc} + */ + public function getMetaData(string $key) + { + $value = $this->get($key); + + if ($value !== null) { + $time = Time::now()->getTimestamp(); + $ttl = $this->redis->ttl(static::validateKey($key, $this->prefix)); + assert(is_int($ttl)); + + return [ + 'expire' => $ttl > 0 ? $time + $ttl : null, + 'mtime' => $time, + 'data' => $value, + ]; + } + + return null; + } + + /** + * {@inheritDoc} + */ + public function isSupported(): bool + { + return extension_loaded('redis'); + } +} diff --git a/system/Cache/Handlers/WincacheHandler.php b/system/Cache/Handlers/WincacheHandler.php new file mode 100644 index 0000000..b1ea45d --- /dev/null +++ b/system/Cache/Handlers/WincacheHandler.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use CodeIgniter\I18n\Time; +use Config\Cache; +use Exception; + +/** + * Cache handler for WinCache from Microsoft & IIS. + * + * @codeCoverageIgnore + */ +class WincacheHandler extends BaseHandler +{ + /** + * Note: Use `CacheFactory::getHandler()` to instantiate. + */ + public function __construct(Cache $config) + { + $this->prefix = $config->prefix; + } + + /** + * {@inheritDoc} + */ + public function initialize() + { + } + + /** + * {@inheritDoc} + */ + public function get(string $key) + { + $key = static::validateKey($key, $this->prefix); + $success = false; + + $data = wincache_ucache_get($key, $success); + + // Success returned by reference from wincache_ucache_get() + return $success ? $data : null; + } + + /** + * {@inheritDoc} + */ + public function save(string $key, $value, int $ttl = 60) + { + $key = static::validateKey($key, $this->prefix); + + return wincache_ucache_set($key, $value, $ttl); + } + + /** + * {@inheritDoc} + */ + public function delete(string $key) + { + $key = static::validateKey($key, $this->prefix); + + return wincache_ucache_delete($key); + } + + /** + * {@inheritDoc} + * + * @return never + */ + public function deleteMatching(string $pattern) + { + throw new Exception('The deleteMatching method is not implemented for Wincache. You must select File, Redis or Predis handlers to use it.'); + } + + /** + * {@inheritDoc} + */ + public function increment(string $key, int $offset = 1) + { + $key = static::validateKey($key, $this->prefix); + + return wincache_ucache_inc($key, $offset); + } + + /** + * {@inheritDoc} + */ + public function decrement(string $key, int $offset = 1) + { + $key = static::validateKey($key, $this->prefix); + + return wincache_ucache_dec($key, $offset); + } + + /** + * {@inheritDoc} + */ + public function clean() + { + return wincache_ucache_clear(); + } + + /** + * {@inheritDoc} + */ + public function getCacheInfo() + { + return wincache_ucache_info(true); + } + + /** + * {@inheritDoc} + */ + public function getMetaData(string $key) + { + $key = static::validateKey($key, $this->prefix); + + if ($stored = wincache_ucache_info(false, $key)) { + $age = $stored['ucache_entries'][1]['age_seconds']; + $ttl = $stored['ucache_entries'][1]['ttl_seconds']; + $hitcount = $stored['ucache_entries'][1]['hitcount']; + + return [ + 'expire' => $ttl > 0 ? Time::now()->getTimestamp() + $ttl : null, + 'hitcount' => $hitcount, + 'age' => $age, + 'ttl' => $ttl, + ]; + } + + return false; // @TODO This will return null in a future release + } + + /** + * {@inheritDoc} + */ + public function isSupported(): bool + { + return extension_loaded('wincache') && ini_get('wincache.ucenabled'); + } +} diff --git a/system/Cache/ResponseCache.php b/system/Cache/ResponseCache.php new file mode 100644 index 0000000..3f66284 --- /dev/null +++ b/system/Cache/ResponseCache.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache; + +use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Cache as CacheConfig; +use Exception; + +/** + * Web Page Caching + * + * @see \CodeIgniter\Cache\ResponseCacheTest + */ +final class ResponseCache +{ + /** + * Whether to take the URL query string into consideration when generating + * output cache files. Valid options are: + * + * false = Disabled + * true = Enabled, take all query parameters into account. + * Please be aware that this may result in numerous cache + * files generated for the same page over and over again. + * array('q') = Enabled, but only take into account the specified list + * of query parameters. + * + * @var bool|string[] + */ + private $cacheQueryString = false; + + /** + * Cache time to live. + * + * @var int seconds + */ + private int $ttl = 0; + + private CacheInterface $cache; + + public function __construct(CacheConfig $config, CacheInterface $cache) + { + $this->cacheQueryString = $config->cacheQueryString; + $this->cache = $cache; + } + + /** + * @return $this + */ + public function setTtl(int $ttl) + { + $this->ttl = $ttl; + + return $this; + } + + /** + * Generates the cache key to use from the current request. + * + * @param CLIRequest|IncomingRequest $request + * + * @internal for testing purposes only + */ + public function generateCacheKey($request): string + { + if ($request instanceof CLIRequest) { + return md5($request->getPath()); + } + + $uri = clone $request->getUri(); + + $query = $this->cacheQueryString + ? $uri->getQuery(is_array($this->cacheQueryString) ? ['only' => $this->cacheQueryString] : []) + : ''; + + return md5($uri->setFragment('')->setQuery($query)); + } + + /** + * Caches the response. + * + * @param CLIRequest|IncomingRequest $request + */ + public function make($request, ResponseInterface $response): bool + { + if ($this->ttl === 0) { + return true; + } + + $headers = []; + + foreach ($response->headers() as $header) { + $headers[$header->getName()] = $header->getValueLine(); + } + + return $this->cache->save( + $this->generateCacheKey($request), + serialize(['headers' => $headers, 'output' => $response->getBody()]), + $this->ttl + ); + } + + /** + * Gets the cached response for the request. + * + * @param CLIRequest|IncomingRequest $request + */ + public function get($request, ResponseInterface $response): ?ResponseInterface + { + if ($cachedResponse = $this->cache->get($this->generateCacheKey($request))) { + $cachedResponse = unserialize($cachedResponse); + + if ( + ! is_array($cachedResponse) + || ! isset($cachedResponse['output']) + || ! isset($cachedResponse['headers']) + ) { + throw new Exception('Error unserializing page cache'); + } + + $headers = $cachedResponse['headers']; + $output = $cachedResponse['output']; + + // Clear all default headers + foreach (array_keys($response->headers()) as $key) { + $response->removeHeader($key); + } + + // Set cached headers + foreach ($headers as $name => $value) { + $response->setHeader($name, $value); + } + + $response->setBody($output); + + return $response; + } + + return null; + } +} diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php new file mode 100644 index 0000000..46eb419 --- /dev/null +++ b/system/CodeIgniter.php @@ -0,0 +1,1163 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter; + +use Closure; +use CodeIgniter\Cache\ResponseCache; +use CodeIgniter\Debug\Timer; +use CodeIgniter\Events\Events; +use CodeIgniter\Exceptions\FrameworkException; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\DownloadResponse; +use CodeIgniter\HTTP\Exceptions\RedirectException; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\HTTP\Request; +use CodeIgniter\HTTP\ResponsableInterface; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\URI; +use CodeIgniter\Router\Exceptions\RedirectException as DeprecatedRedirectException; +use CodeIgniter\Router\RouteCollectionInterface; +use CodeIgniter\Router\Router; +use Config\App; +use Config\Cache; +use Config\Feature; +use Config\Kint as KintConfig; +use Config\Services; +use Exception; +use Kint; +use Kint\Renderer\CliRenderer; +use Kint\Renderer\RichRenderer; +use Locale; +use LogicException; +use Throwable; + +/** + * This class is the core of the framework, and will analyse the + * request, route it to a controller, and send back the response. + * Of course, there are variations to that flow, but this is the brains. + * + * @see \CodeIgniter\CodeIgniterTest + */ +class CodeIgniter +{ + /** + * The current version of CodeIgniter Framework + */ + public const CI_VERSION = '4.4.5'; + + /** + * App startup time. + * + * @var float|null + */ + protected $startTime; + + /** + * Total app execution time + * + * @var float + */ + protected $totalTime; + + /** + * Main application configuration + * + * @var App + */ + protected $config; + + /** + * Timer instance. + * + * @var Timer + */ + protected $benchmark; + + /** + * Current request. + * + * @var CLIRequest|IncomingRequest|null + */ + protected $request; + + /** + * Current response. + * + * @var ResponseInterface + */ + protected $response; + + /** + * Router to use. + * + * @var Router + */ + protected $router; + + /** + * Controller to use. + * + * @var (Closure(mixed...): ResponseInterface|string)|string|null + */ + protected $controller; + + /** + * Controller method to invoke. + * + * @var string + */ + protected $method; + + /** + * Output handler to use. + * + * @var string + */ + protected $output; + + /** + * Cache expiration time + * + * @var int seconds + * + * @deprecated 4.4.0 Moved to ResponseCache::$ttl. No longer used. + */ + protected static $cacheTTL = 0; + + /** + * Request path to use. + * + * @var string|null + * + * @deprecated No longer used. + */ + protected $path; + + /** + * Should the Response instance "pretend" + * to keep from setting headers/cookies/etc + * + * @var bool + * + * @deprecated No longer used. + */ + protected $useSafeOutput = false; + + /** + * Context + * web: Invoked by HTTP request + * php-cli: Invoked by CLI via `php public/index.php` + * + * @phpstan-var 'php-cli'|'web' + */ + protected ?string $context = null; + + /** + * Whether to enable Control Filters. + */ + protected bool $enableFilters = true; + + /** + * Whether to return Response object or send response. + * + * @deprecated No longer used. + */ + protected bool $returnResponse = false; + + /** + * Application output buffering level + */ + protected int $bufferLevel; + + /** + * Web Page Caching + */ + protected ResponseCache $pageCache; + + /** + * Constructor. + */ + public function __construct(App $config) + { + $this->startTime = microtime(true); + $this->config = $config; + + $this->pageCache = Services::responsecache(); + } + + /** + * Handles some basic app and environment setup. + * + * @return void + */ + public function initialize() + { + // Define environment variables + $this->bootstrapEnvironment(); + + // Setup Exception Handling + Services::exceptions()->initialize(); + + // Run this check for manual installations + if (! is_file(COMPOSER_PATH)) { + $this->resolvePlatformExtensions(); // @codeCoverageIgnore + } + + // Set default locale on the server + Locale::setDefault($this->config->defaultLocale ?? 'en'); + + // Set default timezone on the server + date_default_timezone_set($this->config->appTimezone ?? 'UTC'); + + $this->initializeKint(); + } + + /** + * Checks system for missing required PHP extensions. + * + * @return void + * + * @throws FrameworkException + * + * @codeCoverageIgnore + */ + protected function resolvePlatformExtensions() + { + $requiredExtensions = [ + 'intl', + 'json', + 'mbstring', + ]; + + $missingExtensions = []; + + foreach ($requiredExtensions as $extension) { + if (! extension_loaded($extension)) { + $missingExtensions[] = $extension; + } + } + + if ($missingExtensions !== []) { + throw FrameworkException::forMissingExtension(implode(', ', $missingExtensions)); + } + } + + /** + * Initializes Kint + * + * @return void + */ + protected function initializeKint() + { + if (CI_DEBUG) { + $this->autoloadKint(); + $this->configureKint(); + } elseif (class_exists(Kint::class)) { + // In case that Kint is already loaded via Composer. + Kint::$enabled_mode = false; + // @codeCoverageIgnore + } + + helper('kint'); + } + + private function autoloadKint(): void + { + // If we have KINT_DIR it means it's already loaded via composer + if (! defined('KINT_DIR')) { + spl_autoload_register(function ($class) { + $class = explode('\\', $class); + + if (array_shift($class) !== 'Kint') { + return; + } + + $file = SYSTEMPATH . 'ThirdParty/Kint/' . implode('/', $class) . '.php'; + + if (is_file($file)) { + require_once $file; + } + }); + + require_once SYSTEMPATH . 'ThirdParty/Kint/init.php'; + } + } + + private function configureKint(): void + { + $config = config(KintConfig::class); + + Kint::$depth_limit = $config->maxDepth; + Kint::$display_called_from = $config->displayCalledFrom; + Kint::$expanded = $config->expanded; + + if (isset($config->plugins) && is_array($config->plugins)) { + Kint::$plugins = $config->plugins; + } + + $csp = Services::csp(); + if ($csp->enabled()) { + RichRenderer::$js_nonce = $csp->getScriptNonce(); + RichRenderer::$css_nonce = $csp->getStyleNonce(); + } + + RichRenderer::$theme = $config->richTheme; + RichRenderer::$folder = $config->richFolder; + RichRenderer::$sort = $config->richSort; + if (isset($config->richObjectPlugins) && is_array($config->richObjectPlugins)) { + RichRenderer::$value_plugins = $config->richObjectPlugins; + } + if (isset($config->richTabPlugins) && is_array($config->richTabPlugins)) { + RichRenderer::$tab_plugins = $config->richTabPlugins; + } + + CliRenderer::$cli_colors = $config->cliColors; + CliRenderer::$force_utf8 = $config->cliForceUTF8; + CliRenderer::$detect_width = $config->cliDetectWidth; + CliRenderer::$min_terminal_width = $config->cliMinWidth; + } + + /** + * Launch the application! + * + * This is "the loop" if you will. The main entry point into the script + * that gets the required class instances, fires off the filters, + * tries to route the response, loads the controller and generally + * makes all the pieces work together. + * + * @return ResponseInterface|void + */ + public function run(?RouteCollectionInterface $routes = null, bool $returnResponse = false) + { + if ($this->context === null) { + throw new LogicException( + 'Context must be set before run() is called. If you are upgrading from 4.1.x, ' + . 'you need to merge `public/index.php` and `spark` file from `vendor/codeigniter4/framework`.' + ); + } + + $this->pageCache->setTtl(0); + $this->bufferLevel = ob_get_level(); + + $this->startBenchmark(); + + $this->getRequestObject(); + $this->getResponseObject(); + + $this->spoofRequestMethod(); + + try { + $this->response = $this->handleRequest($routes, config(Cache::class), $returnResponse); + } catch (ResponsableInterface|DeprecatedRedirectException $e) { + $this->outputBufferingEnd(); + if ($e instanceof DeprecatedRedirectException) { + $e = new RedirectException($e->getMessage(), $e->getCode(), $e); + } + + $this->response = $e->getResponse(); + } catch (PageNotFoundException $e) { + $this->response = $this->display404errors($e); + } catch (Throwable $e) { + $this->outputBufferingEnd(); + + throw $e; + } + + if ($returnResponse) { + return $this->response; + } + + $this->sendResponse(); + } + + /** + * Set our Response instance to "pretend" mode so that things like + * cookies and headers are not actually sent, allowing PHP 7.2+ to + * not complain when ini_set() function is used. + * + * @return $this + * + * @deprecated No longer used. + */ + public function useSafeOutput(bool $safe = true) + { + $this->useSafeOutput = $safe; + + return $this; + } + + /** + * Invoked via php-cli command? + */ + private function isPhpCli(): bool + { + return $this->context === 'php-cli'; + } + + /** + * Web access? + */ + private function isWeb(): bool + { + return $this->context === 'web'; + } + + /** + * Disables Controller Filters. + */ + public function disableFilters(): void + { + $this->enableFilters = false; + } + + /** + * Handles the main request logic and fires the controller. + * + * @return ResponseInterface + * + * @throws PageNotFoundException + * @throws RedirectException + * + * @deprecated $returnResponse is deprecated. + */ + protected function handleRequest(?RouteCollectionInterface $routes, Cache $cacheConfig, bool $returnResponse = false) + { + $this->forceSecureAccess(); + + if ($this->request instanceof IncomingRequest && strtolower($this->request->getMethod()) === 'cli') { + return $this->response->setStatusCode(405)->setBody('Method Not Allowed'); + } + + Events::trigger('pre_system'); + + // Check for a cached page. Execution will stop + // if the page has been cached. + if (($response = $this->displayCache($cacheConfig)) instanceof ResponseInterface) { + return $response; + } + + $routeFilter = $this->tryToRouteIt($routes); + + $uri = $this->determinePath(); + + if ($this->enableFilters) { + // Start up the filters + $filters = Services::filters(); + + // If any filters were specified within the routes file, + // we need to ensure it's active for the current request + if ($routeFilter !== null) { + $multipleFiltersEnabled = config(Feature::class)->multipleFilters ?? false; + if ($multipleFiltersEnabled) { + $filters->enableFilters($routeFilter, 'before'); + $filters->enableFilters($routeFilter, 'after'); + } else { + // for backward compatibility + $filters->enableFilter($routeFilter, 'before'); + $filters->enableFilter($routeFilter, 'after'); + } + } + + // Run "before" filters + $this->benchmark->start('before_filters'); + $possibleResponse = $filters->run($uri, 'before'); + $this->benchmark->stop('before_filters'); + + // If a ResponseInterface instance is returned then send it back to the client and stop + if ($possibleResponse instanceof ResponseInterface) { + $this->outputBufferingEnd(); + + return $possibleResponse; + } + + if ($possibleResponse instanceof IncomingRequest || $possibleResponse instanceof CLIRequest) { + $this->request = $possibleResponse; + } + } + + $returned = $this->startController(); + + // Closure controller has run in startController(). + if (! is_callable($this->controller)) { + $controller = $this->createController(); + + if (! method_exists($controller, '_remap') && ! is_callable([$controller, $this->method], false)) { + throw PageNotFoundException::forMethodNotFound($this->method); + } + + // Is there a "post_controller_constructor" event? + Events::trigger('post_controller_constructor'); + + $returned = $this->runController($controller); + } else { + $this->benchmark->stop('controller_constructor'); + $this->benchmark->stop('controller'); + } + + // If $returned is a string, then the controller output something, + // probably a view, instead of echoing it directly. Send it along + // so it can be used with the output. + $this->gatherOutput($cacheConfig, $returned); + + if ($this->enableFilters) { + $filters = Services::filters(); + $filters->setResponse($this->response); + + // After filter debug toolbar requires 'total_execution'. + $this->totalTime = $this->benchmark->getElapsedTime('total_execution'); + + // Run "after" filters + $this->benchmark->start('after_filters'); + $response = $filters->run($uri, 'after'); + $this->benchmark->stop('after_filters'); + + if ($response instanceof ResponseInterface) { + $this->response = $response; + } + } + + // Skip unnecessary processing for special Responses. + if ( + ! $this->response instanceof DownloadResponse + && ! $this->response instanceof RedirectResponse + ) { + // Cache it without the performance metrics replaced + // so that we can have live speed updates along the way. + // Must be run after filters to preserve the Response headers. + $this->pageCache->make($this->request, $this->response); + + // Update the performance metrics + $body = $this->response->getBody(); + if ($body !== null) { + $output = $this->displayPerformanceMetrics($body); + $this->response->setBody($output); + } + + // Save our current URI as the previous URI in the session + // for safer, more accurate use with `previous_url()` helper function. + $this->storePreviousURL(current_url(true)); + } + + unset($uri); + + // Is there a post-system event? + Events::trigger('post_system'); + + return $this->response; + } + + /** + * You can load different configurations depending on your + * current environment. Setting the environment also influences + * things like logging and error reporting. + * + * This can be set to anything, but default usage is: + * + * development + * testing + * production + * + * @codeCoverageIgnore + * + * @return void + * + * @deprecated 4.4.0 No longer used. Moved to index.php and spark. + */ + protected function detectEnvironment() + { + // Make sure ENVIRONMENT isn't already set by other means. + if (! defined('ENVIRONMENT')) { + define('ENVIRONMENT', env('CI_ENVIRONMENT', 'production')); + } + } + + /** + * Load any custom boot files based upon the current environment. + * + * If no boot file exists, we shouldn't continue because something + * is wrong. At the very least, they should have error reporting setup. + * + * @return void + */ + protected function bootstrapEnvironment() + { + if (is_file(APPPATH . 'Config/Boot/' . ENVIRONMENT . '.php')) { + require_once APPPATH . 'Config/Boot/' . ENVIRONMENT . '.php'; + } else { + // @codeCoverageIgnoreStart + header('HTTP/1.1 503 Service Unavailable.', true, 503); + echo 'The application environment is not set correctly.'; + + exit(EXIT_ERROR); // EXIT_ERROR + // @codeCoverageIgnoreEnd + } + } + + /** + * Start the Benchmark + * + * The timer is used to display total script execution both in the + * debug toolbar, and potentially on the displayed page. + * + * @return void + */ + protected function startBenchmark() + { + if ($this->startTime === null) { + $this->startTime = microtime(true); + } + + $this->benchmark = Services::timer(); + $this->benchmark->start('total_execution', $this->startTime); + $this->benchmark->start('bootstrap'); + } + + /** + * Sets a Request object to be used for this request. + * Used when running certain tests. + * + * @param CLIRequest|IncomingRequest $request + * + * @return $this + */ + public function setRequest($request) + { + $this->request = $request; + + return $this; + } + + /** + * Get our Request object, (either IncomingRequest or CLIRequest). + * + * @return void + */ + protected function getRequestObject() + { + if ($this->request instanceof Request) { + return; + } + + if ($this->isPhpCli()) { + Services::createRequest($this->config, true); + } else { + Services::createRequest($this->config); + } + + $this->request = Services::request(); + } + + /** + * Get our Response object, and set some default values, including + * the HTTP protocol version and a default successful response. + * + * @return void + */ + protected function getResponseObject() + { + $this->response = Services::response($this->config); + + if ($this->isWeb()) { + $this->response->setProtocolVersion($this->request->getProtocolVersion()); + } + + // Assume success until proven otherwise. + $this->response->setStatusCode(200); + } + + /** + * Force Secure Site Access? If the config value 'forceGlobalSecureRequests' + * is true, will enforce that all requests to this site are made through + * HTTPS. Will redirect the user to the current page with HTTPS, as well + * as set the HTTP Strict Transport Security header for those browsers + * that support it. + * + * @param int $duration How long the Strict Transport Security + * should be enforced for this URL. + * + * @return void + */ + protected function forceSecureAccess($duration = 31_536_000) + { + if ($this->config->forceGlobalSecureRequests !== true) { + return; + } + + force_https($duration, $this->request, $this->response); + } + + /** + * Determines if a response has been cached for the given URI. + * + * @return false|ResponseInterface + * + * @throws Exception + * + * @deprecated 4.4.2 The parameter $config is deprecated. No longer used. + */ + public function displayCache(Cache $config) + { + $cachedResponse = $this->pageCache->get($this->request, $this->response); + if ($cachedResponse instanceof ResponseInterface) { + $this->response = $cachedResponse; + + $this->totalTime = $this->benchmark->getElapsedTime('total_execution'); + $output = $this->displayPerformanceMetrics($cachedResponse->getBody()); + $this->response->setBody($output); + + return $this->response; + } + + return false; + } + + /** + * Tells the app that the final output should be cached. + * + * @deprecated 4.4.0 Moved to ResponseCache::setTtl(). No longer used. + * + * @return void + */ + public static function cache(int $time) + { + static::$cacheTTL = $time; + } + + /** + * Caches the full response from the current request. Used for + * full-page caching for very high performance. + * + * @return bool + * + * @deprecated 4.4.0 No longer used. + */ + public function cachePage(Cache $config) + { + $headers = []; + + foreach ($this->response->headers() as $header) { + $headers[$header->getName()] = $header->getValueLine(); + } + + return cache()->save($this->generateCacheName($config), serialize(['headers' => $headers, 'output' => $this->output]), static::$cacheTTL); + } + + /** + * Returns an array with our basic performance stats collected. + */ + public function getPerformanceStats(): array + { + return [ + 'startTime' => $this->startTime, + 'totalTime' => $this->totalTime, + ]; + } + + /** + * Generates the cache name to use for our full-page caching. + * + * @deprecated 4.4.0 No longer used. + */ + protected function generateCacheName(Cache $config): string + { + if ($this->request instanceof CLIRequest) { + return md5($this->request->getPath()); + } + + $uri = clone $this->request->getUri(); + + $query = $config->cacheQueryString + ? $uri->getQuery(is_array($config->cacheQueryString) ? ['only' => $config->cacheQueryString] : []) + : ''; + + return md5($uri->setFragment('')->setQuery($query)); + } + + /** + * Replaces the elapsed_time tag. + */ + public function displayPerformanceMetrics(string $output): string + { + return str_replace('{elapsed_time}', (string) $this->totalTime, $output); + } + + /** + * Try to Route It - As it sounds like, works with the router to + * match a route against the current URI. If the route is a + * "redirect route", will also handle the redirect. + * + * @param RouteCollectionInterface|null $routes A collection interface to use in place + * of the config file. + * + * @return string|string[]|null Route filters, that is, the filters specified in the routes file + * + * @throws RedirectException + */ + protected function tryToRouteIt(?RouteCollectionInterface $routes = null) + { + if ($routes === null) { + $routes = Services::routes()->loadRoutes(); + } + + // $routes is defined in Config/Routes.php + $this->router = Services::router($routes, $this->request); + + $path = $this->determinePath(); + + $this->benchmark->stop('bootstrap'); + $this->benchmark->start('routing'); + + $this->outputBufferingStart(); + + $this->controller = $this->router->handle($path); + $this->method = $this->router->methodName(); + + // If a {locale} segment was matched in the final route, + // then we need to set the correct locale on our Request. + if ($this->router->hasLocale()) { + $this->request->setLocale($this->router->getLocale()); + } + + $this->benchmark->stop('routing'); + + // for backward compatibility + $multipleFiltersEnabled = config(Feature::class)->multipleFilters ?? false; + if (! $multipleFiltersEnabled) { + return $this->router->getFilter(); + } + + return $this->router->getFilters(); + } + + /** + * Determines the path to use for us to try to route to, based + * on the CLI/IncomingRequest path. + * + * @return string + */ + protected function determinePath() + { + return $this->path ?? + (method_exists($this->request, 'getPath') + ? $this->request->getPath() + : $this->request->getUri()->getPath()); + } + + /** + * Allows the request path to be set from outside the class, + * instead of relying on CLIRequest or IncomingRequest for the path. + * + * This is not used now. + * + * @return $this + * + * @deprecated No longer used. + */ + public function setPath(string $path) + { + $this->path = $path; + + return $this; + } + + /** + * Now that everything has been setup, this method attempts to run the + * controller method and make the script go. If it's not able to, will + * show the appropriate Page Not Found error. + * + * @return ResponseInterface|string|void + */ + protected function startController() + { + $this->benchmark->start('controller'); + $this->benchmark->start('controller_constructor'); + + // Is it routed to a Closure? + if (is_object($this->controller) && (get_class($this->controller) === 'Closure')) { + $controller = $this->controller; + + return $controller(...$this->router->params()); + } + + // No controller specified - we don't know what to do now. + if (! isset($this->controller)) { + throw PageNotFoundException::forEmptyController(); + } + + // Try to autoload the class + if (! class_exists($this->controller, true) || $this->method[0] === '_') { + throw PageNotFoundException::forControllerNotFound($this->controller, $this->method); + } + } + + /** + * Instantiates the controller class. + * + * @return Controller + */ + protected function createController() + { + assert(is_string($this->controller)); + + $class = new $this->controller(); + $class->initController($this->request, $this->response, Services::logger()); + + $this->benchmark->stop('controller_constructor'); + + return $class; + } + + /** + * Runs the controller, allowing for _remap methods to function. + * + * CI4 supports three types of requests: + * 1. Web: URI segments become parameters, sent to Controllers via Routes, + * output controlled by Headers to browser + * 2. PHP CLI: accessed by CLI via php public/index.php, arguments become URI segments, + * sent to Controllers via Routes, output varies + * + * @param Controller $class + * + * @return false|ResponseInterface|string|void + */ + protected function runController($class) + { + // This is a Web request or PHP CLI request + $params = $this->router->params(); + + $output = method_exists($class, '_remap') + ? $class->_remap($this->method, ...$params) + : $class->{$this->method}(...$params); + + $this->benchmark->stop('controller'); + + return $output; + } + + /** + * Displays a 404 Page Not Found error. If set, will try to + * call the 404Override controller/method that was set in routing config. + * + * @return ResponseInterface|void + */ + protected function display404errors(PageNotFoundException $e) + { + // Is there a 404 Override available? + if ($override = $this->router->get404Override()) { + $returned = null; + + if ($override instanceof Closure) { + echo $override($e->getMessage()); + } elseif (is_array($override)) { + $this->benchmark->start('controller'); + $this->benchmark->start('controller_constructor'); + + $this->controller = $override[0]; + $this->method = $override[1]; + + $controller = $this->createController(); + $returned = $this->runController($controller); + } + + unset($override); + + $cacheConfig = config(Cache::class); + $this->gatherOutput($cacheConfig, $returned); + + return $this->response; + } + + // Display 404 Errors + $this->response->setStatusCode($e->getCode()); + + $this->outputBufferingEnd(); + + // Throws new PageNotFoundException and remove exception message on production. + throw PageNotFoundException::forPageNotFound( + (ENVIRONMENT !== 'production' || ! $this->isWeb()) ? $e->getMessage() : null + ); + } + + /** + * Gathers the script output from the buffer, replaces some execution + * time tag in the output and displays the debug toolbar, if required. + * + * @param Cache|null $cacheConfig Deprecated. No longer used. + * @param ResponseInterface|string|null $returned + * + * @deprecated $cacheConfig is deprecated. + * + * @return void + */ + protected function gatherOutput(?Cache $cacheConfig = null, $returned = null) + { + $this->output = $this->outputBufferingEnd(); + + if ($returned instanceof DownloadResponse) { + $this->response = $returned; + + return; + } + // If the controller returned a response object, + // we need to grab the body from it so it can + // be added to anything else that might have been + // echoed already. + // We also need to save the instance locally + // so that any status code changes, etc, take place. + if ($returned instanceof ResponseInterface) { + $this->response = $returned; + $returned = $returned->getBody(); + } + + if (is_string($returned)) { + $this->output .= $returned; + } + + $this->response->setBody($this->output); + } + + /** + * If we have a session object to use, store the current URI + * as the previous URI. This is called just prior to sending the + * response to the client, and will make it available next request. + * + * This helps provider safer, more reliable previous_url() detection. + * + * @param string|URI $uri + * + * @return void + */ + public function storePreviousURL($uri) + { + // Ignore CLI requests + if (! $this->isWeb()) { + return; + } + // Ignore AJAX requests + if (method_exists($this->request, 'isAJAX') && $this->request->isAJAX()) { + return; + } + + // Ignore unroutable responses + if ($this->response instanceof DownloadResponse || $this->response instanceof RedirectResponse) { + return; + } + + // Ignore non-HTML responses + if (strpos($this->response->getHeaderLine('Content-Type'), 'text/html') === false) { + return; + } + + // This is mainly needed during testing... + if (is_string($uri)) { + $uri = new URI($uri); + } + + if (isset($_SESSION)) { + session()->set('_ci_previous_url', URI::createURIString( + $uri->getScheme(), + $uri->getAuthority(), + $uri->getPath(), + $uri->getQuery(), + $uri->getFragment() + )); + } + } + + /** + * Modifies the Request Object to use a different method if a POST + * variable called _method is found. + * + * @return void + */ + public function spoofRequestMethod() + { + // Only works with POSTED forms + if (strtolower($this->request->getMethod()) !== 'post') { + return; + } + + $method = $this->request->getPost('_method'); + + if ($method === null) { + return; + } + + // Only allows PUT, PATCH, DELETE + if (in_array(strtoupper($method), ['PUT', 'PATCH', 'DELETE'], true)) { + $this->request = $this->request->setMethod($method); + } + } + + /** + * Sends the output of this request back to the client. + * This is what they've been waiting for! + * + * @return void + */ + protected function sendResponse() + { + $this->response->send(); + } + + /** + * Exits the application, setting the exit code for CLI-based applications + * that might be watching. + * + * Made into a separate method so that it can be mocked during testing + * without actually stopping script execution. + * + * @param int $code + * + * @deprecated 4.4.0 No longer Used. Moved to index.php. + * + * @return void + */ + protected function callExit($code) + { + exit($code); // @codeCoverageIgnore + } + + /** + * Sets the app context. + * + * @phpstan-param 'php-cli'|'web' $context + * + * @return $this + */ + public function setContext(string $context) + { + $this->context = $context; + + return $this; + } + + protected function outputBufferingStart(): void + { + $this->bufferLevel = ob_get_level(); + ob_start(); + } + + protected function outputBufferingEnd(): string + { + $buffer = ''; + + while (ob_get_level() > $this->bufferLevel) { + $buffer .= ob_get_contents(); + ob_end_clean(); + } + + return $buffer; + } +} diff --git a/system/Commands/Cache/ClearCache.php b/system/Commands/Cache/ClearCache.php new file mode 100644 index 0000000..79fab9d --- /dev/null +++ b/system/Commands/Cache/ClearCache.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Cache; + +use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use Config\Cache; + +/** + * Clears current cache. + */ +class ClearCache extends BaseCommand +{ + /** + * Command grouping. + * + * @var string + */ + protected $group = 'Cache'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'cache:clear'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Clears the current system caches.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = 'cache:clear []'; + + /** + * the Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'driver' => 'The cache driver to use', + ]; + + /** + * Clears the cache + */ + public function run(array $params) + { + $config = config(Cache::class); + $handler = $params[0] ?? $config->handler; + + if (! array_key_exists($handler, $config->validHandlers)) { + CLI::error($handler . ' is not a valid cache handler.'); + + return; + } + + $config->handler = $handler; + $cache = CacheFactory::getHandler($config); + + if (! $cache->clean()) { + // @codeCoverageIgnoreStart + CLI::error('Error while clearing the cache.'); + + return; + // @codeCoverageIgnoreEnd + } + + CLI::write(CLI::color('Cache cleared.', 'green')); + } +} diff --git a/system/Commands/Cache/InfoCache.php b/system/Commands/Cache/InfoCache.php new file mode 100644 index 0000000..1dc2504 --- /dev/null +++ b/system/Commands/Cache/InfoCache.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Cache; + +use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\I18n\Time; +use Config\Cache; + +/** + * Shows information on the cache. + */ +class InfoCache extends BaseCommand +{ + /** + * Command grouping. + * + * @var string + */ + protected $group = 'Cache'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'cache:info'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Shows file cache information in the current system.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = 'cache:info'; + + /** + * Clears the cache + */ + public function run(array $params) + { + $config = config(Cache::class); + helper('number'); + + if ($config->handler !== 'file') { + CLI::error('This command only supports the file cache handler.'); + + return; + } + + $cache = CacheFactory::getHandler($config); + $caches = $cache->getCacheInfo(); + $tbody = []; + + foreach ($caches as $key => $field) { + $tbody[] = [ + $key, + clean_path($field['server_path']), + number_to_size($field['size']), + Time::createFromTimestamp($field['date']), + ]; + } + + $thead = [ + CLI::color('Name', 'green'), + CLI::color('Server Path', 'green'), + CLI::color('Size', 'green'), + CLI::color('Date', 'green'), + ]; + + CLI::table($tbody, $thead); + } +} diff --git a/system/Commands/Database/CreateDatabase.php b/system/Commands/Database/CreateDatabase.php new file mode 100644 index 0000000..d103d97 --- /dev/null +++ b/system/Commands/Database/CreateDatabase.php @@ -0,0 +1,152 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Database; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Config\Factories; +use CodeIgniter\Database\SQLite3\Connection; +use Config\Database; +use Throwable; + +/** + * Creates a new database. + */ +class CreateDatabase extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'Database'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'db:create'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Create a new database schema.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = 'db:create [options]'; + + /** + * The Command's arguments + * + * @var array + */ + protected $arguments = [ + 'db_name' => 'The database name to use', + ]; + + /** + * The Command's options + * + * @var array + */ + protected $options = [ + '--ext' => 'File extension of the database file for SQLite3. Can be `db` or `sqlite`. Defaults to `db`.', + ]; + + /** + * Creates a new database. + */ + public function run(array $params) + { + $name = array_shift($params); + + if (empty($name)) { + $name = CLI::prompt('Database name', null, 'required'); // @codeCoverageIgnore + } + + try { + $config = config(Database::class); + + // Set to an empty database to prevent connection errors. + $group = ENVIRONMENT === 'testing' ? 'tests' : $config->defaultGroup; + + $config->{$group}['database'] = ''; + + $db = Database::connect(); + + // Special SQLite3 handling + if ($db instanceof Connection) { + $ext = $params['ext'] ?? CLI::getOption('ext') ?? 'db'; + + if (! in_array($ext, ['db', 'sqlite'], true)) { + $ext = CLI::prompt('Please choose a valid file extension', ['db', 'sqlite']); // @codeCoverageIgnore + } + + if ($name !== ':memory:') { + $name = str_replace(['.db', '.sqlite'], '', $name) . ".{$ext}"; + } + + $config->{$group}['DBDriver'] = 'SQLite3'; + $config->{$group}['database'] = $name; + + if ($name !== ':memory:') { + $dbName = strpos($name, DIRECTORY_SEPARATOR) === false ? WRITEPATH . $name : $name; + + if (is_file($dbName)) { + CLI::error("Database \"{$dbName}\" already exists.", 'light_gray', 'red'); + CLI::newLine(); + + return; + } + + unset($dbName); + } + + // Connect to new SQLite3 to create new database + $db = Database::connect(null, false); + $db->connect(); + + if (! is_file($db->getDatabase()) && $name !== ':memory:') { + // @codeCoverageIgnoreStart + CLI::error('Database creation failed.', 'light_gray', 'red'); + CLI::newLine(); + + return; + // @codeCoverageIgnoreEnd + } + } elseif (! Database::forge()->createDatabase($name)) { + // @codeCoverageIgnoreStart + CLI::error('Database creation failed.', 'light_gray', 'red'); + CLI::newLine(); + + return; + // @codeCoverageIgnoreEnd + } + + CLI::write("Database \"{$name}\" successfully created.", 'green'); + CLI::newLine(); + } catch (Throwable $e) { + $this->showError($e); + } finally { + Factories::reset('config'); + Database::connect(null, false); + } + } +} diff --git a/system/Commands/Database/Migrate.php b/system/Commands/Database/Migrate.php new file mode 100644 index 0000000..1193351 --- /dev/null +++ b/system/Commands/Database/Migrate.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Database; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use Config\Services; +use Throwable; + +/** + * Runs all new migrations. + */ +class Migrate extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'Database'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'migrate'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Locates and runs all new migrations against the database.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = 'migrate [options]'; + + /** + * the Command's Options + * + * @var array + */ + protected $options = [ + '-n' => 'Set migration namespace', + '-g' => 'Set database group', + '--all' => 'Set for all namespaces, will ignore (-n) option', + ]; + + /** + * Ensures that all migrations have been run. + */ + public function run(array $params) + { + $runner = Services::migrations(); + $runner->clearCliMessages(); + + CLI::write(lang('Migrations.latest'), 'yellow'); + + $namespace = $params['n'] ?? CLI::getOption('n'); + $group = $params['g'] ?? CLI::getOption('g'); + + try { + if (array_key_exists('all', $params) || CLI::getOption('all')) { + $runner->setNamespace(null); + } elseif ($namespace) { + $runner->setNamespace($namespace); + } + + if (! $runner->latest($group)) { + CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore + } + + $messages = $runner->getCliMessages(); + + foreach ($messages as $message) { + CLI::write($message); + } + + CLI::write(lang('Migrations.migrated'), 'green'); + + // @codeCoverageIgnoreStart + } catch (Throwable $e) { + $this->showError($e); + // @codeCoverageIgnoreEnd + } + } +} diff --git a/system/Commands/Database/MigrateRefresh.php b/system/Commands/Database/MigrateRefresh.php new file mode 100644 index 0000000..f683219 --- /dev/null +++ b/system/Commands/Database/MigrateRefresh.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Database; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; + +/** + * Does a rollback followed by a latest to refresh the current state + * of the database. + */ +class MigrateRefresh extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'Database'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'migrate:refresh'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Does a rollback followed by a latest to refresh the current state of the database.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = 'migrate:refresh [options]'; + + /** + * the Command's Options + * + * @var array + */ + protected $options = [ + '-n' => 'Set migration namespace', + '-g' => 'Set database group', + '--all' => 'Set latest for all namespace, will ignore (-n) option', + '-f' => 'Force command - this option allows you to bypass the confirmation question when running this command in a production environment', + ]; + + /** + * Does a rollback followed by a latest to refresh the current state + * of the database. + */ + public function run(array $params) + { + $params['b'] = 0; + + if (ENVIRONMENT === 'production') { + // @codeCoverageIgnoreStart + $force = array_key_exists('f', $params) || CLI::getOption('f'); + + if (! $force && CLI::prompt(lang('Migrations.refreshConfirm'), ['y', 'n']) === 'n') { + return; + } + + $params['f'] = null; + // @codeCoverageIgnoreEnd + } + + $this->call('migrate:rollback', $params); + $this->call('migrate', $params); + } +} diff --git a/system/Commands/Database/MigrateRollback.php b/system/Commands/Database/MigrateRollback.php new file mode 100644 index 0000000..2df223c --- /dev/null +++ b/system/Commands/Database/MigrateRollback.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Database; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use Config\Services; +use Throwable; + +/** + * Runs all of the migrations in reverse order, until they have + * all been unapplied. + */ +class MigrateRollback extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'Database'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'migrate:rollback'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Runs the "down" method for all migrations in the last batch.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = 'migrate:rollback [options]'; + + /** + * the Command's Options + * + * @var array + */ + protected $options = [ + '-b' => 'Specify a batch to roll back to; e.g. "3" to return to batch #3', + '-f' => 'Force command - this option allows you to bypass the confirmation question when running this command in a production environment', + ]; + + /** + * Runs all of the migrations in reverse order, until they have + * all been unapplied. + */ + public function run(array $params) + { + if (ENVIRONMENT === 'production') { + // @codeCoverageIgnoreStart + $force = array_key_exists('f', $params) || CLI::getOption('f'); + + if (! $force && CLI::prompt(lang('Migrations.rollBackConfirm'), ['y', 'n']) === 'n') { + return; + } + // @codeCoverageIgnoreEnd + } + + $runner = Services::migrations(); + + try { + $batch = $params['b'] ?? CLI::getOption('b') ?? $runner->getLastBatch() - 1; + CLI::write(lang('Migrations.rollingBack') . ' ' . $batch, 'yellow'); + + if (! $runner->regress($batch)) { + CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore + } + + $messages = $runner->getCliMessages(); + + foreach ($messages as $message) { + CLI::write($message); + } + + CLI::write('Done rolling back migrations.', 'green'); + + // @codeCoverageIgnoreStart + } catch (Throwable $e) { + $this->showError($e); + // @codeCoverageIgnoreEnd + } + } +} diff --git a/system/Commands/Database/MigrateStatus.php b/system/Commands/Database/MigrateStatus.php new file mode 100644 index 0000000..af3cbac --- /dev/null +++ b/system/Commands/Database/MigrateStatus.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Database; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use Config\Services; + +/** + * Displays a list of all migrations and whether they've been run or not. + * + * @see \CodeIgniter\Commands\Database\MigrateStatusTest + */ +class MigrateStatus extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'Database'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'migrate:status'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Displays a list of all migrations and whether they\'ve been run or not.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = 'migrate:status [options]'; + + /** + * the Command's Options + * + * @var array + */ + protected $options = [ + '-g' => 'Set database group', + ]; + + /** + * Namespaces to ignore when looking for migrations. + * + * @var string[] + */ + protected $ignoredNamespaces = [ + 'CodeIgniter', + 'Config', + 'Kint', + 'Laminas\ZendFrameworkBridge', + 'Laminas\Escaper', + 'Psr\Log', + ]; + + /** + * Displays a list of all migrations and whether they've been run or not. + * + * @param array $params + */ + public function run(array $params) + { + $runner = Services::migrations(); + $paramGroup = $params['g'] ?? CLI::getOption('g'); + + // Get all namespaces + $namespaces = Services::autoloader()->getNamespace(); + + // Collection of migration status + $status = []; + + foreach (array_keys($namespaces) as $namespace) { + if (ENVIRONMENT !== 'testing') { + // Make Tests\\Support discoverable for testing + $this->ignoredNamespaces[] = 'Tests\Support'; // @codeCoverageIgnore + } + + if (in_array($namespace, $this->ignoredNamespaces, true)) { + continue; + } + + if (APP_NAMESPACE !== 'App' && $namespace === 'App') { + continue; // @codeCoverageIgnore + } + + $migrations = $runner->findNamespaceMigrations($namespace); + + if (empty($migrations)) { + continue; + } + + $runner->setNamespace($namespace); + $history = $runner->getHistory((string) $paramGroup); + ksort($migrations); + + foreach ($migrations as $uid => $migration) { + $migrations[$uid]->name = mb_substr($migration->name, mb_strpos($migration->name, $uid . '_')); + + $date = '---'; + $group = '---'; + $batch = '---'; + + foreach ($history as $row) { + // @codeCoverageIgnoreStart + if ($runner->getObjectUid($row) !== $migration->uid) { + continue; + } + + $date = date('Y-m-d H:i:s', $row->time); + $group = $row->group; + $batch = $row->batch; + // @codeCoverageIgnoreEnd + } + + $status[] = [ + $namespace, + $migration->version, + $migration->name, + $group, + $date, + $batch, + ]; + } + } + + if (! $status) { + // @codeCoverageIgnoreStart + CLI::error(lang('Migrations.noneFound'), 'light_gray', 'red'); + CLI::newLine(); + + return; + // @codeCoverageIgnoreEnd + } + + $headers = [ + CLI::color(lang('Migrations.namespace'), 'yellow'), + CLI::color(lang('Migrations.version'), 'yellow'), + CLI::color(lang('Migrations.filename'), 'yellow'), + CLI::color(lang('Migrations.group'), 'yellow'), + CLI::color(str_replace(': ', '', lang('Migrations.on')), 'yellow'), + CLI::color(lang('Migrations.batch'), 'yellow'), + ]; + + CLI::table($status, $headers); + } +} diff --git a/system/Commands/Database/Seed.php b/system/Commands/Database/Seed.php new file mode 100644 index 0000000..adfdd0a --- /dev/null +++ b/system/Commands/Database/Seed.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Database; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Database\Seeder; +use Config\Database; +use Throwable; + +/** + * Runs the specified Seeder file to populate the database + * with some data. + */ +class Seed extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'Database'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'db:seed'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Runs the specified seeder to populate known data into the database.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = 'db:seed '; + + /** + * the Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'seeder_name' => 'The seeder name to run', + ]; + + /** + * Passes to Seeder to populate the database. + */ + public function run(array $params) + { + $seeder = new Seeder(new Database()); + $seedName = array_shift($params); + + if (empty($seedName)) { + $seedName = CLI::prompt(lang('Migrations.migSeeder'), null, 'required'); // @codeCoverageIgnore + } + + try { + $seeder->call($seedName); + } catch (Throwable $e) { + $this->showError($e); + } + } +} diff --git a/system/Commands/Database/ShowTableInfo.php b/system/Commands/Database/ShowTableInfo.php new file mode 100644 index 0000000..4c8f1bf --- /dev/null +++ b/system/Commands/Database/ShowTableInfo.php @@ -0,0 +1,285 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Database; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Database\BaseConnection; +use Config\Database; + +/** + * Get table data if it exists in the database. + * + * @see \CodeIgniter\Commands\Database\ShowTableInfoTest + */ +class ShowTableInfo extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'Database'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'db:table'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Retrieves information on the selected table.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = <<<'EOL' + db:table [] [options] + + Examples: + db:table --show + db:table --metadata + db:table my_table --metadata + db:table my_table + db:table my_table --limit-rows 5 --limit-field-value 10 --desc + EOL; + + /** + * The Command's arguments + * + * @var array + */ + protected $arguments = [ + 'table_name' => 'The table name to show info', + ]; + + /** + * The Command's options + * + * @var array + */ + protected $options = [ + '--show' => 'Lists the names of all database tables.', + '--metadata' => 'Retrieves list containing field information.', + '--desc' => 'Sorts the table rows in DESC order.', + '--limit-rows' => 'Limits the number of rows. Default: 10.', + '--limit-field-value' => 'Limits the length of field values. Default: 15.', + ]; + + /** + * @var list> Table Data. + */ + private array $tbody; + + private BaseConnection $db; + + /** + * @var bool Sort the table rows in DESC order or not. + */ + private bool $sortDesc = false; + + private string $DBPrefix; + + public function run(array $params) + { + $this->db = Database::connect(); + $this->DBPrefix = $this->db->getPrefix(); + + $tables = $this->db->listTables(); + + if (array_key_exists('desc', $params)) { + $this->sortDesc = true; + } + + if ($tables === []) { + CLI::error('Database has no tables!', 'light_gray', 'red'); + CLI::newLine(); + + return; + } + + if (array_key_exists('show', $params)) { + $this->showAllTables($tables); + + return; + } + + $tableName = $params[0] ?? null; + $limitRows = (int) ($params['limit-rows'] ?? 10); + $limitFieldValue = (int) ($params['limit-field-value'] ?? 15); + + while (! in_array($tableName, $tables, true)) { + $tableNameNo = CLI::promptByKey( + ['Here is the list of your database tables:', 'Which table do you want to see?'], + $tables, + 'required' + ); + CLI::newLine(); + + $tableName = $tables[$tableNameNo] ?? null; + } + + if (array_key_exists('metadata', $params)) { + $this->showFieldMetaData($tableName); + + return; + } + + $this->showDataOfTable($tableName, $limitRows, $limitFieldValue); + } + + private function removeDBPrefix(): void + { + $this->db->setPrefix(''); + } + + private function restoreDBPrefix(): void + { + $this->db->setPrefix($this->DBPrefix); + } + + private function showDataOfTable(string $tableName, int $limitRows, int $limitFieldValue) + { + CLI::write("Data of Table \"{$tableName}\":", 'black', 'yellow'); + CLI::newLine(); + + $this->removeDBPrefix(); + $thead = $this->db->getFieldNames($tableName); + $this->restoreDBPrefix(); + + // If there is a field named `id`, sort by it. + $sortField = null; + if (in_array('id', $thead, true)) { + $sortField = 'id'; + } + + $this->tbody = $this->makeTableRows($tableName, $limitRows, $limitFieldValue, $sortField); + CLI::table($this->tbody, $thead); + } + + private function showAllTables(array $tables) + { + CLI::write('The following is a list of the names of all database tables:', 'black', 'yellow'); + CLI::newLine(); + + $thead = ['ID', 'Table Name', 'Num of Rows', 'Num of Fields']; + $this->tbody = $this->makeTbodyForShowAllTables($tables); + + CLI::table($this->tbody, $thead); + CLI::newLine(); + } + + private function makeTbodyForShowAllTables(array $tables): array + { + $this->removeDBPrefix(); + + foreach ($tables as $id => $tableName) { + $table = $this->db->protectIdentifiers($tableName); + $db = $this->db->query("SELECT * FROM {$table}"); + + $this->tbody[] = [ + $id + 1, + $tableName, + $db->getNumRows(), + $db->getFieldCount(), + ]; + } + + $this->restoreDBPrefix(); + + if ($this->sortDesc) { + krsort($this->tbody); + } + + return $this->tbody; + } + + private function makeTableRows( + string $tableName, + int $limitRows, + int $limitFieldValue, + ?string $sortField = null + ): array { + $this->tbody = []; + + $this->removeDBPrefix(); + $builder = $this->db->table($tableName); + $builder->limit($limitRows); + if ($sortField !== null) { + $builder->orderBy($sortField, $this->sortDesc ? 'DESC' : 'ASC'); + } + $rows = $builder->get()->getResultArray(); + $this->restoreDBPrefix(); + + foreach ($rows as $row) { + $row = array_map( + static fn ($item): string => mb_strlen((string) $item) > $limitFieldValue + ? mb_substr((string) $item, 0, $limitFieldValue) . '...' + : (string) $item, + $row + ); + $this->tbody[] = $row; + } + + if ($sortField === null && $this->sortDesc) { + krsort($this->tbody); + } + + return $this->tbody; + } + + private function showFieldMetaData(string $tableName): void + { + CLI::write("List of Metadata Information in Table \"{$tableName}\":", 'black', 'yellow'); + CLI::newLine(); + + $thead = ['Field Name', 'Type', 'Max Length', 'Nullable', 'Default', 'Primary Key']; + + $this->removeDBPrefix(); + $fields = $this->db->getFieldData($tableName); + $this->restoreDBPrefix(); + + foreach ($fields as $row) { + $this->tbody[] = [ + $row->name, + $row->type, + $row->max_length, + isset($row->nullable) ? $this->setYesOrNo($row->nullable) : 'n/a', + $row->default, + isset($row->primary_key) ? $this->setYesOrNo($row->primary_key) : 'n/a', + ]; + } + + if ($this->sortDesc) { + krsort($this->tbody); + } + + CLI::table($this->tbody, $thead); + } + + private function setYesOrNo(bool $fieldValue): string + { + if ($fieldValue) { + return CLI::color('Yes', 'green'); + } + + return CLI::color('No', 'red'); + } +} diff --git a/system/Commands/Encryption/GenerateKey.php b/system/Commands/Encryption/GenerateKey.php new file mode 100644 index 0000000..419ae6e --- /dev/null +++ b/system/Commands/Encryption/GenerateKey.php @@ -0,0 +1,199 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Encryption; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Config\DotEnv; +use CodeIgniter\Encryption\Encryption; + +/** + * Generates a new encryption key. + */ +class GenerateKey extends BaseCommand +{ + /** + * The Command's group. + * + * @var string + */ + protected $group = 'Encryption'; + + /** + * The Command's name. + * + * @var string + */ + protected $name = 'key:generate'; + + /** + * The Command's usage. + * + * @var string + */ + protected $usage = 'key:generate [options]'; + + /** + * The Command's short description. + * + * @var string + */ + protected $description = 'Generates a new encryption key and writes it in an `.env` file.'; + + /** + * The command's options + * + * @var array + */ + protected $options = [ + '--force' => 'Force overwrite existing key in `.env` file.', + '--length' => 'The length of the random string that should be returned in bytes. Defaults to 32.', + '--prefix' => 'Prefix to prepend to encoded key (either hex2bin or base64). Defaults to hex2bin.', + '--show' => 'Shows the generated key in the terminal instead of storing in the `.env` file.', + ]; + + /** + * Actually execute the command. + */ + public function run(array $params) + { + $prefix = $params['prefix'] ?? CLI::getOption('prefix'); + + if (in_array($prefix, [null, true], true)) { + $prefix = 'hex2bin'; + } elseif (! in_array($prefix, ['hex2bin', 'base64'], true)) { + $prefix = CLI::prompt('Please provide a valid prefix to use.', ['hex2bin', 'base64'], 'required'); // @codeCoverageIgnore + } + + $length = $params['length'] ?? CLI::getOption('length'); + + if (in_array($length, [null, true], true)) { + $length = 32; + } + + $encodedKey = $this->generateRandomKey($prefix, $length); + + if (array_key_exists('show', $params) || (bool) CLI::getOption('show')) { + CLI::write($encodedKey, 'yellow'); + CLI::newLine(); + + return; + } + + if (! $this->setNewEncryptionKey($encodedKey, $params)) { + CLI::write('Error in setting new encryption key to .env file.', 'light_gray', 'red'); + CLI::newLine(); + + return; + } + + // force DotEnv to reload the new env vars + putenv('encryption.key'); + unset($_ENV['encryption.key'], $_SERVER['encryption.key']); + $dotenv = new DotEnv(ROOTPATH); + $dotenv->load(); + + CLI::write('Application\'s new encryption key was successfully set.', 'green'); + CLI::newLine(); + } + + /** + * Generates a key and encodes it. + */ + protected function generateRandomKey(string $prefix, int $length): string + { + $key = Encryption::createKey($length); + + if ($prefix === 'hex2bin') { + return 'hex2bin:' . bin2hex($key); + } + + return 'base64:' . base64_encode($key); + } + + /** + * Sets the new encryption key in your .env file. + */ + protected function setNewEncryptionKey(string $key, array $params): bool + { + $currentKey = env('encryption.key', ''); + + if ($currentKey !== '' && ! $this->confirmOverwrite($params)) { + // Not yet testable since it requires keyboard input + return false; // @codeCoverageIgnore + } + + return $this->writeNewEncryptionKeyToFile($currentKey, $key); + } + + /** + * Checks whether to overwrite existing encryption key. + */ + protected function confirmOverwrite(array $params): bool + { + return (array_key_exists('force', $params) || CLI::getOption('force')) || CLI::prompt('Overwrite existing key?', ['n', 'y']) === 'y'; + } + + /** + * Writes the new encryption key to .env file. + */ + protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): bool + { + $baseEnv = ROOTPATH . 'env'; + $envFile = ROOTPATH . '.env'; + + if (! is_file($envFile)) { + if (! is_file($baseEnv)) { + CLI::write('Both default shipped `env` file and custom `.env` are missing.', 'yellow'); + CLI::write('Here\'s your new key instead: ' . CLI::color($newKey, 'yellow')); + CLI::newLine(); + + return false; + } + + copy($baseEnv, $envFile); + } + + $oldFileContents = (string) file_get_contents($envFile); + $replacementKey = "\nencryption.key = {$newKey}"; + + if (strpos($oldFileContents, 'encryption.key') === false) { + return file_put_contents($envFile, $replacementKey, FILE_APPEND) !== false; + } + + $newFileContents = preg_replace($this->keyPattern($oldKey), $replacementKey, $oldFileContents); + + if ($newFileContents === $oldFileContents) { + $newFileContents = preg_replace( + '/^[#\s]*encryption.key[=\s]*(?:hex2bin\:[a-f0-9]{64}|base64\:(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?)$/m', + $replacementKey, + $oldFileContents + ); + } + + return file_put_contents($envFile, $newFileContents) !== false; + } + + /** + * Get the regex of the current encryption key. + */ + protected function keyPattern(string $oldKey): string + { + $escaped = preg_quote($oldKey, '/'); + + if ($escaped !== '') { + $escaped = "[{$escaped}]*"; + } + + return "/^[#\\s]*encryption.key[=\\s]*{$escaped}$/m"; + } +} diff --git a/system/Commands/Generators/CellGenerator.php b/system/Commands/Generators/CellGenerator.php new file mode 100644 index 0000000..358bacc --- /dev/null +++ b/system/Commands/Generators/CellGenerator.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\GeneratorTrait; + +/** + * Generates a skeleton Cell and its view. + */ +class CellGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:cell'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a new Controlled Cell file and its view.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:cell [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The Controlled Cell class name.', + ]; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--force' => 'Force overwrite existing file.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Cell'; + $this->directory = 'Cells'; + + $params = array_merge($params, ['suffix' => null]); + + $this->template = 'cell.tpl.php'; + $this->classNameLang = 'CLI.generator.className.cell'; + $this->generateClass($params); + + $this->name = 'make:cell_view'; + $this->template = 'cell_view.tpl.php'; + $this->classNameLang = 'CLI.generator.viewName.cell'; + + $className = $this->qualifyClassName(); + $viewName = decamelize(class_basename($className)); + $viewName = preg_replace('/([a-z][a-z0-9_\/\\\\]+)(_cell)$/i', '$1', $viewName) ?? $viewName; + $namespace = substr($className, 0, strrpos($className, '\\') + 1); + + $this->generateView($namespace . $viewName, $params); + + return 0; + } +} diff --git a/system/Commands/Generators/CommandGenerator.php b/system/Commands/Generators/CommandGenerator.php new file mode 100644 index 0000000..b844666 --- /dev/null +++ b/system/Commands/Generators/CommandGenerator.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\GeneratorTrait; + +/** + * Generates a skeleton command file. + */ +class CommandGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:command'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a new spark command.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:command [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The command class name.', + ]; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '--command' => 'The command name. Default: "command:name"', + '--type' => 'The command type. Options [basic, generator]. Default: "basic".', + '--group' => 'The command group. Default: [basic -> "App", generator -> "Generators"].', + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--suffix' => 'Append the component title to the class name (e.g. User => UserCommand).', + '--force' => 'Force overwrite existing file.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Command'; + $this->directory = 'Commands'; + $this->template = 'command.tpl.php'; + + $this->classNameLang = 'CLI.generator.className.command'; + $this->execute($params); + } + + /** + * Prepare options and do the necessary replacements. + */ + protected function prepare(string $class): string + { + $command = $this->getOption('command'); + $group = $this->getOption('group'); + $type = $this->getOption('type'); + + $command = is_string($command) ? $command : 'command:name'; + $type = is_string($type) ? $type : 'basic'; + + if (! in_array($type, ['basic', 'generator'], true)) { + // @codeCoverageIgnoreStart + $type = CLI::prompt(lang('CLI.generator.commandType'), ['basic', 'generator'], 'required'); + CLI::newLine(); + // @codeCoverageIgnoreEnd + } + + if (! is_string($group)) { + $group = $type === 'generator' ? 'Generators' : 'App'; + } + + return $this->parseTemplate( + $class, + ['{group}', '{command}'], + [$group, $command], + ['type' => $type] + ); + } +} diff --git a/system/Commands/Generators/ConfigGenerator.php b/system/Commands/Generators/ConfigGenerator.php new file mode 100644 index 0000000..a83a967 --- /dev/null +++ b/system/Commands/Generators/ConfigGenerator.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\GeneratorTrait; + +/** + * Generates a skeleton config file. + */ +class ConfigGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:config'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a new config file.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:config [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The config class name.', + ]; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--suffix' => 'Append the component title to the class name (e.g. User => UserConfig).', + '--force' => 'Force overwrite existing file.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Config'; + $this->directory = 'Config'; + $this->template = 'config.tpl.php'; + + $this->classNameLang = 'CLI.generator.className.config'; + $this->execute($params); + } + + /** + * Prepare options and do the necessary replacements. + */ + protected function prepare(string $class): string + { + $namespace = $this->getOption('namespace') ?? APP_NAMESPACE; + + if ($namespace === APP_NAMESPACE) { + $class = substr($class, strlen($namespace . '\\')); + } + + return $this->parseTemplate($class); + } +} diff --git a/system/Commands/Generators/ControllerGenerator.php b/system/Commands/Generators/ControllerGenerator.php new file mode 100644 index 0000000..2cf912b --- /dev/null +++ b/system/Commands/Generators/ControllerGenerator.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\GeneratorTrait; +use CodeIgniter\Controller; +use CodeIgniter\RESTful\ResourceController; +use CodeIgniter\RESTful\ResourcePresenter; + +/** + * Generates a skeleton controller file. + */ +class ControllerGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:controller'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a new controller file.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:controller [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The controller class name.', + ]; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '--bare' => 'Extends from CodeIgniter\Controller instead of BaseController.', + '--restful' => 'Extends from a RESTful resource, Options: [controller, presenter]. Default: "controller".', + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--suffix' => 'Append the component title to the class name (e.g. User => UserController).', + '--force' => 'Force overwrite existing file.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Controller'; + $this->directory = 'Controllers'; + $this->template = 'controller.tpl.php'; + + $this->classNameLang = 'CLI.generator.className.controller'; + $this->execute($params); + } + + /** + * Prepare options and do the necessary replacements. + */ + protected function prepare(string $class): string + { + $bare = $this->getOption('bare'); + $rest = $this->getOption('restful'); + + $useStatement = trim(APP_NAMESPACE, '\\') . '\Controllers\BaseController'; + $extends = 'BaseController'; + + // Gets the appropriate parent class to extend. + if ($bare || $rest) { + if ($bare) { + $useStatement = Controller::class; + $extends = 'Controller'; + } elseif ($rest) { + $rest = is_string($rest) ? $rest : 'controller'; + + if (! in_array($rest, ['controller', 'presenter'], true)) { + // @codeCoverageIgnoreStart + $rest = CLI::prompt(lang('CLI.generator.parentClass'), ['controller', 'presenter'], 'required'); + CLI::newLine(); + // @codeCoverageIgnoreEnd + } + + if ($rest === 'controller') { + $useStatement = ResourceController::class; + $extends = 'ResourceController'; + } elseif ($rest === 'presenter') { + $useStatement = ResourcePresenter::class; + $extends = 'ResourcePresenter'; + } + } + } + + return $this->parseTemplate( + $class, + ['{useStatement}', '{extends}'], + [$useStatement, $extends], + ['type' => $rest] + ); + } +} diff --git a/system/Commands/Generators/EntityGenerator.php b/system/Commands/Generators/EntityGenerator.php new file mode 100644 index 0000000..bd20daf --- /dev/null +++ b/system/Commands/Generators/EntityGenerator.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\GeneratorTrait; + +/** + * Generates a skeleton Entity file. + */ +class EntityGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:entity'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a new entity file.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:entity [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The entity class name.', + ]; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--suffix' => 'Append the component title to the class name (e.g. User => UserEntity).', + '--force' => 'Force overwrite existing file.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Entity'; + $this->directory = 'Entities'; + $this->template = 'entity.tpl.php'; + + $this->classNameLang = 'CLI.generator.className.entity'; + $this->execute($params); + } +} diff --git a/system/Commands/Generators/FilterGenerator.php b/system/Commands/Generators/FilterGenerator.php new file mode 100644 index 0000000..620bee5 --- /dev/null +++ b/system/Commands/Generators/FilterGenerator.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\GeneratorTrait; + +/** + * Generates a skeleton Filter file. + */ +class FilterGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:filter'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a new filter file.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:filter [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The filter class name.', + ]; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--suffix' => 'Append the component title to the class name (e.g. User => UserFilter).', + '--force' => 'Force overwrite existing file.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Filter'; + $this->directory = 'Filters'; + $this->template = 'filter.tpl.php'; + + $this->classNameLang = 'CLI.generator.className.filter'; + $this->execute($params); + } +} diff --git a/system/Commands/Generators/MigrateCreate.php b/system/Commands/Generators/MigrateCreate.php new file mode 100644 index 0000000..a2fa6bf --- /dev/null +++ b/system/Commands/Generators/MigrateCreate.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; + +/** + * Deprecated class for the migration creation command. + * + * @deprecated Use make:migration instead. + * + * @codeCoverageIgnore + */ +class MigrateCreate extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'migrate:create'; + + /** + * The Command's short description + * + * @var string + */ + protected $description = '[DEPRECATED] Creates a new migration file. Please use "make:migration" instead.'; + + /** + * The Command's usage + * + * @var string + */ + protected $usage = 'migrate:create [options]'; + + /** + * The Command's arguments. + * + * @var array + */ + protected $arguments = [ + 'name' => 'The migration file name.', + ]; + + /** + * The Command's options. + * + * @var array + */ + protected $options = [ + '--namespace' => 'Set root namespace. Defaults to APP_NAMESPACE', + '--force' => 'Force overwrite existing files.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + // Resolve arguments before passing to make:migration + $params[0] ??= CLI::getSegment(2); + + $params['namespace'] ??= CLI::getOption('namespace') ?? APP_NAMESPACE; + + if (array_key_exists('force', $params) || CLI::getOption('force')) { + $params['force'] = null; + } + + $this->call('make:migration', $params); + } +} diff --git a/system/Commands/Generators/MigrationGenerator.php b/system/Commands/Generators/MigrationGenerator.php new file mode 100644 index 0000000..52f9e6e --- /dev/null +++ b/system/Commands/Generators/MigrationGenerator.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\GeneratorTrait; +use Config\Database; +use Config\Migrations; +use Config\Session as SessionConfig; + +/** + * Generates a skeleton migration file. + */ +class MigrationGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:migration'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a new migration file.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:migration [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The migration class name.', + ]; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '--session' => 'Generates the migration file for database sessions.', + '--table' => 'Table name to use for database sessions. Default: "ci_sessions".', + '--dbgroup' => 'Database group to use for database sessions. Default: "default".', + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--suffix' => 'Append the component title to the class name (e.g. User => UserMigration).', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Migration'; + $this->directory = 'Database\Migrations'; + $this->template = 'migration.tpl.php'; + + if (array_key_exists('session', $params) || CLI::getOption('session')) { + $table = $params['table'] ?? CLI::getOption('table') ?? 'ci_sessions'; + $params[0] = "_create_{$table}_table"; + } + + $this->classNameLang = 'CLI.generator.className.migration'; + $this->execute($params); + } + + /** + * Prepare options and do the necessary replacements. + */ + protected function prepare(string $class): string + { + $data = []; + $data['session'] = false; + + if ($this->getOption('session')) { + $table = $this->getOption('table'); + $DBGroup = $this->getOption('dbgroup'); + + $data['session'] = true; + $data['table'] = is_string($table) ? $table : 'ci_sessions'; + $data['DBGroup'] = is_string($DBGroup) ? $DBGroup : 'default'; + $data['DBDriver'] = config(Database::class)->{$data['DBGroup']}['DBDriver']; + + /** @var SessionConfig|null $session */ + $session = config(SessionConfig::class); + + $data['matchIP'] = $session->matchIP; + } + + return $this->parseTemplate($class, [], [], $data); + } + + /** + * Change file basename before saving. + */ + protected function basename(string $filename): string + { + return gmdate(config(Migrations::class)->timestampFormat) . basename($filename); + } +} diff --git a/system/Commands/Generators/ModelGenerator.php b/system/Commands/Generators/ModelGenerator.php new file mode 100644 index 0000000..f4946a9 --- /dev/null +++ b/system/Commands/Generators/ModelGenerator.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\GeneratorTrait; + +/** + * Generates a skeleton Model file. + */ +class ModelGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:model'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a new model file.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:model [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The model class name.', + ]; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '--table' => 'Supply a table name. Default: "the lowercased plural of the class name".', + '--dbgroup' => 'Database group to use. Default: "default".', + '--return' => 'Return type, Options: [array, object, entity]. Default: "array".', + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--suffix' => 'Append the component title to the class name (e.g. User => UserModel).', + '--force' => 'Force overwrite existing file.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Model'; + $this->directory = 'Models'; + $this->template = 'model.tpl.php'; + + $this->classNameLang = 'CLI.generator.className.model'; + $this->execute($params); + } + + /** + * Prepare options and do the necessary replacements. + */ + protected function prepare(string $class): string + { + $table = $this->getOption('table'); + $dbGroup = $this->getOption('dbgroup'); + $return = $this->getOption('return'); + + $baseClass = class_basename($class); + + if (preg_match('/^(\S+)Model$/i', $baseClass, $match) === 1) { + $baseClass = $match[1]; + } + + $table = is_string($table) ? $table : plural(strtolower($baseClass)); + $return = is_string($return) ? $return : 'array'; + + if (! in_array($return, ['array', 'object', 'entity'], true)) { + // @codeCoverageIgnoreStart + $return = CLI::prompt(lang('CLI.generator.returnType'), ['array', 'object', 'entity'], 'required'); + CLI::newLine(); + // @codeCoverageIgnoreEnd + } + + if ($return === 'entity') { + $return = str_replace('Models', 'Entities', $class); + + if (preg_match('/^(\S+)Model$/i', $return, $match) === 1) { + $return = $match[1]; + + if ($this->getOption('suffix')) { + $return .= 'Entity'; + } + } + + $return = '\\' . trim($return, '\\') . '::class'; + $this->call('make:entity', array_merge([$baseClass], $this->params)); + } else { + $return = "'{$return}'"; + } + + return $this->parseTemplate($class, ['{dbGroup}', '{table}', '{return}'], [$dbGroup, $table, $return], compact('dbGroup')); + } +} diff --git a/system/Commands/Generators/ScaffoldGenerator.php b/system/Commands/Generators/ScaffoldGenerator.php new file mode 100644 index 0000000..ef34b92 --- /dev/null +++ b/system/Commands/Generators/ScaffoldGenerator.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\GeneratorTrait; + +/** + * Generates a complete set of scaffold files. + */ +class ScaffoldGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:scaffold'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a complete set of scaffold files.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:scaffold [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The class name', + ]; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '--bare' => 'Add the "--bare" option to controller component.', + '--restful' => 'Add the "--restful" option to controller component.', + '--table' => 'Add the "--table" option to the model component.', + '--dbgroup' => 'Add the "--dbgroup" option to model component.', + '--return' => 'Add the "--return" option to the model component.', + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--suffix' => 'Append the component title to the class name.', + '--force' => 'Force overwrite existing file.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->params = $params; + + $options = []; + + if ($this->getOption('namespace')) { + $options['namespace'] = $this->getOption('namespace'); + } + + if ($this->getOption('suffix')) { + $options['suffix'] = null; + } + + if ($this->getOption('force')) { + $options['force'] = null; + } + + $controllerOpts = []; + + if ($this->getOption('bare')) { + $controllerOpts['bare'] = null; + } elseif ($this->getOption('restful')) { + $controllerOpts['restful'] = $this->getOption('restful'); + } + + $modelOpts = [ + 'table' => $this->getOption('table'), + 'dbgroup' => $this->getOption('dbgroup'), + 'return' => $this->getOption('return'), + ]; + + $class = $params[0] ?? CLI::getSegment(2); + + // Call those commands! + $this->call('make:controller', array_merge([$class], $controllerOpts, $options)); + $this->call('make:model', array_merge([$class], $modelOpts, $options)); + $this->call('make:migration', array_merge([$class], $options)); + $this->call('make:seeder', array_merge([$class], $options)); + } +} diff --git a/system/Commands/Generators/SeederGenerator.php b/system/Commands/Generators/SeederGenerator.php new file mode 100644 index 0000000..e60525a --- /dev/null +++ b/system/Commands/Generators/SeederGenerator.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\GeneratorTrait; + +/** + * Generates a skeleton seeder file. + */ +class SeederGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:seeder'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a new seeder file.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:seeder [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The seeder class name.', + ]; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--suffix' => 'Append the component title to the class name (e.g. User => UserSeeder).', + '--force' => 'Force overwrite existing file.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Seeder'; + $this->directory = 'Database\Seeds'; + $this->template = 'seeder.tpl.php'; + + $this->classNameLang = 'CLI.generator.className.seeder'; + $this->execute($params); + } +} diff --git a/system/Commands/Generators/SessionMigrationGenerator.php b/system/Commands/Generators/SessionMigrationGenerator.php new file mode 100644 index 0000000..cb7da58 --- /dev/null +++ b/system/Commands/Generators/SessionMigrationGenerator.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\GeneratorTrait; +use Config\App; +use Config\Migrations; + +/** + * Generates a migration file for database sessions. + * + * @deprecated Use `make:migration --session` instead. + * + * @codeCoverageIgnore + */ +class SessionMigrationGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'session:migration'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = '[DEPRECATED] Generates the migration file for database sessions, Please use "make:migration --session" instead.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'session:migration [options]'; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '-t' => 'Supply a table name.', + '-g' => 'Database group to use. Default: "default".', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Migration'; + $this->directory = 'Database\Migrations'; + $this->template = 'migration.tpl.php'; + + $table = 'ci_sessions'; + + if (array_key_exists('t', $params) || CLI::getOption('t')) { + $table = $params['t'] ?? CLI::getOption('t'); + } + + $params[0] = "_create_{$table}_table"; + + $this->execute($params); + } + + /** + * Performs the necessary replacements. + */ + protected function prepare(string $class): string + { + $data = []; + $data['session'] = true; + $data['table'] = $this->getOption('t'); + $data['DBGroup'] = $this->getOption('g'); + $data['matchIP'] = config(App::class)->sessionMatchIP ?? false; + + $data['table'] = is_string($data['table']) ? $data['table'] : 'ci_sessions'; + $data['DBGroup'] = is_string($data['DBGroup']) ? $data['DBGroup'] : 'default'; + + return $this->parseTemplate($class, [], [], $data); + } + + /** + * Change file basename before saving. + */ + protected function basename(string $filename): string + { + return gmdate(config(Migrations::class)->timestampFormat) . basename($filename); + } +} diff --git a/system/Commands/Generators/ValidationGenerator.php b/system/Commands/Generators/ValidationGenerator.php new file mode 100644 index 0000000..1b2efb8 --- /dev/null +++ b/system/Commands/Generators/ValidationGenerator.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\GeneratorTrait; + +/** + * Generates a skeleton Validation file. + */ +class ValidationGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:validation'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a new validation file.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:validation [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The validation class name.', + ]; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--suffix' => 'Append the component title to the class name (e.g. User => UserValidation).', + '--force' => 'Force overwrite existing file.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Validation'; + $this->directory = 'Validation'; + $this->template = 'validation.tpl.php'; + + $this->classNameLang = 'CLI.generator.className.validation'; + $this->execute($params); + } +} diff --git a/system/Commands/Generators/Views/cell.tpl.php b/system/Commands/Generators/Views/cell.tpl.php new file mode 100644 index 0000000..f20c078 --- /dev/null +++ b/system/Commands/Generators/Views/cell.tpl.php @@ -0,0 +1,10 @@ +<@php + +namespace {namespace}; + +use CodeIgniter\View\Cells\Cell; + +class {class} extends Cell +{ + // +} diff --git a/system/Commands/Generators/Views/cell_view.tpl.php b/system/Commands/Generators/Views/cell_view.tpl.php new file mode 100644 index 0000000..9866dc2 --- /dev/null +++ b/system/Commands/Generators/Views/cell_view.tpl.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/system/Commands/Generators/Views/command.tpl.php b/system/Commands/Generators/Views/command.tpl.php new file mode 100644 index 0000000..bfbdcc4 --- /dev/null +++ b/system/Commands/Generators/Views/command.tpl.php @@ -0,0 +1,76 @@ +<@php + +namespace {namespace}; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; + +use CodeIgniter\CLI\GeneratorTrait; + + +class {class} extends BaseCommand +{ + + use GeneratorTrait; + + + /** + * The Command's Group + * + * @var string + */ + protected $group = '{group}'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = '{command}'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = ''; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = '{command} [arguments] [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = []; + + /** + * The Command's Options + * + * @var array + */ + protected $options = []; + + /** + * Actually execute a command. + * + * @param array $params + */ + public function run(array $params) + { + + $this->component = 'Command'; + $this->directory = 'Commands'; + $this->template = 'command.tpl.php'; + + $this->execute($params); + + // + + } +} diff --git a/system/Commands/Generators/Views/config.tpl.php b/system/Commands/Generators/Views/config.tpl.php new file mode 100644 index 0000000..31c60cb --- /dev/null +++ b/system/Commands/Generators/Views/config.tpl.php @@ -0,0 +1,10 @@ +<@php + +namespace {namespace}; + +use CodeIgniter\Config\BaseConfig; + +class {class} extends BaseConfig +{ + // +} diff --git a/system/Commands/Generators/Views/controller.tpl.php b/system/Commands/Generators/Views/controller.tpl.php new file mode 100644 index 0000000..2fe99e1 --- /dev/null +++ b/system/Commands/Generators/Views/controller.tpl.php @@ -0,0 +1,178 @@ +<@php + +namespace {namespace}; + +use {useStatement}; +use CodeIgniter\HTTP\ResponseInterface; + +class {class} extends {extends} +{ + + /** + * Return an array of resource objects, themselves in array format + * + * @return ResponseInterface + */ + public function index() + { + // + } + + /** + * Return the properties of a resource object + * + * @return ResponseInterface + */ + public function show($id = null) + { + // + } + + /** + * Return a new resource object, with default properties + * + * @return ResponseInterface + */ + public function new() + { + // + } + + /** + * Create a new resource object, from "posted" parameters + * + * @return ResponseInterface + */ + public function create() + { + // + } + + /** + * Return the editable properties of a resource object + * + * @return ResponseInterface + */ + public function edit($id = null) + { + // + } + + /** + * Add or update a model resource, from "posted" properties + * + * @return ResponseInterface + */ + public function update($id = null) + { + // + } + + /** + * Delete the designated resource object from the model + * + * @return ResponseInterface + */ + public function delete($id = null) + { + // + } + + /** + * Present a view of resource objects + * + * @return ResponseInterface + */ + public function index() + { + // + } + + /** + * Present a view to present a specific resource object + * + * @param string $id + * + * @return ResponseInterface + */ + public function show($id = null) + { + // + } + + /** + * Present a view to present a new single resource object + * + * @return mixed + */ + public function new() + { + // + } + + /** + * Process the creation/insertion of a new resource object. + * This should be a POST. + * + * @return mixed + */ + public function create() + { + // + } + + /** + * Present a view to edit the properties of a specific resource object + * + * @param mixed $id + * + * @return mixed + */ + public function edit($id = null) + { + // + } + + /** + * Process the updating, full or partial, of a specific resource object. + * This should be a POST. + * + * @param mixed $id + * + * @return mixed + */ + public function update($id = null) + { + // + } + + /** + * Present a view to confirm the deletion of a specific resource object + * + * @param mixed $id + * + * @return mixed + */ + public function remove($id = null) + { + // + } + + /** + * Process the deletion of a specific resource object + * + * @param mixed $id + * + * @return mixed + */ + public function delete($id = null) + { + // + } + + public function index() + { + // + } + +} diff --git a/system/Commands/Generators/Views/entity.tpl.php b/system/Commands/Generators/Views/entity.tpl.php new file mode 100644 index 0000000..c74c776 --- /dev/null +++ b/system/Commands/Generators/Views/entity.tpl.php @@ -0,0 +1,12 @@ +<@php + +namespace {namespace}; + +use CodeIgniter\Entity\Entity; + +class {class} extends Entity +{ + protected $datamap = []; + protected $dates = ['created_at', 'updated_at', 'deleted_at']; + protected $casts = []; +} diff --git a/system/Commands/Generators/Views/filter.tpl.php b/system/Commands/Generators/Views/filter.tpl.php new file mode 100644 index 0000000..767ac0b --- /dev/null +++ b/system/Commands/Generators/Views/filter.tpl.php @@ -0,0 +1,47 @@ +<@php + +namespace {namespace}; + +use CodeIgniter\Filters\FilterInterface; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +class {class} implements FilterInterface +{ + /** + * Do whatever processing this filter needs to do. + * By default it should not return anything during + * normal execution. However, when an abnormal state + * is found, it should return an instance of + * CodeIgniter\HTTP\Response. If it does, script + * execution will end and that Response will be + * sent back to the client, allowing for error pages, + * redirects, etc. + * + * @param RequestInterface $request + * @param array|null $arguments + * + * @return RequestInterface|ResponseInterface|string|void + */ + public function before(RequestInterface $request, $arguments = null) + { + // + } + + /** + * Allows After filters to inspect and modify the response + * object as needed. This method does not allow any way + * to stop execution of other after filters, short of + * throwing an Exception or Error. + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @param array|null $arguments + * + * @return ResponseInterface|void + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + // + } +} diff --git a/system/Commands/Generators/Views/migration.tpl.php b/system/Commands/Generators/Views/migration.tpl.php new file mode 100644 index 0000000..321895e --- /dev/null +++ b/system/Commands/Generators/Views/migration.tpl.php @@ -0,0 +1,50 @@ +<@php + +namespace {namespace}; + +use CodeIgniter\Database\Migration; + +class {class} extends Migration +{ + + protected $DBGroup = ''; + + public function up() + { + $this->forge->addField([ + 'id' => ['type' => 'VARCHAR', 'constraint' => 128, 'null' => false], + + 'ip_address' => ['type' => 'VARCHAR', 'constraint' => 45, 'null' => false], + 'timestamp timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL', + 'data' => ['type' => 'BLOB', 'null' => false], + + 'ip_address inet NOT NULL', + 'timestamp timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL', + "data bytea DEFAULT '' NOT NULL", + + ]); + + $this->forge->addKey(['id', 'ip_address'], true); + + $this->forge->addKey('id', true); + + $this->forge->addKey('timestamp'); + $this->forge->createTable('', true); + } + + public function down() + { + $this->forge->dropTable('', true); + } + + public function up() + { + // + } + + public function down() + { + // + } + +} diff --git a/system/Commands/Generators/Views/model.tpl.php b/system/Commands/Generators/Views/model.tpl.php new file mode 100644 index 0000000..72509cd --- /dev/null +++ b/system/Commands/Generators/Views/model.tpl.php @@ -0,0 +1,45 @@ +<@php + +namespace {namespace}; + +use CodeIgniter\Model; + +class {class} extends Model +{ + + protected $DBGroup = '{dbGroup}'; + + protected $table = '{table}'; + protected $primaryKey = 'id'; + protected $useAutoIncrement = true; + protected $returnType = {return}; + protected $useSoftDeletes = false; + protected $protectFields = true; + protected $allowedFields = []; + + protected bool $allowEmptyInserts = false; + + // Dates + protected $useTimestamps = false; + protected $dateFormat = 'datetime'; + protected $createdField = 'created_at'; + protected $updatedField = 'updated_at'; + protected $deletedField = 'deleted_at'; + + // Validation + protected $validationRules = []; + protected $validationMessages = []; + protected $skipValidation = false; + protected $cleanValidationRules = true; + + // Callbacks + protected $allowCallbacks = true; + protected $beforeInsert = []; + protected $afterInsert = []; + protected $beforeUpdate = []; + protected $afterUpdate = []; + protected $beforeFind = []; + protected $afterFind = []; + protected $beforeDelete = []; + protected $afterDelete = []; +} diff --git a/system/Commands/Generators/Views/seeder.tpl.php b/system/Commands/Generators/Views/seeder.tpl.php new file mode 100644 index 0000000..6f21628 --- /dev/null +++ b/system/Commands/Generators/Views/seeder.tpl.php @@ -0,0 +1,13 @@ +<@php + +namespace {namespace}; + +use CodeIgniter\Database\Seeder; + +class {class} extends Seeder +{ + public function run() + { + // + } +} diff --git a/system/Commands/Generators/Views/validation.tpl.php b/system/Commands/Generators/Views/validation.tpl.php new file mode 100644 index 0000000..e040a74 --- /dev/null +++ b/system/Commands/Generators/Views/validation.tpl.php @@ -0,0 +1,11 @@ +<@php + +namespace {namespace}; + +class {class} +{ + // public function custom_rule(): bool + // { + // return true; + // } +} diff --git a/system/Commands/Help.php b/system/Commands/Help.php new file mode 100644 index 0000000..338a5c8 --- /dev/null +++ b/system/Commands/Help.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands; + +use CodeIgniter\CLI\BaseCommand; + +/** + * CI Help command for the spark script. + * + * Lists the basic usage information for the spark script, + * and provides a way to list help for other commands. + */ +class Help extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'CodeIgniter'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'help'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Displays basic usage information.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = 'help []'; + + /** + * the Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'command_name' => 'The command name [default: "help"]', + ]; + + /** + * the Command's Options + * + * @var array + */ + protected $options = []; + + /** + * Displays the help for spark commands. + */ + public function run(array $params) + { + $command = array_shift($params); + $command ??= 'help'; + $commands = $this->commands->getCommands(); + + if (! $this->commands->verifyCommand($command, $commands)) { + return; + } + + $class = new $commands[$command]['class']($this->logger, $this->commands); + $class->showHelp(); + } +} diff --git a/system/Commands/Housekeeping/ClearDebugbar.php b/system/Commands/Housekeeping/ClearDebugbar.php new file mode 100644 index 0000000..da6f76a --- /dev/null +++ b/system/Commands/Housekeeping/ClearDebugbar.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Housekeeping; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; + +/** + * ClearDebugbar Command + */ +class ClearDebugbar extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'Housekeeping'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'debugbar:clear'; + + /** + * The Command's usage + * + * @var string + */ + protected $usage = 'debugbar:clear'; + + /** + * The Command's short description. + * + * @var string + */ + protected $description = 'Clears all debugbar JSON files.'; + + /** + * Actually runs the command. + */ + public function run(array $params) + { + helper('filesystem'); + + if (! delete_files(WRITEPATH . 'debugbar')) { + // @codeCoverageIgnoreStart + CLI::error('Error deleting the debugbar JSON files.'); + CLI::newLine(); + + return; + // @codeCoverageIgnoreEnd + } + + CLI::write('Debugbar cleared.', 'green'); + CLI::newLine(); + } +} diff --git a/system/Commands/Housekeeping/ClearLogs.php b/system/Commands/Housekeeping/ClearLogs.php new file mode 100644 index 0000000..56cfeab --- /dev/null +++ b/system/Commands/Housekeeping/ClearLogs.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Housekeeping; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; + +/** + * ClearLogs command. + */ +class ClearLogs extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'Housekeeping'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'logs:clear'; + + /** + * The Command's short description + * + * @var string + */ + protected $description = 'Clears all log files.'; + + /** + * The Command's usage + * + * @var string + */ + protected $usage = 'logs:clear [option'; + + /** + * The Command's options + * + * @var array + */ + protected $options = [ + '--force' => 'Force delete of all logs files without prompting.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $force = array_key_exists('force', $params) || CLI::getOption('force'); + + if (! $force && CLI::prompt('Are you sure you want to delete the logs?', ['n', 'y']) === 'n') { + // @codeCoverageIgnoreStart + CLI::error('Deleting logs aborted.', 'light_gray', 'red'); + CLI::error('If you want, use the "-force" option to force delete all log files.', 'light_gray', 'red'); + CLI::newLine(); + + return; + // @codeCoverageIgnoreEnd + } + + helper('filesystem'); + + if (! delete_files(WRITEPATH . 'logs', false, true)) { + // @codeCoverageIgnoreStart + CLI::error('Error in deleting the logs files.', 'light_gray', 'red'); + CLI::newLine(); + + return; + // @codeCoverageIgnoreEnd + } + + CLI::write('Logs cleared.', 'green'); + CLI::newLine(); + } +} diff --git a/system/Commands/ListCommands.php b/system/Commands/ListCommands.php new file mode 100644 index 0000000..9accecf --- /dev/null +++ b/system/Commands/ListCommands.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; + +/** + * CI Help command for the spark script. + * + * Lists the basic usage information for the spark script, + * and provides a way to list help for other commands. + */ +class ListCommands extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'CodeIgniter'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'list'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Lists the available commands.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = 'list'; + + /** + * the Command's Arguments + * + * @var array + */ + protected $arguments = []; + + /** + * the Command's Options + * + * @var array + */ + protected $options = [ + '--simple' => 'Prints a list of the commands with no other info', + ]; + + /** + * Displays the help for the spark cli script itself. + */ + public function run(array $params) + { + $commands = $this->commands->getCommands(); + ksort($commands); + + // Check for 'simple' format + return array_key_exists('simple', $params) || CLI::getOption('simple') + ? $this->listSimple($commands) + : $this->listFull($commands); + } + + /** + * Lists the commands with accompanying info. + */ + protected function listFull(array $commands) + { + // Sort into buckets by group + $groups = []; + + foreach ($commands as $title => $command) { + if (! isset($groups[$command['group']])) { + $groups[$command['group']] = []; + } + + $groups[$command['group']][$title] = $command; + } + + $length = max(array_map('strlen', array_keys($commands))); + + ksort($groups); + + // Display it all... + foreach ($groups as $group => $commands) { + CLI::write($group, 'yellow'); + + foreach ($commands as $name => $command) { + $name = $this->setPad($name, $length, 2, 2); + $output = CLI::color($name, 'green'); + + if (isset($command['description'])) { + $output .= CLI::wrap($command['description'], 125, strlen($name)); + } + + CLI::write($output); + } + + if ($group !== array_key_last($groups)) { + CLI::newLine(); + } + } + } + + /** + * Lists the commands only. + */ + protected function listSimple(array $commands) + { + foreach (array_keys($commands) as $title) { + CLI::write($title); + } + } +} diff --git a/system/Commands/Server/Serve.php b/system/Commands/Server/Serve.php new file mode 100644 index 0000000..b538202 --- /dev/null +++ b/system/Commands/Server/Serve.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Server; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; + +/** + * Launch the PHP development server + * + * Not testable, as it throws phpunit for a loop :-/ + * + * @codeCoverageIgnore + */ +class Serve extends BaseCommand +{ + /** + * Group + * + * @var string + */ + protected $group = 'CodeIgniter'; + + /** + * Name + * + * @var string + */ + protected $name = 'serve'; + + /** + * Description + * + * @var string + */ + protected $description = 'Launches the CodeIgniter PHP-Development Server.'; + + /** + * Usage + * + * @var string + */ + protected $usage = 'serve'; + + /** + * Arguments + * + * @var array + */ + protected $arguments = []; + + /** + * The current port offset. + * + * @var int + */ + protected $portOffset = 0; + + /** + * The max number of ports to attempt to serve from + * + * @var int + */ + protected $tries = 10; + + /** + * Options + * + * @var array + */ + protected $options = [ + '--php' => 'The PHP Binary [default: "PHP_BINARY"]', + '--host' => 'The HTTP Host [default: "localhost"]', + '--port' => 'The HTTP Host Port [default: "8080"]', + ]; + + /** + * Run the server + */ + public function run(array $params) + { + // Collect any user-supplied options and apply them. + $php = escapeshellarg(CLI::getOption('php') ?? PHP_BINARY); + $host = CLI::getOption('host') ?? 'localhost'; + $port = (int) (CLI::getOption('port') ?? 8080) + $this->portOffset; + + // Get the party started. + CLI::write('CodeIgniter development server started on http://' . $host . ':' . $port, 'green'); + CLI::write('Press Control-C to stop.'); + + // Set the Front Controller path as Document Root. + $docroot = escapeshellarg(FCPATH); + + // Mimic Apache's mod_rewrite functionality with user settings. + $rewrite = escapeshellarg(__DIR__ . '/rewrite.php'); + + // Call PHP's built-in webserver, making sure to set our + // base path to the public folder, and to use the rewrite file + // to ensure our environment is set and it simulates basic mod_rewrite. + passthru($php . ' -S ' . $host . ':' . $port . ' -t ' . $docroot . ' ' . $rewrite, $status); + + if ($status && $this->portOffset < $this->tries) { + $this->portOffset++; + + $this->run($params); + } + } +} diff --git a/system/Commands/Server/rewrite.php b/system/Commands/Server/rewrite.php new file mode 100644 index 0000000..45a1093 --- /dev/null +++ b/system/Commands/Server/rewrite.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +/* + * CodeIgniter PHP-Development Server Rewrite Rules + * + * This script works with the CLI serve command to help run a seamless + * development server based around PHP's built-in development + * server. This file simply tries to mimic Apache's mod_rewrite + * functionality so the site will operate as normal. + */ + +// @codeCoverageIgnoreStart +// Avoid this file run when listing commands +if (PHP_SAPI === 'cli') { + return; +} + +$uri = urldecode( + parse_url('https://codeigniter.com' . $_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '' +); + +// All request handle by index.php file. +$_SERVER['SCRIPT_NAME'] = '/index.php'; + +// Full path +$path = $_SERVER['DOCUMENT_ROOT'] . DIRECTORY_SEPARATOR . ltrim($uri, '/'); + +// If $path is an existing file or folder within the public folder +// then let the request handle it like normal. +if ($uri !== '/' && (is_file($path) || is_dir($path))) { + return false; +} + +unset($uri, $path); + +// Otherwise, we'll load the index file and let +// the framework handle the request from here. +require_once $_SERVER['DOCUMENT_ROOT'] . DIRECTORY_SEPARATOR . 'index.php'; +// @codeCoverageIgnoreEnd diff --git a/system/Commands/Utilities/Environment.php b/system/Commands/Utilities/Environment.php new file mode 100644 index 0000000..fd8f68f --- /dev/null +++ b/system/Commands/Utilities/Environment.php @@ -0,0 +1,155 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Config\DotEnv; + +/** + * Command to display the current environment, + * or set a new one in the `.env` file. + */ +final class Environment extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'CodeIgniter'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'env'; + + /** + * The Command's short description + * + * @var string + */ + protected $description = 'Retrieves the current environment, or set a new one.'; + + /** + * The Command's usage + * + * @var string + */ + protected $usage = 'env []'; + + /** + * The Command's arguments + * + * @var array + */ + protected $arguments = [ + 'environment' => '[Optional] The new environment to set. If none is provided, this will print the current environment.', + ]; + + /** + * The Command's options + * + * @var array + */ + protected $options = []; + + /** + * Allowed values for environment. `testing` is excluded + * since spark won't work on it. + * + * @var array + */ + private static array $knownTypes = [ + 'production', + 'development', + ]; + + /** + * {@inheritDoc} + */ + public function run(array $params) + { + if ($params === []) { + CLI::write(sprintf('Your environment is currently set as %s.', CLI::color($_SERVER['CI_ENVIRONMENT'] ?? ENVIRONMENT, 'green'))); + CLI::newLine(); + + return; + } + + $env = strtolower(array_shift($params)); + + if ($env === 'testing') { + CLI::error('The "testing" environment is reserved for PHPUnit testing.', 'light_gray', 'red'); + CLI::error('You will not be able to run spark under a "testing" environment.', 'light_gray', 'red'); + CLI::newLine(); + + return; + } + + if (! in_array($env, self::$knownTypes, true)) { + CLI::error(sprintf('Invalid environment type "%s". Expected one of "%s".', $env, implode('" and "', self::$knownTypes)), 'light_gray', 'red'); + CLI::newLine(); + + return; + } + + if (! $this->writeNewEnvironmentToEnvFile($env)) { + CLI::error('Error in writing new environment to .env file.', 'light_gray', 'red'); + CLI::newLine(); + + return; + } + + // force DotEnv to reload the new environment + // however we cannot redefine the ENVIRONMENT constant + putenv('CI_ENVIRONMENT'); + unset($_ENV['CI_ENVIRONMENT'], $_SERVER['CI_ENVIRONMENT']); + (new DotEnv(ROOTPATH))->load(); + + CLI::write(sprintf('Environment is successfully changed to "%s".', $env), 'green'); + CLI::write('The ENVIRONMENT constant will be changed in the next script execution.'); + CLI::newLine(); + } + + /** + * @see https://regex101.com/r/4sSORp/1 for the regex in action + */ + private function writeNewEnvironmentToEnvFile(string $newEnv): bool + { + $baseEnv = ROOTPATH . 'env'; + $envFile = ROOTPATH . '.env'; + + if (! is_file($envFile)) { + if (! is_file($baseEnv)) { + CLI::write('Both default shipped `env` file and custom `.env` are missing.', 'yellow'); + CLI::write('It is impossible to write the new environment type.', 'yellow'); + CLI::newLine(); + + return false; + } + + copy($baseEnv, $envFile); + } + + $pattern = preg_quote($_SERVER['CI_ENVIRONMENT'] ?? ENVIRONMENT, '/'); + $pattern = sprintf('/^[#\s]*CI_ENVIRONMENT[=\s]+%s$/m', $pattern); + + return file_put_contents( + $envFile, + preg_replace($pattern, "\nCI_ENVIRONMENT = {$newEnv}", file_get_contents($envFile), -1, $count) + ) !== false && $count > 0; + } +} diff --git a/system/Commands/Utilities/FilterCheck.php b/system/Commands/Utilities/FilterCheck.php new file mode 100644 index 0000000..1b9937d --- /dev/null +++ b/system/Commands/Utilities/FilterCheck.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Commands\Utilities\Routes\FilterCollector; +use Config\Services; + +/** + * Check filters for a route. + */ +class FilterCheck extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'CodeIgniter'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'filter:check'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Check filters for a route.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = 'filter:check '; + + /** + * the Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'method' => 'The HTTP method. get, post, put, etc.', + 'route' => 'The route (URI path) to check filters.', + ]; + + /** + * the Command's Options + * + * @var array + */ + protected $options = []; + + /** + * @return int exit code + */ + public function run(array $params) + { + $tbody = []; + if (! isset($params[0], $params[1])) { + CLI::error('You must specify a HTTP verb and a route.'); + CLI::write(' Usage: ' . $this->usage); + CLI::write('Example: filter:check get /'); + CLI::write(' filter:check put products/1'); + + return EXIT_ERROR; + } + + $method = strtolower($params[0]); + $route = $params[1]; + + // Load Routes + Services::routes()->loadRoutes(); + + $filterCollector = new FilterCollector(); + + $filters = $filterCollector->get($method, $route); + + // PageNotFoundException + if ($filters['before'] === ['']) { + CLI::error( + "Can't find a route: " . + CLI::color( + '"' . strtoupper($method) . ' ' . $route . '"', + 'black', + 'light_gray' + ), + ); + + return EXIT_ERROR; + } + + $tbody[] = [ + strtoupper($method), + $route, + implode(' ', $filters['before']), + implode(' ', $filters['after']), + ]; + + $thead = [ + 'Method', + 'Route', + 'Before Filters', + 'After Filters', + ]; + + CLI::table($tbody, $thead); + + return EXIT_SUCCESS; + } +} diff --git a/system/Commands/Utilities/Namespaces.php b/system/Commands/Utilities/Namespaces.php new file mode 100644 index 0000000..7146193 --- /dev/null +++ b/system/Commands/Utilities/Namespaces.php @@ -0,0 +1,159 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use Config\Autoload; +use Config\Services; + +/** + * Lists namespaces set in Config\Autoload with their + * full server path. Helps you to verify that you have + * the namespaces setup correctly. + * + * @see \CodeIgniter\Commands\Utilities\NamespacesTest + */ +class Namespaces extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'CodeIgniter'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'namespaces'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Verifies your namespaces are setup correctly.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = 'namespaces'; + + /** + * the Command's Arguments + * + * @var array + */ + protected $arguments = []; + + /** + * the Command's Options + * + * @var array + */ + protected $options = [ + '-c' => 'Show only CodeIgniter config namespaces.', + '-r' => 'Show raw path strings.', + '-m' => 'Specify max length of the path strings to output. Default: 60.', + ]; + + /** + * Displays the help for the spark cli script itself. + */ + public function run(array $params) + { + $params['m'] = (int) ($params['m'] ?? 60); + + $tbody = array_key_exists('c', $params) ? $this->outputCINamespaces($params) : $this->outputAllNamespaces($params); + + $thead = [ + 'Namespace', + 'Path', + 'Found?', + ]; + + CLI::table($tbody, $thead); + } + + private function outputAllNamespaces(array $params): array + { + $maxLength = $params['m']; + + $autoloader = Services::autoloader(); + + $tbody = []; + + foreach ($autoloader->getNamespace() as $ns => $paths) { + foreach ($paths as $path) { + if (array_key_exists('r', $params)) { + $pathOutput = $this->truncate($path, $maxLength); + } else { + $pathOutput = $this->truncate(clean_path($path), $maxLength); + } + + $tbody[] = [ + $ns, + $pathOutput, + is_dir($path) ? 'Yes' : 'MISSING', + ]; + } + } + + return $tbody; + } + + private function truncate(string $string, int $max): string + { + $length = strlen($string); + + if ($length > $max) { + return substr($string, 0, $max - 3) . '...'; + } + + return $string; + } + + private function outputCINamespaces(array $params): array + { + $maxLength = $params['m']; + + $config = new Autoload(); + + $tbody = []; + + foreach ($config->psr4 as $ns => $paths) { + if (array_key_exists('r', $params)) { + $pathOutput = $this->truncate($paths, $maxLength); + } else { + $pathOutput = $this->truncate(clean_path($paths), $maxLength); + } + + foreach ((array) $paths as $path) { + $path = realpath($path) ?: $path; + + $tbody[] = [ + $ns, + $pathOutput, + is_dir($path) ? 'Yes' : 'MISSING', + ]; + } + } + + return $tbody; + } +} diff --git a/system/Commands/Utilities/Publish.php b/system/Commands/Utilities/Publish.php new file mode 100644 index 0000000..1e4103c --- /dev/null +++ b/system/Commands/Utilities/Publish.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Publisher\Publisher; + +/** + * Discovers all Publisher classes from the "Publishers/" directory + * across namespaces. Executes `publish()` from each instance, parsing + * each result. + */ +class Publish extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'CodeIgniter'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'publish'; + + /** + * The Command's short description + * + * @var string + */ + protected $description = 'Discovers and executes all predefined Publisher classes.'; + + /** + * The Command's usage + * + * @var string + */ + protected $usage = 'publish []'; + + /** + * The Command's arguments + * + * @var array + */ + protected $arguments = [ + 'directory' => '[Optional] The directory to scan within each namespace. Default: "Publishers".', + ]; + + /** + * the Command's Options + * + * @var array + */ + protected $options = []; + + /** + * Displays the help for the spark cli script itself. + */ + public function run(array $params) + { + $directory = array_shift($params) ?? 'Publishers'; + + if ([] === $publishers = Publisher::discover($directory)) { + CLI::write(lang('Publisher.publishMissing', [$directory])); + + return; + } + + foreach ($publishers as $publisher) { + if ($publisher->publish()) { + CLI::write(lang('Publisher.publishSuccess', [ + get_class($publisher), + count($publisher->getPublished()), + $publisher->getDestination(), + ]), 'green'); + } else { + CLI::error(lang('Publisher.publishFailure', [ + get_class($publisher), + $publisher->getDestination(), + ]), 'light_gray', 'red'); + + foreach ($publisher->getErrors() as $file => $exception) { + CLI::write($file); + CLI::error($exception->getMessage()); + CLI::newLine(); + } + } + } + } +} diff --git a/system/Commands/Utilities/Routes.php b/system/Commands/Utilities/Routes.php new file mode 100644 index 0000000..5d20da0 --- /dev/null +++ b/system/Commands/Utilities/Routes.php @@ -0,0 +1,206 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Commands\Utilities\Routes\AutoRouteCollector; +use CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved\AutoRouteCollector as AutoRouteCollectorImproved; +use CodeIgniter\Commands\Utilities\Routes\FilterCollector; +use CodeIgniter\Commands\Utilities\Routes\SampleURIGenerator; +use CodeIgniter\Router\DefinedRouteCollector; +use Config\Feature; +use Config\Routing; +use Config\Services; + +/** + * Lists all the routes. This will include any Routes files + * that can be discovered, and will include routes that are not defined + * in routes files, but are instead discovered through auto-routing. + */ +class Routes extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'CodeIgniter'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'routes'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Displays all routes.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = 'routes'; + + /** + * the Command's Arguments + * + * @var array + */ + protected $arguments = []; + + /** + * the Command's Options + * + * @var array + */ + protected $options = [ + '-h' => 'Sort by Handler.', + '--host' => 'Specify hostname in request URI.', + ]; + + /** + * Displays the help for the spark cli script itself. + */ + public function run(array $params) + { + $sortByHandler = array_key_exists('h', $params); + $host = $params['host'] ?? null; + + // Set HTTP_HOST + if ($host) { + $request = Services::request(); + $_SERVER = $request->getServer(); + $_SERVER['HTTP_HOST'] = $host; + $request->setGlobal('server', $_SERVER); + } + + $collection = Services::routes()->loadRoutes(); + + // Reset HTTP_HOST + if ($host) { + unset($_SERVER['HTTP_HOST']); + } + + $methods = [ + 'get', + 'head', + 'post', + 'patch', + 'put', + 'delete', + 'options', + 'trace', + 'connect', + 'cli', + ]; + + $tbody = []; + $uriGenerator = new SampleURIGenerator(); + $filterCollector = new FilterCollector(); + + $definedRouteCollector = new DefinedRouteCollector($collection); + + foreach ($definedRouteCollector->collect() as $route) { + $sampleUri = $uriGenerator->get($route['route']); + $filters = $filterCollector->get($route['method'], $sampleUri); + + $routeName = ($route['route'] === $route['name']) ? '»' : $route['name']; + + $tbody[] = [ + strtoupper($route['method']), + $route['route'], + $routeName, + $route['handler'], + implode(' ', array_map('class_basename', $filters['before'])), + implode(' ', array_map('class_basename', $filters['after'])), + ]; + } + + if ($collection->shouldAutoRoute()) { + $autoRoutesImproved = config(Feature::class)->autoRoutesImproved ?? false; + + if ($autoRoutesImproved) { + $autoRouteCollector = new AutoRouteCollectorImproved( + $collection->getDefaultNamespace(), + $collection->getDefaultController(), + $collection->getDefaultMethod(), + $methods, + $collection->getRegisteredControllers('*') + ); + + $autoRoutes = $autoRouteCollector->get(); + + // Check for Module Routes. + if ($routingConfig = config(Routing::class)) { + foreach ($routingConfig->moduleRoutes as $uri => $namespace) { + $autoRouteCollector = new AutoRouteCollectorImproved( + $namespace, + $collection->getDefaultController(), + $collection->getDefaultMethod(), + $methods, + $collection->getRegisteredControllers('*'), + $uri + ); + + $autoRoutes = [...$autoRoutes, ...$autoRouteCollector->get()]; + } + } + } else { + $autoRouteCollector = new AutoRouteCollector( + $collection->getDefaultNamespace(), + $collection->getDefaultController(), + $collection->getDefaultMethod() + ); + + $autoRoutes = $autoRouteCollector->get(); + + foreach ($autoRoutes as &$routes) { + // There is no `auto` method, but it is intentional not to get route filters. + $filters = $filterCollector->get('auto', $uriGenerator->get($routes[1])); + + $routes[] = implode(' ', array_map('class_basename', $filters['before'])); + $routes[] = implode(' ', array_map('class_basename', $filters['after'])); + } + } + + $tbody = [...$tbody, ...$autoRoutes]; + } + + $thead = [ + 'Method', + 'Route', + 'Name', + $sortByHandler ? 'Handler ↓' : 'Handler', + 'Before Filters', + 'After Filters', + ]; + + // Sort by Handler. + if ($sortByHandler) { + usort($tbody, static fn ($handler1, $handler2) => strcmp($handler1[3], $handler2[3])); + } + + if ($host) { + CLI::write('Host: ' . $host); + } + + CLI::table($tbody, $thead); + } +} diff --git a/system/Commands/Utilities/Routes/AutoRouteCollector.php b/system/Commands/Utilities/Routes/AutoRouteCollector.php new file mode 100644 index 0000000..cc45608 --- /dev/null +++ b/system/Commands/Utilities/Routes/AutoRouteCollector.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +/** + * Collects data for auto route listing. + * + * @see \CodeIgniter\Commands\Utilities\Routes\AutoRouteCollectorTest + */ +final class AutoRouteCollector +{ + /** + * @var string namespace to search + */ + private string $namespace; + + private string $defaultController; + private string $defaultMethod; + + /** + * @param string $namespace namespace to search + */ + public function __construct(string $namespace, string $defaultController, string $defaultMethod) + { + $this->namespace = $namespace; + $this->defaultController = $defaultController; + $this->defaultMethod = $defaultMethod; + } + + /** + * @return list> + */ + public function get(): array + { + $finder = new ControllerFinder($this->namespace); + $reader = new ControllerMethodReader($this->namespace); + + $tbody = []; + + foreach ($finder->find() as $class) { + $output = $reader->read( + $class, + $this->defaultController, + $this->defaultMethod + ); + + foreach ($output as $item) { + $tbody[] = [ + 'auto', + $item['route'], + '', + $item['handler'], + ]; + } + } + + return $tbody; + } +} diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php new file mode 100644 index 0000000..18a158c --- /dev/null +++ b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved; + +use CodeIgniter\Commands\Utilities\Routes\ControllerFinder; +use CodeIgniter\Commands\Utilities\Routes\FilterCollector; + +/** + * Collects data for Auto Routing Improved. + * + * @see \CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved\AutoRouteCollectorTest + */ +final class AutoRouteCollector +{ + /** + * @var string namespace to search + */ + private string $namespace; + + private string $defaultController; + private string $defaultMethod; + private array $httpMethods; + + /** + * List of controllers in Defined Routes that should not be accessed via Auto-Routing. + * + * @var class-string[] + */ + private array $protectedControllers; + + /** + * @var string URI prefix for Module Routing + */ + private string $prefix; + + /** + * @param string $namespace namespace to search + */ + public function __construct( + string $namespace, + string $defaultController, + string $defaultMethod, + array $httpMethods, + array $protectedControllers, + string $prefix = '' + ) { + $this->namespace = $namespace; + $this->defaultController = $defaultController; + $this->defaultMethod = $defaultMethod; + $this->httpMethods = $httpMethods; + $this->protectedControllers = $protectedControllers; + $this->prefix = $prefix; + } + + /** + * @return list> + */ + public function get(): array + { + $finder = new ControllerFinder($this->namespace); + $reader = new ControllerMethodReader($this->namespace, $this->httpMethods); + + $tbody = []; + + foreach ($finder->find() as $class) { + // Exclude controllers in Defined Routes. + if (in_array('\\' . $class, $this->protectedControllers, true)) { + continue; + } + + $routes = $reader->read( + $class, + $this->defaultController, + $this->defaultMethod + ); + + if ($routes === []) { + continue; + } + + $routes = $this->addFilters($routes); + + foreach ($routes as $item) { + $route = $item['route'] . $item['route_params']; + + // For module routing + if ($this->prefix !== '' && $route === '/') { + $route = $this->prefix; + } elseif ($this->prefix !== '') { + $route = $this->prefix . '/' . $route; + } + + $tbody[] = [ + strtoupper($item['method']) . '(auto)', + $route, + '', + $item['handler'], + $item['before'], + $item['after'], + ]; + } + } + + return $tbody; + } + + private function addFilters($routes) + { + $filterCollector = new FilterCollector(true); + + foreach ($routes as &$route) { + $routePath = $route['route']; + + // For module routing + if ($this->prefix !== '' && $route === '/') { + $routePath = $this->prefix; + } elseif ($this->prefix !== '') { + $routePath = $this->prefix . '/' . $routePath; + } + + // Search filters for the URI with all params + $sampleUri = $this->generateSampleUri($route); + $filtersLongest = $filterCollector->get($route['method'], $routePath . $sampleUri); + + // Search filters for the URI without optional params + $sampleUri = $this->generateSampleUri($route, false); + $filtersShortest = $filterCollector->get($route['method'], $routePath . $sampleUri); + + // Get common array elements + $filters['before'] = array_intersect($filtersLongest['before'], $filtersShortest['before']); + $filters['after'] = array_intersect($filtersLongest['after'], $filtersShortest['after']); + + $route['before'] = implode(' ', array_map('class_basename', $filters['before'])); + $route['after'] = implode(' ', array_map('class_basename', $filters['after'])); + } + + return $routes; + } + + private function generateSampleUri(array $route, bool $longest = true): string + { + $sampleUri = ''; + + if (isset($route['params'])) { + $i = 1; + + foreach ($route['params'] as $required) { + if ($longest && ! $required) { + $sampleUri .= '/' . $i++; + } + } + } + + return $sampleUri; + } +} diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php new file mode 100644 index 0000000..a077633 --- /dev/null +++ b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php @@ -0,0 +1,241 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved; + +use Config\Routing; +use ReflectionClass; +use ReflectionMethod; + +/** + * Reads a controller and returns a list of auto route listing. + * + * @see \CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved\ControllerMethodReaderTest + */ +final class ControllerMethodReader +{ + /** + * @var string the default namespace + */ + private string $namespace; + + /** + * @var list + */ + private array $httpMethods; + + private bool $translateURIDashes; + + /** + * @param string $namespace the default namespace + */ + public function __construct(string $namespace, array $httpMethods) + { + $this->namespace = $namespace; + $this->httpMethods = $httpMethods; + + $config = config(Routing::class); + $this->translateURIDashes = $config->translateURIDashes; + } + + /** + * Returns found route info in the controller. + * + * @param class-string $class + * + * @return list> + */ + public function read(string $class, string $defaultController = 'Home', string $defaultMethod = 'index'): array + { + $reflection = new ReflectionClass($class); + + if ($reflection->isAbstract()) { + return []; + } + + $classname = $reflection->getName(); + $classShortname = $reflection->getShortName(); + + $output = []; + $classInUri = $this->getUriByClass($classname); + + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $methodName = $method->getName(); + + foreach ($this->httpMethods as $httpVerb) { + if (strpos($methodName, $httpVerb) === 0) { + // Remove HTTP verb prefix. + $methodInUri = $this->getUriByMethod($httpVerb, $methodName); + + // Check if it is the default method. + if ($methodInUri === $defaultMethod) { + $routeForDefaultController = $this->getRouteForDefaultController( + $classShortname, + $defaultController, + $classInUri, + $classname, + $methodName, + $httpVerb, + $method + ); + + if ($routeForDefaultController !== []) { + // The controller is the default controller. It only + // has a route for the default method. Other methods + // will not be routed even if they exist. + $output = [...$output, ...$routeForDefaultController]; + + continue; + } + + [$params, $routeParams] = $this->getParameters($method); + + // Route for the default method. + $output[] = [ + 'method' => $httpVerb, + 'route' => $classInUri, + 'route_params' => $routeParams, + 'handler' => '\\' . $classname . '::' . $methodName, + 'params' => $params, + ]; + + continue; + } + + $route = $classInUri . '/' . $methodInUri; + + [$params, $routeParams] = $this->getParameters($method); + + // If it is the default controller, the method will not be + // routed. + if ($classShortname === $defaultController) { + $route = 'x ' . $route; + } + + $output[] = [ + 'method' => $httpVerb, + 'route' => $route, + 'route_params' => $routeParams, + 'handler' => '\\' . $classname . '::' . $methodName, + 'params' => $params, + ]; + } + } + } + + return $output; + } + + private function getParameters(ReflectionMethod $method): array + { + $params = []; + $routeParams = ''; + $refParams = $method->getParameters(); + + foreach ($refParams as $param) { + $required = true; + if ($param->isOptional()) { + $required = false; + + $routeParams .= '[/..]'; + } else { + $routeParams .= '/..'; + } + + // [variable_name => required?] + $params[$param->getName()] = $required; + } + + return [$params, $routeParams]; + } + + /** + * @param class-string $classname + * + * @return string URI path part from the folder(s) and controller + */ + private function getUriByClass(string $classname): string + { + // remove the namespace + $pattern = '/' . preg_quote($this->namespace, '/') . '/'; + $class = ltrim(preg_replace($pattern, '', $classname), '\\'); + + $classParts = explode('\\', $class); + $classPath = ''; + + foreach ($classParts as $part) { + // make the first letter lowercase, because auto routing makes + // the URI path's first letter uppercase and search the controller + $classPath .= lcfirst($part) . '/'; + } + + $classUri = rtrim($classPath, '/'); + + if ($this->translateURIDashes) { + $classUri = str_replace('_', '-', $classUri); + } + + return $classUri; + } + + /** + * @return string URI path part from the method + */ + private function getUriByMethod(string $httpVerb, string $methodName): string + { + $methodUri = lcfirst(substr($methodName, strlen($httpVerb))); + + if ($this->translateURIDashes) { + $methodUri = str_replace('_', '-', $methodUri); + } + + return $methodUri; + } + + /** + * Gets a route for the default controller. + * + * @return list + */ + private function getRouteForDefaultController( + string $classShortname, + string $defaultController, + string $uriByClass, + string $classname, + string $methodName, + string $httpVerb, + ReflectionMethod $method + ): array { + $output = []; + + if ($classShortname === $defaultController) { + $pattern = '#' . preg_quote(lcfirst($defaultController), '#') . '\z#'; + $routeWithoutController = rtrim(preg_replace($pattern, '', $uriByClass), '/'); + $routeWithoutController = $routeWithoutController ?: '/'; + + [$params, $routeParams] = $this->getParameters($method); + + if ($routeWithoutController === '/' && $routeParams !== '') { + $routeWithoutController = ''; + } + + $output[] = [ + 'method' => $httpVerb, + 'route' => $routeWithoutController, + 'route_params' => $routeParams, + 'handler' => '\\' . $classname . '::' . $methodName, + 'params' => $params, + ]; + } + + return $output; + } +} diff --git a/system/Commands/Utilities/Routes/ControllerFinder.php b/system/Commands/Utilities/Routes/ControllerFinder.php new file mode 100644 index 0000000..3982c45 --- /dev/null +++ b/system/Commands/Utilities/Routes/ControllerFinder.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +use CodeIgniter\Autoloader\FileLocator; +use CodeIgniter\Config\Services; + +/** + * Finds all controllers in a namespace for auto route listing. + * + * @see \CodeIgniter\Commands\Utilities\Routes\ControllerFinderTest + */ +final class ControllerFinder +{ + /** + * @var string namespace to search + */ + private string $namespace; + + private FileLocator $locator; + + /** + * @param string $namespace namespace to search + */ + public function __construct(string $namespace) + { + $this->namespace = $namespace; + $this->locator = Services::locator(); + } + + /** + * @return class-string[] + */ + public function find(): array + { + $nsArray = explode('\\', trim($this->namespace, '\\')); + $count = count($nsArray); + $ns = ''; + $files = []; + + for ($i = 0; $i < $count; $i++) { + $ns .= '\\' . array_shift($nsArray); + $path = implode('\\', $nsArray); + + $files = $this->locator->listNamespaceFiles($ns, $path); + + if ($files !== []) { + break; + } + } + + $classes = []; + + foreach ($files as $file) { + if (\is_file($file)) { + $classnameOrEmpty = $this->locator->getClassname($file); + + if ($classnameOrEmpty !== '') { + /** @var class-string $classname */ + $classname = $classnameOrEmpty; + + $classes[] = $classname; + } + } + } + + return $classes; + } +} diff --git a/system/Commands/Utilities/Routes/ControllerMethodReader.php b/system/Commands/Utilities/Routes/ControllerMethodReader.php new file mode 100644 index 0000000..31bcde1 --- /dev/null +++ b/system/Commands/Utilities/Routes/ControllerMethodReader.php @@ -0,0 +1,177 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +use ReflectionClass; +use ReflectionMethod; + +/** + * Reads a controller and returns a list of auto route listing. + * + * @see \CodeIgniter\Commands\Utilities\Routes\ControllerMethodReaderTest + */ +final class ControllerMethodReader +{ + /** + * @var string the default namespace + */ + private string $namespace; + + /** + * @param string $namespace the default namespace + */ + public function __construct(string $namespace) + { + $this->namespace = $namespace; + } + + /** + * @param class-string $class + * + * @return list + */ + public function read(string $class, string $defaultController = 'Home', string $defaultMethod = 'index'): array + { + $reflection = new ReflectionClass($class); + + if ($reflection->isAbstract()) { + return []; + } + + $classname = $reflection->getName(); + $classShortname = $reflection->getShortName(); + + $output = []; + $uriByClass = $this->getUriByClass($classname); + + if ($this->hasRemap($reflection)) { + $methodName = '_remap'; + + $routeWithoutController = $this->getRouteWithoutController( + $classShortname, + $defaultController, + $uriByClass, + $classname, + $methodName + ); + $output = [...$output, ...$routeWithoutController]; + + $output[] = [ + 'route' => $uriByClass . '[/...]', + 'handler' => '\\' . $classname . '::' . $methodName, + ]; + + return $output; + } + + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $methodName = $method->getName(); + + $route = $uriByClass . '/' . $methodName; + + // Exclude BaseController and initController + // See system/Config/Routes.php + if (preg_match('#\AbaseController.*#', $route) === 1) { + continue; + } + if (preg_match('#.*/initController\z#', $route) === 1) { + continue; + } + + if ($methodName === $defaultMethod) { + $routeWithoutController = $this->getRouteWithoutController( + $classShortname, + $defaultController, + $uriByClass, + $classname, + $methodName + ); + $output = [...$output, ...$routeWithoutController]; + + $output[] = [ + 'route' => $uriByClass, + 'handler' => '\\' . $classname . '::' . $methodName, + ]; + } + + $output[] = [ + 'route' => $route . '[/...]', + 'handler' => '\\' . $classname . '::' . $methodName, + ]; + } + + return $output; + } + + /** + * Whether the class has a _remap() method. + */ + private function hasRemap(ReflectionClass $class): bool + { + if ($class->hasMethod('_remap')) { + $remap = $class->getMethod('_remap'); + + return $remap->isPublic(); + } + + return false; + } + + /** + * @param class-string $classname + * + * @return string URI path part from the folder(s) and controller + */ + private function getUriByClass(string $classname): string + { + // remove the namespace + $pattern = '/' . preg_quote($this->namespace, '/') . '/'; + $class = ltrim(preg_replace($pattern, '', $classname), '\\'); + + $classParts = explode('\\', $class); + $classPath = ''; + + foreach ($classParts as $part) { + // make the first letter lowercase, because auto routing makes + // the URI path's first letter uppercase and search the controller + $classPath .= lcfirst($part) . '/'; + } + + return rtrim($classPath, '/'); + } + + /** + * Gets a route without default controller. + */ + private function getRouteWithoutController( + string $classShortname, + string $defaultController, + string $uriByClass, + string $classname, + string $methodName + ): array { + $output = []; + + if ($classShortname === $defaultController) { + $pattern = '#' . preg_quote(lcfirst($defaultController), '#') . '\z#'; + $routeWithoutController = rtrim(preg_replace($pattern, '', $uriByClass), '/'); + $routeWithoutController = $routeWithoutController ?: '/'; + + $output[] = [ + 'route' => $routeWithoutController, + 'handler' => '\\' . $classname . '::' . $methodName, + ]; + } + + return $output; + } +} diff --git a/system/Commands/Utilities/Routes/FilterCollector.php b/system/Commands/Utilities/Routes/FilterCollector.php new file mode 100644 index 0000000..01d228a --- /dev/null +++ b/system/Commands/Utilities/Routes/FilterCollector.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +use CodeIgniter\Config\Services; +use CodeIgniter\Filters\Filters; +use CodeIgniter\HTTP\Request; +use CodeIgniter\Router\Router; +use Config\Filters as FiltersConfig; + +/** + * Collects filters for a route. + * + * @see \CodeIgniter\Commands\Utilities\Routes\FilterCollectorTest + */ +final class FilterCollector +{ + /** + * Whether to reset Defined Routes. + * + * If set to true, route filters are not found. + */ + private bool $resetRoutes; + + public function __construct(bool $resetRoutes = false) + { + $this->resetRoutes = $resetRoutes; + } + + /** + * @param string $method HTTP method + * @param string $uri URI path to find filters for + * + * @return array{before: list, after: list} array of filter alias or classname + */ + public function get(string $method, string $uri): array + { + if ($method === 'cli') { + return [ + 'before' => [], + 'after' => [], + ]; + } + + $request = Services::incomingrequest(null, false); + $request->setMethod($method); + + $router = $this->createRouter($request); + $filters = $this->createFilters($request); + + $finder = new FilterFinder($router, $filters); + + return $finder->find($uri); + } + + private function createRouter(Request $request): Router + { + $routes = Services::routes(); + + if ($this->resetRoutes) { + $routes->resetRoutes(); + } + + return new Router($routes, $request); + } + + private function createFilters(Request $request): Filters + { + $config = config(FiltersConfig::class); + + return new Filters($config, $request, Services::response()); + } +} diff --git a/system/Commands/Utilities/Routes/FilterFinder.php b/system/Commands/Utilities/Routes/FilterFinder.php new file mode 100644 index 0000000..2e5da61 --- /dev/null +++ b/system/Commands/Utilities/Routes/FilterFinder.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\Filters\Filters; +use CodeIgniter\HTTP\Exceptions\RedirectException; +use CodeIgniter\Router\Router; +use Config\Feature; +use Config\Services; + +/** + * Finds filters. + * + * @see \CodeIgniter\Commands\Utilities\Routes\FilterFinderTest + */ +final class FilterFinder +{ + private Router $router; + private Filters $filters; + + public function __construct(?Router $router = null, ?Filters $filters = null) + { + $this->router = $router ?? Services::router(); + $this->filters = $filters ?? Services::filters(); + } + + private function getRouteFilters(string $uri): array + { + $this->router->handle($uri); + + $multipleFiltersEnabled = config(Feature::class)->multipleFilters ?? false; + if (! $multipleFiltersEnabled) { + $filter = $this->router->getFilter(); + + return $filter === null ? [] : [$filter]; + } + + return $this->router->getFilters(); + } + + /** + * @param string $uri URI path to find filters for + * + * @return array{before: list, after: list} array of filter alias or classname + */ + public function find(string $uri): array + { + $this->filters->reset(); + + // Add route filters + try { + $routeFilters = $this->getRouteFilters($uri); + $this->filters->enableFilters($routeFilters, 'before'); + $this->filters->enableFilters($routeFilters, 'after'); + + $this->filters->initialize($uri); + + return $this->filters->getFilters(); + } catch (RedirectException $e) { + return [ + 'before' => [], + 'after' => [], + ]; + } catch (PageNotFoundException $e) { + return [ + 'before' => [''], + 'after' => [''], + ]; + } + } +} diff --git a/system/Commands/Utilities/Routes/SampleURIGenerator.php b/system/Commands/Utilities/Routes/SampleURIGenerator.php new file mode 100644 index 0000000..43d1934 --- /dev/null +++ b/system/Commands/Utilities/Routes/SampleURIGenerator.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +use CodeIgniter\Config\Services; +use CodeIgniter\Router\RouteCollection; +use Config\App; + +/** + * Generate a sample URI path from route key regex. + * + * @see \CodeIgniter\Commands\Utilities\Routes\SampleURIGeneratorTest + */ +final class SampleURIGenerator +{ + private RouteCollection $routes; + + /** + * Sample URI path for placeholder. + * + * @var array + */ + private array $samples = [ + 'any' => '123/abc', + 'segment' => 'abc_123', + 'alphanum' => 'abc123', + 'num' => '123', + 'alpha' => 'abc', + 'hash' => 'abc_123', + ]; + + public function __construct(?RouteCollection $routes = null) + { + $this->routes = $routes ?? Services::routes(); + } + + /** + * @param string $routeKey route key regex + * + * @return string sample URI path + */ + public function get(string $routeKey): string + { + $sampleUri = $routeKey; + + if (strpos($routeKey, '{locale}') !== false) { + $sampleUri = str_replace( + '{locale}', + config(App::class)->defaultLocale, + $routeKey + ); + } + + foreach ($this->routes->getPlaceholders() as $placeholder => $regex) { + $sample = $this->samples[$placeholder] ?? '::unknown::'; + + $sampleUri = str_replace('(' . $regex . ')', $sample, $sampleUri); + } + + // auto route + return str_replace('[/...]', '/1/2/3/4/5', $sampleUri); + } +} diff --git a/system/Common.php b/system/Common.php new file mode 100644 index 0000000..690fa3f --- /dev/null +++ b/system/Common.php @@ -0,0 +1,1268 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Config\BaseConfig; +use CodeIgniter\Config\Factories; +use CodeIgniter\Cookie\Cookie; +use CodeIgniter\Cookie\CookieStore; +use CodeIgniter\Cookie\Exceptions\CookieException; +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\ConnectionInterface; +use CodeIgniter\Debug\Timer; +use CodeIgniter\Files\Exceptions\FileNotFoundException; +use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\HTTP\Exceptions\RedirectException; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Model; +use CodeIgniter\Session\Session; +use CodeIgniter\Test\TestLogger; +use Config\App; +use Config\Database; +use Config\DocTypes; +use Config\Logger; +use Config\Services; +use Config\View; +use Laminas\Escaper\Escaper; + +// Services Convenience Functions + +if (! function_exists('app_timezone')) { + /** + * Returns the timezone the application has been set to display + * dates in. This might be different than the timezone set + * at the server level, as you often want to stores dates in UTC + * and convert them on the fly for the user. + */ + function app_timezone(): string + { + $config = config(App::class); + + return $config->appTimezone; + } +} + +if (! function_exists('cache')) { + /** + * A convenience method that provides access to the Cache + * object. If no parameter is provided, will return the object, + * otherwise, will attempt to return the cached value. + * + * Examples: + * cache()->save('foo', 'bar'); + * $foo = cache('bar'); + * + * @return array|bool|CacheInterface|float|int|object|string|null + * @phpstan-return ($key is null ? CacheInterface : array|bool|float|int|object|string|null) + */ + function cache(?string $key = null) + { + $cache = Services::cache(); + + // No params - return cache object + if ($key === null) { + return $cache; + } + + // Still here? Retrieve the value. + return $cache->get($key); + } +} + +if (! function_exists('clean_path')) { + /** + * A convenience method to clean paths for + * a nicer looking output. Useful for exception + * handling, error logging, etc. + */ + function clean_path(string $path): string + { + // Resolve relative paths + try { + $path = realpath($path) ?: $path; + } catch (ErrorException|ValueError $e) { + $path = 'error file path: ' . urlencode($path); + } + + switch (true) { + case strpos($path, APPPATH) === 0: + return 'APPPATH' . DIRECTORY_SEPARATOR . substr($path, strlen(APPPATH)); + + case strpos($path, SYSTEMPATH) === 0: + return 'SYSTEMPATH' . DIRECTORY_SEPARATOR . substr($path, strlen(SYSTEMPATH)); + + case strpos($path, FCPATH) === 0: + return 'FCPATH' . DIRECTORY_SEPARATOR . substr($path, strlen(FCPATH)); + + case defined('VENDORPATH') && strpos($path, VENDORPATH) === 0: + return 'VENDORPATH' . DIRECTORY_SEPARATOR . substr($path, strlen(VENDORPATH)); + + case strpos($path, ROOTPATH) === 0: + return 'ROOTPATH' . DIRECTORY_SEPARATOR . substr($path, strlen(ROOTPATH)); + + default: + return $path; + } + } +} + +if (! function_exists('command')) { + /** + * Runs a single command. + * Input expected in a single string as would + * be used on the command line itself: + * + * > command('migrate:create SomeMigration'); + * + * @return false|string + */ + function command(string $command) + { + $runner = service('commands'); + $regexString = '([^\s]+?)(?:\s|(? $arg) { + if (mb_strpos($arg, '-') !== 0) { + if ($optionValue) { + // if this was an option value, it was already + // included in the previous iteration + $optionValue = false; + } else { + // add to segments if not starting with '-' + // and not an option value + $params[] = $arg; + } + + continue; + } + + $arg = ltrim($arg, '-'); + $value = null; + + if (isset($args[$i + 1]) && mb_strpos($args[$i + 1], '-') !== 0) { + $value = $args[$i + 1]; + $optionValue = true; + } + + $params[$arg] = $value; + } + + ob_start(); + $runner->run($command, $params); + + return ob_get_clean(); + } +} + +if (! function_exists('config')) { + /** + * More simple way of getting config instances from Factories + * + * @template ConfigTemplate of BaseConfig + * + * @param class-string|string $name + * + * @return ConfigTemplate|null + * @phpstan-return ($name is class-string ? ConfigTemplate : object|null) + */ + function config(string $name, bool $getShared = true) + { + return Factories::config($name, ['getShared' => $getShared]); + } +} + +if (! function_exists('cookie')) { + /** + * Simpler way to create a new Cookie instance. + * + * @param string $name Name of the cookie + * @param string $value Value of the cookie + * @param array $options Array of options to be passed to the cookie + * + * @throws CookieException + */ + function cookie(string $name, string $value = '', array $options = []): Cookie + { + return new Cookie($name, $value, $options); + } +} + +if (! function_exists('cookies')) { + /** + * Fetches the global `CookieStore` instance held by `Response`. + * + * @param Cookie[] $cookies If `getGlobal` is false, this is passed to CookieStore's constructor + * @param bool $getGlobal If false, creates a new instance of CookieStore + */ + function cookies(array $cookies = [], bool $getGlobal = true): CookieStore + { + if ($getGlobal) { + return Services::response()->getCookieStore(); + } + + return new CookieStore($cookies); + } +} + +if (! function_exists('csrf_token')) { + /** + * Returns the CSRF token name. + * Can be used in Views when building hidden inputs manually, + * or used in javascript vars when using APIs. + */ + function csrf_token(): string + { + return Services::security()->getTokenName(); + } +} + +if (! function_exists('csrf_header')) { + /** + * Returns the CSRF header name. + * Can be used in Views by adding it to the meta tag + * or used in javascript to define a header name when using APIs. + */ + function csrf_header(): string + { + return Services::security()->getHeaderName(); + } +} + +if (! function_exists('csrf_hash')) { + /** + * Returns the current hash value for the CSRF protection. + * Can be used in Views when building hidden inputs manually, + * or used in javascript vars for API usage. + */ + function csrf_hash(): string + { + return Services::security()->getHash(); + } +} + +if (! function_exists('csrf_field')) { + /** + * Generates a hidden input field for use within manually generated forms. + * + * @param non-empty-string|null $id + */ + function csrf_field(?string $id = null): string + { + return ''; + } +} + +if (! function_exists('csrf_meta')) { + /** + * Generates a meta tag for use within javascript calls. + * + * @param non-empty-string|null $id + */ + function csrf_meta(?string $id = null): string + { + return ''; + } +} + +if (! function_exists('csp_style_nonce')) { + /** + * Generates a nonce attribute for style tag. + */ + function csp_style_nonce(): string + { + $csp = Services::csp(); + + if (! $csp->enabled()) { + return ''; + } + + return 'nonce="' . $csp->getStyleNonce() . '"'; + } +} + +if (! function_exists('csp_script_nonce')) { + /** + * Generates a nonce attribute for script tag. + */ + function csp_script_nonce(): string + { + $csp = Services::csp(); + + if (! $csp->enabled()) { + return ''; + } + + return 'nonce="' . $csp->getScriptNonce() . '"'; + } +} + +if (! function_exists('db_connect')) { + /** + * Grabs a database connection and returns it to the user. + * + * This is a convenience wrapper for \Config\Database::connect() + * and supports the same parameters. Namely: + * + * When passing in $db, you may pass any of the following to connect: + * - group name + * - existing connection instance + * - array of database configuration values + * + * If $getShared === false then a new connection instance will be provided, + * otherwise it will all calls will return the same instance. + * + * @param array|ConnectionInterface|string|null $db + * + * @return BaseConnection + */ + function db_connect($db = null, bool $getShared = true) + { + return Database::connect($db, $getShared); + } +} + +if (! function_exists('env')) { + /** + * Allows user to retrieve values from the environment + * variables that have been set. Especially useful for + * retrieving values set from the .env file for + * use in config files. + * + * @param string|null $default + * + * @return bool|string|null + */ + function env(string $key, $default = null) + { + $value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key); + + // Not found? Return the default value + if ($value === false) { + return $default; + } + + // Handle any boolean values + switch (strtolower($value)) { + case 'true': + return true; + + case 'false': + return false; + + case 'empty': + return ''; + + case 'null': + return null; + } + + return $value; + } +} + +if (! function_exists('esc')) { + /** + * Performs simple auto-escaping of data for security reasons. + * Might consider making this more complex at a later date. + * + * If $data is a string, then it simply escapes and returns it. + * If $data is an array, then it loops over it, escaping each + * 'value' of the key/value pairs. + * + * @param array|string $data + * @phpstan-param 'html'|'js'|'css'|'url'|'attr'|'raw' $context + * @param string|null $encoding Current encoding for escaping. + * If not UTF-8, we convert strings from this encoding + * pre-escaping and back to this encoding post-escaping. + * + * @return array|string + * + * @throws InvalidArgumentException + */ + function esc($data, string $context = 'html', ?string $encoding = null) + { + if (is_array($data)) { + foreach ($data as &$value) { + $value = esc($value, $context); + } + } + + if (is_string($data)) { + $context = strtolower($context); + + // Provide a way to NOT escape data since + // this could be called automatically by + // the View library. + if ($context === 'raw') { + return $data; + } + + if (! in_array($context, ['html', 'js', 'css', 'url', 'attr'], true)) { + throw new InvalidArgumentException('Invalid escape context provided.'); + } + + $method = $context === 'attr' ? 'escapeHtmlAttr' : 'escape' . ucfirst($context); + + static $escaper; + if (! $escaper) { + $escaper = new Escaper($encoding); + } + + if ($encoding && $escaper->getEncoding() !== $encoding) { + $escaper = new Escaper($encoding); + } + + $data = $escaper->{$method}($data); + } + + return $data; + } +} + +if (! function_exists('force_https')) { + /** + * Used to force a page to be accessed in via HTTPS. + * Uses a standard redirect, plus will set the HSTS header + * for modern browsers that support, which gives best + * protection against man-in-the-middle attacks. + * + * @see https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security + * + * @param int $duration How long should the SSL header be set for? (in seconds) + * Defaults to 1 year. + * + * @throws HTTPException + * @throws RedirectException + */ + function force_https( + int $duration = 31_536_000, + ?RequestInterface $request = null, + ?ResponseInterface $response = null + ): void { + $request ??= Services::request(); + + if (! $request instanceof IncomingRequest) { + return; + } + + $response ??= Services::response(); + + if ((ENVIRONMENT !== 'testing' && (is_cli() || $request->isSecure())) + || $request->getServer('HTTPS') === 'test' + ) { + return; // @codeCoverageIgnore + } + + // If the session status is active, we should regenerate + // the session ID for safety sake. + if (ENVIRONMENT !== 'testing' && session_status() === PHP_SESSION_ACTIVE) { + Services::session()->regenerate(); // @codeCoverageIgnore + } + + $uri = $request->getUri()->withScheme('https'); + + // Set an HSTS header + $response->setHeader('Strict-Transport-Security', 'max-age=' . $duration) + ->redirect((string) $uri) + ->setStatusCode(307) + ->setBody('') + ->getCookieStore() + ->clear(); + + throw new RedirectException($response); + } +} + +if (! function_exists('function_usable')) { + /** + * Function usable + * + * Executes a function_exists() check, and if the Suhosin PHP + * extension is loaded - checks whether the function that is + * checked might be disabled in there as well. + * + * This is useful as function_exists() will return FALSE for + * functions disabled via the *disable_functions* php.ini + * setting, but not for *suhosin.executor.func.blacklist* and + * *suhosin.executor.disable_eval*. These settings will just + * terminate script execution if a disabled function is executed. + * + * The above described behavior turned out to be a bug in Suhosin, + * but even though a fix was committed for 0.9.34 on 2012-02-12, + * that version is yet to be released. This function will therefore + * be just temporary, but would probably be kept for a few years. + * + * @see http://www.hardened-php.net/suhosin/ + * + * @param string $functionName Function to check for + * + * @return bool TRUE if the function exists and is safe to call, + * FALSE otherwise. + * + * @codeCoverageIgnore This is too exotic + */ + function function_usable(string $functionName): bool + { + static $_suhosin_func_blacklist; + + if (function_exists($functionName)) { + if (! isset($_suhosin_func_blacklist)) { + $_suhosin_func_blacklist = extension_loaded('suhosin') ? explode(',', trim(ini_get('suhosin.executor.func.blacklist'))) : []; + } + + return ! in_array($functionName, $_suhosin_func_blacklist, true); + } + + return false; + } +} + +if (! function_exists('helper')) { + /** + * Loads a helper file into memory. Supports namespaced helpers, + * both in and out of the 'Helpers' directory of a namespaced directory. + * + * Will load ALL helpers of the matching name, in the following order: + * 1. app/Helpers + * 2. {namespace}/Helpers + * 3. system/Helpers + * + * @param array|string $filenames + * + * @throws FileNotFoundException + */ + function helper($filenames): void + { + static $loaded = []; + + $loader = Services::locator(); + + if (! is_array($filenames)) { + $filenames = [$filenames]; + } + + // Store a list of all files to include... + $includes = []; + + foreach ($filenames as $filename) { + // Store our system and application helper + // versions so that we can control the load ordering. + $systemHelper = null; + $appHelper = null; + $localIncludes = []; + + if (strpos($filename, '_helper') === false) { + $filename .= '_helper'; + } + + // Check if this helper has already been loaded + if (in_array($filename, $loaded, true)) { + continue; + } + + // If the file is namespaced, we'll just grab that + // file and not search for any others + if (strpos($filename, '\\') !== false) { + $path = $loader->locateFile($filename, 'Helpers'); + + if (empty($path)) { + throw FileNotFoundException::forFileNotFound($filename); + } + + $includes[] = $path; + $loaded[] = $filename; + } else { + // No namespaces, so search in all available locations + $paths = $loader->search('Helpers/' . $filename); + + foreach ($paths as $path) { + if (strpos($path, APPPATH . 'Helpers' . DIRECTORY_SEPARATOR) === 0) { + $appHelper = $path; + } elseif (strpos($path, SYSTEMPATH . 'Helpers' . DIRECTORY_SEPARATOR) === 0) { + $systemHelper = $path; + } else { + $localIncludes[] = $path; + $loaded[] = $filename; + } + } + + // App-level helpers should override all others + if (! empty($appHelper)) { + $includes[] = $appHelper; + $loaded[] = $filename; + } + + // All namespaced files get added in next + $includes = [...$includes, ...$localIncludes]; + + // And the system default one should be added in last. + if (! empty($systemHelper)) { + $includes[] = $systemHelper; + $loaded[] = $filename; + } + } + } + + // Now actually include all of the files + foreach ($includes as $path) { + include_once $path; + } + } +} + +if (! function_exists('is_cli')) { + /** + * Check if PHP was invoked from the command line. + * + * @codeCoverageIgnore Cannot be tested fully as PHPUnit always run in php-cli + */ + function is_cli(): bool + { + if (in_array(PHP_SAPI, ['cli', 'phpdbg'], true)) { + return true; + } + + // PHP_SAPI could be 'cgi-fcgi', 'fpm-fcgi'. + // See https://github.com/codeigniter4/CodeIgniter4/pull/5393 + return ! isset($_SERVER['REMOTE_ADDR']) && ! isset($_SERVER['REQUEST_METHOD']); + } +} + +if (! function_exists('is_really_writable')) { + /** + * Tests for file writability + * + * is_writable() returns TRUE on Windows servers when you really can't write to + * the file, based on the read-only attribute. is_writable() is also unreliable + * on Unix servers if safe_mode is on. + * + * @see https://bugs.php.net/bug.php?id=54709 + * + * @throws Exception + * + * @codeCoverageIgnore Not practical to test, as travis runs on linux + */ + function is_really_writable(string $file): bool + { + // If we're on a Unix server we call is_writable + if (! is_windows()) { + return is_writable($file); + } + + /* For Windows servers and safe_mode "on" installations we'll actually + * write a file then read it. Bah... + */ + if (is_dir($file)) { + $file = rtrim($file, '/') . '/' . bin2hex(random_bytes(16)); + if (($fp = @fopen($file, 'ab')) === false) { + return false; + } + + fclose($fp); + @chmod($file, 0777); + @unlink($file); + + return true; + } + + if (! is_file($file) || ($fp = @fopen($file, 'ab')) === false) { + return false; + } + + fclose($fp); + + return true; + } +} + +if (! function_exists('is_windows')) { + /** + * Detect if platform is running in Windows. + */ + function is_windows(?bool $mock = null): bool + { + static $mocked; + + if (func_num_args() === 1) { + $mocked = $mock; + } + + return $mocked ?? DIRECTORY_SEPARATOR === '\\'; + } +} + +if (! function_exists('lang')) { + /** + * A convenience method to translate a string or array of them and format + * the result with the intl extension's MessageFormatter. + * + * @return list|string + */ + function lang(string $line, array $args = [], ?string $locale = null) + { + $language = Services::language(); + + // Get active locale + $activeLocale = $language->getLocale(); + + if ($locale && $locale !== $activeLocale) { + $language->setLocale($locale); + } + + $line = $language->getLine($line, $args); + + if ($locale && $locale !== $activeLocale) { + // Reset to active locale + $language->setLocale($activeLocale); + } + + return $line; + } +} + +if (! function_exists('log_message')) { + /** + * A convenience/compatibility method for logging events through + * the Log system. + * + * Allowed log levels are: + * - emergency + * - alert + * - critical + * - error + * - warning + * - notice + * - info + * - debug + * + * @return bool + */ + function log_message(string $level, string $message, array $context = []) + { + // When running tests, we want to always ensure that the + // TestLogger is running, which provides utilities for + // for asserting that logs were called in the test code. + if (ENVIRONMENT === 'testing') { + $logger = new TestLogger(new Logger()); + + return $logger->log($level, $message, $context); + } + + return Services::logger(true)->log($level, $message, $context); // @codeCoverageIgnore + } +} + +if (! function_exists('model')) { + /** + * More simple way of getting model instances from Factories + * + * @template ModelTemplate of Model + * + * @param class-string|string $name + * + * @return ModelTemplate|null + * @phpstan-return ($name is class-string ? ModelTemplate : object|null) + */ + function model(string $name, bool $getShared = true, ?ConnectionInterface &$conn = null) + { + return Factories::models($name, ['getShared' => $getShared], $conn); + } +} + +if (! function_exists('old')) { + /** + * Provides access to "old input" that was set in the session + * during a redirect()->withInput(). + * + * @param string|null $default + * @param false|string $escape + * @phpstan-param false|'attr'|'css'|'html'|'js'|'raw'|'url' $escape + * + * @return array|string|null + */ + function old(string $key, $default = null, $escape = 'html') + { + // Ensure the session is loaded + if (session_status() === PHP_SESSION_NONE && ENVIRONMENT !== 'testing') { + session(); // @codeCoverageIgnore + } + + $request = Services::request(); + + $value = $request->getOldInput($key); + + // Return the default value if nothing + // found in the old input. + if ($value === null) { + return $default; + } + + return $escape === false ? $value : esc($value, $escape); + } +} + +if (! function_exists('redirect')) { + /** + * Convenience method that works with the current global $request and + * $router instances to redirect using named/reverse-routed routes + * to determine the URL to go to. + * + * If more control is needed, you must use $response->redirect explicitly. + * + * @param non-empty-string|null $route Route name or Controller::method + */ + function redirect(?string $route = null): RedirectResponse + { + $response = Services::redirectresponse(null, true); + + if ($route !== null) { + return $response->route($route); + } + + return $response; + } +} + +if (! function_exists('_solidus')) { + /** + * Generates the solidus character (`/`) depending on the HTML5 compatibility flag in `Config\DocTypes` + * + * @param DocTypes|null $docTypesConfig New config. For testing purpose only. + * + * @internal + */ + function _solidus(?DocTypes $docTypesConfig = null): string + { + static $docTypes = null; + + if ($docTypesConfig !== null) { + $docTypes = $docTypesConfig; + } + + $docTypes ??= new DocTypes(); + + if ($docTypes->html5 ?? false) { + return ''; + } + + return ' /'; + } +} + +if (! function_exists('remove_invisible_characters')) { + /** + * Remove Invisible Characters + * + * This prevents sandwiching null characters + * between ascii characters, like Java\0script. + */ + function remove_invisible_characters(string $str, bool $urlEncoded = true): string + { + $nonDisplayables = []; + + // every control character except newline (dec 10), + // carriage return (dec 13) and horizontal tab (dec 09) + if ($urlEncoded) { + $nonDisplayables[] = '/%0[0-8bcef]/'; // url encoded 00-08, 11, 12, 14, 15 + $nonDisplayables[] = '/%1[0-9a-f]/'; // url encoded 16-31 + } + + $nonDisplayables[] = '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/S'; // 00-08, 11, 12, 14-31, 127 + + do { + $str = preg_replace($nonDisplayables, '', $str, -1, $count); + } while ($count); + + return $str; + } +} + +if (! function_exists('request')) { + /** + * Returns the shared Request. + * + * @return CLIRequest|IncomingRequest + */ + function request() + { + return Services::request(); + } +} + +if (! function_exists('response')) { + /** + * Returns the shared Response. + */ + function response(): ResponseInterface + { + return Services::response(); + } +} + +if (! function_exists('route_to')) { + /** + * Given a route name or controller/method string and any params, + * will attempt to build the relative URL to the + * matching route. + * + * NOTE: This requires the controller/method to + * have a route defined in the routes Config file. + * + * @param string $method Route name or Controller::method + * @param int|string ...$params One or more parameters to be passed to the route. + * The last parameter allows you to set the locale. + * + * @return false|string The route (URI path relative to baseURL) or false if not found. + */ + function route_to(string $method, ...$params) + { + return Services::routes()->reverseRoute($method, ...$params); + } +} + +if (! function_exists('session')) { + /** + * A convenience method for accessing the session instance, + * or an item that has been set in the session. + * + * Examples: + * session()->set('foo', 'bar'); + * $foo = session('bar'); + * + * @return array|bool|float|int|object|Session|string|null + * @phpstan-return ($val is null ? Session : array|bool|float|int|object|string|null) + */ + function session(?string $val = null) + { + $session = Services::session(); + + // Returning a single item? + if (is_string($val)) { + return $session->get($val); + } + + return $session; + } +} + +if (! function_exists('service')) { + /** + * Allows cleaner access to the Services Config file. + * Always returns a SHARED instance of the class, so + * calling the function multiple times should always + * return the same instance. + * + * These are equal: + * - $timer = service('timer') + * - $timer = \CodeIgniter\Config\Services::timer(); + * + * @param array|bool|float|int|object|string|null ...$params + */ + function service(string $name, ...$params): ?object + { + return Services::$name(...$params); + } +} + +if (! function_exists('single_service')) { + /** + * Always returns a new instance of the class. + * + * @param array|bool|float|int|object|string|null ...$params + */ + function single_service(string $name, ...$params): ?object + { + $service = Services::serviceExists($name); + + if ($service === null) { + // The service is not defined anywhere so just return. + return null; + } + + $method = new ReflectionMethod($service, $name); + $count = $method->getNumberOfParameters(); + $mParam = $method->getParameters(); + + if ($count === 1) { + // This service needs only one argument, which is the shared + // instance flag, so let's wrap up and pass false here. + return $service::$name(false); + } + + // Fill in the params with the defaults, but stop before the last + for ($startIndex = count($params); $startIndex <= $count - 2; $startIndex++) { + $params[$startIndex] = $mParam[$startIndex]->getDefaultValue(); + } + + // Ensure the last argument will not create a shared instance + $params[$count - 1] = false; + + return $service::$name(...$params); + } +} + +if (! function_exists('slash_item')) { + // Unlike CI3, this function is placed here because + // it's not a config, or part of a config. + /** + * Fetch a config file item with slash appended (if not empty) + * + * @param string $item Config item name + * + * @return string|null The configuration item or NULL if + * the item doesn't exist + */ + function slash_item(string $item): ?string + { + $config = config(App::class); + + if (! property_exists($config, $item)) { + return null; + } + + $configItem = $config->{$item}; + + if (! is_scalar($configItem)) { + throw new RuntimeException(sprintf( + 'Cannot convert "%s::$%s" of type "%s" to type "string".', + App::class, + $item, + gettype($configItem) + )); + } + + $configItem = trim((string) $configItem); + + if ($configItem === '') { + return $configItem; + } + + return rtrim($configItem, '/') . '/'; + } +} + +if (! function_exists('stringify_attributes')) { + /** + * Stringify attributes for use in HTML tags. + * + * Helper function used to convert a string, array, or object + * of attributes to a string. + * + * @param array|object|string $attributes string, array, object that can be cast to array + */ + function stringify_attributes($attributes, bool $js = false): string + { + $atts = ''; + + if (empty($attributes)) { + return $atts; + } + + if (is_string($attributes)) { + return ' ' . $attributes; + } + + $attributes = (array) $attributes; + + foreach ($attributes as $key => $val) { + $atts .= ($js) ? $key . '=' . esc($val, 'js') . ',' : ' ' . $key . '="' . esc($val) . '"'; + } + + return rtrim($atts, ','); + } +} + +if (! function_exists('timer')) { + /** + * A convenience method for working with the timer. + * If no parameter is passed, it will return the timer instance. + * If callable is passed, it measures time of callable and + * returns its return value if any. + * Otherwise will start or stop the timer intelligently. + * + * @param non-empty-string|null $name + * @param (callable(): mixed)|null $callable + * + * @return mixed|Timer + * @phpstan-return ($name is null ? Timer : ($callable is (callable(): mixed) ? mixed : Timer)) + */ + function timer(?string $name = null, ?callable $callable = null) + { + $timer = Services::timer(); + + if ($name === null) { + return $timer; + } + + if ($callable !== null) { + return $timer->record($name, $callable); + } + + if ($timer->has($name)) { + return $timer->stop($name); + } + + return $timer->start($name); + } +} + +if (! function_exists('view')) { + /** + * Grabs the current RendererInterface-compatible class + * and tells it to render the specified view. Simply provides + * a convenience method that can be used in Controllers, + * libraries, and routed closures. + * + * NOTE: Does not provide any escaping of the data, so that must + * all be handled manually by the developer. + * + * @param array $options Options for saveData or third-party extensions. + */ + function view(string $name, array $data = [], array $options = []): string + { + $renderer = Services::renderer(); + + $config = config(View::class); + $saveData = $config->saveData; + + if (array_key_exists('saveData', $options)) { + $saveData = (bool) $options['saveData']; + unset($options['saveData']); + } + + return $renderer->setData($data, 'raw')->render($name, $options, $saveData); + } +} + +if (! function_exists('view_cell')) { + /** + * View cells are used within views to insert HTML chunks that are managed + * by other classes. + * + * @param array|string|null $params + * + * @throws ReflectionException + */ + function view_cell(string $library, $params = null, int $ttl = 0, ?string $cacheName = null): string + { + return Services::viewcell() + ->render($library, $params, $ttl, $cacheName); + } +} + +/** + * These helpers come from Laravel so will not be + * re-tested and can be ignored safely. + * + * @see https://github.com/laravel/framework/blob/8.x/src/Illuminate/Support/helpers.php + */ +if (! function_exists('class_basename')) { + /** + * Get the class "basename" of the given object / class. + * + * @param object|string $class + * + * @return string + * + * @codeCoverageIgnore + */ + function class_basename($class) + { + $class = is_object($class) ? get_class($class) : $class; + + return basename(str_replace('\\', '/', $class)); + } +} + +if (! function_exists('class_uses_recursive')) { + /** + * Returns all traits used by a class, its parent classes and trait of their traits. + * + * @param object|string $class + * + * @return array + * + * @codeCoverageIgnore + */ + function class_uses_recursive($class) + { + if (is_object($class)) { + $class = get_class($class); + } + + $results = []; + + foreach (array_reverse(class_parents($class)) + [$class => $class] as $class) { + $results += trait_uses_recursive($class); + } + + return array_unique($results); + } +} + +if (! function_exists('trait_uses_recursive')) { + /** + * Returns all traits used by a trait and its traits. + * + * @param string $trait + * + * @return array + * + * @codeCoverageIgnore + */ + function trait_uses_recursive($trait) + { + $traits = class_uses($trait) ?: []; + + foreach ($traits as $trait) { + $traits += trait_uses_recursive($trait); + } + + return $traits; + } +} diff --git a/system/ComposerScripts.php b/system/ComposerScripts.php new file mode 100644 index 0000000..b95bdf5 --- /dev/null +++ b/system/ComposerScripts.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter; + +use FilesystemIterator; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; + +/** + * This class is used by Composer during installs and updates + * to move files to locations within the system folder so that end-users + * do not need to use Composer to install a package, but can simply + * download. + * + * @codeCoverageIgnore + * + * @internal + */ +final class ComposerScripts +{ + /** + * Path to the ThirdParty directory. + */ + private static string $path = __DIR__ . '/ThirdParty/'; + + /** + * Direct dependencies of CodeIgniter to copy + * contents to `system/ThirdParty/`. + * + * @var array> + */ + private static array $dependencies = [ + 'kint-src' => [ + 'license' => __DIR__ . '/../vendor/kint-php/kint/LICENSE', + 'from' => __DIR__ . '/../vendor/kint-php/kint/src/', + 'to' => __DIR__ . '/ThirdParty/Kint/', + ], + 'kint-resources' => [ + 'from' => __DIR__ . '/../vendor/kint-php/kint/resources/', + 'to' => __DIR__ . '/ThirdParty/Kint/resources/', + ], + 'escaper' => [ + 'license' => __DIR__ . '/../vendor/laminas/laminas-escaper/LICENSE.md', + 'from' => __DIR__ . '/../vendor/laminas/laminas-escaper/src/', + 'to' => __DIR__ . '/ThirdParty/Escaper/', + ], + 'psr-log' => [ + 'license' => __DIR__ . '/../vendor/psr/log/LICENSE', + 'from' => __DIR__ . '/../vendor/psr/log/Psr/Log/', + 'to' => __DIR__ . '/ThirdParty/PSR/Log/', + ], + ]; + + /** + * This static method is called by Composer after every update event, + * i.e., `composer install`, `composer update`, `composer remove`. + */ + public static function postUpdate() + { + self::recursiveDelete(self::$path); + + foreach (self::$dependencies as $key => $dependency) { + // Kint may be removed. + if (! is_dir($dependency['from']) && strpos($key, 'kint') === 0) { + continue; + } + + self::recursiveMirror($dependency['from'], $dependency['to']); + + if (isset($dependency['license'])) { + $license = basename($dependency['license']); + copy($dependency['license'], $dependency['to'] . '/' . $license); + } + } + + self::copyKintInitFiles(); + self::recursiveDelete(self::$dependencies['psr-log']['to'] . 'Test/'); + } + + /** + * Recursively remove the contents of the previous `system/ThirdParty`. + */ + private static function recursiveDelete(string $directory): void + { + if (! is_dir($directory)) { + echo sprintf('Cannot recursively delete "%s" as it does not exist.', $directory) . PHP_EOL; + + return; + } + + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator( + new RecursiveDirectoryIterator(rtrim($directory, '\\/'), FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ) as $file) { + $path = $file->getPathname(); + + if ($file->isDir()) { + @rmdir($path); + } else { + @unlink($path); + } + } + } + + /** + * Recursively copy the files and directories of the origin directory + * into the target directory, i.e. "mirror" its contents. + */ + private static function recursiveMirror(string $originDir, string $targetDir): void + { + $originDir = rtrim($originDir, '\\/'); + $targetDir = rtrim($targetDir, '\\/'); + + if (! is_dir($originDir)) { + echo sprintf('The origin directory "%s" was not found.', $originDir); + + exit(1); + } + + if (is_dir($targetDir)) { + echo sprintf('The target directory "%s" is existing. Run %s::recursiveDelete(\'%s\') first.', $targetDir, self::class, $targetDir); + + exit(1); + } + + if (! @mkdir($targetDir, 0755, true)) { + echo sprintf('Cannot create the target directory: "%s"', $targetDir) . PHP_EOL; + + exit(1); + } + + $dirLen = strlen($originDir); + + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($originDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ) as $file) { + $origin = $file->getPathname(); + $target = $targetDir . substr($origin, $dirLen); + + if ($file->isDir()) { + @mkdir($target, 0755); + } else { + @copy($origin, $target); + } + } + } + + /** + * Copy Kint's init files into `system/ThirdParty/Kint/` + */ + private static function copyKintInitFiles(): void + { + $originDir = self::$dependencies['kint-src']['from'] . '../'; + $targetDir = self::$dependencies['kint-src']['to']; + + foreach (['init.php', 'init_helpers.php'] as $kintInit) { + @copy($originDir . $kintInit, $targetDir . $kintInit); + } + } +} diff --git a/system/Config/AutoloadConfig.php b/system/Config/AutoloadConfig.php new file mode 100644 index 0000000..33c977c --- /dev/null +++ b/system/Config/AutoloadConfig.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +use Laminas\Escaper\Escaper; +use Laminas\Escaper\Exception\ExceptionInterface; +use Laminas\Escaper\Exception\InvalidArgumentException as EscaperInvalidArgumentException; +use Laminas\Escaper\Exception\RuntimeException; +use Psr\Log\AbstractLogger; +use Psr\Log\InvalidArgumentException; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\LoggerInterface; +use Psr\Log\LoggerTrait; +use Psr\Log\LogLevel; +use Psr\Log\NullLogger; + +/** + * AUTOLOADER CONFIGURATION + * + * This file defines the namespaces and class maps so the Autoloader + * can find the files as needed. + */ +class AutoloadConfig +{ + /** + * ------------------------------------------------------------------- + * Namespaces + * ------------------------------------------------------------------- + * This maps the locations of any namespaces in your application to + * their location on the file system. These are used by the autoloader + * to locate files the first time they have been instantiated. + * + * The '/app' and '/system' directories are already mapped for you. + * you may change the name of the 'App' namespace if you wish, + * but this should be done prior to creating any namespaced classes, + * else you will need to modify all of those classes for this to work. + * + * @var array|string> + */ + public $psr4 = []; + + /** + * ------------------------------------------------------------------- + * Class Map + * ------------------------------------------------------------------- + * The class map provides a map of class names and their exact + * location on the drive. Classes loaded in this manner will have + * slightly faster performance because they will not have to be + * searched for within one or more directories as they would if they + * were being autoloaded through a namespace. + * + * @var array + */ + public $classmap = []; + + /** + * ------------------------------------------------------------------- + * Files + * ------------------------------------------------------------------- + * The files array provides a list of paths to __non-class__ files + * that will be autoloaded. This can be useful for bootstrap operations + * or for loading functions. + * + * @var list + */ + public $files = []; + + /** + * ------------------------------------------------------------------- + * Namespaces + * ------------------------------------------------------------------- + * This maps the locations of any namespaces in your application to + * their location on the file system. These are used by the autoloader + * to locate files the first time they have been instantiated. + * + * Do not change the name of the CodeIgniter namespace or your application + * will break. + * + * @var array + */ + protected $corePsr4 = [ + 'CodeIgniter' => SYSTEMPATH, + 'App' => APPPATH, // To ensure filters, etc still found, + ]; + + /** + * ------------------------------------------------------------------- + * Class Map + * ------------------------------------------------------------------- + * The class map provides a map of class names and their exact + * location on the drive. Classes loaded in this manner will have + * slightly faster performance because they will not have to be + * searched for within one or more directories as they would if they + * were being autoloaded through a namespace. + * + * @var array + */ + protected $coreClassmap = [ + AbstractLogger::class => SYSTEMPATH . 'ThirdParty/PSR/Log/AbstractLogger.php', + InvalidArgumentException::class => SYSTEMPATH . 'ThirdParty/PSR/Log/InvalidArgumentException.php', + LoggerAwareInterface::class => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerAwareInterface.php', + LoggerAwareTrait::class => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerAwareTrait.php', + LoggerInterface::class => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerInterface.php', + LoggerTrait::class => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerTrait.php', + LogLevel::class => SYSTEMPATH . 'ThirdParty/PSR/Log/LogLevel.php', + NullLogger::class => SYSTEMPATH . 'ThirdParty/PSR/Log/NullLogger.php', + ExceptionInterface::class => SYSTEMPATH . 'ThirdParty/Escaper/Exception/ExceptionInterface.php', + EscaperInvalidArgumentException::class => SYSTEMPATH . 'ThirdParty/Escaper/Exception/InvalidArgumentException.php', + RuntimeException::class => SYSTEMPATH . 'ThirdParty/Escaper/Exception/RuntimeException.php', + Escaper::class => SYSTEMPATH . 'ThirdParty/Escaper/Escaper.php', + ]; + + /** + * ------------------------------------------------------------------- + * Core Files + * ------------------------------------------------------------------- + * List of files from the framework to be autoloaded early. + * + * @var array + */ + protected $coreFiles = []; + + /** + * Constructor. + * + * Merge the built-in and developer-configured psr4 and classmap, + * with preference to the developer ones. + */ + public function __construct() + { + if (isset($_SERVER['CI_ENVIRONMENT']) && $_SERVER['CI_ENVIRONMENT'] === 'testing') { + $this->psr4['Tests\Support'] = SUPPORTPATH; + $this->classmap['CodeIgniter\Log\TestLogger'] = SYSTEMPATH . 'Test/TestLogger.php'; + $this->classmap['CIDatabaseTestCase'] = SYSTEMPATH . 'Test/CIDatabaseTestCase.php'; + } + + $this->psr4 = array_merge($this->corePsr4, $this->psr4); + $this->classmap = array_merge($this->coreClassmap, $this->classmap); + $this->files = [...$this->coreFiles, ...$this->files]; + } +} diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php new file mode 100644 index 0000000..5dd6cde --- /dev/null +++ b/system/Config/BaseConfig.php @@ -0,0 +1,266 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +use Config\Encryption; +use Config\Modules; +use Config\Services; +use ReflectionClass; +use ReflectionException; +use RuntimeException; + +/** + * Class BaseConfig + * + * Not intended to be used on its own, this class will attempt to + * automatically populate the child class' properties with values + * from the environment. + * + * These can be set within the .env file. + * + * @phpstan-consistent-constructor + * @see \CodeIgniter\Config\BaseConfigTest + */ +class BaseConfig +{ + /** + * An optional array of classes that will act as Registrars + * for rapidly setting config class properties. + * + * @var array + */ + public static $registrars = []; + + /** + * Whether to override properties by Env vars and Registrars. + */ + public static bool $override = true; + + /** + * Has module discovery happened yet? + * + * @var bool + */ + protected static $didDiscovery = false; + + /** + * The modules configuration. + * + * @var Modules|null + */ + protected static $moduleConfig; + + public static function __set_state(array $array) + { + static::$override = false; + $obj = new static(); + static::$override = true; + + $properties = array_keys(get_object_vars($obj)); + + foreach ($properties as $property) { + $obj->{$property} = $array[$property]; + } + + return $obj; + } + + /** + * @internal For testing purposes only. + * @testTag + */ + public static function setModules(Modules $modules): void + { + static::$moduleConfig = $modules; + } + + /** + * @internal For testing purposes only. + * @testTag + */ + public static function reset(): void + { + static::$registrars = []; + static::$override = true; + static::$didDiscovery = false; + static::$moduleConfig = null; + } + + /** + * Will attempt to get environment variables with names + * that match the properties of the child class. + * + * The "shortPrefix" is the lowercase-only config class name. + */ + public function __construct() + { + static::$moduleConfig ??= new Modules(); + + if (! static::$override) { + return; + } + + $this->registerProperties(); + + $properties = array_keys(get_object_vars($this)); + $prefix = static::class; + $slashAt = strrpos($prefix, '\\'); + $shortPrefix = strtolower(substr($prefix, $slashAt === false ? 0 : $slashAt + 1)); + + foreach ($properties as $property) { + $this->initEnvValue($this->{$property}, $property, $prefix, $shortPrefix); + + if ($this instanceof Encryption && $property === 'key') { + if (strpos($this->{$property}, 'hex2bin:') === 0) { + // Handle hex2bin prefix + $this->{$property} = hex2bin(substr($this->{$property}, 8)); + } elseif (strpos($this->{$property}, 'base64:') === 0) { + // Handle base64 prefix + $this->{$property} = base64_decode(substr($this->{$property}, 7), true); + } + } + } + } + + /** + * Initialization an environment-specific configuration setting + * + * @param array|bool|float|int|string|null $property + * + * @return void + */ + protected function initEnvValue(&$property, string $name, string $prefix, string $shortPrefix) + { + if (is_array($property)) { + foreach (array_keys($property) as $key) { + $this->initEnvValue($property[$key], "{$name}.{$key}", $prefix, $shortPrefix); + } + } elseif (($value = $this->getEnvValue($name, $prefix, $shortPrefix)) !== false && $value !== null) { + if ($value === 'false') { + $value = false; + } elseif ($value === 'true') { + $value = true; + } + if (is_bool($value)) { + $property = $value; + + return; + } + + $value = trim($value, '\'"'); + + if (is_int($property)) { + $value = (int) $value; + } elseif (is_float($property)) { + $value = (float) $value; + } + + $property = $value; + } + } + + /** + * Retrieve an environment-specific configuration setting + * + * @return string|null + */ + protected function getEnvValue(string $property, string $prefix, string $shortPrefix) + { + $shortPrefix = ltrim($shortPrefix, '\\'); + $underscoreProperty = str_replace('.', '_', $property); + + switch (true) { + case array_key_exists("{$shortPrefix}.{$property}", $_ENV): + return $_ENV["{$shortPrefix}.{$property}"]; + + case array_key_exists("{$shortPrefix}_{$underscoreProperty}", $_ENV): + return $_ENV["{$shortPrefix}_{$underscoreProperty}"]; + + case array_key_exists("{$shortPrefix}.{$property}", $_SERVER): + return $_SERVER["{$shortPrefix}.{$property}"]; + + case array_key_exists("{$shortPrefix}_{$underscoreProperty}", $_SERVER): + return $_SERVER["{$shortPrefix}_{$underscoreProperty}"]; + + case array_key_exists("{$prefix}.{$property}", $_ENV): + return $_ENV["{$prefix}.{$property}"]; + + case array_key_exists("{$prefix}_{$underscoreProperty}", $_ENV): + return $_ENV["{$prefix}_{$underscoreProperty}"]; + + case array_key_exists("{$prefix}.{$property}", $_SERVER): + return $_SERVER["{$prefix}.{$property}"]; + + case array_key_exists("{$prefix}_{$underscoreProperty}", $_SERVER): + return $_SERVER["{$prefix}_{$underscoreProperty}"]; + + default: + $value = getenv("{$shortPrefix}.{$property}"); + $value = $value === false ? getenv("{$shortPrefix}_{$underscoreProperty}") : $value; + $value = $value === false ? getenv("{$prefix}.{$property}") : $value; + $value = $value === false ? getenv("{$prefix}_{$underscoreProperty}") : $value; + + return $value === false ? null : $value; + } + } + + /** + * Provides external libraries a simple way to register one or more + * options into a config file. + * + * @return void + * + * @throws ReflectionException + */ + protected function registerProperties() + { + if (! static::$moduleConfig->shouldDiscover('registrars')) { + return; + } + + if (! static::$didDiscovery) { + $locator = Services::locator(); + $registrarsFiles = $locator->search('Config/Registrar.php'); + + foreach ($registrarsFiles as $file) { + $className = $locator->getClassname($file); + static::$registrars[] = new $className(); + } + + static::$didDiscovery = true; + } + + $shortName = (new ReflectionClass($this))->getShortName(); + + // Check the registrar class for a method named after this class' shortName + foreach (static::$registrars as $callable) { + // ignore non-applicable registrars + if (! method_exists($callable, $shortName)) { + continue; // @codeCoverageIgnore + } + + $properties = $callable::$shortName(); + + if (! is_array($properties)) { + throw new RuntimeException('Registrars must return an array of properties and their values.'); + } + + foreach ($properties as $property => $value) { + if (isset($this->{$property}) && is_array($this->{$property}) && is_array($value)) { + $this->{$property} = array_merge($this->{$property}, $value); + } else { + $this->{$property} = $value; + } + } + } + } +} diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php new file mode 100644 index 0000000..1c80c80 --- /dev/null +++ b/system/Config/BaseService.php @@ -0,0 +1,391 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +use CodeIgniter\Autoloader\Autoloader; +use CodeIgniter\Autoloader\FileLocator; +use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Cache\ResponseCache; +use CodeIgniter\CLI\Commands; +use CodeIgniter\CodeIgniter; +use CodeIgniter\Database\ConnectionInterface; +use CodeIgniter\Database\MigrationRunner; +use CodeIgniter\Debug\Exceptions; +use CodeIgniter\Debug\Iterator; +use CodeIgniter\Debug\Timer; +use CodeIgniter\Debug\Toolbar; +use CodeIgniter\Email\Email; +use CodeIgniter\Encryption\EncrypterInterface; +use CodeIgniter\Filters\Filters; +use CodeIgniter\Format\Format; +use CodeIgniter\Honeypot\Honeypot; +use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\ContentSecurityPolicy; +use CodeIgniter\HTTP\CURLRequest; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\Negotiate; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\HTTP\Request; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\SiteURIFactory; +use CodeIgniter\HTTP\URI; +use CodeIgniter\Images\Handlers\BaseHandler; +use CodeIgniter\Language\Language; +use CodeIgniter\Log\Logger; +use CodeIgniter\Pager\Pager; +use CodeIgniter\Router\RouteCollection; +use CodeIgniter\Router\RouteCollectionInterface; +use CodeIgniter\Router\Router; +use CodeIgniter\Security\Security; +use CodeIgniter\Session\Session; +use CodeIgniter\Superglobals; +use CodeIgniter\Throttle\Throttler; +use CodeIgniter\Typography\Typography; +use CodeIgniter\Validation\ValidationInterface; +use CodeIgniter\View\Cell; +use CodeIgniter\View\Parser; +use CodeIgniter\View\RendererInterface; +use CodeIgniter\View\View; +use Config\App; +use Config\Autoload; +use Config\Cache; +use Config\ContentSecurityPolicy as CSPConfig; +use Config\Encryption; +use Config\Exceptions as ConfigExceptions; +use Config\Filters as ConfigFilters; +use Config\Format as ConfigFormat; +use Config\Honeypot as ConfigHoneyPot; +use Config\Images; +use Config\Migrations; +use Config\Modules; +use Config\Pager as ConfigPager; +use Config\Services as AppServices; +use Config\Toolbar as ConfigToolbar; +use Config\Validation as ConfigValidation; +use Config\View as ConfigView; + +/** + * Services Configuration file. + * + * Services are simply other classes/libraries that the system uses + * to do its job. This is used by CodeIgniter to allow the core of the + * framework to be swapped out easily without affecting the usage within + * the rest of your application. + * + * This is used in place of a Dependency Injection container primarily + * due to its simplicity, which allows a better long-term maintenance + * of the applications built on top of CodeIgniter. A bonus side-effect + * is that IDEs are able to determine what class you are calling + * whereas with DI Containers there usually isn't a way for them to do this. + * + * Warning: To allow overrides by service providers do not use static calls, + * instead call out to \Config\Services (imported as AppServices). + * + * @see http://blog.ircmaxell.com/2015/11/simple-easy-risk-and-change.html + * @see http://www.infoq.com/presentations/Simple-Made-Easy + * + * @method static CacheInterface cache(Cache $config = null, $getShared = true) + * @method static CLIRequest clirequest(App $config = null, $getShared = true) + * @method static CodeIgniter codeigniter(App $config = null, $getShared = true) + * @method static Commands commands($getShared = true) + * @method static void createRequest(App $config, bool $isCli = false) + * @method static ContentSecurityPolicy csp(CSPConfig $config = null, $getShared = true) + * @method static CURLRequest curlrequest($options = [], ResponseInterface $response = null, App $config = null, $getShared = true) + * @method static Email email($config = null, $getShared = true) + * @method static EncrypterInterface encrypter(Encryption $config = null, $getShared = false) + * @method static Exceptions exceptions(ConfigExceptions $config = null, $getShared = true) + * @method static Filters filters(ConfigFilters $config = null, $getShared = true) + * @method static Format format(ConfigFormat $config = null, $getShared = true) + * @method static Honeypot honeypot(ConfigHoneyPot $config = null, $getShared = true) + * @method static BaseHandler image($handler = null, Images $config = null, $getShared = true) + * @method static IncomingRequest incomingrequest(?App $config = null, bool $getShared = true) + * @method static Iterator iterator($getShared = true) + * @method static Language language($locale = null, $getShared = true) + * @method static Logger logger($getShared = true) + * @method static MigrationRunner migrations(Migrations $config = null, ConnectionInterface $db = null, $getShared = true) + * @method static Negotiate negotiator(RequestInterface $request = null, $getShared = true) + * @method static Pager pager(ConfigPager $config = null, RendererInterface $view = null, $getShared = true) + * @method static Parser parser($viewPath = null, ConfigView $config = null, $getShared = true) + * @method static RedirectResponse redirectresponse(App $config = null, $getShared = true) + * @method static View renderer($viewPath = null, ConfigView $config = null, $getShared = true) + * @method static IncomingRequest|CLIRequest request(App $config = null, $getShared = true) + * @method static ResponseInterface response(App $config = null, $getShared = true) + * @method static ResponseCache responsecache(?Cache $config = null, ?CacheInterface $cache = null, bool $getShared = true) + * @method static Router router(RouteCollectionInterface $routes = null, Request $request = null, $getShared = true) + * @method static RouteCollection routes($getShared = true) + * @method static Security security(App $config = null, $getShared = true) + * @method static Session session(App $config = null, $getShared = true) + * @method static SiteURIFactory siteurifactory(App $config = null, Superglobals $superglobals = null, $getShared = true) + * @method static Superglobals superglobals(array $server = null, array $get = null, bool $getShared = true) + * @method static Throttler throttler($getShared = true) + * @method static Timer timer($getShared = true) + * @method static Toolbar toolbar(ConfigToolbar $config = null, $getShared = true) + * @method static Typography typography($getShared = true) + * @method static URI uri($uri = null, $getShared = true) + * @method static ValidationInterface validation(ConfigValidation $config = null, $getShared = true) + * @method static Cell viewcell($getShared = true) + */ +class BaseService +{ + /** + * Cache for instance of any services that + * have been requested as a "shared" instance. + * Keys should be lowercase service names. + * + * @var array + */ + protected static $instances = []; + + /** + * Mock objects for testing which are returned if exist. + * + * @var array + */ + protected static $mocks = []; + + /** + * Have we already discovered other Services? + * + * @var bool + */ + protected static $discovered = false; + + /** + * A cache of other service classes we've found. + * + * @var array + */ + protected static $services = []; + + /** + * A cache of the names of services classes found. + * + * @var array + */ + private static array $serviceNames = []; + + /** + * Returns a shared instance of any of the class' services. + * + * $key must be a name matching a service. + * + * @param array|bool|float|int|object|string|null ...$params + * + * @return object + */ + protected static function getSharedInstance(string $key, ...$params) + { + $key = strtolower($key); + + // Returns mock if exists + if (isset(static::$mocks[$key])) { + return static::$mocks[$key]; + } + + if (! isset(static::$instances[$key])) { + // Make sure $getShared is false + $params[] = false; + + static::$instances[$key] = AppServices::$key(...$params); + } + + return static::$instances[$key]; + } + + /** + * The Autoloader class is the central class that handles our + * spl_autoload_register method, and helper methods. + * + * @return Autoloader + */ + public static function autoloader(bool $getShared = true) + { + if ($getShared) { + if (empty(static::$instances['autoloader'])) { + static::$instances['autoloader'] = new Autoloader(); + } + + return static::$instances['autoloader']; + } + + return new Autoloader(); + } + + /** + * The file locator provides utility methods for looking for non-classes + * within namespaced folders, as well as convenience methods for + * loading 'helpers', and 'libraries'. + * + * @return FileLocator + */ + public static function locator(bool $getShared = true) + { + if ($getShared) { + if (empty(static::$instances['locator'])) { + static::$instances['locator'] = new FileLocator(static::autoloader()); + } + + return static::$mocks['locator'] ?? static::$instances['locator']; + } + + return new FileLocator(static::autoloader()); + } + + /** + * Provides the ability to perform case-insensitive calling of service + * names. + * + * @return object|null + */ + public static function __callStatic(string $name, array $arguments) + { + $service = static::serviceExists($name); + + if ($service === null) { + return null; + } + + return $service::$name(...$arguments); + } + + /** + * Check if the requested service is defined and return the declaring + * class. Return null if not found. + */ + public static function serviceExists(string $name): ?string + { + static::buildServicesCache(); + $services = array_merge(self::$serviceNames, [Services::class]); + $name = strtolower($name); + + foreach ($services as $service) { + if (method_exists($service, $name)) { + return $service; + } + } + + return null; + } + + /** + * Reset shared instances and mocks for testing. + * + * @return void + */ + public static function reset(bool $initAutoloader = true) + { + static::$mocks = []; + static::$instances = []; + + if ($initAutoloader) { + static::autoloader()->initialize(new Autoload(), new Modules()); + } + } + + /** + * Resets any mock and shared instances for a single service. + * + * @return void + */ + public static function resetSingle(string $name) + { + $name = strtolower($name); + unset(static::$mocks[$name], static::$instances[$name]); + } + + /** + * Inject mock object for testing. + * + * @param object $mock + * + * @return void + */ + public static function injectMock(string $name, $mock) + { + static::$mocks[strtolower($name)] = $mock; + } + + /** + * Will scan all psr4 namespaces registered with system to look + * for new Config\Services files. Caches a copy of each one, then + * looks for the service method in each, returning an instance of + * the service, if available. + * + * @return object|null + * + * @deprecated + * + * @codeCoverageIgnore + */ + protected static function discoverServices(string $name, array $arguments) + { + if (! static::$discovered) { + if ((new Modules())->shouldDiscover('services')) { + $locator = static::locator(); + $files = $locator->search('Config/Services'); + + if (empty($files)) { + // no files at all found - this would be really, really bad + return null; + } + + // Get instances of all service classes and cache them locally. + foreach ($files as $file) { + $classname = $locator->getClassname($file); + + if ($classname !== Services::class) { + static::$services[] = new $classname(); + } + } + } + + static::$discovered = true; + } + + if (! static::$services) { + // we found stuff, but no services - this would be really bad + return null; + } + + // Try to find the desired service method + foreach (static::$services as $class) { + if (method_exists($class, $name)) { + return $class::$name(...$arguments); + } + } + + return null; + } + + protected static function buildServicesCache(): void + { + if (! static::$discovered) { + if ((new Modules())->shouldDiscover('services')) { + $locator = static::locator(); + $files = $locator->search('Config/Services'); + + // Get instances of all service classes and cache them locally. + foreach ($files as $file) { + $classname = $locator->getClassname($file); + + if ($classname !== Services::class) { + self::$serviceNames[] = $classname; + static::$services[] = new $classname(); + } + } + } + + static::$discovered = true; + } + } +} diff --git a/system/Config/Config.php b/system/Config/Config.php new file mode 100644 index 0000000..90a6b7c --- /dev/null +++ b/system/Config/Config.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +/** + * @deprecated Use CodeIgniter\Config\Factories::config() + * @see \CodeIgniter\Config\ConfigTest + */ +class Config +{ + /** + * Create new configuration instances or return + * a shared instance + * + * @param string $name Configuration name + * @param bool $getShared Use shared instance + * + * @return object|null + */ + public static function get(string $name, bool $getShared = true) + { + return Factories::config($name, ['getShared' => $getShared]); + } + + /** + * Helper method for injecting mock instances while testing. + * + * @param object $instance + * + * @return void + */ + public static function injectMock(string $name, $instance) + { + Factories::injectMock('config', $name, $instance); + } + + /** + * Resets the static arrays + * + * @return void + */ + public static function reset() + { + Factories::reset('config'); + } +} diff --git a/system/Config/DotEnv.php b/system/Config/DotEnv.php new file mode 100644 index 0000000..05ecab9 --- /dev/null +++ b/system/Config/DotEnv.php @@ -0,0 +1,238 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +use InvalidArgumentException; + +/** + * Environment-specific configuration + * + * @see \CodeIgniter\Config\DotEnvTest + */ +class DotEnv +{ + /** + * The directory where the .env file can be located. + * + * @var string + */ + protected $path; + + /** + * Builds the path to our file. + */ + public function __construct(string $path, string $file = '.env') + { + $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $file; + } + + /** + * The main entry point, will load the .env file and process it + * so that we end up with all settings in the PHP environment vars + * (i.e. getenv(), $_ENV, and $_SERVER) + */ + public function load(): bool + { + $vars = $this->parse(); + + return $vars !== null; + } + + /** + * Parse the .env file into an array of key => value + */ + public function parse(): ?array + { + // We don't want to enforce the presence of a .env file, they should be optional. + if (! is_file($this->path)) { + return null; + } + + // Ensure the file is readable + if (! is_readable($this->path)) { + throw new InvalidArgumentException("The .env file is not readable: {$this->path}"); + } + + $vars = []; + + $lines = file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + + foreach ($lines as $line) { + // Is it a comment? + if (strpos(trim($line), '#') === 0) { + continue; + } + + // If there is an equal sign, then we know we are assigning a variable. + if (strpos($line, '=') !== false) { + [$name, $value] = $this->normaliseVariable($line); + $vars[$name] = $value; + $this->setVariable($name, $value); + } + } + + return $vars; + } + + /** + * Sets the variable into the environment. Will parse the string + * first to look for {name}={value} pattern, ensure that nested + * variables are handled, and strip it of single and double quotes. + * + * @return void + */ + protected function setVariable(string $name, string $value = '') + { + if (! getenv($name, true)) { + putenv("{$name}={$value}"); + } + + if (empty($_ENV[$name])) { + $_ENV[$name] = $value; + } + + if (empty($_SERVER[$name])) { + $_SERVER[$name] = $value; + } + } + + /** + * Parses for assignment, cleans the $name and $value, and ensures + * that nested variables are handled. + */ + public function normaliseVariable(string $name, string $value = ''): array + { + // Split our compound string into its parts. + if (strpos($name, '=') !== false) { + [$name, $value] = explode('=', $name, 2); + } + + $name = trim($name); + $value = trim($value); + + // Sanitize the name + $name = preg_replace('/^export[ \t]++(\S+)/', '$1', $name); + $name = str_replace(['\'', '"'], '', $name); + + // Sanitize the value + $value = $this->sanitizeValue($value); + $value = $this->resolveNestedVariables($value); + + return [$name, $value]; + } + + /** + * Strips quotes from the environment variable value. + * + * This was borrowed from the excellent phpdotenv with very few changes. + * https://github.com/vlucas/phpdotenv + * + * @throws InvalidArgumentException + */ + protected function sanitizeValue(string $value): string + { + if (! $value) { + return $value; + } + + // Does it begin with a quote? + if (strpbrk($value[0], '"\'') !== false) { + // value starts with a quote + $quote = $value[0]; + + $regexPattern = sprintf( + '/^ + %1$s # match a quote at the start of the value + ( # capturing sub-pattern used + (?: # we do not need to capture this + [^%1$s\\\\] # any character other than a quote or backslash + |\\\\\\\\ # or two backslashes together + |\\\\%1$s # or an escaped quote e.g \" + )* # as many characters that match the previous rules + ) # end of the capturing sub-pattern + %1$s # and the closing quote + .*$ # and discard any string after the closing quote + /mx', + $quote + ); + + $value = preg_replace($regexPattern, '$1', $value); + $value = str_replace("\\{$quote}", $quote, $value); + $value = str_replace('\\\\', '\\', $value); + } else { + $parts = explode(' #', $value, 2); + $value = trim($parts[0]); + + // Unquoted values cannot contain whitespace + if (preg_match('/\s+/', $value) > 0) { + throw new InvalidArgumentException('.env values containing spaces must be surrounded by quotes.'); + } + } + + return $value; + } + + /** + * Resolve the nested variables. + * + * Look for ${varname} patterns in the variable value and replace with an existing + * environment variable. + * + * This was borrowed from the excellent phpdotenv with very few changes. + * https://github.com/vlucas/phpdotenv + */ + protected function resolveNestedVariables(string $value): string + { + if (strpos($value, '$') !== false) { + $value = preg_replace_callback( + '/\${([a-zA-Z0-9_\.]+)}/', + function ($matchedPatterns) { + $nestedVariable = $this->getVariable($matchedPatterns[1]); + + if ($nestedVariable === null) { + return $matchedPatterns[0]; + } + + return $nestedVariable; + }, + $value + ); + } + + return $value; + } + + /** + * Search the different places for environment variables and return first value found. + * + * This was borrowed from the excellent phpdotenv with very few changes. + * https://github.com/vlucas/phpdotenv + * + * @return string|null + */ + protected function getVariable(string $name) + { + switch (true) { + case array_key_exists($name, $_ENV): + return $_ENV[$name]; + + case array_key_exists($name, $_SERVER): + return $_SERVER[$name]; + + default: + $value = getenv($name); + + // switch getenv default to null + return $value === false ? null : $value; + } + } +} diff --git a/system/Config/Factories.php b/system/Config/Factories.php new file mode 100644 index 0000000..c86bfd9 --- /dev/null +++ b/system/Config/Factories.php @@ -0,0 +1,539 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +use CodeIgniter\Database\ConnectionInterface; +use CodeIgniter\Model; +use Config\Services; +use InvalidArgumentException; + +/** + * Factories for creating instances. + * + * Factories allow dynamic loading of components by their path + * and name. The "shared instance" implementation provides a + * large performance boost and helps keep code clean of lengthy + * instantiation checks. + * + * @method static BaseConfig|null config(...$arguments) + * @method static Model|null models(string $alias, array $options = [], ?ConnectionInterface &$conn = null) + * @see \CodeIgniter\Config\FactoriesTest + */ +class Factories +{ + /** + * Store of component-specific options, usually + * from CodeIgniter\Config\Factory. + * + * @var array> + */ + protected static $options = []; + + /** + * Explicit options for the Config + * component to prevent logic loops. + * + * @var array + */ + private static array $configOptions = [ + 'component' => 'config', + 'path' => 'Config', + 'instanceOf' => null, + 'getShared' => true, + 'preferApp' => true, + ]; + + /** + * Mapping of class aliases to their true Fully Qualified Class Name (FQCN). + * + * Class aliases can be: + * - FQCN. E.g., 'App\Lib\SomeLib' + * - short classname. E.g., 'SomeLib' + * - short classname with sub-directories. E.g., 'Sub/SomeLib' + * + * [component => [alias => FQCN]] + * + * @var array> + */ + protected static $aliases = []; + + /** + * Store for instances of any component that + * has been requested as "shared". + * + * A multi-dimensional array with components as + * keys to the array of name-indexed instances. + * + * [component => [FQCN => instance]] + * + * @var array> + */ + protected static $instances = []; + + /** + * Whether the component instances are updated? + * + * @var array [component => true] + * + * @internal For caching only + */ + protected static $updated = []; + + /** + * Define the class to load. You can *override* the concrete class. + * + * @param string $component Lowercase, plural component name + * @param string $alias Class alias. See the $aliases property. + * @param class-string $classname FQCN to be loaded + */ + public static function define(string $component, string $alias, string $classname): void + { + $component = strtolower($component); + + if (isset(self::$aliases[$component][$alias])) { + if (self::$aliases[$component][$alias] === $classname) { + return; + } + + throw new InvalidArgumentException( + 'Already defined in Factories: ' . $component . ' ' . $alias . ' -> ' . self::$aliases[$component][$alias] + ); + } + + if (! class_exists($classname)) { + throw new InvalidArgumentException('No such class: ' . $classname); + } + + // Force a configuration to exist for this component. + // Otherwise, getOptions() will reset the component. + self::getOptions($component); + + self::$aliases[$component][$alias] = $classname; + self::$updated[$component] = true; + } + + /** + * Loads instances based on the method component name. Either + * creates a new instance or returns an existing shared instance. + * + * @return object|null + */ + public static function __callStatic(string $component, array $arguments) + { + $component = strtolower($component); + + // First argument is the class alias, second is options + $alias = trim(array_shift($arguments), '\\ '); + $options = array_shift($arguments) ?? []; + + // Determine the component-specific options + $options = array_merge(self::getOptions($component), $options); + + if (! $options['getShared']) { + if (isset(self::$aliases[$component][$alias])) { + $class = self::$aliases[$component][$alias]; + + return new $class(...$arguments); + } + + // Try to locate the class + $class = self::locateClass($options, $alias); + if ($class !== null) { + return new $class(...$arguments); + } + + return null; + } + + // Check for an existing definition + $instance = self::getDefinedInstance($options, $alias, $arguments); + if ($instance !== null) { + return $instance; + } + + // Try to locate the class + if (! $class = self::locateClass($options, $alias)) { + return null; + } + + self::createInstance($options['component'], $class, $arguments); + self::setAlias($options['component'], $alias, $class); + + return self::$instances[$options['component']][$class]; + } + + /** + * Gets the defined instance. If not exists, creates new one. + * + * @return object|null + */ + private static function getDefinedInstance(array $options, string $alias, array $arguments) + { + // The alias is already defined. + if (isset(self::$aliases[$options['component']][$alias])) { + $class = self::$aliases[$options['component']][$alias]; + + // Need to verify if the shared instance matches the request + if (self::verifyInstanceOf($options, $class)) { + // Check for an existing instance + if (isset(self::$instances[$options['component']][$class])) { + return self::$instances[$options['component']][$class]; + } + + self::createInstance($options['component'], $class, $arguments); + + return self::$instances[$options['component']][$class]; + } + } + + // Try to locate the class + if (! $class = self::locateClass($options, $alias)) { + return null; + } + + // Check for an existing instance for the class + if (isset(self::$instances[$options['component']][$class])) { + self::setAlias($options['component'], $alias, $class); + + return self::$instances[$options['component']][$class]; + } + + return null; + } + + /** + * Creates the shared instance. + */ + private static function createInstance(string $component, string $class, array $arguments): void + { + self::$instances[$component][$class] = new $class(...$arguments); + self::$updated[$component] = true; + } + + /** + * Sets alias + */ + private static function setAlias(string $component, string $alias, string $class): void + { + self::$aliases[$component][$alias] = $class; + self::$updated[$component] = true; + + // If a short classname is specified, also register FQCN to share the instance. + if (! isset(self::$aliases[$component][$class]) && ! self::isNamespaced($alias)) { + self::$aliases[$component][$class] = $class; + } + } + + /** + * Is the component Config? + * + * @param string $component Lowercase, plural component name + */ + private static function isConfig(string $component): bool + { + return $component === 'config'; + } + + /** + * Finds a component class + * + * @param array $options The array of component-specific directives + * @param string $alias Class alias. See the $aliases property. + */ + protected static function locateClass(array $options, string $alias): ?string + { + // Check for low-hanging fruit + if ( + class_exists($alias, false) + && self::verifyPreferApp($options, $alias) + && self::verifyInstanceOf($options, $alias) + ) { + return $alias; + } + + // Determine the relative class names we need + $basename = self::getBasename($alias); + $appname = self::isConfig($options['component']) + ? 'Config\\' . $basename + : rtrim(APP_NAMESPACE, '\\') . '\\' . $options['path'] . '\\' . $basename; + + // If an App version was requested then see if it verifies + if ( + // preferApp is used only for no namespaced class. + ! self::isNamespaced($alias) + && $options['preferApp'] && class_exists($appname) + && self::verifyInstanceOf($options, $alias) + ) { + return $appname; + } + + // If we have ruled out an App version and the class exists then try it + if (class_exists($alias) && self::verifyInstanceOf($options, $alias)) { + return $alias; + } + + // Have to do this the hard way... + $locator = Services::locator(); + + // Check if the class alias was namespaced + if (self::isNamespaced($alias)) { + if (! $file = $locator->locateFile($alias, $options['path'])) { + return null; + } + $files = [$file]; + } + // No namespace? Search for it + // Check all namespaces, prioritizing App and modules + elseif (! $files = $locator->search($options['path'] . DIRECTORY_SEPARATOR . $alias)) { + return null; + } + + // Check all files for a valid class + foreach ($files as $file) { + $class = $locator->getClassname($file); + + if ($class && self::verifyInstanceOf($options, $class)) { + return $class; + } + } + + return null; + } + + /** + * Is the class alias namespaced or not? + * + * @param string $alias Class alias. See the $aliases property. + */ + private static function isNamespaced(string $alias): bool + { + return strpos($alias, '\\') !== false; + } + + /** + * Verifies that a class & config satisfy the "preferApp" option + * + * @param array $options The array of component-specific directives + * @param string $alias Class alias. See the $aliases property. + */ + protected static function verifyPreferApp(array $options, string $alias): bool + { + // Anything without that restriction passes + if (! $options['preferApp']) { + return true; + } + + // Special case for Config since its App namespace is actually \Config + if (self::isConfig($options['component'])) { + return strpos($alias, 'Config') === 0; + } + + return strpos($alias, APP_NAMESPACE) === 0; + } + + /** + * Verifies that a class & config satisfy the "instanceOf" option + * + * @param array $options The array of component-specific directives + * @param string $alias Class alias. See the $aliases property. + */ + protected static function verifyInstanceOf(array $options, string $alias): bool + { + // Anything without that restriction passes + if (! $options['instanceOf']) { + return true; + } + + return is_a($alias, $options['instanceOf'], true); + } + + /** + * Returns the component-specific configuration + * + * @param string $component Lowercase, plural component name + * + * @return array + * + * @internal For testing only + * @testTag + */ + public static function getOptions(string $component): array + { + $component = strtolower($component); + + // Check for a stored version + if (isset(self::$options[$component])) { + return self::$options[$component]; + } + + $values = self::isConfig($component) + // Handle Config as a special case to prevent logic loops + ? self::$configOptions + // Load values from the best Factory configuration (will include Registrars) + : config('Factory')->{$component} ?? []; + + // The setOptions() reset the component. So getOptions() may reset + // the component. + return self::setOptions($component, $values); + } + + /** + * Normalizes, stores, and returns the configuration for a specific component + * + * @param string $component Lowercase, plural component name + * @param array $values option values + * + * @return array The result after applying defaults and normalization + */ + public static function setOptions(string $component, array $values): array + { + $component = strtolower($component); + + // Allow the config to replace the component name, to support "aliases" + $values['component'] = strtolower($values['component'] ?? $component); + + // Reset this component so instances can be rediscovered with the updated config + self::reset($values['component']); + + // If no path was available then use the component + $values['path'] = trim($values['path'] ?? ucfirst($values['component']), '\\ '); + + // Add defaults for any missing values + $values = array_merge(Factory::$default, $values); + + // Store the result to the supplied name and potential alias + self::$options[$component] = $values; + self::$options[$values['component']] = $values; + + return $values; + } + + /** + * Resets the static arrays, optionally just for one component + * + * @param string|null $component Lowercase, plural component name + * + * @return void + */ + public static function reset(?string $component = null) + { + if ($component !== null) { + unset( + self::$options[$component], + self::$aliases[$component], + self::$instances[$component], + self::$updated[$component] + ); + + return; + } + + self::$options = []; + self::$aliases = []; + self::$instances = []; + self::$updated = []; + } + + /** + * Helper method for injecting mock instances + * + * @param string $component Lowercase, plural component name + * @param string $alias Class alias. See the $aliases property. + * + * @return void + * + * @internal For testing only + * @testTag + */ + public static function injectMock(string $component, string $alias, object $instance) + { + $component = strtolower($component); + + // Force a configuration to exist for this component + self::getOptions($component); + + $class = get_class($instance); + + self::$instances[$component][$class] = $instance; + self::$aliases[$component][$alias] = $class; + + if (self::isConfig($component)) { + if (self::isNamespaced($alias)) { + self::$aliases[$component][self::getBasename($alias)] = $class; + } else { + self::$aliases[$component]['Config\\' . $alias] = $class; + } + } + } + + /** + * Gets a basename from a class alias, namespaced or not. + * + * @internal For testing only + * @testTag + */ + public static function getBasename(string $alias): string + { + // Determine the basename + if ($basename = strrchr($alias, '\\')) { + return substr($basename, 1); + } + + return $alias; + } + + /** + * Gets component data for caching. + * + * @internal For caching only + */ + public static function getComponentInstances(string $component): array + { + if (! isset(self::$aliases[$component])) { + return [ + 'options' => [], + 'aliases' => [], + 'instances' => [], + ]; + } + + return [ + 'options' => self::$options[$component], + 'aliases' => self::$aliases[$component], + 'instances' => self::$instances[$component], + ]; + } + + /** + * Sets component data + * + * @internal For caching only + */ + public static function setComponentInstances(string $component, array $data): void + { + self::$options[$component] = $data['options']; + self::$aliases[$component] = $data['aliases']; + self::$instances[$component] = $data['instances']; + + unset(self::$updated[$component]); + } + + /** + * Whether the component instances are updated? + * + * @internal For caching only + */ + public static function isUpdated(string $component): bool + { + return isset(self::$updated[$component]); + } +} diff --git a/system/Config/Factory.php b/system/Config/Factory.php new file mode 100644 index 0000000..5889bf4 --- /dev/null +++ b/system/Config/Factory.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +/** + * Factories Configuration file. + * + * Provides overriding directives for how + * Factories should handle discovery and + * instantiation of specific components. + * Each property should correspond to the + * lowercase, plural component name. + */ +class Factory extends BaseConfig +{ + /** + * Supplies a default set of options to merge for + * all unspecified factory components. + * + * @var array + */ + public static $default = [ + 'component' => null, + 'path' => null, + 'instanceOf' => null, + 'getShared' => true, + 'preferApp' => true, + ]; + + /** + * Specifies that Models should always favor child + * classes to allow easy extension of module Models. + * + * @var array + */ + public $models = [ + 'preferApp' => true, + ]; +} diff --git a/system/Config/ForeignCharacters.php b/system/Config/ForeignCharacters.php new file mode 100644 index 0000000..a8569f5 --- /dev/null +++ b/system/Config/ForeignCharacters.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +/** + * Describes foreign characters for transliteration with the text helper. + */ +class ForeignCharacters +{ + /** + * The list of foreign characters. + * + * @var array + */ + public $characterList = [ + '/ä|æ|ǽ/' => 'ae', + '/ö|œ/' => 'oe', + '/ü/' => 'ue', + '/Ä/' => 'Ae', + '/Ü/' => 'Ue', + '/Ö/' => 'Oe', + '/À|Á|Â|Ã|Ä|Å|Ǻ|Ā|Ă|Ą|Ǎ|Α|Ά|Ả|Ạ|Ầ|Ẫ|Ẩ|Ậ|Ằ|Ắ|Ẵ|Ẳ|Ặ|А/' => 'A', + '/à|á|â|ã|å|ǻ|ā|ă|ą|ǎ|ª|α|ά|ả|ạ|ầ|ấ|ẫ|ẩ|ậ|ằ|ắ|ẵ|ẳ|ặ|а/' => 'a', + '/Б/' => 'B', + '/б/' => 'b', + '/Ç|Ć|Ĉ|Ċ|Č/' => 'C', + '/ç|ć|ĉ|ċ|č/' => 'c', + '/Д/' => 'D', + '/д/' => 'd', + '/Ð|Ď|Đ|Δ/' => 'Dj', + '/ð|ď|đ|δ/' => 'dj', + '/È|É|Ê|Ë|Ē|Ĕ|Ė|Ę|Ě|Ε|Έ|Ẽ|Ẻ|Ẹ|Ề|Ế|Ễ|Ể|Ệ|Е|Э/' => 'E', + '/è|é|ê|ë|ē|ĕ|ė|ę|ě|έ|ε|ẽ|ẻ|ẹ|ề|ế|ễ|ể|ệ|е|э/' => 'e', + '/Ф/' => 'F', + '/ф/' => 'f', + '/Ĝ|Ğ|Ġ|Ģ|Γ|Г|Ґ/' => 'G', + '/ĝ|ğ|ġ|ģ|γ|г|ґ/' => 'g', + '/Ĥ|Ħ/' => 'H', + '/ĥ|ħ/' => 'h', + '/Ì|Í|Î|Ï|Ĩ|Ī|Ĭ|Ǐ|Į|İ|Η|Ή|Ί|Ι|Ϊ|Ỉ|Ị|И|Ы/' => 'I', + '/ì|í|î|ï|ĩ|ī|ĭ|ǐ|į|ı|η|ή|ί|ι|ϊ|ỉ|ị|и|ы|ї/' => 'i', + '/Ĵ/' => 'J', + '/ĵ/' => 'j', + '/Ķ|Κ|К/' => 'K', + '/ķ|κ|к/' => 'k', + '/Ĺ|Ļ|Ľ|Ŀ|Ł|Λ|Л/' => 'L', + '/ĺ|ļ|ľ|ŀ|ł|λ|л/' => 'l', + '/М/' => 'M', + '/м/' => 'm', + '/Ñ|Ń|Ņ|Ň|Ν|Н/' => 'N', + '/ñ|ń|ņ|ň|ʼn|ν|н/' => 'n', + '/Ò|Ó|Ô|Õ|Ō|Ŏ|Ǒ|Ő|Ơ|Ø|Ǿ|Ο|Ό|Ω|Ώ|Ỏ|Ọ|Ồ|Ố|Ỗ|Ổ|Ộ|Ờ|Ớ|Ỡ|Ở|Ợ|О/' => 'O', + '/ò|ó|ô|õ|ō|ŏ|ǒ|ő|ơ|ø|ǿ|º|ο|ό|ω|ώ|ỏ|ọ|ồ|ố|ỗ|ổ|ộ|ờ|ớ|ỡ|ở|ợ|о/' => 'o', + '/П/' => 'P', + '/п/' => 'p', + '/Ŕ|Ŗ|Ř|Ρ|Р/' => 'R', + '/ŕ|ŗ|ř|ρ|р/' => 'r', + '/Ś|Ŝ|Ş|Ș|Š|Σ|С/' => 'S', + '/ś|ŝ|ş|ș|š|ſ|σ|ς|с/' => 's', + '/Ț|Ţ|Ť|Ŧ|τ|Т/' => 'T', + '/ț|ţ|ť|ŧ|т/' => 't', + '/Ù|Ú|Û|Ũ|Ū|Ŭ|Ů|Ű|Ų|Ư|Ǔ|Ǖ|Ǘ|Ǚ|Ǜ|Ũ|Ủ|Ụ|Ừ|Ứ|Ữ|Ử|Ự|У/' => 'U', + '/ù|ú|û|ũ|ū|ŭ|ů|ű|ų|ư|ǔ|ǖ|ǘ|ǚ|ǜ|υ|ύ|ϋ|ủ|ụ|ừ|ứ|ữ|ử|ự|у/' => 'u', + '/Ƴ|Ɏ|Ỵ|Ẏ|Ӳ|Ӯ|Ў|Ý|Ÿ|Ŷ|Υ|Ύ|Ϋ|Ỳ|Ỹ|Ỷ|Ỵ|Й/' => 'Y', + '/ẙ|ʏ|ƴ|ɏ|ỵ|ẏ|ӳ|ӯ|ў|ý|ÿ|ŷ|ỳ|ỹ|ỷ|ỵ|й/' => 'y', + '/В/' => 'V', + '/в/' => 'v', + '/Ŵ/' => 'W', + '/ŵ/' => 'w', + '/Ź|Ż|Ž|Ζ|З/' => 'Z', + '/ź|ż|ž|ζ|з/' => 'z', + '/Æ|Ǽ/' => 'AE', + '/ß/' => 'ss', + '/IJ/' => 'IJ', + '/ij/' => 'ij', + '/Œ/' => 'OE', + '/ƒ/' => 'f', + '/ξ/' => 'ks', + '/π/' => 'p', + '/β/' => 'v', + '/μ/' => 'm', + '/ψ/' => 'ps', + '/Ё/' => 'Yo', + '/ё/' => 'yo', + '/Є/' => 'Ye', + '/є/' => 'ye', + '/Ї/' => 'Yi', + '/Ж/' => 'Zh', + '/ж/' => 'zh', + '/Х/' => 'Kh', + '/х/' => 'kh', + '/Ц/' => 'Ts', + '/ц/' => 'ts', + '/Ч/' => 'Ch', + '/ч/' => 'ch', + '/Ш/' => 'Sh', + '/ш/' => 'sh', + '/Щ/' => 'Shch', + '/щ/' => 'shch', + '/Ъ|ъ|Ь|ь/' => '', + '/Ю/' => 'Yu', + '/ю/' => 'yu', + '/Я/' => 'Ya', + '/я/' => 'ya', + ]; +} diff --git a/system/Config/Publisher.php b/system/Config/Publisher.php new file mode 100644 index 0000000..d5f938e --- /dev/null +++ b/system/Config/Publisher.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +/** + * Publisher Configuration + * + * Defines basic security restrictions for the Publisher class + * to prevent abuse by injecting malicious files into a project. + */ +class Publisher extends BaseConfig +{ + /** + * A list of allowed destinations with a (pseudo-)regex + * of allowed files for each destination. + * Attempts to publish to directories not in this list will + * result in a PublisherException. Files that do no fit the + * pattern will cause copy/merge to fail. + * + * @var array + */ + public $restrictions = [ + ROOTPATH => '*', + FCPATH => '#\.(?css|js|map|htm?|xml|json|webmanifest|tff|eot|woff?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i', + ]; + + /** + * Disables Registrars to prevent modules from altering the restrictions. + */ + final protected function registerProperties(): void + { + } +} diff --git a/system/Config/Routing.php b/system/Config/Routing.php new file mode 100644 index 0000000..e6d2513 --- /dev/null +++ b/system/Config/Routing.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +/** + * Routing configuration + */ +class Routing extends BaseConfig +{ + /** + * An array of files that contain route definitions. + * Route files are read in order, with the first match + * found taking precedence. + * + * Default: APPPATH . 'Config/Routes.php' + */ + public array $routeFiles = [ + APPPATH . 'Config/Routes.php', + ]; + + /** + * The default namespace to use for Controllers when no other + * namespace has been specified. + * + * Default: 'App\Controllers' + */ + public string $defaultNamespace = 'App\Controllers'; + + /** + * The default controller to use when no other controller has been + * specified. + * + * Default: 'Home' + */ + public string $defaultController = 'Home'; + + /** + * The default method to call on the controller when no other + * method has been set in the route. + * + * Default: 'index' + */ + public string $defaultMethod = 'index'; + + /** + * Whether to translate dashes in URIs to underscores. + * Primarily useful when using the auto-routing. + * + * Default: false + */ + public bool $translateURIDashes = false; + + /** + * Sets the class/method that should be called if routing doesn't + * find a match. It can be either a closure or the controller/method + * name exactly like a route is defined: Users::index + * + * This setting is passed to the Router class and handled there. + * + * If you want to use a closure, you will have to set it in the + * class constructor or the routes file by calling: + * + * $routes->set404Override(function() { + * // Do something here + * }); + * + * Example: + * public $override404 = 'App\Errors::show404'; + */ + public ?string $override404 = null; + + /** + * If TRUE, the system will attempt to match the URI against + * Controllers by matching each segment against folders/files + * in APPPATH/Controllers, when a match wasn't found against + * defined routes. + * + * If FALSE, will stop searching and do NO automatic routing. + */ + public bool $autoRoute = false; + + /** + * If TRUE, will enable the use of the 'prioritize' option + * when defining routes. + * + * Default: false + */ + public bool $prioritize = false; + + /** + * Map of URI segments and namespaces. For Auto Routing (Improved). + * + * The key is the first URI segment. The value is the controller namespace. + * E.g., + * [ + * 'blog' => 'Acme\Blog\Controllers', + * ] + * + * @var array [ uri_segment => namespace ] + */ + public array $moduleRoutes = []; +} diff --git a/system/Config/Services.php b/system/Config/Services.php new file mode 100644 index 0000000..5ffb9e0 --- /dev/null +++ b/system/Config/Services.php @@ -0,0 +1,849 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Cache\ResponseCache; +use CodeIgniter\CLI\Commands; +use CodeIgniter\CodeIgniter; +use CodeIgniter\Database\ConnectionInterface; +use CodeIgniter\Database\MigrationRunner; +use CodeIgniter\Debug\Exceptions; +use CodeIgniter\Debug\Iterator; +use CodeIgniter\Debug\Timer; +use CodeIgniter\Debug\Toolbar; +use CodeIgniter\Email\Email; +use CodeIgniter\Encryption\EncrypterInterface; +use CodeIgniter\Encryption\Encryption; +use CodeIgniter\Filters\Filters; +use CodeIgniter\Format\Format; +use CodeIgniter\Honeypot\Honeypot; +use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\ContentSecurityPolicy; +use CodeIgniter\HTTP\CURLRequest; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\Negotiate; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\HTTP\Request; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\Response; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\SiteURIFactory; +use CodeIgniter\HTTP\URI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Images\Handlers\BaseHandler; +use CodeIgniter\Language\Language; +use CodeIgniter\Log\Logger; +use CodeIgniter\Pager\Pager; +use CodeIgniter\Router\RouteCollection; +use CodeIgniter\Router\RouteCollectionInterface; +use CodeIgniter\Router\Router; +use CodeIgniter\Security\Security; +use CodeIgniter\Session\Handlers\Database\MySQLiHandler; +use CodeIgniter\Session\Handlers\Database\PostgreHandler; +use CodeIgniter\Session\Handlers\DatabaseHandler; +use CodeIgniter\Session\Session; +use CodeIgniter\Superglobals; +use CodeIgniter\Throttle\Throttler; +use CodeIgniter\Typography\Typography; +use CodeIgniter\Validation\Validation; +use CodeIgniter\Validation\ValidationInterface; +use CodeIgniter\View\Cell; +use CodeIgniter\View\Parser; +use CodeIgniter\View\RendererInterface; +use CodeIgniter\View\View; +use Config\App; +use Config\Cache; +use Config\ContentSecurityPolicy as ContentSecurityPolicyConfig; +use Config\ContentSecurityPolicy as CSPConfig; +use Config\Database; +use Config\Email as EmailConfig; +use Config\Encryption as EncryptionConfig; +use Config\Exceptions as ExceptionsConfig; +use Config\Filters as FiltersConfig; +use Config\Format as FormatConfig; +use Config\Honeypot as HoneypotConfig; +use Config\Images; +use Config\Logger as LoggerConfig; +use Config\Migrations; +use Config\Modules; +use Config\Pager as PagerConfig; +use Config\Paths; +use Config\Routing; +use Config\Security as SecurityConfig; +use Config\Services as AppServices; +use Config\Session as SessionConfig; +use Config\Toolbar as ToolbarConfig; +use Config\Validation as ValidationConfig; +use Config\View as ViewConfig; +use Locale; + +/** + * Services Configuration file. + * + * Services are simply other classes/libraries that the system uses + * to do its job. This is used by CodeIgniter to allow the core of the + * framework to be swapped out easily without affecting the usage within + * the rest of your application. + * + * This is used in place of a Dependency Injection container primarily + * due to its simplicity, which allows a better long-term maintenance + * of the applications built on top of CodeIgniter. A bonus side-effect + * is that IDEs are able to determine what class you are calling + * whereas with DI Containers there usually isn't a way for them to do this. + * + * @see http://blog.ircmaxell.com/2015/11/simple-easy-risk-and-change.html + * @see http://www.infoq.com/presentations/Simple-Made-Easy + * @see \CodeIgniter\Config\ServicesTest + */ +class Services extends BaseService +{ + /** + * The cache class provides a simple way to store and retrieve + * complex data for later. + * + * @return CacheInterface + */ + public static function cache(?Cache $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('cache', $config); + } + + $config ??= config(Cache::class); + + return CacheFactory::getHandler($config); + } + + /** + * The CLI Request class provides for ways to interact with + * a command line request. + * + * @return CLIRequest + * + * @internal + */ + public static function clirequest(?App $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('clirequest', $config); + } + + $config ??= config(App::class); + + return new CLIRequest($config); + } + + /** + * CodeIgniter, the core of the framework. + * + * @return CodeIgniter + */ + public static function codeigniter(?App $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('codeigniter', $config); + } + + $config ??= config(App::class); + + return new CodeIgniter($config); + } + + /** + * The commands utility for running and working with CLI commands. + * + * @return Commands + */ + public static function commands(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('commands'); + } + + return new Commands(); + } + + /** + * Content Security Policy + * + * @return ContentSecurityPolicy + */ + public static function csp(?CSPConfig $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('csp', $config); + } + + $config ??= config(ContentSecurityPolicyConfig::class); + + return new ContentSecurityPolicy($config); + } + + /** + * The CURL Request class acts as a simple HTTP client for interacting + * with other servers, typically through APIs. + * + * @return CURLRequest + */ + public static function curlrequest(array $options = [], ?ResponseInterface $response = null, ?App $config = null, bool $getShared = true) + { + if ($getShared === true) { + return static::getSharedInstance('curlrequest', $options, $response, $config); + } + + $config ??= config(App::class); + $response ??= new Response($config); + + return new CURLRequest( + $config, + new URI($options['base_uri'] ?? null), + $response, + $options + ); + } + + /** + * The Email class allows you to send email via mail, sendmail, SMTP. + * + * @param array|EmailConfig|null $config + * + * @return Email + */ + public static function email($config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('email', $config); + } + + if (empty($config) || ! (is_array($config) || $config instanceof EmailConfig)) { + $config = config(EmailConfig::class); + } + + return new Email($config); + } + + /** + * The Encryption class provides two-way encryption. + * + * @param bool $getShared + * + * @return EncrypterInterface Encryption handler + */ + public static function encrypter(?EncryptionConfig $config = null, $getShared = false) + { + if ($getShared === true) { + return static::getSharedInstance('encrypter', $config); + } + + $config ??= config(EncryptionConfig::class); + $encryption = new Encryption($config); + + return $encryption->initialize($config); + } + + /** + * The Exceptions class holds the methods that handle: + * + * - set_exception_handler + * - set_error_handler + * - register_shutdown_function + * + * @return Exceptions + */ + public static function exceptions( + ?ExceptionsConfig $config = null, + bool $getShared = true + ) { + if ($getShared) { + return static::getSharedInstance('exceptions', $config); + } + + $config ??= config(ExceptionsConfig::class); + + return new Exceptions($config); + } + + /** + * Filters allow you to run tasks before and/or after a controller + * is executed. During before filters, the request can be modified, + * and actions taken based on the request, while after filters can + * act on or modify the response itself before it is sent to the client. + * + * @return Filters + */ + public static function filters(?FiltersConfig $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('filters', $config); + } + + $config ??= config(FiltersConfig::class); + + return new Filters($config, AppServices::request(), AppServices::response()); + } + + /** + * The Format class is a convenient place to create Formatters. + * + * @return Format + */ + public static function format(?FormatConfig $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('format', $config); + } + + $config ??= config(FormatConfig::class); + + return new Format($config); + } + + /** + * The Honeypot provides a secret input on forms that bots should NOT + * fill in, providing an additional safeguard when accepting user input. + * + * @return Honeypot + */ + public static function honeypot(?HoneypotConfig $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('honeypot', $config); + } + + $config ??= config(HoneypotConfig::class); + + return new Honeypot($config); + } + + /** + * Acts as a factory for ImageHandler classes and returns an instance + * of the handler. Used like Services::image()->withFile($path)->rotate(90)->save(); + * + * @return BaseHandler + */ + public static function image(?string $handler = null, ?Images $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('image', $handler, $config); + } + + $config ??= config(Images::class); + assert($config instanceof Images); + + $handler = $handler ?: $config->defaultHandler; + $class = $config->handlers[$handler]; + + return new $class($config); + } + + /** + * The Iterator class provides a simple way of looping over a function + * and timing the results and memory usage. Used when debugging and + * optimizing applications. + * + * @return Iterator + */ + public static function iterator(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('iterator'); + } + + return new Iterator(); + } + + /** + * Responsible for loading the language string translations. + * + * @return Language + */ + public static function language(?string $locale = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('language', $locale)->setLocale($locale); + } + + if (AppServices::request() instanceof IncomingRequest) { + $requestLocale = AppServices::request()->getLocale(); + } else { + $requestLocale = Locale::getDefault(); + } + + // Use '?:' for empty string check + $locale = $locale ?: $requestLocale; + + return new Language($locale); + } + + /** + * The Logger class is a PSR-3 compatible Logging class that supports + * multiple handlers that process the actual logging. + * + * @return Logger + */ + public static function logger(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('logger'); + } + + return new Logger(config(LoggerConfig::class)); + } + + /** + * Return the appropriate Migration runner. + * + * @return MigrationRunner + */ + public static function migrations(?Migrations $config = null, ?ConnectionInterface $db = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('migrations', $config, $db); + } + + $config ??= config(Migrations::class); + + return new MigrationRunner($config, $db); + } + + /** + * The Negotiate class provides the content negotiation features for + * working the request to determine correct language, encoding, charset, + * and more. + * + * @return Negotiate + */ + public static function negotiator(?RequestInterface $request = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('negotiator', $request); + } + + $request ??= AppServices::request(); + + return new Negotiate($request); + } + + /** + * Return the ResponseCache. + * + * @return ResponseCache + */ + public static function responsecache(?Cache $config = null, ?CacheInterface $cache = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('responsecache', $config, $cache); + } + + $config ??= config(Cache::class); + $cache ??= AppServices::cache(); + + return new ResponseCache($config, $cache); + } + + /** + * Return the appropriate pagination handler. + * + * @return Pager + */ + public static function pager(?PagerConfig $config = null, ?RendererInterface $view = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('pager', $config, $view); + } + + $config ??= config(PagerConfig::class); + $view ??= AppServices::renderer(null, null, false); + + return new Pager($config, $view); + } + + /** + * The Parser is a simple template parser. + * + * @return Parser + */ + public static function parser(?string $viewPath = null, ?ViewConfig $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('parser', $viewPath, $config); + } + + $viewPath = $viewPath ?: (new Paths())->viewDirectory; + $config ??= config(ViewConfig::class); + + return new Parser($config, $viewPath, AppServices::locator(), CI_DEBUG, AppServices::logger()); + } + + /** + * The Renderer class is the class that actually displays a file to the user. + * The default View class within CodeIgniter is intentionally simple, but this + * service could easily be replaced by a template engine if the user needed to. + * + * @return View + */ + public static function renderer(?string $viewPath = null, ?ViewConfig $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('renderer', $viewPath, $config); + } + + $viewPath = $viewPath ?: (new Paths())->viewDirectory; + $config ??= config(ViewConfig::class); + + return new View($config, $viewPath, AppServices::locator(), CI_DEBUG, AppServices::logger()); + } + + /** + * Returns the current Request object. + * + * createRequest() injects IncomingRequest or CLIRequest. + * + * @return CLIRequest|IncomingRequest + * + * @deprecated The parameter $config and $getShared are deprecated. + */ + public static function request(?App $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('request', $config); + } + + // @TODO remove the following code for backward compatibility + return AppServices::incomingrequest($config, $getShared); + } + + /** + * Create the current Request object, either IncomingRequest or CLIRequest. + * + * This method is called from CodeIgniter::getRequestObject(). + * + * @internal + */ + public static function createRequest(App $config, bool $isCli = false): void + { + if ($isCli) { + $request = AppServices::clirequest($config); + } else { + $request = AppServices::incomingrequest($config); + + // guess at protocol if needed + $request->setProtocolVersion($_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1'); + } + + // Inject the request object into Services::request(). + static::$instances['request'] = $request; + } + + /** + * The IncomingRequest class models an HTTP request. + * + * @return IncomingRequest + * + * @internal + */ + public static function incomingrequest(?App $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('request', $config); + } + + $config ??= config(App::class); + + return new IncomingRequest( + $config, + AppServices::uri(), + 'php://input', + new UserAgent() + ); + } + + /** + * The Response class models an HTTP response. + * + * @return ResponseInterface + */ + public static function response(?App $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('response', $config); + } + + $config ??= config(App::class); + + return new Response($config); + } + + /** + * The Redirect class provides nice way of working with redirects. + * + * @return RedirectResponse + */ + public static function redirectresponse(?App $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('redirectresponse', $config); + } + + $config ??= config(App::class); + $response = new RedirectResponse($config); + $response->setProtocolVersion(AppServices::request()->getProtocolVersion()); + + return $response; + } + + /** + * The Routes service is a class that allows for easily building + * a collection of routes. + * + * @return RouteCollection + */ + public static function routes(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('routes'); + } + + return new RouteCollection(AppServices::locator(), config(Modules::class), config(Routing::class)); + } + + /** + * The Router class uses a RouteCollection's array of routes, and determines + * the correct Controller and Method to execute. + * + * @return Router + */ + public static function router(?RouteCollectionInterface $routes = null, ?Request $request = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('router', $routes, $request); + } + + $routes ??= AppServices::routes(); + $request ??= AppServices::request(); + + return new Router($routes, $request); + } + + /** + * The Security class provides a few handy tools for keeping the site + * secure, most notably the CSRF protection tools. + * + * @return Security + */ + public static function security(?SecurityConfig $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('security', $config); + } + + $config ??= config(SecurityConfig::class); + + return new Security($config); + } + + /** + * Return the session manager. + * + * @return Session + */ + public static function session(?SessionConfig $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('session', $config); + } + + $config ??= config(SessionConfig::class); + + $logger = AppServices::logger(); + + $driverName = $config->driver; + + if ($driverName === DatabaseHandler::class) { + $DBGroup = $config->DBGroup ?? config(Database::class)->defaultGroup; + $db = Database::connect($DBGroup); + + $driver = $db->getPlatform(); + + if ($driver === 'MySQLi') { + $driverName = MySQLiHandler::class; + } elseif ($driver === 'Postgre') { + $driverName = PostgreHandler::class; + } + } + + $driver = new $driverName($config, AppServices::request()->getIPAddress()); + $driver->setLogger($logger); + + $session = new Session($driver, $config); + $session->setLogger($logger); + + if (session_status() === PHP_SESSION_NONE) { + $session->start(); + } + + return $session; + } + + /** + * The Factory for SiteURI. + * + * @return SiteURIFactory + */ + public static function siteurifactory( + ?App $config = null, + ?Superglobals $superglobals = null, + bool $getShared = true + ) { + if ($getShared) { + return static::getSharedInstance('siteurifactory', $config, $superglobals); + } + + $config ??= config('App'); + $superglobals ??= AppServices::superglobals(); + + return new SiteURIFactory($config, $superglobals); + } + + /** + * Superglobals. + * + * @return Superglobals + */ + public static function superglobals( + ?array $server = null, + ?array $get = null, + bool $getShared = true + ) { + if ($getShared) { + return static::getSharedInstance('superglobals', $server, $get); + } + + return new Superglobals($server, $get); + } + + /** + * The Throttler class provides a simple method for implementing + * rate limiting in your applications. + * + * @return Throttler + */ + public static function throttler(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('throttler'); + } + + return new Throttler(AppServices::cache()); + } + + /** + * The Timer class provides a simple way to Benchmark portions of your + * application. + * + * @return Timer + */ + public static function timer(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('timer'); + } + + return new Timer(); + } + + /** + * Return the debug toolbar. + * + * @return Toolbar + */ + public static function toolbar(?ToolbarConfig $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('toolbar', $config); + } + + $config ??= config(ToolbarConfig::class); + + return new Toolbar($config); + } + + /** + * The URI class provides a way to model and manipulate URIs. + * + * @param string|null $uri The URI string + * + * @return URI The current URI if $uri is null. + */ + public static function uri(?string $uri = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('uri', $uri); + } + + if ($uri === null) { + $appConfig = config(App::class); + $factory = AppServices::siteurifactory($appConfig, AppServices::superglobals()); + + return $factory->createFromGlobals(); + } + + return new URI($uri); + } + + /** + * The Validation class provides tools for validating input data. + * + * @return ValidationInterface + */ + public static function validation(?ValidationConfig $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('validation', $config); + } + + $config ??= config(ValidationConfig::class); + + return new Validation($config, AppServices::renderer()); + } + + /** + * View cells are intended to let you insert HTML into view + * that has been generated by any callable in the system. + * + * @return Cell + */ + public static function viewcell(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('viewcell'); + } + + return new Cell(AppServices::cache()); + } + + /** + * The Typography class provides a way to format text in semantically relevant ways. + * + * @return Typography + */ + public static function typography(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('typography'); + } + + return new Typography(); + } +} diff --git a/system/Config/View.php b/system/Config/View.php new file mode 100644 index 0000000..101b67f --- /dev/null +++ b/system/Config/View.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +use CodeIgniter\View\ViewDecoratorInterface; + +/** + * View configuration + * + * @phpstan-type parser_callable (callable(mixed): mixed) + * @phpstan-type parser_callable_string (callable(mixed): mixed)&string + */ +class View extends BaseConfig +{ + /** + * When false, the view method will clear the data between each + * call. + * + * @var bool + */ + public $saveData = true; + + /** + * Parser Filters map a filter name with any PHP callable. When the + * Parser prepares a variable for display, it will chain it + * through the filters in the order defined, inserting any parameters. + * + * To prevent potential abuse, all filters MUST be defined here + * in order for them to be available for use within the Parser. + * + * @psalm-suppress UndefinedDocblockClass + * + * @var array + * @phpstan-var array + */ + public $filters = []; + + /** + * Parser Plugins provide a way to extend the functionality provided + * by the core Parser by creating aliases that will be replaced with + * any callable. Can be single or tag pair. + * + * @psalm-suppress UndefinedDocblockClass + * + * @var array|callable|string> + * @phpstan-var array|parser_callable_string|parser_callable> + */ + public $plugins = []; + + /** + * Built-in View filters. + * + * @var array + * @phpstan-var array + */ + protected $coreFilters = [ + 'abs' => '\abs', + 'capitalize' => '\CodeIgniter\View\Filters::capitalize', + 'date' => '\CodeIgniter\View\Filters::date', + 'date_modify' => '\CodeIgniter\View\Filters::date_modify', + 'default' => '\CodeIgniter\View\Filters::default', + 'esc' => '\CodeIgniter\View\Filters::esc', + 'excerpt' => '\CodeIgniter\View\Filters::excerpt', + 'highlight' => '\CodeIgniter\View\Filters::highlight', + 'highlight_code' => '\CodeIgniter\View\Filters::highlight_code', + 'limit_words' => '\CodeIgniter\View\Filters::limit_words', + 'limit_chars' => '\CodeIgniter\View\Filters::limit_chars', + 'local_currency' => '\CodeIgniter\View\Filters::local_currency', + 'local_number' => '\CodeIgniter\View\Filters::local_number', + 'lower' => '\strtolower', + 'nl2br' => '\CodeIgniter\View\Filters::nl2br', + 'number_format' => '\number_format', + 'prose' => '\CodeIgniter\View\Filters::prose', + 'round' => '\CodeIgniter\View\Filters::round', + 'strip_tags' => '\strip_tags', + 'title' => '\CodeIgniter\View\Filters::title', + 'upper' => '\strtoupper', + ]; + + /** + * Built-in View plugins. + * + * @var array|callable|string> + * @phpstan-var array|parser_callable_string|parser_callable> + */ + protected $corePlugins = [ + 'csp_script_nonce' => '\CodeIgniter\View\Plugins::cspScriptNonce', + 'csp_style_nonce' => '\CodeIgniter\View\Plugins::cspStyleNonce', + 'current_url' => '\CodeIgniter\View\Plugins::currentURL', + 'previous_url' => '\CodeIgniter\View\Plugins::previousURL', + 'mailto' => '\CodeIgniter\View\Plugins::mailto', + 'safe_mailto' => '\CodeIgniter\View\Plugins::safeMailto', + 'lang' => '\CodeIgniter\View\Plugins::lang', + 'validation_errors' => '\CodeIgniter\View\Plugins::validationErrors', + 'route' => '\CodeIgniter\View\Plugins::route', + 'siteURL' => '\CodeIgniter\View\Plugins::siteURL', + ]; + + /** + * View Decorators are class methods that will be run in sequence to + * have a chance to alter the generated output just prior to caching + * the results. + * + * All classes must implement CodeIgniter\View\ViewDecoratorInterface + * + * @var class-string[] + */ + public array $decorators = []; + + /** + * Merge the built-in and developer-configured filters and plugins, + * with preference to the developer ones. + */ + public function __construct() + { + $this->filters = array_merge($this->coreFilters, $this->filters); + $this->plugins = array_merge($this->corePlugins, $this->plugins); + + parent::__construct(); + } +} diff --git a/system/Controller.php b/system/Controller.php new file mode 100644 index 0000000..a28a7a5 --- /dev/null +++ b/system/Controller.php @@ -0,0 +1,198 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter; + +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Validation\Exceptions\ValidationException; +use CodeIgniter\Validation\ValidationInterface; +use Config\Services; +use Config\Validation; +use Psr\Log\LoggerInterface; + +/** + * Class Controller + * + * @see \CodeIgniter\ControllerTest + */ +class Controller +{ + /** + * Helpers that will be automatically loaded on class instantiation. + * + * @var array + */ + protected $helpers = []; + + /** + * Instance of the main Request object. + * + * @var RequestInterface + */ + protected $request; + + /** + * Instance of the main response object. + * + * @var ResponseInterface + */ + protected $response; + + /** + * Instance of logger to use. + * + * @var LoggerInterface + */ + protected $logger; + + /** + * Should enforce HTTPS access for all methods in this controller. + * + * @var int Number of seconds to set HSTS header + */ + protected $forceHTTPS = 0; + + /** + * Once validation has been run, will hold the Validation instance. + * + * @var ValidationInterface|null + */ + protected $validator; + + /** + * Constructor. + * + * @return void + * + * @throws HTTPException + */ + public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger) + { + $this->request = $request; + $this->response = $response; + $this->logger = $logger; + + if ($this->forceHTTPS > 0) { + $this->forceHTTPS($this->forceHTTPS); + } + + // Autoload helper files. + helper($this->helpers); + } + + /** + * A convenience method to use when you need to ensure that a single + * method is reached only via HTTPS. If it isn't, then a redirect + * will happen back to this method and HSTS header will be sent + * to have modern browsers transform requests automatically. + * + * @param int $duration The number of seconds this link should be + * considered secure for. Only with HSTS header. + * Default value is 1 year. + * + * @return void + * + * @throws HTTPException + */ + protected function forceHTTPS(int $duration = 31_536_000) + { + force_https($duration, $this->request, $this->response); + } + + /** + * How long to cache the current page for. + * + * @params int $time time to live in seconds. + * + * @return void + */ + protected function cachePage(int $time) + { + Services::responsecache()->setTtl($time); + } + + /** + * Handles "auto-loading" helper files. + * + * @deprecated Use `helper` function instead of using this method. + * + * @codeCoverageIgnore + * + * @return void + */ + protected function loadHelpers() + { + if ($this->helpers === []) { + return; + } + + helper($this->helpers); + } + + /** + * A shortcut to performing validation on Request data. + * + * @param array|string $rules + * @param array $messages An array of custom error messages + */ + protected function validate($rules, array $messages = []): bool + { + $this->setValidator($rules, $messages); + + return $this->validator->withRequest($this->request)->run(); + } + + /** + * A shortcut to performing validation on any input data. + * + * @param array $data The data to validate + * @param array|string $rules + * @param array $messages An array of custom error messages + * @param string|null $dbGroup The database group to use + */ + protected function validateData(array $data, $rules, array $messages = [], ?string $dbGroup = null): bool + { + $this->setValidator($rules, $messages); + + return $this->validator->run($data, null, $dbGroup); + } + + /** + * @param array|string $rules + */ + private function setValidator($rules, array $messages): void + { + $this->validator = Services::validation(); + + // If you replace the $rules array with the name of the group + if (is_string($rules)) { + $validation = config(Validation::class); + + // If the rule wasn't found in the \Config\Validation, we + // should throw an exception so the developer can find it. + if (! isset($validation->{$rules})) { + throw ValidationException::forRuleNotFound($rules); + } + + // If no error message is defined, use the error message in the Config\Validation file + if (! $messages) { + $errorName = $rules . '_errors'; + $messages = $validation->{$errorName} ?? []; + } + + $rules = $validation->{$rules}; + } + + $this->validator->setRules($rules, $messages); + } +} diff --git a/system/Cookie/CloneableCookieInterface.php b/system/Cookie/CloneableCookieInterface.php new file mode 100644 index 0000000..93f6031 --- /dev/null +++ b/system/Cookie/CloneableCookieInterface.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cookie; + +use DateTimeInterface; + +/** + * Interface for a fresh Cookie instance with selected attribute(s) + * only changed from the original instance. + */ +interface CloneableCookieInterface extends CookieInterface +{ + /** + * Creates a new Cookie with a new cookie prefix. + * + * @return static + */ + public function withPrefix(string $prefix = ''); + + /** + * Creates a new Cookie with a new name. + * + * @return static + */ + public function withName(string $name); + + /** + * Creates a new Cookie with new value. + * + * @return static + */ + public function withValue(string $value); + + /** + * Creates a new Cookie with a new cookie expires time. + * + * @param DateTimeInterface|int|string $expires + * + * @return static + */ + public function withExpires($expires); + + /** + * Creates a new Cookie that will expire the cookie from the browser. + * + * @return static + */ + public function withExpired(); + + /** + * Creates a new Cookie that will virtually never expire from the browser. + * + * @return static + * + * @deprecated See https://github.com/codeigniter4/CodeIgniter4/pull/6413 + */ + public function withNeverExpiring(); + + /** + * Creates a new Cookie with a new path on the server the cookie is available. + * + * @return static + */ + public function withPath(?string $path); + + /** + * Creates a new Cookie with a new domain the cookie is available. + * + * @return static + */ + public function withDomain(?string $domain); + + /** + * Creates a new Cookie with a new "Secure" attribute. + * + * @return static + */ + public function withSecure(bool $secure = true); + + /** + * Creates a new Cookie with a new "HttpOnly" attribute + * + * @return static + */ + public function withHTTPOnly(bool $httponly = true); + + /** + * Creates a new Cookie with a new "SameSite" attribute. + * + * @return static + */ + public function withSameSite(string $samesite); + + /** + * Creates a new Cookie with URL encoding option updated. + * + * @return static + */ + public function withRaw(bool $raw = true); +} diff --git a/system/Cookie/Cookie.php b/system/Cookie/Cookie.php new file mode 100644 index 0000000..69e4001 --- /dev/null +++ b/system/Cookie/Cookie.php @@ -0,0 +1,787 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cookie; + +use ArrayAccess; +use CodeIgniter\Cookie\Exceptions\CookieException; +use CodeIgniter\I18n\Time; +use Config\Cookie as CookieConfig; +use DateTimeInterface; +use InvalidArgumentException; +use LogicException; +use ReturnTypeWillChange; + +/** + * A `Cookie` class represents an immutable HTTP cookie value object. + * + * Being immutable, modifying one or more of its attributes will return + * a new `Cookie` instance, rather than modifying itself. Users should + * reassign this new instance to a new variable to capture it. + * + * ```php + * $cookie = new Cookie('test_cookie', 'test_value'); + * $cookie->getName(); // test_cookie + * + * $cookie->withName('prod_cookie'); + * $cookie->getName(); // test_cookie + * + * $cookie2 = $cookie->withName('prod_cookie'); + * $cookie2->getName(); // prod_cookie + * ``` + * + * @template-implements ArrayAccess + * @see \CodeIgniter\Cookie\CookieTest + */ +class Cookie implements ArrayAccess, CloneableCookieInterface +{ + /** + * @var string + */ + protected $prefix = ''; + + /** + * @var string + */ + protected $name; + + /** + * @var string + */ + protected $value; + + /** + * @var int Unix timestamp + */ + protected $expires; + + /** + * @var string + */ + protected $path = '/'; + + /** + * @var string + */ + protected $domain = ''; + + /** + * @var bool + */ + protected $secure = false; + + /** + * @var bool + */ + protected $httponly = true; + + /** + * @var string + */ + protected $samesite = self::SAMESITE_LAX; + + /** + * @var bool + */ + protected $raw = false; + + /** + * Default attributes for a Cookie object. The keys here are the + * lowercase attribute names. Do not camelCase! + * + * @var array + */ + private static array $defaults = [ + 'prefix' => '', + 'expires' => 0, + 'path' => '/', + 'domain' => '', + 'secure' => false, + 'httponly' => true, + 'samesite' => self::SAMESITE_LAX, + 'raw' => false, + ]; + + /** + * A cookie name can be any US-ASCII characters, except control characters, + * spaces, tabs, or separator characters. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes + * @see https://tools.ietf.org/html/rfc2616#section-2.2 + */ + private static string $reservedCharsList = "=,; \t\r\n\v\f()<>@:\\\"/[]?{}"; + + /** + * Set the default attributes to a Cookie instance by injecting + * the values from the `CookieConfig` config or an array. + * + * This method is called from Response::__construct(). + * + * @param array|CookieConfig $config + * + * @return array The old defaults array. Useful for resetting. + */ + public static function setDefaults($config = []) + { + $oldDefaults = self::$defaults; + $newDefaults = []; + + if ($config instanceof CookieConfig) { + $newDefaults = [ + 'prefix' => $config->prefix, + 'expires' => $config->expires, + 'path' => $config->path, + 'domain' => $config->domain, + 'secure' => $config->secure, + 'httponly' => $config->httponly, + 'samesite' => $config->samesite, + 'raw' => $config->raw, + ]; + } elseif (is_array($config)) { + $newDefaults = $config; + } + + // This array union ensures that even if passed `$config` is not + // `CookieConfig` or `array`, no empty defaults will occur. + self::$defaults = $newDefaults + $oldDefaults; + + return $oldDefaults; + } + + // ========================================================================= + // CONSTRUCTORS + // ========================================================================= + + /** + * Create a new Cookie instance from a `Set-Cookie` header. + * + * @return static + * + * @throws CookieException + */ + public static function fromHeaderString(string $cookie, bool $raw = false) + { + $data = self::$defaults; + $data['raw'] = $raw; + + $parts = preg_split('/\;[\s]*/', $cookie); + $part = explode('=', array_shift($parts), 2); + + $name = $raw ? $part[0] : urldecode($part[0]); + $value = isset($part[1]) ? ($raw ? $part[1] : urldecode($part[1])) : ''; + unset($part); + + foreach ($parts as $part) { + if (strpos($part, '=') !== false) { + [$attr, $val] = explode('=', $part); + } else { + $attr = $part; + $val = true; + } + + $data[strtolower($attr)] = $val; + } + + return new static($name, $value, $data); + } + + /** + * Construct a new Cookie instance. + * + * @param string $name The cookie's name + * @param string $value The cookie's value + * @param array $options The cookie's options + * + * @throws CookieException + */ + final public function __construct(string $name, string $value = '', array $options = []) + { + $options += self::$defaults; + + $options['expires'] = static::convertExpiresTimestamp($options['expires']); + + // If both `Expires` and `Max-Age` are set, `Max-Age` has precedence. + if (isset($options['max-age']) && is_numeric($options['max-age'])) { + $options['expires'] = Time::now()->getTimestamp() + (int) $options['max-age']; + unset($options['max-age']); + } + + // to preserve backward compatibility with array-based cookies in previous CI versions + $prefix = ($options['prefix'] === '') ? self::$defaults['prefix'] : $options['prefix']; + $path = $options['path'] ?: self::$defaults['path']; + $domain = $options['domain'] ?: self::$defaults['domain']; + + // empty string SameSite should use the default for browsers + $samesite = $options['samesite'] ?: self::$defaults['samesite']; + + $raw = $options['raw']; + $secure = $options['secure']; + $httponly = $options['httponly']; + + $this->validateName($name, $raw); + $this->validatePrefix($prefix, $secure, $path, $domain); + $this->validateSameSite($samesite, $secure); + + $this->prefix = $prefix; + $this->name = $name; + $this->value = $value; + $this->expires = static::convertExpiresTimestamp($options['expires']); + $this->path = $path; + $this->domain = $domain; + $this->secure = $secure; + $this->httponly = $httponly; + $this->samesite = ucfirst(strtolower($samesite)); + $this->raw = $raw; + } + + // ========================================================================= + // GETTERS + // ========================================================================= + + /** + * {@inheritDoc} + */ + public function getId(): string + { + return implode(';', [$this->getPrefixedName(), $this->getPath(), $this->getDomain()]); + } + + /** + * {@inheritDoc} + */ + public function getPrefix(): string + { + return $this->prefix; + } + + /** + * {@inheritDoc} + */ + public function getName(): string + { + return $this->name; + } + + /** + * {@inheritDoc} + */ + public function getPrefixedName(): string + { + $name = $this->getPrefix(); + + if ($this->isRaw()) { + $name .= $this->getName(); + } else { + $search = str_split(self::$reservedCharsList); + $replace = array_map('rawurlencode', $search); + + $name .= str_replace($search, $replace, $this->getName()); + } + + return $name; + } + + /** + * {@inheritDoc} + */ + public function getValue(): string + { + return $this->value; + } + + /** + * {@inheritDoc} + */ + public function getExpiresTimestamp(): int + { + return $this->expires; + } + + /** + * {@inheritDoc} + */ + public function getExpiresString(): string + { + return gmdate(self::EXPIRES_FORMAT, $this->expires); + } + + /** + * {@inheritDoc} + */ + public function isExpired(): bool + { + return $this->expires === 0 || $this->expires < Time::now()->getTimestamp(); + } + + /** + * {@inheritDoc} + */ + public function getMaxAge(): int + { + $maxAge = $this->expires - Time::now()->getTimestamp(); + + return $maxAge >= 0 ? $maxAge : 0; + } + + /** + * {@inheritDoc} + */ + public function getPath(): string + { + return $this->path; + } + + /** + * {@inheritDoc} + */ + public function getDomain(): string + { + return $this->domain; + } + + /** + * {@inheritDoc} + */ + public function isSecure(): bool + { + return $this->secure; + } + + /** + * {@inheritDoc} + */ + public function isHTTPOnly(): bool + { + return $this->httponly; + } + + /** + * {@inheritDoc} + */ + public function getSameSite(): string + { + return $this->samesite; + } + + /** + * {@inheritDoc} + */ + public function isRaw(): bool + { + return $this->raw; + } + + /** + * {@inheritDoc} + */ + public function getOptions(): array + { + // This is the order of options in `setcookie`. DO NOT CHANGE. + return [ + 'expires' => $this->expires, + 'path' => $this->path, + 'domain' => $this->domain, + 'secure' => $this->secure, + 'httponly' => $this->httponly, + 'samesite' => $this->samesite ?: ucfirst(self::SAMESITE_LAX), + ]; + } + + // ========================================================================= + // CLONING + // ========================================================================= + + /** + * {@inheritDoc} + */ + public function withPrefix(string $prefix = '') + { + $this->validatePrefix($prefix, $this->secure, $this->path, $this->domain); + + $cookie = clone $this; + + $cookie->prefix = $prefix; + + return $cookie; + } + + /** + * {@inheritDoc} + */ + public function withName(string $name) + { + $this->validateName($name, $this->raw); + + $cookie = clone $this; + + $cookie->name = $name; + + return $cookie; + } + + /** + * {@inheritDoc} + */ + public function withValue(string $value) + { + $cookie = clone $this; + + $cookie->value = $value; + + return $cookie; + } + + /** + * {@inheritDoc} + */ + public function withExpires($expires) + { + $cookie = clone $this; + + $cookie->expires = static::convertExpiresTimestamp($expires); + + return $cookie; + } + + /** + * {@inheritDoc} + */ + public function withExpired() + { + $cookie = clone $this; + + $cookie->expires = 0; + + return $cookie; + } + + /** + * @deprecated See https://github.com/codeigniter4/CodeIgniter4/pull/6413 + */ + public function withNeverExpiring() + { + $cookie = clone $this; + + $cookie->expires = Time::now()->getTimestamp() + 5 * YEAR; + + return $cookie; + } + + /** + * {@inheritDoc} + */ + public function withPath(?string $path) + { + $path = $path ?: self::$defaults['path']; + $this->validatePrefix($this->prefix, $this->secure, $path, $this->domain); + + $cookie = clone $this; + + $cookie->path = $path; + + return $cookie; + } + + /** + * {@inheritDoc} + */ + public function withDomain(?string $domain) + { + $domain ??= self::$defaults['domain']; + $this->validatePrefix($this->prefix, $this->secure, $this->path, $domain); + + $cookie = clone $this; + + $cookie->domain = $domain; + + return $cookie; + } + + /** + * {@inheritDoc} + */ + public function withSecure(bool $secure = true) + { + $this->validatePrefix($this->prefix, $secure, $this->path, $this->domain); + $this->validateSameSite($this->samesite, $secure); + + $cookie = clone $this; + + $cookie->secure = $secure; + + return $cookie; + } + + /** + * {@inheritDoc} + */ + public function withHTTPOnly(bool $httponly = true) + { + $cookie = clone $this; + + $cookie->httponly = $httponly; + + return $cookie; + } + + /** + * {@inheritDoc} + */ + public function withSameSite(string $samesite) + { + $this->validateSameSite($samesite, $this->secure); + + $cookie = clone $this; + + $cookie->samesite = ucfirst(strtolower($samesite)); + + return $cookie; + } + + /** + * {@inheritDoc} + */ + public function withRaw(bool $raw = true) + { + $this->validateName($this->name, $raw); + + $cookie = clone $this; + + $cookie->raw = $raw; + + return $cookie; + } + + // ========================================================================= + // ARRAY ACCESS FOR BC + // ========================================================================= + + /** + * Whether an offset exists. + * + * @param string $offset + */ + public function offsetExists($offset): bool + { + return $offset === 'expire' ? true : property_exists($this, $offset); + } + + /** + * Offset to retrieve. + * + * @param string $offset + * + * @return bool|int|string + * + * @throws InvalidArgumentException + */ + #[ReturnTypeWillChange] + public function offsetGet($offset) + { + if (! $this->offsetExists($offset)) { + throw new InvalidArgumentException(sprintf('Undefined offset "%s".', $offset)); + } + + return $offset === 'expire' ? $this->expires : $this->{$offset}; + } + + /** + * Offset to set. + * + * @param string $offset + * @param bool|int|string $value + * + * @throws LogicException + */ + public function offsetSet($offset, $value): void + { + throw new LogicException(sprintf('Cannot set values of properties of %s as it is immutable.', static::class)); + } + + /** + * Offset to unset. + * + * @param string $offset + * + * @throws LogicException + */ + public function offsetUnset($offset): void + { + throw new LogicException(sprintf('Cannot unset values of properties of %s as it is immutable.', static::class)); + } + + // ========================================================================= + // CONVERTERS + // ========================================================================= + + /** + * {@inheritDoc} + */ + public function toHeaderString(): string + { + return $this->__toString(); + } + + /** + * {@inheritDoc} + */ + public function __toString() + { + $cookieHeader = []; + + if ($this->getValue() === '') { + $cookieHeader[] = $this->getPrefixedName() . '=deleted'; + $cookieHeader[] = 'Expires=' . gmdate(self::EXPIRES_FORMAT, 0); + $cookieHeader[] = 'Max-Age=0'; + } else { + $value = $this->isRaw() ? $this->getValue() : rawurlencode($this->getValue()); + + $cookieHeader[] = sprintf('%s=%s', $this->getPrefixedName(), $value); + + if ($this->getExpiresTimestamp() !== 0) { + $cookieHeader[] = 'Expires=' . $this->getExpiresString(); + $cookieHeader[] = 'Max-Age=' . $this->getMaxAge(); + } + } + + if ($this->getPath() !== '') { + $cookieHeader[] = 'Path=' . $this->getPath(); + } + + if ($this->getDomain() !== '') { + $cookieHeader[] = 'Domain=' . $this->getDomain(); + } + + if ($this->isSecure()) { + $cookieHeader[] = 'Secure'; + } + + if ($this->isHTTPOnly()) { + $cookieHeader[] = 'HttpOnly'; + } + + $samesite = $this->getSameSite(); + + if ($samesite === '') { + // modern browsers warn in console logs that an empty SameSite attribute + // will be given the `Lax` value + $samesite = self::SAMESITE_LAX; + } + + $cookieHeader[] = 'SameSite=' . ucfirst(strtolower($samesite)); + + return implode('; ', $cookieHeader); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'value' => $this->value, + 'prefix' => $this->prefix, + 'raw' => $this->raw, + ] + $this->getOptions(); + } + + /** + * Converts expires time to Unix format. + * + * @param DateTimeInterface|int|string $expires + */ + protected static function convertExpiresTimestamp($expires = 0): int + { + if ($expires instanceof DateTimeInterface) { + $expires = $expires->format('U'); + } + + if (! is_string($expires) && ! is_int($expires)) { + throw CookieException::forInvalidExpiresTime(gettype($expires)); + } + + if (! is_numeric($expires)) { + $expires = strtotime($expires); + + if ($expires === false) { + throw CookieException::forInvalidExpiresValue(); + } + } + + return $expires > 0 ? (int) $expires : 0; + } + + // ========================================================================= + // VALIDATION + // ========================================================================= + + /** + * Validates the cookie name per RFC 2616. + * + * If `$raw` is true, names should not contain invalid characters + * as `setrawcookie()` will reject this. + * + * @throws CookieException + */ + protected function validateName(string $name, bool $raw): void + { + if ($raw && strpbrk($name, self::$reservedCharsList) !== false) { + throw CookieException::forInvalidCookieName($name); + } + + if ($name === '') { + throw CookieException::forEmptyCookieName(); + } + } + + /** + * Validates the special prefixes if some attribute requirements are met. + * + * @throws CookieException + */ + protected function validatePrefix(string $prefix, bool $secure, string $path, string $domain): void + { + if (strpos($prefix, '__Secure-') === 0 && ! $secure) { + throw CookieException::forInvalidSecurePrefix(); + } + + if (strpos($prefix, '__Host-') === 0 && (! $secure || $domain !== '' || $path !== '/')) { + throw CookieException::forInvalidHostPrefix(); + } + } + + /** + * Validates the `SameSite` to be within the allowed types. + * + * @throws CookieException + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite + */ + protected function validateSameSite(string $samesite, bool $secure): void + { + if ($samesite === '') { + $samesite = self::$defaults['samesite']; + } + + if ($samesite === '') { + $samesite = self::SAMESITE_LAX; + } + + if (! in_array(strtolower($samesite), self::ALLOWED_SAMESITE_VALUES, true)) { + throw CookieException::forInvalidSameSite($samesite); + } + + if (strtolower($samesite) === self::SAMESITE_NONE && ! $secure) { + throw CookieException::forInvalidSameSiteNone(); + } + } +} diff --git a/system/Cookie/CookieInterface.php b/system/Cookie/CookieInterface.php new file mode 100644 index 0000000..5a21d6a --- /dev/null +++ b/system/Cookie/CookieInterface.php @@ -0,0 +1,168 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cookie; + +/** + * Interface for a value object representation of an HTTP cookie. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie + */ +interface CookieInterface +{ + /** + * Cookies will be sent in all contexts, i.e in responses to both + * first-party and cross-origin requests. If `SameSite=None` is set, + * the cookie `Secure` attribute must also be set (or the cookie will be blocked). + */ + public const SAMESITE_NONE = 'none'; + + /** + * Cookies are not sent on normal cross-site subrequests (for example to + * load images or frames into a third party site), but are sent when a + * user is navigating to the origin site (i.e. when following a link). + */ + public const SAMESITE_LAX = 'lax'; + + /** + * Cookies will only be sent in a first-party context and not be sent + * along with requests initiated by third party websites. + */ + public const SAMESITE_STRICT = 'strict'; + + /** + * RFC 6265 allowed values for the "SameSite" attribute. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite + */ + public const ALLOWED_SAMESITE_VALUES = [ + self::SAMESITE_NONE, + self::SAMESITE_LAX, + self::SAMESITE_STRICT, + ]; + + /** + * Expires date format. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date + * @see https://tools.ietf.org/html/rfc7231#section-7.1.1.2 + */ + public const EXPIRES_FORMAT = 'D, d-M-Y H:i:s T'; + + /** + * Returns a unique identifier for the cookie consisting + * of its prefixed name, path, and domain. + */ + public function getId(): string; + + /** + * Gets the cookie prefix. + */ + public function getPrefix(): string; + + /** + * Gets the cookie name. + */ + public function getName(): string; + + /** + * Gets the cookie name prepended with the prefix, if any. + */ + public function getPrefixedName(): string; + + /** + * Gets the cookie value. + */ + public function getValue(): string; + + /** + * Gets the time in Unix timestamp the cookie expires. + */ + public function getExpiresTimestamp(): int; + + /** + * Gets the formatted expires time. + */ + public function getExpiresString(): string; + + /** + * Checks if the cookie is expired. + */ + public function isExpired(): bool; + + /** + * Gets the "Max-Age" cookie attribute. + */ + public function getMaxAge(): int; + + /** + * Gets the "Path" cookie attribute. + */ + public function getPath(): string; + + /** + * Gets the "Domain" cookie attribute. + */ + public function getDomain(): string; + + /** + * Gets the "Secure" cookie attribute. + * + * Checks if the cookie is only sent to the server when a request is made + * with the `https:` scheme (except on `localhost`), and therefore is more + * resistent to man-in-the-middle attacks. + */ + public function isSecure(): bool; + + /** + * Gets the "HttpOnly" cookie attribute. + * + * Checks if JavaScript is forbidden from accessing the cookie. + */ + public function isHTTPOnly(): bool; + + /** + * Gets the "SameSite" cookie attribute. + */ + public function getSameSite(): string; + + /** + * Checks if the cookie should be sent with no URL encoding. + */ + public function isRaw(): bool; + + /** + * Gets the options that are passable to the `setcookie` variant + * available on PHP 7.3+ + * + * @return array + */ + public function getOptions(): array; + + /** + * Returns the Cookie as a header value. + */ + public function toHeaderString(): string; + + /** + * Returns the string representation of the Cookie object. + * + * @return string + */ + public function __toString(); + + /** + * Returns the array representation of the Cookie object. + * + * @return array + */ + public function toArray(): array; +} diff --git a/system/Cookie/CookieStore.php b/system/Cookie/CookieStore.php new file mode 100644 index 0000000..d893a4a --- /dev/null +++ b/system/Cookie/CookieStore.php @@ -0,0 +1,257 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cookie; + +use ArrayIterator; +use CodeIgniter\Cookie\Exceptions\CookieException; +use Countable; +use IteratorAggregate; +use Traversable; + +/** + * The CookieStore object represents an immutable collection of `Cookie` value objects. + * + * @implements IteratorAggregate + * @see \CodeIgniter\Cookie\CookieStoreTest + */ +class CookieStore implements Countable, IteratorAggregate +{ + /** + * The cookie collection. + * + * @var array + */ + protected $cookies = []; + + /** + * Creates a CookieStore from an array of `Set-Cookie` headers. + * + * @param string[] $headers + * + * @return static + * + * @throws CookieException + */ + public static function fromCookieHeaders(array $headers, bool $raw = false) + { + /** + * @var Cookie[] $cookies + */ + $cookies = array_filter(array_map(static function (string $header) use ($raw) { + try { + return Cookie::fromHeaderString($header, $raw); + } catch (CookieException $e) { + log_message('error', (string) $e); + + return false; + } + }, $headers)); + + return new static($cookies); + } + + /** + * @param Cookie[] $cookies + * + * @throws CookieException + */ + final public function __construct(array $cookies) + { + $this->validateCookies($cookies); + + foreach ($cookies as $cookie) { + $this->cookies[$cookie->getId()] = $cookie; + } + } + + /** + * Checks if a `Cookie` object identified by name and + * prefix is present in the collection. + */ + public function has(string $name, string $prefix = '', ?string $value = null): bool + { + $name = $prefix . $name; + + foreach ($this->cookies as $cookie) { + if ($cookie->getPrefixedName() !== $name) { + continue; + } + + if ($value === null) { + return true; // for BC + } + + return $cookie->getValue() === $value; + } + + return false; + } + + /** + * Retrieves an instance of `Cookie` identified by a name and prefix. + * This throws an exception if not found. + * + * @throws CookieException + */ + public function get(string $name, string $prefix = ''): Cookie + { + $name = $prefix . $name; + + foreach ($this->cookies as $cookie) { + if ($cookie->getPrefixedName() === $name) { + return $cookie; + } + } + + throw CookieException::forUnknownCookieInstance([$name, $prefix]); + } + + /** + * Store a new cookie and return a new collection. The original collection + * is left unchanged. + * + * @return static + */ + public function put(Cookie $cookie) + { + $store = clone $this; + + $store->cookies[$cookie->getId()] = $cookie; + + return $store; + } + + /** + * Removes a cookie from a collection and returns an updated collection. + * The original collection is left unchanged. + * + * Removing a cookie from the store **DOES NOT** delete it from the browser. + * If you intend to delete a cookie *from the browser*, you must put an empty + * value cookie with the same name to the store. + * + * @return static + */ + public function remove(string $name, string $prefix = '') + { + $default = Cookie::setDefaults(); + + $id = implode(';', [$prefix . $name, $default['path'], $default['domain']]); + + $store = clone $this; + + foreach (array_keys($store->cookies) as $index) { + if ($index === $id) { + unset($store->cookies[$index]); + } + } + + return $store; + } + + /** + * Dispatches all cookies in store. + * + * @deprecated Response should dispatch cookies. + */ + public function dispatch(): void + { + foreach ($this->cookies as $cookie) { + $name = $cookie->getPrefixedName(); + $value = $cookie->getValue(); + $options = $cookie->getOptions(); + + if ($cookie->isRaw()) { + $this->setRawCookie($name, $value, $options); + } else { + $this->setCookie($name, $value, $options); + } + } + + $this->clear(); + } + + /** + * Returns all cookie instances in store. + * + * @return array + */ + public function display(): array + { + return $this->cookies; + } + + /** + * Clears the cookie collection. + */ + public function clear(): void + { + $this->cookies = []; + } + + /** + * Gets the Cookie count in this collection. + */ + public function count(): int + { + return count($this->cookies); + } + + /** + * Gets the iterator for the cookie collection. + * + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->cookies); + } + + /** + * Validates all cookies passed to be instances of Cookie. + * + * @throws CookieException + */ + protected function validateCookies(array $cookies): void + { + foreach ($cookies as $index => $cookie) { + $type = is_object($cookie) ? get_class($cookie) : gettype($cookie); + + if (! $cookie instanceof Cookie) { + throw CookieException::forInvalidCookieInstance([static::class, Cookie::class, $type, $index]); + } + } + } + + /** + * Extracted call to `setrawcookie()` in order to run unit tests on it. + * + * @codeCoverageIgnore + * + * @deprecated + */ + protected function setRawCookie(string $name, string $value, array $options): void + { + setrawcookie($name, $value, $options); + } + + /** + * Extracted call to `setcookie()` in order to run unit tests on it. + * + * @codeCoverageIgnore + * + * @deprecated + */ + protected function setCookie(string $name, string $value, array $options): void + { + setcookie($name, $value, $options); + } +} diff --git a/system/Cookie/Exceptions/CookieException.php b/system/Cookie/Exceptions/CookieException.php new file mode 100644 index 0000000..2e69a0e --- /dev/null +++ b/system/Cookie/Exceptions/CookieException.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cookie\Exceptions; + +use CodeIgniter\Exceptions\FrameworkException; + +/** + * CookieException is thrown for invalid cookies initialization and management. + */ +class CookieException extends FrameworkException +{ + /** + * Thrown for invalid type given for the "Expires" attribute. + * + * @return static + */ + public static function forInvalidExpiresTime(string $type) + { + return new static(lang('Cookie.invalidExpiresTime', [$type])); + } + + /** + * Thrown when the value provided for "Expires" is invalid. + * + * @return static + */ + public static function forInvalidExpiresValue() + { + return new static(lang('Cookie.invalidExpiresValue')); + } + + /** + * Thrown when the cookie name contains invalid characters per RFC 2616. + * + * @return static + */ + public static function forInvalidCookieName(string $name) + { + return new static(lang('Cookie.invalidCookieName', [$name])); + } + + /** + * Thrown when the cookie name is empty. + * + * @return static + */ + public static function forEmptyCookieName() + { + return new static(lang('Cookie.emptyCookieName')); + } + + /** + * Thrown when using the `__Secure-` prefix but the `Secure` attribute + * is not set to true. + * + * @return static + */ + public static function forInvalidSecurePrefix() + { + return new static(lang('Cookie.invalidSecurePrefix')); + } + + /** + * Thrown when using the `__Host-` prefix but the `Secure` flag is not + * set, the `Domain` is set, and the `Path` is not `/`. + * + * @return static + */ + public static function forInvalidHostPrefix() + { + return new static(lang('Cookie.invalidHostPrefix')); + } + + /** + * Thrown when the `SameSite` attribute given is not of the valid types. + * + * @return static + */ + public static function forInvalidSameSite(string $sameSite) + { + return new static(lang('Cookie.invalidSameSite', [$sameSite])); + } + + /** + * Thrown when the `SameSite` attribute is set to `None` but the `Secure` + * attribute is not set. + * + * @return static + */ + public static function forInvalidSameSiteNone() + { + return new static(lang('Cookie.invalidSameSiteNone')); + } + + /** + * Thrown when the `CookieStore` class is filled with invalid Cookie objects. + * + * @param array $data + * + * @return static + */ + public static function forInvalidCookieInstance(array $data) + { + return new static(lang('Cookie.invalidCookieInstance', $data)); + } + + /** + * Thrown when the queried Cookie object does not exist in the cookie collection. + * + * @param string[] $data + * + * @return static + */ + public static function forUnknownCookieInstance(array $data) + { + return new static(lang('Cookie.unknownCookieInstance', $data)); + } +} diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php new file mode 100644 index 0000000..48302ab --- /dev/null +++ b/system/Database/BaseBuilder.php @@ -0,0 +1,3519 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use Closure; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Traits\ConditionalTrait; +use InvalidArgumentException; + +/** + * Class BaseBuilder + * + * Provides the core Query Builder methods. + * Database-specific Builders might need to override + * certain methods to make them work. + */ +class BaseBuilder +{ + use ConditionalTrait; + + /** + * Reset DELETE data flag + * + * @var bool + */ + protected $resetDeleteData = false; + + /** + * QB SELECT data + * + * @var array + */ + protected $QBSelect = []; + + /** + * QB DISTINCT flag + * + * @var bool + */ + protected $QBDistinct = false; + + /** + * QB FROM data + * + * @var array + */ + protected $QBFrom = []; + + /** + * QB JOIN data + * + * @var array + */ + protected $QBJoin = []; + + /** + * QB WHERE data + * + * @var array + */ + protected $QBWhere = []; + + /** + * QB GROUP BY data + * + * @var array + */ + public $QBGroupBy = []; + + /** + * QB HAVING data + * + * @var array + */ + protected $QBHaving = []; + + /** + * QB keys + * list of column names. + * + * @var list + */ + protected $QBKeys = []; + + /** + * QB LIMIT data + * + * @var bool|int + */ + protected $QBLimit = false; + + /** + * QB OFFSET data + * + * @var bool|int + */ + protected $QBOffset = false; + + /** + * QB ORDER BY data + * + * @var array|string|null + */ + public $QBOrderBy = []; + + /** + * QB UNION data + * + * @var array + */ + protected array $QBUnion = []; + + /** + * QB NO ESCAPE data + * + * @var array + */ + public $QBNoEscape = []; + + /** + * QB data sets + * + * @var array|list> + */ + protected $QBSet = []; + + /** + * QB WHERE group started flag + * + * @var bool + */ + protected $QBWhereGroupStarted = false; + + /** + * QB WHERE group count + * + * @var int + */ + protected $QBWhereGroupCount = 0; + + /** + * Ignore data that cause certain + * exceptions, for example in case of + * duplicate keys. + * + * @var bool + */ + protected $QBIgnore = false; + + /** + * QB Options data + * Holds additional options and data used to render SQL + * and is reset by resetWrite() + * + * @var array{ + * updateFieldsAdditional?: array, + * tableIdentity?: string, + * updateFields?: array, + * constraints?: array, + * setQueryAsData?: string, + * sql?: string, + * alias?: string, + * fieldTypes?: array> + * } + * + * fieldTypes: [ProtectedTableName => [FieldName => Type]] + */ + protected $QBOptions; + + /** + * A reference to the database connection. + * + * @var BaseConnection + */ + protected $db; + + /** + * Name of the primary table for this instance. + * Tracked separately because $QBFrom gets escaped + * and prefixed. + * + * When $tableName to the constructor has multiple tables, + * the value is empty string. + * + * @var string + */ + protected $tableName; + + /** + * ORDER BY random keyword + * + * @var array + */ + protected $randomKeyword = [ + 'RAND()', + 'RAND(%d)', + ]; + + /** + * COUNT string + * + * @used-by CI_DB_driver::count_all() + * @used-by BaseBuilder::count_all_results() + * + * @var string + */ + protected $countString = 'SELECT COUNT(*) AS '; + + /** + * Collects the named parameters and + * their values for later binding + * in the Query object. + * + * @var array + */ + protected $binds = []; + + /** + * Collects the key count for named parameters + * in the Query object. + * + * @var array + */ + protected $bindsKeyCount = []; + + /** + * Some databases, like SQLite, do not by default + * allow limiting of delete clauses. + * + * @var bool + */ + protected $canLimitDeletes = true; + + /** + * Some databases do not by default + * allow limit update queries with WHERE. + * + * @var bool + */ + protected $canLimitWhereUpdates = true; + + /** + * Specifies which sql statements + * support the ignore option. + * + * @var array + */ + protected $supportedIgnoreStatements = []; + + /** + * Builder testing mode status. + * + * @var bool + */ + protected $testMode = false; + + /** + * Tables relation types + * + * @var array + */ + protected $joinTypes = [ + 'LEFT', + 'RIGHT', + 'OUTER', + 'INNER', + 'LEFT OUTER', + 'RIGHT OUTER', + ]; + + /** + * Strings that determine if a string represents a literal value or a field name + * + * @var string[] + */ + protected $isLiteralStr = []; + + /** + * RegExp used to get operators + * + * @var string[] + */ + protected $pregOperators = []; + + /** + * Constructor + * + * @param array|string $tableName tablename or tablenames with or without aliases + * + * Examples of $tableName: `mytable`, `jobs j`, `jobs j, users u`, `['jobs j','users u']` + * + * @throws DatabaseException + */ + public function __construct($tableName, ConnectionInterface $db, ?array $options = null) + { + if (empty($tableName)) { + throw new DatabaseException('A table must be specified when creating a new Query Builder.'); + } + + /** + * @var BaseConnection $db + */ + $this->db = $db; + + // If it contains `,`, it has multiple tables + if (is_string($tableName) && strpos($tableName, ',') === false) { + $this->tableName = $tableName; // @TODO remove alias if exists + } else { + $this->tableName = ''; + } + + $this->from($tableName); + + if ($options !== null && $options !== []) { + foreach ($options as $key => $value) { + if (property_exists($this, $key)) { + $this->{$key} = $value; + } + } + } + } + + /** + * Returns the current database connection + * + * @return BaseConnection|ConnectionInterface + */ + public function db(): ConnectionInterface + { + return $this->db; + } + + /** + * Sets a test mode status. + * + * @return $this + */ + public function testMode(bool $mode = true) + { + $this->testMode = $mode; + + return $this; + } + + /** + * Gets the name of the primary table. + */ + public function getTable(): string + { + return $this->tableName; + } + + /** + * Returns an array of bind values and their + * named parameters for binding in the Query object later. + */ + public function getBinds(): array + { + return $this->binds; + } + + /** + * Ignore + * + * Set ignore Flag for next insert, + * update or delete query. + * + * @return $this + */ + public function ignore(bool $ignore = true) + { + $this->QBIgnore = $ignore; + + return $this; + } + + /** + * Generates the SELECT portion of the query + * + * @param array|RawSql|string $select + * + * @return $this + */ + public function select($select = '*', ?bool $escape = null) + { + // If the escape value was not set, we will base it on the global setting + if (! is_bool($escape)) { + $escape = $this->db->protectIdentifiers; + } + + if ($select instanceof RawSql) { + $this->QBSelect[] = $select; + + return $this; + } + + if (is_string($select)) { + $select = $escape === false ? [$select] : explode(',', $select); + } + + foreach ($select as $val) { + $val = trim($val); + + if ($val !== '') { + $this->QBSelect[] = $val; + + /* + * When doing 'SELECT NULL as field_alias FROM table' + * null gets taken as a field, and therefore escaped + * with backticks. + * This prevents NULL being escaped + * @see https://github.com/codeigniter4/CodeIgniter4/issues/1169 + */ + if (mb_stripos(trim($val), 'NULL') === 0) { + $escape = false; + } + + $this->QBNoEscape[] = $escape; + } + } + + return $this; + } + + /** + * Generates a SELECT MAX(field) portion of a query + * + * @return $this + */ + public function selectMax(string $select = '', string $alias = '') + { + return $this->maxMinAvgSum($select, $alias); + } + + /** + * Generates a SELECT MIN(field) portion of a query + * + * @return $this + */ + public function selectMin(string $select = '', string $alias = '') + { + return $this->maxMinAvgSum($select, $alias, 'MIN'); + } + + /** + * Generates a SELECT AVG(field) portion of a query + * + * @return $this + */ + public function selectAvg(string $select = '', string $alias = '') + { + return $this->maxMinAvgSum($select, $alias, 'AVG'); + } + + /** + * Generates a SELECT SUM(field) portion of a query + * + * @return $this + */ + public function selectSum(string $select = '', string $alias = '') + { + return $this->maxMinAvgSum($select, $alias, 'SUM'); + } + + /** + * Generates a SELECT COUNT(field) portion of a query + * + * @return $this + */ + public function selectCount(string $select = '', string $alias = '') + { + return $this->maxMinAvgSum($select, $alias, 'COUNT'); + } + + /** + * Adds a subquery to the selection + */ + public function selectSubquery(BaseBuilder $subquery, string $as): self + { + $this->QBSelect[] = $this->buildSubquery($subquery, true, $as); + + return $this; + } + + /** + * SELECT [MAX|MIN|AVG|SUM|COUNT]() + * + * @used-by selectMax() + * @used-by selectMin() + * @used-by selectAvg() + * @used-by selectSum() + * + * @return $this + * + * @throws DatabaseException + * @throws DataException + */ + protected function maxMinAvgSum(string $select = '', string $alias = '', string $type = 'MAX') + { + if ($select === '') { + throw DataException::forEmptyInputGiven('Select'); + } + + if (strpos($select, ',') !== false) { + throw DataException::forInvalidArgument('column name not separated by comma'); + } + + $type = strtoupper($type); + + if (! in_array($type, ['MAX', 'MIN', 'AVG', 'SUM', 'COUNT'], true)) { + throw new DatabaseException('Invalid function type: ' . $type); + } + + if ($alias === '') { + $alias = $this->createAliasFromTable(trim($select)); + } + + $sql = $type . '(' . $this->db->protectIdentifiers(trim($select)) . ') AS ' . $this->db->escapeIdentifiers(trim($alias)); + + $this->QBSelect[] = $sql; + $this->QBNoEscape[] = null; + + return $this; + } + + /** + * Determines the alias name based on the table + */ + protected function createAliasFromTable(string $item): string + { + if (strpos($item, '.') !== false) { + $item = explode('.', $item); + + return end($item); + } + + return $item; + } + + /** + * Sets a flag which tells the query string compiler to add DISTINCT + * + * @return $this + */ + public function distinct(bool $val = true) + { + $this->QBDistinct = $val; + + return $this; + } + + /** + * Generates the FROM portion of the query + * + * @param array|string $from + * + * @return $this + */ + public function from($from, bool $overwrite = false): self + { + if ($overwrite === true) { + $this->QBFrom = []; + $this->db->setAliasedTables([]); + } + + foreach ((array) $from as $table) { + if (strpos($table, ',') !== false) { + $this->from(explode(',', $table)); + } else { + $table = trim($table); + + if ($table === '') { + continue; + } + + $this->trackAliases($table); + $this->QBFrom[] = $this->db->protectIdentifiers($table, true, null, false); + } + } + + return $this; + } + + /** + * @param BaseBuilder $from Expected subquery + * @param string $alias Subquery alias + * + * @return $this + */ + public function fromSubquery(BaseBuilder $from, string $alias): self + { + $table = $this->buildSubquery($from, true, $alias); + + $this->db->addTableAlias($alias); + $this->QBFrom[] = $table; + + return $this; + } + + /** + * Generates the JOIN portion of the query + * + * @param RawSql|string $cond + * + * @return $this + */ + public function join(string $table, $cond, string $type = '', ?bool $escape = null) + { + if ($type !== '') { + $type = strtoupper(trim($type)); + + if (! in_array($type, $this->joinTypes, true)) { + $type = ''; + } else { + $type .= ' '; + } + } + + // Extract any aliases that might exist. We use this information + // in the protectIdentifiers to know whether to add a table prefix + $this->trackAliases($table); + + if (! is_bool($escape)) { + $escape = $this->db->protectIdentifiers; + } + + // Do we want to escape the table name? + if ($escape === true) { + $table = $this->db->protectIdentifiers($table, true, null, false); + } + + if ($cond instanceof RawSql) { + $this->QBJoin[] = $type . 'JOIN ' . $table . ' ON ' . $cond; + + return $this; + } + + if (! $this->hasOperator($cond)) { + $cond = ' USING (' . ($escape ? $this->db->escapeIdentifiers($cond) : $cond) . ')'; + } elseif ($escape === false) { + $cond = ' ON ' . $cond; + } else { + // Split multiple conditions + if (preg_match_all('/\sAND\s|\sOR\s/i', $cond, $joints, PREG_OFFSET_CAPTURE)) { + $conditions = []; + $joints = $joints[0]; + array_unshift($joints, ['', 0]); + + for ($i = count($joints) - 1, $pos = strlen($cond); $i >= 0; $i--) { + $joints[$i][1] += strlen($joints[$i][0]); // offset + $conditions[$i] = substr($cond, $joints[$i][1], $pos - $joints[$i][1]); + $pos = $joints[$i][1] - strlen($joints[$i][0]); + $joints[$i] = $joints[$i][0]; + } + ksort($conditions); + } else { + $conditions = [$cond]; + $joints = ['']; + } + + $cond = ' ON '; + + foreach ($conditions as $i => $condition) { + $operator = $this->getOperator($condition); + + $cond .= $joints[$i]; + $cond .= preg_match('/(\(*)?([\[\]\w\.\'-]+)' . preg_quote($operator, '/') . '(.*)/i', $condition, $match) ? $match[1] . $this->db->protectIdentifiers($match[2]) . $operator . $this->db->protectIdentifiers($match[3]) : $condition; + } + } + + // Assemble the JOIN statement + $this->QBJoin[] = $type . 'JOIN ' . $table . $cond; + + return $this; + } + + /** + * Generates the WHERE portion of the query. + * Separates multiple calls with 'AND'. + * + * @param array|RawSql|string $key + * @param mixed $value + * + * @return $this + */ + public function where($key, $value = null, ?bool $escape = null) + { + return $this->whereHaving('QBWhere', $key, $value, 'AND ', $escape); + } + + /** + * OR WHERE + * + * Generates the WHERE portion of the query. + * Separates multiple calls with 'OR'. + * + * @param array|RawSql|string $key + * @param mixed $value + * + * @return $this + */ + public function orWhere($key, $value = null, ?bool $escape = null) + { + return $this->whereHaving('QBWhere', $key, $value, 'OR ', $escape); + } + + /** + * @used-by where() + * @used-by orWhere() + * @used-by having() + * @used-by orHaving() + * + * @param array|RawSql|string $key + * @param mixed $value + * + * @return $this + */ + protected function whereHaving(string $qbKey, $key, $value = null, string $type = 'AND ', ?bool $escape = null) + { + $rawSqlOnly = false; + + if ($key instanceof RawSql) { + if ($value === null) { + $keyValue = [(string) $key => $key]; + $rawSqlOnly = true; + } else { + $keyValue = [(string) $key => $value]; + } + } elseif (! is_array($key)) { + $keyValue = [$key => $value]; + } else { + $keyValue = $key; + } + + // If the escape value was not set will base it on the global setting + if (! is_bool($escape)) { + $escape = $this->db->protectIdentifiers; + } + + foreach ($keyValue as $k => $v) { + $prefix = empty($this->{$qbKey}) ? $this->groupGetType('') : $this->groupGetType($type); + + if ($rawSqlOnly === true) { + $k = ''; + $op = ''; + } elseif ($v !== null) { + $op = $this->getOperatorFromWhereKey($k); + + if (! empty($op)) { + $k = trim($k); + + end($op); + $op = trim(current($op)); + + // Does the key end with operator? + if (substr($k, -strlen($op)) === $op) { + $k = rtrim(substr($k, 0, -strlen($op))); + $op = " {$op}"; + } else { + $op = ''; + } + } else { + $op = ' ='; + } + + if ($this->isSubquery($v)) { + $v = $this->buildSubquery($v, true); + } else { + $bind = $this->setBind($k, $v, $escape); + $v = " :{$bind}:"; + } + } elseif (! $this->hasOperator($k) && $qbKey !== 'QBHaving') { + // value appears not to have been set, assign the test to IS NULL + $op = ' IS NULL'; + } elseif ( + // The key ends with !=, =, <>, IS, IS NOT + preg_match( + '/\s*(!?=|<>|IS(?:\s+NOT)?)\s*$/i', + $k, + $match, + PREG_OFFSET_CAPTURE + ) + ) { + $k = substr($k, 0, $match[0][1]); + $op = $match[1][0] === '=' ? ' IS NULL' : ' IS NOT NULL'; + } else { + $op = ''; + } + + if ($v instanceof RawSql) { + $this->{$qbKey}[] = [ + 'condition' => $v->with($prefix . $k . $op . $v), + 'escape' => $escape, + ]; + } else { + $this->{$qbKey}[] = [ + 'condition' => $prefix . $k . $op . $v, + 'escape' => $escape, + ]; + } + } + + return $this; + } + + /** + * Generates a WHERE field IN('item', 'item') SQL query, + * joined with 'AND' if appropriate. + * + * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery + * + * @return $this + */ + public function whereIn(?string $key = null, $values = null, ?bool $escape = null) + { + return $this->_whereIn($key, $values, false, 'AND ', $escape); + } + + /** + * Generates a WHERE field IN('item', 'item') SQL query, + * joined with 'OR' if appropriate. + * + * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery + * + * @return $this + */ + public function orWhereIn(?string $key = null, $values = null, ?bool $escape = null) + { + return $this->_whereIn($key, $values, false, 'OR ', $escape); + } + + /** + * Generates a WHERE field NOT IN('item', 'item') SQL query, + * joined with 'AND' if appropriate. + * + * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery + * + * @return $this + */ + public function whereNotIn(?string $key = null, $values = null, ?bool $escape = null) + { + return $this->_whereIn($key, $values, true, 'AND ', $escape); + } + + /** + * Generates a WHERE field NOT IN('item', 'item') SQL query, + * joined with 'OR' if appropriate. + * + * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery + * + * @return $this + */ + public function orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null) + { + return $this->_whereIn($key, $values, true, 'OR ', $escape); + } + + /** + * Generates a HAVING field IN('item', 'item') SQL query, + * joined with 'AND' if appropriate. + * + * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery + * + * @return $this + */ + public function havingIn(?string $key = null, $values = null, ?bool $escape = null) + { + return $this->_whereIn($key, $values, false, 'AND ', $escape, 'QBHaving'); + } + + /** + * Generates a HAVING field IN('item', 'item') SQL query, + * joined with 'OR' if appropriate. + * + * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery + * + * @return $this + */ + public function orHavingIn(?string $key = null, $values = null, ?bool $escape = null) + { + return $this->_whereIn($key, $values, false, 'OR ', $escape, 'QBHaving'); + } + + /** + * Generates a HAVING field NOT IN('item', 'item') SQL query, + * joined with 'AND' if appropriate. + * + * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery + * + * @return $this + */ + public function havingNotIn(?string $key = null, $values = null, ?bool $escape = null) + { + return $this->_whereIn($key, $values, true, 'AND ', $escape, 'QBHaving'); + } + + /** + * Generates a HAVING field NOT IN('item', 'item') SQL query, + * joined with 'OR' if appropriate. + * + * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery + * + * @return $this + */ + public function orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null) + { + return $this->_whereIn($key, $values, true, 'OR ', $escape, 'QBHaving'); + } + + /** + * @used-by WhereIn() + * @used-by orWhereIn() + * @used-by whereNotIn() + * @used-by orWhereNotIn() + * + * @param non-empty-string|null $key + * @param array|BaseBuilder|Closure|null $values The values searched on, or anonymous function with subquery + * + * @return $this + * + * @throws InvalidArgumentException + */ + protected function _whereIn(?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null, string $clause = 'QBWhere') + { + if ($key === null || $key === '') { + throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function'])); + } + + if ($values === null || (! is_array($values) && ! $this->isSubquery($values))) { + throw new InvalidArgumentException(sprintf('%s() expects $values to be of type array or closure', debug_backtrace(0, 2)[1]['function'])); + } + + if (! is_bool($escape)) { + $escape = $this->db->protectIdentifiers; + } + + $ok = $key; + + if ($escape === true) { + $key = $this->db->protectIdentifiers($key); + } + + $not = ($not) ? ' NOT' : ''; + + if ($this->isSubquery($values)) { + $whereIn = $this->buildSubquery($values, true); + $escape = false; + } else { + $whereIn = array_values($values); + } + + $ok = $this->setBind($ok, $whereIn, $escape); + + $prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type); + + $whereIn = [ + 'condition' => "{$prefix}{$key}{$not} IN :{$ok}:", + 'escape' => false, + ]; + + $this->{$clause}[] = $whereIn; + + return $this; + } + + /** + * Generates a %LIKE% portion of the query. + * Separates multiple calls with 'AND'. + * + * @param array|RawSql|string $field + * + * @return $this + */ + public function like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + { + return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch); + } + + /** + * Generates a NOT LIKE portion of the query. + * Separates multiple calls with 'AND'. + * + * @param array|RawSql|string $field + * + * @return $this + */ + public function notLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + { + return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch); + } + + /** + * Generates a %LIKE% portion of the query. + * Separates multiple calls with 'OR'. + * + * @param array|RawSql|string $field + * + * @return $this + */ + public function orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + { + return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch); + } + + /** + * Generates a NOT LIKE portion of the query. + * Separates multiple calls with 'OR'. + * + * @param array|RawSql|string $field + * + * @return $this + */ + public function orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + { + return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch); + } + + /** + * Generates a %LIKE% portion of the query. + * Separates multiple calls with 'AND'. + * + * @param array|RawSql|string $field + * + * @return $this + */ + public function havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + { + return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch, 'QBHaving'); + } + + /** + * Generates a NOT LIKE portion of the query. + * Separates multiple calls with 'AND'. + * + * @param array|RawSql|string $field + * + * @return $this + */ + public function notHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + { + return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving'); + } + + /** + * Generates a %LIKE% portion of the query. + * Separates multiple calls with 'OR'. + * + * @param array|RawSql|string $field + * + * @return $this + */ + public function orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + { + return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch, 'QBHaving'); + } + + /** + * Generates a NOT LIKE portion of the query. + * Separates multiple calls with 'OR'. + * + * @param array|RawSql|string $field + * + * @return $this + */ + public function orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + { + return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving'); + } + + /** + * @used-by like() + * @used-by orLike() + * @used-by notLike() + * @used-by orNotLike() + * @used-by havingLike() + * @used-by orHavingLike() + * @used-by notHavingLike() + * @used-by orNotHavingLike() + * + * @param array|RawSql|string $field + * + * @return $this + */ + protected function _like($field, string $match = '', string $type = 'AND ', string $side = 'both', string $not = '', ?bool $escape = null, bool $insensitiveSearch = false, string $clause = 'QBWhere') + { + $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers; + $side = strtolower($side); + + if ($field instanceof RawSql) { + $k = (string) $field; + $v = $match; + $insensitiveSearch = false; + + $prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type); + + if ($side === 'none') { + $bind = $this->setBind($field->getBindingKey(), $v, $escape); + } elseif ($side === 'before') { + $bind = $this->setBind($field->getBindingKey(), "%{$v}", $escape); + } elseif ($side === 'after') { + $bind = $this->setBind($field->getBindingKey(), "{$v}%", $escape); + } else { + $bind = $this->setBind($field->getBindingKey(), "%{$v}%", $escape); + } + + $likeStatement = $this->_like_statement($prefix, $k, $not, $bind, $insensitiveSearch); + + // some platforms require an escape sequence definition for LIKE wildcards + if ($escape === true && $this->db->likeEscapeStr !== '') { + $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar); + } + + $this->{$clause}[] = [ + 'condition' => $field->with($likeStatement), + 'escape' => $escape, + ]; + + return $this; + } + + $keyValue = ! is_array($field) ? [$field => $match] : $field; + + foreach ($keyValue as $k => $v) { + if ($insensitiveSearch === true) { + $v = strtolower($v); + } + + $prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type); + + if ($side === 'none') { + $bind = $this->setBind($k, $v, $escape); + } elseif ($side === 'before') { + $bind = $this->setBind($k, "%{$v}", $escape); + } elseif ($side === 'after') { + $bind = $this->setBind($k, "{$v}%", $escape); + } else { + $bind = $this->setBind($k, "%{$v}%", $escape); + } + + $likeStatement = $this->_like_statement($prefix, $k, $not, $bind, $insensitiveSearch); + + // some platforms require an escape sequence definition for LIKE wildcards + if ($escape === true && $this->db->likeEscapeStr !== '') { + $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar); + } + + $this->{$clause}[] = [ + 'condition' => $likeStatement, + 'escape' => $escape, + ]; + } + + return $this; + } + + /** + * Platform independent LIKE statement builder. + */ + protected function _like_statement(?string $prefix, string $column, ?string $not, string $bind, bool $insensitiveSearch = false): string + { + if ($insensitiveSearch === true) { + return "{$prefix} LOWER(" . $this->db->escapeIdentifiers($column) . ") {$not} LIKE :{$bind}:"; + } + + return "{$prefix} {$column} {$not} LIKE :{$bind}:"; + } + + /** + * Add UNION statement + * + * @param BaseBuilder|Closure $union + * + * @return $this + */ + public function union($union) + { + return $this->addUnionStatement($union); + } + + /** + * Add UNION ALL statement + * + * @param BaseBuilder|Closure $union + * + * @return $this + */ + public function unionAll($union) + { + return $this->addUnionStatement($union, true); + } + + /** + * @used-by union() + * @used-by unionAll() + * + * @param BaseBuilder|Closure $union + * + * @return $this + */ + protected function addUnionStatement($union, bool $all = false) + { + $this->QBUnion[] = "\nUNION " + . ($all ? 'ALL ' : '') + . 'SELECT * FROM ' + . $this->buildSubquery($union, true, 'uwrp' . (count($this->QBUnion) + 1)); + + return $this; + } + + /** + * Starts a query group. + * + * @return $this + */ + public function groupStart() + { + return $this->groupStartPrepare(); + } + + /** + * Starts a query group, but ORs the group + * + * @return $this + */ + public function orGroupStart() + { + return $this->groupStartPrepare('', 'OR '); + } + + /** + * Starts a query group, but NOTs the group + * + * @return $this + */ + public function notGroupStart() + { + return $this->groupStartPrepare('NOT '); + } + + /** + * Starts a query group, but OR NOTs the group + * + * @return $this + */ + public function orNotGroupStart() + { + return $this->groupStartPrepare('NOT ', 'OR '); + } + + /** + * Ends a query group + * + * @return $this + */ + public function groupEnd() + { + return $this->groupEndPrepare(); + } + + /** + * Starts a query group for HAVING clause. + * + * @return $this + */ + public function havingGroupStart() + { + return $this->groupStartPrepare('', 'AND ', 'QBHaving'); + } + + /** + * Starts a query group for HAVING clause, but ORs the group. + * + * @return $this + */ + public function orHavingGroupStart() + { + return $this->groupStartPrepare('', 'OR ', 'QBHaving'); + } + + /** + * Starts a query group for HAVING clause, but NOTs the group. + * + * @return $this + */ + public function notHavingGroupStart() + { + return $this->groupStartPrepare('NOT ', 'AND ', 'QBHaving'); + } + + /** + * Starts a query group for HAVING clause, but OR NOTs the group. + * + * @return $this + */ + public function orNotHavingGroupStart() + { + return $this->groupStartPrepare('NOT ', 'OR ', 'QBHaving'); + } + + /** + * Ends a query group for HAVING clause. + * + * @return $this + */ + public function havingGroupEnd() + { + return $this->groupEndPrepare('QBHaving'); + } + + /** + * Prepate a query group start. + * + * @return $this + */ + protected function groupStartPrepare(string $not = '', string $type = 'AND ', string $clause = 'QBWhere') + { + $type = $this->groupGetType($type); + + $this->QBWhereGroupStarted = true; + $prefix = empty($this->{$clause}) ? '' : $type; + $where = [ + 'condition' => $prefix . $not . str_repeat(' ', ++$this->QBWhereGroupCount) . ' (', + 'escape' => false, + ]; + + $this->{$clause}[] = $where; + + return $this; + } + + /** + * Prepate a query group end. + * + * @return $this + */ + protected function groupEndPrepare(string $clause = 'QBWhere') + { + $this->QBWhereGroupStarted = false; + $where = [ + 'condition' => str_repeat(' ', $this->QBWhereGroupCount--) . ')', + 'escape' => false, + ]; + + $this->{$clause}[] = $where; + + return $this; + } + + /** + * @used-by groupStart() + * @used-by _like() + * @used-by whereHaving() + * @used-by _whereIn() + * @used-by havingGroupStart() + */ + protected function groupGetType(string $type): string + { + if ($this->QBWhereGroupStarted) { + $type = ''; + $this->QBWhereGroupStarted = false; + } + + return $type; + } + + /** + * @param array|string $by + * + * @return $this + */ + public function groupBy($by, ?bool $escape = null) + { + if (! is_bool($escape)) { + $escape = $this->db->protectIdentifiers; + } + + if (is_string($by)) { + $by = ($escape === true) ? explode(',', $by) : [$by]; + } + + foreach ($by as $val) { + $val = trim($val); + + if ($val !== '') { + $val = [ + 'field' => $val, + 'escape' => $escape, + ]; + + $this->QBGroupBy[] = $val; + } + } + + return $this; + } + + /** + * Separates multiple calls with 'AND'. + * + * @param array|RawSql|string $key + * @param mixed $value + * + * @return $this + */ + public function having($key, $value = null, ?bool $escape = null) + { + return $this->whereHaving('QBHaving', $key, $value, 'AND ', $escape); + } + + /** + * Separates multiple calls with 'OR'. + * + * @param array|RawSql|string $key + * @param mixed $value + * + * @return $this + */ + public function orHaving($key, $value = null, ?bool $escape = null) + { + return $this->whereHaving('QBHaving', $key, $value, 'OR ', $escape); + } + + /** + * @param string $direction ASC, DESC or RANDOM + * + * @return $this + */ + public function orderBy(string $orderBy, string $direction = '', ?bool $escape = null) + { + $qbOrderBy = []; + if ($orderBy === '') { + return $this; + } + + $direction = strtoupper(trim($direction)); + + if ($direction === 'RANDOM') { + $direction = ''; + $orderBy = ctype_digit($orderBy) ? sprintf($this->randomKeyword[1], $orderBy) : $this->randomKeyword[0]; + $escape = false; + } elseif ($direction !== '') { + $direction = in_array($direction, ['ASC', 'DESC'], true) ? ' ' . $direction : ''; + } + + if (! is_bool($escape)) { + $escape = $this->db->protectIdentifiers; + } + + if ($escape === false) { + $qbOrderBy[] = [ + 'field' => $orderBy, + 'direction' => $direction, + 'escape' => false, + ]; + } else { + $qbOrderBy = []; + + foreach (explode(',', $orderBy) as $field) { + $qbOrderBy[] = ($direction === '' && preg_match('/\s+(ASC|DESC)$/i', rtrim($field), $match, PREG_OFFSET_CAPTURE)) + ? [ + 'field' => ltrim(substr($field, 0, $match[0][1])), + 'direction' => ' ' . $match[1][0], + 'escape' => true, + ] + : [ + 'field' => trim($field), + 'direction' => $direction, + 'escape' => true, + ]; + } + } + + $this->QBOrderBy = array_merge($this->QBOrderBy, $qbOrderBy); + + return $this; + } + + /** + * @return $this + */ + public function limit(?int $value = null, ?int $offset = 0) + { + if ($value !== null) { + $this->QBLimit = $value; + } + + if ($offset !== null && $offset !== 0) { + $this->QBOffset = $offset; + } + + return $this; + } + + /** + * Sets the OFFSET value + * + * @return $this + */ + public function offset(int $offset) + { + if ($offset !== 0) { + $this->QBOffset = $offset; + } + + return $this; + } + + /** + * Generates a platform-specific LIMIT clause. + */ + protected function _limit(string $sql, bool $offsetIgnore = false): string + { + return $sql . ' LIMIT ' . ($offsetIgnore === false && $this->QBOffset ? $this->QBOffset . ', ' : '') . $this->QBLimit; + } + + /** + * Allows key/value pairs to be set for insert(), update() or replace(). + * + * @param array|object|string $key Field name, or an array of field/value pairs + * @param mixed $value Field value, if $key is a single field + * @param bool|null $escape Whether to escape values + * + * @return $this + */ + public function set($key, $value = '', ?bool $escape = null) + { + $key = $this->objectToArray($key); + + if (! is_array($key)) { + $key = [$key => $value]; + } + + $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers; + + foreach ($key as $k => $v) { + if ($escape) { + $bind = $this->setBind($k, $v, $escape); + + $this->QBSet[$this->db->protectIdentifiers($k, false)] = ":{$bind}:"; + } else { + $this->QBSet[$this->db->protectIdentifiers($k, false)] = $v; + } + } + + return $this; + } + + /** + * Returns the previously set() data, alternatively resetting it if needed. + */ + public function getSetData(bool $clean = false): array + { + $data = $this->QBSet; + + if ($clean) { + $this->QBSet = []; + } + + return $data; + } + + /** + * Compiles a SELECT query string and returns the sql. + */ + public function getCompiledSelect(bool $reset = true): string + { + $select = $this->compileSelect(); + + if ($reset === true) { + $this->resetSelect(); + } + + return $this->compileFinalQuery($select); + } + + /** + * Returns a finalized, compiled query string with the bindings + * inserted and prefixes swapped out. + */ + protected function compileFinalQuery(string $sql): string + { + $query = new Query($this->db); + $query->setQuery($sql, $this->binds, false); + + if (! empty($this->db->swapPre) && ! empty($this->db->DBPrefix)) { + $query->swapPrefix($this->db->DBPrefix, $this->db->swapPre); + } + + return $query->getQuery(); + } + + /** + * Compiles the select statement based on the other functions called + * and runs the query + * + * @return false|ResultInterface + */ + public function get(?int $limit = null, int $offset = 0, bool $reset = true) + { + if ($limit !== null) { + $this->limit($limit, $offset); + } + + $result = $this->testMode + ? $this->getCompiledSelect($reset) + : $this->db->query($this->compileSelect(), $this->binds, false); + + if ($reset === true) { + $this->resetSelect(); + + // Clear our binds so we don't eat up memory + $this->binds = []; + } + + return $result; + } + + /** + * Generates a platform-specific query string that counts all records in + * the particular table + * + * @return int|string + */ + public function countAll(bool $reset = true) + { + $table = $this->QBFrom[0]; + + $sql = $this->countString . $this->db->escapeIdentifiers('numrows') . ' FROM ' . + $this->db->protectIdentifiers($table, true, null, false); + + if ($this->testMode) { + return $sql; + } + + $query = $this->db->query($sql, null, false); + + if (empty($query->getResult())) { + return 0; + } + + $query = $query->getRow(); + + if ($reset === true) { + $this->resetSelect(); + } + + return (int) $query->numrows; + } + + /** + * Generates a platform-specific query string that counts all records + * returned by an Query Builder query. + * + * @return int|string + */ + public function countAllResults(bool $reset = true) + { + // ORDER BY usage is often problematic here (most notably + // on Microsoft SQL Server) and ultimately unnecessary + // for selecting COUNT(*) ... + $orderBy = []; + + if (! empty($this->QBOrderBy)) { + $orderBy = $this->QBOrderBy; + + $this->QBOrderBy = null; + } + + // We cannot use a LIMIT when getting the single row COUNT(*) result + $limit = $this->QBLimit; + + $this->QBLimit = false; + + if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) { + // We need to backup the original SELECT in case DBPrefix is used + $select = $this->QBSelect; + $sql = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results"; + + // Restore SELECT part + $this->QBSelect = $select; + unset($select); + } else { + $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows')); + } + + if ($this->testMode) { + return $sql; + } + + $result = $this->db->query($sql, $this->binds, false); + + if ($reset === true) { + $this->resetSelect(); + } elseif (! isset($this->QBOrderBy)) { + $this->QBOrderBy = $orderBy; + } + + // Restore the LIMIT setting + $this->QBLimit = $limit; + + $row = ! $result instanceof ResultInterface ? null : $result->getRow(); + + if (empty($row)) { + return 0; + } + + return (int) $row->numrows; + } + + /** + * Compiles the set conditions and returns the sql statement + * + * @return array + */ + public function getCompiledQBWhere() + { + return $this->QBWhere; + } + + /** + * Allows the where clause, limit and offset to be added directly + * + * @param array|string $where + * + * @return ResultInterface + */ + public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bool $reset = true) + { + if ($where !== null) { + $this->where($where); + } + + if ($limit !== null && $limit !== 0) { + $this->limit($limit, $offset); + } + + $result = $this->testMode + ? $this->getCompiledSelect($reset) + : $this->db->query($this->compileSelect(), $this->binds, false); + + if ($reset === true) { + $this->resetSelect(); + + // Clear our binds so we don't eat up memory + $this->binds = []; + } + + return $result; + } + + /** + * Compiles batch insert/update/upsert strings and runs the queries + * + * @param '_deleteBatch'|'_insertBatch'|'_updateBatch'|'_upsertBatch' $renderMethod + * + * @return false|int|string[] Number of rows inserted or FALSE on failure, SQL array when testMode + * + * @throws DatabaseException + */ + protected function batchExecute(string $renderMethod, int $batchSize = 100) + { + if (empty($this->QBSet)) { + if ($this->db->DBDebug) { + throw new DatabaseException(trim($renderMethod, '_') . '() has no data.'); + } + + return false; // @codeCoverageIgnore + } + + $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false); + + $affectedRows = 0; + $savedSQL = []; + $cnt = count($this->QBSet); + + // batch size 0 for unlimited + if ($batchSize === 0) { + $batchSize = $cnt; + } + + for ($i = 0, $total = $cnt; $i < $total; $i += $batchSize) { + $QBSet = array_slice($this->QBSet, $i, $batchSize); + + $sql = $this->{$renderMethod}($table, $this->QBKeys, $QBSet); + + if ($sql === '') { + return false; // @codeCoverageIgnore + } + + if ($this->testMode) { + $savedSQL[] = $sql; + } else { + $this->db->query($sql, null, false); + $affectedRows += $this->db->affectedRows(); + } + } + + if (! $this->testMode) { + $this->resetWrite(); + } + + return $this->testMode ? $savedSQL : $affectedRows; + } + + /** + * Allows a row or multiple rows to be set for batch inserts/upserts/updates + * + * @param array|object $set + * @param string $alias alias for sql table + * + * @return $this|null + */ + public function setData($set, ?bool $escape = null, string $alias = '') + { + if (empty($set)) { + if ($this->db->DBDebug) { + throw new DatabaseException('setData() has no data.'); + } + + return null; // @codeCoverageIgnore + } + + $this->setAlias($alias); + + // this allows to set just one row at a time + if (is_object($set) || (! is_array(current($set)) && ! is_object(current($set)))) { + $set = [$set]; + } + + $set = $this->batchObjectToArray($set); + + $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers; + + $keys = array_keys($this->objectToArray(current($set))); + sort($keys); + + foreach ($set as $row) { + $row = $this->objectToArray($row); + if (array_diff($keys, array_keys($row)) !== [] || array_diff(array_keys($row), $keys) !== []) { + // batchExecute() function returns an error on an empty array + $this->QBSet[] = []; + + return null; + } + + ksort($row); // puts $row in the same order as our keys + + $clean = []; + + foreach ($row as $rowValue) { + $clean[] = $escape ? $this->db->escape($rowValue) : $rowValue; + } + + $row = $clean; + + $this->QBSet[] = $row; + } + + foreach ($keys as $k) { + $k = $this->db->protectIdentifiers($k, false); + + if (! in_array($k, $this->QBKeys, true)) { + $this->QBKeys[] = $k; + } + } + + return $this; + } + + /** + * Compiles an upsert query and returns the sql + * + * @return string + * + * @throws DatabaseException + */ + public function getCompiledUpsert() + { + [$currentTestMode, $this->testMode] = [$this->testMode, true]; + + $sql = implode(";\n", $this->upsert()); + + $this->testMode = $currentTestMode; + + return $this->compileFinalQuery($sql); + } + + /** + * Converts call to batchUpsert + * + * @param array|object|null $set + * + * @return false|int|string[] Number of affected rows or FALSE on failure, SQL array when testMode + * + * @throws DatabaseException + */ + public function upsert($set = null, ?bool $escape = null) + { + // if set() has been used merge QBSet with binds and then setData() + if ($set === null && ! is_array(current($this->QBSet))) { + $set = []; + + foreach ($this->QBSet as $field => $value) { + $k = trim($field, $this->db->escapeChar); + // use binds if available else use QBSet value but with RawSql to avoid escape + $set[$k] = isset($this->binds[$k]) ? $this->binds[$k][0] : new RawSql($value); + } + + $this->binds = []; + + $this->resetRun([ + 'QBSet' => [], + 'QBKeys' => [], + ]); + + $this->setData($set, true); // unescaped items are RawSql now + } elseif ($set !== null) { + $this->setData($set, $escape); + } // else setData() has already been used and we need to do nothing + + return $this->batchExecute('_upsertBatch'); + } + + /** + * Compiles batch upsert strings and runs the queries + * + * @param array|object|null $set a dataset + * + * @return false|int|string[] Number of affected rows or FALSE on failure, SQL array when testMode + * + * @throws DatabaseException + */ + public function upsertBatch($set = null, ?bool $escape = null, int $batchSize = 100) + { + if (isset($this->QBOptions['setQueryAsData'])) { + $sql = $this->_upsertBatch($this->QBFrom[0], $this->QBKeys, []); + + if ($sql === '') { + return false; // @codeCoverageIgnore + } + + if ($this->testMode === false) { + $this->db->query($sql, null, false); + } + + $this->resetWrite(); + + return $this->testMode ? $sql : $this->db->affectedRows(); + } + + if ($set !== null) { + $this->setData($set, $escape); + } + + return $this->batchExecute('_upsertBatch', $batchSize); + } + + /** + * Generates a platform-specific upsertBatch string from the supplied data + * + * @used-by batchExecute + * + * @param string $table Protected table name + * @param list $keys QBKeys + * @param list> $values QBSet + */ + protected function _upsertBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $updateFields = $this->QBOptions['updateFields'] ?? $this->updateFields($keys)->QBOptions['updateFields'] ?? []; + + $sql = 'INSERT INTO ' . $table . ' (' . implode(', ', $keys) . ")\n{:_table_:}ON DUPLICATE KEY UPDATE\n" . implode( + ",\n", + array_map( + static fn ($key, $value) => $table . '.' . $key . ($value instanceof RawSql ? + ' = ' . $value : + ' = VALUES(' . $value . ')'), + array_keys($updateFields), + $updateFields + ) + ); + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData'] . "\n"; + } else { + $data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Set table alias for dataset pseudo table. + */ + private function setAlias(string $alias): BaseBuilder + { + if ($alias !== '') { + $this->db->addTableAlias($alias); + $this->QBOptions['alias'] = $this->db->protectIdentifiers($alias); + } + + return $this; + } + + /** + * Sets update fields for upsert, update + * + * @param RawSql[]|string|string[] $set + * @param bool $addToDefault adds update fields to the default ones + * @param array|null $ignore ignores items in set + * + * @return $this + */ + public function updateFields($set, bool $addToDefault = false, ?array $ignore = null) + { + if (! empty($set)) { + if (! is_array($set)) { + $set = explode(',', $set); + } + + foreach ($set as $key => $value) { + if (! ($value instanceof RawSql)) { + $value = $this->db->protectIdentifiers($value); + } + + if (is_numeric($key)) { + $key = $value; + } + + if ($ignore === null || ! in_array($key, $ignore, true)) { + if ($addToDefault) { + $this->QBOptions['updateFieldsAdditional'][$this->db->protectIdentifiers($key)] = $value; + } else { + $this->QBOptions['updateFields'][$this->db->protectIdentifiers($key)] = $value; + } + } + } + + if ($addToDefault === false && isset($this->QBOptions['updateFieldsAdditional'], $this->QBOptions['updateFields'])) { + $this->QBOptions['updateFields'] = array_merge($this->QBOptions['updateFields'], $this->QBOptions['updateFieldsAdditional']); + + unset($this->QBOptions['updateFieldsAdditional']); + } + } + + return $this; + } + + /** + * Sets constraints for batch upsert, update + * + * @param array|RawSql|string $set a string of columns, key value pairs, or RawSql + * + * @return $this + */ + public function onConstraint($set) + { + if (! empty($set)) { + if (is_string($set)) { + $set = explode(',', $set); + + $set = array_map(static fn ($key) => trim($key), $set); + } + + if ($set instanceof RawSql) { + $set = [$set]; + } + + foreach ($set as $key => $value) { + if (! ($value instanceof RawSql)) { + $value = $this->db->protectIdentifiers($value); + } + + if (is_string($key)) { + $key = $this->db->protectIdentifiers($key); + } + + $this->QBOptions['constraints'][$key] = $value; + } + } + + return $this; + } + + /** + * Sets data source as a query for insertBatch()/updateBatch()/upsertBatch()/deleteBatch() + * + * @param BaseBuilder|RawSql $query + * @param array|string|null $columns an array or comma delimited string of columns + */ + public function setQueryAsData($query, ?string $alias = null, $columns = null): BaseBuilder + { + if (is_string($query)) { + throw new InvalidArgumentException('$query parameter must be BaseBuilder or RawSql class.'); + } + + if ($query instanceof BaseBuilder) { + $query = $query->getCompiledSelect(); + } elseif ($query instanceof RawSql) { + $query = $query->__toString(); + } + + if (is_string($query)) { + if ($columns !== null && is_string($columns)) { + $columns = explode(',', $columns); + $columns = array_map(static fn ($key) => trim($key), $columns); + } + + $columns = (array) $columns; + + if ($columns === []) { + $columns = $this->fieldsFromQuery($query); + } + + if ($alias !== null) { + $this->setAlias($alias); + } + + foreach ($columns as $key => $value) { + $columns[$key] = $this->db->escapeChar . $value . $this->db->escapeChar; + } + + $this->QBOptions['setQueryAsData'] = $query; + $this->QBKeys = $columns; + $this->QBSet = []; + } + + return $this; + } + + /** + * Gets column names from a select query + */ + protected function fieldsFromQuery(string $sql): array + { + return $this->db->query('SELECT * FROM (' . $sql . ') _u_ LIMIT 1')->getFieldNames(); + } + + /** + * Converts value array of array to array of strings + */ + protected function formatValues(array $values): array + { + return array_map(static fn ($index) => '(' . implode(',', $index) . ')', $values); + } + + /** + * Compiles batch insert strings and runs the queries + * + * @param array|object|null $set a dataset + * + * @return false|int|string[] Number of rows inserted or FALSE on failure, SQL array when testMode + */ + public function insertBatch($set = null, ?bool $escape = null, int $batchSize = 100) + { + if (isset($this->QBOptions['setQueryAsData'])) { + $sql = $this->_insertBatch($this->QBFrom[0], $this->QBKeys, []); + + if ($sql === '') { + return false; // @codeCoverageIgnore + } + + if ($this->testMode === false) { + $this->db->query($sql, null, false); + } + + $this->resetWrite(); + + return $this->testMode ? $sql : $this->db->affectedRows(); + } + + if ($set !== null && $set !== []) { + $this->setData($set, $escape); + } + + return $this->batchExecute('_insertBatch', $batchSize); + } + + /** + * Generates a platform-specific insert string from the supplied data. + * + * @used-by batchExecute + * + * @param string $table Protected table name + * @param list $keys QBKeys + * @param list> $values QBSet + */ + protected function _insertBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $sql = 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $table + . ' (' . implode(', ', $keys) . ")\n{:_table_:}"; + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = 'VALUES ' . implode(', ', $this->formatValues($values)); + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Allows key/value pairs to be set for batch inserts + * + * @param mixed $key + * + * @return $this|null + * + * @deprecated + */ + public function setInsertBatch($key, string $value = '', ?bool $escape = null) + { + if (! is_array($key)) { + $key = [[$key => $value]]; + } + + return $this->setData($key, $escape); + } + + /** + * Compiles an insert query and returns the sql + * + * @return bool|string + * + * @throws DatabaseException + */ + public function getCompiledInsert(bool $reset = true) + { + if ($this->validateInsert() === false) { + return false; + } + + $sql = $this->_insert( + $this->db->protectIdentifiers( + $this->removeAlias($this->QBFrom[0]), + true, + null, + false + ), + array_keys($this->QBSet), + array_values($this->QBSet) + ); + + if ($reset === true) { + $this->resetWrite(); + } + + return $this->compileFinalQuery($sql); + } + + /** + * Compiles an insert string and runs the query + * + * @param array|object|null $set + * + * @return BaseResult|bool|Query + * + * @throws DatabaseException + */ + public function insert($set = null, ?bool $escape = null) + { + if ($set !== null) { + $this->set($set, '', $escape); + } + + if ($this->validateInsert() === false) { + return false; + } + + $sql = $this->_insert( + $this->db->protectIdentifiers( + $this->removeAlias($this->QBFrom[0]), + true, + $escape, + false + ), + array_keys($this->QBSet), + array_values($this->QBSet) + ); + + if (! $this->testMode) { + $this->resetWrite(); + + $result = $this->db->query($sql, $this->binds, false); + + // Clear our binds so we don't eat up memory + $this->binds = []; + + return $result; + } + + return false; + } + + /** + * @internal This is a temporary solution. + * + * @see https://github.com/codeigniter4/CodeIgniter4/pull/5376 + * + * @TODO Fix a root cause, and this method should be removed. + */ + protected function removeAlias(string $from): string + { + if (strpos($from, ' ') !== false) { + // if the alias is written with the AS keyword, remove it + $from = preg_replace('/\s+AS\s+/i', ' ', $from); + + $parts = explode(' ', $from); + $from = $parts[0]; + } + + return $from; + } + + /** + * This method is used by both insert() and getCompiledInsert() to + * validate that the there data is actually being set and that table + * has been chosen to be inserted into. + * + * @throws DatabaseException + */ + protected function validateInsert(): bool + { + if (empty($this->QBSet)) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must use the "set" method to insert an entry.'); + } + + return false; // @codeCoverageIgnore + } + + return true; + } + + /** + * Generates a platform-specific insert string from the supplied data + * + * @param string $table Protected table name + */ + protected function _insert(string $table, array $keys, array $unescapedKeys): string + { + return 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $unescapedKeys) . ')'; + } + + /** + * Compiles a replace into string and runs the query + * + * @return BaseResult|false|Query|string + * + * @throws DatabaseException + */ + public function replace(?array $set = null) + { + if ($set !== null) { + $this->set($set); + } + + if (empty($this->QBSet)) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must use the "set" method to update an entry.'); + } + + return false; // @codeCoverageIgnore + } + + $table = $this->QBFrom[0]; + + $sql = $this->_replace($table, array_keys($this->QBSet), array_values($this->QBSet)); + + $this->resetWrite(); + + return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false); + } + + /** + * Generates a platform-specific replace string from the supplied data + * + * @param string $table Protected table name + */ + protected function _replace(string $table, array $keys, array $values): string + { + return 'REPLACE INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $values) . ')'; + } + + /** + * Groups tables in FROM clauses if needed, so there is no confusion + * about operator precedence. + * + * Note: This is only used (and overridden) by MySQL and SQLSRV. + */ + protected function _fromTables(): string + { + return implode(', ', $this->QBFrom); + } + + /** + * Compiles an update query and returns the sql + * + * @return bool|string + */ + public function getCompiledUpdate(bool $reset = true) + { + if ($this->validateUpdate() === false) { + return false; + } + + $sql = $this->_update($this->QBFrom[0], $this->QBSet); + + if ($reset === true) { + $this->resetWrite(); + } + + return $this->compileFinalQuery($sql); + } + + /** + * Compiles an update string and runs the query. + * + * @param array|object|null $set + * @param array|RawSql|string|null $where + * + * @throws DatabaseException + */ + public function update($set = null, $where = null, ?int $limit = null): bool + { + if ($set !== null) { + $this->set($set); + } + + if ($this->validateUpdate() === false) { + return false; + } + + if ($where !== null) { + $this->where($where); + } + + if ($limit !== null && $limit !== 0) { + if (! $this->canLimitWhereUpdates) { + throw new DatabaseException('This driver does not allow LIMITs on UPDATE queries using WHERE.'); + } + + $this->limit($limit); + } + + $sql = $this->_update($this->QBFrom[0], $this->QBSet); + + if (! $this->testMode) { + $this->resetWrite(); + + $result = $this->db->query($sql, $this->binds, false); + + if ($result !== false) { + // Clear our binds so we don't eat up memory + $this->binds = []; + + return true; + } + + return false; + } + + return true; + } + + /** + * Generates a platform-specific update string from the supplied data + * + * @param string $table Protected table name + */ + protected function _update(string $table, array $values): string + { + $valStr = []; + + foreach ($values as $key => $val) { + $valStr[] = $key . ' = ' . $val; + } + + return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr) + . $this->compileWhereHaving('QBWhere') + . $this->compileOrderBy() + . ($this->QBLimit ? $this->_limit(' ', true) : ''); + } + + /** + * This method is used by both update() and getCompiledUpdate() to + * validate that data is actually being set and that a table has been + * chosen to be updated. + * + * @throws DatabaseException + */ + protected function validateUpdate(): bool + { + if (empty($this->QBSet)) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must use the "set" method to update an entry.'); + } + + return false; // @codeCoverageIgnore + } + + return true; + } + + /** + * Sets data and calls batchExecute to run queries + * + * @param array|object|null $set a dataset + * @param array|RawSql|string|null $constraints + * + * @return false|int|string[] Number of rows affected or FALSE on failure, SQL array when testMode + */ + public function updateBatch($set = null, $constraints = null, int $batchSize = 100) + { + $this->onConstraint($constraints); + + if (isset($this->QBOptions['setQueryAsData'])) { + $sql = $this->_updateBatch($this->QBFrom[0], $this->QBKeys, []); + + if ($sql === '') { + return false; // @codeCoverageIgnore + } + + if ($this->testMode === false) { + $this->db->query($sql, null, false); + } + + $this->resetWrite(); + + return $this->testMode ? $sql : $this->db->affectedRows(); + } + + if ($set !== null && $set !== []) { + $this->setData($set, true); + } + + return $this->batchExecute('_updateBatch', $batchSize); + } + + /** + * Generates a platform-specific batch update string from the supplied data + * + * @used-by batchExecute + * + * @param string $table Protected table name + * @param list $keys QBKeys + * @param list> $values QBSet + */ + protected function _updateBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $constraints = $this->QBOptions['constraints'] ?? []; + + if ($constraints === []) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must specify a constraint to match on for batch updates.'); // @codeCoverageIgnore + } + + return ''; // @codeCoverageIgnore + } + + $updateFields = $this->QBOptions['updateFields'] ?? + $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ?? + []; + + $alias = $this->QBOptions['alias'] ?? '_u'; + + $sql = 'UPDATE ' . $this->compileIgnore('update') . $table . "\n"; + + $sql .= "SET\n"; + + $sql .= implode( + ",\n", + array_map( + static fn ($key, $value) => $key . ($value instanceof RawSql ? + ' = ' . $value : + ' = ' . $alias . '.' . $value), + array_keys($updateFields), + $updateFields + ) + ) . "\n"; + + $sql .= "FROM (\n{:_table_:}"; + + $sql .= ') ' . $alias . "\n"; + + $sql .= 'WHERE ' . implode( + ' AND ', + array_map( + static fn ($key, $value) => ( + ($value instanceof RawSql && is_string($key)) + ? + $table . '.' . $key . ' = ' . $value + : + ( + $value instanceof RawSql + ? + $value + : + $table . '.' . $value . ' = ' . $alias . '.' . $value + ) + ), + array_keys($constraints), + $constraints + ) + ); + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = implode( + " UNION ALL\n", + array_map( + static fn ($value) => 'SELECT ' . implode(', ', array_map( + static fn ($key, $index) => $index . ' ' . $key, + $keys, + $value + )), + $values + ) + ) . "\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Allows key/value pairs to be set for batch updating + * + * @param array|object $key + * + * @return $this + * + * @throws DatabaseException + * + * @deprecated + */ + public function setUpdateBatch($key, string $index = '', ?bool $escape = null) + { + if ($index !== '') { + $this->onConstraint($index); + } + + $this->setData($key, $escape); + + return $this; + } + + /** + * Compiles a delete string and runs "DELETE FROM table" + * + * @return bool|string TRUE on success, FALSE on failure, string on testMode + */ + public function emptyTable() + { + $table = $this->QBFrom[0]; + + $sql = $this->_delete($table); + + if ($this->testMode) { + return $sql; + } + + $this->resetWrite(); + + return $this->db->query($sql, null, false); + } + + /** + * Compiles a truncate string and runs the query + * If the database does not support the truncate() command + * This function maps to "DELETE FROM table" + * + * @return bool|string TRUE on success, FALSE on failure, string on testMode + */ + public function truncate() + { + $table = $this->QBFrom[0]; + + $sql = $this->_truncate($table); + + if ($this->testMode) { + return $sql; + } + + $this->resetWrite(); + + return $this->db->query($sql, null, false); + } + + /** + * Generates a platform-specific truncate string from the supplied data + * + * If the database does not support the truncate() command, + * then this method maps to 'DELETE FROM table' + * + * @param string $table Protected table name + */ + protected function _truncate(string $table): string + { + return 'TRUNCATE ' . $table; + } + + /** + * Compiles a delete query string and returns the sql + */ + public function getCompiledDelete(bool $reset = true): string + { + $sql = $this->testMode()->delete('', null, $reset); + $this->testMode(false); + + return $this->compileFinalQuery($sql); + } + + /** + * Compiles a delete string and runs the query + * + * @param array|RawSql|string $where + * + * @return bool|string Returns a SQL string if in test mode. + * + * @throws DatabaseException + */ + public function delete($where = '', ?int $limit = null, bool $resetData = true) + { + $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false); + + if ($where !== '') { + $this->where($where); + } + + if (empty($this->QBWhere)) { + if ($this->db->DBDebug) { + throw new DatabaseException('Deletes are not allowed unless they contain a "where" or "like" clause.'); + } + + return false; // @codeCoverageIgnore + } + + $sql = $this->_delete($this->removeAlias($table)); + + if ($limit !== null && $limit !== 0) { + $this->QBLimit = $limit; + } + + if (! empty($this->QBLimit)) { + if (! $this->canLimitDeletes) { + throw new DatabaseException('SQLite3 does not allow LIMITs on DELETE queries.'); + } + + $sql = $this->_limit($sql, true); + } + + if ($resetData) { + $this->resetWrite(); + } + + return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false); + } + + /** + * Sets data and calls batchExecute to run queries + * + * @param array|object|null $set a dataset + * @param array|RawSql|null $constraints + * + * @return false|int|string[] Number of rows affected or FALSE on failure, SQL array when testMode + */ + public function deleteBatch($set = null, $constraints = null, int $batchSize = 100) + { + $this->onConstraint($constraints); + + if (isset($this->QBOptions['setQueryAsData'])) { + $sql = $this->_deleteBatch($this->QBFrom[0], $this->QBKeys, []); + + if ($sql === '') { + return false; // @codeCoverageIgnore + } + + if ($this->testMode === false) { + $this->db->query($sql, null, false); + } + + $this->resetWrite(); + + return $this->testMode ? $sql : $this->db->affectedRows(); + } + + if ($set !== null && $set !== []) { + $this->setData($set, true); + } + + return $this->batchExecute('_deleteBatch', $batchSize); + } + + /** + * Generates a platform-specific batch update string from the supplied data + * + * @used-by batchExecute + * + * @param string $table Protected table name + * @param list $keys QBKeys + * @paramst> $values QBSet + */ + protected function _deleteBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $constraints = $this->QBOptions['constraints'] ?? []; + + if ($constraints === []) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must specify a constraint to match on for batch deletes.'); // @codeCoverageIgnore + } + + return ''; // @codeCoverageIgnore + } + + $alias = $this->QBOptions['alias'] ?? '_u'; + + $sql = 'DELETE ' . $table . ' FROM ' . $table . "\n"; + + $sql .= "INNER JOIN (\n{:_table_:}"; + + $sql .= ') ' . $alias . "\n"; + + $sql .= 'ON ' . implode( + ' AND ', + array_map( + static fn ($key, $value) => ( + $value instanceof RawSql ? + $value : + ( + is_string($key) ? + $table . '.' . $key . ' = ' . $alias . '.' . $value : + $table . '.' . $value . ' = ' . $alias . '.' . $value + ) + ), + array_keys($constraints), + $constraints + ) + ); + + // convert binds in where + foreach ($this->QBWhere as $key => $where) { + foreach ($this->binds as $field => $bind) { + $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']); + } + } + + $sql .= ' ' . $this->compileWhereHaving('QBWhere'); + + $this->QBOptions['sql'] = trim($sql); + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = implode( + " UNION ALL\n", + array_map( + static fn ($value) => 'SELECT ' . implode(', ', array_map( + static fn ($key, $index) => $index . ' ' . $key, + $keys, + $value + )), + $values + ) + ) . "\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Increments a numeric column by the specified value. + * + * @return bool + */ + public function increment(string $column, int $value = 1) + { + $column = $this->db->protectIdentifiers($column); + + $sql = $this->_update($this->QBFrom[0], [$column => "{$column} + {$value}"]); + + if (! $this->testMode) { + $this->resetWrite(); + + return $this->db->query($sql, $this->binds, false); + } + + return true; + } + + /** + * Decrements a numeric column by the specified value. + * + * @return bool + */ + public function decrement(string $column, int $value = 1) + { + $column = $this->db->protectIdentifiers($column); + + $sql = $this->_update($this->QBFrom[0], [$column => "{$column}-{$value}"]); + + if (! $this->testMode) { + $this->resetWrite(); + + return $this->db->query($sql, $this->binds, false); + } + + return true; + } + + /** + * Generates a platform-specific delete string from the supplied data + * + * @param string $table Protected table name + */ + protected function _delete(string $table): string + { + return 'DELETE ' . $this->compileIgnore('delete') . 'FROM ' . $table . $this->compileWhereHaving('QBWhere'); + } + + /** + * Used to track SQL statements written with aliased tables. + * + * @param array|string $table The table to inspect + * + * @return string|void + */ + protected function trackAliases($table) + { + if (is_array($table)) { + foreach ($table as $t) { + $this->trackAliases($t); + } + + return; + } + + // Does the string contain a comma? If so, we need to separate + // the string into discreet statements + if (strpos($table, ',') !== false) { + return $this->trackAliases(explode(',', $table)); + } + + // if a table alias is used we can recognize it by a space + if (strpos($table, ' ') !== false) { + // if the alias is written with the AS keyword, remove it + $table = preg_replace('/\s+AS\s+/i', ' ', $table); + + // Grab the alias + $table = trim(strrchr($table, ' ')); + + // Store the alias, if it doesn't already exist + $this->db->addTableAlias($table); + } + } + + /** + * Compile the SELECT statement + * + * Generates a query string based on which functions were used. + * Should not be called directly. + * + * @param mixed $selectOverride + */ + protected function compileSelect($selectOverride = false): string + { + if ($selectOverride !== false) { + $sql = $selectOverride; + } else { + $sql = (! $this->QBDistinct) ? 'SELECT ' : 'SELECT DISTINCT '; + + if (empty($this->QBSelect)) { + $sql .= '*'; + } elseif ($this->QBSelect[0] instanceof RawSql) { + $sql .= (string) $this->QBSelect[0]; + } else { + // Cycle through the "select" portion of the query and prep each column name. + // The reason we protect identifiers here rather than in the select() function + // is because until the user calls the from() function we don't know if there are aliases + foreach ($this->QBSelect as $key => $val) { + $noEscape = $this->QBNoEscape[$key] ?? null; + $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $noEscape); + } + + $sql .= implode(', ', $this->QBSelect); + } + } + + if (! empty($this->QBFrom)) { + $sql .= "\nFROM " . $this->_fromTables(); + } + + if (! empty($this->QBJoin)) { + $sql .= "\n" . implode("\n", $this->QBJoin); + } + + $sql .= $this->compileWhereHaving('QBWhere') + . $this->compileGroupBy() + . $this->compileWhereHaving('QBHaving') + . $this->compileOrderBy(); + + if ($this->QBLimit) { + $sql = $this->_limit($sql . "\n"); + } + + return $this->unionInjection($sql); + } + + /** + * Checks if the ignore option is supported by + * the Database Driver for the specific statement. + * + * @return string + */ + protected function compileIgnore(string $statement) + { + if ($this->QBIgnore && isset($this->supportedIgnoreStatements[$statement])) { + return trim($this->supportedIgnoreStatements[$statement]) . ' '; + } + + return ''; + } + + /** + * Escapes identifiers in WHERE and HAVING statements at execution time. + * + * Required so that aliases are tracked properly, regardless of whether + * where(), orWhere(), having(), orHaving are called prior to from(), + * join() and prefixTable is added only if needed. + * + * @param string $qbKey 'QBWhere' or 'QBHaving' + * + * @return string SQL statement + */ + protected function compileWhereHaving(string $qbKey): string + { + if (! empty($this->{$qbKey})) { + foreach ($this->{$qbKey} as &$qbkey) { + // Is this condition already compiled? + if (is_string($qbkey)) { + continue; + } + + if ($qbkey instanceof RawSql) { + continue; + } + + if ($qbkey['condition'] instanceof RawSql) { + $qbkey = $qbkey['condition']; + + continue; + } + + if ($qbkey['escape'] === false) { + $qbkey = $qbkey['condition']; + + continue; + } + + // Split multiple conditions + $conditions = preg_split( + '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i', + $qbkey['condition'], + -1, + PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY + ); + + foreach ($conditions as &$condition) { + if (($op = $this->getOperator($condition)) === false + || ! preg_match('/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(? '(test <= foo)', /* the whole thing */ + // 1 => '(', /* optional */ + // 2 => 'test', /* the field name */ + // 3 => ' <= ', /* $op */ + // 4 => 'foo', /* optional, if $op is e.g. 'IS NULL' */ + // 5 => ')' /* optional */ + // ); + + if (! empty($matches[4])) { + $protectIdentifiers = false; + if (strpos($matches[4], '.') !== false) { + $protectIdentifiers = true; + } + + if (strpos($matches[4], ':') === false) { + $matches[4] = $this->db->protectIdentifiers(trim($matches[4]), false, $protectIdentifiers); + } + + $matches[4] = ' ' . $matches[4]; + } + + $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2])) + . ' ' . trim($matches[3]) . $matches[4] . $matches[5]; + } + + $qbkey = implode('', $conditions); + } + + return ($qbKey === 'QBHaving' ? "\nHAVING " : "\nWHERE ") + . implode("\n", $this->{$qbKey}); + } + + return ''; + } + + /** + * Escapes identifiers in GROUP BY statements at execution time. + * + * Required so that aliases are tracked properly, regardless of whether + * groupBy() is called prior to from(), join() and prefixTable is added + * only if needed. + */ + protected function compileGroupBy(): string + { + if (! empty($this->QBGroupBy)) { + foreach ($this->QBGroupBy as &$groupBy) { + // Is it already compiled? + if (is_string($groupBy)) { + continue; + } + + $groupBy = ($groupBy['escape'] === false || $this->isLiteral($groupBy['field'])) + ? $groupBy['field'] + : $this->db->protectIdentifiers($groupBy['field']); + } + + return "\nGROUP BY " . implode(', ', $this->QBGroupBy); + } + + return ''; + } + + /** + * Escapes identifiers in ORDER BY statements at execution time. + * + * Required so that aliases are tracked properly, regardless of whether + * orderBy() is called prior to from(), join() and prefixTable is added + * only if needed. + */ + protected function compileOrderBy(): string + { + if (is_array($this->QBOrderBy) && $this->QBOrderBy !== []) { + foreach ($this->QBOrderBy as &$orderBy) { + if ($orderBy['escape'] !== false && ! $this->isLiteral($orderBy['field'])) { + $orderBy['field'] = $this->db->protectIdentifiers($orderBy['field']); + } + + $orderBy = $orderBy['field'] . $orderBy['direction']; + } + + return $this->QBOrderBy = "\nORDER BY " . implode(', ', $this->QBOrderBy); + } + + if (is_string($this->QBOrderBy)) { + return $this->QBOrderBy; + } + + return ''; + } + + protected function unionInjection(string $sql): string + { + if ($this->QBUnion === []) { + return $sql; + } + + return 'SELECT * FROM (' . $sql . ') ' + . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers('uwrp0') : 'uwrp0') + . implode("\n", $this->QBUnion); + } + + /** + * Takes an object as input and converts the class variables to array key/vals + * + * @param array|object $object + * + * @return array + */ + protected function objectToArray($object) + { + if (! is_object($object)) { + return $object; + } + + if ($object instanceof RawSql) { + throw new InvalidArgumentException('RawSql "' . $object . '" cannot be used here.'); + } + + $array = []; + + foreach (get_object_vars($object) as $key => $val) { + if ((! is_object($val) || $val instanceof RawSql) && ! is_array($val)) { + $array[$key] = $val; + } + } + + return $array; + } + + /** + * Takes an object as input and converts the class variables to array key/vals + * + * @param array|object $object + * + * @return array + */ + protected function batchObjectToArray($object) + { + if (! is_object($object)) { + return $object; + } + + $array = []; + $out = get_object_vars($object); + $fields = array_keys($out); + + foreach ($fields as $val) { + $i = 0; + + foreach ($out[$val] as $data) { + $array[$i++][$val] = $data; + } + } + + return $array; + } + + /** + * Determines if a string represents a literal value or a field name + */ + protected function isLiteral(string $str): bool + { + $str = trim($str); + + if ($str === '' + || ctype_digit($str) + || (string) (float) $str === $str + || in_array(strtoupper($str), ['TRUE', 'FALSE'], true) + ) { + return true; + } + + if ($this->isLiteralStr === []) { + $this->isLiteralStr = $this->db->escapeChar !== '"' ? ['"', "'"] : ["'"]; + } + + return in_array($str[0], $this->isLiteralStr, true); + } + + /** + * Publicly-visible method to reset the QB values. + * + * @return $this + */ + public function resetQuery() + { + $this->resetSelect(); + $this->resetWrite(); + + return $this; + } + + /** + * Resets the query builder values. Called by the get() function + * + * @param array $qbResetItems An array of fields to reset + */ + protected function resetRun(array $qbResetItems) + { + foreach ($qbResetItems as $item => $defaultValue) { + $this->{$item} = $defaultValue; + } + } + + /** + * Resets the query builder values. Called by the get() function + */ + protected function resetSelect() + { + $this->resetRun([ + 'QBSelect' => [], + 'QBJoin' => [], + 'QBWhere' => [], + 'QBGroupBy' => [], + 'QBHaving' => [], + 'QBOrderBy' => [], + 'QBNoEscape' => [], + 'QBDistinct' => false, + 'QBLimit' => false, + 'QBOffset' => false, + 'QBUnion' => [], + ]); + + if (! empty($this->db)) { + $this->db->setAliasedTables([]); + } + + // Reset QBFrom part + if (! empty($this->QBFrom)) { + $this->from(array_shift($this->QBFrom), true); + } + } + + /** + * Resets the query builder "write" values. + * + * Called by the insert() update() insertBatch() updateBatch() and delete() functions + */ + protected function resetWrite() + { + $this->resetRun([ + 'QBSet' => [], + 'QBJoin' => [], + 'QBWhere' => [], + 'QBOrderBy' => [], + 'QBKeys' => [], + 'QBLimit' => false, + 'QBIgnore' => false, + 'QBOptions' => [], + ]); + } + + /** + * Tests whether the string has an SQL operator + */ + protected function hasOperator(string $str): bool + { + return preg_match( + '/(<|>|!|=|\sIS NULL|\sIS NOT NULL|\sEXISTS|\sBETWEEN|\sLIKE|\sIN\s*\(|\s)/i', + trim($str) + ) === 1; + } + + /** + * Returns the SQL string operator + * + * @return array|false|string + */ + protected function getOperator(string $str, bool $list = false) + { + if ($this->pregOperators === []) { + $_les = $this->db->likeEscapeStr !== '' + ? '\s+' . preg_quote(trim(sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar)), '/') + : ''; + $this->pregOperators = [ + '\s*(?:<|>|!)?=\s*', // =, <=, >=, != + '\s*<>?\s*', // <, <> + '\s*>\s*', // > + '\s+IS NULL', // IS NULL + '\s+IS NOT NULL', // IS NOT NULL + '\s+EXISTS\s*\(.*\)', // EXISTS (sql) + '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS(sql) + '\s+BETWEEN\s+', // BETWEEN value AND value + '\s+IN\s*\(.*\)', // IN (list) + '\s+NOT IN\s*\(.*\)', // NOT IN (list) + '\s+LIKE\s+\S.*(' . $_les . ')?', // LIKE 'expr'[ ESCAPE '%s'] + '\s+NOT LIKE\s+\S.*(' . $_les . ')?', // NOT LIKE 'expr'[ ESCAPE '%s'] + ]; + } + + return preg_match_all( + '/' . implode('|', $this->pregOperators) . '/i', + $str, + $match + ) ? ($list ? $match[0] : $match[0][0]) : false; + } + + /** + * Returns the SQL string operator from where key + * + * @return false|list + */ + private function getOperatorFromWhereKey(string $whereKey) + { + $whereKey = trim($whereKey); + + $pregOperators = [ + '\s*(?:<|>|!)?=', // =, <=, >=, != + '\s*<>?', // <, <> + '\s*>', // > + '\s+IS NULL', // IS NULL + '\s+IS NOT NULL', // IS NOT NULL + '\s+EXISTS\s*\(.*\)', // EXISTS (sql) + '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS (sql) + '\s+BETWEEN\s+', // BETWEEN value AND value + '\s+IN\s*\(.*\)', // IN (list) + '\s+NOT IN\s*\(.*\)', // NOT IN (list) + '\s+LIKE', // LIKE + '\s+NOT LIKE', // NOT LIKE + ]; + + return preg_match_all( + '/' . implode('|', $pregOperators) . '/i', + $whereKey, + $match + ) ? $match[0] : false; + } + + /** + * Stores a bind value after ensuring that it's unique. + * While it might be nicer to have named keys for our binds array + * with PHP 7+ we get a huge memory/performance gain with indexed + * arrays instead, so lets take advantage of that here. + * + * @param mixed $value + */ + protected function setBind(string $key, $value = null, bool $escape = true): string + { + if (! array_key_exists($key, $this->binds)) { + $this->binds[$key] = [ + $value, + $escape, + ]; + + return $key; + } + + if (! array_key_exists($key, $this->bindsKeyCount)) { + $this->bindsKeyCount[$key] = 1; + } + + $count = $this->bindsKeyCount[$key]++; + + $this->binds[$key . '.' . $count] = [ + $value, + $escape, + ]; + + return $key . '.' . $count; + } + + /** + * Returns a clone of a Base Builder with reset query builder values. + * + * @return $this + * + * @deprecated + */ + protected function cleanClone() + { + return (clone $this)->from([], true)->resetQuery(); + } + + /** + * @param mixed $value + */ + protected function isSubquery($value): bool + { + return $value instanceof BaseBuilder || $value instanceof Closure; + } + + /** + * @param BaseBuilder|Closure $builder + * @param bool $wrapped Wrap the subquery in brackets + * @param string $alias Subquery alias + */ + protected function buildSubquery($builder, bool $wrapped = false, string $alias = ''): string + { + if ($builder instanceof Closure) { + $builder($builder = $this->db->newQuery()); + } + + if ($builder === $this) { + throw new DatabaseException('The subquery cannot be the same object as the main query object.'); + } + + $subquery = strtr($builder->getCompiledSelect(false), "\n", ' '); + + if ($wrapped) { + $subquery = '(' . $subquery . ')'; + $alias = trim($alias); + + if ($alias !== '') { + $subquery .= ' ' . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers($alias) : $alias); + } + } + + return $subquery; + } +} diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php new file mode 100644 index 0000000..1116d39 --- /dev/null +++ b/system/Database/BaseConnection.php @@ -0,0 +1,1770 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use Closure; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Events\Events; +use stdClass; +use Throwable; + +/** + * @property array $aliasedTables + * @property string $charset + * @property bool $compress + * @property float $connectDuration + * @property float $connectTime + * @property string $database + * @property string $DBCollat + * @property bool $DBDebug + * @property string $DBDriver + * @property string $DBPrefix + * @property string $DSN + * @property array|bool $encrypt + * @property array $failover + * @property string $hostname + * @property Query $lastQuery + * @property string $password + * @property bool $pConnect + * @property int|string $port + * @property bool $pretend + * @property string $queryClass + * @property array $reservedIdentifiers + * @property bool $strictOn + * @property string $subdriver + * @property string $swapPre + * @property int $transDepth + * @property bool $transFailure + * @property bool $transStatus + * + * @template TConnection + * @template TResult + * + * @implements ConnectionInterface + * @see \CodeIgniter\Database\BaseConnectionTest + */ +abstract class BaseConnection implements ConnectionInterface +{ + /** + * Data Source Name / Connect string + * + * @var string + */ + protected $DSN; + + /** + * Database port + * + * @var int|string + */ + protected $port = ''; + + /** + * Hostname + * + * @var string + */ + protected $hostname; + + /** + * Username + * + * @var string + */ + protected $username; + + /** + * Password + * + * @var string + */ + protected $password; + + /** + * Database name + * + * @var string + */ + protected $database; + + /** + * Database driver + * + * @var string + */ + protected $DBDriver = 'MySQLi'; + + /** + * Sub-driver + * + * @used-by CI_DB_pdo_driver + * + * @var string + */ + protected $subdriver; + + /** + * Table prefix + * + * @var string + */ + protected $DBPrefix = ''; + + /** + * Persistent connection flag + * + * @var bool + */ + protected $pConnect = false; + + /** + * Whether to throw Exception or not when an error occurs. + * + * @var bool + */ + protected $DBDebug = true; + + /** + * Character set + * + * @var string + */ + protected $charset = 'utf8'; + + /** + * Collation + * + * @var string + */ + protected $DBCollat = 'utf8_general_ci'; + + /** + * Swap Prefix + * + * @var string + */ + protected $swapPre = ''; + + /** + * Encryption flag/data + * + * @var array|bool + */ + protected $encrypt = false; + + /** + * Compression flag + * + * @var bool + */ + protected $compress = false; + + /** + * Strict ON flag + * + * Whether we're running in strict SQL mode. + * + * @var bool + */ + protected $strictOn; + + /** + * Settings for a failover connection. + * + * @var array + */ + protected $failover = []; + + /** + * The last query object that was executed + * on this connection. + * + * @var Query + */ + protected $lastQuery; + + /** + * Connection ID + * + * @var false|object|resource + * @phpstan-var false|TConnection + */ + public $connID = false; + + /** + * Result ID + * + * @var false|object|resource + * @phpstan-var false|TResult + */ + public $resultID = false; + + /** + * Protect identifiers flag + * + * @var bool + */ + public $protectIdentifiers = true; + + /** + * List of reserved identifiers + * + * Identifiers that must NOT be escaped. + * + * @var array + */ + protected $reservedIdentifiers = ['*']; + + /** + * Identifier escape character + * + * @var array|string + */ + public $escapeChar = '"'; + + /** + * ESCAPE statement string + * + * @var string + */ + public $likeEscapeStr = " ESCAPE '%s' "; + + /** + * ESCAPE character + * + * @var string + */ + public $likeEscapeChar = '!'; + + /** + * RegExp used to escape identifiers + * + * @var array + */ + protected $pregEscapeChar = []; + + /** + * Holds previously looked up data + * for performance reasons. + * + * @var array + */ + public $dataCache = []; + + /** + * Microtime when connection was made + * + * @var float + */ + protected $connectTime = 0.0; + + /** + * How long it took to establish connection. + * + * @var float + */ + protected $connectDuration = 0.0; + + /** + * If true, no queries will actually be + * run against the database. + * + * @var bool + */ + protected $pretend = false; + + /** + * Transaction enabled flag + * + * @var bool + */ + public $transEnabled = true; + + /** + * Strict transaction mode flag + * + * @var bool + */ + public $transStrict = true; + + /** + * Transaction depth level + * + * @var int + */ + protected $transDepth = 0; + + /** + * Transaction status flag + * + * Used with transactions to determine if a rollback should occur. + * + * @var bool + */ + protected $transStatus = true; + + /** + * Transaction failure flag + * + * Used with transactions to determine if a transaction has failed. + * + * @var bool + */ + protected $transFailure = false; + + /** + * Whether to throw exceptions during transaction + */ + protected bool $transException = false; + + /** + * Array of table aliases. + * + * @var array + */ + protected $aliasedTables = []; + + /** + * Query Class + * + * @var string + */ + protected $queryClass = Query::class; + + /** + * Saves our connection settings. + */ + public function __construct(array $params) + { + foreach ($params as $key => $value) { + if (property_exists($this, $key)) { + $this->{$key} = $value; + } + } + + $queryClass = str_replace('Connection', 'Query', static::class); + + if (class_exists($queryClass)) { + $this->queryClass = $queryClass; + } + + if ($this->failover !== []) { + // If there is a failover database, connect now to do failover. + // Otherwise, Query Builder creates SQL statement with the main database config + // (DBPrefix) even when the main database is down. + $this->initialize(); + } + } + + /** + * Initializes the database connection/settings. + * + * @return void + * + * @throws DatabaseException + */ + public function initialize() + { + /* If an established connection is available, then there's + * no need to connect and select the database. + * + * Depending on the database driver, conn_id can be either + * boolean TRUE, a resource or an object. + */ + if ($this->connID) { + return; + } + + $this->connectTime = microtime(true); + $connectionErrors = []; + + try { + // Connect to the database and set the connection ID + $this->connID = $this->connect($this->pConnect); + } catch (Throwable $e) { + $connectionErrors[] = sprintf('Main connection [%s]: %s', $this->DBDriver, $e->getMessage()); + log_message('error', 'Error connecting to the database: ' . $e); + } + + // No connection resource? Check if there is a failover else throw an error + if (! $this->connID) { + // Check if there is a failover set + if (! empty($this->failover) && is_array($this->failover)) { + // Go over all the failovers + foreach ($this->failover as $index => $failover) { + // Replace the current settings with those of the failover + foreach ($failover as $key => $val) { + if (property_exists($this, $key)) { + $this->{$key} = $val; + } + } + + try { + // Try to connect + $this->connID = $this->connect($this->pConnect); + } catch (Throwable $e) { + $connectionErrors[] = sprintf('Failover #%d [%s]: %s', ++$index, $this->DBDriver, $e->getMessage()); + log_message('error', 'Error connecting to the database: ' . $e); + } + + // If a connection is made break the foreach loop + if ($this->connID) { + break; + } + } + } + + // We still don't have a connection? + if (! $this->connID) { + throw new DatabaseException(sprintf( + 'Unable to connect to the database.%s%s', + PHP_EOL, + implode(PHP_EOL, $connectionErrors) + )); + } + } + + $this->connectDuration = microtime(true) - $this->connectTime; + } + + /** + * Close the database connection. + */ + public function close() + { + if ($this->connID) { + $this->_close(); + $this->connID = false; + } + } + + /** + * Platform dependent way method for closing the connection. + * + * @return mixed + */ + abstract protected function _close(); + + /** + * Create a persistent database connection. + * + * @return false|object|resource + * @phpstan-return false|TConnection + */ + public function persistentConnect() + { + return $this->connect(true); + } + + /** + * Returns the actual connection object. If both a 'read' and 'write' + * connection has been specified, you can pass either term in to + * get that connection. If you pass either alias in and only a single + * connection is present, it must return the sole connection. + * + * @return false|object|resource + * @phpstan-return TConnection + */ + public function getConnection(?string $alias = null) + { + // @todo work with read/write connections + return $this->connID; + } + + /** + * Returns the name of the current database being used. + */ + public function getDatabase(): string + { + return empty($this->database) ? '' : $this->database; + } + + /** + * Set DB Prefix + * + * Set's the DB Prefix to something new without needing to reconnect + * + * @param string $prefix The prefix + */ + public function setPrefix(string $prefix = ''): string + { + return $this->DBPrefix = $prefix; + } + + /** + * Returns the database prefix. + */ + public function getPrefix(): string + { + return $this->DBPrefix; + } + + /** + * The name of the platform in use (MySQLi, Postgre, SQLite3, OCI8, etc) + */ + public function getPlatform(): string + { + return $this->DBDriver; + } + + /** + * Sets the Table Aliases to use. These are typically + * collected during use of the Builder, and set here + * so queries are built correctly. + * + * @return $this + */ + public function setAliasedTables(array $aliases) + { + $this->aliasedTables = $aliases; + + return $this; + } + + /** + * Add a table alias to our list. + * + * @return $this + */ + public function addTableAlias(string $table) + { + if (! in_array($table, $this->aliasedTables, true)) { + $this->aliasedTables[] = $table; + } + + return $this; + } + + /** + * Executes the query against the database. + * + * @return false|object|resource + * @phpstan-return false|TResult + */ + abstract protected function execute(string $sql); + + /** + * Orchestrates a query against the database. Queries must use + * Database\Statement objects to store the query and build it. + * This method works with the cache. + * + * Should automatically handle different connections for read/write + * queries if needed. + * + * @param array|string|null $binds + * + * @return BaseResult|bool|Query BaseResult when “read” type query, bool when “write” type query, Query when prepared query + * @phpstan-return BaseResult|bool|Query + * + * @todo BC set $queryClass default as null in 4.1 + */ + public function query(string $sql, $binds = null, bool $setEscapeFlags = true, string $queryClass = '') + { + $queryClass = $queryClass ?: $this->queryClass; + + if (empty($this->connID)) { + $this->initialize(); + } + + /** + * @var Query $query + */ + $query = new $queryClass($this); + + $query->setQuery($sql, $binds, $setEscapeFlags); + + if (! empty($this->swapPre) && ! empty($this->DBPrefix)) { + $query->swapPrefix($this->DBPrefix, $this->swapPre); + } + + $startTime = microtime(true); + + // Always save the last query so we can use + // the getLastQuery() method. + $this->lastQuery = $query; + + // If $pretend is true, then we just want to return + // the actual query object here. There won't be + // any results to return. + if ($this->pretend) { + $query->setDuration($startTime); + + return $query; + } + + // Run the query for real + try { + $exception = null; + $this->resultID = $this->simpleQuery($query->getQuery()); + } catch (DatabaseException $exception) { + $this->resultID = false; + } + + if ($this->resultID === false) { + $query->setDuration($startTime, $startTime); + + // This will trigger a rollback if transactions are being used + if ($this->transDepth !== 0) { + $this->transStatus = false; + } + + if ( + $this->DBDebug + && ( + // Not in transactions + $this->transDepth === 0 + // In transactions, do not throw exception by default. + || $this->transException + ) + ) { + // We call this function in order to roll-back queries + // if transactions are enabled. If we don't call this here + // the error message will trigger an exit, causing the + // transactions to remain in limbo. + while ($this->transDepth !== 0) { + $transDepth = $this->transDepth; + $this->transComplete(); + + if ($transDepth === $this->transDepth) { + log_message('error', 'Database: Failure during an automated transaction commit/rollback!'); + break; + } + } + + // Let others do something with this query. + Events::trigger('DBQuery', $query); + + if ($exception !== null) { + throw new DatabaseException( + $exception->getMessage(), + $exception->getCode(), + $exception + ); + } + + return false; + } + + // Let others do something with this query. + Events::trigger('DBQuery', $query); + + return false; + } + + $query->setDuration($startTime); + + // Let others do something with this query + Events::trigger('DBQuery', $query); + + // resultID is not false, so it must be successful + if ($this->isWriteType($sql)) { + return true; + } + + // query is not write-type, so it must be read-type query; return QueryResult + $resultClass = str_replace('Connection', 'Result', static::class); + + return new $resultClass($this->connID, $this->resultID); + } + + /** + * Performs a basic query against the database. No binding or caching + * is performed, nor are transactions handled. Simply takes a raw + * query string and returns the database-specific result id. + * + * @return false|object|resource + * @phpstan-return false|TResult + */ + public function simpleQuery(string $sql) + { + if (empty($this->connID)) { + $this->initialize(); + } + + return $this->execute($sql); + } + + /** + * Disable Transactions + * + * This permits transactions to be disabled at run-time. + */ + public function transOff() + { + $this->transEnabled = false; + } + + /** + * Enable/disable Transaction Strict Mode + * + * When strict mode is enabled, if you are running multiple groups of + * transactions, if one group fails all subsequent groups will be + * rolled back. + * + * If strict mode is disabled, each group is treated autonomously, + * meaning a failure of one group will not affect any others + * + * @param bool $mode = true + * + * @return $this + */ + public function transStrict(bool $mode = true) + { + $this->transStrict = $mode; + + return $this; + } + + /** + * Start Transaction + */ + public function transStart(bool $testMode = false): bool + { + if (! $this->transEnabled) { + return false; + } + + return $this->transBegin($testMode); + } + + /** + * If set to true, exceptions are thrown during transactions. + * + * @return $this + */ + public function transException(bool $transExcetion) + { + $this->transException = $transExcetion; + + return $this; + } + + /** + * Complete Transaction + */ + public function transComplete(): bool + { + if (! $this->transEnabled) { + return false; + } + + // The query() function will set this flag to FALSE in the event that a query failed + if ($this->transStatus === false || $this->transFailure === true) { + $this->transRollback(); + + // If we are NOT running in strict mode, we will reset + // the _trans_status flag so that subsequent groups of + // transactions will be permitted. + if ($this->transStrict === false) { + $this->transStatus = true; + } + + return false; + } + + return $this->transCommit(); + } + + /** + * Lets you retrieve the transaction flag to determine if it has failed + */ + public function transStatus(): bool + { + return $this->transStatus; + } + + /** + * Begin Transaction + */ + public function transBegin(bool $testMode = false): bool + { + if (! $this->transEnabled) { + return false; + } + + // When transactions are nested we only begin/commit/rollback the outermost ones + if ($this->transDepth > 0) { + $this->transDepth++; + + return true; + } + + if (empty($this->connID)) { + $this->initialize(); + } + + // Reset the transaction failure flag. + // If the $test_mode flag is set to TRUE transactions will be rolled back + // even if the queries produce a successful result. + $this->transFailure = ($testMode === true); + + if ($this->_transBegin()) { + $this->transDepth++; + + return true; + } + + return false; + } + + /** + * Commit Transaction + */ + public function transCommit(): bool + { + if (! $this->transEnabled || $this->transDepth === 0) { + return false; + } + + // When transactions are nested we only begin/commit/rollback the outermost ones + if ($this->transDepth > 1 || $this->_transCommit()) { + $this->transDepth--; + + return true; + } + + return false; + } + + /** + * Rollback Transaction + */ + public function transRollback(): bool + { + if (! $this->transEnabled || $this->transDepth === 0) { + return false; + } + + // When transactions are nested we only begin/commit/rollback the outermost ones + if ($this->transDepth > 1 || $this->_transRollback()) { + $this->transDepth--; + + return true; + } + + return false; + } + + /** + * Begin Transaction + */ + abstract protected function _transBegin(): bool; + + /** + * Commit Transaction + */ + abstract protected function _transCommit(): bool; + + /** + * Rollback Transaction + */ + abstract protected function _transRollback(): bool; + + /** + * Returns a non-shared new instance of the query builder for this connection. + * + * @param array|string $tableName + * + * @return BaseBuilder + * + * @throws DatabaseException + */ + public function table($tableName) + { + if (empty($tableName)) { + throw new DatabaseException('You must set the database table to be used with your query.'); + } + + $className = str_replace('Connection', 'Builder', static::class); + + return new $className($tableName, $this); + } + + /** + * Returns a new instance of the BaseBuilder class with a cleared FROM clause. + */ + public function newQuery(): BaseBuilder + { + // save table aliases + $tempAliases = $this->aliasedTables; + $builder = $this->table(',')->from([], true); + $this->aliasedTables = $tempAliases; + + return $builder; + } + + /** + * Creates a prepared statement with the database that can then + * be used to execute multiple statements against. Within the + * closure, you would build the query in any normal way, though + * the Query Builder is the expected manner. + * + * Example: + * $stmt = $db->prepare(function($db) + * { + * return $db->table('users') + * ->where('id', 1) + * ->get(); + * }) + * + * @return BasePreparedQuery|null + */ + public function prepare(Closure $func, array $options = []) + { + if (empty($this->connID)) { + $this->initialize(); + } + + $this->pretend(); + + $sql = $func($this); + + $this->pretend(false); + + if ($sql instanceof QueryInterface) { + $sql = $sql->getOriginalQuery(); + } + + $class = str_ireplace('Connection', 'PreparedQuery', static::class); + /** @var BasePreparedQuery $class */ + $class = new $class($this); + + return $class->prepare($sql, $options); + } + + /** + * Returns the last query's statement object. + * + * @return Query + */ + public function getLastQuery() + { + return $this->lastQuery; + } + + /** + * Returns a string representation of the last query's statement object. + */ + public function showLastQuery(): string + { + return (string) $this->lastQuery; + } + + /** + * Returns the time we started to connect to this database in + * seconds with microseconds. + * + * Used by the Debug Toolbar's timeline. + */ + public function getConnectStart(): ?float + { + return $this->connectTime; + } + + /** + * Returns the number of seconds with microseconds that it took + * to connect to the database. + * + * Used by the Debug Toolbar's timeline. + */ + public function getConnectDuration(int $decimals = 6): string + { + return number_format($this->connectDuration, $decimals); + } + + /** + * Protect Identifiers + * + * This function is used extensively by the Query Builder class, and by + * a couple functions in this class. + * It takes a column or table name (optionally with an alias) and inserts + * the table prefix onto it. Some logic is necessary in order to deal with + * column names that include the path. Consider a query like this: + * + * SELECT hostname.database.table.column AS c FROM hostname.database.table + * + * Or a query with aliasing: + * + * SELECT m.member_id, m.member_name FROM members AS m + * + * Since the column name can include up to four segments (host, DB, table, column) + * or also have an alias prefix, we need to do a bit of work to figure this out and + * insert the table prefix (if it exists) in the proper position, and escape only + * the correct identifiers. + * + * @param array|string $item + * @param bool $prefixSingle Prefix a table name with no segments? + * @param bool $protectIdentifiers Protect table or column names? + * @param bool $fieldExists Supplied $item contains a column name? + * + * @return array|string + * @phpstan-return ($item is array ? array : string) + */ + public function protectIdentifiers($item, bool $prefixSingle = false, ?bool $protectIdentifiers = null, bool $fieldExists = true) + { + if (! is_bool($protectIdentifiers)) { + $protectIdentifiers = $this->protectIdentifiers; + } + + if (is_array($item)) { + $escapedArray = []; + + foreach ($item as $k => $v) { + $escapedArray[$this->protectIdentifiers($k)] = $this->protectIdentifiers($v, $prefixSingle, $protectIdentifiers, $fieldExists); + } + + return $escapedArray; + } + + // This is basically a bug fix for queries that use MAX, MIN, etc. + // If a parenthesis is found we know that we do not need to + // escape the data or add a prefix. There's probably a more graceful + // way to deal with this, but I'm not thinking of it + // + // Added exception for single quotes as well, we don't want to alter + // literal strings. + if (strcspn($item, "()'") !== strlen($item)) { + /** @psalm-suppress NoValue I don't know why ERROR. */ + return $item; + } + + // Do not protect identifiers and do not prefix, no swap prefix, there is nothing to do + if ($protectIdentifiers === false && $prefixSingle === false && $this->swapPre === '') { + /** @psalm-suppress NoValue I don't know why ERROR. */ + return $item; + } + + // Convert tabs or multiple spaces into single spaces + /** @psalm-suppress NoValue I don't know why ERROR. */ + $item = preg_replace('/\s+/', ' ', trim($item)); + + // If the item has an alias declaration we remove it and set it aside. + // Note: strripos() is used in order to support spaces in table names + if ($offset = strripos($item, ' AS ')) { + $alias = ($protectIdentifiers) ? substr($item, $offset, 4) . $this->escapeIdentifiers(substr($item, $offset + 4)) : substr($item, $offset); + $item = substr($item, 0, $offset); + } elseif ($offset = strrpos($item, ' ')) { + $alias = ($protectIdentifiers) ? ' ' . $this->escapeIdentifiers(substr($item, $offset + 1)) : substr($item, $offset); + $item = substr($item, 0, $offset); + } else { + $alias = ''; + } + + // Break the string apart if it contains periods, then insert the table prefix + // in the correct location, assuming the period doesn't indicate that we're dealing + // with an alias. While we're at it, we will escape the components + if (strpos($item, '.') !== false) { + return $this->protectDotItem($item, $alias, $protectIdentifiers, $fieldExists); + } + + // In some cases, especially 'from', we end up running through + // protect_identifiers twice. This algorithm won't work when + // it contains the escapeChar so strip it out. + $item = trim($item, $this->escapeChar); + + // Is there a table prefix? If not, no need to insert it + if ($this->DBPrefix !== '') { + // Verify table prefix and replace if necessary + if ($this->swapPre !== '' && strpos($item, $this->swapPre) === 0) { + $item = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $item); + } + // Do we prefix an item with no segments? + elseif ($prefixSingle === true && strpos($item, $this->DBPrefix) !== 0) { + $item = $this->DBPrefix . $item; + } + } + + if ($protectIdentifiers === true && ! in_array($item, $this->reservedIdentifiers, true)) { + $item = $this->escapeIdentifiers($item); + } + + return $item . $alias; + } + + private function protectDotItem(string $item, string $alias, bool $protectIdentifiers, bool $fieldExists): string + { + $parts = explode('.', $item); + + // Does the first segment of the exploded item match + // one of the aliases previously identified? If so, + // we have nothing more to do other than escape the item + // + // NOTE: The ! empty() condition prevents this method + // from breaking when QB isn't enabled. + if (! empty($this->aliasedTables) && in_array($parts[0], $this->aliasedTables, true)) { + if ($protectIdentifiers === true) { + foreach ($parts as $key => $val) { + if (! in_array($val, $this->reservedIdentifiers, true)) { + $parts[$key] = $this->escapeIdentifiers($val); + } + } + + $item = implode('.', $parts); + } + + return $item . $alias; + } + + // Is there a table prefix defined in the config file? If not, no need to do anything + if ($this->DBPrefix !== '') { + // We now add the table prefix based on some logic. + // Do we have 4 segments (hostname.database.table.column)? + // If so, we add the table prefix to the column name in the 3rd segment. + if (isset($parts[3])) { + $i = 2; + } + // Do we have 3 segments (database.table.column)? + // If so, we add the table prefix to the column name in 2nd position + elseif (isset($parts[2])) { + $i = 1; + } + // Do we have 2 segments (table.column)? + // If so, we add the table prefix to the column name in 1st segment + else { + $i = 0; + } + + // This flag is set when the supplied $item does not contain a field name. + // This can happen when this function is being called from a JOIN. + if ($fieldExists === false) { + $i++; + } + + // Verify table prefix and replace if necessary + if ($this->swapPre !== '' && strpos($parts[$i], $this->swapPre) === 0) { + $parts[$i] = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $parts[$i]); + } + // We only add the table prefix if it does not already exist + elseif (strpos($parts[$i], $this->DBPrefix) !== 0) { + $parts[$i] = $this->DBPrefix . $parts[$i]; + } + + // Put the parts back together + $item = implode('.', $parts); + } + + if ($protectIdentifiers === true) { + $item = $this->escapeIdentifiers($item); + } + + return $item . $alias; + } + + /** + * Escape the SQL Identifiers + * + * This function escapes column and table names + * + * @param array|string $item + * + * @return array|string + * @phpstan-return ($item is array ? array : string) + */ + public function escapeIdentifiers($item) + { + if ($this->escapeChar === '' || empty($item) || in_array($item, $this->reservedIdentifiers, true)) { + return $item; + } + + if (is_array($item)) { + foreach ($item as $key => $value) { + $item[$key] = $this->escapeIdentifiers($value); + } + + return $item; + } + + // Avoid breaking functions and literal values inside queries + if (ctype_digit($item) + || $item[0] === "'" + || ($this->escapeChar !== '"' && $item[0] === '"') + || strpos($item, '(') !== false) { + return $item; + } + + if ($this->pregEscapeChar === []) { + if (is_array($this->escapeChar)) { + $this->pregEscapeChar = [ + preg_quote($this->escapeChar[0], '/'), + preg_quote($this->escapeChar[1], '/'), + $this->escapeChar[0], + $this->escapeChar[1], + ]; + } else { + $this->pregEscapeChar[0] = $this->pregEscapeChar[1] = preg_quote($this->escapeChar, '/'); + $this->pregEscapeChar[2] = $this->pregEscapeChar[3] = $this->escapeChar; + } + } + + foreach ($this->reservedIdentifiers as $id) { + /** @psalm-suppress NoValue I don't know why ERROR. */ + if (strpos($item, '.' . $id) !== false) { + return preg_replace( + '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?\./i', + $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '.', + $item + ); + } + } + + /** @psalm-suppress NoValue I don't know why ERROR. */ + return preg_replace( + '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?(\.)?/i', + $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '$2', + $item + ); + } + + /** + * Prepends a database prefix if one exists in configuration + * + * @throws DatabaseException + */ + public function prefixTable(string $table = ''): string + { + if ($table === '') { + throw new DatabaseException('A table name is required for that operation.'); + } + + return $this->DBPrefix . $table; + } + + /** + * Returns the total number of rows affected by this query. + */ + abstract public function affectedRows(): int; + + /** + * "Smart" Escape String + * + * Escapes data based on type. + * Sets boolean and null types + * + * @param array|bool|float|int|object|string|null $str + * + * @return array|float|int|string + * @phpstan-return ($str is array ? array : float|int|string) + */ + public function escape($str) + { + if (is_array($str)) { + return array_map([&$this, 'escape'], $str); + } + + /** @psalm-suppress NoValue I don't know why ERROR. */ + if (is_string($str) || (is_object($str) && method_exists($str, '__toString'))) { + if ($str instanceof RawSql) { + return $str->__toString(); + } + + return "'" . $this->escapeString($str) . "'"; + } + + if (is_bool($str)) { + return ($str === false) ? 0 : 1; + } + + return $str ?? 'NULL'; + } + + /** + * Escape String + * + * @param string|string[] $str Input string + * @param bool $like Whether or not the string will be used in a LIKE condition + * + * @return string|string[] + */ + public function escapeString($str, bool $like = false) + { + if (is_array($str)) { + foreach ($str as $key => $val) { + $str[$key] = $this->escapeString($val, $like); + } + + return $str; + } + + $str = $this->_escapeString($str); + + // escape LIKE condition wildcards + if ($like === true) { + return str_replace( + [ + $this->likeEscapeChar, + '%', + '_', + ], + [ + $this->likeEscapeChar . $this->likeEscapeChar, + $this->likeEscapeChar . '%', + $this->likeEscapeChar . '_', + ], + $str + ); + } + + return $str; + } + + /** + * Escape LIKE String + * + * Calls the individual driver for platform + * specific escaping for LIKE conditions + * + * @param string|string[] $str + * + * @return string|string[] + */ + public function escapeLikeString($str) + { + return $this->escapeString($str, true); + } + + /** + * Platform independent string escape. + * + * Will likely be overridden in child classes. + */ + protected function _escapeString(string $str): string + { + return str_replace("'", "''", remove_invisible_characters($str, false)); + } + + /** + * This function enables you to call PHP database functions that are not natively included + * in CodeIgniter, in a platform independent manner. + * + * @param array ...$params + * + * @throws DatabaseException + */ + public function callFunction(string $functionName, ...$params): bool + { + $driver = $this->getDriverFunctionPrefix(); + + if (strpos($driver, $functionName) === false) { + $functionName = $driver . $functionName; + } + + if (! function_exists($functionName)) { + if ($this->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; + } + + return $functionName(...$params); + } + + /** + * Get the prefix of the function to access the DB. + */ + protected function getDriverFunctionPrefix(): string + { + return strtolower($this->DBDriver) . '_'; + } + + // -------------------------------------------------------------------- + // META Methods + // -------------------------------------------------------------------- + + /** + * Returns an array of table names + * + * @return array|false + * + * @throws DatabaseException + */ + public function listTables(bool $constrainByPrefix = false) + { + if (isset($this->dataCache['table_names']) && $this->dataCache['table_names']) { + return $constrainByPrefix + ? preg_grep("/^{$this->DBPrefix}/", $this->dataCache['table_names']) + : $this->dataCache['table_names']; + } + + $sql = $this->_listTables($constrainByPrefix); + + if ($sql === false) { + if ($this->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; + } + + $this->dataCache['table_names'] = []; + + $query = $this->query($sql); + + foreach ($query->getResultArray() as $row) { + $table = $row['table_name'] ?? $row['TABLE_NAME'] ?? $row[array_key_first($row)]; + + $this->dataCache['table_names'][] = $table; + } + + return $this->dataCache['table_names']; + } + + /** + * Determine if a particular table exists + * + * @param bool $cached Whether to use data cache + */ + public function tableExists(string $tableName, bool $cached = true): bool + { + if ($cached === true) { + return in_array($this->protectIdentifiers($tableName, true, false, false), $this->listTables(), true); + } + + if (false === ($sql = $this->_listTables(false, $tableName))) { + if ($this->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; + } + + $tableExists = $this->query($sql)->getResultArray() !== []; + + // if cache has been built already + if (! empty($this->dataCache['table_names'])) { + $key = array_search( + strtolower($tableName), + array_map('strtolower', $this->dataCache['table_names']), + true + ); + + // table doesn't exist but still in cache - lets reset cache, it can be rebuilt later + // OR if table does exist but is not found in cache + if (($key !== false && ! $tableExists) || ($key === false && $tableExists)) { + $this->resetDataCache(); + } + } + + return $tableExists; + } + + /** + * Fetch Field Names + * + * @return array|false + * + * @throws DatabaseException + */ + public function getFieldNames(string $table) + { + // Is there a cached result? + if (isset($this->dataCache['field_names'][$table])) { + return $this->dataCache['field_names'][$table]; + } + + if (empty($this->connID)) { + $this->initialize(); + } + + if (false === ($sql = $this->_listColumns($table))) { + if ($this->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; + } + + $query = $this->query($sql); + + $this->dataCache['field_names'][$table] = []; + + foreach ($query->getResultArray() as $row) { + // Do we know from where to get the column's name? + if (! isset($key)) { + if (isset($row['column_name'])) { + $key = 'column_name'; + } elseif (isset($row['COLUMN_NAME'])) { + $key = 'COLUMN_NAME'; + } else { + // We have no other choice but to just get the first element's key. + $key = key($row); + } + } + + $this->dataCache['field_names'][$table][] = $row[$key]; + } + + return $this->dataCache['field_names'][$table]; + } + + /** + * Determine if a particular field exists + */ + public function fieldExists(string $fieldName, string $tableName): bool + { + return in_array($fieldName, $this->getFieldNames($tableName), true); + } + + /** + * Returns an object with field data + * + * @return stdClass[] + */ + public function getFieldData(string $table) + { + return $this->_fieldData($this->protectIdentifiers($table, true, false, false)); + } + + /** + * Returns an object with key data + * + * @return array + */ + public function getIndexData(string $table) + { + return $this->_indexData($this->protectIdentifiers($table, true, false, false)); + } + + /** + * Returns an object with foreign key data + * + * @return array + */ + public function getForeignKeyData(string $table) + { + return $this->_foreignKeyData($this->protectIdentifiers($table, true, false, false)); + } + + /** + * Converts array of arrays generated by _foreignKeyData() to array of objects + * + * @return array[ + * {constraint_name} => + * stdClass[ + * 'constraint_name' => string, + * 'table_name' => string, + * 'column_name' => string[], + * 'foreign_table_name' => string, + * 'foreign_column_name' => string[], + * 'on_delete' => string, + * 'on_update' => string, + * 'match' => string + * ] + * ] + */ + protected function foreignKeyDataToObjects(array $data) + { + $retVal = []; + + foreach ($data as $row) { + $name = $row['constraint_name']; + + // for sqlite generate name + if ($name === null) { + $name = $row['table_name'] . '_' . implode('_', $row['column_name']) . '_foreign'; + } + + $obj = new stdClass(); + $obj->constraint_name = $name; + $obj->table_name = $row['table_name']; + $obj->column_name = $row['column_name']; + $obj->foreign_table_name = $row['foreign_table_name']; + $obj->foreign_column_name = $row['foreign_column_name']; + $obj->on_delete = $row['on_delete']; + $obj->on_update = $row['on_update']; + $obj->match = $row['match']; + + $retVal[$name] = $obj; + } + + return $retVal; + } + + /** + * Disables foreign key checks temporarily. + * + * @return bool + */ + public function disableForeignKeyChecks() + { + $sql = $this->_disableForeignKeyChecks(); + + if ($sql === '') { + // The feature is not supported. + return false; + } + + return $this->query($sql); + } + + /** + * Enables foreign key checks temporarily. + * + * @return bool + */ + public function enableForeignKeyChecks() + { + $sql = $this->_enableForeignKeyChecks(); + + if ($sql === '') { + // The feature is not supported. + return false; + } + + return $this->query($sql); + } + + /** + * Allows the engine to be set into a mode where queries are not + * actually executed, but they are still generated, timed, etc. + * + * This is primarily used by the prepared query functionality. + * + * @return $this + */ + public function pretend(bool $pretend = true) + { + $this->pretend = $pretend; + + return $this; + } + + /** + * Empties our data cache. Especially helpful during testing. + * + * @return $this + */ + public function resetDataCache() + { + $this->dataCache = []; + + return $this; + } + + /** + * Determines if the statement is a write-type query or not. + * + * @param string $sql + */ + public function isWriteType($sql): bool + { + return (bool) preg_match('/^\s*"?(SET|INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|TRUNCATE|LOAD|COPY|ALTER|RENAME|GRANT|REVOKE|LOCK|UNLOCK|REINDEX|MERGE)\s/i', $sql); + } + + /** + * Returns the last error code and message. + * + * Must return an array with keys 'code' and 'message': + * + * @return array + * @phpstan-return array{code: int|string|null, message: string|null} + */ + abstract public function error(): array; + + /** + * Insert ID + * + * @return int|string + */ + abstract public function insertID(); + + /** + * Generates the SQL for listing tables in a platform-dependent manner. + * + * @param string|null $tableName If $tableName is provided will return only this table if exists. + * + * @return false|string + */ + abstract protected function _listTables(bool $constrainByPrefix = false, ?string $tableName = null); + + /** + * Generates a platform-specific query string so that the column names can be fetched. + * + * @return false|string + */ + abstract protected function _listColumns(string $table = ''); + + /** + * Platform-specific field data information. + * + * @see getFieldData() + */ + abstract protected function _fieldData(string $table): array; + + /** + * Platform-specific index data. + * + * @see getIndexData() + */ + abstract protected function _indexData(string $table): array; + + /** + * Platform-specific foreign keys data. + * + * @see getForeignKeyData() + */ + abstract protected function _foreignKeyData(string $table): array; + + /** + * Platform-specific SQL statement to disable foreign key checks. + * + * If this feature is not supported, return empty string. + * + * @TODO This method should be moved to an interface that represents foreign key support. + * + * @return string + * + * @see disableForeignKeyChecks() + */ + protected function _disableForeignKeyChecks() + { + return ''; + } + + /** + * Platform-specific SQL statement to enable foreign key checks. + * + * If this feature is not supported, return empty string. + * + * @TODO This method should be moved to an interface that represents foreign key support. + * + * @return string + * + * @see enableForeignKeyChecks() + */ + protected function _enableForeignKeyChecks() + { + return ''; + } + + /** + * Accessor for properties if they exist. + * + * @return array|bool|float|int|object|resource|string|null + */ + public function __get(string $key) + { + if (property_exists($this, $key)) { + return $this->{$key}; + } + + return null; + } + + /** + * Checker for properties existence. + */ + public function __isset(string $key): bool + { + return property_exists($this, $key); + } +} diff --git a/system/Database/BasePreparedQuery.php b/system/Database/BasePreparedQuery.php new file mode 100644 index 0000000..bfc0a60 --- /dev/null +++ b/system/Database/BasePreparedQuery.php @@ -0,0 +1,260 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use ArgumentCountError; +use BadMethodCallException; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Events\Events; +use ErrorException; + +/** + * @template TConnection + * @template TStatement + * @template TResult + * + * @implements PreparedQueryInterface + */ +abstract class BasePreparedQuery implements PreparedQueryInterface +{ + /** + * The prepared statement itself. + * + * @var object|resource|null + * @phpstan-var TStatement|null + */ + protected $statement; + + /** + * The error code, if any. + * + * @var int + */ + protected $errorCode; + + /** + * The error message, if any. + * + * @var string + */ + protected $errorString; + + /** + * Holds the prepared query object + * that is cloned during execute. + * + * @var Query + */ + protected $query; + + /** + * A reference to the db connection to use. + * + * @var BaseConnection + * @phpstan-var BaseConnection + */ + protected $db; + + public function __construct(BaseConnection $db) + { + $this->db = $db; + } + + /** + * Prepares the query against the database, and saves the connection + * info necessary to execute the query later. + * + * NOTE: This version is based on SQL code. Child classes should + * override this method. + * + * @return $this + */ + public function prepare(string $sql, array $options = [], string $queryClass = Query::class) + { + // We only supports positional placeholders (?) + // in order to work with the execute method below, so we + // need to replace our named placeholders (:name) + $sql = preg_replace('/:[^\s,)]+/', '?', $sql); + + /** @var Query $query */ + $query = new $queryClass($this->db); + + $query->setQuery($sql); + + if (! empty($this->db->swapPre) && ! empty($this->db->DBPrefix)) { + $query->swapPrefix($this->db->DBPrefix, $this->db->swapPre); + } + + $this->query = $query; + + return $this->_prepare($query->getOriginalQuery(), $options); + } + + /** + * The database-dependent portion of the prepare statement. + * + * @return $this + */ + abstract public function _prepare(string $sql, array $options = []); + + /** + * Takes a new set of data and runs it against the currently + * prepared query. Upon success, will return a Results object. + * + * @return bool|ResultInterface + * @phpstan-return bool|ResultInterface + * + * @throws DatabaseException + */ + public function execute(...$data) + { + // Execute the Query. + $startTime = microtime(true); + + try { + $exception = null; + $result = $this->_execute($data); + } catch (ArgumentCountError|ErrorException $exception) { + $result = false; + } + + // Update our query object + $query = clone $this->query; + $query->setBinds($data); + + if ($result === false) { + $query->setDuration($startTime, $startTime); + + // This will trigger a rollback if transactions are being used + if ($this->db->transDepth !== 0) { + $this->db->transStatus = false; + } + + if ($this->db->DBDebug) { + // We call this function in order to roll-back queries + // if transactions are enabled. If we don't call this here + // the error message will trigger an exit, causing the + // transactions to remain in limbo. + while ($this->db->transDepth !== 0) { + $transDepth = $this->db->transDepth; + $this->db->transComplete(); + + if ($transDepth === $this->db->transDepth) { + log_message('error', 'Database: Failure during an automated transaction commit/rollback!'); + break; + } + } + + // Let others do something with this query. + Events::trigger('DBQuery', $query); + + if ($exception !== null) { + throw new DatabaseException($exception->getMessage(), $exception->getCode(), $exception); + } + + return false; + } + + // Let others do something with this query. + Events::trigger('DBQuery', $query); + + return false; + } + + $query->setDuration($startTime); + + // Let others do something with this query + Events::trigger('DBQuery', $query); + + if ($this->db->isWriteType($query)) { + return true; + } + + // Return a result object + $resultClass = str_replace('PreparedQuery', 'Result', static::class); + + $resultID = $this->_getResult(); + + return new $resultClass($this->db->connID, $resultID); + } + + /** + * The database dependant version of the execute method. + */ + abstract public function _execute(array $data): bool; + + /** + * Returns the result object for the prepared query. + * + * @return object|resource|null + */ + abstract public function _getResult(); + + /** + * Explicitly closes the prepared statement. + * + * @throws BadMethodCallException + */ + public function close(): bool + { + if (! isset($this->statement)) { + throw new BadMethodCallException('Cannot call close on a non-existing prepared statement.'); + } + + try { + return $this->_close(); + } finally { + $this->statement = null; + } + } + + /** + * The database-dependent version of the close method. + */ + abstract protected function _close(): bool; + + /** + * Returns the SQL that has been prepared. + */ + public function getQueryString(): string + { + if (! $this->query instanceof QueryInterface) { + throw new BadMethodCallException('Cannot call getQueryString on a prepared query until after the query has been prepared.'); + } + + return $this->query->getQuery(); + } + + /** + * A helper to determine if any error exists. + */ + public function hasError(): bool + { + return ! empty($this->errorString); + } + + /** + * Returns the error code created while executing this statement. + */ + public function getErrorCode(): int + { + return $this->errorCode; + } + + /** + * Returns the error message created while executing this statement. + */ + public function getErrorMessage(): string + { + return $this->errorString; + } +} diff --git a/system/Database/BaseResult.php b/system/Database/BaseResult.php new file mode 100644 index 0000000..b807b0b --- /dev/null +++ b/system/Database/BaseResult.php @@ -0,0 +1,535 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Entity\Entity; +use stdClass; + +/** + * @template TConnection + * @template TResult + * + * @implements ResultInterface + */ +abstract class BaseResult implements ResultInterface +{ + /** + * Connection ID + * + * @var object|resource + * @phpstan-var TConnection + */ + public $connID; + + /** + * Result ID + * + * @var false|object|resource + * @phpstan-var false|TResult + */ + public $resultID; + + /** + * Result Array + * + * @var array[] + */ + public $resultArray = []; + + /** + * Result Object + * + * @var object[] + */ + public $resultObject = []; + + /** + * Custom Result Object + * + * @var array + */ + public $customResultObject = []; + + /** + * Current Row index + * + * @var int + */ + public $currentRow = 0; + + /** + * The number of records in the query result + * + * @var int|null + */ + protected $numRows; + + /** + * Row data + * + * @var array|null + */ + public $rowData; + + /** + * Constructor + * + * @param object|resource $connID + * @param object|resource $resultID + * @phpstan-param TConnection $connID + * @phpstan-param TResult $resultID + */ + public function __construct(&$connID, &$resultID) + { + $this->connID = $connID; + $this->resultID = $resultID; + } + + /** + * Retrieve the results of the query. Typically an array of + * individual data rows, which can be either an 'array', an + * 'object', or a custom class name. + * + * @param string $type The row type. Either 'array', 'object', or a class name to use + */ + public function getResult(string $type = 'object'): array + { + if ($type === 'array') { + return $this->getResultArray(); + } + + if ($type === 'object') { + return $this->getResultObject(); + } + + return $this->getCustomResultObject($type); + } + + /** + * Returns the results as an array of custom objects. + * + * @phpstan-param class-string $className + * + * @return array + */ + public function getCustomResultObject(string $className) + { + if (isset($this->customResultObject[$className])) { + return $this->customResultObject[$className]; + } + + if (! $this->isValidResultId()) { + return []; + } + + // Don't fetch the result set again if we already have it + $_data = null; + if (($c = count($this->resultArray)) > 0) { + $_data = 'resultArray'; + } elseif (($c = count($this->resultObject)) > 0) { + $_data = 'resultObject'; + } + + if ($_data !== null) { + for ($i = 0; $i < $c; $i++) { + $this->customResultObject[$className][$i] = new $className(); + + foreach ($this->{$_data}[$i] as $key => $value) { + $this->customResultObject[$className][$i]->{$key} = $value; + } + } + + return $this->customResultObject[$className]; + } + + if ($this->rowData !== null) { + $this->dataSeek(); + } + $this->customResultObject[$className] = []; + + while ($row = $this->fetchObject($className)) { + if (! is_subclass_of($row, Entity::class) && method_exists($row, 'syncOriginal')) { + $row->syncOriginal(); + } + + $this->customResultObject[$className][] = $row; + } + + return $this->customResultObject[$className]; + } + + /** + * Returns the results as an array of arrays. + * + * If no results, an empty array is returned. + */ + public function getResultArray(): array + { + if ($this->resultArray !== []) { + return $this->resultArray; + } + + // In the event that query caching is on, the result_id variable + // will not be a valid resource so we'll simply return an empty + // array. + if (! $this->isValidResultId()) { + return []; + } + + if ($this->resultObject) { + foreach ($this->resultObject as $row) { + $this->resultArray[] = (array) $row; + } + + return $this->resultArray; + } + + if ($this->rowData !== null) { + $this->dataSeek(); + } + + while ($row = $this->fetchAssoc()) { + $this->resultArray[] = $row; + } + + return $this->resultArray; + } + + /** + * Returns the results as an array of objects. + * + * If no results, an empty array is returned. + * + * @return array + * @phpstan-return list + */ + public function getResultObject(): array + { + if ($this->resultObject !== []) { + return $this->resultObject; + } + + // In the event that query caching is on, the result_id variable + // will not be a valid resource so we'll simply return an empty + // array. + if (! $this->isValidResultId()) { + return []; + } + + if ($this->resultArray) { + foreach ($this->resultArray as $row) { + $this->resultObject[] = (object) $row; + } + + return $this->resultObject; + } + + if ($this->rowData !== null) { + $this->dataSeek(); + } + + while ($row = $this->fetchObject()) { + if (! is_subclass_of($row, Entity::class) && method_exists($row, 'syncOriginal')) { + $row->syncOriginal(); + } + + $this->resultObject[] = $row; + } + + return $this->resultObject; + } + + /** + * Wrapper object to return a row as either an array, an object, or + * a custom class. + * + * If row doesn't exist, returns null. + * + * @param int|string $n The index of the results to return, or column name. + * @param string $type The type of result object. 'array', 'object' or class name. + * @phpstan-param class-string|'array'|'object' $type + * + * @return array|object|stdClass|null + * @phpstan-return ($type is 'object' ? stdClass|null : ($type is 'array' ? array|null : object|null)) + */ + public function getRow($n = 0, string $type = 'object') + { + // $n is a column name. + if (! is_numeric($n)) { + // We cache the row data for subsequent uses + if (! is_array($this->rowData)) { + $this->rowData = $this->getRowArray(); + } + + // array_key_exists() instead of isset() to allow for NULL values + if (empty($this->rowData) || ! array_key_exists($n, $this->rowData)) { + return null; + } + + return $this->rowData[$n]; + } + + if ($type === 'object') { + return $this->getRowObject($n); + } + + if ($type === 'array') { + return $this->getRowArray($n); + } + + return $this->getCustomRowObject($n, $type); + } + + /** + * Returns a row as a custom class instance. + * + * If row doesn't exists, returns null. + * + * @return array|null + */ + public function getCustomRowObject(int $n, string $className) + { + if (! isset($this->customResultObject[$className])) { + $this->getCustomResultObject($className); + } + + if (empty($this->customResultObject[$className])) { + return null; + } + + if ($n !== $this->currentRow && isset($this->customResultObject[$className][$n])) { + $this->currentRow = $n; + } + + return $this->customResultObject[$className][$this->currentRow]; + } + + /** + * Returns a single row from the results as an array. + * + * If row doesn't exist, returns null. + * + * @return array|null + */ + public function getRowArray(int $n = 0) + { + $result = $this->getResultArray(); + if ($result === []) { + return null; + } + + if ($n !== $this->currentRow && isset($result[$n])) { + $this->currentRow = $n; + } + + return $result[$this->currentRow]; + } + + /** + * Returns a single row from the results as an object. + * + * If row doesn't exist, returns null. + * + * @return object|stdClass|null + */ + public function getRowObject(int $n = 0) + { + $result = $this->getResultObject(); + if ($result === []) { + return null; + } + + if ($n !== $this->customResultObject && isset($result[$n])) { + $this->currentRow = $n; + } + + return $result[$this->currentRow]; + } + + /** + * Assigns an item into a particular column slot. + * + * @param array|string $key + * @param array|object|stdClass|null $value + * + * @return void + */ + public function setRow($key, $value = null) + { + // We cache the row data for subsequent uses + if (! is_array($this->rowData)) { + $this->rowData = $this->getRowArray(); + } + + if (is_array($key)) { + foreach ($key as $k => $v) { + $this->rowData[$k] = $v; + } + + return; + } + + if ($key !== '' && $value !== null) { + $this->rowData[$key] = $value; + } + } + + /** + * Returns the "first" row of the current results. + * + * @return array|object|null + */ + public function getFirstRow(string $type = 'object') + { + $result = $this->getResult($type); + + return ($result === []) ? null : $result[0]; + } + + /** + * Returns the "last" row of the current results. + * + * @return array|object|null + */ + public function getLastRow(string $type = 'object') + { + $result = $this->getResult($type); + + return ($result === []) ? null : $result[count($result) - 1]; + } + + /** + * Returns the "next" row of the current results. + * + * @return array|object|null + */ + public function getNextRow(string $type = 'object') + { + $result = $this->getResult($type); + if ($result === []) { + return null; + } + + return isset($result[$this->currentRow + 1]) ? $result[++$this->currentRow] : null; + } + + /** + * Returns the "previous" row of the current results. + * + * @return array|object|null + */ + public function getPreviousRow(string $type = 'object') + { + $result = $this->getResult($type); + if ($result === []) { + return null; + } + + if (isset($result[$this->currentRow - 1])) { + $this->currentRow--; + } + + return $result[$this->currentRow]; + } + + /** + * Returns an unbuffered row and move the pointer to the next row. + * + * @return array|object|null + */ + public function getUnbufferedRow(string $type = 'object') + { + if ($type === 'array') { + return $this->fetchAssoc(); + } + + if ($type === 'object') { + return $this->fetchObject(); + } + + return $this->fetchObject($type); + } + + /** + * Number of rows in the result set; checks for previous count, falls + * back on counting resultArray or resultObject, finally fetching resultArray + * if nothing was previously fetched + */ + public function getNumRows(): int + { + if (is_int($this->numRows)) { + return $this->numRows; + } + if ($this->resultArray !== []) { + return $this->numRows = count($this->resultArray); + } + if ($this->resultObject !== []) { + return $this->numRows = count($this->resultObject); + } + + return $this->numRows = count($this->getResultArray()); + } + + private function isValidResultId(): bool + { + return is_resource($this->resultID) || is_object($this->resultID); + } + + /** + * Gets the number of fields in the result set. + */ + abstract public function getFieldCount(): int; + + /** + * Generates an array of column names in the result set. + */ + abstract public function getFieldNames(): array; + + /** + * Generates an array of objects representing field meta-data. + */ + abstract public function getFieldData(): array; + + /** + * Frees the current result. + * + * @return void + */ + abstract public function freeResult(); + + /** + * Moves the internal pointer to the desired offset. This is called + * internally before fetching results to make sure the result set + * starts at zero. + * + * @return bool + */ + abstract public function dataSeek(int $n = 0); + + /** + * Returns the result set as an array. + * + * Overridden by driver classes. + * + * @return array|false|null + */ + abstract protected function fetchAssoc(); + + /** + * Returns the result set as an object. + * + * Overridden by child classes. + * + * @return Entity|false|object|stdClass + */ + abstract protected function fetchObject(string $className = 'stdClass'); +} diff --git a/system/Database/BaseUtils.php b/system/Database/BaseUtils.php new file mode 100644 index 0000000..d26bde1 --- /dev/null +++ b/system/Database/BaseUtils.php @@ -0,0 +1,322 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Database\Exceptions\DatabaseException; + +/** + * Class BaseUtils + */ +abstract class BaseUtils +{ + /** + * Database object + * + * @var object + */ + protected $db; + + /** + * List databases statement + * + * @var bool|string + */ + protected $listDatabases = false; + + /** + * OPTIMIZE TABLE statement + * + * @var bool|string + */ + protected $optimizeTable = false; + + /** + * REPAIR TABLE statement + * + * @var bool|string + */ + protected $repairTable = false; + + /** + * Class constructor + */ + public function __construct(ConnectionInterface $db) + { + $this->db = $db; + } + + /** + * List databases + * + * @return array|bool + * + * @throws DatabaseException + */ + public function listDatabases() + { + // Is there a cached result? + if (isset($this->db->dataCache['db_names'])) { + return $this->db->dataCache['db_names']; + } + + if ($this->listDatabases === false) { + if ($this->db->DBDebug) { + throw new DatabaseException('Unsupported feature of the database platform you are using.'); + } + + return false; + } + + $this->db->dataCache['db_names'] = []; + + $query = $this->db->query($this->listDatabases); + if ($query === false) { + return $this->db->dataCache['db_names']; + } + + for ($i = 0, $query = $query->getResultArray(), $c = count($query); $i < $c; $i++) { + $this->db->dataCache['db_names'][] = current($query[$i]); + } + + return $this->db->dataCache['db_names']; + } + + /** + * Determine if a particular database exists + */ + public function databaseExists(string $databaseName): bool + { + return in_array($databaseName, $this->listDatabases(), true); + } + + /** + * Optimize Table + * + * @return bool + * + * @throws DatabaseException + */ + public function optimizeTable(string $tableName) + { + if ($this->optimizeTable === false) { + if ($this->db->DBDebug) { + throw new DatabaseException('Unsupported feature of the database platform you are using.'); + } + + return false; + } + + $query = $this->db->query(sprintf($this->optimizeTable, $this->db->escapeIdentifiers($tableName))); + + return $query !== false; + } + + /** + * Optimize Database + * + * @return mixed + * + * @throws DatabaseException + */ + public function optimizeDatabase() + { + if ($this->optimizeTable === false) { + if ($this->db->DBDebug) { + throw new DatabaseException('Unsupported feature of the database platform you are using.'); + } + + return false; + } + + $result = []; + + foreach ($this->db->listTables() as $tableName) { + $res = $this->db->query(sprintf($this->optimizeTable, $this->db->escapeIdentifiers($tableName))); + if (is_bool($res)) { + return $res; + } + + // Build the result array... + + $res = $res->getResultArray(); + + // Postgre & SQLite3 returns empty array + if (empty($res)) { + $key = $tableName; + } else { + $res = current($res); + $key = str_replace($this->db->database . '.', '', current($res)); + $keys = array_keys($res); + unset($res[$keys[0]]); + } + + $result[$key] = $res; + } + + return $result; + } + + /** + * Repair Table + * + * @return mixed + * + * @throws DatabaseException + */ + public function repairTable(string $tableName) + { + if ($this->repairTable === false) { + if ($this->db->DBDebug) { + throw new DatabaseException('Unsupported feature of the database platform you are using.'); + } + + return false; + } + + $query = $this->db->query(sprintf($this->repairTable, $this->db->escapeIdentifiers($tableName))); + if (is_bool($query)) { + return $query; + } + + $query = $query->getResultArray(); + + return current($query); + } + + /** + * Generate CSV from a query result object + * + * @return string + */ + public function getCSVFromResult(ResultInterface $query, string $delim = ',', string $newline = "\n", string $enclosure = '"') + { + $out = ''; + + foreach ($query->getFieldNames() as $name) { + $out .= $enclosure . str_replace($enclosure, $enclosure . $enclosure, $name) . $enclosure . $delim; + } + + $out = substr($out, 0, -strlen($delim)) . $newline; + + // Next blast through the result array and build out the rows + while ($row = $query->getUnbufferedRow('array')) { + $line = []; + + foreach ($row as $item) { + $line[] = $enclosure . str_replace($enclosure, $enclosure . $enclosure, $item ?? '') . $enclosure; + } + + $out .= implode($delim, $line) . $newline; + } + + return $out; + } + + /** + * Generate XML data from a query result object + */ + public function getXMLFromResult(ResultInterface $query, array $params = []): string + { + foreach (['root' => 'root', 'element' => 'element', 'newline' => "\n", 'tab' => "\t"] as $key => $val) { + if (! isset($params[$key])) { + $params[$key] = $val; + } + } + + $root = $params['root']; + $newline = $params['newline']; + $tab = $params['tab']; + $element = $params['element']; + + helper('xml'); + $xml = '<' . $root . '>' . $newline; + + while ($row = $query->getUnbufferedRow()) { + $xml .= $tab . '<' . $element . '>' . $newline; + + foreach ($row as $key => $val) { + $val = (! empty($val)) ? xml_convert($val) : ''; + + $xml .= $tab . $tab . '<' . $key . '>' . $val . '' . $newline; + } + + $xml .= $tab . '' . $newline; + } + + return $xml . '' . $newline; + } + + /** + * Database Backup + * + * @param array|string $params + * + * @return false|never|string + * + * @throws DatabaseException + */ + public function backup($params = []) + { + if (is_string($params)) { + $params = ['tables' => $params]; + } + + $prefs = [ + 'tables' => [], + 'ignore' => [], + 'filename' => '', + 'format' => 'gzip', // gzip, txt + 'add_drop' => true, + 'add_insert' => true, + 'newline' => "\n", + 'foreign_key_checks' => true, + ]; + + if (! empty($params)) { + foreach (array_keys($prefs) as $key) { + if (isset($params[$key])) { + $prefs[$key] = $params[$key]; + } + } + } + + if (empty($prefs['tables'])) { + $prefs['tables'] = $this->db->listTables(); + } + + if (! in_array($prefs['format'], ['gzip', 'txt'], true)) { + $prefs['format'] = 'txt'; + } + + if ($prefs['format'] === 'gzip' && ! function_exists('gzencode')) { + if ($this->db->DBDebug) { + throw new DatabaseException('The file compression format you chose is not supported by your server.'); + } + + $prefs['format'] = 'txt'; + } + + if ($prefs['format'] === 'txt') { + return $this->_backup($prefs); + } + + // @TODO gzencode() requires `ext-zlib`, but _backup() is not implemented in all databases. + return gzencode($this->_backup($prefs)); + } + + /** + * Platform dependent version of the backup function. + * + * @return false|never|string + */ + abstract public function _backup(?array $prefs = null); +} diff --git a/system/Database/Config.php b/system/Database/Config.php new file mode 100644 index 0000000..92556c6 --- /dev/null +++ b/system/Database/Config.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Config\BaseConfig; +use Config\Database as DbConfig; +use InvalidArgumentException; + +/** + * Class Config + * + * @see \CodeIgniter\Database\ConfigTest + */ +class Config extends BaseConfig +{ + /** + * Cache for instance of any connections that + * have been requested as a "shared" instance. + * + * @var array + */ + protected static $instances = []; + + /** + * The main instance used to manage all of + * our open database connections. + * + * @var Database|null + */ + protected static $factory; + + /** + * Returns the database connection + * + * @param array|BaseConnection|non-empty-string|null $group The name of the connection group to use, + * or an array of configuration settings. + * @param bool $getShared Whether to return a shared instance of the connection. + * + * @return BaseConnection + */ + public static function connect($group = null, bool $getShared = true) + { + // If a DB connection is passed in, just pass it back + if ($group instanceof BaseConnection) { + return $group; + } + + if (is_array($group)) { + $config = $group; + $group = 'custom-' . md5(json_encode($config)); + } else { + $dbConfig = config(DbConfig::class); + + if ($group === null) { + $group = (ENVIRONMENT === 'testing') ? 'tests' : $dbConfig->defaultGroup; + } + + assert(is_string($group)); + + if (! isset($dbConfig->{$group})) { + throw new InvalidArgumentException($group . ' is not a valid database connection group.'); + } + + $config = $dbConfig->{$group}; + } + + if ($getShared && isset(static::$instances[$group])) { + return static::$instances[$group]; + } + + static::ensureFactory(); + + $connection = static::$factory->load($config, $group); + + static::$instances[$group] = $connection; + + return $connection; + } + + /** + * Returns an array of all db connections currently made. + */ + public static function getConnections(): array + { + return static::$instances; + } + + /** + * Loads and returns an instance of the Forge for the specified + * database group, and loads the group if it hasn't been loaded yet. + * + * @param array|ConnectionInterface|string|null $group + * + * @return Forge + */ + public static function forge($group = null) + { + $db = static::connect($group); + + return static::$factory->loadForge($db); + } + + /** + * Returns a new instance of the Database Utilities class. + * + * @param array|string|null $group + * + * @return BaseUtils + */ + public static function utils($group = null) + { + $db = static::connect($group); + + return static::$factory->loadUtils($db); + } + + /** + * Returns a new instance of the Database Seeder. + * + * @param non-empty-string|null $group + * + * @return Seeder + */ + public static function seeder(?string $group = null) + { + $config = config(DbConfig::class); + + return new Seeder($config, static::connect($group)); + } + + /** + * Ensures the database Connection Manager/Factory is loaded and ready to use. + */ + protected static function ensureFactory() + { + if (static::$factory instanceof Database) { + return; + } + + static::$factory = new Database(); + } +} diff --git a/system/Database/ConnectionInterface.php b/system/Database/ConnectionInterface.php new file mode 100644 index 0000000..85125eb --- /dev/null +++ b/system/Database/ConnectionInterface.php @@ -0,0 +1,163 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +/** + * @template TConnection + * @template TResult + */ +interface ConnectionInterface +{ + /** + * Initializes the database connection/settings. + * + * @return void + */ + public function initialize(); + + /** + * Connect to the database. + * + * @return false|object|resource + * @phpstan-return false|TConnection + */ + public function connect(bool $persistent = false); + + /** + * Create a persistent database connection. + * + * @return false|object|resource + * @phpstan-return false|TConnection + */ + public function persistentConnect(); + + /** + * Keep or establish the connection if no queries have been sent for + * a length of time exceeding the server's idle timeout. + * + * @return void + */ + public function reconnect(); + + /** + * Returns the actual connection object. If both a 'read' and 'write' + * connection has been specified, you can pass either term in to + * get that connection. If you pass either alias in and only a single + * connection is present, it must return the sole connection. + * + * @return false|object|resource + * @phpstan-return false|TConnection + */ + public function getConnection(?string $alias = null); + + /** + * Select a specific database table to use. + * + * @return bool + */ + public function setDatabase(string $databaseName); + + /** + * Returns the name of the current database being used. + */ + public function getDatabase(): string; + + /** + * Returns the last error encountered by this connection. + * Must return this format: ['code' => string|int, 'message' => string] + * intval(code) === 0 means "no error". + * + * @return array + */ + public function error(): array; + + /** + * The name of the platform in use (MySQLi, mssql, etc) + */ + public function getPlatform(): string; + + /** + * Returns a string containing the version of the database being used. + */ + public function getVersion(): string; + + /** + * Orchestrates a query against the database. Queries must use + * Database\Statement objects to store the query and build it. + * This method works with the cache. + * + * Should automatically handle different connections for read/write + * queries if needed. + * + * @param array|string|null $binds + * + * @return BaseResult|bool|Query + * @phpstan-return BaseResult|bool|Query + */ + public function query(string $sql, $binds = null); + + /** + * Performs a basic query against the database. No binding or caching + * is performed, nor are transactions handled. Simply takes a raw + * query string and returns the database-specific result id. + * + * @return false|object|resource + * @phpstan-return false|TResult + */ + public function simpleQuery(string $sql); + + /** + * Returns an instance of the query builder for this connection. + * + * @param array|string $tableName Table name. + * + * @return BaseBuilder Builder. + */ + public function table($tableName); + + /** + * Returns the last query's statement object. + * + * @return Query + */ + public function getLastQuery(); + + /** + * "Smart" Escaping + * + * Escapes data based on type. + * Sets boolean and null types. + * + * @param array|bool|float|int|object|string|null $str + * + * @return array|float|int|string + * @phpstan-return ($str is array ? array : float|int|string) + */ + public function escape($str); + + /** + * Allows for custom calls to the database engine that are not + * supported through our database layer. + * + * @param array ...$params + * + * @return array|bool|float|int|object|resource|string|null + */ + public function callFunction(string $functionName, ...$params); + + /** + * Determines if the statement is a write-type query or not. + * + * @param string $sql + */ + public function isWriteType($sql): bool; +} diff --git a/system/Database/Database.php b/system/Database/Database.php new file mode 100644 index 0000000..58a63e1 --- /dev/null +++ b/system/Database/Database.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use InvalidArgumentException; + +/** + * Database Connection Factory + * + * Creates and returns an instance of the appropriate Database Connection. + */ +class Database +{ + /** + * Maintains an array of the instances of all connections that have + * been created. + * + * Helps to keep track of all open connections for performance + * monitoring, logging, etc. + * + * @var array + */ + protected $connections = []; + + /** + * Parses the connection binds and creates a Database Connection instance. + * + * @return BaseConnection + * + * @throws InvalidArgumentException + */ + public function load(array $params = [], string $alias = '') + { + if ($alias === '') { + throw new InvalidArgumentException('You must supply the parameter: alias.'); + } + + if (! empty($params['DSN']) && strpos($params['DSN'], '://') !== false) { + $params = $this->parseDSN($params); + } + + if (empty($params['DBDriver'])) { + throw new InvalidArgumentException('You have not selected a database type to connect to.'); + } + + $this->connections[$alias] = $this->initDriver($params['DBDriver'], 'Connection', $params); + + return $this->connections[$alias]; + } + + /** + * Creates a Forge instance for the current database type. + */ + public function loadForge(ConnectionInterface $db): Forge + { + if (! $db->connID) { + $db->initialize(); + } + + return $this->initDriver($db->DBDriver, 'Forge', $db); + } + + /** + * Creates an instance of Utils for the current database type. + */ + public function loadUtils(ConnectionInterface $db): BaseUtils + { + if (! $db->connID) { + $db->initialize(); + } + + return $this->initDriver($db->DBDriver, 'Utils', $db); + } + + /** + * Parses universal DSN string + * + * @throws InvalidArgumentException + */ + protected function parseDSN(array $params): array + { + $dsn = parse_url($params['DSN']); + + if (! $dsn) { + throw new InvalidArgumentException('Your DSN connection string is invalid.'); + } + + $dsnParams = [ + 'DSN' => '', + 'DBDriver' => $dsn['scheme'], + 'hostname' => isset($dsn['host']) ? rawurldecode($dsn['host']) : '', + 'port' => isset($dsn['port']) ? rawurldecode((string) $dsn['port']) : '', + 'username' => isset($dsn['user']) ? rawurldecode($dsn['user']) : '', + 'password' => isset($dsn['pass']) ? rawurldecode($dsn['pass']) : '', + 'database' => isset($dsn['path']) ? rawurldecode(substr($dsn['path'], 1)) : '', + ]; + + if (isset($dsn['query']) && ($dsn['query'] !== '')) { + parse_str($dsn['query'], $extra); + + foreach ($extra as $key => $val) { + if (is_string($val) && in_array(strtolower($val), ['true', 'false', 'null'], true)) { + $val = $val === 'null' ? null : filter_var($val, FILTER_VALIDATE_BOOLEAN); + } + + $dsnParams[$key] = $val; + } + } + + return array_merge($params, $dsnParams); + } + + /** + * Creates a database object. + * + * @param string $driver Driver name. FQCN can be used. + * @param string $class 'Connection'|'Forge'|'Utils' + * @param array|object $argument The constructor parameter. + * + * @return BaseConnection|BaseUtils|Forge + */ + protected function initDriver(string $driver, string $class, $argument): object + { + $classname = (strpos($driver, '\\') === false) + ? "CodeIgniter\\Database\\{$driver}\\{$class}" + : $driver . '\\' . $class; + + return new $classname($argument); + } +} diff --git a/system/Database/Exceptions/DataException.php b/system/Database/Exceptions/DataException.php new file mode 100644 index 0000000..09b9ff3 --- /dev/null +++ b/system/Database/Exceptions/DataException.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Exceptions; + +use CodeIgniter\Exceptions\DebugTraceableTrait; +use RuntimeException; + +class DataException extends RuntimeException implements ExceptionInterface +{ + use DebugTraceableTrait; + + /** + * Used by the Model's trigger() method when the callback cannot be found. + * + * @return DataException + */ + public static function forInvalidMethodTriggered(string $method) + { + return new static(lang('Database.invalidEvent', [$method])); + } + + /** + * Used by Model's insert/update methods when there isn't + * any data to actually work with. + * + * @return DataException + */ + public static function forEmptyDataset(string $mode) + { + return new static(lang('Database.emptyDataset', [$mode])); + } + + /** + * Used by Model's insert/update methods when there is no + * primary key defined and Model has option `useAutoIncrement` + * set to false. + * + * @return DataException + */ + public static function forEmptyPrimaryKey(string $mode) + { + return new static(lang('Database.emptyPrimaryKey', [$mode])); + } + + /** + * Thrown when an argument for one of the Model's methods + * were empty or otherwise invalid, and they could not be + * to work correctly for that method. + * + * @return DataException + */ + public static function forInvalidArgument(string $argument) + { + return new static(lang('Database.invalidArgument', [$argument])); + } + + public static function forInvalidAllowedFields(string $model) + { + return new static(lang('Database.invalidAllowedFields', [$model])); + } + + public static function forTableNotFound(string $table) + { + return new static(lang('Database.tableNotFound', [$table])); + } + + public static function forEmptyInputGiven(string $argument) + { + return new static(lang('Database.forEmptyInputGiven', [$argument])); + } + + public static function forFindColumnHaveMultipleColumns() + { + return new static(lang('Database.forFindColumnHaveMultipleColumns')); + } +} diff --git a/system/Database/Exceptions/DatabaseException.php b/system/Database/Exceptions/DatabaseException.php new file mode 100644 index 0000000..4a58787 --- /dev/null +++ b/system/Database/Exceptions/DatabaseException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Exceptions; + +use CodeIgniter\Exceptions\HasExitCodeInterface; +use Error; + +class DatabaseException extends Error implements ExceptionInterface, HasExitCodeInterface +{ + public function getExitCode(): int + { + return EXIT_DATABASE; + } +} diff --git a/system/Database/Exceptions/ExceptionInterface.php b/system/Database/Exceptions/ExceptionInterface.php new file mode 100644 index 0000000..8156523 --- /dev/null +++ b/system/Database/Exceptions/ExceptionInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Exceptions; + +/** + * Provides a domain-level interface for broad capture + * of all database-related exceptions. + * + * catch (\CodeIgniter\Database\Exceptions\ExceptionInterface) { ... } + */ +interface ExceptionInterface extends \CodeIgniter\Exceptions\ExceptionInterface +{ +} diff --git a/system/Database/Forge.php b/system/Database/Forge.php new file mode 100644 index 0000000..adbdc20 --- /dev/null +++ b/system/Database/Forge.php @@ -0,0 +1,1245 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Database\Exceptions\DatabaseException; +use InvalidArgumentException; +use RuntimeException; +use Throwable; + +/** + * The Forge class transforms migrations to executable + * SQL statements. + */ +class Forge +{ + /** + * The active database connection. + * + * @var BaseConnection + */ + protected $db; + + /** + * List of fields. + * + * @var array [name => attributes] + */ + protected $fields = []; + + /** + * List of keys. + * + * @var list + */ + protected $keys = []; + + /** + * List of unique keys. + * + * @var array + */ + protected $uniqueKeys = []; + + /** + * Primary keys. + * + * @var array{fields?: string[], keyName?: string} + */ + protected $primaryKeys = []; + + /** + * List of foreign keys. + * + * @var array + */ + protected $foreignKeys = []; + + /** + * Character set used. + * + * @var string + */ + protected $charset = ''; + + /** + * CREATE DATABASE statement + * + * @var false|string + */ + protected $createDatabaseStr = 'CREATE DATABASE %s'; + + /** + * CREATE DATABASE IF statement + * + * @var string + */ + protected $createDatabaseIfStr; + + /** + * CHECK DATABASE EXIST statement + * + * @var string + */ + protected $checkDatabaseExistStr; + + /** + * DROP DATABASE statement + * + * @var false|string + */ + protected $dropDatabaseStr = 'DROP DATABASE %s'; + + /** + * CREATE TABLE statement + * + * @var string + */ + protected $createTableStr = "%s %s (%s\n)"; + + /** + * CREATE TABLE IF statement + * + * @var bool|string + * + * @deprecated This is no longer used. + */ + protected $createTableIfStr = 'CREATE TABLE IF NOT EXISTS'; + + /** + * CREATE TABLE keys flag + * + * Whether table keys are created from within the + * CREATE TABLE statement. + * + * @var bool + */ + protected $createTableKeys = false; + + /** + * DROP TABLE IF EXISTS statement + * + * @var bool|string + */ + protected $dropTableIfStr = 'DROP TABLE IF EXISTS'; + + /** + * RENAME TABLE statement + * + * @var false|string + */ + protected $renameTableStr = 'ALTER TABLE %s RENAME TO %s'; + + /** + * UNSIGNED support + * + * @var array|bool + */ + protected $unsigned = true; + + /** + * NULL value representation in CREATE/ALTER TABLE statements + * + * @var string + * + * @internal Used for marking nullable fields. Not covered by BC promise. + */ + protected $null = 'NULL'; + + /** + * DEFAULT value representation in CREATE/ALTER TABLE statements + * + * @var false|string + */ + protected $default = ' DEFAULT '; + + /** + * DROP CONSTRAINT statement + * + * @var string + */ + protected $dropConstraintStr; + + /** + * DROP INDEX statement + * + * @var string + */ + protected $dropIndexStr = 'DROP INDEX %s ON %s'; + + /** + * Foreign Key Allowed Actions + * + * @var array + */ + protected $fkAllowActions = ['CASCADE', 'SET NULL', 'NO ACTION', 'RESTRICT', 'SET DEFAULT']; + + /** + * Constructor. + */ + public function __construct(BaseConnection $db) + { + $this->db = $db; + } + + /** + * Provides access to the forge's current database connection. + * + * @return ConnectionInterface + */ + public function getConnection() + { + return $this->db; + } + + /** + * Create database + * + * @param bool $ifNotExists Whether to add IF NOT EXISTS condition + * + * @throws DatabaseException + */ + public function createDatabase(string $dbName, bool $ifNotExists = false): bool + { + if ($ifNotExists && $this->createDatabaseIfStr === null) { + if ($this->databaseExists($dbName)) { + return true; + } + + $ifNotExists = false; + } + + if ($this->createDatabaseStr === false) { + if ($this->db->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; // @codeCoverageIgnore + } + + try { + if (! $this->db->query(sprintf($ifNotExists ? $this->createDatabaseIfStr : $this->createDatabaseStr, $dbName, $this->db->charset, $this->db->DBCollat))) { + // @codeCoverageIgnoreStart + if ($this->db->DBDebug) { + throw new DatabaseException('Unable to create the specified database.'); + } + + return false; + // @codeCoverageIgnoreEnd + } + + if (! empty($this->db->dataCache['db_names'])) { + $this->db->dataCache['db_names'][] = $dbName; + } + + return true; + } catch (Throwable $e) { + if ($this->db->DBDebug) { + throw new DatabaseException('Unable to create the specified database.', 0, $e); + } + + return false; // @codeCoverageIgnore + } + } + + /** + * Determine if a database exists + * + * @throws DatabaseException + */ + private function databaseExists(string $dbName): bool + { + if ($this->checkDatabaseExistStr === null) { + if ($this->db->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; + } + + return $this->db->query($this->checkDatabaseExistStr, $dbName)->getRow() !== null; + } + + /** + * Drop database + * + * @throws DatabaseException + */ + public function dropDatabase(string $dbName): bool + { + if ($this->dropDatabaseStr === false) { + if ($this->db->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; + } + + if (! $this->db->query(sprintf($this->dropDatabaseStr, $dbName))) { + if ($this->db->DBDebug) { + throw new DatabaseException('Unable to drop the specified database.'); + } + + return false; + } + + if (! empty($this->db->dataCache['db_names'])) { + $key = array_search(strtolower($dbName), array_map('strtolower', $this->db->dataCache['db_names']), true); + if ($key !== false) { + unset($this->db->dataCache['db_names'][$key]); + } + } + + return true; + } + + /** + * Add Key + * + * @param array|string $key + * + * @return Forge + */ + public function addKey($key, bool $primary = false, bool $unique = false, string $keyName = '') + { + if ($primary) { + $this->primaryKeys = ['fields' => (array) $key, 'keyName' => $keyName]; + } else { + $this->keys[] = ['fields' => (array) $key, 'keyName' => $keyName]; + + if ($unique) { + $this->uniqueKeys[] = count($this->keys) - 1; + } + } + + return $this; + } + + /** + * Add Primary Key + * + * @param array|string $key + * + * @return Forge + */ + public function addPrimaryKey($key, string $keyName = '') + { + return $this->addKey($key, true, false, $keyName); + } + + /** + * Add Unique Key + * + * @param array|string $key + * + * @return Forge + */ + public function addUniqueKey($key, string $keyName = '') + { + return $this->addKey($key, false, true, $keyName); + } + + /** + * Add Field + * + * @param array|string $fields Field array or Field string + * + * @return Forge + */ + public function addField($fields) + { + if (is_string($fields)) { + if ($fields === 'id') { + $this->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 9, + 'auto_increment' => true, + ], + ]); + $this->addKey('id', true); + } else { + if (strpos($fields, ' ') === false) { + throw new InvalidArgumentException('Field information is required for that operation.'); + } + + $fieldName = explode(' ', $fields, 2)[0]; + $fieldName = trim($fieldName, '`\'"'); + + $this->fields[$fieldName] = $fields; + } + } + + if (is_array($fields)) { + foreach ($fields as $name => $attributes) { + if (is_string($attributes)) { + $this->addField($attributes); + + continue; + } + + if (is_array($attributes)) { + $this->fields = array_merge($this->fields, [$name => $attributes]); + } + } + } + + return $this; + } + + /** + * Add Foreign Key + * + * @param string|string[] $fieldName + * @param string|string[] $tableField + * + * @throws DatabaseException + */ + public function addForeignKey( + $fieldName = '', + string $tableName = '', + $tableField = '', + string $onUpdate = '', + string $onDelete = '', + string $fkName = '' + ): Forge { + $fieldName = (array) $fieldName; + $tableField = (array) $tableField; + + $this->foreignKeys[] = [ + 'field' => $fieldName, + 'referenceTable' => $tableName, + 'referenceField' => $tableField, + 'onDelete' => strtoupper($onDelete), + 'onUpdate' => strtoupper($onUpdate), + 'fkName' => $fkName, + ]; + + return $this; + } + + /** + * Drop Key + * + * @throws DatabaseException + */ + public function dropKey(string $table, string $keyName, bool $prefixKeyName = true): bool + { + $keyName = $this->db->escapeIdentifiers(($prefixKeyName === true ? $this->db->DBPrefix : '') . $keyName); + $table = $this->db->escapeIdentifiers($this->db->DBPrefix . $table); + + $dropKeyAsConstraint = $this->dropKeyAsConstraint($table, $keyName); + + if ($dropKeyAsConstraint === true) { + $sql = sprintf( + $this->dropConstraintStr, + $table, + $keyName, + ); + } else { + $sql = sprintf( + $this->dropIndexStr, + $keyName, + $table, + ); + } + + if ($sql === '') { + if ($this->db->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; + } + + return $this->db->query($sql); + } + + /** + * Checks if key needs to be dropped as a constraint. + */ + protected function dropKeyAsConstraint(string $table, string $constraintName): bool + { + $sql = $this->_dropKeyAsConstraint($table, $constraintName); + + if ($sql === '') { + return false; + } + + return $this->db->query($sql)->getResultArray() !== []; + } + + /** + * Constructs sql to check if key is a constraint. + */ + protected function _dropKeyAsConstraint(string $table, string $constraintName): string + { + return ''; + } + + /** + * Drop Primary Key + */ + public function dropPrimaryKey(string $table, string $keyName = ''): bool + { + $sql = sprintf( + 'ALTER TABLE %s DROP CONSTRAINT %s', + $this->db->escapeIdentifiers($this->db->DBPrefix . $table), + ($keyName === '') ? $this->db->escapeIdentifiers('pk_' . $this->db->DBPrefix . $table) : $this->db->escapeIdentifiers($keyName), + ); + + return $this->db->query($sql); + } + + /** + * @return bool + * + * @throws DatabaseException + */ + public function dropForeignKey(string $table, string $foreignName) + { + $sql = sprintf( + (string) $this->dropConstraintStr, + $this->db->escapeIdentifiers($this->db->DBPrefix . $table), + $this->db->escapeIdentifiers($foreignName) + ); + + if ($sql === '') { + if ($this->db->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; + } + + return $this->db->query($sql); + } + + /** + * @param array $attributes Table attributes + * + * @return bool + * + * @throws DatabaseException + */ + public function createTable(string $table, bool $ifNotExists = false, array $attributes = []) + { + if ($table === '') { + throw new InvalidArgumentException('A table name is required for that operation.'); + } + + $table = $this->db->DBPrefix . $table; + + if ($this->fields === []) { + throw new RuntimeException('Field information is required.'); + } + + // If table exists lets stop here + if ($ifNotExists === true && $this->db->tableExists($table, false)) { + $this->reset(); + + return true; + } + + $sql = $this->_createTable($table, false, $attributes); + + if (($result = $this->db->query($sql)) !== false) { + if (isset($this->db->dataCache['table_names']) && ! in_array($table, $this->db->dataCache['table_names'], true)) { + $this->db->dataCache['table_names'][] = $table; + } + + // Most databases don't support creating indexes from within the CREATE TABLE statement + if (! empty($this->keys)) { + for ($i = 0, $sqls = $this->_processIndexes($table), $c = count($sqls); $i < $c; $i++) { + $this->db->query($sqls[$i]); + } + } + } + + $this->reset(); + + return $result; + } + + /** + * @param array $attributes Table attributes + * + * @return string SQL string + * + * @deprecated $ifNotExists is no longer used, and will be removed. + */ + protected function _createTable(string $table, bool $ifNotExists, array $attributes) + { + $processedFields = $this->_processFields(true); + + for ($i = 0, $c = count($processedFields); $i < $c; $i++) { + $processedFields[$i] = ($processedFields[$i]['_literal'] !== false) ? "\n\t" . $processedFields[$i]['_literal'] + : "\n\t" . $this->_processColumn($processedFields[$i]); + } + + $processedFields = implode(',', $processedFields); + + $processedFields .= $this->_processPrimaryKeys($table); + $processedFields .= current($this->_processForeignKeys($table)); + + if ($this->createTableKeys === true) { + $indexes = current($this->_processIndexes($table)); + if (is_string($indexes)) { + $processedFields .= $indexes; + } + } + + return sprintf( + $this->createTableStr . '%s', + 'CREATE TABLE', + $this->db->escapeIdentifiers($table), + $processedFields, + $this->_createTableAttributes($attributes) + ); + } + + protected function _createTableAttributes(array $attributes): string + { + $sql = ''; + + foreach (array_keys($attributes) as $key) { + if (is_string($key)) { + $sql .= ' ' . strtoupper($key) . ' ' . $this->db->escape($attributes[$key]); + } + } + + return $sql; + } + + /** + * @return bool + * + * @throws DatabaseException + */ + public function dropTable(string $tableName, bool $ifExists = false, bool $cascade = false) + { + if ($tableName === '') { + if ($this->db->DBDebug) { + throw new DatabaseException('A table name is required for that operation.'); + } + + return false; + } + + if ($this->db->DBPrefix && strpos($tableName, $this->db->DBPrefix) === 0) { + $tableName = substr($tableName, strlen($this->db->DBPrefix)); + } + + if (($query = $this->_dropTable($this->db->DBPrefix . $tableName, $ifExists, $cascade)) === true) { + return true; + } + + $this->db->disableForeignKeyChecks(); + + $query = $this->db->query($query); + + $this->db->enableForeignKeyChecks(); + + if ($query && ! empty($this->db->dataCache['table_names'])) { + $key = array_search( + strtolower($this->db->DBPrefix . $tableName), + array_map('strtolower', $this->db->dataCache['table_names']), + true + ); + + if ($key !== false) { + unset($this->db->dataCache['table_names'][$key]); + } + } + + return $query; + } + + /** + * Generates a platform-specific DROP TABLE string + * + * @return bool|string + */ + protected function _dropTable(string $table, bool $ifExists, bool $cascade) + { + $sql = 'DROP TABLE'; + + if ($ifExists) { + if ($this->dropTableIfStr === false) { + if (! $this->db->tableExists($table)) { + return true; + } + } else { + $sql = sprintf($this->dropTableIfStr, $this->db->escapeIdentifiers($table)); + } + } + + return $sql . ' ' . $this->db->escapeIdentifiers($table); + } + + /** + * @return bool + * + * @throws DatabaseException + */ + public function renameTable(string $tableName, string $newTableName) + { + if ($tableName === '' || $newTableName === '') { + throw new InvalidArgumentException('A table name is required for that operation.'); + } + + if ($this->renameTableStr === false) { + if ($this->db->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; + } + + $result = $this->db->query(sprintf( + $this->renameTableStr, + $this->db->escapeIdentifiers($this->db->DBPrefix . $tableName), + $this->db->escapeIdentifiers($this->db->DBPrefix . $newTableName) + )); + + if ($result && ! empty($this->db->dataCache['table_names'])) { + $key = array_search( + strtolower($this->db->DBPrefix . $tableName), + array_map('strtolower', $this->db->dataCache['table_names']), + true + ); + + if ($key !== false) { + $this->db->dataCache['table_names'][$key] = $this->db->DBPrefix . $newTableName; + } + } + + return $result; + } + + /** + * @param array|string $fields Field array or Field string + * + * @throws DatabaseException + */ + public function addColumn(string $table, $fields): bool + { + // Work-around for literal column definitions + if (is_string($fields)) { + $fields = [$fields]; + } + + foreach (array_keys($fields) as $name) { + $this->addField([$name => $fields[$name]]); + } + + $sqls = $this->_alterTable('ADD', $this->db->DBPrefix . $table, $this->_processFields()); + $this->reset(); + + if ($sqls === false) { + if ($this->db->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; + } + + foreach ($sqls as $sql) { + if ($this->db->query($sql) === false) { + return false; + } + } + + return true; + } + + /** + * @param array|string $columnNames column names to DROP + * + * @return bool + * + * @throws DatabaseException + */ + public function dropColumn(string $table, $columnNames) + { + $sql = $this->_alterTable('DROP', $this->db->DBPrefix . $table, $columnNames); + + if ($sql === false) { + if ($this->db->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; + } + + return $this->db->query($sql); + } + + /** + * @param array|string $fields Field array or Field string + * + * @throws DatabaseException + */ + public function modifyColumn(string $table, $fields): bool + { + // Work-around for literal column definitions + if (is_string($fields)) { + $fields = [$fields]; + } + + foreach (array_keys($fields) as $name) { + $this->addField([$name => $fields[$name]]); + } + + if ($this->fields === []) { + throw new RuntimeException('Field information is required'); + } + + $sqls = $this->_alterTable('CHANGE', $this->db->DBPrefix . $table, $this->_processFields()); + $this->reset(); + + if ($sqls === false) { + if ($this->db->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; + } + + if (is_array($sqls)) { + foreach ($sqls as $sql) { + if ($this->db->query($sql) === false) { + return false; + } + } + } + + return true; + } + + /** + * @param 'ADD'|'CHANGE'|'DROP' $alterType + * @param array|string $processedFields Processed column definitions + * or column names to DROP + * + * @return false|list|string|null SQL string + * @phpstan-return ($alterType is 'DROP' ? string : list|false|null) + */ + protected function _alterTable(string $alterType, string $table, $processedFields) + { + $sql = 'ALTER TABLE ' . $this->db->escapeIdentifiers($table) . ' '; + + // DROP has everything it needs now. + if ($alterType === 'DROP') { + $columnNamesToDrop = $processedFields; + + if (is_string($columnNamesToDrop)) { + $columnNamesToDrop = explode(',', $columnNamesToDrop); + } + + $columnNamesToDrop = array_map(fn ($field) => 'DROP COLUMN ' . $this->db->escapeIdentifiers(trim($field)), $columnNamesToDrop); + + return $sql . implode(', ', $columnNamesToDrop); + } + + $sql .= ($alterType === 'ADD') ? 'ADD ' : $alterType . ' COLUMN '; + + $sqls = []; + + foreach ($processedFields as $field) { + $sqls[] = $sql . ($field['_literal'] !== false + ? $field['_literal'] + : $this->_processColumn($field)); + } + + return $sqls; + } + + /** + * Returns $processedFields array from $this->fields data. + */ + protected function _processFields(bool $createTable = false): array + { + $processedFields = []; + + foreach ($this->fields as $name => $attributes) { + if (! is_array($attributes)) { + $processedFields[] = ['_literal' => $attributes]; + + continue; + } + + $attributes = array_change_key_case($attributes, CASE_UPPER); + + if ($createTable === true && empty($attributes['TYPE'])) { + continue; + } + + if (isset($attributes['TYPE'])) { + $this->_attributeType($attributes); + } + + $field = [ + 'name' => $name, + 'new_name' => $attributes['NAME'] ?? null, + 'type' => $attributes['TYPE'] ?? null, + 'length' => '', + 'unsigned' => '', + 'null' => '', + 'unique' => '', + 'default' => '', + 'auto_increment' => '', + '_literal' => false, + ]; + + if (isset($attributes['TYPE'])) { + $this->_attributeUnsigned($attributes, $field); + } + + if ($createTable === false) { + if (isset($attributes['AFTER'])) { + $field['after'] = $attributes['AFTER']; + } elseif (isset($attributes['FIRST'])) { + $field['first'] = (bool) $attributes['FIRST']; + } + } + + $this->_attributeDefault($attributes, $field); + + if (isset($attributes['NULL'])) { + $nullString = ' ' . $this->null; + + if ($attributes['NULL'] === true) { + $field['null'] = empty($this->null) ? '' : $nullString; + } elseif ($attributes['NULL'] === $nullString) { + $field['null'] = $nullString; + } elseif ($attributes['NULL'] === '') { + $field['null'] = ''; + } else { + $field['null'] = ' NOT ' . $this->null; + } + } elseif ($createTable === true) { + $field['null'] = ' NOT ' . $this->null; + } + + $this->_attributeAutoIncrement($attributes, $field); + $this->_attributeUnique($attributes, $field); + + if (isset($attributes['COMMENT'])) { + $field['comment'] = $this->db->escape($attributes['COMMENT']); + } + + if (isset($attributes['TYPE']) && ! empty($attributes['CONSTRAINT'])) { + if (is_array($attributes['CONSTRAINT'])) { + $attributes['CONSTRAINT'] = $this->db->escape($attributes['CONSTRAINT']); + $attributes['CONSTRAINT'] = implode(',', $attributes['CONSTRAINT']); + } + + $field['length'] = '(' . $attributes['CONSTRAINT'] . ')'; + } + + $processedFields[] = $field; + } + + return $processedFields; + } + + /** + * Converts $processedField array to field definition string. + */ + protected function _processColumn(array $processedField): string + { + return $this->db->escapeIdentifiers($processedField['name']) + . ' ' . $processedField['type'] . $processedField['length'] + . $processedField['unsigned'] + . $processedField['default'] + . $processedField['null'] + . $processedField['auto_increment'] + . $processedField['unique']; + } + + /** + * Performs a data type mapping between different databases. + */ + protected function _attributeType(array &$attributes) + { + // Usually overridden by drivers + } + + /** + * Depending on the unsigned property value: + * + * - TRUE will always set $field['unsigned'] to 'UNSIGNED' + * - FALSE will always set $field['unsigned'] to '' + * - array(TYPE) will set $field['unsigned'] to 'UNSIGNED', + * if $attributes['TYPE'] is found in the array + * - array(TYPE => UTYPE) will change $field['type'], + * from TYPE to UTYPE in case of a match + */ + protected function _attributeUnsigned(array &$attributes, array &$field) + { + if (empty($attributes['UNSIGNED']) || $attributes['UNSIGNED'] !== true) { + return; + } + + // Reset the attribute in order to avoid issues if we do type conversion + $attributes['UNSIGNED'] = false; + + if (is_array($this->unsigned)) { + foreach (array_keys($this->unsigned) as $key) { + if (is_int($key) && strcasecmp($attributes['TYPE'], $this->unsigned[$key]) === 0) { + $field['unsigned'] = ' UNSIGNED'; + + return; + } + + if (is_string($key) && strcasecmp($attributes['TYPE'], $key) === 0) { + $field['type'] = $key; + + return; + } + } + + return; + } + + $field['unsigned'] = ($this->unsigned === true) ? ' UNSIGNED' : ''; + } + + protected function _attributeDefault(array &$attributes, array &$field) + { + if ($this->default === false) { + return; + } + + if (array_key_exists('DEFAULT', $attributes)) { + if ($attributes['DEFAULT'] === null) { + $field['default'] = empty($this->null) ? '' : $this->default . $this->null; + + // Override the NULL attribute if that's our default + $attributes['NULL'] = true; + $field['null'] = empty($this->null) ? '' : ' ' . $this->null; + } elseif ($attributes['DEFAULT'] instanceof RawSql) { + $field['default'] = $this->default . $attributes['DEFAULT']; + } else { + $field['default'] = $this->default . $this->db->escape($attributes['DEFAULT']); + } + } + } + + protected function _attributeUnique(array &$attributes, array &$field) + { + if (! empty($attributes['UNIQUE']) && $attributes['UNIQUE'] === true) { + $field['unique'] = ' UNIQUE'; + } + } + + protected function _attributeAutoIncrement(array &$attributes, array &$field) + { + if (! empty($attributes['AUTO_INCREMENT']) && $attributes['AUTO_INCREMENT'] === true + && stripos($field['type'], 'int') !== false + ) { + $field['auto_increment'] = ' AUTO_INCREMENT'; + } + } + + /** + * Generates SQL to add primary key + * + * @param bool $asQuery When true returns stand alone SQL, else partial SQL used with CREATE TABLE + */ + protected function _processPrimaryKeys(string $table, bool $asQuery = false): string + { + $sql = ''; + + if (isset($this->primaryKeys['fields'])) { + for ($i = 0, $c = count($this->primaryKeys['fields']); $i < $c; $i++) { + if (! isset($this->fields[$this->primaryKeys['fields'][$i]])) { + unset($this->primaryKeys['fields'][$i]); + } + } + } + + if (isset($this->primaryKeys['fields']) && $this->primaryKeys['fields'] !== []) { + if ($asQuery === true) { + $sql .= 'ALTER TABLE ' . $this->db->escapeIdentifiers($this->db->DBPrefix . $table) . ' ADD '; + } else { + $sql .= ",\n\t"; + } + $sql .= 'CONSTRAINT ' . $this->db->escapeIdentifiers(($this->primaryKeys['keyName'] === '' ? + 'pk_' . $table : + $this->primaryKeys['keyName'])) + . ' PRIMARY KEY(' . implode(', ', $this->db->escapeIdentifiers($this->primaryKeys['fields'])) . ')'; + } + + return $sql; + } + + /** + * Executes Sql to add indexes without createTable + */ + public function processIndexes(string $table): bool + { + $sqls = []; + $fk = $this->foreignKeys; + + if ($this->fields === []) { + $this->fields = array_flip(array_map( + static fn ($columnName) => $columnName->name, + $this->db->getFieldData($this->db->DBPrefix . $table) + )); + } + + $fields = $this->fields; + + if ($this->keys !== []) { + $sqls = $this->_processIndexes($this->db->DBPrefix . $table, true); + } + + if ($this->primaryKeys !== []) { + $sqls[] = $this->_processPrimaryKeys($table, true); + } + + $this->foreignKeys = $fk; + $this->fields = $fields; + + if ($this->foreignKeys !== []) { + $sqls = array_merge($sqls, $this->_processForeignKeys($table, true)); + } + + foreach ($sqls as $sql) { + if ($this->db->query($sql) === false) { + return false; + } + } + + $this->reset(); + + return true; + } + + /** + * Generates SQL to add indexes + * + * @param bool $asQuery When true returns stand alone SQL, else partial SQL used with CREATE TABLE + */ + protected function _processIndexes(string $table, bool $asQuery = false): array + { + $sqls = []; + + for ($i = 0, $c = count($this->keys); $i < $c; $i++) { + for ($i2 = 0, $c2 = count($this->keys[$i]['fields']); $i2 < $c2; $i2++) { + if (! isset($this->fields[$this->keys[$i]['fields'][$i2]])) { + unset($this->keys[$i]['fields'][$i2]); + } + } + + if (count($this->keys[$i]['fields']) <= 0) { + continue; + } + + $keyName = $this->db->escapeIdentifiers(($this->keys[$i]['keyName'] === '') ? + $table . '_' . implode('_', $this->keys[$i]['fields']) : + $this->keys[$i]['keyName']); + + if (in_array($i, $this->uniqueKeys, true)) { + if ($this->db->DBDriver === 'SQLite3') { + $sqls[] = 'CREATE UNIQUE INDEX ' . $keyName + . ' ON ' . $this->db->escapeIdentifiers($table) + . ' (' . implode(', ', $this->db->escapeIdentifiers($this->keys[$i]['fields'])) . ')'; + } else { + $sqls[] = 'ALTER TABLE ' . $this->db->escapeIdentifiers($table) + . ' ADD CONSTRAINT ' . $keyName + . ' UNIQUE (' . implode(', ', $this->db->escapeIdentifiers($this->keys[$i]['fields'])) . ')'; + } + + continue; + } + + $sqls[] = 'CREATE INDEX ' . $keyName + . ' ON ' . $this->db->escapeIdentifiers($table) + . ' (' . implode(', ', $this->db->escapeIdentifiers($this->keys[$i]['fields'])) . ')'; + } + + return $sqls; + } + + /** + * Generates SQL to add foreign keys + * + * @param bool $asQuery When true returns stand alone SQL, else partial SQL used with CREATE TABLE + */ + protected function _processForeignKeys(string $table, bool $asQuery = false): array + { + $errorNames = []; + + foreach ($this->foreignKeys as $fkeyInfo) { + foreach ($fkeyInfo['field'] as $fieldName) { + if (! isset($this->fields[$fieldName])) { + $errorNames[] = $fieldName; + } + } + } + + if ($errorNames !== []) { + $errorNames = [implode(', ', $errorNames)]; + + throw new DatabaseException(lang('Database.fieldNotExists', $errorNames)); + } + + $sqls = ['']; + + foreach ($this->foreignKeys as $index => $fkey) { + if ($asQuery === false) { + $index = 0; + } else { + $sqls[$index] = ''; + } + + $nameIndex = $fkey['fkName'] !== '' ? + $fkey['fkName'] : + $table . '_' . implode('_', $fkey['field']) . ($this->db->DBDriver === 'OCI8' ? '_fk' : '_foreign'); + + $nameIndexFilled = $this->db->escapeIdentifiers($nameIndex); + $foreignKeyFilled = implode(', ', $this->db->escapeIdentifiers($fkey['field'])); + $referenceTableFilled = $this->db->escapeIdentifiers($this->db->DBPrefix . $fkey['referenceTable']); + $referenceFieldFilled = implode(', ', $this->db->escapeIdentifiers($fkey['referenceField'])); + + if ($asQuery === true) { + $sqls[$index] .= 'ALTER TABLE ' . $this->db->escapeIdentifiers($this->db->DBPrefix . $table) . ' ADD '; + } else { + $sqls[$index] .= ",\n\t"; + } + + $formatSql = 'CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s(%s)'; + $sqls[$index] .= sprintf($formatSql, $nameIndexFilled, $foreignKeyFilled, $referenceTableFilled, $referenceFieldFilled); + + if ($fkey['onDelete'] !== false && in_array($fkey['onDelete'], $this->fkAllowActions, true)) { + $sqls[$index] .= ' ON DELETE ' . $fkey['onDelete']; + } + + if ($this->db->DBDriver !== 'OCI8' && $fkey['onUpdate'] !== false && in_array($fkey['onUpdate'], $this->fkAllowActions, true)) { + $sqls[$index] .= ' ON UPDATE ' . $fkey['onUpdate']; + } + } + + return $sqls; + } + + /** + * Resets table creation vars + */ + public function reset() + { + $this->fields = $this->keys = $this->uniqueKeys = $this->primaryKeys = $this->foreignKeys = []; + } +} diff --git a/system/Database/Migration.php b/system/Database/Migration.php new file mode 100644 index 0000000..f12509d --- /dev/null +++ b/system/Database/Migration.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use Config\Database; + +/** + * Class Migration + */ +abstract class Migration +{ + /** + * The name of the database group to use. + * + * @var string|null + */ + protected $DBGroup; + + /** + * Database Connection instance + * + * @var ConnectionInterface + */ + protected $db; + + /** + * Database Forge instance. + * + * @var Forge + */ + protected $forge; + + public function __construct(?Forge $forge = null) + { + if (isset($this->DBGroup)) { + $this->forge = Database::forge($this->DBGroup); + } elseif ($forge !== null) { + $this->forge = $forge; + } else { + $this->forge = Database::forge(config(Database::class)->defaultGroup); + } + + $this->db = $this->forge->getConnection(); + } + + /** + * Returns the database group name this migration uses. + */ + public function getDBGroup(): ?string + { + return $this->DBGroup; + } + + /** + * Perform a migration step. + */ + abstract public function up(); + + /** + * Revert a migration step. + */ + abstract public function down(); +} diff --git a/system/Database/MigrationRunner.php b/system/Database/MigrationRunner.php new file mode 100644 index 0000000..435c118 --- /dev/null +++ b/system/Database/MigrationRunner.php @@ -0,0 +1,869 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\CLI\CLI; +use CodeIgniter\Events\Events; +use CodeIgniter\Exceptions\ConfigException; +use CodeIgniter\I18n\Time; +use Config\Database; +use Config\Migrations as MigrationsConfig; +use Config\Services; +use RuntimeException; +use stdClass; + +/** + * Class MigrationRunner + */ +class MigrationRunner +{ + /** + * Whether or not migrations are allowed to run. + * + * @var bool + */ + protected $enabled = false; + + /** + * Name of table to store meta information + * + * @var string + */ + protected $table; + + /** + * The Namespace where migrations can be found. + * `null` is all namespaces. + * + * @var string|null + */ + protected $namespace; + + /** + * The database Group to migrate. + * + * @var string + */ + protected $group; + + /** + * The migration name. + * + * @var string + */ + protected $name; + + /** + * The pattern used to locate migration file versions. + * + * @var string + */ + protected $regex = '/\A(\d{4}[_-]?\d{2}[_-]?\d{2}[_-]?\d{6})_(\w+)\z/'; + + /** + * The main database connection. Used to store + * migration information in. + * + * @var BaseConnection + */ + protected $db; + + /** + * If true, will continue instead of throwing + * exceptions. + * + * @var bool + */ + protected $silent = false; + + /** + * used to return messages for CLI. + * + * @var array + */ + protected $cliMessages = []; + + /** + * Tracks whether we have already ensured + * the table exists or not. + * + * @var bool + */ + protected $tableChecked = false; + + /** + * The full path to locate migration files. + * + * @var string + */ + protected $path; + + /** + * The database Group filter. + * + * @var string|null + */ + protected $groupFilter; + + /** + * Used to skip current migration. + * + * @var bool + */ + protected $groupSkip = false; + + /** + * The migration can manage multiple databases. So it should always use the + * default DB group so that it creates the `migrations` table in the default + * DB group. Therefore, passing $db is for testing purposes only. + * + * @param array|ConnectionInterface|string|null $db DB group. For testing purposes only. + * + * @throws ConfigException + */ + public function __construct(MigrationsConfig $config, $db = null) + { + $this->enabled = $config->enabled ?? false; + $this->table = $config->table ?? 'migrations'; + + $this->namespace = APP_NAMESPACE; + + // Even if a DB connection is passed, since it is a test, + // it is assumed to use the default group name + $this->group = is_string($db) ? $db : config(Database::class)->defaultGroup; + + $this->db = db_connect($db); + } + + /** + * Locate and run all new migrations + * + * @return bool + * + * @throws ConfigException + * @throws RuntimeException + */ + public function latest(?string $group = null) + { + if (! $this->enabled) { + throw ConfigException::forDisabledMigrations(); + } + + $this->ensureTable(); + + if ($group !== null) { + $this->groupFilter = $group; + $this->setGroup($group); + } + + $migrations = $this->findMigrations(); + + if ($migrations === []) { + return true; + } + + foreach ($this->getHistory((string) $group) as $history) { + unset($migrations[$this->getObjectUid($history)]); + } + + $batch = $this->getLastBatch() + 1; + + foreach ($migrations as $migration) { + if ($this->migrate('up', $migration)) { + if ($this->groupSkip === true) { + $this->groupSkip = false; + + continue; + } + + $this->addHistory($migration, $batch); + } else { + $this->regress(-1); + + $message = lang('Migrations.generalFault'); + + if ($this->silent) { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + + return false; + } + + throw new RuntimeException($message); + } + } + + $data = get_object_vars($this); + $data['method'] = 'latest'; + Events::trigger('migrate', $data); + + return true; + } + + /** + * Migrate down to a previous batch + * + * Calls each migration step required to get to the provided batch + * + * @param int $targetBatch Target batch number, or negative for a relative batch, 0 for all + * @param string|null $group Deprecated. The designation has no effect. + * + * @return bool True on success, FALSE on failure or no migrations are found + * + * @throws ConfigException + * @throws RuntimeException + */ + public function regress(int $targetBatch = 0, ?string $group = null) + { + if (! $this->enabled) { + throw ConfigException::forDisabledMigrations(); + } + + $this->ensureTable(); + + $batches = $this->getBatches(); + + if ($targetBatch < 0) { + $targetBatch = $batches[count($batches) - 1 + $targetBatch] ?? 0; + } + + if ($batches === [] && $targetBatch === 0) { + return true; + } + + if ($targetBatch !== 0 && ! in_array($targetBatch, $batches, true)) { + $message = lang('Migrations.batchNotFound') . $targetBatch; + + if ($this->silent) { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + + return false; + } + + throw new RuntimeException($message); + } + + $tmpNamespace = $this->namespace; + + $this->namespace = null; + $allMigrations = $this->findMigrations(); + + $migrations = []; + + while ($batch = array_pop($batches)) { + if ($batch <= $targetBatch) { + break; + } + + foreach ($this->getBatchHistory($batch, 'desc') as $history) { + $uid = $this->getObjectUid($history); + + if (! isset($allMigrations[$uid])) { + $message = lang('Migrations.gap') . ' ' . $history->version; + + if ($this->silent) { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + + return false; + } + + throw new RuntimeException($message); + } + + $migration = $allMigrations[$uid]; + $migration->history = $history; + $migrations[] = $migration; + } + } + + foreach ($migrations as $migration) { + if ($this->migrate('down', $migration)) { + $this->removeHistory($migration->history); + } else { + $message = lang('Migrations.generalFault'); + + if ($this->silent) { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + + return false; + } + + throw new RuntimeException($message); + } + } + + $data = get_object_vars($this); + $data['method'] = 'regress'; + Events::trigger('migrate', $data); + + $this->namespace = $tmpNamespace; + + return true; + } + + /** + * Migrate a single file regardless of order or batches. + * Method "up" or "down" determined by presence in history. + * NOTE: This is not recommended and provided mostly for testing. + * + * @param string $path Full path to a valid migration file + * @param string $path Namespace of the target migration + */ + public function force(string $path, string $namespace, ?string $group = null) + { + if (! $this->enabled) { + throw ConfigException::forDisabledMigrations(); + } + + $this->ensureTable(); + + if ($group !== null) { + $this->groupFilter = $group; + $this->setGroup($group); + } + + $migration = $this->migrationFromFile($path, $namespace); + if (empty($migration)) { + $message = lang('Migrations.notFound'); + + if ($this->silent) { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + + return false; + } + + throw new RuntimeException($message); + } + + $method = 'up'; + $this->setNamespace($migration->namespace); + + foreach ($this->getHistory($this->group) as $history) { + if ($this->getObjectUid($history) === $migration->uid) { + $method = 'down'; + $migration->history = $history; + break; + } + } + + if ($method === 'up') { + $batch = $this->getLastBatch() + 1; + + if ($this->migrate('up', $migration) && $this->groupSkip === false) { + $this->addHistory($migration, $batch); + + return true; + } + + $this->groupSkip = false; + } elseif ($this->migrate('down', $migration)) { + $this->removeHistory($migration->history); + + return true; + } + + $message = lang('Migrations.generalFault'); + + if ($this->silent) { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + + return false; + } + + throw new RuntimeException($message); + } + + /** + * Retrieves list of available migration scripts + * + * @return array List of all located migrations by their UID + */ + public function findMigrations(): array + { + $namespaces = $this->namespace ? [$this->namespace] : array_keys(Services::autoloader()->getNamespace()); + $migrations = []; + + foreach ($namespaces as $namespace) { + if (ENVIRONMENT !== 'testing' && $namespace === 'Tests\Support') { + continue; + } + + foreach ($this->findNamespaceMigrations($namespace) as $migration) { + $migrations[$migration->uid] = $migration; + } + } + + // Sort migrations ascending by their UID (version) + ksort($migrations); + + return $migrations; + } + + /** + * Retrieves a list of available migration scripts for one namespace + */ + public function findNamespaceMigrations(string $namespace): array + { + $migrations = []; + $locator = Services::locator(true); + + if (! empty($this->path)) { + helper('filesystem'); + $dir = rtrim($this->path, DIRECTORY_SEPARATOR) . '/'; + $files = get_filenames($dir, true, false, false); + } else { + $files = $locator->listNamespaceFiles($namespace, '/Database/Migrations/'); + } + + foreach ($files as $file) { + $file = empty($this->path) ? $file : $this->path . str_replace($this->path, '', $file); + + if ($migration = $this->migrationFromFile($file, $namespace)) { + $migrations[] = $migration; + } + } + + return $migrations; + } + + /** + * Create a migration object from a file path. + * + * @param string $path Full path to a valid migration file. + * + * @return false|object Returns the migration object, or false on failure + */ + protected function migrationFromFile(string $path, string $namespace) + { + if (substr($path, -4) !== '.php') { + return false; + } + + $filename = basename($path, '.php'); + + if (! preg_match($this->regex, $filename)) { + return false; + } + + $locator = Services::locator(true); + + $migration = new stdClass(); + + $migration->version = $this->getMigrationNumber($filename); + $migration->name = $this->getMigrationName($filename); + $migration->path = $path; + $migration->class = $locator->getClassname($path); + $migration->namespace = $namespace; + $migration->uid = $this->getObjectUid($migration); + + return $migration; + } + + /** + * Allows other scripts to modify on the fly as needed. + * + * @return MigrationRunner + */ + public function setNamespace(?string $namespace) + { + $this->namespace = $namespace; + + return $this; + } + + /** + * Allows other scripts to modify on the fly as needed. + * + * @return MigrationRunner + */ + public function setGroup(string $group) + { + $this->group = $group; + + return $this; + } + + /** + * @return MigrationRunner + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * If $silent == true, then will not throw exceptions and will + * attempt to continue gracefully. + * + * @return MigrationRunner + */ + public function setSilent(bool $silent) + { + $this->silent = $silent; + + return $this; + } + + /** + * Extracts the migration number from a filename + * + * @param string $migration A migration filename w/o path. + */ + protected function getMigrationNumber(string $migration): string + { + preg_match($this->regex, $migration, $matches); + + return count($matches) ? $matches[1] : '0'; + } + + /** + * Extracts the migration name from a filename + * + * Note: The migration name should be the classname, but maybe they are + * different. + * + * @param string $migration A migration filename w/o path. + */ + protected function getMigrationName(string $migration): string + { + preg_match($this->regex, $migration, $matches); + + return count($matches) ? $matches[2] : ''; + } + + /** + * Uses the non-repeatable portions of a migration or history + * to create a sortable unique key + * + * @param object $object migration or $history + */ + public function getObjectUid($object): string + { + return preg_replace('/[^0-9]/', '', $object->version) . $object->class; + } + + /** + * Retrieves messages formatted for CLI output + */ + public function getCliMessages(): array + { + return $this->cliMessages; + } + + /** + * Clears any CLI messages. + * + * @return MigrationRunner + */ + public function clearCliMessages() + { + $this->cliMessages = []; + + return $this; + } + + /** + * Truncates the history table. + */ + public function clearHistory() + { + if ($this->db->tableExists($this->table)) { + $this->db->table($this->table)->truncate(); + } + } + + /** + * Add a history to the table. + * + * @param object $migration + */ + protected function addHistory($migration, int $batch) + { + $this->db->table($this->table)->insert([ + 'version' => $migration->version, + 'class' => $migration->class, + 'group' => $this->group, + 'namespace' => $migration->namespace, + 'time' => Time::now()->getTimestamp(), + 'batch' => $batch, + ]); + + if (is_cli()) { + $this->cliMessages[] = sprintf( + "\t%s(%s) %s_%s", + CLI::color(lang('Migrations.added'), 'yellow'), + $migration->namespace, + $migration->version, + $migration->class + ); + } + } + + /** + * Removes a single history + * + * @param object $history + */ + protected function removeHistory($history) + { + $this->db->table($this->table)->where('id', $history->id)->delete(); + + if (is_cli()) { + $this->cliMessages[] = sprintf( + "\t%s(%s) %s_%s", + CLI::color(lang('Migrations.removed'), 'yellow'), + $history->namespace, + $history->version, + $history->class + ); + } + } + + /** + * Grabs the full migration history from the database for a group + */ + public function getHistory(string $group = 'default'): array + { + $this->ensureTable(); + + $builder = $this->db->table($this->table); + + // If group was specified then use it + if ($group !== '') { + $builder->where('group', $group); + } + + // If a namespace was specified then use it + if ($this->namespace) { + $builder->where('namespace', $this->namespace); + } + + $query = $builder->orderBy('id', 'ASC')->get(); + + return ! empty($query) ? $query->getResultObject() : []; + } + + /** + * Returns the migration history for a single batch. + * + * @param string $order + */ + public function getBatchHistory(int $batch, $order = 'asc'): array + { + $this->ensureTable(); + + $query = $this->db->table($this->table) + ->where('batch', $batch) + ->orderBy('id', $order) + ->get(); + + return ! empty($query) ? $query->getResultObject() : []; + } + + /** + * Returns all the batches from the database history in order + */ + public function getBatches(): array + { + $this->ensureTable(); + + $batches = $this->db->table($this->table) + ->select('batch') + ->distinct() + ->orderBy('batch', 'asc') + ->get() + ->getResultArray(); + + return array_map('intval', array_column($batches, 'batch')); + } + + /** + * Returns the value of the last batch in the database. + */ + public function getLastBatch(): int + { + $this->ensureTable(); + + $batch = $this->db->table($this->table) + ->selectMax('batch') + ->get() + ->getResultObject(); + + $batch = is_array($batch) && count($batch) + ? end($batch)->batch + : 0; + + return (int) $batch; + } + + /** + * Returns the version number of the first migration for a batch. + * Mostly just for tests. + */ + public function getBatchStart(int $batch): string + { + if ($batch < 0) { + $batches = $this->getBatches(); + $batch = $batches[count($batches) - 1] ?? 0; + } + + $migration = $this->db->table($this->table) + ->where('batch', $batch) + ->orderBy('id', 'asc') + ->limit(1) + ->get() + ->getResultObject(); + + return count($migration) ? $migration[0]->version : '0'; + } + + /** + * Returns the version number of the last migration for a batch. + * Mostly just for tests. + */ + public function getBatchEnd(int $batch): string + { + if ($batch < 0) { + $batches = $this->getBatches(); + $batch = $batches[count($batches) - 1] ?? 0; + } + + $migration = $this->db->table($this->table) + ->where('batch', $batch) + ->orderBy('id', 'desc') + ->limit(1) + ->get() + ->getResultObject(); + + return count($migration) ? $migration[0]->version : 0; + } + + /** + * Ensures that we have created our migrations table + * in the database. + */ + public function ensureTable() + { + if ($this->tableChecked || $this->db->tableExists($this->table)) { + return; + } + + $forge = Database::forge($this->db); + + $forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'version' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => false, + ], + 'class' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => false, + ], + 'group' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => false, + ], + 'namespace' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => false, + ], + 'time' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => false, + ], + 'batch' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => false, + ], + ]); + + $forge->addPrimaryKey('id'); + $forge->createTable($this->table, true); + + $this->tableChecked = true; + } + + /** + * Handles the actual running of a migration. + * + * @param string $direction "up" or "down" + * @param object $migration The migration to run + */ + protected function migrate($direction, $migration): bool + { + include_once $migration->path; + + $class = $migration->class; + $this->setName($migration->name); + + // Validate the migration file structure + if (! class_exists($class, false)) { + $message = sprintf(lang('Migrations.classNotFound'), $class); + + if ($this->silent) { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + + return false; + } + + throw new RuntimeException($message); + } + + /** @var Migration $instance */ + $instance = new $class(Database::forge($this->db)); + $group = $instance->getDBGroup() ?? $this->group; + + if (ENVIRONMENT !== 'testing' && $group === 'tests' && $this->groupFilter !== 'tests') { + // @codeCoverageIgnoreStart + $this->groupSkip = true; + + return true; + // @codeCoverageIgnoreEnd + } + + if ($direction === 'up' && $this->groupFilter !== null && $this->groupFilter !== $group) { + $this->groupSkip = true; + + return true; + } + + if (! is_callable([$instance, $direction])) { + $message = sprintf(lang('Migrations.missingMethod'), $direction); + + if ($this->silent) { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + + return false; + } + + throw new RuntimeException($message); + } + + $instance->{$direction}(); + + return true; + } +} diff --git a/system/Database/ModelFactory.php b/system/Database/ModelFactory.php new file mode 100644 index 0000000..76a30e6 --- /dev/null +++ b/system/Database/ModelFactory.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Config\Factories; + +/** + * Returns new or shared Model instances + * + * @deprecated Use CodeIgniter\Config\Factories::models() + * + * @codeCoverageIgnore + * @see \CodeIgniter\Database\ModelFactoryTest + */ +class ModelFactory +{ + /** + * Creates new Model instances or returns a shared instance + * + * @return mixed + */ + public static function get(string $name, bool $getShared = true, ?ConnectionInterface $connection = null) + { + return Factories::models($name, ['getShared' => $getShared], $connection); + } + + /** + * Helper method for injecting mock instances while testing. + * + * @param object $instance + */ + public static function injectMock(string $name, $instance) + { + Factories::injectMock('models', $name, $instance); + } + + /** + * Resets the static arrays + */ + public static function reset() + { + Factories::reset('models'); + } +} diff --git a/system/Database/MySQLi/Builder.php b/system/Database/MySQLi/Builder.php new file mode 100644 index 0000000..096bbed --- /dev/null +++ b/system/Database/MySQLi/Builder.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\MySQLi; + +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\RawSql; + +/** + * Builder for MySQLi + */ +class Builder extends BaseBuilder +{ + /** + * Identifier escape character + * + * @var string + */ + protected $escapeChar = '`'; + + /** + * Specifies which sql statements + * support the ignore option. + * + * @var array + */ + protected $supportedIgnoreStatements = [ + 'update' => 'IGNORE', + 'insert' => 'IGNORE', + 'delete' => 'IGNORE', + ]; + + /** + * FROM tables + * + * Groups tables in FROM clauses if needed, so there is no confusion + * about operator precedence. + * + * Note: This is only used (and overridden) by MySQL. + */ + protected function _fromTables(): string + { + if ($this->QBJoin !== [] && count($this->QBFrom) > 1) { + return '(' . implode(', ', $this->QBFrom) . ')'; + } + + return implode(', ', $this->QBFrom); + } + + /** + * Generates a platform-specific batch update string from the supplied data + */ + protected function _updateBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $constraints = $this->QBOptions['constraints'] ?? []; + + if ($constraints === []) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must specify a constraint to match on for batch updates.'); // @codeCoverageIgnore + } + + return ''; // @codeCoverageIgnore + } + + $updateFields = $this->QBOptions['updateFields'] ?? + $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ?? + []; + + $alias = $this->QBOptions['alias'] ?? '`_u`'; + + $sql = 'UPDATE ' . $this->compileIgnore('update') . $table . "\n"; + + $sql .= "INNER JOIN (\n{:_table_:}"; + + $sql .= ') ' . $alias . "\n"; + + $sql .= 'ON ' . implode( + ' AND ', + array_map( + static fn ($key, $value) => ( + ($value instanceof RawSql && is_string($key)) + ? + $table . '.' . $key . ' = ' . $value + : + ( + $value instanceof RawSql + ? + $value + : + $table . '.' . $value . ' = ' . $alias . '.' . $value + ) + ), + array_keys($constraints), + $constraints + ) + ) . "\n"; + + $sql .= "SET\n"; + + $sql .= implode( + ",\n", + array_map( + static fn ($key, $value) => $table . '.' . $key . ($value instanceof RawSql ? + ' = ' . $value : + ' = ' . $alias . '.' . $value), + array_keys($updateFields), + $updateFields + ) + ); + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = implode( + " UNION ALL\n", + array_map( + static fn ($value) => 'SELECT ' . implode(', ', array_map( + static fn ($key, $index) => $index . ' ' . $key, + $keys, + $value + )), + $values + ) + ) . "\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } +} diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php new file mode 100644 index 0000000..3139185 --- /dev/null +++ b/system/Database/MySQLi/Connection.php @@ -0,0 +1,628 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\MySQLi; + +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\Exceptions\DatabaseException; +use LogicException; +use mysqli; +use mysqli_result; +use mysqli_sql_exception; +use stdClass; +use Throwable; + +/** + * Connection for MySQLi + * + * @extends BaseConnection + */ +class Connection extends BaseConnection +{ + /** + * Database driver + * + * @var string + */ + public $DBDriver = 'MySQLi'; + + /** + * DELETE hack flag + * + * Whether to use the MySQL "delete hack" which allows the number + * of affected rows to be shown. Uses a preg_replace when enabled, + * adding a bit more processing to all queries. + * + * @var bool + */ + public $deleteHack = true; + + /** + * Identifier escape character + * + * @var string + */ + public $escapeChar = '`'; + + /** + * MySQLi object + * + * Has to be preserved without being assigned to $conn_id. + * + * @var false|mysqli + */ + public $mysqli; + + /** + * MySQLi constant + * + * For unbuffered queries use `MYSQLI_USE_RESULT`. + * + * Default mode for buffered queries uses `MYSQLI_STORE_RESULT`. + * + * @var int + */ + public $resultMode = MYSQLI_STORE_RESULT; + + /** + * Use MYSQLI_OPT_INT_AND_FLOAT_NATIVE + * + * @var bool + */ + public $numberNative = false; + + /** + * Connect to the database. + * + * @return false|mysqli + * + * @throws DatabaseException + */ + public function connect(bool $persistent = false) + { + // Do we have a socket path? + if ($this->hostname[0] === '/') { + $hostname = null; + $port = null; + $socket = $this->hostname; + } else { + $hostname = ($persistent === true) ? 'p:' . $this->hostname : $this->hostname; + $port = empty($this->port) ? null : $this->port; + $socket = ''; + } + + $clientFlags = ($this->compress === true) ? MYSQLI_CLIENT_COMPRESS : 0; + $this->mysqli = mysqli_init(); + + mysqli_report(MYSQLI_REPORT_ALL & ~MYSQLI_REPORT_INDEX); + + $this->mysqli->options(MYSQLI_OPT_CONNECT_TIMEOUT, 10); + + if ($this->numberNative === true) { + $this->mysqli->options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, 1); + } + + if (isset($this->strictOn)) { + if ($this->strictOn) { + $this->mysqli->options( + MYSQLI_INIT_COMMAND, + "SET SESSION sql_mode = CONCAT(@@sql_mode, ',', 'STRICT_ALL_TABLES')" + ); + } else { + $this->mysqli->options( + MYSQLI_INIT_COMMAND, + "SET SESSION sql_mode = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + @@sql_mode, + 'STRICT_ALL_TABLES,', ''), + ',STRICT_ALL_TABLES', ''), + 'STRICT_ALL_TABLES', ''), + 'STRICT_TRANS_TABLES,', ''), + ',STRICT_TRANS_TABLES', ''), + 'STRICT_TRANS_TABLES', '')" + ); + } + } + + if (is_array($this->encrypt)) { + $ssl = []; + + if (! empty($this->encrypt['ssl_key'])) { + $ssl['key'] = $this->encrypt['ssl_key']; + } + if (! empty($this->encrypt['ssl_cert'])) { + $ssl['cert'] = $this->encrypt['ssl_cert']; + } + if (! empty($this->encrypt['ssl_ca'])) { + $ssl['ca'] = $this->encrypt['ssl_ca']; + } + if (! empty($this->encrypt['ssl_capath'])) { + $ssl['capath'] = $this->encrypt['ssl_capath']; + } + if (! empty($this->encrypt['ssl_cipher'])) { + $ssl['cipher'] = $this->encrypt['ssl_cipher']; + } + + if ($ssl !== []) { + if (isset($this->encrypt['ssl_verify'])) { + if ($this->encrypt['ssl_verify']) { + if (defined('MYSQLI_OPT_SSL_VERIFY_SERVER_CERT')) { + $this->mysqli->options(MYSQLI_OPT_SSL_VERIFY_SERVER_CERT, 1); + } + } + // Apparently (when it exists), setting MYSQLI_OPT_SSL_VERIFY_SERVER_CERT + // to FALSE didn't do anything, so PHP 5.6.16 introduced yet another + // constant ... + // + // https://secure.php.net/ChangeLog-5.php#5.6.16 + // https://bugs.php.net/bug.php?id=68344 + elseif (defined('MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT') && version_compare($this->mysqli->client_info, 'mysqlnd 5.6', '>=')) { + $clientFlags += MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT; + } + } + + $this->mysqli->ssl_set( + $ssl['key'] ?? null, + $ssl['cert'] ?? null, + $ssl['ca'] ?? null, + $ssl['capath'] ?? null, + $ssl['cipher'] ?? null + ); + } + + $clientFlags += MYSQLI_CLIENT_SSL; + } + + try { + if ($this->mysqli->real_connect( + $hostname, + $this->username, + $this->password, + $this->database, + $port, + $socket, + $clientFlags + )) { + // Prior to version 5.7.3, MySQL silently downgrades to an unencrypted connection if SSL setup fails + if (($clientFlags & MYSQLI_CLIENT_SSL) && version_compare($this->mysqli->client_info, 'mysqlnd 5.7.3', '<=') + && empty($this->mysqli->query("SHOW STATUS LIKE 'ssl_cipher'")->fetch_object()->Value) + ) { + $this->mysqli->close(); + $message = 'MySQLi was configured for an SSL connection, but got an unencrypted connection instead!'; + log_message('error', $message); + + if ($this->DBDebug) { + throw new DatabaseException($message); + } + + return false; + } + + if (! $this->mysqli->set_charset($this->charset)) { + log_message('error', "Database: Unable to set the configured connection charset ('{$this->charset}')."); + + $this->mysqli->close(); + + if ($this->DBDebug) { + throw new DatabaseException('Unable to set client connection character set: ' . $this->charset); + } + + return false; + } + + return $this->mysqli; + } + } catch (Throwable $e) { + // Clean sensitive information from errors. + $msg = $e->getMessage(); + + $msg = str_replace($this->username, '****', $msg); + $msg = str_replace($this->password, '****', $msg); + + throw new DatabaseException($msg, $e->getCode(), $e); + } + + return false; + } + + /** + * Keep or establish the connection if no queries have been sent for + * a length of time exceeding the server's idle timeout. + */ + public function reconnect() + { + $this->close(); + $this->initialize(); + } + + /** + * Close the database connection. + */ + protected function _close() + { + $this->connID->close(); + } + + /** + * Select a specific database table to use. + */ + public function setDatabase(string $databaseName): bool + { + if ($databaseName === '') { + $databaseName = $this->database; + } + + if (empty($this->connID)) { + $this->initialize(); + } + + if ($this->connID->select_db($databaseName)) { + $this->database = $databaseName; + + return true; + } + + return false; + } + + /** + * Returns a string containing the version of the database being used. + */ + public function getVersion(): string + { + if (isset($this->dataCache['version'])) { + return $this->dataCache['version']; + } + + if (empty($this->mysqli)) { + $this->initialize(); + } + + return $this->dataCache['version'] = $this->mysqli->server_info; + } + + /** + * Executes the query against the database. + * + * @return false|mysqli_result; + */ + protected function execute(string $sql) + { + while ($this->connID->more_results()) { + $this->connID->next_result(); + if ($res = $this->connID->store_result()) { + $res->free(); + } + } + + try { + return $this->connID->query($this->prepQuery($sql), $this->resultMode); + } catch (mysqli_sql_exception $e) { + log_message('error', (string) $e); + + if ($this->DBDebug) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + } + + return false; + } + + /** + * Prep the query. If needed, each database adapter can prep the query string + */ + protected function prepQuery(string $sql): string + { + // mysqli_affected_rows() returns 0 for "DELETE FROM TABLE" queries. This hack + // modifies the query so that it a proper number of affected rows is returned. + if ($this->deleteHack === true && preg_match('/^\s*DELETE\s+FROM\s+(\S+)\s*$/i', $sql)) { + return trim($sql) . ' WHERE 1=1'; + } + + return $sql; + } + + /** + * Returns the total number of rows affected by this query. + */ + public function affectedRows(): int + { + return $this->connID->affected_rows ?? 0; + } + + /** + * Platform-dependant string escape + */ + protected function _escapeString(string $str): string + { + if (! $this->connID) { + $this->initialize(); + } + + return $this->connID->real_escape_string($str); + } + + /** + * Escape Like String Direct + * There are a few instances where MySQLi queries cannot take the + * additional "ESCAPE x" parameter for specifying the escape character + * in "LIKE" strings, and this handles those directly with a backslash. + * + * @param string|string[] $str Input string + * + * @return string|string[] + */ + public function escapeLikeStringDirect($str) + { + if (is_array($str)) { + foreach ($str as $key => $val) { + $str[$key] = $this->escapeLikeStringDirect($val); + } + + return $str; + } + + $str = $this->_escapeString($str); + + // Escape LIKE condition wildcards + return str_replace( + [$this->likeEscapeChar, '%', '_'], + ['\\' . $this->likeEscapeChar, '\\%', '\\_'], + $str + ); + } + + /** + * Generates the SQL for listing tables in a platform-dependent manner. + * Uses escapeLikeStringDirect(). + * + * @param string|null $tableName If $tableName is provided will return only this table if exists. + */ + protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string + { + $sql = 'SHOW TABLES FROM ' . $this->escapeIdentifiers($this->database); + + if ($tableName !== null) { + return $sql . ' LIKE ' . $this->escape($tableName); + } + + if ($prefixLimit !== false && $this->DBPrefix !== '') { + return $sql . " LIKE '" . $this->escapeLikeStringDirect($this->DBPrefix) . "%'"; + } + + return $sql; + } + + /** + * Generates a platform-specific query string so that the column names can be fetched. + */ + protected function _listColumns(string $table = ''): string + { + return 'SHOW COLUMNS FROM ' . $this->protectIdentifiers($table, true, null, false); + } + + /** + * Returns an array of objects with field data + * + * @return stdClass[] + * + * @throws DatabaseException + */ + protected function _fieldData(string $table): array + { + $table = $this->protectIdentifiers($table, true, null, false); + + if (($query = $this->query('SHOW COLUMNS FROM ' . $table)) === false) { + throw new DatabaseException(lang('Database.failGetFieldData')); + } + $query = $query->getResultObject(); + + $retVal = []; + + for ($i = 0, $c = count($query); $i < $c; $i++) { + $retVal[$i] = new stdClass(); + $retVal[$i]->name = $query[$i]->Field; + + sscanf($query[$i]->Type, '%[a-z](%d)', $retVal[$i]->type, $retVal[$i]->max_length); + + $retVal[$i]->nullable = $query[$i]->Null === 'YES'; + $retVal[$i]->default = $query[$i]->Default; + $retVal[$i]->primary_key = (int) ($query[$i]->Key === 'PRI'); + } + + return $retVal; + } + + /** + * Returns an array of objects with index data + * + * @return stdClass[] + * + * @throws DatabaseException + * @throws LogicException + */ + protected function _indexData(string $table): array + { + $table = $this->protectIdentifiers($table, true, null, false); + + if (($query = $this->query('SHOW INDEX FROM ' . $table)) === false) { + throw new DatabaseException(lang('Database.failGetIndexData')); + } + + if (! $indexes = $query->getResultArray()) { + return []; + } + + $keys = []; + + foreach ($indexes as $index) { + if (empty($keys[$index['Key_name']])) { + $keys[$index['Key_name']] = new stdClass(); + $keys[$index['Key_name']]->name = $index['Key_name']; + + if ($index['Key_name'] === 'PRIMARY') { + $type = 'PRIMARY'; + } elseif ($index['Index_type'] === 'FULLTEXT') { + $type = 'FULLTEXT'; + } elseif ($index['Non_unique']) { + $type = $index['Index_type'] === 'SPATIAL' ? 'SPATIAL' : 'INDEX'; + } else { + $type = 'UNIQUE'; + } + + $keys[$index['Key_name']]->type = $type; + } + + $keys[$index['Key_name']]->fields[] = $index['Column_name']; + } + + return $keys; + } + + /** + * Returns an array of objects with Foreign key data + * + * @return stdClass[] + * + * @throws DatabaseException + */ + protected function _foreignKeyData(string $table): array + { + $sql = ' + SELECT + tc.CONSTRAINT_NAME, + tc.TABLE_NAME, + kcu.COLUMN_NAME, + rc.REFERENCED_TABLE_NAME, + kcu.REFERENCED_COLUMN_NAME, + rc.DELETE_RULE, + rc.UPDATE_RULE, + rc.MATCH_OPTION + FROM information_schema.table_constraints AS tc + INNER JOIN information_schema.referential_constraints AS rc + ON tc.constraint_name = rc.constraint_name + AND tc.constraint_schema = rc.constraint_schema + INNER JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.constraint_schema = kcu.constraint_schema + WHERE + tc.constraint_type = ' . $this->escape('FOREIGN KEY') . ' AND + tc.table_schema = ' . $this->escape($this->database) . ' AND + tc.table_name = ' . $this->escape($table); + + if (($query = $this->query($sql)) === false) { + throw new DatabaseException(lang('Database.failGetForeignKeyData')); + } + + $query = $query->getResultObject(); + $indexes = []; + + foreach ($query as $row) { + $indexes[$row->CONSTRAINT_NAME]['constraint_name'] = $row->CONSTRAINT_NAME; + $indexes[$row->CONSTRAINT_NAME]['table_name'] = $row->TABLE_NAME; + $indexes[$row->CONSTRAINT_NAME]['column_name'][] = $row->COLUMN_NAME; + $indexes[$row->CONSTRAINT_NAME]['foreign_table_name'] = $row->REFERENCED_TABLE_NAME; + $indexes[$row->CONSTRAINT_NAME]['foreign_column_name'][] = $row->REFERENCED_COLUMN_NAME; + $indexes[$row->CONSTRAINT_NAME]['on_delete'] = $row->DELETE_RULE; + $indexes[$row->CONSTRAINT_NAME]['on_update'] = $row->UPDATE_RULE; + $indexes[$row->CONSTRAINT_NAME]['match'] = $row->MATCH_OPTION; + } + + return $this->foreignKeyDataToObjects($indexes); + } + + /** + * Returns platform-specific SQL to disable foreign key checks. + * + * @return string + */ + protected function _disableForeignKeyChecks() + { + return 'SET FOREIGN_KEY_CHECKS=0'; + } + + /** + * Returns platform-specific SQL to enable foreign key checks. + * + * @return string + */ + protected function _enableForeignKeyChecks() + { + return 'SET FOREIGN_KEY_CHECKS=1'; + } + + /** + * Returns the last error code and message. + * Must return this format: ['code' => string|int, 'message' => string] + * intval(code) === 0 means "no error". + * + * @return array + */ + public function error(): array + { + if (! empty($this->mysqli->connect_errno)) { + return [ + 'code' => $this->mysqli->connect_errno, + 'message' => $this->mysqli->connect_error, + ]; + } + + return [ + 'code' => $this->connID->errno, + 'message' => $this->connID->error, + ]; + } + + /** + * Insert ID + */ + public function insertID(): int + { + return $this->connID->insert_id; + } + + /** + * Begin Transaction + */ + protected function _transBegin(): bool + { + $this->connID->autocommit(false); + + return $this->connID->begin_transaction(); + } + + /** + * Commit Transaction + */ + protected function _transCommit(): bool + { + if ($this->connID->commit()) { + $this->connID->autocommit(true); + + return true; + } + + return false; + } + + /** + * Rollback Transaction + */ + protected function _transRollback(): bool + { + if ($this->connID->rollback()) { + $this->connID->autocommit(true); + + return true; + } + + return false; + } +} diff --git a/system/Database/MySQLi/Forge.php b/system/Database/MySQLi/Forge.php new file mode 100644 index 0000000..b1beba2 --- /dev/null +++ b/system/Database/MySQLi/Forge.php @@ -0,0 +1,264 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\MySQLi; + +use CodeIgniter\Database\Forge as BaseForge; + +/** + * Forge for MySQLi + */ +class Forge extends BaseForge +{ + /** + * CREATE DATABASE statement + * + * @var string + */ + protected $createDatabaseStr = 'CREATE DATABASE %s CHARACTER SET %s COLLATE %s'; + + /** + * CREATE DATABASE IF statement + * + * @var string + */ + protected $createDatabaseIfStr = 'CREATE DATABASE IF NOT EXISTS %s CHARACTER SET %s COLLATE %s'; + + /** + * DROP CONSTRAINT statement + * + * @var string + */ + protected $dropConstraintStr = 'ALTER TABLE %s DROP FOREIGN KEY %s'; + + /** + * CREATE TABLE keys flag + * + * Whether table keys are created from within the + * CREATE TABLE statement. + * + * @var bool + */ + protected $createTableKeys = true; + + /** + * UNSIGNED support + * + * @var array + */ + protected $_unsigned = [ + 'TINYINT', + 'SMALLINT', + 'MEDIUMINT', + 'INT', + 'INTEGER', + 'BIGINT', + 'REAL', + 'DOUBLE', + 'DOUBLE PRECISION', + 'FLOAT', + 'DECIMAL', + 'NUMERIC', + ]; + + /** + * Table Options list which required to be quoted + * + * @var array + */ + protected $_quoted_table_options = [ + 'COMMENT', + 'COMPRESSION', + 'CONNECTION', + 'DATA DIRECTORY', + 'INDEX DIRECTORY', + 'ENCRYPTION', + 'PASSWORD', + ]; + + /** + * NULL value representation in CREATE/ALTER TABLE statements + * + * @var string + * + * @internal + */ + protected $null = 'NULL'; + + /** + * CREATE TABLE attributes + * + * @param array $attributes Associative array of table attributes + */ + protected function _createTableAttributes(array $attributes): string + { + $sql = ''; + + foreach (array_keys($attributes) as $key) { + if (is_string($key)) { + $sql .= ' ' . strtoupper($key) . ' = '; + + if (in_array(strtoupper($key), $this->_quoted_table_options, true)) { + $sql .= $this->db->escape($attributes[$key]); + } else { + $sql .= $this->db->escapeString($attributes[$key]); + } + } + } + + if (! empty($this->db->charset) && ! strpos($sql, 'CHARACTER SET') && ! strpos($sql, 'CHARSET')) { + $sql .= ' DEFAULT CHARACTER SET = ' . $this->db->escapeString($this->db->charset); + } + + if (! empty($this->db->DBCollat) && ! strpos($sql, 'COLLATE')) { + $sql .= ' COLLATE = ' . $this->db->escapeString($this->db->DBCollat); + } + + return $sql; + } + + /** + * ALTER TABLE + * + * @param string $alterType ALTER type + * @param string $table Table name + * @param array|string $processedFields Processed column definitions + * or column names to DROP + * + * @return list|string SQL string + * @phpstan-return ($alterType is 'DROP' ? string : list) + */ + protected function _alterTable(string $alterType, string $table, $processedFields) + { + if ($alterType === 'DROP') { + return parent::_alterTable($alterType, $table, $processedFields); + } + + $sql = 'ALTER TABLE ' . $this->db->escapeIdentifiers($table); + + foreach ($processedFields as $i => $field) { + if ($field['_literal'] !== false) { + $processedFields[$i] = ($alterType === 'ADD') ? "\n\tADD " . $field['_literal'] : "\n\tMODIFY " . $field['_literal']; + } else { + if ($alterType === 'ADD') { + $processedFields[$i]['_literal'] = "\n\tADD "; + } else { + $processedFields[$i]['_literal'] = empty($field['new_name']) ? "\n\tMODIFY " : "\n\tCHANGE "; + } + + $processedFields[$i] = $processedFields[$i]['_literal'] . $this->_processColumn($processedFields[$i]); + } + } + + return [$sql . implode(',', $processedFields)]; + } + + /** + * Process column + */ + protected function _processColumn(array $processedField): string + { + $extraClause = isset($processedField['after']) ? ' AFTER ' . $this->db->escapeIdentifiers($processedField['after']) : ''; + + if (empty($extraClause) && isset($processedField['first']) && $processedField['first'] === true) { + $extraClause = ' FIRST'; + } + + return $this->db->escapeIdentifiers($processedField['name']) + . (empty($processedField['new_name']) ? '' : ' ' . $this->db->escapeIdentifiers($processedField['new_name'])) + . ' ' . $processedField['type'] . $processedField['length'] + . $processedField['unsigned'] + . $processedField['null'] + . $processedField['default'] + . $processedField['auto_increment'] + . $processedField['unique'] + . (empty($processedField['comment']) ? '' : ' COMMENT ' . $processedField['comment']) + . $extraClause; + } + + /** + * Generates SQL to add indexes + * + * @param bool $asQuery When true returns stand alone SQL, else partial SQL used with CREATE TABLE + */ + protected function _processIndexes(string $table, bool $asQuery = false): array + { + $sqls = ['']; + $index = 0; + + for ($i = 0, $c = count($this->keys); $i < $c; $i++) { + $index = $i; + if ($asQuery === false) { + $index = 0; + } + + if (isset($this->keys[$i]['fields'])) { + for ($i2 = 0, $c2 = count($this->keys[$i]['fields']); $i2 < $c2; $i2++) { + if (! isset($this->fields[$this->keys[$i]['fields'][$i2]])) { + unset($this->keys[$i]['fields'][$i2]); + + continue; + } + } + } + + if (! is_array($this->keys[$i]['fields'])) { + $this->keys[$i]['fields'] = [$this->keys[$i]['fields']]; + } + + $unique = in_array($i, $this->uniqueKeys, true) ? 'UNIQUE ' : ''; + + $keyName = $this->db->escapeIdentifiers(($this->keys[$i]['keyName'] === '') ? + implode('_', $this->keys[$i]['fields']) : + $this->keys[$i]['keyName']); + + if ($asQuery === true) { + $sqls[$index] = 'ALTER TABLE ' . $this->db->escapeIdentifiers($table) . " ADD {$unique}KEY " + . $keyName + . ' (' . implode(', ', $this->db->escapeIdentifiers($this->keys[$i]['fields'])) . ')'; + } else { + $sqls[$index] .= ",\n\t{$unique}KEY " . $keyName + . ' (' . implode(', ', $this->db->escapeIdentifiers($this->keys[$i]['fields'])) . ')'; + } + } + + $this->keys = []; + + return $sqls; + } + + /** + * Drop Key + */ + public function dropKey(string $table, string $keyName, bool $prefixKeyName = true): bool + { + $sql = sprintf( + $this->dropIndexStr, + $this->db->escapeIdentifiers($keyName), + $this->db->escapeIdentifiers($this->db->DBPrefix . $table), + ); + + return $this->db->query($sql); + } + + /** + * Drop Primary Key + */ + public function dropPrimaryKey(string $table, string $keyName = ''): bool + { + $sql = sprintf( + 'ALTER TABLE %s DROP PRIMARY KEY', + $this->db->escapeIdentifiers($this->db->DBPrefix . $table) + ); + + return $this->db->query($sql); + } +} diff --git a/system/Database/MySQLi/PreparedQuery.php b/system/Database/MySQLi/PreparedQuery.php new file mode 100644 index 0000000..594fcba --- /dev/null +++ b/system/Database/MySQLi/PreparedQuery.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\MySQLi; + +use BadMethodCallException; +use CodeIgniter\Database\BasePreparedQuery; +use CodeIgniter\Database\Exceptions\DatabaseException; +use mysqli; +use mysqli_result; +use mysqli_sql_exception; +use mysqli_stmt; + +/** + * Prepared query for MySQLi + * + * @extends BasePreparedQuery + */ +class PreparedQuery extends BasePreparedQuery +{ + /** + * Prepares the query against the database, and saves the connection + * info necessary to execute the query later. + * + * NOTE: This version is based on SQL code. Child classes should + * override this method. + * + * @param array $options Passed to the connection's prepare statement. + * Unused in the MySQLi driver. + */ + public function _prepare(string $sql, array $options = []): PreparedQuery + { + // Mysqli driver doesn't like statements + // with terminating semicolons. + $sql = rtrim($sql, ';'); + + if (! $this->statement = $this->db->mysqli->prepare($sql)) { + $this->errorCode = $this->db->mysqli->errno; + $this->errorString = $this->db->mysqli->error; + + if ($this->db->DBDebug) { + throw new DatabaseException($this->errorString . ' code: ' . $this->errorCode); + } + } + + return $this; + } + + /** + * Takes a new set of data and runs it against the currently + * prepared query. Upon success, will return a Results object. + */ + public function _execute(array $data): bool + { + if (! isset($this->statement)) { + throw new BadMethodCallException('You must call prepare before trying to execute a prepared statement.'); + } + + // First off -bind the parameters + $bindTypes = ''; + + // Determine the type string + foreach ($data as $item) { + if (is_int($item)) { + $bindTypes .= 'i'; + } elseif (is_numeric($item)) { + $bindTypes .= 'd'; + } else { + $bindTypes .= 's'; + } + } + + // Bind it + $this->statement->bind_param($bindTypes, ...$data); + + try { + return $this->statement->execute(); + } catch (mysqli_sql_exception $e) { + if ($this->db->DBDebug) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + + return false; + } + } + + /** + * Returns the result object for the prepared query or false on failure. + * + * @return false|mysqli_result + */ + public function _getResult() + { + return $this->statement->get_result(); + } + + /** + * Deallocate prepared statements. + */ + protected function _close(): bool + { + return $this->statement->close(); + } +} diff --git a/system/Database/MySQLi/Result.php b/system/Database/MySQLi/Result.php new file mode 100644 index 0000000..578169d --- /dev/null +++ b/system/Database/MySQLi/Result.php @@ -0,0 +1,168 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\MySQLi; + +use CodeIgniter\Database\BaseResult; +use CodeIgniter\Entity\Entity; +use mysqli; +use mysqli_result; +use stdClass; + +/** + * Result for MySQLi + * + * @extends BaseResult + */ +class Result extends BaseResult +{ + /** + * Gets the number of fields in the result set. + */ + public function getFieldCount(): int + { + return $this->resultID->field_count; + } + + /** + * Generates an array of column names in the result set. + */ + public function getFieldNames(): array + { + $fieldNames = []; + $this->resultID->field_seek(0); + + while ($field = $this->resultID->fetch_field()) { + $fieldNames[] = $field->name; + } + + return $fieldNames; + } + + /** + * Generates an array of objects representing field meta-data. + */ + public function getFieldData(): array + { + static $dataTypes = [ + MYSQLI_TYPE_DECIMAL => 'decimal', + MYSQLI_TYPE_NEWDECIMAL => 'newdecimal', + MYSQLI_TYPE_FLOAT => 'float', + MYSQLI_TYPE_DOUBLE => 'double', + + MYSQLI_TYPE_BIT => 'bit', + MYSQLI_TYPE_SHORT => 'short', + MYSQLI_TYPE_LONG => 'long', + MYSQLI_TYPE_LONGLONG => 'longlong', + MYSQLI_TYPE_INT24 => 'int24', + + MYSQLI_TYPE_YEAR => 'year', + + MYSQLI_TYPE_TIMESTAMP => 'timestamp', + MYSQLI_TYPE_DATE => 'date', + MYSQLI_TYPE_TIME => 'time', + MYSQLI_TYPE_DATETIME => 'datetime', + MYSQLI_TYPE_NEWDATE => 'newdate', + + MYSQLI_TYPE_SET => 'set', + + MYSQLI_TYPE_VAR_STRING => 'var_string', + MYSQLI_TYPE_STRING => 'string', + + MYSQLI_TYPE_GEOMETRY => 'geometry', + MYSQLI_TYPE_TINY_BLOB => 'tiny_blob', + MYSQLI_TYPE_MEDIUM_BLOB => 'medium_blob', + MYSQLI_TYPE_LONG_BLOB => 'long_blob', + MYSQLI_TYPE_BLOB => 'blob', + ]; + + $retVal = []; + $fieldData = $this->resultID->fetch_fields(); + + foreach ($fieldData as $i => $data) { + $retVal[$i] = new stdClass(); + $retVal[$i]->name = $data->name; + $retVal[$i]->type = $data->type; + $retVal[$i]->type_name = in_array($data->type, [1, 247], true) ? 'char' : ($dataTypes[$data->type] ?? null); + $retVal[$i]->max_length = $data->max_length; + $retVal[$i]->primary_key = $data->flags & 2; + $retVal[$i]->length = $data->length; + $retVal[$i]->default = $data->def; + } + + return $retVal; + } + + /** + * Frees the current result. + * + * @return void + */ + public function freeResult() + { + if (is_object($this->resultID)) { + $this->resultID->free(); + $this->resultID = false; + } + } + + /** + * Moves the internal pointer to the desired offset. This is called + * internally before fetching results to make sure the result set + * starts at zero. + * + * @return bool + */ + public function dataSeek(int $n = 0) + { + return $this->resultID->data_seek($n); + } + + /** + * Returns the result set as an array. + * + * Overridden by driver classes. + * + * @return array|false|null + */ + protected function fetchAssoc() + { + return $this->resultID->fetch_assoc(); + } + + /** + * Returns the result set as an object. + * + * Overridden by child classes. + * + * @return Entity|false|object|stdClass + */ + protected function fetchObject(string $className = 'stdClass') + { + if (is_subclass_of($className, Entity::class)) { + return empty($data = $this->fetchAssoc()) ? false : (new $className())->injectRawData($data); + } + + return $this->resultID->fetch_object($className); + } + + /** + * Returns the number of rows in the resultID (i.e., mysqli_result object) + */ + public function getNumRows(): int + { + if (! is_int($this->numRows)) { + $this->numRows = $this->resultID->num_rows; + } + + return $this->numRows; + } +} diff --git a/system/Database/MySQLi/Utils.php b/system/Database/MySQLi/Utils.php new file mode 100644 index 0000000..08989f7 --- /dev/null +++ b/system/Database/MySQLi/Utils.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\MySQLi; + +use CodeIgniter\Database\BaseUtils; +use CodeIgniter\Database\Exceptions\DatabaseException; + +/** + * Utils for MySQLi + */ +class Utils extends BaseUtils +{ + /** + * List databases statement + * + * @var string + */ + protected $listDatabases = 'SHOW DATABASES'; + + /** + * OPTIMIZE TABLE statement + * + * @var string + */ + protected $optimizeTable = 'OPTIMIZE TABLE %s'; + + /** + * Platform dependent version of the backup function. + * + * @return never + */ + public function _backup(?array $prefs = null) + { + throw new DatabaseException('Unsupported feature of the database platform you are using.'); + } +} diff --git a/system/Database/OCI8/Builder.php b/system/Database/OCI8/Builder.php new file mode 100644 index 0000000..f5527ce --- /dev/null +++ b/system/Database/OCI8/Builder.php @@ -0,0 +1,524 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\OCI8; + +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\RawSql; + +/** + * Builder for OCI8 + */ +class Builder extends BaseBuilder +{ + /** + * Identifier escape character + * + * @var string + */ + protected $escapeChar = '"'; + + /** + * ORDER BY random keyword + * + * @var array + */ + protected $randomKeyword = [ + '"DBMS_RANDOM"."RANDOM"', + ]; + + /** + * COUNT string + * + * @used-by CI_DB_driver::count_all() + * @used-by BaseBuilder::count_all_results() + * + * @var string + */ + protected $countString = 'SELECT COUNT(1) '; + + /** + * Limit used flag + * + * If we use LIMIT, we'll add a field that will + * throw off num_fields later. + * + * @var bool + */ + protected $limitUsed = false; + + /** + * A reference to the database connection. + * + * @var Connection + */ + protected $db; + + /** + * Generates a platform-specific insert string from the supplied data. + */ + protected function _insertBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $insertKeys = implode(', ', $keys); + $hasPrimaryKey = in_array('PRIMARY', array_column($this->db->getIndexData($table), 'type'), true); + + // ORA-00001 measures + $sql = 'INSERT' . ($hasPrimaryKey ? '' : ' ALL') . ' INTO ' . $table . ' (' . $insertKeys . ")\n{:_table_:}"; + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = implode( + " FROM DUAL UNION ALL\n", + array_map( + static fn ($value) => 'SELECT ' . implode(', ', array_map( + static fn ($key, $index) => $index . ' ' . $key, + $keys, + $value + )), + $values + ) + ) . " FROM DUAL\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Generates a platform-specific replace string from the supplied data + */ + protected function _replace(string $table, array $keys, array $values): string + { + $fieldNames = array_map(static fn ($columnName) => trim($columnName, '"'), $keys); + + $uniqueIndexes = array_filter($this->db->getIndexData($table), static function ($index) use ($fieldNames) { + $hasAllFields = count(array_intersect($index->fields, $fieldNames)) === count($index->fields); + + return ($index->type === 'PRIMARY') && $hasAllFields; + }); + $replaceableFields = array_filter($keys, static function ($columnName) use ($uniqueIndexes) { + foreach ($uniqueIndexes as $index) { + if (in_array(trim($columnName, '"'), $index->fields, true)) { + return false; + } + } + + return true; + }); + + $sql = 'MERGE INTO ' . $table . "\n USING (SELECT "; + + $sql .= implode(', ', array_map(static fn ($columnName, $value) => $value . ' ' . $columnName, $keys, $values)); + + $sql .= ' FROM DUAL) "_replace" ON ( '; + + $onList = []; + $onList[] = '1 != 1'; + + foreach ($uniqueIndexes as $index) { + $onList[] = '(' . implode(' AND ', array_map(static fn ($columnName) => $table . '."' . $columnName . '" = "_replace"."' . $columnName . '"', $index->fields)) . ')'; + } + + $sql .= implode(' OR ', $onList) . ') WHEN MATCHED THEN UPDATE SET '; + + $sql .= implode(', ', array_map(static fn ($columnName) => $columnName . ' = "_replace".' . $columnName, $replaceableFields)); + + $sql .= ' WHEN NOT MATCHED THEN INSERT (' . implode(', ', $replaceableFields) . ') VALUES '; + + return $sql . (' (' . implode(', ', array_map(static fn ($columnName) => '"_replace".' . $columnName, $replaceableFields)) . ')'); + } + + /** + * Generates a platform-specific truncate string from the supplied data + * + * If the database does not support the truncate() command, + * then this method maps to 'DELETE FROM table' + */ + protected function _truncate(string $table): string + { + return 'TRUNCATE TABLE ' . $table; + } + + /** + * Compiles a delete string and runs the query + * + * @param mixed $where + * + * @return mixed + * + * @throws DatabaseException + */ + public function delete($where = '', ?int $limit = null, bool $resetData = true) + { + if ($limit !== null && $limit !== 0) { + $this->QBLimit = $limit; + } + + return parent::delete($where, null, $resetData); + } + + /** + * Generates a platform-specific delete string from the supplied data + */ + protected function _delete(string $table): string + { + if ($this->QBLimit) { + $this->where('rownum <= ', $this->QBLimit, false); + $this->QBLimit = false; + } + + return parent::_delete($table); + } + + /** + * Generates a platform-specific update string from the supplied data + */ + protected function _update(string $table, array $values): string + { + $valStr = []; + + foreach ($values as $key => $val) { + $valStr[] = $key . ' = ' . $val; + } + + if ($this->QBLimit) { + $this->where('rownum <= ', $this->QBLimit, false); + } + + return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr) + . $this->compileWhereHaving('QBWhere') + . $this->compileOrderBy(); + } + + /** + * Generates a platform-specific LIMIT clause. + */ + protected function _limit(string $sql, bool $offsetIgnore = false): string + { + $offset = (int) ($offsetIgnore === false ? $this->QBOffset : 0); + if (version_compare($this->db->getVersion(), '12.1', '>=')) { + // OFFSET-FETCH can be used only with the ORDER BY clause + if (empty($this->QBOrderBy)) { + $sql .= ' ORDER BY 1'; + } + + return $sql . ' OFFSET ' . $offset . ' ROWS FETCH NEXT ' . $this->QBLimit . ' ROWS ONLY'; + } + + $this->limitUsed = true; + $limitTemplateQuery = 'SELECT * FROM (SELECT INNER_QUERY.*, ROWNUM RNUM FROM (%s) INNER_QUERY WHERE ROWNUM < %d)' . ($offset ? ' WHERE RNUM >= %d' : ''); + + return sprintf($limitTemplateQuery, $sql, $offset + $this->QBLimit + 1, $offset); + } + + /** + * Resets the query builder values. Called by the get() function + */ + protected function resetSelect() + { + $this->limitUsed = false; + parent::resetSelect(); + } + + /** + * Generates a platform-specific batch update string from the supplied data + */ + protected function _updateBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $constraints = $this->QBOptions['constraints'] ?? []; + + if ($constraints === []) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must specify a constraint to match on for batch updates.'); + } + + return ''; // @codeCoverageIgnore + } + + $updateFields = $this->QBOptions['updateFields'] ?? + $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ?? + []; + + $alias = $this->QBOptions['alias'] ?? '"_u"'; + + // Oracle doesn't support ignore on updates so we will use MERGE + $sql = 'MERGE INTO ' . $table . "\n"; + + $sql .= "USING (\n{:_table_:}"; + + $sql .= ') ' . $alias . "\n"; + + $sql .= 'ON (' . implode( + ' AND ', + array_map( + static fn ($key, $value) => ( + ($value instanceof RawSql && is_string($key)) + ? + $table . '.' . $key . ' = ' . $value + : + ( + $value instanceof RawSql + ? + $value + : + $table . '.' . $value . ' = ' . $alias . '.' . $value + ) + ), + array_keys($constraints), + $constraints + ) + ) . ")\n"; + + $sql .= "WHEN MATCHED THEN UPDATE\n"; + + $sql .= "SET\n"; + + $sql .= implode( + ",\n", + array_map( + static fn ($key, $value) => $table . '.' . $key . ($value instanceof RawSql ? + ' = ' . $value : + ' = ' . $alias . '.' . $value), + array_keys($updateFields), + $updateFields + ) + ); + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = implode( + " UNION ALL\n", + array_map( + static fn ($value) => 'SELECT ' . implode(', ', array_map( + static fn ($key, $index) => $index . ' ' . $key, + $keys, + $value + )) . ' FROM DUAL', + $values + ) + ) . "\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Generates a platform-specific upsertBatch string from the supplied data + * + * @throws DatabaseException + */ + protected function _upsertBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $constraints = $this->QBOptions['constraints'] ?? []; + + if (empty($constraints)) { + $fieldNames = array_map(static fn ($columnName) => trim($columnName, '"'), $keys); + + $uniqueIndexes = array_filter($this->db->getIndexData($table), static function ($index) use ($fieldNames) { + $hasAllFields = count(array_intersect($index->fields, $fieldNames)) === count($index->fields); + + return ($index->type === 'PRIMARY' || $index->type === 'UNIQUE') && $hasAllFields; + }); + + // only take first index + foreach ($uniqueIndexes as $index) { + $constraints = $index->fields; + break; + } + + $constraints = $this->onConstraint($constraints)->QBOptions['constraints'] ?? []; + } + + if (empty($constraints)) { + if ($this->db->DBDebug) { + throw new DatabaseException('No constraint found for upsert.'); + } + + return ''; // @codeCoverageIgnore + } + + $alias = $this->QBOptions['alias'] ?? '"_upsert"'; + + $updateFields = $this->QBOptions['updateFields'] ?? $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ?? []; + + $sql = 'MERGE INTO ' . $table . "\nUSING (\n{:_table_:}"; + + $sql .= ") {$alias}\nON ("; + + $sql .= implode( + ' AND ', + array_map( + static fn ($key, $value) => ( + ($value instanceof RawSql && is_string($key)) + ? + $table . '.' . $key . ' = ' . $value + : + ( + $value instanceof RawSql + ? + $value + : + $table . '.' . $value . ' = ' . $alias . '.' . $value + ) + ), + array_keys($constraints), + $constraints + ) + ) . ")\n"; + + $sql .= "WHEN MATCHED THEN UPDATE SET\n"; + + $sql .= implode( + ",\n", + array_map( + static fn ($key, $value) => $key . ($value instanceof RawSql ? + " = {$value}" : + " = {$alias}.{$value}"), + array_keys($updateFields), + $updateFields + ) + ); + + $sql .= "\nWHEN NOT MATCHED THEN INSERT (" . implode(', ', $keys) . ")\nVALUES "; + + $sql .= (' (' + . implode(', ', array_map(static fn ($columnName) => "{$alias}.{$columnName}", $keys)) + . ')'); + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = implode( + " FROM DUAL UNION ALL\n", + array_map( + static fn ($value) => 'SELECT ' . implode(', ', array_map( + static fn ($key, $index) => $index . ' ' . $key, + $keys, + $value + )), + $values + ) + ) . " FROM DUAL\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Generates a platform-specific batch update string from the supplied data + */ + protected function _deleteBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $constraints = $this->QBOptions['constraints'] ?? []; + + if ($constraints === []) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must specify a constraint to match on for batch deletes.'); // @codeCoverageIgnore + } + + return ''; // @codeCoverageIgnore + } + + $alias = $this->QBOptions['alias'] ?? '_u'; + + $sql = 'DELETE ' . $table . "\n"; + + $sql .= "WHERE EXISTS (SELECT * FROM (\n{:_table_:}"; + + $sql .= ') ' . $alias . "\n"; + + $sql .= 'WHERE ' . implode( + ' AND ', + array_map( + static fn ($key, $value) => ( + $value instanceof RawSql ? + $value : + ( + is_string($key) ? + $table . '.' . $key . ' = ' . $alias . '.' . $value : + $table . '.' . $value . ' = ' . $alias . '.' . $value + ) + ), + array_keys($constraints), + $constraints + ) + ); + + // convert binds in where + foreach ($this->QBWhere as $key => $where) { + foreach ($this->binds as $field => $bind) { + $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']); + } + } + + $sql .= ' ' . str_replace( + 'WHERE ', + 'AND ', + $this->compileWhereHaving('QBWhere') + ) . ')'; + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = implode( + " FROM DUAL UNION ALL\n", + array_map( + static fn ($value) => 'SELECT ' . implode(', ', array_map( + static fn ($key, $index) => $index . ' ' . $key, + $keys, + $value + )), + $values + ) + ) . " FROM DUAL\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Gets column names from a select query + */ + protected function fieldsFromQuery(string $sql): array + { + return $this->db->query('SELECT * FROM (' . $sql . ') "_u_" WHERE ROWNUM = 1')->getFieldNames(); + } +} diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php new file mode 100644 index 0000000..8cb7e3a --- /dev/null +++ b/system/Database/OCI8/Connection.php @@ -0,0 +1,728 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\OCI8; + +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Query; +use ErrorException; +use stdClass; + +/** + * Connection for OCI8 + * + * @extends BaseConnection + */ +class Connection extends BaseConnection +{ + /** + * Database driver + * + * @var string + */ + protected $DBDriver = 'OCI8'; + + /** + * Identifier escape character + * + * @var string + */ + public $escapeChar = '"'; + + /** + * List of reserved identifiers + * + * Identifiers that must NOT be escaped. + * + * @var array + */ + protected $reservedIdentifiers = [ + '*', + 'rownum', + ]; + + protected $validDSNs = [ + 'tns' => '/^\(DESCRIPTION=(\(.+\)){2,}\)$/', // TNS + // Easy Connect string (Oracle 10g+) + 'ec' => '/^(\/\/)?[a-z0-9.:_-]+(:[1-9][0-9]{0,4})?(\/[a-z0-9$_]+)?(:[^\/])?(\/[a-z0-9$_]+)?$/i', + 'in' => '/^[a-z0-9$_]+$/i', // Instance name (defined in tnsnames.ora) + ]; + + /** + * Reset $stmtId flag + * + * Used by storedProcedure() to prevent execute() from + * re-setting the statement ID. + */ + protected $resetStmtId = true; + + /** + * Statement ID + * + * @var resource + */ + protected $stmtId; + + /** + * Commit mode flag + * + * @used-by PreparedQuery::_execute() + * + * @var int + */ + public $commitMode = OCI_COMMIT_ON_SUCCESS; + + /** + * Cursor ID + * + * @var resource + */ + protected $cursorId; + + /** + * Latest inserted table name. + * + * @used-by PreparedQuery::_execute() + * + * @var string|null + */ + public $lastInsertedTableName; + + /** + * confirm DSN format. + */ + private function isValidDSN(): bool + { + foreach ($this->validDSNs as $regexp) { + if (preg_match($regexp, $this->DSN)) { + return true; + } + } + + return false; + } + + /** + * Connect to the database. + * + * @return false|resource + */ + public function connect(bool $persistent = false) + { + if (empty($this->DSN) && ! $this->isValidDSN()) { + $this->buildDSN(); + } + + $func = $persistent ? 'oci_pconnect' : 'oci_connect'; + + return empty($this->charset) + ? $func($this->username, $this->password, $this->DSN) + : $func($this->username, $this->password, $this->DSN, $this->charset); + } + + /** + * Keep or establish the connection if no queries have been sent for + * a length of time exceeding the server's idle timeout. + * + * @return void + */ + public function reconnect() + { + } + + /** + * Close the database connection. + * + * @return void + */ + protected function _close() + { + if (is_resource($this->cursorId)) { + oci_free_statement($this->cursorId); + } + if (is_resource($this->stmtId)) { + oci_free_statement($this->stmtId); + } + oci_close($this->connID); + } + + /** + * Select a specific database table to use. + */ + public function setDatabase(string $databaseName): bool + { + return false; + } + + /** + * Returns a string containing the version of the database being used. + */ + public function getVersion(): string + { + if (isset($this->dataCache['version'])) { + return $this->dataCache['version']; + } + + if (! $this->connID || ($versionString = oci_server_version($this->connID)) === false) { + return ''; + } + if (preg_match('#Release\s(\d+(?:\.\d+)+)#', $versionString, $match)) { + return $this->dataCache['version'] = $match[1]; + } + + return ''; + } + + /** + * Executes the query against the database. + * + * @return false|resource + */ + protected function execute(string $sql) + { + try { + if ($this->resetStmtId === true) { + $this->stmtId = oci_parse($this->connID, $sql); + } + + oci_set_prefetch($this->stmtId, 1000); + + $result = oci_execute($this->stmtId, $this->commitMode) ? $this->stmtId : false; + $insertTableName = $this->parseInsertTableName($sql); + + if ($result && $insertTableName !== '') { + $this->lastInsertedTableName = $insertTableName; + } + + return $result; + } catch (ErrorException $e) { + log_message('error', (string) $e); + + if ($this->DBDebug) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + } + + return false; + } + + /** + * Get the table name for the insert statement from sql. + */ + public function parseInsertTableName(string $sql): string + { + $commentStrippedSql = preg_replace(['/\/\*(.|\n)*?\*\//m', '/--.+/'], '', $sql); + $isInsertQuery = strpos(strtoupper(ltrim($commentStrippedSql)), 'INSERT') === 0; + + if (! $isInsertQuery) { + return ''; + } + + preg_match('/(?is)\b(?:into)\s+("?\w+"?)/', $commentStrippedSql, $match); + $tableName = $match[1] ?? ''; + + return strpos($tableName, '"') === 0 ? trim($tableName, '"') : strtoupper($tableName); + } + + /** + * Returns the total number of rows affected by this query. + */ + public function affectedRows(): int + { + return oci_num_rows($this->stmtId); + } + + /** + * Generates the SQL for listing tables in a platform-dependent manner. + * + * @param string|null $tableName If $tableName is provided will return only this table if exists. + */ + protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string + { + $sql = 'SELECT "TABLE_NAME" FROM "USER_TABLES"'; + + if ($tableName !== null) { + return $sql . ' WHERE "TABLE_NAME" LIKE ' . $this->escape($tableName); + } + + if ($prefixLimit !== false && $this->DBPrefix !== '') { + return $sql . ' WHERE "TABLE_NAME" LIKE \'' . $this->escapeLikeString($this->DBPrefix) . "%' " + . sprintf($this->likeEscapeStr, $this->likeEscapeChar); + } + + return $sql; + } + + /** + * Generates a platform-specific query string so that the column names can be fetched. + */ + protected function _listColumns(string $table = ''): string + { + if (strpos($table, '.') !== false) { + sscanf($table, '%[^.].%s', $owner, $table); + } else { + $owner = $this->username; + } + + return 'SELECT COLUMN_NAME FROM ALL_TAB_COLUMNS + WHERE UPPER(OWNER) = ' . $this->escape(strtoupper($owner)) . ' + AND UPPER(TABLE_NAME) = ' . $this->escape(strtoupper($this->DBPrefix . $table)); + } + + /** + * Returns an array of objects with field data + * + * @return stdClass[] + * + * @throws DatabaseException + */ + protected function _fieldData(string $table): array + { + if (strpos($table, '.') !== false) { + sscanf($table, '%[^.].%s', $owner, $table); + } else { + $owner = $this->username; + } + + $sql = 'SELECT COLUMN_NAME, DATA_TYPE, CHAR_LENGTH, DATA_PRECISION, DATA_LENGTH, DATA_DEFAULT, NULLABLE + FROM ALL_TAB_COLUMNS + WHERE UPPER(OWNER) = ' . $this->escape(strtoupper($owner)) . ' + AND UPPER(TABLE_NAME) = ' . $this->escape(strtoupper($table)); + + if (($query = $this->query($sql)) === false) { + throw new DatabaseException(lang('Database.failGetFieldData')); + } + $query = $query->getResultObject(); + + $retval = []; + + for ($i = 0, $c = count($query); $i < $c; $i++) { + $retval[$i] = new stdClass(); + $retval[$i]->name = $query[$i]->COLUMN_NAME; + $retval[$i]->type = $query[$i]->DATA_TYPE; + + $length = $query[$i]->CHAR_LENGTH > 0 ? $query[$i]->CHAR_LENGTH : $query[$i]->DATA_PRECISION; + $length ??= $query[$i]->DATA_LENGTH; + + $retval[$i]->max_length = $length; + + $default = $query[$i]->DATA_DEFAULT; + if ($default === null && $query[$i]->NULLABLE === 'N') { + $default = ''; + } + $retval[$i]->default = $default; + $retval[$i]->nullable = $query[$i]->NULLABLE === 'Y'; + } + + return $retval; + } + + /** + * Returns an array of objects with index data + * + * @return stdClass[] + * + * @throws DatabaseException + */ + protected function _indexData(string $table): array + { + if (strpos($table, '.') !== false) { + sscanf($table, '%[^.].%s', $owner, $table); + } else { + $owner = $this->username; + } + + $sql = 'SELECT AIC.INDEX_NAME, UC.CONSTRAINT_TYPE, AIC.COLUMN_NAME ' + . ' FROM ALL_IND_COLUMNS AIC ' + . ' LEFT JOIN USER_CONSTRAINTS UC ON AIC.INDEX_NAME = UC.CONSTRAINT_NAME AND AIC.TABLE_NAME = UC.TABLE_NAME ' + . 'WHERE AIC.TABLE_NAME = ' . $this->escape(strtolower($table)) . ' ' + . 'AND AIC.TABLE_OWNER = ' . $this->escape(strtoupper($owner)) . ' ' + . ' ORDER BY UC.CONSTRAINT_TYPE, AIC.COLUMN_POSITION'; + + if (($query = $this->query($sql)) === false) { + throw new DatabaseException(lang('Database.failGetIndexData')); + } + $query = $query->getResultObject(); + + $retVal = []; + $constraintTypes = [ + 'P' => 'PRIMARY', + 'U' => 'UNIQUE', + ]; + + foreach ($query as $row) { + if (isset($retVal[$row->INDEX_NAME])) { + $retVal[$row->INDEX_NAME]->fields[] = $row->COLUMN_NAME; + + continue; + } + + $retVal[$row->INDEX_NAME] = new stdClass(); + $retVal[$row->INDEX_NAME]->name = $row->INDEX_NAME; + $retVal[$row->INDEX_NAME]->fields = [$row->COLUMN_NAME]; + $retVal[$row->INDEX_NAME]->type = $constraintTypes[$row->CONSTRAINT_TYPE] ?? 'INDEX'; + } + + return $retVal; + } + + /** + * Returns an array of objects with Foreign key data + * + * @return stdClass[] + * + * @throws DatabaseException + */ + protected function _foreignKeyData(string $table): array + { + $sql = 'SELECT + acc.constraint_name, + acc.table_name, + acc.column_name, + ccu.table_name foreign_table_name, + accu.column_name foreign_column_name, + ac.delete_rule + FROM all_cons_columns acc + JOIN all_constraints ac ON acc.owner = ac.owner + AND acc.constraint_name = ac.constraint_name + JOIN all_constraints ccu ON ac.r_owner = ccu.owner + AND ac.r_constraint_name = ccu.constraint_name + JOIN all_cons_columns accu ON accu.constraint_name = ccu.constraint_name + AND accu.position = acc.position + AND accu.table_name = ccu.table_name + WHERE ac.constraint_type = ' . $this->escape('R') . ' + AND acc.table_name = ' . $this->escape($table); + + $query = $this->query($sql); + + if ($query === false) { + throw new DatabaseException(lang('Database.failGetForeignKeyData')); + } + + $query = $query->getResultObject(); + $indexes = []; + + foreach ($query as $row) { + $indexes[$row->CONSTRAINT_NAME]['constraint_name'] = $row->CONSTRAINT_NAME; + $indexes[$row->CONSTRAINT_NAME]['table_name'] = $row->TABLE_NAME; + $indexes[$row->CONSTRAINT_NAME]['column_name'][] = $row->COLUMN_NAME; + $indexes[$row->CONSTRAINT_NAME]['foreign_table_name'] = $row->FOREIGN_TABLE_NAME; + $indexes[$row->CONSTRAINT_NAME]['foreign_column_name'][] = $row->FOREIGN_COLUMN_NAME; + $indexes[$row->CONSTRAINT_NAME]['on_delete'] = $row->DELETE_RULE; + $indexes[$row->CONSTRAINT_NAME]['on_update'] = null; + $indexes[$row->CONSTRAINT_NAME]['match'] = null; + } + + return $this->foreignKeyDataToObjects($indexes); + } + + /** + * Returns platform-specific SQL to disable foreign key checks. + * + * @return string + */ + protected function _disableForeignKeyChecks() + { + return <<<'SQL' + BEGIN + FOR c IN + (SELECT c.owner, c.table_name, c.constraint_name + FROM user_constraints c, user_tables t + WHERE c.table_name = t.table_name + AND c.status = 'ENABLED' + AND c.constraint_type = 'R' + AND t.iot_type IS NULL + ORDER BY c.constraint_type DESC) + LOOP + dbms_utility.exec_ddl_statement('alter table "' || c.owner || '"."' || c.table_name || '" disable constraint "' || c.constraint_name || '"'); + END LOOP; + END; + SQL; + } + + /** + * Returns platform-specific SQL to enable foreign key checks. + * + * @return string + */ + protected function _enableForeignKeyChecks() + { + return <<<'SQL' + BEGIN + FOR c IN + (SELECT c.owner, c.table_name, c.constraint_name + FROM user_constraints c, user_tables t + WHERE c.table_name = t.table_name + AND c.status = 'DISABLED' + AND c.constraint_type = 'R' + AND t.iot_type IS NULL + ORDER BY c.constraint_type DESC) + LOOP + dbms_utility.exec_ddl_statement('alter table "' || c.owner || '"."' || c.table_name || '" enable constraint "' || c.constraint_name || '"'); + END LOOP; + END; + SQL; + } + + /** + * Get cursor. Returns a cursor from the database + * + * @return resource + */ + public function getCursor() + { + return $this->cursorId = oci_new_cursor($this->connID); + } + + /** + * Executes a stored procedure + * + * @param string $procedureName procedure name to execute + * @param array $params params array keys + * KEY OPTIONAL NOTES + * name no the name of the parameter should be in : format + * value no the value of the parameter. If this is an OUT or IN OUT parameter, + * this should be a reference to a variable + * type yes the type of the parameter + * length yes the max size of the parameter + * + * @return bool|Query|Result + */ + public function storedProcedure(string $procedureName, array $params) + { + if ($procedureName === '') { + throw new DatabaseException(lang('Database.invalidArgument', [$procedureName])); + } + + // Build the query string + $sql = sprintf( + 'BEGIN %s (' . substr(str_repeat(',%s', count($params)), 1) . '); END;', + $procedureName, + ...array_map(static fn ($row) => $row['name'], $params) + ); + + $this->resetStmtId = false; + $this->stmtId = oci_parse($this->connID, $sql); + $this->bindParams($params); + $result = $this->query($sql); + $this->resetStmtId = true; + + return $result; + } + + /** + * Bind parameters + * + * @param array $params + * + * @return void + */ + protected function bindParams($params) + { + if (! is_array($params) || ! is_resource($this->stmtId)) { + return; + } + + foreach ($params as $param) { + oci_bind_by_name( + $this->stmtId, + $param['name'], + $param['value'], + $param['length'] ?? -1, + $param['type'] ?? SQLT_CHR + ); + } + } + + /** + * Returns the last error code and message. + * + * Must return an array with keys 'code' and 'message': + * + * return ['code' => null, 'message' => null); + */ + public function error(): array + { + // oci_error() returns an array that already contains + // 'code' and 'message' keys, but it can return false + // if there was no error .... + $error = oci_error(); + $resources = [$this->cursorId, $this->stmtId, $this->connID]; + + foreach ($resources as $resource) { + if (is_resource($resource)) { + $error = oci_error($resource); + break; + } + } + + return is_array($error) + ? $error + : [ + 'code' => '', + 'message' => '', + ]; + } + + public function insertID(): int + { + if (empty($this->lastInsertedTableName)) { + return 0; + } + + $indexs = $this->getIndexData($this->lastInsertedTableName); + $fieldDatas = $this->getFieldData($this->lastInsertedTableName); + + if (! $indexs || ! $fieldDatas) { + return 0; + } + + $columnTypeList = array_column($fieldDatas, 'type', 'name'); + $primaryColumnName = ''; + + foreach ($indexs as $index) { + if ($index->type !== 'PRIMARY' || count($index->fields) !== 1) { + continue; + } + + $primaryColumnName = $this->protectIdentifiers($index->fields[0], false, false); + $primaryColumnType = $columnTypeList[$primaryColumnName]; + + if ($primaryColumnType !== 'NUMBER') { + $primaryColumnName = ''; + } + } + + if ($primaryColumnName === '') { + return 0; + } + + $query = $this->query('SELECT DATA_DEFAULT FROM USER_TAB_COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ?', [$this->lastInsertedTableName, $primaryColumnName])->getRow(); + $lastInsertValue = str_replace('nextval', 'currval', $query->DATA_DEFAULT ?? '0'); + $query = $this->query(sprintf('SELECT %s SEQ FROM DUAL', $lastInsertValue))->getRow(); + + return (int) ($query->SEQ ?? 0); + } + + /** + * Build a DSN from the provided parameters + * + * @return void + */ + protected function buildDSN() + { + if ($this->DSN !== '') { + $this->DSN = ''; + } + + // Legacy support for TNS in the hostname configuration field + $this->hostname = str_replace(["\n", "\r", "\t", ' '], '', $this->hostname); + + if (preg_match($this->validDSNs['tns'], $this->hostname)) { + $this->DSN = $this->hostname; + + return; + } + + $isEasyConnectableHostName = $this->hostname !== '' && strpos($this->hostname, '/') === false && strpos($this->hostname, ':') === false; + $easyConnectablePort = ! empty($this->port) && ctype_digit($this->port) ? ':' . $this->port : ''; + $easyConnectableDatabase = $this->database !== '' ? '/' . ltrim($this->database, '/') : ''; + + if ($isEasyConnectableHostName && ($easyConnectablePort !== '' || $easyConnectableDatabase !== '')) { + /* If the hostname field isn't empty, doesn't contain + * ':' and/or '/' and if port and/or database aren't + * empty, then the hostname field is most likely indeed + * just a hostname. Therefore we'll try and build an + * Easy Connect string from these 3 settings, assuming + * that the database field is a service name. + */ + $this->DSN = $this->hostname . $easyConnectablePort . $easyConnectableDatabase; + + if (preg_match($this->validDSNs['ec'], $this->DSN)) { + return; + } + } + + /* At this point, we can only try and validate the hostname and + * database fields separately as DSNs. + */ + if (preg_match($this->validDSNs['ec'], $this->hostname) || preg_match($this->validDSNs['in'], $this->hostname)) { + $this->DSN = $this->hostname; + + return; + } + + $this->database = str_replace(["\n", "\r", "\t", ' '], '', $this->database); + + foreach ($this->validDSNs as $regexp) { + if (preg_match($regexp, $this->database)) { + return; + } + } + + /* Well - OK, an empty string should work as well. + * PHP will try to use environment variables to + * determine which Oracle instance to connect to. + */ + $this->DSN = ''; + } + + /** + * Begin Transaction + */ + protected function _transBegin(): bool + { + $this->commitMode = OCI_NO_AUTO_COMMIT; + + return true; + } + + /** + * Commit Transaction + */ + protected function _transCommit(): bool + { + $this->commitMode = OCI_COMMIT_ON_SUCCESS; + + return oci_commit($this->connID); + } + + /** + * Rollback Transaction + */ + protected function _transRollback(): bool + { + $this->commitMode = OCI_COMMIT_ON_SUCCESS; + + return oci_rollback($this->connID); + } + + /** + * Returns the name of the current database being used. + */ + public function getDatabase(): string + { + if (! empty($this->database)) { + return $this->database; + } + + return $this->query('SELECT DEFAULT_TABLESPACE FROM USER_USERS')->getRow()->DEFAULT_TABLESPACE ?? ''; + } + + /** + * Get the prefix of the function to access the DB. + */ + protected function getDriverFunctionPrefix(): string + { + return 'oci_'; + } +} diff --git a/system/Database/OCI8/Forge.php b/system/Database/OCI8/Forge.php new file mode 100644 index 0000000..2735b88 --- /dev/null +++ b/system/Database/OCI8/Forge.php @@ -0,0 +1,312 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\OCI8; + +use CodeIgniter\Database\Forge as BaseForge; + +/** + * Forge for OCI8 + */ +class Forge extends BaseForge +{ + /** + * DROP INDEX statement + * + * @var string + */ + protected $dropIndexStr = 'DROP INDEX %s'; + + /** + * CREATE DATABASE statement + * + * @var false + */ + protected $createDatabaseStr = false; + + /** + * CREATE TABLE IF statement + * + * @var false + * + * @deprecated This is no longer used. + */ + protected $createTableIfStr = false; + + /** + * DROP TABLE IF EXISTS statement + * + * @var false + */ + protected $dropTableIfStr = false; + + /** + * DROP DATABASE statement + * + * @var false + */ + protected $dropDatabaseStr = false; + + /** + * UNSIGNED support + * + * @var array|bool + */ + protected $unsigned = false; + + /** + * NULL value representation in CREATE/ALTER TABLE statements + * + * @var string + */ + protected $null = 'NULL'; + + /** + * RENAME TABLE statement + * + * @var string + */ + protected $renameTableStr = 'ALTER TABLE %s RENAME TO %s'; + + /** + * DROP CONSTRAINT statement + * + * @var string + */ + protected $dropConstraintStr = 'ALTER TABLE %s DROP CONSTRAINT %s'; + + /** + * Foreign Key Allowed Actions + * + * @var array + */ + protected $fkAllowActions = ['CASCADE', 'SET NULL', 'NO ACTION']; + + /** + * ALTER TABLE + * + * @param string $alterType ALTER type + * @param string $table Table name + * @param array|string $processedFields Processed column definitions + * or column names to DROP + * + * @return list|string SQL string + * @phpstan-return ($alterType is 'DROP' ? string : list) + */ + protected function _alterTable(string $alterType, string $table, $processedFields) + { + $sql = 'ALTER TABLE ' . $this->db->escapeIdentifiers($table); + + if ($alterType === 'DROP') { + $columnNamesToDrop = $processedFields; + + $fields = array_map( + fn ($field) => $this->db->escapeIdentifiers(trim($field)), + is_string($columnNamesToDrop) ? explode(',', $columnNamesToDrop) : $columnNamesToDrop + ); + + return $sql . ' DROP (' . implode(',', $fields) . ') CASCADE CONSTRAINT INVALIDATE'; + } + + if ($alterType === 'CHANGE') { + $alterType = 'MODIFY'; + } + + $nullableMap = array_column($this->db->getFieldData($table), 'nullable', 'name'); + $sqls = []; + + for ($i = 0, $c = count($processedFields); $i < $c; $i++) { + if ($alterType === 'MODIFY') { + // If a null constraint is added to a column with a null constraint, + // ORA-01451 will occur, + // so add null constraint is used only when it is different from the current null constraint. + // If a not null constraint is added to a column with a not null constraint, + // ORA-01442 will occur. + $wantToAddNull = strpos($processedFields[$i]['null'], ' NOT') === false; + $currentNullable = $nullableMap[$processedFields[$i]['name']]; + + if ($wantToAddNull === true && $currentNullable === true) { + $processedFields[$i]['null'] = ''; + } elseif ($processedFields[$i]['null'] === '' && $currentNullable === false) { + // Nullable by default + $processedFields[$i]['null'] = ' NULL'; + } elseif ($wantToAddNull === false && $currentNullable === false) { + $processedFields[$i]['null'] = ''; + } + } + + if ($processedFields[$i]['_literal'] !== false) { + $processedFields[$i] = "\n\t" . $processedFields[$i]['_literal']; + } else { + $processedFields[$i]['_literal'] = "\n\t" . $this->_processColumn($processedFields[$i]); + + if (! empty($processedFields[$i]['comment'])) { + $sqls[] = 'COMMENT ON COLUMN ' + . $this->db->escapeIdentifiers($table) . '.' . $this->db->escapeIdentifiers($processedFields[$i]['name']) + . ' IS ' . $processedFields[$i]['comment']; + } + + if ($alterType === 'MODIFY' && ! empty($processedFields[$i]['new_name'])) { + $sqls[] = $sql . ' RENAME COLUMN ' . $this->db->escapeIdentifiers($processedFields[$i]['name']) + . ' TO ' . $this->db->escapeIdentifiers($processedFields[$i]['new_name']); + } + + $processedFields[$i] = "\n\t" . $processedFields[$i]['_literal']; + } + } + + $sql .= ' ' . $alterType . ' '; + $sql .= count($processedFields) === 1 + ? $processedFields[0] + : '(' . implode(',', $processedFields) . ')'; + + // RENAME COLUMN must be executed after MODIFY + array_unshift($sqls, $sql); + + return $sqls; + } + + /** + * Field attribute AUTO_INCREMENT + * + * @return void + */ + protected function _attributeAutoIncrement(array &$attributes, array &$field) + { + if (! empty($attributes['AUTO_INCREMENT']) && $attributes['AUTO_INCREMENT'] === true + && stripos($field['type'], 'NUMBER') !== false + && version_compare($this->db->getVersion(), '12.1', '>=') + ) { + $field['auto_increment'] = ' GENERATED BY DEFAULT ON NULL AS IDENTITY'; + } + } + + /** + * Process column + */ + protected function _processColumn(array $processedField): string + { + $constraint = ''; + // @todo: can't cover multi pattern when set type. + if ($processedField['type'] === 'VARCHAR2' && strpos($processedField['length'], "('") === 0) { + $constraint = ' CHECK(' . $this->db->escapeIdentifiers($processedField['name']) + . ' IN ' . $processedField['length'] . ')'; + + $processedField['length'] = '(' . max(array_map('mb_strlen', explode("','", mb_substr($processedField['length'], 2, -2)))) . ')' . $constraint; + } elseif (isset($this->primaryKeys['fields']) && count($this->primaryKeys['fields']) === 1 && $processedField['name'] === $this->primaryKeys['fields'][0]) { + $processedField['unique'] = ''; + } + + return $this->db->escapeIdentifiers($processedField['name']) + . ' ' . $processedField['type'] . $processedField['length'] + . $processedField['unsigned'] + . $processedField['default'] + . $processedField['auto_increment'] + . $processedField['null'] + . $processedField['unique']; + } + + /** + * Performs a data type mapping between different databases. + * + * @return void + */ + protected function _attributeType(array &$attributes) + { + // Reset field lengths for data types that don't support it + // Usually overridden by drivers + switch (strtoupper($attributes['TYPE'])) { + case 'TINYINT': + $attributes['CONSTRAINT'] ??= 3; + + // no break + case 'SMALLINT': + $attributes['CONSTRAINT'] ??= 5; + + // no break + case 'MEDIUMINT': + $attributes['CONSTRAINT'] ??= 7; + + // no break + case 'INT': + case 'INTEGER': + $attributes['CONSTRAINT'] ??= 11; + + // no break + case 'BIGINT': + $attributes['CONSTRAINT'] ??= 19; + + // no break + case 'NUMERIC': + $attributes['TYPE'] = 'NUMBER'; + + return; + + case 'BOOLEAN': + $attributes['TYPE'] = 'NUMBER'; + $attributes['CONSTRAINT'] = 1; + $attributes['UNSIGNED'] = true; + + return; + + case 'DOUBLE': + $attributes['TYPE'] = 'FLOAT'; + $attributes['CONSTRAINT'] ??= 126; + + return; + + case 'DATETIME': + case 'TIME': + $attributes['TYPE'] = 'DATE'; + + return; + + case 'SET': + case 'ENUM': + case 'VARCHAR': + $attributes['CONSTRAINT'] ??= 255; + + // no break + case 'TEXT': + case 'MEDIUMTEXT': + $attributes['CONSTRAINT'] ??= 4000; + $attributes['TYPE'] = 'VARCHAR2'; + } + } + + /** + * Generates a platform-specific DROP TABLE string + * + * @return bool|string + */ + protected function _dropTable(string $table, bool $ifExists, bool $cascade) + { + $sql = parent::_dropTable($table, $ifExists, $cascade); + + if ($sql !== true && $cascade === true) { + $sql .= ' CASCADE CONSTRAINTS PURGE'; + } elseif ($sql !== true) { + $sql .= ' PURGE'; + } + + return $sql; + } + + /** + * Constructs sql to check if key is a constraint. + */ + protected function _dropKeyAsConstraint(string $table, string $constraintName): string + { + return "SELECT constraint_name FROM all_constraints WHERE table_name = '" + . trim($table, '"') . "' AND index_name = '" + . trim($constraintName, '"') . "'"; + } +} diff --git a/system/Database/OCI8/PreparedQuery.php b/system/Database/OCI8/PreparedQuery.php new file mode 100644 index 0000000..93d2607 --- /dev/null +++ b/system/Database/OCI8/PreparedQuery.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\OCI8; + +use BadMethodCallException; +use CodeIgniter\Database\BasePreparedQuery; +use CodeIgniter\Database\Exceptions\DatabaseException; + +/** + * Prepared query for OCI8 + * + * @extends BasePreparedQuery + */ +class PreparedQuery extends BasePreparedQuery +{ + /** + * A reference to the db connection to use. + * + * @var Connection + */ + protected $db; + + /** + * Latest inserted table name. + */ + private ?string $lastInsertTableName = null; + + /** + * Prepares the query against the database, and saves the connection + * info necessary to execute the query later. + * + * NOTE: This version is based on SQL code. Child classes should + * override this method. + * + * @param array $options Passed to the connection's prepare statement. + * Unused in the OCI8 driver. + */ + public function _prepare(string $sql, array $options = []): PreparedQuery + { + if (! $this->statement = oci_parse($this->db->connID, $this->parameterize($sql))) { + $error = oci_error($this->db->connID); + $this->errorCode = $error['code'] ?? 0; + $this->errorString = $error['message'] ?? ''; + + if ($this->db->DBDebug) { + throw new DatabaseException($this->errorString . ' code: ' . $this->errorCode); + } + } + + $this->lastInsertTableName = $this->db->parseInsertTableName($sql); + + return $this; + } + + /** + * Takes a new set of data and runs it against the currently + * prepared query. Upon success, will return a Results object. + */ + public function _execute(array $data): bool + { + if (! isset($this->statement)) { + throw new BadMethodCallException('You must call prepare before trying to execute a prepared statement.'); + } + + foreach (array_keys($data) as $key) { + oci_bind_by_name($this->statement, ':' . $key, $data[$key]); + } + + $result = oci_execute($this->statement, $this->db->commitMode); + + if ($result && $this->lastInsertTableName !== '') { + $this->db->lastInsertedTableName = $this->lastInsertTableName; + } + + return $result; + } + + /** + * Returns the statement resource for the prepared query or false when preparing failed. + * + * @return resource|null + */ + public function _getResult() + { + return $this->statement; + } + + /** + * Deallocate prepared statements. + */ + protected function _close(): bool + { + return oci_free_statement($this->statement); + } + + /** + * Replaces the ? placeholders with :0, :1, etc parameters for use + * within the prepared query. + */ + public function parameterize(string $sql): string + { + // Track our current value + $count = 0; + + return preg_replace_callback('/\?/', static function ($matches) use (&$count) { + return ':' . ($count++); + }, $sql); + } +} diff --git a/system/Database/OCI8/Result.php b/system/Database/OCI8/Result.php new file mode 100644 index 0000000..0102576 --- /dev/null +++ b/system/Database/OCI8/Result.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\OCI8; + +use CodeIgniter\Database\BaseResult; +use CodeIgniter\Entity\Entity; +use stdClass; + +/** + * Result for OCI8 + * + * @extends BaseResult + */ +class Result extends BaseResult +{ + /** + * Gets the number of fields in the result set. + */ + public function getFieldCount(): int + { + return oci_num_fields($this->resultID); + } + + /** + * Generates an array of column names in the result set. + */ + public function getFieldNames(): array + { + return array_map(fn ($fieldIndex) => oci_field_name($this->resultID, $fieldIndex), range(1, $this->getFieldCount())); + } + + /** + * Generates an array of objects representing field meta-data. + */ + public function getFieldData(): array + { + return array_map(fn ($fieldIndex) => (object) [ + 'name' => oci_field_name($this->resultID, $fieldIndex), + 'type' => oci_field_type($this->resultID, $fieldIndex), + 'max_length' => oci_field_size($this->resultID, $fieldIndex), + ], range(1, $this->getFieldCount())); + } + + /** + * Frees the current result. + * + * @return void + */ + public function freeResult() + { + if (is_resource($this->resultID)) { + oci_free_statement($this->resultID); + $this->resultID = false; + } + } + + /** + * Moves the internal pointer to the desired offset. This is called + * internally before fetching results to make sure the result set + * starts at zero. + * + * @return false + */ + public function dataSeek(int $n = 0) + { + // We can't support data seek by oci + return false; + } + + /** + * Returns the result set as an array. + * + * Overridden by driver classes. + * + * @return array|false + */ + protected function fetchAssoc() + { + return oci_fetch_assoc($this->resultID); + } + + /** + * Returns the result set as an object. + * + * Overridden by child classes. + * + * @return Entity|false|object|stdClass + */ + protected function fetchObject(string $className = 'stdClass') + { + $row = oci_fetch_object($this->resultID); + + if ($className === 'stdClass' || ! $row) { + return $row; + } + if (is_subclass_of($className, Entity::class)) { + return (new $className())->injectRawData((array) $row); + } + + $instance = new $className(); + + foreach (get_object_vars($row) as $key => $value) { + $instance->{$key} = $value; + } + + return $instance; + } +} diff --git a/system/Database/OCI8/Utils.php b/system/Database/OCI8/Utils.php new file mode 100644 index 0000000..2f3a330 --- /dev/null +++ b/system/Database/OCI8/Utils.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\OCI8; + +use CodeIgniter\Database\BaseUtils; +use CodeIgniter\Database\Exceptions\DatabaseException; + +/** + * Utils for OCI8 + */ +class Utils extends BaseUtils +{ + /** + * List databases statement + * + * @var string + */ + protected $listDatabases = 'SELECT TABLESPACE_NAME FROM USER_TABLESPACES'; + + /** + * Platform dependent version of the backup function. + * + * @return never + */ + public function _backup(?array $prefs = null) + { + throw new DatabaseException('Unsupported feature of the database platform you are using.'); + } +} diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php new file mode 100644 index 0000000..3749d83 --- /dev/null +++ b/system/Database/Postgre/Builder.php @@ -0,0 +1,623 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Postgre; + +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\RawSql; +use InvalidArgumentException; + +/** + * Builder for Postgre + */ +class Builder extends BaseBuilder +{ + /** + * ORDER BY random keyword + * + * @var array + */ + protected $randomKeyword = [ + 'RANDOM()', + ]; + + /** + * Specifies which sql statements + * support the ignore option. + * + * @var array + */ + protected $supportedIgnoreStatements = [ + 'insert' => 'ON CONFLICT DO NOTHING', + ]; + + /** + * Checks if the ignore option is supported by + * the Database Driver for the specific statement. + * + * @return string + */ + protected function compileIgnore(string $statement) + { + $sql = parent::compileIgnore($statement); + + if (! empty($sql)) { + $sql = ' ' . trim($sql); + } + + return $sql; + } + + /** + * ORDER BY + * + * @param string $direction ASC, DESC or RANDOM + * + * @return BaseBuilder + */ + public function orderBy(string $orderBy, string $direction = '', ?bool $escape = null) + { + $direction = strtoupper(trim($direction)); + if ($direction === 'RANDOM') { + if (ctype_digit($orderBy)) { + $orderBy = (float) ($orderBy > 1 ? "0.{$orderBy}" : $orderBy); + } + + if (is_float($orderBy)) { + $this->db->simpleQuery("SET SEED {$orderBy}"); + } + + $orderBy = $this->randomKeyword[0]; + $direction = ''; + $escape = false; + } + + return parent::orderBy($orderBy, $direction, $escape); + } + + /** + * Increments a numeric column by the specified value. + * + * @return mixed + * + * @throws DatabaseException + */ + public function increment(string $column, int $value = 1) + { + $column = $this->db->protectIdentifiers($column); + + $sql = $this->_update($this->QBFrom[0], [$column => "to_number({$column}, '9999999') + {$value}"]); + + if (! $this->testMode) { + $this->resetWrite(); + + return $this->db->query($sql, $this->binds, false); + } + + return true; + } + + /** + * Decrements a numeric column by the specified value. + * + * @return mixed + * + * @throws DatabaseException + */ + public function decrement(string $column, int $value = 1) + { + $column = $this->db->protectIdentifiers($column); + + $sql = $this->_update($this->QBFrom[0], [$column => "to_number({$column}, '9999999') - {$value}"]); + + if (! $this->testMode) { + $this->resetWrite(); + + return $this->db->query($sql, $this->binds, false); + } + + return true; + } + + /** + * Compiles an replace into string and runs the query. + * Because PostgreSQL doesn't support the replace into command, + * we simply do a DELETE and an INSERT on the first key/value + * combo, assuming that it's either the primary key or a unique key. + * + * @param array|null $set An associative array of insert values + * + * @return mixed + * + * @throws DatabaseException + */ + public function replace(?array $set = null) + { + if ($set !== null) { + $this->set($set); + } + + if ($this->QBSet === []) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must use the "set" method to update an entry.'); + } + + return false; // @codeCoverageIgnore + } + + $table = $this->QBFrom[0]; + $set = $this->binds; + + array_walk($set, static function (array &$item) { + $item = $item[0]; + }); + + $key = array_key_first($set); + $value = $set[$key]; + + $builder = $this->db->table($table); + $exists = $builder->where($key, $value, true)->get()->getFirstRow(); + + if (empty($exists) && $this->testMode) { + $result = $this->getCompiledInsert(); + } elseif (empty($exists)) { + $result = $builder->insert($set); + } elseif ($this->testMode) { + $result = $this->where($key, $value, true)->getCompiledUpdate(); + } else { + array_shift($set); + $result = $builder->where($key, $value, true)->update($set); + } + + unset($builder); + $this->resetWrite(); + $this->binds = []; + + return $result; + } + + /** + * Generates a platform-specific insert string from the supplied data + */ + protected function _insert(string $table, array $keys, array $unescapedKeys): string + { + return trim(sprintf('INSERT INTO %s (%s) VALUES (%s) %s', $table, implode(', ', $keys), implode(', ', $unescapedKeys), $this->compileIgnore('insert'))); + } + + /** + * Generates a platform-specific insert string from the supplied data. + */ + protected function _insertBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $sql = 'INSERT INTO ' . $table . '(' . implode(', ', $keys) . ")\n{:_table_:}\n"; + + $sql .= $this->compileIgnore('insert'); + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = 'VALUES ' . implode(', ', $this->formatValues($values)); + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Compiles a delete string and runs the query + * + * @param mixed $where + * + * @return mixed + * + * @throws DatabaseException + */ + public function delete($where = '', ?int $limit = null, bool $resetData = true) + { + if ($limit !== null && $limit !== 0 || ! empty($this->QBLimit)) { + throw new DatabaseException('PostgreSQL does not allow LIMITs on DELETE queries.'); + } + + return parent::delete($where, $limit, $resetData); + } + + /** + * Generates a platform-specific LIMIT clause. + */ + protected function _limit(string $sql, bool $offsetIgnore = false): string + { + return $sql . ' LIMIT ' . $this->QBLimit . ($this->QBOffset ? " OFFSET {$this->QBOffset}" : ''); + } + + /** + * Generates a platform-specific update string from the supplied data + * + * @throws DatabaseException + */ + protected function _update(string $table, array $values): string + { + if (! empty($this->QBLimit)) { + throw new DatabaseException('Postgres does not support LIMITs with UPDATE queries.'); + } + + $this->QBOrderBy = []; + + return parent::_update($table, $values); + } + + /** + * Generates a platform-specific delete string from the supplied data + */ + protected function _delete(string $table): string + { + $this->QBLimit = false; + + return parent::_delete($table); + } + + /** + * Generates a platform-specific truncate string from the supplied data + * + * If the database does not support the truncate() command, + * then this method maps to 'DELETE FROM table' + */ + protected function _truncate(string $table): string + { + return 'TRUNCATE ' . $table . ' RESTART IDENTITY'; + } + + /** + * Platform independent LIKE statement builder. + * + * In PostgreSQL, the ILIKE operator will perform case insensitive + * searches according to the current locale. + * + * @see https://www.postgresql.org/docs/9.2/static/functions-matching.html + */ + protected function _like_statement(?string $prefix, string $column, ?string $not, string $bind, bool $insensitiveSearch = false): string + { + $op = $insensitiveSearch === true ? 'ILIKE' : 'LIKE'; + + return "{$prefix} {$column} {$not} {$op} :{$bind}:"; + } + + /** + * Generates the JOIN portion of the query + * + * @param RawSql|string $cond + * + * @return BaseBuilder + */ + public function join(string $table, $cond, string $type = '', ?bool $escape = null) + { + if (! in_array('FULL OUTER', $this->joinTypes, true)) { + $this->joinTypes = array_merge($this->joinTypes, ['FULL OUTER']); + } + + return parent::join($table, $cond, $type, $escape); + } + + /** + * Generates a platform-specific batch update string from the supplied data + * + * @used-by batchExecute + * + * @param string $table Protected table name + * @param list $keys QBKeys + * @param list> $values QBSet + */ + protected function _updateBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $constraints = $this->QBOptions['constraints'] ?? []; + + if ($constraints === []) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must specify a constraint to match on for batch updates.'); // @codeCoverageIgnore + } + + return ''; // @codeCoverageIgnore + } + + $updateFields = $this->QBOptions['updateFields'] ?? + $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ?? + []; + + $alias = $this->QBOptions['alias'] ?? '_u'; + + $sql = 'UPDATE ' . $this->compileIgnore('update') . $table . "\n"; + + $sql .= "SET\n"; + + $that = $this; + $sql .= implode( + ",\n", + array_map( + static fn ($key, $value) => $key . ($value instanceof RawSql ? + ' = ' . $value : + ' = ' . $that->cast($alias . '.' . $value, $that->getFieldType($table, $key))), + array_keys($updateFields), + $updateFields + ) + ) . "\n"; + + $sql .= "FROM (\n{:_table_:}"; + + $sql .= ') ' . $alias . "\n"; + + $sql .= 'WHERE ' . implode( + ' AND ', + array_map( + static function ($key, $value) use ($table, $alias, $that) { + if ($value instanceof RawSql && is_string($key)) { + return $table . '.' . $key . ' = ' . $value; + } + + if ($value instanceof RawSql) { + return $value; + } + + return $table . '.' . $value . ' = ' + . $that->cast($alias . '.' . $value, $that->getFieldType($table, $value)); + }, + array_keys($constraints), + $constraints + ) + ); + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = implode( + " UNION ALL\n", + array_map( + static fn ($value) => 'SELECT ' . implode(', ', array_map( + static fn ($key, $index) => $index . ' ' . $key, + $keys, + $value + )), + $values + ) + ) . "\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Returns cast expression. + * + * @TODO move this to BaseBuilder in 4.5.0 + * + * @param float|int|string $expression + */ + private function cast($expression, ?string $type): string + { + return ($type === null) ? $expression : 'CAST(' . $expression . ' AS ' . strtoupper($type) . ')'; + } + + /** + * Returns the filed type from database meta data. + * + * @param string $table Protected table name. + * @param string $fieldName Field name. May be protected. + */ + private function getFieldType(string $table, string $fieldName): ?string + { + $fieldName = trim($fieldName, $this->db->escapeChar); + + if (! isset($this->QBOptions['fieldTypes'][$table])) { + $this->QBOptions['fieldTypes'][$table] = []; + + foreach ($this->db->getFieldData($table) as $field) { + $this->QBOptions['fieldTypes'][$table][$field->name] = $field->type; + } + } + + return $this->QBOptions['fieldTypes'][$table][$fieldName] ?? null; + } + + /** + * Generates a platform-specific upsertBatch string from the supplied data + * + * @throws DatabaseException + */ + protected function _upsertBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $fieldNames = array_map(static fn ($columnName) => trim($columnName, '"'), $keys); + + $constraints = $this->QBOptions['constraints'] ?? []; + + if (empty($constraints)) { + $allIndexes = array_filter($this->db->getIndexData($table), static function ($index) use ($fieldNames) { + $hasAllFields = count(array_intersect($index->fields, $fieldNames)) === count($index->fields); + + return ($index->type === 'UNIQUE' || $index->type === 'PRIMARY') && $hasAllFields; + }); + + foreach (array_map(static fn ($index) => $index->fields, $allIndexes) as $index) { + $constraints[] = current($index); + // only one index can be used? + break; + } + + $constraints = $this->onConstraint($constraints)->QBOptions['constraints'] ?? []; + } + + if (empty($constraints)) { + if ($this->db->DBDebug) { + throw new DatabaseException('No constraint found for upsert.'); + } + + return ''; // @codeCoverageIgnore + } + + // in value set - replace null with DEFAULT where constraint is presumed not null + // autoincrement identity field must use DEFAULT and not NULL + // this could be removed in favour of leaving to developer but does make things easier and function like other DBMS + foreach ($constraints as $constraint) { + $key = array_search(trim($constraint, '"'), $fieldNames, true); + + if ($key !== false) { + foreach ($values as $arrayKey => $value) { + if (strtoupper($value[$key]) === 'NULL') { + $values[$arrayKey][$key] = 'DEFAULT'; + } + } + } + } + + $alias = $this->QBOptions['alias'] ?? '"excluded"'; + + if (strtolower($alias) !== '"excluded"') { + throw new InvalidArgumentException('Postgres alias is always named "excluded". A custom alias cannot be used.'); + } + + $updateFields = $this->QBOptions['updateFields'] ?? $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ?? []; + + $sql = 'INSERT INTO ' . $table . ' ('; + + $sql .= implode(', ', $keys); + + $sql .= ")\n"; + + $sql .= '{:_table_:}'; + + $sql .= 'ON CONFLICT(' . implode(',', $constraints) . ")\n"; + + $sql .= "DO UPDATE SET\n"; + + $sql .= implode( + ",\n", + array_map( + static fn ($key, $value) => $key . ($value instanceof RawSql ? + " = {$value}" : + " = {$alias}.{$value}"), + array_keys($updateFields), + $updateFields + ) + ); + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Generates a platform-specific batch update string from the supplied data + */ + protected function _deleteBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $constraints = $this->QBOptions['constraints'] ?? []; + + if ($constraints === []) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must specify a constraint to match on for batch deletes.'); // @codeCoverageIgnore + } + + return ''; // @codeCoverageIgnore + } + + $alias = $this->QBOptions['alias'] ?? '_u'; + + $sql = 'DELETE FROM ' . $table . "\n"; + + $sql .= "USING (\n{:_table_:}"; + + $sql .= ') ' . $alias . "\n"; + + $that = $this; + $sql .= 'WHERE ' . implode( + ' AND ', + array_map( + static function ($key, $value) use ($table, $alias, $that) { + if ($value instanceof RawSql) { + return $value; + } + + if (is_string($key)) { + return $table . '.' . $key . ' = ' + . $that->cast( + $alias . '.' . $value, + $that->getFieldType($table, $key) + ); + } + + return $table . '.' . $value . ' = ' . $alias . '.' . $value; + }, + array_keys($constraints), + $constraints + ) + ); + + // convert binds in where + foreach ($this->QBWhere as $key => $where) { + foreach ($this->binds as $field => $bind) { + $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']); + } + } + + $sql .= ' ' . str_replace( + 'WHERE ', + 'AND ', + $this->compileWhereHaving('QBWhere') + ); + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = implode( + " UNION ALL\n", + array_map( + static fn ($value) => 'SELECT ' . implode(', ', array_map( + static fn ($key, $index) => $index . ' ' . $key, + $keys, + $value + )), + $values + ) + ) . "\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } +} diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php new file mode 100644 index 0000000..56905ec --- /dev/null +++ b/system/Database/Postgre/Connection.php @@ -0,0 +1,586 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Postgre; + +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\RawSql; +use ErrorException; +use PgSql\Connection as PgSqlConnection; +use PgSql\Result as PgSqlResult; +use stdClass; + +/** + * Connection for Postgre + * + * @extends BaseConnection + */ +class Connection extends BaseConnection +{ + /** + * Database driver + * + * @var string + */ + public $DBDriver = 'Postgre'; + + /** + * Database schema + * + * @var string + */ + public $schema = 'public'; + + /** + * Identifier escape character + * + * @var string + */ + public $escapeChar = '"'; + + protected $connect_timeout; + protected $options; + protected $sslmode; + protected $service; + + /** + * Connect to the database. + * + * @return false|resource + * @phpstan-return false|PgSqlConnection + */ + public function connect(bool $persistent = false) + { + if (empty($this->DSN)) { + $this->buildDSN(); + } + + // Convert DSN string + if (mb_strpos($this->DSN, 'pgsql:') === 0) { + $this->convertDSN(); + } + + $this->connID = $persistent === true ? pg_pconnect($this->DSN) : pg_connect($this->DSN); + + if ($this->connID !== false) { + if ($persistent === true && pg_connection_status($this->connID) === PGSQL_CONNECTION_BAD && pg_ping($this->connID) === false + ) { + return false; + } + + if (! empty($this->schema)) { + $this->simpleQuery("SET search_path TO {$this->schema},public"); + } + + if ($this->setClientEncoding($this->charset) === false) { + return false; + } + } + + return $this->connID; + } + + /** + * Converts the DSN with semicolon syntax. + */ + private function convertDSN() + { + // Strip pgsql + $this->DSN = mb_substr($this->DSN, 6); + + // Convert semicolons to spaces in DSN format like: + // pgsql:host=localhost;port=5432;dbname=database_name + // https://www.php.net/manual/en/function.pg-connect.php + $allowedParams = ['host', 'port', 'dbname', 'user', 'password', 'connect_timeout', 'options', 'sslmode', 'service']; + + $parameters = explode(';', $this->DSN); + + $output = ''; + $previousParameter = ''; + + foreach ($parameters as $parameter) { + [$key, $value] = explode('=', $parameter, 2); + if (in_array($key, $allowedParams, true)) { + if ($previousParameter !== '') { + if (array_search($key, $allowedParams, true) < array_search($previousParameter, $allowedParams, true)) { + $output .= ';'; + } else { + $output .= ' '; + } + } + $output .= $parameter; + $previousParameter = $key; + } else { + $output .= ';' . $parameter; + } + } + + $this->DSN = $output; + } + + /** + * Keep or establish the connection if no queries have been sent for + * a length of time exceeding the server's idle timeout. + */ + public function reconnect() + { + if (pg_ping($this->connID) === false) { + $this->connID = false; + } + } + + /** + * Close the database connection. + */ + protected function _close() + { + pg_close($this->connID); + } + + /** + * Select a specific database table to use. + */ + public function setDatabase(string $databaseName): bool + { + return false; + } + + /** + * Returns a string containing the version of the database being used. + */ + public function getVersion(): string + { + if (isset($this->dataCache['version'])) { + return $this->dataCache['version']; + } + + if (! $this->connID) { + $this->initialize(); + } + + $pgVersion = pg_version($this->connID); + $this->dataCache['version'] = isset($pgVersion['server']) ? + (preg_match('/^(\d+\.\d+)/', $pgVersion['server'], $matches) ? $matches[1] : '') : + ''; + + return $this->dataCache['version']; + } + + /** + * Executes the query against the database. + * + * @return false|resource + * @phpstan-return false|PgSqlResult + */ + protected function execute(string $sql) + { + try { + return pg_query($this->connID, $sql); + } catch (ErrorException $e) { + log_message('error', (string) $e); + + if ($this->DBDebug) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + } + + return false; + } + + /** + * Get the prefix of the function to access the DB. + */ + protected function getDriverFunctionPrefix(): string + { + return 'pg_'; + } + + /** + * Returns the total number of rows affected by this query. + */ + public function affectedRows(): int + { + return pg_affected_rows($this->resultID); + } + + /** + * "Smart" Escape String + * + * Escapes data based on type + * + * @param array|bool|float|int|object|string|null $str + * + * @return array|float|int|string + * @phpstan-return ($str is array ? array : float|int|string) + */ + public function escape($str) + { + if (! $this->connID) { + $this->initialize(); + } + + /** @psalm-suppress NoValue I don't know why ERROR. */ + if (is_string($str) || (is_object($str) && method_exists($str, '__toString'))) { + if ($str instanceof RawSql) { + return $str->__toString(); + } + + return pg_escape_literal($this->connID, $str); + } + + if (is_bool($str)) { + return $str ? 'TRUE' : 'FALSE'; + } + + /** @psalm-suppress NoValue I don't know why ERROR. */ + return parent::escape($str); + } + + /** + * Platform-dependant string escape + */ + protected function _escapeString(string $str): string + { + if (! $this->connID) { + $this->initialize(); + } + + return pg_escape_string($this->connID, $str); + } + + /** + * Generates the SQL for listing tables in a platform-dependent manner. + * + * @param string|null $tableName If $tableName is provided will return only this table if exists. + */ + protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string + { + $sql = 'SELECT "table_name" FROM "information_schema"."tables" WHERE "table_schema" = \'' . $this->schema . "'"; + + if ($tableName !== null) { + return $sql . ' AND "table_name" LIKE ' . $this->escape($tableName); + } + + if ($prefixLimit !== false && $this->DBPrefix !== '') { + return $sql . ' AND "table_name" LIKE \'' + . $this->escapeLikeString($this->DBPrefix) . "%' " + . sprintf($this->likeEscapeStr, $this->likeEscapeChar); + } + + return $sql; + } + + /** + * Generates a platform-specific query string so that the column names can be fetched. + */ + protected function _listColumns(string $table = ''): string + { + return 'SELECT "column_name" + FROM "information_schema"."columns" + WHERE LOWER("table_name") = ' + . $this->escape($this->DBPrefix . strtolower($table)) + . ' ORDER BY "ordinal_position"'; + } + + /** + * Returns an array of objects with field data + * + * @return stdClass[] + * + * @throws DatabaseException + */ + protected function _fieldData(string $table): array + { + $sql = 'SELECT "column_name", "data_type", "character_maximum_length", "numeric_precision", "column_default", "is_nullable" + FROM "information_schema"."columns" + WHERE LOWER("table_name") = ' + . $this->escape(strtolower($table)) + . ' ORDER BY "ordinal_position"'; + + if (($query = $this->query($sql)) === false) { + throw new DatabaseException(lang('Database.failGetFieldData')); + } + $query = $query->getResultObject(); + + $retVal = []; + + for ($i = 0, $c = count($query); $i < $c; $i++) { + $retVal[$i] = new stdClass(); + + $retVal[$i]->name = $query[$i]->column_name; + $retVal[$i]->type = $query[$i]->data_type; + $retVal[$i]->nullable = $query[$i]->is_nullable === 'YES'; + $retVal[$i]->default = $query[$i]->column_default; + $retVal[$i]->max_length = $query[$i]->character_maximum_length > 0 ? $query[$i]->character_maximum_length : $query[$i]->numeric_precision; + } + + return $retVal; + } + + /** + * Returns an array of objects with index data + * + * @return stdClass[] + * + * @throws DatabaseException + */ + protected function _indexData(string $table): array + { + $sql = 'SELECT "indexname", "indexdef" + FROM "pg_indexes" + WHERE LOWER("tablename") = ' . $this->escape(strtolower($table)) . ' + AND "schemaname" = ' . $this->escape('public'); + + if (($query = $this->query($sql)) === false) { + throw new DatabaseException(lang('Database.failGetIndexData')); + } + $query = $query->getResultObject(); + + $retVal = []; + + foreach ($query as $row) { + $obj = new stdClass(); + $obj->name = $row->indexname; + $_fields = explode(',', preg_replace('/^.*\((.+?)\)$/', '$1', trim($row->indexdef))); + $obj->fields = array_map(static fn ($v) => trim($v), $_fields); + + if (strpos($row->indexdef, 'CREATE UNIQUE INDEX pk') === 0) { + $obj->type = 'PRIMARY'; + } else { + $obj->type = (strpos($row->indexdef, 'CREATE UNIQUE') === 0) ? 'UNIQUE' : 'INDEX'; + } + + $retVal[$obj->name] = $obj; + } + + return $retVal; + } + + /** + * Returns an array of objects with Foreign key data + * + * @return stdClass[] + * + * @throws DatabaseException + */ + protected function _foreignKeyData(string $table): array + { + $sql = 'SELECT c.constraint_name, + x.table_name, + x.column_name, + y.table_name as foreign_table_name, + y.column_name as foreign_column_name, + c.delete_rule, + c.update_rule, + c.match_option + FROM information_schema.referential_constraints c + JOIN information_schema.key_column_usage x + on x.constraint_name = c.constraint_name + JOIN information_schema.key_column_usage y + on y.ordinal_position = x.position_in_unique_constraint + and y.constraint_name = c.unique_constraint_name + WHERE x.table_name = ' . $this->escape($table) . + 'order by c.constraint_name, x.ordinal_position'; + + if (($query = $this->query($sql)) === false) { + throw new DatabaseException(lang('Database.failGetForeignKeyData')); + } + + $query = $query->getResultObject(); + $indexes = []; + + foreach ($query as $row) { + $indexes[$row->constraint_name]['constraint_name'] = $row->constraint_name; + $indexes[$row->constraint_name]['table_name'] = $table; + $indexes[$row->constraint_name]['column_name'][] = $row->column_name; + $indexes[$row->constraint_name]['foreign_table_name'] = $row->foreign_table_name; + $indexes[$row->constraint_name]['foreign_column_name'][] = $row->foreign_column_name; + $indexes[$row->constraint_name]['on_delete'] = $row->delete_rule; + $indexes[$row->constraint_name]['on_update'] = $row->update_rule; + $indexes[$row->constraint_name]['match'] = $row->match_option; + } + + return $this->foreignKeyDataToObjects($indexes); + } + + /** + * Returns platform-specific SQL to disable foreign key checks. + * + * @return string + */ + protected function _disableForeignKeyChecks() + { + return 'SET CONSTRAINTS ALL DEFERRED'; + } + + /** + * Returns platform-specific SQL to enable foreign key checks. + * + * @return string + */ + protected function _enableForeignKeyChecks() + { + return 'SET CONSTRAINTS ALL IMMEDIATE;'; + } + + /** + * Returns the last error code and message. + * Must return this format: ['code' => string|int, 'message' => string] + * intval(code) === 0 means "no error". + * + * @return array + */ + public function error(): array + { + return [ + 'code' => '', + 'message' => pg_last_error($this->connID) ?: '', + ]; + } + + /** + * @return int|string + */ + public function insertID() + { + $v = pg_version($this->connID); + // 'server' key is only available since PostgreSQL 7.4 + $v = explode(' ', $v['server'])[0] ?? 0; + + $table = func_num_args() > 0 ? func_get_arg(0) : null; + $column = func_num_args() > 1 ? func_get_arg(1) : null; + + if ($table === null && $v >= '8.1') { + $sql = 'SELECT LASTVAL() AS ins_id'; + } elseif ($table !== null) { + if ($column !== null && $v >= '8.0') { + $sql = "SELECT pg_get_serial_sequence('{$table}', '{$column}') AS seq"; + $query = $this->query($sql); + $query = $query->getRow(); + $seq = $query->seq; + } else { + // seq_name passed in table parameter + $seq = $table; + } + + $sql = "SELECT CURRVAL('{$seq}') AS ins_id"; + } else { + return pg_last_oid($this->resultID); + } + + $query = $this->query($sql); + $query = $query->getRow(); + + return (int) $query->ins_id; + } + + /** + * Build a DSN from the provided parameters + */ + protected function buildDSN() + { + if ($this->DSN !== '') { + $this->DSN = ''; + } + + // If UNIX sockets are used, we shouldn't set a port + if (strpos($this->hostname, '/') !== false) { + $this->port = ''; + } + + if ($this->hostname !== '') { + $this->DSN = "host={$this->hostname} "; + } + + // ctype_digit only accepts strings + $port = (string) $this->port; + + if ($port !== '' && ctype_digit($port)) { + $this->DSN .= "port={$port} "; + } + + if ($this->username !== '') { + $this->DSN .= "user={$this->username} "; + + // An empty password is valid! + // password must be set to null to ignore it. + if ($this->password !== null) { + $this->DSN .= "password='{$this->password}' "; + } + } + + if ($this->database !== '') { + $this->DSN .= "dbname={$this->database} "; + } + + // We don't have these options as elements in our standard configuration + // array, but they might be set by parse_url() if the configuration was + // provided via string> Example: + // + // Postgre://username:password@localhost:5432/database?connect_timeout=5&sslmode=1 + foreach (['connect_timeout', 'options', 'sslmode', 'service'] as $key) { + if (isset($this->{$key}) && is_string($this->{$key}) && $this->{$key} !== '') { + $this->DSN .= "{$key}='{$this->{$key}}' "; + } + } + + $this->DSN = rtrim($this->DSN); + } + + /** + * Set client encoding + */ + protected function setClientEncoding(string $charset): bool + { + return pg_set_client_encoding($this->connID, $charset) === 0; + } + + /** + * Begin Transaction + */ + protected function _transBegin(): bool + { + return (bool) pg_query($this->connID, 'BEGIN'); + } + + /** + * Commit Transaction + */ + protected function _transCommit(): bool + { + return (bool) pg_query($this->connID, 'COMMIT'); + } + + /** + * Rollback Transaction + */ + protected function _transRollback(): bool + { + return (bool) pg_query($this->connID, 'ROLLBACK'); + } + + /** + * Determines if a query is a "write" type. + * + * Overrides BaseConnection::isWriteType, adding additional read query types. + * + * @param string $sql + */ + public function isWriteType($sql): bool + { + if (preg_match('#^(INSERT|UPDATE).*RETURNING\s.+(\,\s?.+)*$#is', $sql)) { + return false; + } + + return parent::isWriteType($sql); + } +} diff --git a/system/Database/Postgre/Forge.php b/system/Database/Postgre/Forge.php new file mode 100644 index 0000000..ee75836 --- /dev/null +++ b/system/Database/Postgre/Forge.php @@ -0,0 +1,218 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Postgre; + +use CodeIgniter\Database\Forge as BaseForge; + +/** + * Forge for Postgre + */ +class Forge extends BaseForge +{ + /** + * CHECK DATABASE EXIST statement + * + * @var string + */ + protected $checkDatabaseExistStr = 'SELECT 1 FROM pg_database WHERE datname = ?'; + + /** + * DROP CONSTRAINT statement + * + * @var string + */ + protected $dropConstraintStr = 'ALTER TABLE %s DROP CONSTRAINT %s'; + + /** + * DROP INDEX statement + * + * @var string + */ + protected $dropIndexStr = 'DROP INDEX %s'; + + /** + * UNSIGNED support + * + * @var array + */ + protected $_unsigned = [ + 'INT2' => 'INTEGER', + 'SMALLINT' => 'INTEGER', + 'INT' => 'BIGINT', + 'INT4' => 'BIGINT', + 'INTEGER' => 'BIGINT', + 'INT8' => 'NUMERIC', + 'BIGINT' => 'NUMERIC', + 'REAL' => 'DOUBLE PRECISION', + 'FLOAT' => 'DOUBLE PRECISION', + ]; + + /** + * NULL value representation in CREATE/ALTER TABLE statements + * + * @var string + * + * @internal + */ + protected $null = 'NULL'; + + /** + * @var Connection + */ + protected $db; + + /** + * CREATE TABLE attributes + * + * @param array $attributes Associative array of table attributes + */ + protected function _createTableAttributes(array $attributes): string + { + return ''; + } + + /** + * @param array|string $processedFields Processed column definitions + * or column names to DROP + * + * @return false|list|string SQL string or false + * @phpstan-return ($alterType is 'DROP' ? string : list|false) + */ + protected function _alterTable(string $alterType, string $table, $processedFields) + { + if (in_array($alterType, ['DROP', 'ADD'], true)) { + return parent::_alterTable($alterType, $table, $processedFields); + } + + $sql = 'ALTER TABLE ' . $this->db->escapeIdentifiers($table); + $sqls = []; + + foreach ($processedFields as $field) { + if ($field['_literal'] !== false) { + return false; + } + + if (version_compare($this->db->getVersion(), '8', '>=') && isset($field['type'])) { + $sqls[] = $sql . ' ALTER COLUMN ' . $this->db->escapeIdentifiers($field['name']) + . " TYPE {$field['type']}{$field['length']}"; + } + + if (! empty($field['default'])) { + $sqls[] = $sql . ' ALTER COLUMN ' . $this->db->escapeIdentifiers($field['name']) + . " SET DEFAULT {$field['default']}"; + } + + $nullable = true; // Nullable by default. + if (isset($field['null']) && ($field['null'] === false || $field['null'] === ' NOT ' . $this->null)) { + $nullable = false; + } + $sqls[] = $sql . ' ALTER COLUMN ' . $this->db->escapeIdentifiers($field['name']) + . ($nullable === true ? ' DROP' : ' SET') . ' NOT NULL'; + + if (! empty($field['new_name'])) { + $sqls[] = $sql . ' RENAME COLUMN ' . $this->db->escapeIdentifiers($field['name']) + . ' TO ' . $this->db->escapeIdentifiers($field['new_name']); + } + + if (! empty($field['comment'])) { + $sqls[] = 'COMMENT ON COLUMN' . $this->db->escapeIdentifiers($table) + . '.' . $this->db->escapeIdentifiers($field['name']) + . " IS {$field['comment']}"; + } + } + + return $sqls; + } + + /** + * Process column + */ + protected function _processColumn(array $processedField): string + { + return $this->db->escapeIdentifiers($processedField['name']) + . ' ' . $processedField['type'] . ($processedField['type'] === 'text' ? '' : $processedField['length']) + . $processedField['default'] + . $processedField['null'] + . $processedField['auto_increment'] + . $processedField['unique']; + } + + /** + * Performs a data type mapping between different databases. + */ + protected function _attributeType(array &$attributes) + { + // Reset field lengths for data types that don't support it + if (isset($attributes['CONSTRAINT']) && stripos($attributes['TYPE'], 'int') !== false) { + $attributes['CONSTRAINT'] = null; + } + + switch (strtoupper($attributes['TYPE'])) { + case 'TINYINT': + $attributes['TYPE'] = 'SMALLINT'; + $attributes['UNSIGNED'] = false; + break; + + case 'MEDIUMINT': + $attributes['TYPE'] = 'INTEGER'; + $attributes['UNSIGNED'] = false; + break; + + case 'DATETIME': + $attributes['TYPE'] = 'TIMESTAMP'; + break; + + default: + break; + } + } + + /** + * Field attribute AUTO_INCREMENT + */ + protected function _attributeAutoIncrement(array &$attributes, array &$field) + { + if (! empty($attributes['AUTO_INCREMENT']) && $attributes['AUTO_INCREMENT'] === true) { + $field['type'] = $field['type'] === 'NUMERIC' || $field['type'] === 'BIGINT' ? 'BIGSERIAL' : 'SERIAL'; + } + } + + /** + * Generates a platform-specific DROP TABLE string + */ + protected function _dropTable(string $table, bool $ifExists, bool $cascade): string + { + $sql = parent::_dropTable($table, $ifExists, $cascade); + + if ($cascade === true) { + $sql .= ' CASCADE'; + } + + return $sql; + } + + /** + * Constructs sql to check if key is a constraint. + */ + protected function _dropKeyAsConstraint(string $table, string $constraintName): string + { + return "SELECT con.conname + FROM pg_catalog.pg_constraint con + INNER JOIN pg_catalog.pg_class rel + ON rel.oid = con.conrelid + INNER JOIN pg_catalog.pg_namespace nsp + ON nsp.oid = connamespace + WHERE nsp.nspname = '{$this->db->schema}' + AND rel.relname = '" . trim($table, '"') . "' + AND con.conname = '" . trim($constraintName, '"') . "'"; + } +} diff --git a/system/Database/Postgre/PreparedQuery.php b/system/Database/Postgre/PreparedQuery.php new file mode 100644 index 0000000..fd60793 --- /dev/null +++ b/system/Database/Postgre/PreparedQuery.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Postgre; + +use BadMethodCallException; +use CodeIgniter\Database\BasePreparedQuery; +use CodeIgniter\Database\Exceptions\DatabaseException; +use Exception; +use PgSql\Connection as PgSqlConnection; +use PgSql\Result as PgSqlResult; + +/** + * Prepared query for Postgre + * + * @extends BasePreparedQuery + */ +class PreparedQuery extends BasePreparedQuery +{ + /** + * Stores the name this query can be + * used under by postgres. Only used internally. + * + * @var string + */ + protected $name; + + /** + * The result resource from a successful + * pg_exec. Or false. + * + * @var false|PgSqlResult + */ + protected $result; + + /** + * Prepares the query against the database, and saves the connection + * info necessary to execute the query later. + * + * NOTE: This version is based on SQL code. Child classes should + * override this method. + * + * @param array $options Passed to the connection's prepare statement. + * Unused in the MySQLi driver. + * + * @throws Exception + */ + public function _prepare(string $sql, array $options = []): PreparedQuery + { + $this->name = (string) random_int(1, 10_000_000_000_000_000); + + $sql = $this->parameterize($sql); + + // Update the query object since the parameters are slightly different + // than what was put in. + $this->query->setQuery($sql); + + if (! $this->statement = pg_prepare($this->db->connID, $this->name, $sql)) { + $this->errorCode = 0; + $this->errorString = pg_last_error($this->db->connID); + + if ($this->db->DBDebug) { + throw new DatabaseException($this->errorString . ' code: ' . $this->errorCode); + } + } + + return $this; + } + + /** + * Takes a new set of data and runs it against the currently + * prepared query. Upon success, will return a Results object. + */ + public function _execute(array $data): bool + { + if (! isset($this->statement)) { + throw new BadMethodCallException('You must call prepare before trying to execute a prepared statement.'); + } + + $this->result = pg_execute($this->db->connID, $this->name, $data); + + return (bool) $this->result; + } + + /** + * Returns the result object for the prepared query or false on failure. + * + * @return resource|null + * @phpstan-return PgSqlResult|null + */ + public function _getResult() + { + return $this->result; + } + + /** + * Deallocate prepared statements. + */ + protected function _close(): bool + { + return pg_query($this->db->connID, 'DEALLOCATE "' . $this->db->escapeIdentifiers($this->name) . '"') !== false; + } + + /** + * Replaces the ? placeholders with $1, $2, etc parameters for use + * within the prepared query. + */ + public function parameterize(string $sql): string + { + // Track our current value + $count = 0; + + return preg_replace_callback('/\?/', static function () use (&$count) { + $count++; + + return "\${$count}"; + }, $sql); + } +} diff --git a/system/Database/Postgre/Result.php b/system/Database/Postgre/Result.php new file mode 100644 index 0000000..0a82875 --- /dev/null +++ b/system/Database/Postgre/Result.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Postgre; + +use CodeIgniter\Database\BaseResult; +use CodeIgniter\Entity\Entity; +use PgSql\Connection as PgSqlConnection; +use PgSql\Result as PgSqlResult; +use stdClass; + +/** + * Result for Postgre + * + * @extends BaseResult + */ +class Result extends BaseResult +{ + /** + * Gets the number of fields in the result set. + */ + public function getFieldCount(): int + { + return pg_num_fields($this->resultID); + } + + /** + * Generates an array of column names in the result set. + */ + public function getFieldNames(): array + { + $fieldNames = []; + + for ($i = 0, $c = $this->getFieldCount(); $i < $c; $i++) { + $fieldNames[] = pg_field_name($this->resultID, $i); + } + + return $fieldNames; + } + + /** + * Generates an array of objects representing field meta-data. + */ + public function getFieldData(): array + { + $retVal = []; + + for ($i = 0, $c = $this->getFieldCount(); $i < $c; $i++) { + $retVal[$i] = new stdClass(); + $retVal[$i]->name = pg_field_name($this->resultID, $i); + $retVal[$i]->type = pg_field_type_oid($this->resultID, $i); + $retVal[$i]->type_name = pg_field_type($this->resultID, $i); + $retVal[$i]->max_length = pg_field_size($this->resultID, $i); + $retVal[$i]->length = $retVal[$i]->max_length; + // $retVal[$i]->primary_key = (int)($fieldData[$i]->flags & 2); + // $retVal[$i]->default = $fieldData[$i]->def; + } + + return $retVal; + } + + /** + * Frees the current result. + * + * @return void + */ + public function freeResult() + { + if ($this->resultID !== false) { + pg_free_result($this->resultID); + $this->resultID = false; + } + } + + /** + * Moves the internal pointer to the desired offset. This is called + * internally before fetching results to make sure the result set + * starts at zero. + * + * @return bool + */ + public function dataSeek(int $n = 0) + { + return pg_result_seek($this->resultID, $n); + } + + /** + * Returns the result set as an array. + * + * Overridden by driver classes. + * + * @return array|false + */ + protected function fetchAssoc() + { + return pg_fetch_assoc($this->resultID); + } + + /** + * Returns the result set as an object. + * + * Overridden by child classes. + * + * @return Entity|false|object|stdClass + */ + protected function fetchObject(string $className = 'stdClass') + { + if (is_subclass_of($className, Entity::class)) { + return empty($data = $this->fetchAssoc()) ? false : (new $className())->injectRawData($data); + } + + return pg_fetch_object($this->resultID, null, $className); + } + + /** + * Returns the number of rows in the resultID (i.e., PostgreSQL query result resource) + */ + public function getNumRows(): int + { + if (! is_int($this->numRows)) { + $this->numRows = pg_num_rows($this->resultID); + } + + return $this->numRows; + } +} diff --git a/system/Database/Postgre/Utils.php b/system/Database/Postgre/Utils.php new file mode 100644 index 0000000..516104e --- /dev/null +++ b/system/Database/Postgre/Utils.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Postgre; + +use CodeIgniter\Database\BaseUtils; +use CodeIgniter\Database\Exceptions\DatabaseException; + +/** + * Utils for Postgre + */ +class Utils extends BaseUtils +{ + /** + * List databases statement + * + * @var string + */ + protected $listDatabases = 'SELECT datname FROM pg_database'; + + /** + * OPTIMIZE TABLE statement + * + * @var string + */ + protected $optimizeTable = 'REINDEX TABLE %s'; + + /** + * Platform dependent version of the backup function. + * + * @return never + */ + public function _backup(?array $prefs = null) + { + throw new DatabaseException('Unsupported feature of the database platform you are using.'); + } +} diff --git a/system/Database/PreparedQueryInterface.php b/system/Database/PreparedQueryInterface.php new file mode 100644 index 0000000..7b55991 --- /dev/null +++ b/system/Database/PreparedQueryInterface.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use BadMethodCallException; + +/** + * @template TConnection + * @template TStatement + * @template TResult + */ +interface PreparedQueryInterface +{ + /** + * Takes a new set of data and runs it against the currently + * prepared query. Upon success, will return a Results object. + * + * @return bool|ResultInterface + * @phpstan-return bool|ResultInterface + */ + public function execute(...$data); + + /** + * Prepares the query against the database, and saves the connection + * info necessary to execute the query later. + * + * @return $this + */ + public function prepare(string $sql, array $options = []); + + /** + * Explicity closes the statement. + * + * @throws BadMethodCallException + */ + public function close(): bool; + + /** + * Returns the SQL that has been prepared. + */ + public function getQueryString(): string; + + /** + * Returns the error code created while executing this statement. + */ + public function getErrorCode(): int; + + /** + * Returns the error message created while executing this statement. + */ + public function getErrorMessage(): string; +} diff --git a/system/Database/Query.php b/system/Database/Query.php new file mode 100644 index 0000000..a78190a --- /dev/null +++ b/system/Database/Query.php @@ -0,0 +1,427 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +/** + * Query builder + */ +class Query implements QueryInterface +{ + /** + * The query string, as provided by the user. + * + * @var string + */ + protected $originalQueryString; + + /** + * The query string if table prefix has been swapped. + * + * @var string|null + */ + protected $swappedQueryString; + + /** + * The final query string after binding, etc. + * + * @var string|null + */ + protected $finalQueryString; + + /** + * The binds and their values used for binding. + * + * @var array + */ + protected $binds = []; + + /** + * Bind marker + * + * Character used to identify values in a prepared statement. + * + * @var string + */ + protected $bindMarker = '?'; + + /** + * The start time in seconds with microseconds + * for when this query was executed. + * + * @var float|string + */ + protected $startTime; + + /** + * The end time in seconds with microseconds + * for when this query was executed. + * + * @var float + */ + protected $endTime; + + /** + * The error code, if any. + * + * @var int + */ + protected $errorCode; + + /** + * The error message, if any. + * + * @var string + */ + protected $errorString; + + /** + * Pointer to database connection. + * Mainly for escaping features. + * + * @var ConnectionInterface + */ + public $db; + + public function __construct(ConnectionInterface $db) + { + $this->db = $db; + } + + /** + * Sets the raw query string to use for this statement. + * + * @param mixed $binds + * + * @return $this + */ + public function setQuery(string $sql, $binds = null, bool $setEscape = true) + { + $this->originalQueryString = $sql; + unset($this->swappedQueryString); + + if ($binds !== null) { + if (! is_array($binds)) { + $binds = [$binds]; + } + + if ($setEscape) { + array_walk($binds, static function (&$item) { + $item = [ + $item, + true, + ]; + }); + } + $this->binds = $binds; + } + + unset($this->finalQueryString); + + return $this; + } + + /** + * Will store the variables to bind into the query later. + * + * @return $this + */ + public function setBinds(array $binds, bool $setEscape = true) + { + if ($setEscape) { + array_walk($binds, static function (&$item) { + $item = [$item, true]; + }); + } + + $this->binds = $binds; + + unset($this->finalQueryString); + + return $this; + } + + /** + * Returns the final, processed query string after binding, etal + * has been performed. + */ + public function getQuery(): string + { + if (empty($this->finalQueryString)) { + $this->compileBinds(); + } + + return $this->finalQueryString; + } + + /** + * Records the execution time of the statement using microtime(true) + * for it's start and end values. If no end value is present, will + * use the current time to determine total duration. + * + * @return $this + */ + public function setDuration(float $start, ?float $end = null) + { + $this->startTime = $start; + + if ($end === null) { + $end = microtime(true); + } + + $this->endTime = $end; + + return $this; + } + + /** + * Returns the start time in seconds with microseconds. + * + * @return float|string + */ + public function getStartTime(bool $returnRaw = false, int $decimals = 6) + { + if ($returnRaw) { + return $this->startTime; + } + + return number_format($this->startTime, $decimals); + } + + /** + * Returns the duration of this query during execution, or null if + * the query has not been executed yet. + * + * @param int $decimals The accuracy of the returned time. + */ + public function getDuration(int $decimals = 6): string + { + return number_format(($this->endTime - $this->startTime), $decimals); + } + + /** + * Stores the error description that happened for this query. + * + * @return $this + */ + public function setError(int $code, string $error) + { + $this->errorCode = $code; + $this->errorString = $error; + + return $this; + } + + /** + * Reports whether this statement created an error not. + */ + public function hasError(): bool + { + return ! empty($this->errorString); + } + + /** + * Returns the error code created while executing this statement. + */ + public function getErrorCode(): int + { + return $this->errorCode; + } + + /** + * Returns the error message created while executing this statement. + */ + public function getErrorMessage(): string + { + return $this->errorString; + } + + /** + * Determines if the statement is a write-type query or not. + */ + public function isWriteType(): bool + { + return $this->db->isWriteType($this->originalQueryString); + } + + /** + * Swaps out one table prefix for a new one. + * + * @return $this + */ + public function swapPrefix(string $orig, string $swap) + { + $sql = $this->swappedQueryString ?? $this->originalQueryString; + + $from = '/(\W)' . $orig . '(\S)/'; + $to = '\\1' . $swap . '\\2'; + + $this->swappedQueryString = preg_replace($from, $to, $sql); + + unset($this->finalQueryString); + + return $this; + } + + /** + * Returns the original SQL that was passed into the system. + */ + public function getOriginalQuery(): string + { + return $this->originalQueryString; + } + + /** + * Escapes and inserts any binds into the finalQueryString property. + * + * @see https://regex101.com/r/EUEhay/5 + */ + protected function compileBinds() + { + $sql = $this->swappedQueryString ?? $this->originalQueryString; + $binds = $this->binds; + + if (empty($binds)) { + $this->finalQueryString = $sql; + + return; + } + + if (is_int(array_key_first($binds))) { + $bindCount = count($binds); + $ml = strlen($this->bindMarker); + + $this->finalQueryString = $this->matchSimpleBinds($sql, $binds, $bindCount, $ml); + } else { + // Reverse the binds so that duplicate named binds + // will be processed prior to the original binds. + $binds = array_reverse($binds); + + $this->finalQueryString = $this->matchNamedBinds($sql, $binds); + } + } + + /** + * Match bindings + */ + protected function matchNamedBinds(string $sql, array $binds): string + { + $replacers = []; + + foreach ($binds as $placeholder => $value) { + // $value[1] contains the boolean whether should be escaped or not + $escapedValue = $value[1] ? $this->db->escape($value[0]) : $value[0]; + + // In order to correctly handle backlashes in saved strings + // we will need to preg_quote, so remove the wrapping escape characters + // otherwise it will get escaped. + if (is_array($value[0])) { + $escapedValue = '(' . implode(',', $escapedValue) . ')'; + } + + $replacers[":{$placeholder}:"] = $escapedValue; + } + + return strtr($sql, $replacers); + } + + /** + * Match bindings + */ + protected function matchSimpleBinds(string $sql, array $binds, int $bindCount, int $ml): string + { + if ($c = preg_match_all("/'[^']*'/", $sql, $matches)) { + $c = preg_match_all('/' . preg_quote($this->bindMarker, '/') . '/i', str_replace($matches[0], str_replace($this->bindMarker, str_repeat(' ', $ml), $matches[0]), $sql, $c), $matches, PREG_OFFSET_CAPTURE); + + // Bind values' count must match the count of markers in the query + if ($bindCount !== $c) { + return $sql; + } + } elseif (($c = preg_match_all('/' . preg_quote($this->bindMarker, '/') . '/i', $sql, $matches, PREG_OFFSET_CAPTURE)) !== $bindCount) { + return $sql; + } + + do { + $c--; + $escapedValue = $binds[$c][1] ? $this->db->escape($binds[$c][0]) : $binds[$c][0]; + + if (is_array($escapedValue)) { + $escapedValue = '(' . implode(',', $escapedValue) . ')'; + } + + $sql = substr_replace($sql, $escapedValue, $matches[0][$c][1], $ml); + } while ($c !== 0); + + return $sql; + } + + /** + * Returns string to display in debug toolbar + */ + public function debugToolbarDisplay(): string + { + // Key words we want bolded + static $highlight = [ + 'AND', + 'AS', + 'ASC', + 'AVG', + 'BY', + 'COUNT', + 'DESC', + 'DISTINCT', + 'FROM', + 'GROUP', + 'HAVING', + 'IN', + 'INNER', + 'INSERT', + 'INTO', + 'IS', + 'JOIN', + 'LEFT', + 'LIKE', + 'LIMIT', + 'MAX', + 'MIN', + 'NOT', + 'NULL', + 'OFFSET', + 'ON', + 'OR', + 'ORDER', + 'RIGHT', + 'SELECT', + 'SUM', + 'UPDATE', + 'VALUES', + 'WHERE', + ]; + + $sql = esc($this->getQuery()); + + /** + * @see https://stackoverflow.com/a/20767160 + * @see https://regex101.com/r/hUlrGN/4 + */ + $search = '/\b(?:' . implode('|', $highlight) . ')\b(?![^(')]*'(?:(?:[^(')]*'){2})*[^(')]*$)/'; + + return preg_replace_callback($search, static fn ($matches) => '' . str_replace(' ', ' ', $matches[0]) . '', $sql); + } + + /** + * Return text representation of the query + */ + public function __toString(): string + { + return $this->getQuery(); + } +} diff --git a/system/Database/QueryInterface.php b/system/Database/QueryInterface.php new file mode 100644 index 0000000..4f40f00 --- /dev/null +++ b/system/Database/QueryInterface.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +/** + * Interface QueryInterface + * + * Represents a single statement that can be executed against the database. + * Statements are platform-specific and can handle binding of binds. + */ +interface QueryInterface +{ + /** + * Sets the raw query string to use for this statement. + * + * @param mixed $binds + * + * @return mixed + */ + public function setQuery(string $sql, $binds = null, bool $setEscape = true); + + /** + * Returns the final, processed query string after binding, etal + * has been performed. + * + * @return mixed + */ + public function getQuery(); + + /** + * Records the execution time of the statement using microtime(true) + * for it's start and end values. If no end value is present, will + * use the current time to determine total duration. + * + * @return mixed + */ + public function setDuration(float $start, ?float $end = null); + + /** + * Returns the duration of this query during execution, or null if + * the query has not been executed yet. + * + * @param int $decimals The accuracy of the returned time. + */ + public function getDuration(int $decimals = 6): string; + + /** + * Stores the error description that happened for this query. + */ + public function setError(int $code, string $error); + + /** + * Reports whether this statement created an error not. + */ + public function hasError(): bool; + + /** + * Returns the error code created while executing this statement. + */ + public function getErrorCode(): int; + + /** + * Returns the error message created while executing this statement. + */ + public function getErrorMessage(): string; + + /** + * Determines if the statement is a write-type query or not. + */ + public function isWriteType(): bool; + + /** + * Swaps out one table prefix for a new one. + * + * @return mixed + */ + public function swapPrefix(string $orig, string $swap); +} diff --git a/system/Database/RawSql.php b/system/Database/RawSql.php new file mode 100644 index 0000000..efa97d3 --- /dev/null +++ b/system/Database/RawSql.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +/** + * @see \CodeIgniter\Database\RawSqlTest + */ +class RawSql +{ + /** + * @var string Raw SQL string + */ + private string $string; + + public function __construct(string $sqlString) + { + $this->string = $sqlString; + } + + public function __toString(): string + { + return $this->string; + } + + /** + * Create new instance with new SQL string + */ + public function with(string $newSqlString): self + { + $new = clone $this; + $new->string = $newSqlString; + + return $new; + } + + /** + * Returns unique id for binding key + */ + public function getBindingKey(): string + { + return 'RawSql' . spl_object_id($this); + } +} diff --git a/system/Database/ResultInterface.php b/system/Database/ResultInterface.php new file mode 100644 index 0000000..b06de85 --- /dev/null +++ b/system/Database/ResultInterface.php @@ -0,0 +1,174 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use stdClass; + +/** + * @template TConnection + * @template TResult + */ +interface ResultInterface +{ + /** + * Retrieve the results of the query. Typically an array of + * individual data rows, which can be either an 'array', an + * 'object', or a custom class name. + * + * @param string $type The row type. Either 'array', 'object', or a class name to use + */ + public function getResult(string $type = 'object'): array; + + /** + * Returns the results as an array of custom objects. + * + * @param string $className The name of the class to use. + * + * @return array + */ + public function getCustomResultObject(string $className); + + /** + * Returns the results as an array of arrays. + * + * If no results, an empty array is returned. + */ + public function getResultArray(): array; + + /** + * Returns the results as an array of objects. + * + * If no results, an empty array is returned. + */ + public function getResultObject(): array; + + /** + * Wrapper object to return a row as either an array, an object, or + * a custom class. + * + * If row doesn't exist, returns null. + * + * @param int|string $n The index of the results to return, or column name. + * @param string $type The type of result object. 'array', 'object' or class name. + * @phpstan-param class-string|'array'|'object' $type + * + * @return array|object|stdClass|null + * @phpstan-return ($type is 'object' ? stdClass|null : ($type is 'array' ? array|null : object|null)) + */ + public function getRow($n = 0, string $type = 'object'); + + /** + * Returns a row as a custom class instance. + * + * If row doesn't exists, returns null. + * + * @return array|null + */ + public function getCustomRowObject(int $n, string $className); + + /** + * Returns a single row from the results as an array. + * + * If row doesn't exist, returns null. + * + * @return array|null + */ + public function getRowArray(int $n = 0); + + /** + * Returns a single row from the results as an object. + * + * If row doesn't exist, returns null. + * + * @return object|stdClass|null + */ + public function getRowObject(int $n = 0); + + /** + * Assigns an item into a particular column slot. + * + * @param array|string $key + * @param array|object|stdClass|null $value + * + * @return void + */ + public function setRow($key, $value = null); + + /** + * Returns the "first" row of the current results. + * + * @return array|object|null + */ + public function getFirstRow(string $type = 'object'); + + /** + * Returns the "last" row of the current results. + * + * @return array|object|null + */ + public function getLastRow(string $type = 'object'); + + /** + * Returns the "next" row of the current results. + * + * @return array|object|null + */ + public function getNextRow(string $type = 'object'); + + /** + * Returns the "previous" row of the current results. + * + * @return array|object|null + */ + public function getPreviousRow(string $type = 'object'); + + /** + * Returns number of rows in the result set. + */ + public function getNumRows(): int; + + /** + * Returns an unbuffered row and move the pointer to the next row. + * + * @return array|object|null + */ + public function getUnbufferedRow(string $type = 'object'); + + /** + * Gets the number of fields in the result set. + */ + public function getFieldCount(): int; + + /** + * Generates an array of column names in the result set. + */ + public function getFieldNames(): array; + + /** + * Generates an array of objects representing field meta-data. + */ + public function getFieldData(): array; + + /** + * Frees the current result. + */ + public function freeResult(); + + /** + * Moves the internal pointer to the desired offset. This is called + * internally before fetching results to make sure the result set + * starts at zero. + * + * @return bool + */ + public function dataSeek(int $n = 0); +} diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php new file mode 100644 index 0000000..c86e826 --- /dev/null +++ b/system/Database/SQLSRV/Builder.php @@ -0,0 +1,775 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\SQLSRV; + +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Database\RawSql; +use CodeIgniter\Database\ResultInterface; + +/** + * Builder for SQLSRV + * + * @todo auto check for TextCastToInt + * @todo auto check for InsertIndexValue + * @todo replace: delete index entries before insert + */ +class Builder extends BaseBuilder +{ + /** + * ORDER BY random keyword + * + * @var array + */ + protected $randomKeyword = [ + 'NEWID()', + 'RAND(%d)', + ]; + + /** + * Quoted identifier flag + * + * Whether to use SQL-92 standard quoted identifier + * (double quotes) or brackets for identifier escaping. + * + * @var bool + */ + protected $_quoted_identifier = true; + + /** + * Handle increment/decrement on text + * + * @var bool + */ + public $castTextToInt = true; + + /** + * Handle IDENTITY_INSERT property/ + * + * @var bool + */ + public $keyPermission = false; + + /** + * Groups tables in FROM clauses if needed, so there is no confusion + * about operator precedence. + */ + protected function _fromTables(): string + { + $from = []; + + foreach ($this->QBFrom as $value) { + $from[] = strpos($value, '(SELECT') === 0 ? $value : $this->getFullName($value); + } + + return implode(', ', $from); + } + + /** + * Generates a platform-specific truncate string from the supplied data + * + * If the database does not support the truncate() command, + * then this method maps to 'DELETE FROM table' + */ + protected function _truncate(string $table): string + { + return 'TRUNCATE TABLE ' . $this->getFullName($table); + } + + /** + * Generates the JOIN portion of the query + * + * @param RawSql|string $cond + * + * @return $this + */ + public function join(string $table, $cond, string $type = '', ?bool $escape = null) + { + if ($type !== '') { + $type = strtoupper(trim($type)); + + if (! in_array($type, $this->joinTypes, true)) { + $type = ''; + } else { + $type .= ' '; + } + } + + // Extract any aliases that might exist. We use this information + // in the protectIdentifiers to know whether to add a table prefix + $this->trackAliases($table); + + if (! is_bool($escape)) { + $escape = $this->db->protectIdentifiers; + } + + if (! $this->hasOperator($cond)) { + $cond = ' USING (' . ($escape ? $this->db->escapeIdentifiers($cond) : $cond) . ')'; + } elseif ($escape === false) { + $cond = ' ON ' . $cond; + } else { + // Split multiple conditions + if (preg_match_all('/\sAND\s|\sOR\s/i', $cond, $joints, PREG_OFFSET_CAPTURE)) { + $conditions = []; + $joints = $joints[0]; + array_unshift($joints, ['', 0]); + + for ($i = count($joints) - 1, $pos = strlen($cond); $i >= 0; $i--) { + $joints[$i][1] += strlen($joints[$i][0]); // offset + $conditions[$i] = substr($cond, $joints[$i][1], $pos - $joints[$i][1]); + $pos = $joints[$i][1] - strlen($joints[$i][0]); + $joints[$i] = $joints[$i][0]; + } + + ksort($conditions); + } else { + $conditions = [$cond]; + $joints = ['']; + } + + $cond = ' ON '; + + foreach ($conditions as $i => $condition) { + $operator = $this->getOperator($condition); + + $cond .= $joints[$i]; + $cond .= preg_match('/(\(*)?([\[\]\w\.\'-]+)' . preg_quote($operator, '/') . '(.*)/i', $condition, $match) ? $match[1] . $this->db->protectIdentifiers($match[2]) . $operator . $this->db->protectIdentifiers($match[3]) : $condition; + } + } + + // Do we want to escape the table name? + if ($escape === true) { + $table = $this->db->protectIdentifiers($table, true, null, false); + } + + // Assemble the JOIN statement + $this->QBJoin[] = $type . 'JOIN ' . $this->getFullName($table) . $cond; + + return $this; + } + + /** + * Generates a platform-specific insert string from the supplied data + * + * @todo implement check for this instead static $insertKeyPermission + */ + protected function _insert(string $table, array $keys, array $unescapedKeys): string + { + $fullTableName = $this->getFullName($table); + + // insert statement + $statement = 'INSERT INTO ' . $fullTableName . ' (' . implode(',', $keys) . ') VALUES (' . implode(', ', $unescapedKeys) . ')'; + + return $this->keyPermission ? $this->addIdentity($fullTableName, $statement) : $statement; + } + + /** + * Insert batch statement + * + * Generates a platform-specific insert string from the supplied data. + */ + protected function _insertBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $sql = 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $this->getFullName($table) + . ' (' . implode(', ', $keys) . ")\n{:_table_:}"; + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = 'VALUES ' . implode(', ', $this->formatValues($values)); + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Generates a platform-specific update string from the supplied data + */ + protected function _update(string $table, array $values): string + { + $valstr = []; + + foreach ($values as $key => $val) { + $valstr[] = $key . ' = ' . $val; + } + + $fullTableName = $this->getFullName($table); + + $statement = sprintf('UPDATE %s%s SET ', empty($this->QBLimit) ? '' : 'TOP(' . $this->QBLimit . ') ', $fullTableName); + + $statement .= implode(', ', $valstr) + . $this->compileWhereHaving('QBWhere') + . $this->compileOrderBy(); + + return $this->keyPermission ? $this->addIdentity($fullTableName, $statement) : $statement; + } + + /** + * Increments a numeric column by the specified value. + * + * @return bool + */ + public function increment(string $column, int $value = 1) + { + $column = $this->db->protectIdentifiers($column); + + if ($this->castTextToInt) { + $values = [$column => "CONVERT(VARCHAR(MAX),CONVERT(INT,CONVERT(VARCHAR(MAX), {$column})) + {$value})"]; + } else { + $values = [$column => "{$column} + {$value}"]; + } + + $sql = $this->_update($this->QBFrom[0], $values); + + if (! $this->testMode) { + $this->resetWrite(); + + return $this->db->query($sql, $this->binds, false); + } + + return true; + } + + /** + * Decrements a numeric column by the specified value. + * + * @return bool + */ + public function decrement(string $column, int $value = 1) + { + $column = $this->db->protectIdentifiers($column); + + if ($this->castTextToInt) { + $values = [$column => "CONVERT(VARCHAR(MAX),CONVERT(INT,CONVERT(VARCHAR(MAX), {$column})) - {$value})"]; + } else { + $values = [$column => "{$column} + {$value}"]; + } + + $sql = $this->_update($this->QBFrom[0], $values); + + if (! $this->testMode) { + $this->resetWrite(); + + return $this->db->query($sql, $this->binds, false); + } + + return true; + } + + /** + * Get full name of the table + */ + private function getFullName(string $table): string + { + $alias = ''; + + if (strpos($table, ' ') !== false) { + $alias = explode(' ', $table); + $table = array_shift($alias); + $alias = ' ' . implode(' ', $alias); + } + + if ($this->db->escapeChar === '"') { + return '"' . $this->db->getDatabase() . '"."' . $this->db->schema . '"."' . str_replace('"', '', $table) . '"' . $alias; + } + + return '[' . $this->db->getDatabase() . '].[' . $this->db->schema . '].[' . str_replace('"', '', $table) . ']' . str_replace('"', '', $alias); + } + + /** + * Add permision statements for index value inserts + */ + private function addIdentity(string $fullTable, string $insert): string + { + return 'SET IDENTITY_INSERT ' . $fullTable . " ON\n" . $insert . "\nSET IDENTITY_INSERT " . $fullTable . ' OFF'; + } + + /** + * Local implementation of limit + */ + protected function _limit(string $sql, bool $offsetIgnore = false): string + { + if (empty($this->QBOrderBy)) { + $sql .= ' ORDER BY (SELECT NULL) '; + } + + if ($offsetIgnore) { + $sql .= ' OFFSET 0 '; + } else { + $sql .= is_int($this->QBOffset) ? ' OFFSET ' . $this->QBOffset : ' OFFSET 0 '; + } + + return $sql . ' ROWS FETCH NEXT ' . $this->QBLimit . ' ROWS ONLY '; + } + + /** + * Compiles a replace into string and runs the query + * + * @return mixed + * + * @throws DatabaseException + */ + public function replace(?array $set = null) + { + if ($set !== null) { + $this->set($set); + } + + if ($this->QBSet === []) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must use the "set" method to update an entry.'); + } + + return false; // @codeCoverageIgnore + } + + $table = $this->QBFrom[0]; + + $sql = $this->_replace($table, array_keys($this->QBSet), array_values($this->QBSet)); + + $this->resetWrite(); + + if ($this->testMode) { + return $sql; + } + + $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->getFullName($table) . ' ON'); + + $result = $this->db->query($sql, $this->binds, false); + $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->getFullName($table) . ' OFF'); + + return $result; + } + + /** + * Generates a platform-specific replace string from the supplied data + * on match delete and insert + */ + protected function _replace(string $table, array $keys, array $values): string + { + // check whether the existing keys are part of the primary key. + // if so then use them for the "ON" part and exclude them from the $values and $keys + $pKeys = $this->db->getIndexData($table); + $keyFields = []; + + foreach ($pKeys as $key) { + if ($key->type === 'PRIMARY') { + $keyFields = array_merge($keyFields, $key->fields); + } + + if ($key->type === 'UNIQUE') { + $keyFields = array_merge($keyFields, $key->fields); + } + } + + // Get the unique field names + $escKeyFields = array_map(fn (string $field): string => $this->db->protectIdentifiers($field), array_values(array_unique($keyFields))); + + // Get the binds + $binds = $this->binds; + array_walk($binds, static function (&$item) { + $item = $item[0]; + }); + + // Get the common field and values from the keys data and index fields + $common = array_intersect($keys, $escKeyFields); + $bingo = []; + + foreach ($common as $v) { + $k = array_search($v, $keys, true); + + $bingo[$keys[$k]] = $binds[trim($values[$k], ':')]; + } + + // Querying existing data + $builder = $this->db->table($table); + + foreach ($bingo as $k => $v) { + $builder->where($k, $v); + } + + $q = $builder->get()->getResult(); + + // Delete entries if we find them + if ($q !== []) { + $delete = $this->db->table($table); + + foreach ($bingo as $k => $v) { + $delete->where($k, $v); + } + + $delete->delete(); + } + + return sprintf('INSERT INTO %s (%s) VALUES (%s);', $this->getFullName($table), implode(',', $keys), implode(',', $values)); + } + + /** + * SELECT [MAX|MIN|AVG|SUM|COUNT]() + * + * Handle float return value + * + * @return BaseBuilder + */ + protected function maxMinAvgSum(string $select = '', string $alias = '', string $type = 'MAX') + { + // int functions can be handled by parent + if ($type !== 'AVG') { + return parent::maxMinAvgSum($select, $alias, $type); + } + + if ($select === '') { + throw DataException::forEmptyInputGiven('Select'); + } + + if (strpos($select, ',') !== false) { + throw DataException::forInvalidArgument('Column name not separated by comma'); + } + + if ($alias === '') { + $alias = $this->createAliasFromTable(trim($select)); + } + + $sql = $type . '( CAST( ' . $this->db->protectIdentifiers(trim($select)) . ' AS FLOAT ) ) AS ' . $this->db->escapeIdentifiers(trim($alias)); + + $this->QBSelect[] = $sql; + $this->QBNoEscape[] = null; + + return $this; + } + + /** + * "Count All" query + * + * Generates a platform-specific query string that counts all records in + * the particular table + * + * @param bool $reset Are we want to clear query builder values? + * + * @return int|string when $test = true + */ + public function countAll(bool $reset = true) + { + $table = $this->QBFrom[0]; + + $sql = $this->countString . $this->db->escapeIdentifiers('numrows') . ' FROM ' . $this->getFullName($table); + + if ($this->testMode) { + return $sql; + } + + $query = $this->db->query($sql, null, false); + if (empty($query->getResult())) { + return 0; + } + + $query = $query->getRow(); + + if ($reset === true) { + $this->resetSelect(); + } + + return (int) $query->numrows; + } + + /** + * Delete statement + */ + protected function _delete(string $table): string + { + return 'DELETE' . (empty($this->QBLimit) ? '' : ' TOP (' . $this->QBLimit . ') ') . ' FROM ' . $this->getFullName($table) . $this->compileWhereHaving('QBWhere'); + } + + /** + * Compiles a delete string and runs the query + * + * @param mixed $where + * + * @return mixed + * + * @throws DatabaseException + */ + public function delete($where = '', ?int $limit = null, bool $resetData = true) + { + $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false); + + if ($where !== '') { + $this->where($where); + } + + if ($this->QBWhere === []) { + if ($this->db->DBDebug) { + throw new DatabaseException('Deletes are not allowed unless they contain a "where" or "like" clause.'); + } + + return false; // @codeCoverageIgnore + } + + if ($limit !== null && $limit !== 0) { + $this->QBLimit = $limit; + } + + $sql = $this->_delete($table); + + if ($resetData) { + $this->resetWrite(); + } + + return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false); + } + + /** + * Compile the SELECT statement + * + * Generates a query string based on which functions were used. + * + * @param bool $selectOverride + */ + protected function compileSelect($selectOverride = false): string + { + // Write the "select" portion of the query + if ($selectOverride !== false) { + $sql = $selectOverride; + } else { + $sql = (! $this->QBDistinct) ? 'SELECT ' : 'SELECT DISTINCT '; + + // SQL Server can't work with select * if group by is specified + if (empty($this->QBSelect) && $this->QBGroupBy !== [] && is_array($this->QBGroupBy)) { + foreach ($this->QBGroupBy as $field) { + $this->QBSelect[] = is_array($field) ? $field['field'] : $field; + } + } + + if (empty($this->QBSelect)) { + $sql .= '*'; + } else { + // Cycle through the "select" portion of the query and prep each column name. + // The reason we protect identifiers here rather than in the select() function + // is because until the user calls the from() function we don't know if there are aliases + foreach ($this->QBSelect as $key => $val) { + $noEscape = $this->QBNoEscape[$key] ?? null; + $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $noEscape); + } + + $sql .= implode(', ', $this->QBSelect); + } + } + + // Write the "FROM" portion of the query + if ($this->QBFrom !== []) { + $sql .= "\nFROM " . $this->_fromTables(); + } + + // Write the "JOIN" portion of the query + if (! empty($this->QBJoin)) { + $sql .= "\n" . implode("\n", $this->QBJoin); + } + + $sql .= $this->compileWhereHaving('QBWhere') + . $this->compileGroupBy() + . $this->compileWhereHaving('QBHaving') + . $this->compileOrderBy(); // ORDER BY + + // LIMIT + if ($this->QBLimit) { + $sql = $this->_limit($sql . "\n"); + } + + return $this->unionInjection($sql); + } + + /** + * Compiles the select statement based on the other functions called + * and runs the query + * + * @return ResultInterface + */ + public function get(?int $limit = null, int $offset = 0, bool $reset = true) + { + if ($limit !== null) { + $this->limit($limit, $offset); + } + + $result = $this->testMode ? $this->getCompiledSelect($reset) : $this->db->query($this->compileSelect(), $this->binds, false); + + if ($reset) { + $this->resetSelect(); + + // Clear our binds so we don't eat up memory + $this->binds = []; + } + + return $result; + } + + /** + * Generates a platform-specific upsertBatch string from the supplied data + * + * @throws DatabaseException + */ + protected function _upsertBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $fullTableName = $this->getFullName($table); + + $constraints = $this->QBOptions['constraints'] ?? []; + + $tableIdentity = $this->QBOptions['tableIdentity'] ?? ''; + $sql = "SELECT name from syscolumns where id = Object_ID('" . $table . "') and colstat = 1"; + if (($query = $this->db->query($sql)) === false) { + throw new DatabaseException('Failed to get table identity'); + } + $query = $query->getResultObject(); + + foreach ($query as $row) { + $tableIdentity = '"' . $row->name . '"'; + } + $this->QBOptions['tableIdentity'] = $tableIdentity; + + $identityInFields = in_array($tableIdentity, $keys, true); + + $fieldNames = array_map(static fn ($columnName) => trim($columnName, '"'), $keys); + + if (empty($constraints)) { + $tableIndexes = $this->db->getIndexData($table); + + $uniqueIndexes = array_filter($tableIndexes, static function ($index) use ($fieldNames) { + $hasAllFields = count(array_intersect($index->fields, $fieldNames)) === count($index->fields); + + return $index->type === 'PRIMARY' && $hasAllFields; + }); + + // if no primary found then look for unique - since indexes have no order + if ($uniqueIndexes === []) { + $uniqueIndexes = array_filter($tableIndexes, static function ($index) use ($fieldNames) { + $hasAllFields = count(array_intersect($index->fields, $fieldNames)) === count($index->fields); + + return $index->type === 'UNIQUE' && $hasAllFields; + }); + } + + // only take first index + foreach ($uniqueIndexes as $index) { + $constraints = $index->fields; + break; + } + + $constraints = $this->onConstraint($constraints)->QBOptions['constraints'] ?? []; + } + + if (empty($constraints)) { + if ($this->db->DBDebug) { + throw new DatabaseException('No constraint found for upsert.'); + } + + return ''; // @codeCoverageIgnore + } + + $alias = $this->QBOptions['alias'] ?? '"_upsert"'; + + $updateFields = $this->QBOptions['updateFields'] ?? $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ?? []; + + $sql = 'MERGE INTO ' . $fullTableName . "\nUSING (\n"; + + $sql .= '{:_table_:}'; + + $sql .= ") {$alias} ("; + + $sql .= implode(', ', $keys); + + $sql .= ')'; + + $sql .= "\nON ("; + + $sql .= implode( + ' AND ', + array_map( + static fn ($key, $value) => ( + ($value instanceof RawSql && is_string($key)) + ? + $fullTableName . '.' . $key . ' = ' . $value + : + ( + $value instanceof RawSql + ? + $value + : + $fullTableName . '.' . $value . ' = ' . $alias . '.' . $value + ) + ), + array_keys($constraints), + $constraints + ) + ) . ")\n"; + + $sql .= "WHEN MATCHED THEN UPDATE SET\n"; + + $sql .= implode( + ",\n", + array_map( + static fn ($key, $value) => $key . ($value instanceof RawSql ? + ' = ' . $value : + " = {$alias}.{$value}"), + array_keys($updateFields), + $updateFields + ) + ); + + $sql .= "\nWHEN NOT MATCHED THEN INSERT (" . implode(', ', $keys) . ")\nVALUES "; + + $sql .= ( + '(' . implode( + ', ', + array_map( + static fn ($columnName) => $columnName === $tableIdentity + ? "CASE WHEN {$alias}.{$columnName} IS NULL THEN (SELECT " + . 'isnull(IDENT_CURRENT(\'' . $fullTableName . '\')+IDENT_INCR(\'' + . $fullTableName . "'),1)) ELSE {$alias}.{$columnName} END" + : "{$alias}.{$columnName}", + $keys + ) + ) . ');' + ); + + $sql = $identityInFields ? $this->addIdentity($fullTableName, $sql) : $sql; + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Gets column names from a select query + */ + protected function fieldsFromQuery(string $sql): array + { + return $this->db->query('SELECT TOP 1 * FROM (' . $sql . ') _u_')->getFieldNames(); + } +} diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php new file mode 100644 index 0000000..bd9a22e --- /dev/null +++ b/system/Database/SQLSRV/Connection.php @@ -0,0 +1,563 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\SQLSRV; + +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\Exceptions\DatabaseException; +use stdClass; + +/** + * Connection for SQLSRV + * + * @extends BaseConnection + */ +class Connection extends BaseConnection +{ + /** + * Database driver + * + * @var string + */ + public $DBDriver = 'SQLSRV'; + + /** + * Database name + * + * @var string + */ + public $database; + + /** + * Scrollable flag + * + * Determines what cursor type to use when executing queries. + * + * FALSE or SQLSRV_CURSOR_FORWARD would increase performance, + * but would disable num_rows() (and possibly insert_id()) + * + * @var false|string + */ + public $scrollable; + + /** + * Identifier escape character + * + * @var string + */ + public $escapeChar = '"'; + + /** + * Database schema + * + * @var string + */ + public $schema = 'dbo'; + + /** + * Quoted identifier flag + * + * Whether to use SQL-92 standard quoted identifier + * (double quotes) or brackets for identifier escaping. + * + * @var bool + */ + protected $_quoted_identifier = true; + + /** + * List of reserved identifiers + * + * Identifiers that must NOT be escaped. + * + * @var string[] + */ + protected $_reserved_identifiers = ['*']; + + /** + * Class constructor + */ + public function __construct(array $params) + { + parent::__construct($params); + + // This is only supported as of SQLSRV 3.0 + if ($this->scrollable === null) { + $this->scrollable = defined('SQLSRV_CURSOR_CLIENT_BUFFERED') ? SQLSRV_CURSOR_CLIENT_BUFFERED : false; + } + } + + /** + * Connect to the database. + * + * @return false|resource + * + * @throws DatabaseException + */ + public function connect(bool $persistent = false) + { + $charset = in_array(strtolower($this->charset), ['utf-8', 'utf8'], true) ? 'UTF-8' : SQLSRV_ENC_CHAR; + + $connection = [ + 'UID' => empty($this->username) ? '' : $this->username, + 'PWD' => empty($this->password) ? '' : $this->password, + 'Database' => $this->database, + 'ConnectionPooling' => $persistent ? 1 : 0, + 'CharacterSet' => $charset, + 'Encrypt' => $this->encrypt === true ? 1 : 0, + 'ReturnDatesAsStrings' => 1, + ]; + + // If the username and password are both empty, assume this is a + // 'Windows Authentication Mode' connection. + if (empty($connection['UID']) && empty($connection['PWD'])) { + unset($connection['UID'], $connection['PWD']); + } + + if (strpos($this->hostname, ',') === false && $this->port !== '') { + $this->hostname .= ', ' . $this->port; + } + + sqlsrv_configure('WarningsReturnAsErrors', 0); + $this->connID = sqlsrv_connect($this->hostname, $connection); + + if ($this->connID !== false) { + // Determine how identifiers are escaped + $query = $this->query('SELECT CASE WHEN (@@OPTIONS | 256) = @@OPTIONS THEN 1 ELSE 0 END AS qi'); + $query = $query->getResultObject(); + + $this->_quoted_identifier = empty($query) ? false : (bool) $query[0]->qi; + $this->escapeChar = ($this->_quoted_identifier) ? '"' : ['[', ']']; + + return $this->connID; + } + + throw new DatabaseException($this->getAllErrorMessages()); + } + + /** + * For exception message + * + * @internal + */ + public function getAllErrorMessages(): string + { + $errors = []; + + foreach (sqlsrv_errors() as $error) { + $errors[] = $error['message'] + . ' SQLSTATE: ' . $error['SQLSTATE'] . ', code: ' . $error['code']; + } + + return implode("\n", $errors); + } + + /** + * Keep or establish the connection if no queries have been sent for + * a length of time exceeding the server's idle timeout. + */ + public function reconnect() + { + $this->close(); + $this->initialize(); + } + + /** + * Close the database connection. + */ + protected function _close() + { + sqlsrv_close($this->connID); + } + + /** + * Platform-dependant string escape + */ + protected function _escapeString(string $str): string + { + return str_replace("'", "''", remove_invisible_characters($str, false)); + } + + /** + * Insert ID + */ + public function insertID(): int + { + return $this->query('SELECT SCOPE_IDENTITY() AS insert_id')->getRow()->insert_id ?? 0; + } + + /** + * Generates the SQL for listing tables in a platform-dependent manner. + * + * @param string|null $tableName If $tableName is provided will return only this table if exists. + */ + protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string + { + $sql = 'SELECT [TABLE_NAME] AS "name"' + . ' FROM [INFORMATION_SCHEMA].[TABLES] ' + . ' WHERE ' + . " [TABLE_SCHEMA] = '" . $this->schema . "' "; + + if ($tableName !== null) { + return $sql .= ' AND [TABLE_NAME] LIKE ' . $this->escape($tableName); + } + + if ($prefixLimit === true && $this->DBPrefix !== '') { + $sql .= " AND [TABLE_NAME] LIKE '" . $this->escapeLikeString($this->DBPrefix) . "%' " + . sprintf($this->likeEscapeStr, $this->likeEscapeChar); + } + + return $sql; + } + + /** + * Generates a platform-specific query string so that the column names can be fetched. + */ + protected function _listColumns(string $table = ''): string + { + return 'SELECT [COLUMN_NAME] ' + . ' FROM [INFORMATION_SCHEMA].[COLUMNS]' + . ' WHERE [TABLE_NAME] = ' . $this->escape($this->DBPrefix . $table) + . ' AND [TABLE_SCHEMA] = ' . $this->escape($this->schema); + } + + /** + * Returns an array of objects with index data + * + * @return stdClass[] + * + * @throws DatabaseException + */ + protected function _indexData(string $table): array + { + $sql = 'EXEC sp_helpindex ' . $this->escape($this->schema . '.' . $table); + + if (($query = $this->query($sql)) === false) { + throw new DatabaseException(lang('Database.failGetIndexData')); + } + $query = $query->getResultObject(); + + $retVal = []; + + foreach ($query as $row) { + $obj = new stdClass(); + $obj->name = $row->index_name; + + $_fields = explode(',', trim($row->index_keys)); + $obj->fields = array_map(static fn ($v) => trim($v), $_fields); + + if (strpos($row->index_description, 'primary key located on') !== false) { + $obj->type = 'PRIMARY'; + } else { + $obj->type = (strpos($row->index_description, 'nonclustered, unique') !== false) ? 'UNIQUE' : 'INDEX'; + } + + $retVal[$obj->name] = $obj; + } + + return $retVal; + } + + /** + * Returns an array of objects with Foreign key data + * referenced_object_id parent_object_id + * + * @return stdClass[] + * + * @throws DatabaseException + */ + protected function _foreignKeyData(string $table): array + { + $sql = 'SELECT + f.name as constraint_name, + OBJECT_NAME (f.parent_object_id) as table_name, + COL_NAME(fc.parent_object_id,fc.parent_column_id) column_name, + OBJECT_NAME(f.referenced_object_id) foreign_table_name, + COL_NAME(fc.referenced_object_id,fc.referenced_column_id) foreign_column_name, + rc.delete_rule, + rc.update_rule, + rc.match_option + FROM + sys.foreign_keys AS f + INNER JOIN sys.foreign_key_columns AS fc ON f.OBJECT_ID = fc.constraint_object_id + INNER JOIN sys.tables t ON t.OBJECT_ID = fc.referenced_object_id + INNER JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc ON rc.CONSTRAINT_NAME = f.name + WHERE OBJECT_NAME (f.parent_object_id) = ' . $this->escape($table); + + if (($query = $this->query($sql)) === false) { + throw new DatabaseException(lang('Database.failGetForeignKeyData')); + } + + $query = $query->getResultObject(); + $indexes = []; + + foreach ($query as $row) { + $indexes[$row->constraint_name]['constraint_name'] = $row->constraint_name; + $indexes[$row->constraint_name]['table_name'] = $row->table_name; + $indexes[$row->constraint_name]['column_name'][] = $row->column_name; + $indexes[$row->constraint_name]['foreign_table_name'] = $row->foreign_table_name; + $indexes[$row->constraint_name]['foreign_column_name'][] = $row->foreign_column_name; + $indexes[$row->constraint_name]['on_delete'] = $row->delete_rule; + $indexes[$row->constraint_name]['on_update'] = $row->update_rule; + $indexes[$row->constraint_name]['match'] = $row->match_option; + } + + return $this->foreignKeyDataToObjects($indexes); + } + + /** + * Disables foreign key checks temporarily. + * + * @return string + */ + protected function _disableForeignKeyChecks() + { + return 'EXEC sp_MSforeachtable "ALTER TABLE ? NOCHECK CONSTRAINT ALL"'; + } + + /** + * Enables foreign key checks temporarily. + * + * @return string + */ + protected function _enableForeignKeyChecks() + { + return 'EXEC sp_MSforeachtable "ALTER TABLE ? WITH CHECK CHECK CONSTRAINT ALL"'; + } + + /** + * Returns an array of objects with field data + * + * @return stdClass[] + * + * @throws DatabaseException + */ + protected function _fieldData(string $table): array + { + $sql = 'SELECT + COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION, + COLUMN_DEFAULT, IS_NULLABLE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME= ' . $this->escape(($table)); + + if (($query = $this->query($sql)) === false) { + throw new DatabaseException(lang('Database.failGetFieldData')); + } + + $query = $query->getResultObject(); + $retVal = []; + + for ($i = 0, $c = count($query); $i < $c; $i++) { + $retVal[$i] = new stdClass(); + + $retVal[$i]->name = $query[$i]->COLUMN_NAME; + $retVal[$i]->type = $query[$i]->DATA_TYPE; + $retVal[$i]->default = $query[$i]->COLUMN_DEFAULT; + + $retVal[$i]->max_length = $query[$i]->CHARACTER_MAXIMUM_LENGTH > 0 + ? $query[$i]->CHARACTER_MAXIMUM_LENGTH + : $query[$i]->NUMERIC_PRECISION; + + $retVal[$i]->nullable = $query[$i]->IS_NULLABLE !== 'NO'; + } + + return $retVal; + } + + /** + * Begin Transaction + */ + protected function _transBegin(): bool + { + return sqlsrv_begin_transaction($this->connID); + } + + /** + * Commit Transaction + */ + protected function _transCommit(): bool + { + return sqlsrv_commit($this->connID); + } + + /** + * Rollback Transaction + */ + protected function _transRollback(): bool + { + return sqlsrv_rollback($this->connID); + } + + /** + * Returns the last error code and message. + * Must return this format: ['code' => string|int, 'message' => string] + * intval(code) === 0 means "no error". + * + * @return array + */ + public function error(): array + { + $error = [ + 'code' => '00000', + 'message' => '', + ]; + + $sqlsrvErrors = sqlsrv_errors(SQLSRV_ERR_ERRORS); + + if (! is_array($sqlsrvErrors)) { + return $error; + } + + $sqlsrvError = array_shift($sqlsrvErrors); + if (isset($sqlsrvError['SQLSTATE'])) { + $error['code'] = isset($sqlsrvError['code']) ? $sqlsrvError['SQLSTATE'] . '/' . $sqlsrvError['code'] : $sqlsrvError['SQLSTATE']; + } elseif (isset($sqlsrvError['code'])) { + $error['code'] = $sqlsrvError['code']; + } + + if (isset($sqlsrvError['message'])) { + $error['message'] = $sqlsrvError['message']; + } + + return $error; + } + + /** + * Returns the total number of rows affected by this query. + */ + public function affectedRows(): int + { + return sqlsrv_rows_affected($this->resultID); + } + + /** + * Select a specific database table to use. + * + * @return bool + */ + public function setDatabase(?string $databaseName = null) + { + if ($databaseName === null || $databaseName === '') { + $databaseName = $this->database; + } + + if (empty($this->connID)) { + $this->initialize(); + } + + if ($this->execute('USE ' . $this->_escapeString($databaseName))) { + $this->database = $databaseName; + $this->dataCache = []; + + return true; + } + + return false; + } + + /** + * Executes the query against the database. + * + * @return false|resource + */ + protected function execute(string $sql) + { + $stmt = ($this->scrollable === false || $this->isWriteType($sql)) ? + sqlsrv_query($this->connID, $sql) : + sqlsrv_query($this->connID, $sql, [], ['Scrollable' => $this->scrollable]); + + if ($stmt === false) { + $error = $this->error(); + + log_message('error', $error['message']); + + if ($this->DBDebug) { + throw new DatabaseException($error['message']); + } + } + + return $stmt; + } + + /** + * Returns the last error encountered by this connection. + * + * @return array + * + * @deprecated Use `error()` instead. + */ + public function getError() + { + $error = [ + 'code' => '00000', + 'message' => '', + ]; + + $sqlsrvErrors = sqlsrv_errors(SQLSRV_ERR_ERRORS); + + if (! is_array($sqlsrvErrors)) { + return $error; + } + + $sqlsrvError = array_shift($sqlsrvErrors); + if (isset($sqlsrvError['SQLSTATE'])) { + $error['code'] = isset($sqlsrvError['code']) ? $sqlsrvError['SQLSTATE'] . '/' . $sqlsrvError['code'] : $sqlsrvError['SQLSTATE']; + } elseif (isset($sqlsrvError['code'])) { + $error['code'] = $sqlsrvError['code']; + } + + if (isset($sqlsrvError['message'])) { + $error['message'] = $sqlsrvError['message']; + } + + return $error; + } + + /** + * The name of the platform in use (MySQLi, mssql, etc) + */ + public function getPlatform(): string + { + return $this->DBDriver; + } + + /** + * Returns a string containing the version of the database being used. + */ + public function getVersion(): string + { + $info = []; + if (isset($this->dataCache['version'])) { + return $this->dataCache['version']; + } + + if (! $this->connID || ($info = sqlsrv_server_info($this->connID)) === []) { + $this->initialize(); + } + + return isset($info['SQLServerVersion']) ? $this->dataCache['version'] = $info['SQLServerVersion'] : false; + } + + /** + * Determines if a query is a "write" type. + * + * Overrides BaseConnection::isWriteType, adding additional read query types. + * + * @param string $sql + */ + public function isWriteType($sql): bool + { + if (preg_match('/^\s*"?(EXEC\s*sp_rename)\s/i', $sql)) { + return true; + } + + return parent::isWriteType($sql); + } +} diff --git a/system/Database/SQLSRV/Forge.php b/system/Database/SQLSRV/Forge.php new file mode 100644 index 0000000..522d5fa --- /dev/null +++ b/system/Database/SQLSRV/Forge.php @@ -0,0 +1,387 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\SQLSRV; + +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\Forge as BaseForge; + +/** + * Forge for SQLSRV + */ +class Forge extends BaseForge +{ + /** + * DROP CONSTRAINT statement + * + * @var string + */ + protected $dropConstraintStr; + + /** + * DROP INDEX statement + * + * @var string + */ + protected $dropIndexStr; + + /** + * CREATE DATABASE IF statement + * + * @todo missing charset, collat & check for existent + * + * @var string + */ + protected $createDatabaseIfStr = "DECLARE @DBName VARCHAR(255) = '%s'\nDECLARE @SQL VARCHAR(max) = 'IF DB_ID( ''' + @DBName + ''' ) IS NULL CREATE DATABASE ' + @DBName\nEXEC( @SQL )"; + + /** + * CREATE DATABASE IF statement + * + * @todo missing charset & collat + * + * @var string + */ + protected $createDatabaseStr = 'CREATE DATABASE %s '; + + /** + * CHECK DATABASE EXIST statement + * + * @var string + */ + protected $checkDatabaseExistStr = 'IF DB_ID( %s ) IS NOT NULL SELECT 1'; + + /** + * RENAME TABLE statement + * + * While the below statement would work, it returns an error. + * Also MS recommends dropping and dropping and re-creating the table. + * + * @see https://docs.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-rename-transact-sql?view=sql-server-2017 + * 'EXEC sp_rename %s , %s ;' + * + * @var string + */ + protected $renameTableStr; + + /** + * UNSIGNED support + * + * @var array + */ + protected $unsigned = [ + 'TINYINT' => 'SMALLINT', + 'SMALLINT' => 'INT', + 'INT' => 'BIGINT', + 'REAL' => 'FLOAT', + ]; + + /** + * Foreign Key Allowed Actions + * + * @var array + */ + protected $fkAllowActions = ['CASCADE', 'SET NULL', 'NO ACTION', 'RESTRICT', 'SET DEFAULT']; + + /** + * CREATE TABLE IF statement + * + * @var string + * + * @deprecated This is no longer used. + */ + protected $createTableIfStr; + + /** + * CREATE TABLE statement + * + * @var string + */ + protected $createTableStr; + + public function __construct(BaseConnection $db) + { + parent::__construct($db); + + $this->createTableStr = '%s ' . $this->db->escapeIdentifiers($this->db->schema) . ".%s (%s\n) "; + $this->renameTableStr = 'EXEC sp_rename [' . $this->db->escapeIdentifiers($this->db->schema) . '.%s] , %s ;'; + + $this->dropConstraintStr = 'ALTER TABLE ' . $this->db->escapeIdentifiers($this->db->schema) . '.%s DROP CONSTRAINT %s'; + $this->dropIndexStr = 'DROP INDEX %s ON ' . $this->db->escapeIdentifiers($this->db->schema) . '.%s'; + } + + /** + * CREATE TABLE attributes + */ + protected function _createTableAttributes(array $attributes): string + { + return ''; + } + + /** + * @param array|string $processedFields Processed column definitions + * or column names to DROP + * + * @return false|list|string SQL string or false + * @phpstan-return ($alterType is 'DROP' ? string : list|false) + */ + protected function _alterTable(string $alterType, string $table, $processedFields) + { + // Handle DROP here + if ($alterType === 'DROP') { + $columnNamesToDrop = $processedFields; + + // check if fields are part of any indexes + $indexData = $this->db->getIndexData($table); + + foreach ($indexData as $index) { + if (is_string($columnNamesToDrop)) { + $columnNamesToDrop = explode(',', $columnNamesToDrop); + } + + $fld = array_intersect($columnNamesToDrop, $index->fields); + + // Drop index if field is part of an index + if ($fld !== []) { + $this->_dropIndex($table, $index); + } + } + + $fullTable = $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($table); + + // Drop default constraints + $fields = implode(',', $this->db->escape((array) $columnNamesToDrop)); + + $sql = <<db->query($sql)->getResultArray() as $index) { + $this->db->query('ALTER TABLE ' . $fullTable . ' DROP CONSTRAINT ' . $index['name'] . ''); + } + + $sql = 'ALTER TABLE ' . $fullTable . ' DROP '; + + $fields = array_map(static fn ($item) => 'COLUMN [' . trim($item) . ']', (array) $columnNamesToDrop); + + return $sql . implode(',', $fields); + } + + $sql = 'ALTER TABLE ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($table); + $sql .= ($alterType === 'ADD') ? 'ADD ' : ' '; + + $sqls = []; + + if ($alterType === 'ADD') { + foreach ($processedFields as $field) { + $sqls[] = $sql . ($field['_literal'] !== false ? $field['_literal'] : $this->_processColumn($field)); + } + + return $sqls; + } + + foreach ($processedFields as $field) { + if ($field['_literal'] !== false) { + return false; + } + + if (isset($field['type'])) { + $sqls[] = $sql . ' ALTER COLUMN ' . $this->db->escapeIdentifiers($field['name']) + . " {$field['type']}{$field['length']}"; + } + + if (! empty($field['default'])) { + $sqls[] = $sql . ' ALTER COLUMN ADD CONSTRAINT ' . $this->db->escapeIdentifiers($field['name']) . '_def' + . " DEFAULT {$field['default']} FOR " . $this->db->escapeIdentifiers($field['name']); + } + + $nullable = true; // Nullable by default. + if (isset($field['null']) && ($field['null'] === false || $field['null'] === ' NOT ' . $this->null)) { + $nullable = false; + } + $sqls[] = $sql . ' ALTER COLUMN ' . $this->db->escapeIdentifiers($field['name']) + . " {$field['type']}{$field['length']} " . ($nullable === true ? '' : 'NOT') . ' NULL'; + + if (! empty($field['comment'])) { + $sqls[] = 'EXEC sys.sp_addextendedproperty ' + . "@name=N'Caption', @value=N'" . $field['comment'] . "' , " + . "@level0type=N'SCHEMA',@level0name=N'" . $this->db->schema . "', " + . "@level1type=N'TABLE',@level1name=N'" . $this->db->escapeIdentifiers($table) . "', " + . "@level2type=N'COLUMN',@level2name=N'" . $this->db->escapeIdentifiers($field['name']) . "'"; + } + + if (! empty($field['new_name'])) { + $sqls[] = "EXEC sp_rename '[" . $this->db->schema . '].[' . $table . '].[' . $field['name'] . "]' , '" . $field['new_name'] . "', 'COLUMN';"; + } + } + + return $sqls; + } + + /** + * Drop index for table + * + * @return mixed + */ + protected function _dropIndex(string $table, object $indexData) + { + if ($indexData->type === 'PRIMARY') { + $sql = 'ALTER TABLE [' . $this->db->schema . '].[' . $table . '] DROP [' . $indexData->name . ']'; + } else { + $sql = 'DROP INDEX [' . $indexData->name . '] ON [' . $this->db->schema . '].[' . $table . ']'; + } + + return $this->db->simpleQuery($sql); + } + + /** + * Generates SQL to add indexes + * + * @param bool $asQuery When true returns stand alone SQL, else partial SQL used with CREATE TABLE + */ + protected function _processIndexes(string $table, bool $asQuery = false): array + { + $sqls = []; + + for ($i = 0, $c = count($this->keys); $i < $c; $i++) { + for ($i2 = 0, $c2 = count($this->keys[$i]['fields']); $i2 < $c2; $i2++) { + if (! isset($this->fields[$this->keys[$i]['fields'][$i2]])) { + unset($this->keys[$i]['fields'][$i2]); + } + } + + if (count($this->keys[$i]['fields']) <= 0) { + continue; + } + + $keyName = $this->db->escapeIdentifiers(($this->keys[$i]['keyName'] === '') ? + $table . '_' . implode('_', $this->keys[$i]['fields']) : + $this->keys[$i]['keyName']); + + if (in_array($i, $this->uniqueKeys, true)) { + $sqls[] = 'ALTER TABLE ' + . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($table) + . ' ADD CONSTRAINT ' . $keyName + . ' UNIQUE (' . implode(', ', $this->db->escapeIdentifiers($this->keys[$i]['fields'])) . ');'; + + continue; + } + + $sqls[] = 'CREATE INDEX ' + . $keyName + . ' ON ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($table) + . ' (' . implode(', ', $this->db->escapeIdentifiers($this->keys[$i]['fields'])) . ');'; + } + + return $sqls; + } + + /** + * Process column + */ + protected function _processColumn(array $processedField): string + { + return $this->db->escapeIdentifiers($processedField['name']) + . (empty($processedField['new_name']) ? '' : ' ' . $this->db->escapeIdentifiers($processedField['new_name'])) + . ' ' . $processedField['type'] . ($processedField['type'] === 'text' ? '' : $processedField['length']) + . $processedField['default'] + . $processedField['null'] + . $processedField['auto_increment'] + . '' + . $processedField['unique']; + } + + /** + * Performs a data type mapping between different databases. + */ + protected function _attributeType(array &$attributes) + { + // Reset field lengths for data types that don't support it + if (isset($attributes['CONSTRAINT']) && stripos($attributes['TYPE'], 'int') !== false) { + $attributes['CONSTRAINT'] = null; + } + + switch (strtoupper($attributes['TYPE'])) { + case 'MEDIUMINT': + $attributes['TYPE'] = 'INTEGER'; + $attributes['UNSIGNED'] = false; + break; + + case 'INTEGER': + $attributes['TYPE'] = 'INT'; + break; + + case 'ENUM': + $attributes['TYPE'] = 'TEXT'; + $attributes['CONSTRAINT'] = null; + break; + + case 'TIMESTAMP': + $attributes['TYPE'] = 'DATETIME'; + break; + + case 'BOOLEAN': + $attributes['TYPE'] = 'BIT'; + break; + + default: + break; + } + } + + /** + * Field attribute AUTO_INCREMENT + */ + protected function _attributeAutoIncrement(array &$attributes, array &$field) + { + if (! empty($attributes['AUTO_INCREMENT']) && $attributes['AUTO_INCREMENT'] === true && stripos($field['type'], 'INT') !== false) { + $field['auto_increment'] = ' IDENTITY(1,1)'; + } + } + + /** + * Generates a platform-specific DROP TABLE string + * + * @todo Support for cascade + */ + protected function _dropTable(string $table, bool $ifExists, bool $cascade): string + { + $sql = 'DROP TABLE'; + + if ($ifExists) { + $sql .= ' IF EXISTS '; + } + + $table = ' [' . $this->db->database . '].[' . $this->db->schema . '].[' . $table . '] '; + + $sql .= $table; + + if ($cascade) { + $sql .= ''; + } + + return $sql; + } + + /** + * Constructs sql to check if key is a constraint. + */ + protected function _dropKeyAsConstraint(string $table, string $constraintName): string + { + return "SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS + WHERE TABLE_NAME= '" . trim($table, '"') . "' + AND CONSTRAINT_NAME = '" . trim($constraintName, '"') . "'"; + } +} diff --git a/system/Database/SQLSRV/PreparedQuery.php b/system/Database/SQLSRV/PreparedQuery.php new file mode 100644 index 0000000..f752e76 --- /dev/null +++ b/system/Database/SQLSRV/PreparedQuery.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\SQLSRV; + +use BadMethodCallException; +use CodeIgniter\Database\BasePreparedQuery; +use CodeIgniter\Database\Exceptions\DatabaseException; + +/** + * Prepared query for Postgre + * + * @extends BasePreparedQuery + */ +class PreparedQuery extends BasePreparedQuery +{ + /** + * Parameters array used to store the dynamic variables. + * + * @var array + */ + protected $parameters = []; + + /** + * A reference to the db connection to use. + * + * @var Connection + */ + protected $db; + + public function __construct(Connection $db) + { + parent::__construct($db); + } + + /** + * Prepares the query against the database, and saves the connection + * info necessary to execute the query later. + * + * NOTE: This version is based on SQL code. Child classes should + * override this method. + * + * @param array $options Options takes an associative array; + * + * @throws DatabaseException + */ + public function _prepare(string $sql, array $options = []): PreparedQuery + { + // Prepare parameters for the query + $queryString = $this->getQueryString(); + + $parameters = $this->parameterize($queryString); + + // Prepare the query + $this->statement = sqlsrv_prepare($this->db->connID, $sql, $parameters); + + if (! $this->statement) { + if ($this->db->DBDebug) { + throw new DatabaseException($this->db->getAllErrorMessages()); + } + + $info = $this->db->error(); + $this->errorCode = $info['code']; + $this->errorString = $info['message']; + } + + return $this; + } + + /** + * Takes a new set of data and runs it against the currently + * prepared query. + */ + public function _execute(array $data): bool + { + if (! isset($this->statement)) { + throw new BadMethodCallException('You must call prepare before trying to execute a prepared statement.'); + } + + foreach ($data as $key => $value) { + $this->parameters[$key] = $value; + } + + $result = sqlsrv_execute($this->statement); + + if ($result === false && $this->db->DBDebug) { + throw new DatabaseException($this->db->getAllErrorMessages()); + } + + return $result; + } + + /** + * Returns the statement resource for the prepared query or false when preparing failed. + * + * @return resource|null + */ + public function _getResult() + { + return $this->statement; + } + + /** + * Deallocate prepared statements. + */ + protected function _close(): bool + { + return sqlsrv_free_stmt($this->statement); + } + + /** + * Handle parameters. + */ + protected function parameterize(string $queryString): array + { + $numberOfVariables = substr_count($queryString, '?'); + + $params = []; + + for ($c = 0; $c < $numberOfVariables; $c++) { + $this->parameters[$c] = null; + $params[] = &$this->parameters[$c]; + } + + return $params; + } +} diff --git a/system/Database/SQLSRV/Result.php b/system/Database/SQLSRV/Result.php new file mode 100644 index 0000000..f245a01 --- /dev/null +++ b/system/Database/SQLSRV/Result.php @@ -0,0 +1,174 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\SQLSRV; + +use CodeIgniter\Database\BaseResult; +use CodeIgniter\Entity\Entity; +use stdClass; + +/** + * Result for SQLSRV + * + * @extends BaseResult + */ +class Result extends BaseResult +{ + /** + * Gets the number of fields in the result set. + */ + public function getFieldCount(): int + { + return @sqlsrv_num_fields($this->resultID); + } + + /** + * Generates an array of column names in the result set. + */ + public function getFieldNames(): array + { + $fieldNames = []; + + foreach (sqlsrv_field_metadata($this->resultID) as $field) { + $fieldNames[] = $field['Name']; + } + + return $fieldNames; + } + + /** + * Generates an array of objects representing field meta-data. + */ + public function getFieldData(): array + { + static $dataTypes = [ + SQLSRV_SQLTYPE_BIGINT => 'bigint', + SQLSRV_SQLTYPE_BIT => 'bit', + SQLSRV_SQLTYPE_CHAR => 'char', + + SQLSRV_SQLTYPE_DATE => 'date', + SQLSRV_SQLTYPE_DATETIME => 'datetime', + SQLSRV_SQLTYPE_DATETIME2 => 'datetime2', + SQLSRV_SQLTYPE_DATETIMEOFFSET => 'datetimeoffset', + + SQLSRV_SQLTYPE_DECIMAL => 'decimal', + SQLSRV_SQLTYPE_FLOAT => 'float', + + SQLSRV_SQLTYPE_IMAGE => 'image', + SQLSRV_SQLTYPE_INT => 'int', + SQLSRV_SQLTYPE_MONEY => 'money', + SQLSRV_SQLTYPE_NCHAR => 'nchar', + SQLSRV_SQLTYPE_NUMERIC => 'numeric', + + SQLSRV_SQLTYPE_NVARCHAR => 'nvarchar', + SQLSRV_SQLTYPE_NTEXT => 'ntext', + + SQLSRV_SQLTYPE_REAL => 'real', + SQLSRV_SQLTYPE_SMALLDATETIME => 'smalldatetime', + SQLSRV_SQLTYPE_SMALLINT => 'smallint', + SQLSRV_SQLTYPE_SMALLMONEY => 'smallmoney', + SQLSRV_SQLTYPE_TEXT => 'text', + + SQLSRV_SQLTYPE_TIME => 'time', + SQLSRV_SQLTYPE_TIMESTAMP => 'timestamp', + SQLSRV_SQLTYPE_TINYINT => 'tinyint', + SQLSRV_SQLTYPE_UNIQUEIDENTIFIER => 'uniqueidentifier', + SQLSRV_SQLTYPE_UDT => 'udt', + SQLSRV_SQLTYPE_VARBINARY => 'varbinary', + SQLSRV_SQLTYPE_VARCHAR => 'varchar', + SQLSRV_SQLTYPE_XML => 'xml', + ]; + + $retVal = []; + + foreach (sqlsrv_field_metadata($this->resultID) as $i => $field) { + $retVal[$i] = new stdClass(); + + $retVal[$i]->name = $field['Name']; + $retVal[$i]->type = $field['Type']; + $retVal[$i]->type_name = $dataTypes[$field['Type']] ?? null; + $retVal[$i]->max_length = $field['Size']; + } + + return $retVal; + } + + /** + * Frees the current result. + * + * @return void + */ + public function freeResult() + { + if (is_resource($this->resultID)) { + sqlsrv_free_stmt($this->resultID); + $this->resultID = false; + } + } + + /** + * Moves the internal pointer to the desired offset. This is called + * internally before fetching results to make sure the result set + * starts at zero. + * + * @return bool + */ + public function dataSeek(int $n = 0) + { + if ($n > 0) { + for ($i = 0; $i < $n; $i++) { + if (sqlsrv_fetch($this->resultID) === false) { + return false; + } + } + } + + return true; + } + + /** + * Returns the result set as an array. + * + * Overridden by driver classes. + * + * @return array|false|null + */ + protected function fetchAssoc() + { + return sqlsrv_fetch_array($this->resultID, SQLSRV_FETCH_ASSOC); + } + + /** + * Returns the result set as an object. + * + * @return Entity|false|object|stdClass + */ + protected function fetchObject(string $className = 'stdClass') + { + if (is_subclass_of($className, Entity::class)) { + return empty($data = $this->fetchAssoc()) ? false : (new $className())->injectRawData($data); + } + + return sqlsrv_fetch_object($this->resultID, $className); + } + + /** + * Returns the number of rows in the resultID (i.e., SQLSRV query result resource) + */ + public function getNumRows(): int + { + if (! is_int($this->numRows)) { + $this->numRows = sqlsrv_num_rows($this->resultID); + } + + return $this->numRows; + } +} diff --git a/system/Database/SQLSRV/Utils.php b/system/Database/SQLSRV/Utils.php new file mode 100644 index 0000000..ae52ae4 --- /dev/null +++ b/system/Database/SQLSRV/Utils.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\SQLSRV; + +use CodeIgniter\Database\BaseUtils; +use CodeIgniter\Database\ConnectionInterface; +use CodeIgniter\Database\Exceptions\DatabaseException; + +/** + * Utils for SQLSRV + */ +class Utils extends BaseUtils +{ + /** + * List databases statement + * + * @var string + */ + protected $listDatabases = 'EXEC sp_helpdb'; // Can also be: EXEC sp_databases + + /** + * OPTIMIZE TABLE statement + * + * @var string + */ + protected $optimizeTable = 'ALTER INDEX all ON %s REORGANIZE'; + + public function __construct(ConnectionInterface $db) + { + parent::__construct($db); + + $this->optimizeTable = 'ALTER INDEX all ON ' . $this->db->schema . '.%s REORGANIZE'; + } + + /** + * Platform dependent version of the backup function. + * + * @return never + */ + public function _backup(?array $prefs = null) + { + throw new DatabaseException('Unsupported feature of the database platform you are using.'); + } +} diff --git a/system/Database/SQLite3/Builder.php b/system/Database/SQLite3/Builder.php new file mode 100644 index 0000000..994ac69 --- /dev/null +++ b/system/Database/SQLite3/Builder.php @@ -0,0 +1,278 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\SQLite3; + +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\RawSql; +use InvalidArgumentException; + +/** + * Builder for SQLite3 + */ +class Builder extends BaseBuilder +{ + /** + * Default installs of SQLite typically do not + * support limiting delete clauses. + * + * @var bool + */ + protected $canLimitDeletes = false; + + /** + * Default installs of SQLite do no support + * limiting update queries in combo with WHERE. + * + * @var bool + */ + protected $canLimitWhereUpdates = false; + + /** + * ORDER BY random keyword + * + * @var array + */ + protected $randomKeyword = [ + 'RANDOM()', + ]; + + /** + * @var array + */ + protected $supportedIgnoreStatements = [ + 'insert' => 'OR IGNORE', + ]; + + /** + * Replace statement + * + * Generates a platform-specific replace string from the supplied data + */ + protected function _replace(string $table, array $keys, array $values): string + { + return 'INSERT OR ' . parent::_replace($table, $keys, $values); + } + + /** + * Generates a platform-specific truncate string from the supplied data + * + * If the database does not support the TRUNCATE statement, + * then this method maps to 'DELETE FROM table' + */ + protected function _truncate(string $table): string + { + return 'DELETE FROM ' . $table; + } + + /** + * Generates a platform-specific batch update string from the supplied data + */ + protected function _updateBatch(string $table, array $keys, array $values): string + { + if (version_compare($this->db->getVersion(), '3.33.0') >= 0) { + return parent::_updateBatch($table, $keys, $values); + } + + $constraints = $this->QBOptions['constraints'] ?? []; + + if ($constraints === []) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must specify a constraint to match on for batch updates.'); + } + + return ''; // @codeCoverageIgnore + } + + if (count($constraints) > 1 || isset($this->QBOptions['setQueryAsData']) || (current($constraints) instanceof RawSql)) { + throw new DatabaseException('You are trying to use a feature which requires SQLite version 3.33 or higher.'); + } + + $index = current($constraints); + + $ids = []; + $final = []; + + foreach ($values as $val) { + $val = array_combine($keys, $val); + + $ids[] = $val[$index]; + + foreach (array_keys($val) as $field) { + if ($field !== $index) { + $final[$field][] = 'WHEN ' . $index . ' = ' . $val[$index] . ' THEN ' . $val[$field]; + } + } + } + + $cases = ''; + + foreach ($final as $k => $v) { + $cases .= $k . " = CASE \n" + . implode("\n", $v) . "\n" + . 'ELSE ' . $k . ' END, '; + } + + $this->where($index . ' IN(' . implode(',', $ids) . ')', null, false); + + return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . substr($cases, 0, -2) . $this->compileWhereHaving('QBWhere'); + } + + /** + * Generates a platform-specific upsertBatch string from the supplied data + * + * @throws DatabaseException + */ + protected function _upsertBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $constraints = $this->QBOptions['constraints'] ?? []; + + if (empty($constraints)) { + $fieldNames = array_map(static fn ($columnName) => trim($columnName, '`'), $keys); + + $allIndexes = array_filter($this->db->getIndexData($table), static function ($index) use ($fieldNames) { + $hasAllFields = count(array_intersect($index->fields, $fieldNames)) === count($index->fields); + + return ($index->type === 'PRIMARY' || $index->type === 'UNIQUE') && $hasAllFields; + }); + + foreach (array_map(static fn ($index) => $index->fields, $allIndexes) as $index) { + $constraints[] = current($index); + break; + } + + $constraints = $this->onConstraint($constraints)->QBOptions['constraints'] ?? []; + } + + if (empty($constraints)) { + if ($this->db->DBDebug) { + throw new DatabaseException('No constraint found for upsert.'); + } + + return ''; // @codeCoverageIgnore + } + + $alias = $this->QBOptions['alias'] ?? '`excluded`'; + + if (strtolower($alias) !== '`excluded`') { + throw new InvalidArgumentException('SQLite alias is always named "excluded". A custom alias cannot be used.'); + } + + $updateFields = $this->QBOptions['updateFields'] ?? + $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ?? + []; + + $sql = 'INSERT INTO ' . $table . ' ('; + + $sql .= implode(', ', array_map(static fn ($columnName) => $columnName, $keys)); + + $sql .= ")\n"; + + $sql .= '{:_table_:}'; + + $sql .= 'ON CONFLICT(' . implode(',', $constraints) . ")\n"; + + $sql .= "DO UPDATE SET\n"; + + $sql .= implode( + ",\n", + array_map( + static fn ($key, $value) => $key . ($value instanceof RawSql ? + " = {$value}" : + " = {$alias}.{$value}"), + array_keys($updateFields), + $updateFields + ) + ); + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $hasWhere = stripos($this->QBOptions['setQueryAsData'], 'WHERE') > 0; + + $data = $this->QBOptions['setQueryAsData'] . ($hasWhere ? '' : "\nWHERE 1 = 1\n"); + } else { + $data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Generates a platform-specific batch update string from the supplied data + */ + protected function _deleteBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $constraints = $this->QBOptions['constraints'] ?? []; + + if ($constraints === []) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must specify a constraint to match on for batch deletes.'); // @codeCoverageIgnore + } + + return ''; // @codeCoverageIgnore + } + + $sql = 'DELETE FROM ' . $table . "\n"; + + if (current($constraints) instanceof RawSql && $this->db->DBDebug) { + throw new DatabaseException('You cannot use RawSql for constraint in SQLite.'); + // @codeCoverageIgnore + } + + if (is_string(current(array_keys($constraints)))) { + $concat1 = implode(' || ', array_keys($constraints)); + $concat2 = implode(' || ', array_values($constraints)); + } else { + $concat1 = implode(' || ', $constraints); + $concat2 = $concat1; + } + + $sql .= "WHERE {$concat1} IN (SELECT {$concat2} FROM (\n{:_table_:}))"; + + // where is not supported + if ($this->QBWhere !== [] && $this->db->DBDebug) { + throw new DatabaseException('You cannot use WHERE with SQLite.'); + // @codeCoverageIgnore + } + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = implode( + " UNION ALL\n", + array_map( + static fn ($value) => 'SELECT ' . implode(', ', array_map( + static fn ($key, $index) => $index . ' ' . $key, + $keys, + $value + )), + $values + ) + ) . "\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } +} diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php new file mode 100644 index 0000000..f41c8e4 --- /dev/null +++ b/system/Database/SQLite3/Connection.php @@ -0,0 +1,445 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\SQLite3; + +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\Exceptions\DatabaseException; +use ErrorException; +use Exception; +use SQLite3; +use SQLite3Result; +use stdClass; + +/** + * Connection for SQLite3 + * + * @extends BaseConnection + */ +class Connection extends BaseConnection +{ + /** + * Database driver + * + * @var string + */ + public $DBDriver = 'SQLite3'; + + /** + * Identifier escape character + * + * @var string + */ + public $escapeChar = '`'; + + /** + * @var bool Enable Foreign Key constraint or not + */ + protected $foreignKeys = false; + + /** + * The milliseconds to sleep + * + * @var int|null milliseconds + * + * @see https://www.php.net/manual/en/sqlite3.busytimeout + */ + protected $busyTimeout; + + public function initialize() + { + parent::initialize(); + + if ($this->foreignKeys) { + $this->enableForeignKeyChecks(); + } + + if (is_int($this->busyTimeout)) { + $this->connID->busyTimeout($this->busyTimeout); + } + } + + /** + * Connect to the database. + * + * @return SQLite3 + * + * @throws DatabaseException + */ + public function connect(bool $persistent = false) + { + if ($persistent && $this->DBDebug) { + throw new DatabaseException('SQLite3 doesn\'t support persistent connections.'); + } + + try { + if ($this->database !== ':memory:' && strpos($this->database, DIRECTORY_SEPARATOR) === false) { + $this->database = WRITEPATH . $this->database; + } + + return (! $this->password) + ? new SQLite3($this->database) + : new SQLite3($this->database, SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE, $this->password); + } catch (Exception $e) { + throw new DatabaseException('SQLite3 error: ' . $e->getMessage()); + } + } + + /** + * Keep or establish the connection if no queries have been sent for + * a length of time exceeding the server's idle timeout. + */ + public function reconnect() + { + $this->close(); + $this->initialize(); + } + + /** + * Close the database connection. + */ + protected function _close() + { + $this->connID->close(); + } + + /** + * Select a specific database table to use. + */ + public function setDatabase(string $databaseName): bool + { + return false; + } + + /** + * Returns a string containing the version of the database being used. + */ + public function getVersion(): string + { + if (isset($this->dataCache['version'])) { + return $this->dataCache['version']; + } + + $version = SQLite3::version(); + + return $this->dataCache['version'] = $version['versionString']; + } + + /** + * Execute the query + * + * @return false|SQLite3Result + */ + protected function execute(string $sql) + { + try { + return $this->isWriteType($sql) + ? $this->connID->exec($sql) + : $this->connID->query($sql); + } catch (ErrorException $e) { + log_message('error', (string) $e); + + if ($this->DBDebug) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + } + + return false; + } + + /** + * Returns the total number of rows affected by this query. + */ + public function affectedRows(): int + { + return $this->connID->changes(); + } + + /** + * Platform-dependant string escape + */ + protected function _escapeString(string $str): string + { + if (! $this->connID instanceof SQLite3) { + $this->initialize(); + } + + return $this->connID->escapeString($str); + } + + /** + * Generates the SQL for listing tables in a platform-dependent manner. + * + * @param string|null $tableName If $tableName is provided will return only this table if exists. + */ + protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string + { + if ($tableName !== null) { + return 'SELECT "NAME" FROM "SQLITE_MASTER" WHERE "TYPE" = \'table\'' + . ' AND "NAME" NOT LIKE \'sqlite!_%\' ESCAPE \'!\'' + . ' AND "NAME" LIKE ' . $this->escape($tableName); + } + + return 'SELECT "NAME" FROM "SQLITE_MASTER" WHERE "TYPE" = \'table\'' + . ' AND "NAME" NOT LIKE \'sqlite!_%\' ESCAPE \'!\'' + . (($prefixLimit !== false && $this->DBPrefix !== '') + ? ' AND "NAME" LIKE \'' . $this->escapeLikeString($this->DBPrefix) . '%\' ' . sprintf($this->likeEscapeStr, $this->likeEscapeChar) + : ''); + } + + /** + * Generates a platform-specific query string so that the column names can be fetched. + */ + protected function _listColumns(string $table = ''): string + { + return 'PRAGMA TABLE_INFO(' . $this->protectIdentifiers($table, true, null, false) . ')'; + } + + /** + * @return array|false + * + * @throws DatabaseException + */ + public function getFieldNames(string $table) + { + // Is there a cached result? + if (isset($this->dataCache['field_names'][$table])) { + return $this->dataCache['field_names'][$table]; + } + + if (! $this->connID instanceof SQLite3) { + $this->initialize(); + } + + $sql = $this->_listColumns($table); + + $query = $this->query($sql); + $this->dataCache['field_names'][$table] = []; + + foreach ($query->getResultArray() as $row) { + // Do we know from where to get the column's name? + if (! isset($key)) { + if (isset($row['column_name'])) { + $key = 'column_name'; + } elseif (isset($row['COLUMN_NAME'])) { + $key = 'COLUMN_NAME'; + } elseif (isset($row['name'])) { + $key = 'name'; + } else { + // We have no other choice but to just get the first element's key. + $key = key($row); + } + } + + $this->dataCache['field_names'][$table][] = $row[$key]; + } + + return $this->dataCache['field_names'][$table]; + } + + /** + * Returns an array of objects with field data + * + * @return stdClass[] + * + * @throws DatabaseException + */ + protected function _fieldData(string $table): array + { + if (false === $query = $this->query('PRAGMA TABLE_INFO(' . $this->protectIdentifiers($table, true, null, false) . ')')) { + throw new DatabaseException(lang('Database.failGetFieldData')); + } + + $query = $query->getResultObject(); + + if (empty($query)) { + return []; + } + + $retVal = []; + + for ($i = 0, $c = count($query); $i < $c; $i++) { + $retVal[$i] = new stdClass(); + + $retVal[$i]->name = $query[$i]->name; + $retVal[$i]->type = $query[$i]->type; + $retVal[$i]->max_length = null; + $retVal[$i]->default = $query[$i]->dflt_value; + $retVal[$i]->primary_key = isset($query[$i]->pk) && (bool) $query[$i]->pk; + $retVal[$i]->nullable = isset($query[$i]->notnull) && ! (bool) $query[$i]->notnull; + } + + return $retVal; + } + + /** + * Returns an array of objects with index data + * + * @return stdClass[] + * + * @throws DatabaseException + */ + protected function _indexData(string $table): array + { + $sql = "SELECT 'PRIMARY' as indexname, l.name as fieldname, 'PRIMARY' as indextype + FROM pragma_table_info(" . $this->escape(strtolower($table)) . ") as l + WHERE l.pk <> 0 + UNION ALL + SELECT sqlite_master.name as indexname, ii.name as fieldname, + CASE + WHEN ti.pk <> 0 AND sqlite_master.name LIKE 'sqlite_autoindex_%' THEN 'PRIMARY' + WHEN sqlite_master.name LIKE 'sqlite_autoindex_%' THEN 'UNIQUE' + WHEN sqlite_master.sql LIKE '% UNIQUE %' THEN 'UNIQUE' + ELSE 'INDEX' + END as indextype + FROM sqlite_master + INNER JOIN pragma_index_xinfo(sqlite_master.name) ii ON ii.name IS NOT NULL + LEFT JOIN pragma_table_info(" . $this->escape(strtolower($table)) . ") ti ON ti.name = ii.name + WHERE sqlite_master.type='index' AND sqlite_master.tbl_name = " . $this->escape(strtolower($table)) . ' COLLATE NOCASE'; + + if (($query = $this->query($sql)) === false) { + throw new DatabaseException(lang('Database.failGetIndexData')); + } + $query = $query->getResultObject(); + + $tempVal = []; + + foreach ($query as $row) { + if ($row->indextype === 'PRIMARY') { + $tempVal['PRIMARY']['indextype'] = $row->indextype; + $tempVal['PRIMARY']['indexname'] = $row->indexname; + $tempVal['PRIMARY']['fields'][$row->fieldname] = $row->fieldname; + } else { + $tempVal[$row->indexname]['indextype'] = $row->indextype; + $tempVal[$row->indexname]['indexname'] = $row->indexname; + $tempVal[$row->indexname]['fields'][$row->fieldname] = $row->fieldname; + } + } + + $retVal = []; + + foreach ($tempVal as $val) { + $obj = new stdClass(); + $obj->name = $val['indexname']; + $obj->fields = array_values($val['fields']); + $obj->type = $val['indextype']; + $retVal[$obj->name] = $obj; + } + + return $retVal; + } + + /** + * Returns an array of objects with Foreign key data + * + * @return stdClass[] + */ + protected function _foreignKeyData(string $table): array + { + if ($this->supportsForeignKeys() !== true) { + return []; + } + + $query = $this->query("PRAGMA foreign_key_list({$table})")->getResult(); + $indexes = []; + + foreach ($query as $row) { + $indexes[$row->id]['constraint_name'] = null; + $indexes[$row->id]['table_name'] = $table; + $indexes[$row->id]['foreign_table_name'] = $row->table; + $indexes[$row->id]['column_name'][] = $row->from; + $indexes[$row->id]['foreign_column_name'][] = $row->to; + $indexes[$row->id]['on_delete'] = $row->on_delete; + $indexes[$row->id]['on_update'] = $row->on_update; + $indexes[$row->id]['match'] = $row->match; + } + + return $this->foreignKeyDataToObjects($indexes); + } + + /** + * Returns platform-specific SQL to disable foreign key checks. + * + * @return string + */ + protected function _disableForeignKeyChecks() + { + return 'PRAGMA foreign_keys = OFF'; + } + + /** + * Returns platform-specific SQL to enable foreign key checks. + * + * @return string + */ + protected function _enableForeignKeyChecks() + { + return 'PRAGMA foreign_keys = ON'; + } + + /** + * Returns the last error code and message. + * Must return this format: ['code' => string|int, 'message' => string] + * intval(code) === 0 means "no error". + * + * @return array + */ + public function error(): array + { + return [ + 'code' => $this->connID->lastErrorCode(), + 'message' => $this->connID->lastErrorMsg(), + ]; + } + + /** + * Insert ID + */ + public function insertID(): int + { + return $this->connID->lastInsertRowID(); + } + + /** + * Begin Transaction + */ + protected function _transBegin(): bool + { + return $this->connID->exec('BEGIN TRANSACTION'); + } + + /** + * Commit Transaction + */ + protected function _transCommit(): bool + { + return $this->connID->exec('END TRANSACTION'); + } + + /** + * Rollback Transaction + */ + protected function _transRollback(): bool + { + return $this->connID->exec('ROLLBACK'); + } + + /** + * Checks to see if the current install supports Foreign Keys + * and has them enabled. + */ + public function supportsForeignKeys(): bool + { + $result = $this->simpleQuery('PRAGMA foreign_keys'); + + return (bool) $result; + } +} diff --git a/system/Database/SQLite3/Forge.php b/system/Database/SQLite3/Forge.php new file mode 100644 index 0000000..d7112c6 --- /dev/null +++ b/system/Database/SQLite3/Forge.php @@ -0,0 +1,303 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\SQLite3; + +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Forge as BaseForge; + +/** + * Forge for SQLite3 + */ +class Forge extends BaseForge +{ + /** + * DROP INDEX statement + * + * @var string + */ + protected $dropIndexStr = 'DROP INDEX %s'; + + /** + * @var Connection + */ + protected $db; + + /** + * UNSIGNED support + * + * @var array|bool + */ + protected $_unsigned = false; + + /** + * NULL value representation in CREATE/ALTER TABLE statements + * + * @var string + * + * @internal + */ + protected $null = 'NULL'; + + /** + * Constructor. + */ + public function __construct(BaseConnection $db) + { + parent::__construct($db); + + if (version_compare($this->db->getVersion(), '3.3', '<')) { + $this->dropTableIfStr = false; + } + } + + /** + * Create database + * + * @param bool $ifNotExists Whether to add IF NOT EXISTS condition + */ + public function createDatabase(string $dbName, bool $ifNotExists = false): bool + { + // In SQLite, a database is created when you connect to the database. + // We'll return TRUE so that an error isn't generated. + return true; + } + + /** + * Drop database + * + * @throws DatabaseException + */ + public function dropDatabase(string $dbName): bool + { + // In SQLite, a database is dropped when we delete a file + if (! is_file($dbName)) { + if ($this->db->DBDebug) { + throw new DatabaseException('Unable to drop the specified database.'); + } + + return false; + } + + // We need to close the pseudo-connection first + $this->db->close(); + if (! @unlink($dbName)) { + if ($this->db->DBDebug) { + throw new DatabaseException('Unable to drop the specified database.'); + } + + return false; + } + + if (! empty($this->db->dataCache['db_names'])) { + $key = array_search(strtolower($dbName), array_map('strtolower', $this->db->dataCache['db_names']), true); + if ($key !== false) { + unset($this->db->dataCache['db_names'][$key]); + } + } + + return true; + } + + /** + * @param array|string $processedFields Processed column definitions + * or column names to DROP + * + * @return array|string|null + * @return list|string|null SQL string or null + * @phpstan-return ($alterType is 'DROP' ? string : list|null) + */ + protected function _alterTable(string $alterType, string $table, $processedFields) + { + switch ($alterType) { + case 'DROP': + $columnNamesToDrop = $processedFields; + + $sqlTable = new Table($this->db, $this); + + $sqlTable->fromTable($table) + ->dropColumn($columnNamesToDrop) + ->run(); + + return ''; // Why empty string? + + case 'CHANGE': + (new Table($this->db, $this)) + ->fromTable($table) + ->modifyColumn($processedFields) // @TODO Bug: should be NOT processed fields + ->run(); + + return null; // Why null? + + default: + return parent::_alterTable($alterType, $table, $processedFields); + } + } + + /** + * Process column + */ + protected function _processColumn(array $processedField): string + { + if ($processedField['type'] === 'TEXT' && strpos($processedField['length'], "('") === 0) { + $processedField['type'] .= ' CHECK(' . $this->db->escapeIdentifiers($processedField['name']) + . ' IN ' . $processedField['length'] . ')'; + } + + return $this->db->escapeIdentifiers($processedField['name']) + . ' ' . $processedField['type'] + . $processedField['auto_increment'] + . $processedField['null'] + . $processedField['unique'] + . $processedField['default']; + } + + /** + * Field attribute TYPE + * + * Performs a data type mapping between different databases. + */ + protected function _attributeType(array &$attributes) + { + switch (strtoupper($attributes['TYPE'])) { + case 'ENUM': + case 'SET': + $attributes['TYPE'] = 'TEXT'; + break; + + case 'BOOLEAN': + $attributes['TYPE'] = 'INT'; + break; + + default: + break; + } + } + + /** + * Field attribute AUTO_INCREMENT + */ + protected function _attributeAutoIncrement(array &$attributes, array &$field) + { + if ( + ! empty($attributes['AUTO_INCREMENT']) + && $attributes['AUTO_INCREMENT'] === true + && stripos($field['type'], 'int') !== false + ) { + $field['type'] = 'INTEGER PRIMARY KEY'; + $field['default'] = ''; + $field['null'] = ''; + $field['unique'] = ''; + $field['auto_increment'] = ' AUTOINCREMENT'; + + $this->primaryKeys = []; + } + } + + /** + * Foreign Key Drop + * + * @throws DatabaseException + */ + public function dropForeignKey(string $table, string $foreignName): bool + { + // If this version of SQLite doesn't support it, we're done here + if ($this->db->supportsForeignKeys() !== true) { + return true; + } + + // Otherwise we have to copy the table and recreate + // without the foreign key being involved now + $sqlTable = new Table($this->db, $this); + + return $sqlTable->fromTable($this->db->DBPrefix . $table) + ->dropForeignKey($foreignName) + ->run(); + } + + /** + * Drop Primary Key + */ + public function dropPrimaryKey(string $table, string $keyName = ''): bool + { + $sqlTable = new Table($this->db, $this); + + return $sqlTable->fromTable($this->db->DBPrefix . $table) + ->dropPrimaryKey() + ->run(); + } + + public function addForeignKey($fieldName = '', string $tableName = '', $tableField = '', string $onUpdate = '', string $onDelete = '', string $fkName = ''): BaseForge + { + if ($fkName === '') { + return parent::addForeignKey($fieldName, $tableName, $tableField, $onUpdate, $onDelete, $fkName); + } + + throw new DatabaseException('SQLite does not support foreign key names. CodeIgniter will refer to them in the format: prefix_table_column_referencecolumn_foreign'); + } + + /** + * Generates SQL to add primary key + * + * @param bool $asQuery When true recreates table with key, else partial SQL used with CREATE TABLE + */ + protected function _processPrimaryKeys(string $table, bool $asQuery = false): string + { + if ($asQuery === false) { + return parent::_processPrimaryKeys($table, $asQuery); + } + + $sqlTable = new Table($this->db, $this); + + $sqlTable->fromTable($this->db->DBPrefix . $table) + ->addPrimaryKey($this->primaryKeys) + ->run(); + + return ''; + } + + /** + * Generates SQL to add foreign keys + * + * @param bool $asQuery When true recreates table with key, else partial SQL used with CREATE TABLE + */ + protected function _processForeignKeys(string $table, bool $asQuery = false): array + { + if ($asQuery === false) { + return parent::_processForeignKeys($table, $asQuery); + } + + $errorNames = []; + + foreach ($this->foreignKeys as $name) { + foreach ($name['field'] as $f) { + if (! isset($this->fields[$f])) { + $errorNames[] = $f; + } + } + } + + if ($errorNames !== []) { + $errorNames = [implode(', ', $errorNames)]; + + throw new DatabaseException(lang('Database.fieldNotExists', $errorNames)); + } + + $sqlTable = new Table($this->db, $this); + + $sqlTable->fromTable($this->db->DBPrefix . $table) + ->addForeignKey($this->foreignKeys) + ->run(); + + return []; + } +} diff --git a/system/Database/SQLite3/PreparedQuery.php b/system/Database/SQLite3/PreparedQuery.php new file mode 100644 index 0000000..e892e73 --- /dev/null +++ b/system/Database/SQLite3/PreparedQuery.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\SQLite3; + +use BadMethodCallException; +use CodeIgniter\Database\BasePreparedQuery; +use CodeIgniter\Database\Exceptions\DatabaseException; +use SQLite3; +use SQLite3Result; +use SQLite3Stmt; + +/** + * Prepared query for SQLite3 + * + * @extends BasePreparedQuery + */ +class PreparedQuery extends BasePreparedQuery +{ + /** + * The SQLite3Result resource, or false. + * + * @var false|SQLite3Result + */ + protected $result; + + /** + * Prepares the query against the database, and saves the connection + * info necessary to execute the query later. + * + * NOTE: This version is based on SQL code. Child classes should + * override this method. + * + * @param array $options Passed to the connection's prepare statement. + * Unused in the MySQLi driver. + */ + public function _prepare(string $sql, array $options = []): PreparedQuery + { + if (! ($this->statement = $this->db->connID->prepare($sql))) { + $this->errorCode = $this->db->connID->lastErrorCode(); + $this->errorString = $this->db->connID->lastErrorMsg(); + + if ($this->db->DBDebug) { + throw new DatabaseException($this->errorString . ' code: ' . $this->errorCode); + } + } + + return $this; + } + + /** + * Takes a new set of data and runs it against the currently + * prepared query. Upon success, will return a Results object. + */ + public function _execute(array $data): bool + { + if (! isset($this->statement)) { + throw new BadMethodCallException('You must call prepare before trying to execute a prepared statement.'); + } + + foreach ($data as $key => $item) { + // Determine the type string + if (is_int($item)) { + $bindType = SQLITE3_INTEGER; + } elseif (is_float($item)) { + $bindType = SQLITE3_FLOAT; + } else { + $bindType = SQLITE3_TEXT; + } + + // Bind it + $this->statement->bindValue($key + 1, $item, $bindType); + } + + $this->result = $this->statement->execute(); + + return $this->result !== false; + } + + /** + * Returns the result object for the prepared query or false on failure. + * + * @return false|SQLite3Result + */ + public function _getResult() + { + return $this->result; + } + + /** + * Deallocate prepared statements. + */ + protected function _close(): bool + { + return $this->statement->close(); + } +} diff --git a/system/Database/SQLite3/Result.php b/system/Database/SQLite3/Result.php new file mode 100644 index 0000000..f3887b0 --- /dev/null +++ b/system/Database/SQLite3/Result.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\SQLite3; + +use Closure; +use CodeIgniter\Database\BaseResult; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Entity\Entity; +use SQLite3; +use SQLite3Result; +use stdClass; + +/** + * Result for SQLite3 + * + * @extends BaseResult + */ +class Result extends BaseResult +{ + /** + * Gets the number of fields in the result set. + */ + public function getFieldCount(): int + { + return $this->resultID->numColumns(); + } + + /** + * Generates an array of column names in the result set. + */ + public function getFieldNames(): array + { + $fieldNames = []; + + for ($i = 0, $c = $this->getFieldCount(); $i < $c; $i++) { + $fieldNames[] = $this->resultID->columnName($i); + } + + return $fieldNames; + } + + /** + * Generates an array of objects representing field meta-data. + */ + public function getFieldData(): array + { + static $dataTypes = [ + SQLITE3_INTEGER => 'integer', + SQLITE3_FLOAT => 'float', + SQLITE3_TEXT => 'text', + SQLITE3_BLOB => 'blob', + SQLITE3_NULL => 'null', + ]; + + $retVal = []; + $this->resultID->fetchArray(SQLITE3_NUM); + + for ($i = 0, $c = $this->getFieldCount(); $i < $c; $i++) { + $retVal[$i] = new stdClass(); + $retVal[$i]->name = $this->resultID->columnName($i); + $type = $this->resultID->columnType($i); + $retVal[$i]->type = $type; + $retVal[$i]->type_name = $dataTypes[$type] ?? null; + $retVal[$i]->max_length = null; + $retVal[$i]->length = null; + } + $this->resultID->reset(); + + return $retVal; + } + + /** + * Frees the current result. + * + * @return void + */ + public function freeResult() + { + if (is_object($this->resultID)) { + $this->resultID->finalize(); + $this->resultID = false; + } + } + + /** + * Moves the internal pointer to the desired offset. This is called + * internally before fetching results to make sure the result set + * starts at zero. + * + * @return bool + * + * @throws DatabaseException + */ + public function dataSeek(int $n = 0) + { + if ($n !== 0) { + throw new DatabaseException('SQLite3 doesn\'t support seeking to other offset.'); + } + + return $this->resultID->reset(); + } + + /** + * Returns the result set as an array. + * + * Overridden by driver classes. + * + * @return array|false + */ + protected function fetchAssoc() + { + return $this->resultID->fetchArray(SQLITE3_ASSOC); + } + + /** + * Returns the result set as an object. + * + * Overridden by child classes. + * + * @return Entity|false|object|stdClass + */ + protected function fetchObject(string $className = 'stdClass') + { + // No native support for fetching rows as objects + if (($row = $this->fetchAssoc()) === false) { + return false; + } + + if ($className === 'stdClass') { + return (object) $row; + } + + $classObj = new $className(); + + if (is_subclass_of($className, Entity::class)) { + return $classObj->injectRawData($row); + } + + $classSet = Closure::bind(function ($key, $value) { + $this->{$key} = $value; + }, $classObj, $className); + + foreach (array_keys($row) as $key) { + $classSet($key, $row[$key]); + } + + return $classObj; + } +} diff --git a/system/Database/SQLite3/Table.php b/system/Database/SQLite3/Table.php new file mode 100644 index 0000000..620d284 --- /dev/null +++ b/system/Database/SQLite3/Table.php @@ -0,0 +1,450 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\SQLite3; + +use CodeIgniter\Database\Exceptions\DataException; +use stdClass; + +/** + * Class Table + * + * Provides missing features for altering tables that are common + * in other supported databases, but are missing from SQLite. + * These are needed in order to support migrations during testing + * when another database is used as the primary engine, but + * SQLite in memory databases are used for faster test execution. + */ +class Table +{ + /** + * All of the fields this table represents. + * + * @var array> [name => attributes] + */ + protected $fields = []; + + /** + * All of the unique/primary keys in the table. + * + * @var array + */ + protected $keys = []; + + /** + * All of the foreign keys in the table. + * + * @var array + */ + protected $foreignKeys = []; + + /** + * The name of the table we're working with. + * + * @var string + */ + protected $tableName; + + /** + * The name of the table, with database prefix + * + * @var string + */ + protected $prefixedTableName; + + /** + * Database connection. + * + * @var Connection + */ + protected $db; + + /** + * Handle to our forge. + * + * @var Forge + */ + protected $forge; + + /** + * Table constructor. + */ + public function __construct(Connection $db, Forge $forge) + { + $this->db = $db; + $this->forge = $forge; + } + + /** + * Reads an existing database table and + * collects all of the information needed to + * recreate this table. + * + * @return Table + */ + public function fromTable(string $table) + { + $this->prefixedTableName = $table; + + $prefix = $this->db->DBPrefix; + + if (! empty($prefix) && strpos($table, $prefix) === 0) { + $table = substr($table, strlen($prefix)); + } + + if (! $this->db->tableExists($this->prefixedTableName)) { + throw DataException::forTableNotFound($this->prefixedTableName); + } + + $this->tableName = $table; + + $this->fields = $this->formatFields($this->db->getFieldData($table)); + + $this->keys = array_merge($this->keys, $this->formatKeys($this->db->getIndexData($table))); + + // if primary key index exists twice then remove psuedo index name 'primary'. + $primaryIndexes = array_filter($this->keys, static fn ($index) => $index['type'] === 'primary'); + + if ($primaryIndexes !== [] && count($primaryIndexes) > 1 && array_key_exists('primary', $this->keys)) { + unset($this->keys['primary']); + } + + $this->foreignKeys = $this->db->getForeignKeyData($table); + + return $this; + } + + /** + * Called after `fromTable` and any actions, like `dropColumn`, etc, + * to finalize the action. It creates a temp table, creates the new + * table with modifications, and copies the data over to the new table. + * Resets the connection dataCache to be sure changes are collected. + */ + public function run(): bool + { + $this->db->query('PRAGMA foreign_keys = OFF'); + + $this->db->transStart(); + + $this->forge->renameTable($this->tableName, "temp_{$this->tableName}"); + + $this->forge->reset(); + + $this->createTable(); + + $this->copyData(); + + $this->forge->dropTable("temp_{$this->tableName}"); + + $success = $this->db->transComplete(); + + $this->db->query('PRAGMA foreign_keys = ON'); + + $this->db->resetDataCache(); + + return $success; + } + + /** + * Drops columns from the table. + * + * @param list|string $columns Column names to drop. + * + * @return Table + */ + public function dropColumn($columns) + { + if (is_string($columns)) { + $columns = explode(',', $columns); + } + + foreach ($columns as $column) { + $column = trim($column); + if (isset($this->fields[$column])) { + unset($this->fields[$column]); + } + } + + return $this; + } + + /** + * Modifies a field, including changing data type, renaming, etc. + * + * @param list> $fieldsToModify + * + * @return Table + */ + public function modifyColumn(array $fieldsToModify) + { + foreach ($fieldsToModify as $field) { + $oldName = $field['name']; + unset($field['name']); + + $this->fields[$oldName] = $field; + } + + return $this; + } + + /** + * Drops the primary key + */ + public function dropPrimaryKey(): Table + { + $primaryIndexes = array_filter($this->keys, static fn ($index) => strtolower($index['type']) === 'primary'); + + foreach (array_keys($primaryIndexes) as $key) { + unset($this->keys[$key]); + } + + return $this; + } + + /** + * Drops a foreign key from this table so that + * it won't be recreated in the future. + * + * @return Table + */ + public function dropForeignKey(string $foreignName) + { + if (empty($this->foreignKeys)) { + return $this; + } + + if (isset($this->foreignKeys[$foreignName])) { + unset($this->foreignKeys[$foreignName]); + } + + return $this; + } + + /** + * Adds primary key + */ + public function addPrimaryKey(array $fields): Table + { + $primaryIndexes = array_filter($this->keys, static fn ($index) => strtolower($index['type']) === 'primary'); + + // if primary key already exists we can't add another one + if ($primaryIndexes !== []) { + return $this; + } + + // add array to keys of fields + $pk = [ + 'fields' => $fields['fields'], + 'type' => 'primary', + ]; + + $this->keys['primary'] = $pk; + + return $this; + } + + /** + * Add a foreign key + * + * @return $this + */ + public function addForeignKey(array $foreignKeys) + { + $fk = []; + + // convert to object + foreach ($foreignKeys as $row) { + $obj = new stdClass(); + $obj->column_name = $row['field']; + $obj->foreign_table_name = $row['referenceTable']; + $obj->foreign_column_name = $row['referenceField']; + $obj->on_delete = $row['onDelete']; + $obj->on_update = $row['onUpdate']; + + $fk[] = $obj; + } + + $this->foreignKeys = array_merge($this->foreignKeys, $fk); + + return $this; + } + + /** + * Creates the new table based on our current fields. + * + * @return mixed + */ + protected function createTable() + { + $this->dropIndexes(); + $this->db->resetDataCache(); + + // Handle any modified columns. + $fields = []; + + foreach ($this->fields as $name => $field) { + if (isset($field['new_name'])) { + $fields[$field['new_name']] = $field; + + continue; + } + + $fields[$name] = $field; + } + + $this->forge->addField($fields); + + $fieldNames = array_keys($fields); + + $this->keys = array_filter( + $this->keys, + static fn ($index) => count(array_intersect($index['fields'], $fieldNames)) === count($index['fields']) + ); + + // Unique/Index keys + if (is_array($this->keys)) { + foreach ($this->keys as $keyName => $key) { + switch ($key['type']) { + case 'primary': + $this->forge->addPrimaryKey($key['fields']); + break; + + case 'unique': + $this->forge->addUniqueKey($key['fields'], $keyName); + break; + + case 'index': + $this->forge->addKey($key['fields'], false, false, $keyName); + break; + } + } + } + + foreach ($this->foreignKeys as $foreignKey) { + $this->forge->addForeignKey( + $foreignKey->column_name, + trim($foreignKey->foreign_table_name, $this->db->DBPrefix), + $foreignKey->foreign_column_name + ); + } + + return $this->forge->createTable($this->tableName); + } + + /** + * Copies data from our old table to the new one, + * taking care map data correctly based on any columns + * that have been renamed. + */ + protected function copyData() + { + $exFields = []; + $newFields = []; + + foreach ($this->fields as $name => $details) { + $newFields[] = $details['new_name'] ?? $name; + $exFields[] = $name; + } + + $exFields = implode( + ', ', + array_map(fn ($item) => $this->db->protectIdentifiers($item), $exFields) + ); + $newFields = implode( + ', ', + array_map(fn ($item) => $this->db->protectIdentifiers($item), $newFields) + ); + + $this->db->query( + "INSERT INTO {$this->prefixedTableName}({$newFields}) SELECT {$exFields} FROM {$this->db->DBPrefix}temp_{$this->tableName}" + ); + } + + /** + * Converts fields retrieved from the database to + * the format needed for creating fields with Forge. + * + * @param array|bool $fields + * + * @return mixed + * @phpstan-return ($fields is array ? array : mixed) + */ + protected function formatFields($fields) + { + if (! is_array($fields)) { + return $fields; + } + + $return = []; + + foreach ($fields as $field) { + $return[$field->name] = [ + 'type' => $field->type, + 'default' => $field->default, + 'null' => $field->nullable, + ]; + + if ($field->primary_key) { + $this->keys['primary'] = [ + 'fields' => [$field->name], + 'type' => 'primary', + ]; + } + } + + return $return; + } + + /** + * Converts keys retrieved from the database to + * the format needed to create later. + * + * @param mixed $keys + * + * @return mixed + */ + protected function formatKeys($keys) + { + if (! is_array($keys)) { + return $keys; + } + + $return = []; + + foreach ($keys as $name => $key) { + $return[strtolower($name)] = [ + 'fields' => $key->fields, + 'type' => strtolower($key->type), + ]; + } + + return $return; + } + + /** + * Attempts to drop all indexes and constraints + * from the database for this table. + */ + protected function dropIndexes() + { + if (! is_array($this->keys) || $this->keys === []) { + return; + } + + foreach (array_keys($this->keys) as $name) { + if ($name === 'primary') { + continue; + } + + $this->db->query("DROP INDEX IF EXISTS '{$name}'"); + } + } +} diff --git a/system/Database/SQLite3/Utils.php b/system/Database/SQLite3/Utils.php new file mode 100644 index 0000000..b8f45dd --- /dev/null +++ b/system/Database/SQLite3/Utils.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\SQLite3; + +use CodeIgniter\Database\BaseUtils; +use CodeIgniter\Database\Exceptions\DatabaseException; + +/** + * Utils for SQLite3 + */ +class Utils extends BaseUtils +{ + /** + * OPTIMIZE TABLE statement + * + * @var string + */ + protected $optimizeTable = 'REINDEX %s'; + + /** + * Platform dependent version of the backup function. + * + * @return never + */ + public function _backup(?array $prefs = null) + { + throw new DatabaseException('Unsupported feature of the database platform you are using.'); + } +} diff --git a/system/Database/Seeder.php b/system/Database/Seeder.php new file mode 100644 index 0000000..c0f267d --- /dev/null +++ b/system/Database/Seeder.php @@ -0,0 +1,191 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\CLI\CLI; +use Config\Database; +use Faker\Factory; +use Faker\Generator; +use InvalidArgumentException; + +/** + * Class Seeder + */ +class Seeder +{ + /** + * The name of the database group to use. + * + * @var non-empty-string + */ + protected $DBGroup; + + /** + * Where we can find the Seed files. + * + * @var string + */ + protected $seedPath; + + /** + * An instance of the main Database configuration + * + * @var Database + */ + protected $config; + + /** + * Database Connection instance + * + * @var BaseConnection + */ + protected $db; + + /** + * Database Forge instance. + * + * @var Forge + */ + protected $forge; + + /** + * If true, will not display CLI messages. + * + * @var bool + */ + protected $silent = false; + + /** + * Faker Generator instance. + * + * @deprecated + */ + private static ?Generator $faker = null; + + /** + * Seeder constructor. + */ + public function __construct(Database $config, ?BaseConnection $db = null) + { + $this->seedPath = $config->filesPath ?? APPPATH . 'Database/'; + + if ($this->seedPath === '') { + throw new InvalidArgumentException('Invalid filesPath set in the Config\Database.'); + } + + $this->seedPath = rtrim($this->seedPath, '\\/') . '/Seeds/'; + + if (! is_dir($this->seedPath)) { + throw new InvalidArgumentException('Unable to locate the seeds directory. Please check Config\Database::filesPath'); + } + + $this->config = &$config; + + $db ??= Database::connect($this->DBGroup); + + $this->db = $db; + $this->forge = Database::forge($this->DBGroup); + } + + /** + * Gets the Faker Generator instance. + * + * @deprecated + */ + public static function faker(): ?Generator + { + if (self::$faker === null && class_exists(Factory::class)) { + self::$faker = Factory::create(); + } + + return self::$faker; + } + + /** + * Loads the specified seeder and runs it. + * + * @throws InvalidArgumentException + */ + public function call(string $class) + { + $class = trim($class); + + if ($class === '') { + throw new InvalidArgumentException('No seeder was specified.'); + } + + if (strpos($class, '\\') === false) { + $path = $this->seedPath . str_replace('.php', '', $class) . '.php'; + + if (! is_file($path)) { + throw new InvalidArgumentException('The specified seeder is not a valid file: ' . $path); + } + + // Assume the class has the correct namespace + // @codeCoverageIgnoreStart + $class = APP_NAMESPACE . '\Database\Seeds\\' . $class; + + if (! class_exists($class, false)) { + require_once $path; + } + // @codeCoverageIgnoreEnd + } + + /** @var Seeder $seeder */ + $seeder = new $class($this->config); + $seeder->setSilent($this->silent)->run(); + + unset($seeder); + + if (is_cli() && ! $this->silent) { + CLI::write("Seeded: {$class}", 'green'); + } + } + + /** + * Sets the location of the directory that seed files can be located in. + * + * @return $this + */ + public function setPath(string $path) + { + $this->seedPath = rtrim($path, '\\/') . '/'; + + return $this; + } + + /** + * Sets the silent treatment. + * + * @return $this + */ + public function setSilent(bool $silent) + { + $this->silent = $silent; + + return $this; + } + + /** + * Run the database seeds. This is where the magic happens. + * + * Child classes must implement this method and take care + * of inserting their data here. + * + * @return mixed + * + * @codeCoverageIgnore + */ + public function run() + { + } +} diff --git a/system/Debug/BaseExceptionHandler.php b/system/Debug/BaseExceptionHandler.php new file mode 100644 index 0000000..6a5b5e4 --- /dev/null +++ b/system/Debug/BaseExceptionHandler.php @@ -0,0 +1,263 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Exceptions as ExceptionsConfig; +use Throwable; + +/** + * Provides common functions for exception handlers, + * especially around displaying the output. + */ +abstract class BaseExceptionHandler +{ + /** + * Config for debug exceptions. + */ + protected ExceptionsConfig $config; + + /** + * Nesting level of the output buffering mechanism + */ + protected int $obLevel; + + /** + * The path to the directory containing the + * cli and html error view directories. + */ + protected ?string $viewPath = null; + + public function __construct(ExceptionsConfig $config) + { + $this->config = $config; + + $this->obLevel = ob_get_level(); + + if ($this->viewPath === null) { + $this->viewPath = rtrim($this->config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; + } + } + + /** + * The main entry point into the handler. + * + * @return void + */ + abstract public function handle( + Throwable $exception, + RequestInterface $request, + ResponseInterface $response, + int $statusCode, + int $exitCode + ); + + /** + * Gathers the variables that will be made available to the view. + */ + protected function collectVars(Throwable $exception, int $statusCode): array + { + // Get the first exception. + $firstException = $exception; + + while ($prevException = $firstException->getPrevious()) { + $firstException = $prevException; + } + + $trace = $firstException->getTrace(); + + if ($this->config->sensitiveDataInTrace !== []) { + $trace = $this->maskSensitiveData($trace, $this->config->sensitiveDataInTrace); + } + + return [ + 'title' => get_class($exception), + 'type' => get_class($exception), + 'code' => $statusCode, + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $trace, + ]; + } + + /** + * Mask sensitive data in the trace. + */ + protected function maskSensitiveData(array $trace, array $keysToMask, string $path = ''): array + { + foreach ($trace as $i => $line) { + $trace[$i]['args'] = $this->maskData($line['args'], $keysToMask); + } + + return $trace; + } + + /** + * @param array|object $args + * + * @return array|object + */ + private function maskData($args, array $keysToMask, string $path = '') + { + foreach ($keysToMask as $keyToMask) { + $explode = explode('/', $keyToMask); + $index = end($explode); + + if (strpos(strrev($path . '/' . $index), strrev($keyToMask)) === 0) { + if (is_array($args) && array_key_exists($index, $args)) { + $args[$index] = '******************'; + } elseif ( + is_object($args) && property_exists($args, $index) + && isset($args->{$index}) && is_scalar($args->{$index}) + ) { + $args->{$index} = '******************'; + } + } + } + + if (is_array($args)) { + foreach ($args as $pathKey => $subarray) { + $args[$pathKey] = $this->maskData($subarray, $keysToMask, $path . '/' . $pathKey); + } + } elseif (is_object($args)) { + foreach ($args as $pathKey => $subarray) { + $args->{$pathKey} = $this->maskData($subarray, $keysToMask, $path . '/' . $pathKey); + } + } + + return $args; + } + + /** + * Describes memory usage in real-world units. Intended for use + * with memory_get_usage, etc. + * + * @used-by app/Views/errors/html/error_exception.php + */ + protected static function describeMemory(int $bytes): string + { + helper('number'); + + return number_to_size($bytes, 2); + } + + /** + * Creates a syntax-highlighted version of a PHP file. + * + * @used-by app/Views/errors/html/error_exception.php + * + * @return bool|string + */ + protected static function highlightFile(string $file, int $lineNumber, int $lines = 15) + { + if ($file === '' || ! is_readable($file)) { + return false; + } + + // Set our highlight colors: + if (function_exists('ini_set')) { + ini_set('highlight.comment', '#767a7e; font-style: italic'); + ini_set('highlight.default', '#c7c7c7'); + ini_set('highlight.html', '#06B'); + ini_set('highlight.keyword', '#f1ce61;'); + ini_set('highlight.string', '#869d6a'); + } + + try { + $source = file_get_contents($file); + } catch (Throwable $e) { + return false; + } + + $source = str_replace(["\r\n", "\r"], "\n", $source); + $source = explode("\n", highlight_string($source, true)); + + if (PHP_VERSION_ID < 80300) { + $source = str_replace('
', "\n", $source[1]); + $source = explode("\n", str_replace("\r\n", "\n", $source)); + } else { + // We have to remove these tags since we're preparing the result + // ourselves and these tags are added manually at the end. + $source = str_replace(['
', '
'], '', $source); + } + + // Get just the part to show + $start = max($lineNumber - (int) round($lines / 2), 0); + + // Get just the lines we need to display, while keeping line numbers... + $source = array_splice($source, $start, $lines, true); + + // Used to format the line number in the source + $format = '% ' . strlen((string) ($start + $lines)) . 'd'; + + $out = ''; + // Because the highlighting may have an uneven number + // of open and close span tags on one line, we need + // to ensure we can close them all to get the lines + // showing correctly. + $spans = 0; + + foreach ($source as $n => $row) { + $spans += substr_count($row, ']+>#', $row, $tags); + + $out .= sprintf( + "{$format} %s\n%s", + $n + $start + 1, + strip_tags($row), + implode('', $tags[0]) + ); + } else { + $out .= sprintf('' . $format . ' %s', $n + $start + 1, $row) . "\n"; + // We're closing only one span tag we added manually line before, + // so we have to increment $spans count to close this tag later. + $spans++; + } + } + + if ($spans > 0) { + $out .= str_repeat('', $spans); + } + + return '
' . $out . '
'; + } + + /** + * Given an exception and status code will display the error to the client. + * + * @param string|null $viewFile + */ + protected function render(Throwable $exception, int $statusCode, $viewFile = null): void + { + if (empty($viewFile) || ! is_file($viewFile)) { + echo 'The error view files were not found. Cannot render exception trace.'; + + exit(1); + } + + echo (function () use ($exception, $statusCode, $viewFile): string { + $vars = $this->collectVars($exception, $statusCode); + extract($vars, EXTR_SKIP); + + // CLI error views output to STDERR/STDOUT, so ob_start() does not work. + ob_start(); + include $viewFile; + + return ob_get_clean(); + })(); + } +} diff --git a/system/Debug/ExceptionHandler.php b/system/Debug/ExceptionHandler.php new file mode 100644 index 0000000..6344988 --- /dev/null +++ b/system/Debug/ExceptionHandler.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use CodeIgniter\API\ResponseTrait; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Paths; +use Throwable; + +/** + * @see \CodeIgniter\Debug\ExceptionHandlerTest + */ +final class ExceptionHandler extends BaseExceptionHandler implements ExceptionHandlerInterface +{ + use ResponseTrait; + + /** + * ResponseTrait needs this. + */ + private ?RequestInterface $request = null; + + /** + * ResponseTrait needs this. + */ + private ?ResponseInterface $response = null; + + /** + * Determines the correct way to display the error. + */ + public function handle( + Throwable $exception, + RequestInterface $request, + ResponseInterface $response, + int $statusCode, + int $exitCode + ): void { + // ResponseTrait needs these properties. + $this->request = $request; + $this->response = $response; + + if ($request instanceof IncomingRequest) { + try { + $response->setStatusCode($statusCode); + } catch (HTTPException $e) { + // Workaround for invalid HTTP status code. + $statusCode = 500; + $response->setStatusCode($statusCode); + } + + if (! headers_sent()) { + header( + sprintf( + 'HTTP/%s %s %s', + $request->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase() + ), + true, + $statusCode + ); + } + + if (strpos($request->getHeaderLine('accept'), 'text/html') === false) { + $data = (ENVIRONMENT === 'development' || ENVIRONMENT === 'testing') + ? $this->collectVars($exception, $statusCode) + : ''; + + $this->respond($data, $statusCode)->send(); + + if (ENVIRONMENT !== 'testing') { + // @codeCoverageIgnoreStart + exit($exitCode); + // @codeCoverageIgnoreEnd + } + + return; + } + } + + // Determine possible directories of error views + $addPath = ($request instanceof IncomingRequest ? 'html' : 'cli') . DIRECTORY_SEPARATOR; + $path = $this->viewPath . $addPath; + $altPath = rtrim((new Paths())->viewDirectory, '\\/ ') + . DIRECTORY_SEPARATOR . 'errors' . DIRECTORY_SEPARATOR . $addPath; + + // Determine the views + $view = $this->determineView($exception, $path); + $altView = $this->determineView($exception, $altPath); + + // Check if the view exists + $viewFile = null; + if (is_file($path . $view)) { + $viewFile = $path . $view; + } elseif (is_file($altPath . $altView)) { + $viewFile = $altPath . $altView; + } + + // Displays the HTML or CLI error code. + $this->render($exception, $statusCode, $viewFile); + + if (ENVIRONMENT !== 'testing') { + // @codeCoverageIgnoreStart + exit($exitCode); + // @codeCoverageIgnoreEnd + } + } + + /** + * Determines the view to display based on the exception thrown, + * whether an HTTP or CLI request, etc. + * + * @return string The filename of the view file to use + */ + protected function determineView(Throwable $exception, string $templatePath): string + { + // Production environments should have a custom exception file. + $view = 'production.php'; + + if ( + in_array( + strtolower(ini_get('display_errors')), + ['1', 'true', 'on', 'yes'], + true + ) + ) { + $view = 'error_exception.php'; + } + + // 404 Errors + if ($exception instanceof PageNotFoundException) { + return 'error_404.php'; + } + + $templatePath = rtrim($templatePath, '\\/ ') . DIRECTORY_SEPARATOR; + + // Allow for custom views based upon the status code + if (is_file($templatePath . 'error_' . $exception->getCode() . '.php')) { + return 'error_' . $exception->getCode() . '.php'; + } + + return $view; + } +} diff --git a/system/Debug/ExceptionHandlerInterface.php b/system/Debug/ExceptionHandlerInterface.php new file mode 100644 index 0000000..bbfcb6b --- /dev/null +++ b/system/Debug/ExceptionHandlerInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Throwable; + +interface ExceptionHandlerInterface +{ + /** + * Determines the correct way to display the error. + */ + public function handle( + Throwable $exception, + RequestInterface $request, + ResponseInterface $response, + int $statusCode, + int $exitCode + ): void; +} diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php new file mode 100644 index 0000000..1f5a5b5 --- /dev/null +++ b/system/Debug/Exceptions.php @@ -0,0 +1,637 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use CodeIgniter\API\ResponseTrait; +use CodeIgniter\Exceptions\HasExitCodeInterface; +use CodeIgniter\Exceptions\HTTPExceptionInterface; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Exceptions as ExceptionsConfig; +use Config\Paths; +use Config\Services; +use ErrorException; +use Psr\Log\LogLevel; +use Throwable; + +/** + * Exceptions manager + * + * @see \CodeIgniter\Debug\ExceptionsTest + */ +class Exceptions +{ + use ResponseTrait; + + /** + * Nesting level of the output buffering mechanism + * + * @var int + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. + */ + public $ob_level; + + /** + * The path to the directory containing the + * cli and html error view directories. + * + * @var string + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. + */ + protected $viewPath; + + /** + * Config for debug exceptions. + * + * @var ExceptionsConfig + */ + protected $config; + + /** + * The request. + * + * @var RequestInterface|null + */ + protected $request; + + /** + * The outgoing response. + * + * @var ResponseInterface + */ + protected $response; + + private ?Throwable $exceptionCaughtByExceptionHandler = null; + + public function __construct(ExceptionsConfig $config) + { + // For backward compatibility + $this->ob_level = ob_get_level(); + $this->viewPath = rtrim($config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; + + $this->config = $config; + + // workaround for upgraded users + // This causes "Deprecated: Creation of dynamic property" in PHP 8.2. + // @TODO remove this after dropping PHP 8.1 support. + if (! isset($this->config->sensitiveDataInTrace)) { + $this->config->sensitiveDataInTrace = []; + } + if (! isset($this->config->logDeprecations, $this->config->deprecationLogLevel)) { + $this->config->logDeprecations = false; + $this->config->deprecationLogLevel = LogLevel::WARNING; + } + } + + /** + * Responsible for registering the error, exception and shutdown + * handling of our application. + * + * @codeCoverageIgnore + * + * @return void + */ + public function initialize() + { + set_exception_handler([$this, 'exceptionHandler']); + set_error_handler([$this, 'errorHandler']); + register_shutdown_function([$this, 'shutdownHandler']); + } + + /** + * Catches any uncaught errors and exceptions, including most Fatal errors + * (Yay PHP7!). Will log the error, display it if display_errors is on, + * and fire an event that allows custom actions to be taken at this point. + * + * @return void + */ + public function exceptionHandler(Throwable $exception) + { + $this->exceptionCaughtByExceptionHandler = $exception; + + [$statusCode, $exitCode] = $this->determineCodes($exception); + + if ($this->config->log === true && ! in_array($statusCode, $this->config->ignoreCodes, true)) { + log_message('critical', get_class($exception) . ": {message}\nin {exFile} on line {exLine}.\n{trace}", [ + 'message' => $exception->getMessage(), + 'exFile' => clean_path($exception->getFile()), // {file} refers to THIS file + 'exLine' => $exception->getLine(), // {line} refers to THIS line + 'trace' => self::renderBacktrace($exception->getTrace()), + ]); + + // Get the first exception. + $last = $exception; + + while ($prevException = $last->getPrevious()) { + $last = $prevException; + + log_message('critical', '[Caused by] ' . get_class($prevException) . ": {message}\nin {exFile} on line {exLine}.\n{trace}", [ + 'message' => $prevException->getMessage(), + 'exFile' => clean_path($prevException->getFile()), // {file} refers to THIS file + 'exLine' => $prevException->getLine(), // {line} refers to THIS line + 'trace' => self::renderBacktrace($prevException->getTrace()), + ]); + } + } + + $this->request = Services::request(); + $this->response = Services::response(); + + if (method_exists($this->config, 'handler')) { + // Use new ExceptionHandler + $handler = $this->config->handler($statusCode, $exception); + $handler->handle( + $exception, + $this->request, + $this->response, + $statusCode, + $exitCode + ); + + return; + } + + // For backward compatibility + if (! is_cli()) { + try { + $this->response->setStatusCode($statusCode); + } catch (HTTPException $e) { + // Workaround for invalid HTTP status code. + $statusCode = 500; + $this->response->setStatusCode($statusCode); + } + + if (! headers_sent()) { + header(sprintf('HTTP/%s %s %s', $this->request->getProtocolVersion(), $this->response->getStatusCode(), $this->response->getReasonPhrase()), true, $statusCode); + } + + if (strpos($this->request->getHeaderLine('accept'), 'text/html') === false) { + $this->respond(ENVIRONMENT === 'development' ? $this->collectVars($exception, $statusCode) : '', $statusCode)->send(); + + exit($exitCode); + } + } + + $this->render($exception, $statusCode); + + exit($exitCode); + } + + /** + * The callback to be registered to `set_error_handler()`. + * + * @return bool + * + * @throws ErrorException + * + * @codeCoverageIgnore + */ + public function errorHandler(int $severity, string $message, ?string $file = null, ?int $line = null) + { + if ($this->isDeprecationError($severity)) { + if (! $this->config->logDeprecations || (bool) env('CODEIGNITER_SCREAM_DEPRECATIONS')) { + throw new ErrorException($message, 0, $severity, $file, $line); + } + + return $this->handleDeprecationError($message, $file, $line); + } + + if ((error_reporting() & $severity) !== 0) { + throw new ErrorException($message, 0, $severity, $file, $line); + } + + return false; // return false to propagate the error to PHP standard error handler + } + + /** + * Checks to see if any errors have happened during shutdown that + * need to be caught and handle them. + * + * @codeCoverageIgnore + * + * @return void + */ + public function shutdownHandler() + { + $error = error_get_last(); + + if ($error === null) { + return; + } + + ['type' => $type, 'message' => $message, 'file' => $file, 'line' => $line] = $error; + + if ($this->exceptionCaughtByExceptionHandler instanceof Throwable) { + $message .= "\n【Previous Exception】\n" + . get_class($this->exceptionCaughtByExceptionHandler) . "\n" + . $this->exceptionCaughtByExceptionHandler->getMessage() . "\n" + . $this->exceptionCaughtByExceptionHandler->getTraceAsString(); + } + + if (in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true)) { + $this->exceptionHandler(new ErrorException($message, 0, $type, $file, $line)); + } + } + + /** + * Determines the view to display based on the exception thrown, + * whether an HTTP or CLI request, etc. + * + * @return string The path and filename of the view file to use + * + * @deprecated 4.4.0 No longer used. Moved to ExceptionHandler. + */ + protected function determineView(Throwable $exception, string $templatePath): string + { + // Production environments should have a custom exception file. + $view = 'production.php'; + $templatePath = rtrim($templatePath, '\\/ ') . DIRECTORY_SEPARATOR; + + if ( + in_array( + strtolower(ini_get('display_errors')), + ['1', 'true', 'on', 'yes'], + true + ) + ) { + $view = 'error_exception.php'; + } + + // 404 Errors + if ($exception instanceof PageNotFoundException) { + return 'error_404.php'; + } + + // Allow for custom views based upon the status code + if (is_file($templatePath . 'error_' . $exception->getCode() . '.php')) { + return 'error_' . $exception->getCode() . '.php'; + } + + return $view; + } + + /** + * Given an exception and status code will display the error to the client. + * + * @return void + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. + */ + protected function render(Throwable $exception, int $statusCode) + { + // Determine possible directories of error views + $path = $this->viewPath; + $altPath = rtrim((new Paths())->viewDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'errors' . DIRECTORY_SEPARATOR; + + $path .= (is_cli() ? 'cli' : 'html') . DIRECTORY_SEPARATOR; + $altPath .= (is_cli() ? 'cli' : 'html') . DIRECTORY_SEPARATOR; + + // Determine the views + $view = $this->determineView($exception, $path); + $altView = $this->determineView($exception, $altPath); + + // Check if the view exists + if (is_file($path . $view)) { + $viewFile = $path . $view; + } elseif (is_file($altPath . $altView)) { + $viewFile = $altPath . $altView; + } + + if (! isset($viewFile)) { + echo 'The error view files were not found. Cannot render exception trace.'; + + exit(1); + } + + echo (function () use ($exception, $statusCode, $viewFile): string { + $vars = $this->collectVars($exception, $statusCode); + extract($vars, EXTR_SKIP); + + ob_start(); + include $viewFile; + + return ob_get_clean(); + })(); + } + + /** + * Gathers the variables that will be made available to the view. + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. + */ + protected function collectVars(Throwable $exception, int $statusCode): array + { + // Get the first exception. + $firstException = $exception; + + while ($prevException = $firstException->getPrevious()) { + $firstException = $prevException; + } + + $trace = $firstException->getTrace(); + + if ($this->config->sensitiveDataInTrace !== []) { + $trace = $this->maskSensitiveData($trace, $this->config->sensitiveDataInTrace); + } + + return [ + 'title' => get_class($exception), + 'type' => get_class($exception), + 'code' => $statusCode, + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $trace, + ]; + } + + /** + * Mask sensitive data in the trace. + * + * @param array $trace + * + * @return array + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. + */ + protected function maskSensitiveData($trace, array $keysToMask, string $path = '') + { + foreach ($trace as $i => $line) { + $trace[$i]['args'] = $this->maskData($line['args'], $keysToMask); + } + + return $trace; + } + + /** + * @param array|object $args + * + * @return array|object + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. + */ + private function maskData($args, array $keysToMask, string $path = '') + { + foreach ($keysToMask as $keyToMask) { + $explode = explode('/', $keyToMask); + $index = end($explode); + + if (strpos(strrev($path . '/' . $index), strrev($keyToMask)) === 0) { + if (is_array($args) && array_key_exists($index, $args)) { + $args[$index] = '******************'; + } elseif ( + is_object($args) && property_exists($args, $index) + && isset($args->{$index}) && is_scalar($args->{$index}) + ) { + $args->{$index} = '******************'; + } + } + } + + if (is_array($args)) { + foreach ($args as $pathKey => $subarray) { + $args[$pathKey] = $this->maskData($subarray, $keysToMask, $path . '/' . $pathKey); + } + } elseif (is_object($args)) { + foreach ($args as $pathKey => $subarray) { + $args->{$pathKey} = $this->maskData($subarray, $keysToMask, $path . '/' . $pathKey); + } + } + + return $args; + } + + /** + * Determines the HTTP status code and the exit status code for this request. + */ + protected function determineCodes(Throwable $exception): array + { + $statusCode = 500; + $exitStatus = EXIT_ERROR; + + if ($exception instanceof HTTPExceptionInterface) { + $statusCode = $exception->getCode(); + } + + if ($exception instanceof HasExitCodeInterface) { + $exitStatus = $exception->getExitCode(); + } + + return [$statusCode, $exitStatus]; + } + + private function isDeprecationError(int $error): bool + { + $deprecations = E_DEPRECATED | E_USER_DEPRECATED; + + return ($error & $deprecations) !== 0; + } + + /** + * @return true + */ + private function handleDeprecationError(string $message, ?string $file = null, ?int $line = null): bool + { + // Remove the trace of the error handler. + $trace = array_slice(debug_backtrace(), 2); + + log_message( + $this->config->deprecationLogLevel, + "[DEPRECATED] {message} in {errFile} on line {errLine}.\n{trace}", + [ + 'message' => $message, + 'errFile' => clean_path($file ?? ''), + 'errLine' => $line ?? 0, + 'trace' => self::renderBacktrace($trace), + ] + ); + + return true; + } + + // -------------------------------------------------------------------- + // Display Methods + // -------------------------------------------------------------------- + + /** + * This makes nicer looking paths for the error output. + * + * @deprecated Use dedicated `clean_path()` function. + */ + public static function cleanPath(string $file): string + { + switch (true) { + case strpos($file, APPPATH) === 0: + $file = 'APPPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(APPPATH)); + break; + + case strpos($file, SYSTEMPATH) === 0: + $file = 'SYSTEMPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(SYSTEMPATH)); + break; + + case strpos($file, FCPATH) === 0: + $file = 'FCPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(FCPATH)); + break; + + case defined('VENDORPATH') && strpos($file, VENDORPATH) === 0: + $file = 'VENDORPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(VENDORPATH)); + break; + } + + return $file; + } + + /** + * Describes memory usage in real-world units. Intended for use + * with memory_get_usage, etc. + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. + */ + public static function describeMemory(int $bytes): string + { + if ($bytes < 1024) { + return $bytes . 'B'; + } + + if ($bytes < 1_048_576) { + return round($bytes / 1024, 2) . 'KB'; + } + + return round($bytes / 1_048_576, 2) . 'MB'; + } + + /** + * Creates a syntax-highlighted version of a PHP file. + * + * @return bool|string + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. + */ + public static function highlightFile(string $file, int $lineNumber, int $lines = 15) + { + if ($file === '' || ! is_readable($file)) { + return false; + } + + // Set our highlight colors: + if (function_exists('ini_set')) { + ini_set('highlight.comment', '#767a7e; font-style: italic'); + ini_set('highlight.default', '#c7c7c7'); + ini_set('highlight.html', '#06B'); + ini_set('highlight.keyword', '#f1ce61;'); + ini_set('highlight.string', '#869d6a'); + } + + try { + $source = file_get_contents($file); + } catch (Throwable $e) { + return false; + } + + $source = str_replace(["\r\n", "\r"], "\n", $source); + $source = explode("\n", highlight_string($source, true)); + $source = str_replace('
', "\n", $source[1]); + $source = explode("\n", str_replace("\r\n", "\n", $source)); + + // Get just the part to show + $start = max($lineNumber - (int) round($lines / 2), 0); + + // Get just the lines we need to display, while keeping line numbers... + $source = array_splice($source, $start, $lines, true); + + // Used to format the line number in the source + $format = '% ' . strlen((string) ($start + $lines)) . 'd'; + + $out = ''; + // Because the highlighting may have an uneven number + // of open and close span tags on one line, we need + // to ensure we can close them all to get the lines + // showing correctly. + $spans = 1; + + foreach ($source as $n => $row) { + $spans += substr_count($row, ']+>#', $row, $tags); + + $out .= sprintf( + "{$format} %s\n%s", + $n + $start + 1, + strip_tags($row), + implode('', $tags[0]) + ); + } else { + $out .= sprintf('' . $format . ' %s', $n + $start + 1, $row) . "\n"; + } + } + + if ($spans > 0) { + $out .= str_repeat('', $spans); + } + + return '
' . $out . '
'; + } + + private static function renderBacktrace(array $backtrace): string + { + $backtraces = []; + + foreach ($backtrace as $index => $trace) { + $frame = $trace + ['file' => '[internal function]', 'line' => '', 'class' => '', 'type' => '', 'args' => []]; + + if ($frame['file'] !== '[internal function]') { + $frame['file'] = sprintf('%s(%s)', $frame['file'], $frame['line']); + } + + unset($frame['line']); + $idx = $index; + $idx = str_pad((string) ++$idx, 2, ' ', STR_PAD_LEFT); + + $args = implode(', ', array_map(static function ($value): string { + switch (true) { + case is_object($value): + return sprintf('Object(%s)', get_class($value)); + + case is_array($value): + return $value !== [] ? '[...]' : '[]'; + + case $value === null: + return 'null'; + + case is_resource($value): + return sprintf('resource (%s)', get_resource_type($value)); + + default: + return var_export($value, true); + } + }, $frame['args'])); + + $backtraces[] = sprintf( + '%s %s: %s%s%s(%s)', + $idx, + clean_path($frame['file']), + $frame['class'], + $frame['type'], + $frame['function'], + $args + ); + } + + return implode("\n", $backtraces); + } +} diff --git a/system/Debug/Iterator.php b/system/Debug/Iterator.php new file mode 100644 index 0000000..4bfb3a5 --- /dev/null +++ b/system/Debug/Iterator.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use Closure; + +/** + * Iterator for debugging. + */ +class Iterator +{ + /** + * Stores the tests that we are to run. + * + * @var array + */ + protected $tests = []; + + /** + * Stores the results of each of the tests. + * + * @var array + */ + protected $results = []; + + /** + * Adds a test to run. + * + * Tests are simply closures that the user can define any sequence of + * things to happen during the test. + * + * @param Closure(): mixed $closure + * + * @return $this + */ + public function add(string $name, Closure $closure) + { + $name = strtolower($name); + + $this->tests[$name] = $closure; + + return $this; + } + + /** + * Runs through all of the tests that have been added, recording + * time to execute the desired number of iterations, and the approximate + * memory usage used during those iterations. + * + * @return string|null + */ + public function run(int $iterations = 1000, bool $output = true) + { + foreach ($this->tests as $name => $test) { + // clear memory before start + gc_collect_cycles(); + + $start = microtime(true); + $startMem = $maxMemory = memory_get_usage(true); + + for ($i = 0; $i < $iterations; $i++) { + $result = $test(); + $maxMemory = max($maxMemory, memory_get_usage(true)); + + unset($result); + } + + $this->results[$name] = [ + 'time' => microtime(true) - $start, + 'memory' => $maxMemory - $startMem, + 'n' => $iterations, + ]; + } + + if ($output) { + return $this->getReport(); + } + + return null; + } + + /** + * Get results. + */ + public function getReport(): string + { + if ($this->results === []) { + return 'No results to display.'; + } + + helper('number'); + + // Template + $tpl = ' + + + + + + + + + {rows} + +
TestTimeMemory
'; + + $rows = ''; + + foreach ($this->results as $name => $result) { + $memory = number_to_size($result['memory'], 4); + + $rows .= " + {$name} + " . number_format($result['time'], 4) . " + {$memory} + "; + } + + $tpl = str_replace('{rows}', $rows, $tpl); + + return $tpl . '
'; + } +} diff --git a/system/Debug/Timer.php b/system/Debug/Timer.php new file mode 100644 index 0000000..45c0bf8 --- /dev/null +++ b/system/Debug/Timer.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use RuntimeException; + +/** + * Class Timer + * + * Provides a simple way to measure the amount of time + * that elapses between two points. + * + * @see \CodeIgniter\Debug\TimerTest + */ +class Timer +{ + /** + * List of all timers. + * + * @var array + */ + protected $timers = []; + + /** + * Starts a timer running. + * + * Multiple calls can be made to this method so that several + * execution points can be measured. + * + * @param string $name The name of this timer. + * @param float|null $time Allows user to provide time. + * + * @return Timer + */ + public function start(string $name, ?float $time = null) + { + $this->timers[strtolower($name)] = [ + 'start' => ! empty($time) ? $time : microtime(true), + 'end' => null, + ]; + + return $this; + } + + /** + * Stops a running timer. + * + * If the timer is not stopped before the timers() method is called, + * it will be automatically stopped at that point. + * + * @param string $name The name of this timer. + * + * @return Timer + */ + public function stop(string $name) + { + $name = strtolower($name); + + if (empty($this->timers[$name])) { + throw new RuntimeException('Cannot stop timer: invalid name given.'); + } + + $this->timers[$name]['end'] = microtime(true); + + return $this; + } + + /** + * Returns the duration of a recorded timer. + * + * @param string $name The name of the timer. + * @param int $decimals Number of decimal places. + * + * @return float|null Returns null if timer does not exist by that name. + * Returns a float representing the number of + * seconds elapsed while that timer was running. + */ + public function getElapsedTime(string $name, int $decimals = 4) + { + $name = strtolower($name); + + if (empty($this->timers[$name])) { + return null; + } + + $timer = $this->timers[$name]; + + if (empty($timer['end'])) { + $timer['end'] = microtime(true); + } + + return (float) number_format($timer['end'] - $timer['start'], $decimals, '.', ''); + } + + /** + * Returns the array of timers, with the duration pre-calculated for you. + * + * @param int $decimals Number of decimal places + */ + public function getTimers(int $decimals = 4): array + { + $timers = $this->timers; + + foreach ($timers as &$timer) { + if (empty($timer['end'])) { + $timer['end'] = microtime(true); + } + + $timer['duration'] = (float) number_format($timer['end'] - $timer['start'], $decimals); + } + + return $timers; + } + + /** + * Checks whether or not a timer with the specified name exists. + */ + public function has(string $name): bool + { + return array_key_exists(strtolower($name), $this->timers); + } + + /** + * Executes callable and measures its time. + * Returns its return value if any. + * + * @param string $name The name of the timer + * @param callable(): mixed $callable callable to be executed + * + * @return mixed + */ + public function record(string $name, callable $callable) + { + $this->start($name); + $returnValue = $callable(); + $this->stop($name); + + return $returnValue; + } +} diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php new file mode 100644 index 0000000..b99d839 --- /dev/null +++ b/system/Debug/Toolbar.php @@ -0,0 +1,529 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use CodeIgniter\CodeIgniter; +use CodeIgniter\Debug\Toolbar\Collectors\BaseCollector; +use CodeIgniter\Debug\Toolbar\Collectors\Config; +use CodeIgniter\Debug\Toolbar\Collectors\History; +use CodeIgniter\Format\JSONFormatter; +use CodeIgniter\Format\XMLFormatter; +use CodeIgniter\HTTP\DownloadResponse; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\I18n\Time; +use Config\Services; +use Config\Toolbar as ToolbarConfig; +use Kint\Kint; + +/** + * Displays a toolbar with bits of stats to aid a developer in debugging. + * + * Inspiration: http://prophiler.fabfuel.de + */ +class Toolbar +{ + /** + * Toolbar configuration settings. + * + * @var ToolbarConfig + */ + protected $config; + + /** + * Collectors to be used and displayed. + * + * @var BaseCollector[] + */ + protected $collectors = []; + + public function __construct(ToolbarConfig $config) + { + $this->config = $config; + + foreach ($config->collectors as $collector) { + if (! class_exists($collector)) { + log_message( + 'critical', + 'Toolbar collector does not exist (' . $collector . ').' + . ' Please check $collectors in the app/Config/Toolbar.php file.' + ); + + continue; + } + + $this->collectors[] = new $collector(); + } + } + + /** + * Returns all the data required by Debug Bar + * + * @param float $startTime App start time + * @param IncomingRequest $request + * + * @return string JSON encoded data + */ + public function run(float $startTime, float $totalTime, RequestInterface $request, ResponseInterface $response): string + { + $data = []; + // Data items used within the view. + $data['url'] = current_url(); + $data['method'] = strtoupper($request->getMethod()); + $data['isAJAX'] = $request->isAJAX(); + $data['startTime'] = $startTime; + $data['totalTime'] = $totalTime * 1000; + $data['totalMemory'] = number_format(memory_get_peak_usage() / 1024 / 1024, 3); + $data['segmentDuration'] = $this->roundTo($data['totalTime'] / 7); + $data['segmentCount'] = (int) ceil($data['totalTime'] / $data['segmentDuration']); + $data['CI_VERSION'] = CodeIgniter::CI_VERSION; + $data['collectors'] = []; + + foreach ($this->collectors as $collector) { + $data['collectors'][] = $collector->getAsArray(); + } + + foreach ($this->collectVarData() as $heading => $items) { + $varData = []; + + if (is_array($items)) { + foreach ($items as $key => $value) { + if (is_string($value)) { + $varData[esc($key)] = esc($value); + } else { + $oldKintMode = Kint::$mode_default; + $oldKintCalledFrom = Kint::$display_called_from; + + Kint::$mode_default = Kint::MODE_RICH; + Kint::$display_called_from = false; + + $kint = @Kint::dump($value); + $kint = substr($kint, strpos($kint, '') + 8); + + Kint::$mode_default = $oldKintMode; + Kint::$display_called_from = $oldKintCalledFrom; + + $varData[esc($key)] = $kint; + } + } + } + + $data['vars']['varData'][esc($heading)] = $varData; + } + + if (isset($_SESSION)) { + foreach ($_SESSION as $key => $value) { + // Replace the binary data with string to avoid json_encode failure. + if (is_string($value) && preg_match('~[^\x20-\x7E\t\r\n]~', $value)) { + $value = 'binary data'; + } + + $data['vars']['session'][esc($key)] = is_string($value) ? esc($value) : '
' . esc(print_r($value, true)) . '
'; + } + } + + foreach ($request->getGet() as $name => $value) { + $data['vars']['get'][esc($name)] = is_array($value) ? '
' . esc(print_r($value, true)) . '
' : esc($value); + } + + foreach ($request->getPost() as $name => $value) { + $data['vars']['post'][esc($name)] = is_array($value) ? '
' . esc(print_r($value, true)) . '
' : esc($value); + } + + foreach ($request->headers() as $header) { + $data['vars']['headers'][esc($header->getName())] = esc($header->getValueLine()); + } + + foreach ($request->getCookie() as $name => $value) { + $data['vars']['cookies'][esc($name)] = esc($value); + } + + $data['vars']['request'] = ($request->isSecure() ? 'HTTPS' : 'HTTP') . '/' . $request->getProtocolVersion(); + + $data['vars']['response'] = [ + 'statusCode' => $response->getStatusCode(), + 'reason' => esc($response->getReasonPhrase()), + 'contentType' => esc($response->getHeaderLine('content-type')), + 'headers' => [], + ]; + + foreach ($response->headers() as $header) { + $data['vars']['response']['headers'][esc($header->getName())] = esc($header->getValueLine()); + } + + $data['config'] = Config::display(); + + $response->getCSP()->addImageSrc('data:'); + + return json_encode($data); + } + + /** + * Called within the view to display the timeline itself. + */ + protected function renderTimeline(array $collectors, float $startTime, int $segmentCount, int $segmentDuration, array &$styles): string + { + $rows = $this->collectTimelineData($collectors); + $styleCount = 0; + + // Use recursive render function + return $this->renderTimelineRecursive($rows, $startTime, $segmentCount, $segmentDuration, $styles, $styleCount); + } + + /** + * Recursively renders timeline elements and their children. + */ + protected function renderTimelineRecursive(array $rows, float $startTime, int $segmentCount, int $segmentDuration, array &$styles, int &$styleCount, int $level = 0, bool $isChild = false): string + { + $displayTime = $segmentCount * $segmentDuration; + + $output = ''; + + foreach ($rows as $row) { + $hasChildren = isset($row['children']) && ! empty($row['children']); + $isQuery = isset($row['query']) && ! empty($row['query']); + + // Open controller timeline by default + $open = $row['name'] === 'Controller'; + + if ($hasChildren || $isQuery) { + $output .= ''; + } else { + $output .= ''; + } + + $output .= '' . ($hasChildren || $isQuery ? '' : '') . $row['name'] . ''; + $output .= '' . $row['component'] . ''; + $output .= '' . number_format($row['duration'] * 1000, 2) . ' ms'; + $output .= ""; + + $offset = ((((float) $row['start'] - $startTime) * 1000) / $displayTime) * 100; + $length = (((float) $row['duration'] * 1000) / $displayTime) * 100; + + $styles['debug-bar-timeline-' . $styleCount] = "left: {$offset}%; width: {$length}%;"; + + $output .= ""; + $output .= ''; + $output .= ''; + + $styleCount++; + + // Add children if any + if ($hasChildren || $isQuery) { + $output .= ''; + $output .= ''; + $output .= ''; + $output .= ''; + + if ($isQuery) { + // Output query string if query + $output .= ''; + $output .= ''; + $output .= ''; + } else { + // Recursively render children + $output .= $this->renderTimelineRecursive($row['children'], $startTime, $segmentCount, $segmentDuration, $styles, $styleCount, $level + 1, true); + } + + $output .= ''; + $output .= '
' . $row['query'] . '
'; + $output .= ''; + $output .= ''; + } + } + + return $output; + } + + /** + * Returns a sorted array of timeline data arrays from the collectors. + * + * @param array $collectors + */ + protected function collectTimelineData($collectors): array + { + $data = []; + + // Collect it + foreach ($collectors as $collector) { + if (! $collector['hasTimelineData']) { + continue; + } + + $data = array_merge($data, $collector['timelineData']); + } + + // Sort it + $sortArray = [ + array_column($data, 'start'), SORT_NUMERIC, SORT_ASC, + array_column($data, 'duration'), SORT_NUMERIC, SORT_DESC, + &$data, + ]; + + array_multisort(...$sortArray); + + // Add end time to each element + array_walk($data, static function (&$row) { + $row['end'] = $row['start'] + $row['duration']; + }); + + // Group it + $data = $this->structureTimelineData($data); + + return $data; + } + + /** + * Arranges the already sorted timeline data into a parent => child structure. + */ + protected function structureTimelineData(array $elements): array + { + // We define ourselves as the first element of the array + $element = array_shift($elements); + + // If we have children behind us, collect and attach them to us + while ($elements !== [] && $elements[array_key_first($elements)]['end'] <= $element['end']) { + $element['children'][] = array_shift($elements); + } + + // Make sure our children know whether they have children, too + if (isset($element['children'])) { + $element['children'] = $this->structureTimelineData($element['children']); + } + + // If we have no younger siblings, we can return + if ($elements === []) { + return [$element]; + } + + // Make sure our younger siblings know their relatives, too + return array_merge([$element], $this->structureTimelineData($elements)); + } + + /** + * Returns an array of data from all of the modules + * that should be displayed in the 'Vars' tab. + */ + protected function collectVarData(): array + { + if (! ($this->config->collectVarData ?? true)) { + return []; + } + + $data = []; + + foreach ($this->collectors as $collector) { + if (! $collector->hasVarData()) { + continue; + } + + $data = array_merge($data, $collector->getVarData()); + } + + return $data; + } + + /** + * Rounds a number to the nearest incremental value. + */ + protected function roundTo(float $number, int $increments = 5): float + { + $increments = 1 / $increments; + + return ceil($number * $increments) / $increments; + } + + /** + * Prepare for debugging.. + * + * @return void + */ + public function prepare(?RequestInterface $request = null, ?ResponseInterface $response = null) + { + /** + * @var IncomingRequest|null $request + */ + if (CI_DEBUG && ! is_cli()) { + $app = Services::codeigniter(); + + $request ??= Services::request(); + $response ??= Services::response(); + + // Disable the toolbar for downloads + if ($response instanceof DownloadResponse) { + return; + } + + $toolbar = Services::toolbar(config(ToolbarConfig::class)); + $stats = $app->getPerformanceStats(); + $data = $toolbar->run( + $stats['startTime'], + $stats['totalTime'], + $request, + $response + ); + + helper('filesystem'); + + // Updated to microtime() so we can get history + $time = sprintf('%.6f', Time::now()->format('U.u')); + + if (! is_dir(WRITEPATH . 'debugbar')) { + mkdir(WRITEPATH . 'debugbar', 0777); + } + + write_file(WRITEPATH . 'debugbar/debugbar_' . $time . '.json', $data, 'w+'); + + $format = $response->getHeaderLine('content-type'); + + // Non-HTML formats should not include the debugbar + // then we send headers saying where to find the debug data + // for this response + if ($request->isAJAX() || strpos($format, 'html') === false) { + $response->setHeader('Debugbar-Time', "{$time}") + ->setHeader('Debugbar-Link', site_url("?debugbar_time={$time}")); + + return; + } + + $oldKintMode = Kint::$mode_default; + Kint::$mode_default = Kint::MODE_RICH; + $kintScript = @Kint::dump(''); + Kint::$mode_default = $oldKintMode; + $kintScript = substr($kintScript, 0, strpos($kintScript, '') + 8); + $kintScript = ($kintScript === '0') ? '' : $kintScript; + + $script = PHP_EOL + . '' + . '' + . '' + . $kintScript + . PHP_EOL; + + if (strpos($response->getBody(), '') !== false) { + $response->setBody( + preg_replace( + '//', + '' . $script, + $response->getBody(), + 1 + ) + ); + + return; + } + + $response->appendBody($script); + } + } + + /** + * Inject debug toolbar into the response. + * + * @codeCoverageIgnore + * + * @return void + */ + public function respond() + { + if (ENVIRONMENT === 'testing') { + return; + } + + $request = Services::request(); + + // If the request contains '?debugbar then we're + // simply returning the loading script + if ($request->getGet('debugbar') !== null) { + header('Content-Type: application/javascript'); + + ob_start(); + include $this->config->viewsPath . 'toolbarloader.js'; + $output = ob_get_clean(); + $output = str_replace('{url}', rtrim(site_url(), '/'), $output); + echo $output; + + exit; + } + + // Otherwise, if it includes ?debugbar_time, then + // we should return the entire debugbar. + if ($request->getGet('debugbar_time')) { + helper('security'); + + // Negotiate the content-type to format the output + $format = $request->negotiate('media', ['text/html', 'application/json', 'application/xml']); + $format = explode('/', $format)[1]; + + $filename = sanitize_filename('debugbar_' . $request->getGet('debugbar_time')); + $filename = WRITEPATH . 'debugbar/' . $filename . '.json'; + + if (is_file($filename)) { + // Show the toolbar if it exists + echo $this->format(file_get_contents($filename), $format); + + exit; + } + + // Filename not found + http_response_code(404); + + exit; // Exit here is needed to avoid loading the index page + } + } + + /** + * Format output + */ + protected function format(string $data, string $format = 'html'): string + { + $data = json_decode($data, true); + + if ($this->config->maxHistory !== 0 && preg_match('/\d+\.\d{6}/s', (string) Services::request()->getGet('debugbar_time'), $debugbarTime)) { + $history = new History(); + $history->setFiles( + $debugbarTime[0], + $this->config->maxHistory + ); + + $data['collectors'][] = $history->getAsArray(); + } + + $output = ''; + + switch ($format) { + case 'html': + $data['styles'] = []; + extract($data); + $parser = Services::parser($this->config->viewsPath, null, false); + ob_start(); + include $this->config->viewsPath . 'toolbar.tpl.php'; + $output = ob_get_clean(); + break; + + case 'json': + $formatter = new JSONFormatter(); + $output = $formatter->format($data); + break; + + case 'xml': + $formatter = new XMLFormatter(); + $output = $formatter->format($data); + break; + } + + return $output; + } +} diff --git a/system/Debug/Toolbar/Collectors/BaseCollector.php b/system/Debug/Toolbar/Collectors/BaseCollector.php new file mode 100644 index 0000000..4704fcd --- /dev/null +++ b/system/Debug/Toolbar/Collectors/BaseCollector.php @@ -0,0 +1,236 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug\Toolbar\Collectors; + +/** + * Base Toolbar collector + */ +class BaseCollector +{ + /** + * Whether this collector has data that can + * be displayed in the Timeline. + * + * @var bool + */ + protected $hasTimeline = false; + + /** + * Whether this collector needs to display + * content in a tab or not. + * + * @var bool + */ + protected $hasTabContent = false; + + /** + * Whether this collector needs to display + * a label or not. + * + * @var bool + */ + protected $hasLabel = false; + + /** + * Whether this collector has data that + * should be shown in the Vars tab. + * + * @var bool + */ + protected $hasVarData = false; + + /** + * The 'title' of this Collector. + * Used to name things in the toolbar HTML. + * + * @var string + */ + protected $title = ''; + + /** + * Gets the Collector's title. + */ + public function getTitle(bool $safe = false): string + { + if ($safe) { + return str_replace(' ', '-', strtolower($this->title)); + } + + return $this->title; + } + + /** + * Returns any information that should be shown next to the title. + */ + public function getTitleDetails(): string + { + return ''; + } + + /** + * Does this collector need it's own tab? + */ + public function hasTabContent(): bool + { + return (bool) $this->hasTabContent; + } + + /** + * Does this collector have a label? + */ + public function hasLabel(): bool + { + return (bool) $this->hasLabel; + } + + /** + * Does this collector have information for the timeline? + */ + public function hasTimelineData(): bool + { + return (bool) $this->hasTimeline; + } + + /** + * Grabs the data for the timeline, properly formatted, + * or returns an empty array. + */ + public function timelineData(): array + { + if (! $this->hasTimeline) { + return []; + } + + return $this->formatTimelineData(); + } + + /** + * Does this Collector have data that should be shown in the + * 'Vars' tab? + */ + public function hasVarData(): bool + { + return (bool) $this->hasVarData; + } + + /** + * Gets a collection of data that should be shown in the 'Vars' tab. + * The format is an array of sections, each with their own array + * of key/value pairs: + * + * $data = [ + * 'section 1' => [ + * 'foo' => 'bar, + * 'bar' => 'baz' + * ], + * 'section 2' => [ + * 'foo' => 'bar, + * 'bar' => 'baz' + * ], + * ]; + * + * @return array|null + */ + public function getVarData() + { + return null; + } + + /** + * Child classes should implement this to return the timeline data + * formatted for correct usage. + * + * Timeline data should be formatted into arrays that look like: + * + * [ + * 'name' => 'Database::Query', + * 'component' => 'Database', + * 'start' => 10 // milliseconds + * 'duration' => 15 // milliseconds + * ] + */ + protected function formatTimelineData(): array + { + return []; + } + + /** + * Returns the data of this collector to be formatted in the toolbar + * + * @return array|string + */ + public function display() + { + return []; + } + + /** + * This makes nicer looking paths for the error output. + * + * @deprecated Use the dedicated `clean_path()` function. + */ + public function cleanPath(string $file): string + { + return clean_path($file); + } + + /** + * Gets the "badge" value for the button. + * + * @return int|null + */ + public function getBadgeValue() + { + return null; + } + + /** + * Does this collector have any data collected? + * + * If not, then the toolbar button won't get shown. + */ + public function isEmpty(): bool + { + return false; + } + + /** + * Returns the HTML to display the icon. Should either + * be SVG, or a base-64 encoded. + * + * Recommended dimensions are 24px x 24px + */ + public function icon(): string + { + return ''; + } + + /** + * Return settings as an array. + */ + public function getAsArray(): array + { + return [ + 'title' => $this->getTitle(), + 'titleSafe' => $this->getTitle(true), + 'titleDetails' => $this->getTitleDetails(), + 'display' => $this->display(), + 'badgeValue' => $this->getBadgeValue(), + 'isEmpty' => $this->isEmpty(), + 'hasTabContent' => $this->hasTabContent(), + 'hasLabel' => $this->hasLabel(), + 'icon' => $this->icon(), + 'hasTimelineData' => $this->hasTimelineData(), + 'timelineData' => $this->timelineData(), + ]; + } +} diff --git a/system/Debug/Toolbar/Collectors/Config.php b/system/Debug/Toolbar/Collectors/Config.php new file mode 100644 index 0000000..faf7a19 --- /dev/null +++ b/system/Debug/Toolbar/Collectors/Config.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug\Toolbar\Collectors; + +use CodeIgniter\CodeIgniter; +use Config\App; +use Config\Services; + +/** + * Debug toolbar configuration + */ +class Config +{ + /** + * Return toolbar config values as an array. + */ + public static function display(): array + { + $config = config(App::class); + + return [ + 'ciVersion' => CodeIgniter::CI_VERSION, + 'phpVersion' => PHP_VERSION, + 'phpSAPI' => PHP_SAPI, + 'environment' => ENVIRONMENT, + 'baseURL' => $config->baseURL, + 'timezone' => app_timezone(), + 'locale' => Services::request()->getLocale(), + 'cspEnabled' => $config->CSPEnabled, + ]; + } +} diff --git a/system/Debug/Toolbar/Collectors/Database.php b/system/Debug/Toolbar/Collectors/Database.php new file mode 100644 index 0000000..5b5614a --- /dev/null +++ b/system/Debug/Toolbar/Collectors/Database.php @@ -0,0 +1,258 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug\Toolbar\Collectors; + +use CodeIgniter\Database\Query; +use CodeIgniter\I18n\Time; +use Config\Toolbar; + +/** + * Collector for the Database tab of the Debug Toolbar. + * + * @see \CodeIgniter\Debug\Toolbar\Collectors\DatabaseTest + */ +class Database extends BaseCollector +{ + /** + * Whether this collector has timeline data. + * + * @var bool + */ + protected $hasTimeline = true; + + /** + * Whether this collector should display its own tab. + * + * @var bool + */ + protected $hasTabContent = true; + + /** + * Whether this collector has data for the Vars tab. + * + * @var bool + */ + protected $hasVarData = false; + + /** + * The name used to reference this collector in the toolbar. + * + * @var string + */ + protected $title = 'Database'; + + /** + * Array of database connections. + * + * @var array + */ + protected $connections; + + /** + * The query instances that have been collected + * through the DBQuery Event. + * + * @var array + */ + protected static $queries = []; + + /** + * Constructor + */ + public function __construct() + { + $this->getConnections(); + } + + /** + * The static method used during Events to collect + * data. + * + * @internal + * + * @return void + */ + public static function collect(Query $query) + { + $config = config(Toolbar::class); + + // Provide default in case it's not set + $max = $config->maxQueries ?: 100; + + if (count(static::$queries) < $max) { + $queryString = $query->getQuery(); + + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + + if (! is_cli()) { + // when called in the browser, the first two trace arrays + // are from the DB event trigger, which are unneeded + $backtrace = array_slice($backtrace, 2); + } + + static::$queries[] = [ + 'query' => $query, + 'string' => $queryString, + 'duplicate' => in_array($queryString, array_column(static::$queries, 'string', null), true), + 'trace' => $backtrace, + ]; + } + } + + /** + * Returns timeline data formatted for the toolbar. + * + * @return array The formatted data or an empty array. + */ + protected function formatTimelineData(): array + { + $data = []; + + foreach ($this->connections as $alias => $connection) { + // Connection Time + $data[] = [ + 'name' => 'Connecting to Database: "' . $alias . '"', + 'component' => 'Database', + 'start' => $connection->getConnectStart(), + 'duration' => $connection->getConnectDuration(), + ]; + } + + foreach (static::$queries as $query) { + $data[] = [ + 'name' => 'Query', + 'component' => 'Database', + 'start' => $query['query']->getStartTime(true), + 'duration' => $query['query']->getDuration(), + 'query' => $query['query']->debugToolbarDisplay(), + ]; + } + + return $data; + } + + /** + * Returns the data of this collector to be formatted in the toolbar + */ + public function display(): array + { + $data = []; + $data['queries'] = array_map(static function (array $query) { + $isDuplicate = $query['duplicate'] === true; + + $firstNonSystemLine = ''; + + foreach ($query['trace'] as $index => &$line) { + // simplify file and line + if (isset($line['file'])) { + $line['file'] = clean_path($line['file']) . ':' . $line['line']; + unset($line['line']); + } else { + $line['file'] = '[internal function]'; + } + + // find the first trace line that does not originate from `system/` + if ($firstNonSystemLine === '' && strpos($line['file'], 'SYSTEMPATH') === false) { + $firstNonSystemLine = $line['file']; + } + + // simplify function call + if (isset($line['class'])) { + $line['function'] = $line['class'] . $line['type'] . $line['function']; + unset($line['class'], $line['type']); + } + + if (strrpos($line['function'], '{closure}') === false) { + $line['function'] .= '()'; + } + + $line['function'] = str_repeat(chr(0xC2) . chr(0xA0), 8) . $line['function']; + + // add index numbering padded with nonbreaking space + $indexPadded = str_pad(sprintf('%d', $index + 1), 3, ' ', STR_PAD_LEFT); + $indexPadded = preg_replace('/\s/', chr(0xC2) . chr(0xA0), $indexPadded); + + $line['index'] = $indexPadded . str_repeat(chr(0xC2) . chr(0xA0), 4); + } + + return [ + 'hover' => $isDuplicate ? 'This query was called more than once.' : '', + 'class' => $isDuplicate ? 'duplicate' : '', + 'duration' => ((float) $query['query']->getDuration(5) * 1000) . ' ms', + 'sql' => $query['query']->debugToolbarDisplay(), + 'trace' => $query['trace'], + 'trace-file' => $firstNonSystemLine, + 'qid' => md5($query['query'] . Time::now()->format('0.u00 U')), + ]; + }, static::$queries); + + return $data; + } + + /** + * Gets the "badge" value for the button. + */ + public function getBadgeValue(): int + { + return count(static::$queries); + } + + /** + * Information to be displayed next to the title. + * + * @return string The number of queries (in parentheses) or an empty string. + */ + public function getTitleDetails(): string + { + $this->getConnections(); + + $queryCount = count(static::$queries); + $uniqueCount = count(array_filter(static::$queries, static fn ($query) => $query['duplicate'] === false)); + $connectionCount = count($this->connections); + + return sprintf( + '(%d total Quer%s, %d %s unique across %d Connection%s)', + $queryCount, + $queryCount > 1 ? 'ies' : 'y', + $uniqueCount, + $uniqueCount > 1 ? 'of them' : '', + $connectionCount, + $connectionCount > 1 ? 's' : '' + ); + } + + /** + * Does this collector have any data collected? + */ + public function isEmpty(): bool + { + return static::$queries === []; + } + + /** + * Display the icon. + * + * Icon from https://icons8.com - 1em package + */ + public function icon(): string + { + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADMSURBVEhLY6A3YExLSwsA4nIycQDIDIhRWEBqamo/UNF/SjDQjF6ocZgAKPkRiFeEhoYyQ4WIBiA9QAuWAPEHqBAmgLqgHcolGQD1V4DMgHIxwbCxYD+QBqcKINseKo6eWrBioPrtQBq/BcgY5ht0cUIYbBg2AJKkRxCNWkDQgtFUNJwtABr+F6igE8olGQD114HMgHIxAVDyAhA/AlpSA8RYUwoeXAPVex5qHCbIyMgwBCkAuQJIY00huDBUz/mUlBQDqHGjgBjAwAAACexpph6oHSQAAAAASUVORK5CYII='; + } + + /** + * Gets the connections from the database config + */ + private function getConnections(): void + { + $this->connections = \Config\Database::getConnections(); + } +} diff --git a/system/Debug/Toolbar/Collectors/Events.php b/system/Debug/Toolbar/Collectors/Events.php new file mode 100644 index 0000000..178a886 --- /dev/null +++ b/system/Debug/Toolbar/Collectors/Events.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug\Toolbar\Collectors; + +/** + * Events collector + */ +class Events extends BaseCollector +{ + /** + * Whether this collector has data that can + * be displayed in the Timeline. + * + * @var bool + */ + protected $hasTimeline = true; + + /** + * Whether this collector needs to display + * content in a tab or not. + * + * @var bool + */ + protected $hasTabContent = true; + + /** + * Whether this collector has data that + * should be shown in the Vars tab. + * + * @var bool + */ + protected $hasVarData = false; + + /** + * The 'title' of this Collector. + * Used to name things in the toolbar HTML. + * + * @var string + */ + protected $title = 'Events'; + + /** + * Child classes should implement this to return the timeline data + * formatted for correct usage. + */ + protected function formatTimelineData(): array + { + $data = []; + + $rows = \CodeIgniter\Events\Events::getPerformanceLogs(); + + foreach ($rows as $info) { + $data[] = [ + 'name' => 'Event: ' . $info['event'], + 'component' => 'Events', + 'start' => $info['start'], + 'duration' => $info['end'] - $info['start'], + ]; + } + + return $data; + } + + /** + * Returns the data of this collector to be formatted in the toolbar + */ + public function display(): array + { + $data = [ + 'events' => [], + ]; + + foreach (\CodeIgniter\Events\Events::getPerformanceLogs() as $row) { + $key = $row['event']; + + if (! array_key_exists($key, $data['events'])) { + $data['events'][$key] = [ + 'event' => $key, + 'duration' => ($row['end'] - $row['start']) * 1000, + 'count' => 1, + ]; + + continue; + } + + $data['events'][$key]['duration'] += ($row['end'] - $row['start']) * 1000; + $data['events'][$key]['count']++; + } + + foreach ($data['events'] as &$row) { + $row['duration'] = number_format($row['duration'], 2); + } + + return $data; + } + + /** + * Gets the "badge" value for the button. + */ + public function getBadgeValue(): int + { + return count(\CodeIgniter\Events\Events::getPerformanceLogs()); + } + + /** + * Display the icon. + * + * Icon from https://icons8.com - 1em package + */ + public function icon(): string + { + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAEASURBVEhL7ZXNDcIwDIVTsRBH1uDQDdquUA6IM1xgCA6MwJUN2hk6AQzAz0vl0ETUxC5VT3zSU5w81/mRMGZysixbFEVR0jSKNt8geQU9aRpFmp/keX6AbjZ5oB74vsaN5lSzA4tLSjpBFxsjeSuRy4d2mDdQTWU7YLbXTNN05mKyovj5KL6B7q3hoy3KwdZxBlT+Ipz+jPHrBqOIynZgcZonoukb/0ckiTHqNvDXtXEAaygRbaB9FvUTjRUHsIYS0QaSp+Dw6wT4hiTmYHOcYZsdLQ2CbXa4ftuuYR4x9vYZgdb4vsFYUdmABMYeukK9/SUme3KMFQ77+Yfzh8eYF8+orDuDWU5LAAAAAElFTkSuQmCC'; + } +} diff --git a/system/Debug/Toolbar/Collectors/Files.php b/system/Debug/Toolbar/Collectors/Files.php new file mode 100644 index 0000000..c281906 --- /dev/null +++ b/system/Debug/Toolbar/Collectors/Files.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug\Toolbar\Collectors; + +/** + * Files collector + */ +class Files extends BaseCollector +{ + /** + * Whether this collector has data that can + * be displayed in the Timeline. + * + * @var bool + */ + protected $hasTimeline = false; + + /** + * Whether this collector needs to display + * content in a tab or not. + * + * @var bool + */ + protected $hasTabContent = true; + + /** + * The 'title' of this Collector. + * Used to name things in the toolbar HTML. + * + * @var string + */ + protected $title = 'Files'; + + /** + * Returns any information that should be shown next to the title. + */ + public function getTitleDetails(): string + { + return '( ' . count(get_included_files()) . ' )'; + } + + /** + * Returns the data of this collector to be formatted in the toolbar + */ + public function display(): array + { + $rawFiles = get_included_files(); + $coreFiles = []; + $userFiles = []; + + foreach ($rawFiles as $file) { + $path = clean_path($file); + + if (strpos($path, 'SYSTEMPATH') !== false) { + $coreFiles[] = [ + 'path' => $path, + 'name' => basename($file), + ]; + } else { + $userFiles[] = [ + 'path' => $path, + 'name' => basename($file), + ]; + } + } + + sort($userFiles); + sort($coreFiles); + + return [ + 'coreFiles' => $coreFiles, + 'userFiles' => $userFiles, + ]; + } + + /** + * Displays the number of included files as a badge in the tab button. + */ + public function getBadgeValue(): int + { + return count(get_included_files()); + } + + /** + * Display the icon. + * + * Icon from https://icons8.com - 1em package + */ + public function icon(): string + { + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGBSURBVEhL7ZQ9S8NQGIVTBQUncfMfCO4uLgoKbuKQOWg+OkXERRE1IAXrIHbVDrqIDuLiJgj+gro7S3dnpfq88b1FMTE3VZx64HBzzvvZWxKnj15QCcPwCD5HUfSWR+JtzgmtsUcQBEva5IIm9SwSu+95CAWbUuy67qBa32ByZEDpIaZYZSZMjjQuPcQUq8yEyYEb8FSerYeQVGbAFzJkX1PyQWLhgCz0BxTCekC1Wp0hsa6yokzhed4oje6Iz6rlJEkyIKfUEFtITVtQdAibn5rMyaYsMS+a5wTv8qeXMhcU16QZbKgl3hbs+L4/pnpdc87MElZgq10p5DxGdq8I7xrvUWUKvG3NbSK7ubngYzdJwSsF7TiOh9VOgfcEz1UayNe3JUPM1RWC5GXYgTfc75B4NBmXJnAtTfpABX0iPvEd9ezALwkplCFXcr9styiNOKc1RRZpaPM9tcqBwlWzGY1qPL9wjqRBgF5BH6j8HWh2S7MHlX8PrmbK+k/8PzjOOzx1D3i1pKTTAAAAAElFTkSuQmCC'; + } +} diff --git a/system/Debug/Toolbar/Collectors/History.php b/system/Debug/Toolbar/Collectors/History.php new file mode 100644 index 0000000..fcf06e1 --- /dev/null +++ b/system/Debug/Toolbar/Collectors/History.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug\Toolbar\Collectors; + +use DateTime; + +/** + * History collector + * + * @see \CodeIgniter\Debug\Toolbar\Collectors\HistoryTest + */ +class History extends BaseCollector +{ + /** + * Whether this collector has data that can + * be displayed in the Timeline. + * + * @var bool + */ + protected $hasTimeline = false; + + /** + * Whether this collector needs to display + * content in a tab or not. + * + * @var bool + */ + protected $hasTabContent = true; + + /** + * Whether this collector needs to display + * a label or not. + * + * @var bool + */ + protected $hasLabel = true; + + /** + * The 'title' of this Collector. + * Used to name things in the toolbar HTML. + * + * @var string + */ + protected $title = 'History'; + + /** + * @var array History files + */ + protected $files = []; + + /** + * Specify time limit & file count for debug history. + * + * @param string $current Current history time + * @param int $limit Max history files + * + * @return void + */ + public function setFiles(string $current, int $limit = 20) + { + $filenames = glob(WRITEPATH . 'debugbar/debugbar_*.json'); + + $files = []; + $counter = 0; + + foreach (array_reverse($filenames) as $filename) { + $counter++; + + // Oldest files will be deleted + if ($limit >= 0 && $counter > $limit) { + unlink($filename); + + continue; + } + + // Get the contents of this specific history request + $contents = file_get_contents($filename); + + $contents = @json_decode($contents); + if (json_last_error() === JSON_ERROR_NONE) { + preg_match('/debugbar_(.*)\.json$/s', $filename, $time); + $time = sprintf('%.6f', $time[1] ?? 0); + + // Debugbar files shown in History Collector + $files[] = [ + 'time' => $time, + 'datetime' => DateTime::createFromFormat('U.u', $time)->format('Y-m-d H:i:s.u'), + 'active' => $time === $current, + 'status' => $contents->vars->response->statusCode, + 'method' => $contents->method, + 'url' => $contents->url, + 'isAJAX' => $contents->isAJAX ? 'Yes' : 'No', + 'contentType' => $contents->vars->response->contentType, + ]; + } + } + + $this->files = $files; + } + + /** + * Returns the data of this collector to be formatted in the toolbar + */ + public function display(): array + { + return ['files' => $this->files]; + } + + /** + * Displays the number of included files as a badge in the tab button. + */ + public function getBadgeValue(): int + { + return count($this->files); + } + + /** + * Return true if there are no history files. + */ + public function isEmpty(): bool + { + return $this->files === []; + } + + /** + * Display the icon. + * + * Icon from https://icons8.com - 1em package + */ + public function icon(): string + { + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAJySURBVEhL3ZU7aJNhGIVTpV6i4qCIgkIHxcXLErS4FBwUFNwiCKGhuTYJGaIgnRoo4qRu6iCiiIuIXXTTIkIpuqoFwaGgonUQlC5KafU5ycmNP0lTdPLA4fu+8573/a4/f6hXpFKpwUwmc9fDfweKbk+n07fgEv33TLSbtt/hvwNFT1PsG/zdTE0Gp+GFfD6/2fbVIxqNrqPIRbjg4t/hY8aztcngfDabHXbKyiiXy2vcrcPH8oDCry2FKDrA+Ar6L01E/ypyXzXaARjDGGcoeNxSDZXE0dHRA5VRE5LJ5CFy5jzJuOX2wHRHRnjbklZ6isQ3tIctBaAd4vlK3jLtkOVWqABBXd47jGHLmjTmSScttQV5J+SjfcUweFQEbsjAas5aqoCLXutJl7vtQsAzpRowYqkBinyCC8Vicb2lOih8zoldd0F8RD7qTFiqAnGrAy8stUAvi/hbqDM+YzkAFrLPdR5ZqoLXsd+Bh5YCIH7JniVdquUWxOPxDfboHhrI5XJ7HHhiqQXox+APe/Qk64+gGYVCYZs8cMpSFQj9JOoFzVqqo7k4HIvFYpscCoAjOmLffUsNUGRaQUwDlmofUa34ecsdgXdcXo4wbakBgiUFafXJV8A4DJ/2UrxUKm3E95H8RbjLcgOJRGILhnmCP+FBy5XvwN2uIPcy1AJvWgqC4xm2aU4Xb3lF4I+Tpyf8hRe5w3J7YLymSeA8Z3nSclv4WLRyFdfOjzrUFX0klJUEtZtntCNc+F69cz/FiDzEPtjzmcUMOr83kDQEX6pAJxJfpL3OX22n01YN7SZCoQnaSdoZ+Jz+PZihH3wt/xlCoT9M6nEtmRSPCQAAAABJRU5ErkJggg=='; + } +} diff --git a/system/Debug/Toolbar/Collectors/Logs.php b/system/Debug/Toolbar/Collectors/Logs.php new file mode 100644 index 0000000..d656ca9 --- /dev/null +++ b/system/Debug/Toolbar/Collectors/Logs.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug\Toolbar\Collectors; + +use Config\Services; + +/** + * Loags collector + */ +class Logs extends BaseCollector +{ + /** + * Whether this collector has data that can + * be displayed in the Timeline. + * + * @var bool + */ + protected $hasTimeline = false; + + /** + * Whether this collector needs to display + * content in a tab or not. + * + * @var bool + */ + protected $hasTabContent = true; + + /** + * The 'title' of this Collector. + * Used to name things in the toolbar HTML. + * + * @var string + */ + protected $title = 'Logs'; + + /** + * Our collected data. + * + * @var array + */ + protected $data; + + /** + * Returns the data of this collector to be formatted in the toolbar + */ + public function display(): array + { + return [ + 'logs' => $this->collectLogs(), + ]; + } + + /** + * Does this collector actually have any data to display? + */ + public function isEmpty(): bool + { + $this->collectLogs(); + + return empty($this->data); + } + + /** + * Display the icon. + * + * Icon from https://icons8.com - 1em package + */ + public function icon(): string + { + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACYSURBVEhLYxgFJIHU1FSjtLS0i0D8AYj7gEKMEBkqAaAFF4D4ERCvAFrwH4gDoFIMKSkpFkB+OTEYqgUTACXfA/GqjIwMQyD9H2hRHlQKJFcBEiMGQ7VgAqCBvUgK32dmZspCpagGGNPT0/1BLqeF4bQHQJePpiIwhmrBBEADR1MRfgB0+WgqAmOoFkwANHA0FY0CUgEDAwCQ0PUpNB3kqwAAAABJRU5ErkJggg=='; + } + + /** + * Ensures the data has been collected. + * + * @return array + */ + protected function collectLogs() + { + if (! empty($this->data)) { + return $this->data; + } + + return $this->data = Services::logger(true)->logCache ?? []; + } +} diff --git a/system/Debug/Toolbar/Collectors/Routes.php b/system/Debug/Toolbar/Collectors/Routes.php new file mode 100644 index 0000000..0420c19 --- /dev/null +++ b/system/Debug/Toolbar/Collectors/Routes.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug\Toolbar\Collectors; + +use CodeIgniter\Router\DefinedRouteCollector; +use Config\Services; +use ReflectionException; +use ReflectionFunction; +use ReflectionMethod; + +/** + * Routes collector + */ +class Routes extends BaseCollector +{ + /** + * Whether this collector has data that can + * be displayed in the Timeline. + * + * @var bool + */ + protected $hasTimeline = false; + + /** + * Whether this collector needs to display + * content in a tab or not. + * + * @var bool + */ + protected $hasTabContent = true; + + /** + * The 'title' of this Collector. + * Used to name things in the toolbar HTML. + * + * @var string + */ + protected $title = 'Routes'; + + /** + * Returns the data of this collector to be formatted in the toolbar + * + * @throws ReflectionException + */ + public function display(): array + { + $rawRoutes = Services::routes(true); + $router = Services::router(null, null, true); + + // Get our parameters + // Closure routes + if (is_callable($router->controllerName())) { + $method = new ReflectionFunction($router->controllerName()); + } else { + try { + $method = new ReflectionMethod($router->controllerName(), $router->methodName()); + } catch (ReflectionException $e) { + // If we're here, the method doesn't exist + // and is likely calculated in _remap. + $method = new ReflectionMethod($router->controllerName(), '_remap'); + } + } + + $rawParams = $method->getParameters(); + + $params = []; + + foreach ($rawParams as $key => $param) { + $params[] = [ + 'name' => '$' . $param->getName() . ' = ', + 'value' => $router->params()[$key] ?? + ' | default: ' + . var_export( + $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, + true + ), + ]; + } + + $matchedRoute = [ + [ + 'directory' => $router->directory(), + 'controller' => $router->controllerName(), + 'method' => $router->methodName(), + 'paramCount' => count($router->params()), + 'truePCount' => count($params), + 'params' => $params, + ], + ]; + + // Defined Routes + $routes = []; + + $definedRouteCollector = new DefinedRouteCollector($rawRoutes); + + foreach ($definedRouteCollector->collect() as $route) { + // filter for strings, as callbacks aren't displayable + if ($route['handler'] !== '(Closure)') { + $routes[] = [ + 'method' => strtoupper($route['method']), + 'route' => $route['route'], + 'handler' => $route['handler'], + ]; + } + } + + return [ + 'matchedRoute' => $matchedRoute, + 'routes' => $routes, + ]; + } + + /** + * Returns a count of all the routes in the system. + */ + public function getBadgeValue(): int + { + $rawRoutes = Services::routes(true); + + return count($rawRoutes->getRoutes()); + } + + /** + * Display the icon. + * + * Icon from https://icons8.com - 1em package + */ + public function icon(): string + { + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAFDSURBVEhL7ZRNSsNQFIUjVXSiOFEcuQIHDpzpxC0IGYeE/BEInbWlCHEDLsSiuANdhKDjgm6ggtSJ+l25ldrmmTwIgtgDh/t37r1J+16cX0dRFMtpmu5pWAkrvYjjOB7AETzStBFW+inxu3KUJMmhludQpoflS1zXban4LYqiO224h6VLTHr8Z+z8EpIHFF9gG78nDVmW7UgTHKjsCyY98QP+pcq+g8Ku2s8G8X3f3/I8b038WZTp+bO38zxfFd+I6YY6sNUvFlSDk9CRhiAI1jX1I9Cfw7GG1UB8LAuwbU0ZwQnbRDeEN5qqBxZMLtE1ti9LtbREnMIuOXnyIf5rGIb7Wq8HmlZgwYBH7ORTcKH5E4mpjeGt9fBZcHE2GCQ3Vt7oTNPNg+FXLHnSsHkw/FR+Gg2bB8Ptzrst/v6C/wrH+QB+duli6MYJdQAAAABJRU5ErkJggg=='; + } +} diff --git a/system/Debug/Toolbar/Collectors/Timers.php b/system/Debug/Toolbar/Collectors/Timers.php new file mode 100644 index 0000000..cfce9e6 --- /dev/null +++ b/system/Debug/Toolbar/Collectors/Timers.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug\Toolbar\Collectors; + +use Config\Services; + +/** + * Timers collector + */ +class Timers extends BaseCollector +{ + /** + * Whether this collector has data that can + * be displayed in the Timeline. + * + * @var bool + */ + protected $hasTimeline = true; + + /** + * Whether this collector needs to display + * content in a tab or not. + * + * @var bool + */ + protected $hasTabContent = false; + + /** + * The 'title' of this Collector. + * Used to name things in the toolbar HTML. + * + * @var string + */ + protected $title = 'Timers'; + + /** + * Child classes should implement this to return the timeline data + * formatted for correct usage. + */ + protected function formatTimelineData(): array + { + $data = []; + + $benchmark = Services::timer(true); + $rows = $benchmark->getTimers(6); + + foreach ($rows as $name => $info) { + if ($name === 'total_execution') { + continue; + } + + $data[] = [ + 'name' => ucwords(str_replace('_', ' ', $name)), + 'component' => 'Timer', + 'start' => $info['start'], + 'duration' => $info['end'] - $info['start'], + ]; + } + + return $data; + } +} diff --git a/system/Debug/Toolbar/Collectors/Views.php b/system/Debug/Toolbar/Collectors/Views.php new file mode 100644 index 0000000..ea44dae --- /dev/null +++ b/system/Debug/Toolbar/Collectors/Views.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug\Toolbar\Collectors; + +use CodeIgniter\View\RendererInterface; +use Config\Services; + +/** + * Views collector + */ +class Views extends BaseCollector +{ + /** + * Whether this collector has data that can + * be displayed in the Timeline. + * + * @var bool + */ + protected $hasTimeline = true; + + /** + * Whether this collector needs to display + * content in a tab or not. + * + * @var bool + */ + protected $hasTabContent = false; + + /** + * Whether this collector needs to display + * a label or not. + * + * @var bool + */ + protected $hasLabel = true; + + /** + * Whether this collector has data that + * should be shown in the Vars tab. + * + * @var bool + */ + protected $hasVarData = true; + + /** + * The 'title' of this Collector. + * Used to name things in the toolbar HTML. + * + * @var string + */ + protected $title = 'Views'; + + /** + * Instance of the shared Renderer service + * + * @var RendererInterface|null + */ + protected $viewer; + + /** + * Views counter + * + * @var array + */ + protected $views = []; + + private function initViewer(): void + { + $this->viewer ??= Services::renderer(); + } + + /** + * Child classes should implement this to return the timeline data + * formatted for correct usage. + */ + protected function formatTimelineData(): array + { + $this->initViewer(); + + $data = []; + + $rows = $this->viewer->getPerformanceData(); + + foreach ($rows as $info) { + $data[] = [ + 'name' => 'View: ' . $info['view'], + 'component' => 'Views', + 'start' => $info['start'], + 'duration' => $info['end'] - $info['start'], + ]; + } + + return $data; + } + + /** + * Gets a collection of data that should be shown in the 'Vars' tab. + * The format is an array of sections, each with their own array + * of key/value pairs: + * + * $data = [ + * 'section 1' => [ + * 'foo' => 'bar, + * 'bar' => 'baz' + * ], + * 'section 2' => [ + * 'foo' => 'bar, + * 'bar' => 'baz' + * ], + * ]; + */ + public function getVarData(): array + { + $this->initViewer(); + + return [ + 'View Data' => $this->viewer->getData(), + ]; + } + + /** + * Returns a count of all views. + */ + public function getBadgeValue(): int + { + $this->initViewer(); + + return count($this->viewer->getPerformanceData()); + } + + /** + * Display the icon. + * + * Icon from https://icons8.com - 1em package + */ + public function icon(): string + { + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADeSURBVEhL7ZSxDcIwEEWNYA0YgGmgyAaJLTcUaaBzQQEVjMEabBQxAdw53zTHiThEovGTfnE/9rsoRUxhKLOmaa6Uh7X2+UvguLCzVxN1XW9x4EYHzik033Hp3X0LO+DaQG8MDQcuq6qao4qkHuMgQggLvkPLjqh00ZgFDBacMJYFkuwFlH1mshdkZ5JPJERA9JpI6xNCBESvibQ+IURA9JpI6xNCBESvibQ+IURA9DTsuHTOrVFFxixgB/eUFlU8uKJ0eDBFOu/9EvoeKnlJS2/08Tc8NOwQ8sIfMeYFjqKDjdU2sp4AAAAASUVORK5CYII='; + } +} diff --git a/system/Debug/Toolbar/Views/_config.tpl b/system/Debug/Toolbar/Views/_config.tpl new file mode 100644 index 0000000..b6c7e0c --- /dev/null +++ b/system/Debug/Toolbar/Views/_config.tpl @@ -0,0 +1,48 @@ +

+ Read the CodeIgniter docs... +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeIgniter Version:{ ciVersion }
PHP Version:{ phpVersion }
PHP SAPI:{ phpSAPI }
Environment:{ environment }
Base URL: + { if $baseURL == '' } +
+ The $baseURL should always be set manually to prevent possible URL personification from external parties. +
+ { else } + { baseURL } + { endif } +
Timezone:{ timezone }
Locale:{ locale }
Content Security Policy Enabled:{ if $cspEnabled } Yes { else } No { endif }
diff --git a/system/Debug/Toolbar/Views/_database.tpl b/system/Debug/Toolbar/Views/_database.tpl new file mode 100644 index 0000000..1bd9b8a --- /dev/null +++ b/system/Debug/Toolbar/Views/_database.tpl @@ -0,0 +1,26 @@ + + + + + + + + + {queries} + + + + + + + + + + {/queries} + +
TimeQuery String
{duration}{! sql !}{trace-file}
diff --git a/system/Debug/Toolbar/Views/_events.tpl b/system/Debug/Toolbar/Views/_events.tpl new file mode 100644 index 0000000..88d732f --- /dev/null +++ b/system/Debug/Toolbar/Views/_events.tpl @@ -0,0 +1,18 @@ + + + + + + + + + + {events} + + + + + + {/events} + +
TimeEvent NameTimes Called
{ duration } ms{event}{count}
diff --git a/system/Debug/Toolbar/Views/_files.tpl b/system/Debug/Toolbar/Views/_files.tpl new file mode 100644 index 0000000..9c992ab --- /dev/null +++ b/system/Debug/Toolbar/Views/_files.tpl @@ -0,0 +1,16 @@ + + + {userFiles} + + + + + {/userFiles} + {coreFiles} + + + + + {/coreFiles} + +
{name}{path}
{name}{path}
diff --git a/system/Debug/Toolbar/Views/_history.tpl b/system/Debug/Toolbar/Views/_history.tpl new file mode 100644 index 0000000..7f22f56 --- /dev/null +++ b/system/Debug/Toolbar/Views/_history.tpl @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + {files} + + + + + + + + + + {/files} + +
ActionDatetimeStatusMethodURLContent-TypeIs AJAX?
+ + {datetime}{status}{method}{url}{contentType}{isAJAX}
diff --git a/system/Debug/Toolbar/Views/_logs.tpl b/system/Debug/Toolbar/Views/_logs.tpl new file mode 100644 index 0000000..7c80d84 --- /dev/null +++ b/system/Debug/Toolbar/Views/_logs.tpl @@ -0,0 +1,20 @@ +{ if $logs == [] } +

Nothing was logged. If you were expecting logged items, ensure that LoggerConfig file has the correct threshold set.

+{ else } + + + + + + + + + {logs} + + + + + {/logs} + +
SeverityMessage
{level}{msg}
+{ endif } diff --git a/system/Debug/Toolbar/Views/_routes.tpl b/system/Debug/Toolbar/Views/_routes.tpl new file mode 100644 index 0000000..e277046 --- /dev/null +++ b/system/Debug/Toolbar/Views/_routes.tpl @@ -0,0 +1,52 @@ +

Matched Route

+ + + + {matchedRoute} + + + + + + + + + + + + + + + + + {params} + + + + + {/params} + {/matchedRoute} + +
Directory:{directory}
Controller:{controller}
Method:{method}
Params:{paramCount} / {truePCount}
{name}{value}
+ + +

Defined Routes

+ + + + + + + + + + + {routes} + + + + + + {/routes} + +
MethodRouteHandler
{method}{route}{handler}
diff --git a/system/Debug/Toolbar/Views/toolbar.css b/system/Debug/Toolbar/Views/toolbar.css new file mode 100644 index 0000000..8a302b4 --- /dev/null +++ b/system/Debug/Toolbar/Views/toolbar.css @@ -0,0 +1,806 @@ +/** + * This file is part of the CodeIgniter 4 framework. + * + * (c) CodeIgniter Foundation + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +#debug-icon { + bottom: 0; + position: fixed; + right: 0; + z-index: 10000; + height: 36px; + width: 36px; + margin: 0px; + padding: 0px; + clear: both; + text-align: center; +} +#debug-icon a svg { + margin: 8px; + max-width: 20px; + max-height: 20px; +} +#debug-icon.fixed-top { + bottom: auto; + top: 0; +} +#debug-icon .debug-bar-ndisplay { + display: none; +} + +#debug-bar { + bottom: 0; + left: 0; + position: fixed; + right: 0; + z-index: 10000; + height: 36px; + line-height: 36px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-size: 16px; + font-weight: 400; +} +#debug-bar h1 { + display: flex; + font-weight: normal; + margin: 0 0 0 auto; +} +#debug-bar h1 svg { + width: 16px; + margin-right: 5px; +} +#debug-bar h2 { + font-size: 16px; + margin: 0; + padding: 5px 0 10px 0; +} +#debug-bar h2 span { + font-size: 13px; +} +#debug-bar h3 { + font-size: 12px; + font-weight: 200; + margin: 0 0 0 10px; + padding: 0; + text-transform: uppercase; +} +#debug-bar p { + font-size: 12px; + margin: 0 0 0 15px; + padding: 0; +} +#debug-bar a { + text-decoration: none; +} +#debug-bar a:hover { + text-decoration: underline; +} +#debug-bar button { + border: 1px solid; + border-radius: 4px; + -moz-border-radius: 4px; + -webkit-border-radius: 4px; + cursor: pointer; + line-height: 15px; +} +#debug-bar button:hover { + text-decoration: underline; +} +#debug-bar table { + border-collapse: collapse; + font-size: 14px; + line-height: normal; + margin: 5px 10px 15px 10px; + width: calc(100% - 10px); +} +#debug-bar table strong { + font-weight: 500; +} +#debug-bar table th { + display: table-cell; + font-weight: 600; + padding-bottom: 0.7em; + text-align: left; +} +#debug-bar table tr { + border: none; +} +#debug-bar table td { + border: none; + display: table-cell; + margin: 0; + text-align: left; +} +#debug-bar table td:first-child { + max-width: 20%; +} +#debug-bar table td:first-child.narrow { + width: 7em; +} +#debug-bar td[data-debugbar-route] form { + display: none; +} +#debug-bar td[data-debugbar-route]:hover form { + display: block; +} +#debug-bar td[data-debugbar-route]:hover > div { + display: none; +} +#debug-bar td[data-debugbar-route] input[type=text] { + padding: 2px; +} +#debug-bar .toolbar { + display: flex; + overflow: hidden; + overflow-y: auto; + padding: 0 12px 0 12px; + white-space: nowrap; + z-index: 10000; +} +#debug-bar .toolbar .rotate { + animation: toolbar-rotate 9s linear infinite; +} +@keyframes toolbar-rotate { + to { + transform: rotate(360deg); + } +} +#debug-bar.fixed-top { + bottom: auto; + top: 0; +} +#debug-bar.fixed-top .tab { + bottom: auto; + top: 36px; +} +#debug-bar #toolbar-position a, +#debug-bar #toolbar-theme a { + padding: 0 6px; + display: inline-flex; + vertical-align: top; +} +#debug-bar #toolbar-position a:hover, +#debug-bar #toolbar-theme a:hover { + text-decoration: none; +} +#debug-bar #debug-bar-link { + display: flex; + padding: 6px; +} +#debug-bar .ci-label { + display: inline-flex; + font-size: 14px; +} +#debug-bar .ci-label:hover { + cursor: pointer; +} +#debug-bar .ci-label a { + color: inherit; + display: flex; + letter-spacing: normal; + padding: 0 10px; + text-decoration: none; + align-items: center; +} +#debug-bar .ci-label img { + margin: 6px 3px 6px 0; + width: 16px !important; +} +#debug-bar .ci-label .badge { + border-radius: 12px; + -moz-border-radius: 12px; + -webkit-border-radius: 12px; + display: inline-block; + font-size: 75%; + font-weight: bold; + line-height: 12px; + margin-left: 5px; + padding: 2px 5px; + text-align: center; + vertical-align: baseline; + white-space: nowrap; +} +#debug-bar .tab { + bottom: 35px; + display: none; + left: 0; + max-height: 62%; + overflow: hidden; + overflow-y: auto; + padding: 1em 2em; + position: fixed; + right: 0; + z-index: 9999; +} +#debug-bar .timeline { + margin-left: 0; + width: 100%; +} +#debug-bar .timeline th { + border-left: 1px solid; + font-size: 12px; + font-weight: 200; + padding: 5px 5px 10px 5px; + position: relative; + text-align: left; +} +#debug-bar .timeline th:first-child { + border-left: 0; +} +#debug-bar .timeline td { + border-left: 1px solid; + padding: 5px; + position: relative; +} +#debug-bar .timeline td:first-child { + border-left: 0; + max-width: none; +} +#debug-bar .timeline td.child-container { + padding: 0px; +} +#debug-bar .timeline td.child-container .timeline { + margin: 0px; +} +#debug-bar .timeline td.child-container .timeline td:first-child:not(.child-container) { + padding-left: calc(5px + 10px * var(--level)); +} +#debug-bar .timeline .timer { + border-radius: 4px; + -moz-border-radius: 4px; + -webkit-border-radius: 4px; + display: inline-block; + padding: 5px; + position: absolute; + top: 30%; +} +#debug-bar .timeline .timeline-parent { + cursor: pointer; +} +#debug-bar .timeline .timeline-parent td:first-child nav { + background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMCAxNTAiPjxwYXRoIGQ9Ik02IDdoMThsLTkgMTV6bTAgMzBoMThsLTkgMTV6bTAgNDVoMThsLTktMTV6bTAgMzBoMThsLTktMTV6bTAgMTJsMTggMThtLTE4IDBsMTgtMTgiIGZpbGw9IiM1NTUiLz48cGF0aCBkPSJNNiAxMjZsMTggMThtLTE4IDBsMTgtMTgiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSIjNTU1Ii8+PC9zdmc+") no-repeat scroll 0 0/15px 75px transparent; + background-position: 0 25%; + display: inline-block; + height: 15px; + width: 15px; + margin-right: 3px; + vertical-align: middle; +} +#debug-bar .timeline .timeline-parent-open { + background-color: #DFDFDF; +} +#debug-bar .timeline .timeline-parent-open td:first-child nav { + background-position: 0 75%; +} +#debug-bar .timeline .child-row:hover { + background: transparent; +} +#debug-bar .route-params, +#debug-bar .route-params-item { + vertical-align: top; +} +#debug-bar .route-params td:first-child, +#debug-bar .route-params-item td:first-child { + font-style: italic; + padding-left: 1em; + text-align: right; +} + +.debug-view.show-view { + border: 1px solid; + margin: 4px; +} + +.debug-view-path { + font-family: monospace; + font-size: 12px; + letter-spacing: normal; + min-height: 16px; + padding: 2px; + text-align: left; +} + +.show-view .debug-view-path { + display: block !important; +} + +@media screen and (max-width: 1024px) { + #debug-bar .ci-label img { + margin: unset; + } + .hide-sm { + display: none !important; + } +} +#debug-icon { + background-color: #FFFFFF; + box-shadow: 0 0 4px #DFDFDF; + -moz-box-shadow: 0 0 4px #DFDFDF; + -webkit-box-shadow: 0 0 4px #DFDFDF; +} +#debug-icon a:active, +#debug-icon a:link, +#debug-icon a:visited { + color: #DD8615; +} + +#debug-bar { + background-color: #FFFFFF; + color: #434343; +} +#debug-bar h1, +#debug-bar h2, +#debug-bar h3, +#debug-bar p, +#debug-bar a, +#debug-bar button, +#debug-bar table, +#debug-bar thead, +#debug-bar tr, +#debug-bar td, +#debug-bar button, +#debug-bar .toolbar { + background-color: transparent; + color: #434343; +} +#debug-bar button { + background-color: #FFFFFF; +} +#debug-bar table strong { + color: #DD8615; +} +#debug-bar table tbody tr:hover { + background-color: #DFDFDF; +} +#debug-bar table tbody tr.current { + background-color: #FDC894; +} +#debug-bar table tbody tr.current:hover td { + background-color: #DD4814; + color: #FFFFFF; +} +#debug-bar .toolbar { + background-color: #FFFFFF; + box-shadow: 0 0 4px #DFDFDF; + -moz-box-shadow: 0 0 4px #DFDFDF; + -webkit-box-shadow: 0 0 4px #DFDFDF; +} +#debug-bar .toolbar img { + filter: brightness(0) invert(0.4); +} +#debug-bar.fixed-top .toolbar { + box-shadow: 0 0 4px #DFDFDF; + -moz-box-shadow: 0 0 4px #DFDFDF; + -webkit-box-shadow: 0 0 4px #DFDFDF; +} +#debug-bar.fixed-top .tab { + box-shadow: 0 1px 4px #DFDFDF; + -moz-box-shadow: 0 1px 4px #DFDFDF; + -webkit-box-shadow: 0 1px 4px #DFDFDF; +} +#debug-bar .muted { + color: #434343; +} +#debug-bar .muted td { + color: #DFDFDF; +} +#debug-bar .muted:hover td { + color: #434343; +} +#debug-bar #toolbar-position, +#debug-bar #toolbar-theme { + filter: brightness(0) invert(0.6); +} +#debug-bar .ci-label.active { + background-color: #DFDFDF; +} +#debug-bar .ci-label:hover { + background-color: #DFDFDF; +} +#debug-bar .ci-label .badge { + background-color: #DD4814; + color: #FFFFFF; +} +#debug-bar .tab { + background-color: #FFFFFF; + box-shadow: 0 -1px 4px #DFDFDF; + -moz-box-shadow: 0 -1px 4px #DFDFDF; + -webkit-box-shadow: 0 -1px 4px #DFDFDF; +} +#debug-bar .timeline th, +#debug-bar .timeline td { + border-color: #DFDFDF; +} +#debug-bar .timeline .timer { + background-color: #DD8615; +} + +.debug-view.show-view { + border-color: #DD8615; +} + +.debug-view-path { + background-color: #FDC894; + color: #434343; +} + +@media (prefers-color-scheme: dark) { + #debug-icon { + background-color: #252525; + box-shadow: 0 0 4px #DFDFDF; + -moz-box-shadow: 0 0 4px #DFDFDF; + -webkit-box-shadow: 0 0 4px #DFDFDF; + } + #debug-icon a:active, + #debug-icon a:link, + #debug-icon a:visited { + color: #DD8615; + } + #debug-bar { + background-color: #252525; + color: #DFDFDF; + } + #debug-bar h1, + #debug-bar h2, + #debug-bar h3, + #debug-bar p, + #debug-bar a, + #debug-bar button, + #debug-bar table, + #debug-bar thead, + #debug-bar tr, + #debug-bar td, + #debug-bar button, + #debug-bar .toolbar { + background-color: transparent; + color: #DFDFDF; + } + #debug-bar button { + background-color: #252525; + } + #debug-bar table strong { + color: #DD8615; + } + #debug-bar table tbody tr:hover { + background-color: #434343; + } + #debug-bar table tbody tr.current { + background-color: #FDC894; + } + #debug-bar table tbody tr.current td { + color: #252525; + } + #debug-bar table tbody tr.current:hover td { + background-color: #DD4814; + color: #FFFFFF; + } + #debug-bar .toolbar { + background-color: #434343; + box-shadow: 0 0 4px #434343; + -moz-box-shadow: 0 0 4px #434343; + -webkit-box-shadow: 0 0 4px #434343; + } + #debug-bar .toolbar img { + filter: brightness(0) invert(1); + } + #debug-bar.fixed-top .toolbar { + box-shadow: 0 0 4px #434343; + -moz-box-shadow: 0 0 4px #434343; + -webkit-box-shadow: 0 0 4px #434343; + } + #debug-bar.fixed-top .tab { + box-shadow: 0 1px 4px #434343; + -moz-box-shadow: 0 1px 4px #434343; + -webkit-box-shadow: 0 1px 4px #434343; + } + #debug-bar .muted { + color: #DFDFDF; + } + #debug-bar .muted td { + color: #434343; + } + #debug-bar .muted:hover td { + color: #DFDFDF; + } + #debug-bar #toolbar-position, + #debug-bar #toolbar-theme { + filter: brightness(0) invert(0.6); + } + #debug-bar .ci-label.active { + background-color: #252525; + } + #debug-bar .ci-label:hover { + background-color: #252525; + } + #debug-bar .ci-label .badge { + background-color: #DD4814; + color: #FFFFFF; + } + #debug-bar .tab { + background-color: #252525; + box-shadow: 0 -1px 4px #434343; + -moz-box-shadow: 0 -1px 4px #434343; + -webkit-box-shadow: 0 -1px 4px #434343; + } + #debug-bar .timeline th, + #debug-bar .timeline td { + border-color: #434343; + } + #debug-bar .timeline .timer { + background-color: #DD8615; + } + .debug-view.show-view { + border-color: #DD8615; + } + .debug-view-path { + background-color: #FDC894; + color: #434343; + } +} +#toolbarContainer.dark #debug-icon { + background-color: #252525; + box-shadow: 0 0 4px #DFDFDF; + -moz-box-shadow: 0 0 4px #DFDFDF; + -webkit-box-shadow: 0 0 4px #DFDFDF; +} +#toolbarContainer.dark #debug-icon a:active, +#toolbarContainer.dark #debug-icon a:link, +#toolbarContainer.dark #debug-icon a:visited { + color: #DD8615; +} +#toolbarContainer.dark #debug-bar { + background-color: #252525; + color: #DFDFDF; +} +#toolbarContainer.dark #debug-bar h1, +#toolbarContainer.dark #debug-bar h2, +#toolbarContainer.dark #debug-bar h3, +#toolbarContainer.dark #debug-bar p, +#toolbarContainer.dark #debug-bar a, +#toolbarContainer.dark #debug-bar button, +#toolbarContainer.dark #debug-bar table, +#toolbarContainer.dark #debug-bar thead, +#toolbarContainer.dark #debug-bar tr, +#toolbarContainer.dark #debug-bar td, +#toolbarContainer.dark #debug-bar button, +#toolbarContainer.dark #debug-bar .toolbar { + background-color: transparent; + color: #DFDFDF; +} +#toolbarContainer.dark #debug-bar button { + background-color: #252525; +} +#toolbarContainer.dark #debug-bar table strong { + color: #DD8615; +} +#toolbarContainer.dark #debug-bar table tbody tr:hover { + background-color: #434343; +} +#toolbarContainer.dark #debug-bar table tbody tr.current { + background-color: #FDC894; +} +#toolbarContainer.dark #debug-bar table tbody tr.current td { + color: #252525; +} +#toolbarContainer.dark #debug-bar table tbody tr.current:hover td { + background-color: #DD4814; + color: #FFFFFF; +} +#toolbarContainer.dark #debug-bar .toolbar { + background-color: #434343; + box-shadow: 0 0 4px #434343; + -moz-box-shadow: 0 0 4px #434343; + -webkit-box-shadow: 0 0 4px #434343; +} +#toolbarContainer.dark #debug-bar .toolbar img { + filter: brightness(0) invert(1); +} +#toolbarContainer.dark #debug-bar.fixed-top .toolbar { + box-shadow: 0 0 4px #434343; + -moz-box-shadow: 0 0 4px #434343; + -webkit-box-shadow: 0 0 4px #434343; +} +#toolbarContainer.dark #debug-bar.fixed-top .tab { + box-shadow: 0 1px 4px #434343; + -moz-box-shadow: 0 1px 4px #434343; + -webkit-box-shadow: 0 1px 4px #434343; +} +#toolbarContainer.dark #debug-bar .muted { + color: #DFDFDF; +} +#toolbarContainer.dark #debug-bar .muted td { + color: #434343; +} +#toolbarContainer.dark #debug-bar .muted:hover td { + color: #DFDFDF; +} +#toolbarContainer.dark #debug-bar #toolbar-position, +#toolbarContainer.dark #debug-bar #toolbar-theme { + filter: brightness(0) invert(0.6); +} +#toolbarContainer.dark #debug-bar .ci-label.active { + background-color: #252525; +} +#toolbarContainer.dark #debug-bar .ci-label:hover { + background-color: #252525; +} +#toolbarContainer.dark #debug-bar .ci-label .badge { + background-color: #DD4814; + color: #FFFFFF; +} +#toolbarContainer.dark #debug-bar .tab { + background-color: #252525; + box-shadow: 0 -1px 4px #434343; + -moz-box-shadow: 0 -1px 4px #434343; + -webkit-box-shadow: 0 -1px 4px #434343; +} +#toolbarContainer.dark #debug-bar .timeline th, +#toolbarContainer.dark #debug-bar .timeline td { + border-color: #434343; +} +#toolbarContainer.dark #debug-bar .timeline .timer { + background-color: #DD8615; +} +#toolbarContainer.dark .debug-view.show-view { + border-color: #DD8615; +} +#toolbarContainer.dark .debug-view-path { + background-color: #FDC894; + color: #434343; +} +#toolbarContainer.dark td[data-debugbar-route] input[type=text] { + background: #000; + color: #fff; +} + +#toolbarContainer.light #debug-icon { + background-color: #FFFFFF; + box-shadow: 0 0 4px #DFDFDF; + -moz-box-shadow: 0 0 4px #DFDFDF; + -webkit-box-shadow: 0 0 4px #DFDFDF; +} +#toolbarContainer.light #debug-icon a:active, +#toolbarContainer.light #debug-icon a:link, +#toolbarContainer.light #debug-icon a:visited { + color: #DD8615; +} +#toolbarContainer.light #debug-bar { + background-color: #FFFFFF; + color: #434343; +} +#toolbarContainer.light #debug-bar h1, +#toolbarContainer.light #debug-bar h2, +#toolbarContainer.light #debug-bar h3, +#toolbarContainer.light #debug-bar p, +#toolbarContainer.light #debug-bar a, +#toolbarContainer.light #debug-bar button, +#toolbarContainer.light #debug-bar table, +#toolbarContainer.light #debug-bar thead, +#toolbarContainer.light #debug-bar tr, +#toolbarContainer.light #debug-bar td, +#toolbarContainer.light #debug-bar button, +#toolbarContainer.light #debug-bar .toolbar { + background-color: transparent; + color: #434343; +} +#toolbarContainer.light #debug-bar button { + background-color: #FFFFFF; +} +#toolbarContainer.light #debug-bar table strong { + color: #DD8615; +} +#toolbarContainer.light #debug-bar table tbody tr:hover { + background-color: #DFDFDF; +} +#toolbarContainer.light #debug-bar table tbody tr.current { + background-color: #FDC894; +} +#toolbarContainer.light #debug-bar table tbody tr.current:hover td { + background-color: #DD4814; + color: #FFFFFF; +} +#toolbarContainer.light #debug-bar .toolbar { + background-color: #FFFFFF; + box-shadow: 0 0 4px #DFDFDF; + -moz-box-shadow: 0 0 4px #DFDFDF; + -webkit-box-shadow: 0 0 4px #DFDFDF; +} +#toolbarContainer.light #debug-bar .toolbar img { + filter: brightness(0) invert(0.4); +} +#toolbarContainer.light #debug-bar.fixed-top .toolbar { + box-shadow: 0 0 4px #DFDFDF; + -moz-box-shadow: 0 0 4px #DFDFDF; + -webkit-box-shadow: 0 0 4px #DFDFDF; +} +#toolbarContainer.light #debug-bar.fixed-top .tab { + box-shadow: 0 1px 4px #DFDFDF; + -moz-box-shadow: 0 1px 4px #DFDFDF; + -webkit-box-shadow: 0 1px 4px #DFDFDF; +} +#toolbarContainer.light #debug-bar .muted { + color: #434343; +} +#toolbarContainer.light #debug-bar .muted td { + color: #DFDFDF; +} +#toolbarContainer.light #debug-bar .muted:hover td { + color: #434343; +} +#toolbarContainer.light #debug-bar #toolbar-position, +#toolbarContainer.light #debug-bar #toolbar-theme { + filter: brightness(0) invert(0.6); +} +#toolbarContainer.light #debug-bar .ci-label.active { + background-color: #DFDFDF; +} +#toolbarContainer.light #debug-bar .ci-label:hover { + background-color: #DFDFDF; +} +#toolbarContainer.light #debug-bar .ci-label .badge { + background-color: #DD4814; + color: #FFFFFF; +} +#toolbarContainer.light #debug-bar .tab { + background-color: #FFFFFF; + box-shadow: 0 -1px 4px #DFDFDF; + -moz-box-shadow: 0 -1px 4px #DFDFDF; + -webkit-box-shadow: 0 -1px 4px #DFDFDF; +} +#toolbarContainer.light #debug-bar .timeline th, +#toolbarContainer.light #debug-bar .timeline td { + border-color: #DFDFDF; +} +#toolbarContainer.light #debug-bar .timeline .timer { + background-color: #DD8615; +} +#toolbarContainer.light .debug-view.show-view { + border-color: #DD8615; +} +#toolbarContainer.light .debug-view-path { + background-color: #FDC894; + color: #434343; +} + +.debug-bar-width30 { + width: 30%; +} + +.debug-bar-width10 { + width: 10%; +} + +.debug-bar-width70p { + width: 70px; +} + +.debug-bar-width190p { + width: 190px; +} + +.debug-bar-width20e { + width: 20em; +} + +.debug-bar-width6r { + width: 6rem; +} + +.debug-bar-ndisplay { + display: none; +} + +.debug-bar-alignRight { + text-align: right; +} + +.debug-bar-alignLeft { + text-align: left; +} + +.debug-bar-noverflow { + overflow: hidden; +} diff --git a/system/Debug/Toolbar/Views/toolbar.js b/system/Debug/Toolbar/Views/toolbar.js new file mode 100644 index 0000000..073a76a --- /dev/null +++ b/system/Debug/Toolbar/Views/toolbar.js @@ -0,0 +1,782 @@ +/* + * Functionality for the CodeIgniter Debug Toolbar. + */ + +var ciDebugBar = { + toolbarContainer: null, + toolbar: null, + icon: null, + + init: function () { + this.toolbarContainer = document.getElementById("toolbarContainer"); + this.toolbar = document.getElementById("debug-bar"); + this.icon = document.getElementById("debug-icon"); + + ciDebugBar.createListeners(); + ciDebugBar.setToolbarState(); + ciDebugBar.setToolbarPosition(); + ciDebugBar.setToolbarTheme(); + ciDebugBar.toggleViewsHints(); + ciDebugBar.routerLink(); + ciDebugBar.setHotReloadState(); + + document + .getElementById("debug-bar-link") + .addEventListener("click", ciDebugBar.toggleToolbar, true); + document + .getElementById("debug-icon-link") + .addEventListener("click", ciDebugBar.toggleToolbar, true); + + // Allows to highlight the row of the current history request + var btn = this.toolbar.querySelector( + 'button[data-time="' + localStorage.getItem("debugbar-time") + '"]' + ); + ciDebugBar.addClass(btn.parentNode.parentNode, "current"); + + historyLoad = this.toolbar.getElementsByClassName("ci-history-load"); + + for (var i = 0; i < historyLoad.length; i++) { + historyLoad[i].addEventListener( + "click", + function () { + loadDoc(this.getAttribute("data-time")); + }, + true + ); + } + + // Display the active Tab on page load + var tab = ciDebugBar.readCookie("debug-bar-tab"); + if (document.getElementById(tab)) { + var el = document.getElementById(tab); + el.style.display = "block"; + ciDebugBar.addClass(el, "active"); + tab = document.querySelector("[data-tab=" + tab + "]"); + if (tab) { + ciDebugBar.addClass(tab.parentNode, "active"); + } + } + }, + + createListeners: function () { + var buttons = [].slice.call( + this.toolbar.querySelectorAll(".ci-label a") + ); + + for (var i = 0; i < buttons.length; i++) { + buttons[i].addEventListener("click", ciDebugBar.showTab, true); + } + + // Hook up generic toggle via data attributes `data-toggle="foo"` + var links = this.toolbar.querySelectorAll("[data-toggle]"); + for (var i = 0; i < links.length; i++) { + links[i].addEventListener("click", ciDebugBar.toggleRows, true); + } + }, + + showTab: function () { + // Get the target tab, if any + var tab = document.getElementById(this.getAttribute("data-tab")); + + // If the label have not a tab stops here + if (!tab) { + return; + } + + // Remove debug-bar-tab cookie + ciDebugBar.createCookie("debug-bar-tab", "", -1); + + // Check our current state. + var state = tab.style.display; + + // Hide all tabs + var tabs = document.querySelectorAll("#debug-bar .tab"); + + for (var i = 0; i < tabs.length; i++) { + tabs[i].style.display = "none"; + } + + // Mark all labels as inactive + var labels = document.querySelectorAll("#debug-bar .ci-label"); + + for (var i = 0; i < labels.length; i++) { + ciDebugBar.removeClass(labels[i], "active"); + } + + // Show/hide the selected tab + if (state != "block") { + tab.style.display = "block"; + ciDebugBar.addClass(this.parentNode, "active"); + // Create debug-bar-tab cookie to persistent state + ciDebugBar.createCookie( + "debug-bar-tab", + this.getAttribute("data-tab"), + 365 + ); + } + }, + + addClass: function (el, className) { + if (el.classList) { + el.classList.add(className); + } else { + el.className += " " + className; + } + }, + + removeClass: function (el, className) { + if (el.classList) { + el.classList.remove(className); + } else { + el.className = el.className.replace( + new RegExp( + "(^|\\b)" + className.split(" ").join("|") + "(\\b|$)", + "gi" + ), + " " + ); + } + }, + + /** + * Toggle display of another object based on + * the data-toggle value of this object + * + * @param event + */ + toggleRows: function (event) { + if (event.target) { + let row = event.target.closest("tr"); + let target = document.getElementById( + row.getAttribute("data-toggle") + ); + target.style.display = + target.style.display === "none" ? "table-row" : "none"; + } + }, + + /** + * Toggle display of a data table + * + * @param obj + */ + toggleDataTable: function (obj) { + if (typeof obj == "string") { + obj = document.getElementById(obj + "_table"); + } + + if (obj) { + obj.style.display = obj.style.display === "none" ? "block" : "none"; + } + }, + + /** + * Toggle display of timeline child elements + * + * @param obj + */ + toggleChildRows: function (obj) { + if (typeof obj == "string") { + par = document.getElementById(obj + "_parent"); + obj = document.getElementById(obj + "_children"); + } + + if (par && obj) { + obj.style.display = obj.style.display === "none" ? "" : "none"; + par.classList.toggle("timeline-parent-open"); + } + }, + + //-------------------------------------------------------------------- + + /** + * Toggle tool bar from full to icon and icon to full + */ + toggleToolbar: function () { + var open = ciDebugBar.toolbar.style.display != "none"; + + ciDebugBar.icon.style.display = open == true ? "inline-block" : "none"; + ciDebugBar.toolbar.style.display = + open == false ? "inline-block" : "none"; + + // Remember it for other page loads on this site + ciDebugBar.createCookie("debug-bar-state", "", -1); + ciDebugBar.createCookie( + "debug-bar-state", + open == true ? "minimized" : "open", + 365 + ); + }, + + /** + * Sets the initial state of the toolbar (open or minimized) when + * the page is first loaded to allow it to remember the state between refreshes. + */ + setToolbarState: function () { + var open = ciDebugBar.readCookie("debug-bar-state"); + + ciDebugBar.icon.style.display = + open != "open" ? "inline-block" : "none"; + ciDebugBar.toolbar.style.display = + open == "open" ? "inline-block" : "none"; + }, + + toggleViewsHints: function () { + // Avoid toggle hints on history requests that are not the initial + if ( + localStorage.getItem("debugbar-time") != + localStorage.getItem("debugbar-time-new") + ) { + var a = document.querySelector('a[data-tab="ci-views"]'); + a.href = "#"; + return; + } + + var nodeList = []; // [ Element, NewElement( 1 )/OldElement( 0 ) ] + var sortedComments = []; + var comments = []; + + var getComments = function () { + var nodes = []; + var result = []; + var xpathResults = document.evaluate( + "//comment()[starts-with(., ' DEBUG-VIEW')]", + document, + null, + XPathResult.ANY_TYPE, + null + ); + var nextNode = xpathResults.iterateNext(); + while (nextNode) { + nodes.push(nextNode); + nextNode = xpathResults.iterateNext(); + } + + // sort comment by opening and closing tags + for (var i = 0; i < nodes.length; ++i) { + // get file path + name to use as key + var path = nodes[i].nodeValue.substring( + 18, + nodes[i].nodeValue.length - 1 + ); + + if (nodes[i].nodeValue[12] === "S") { + // simple check for start comment + // create new entry + result[path] = [nodes[i], null]; + } else if (result[path]) { + // add to existing entry + result[path][1] = nodes[i]; + } + } + + return result; + }; + + // find node that has TargetNode as parentNode + var getParentNode = function (node, targetNode) { + if (node.parentNode === null) { + return null; + } + + if (node.parentNode !== targetNode) { + return getParentNode(node.parentNode, targetNode); + } + + return node; + }; + + // define invalid & outer ( also invalid ) elements + const INVALID_ELEMENTS = ["NOSCRIPT", "SCRIPT", "STYLE"]; + const OUTER_ELEMENTS = ["HTML", "BODY", "HEAD"]; + + var getValidElementInner = function (node, reverse) { + // handle invalid tags + if (OUTER_ELEMENTS.indexOf(node.nodeName) !== -1) { + for (var i = 0; i < document.body.children.length; ++i) { + var index = reverse + ? document.body.children.length - (i + 1) + : i; + var element = document.body.children[index]; + + // skip invalid tags + if (INVALID_ELEMENTS.indexOf(element.nodeName) !== -1) { + continue; + } + + return [element, reverse]; + } + + return null; + } + + // get to next valid element + while ( + node !== null && + INVALID_ELEMENTS.indexOf(node.nodeName) !== -1 + ) { + node = reverse + ? node.previousElementSibling + : node.nextElementSibling; + } + + // return non array if we couldnt find something + if (node === null) { + return null; + } + + return [node, reverse]; + }; + + // get next valid element ( to be safe to add divs ) + // @return [ element, skip element ] or null if we couldnt find a valid place + var getValidElement = function (nodeElement) { + if (nodeElement) { + if (nodeElement.nextElementSibling !== null) { + return ( + getValidElementInner( + nodeElement.nextElementSibling, + false + ) || + getValidElementInner( + nodeElement.previousElementSibling, + true + ) + ); + } + if (nodeElement.previousElementSibling !== null) { + return getValidElementInner( + nodeElement.previousElementSibling, + true + ); + } + } + + // something went wrong! -> element is not in DOM + return null; + }; + + function showHints() { + // Had AJAX? Reset view blocks + sortedComments = getComments(); + + for (var key in sortedComments) { + var startElement = getValidElement(sortedComments[key][0]); + var endElement = getValidElement(sortedComments[key][1]); + + // skip if we couldnt get a valid element + if (startElement === null || endElement === null) { + continue; + } + + // find element which has same parent as startelement + var jointParent = getParentNode( + endElement[0], + startElement[0].parentNode + ); + if (jointParent === null) { + // find element which has same parent as endelement + jointParent = getParentNode( + startElement[0], + endElement[0].parentNode + ); + if (jointParent === null) { + // both tries failed + continue; + } else { + startElement[0] = jointParent; + } + } else { + endElement[0] = jointParent; + } + + var debugDiv = document.createElement("div"); // holder + var debugPath = document.createElement("div"); // path + var childArray = startElement[0].parentNode.childNodes; // target child array + var parent = startElement[0].parentNode; + var start, end; + + // setup container + debugDiv.classList.add("debug-view"); + debugDiv.classList.add("show-view"); + debugPath.classList.add("debug-view-path"); + debugPath.innerText = key; + debugDiv.appendChild(debugPath); + + // calc distance between them + // start + for (var i = 0; i < childArray.length; ++i) { + // check for comment ( start & end ) -> if its before valid start element + if ( + childArray[i] === sortedComments[key][1] || + childArray[i] === sortedComments[key][0] || + childArray[i] === startElement[0] + ) { + start = i; + if (childArray[i] === sortedComments[key][0]) { + start++; // increase to skip the start comment + } + break; + } + } + // adjust if we want to skip the start element + if (startElement[1]) { + start++; + } + + // end + for (var i = start; i < childArray.length; ++i) { + if (childArray[i] === endElement[0]) { + end = i; + // dont break to check for end comment after end valid element + } else if (childArray[i] === sortedComments[key][1]) { + // if we found the end comment, we can break + end = i; + break; + } + } + + // move elements + var number = end - start; + if (endElement[1]) { + number++; + } + for (var i = 0; i < number; ++i) { + if (INVALID_ELEMENTS.indexOf(childArray[start]) !== -1) { + // skip invalid childs that can cause problems if moved + start++; + continue; + } + debugDiv.appendChild(childArray[start]); + } + + // add container to DOM + nodeList.push(parent.insertBefore(debugDiv, childArray[start])); + } + + ciDebugBar.createCookie("debug-view", "show", 365); + ciDebugBar.addClass(btn, "active"); + } + + function hideHints() { + for (var i = 0; i < nodeList.length; ++i) { + var index; + + // find index + for ( + var j = 0; + j < nodeList[i].parentNode.childNodes.length; + ++j + ) { + if (nodeList[i].parentNode.childNodes[j] === nodeList[i]) { + index = j; + break; + } + } + + // move child back + while (nodeList[i].childNodes.length !== 1) { + nodeList[i].parentNode.insertBefore( + nodeList[i].childNodes[1], + nodeList[i].parentNode.childNodes[index].nextSibling + ); + index++; + } + + nodeList[i].parentNode.removeChild(nodeList[i]); + } + nodeList.length = 0; + + ciDebugBar.createCookie("debug-view", "", -1); + ciDebugBar.removeClass(btn, "active"); + } + + var btn = document.querySelector("[data-tab=ci-views]"); + + // If the Views Collector is inactive stops here + if (!btn) { + return; + } + + btn.parentNode.onclick = function () { + if (ciDebugBar.readCookie("debug-view")) { + hideHints(); + } else { + showHints(); + } + }; + + // Determine Hints state on page load + if (ciDebugBar.readCookie("debug-view")) { + showHints(); + } + }, + + setToolbarPosition: function () { + var btnPosition = this.toolbar.querySelector("#toolbar-position"); + + if (ciDebugBar.readCookie("debug-bar-position") === "top") { + ciDebugBar.addClass(ciDebugBar.icon, "fixed-top"); + ciDebugBar.addClass(ciDebugBar.toolbar, "fixed-top"); + } + + btnPosition.addEventListener( + "click", + function () { + var position = ciDebugBar.readCookie("debug-bar-position"); + + ciDebugBar.createCookie("debug-bar-position", "", -1); + + if (!position || position === "bottom") { + ciDebugBar.createCookie("debug-bar-position", "top", 365); + ciDebugBar.addClass(ciDebugBar.icon, "fixed-top"); + ciDebugBar.addClass(ciDebugBar.toolbar, "fixed-top"); + } else { + ciDebugBar.createCookie( + "debug-bar-position", + "bottom", + 365 + ); + ciDebugBar.removeClass(ciDebugBar.icon, "fixed-top"); + ciDebugBar.removeClass(ciDebugBar.toolbar, "fixed-top"); + } + }, + true + ); + }, + + setToolbarTheme: function () { + var btnTheme = this.toolbar.querySelector("#toolbar-theme"); + var isDarkMode = window.matchMedia( + "(prefers-color-scheme: dark)" + ).matches; + var isLightMode = window.matchMedia( + "(prefers-color-scheme: light)" + ).matches; + + // If a cookie is set with a value, we force the color scheme + if (ciDebugBar.readCookie("debug-bar-theme") === "dark") { + ciDebugBar.removeClass(ciDebugBar.toolbarContainer, "light"); + ciDebugBar.addClass(ciDebugBar.toolbarContainer, "dark"); + } else if (ciDebugBar.readCookie("debug-bar-theme") === "light") { + ciDebugBar.removeClass(ciDebugBar.toolbarContainer, "dark"); + ciDebugBar.addClass(ciDebugBar.toolbarContainer, "light"); + } + + btnTheme.addEventListener( + "click", + function () { + var theme = ciDebugBar.readCookie("debug-bar-theme"); + + if ( + !theme && + window.matchMedia("(prefers-color-scheme: dark)").matches + ) { + // If there is no cookie, and "prefers-color-scheme" is set to "dark" + // It means that the user wants to switch to light mode + ciDebugBar.createCookie("debug-bar-theme", "light", 365); + ciDebugBar.removeClass(ciDebugBar.toolbarContainer, "dark"); + ciDebugBar.addClass(ciDebugBar.toolbarContainer, "light"); + } else { + if (theme === "dark") { + ciDebugBar.createCookie( + "debug-bar-theme", + "light", + 365 + ); + ciDebugBar.removeClass( + ciDebugBar.toolbarContainer, + "dark" + ); + ciDebugBar.addClass( + ciDebugBar.toolbarContainer, + "light" + ); + } else { + // In any other cases: if there is no cookie, or the cookie is set to + // "light", or the "prefers-color-scheme" is "light"... + ciDebugBar.createCookie("debug-bar-theme", "dark", 365); + ciDebugBar.removeClass( + ciDebugBar.toolbarContainer, + "light" + ); + ciDebugBar.addClass( + ciDebugBar.toolbarContainer, + "dark" + ); + } + } + }, + true + ); + }, + + setHotReloadState: function () { + var btn = document.getElementById("debug-hot-reload").parentNode; + var btnImg = btn.getElementsByTagName("img")[0]; + var eventSource; + + // If the Hot Reload Collector is inactive stops here + if (!btn) { + return; + } + + btn.onclick = function () { + if (ciDebugBar.readCookie("debug-hot-reload")) { + ciDebugBar.createCookie("debug-hot-reload", "", -1); + ciDebugBar.removeClass(btn, "active"); + ciDebugBar.removeClass(btnImg, "rotate"); + + // Close the EventSource connection if it exists + if (typeof eventSource !== "undefined") { + eventSource.close(); + eventSource = void 0; // Undefine the variable + } + } else { + ciDebugBar.createCookie("debug-hot-reload", "show", 365); + ciDebugBar.addClass(btn, "active"); + ciDebugBar.addClass(btnImg, "rotate"); + + eventSource = ciDebugBar.hotReloadConnect(); + } + }; + + // Determine Hot Reload state on page load + if (ciDebugBar.readCookie("debug-hot-reload")) { + ciDebugBar.addClass(btn, "active"); + ciDebugBar.addClass(btnImg, "rotate"); + eventSource = ciDebugBar.hotReloadConnect(); + } + }, + + hotReloadConnect: function () { + const eventSource = new EventSource(ciSiteURL + "/__hot-reload"); + + eventSource.addEventListener("reload", function (e) { + console.log("reload", e); + window.location.reload(); + }); + + eventSource.onerror = (err) => { + console.error("EventSource failed:", err); + }; + + return eventSource; + }, + + /** + * Helper to create a cookie. + * + * @param name + * @param value + * @param days + */ + createCookie: function (name, value, days) { + if (days) { + var date = new Date(); + + date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); + + var expires = "; expires=" + date.toGMTString(); + } else { + var expires = ""; + } + + document.cookie = + name + "=" + value + expires + "; path=/; samesite=Lax"; + }, + + readCookie: function (name) { + var nameEQ = name + "="; + var ca = document.cookie.split(";"); + + for (var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) == " ") { + c = c.substring(1, c.length); + } + if (c.indexOf(nameEQ) == 0) { + return c.substring(nameEQ.length, c.length); + } + } + return null; + }, + + trimSlash: function (text) { + return text.replace(/^\/|\/$/g, ""); + }, + + routerLink: function () { + var row, _location; + var rowGet = this.toolbar.querySelectorAll( + 'td[data-debugbar-route="GET"]' + ); + var patt = /\((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*\)/; + + for (var i = 0; i < rowGet.length; i++) { + row = rowGet[i]; + if (!/\/\(.+?\)/.test(rowGet[i].innerText)) { + row.style = "cursor: pointer;"; + row.setAttribute( + "title", + location.origin + "/" + ciDebugBar.trimSlash(row.innerText) + ); + row.addEventListener("click", function (ev) { + _location = + location.origin + + "/" + + ciDebugBar.trimSlash(ev.target.innerText); + var redirectWindow = window.open(_location, "_blank"); + redirectWindow.location; + }); + } else { + row.innerHTML = + "
" + + row.innerText + + "
" + + '' + + row.innerText.replace( + patt, + '' + ) + + '' + + ""; + } + } + + rowGet = this.toolbar.querySelectorAll( + 'td[data-debugbar-route="GET"] form' + ); + for (var i = 0; i < rowGet.length; i++) { + row = rowGet[i]; + + row.addEventListener("submit", function (event) { + event.preventDefault(); + var inputArray = [], + t = 0; + var input = event.target.querySelectorAll("input[type=text]"); + var tpl = event.target.getAttribute("data-debugbar-route-tpl"); + + for (var n = 0; n < input.length; n++) { + if (input[n].value.length > 0) { + inputArray.push(input[n].value); + } + } + + if (inputArray.length > 0) { + _location = + location.origin + + "/" + + tpl.replace(/\?/g, function () { + return inputArray[t++]; + }); + + var redirectWindow = window.open(_location, "_blank"); + redirectWindow.location; + } + }); + } + }, +}; diff --git a/system/Debug/Toolbar/Views/toolbar.tpl.php b/system/Debug/Toolbar/Views/toolbar.tpl.php new file mode 100644 index 0000000..3652a53 --- /dev/null +++ b/system/Debug/Toolbar/Views/toolbar.tpl.php @@ -0,0 +1,277 @@ + + + + + +
+
+ + 🔅 + + + + + + + + + ms   MB + + + + + + + + + + + + + + + + + + + + + + + Vars + + + +

+ + + + + + +

+ + + + + +
+ + +
+ + + + + + + + + + + + + renderTimeline($collectors, $startTime, $segmentCount, $segmentDuration, $styles) ?> + +
NAMECOMPONENTDURATION ms
+
+ + + + + +
+

+ + setData($c['display'])->render("_{$c['titleSafe']}.tpl") ?> +
+ + + + + +
+ + + + $items) : ?> + + +

+
+ + + + + + $value) : ?> + + + + + + +
+ + +

No data to display.

+ + + + + + +

Session User Data

+
+ + + + + + $value) : ?> + + + + + + +
+ +

No data to display.

+ + +

Session doesn't seem to be active.

+ + +

Request ( )

+ + + +

$_GET

+
+ + + + $value) : ?> + + + + + + +
+ + + + +

$_POST

+
+ + + + $value) : ?> + + + + + + +
+ + + + +

Headers

+
+ + + + $value) : ?> + + + + + + +
+ + + + +

Cookies

+
+ + + + $value) : ?> + + + + + + + + + +

Response + ( ) +

+ + + +

Headers

+
+ + + + $value) : ?> + + + + + + +
+ +
+ + +
+

System Configuration

+ + setData($config)->render('_config.tpl') ?> +
+
+ diff --git a/system/Debug/Toolbar/Views/toolbarloader.js b/system/Debug/Toolbar/Views/toolbarloader.js new file mode 100644 index 0000000..7e59143 --- /dev/null +++ b/system/Debug/Toolbar/Views/toolbarloader.js @@ -0,0 +1,87 @@ +document.addEventListener('DOMContentLoaded', loadDoc, false); + +function loadDoc(time) { + if (isNaN(time)) { + time = document.getElementById("debugbar_loader").getAttribute("data-time"); + localStorage.setItem('debugbar-time', time); + } + + localStorage.setItem('debugbar-time-new', time); + + let url = '{url}'; + let xhttp = new XMLHttpRequest(); + + xhttp.onreadystatechange = function() { + if (this.readyState === 4 && this.status === 200) { + let toolbar = document.getElementById("toolbarContainer"); + + if (! toolbar) { + toolbar = document.createElement('div'); + toolbar.setAttribute('id', 'toolbarContainer'); + document.body.appendChild(toolbar); + } + + let responseText = this.responseText; + let dynamicStyle = document.getElementById('debugbar_dynamic_style'); + let dynamicScript = document.getElementById('debugbar_dynamic_script'); + + // get the first style block, copy contents to dynamic_style, then remove here + let start = responseText.indexOf('>', responseText.indexOf('', start); + dynamicStyle.innerHTML = responseText.substr(start, end - start); + responseText = responseText.substr(end + 8); + + // get the first script after the first style, copy contents to dynamic_script, then remove here + start = responseText.indexOf('>', responseText.indexOf('', start); + dynamicScript.innerHTML = responseText.substr(start, end - start); + responseText = responseText.substr(end + 9); + + // check for last style block, append contents to dynamic_style, then remove here + start = responseText.indexOf('>', responseText.indexOf('', start); + dynamicStyle.innerHTML += responseText.substr(start, end - start); + responseText = responseText.substr(0, start - 8); + + toolbar.innerHTML = responseText; + + if (typeof ciDebugBar === 'object') { + ciDebugBar.init(); + } + } else if (this.readyState === 4 && this.status === 404) { + console.log('CodeIgniter DebugBar: File "WRITEPATH/debugbar/debugbar_' + time + '" not found.'); + } + }; + + xhttp.open("GET", url + "?debugbar_time=" + time, true); + xhttp.send(); +} + +window.oldXHR = window.ActiveXObject + ? new ActiveXObject('Microsoft.XMLHTTP') + : window.XMLHttpRequest; + +function newXHR() { + const realXHR = new window.oldXHR(); + + realXHR.addEventListener("readystatechange", function() { + // Only success responses and URLs that do not contains "debugbar_time" are tracked + if (realXHR.readyState === 4 && realXHR.status.toString()[0] === '2' && realXHR.responseURL.indexOf('debugbar_time') === -1) { + if (realXHR.getAllResponseHeaders().indexOf("Debugbar-Time") >= 0) { + let debugbarTime = realXHR.getResponseHeader('Debugbar-Time'); + + if (debugbarTime) { + let h2 = document.querySelector('#ci-history > h2'); + + if (h2) { + h2.innerHTML = 'History You have new debug data. '; + document.querySelector('a[data-tab="ci-history"] > span > .badge').className += ' active'; + } + } + } + } + }, false); + return realXHR; +} + +window.XMLHttpRequest = newXHR; diff --git a/system/Email/Email.php b/system/Email/Email.php new file mode 100644 index 0000000..42835c3 --- /dev/null +++ b/system/Email/Email.php @@ -0,0 +1,2273 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Email; + +use CodeIgniter\Events\Events; +use CodeIgniter\I18n\Time; +use Config\Mimes; +use ErrorException; + +/** + * CodeIgniter Email Class + * + * Permits email to be sent using Mail, Sendmail, or SMTP. + * + * @see \CodeIgniter\Email\EmailTest + */ +class Email +{ + /** + * Properties from the last successful send. + * + * @var array|null + */ + public $archive; + + /** + * Properties to be added to the next archive. + * + * @var array + */ + protected $tmpArchive = []; + + /** + * @var string + */ + public $fromEmail; + + /** + * @var string + */ + public $fromName; + + /** + * Used as the User-Agent and X-Mailer headers' value. + * + * @var string + */ + public $userAgent = 'CodeIgniter'; + + /** + * Path to the Sendmail binary. + * + * @var string + */ + public $mailPath = '/usr/sbin/sendmail'; + + /** + * Which method to use for sending e-mails. + * + * @var string 'mail', 'sendmail' or 'smtp' + */ + public $protocol = 'mail'; + + /** + * STMP Server Hostname + * + * @var string + */ + public $SMTPHost = ''; + + /** + * SMTP Username + * + * @var string + */ + public $SMTPUser = ''; + + /** + * SMTP Password + * + * @var string + */ + public $SMTPPass = ''; + + /** + * SMTP Server port + * + * @var int + */ + public $SMTPPort = 25; + + /** + * SMTP connection timeout in seconds + * + * @var int + */ + public $SMTPTimeout = 5; + + /** + * SMTP persistent connection + * + * @var bool + */ + public $SMTPKeepAlive = false; + + /** + * SMTP Encryption + * + * @var string '', 'tls' or 'ssl'. 'tls' will issue a STARTTLS command + * to the server. 'ssl' means implicit SSL. Connection on port + * 465 should set this to ''. + */ + public $SMTPCrypto = ''; + + /** + * Whether to apply word-wrapping to the message body. + * + * @var bool + */ + public $wordWrap = true; + + /** + * Number of characters to wrap at. + * + * @see Email::$wordWrap + * + * @var int + */ + public $wrapChars = 76; + + /** + * Message format. + * + * @var string 'text' or 'html' + */ + public $mailType = 'text'; + + /** + * Character set (default: utf-8) + * + * @var string + */ + public $charset = 'utf-8'; + + /** + * Alternative message (for HTML messages only) + * + * @var string + */ + public $altMessage = ''; + + /** + * Whether to validate e-mail addresses. + * + * @var bool + */ + public $validate = true; + + /** + * X-Priority header value. + * + * @var int 1-5 + */ + public $priority = 3; + + /** + * Newline character sequence. + * Use "\r\n" to comply with RFC 822. + * + * @see http://www.ietf.org/rfc/rfc822.txt + * + * @var string "\r\n" or "\n" + */ + public $newline = "\n"; + + /** + * CRLF character sequence + * + * RFC 2045 specifies that for 'quoted-printable' encoding, + * "\r\n" must be used. However, it appears that some servers + * (even on the receiving end) don't handle it properly and + * switching to "\n", while improper, is the only solution + * that seems to work for all environments. + * + * @see http://www.ietf.org/rfc/rfc822.txt + * + * @var string + */ + public $CRLF = "\n"; + + /** + * Whether to use Delivery Status Notification. + * + * @var bool + */ + public $DSN = false; + + /** + * Whether to send multipart alternatives. + * Yahoo! doesn't seem to like these. + * + * @var bool + */ + public $sendMultipart = true; + + /** + * Whether to send messages to BCC recipients in batches. + * + * @var bool + */ + public $BCCBatchMode = false; + + /** + * BCC Batch max number size. + * + * @see Email::$BCCBatchMode + * + * @var int|string + */ + public $BCCBatchSize = 200; + + /** + * Subject header + * + * @var string + */ + protected $subject = ''; + + /** + * Message body + * + * @var string + */ + protected $body = ''; + + /** + * Final message body to be sent. + * + * @var string + */ + protected $finalBody = ''; + + /** + * Final headers to send + * + * @var string + */ + protected $headerStr = ''; + + /** + * SMTP Connection socket placeholder + * + * @var resource|null + */ + protected $SMTPConnect; + + /** + * Mail encoding + * + * @var string '8bit' or '7bit' + */ + protected $encoding = '8bit'; + + /** + * Whether to perform SMTP authentication + * + * @var bool + */ + protected $SMTPAuth = false; + + /** + * Whether to send a Reply-To header + * + * @var bool + */ + protected $replyToFlag = false; + + /** + * Debug messages + * + * @see Email::printDebugger() + * + * @var array + */ + protected $debugMessage = []; + + /** + * Raw debug messages + * + * @var string[] + */ + private array $debugMessageRaw = []; + + /** + * Recipients + * + * @var array|string + */ + protected $recipients = []; + + /** + * CC Recipients + * + * @var array + */ + protected $CCArray = []; + + /** + * BCC Recipients + * + * @var array + */ + protected $BCCArray = []; + + /** + * Message headers + * + * @var array + */ + protected $headers = []; + + /** + * Attachment data + * + * @var array + */ + protected $attachments = []; + + /** + * Valid $protocol values + * + * @see Email::$protocol + * + * @var array + */ + protected $protocols = [ + 'mail', + 'sendmail', + 'smtp', + ]; + + /** + * Character sets valid for 7-bit encoding, + * excluding language suffix. + * + * @var list + */ + protected $baseCharsets = [ + 'us-ascii', + 'iso-2022-', + ]; + + /** + * Bit depths + * + * Valid mail encodings + * + * @see Email::$encoding + * + * @var array + */ + protected $bitDepths = [ + '7bit', + '8bit', + ]; + + /** + * $priority translations + * + * Actual values to send with the X-Priority header + * + * @var array + */ + protected $priorities = [ + 1 => '1 (Highest)', + 2 => '2 (High)', + 3 => '3 (Normal)', + 4 => '4 (Low)', + 5 => '5 (Lowest)', + ]; + + /** + * mbstring.func_overload flag + * + * @var bool + */ + protected static $func_overload; + + /** + * @param array|\Config\Email|null $config + */ + public function __construct($config = null) + { + $this->initialize($config); + if (! isset(static::$func_overload)) { + static::$func_overload = (extension_loaded('mbstring') && ini_get('mbstring.func_overload')); + } + } + + /** + * Initialize preferences + * + * @param array|\Config\Email|null $config + * + * @return Email + */ + public function initialize($config) + { + $this->clear(); + + if ($config instanceof \Config\Email) { + $config = get_object_vars($config); + } + + foreach (array_keys(get_class_vars(static::class)) as $key) { + if (property_exists($this, $key) && isset($config[$key])) { + $method = 'set' . ucfirst($key); + + if (method_exists($this, $method)) { + $this->{$method}($config[$key]); + } else { + $this->{$key} = $config[$key]; + } + } + } + + $this->charset = strtoupper($this->charset); + $this->SMTPAuth = isset($this->SMTPUser[0], $this->SMTPPass[0]); + + return $this; + } + + /** + * @param bool $clearAttachments + * + * @return Email + */ + public function clear($clearAttachments = false) + { + $this->subject = ''; + $this->body = ''; + $this->finalBody = ''; + $this->headerStr = ''; + $this->replyToFlag = false; + $this->recipients = []; + $this->CCArray = []; + $this->BCCArray = []; + $this->headers = []; + $this->debugMessage = []; + $this->debugMessageRaw = []; + + $this->setHeader('Date', $this->setDate()); + + if ($clearAttachments !== false) { + $this->attachments = []; + } + + return $this; + } + + /** + * @param string $from + * @param string $name + * @param string|null $returnPath Return-Path + * + * @return Email + */ + public function setFrom($from, $name = '', $returnPath = null) + { + if (preg_match('/\<(.*)\>/', $from, $match)) { + $from = $match[1]; + } + + if ($this->validate) { + $this->validateEmail($this->stringToArray($from)); + + if ($returnPath) { + $this->validateEmail($this->stringToArray($returnPath)); + } + } + + $this->tmpArchive['fromEmail'] = $from; + $this->tmpArchive['fromName'] = $name; + + if ($name !== '') { + // only use Q encoding if there are characters that would require it + if (! preg_match('/[\200-\377]/', $name)) { + $name = '"' . addcslashes($name, "\0..\37\177'\"\\") . '"'; + } else { + $name = $this->prepQEncoding($name); + } + } + + $this->setHeader('From', $name . ' <' . $from . '>'); + if (! isset($returnPath)) { + $returnPath = $from; + } + $this->setHeader('Return-Path', '<' . $returnPath . '>'); + $this->tmpArchive['returnPath'] = $returnPath; + + return $this; + } + + /** + * @param string $replyto + * @param string $name + * + * @return Email + */ + public function setReplyTo($replyto, $name = '') + { + if (preg_match('/\<(.*)\>/', $replyto, $match)) { + $replyto = $match[1]; + } + + if ($this->validate) { + $this->validateEmail($this->stringToArray($replyto)); + } + + if ($name !== '') { + $this->tmpArchive['replyName'] = $name; + + // only use Q encoding if there are characters that would require it + if (! preg_match('/[\200-\377]/', $name)) { + $name = '"' . addcslashes($name, "\0..\37\177'\"\\") . '"'; + } else { + $name = $this->prepQEncoding($name); + } + } + + $this->setHeader('Reply-To', $name . ' <' . $replyto . '>'); + $this->replyToFlag = true; + $this->tmpArchive['replyTo'] = $replyto; + + return $this; + } + + /** + * @param array|string $to + * + * @return Email + */ + public function setTo($to) + { + $to = $this->stringToArray($to); + $to = $this->cleanEmail($to); + + if ($this->validate) { + $this->validateEmail($to); + } + + if ($this->getProtocol() !== 'mail') { + $this->setHeader('To', implode(', ', $to)); + } + + $this->recipients = $to; + + return $this; + } + + /** + * @param string $cc + * + * @return Email + */ + public function setCC($cc) + { + $cc = $this->cleanEmail($this->stringToArray($cc)); + + if ($this->validate) { + $this->validateEmail($cc); + } + + $this->setHeader('Cc', implode(', ', $cc)); + + if ($this->getProtocol() === 'smtp') { + $this->CCArray = $cc; + } + + $this->tmpArchive['CCArray'] = $cc; + + return $this; + } + + /** + * @param string $bcc + * @param string $limit + * + * @return Email + */ + public function setBCC($bcc, $limit = '') + { + if ($limit !== '' && is_numeric($limit)) { + $this->BCCBatchMode = true; + $this->BCCBatchSize = $limit; + } + + $bcc = $this->cleanEmail($this->stringToArray($bcc)); + + if ($this->validate) { + $this->validateEmail($bcc); + } + + if ($this->getProtocol() === 'smtp' || ($this->BCCBatchMode && count($bcc) > $this->BCCBatchSize)) { + $this->BCCArray = $bcc; + } else { + $this->setHeader('Bcc', implode(', ', $bcc)); + $this->tmpArchive['BCCArray'] = $bcc; + } + + return $this; + } + + /** + * @param string $subject + * + * @return Email + */ + public function setSubject($subject) + { + $this->tmpArchive['subject'] = $subject; + + $subject = $this->prepQEncoding($subject); + $this->setHeader('Subject', $subject); + + return $this; + } + + /** + * @param string $body + * + * @return Email + */ + public function setMessage($body) + { + $this->body = rtrim(str_replace("\r", '', $body)); + + return $this; + } + + /** + * @param string $file Can be local path, URL or buffered content + * @param string $disposition 'attachment' + * @param string|null $newname + * @param string $mime + * + * @return bool|Email + */ + public function attach($file, $disposition = '', $newname = null, $mime = '') + { + if ($mime === '') { + if (strpos($file, '://') === false && ! is_file($file)) { + $this->setErrorMessage(lang('Email.attachmentMissing', [$file])); + + return false; + } + + if (! $fp = @fopen($file, 'rb')) { + $this->setErrorMessage(lang('Email.attachmentUnreadable', [$file])); + + return false; + } + + $fileContent = stream_get_contents($fp); + + $mime = $this->mimeTypes(pathinfo($file, PATHINFO_EXTENSION)); + + fclose($fp); + } else { + $fileContent = &$file; // buffered file + } + + // declare names on their own, to make phpcbf happy + $namesAttached = [$file, $newname]; + + $this->attachments[] = [ + 'name' => $namesAttached, + 'disposition' => empty($disposition) ? 'attachment' : $disposition, + // Can also be 'inline' Not sure if it matters + 'type' => $mime, + 'content' => chunk_split(base64_encode($fileContent)), + 'multipart' => 'mixed', + ]; + + return $this; + } + + /** + * Set and return attachment Content-ID + * Useful for attached inline pictures + * + * @param string $filename + * + * @return bool|string + */ + public function setAttachmentCID($filename) + { + foreach ($this->attachments as $i => $attachment) { + // For file path. + if ($attachment['name'][0] === $filename) { + $this->attachments[$i]['multipart'] = 'related'; + + $this->attachments[$i]['cid'] = uniqid(basename($attachment['name'][0]) . '@', true); + + return $this->attachments[$i]['cid']; + } + + // For buffer string. + if ($attachment['name'][1] === $filename) { + $this->attachments[$i]['multipart'] = 'related'; + + $this->attachments[$i]['cid'] = uniqid(basename($attachment['name'][1]) . '@', true); + + return $this->attachments[$i]['cid']; + } + } + + return false; + } + + /** + * @param string $header + * @param string $value + * + * @return Email + */ + public function setHeader($header, $value) + { + $this->headers[$header] = str_replace(["\n", "\r"], '', $value); + + return $this; + } + + /** + * @param array|string $email + * + * @return array + */ + protected function stringToArray($email) + { + if (! is_array($email)) { + return (strpos($email, ',') !== false) ? preg_split('/[\s,]/', $email, -1, PREG_SPLIT_NO_EMPTY) : (array) trim($email); + } + + return $email; + } + + /** + * @param string $str + * + * @return Email + */ + public function setAltMessage($str) + { + $this->altMessage = (string) $str; + + return $this; + } + + /** + * @param string $type + * + * @return Email + */ + public function setMailType($type = 'text') + { + $this->mailType = ($type === 'html') ? 'html' : 'text'; + + return $this; + } + + /** + * @param bool $wordWrap + * + * @return Email + */ + public function setWordWrap($wordWrap = true) + { + $this->wordWrap = (bool) $wordWrap; + + return $this; + } + + /** + * @param string $protocol + * + * @return Email + */ + public function setProtocol($protocol = 'mail') + { + $this->protocol = in_array($protocol, $this->protocols, true) ? strtolower($protocol) : 'mail'; + + return $this; + } + + /** + * @param int $n + * + * @return Email + */ + public function setPriority($n = 3) + { + $this->priority = preg_match('/^[1-5]$/', (string) $n) ? (int) $n : 3; + + return $this; + } + + /** + * @param string $newline + * + * @return Email + */ + public function setNewline($newline = "\n") + { + $this->newline = in_array($newline, ["\n", "\r\n", "\r"], true) ? $newline : "\n"; + + return $this; + } + + /** + * @param string $CRLF + * + * @return Email + */ + public function setCRLF($CRLF = "\n") + { + $this->CRLF = ($CRLF !== "\n" && $CRLF !== "\r\n" && $CRLF !== "\r") ? "\n" : $CRLF; + + return $this; + } + + /** + * @return string + */ + protected function getMessageID() + { + $from = str_replace(['>', '<'], '', $this->headers['Return-Path']); + + return '<' . uniqid('', true) . strstr($from, '@') . '>'; + } + + /** + * @return string + */ + protected function getProtocol() + { + $this->protocol = strtolower($this->protocol); + + if (! in_array($this->protocol, $this->protocols, true)) { + $this->protocol = 'mail'; + } + + return $this->protocol; + } + + /** + * @return string + */ + protected function getEncoding() + { + if (! in_array($this->encoding, $this->bitDepths, true)) { + $this->encoding = '8bit'; + } + + foreach ($this->baseCharsets as $charset) { + if (strpos($this->charset, $charset) === 0) { + $this->encoding = '7bit'; + + break; + } + } + + return $this->encoding; + } + + /** + * @return string + */ + protected function getContentType() + { + if ($this->mailType === 'html') { + return empty($this->attachments) ? 'html' : 'html-attach'; + } + + if ($this->mailType === 'text' && ! empty($this->attachments)) { + return 'plain-attach'; + } + + return 'plain'; + } + + /** + * Set RFC 822 Date + * + * @return string + */ + protected function setDate() + { + $timezone = date('Z'); + $operator = ($timezone[0] === '-') ? '-' : '+'; + $timezone = abs($timezone); + $timezone = floor($timezone / 3600) * 100 + ($timezone % 3600) / 60; + + return sprintf('%s %s%04d', date('D, j M Y H:i:s'), $operator, $timezone); + } + + /** + * @return string + */ + protected function getMimeMessage() + { + return 'This is a multi-part message in MIME format.' . $this->newline . 'Your email application may not support this format.'; + } + + /** + * @param array|string $email + * + * @return bool + */ + public function validateEmail($email) + { + if (! is_array($email)) { + $this->setErrorMessage(lang('Email.mustBeArray')); + + return false; + } + + foreach ($email as $val) { + if (! $this->isValidEmail($val)) { + $this->setErrorMessage(lang('Email.invalidAddress', [$val])); + + return false; + } + } + + return true; + } + + /** + * @param string $email + * + * @return bool + */ + public function isValidEmail($email) + { + if (function_exists('idn_to_ascii') && defined('INTL_IDNA_VARIANT_UTS46') && $atpos = strpos($email, '@')) { + $email = static::substr($email, 0, ++$atpos) + . idn_to_ascii(static::substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46); + } + + return (bool) filter_var($email, FILTER_VALIDATE_EMAIL); + } + + /** + * @param array|string $email + * + * @return array|string + */ + public function cleanEmail($email) + { + if (! is_array($email)) { + return preg_match('/\<(.*)\>/', $email, $match) ? $match[1] : $email; + } + + $cleanEmail = []; + + foreach ($email as $addy) { + $cleanEmail[] = preg_match('/\<(.*)\>/', $addy, $match) ? $match[1] : $addy; + } + + return $cleanEmail; + } + + /** + * Build alternative plain text message + * + * Provides the raw message for use in plain-text headers of + * HTML-formatted emails. + * + * If the user hasn't specified his own alternative message + * it creates one by stripping the HTML + * + * @return string + */ + protected function getAltMessage() + { + if (! empty($this->altMessage)) { + return ($this->wordWrap) ? $this->wordWrap($this->altMessage, 76) : $this->altMessage; + } + + $body = preg_match('/\(.*)\<\/body\>/si', $this->body, $match) ? $match[1] : $this->body; + $body = str_replace("\t", '', preg_replace('# Class property: bool + */ +final class IntBoolCast extends BaseCast +{ + /** + * @param int $value + */ + public static function get($value, array $params = []): bool + { + return (bool) $value; + } + + /** + * @param bool|int|string $value + */ + public static function set($value, array $params = []): int + { + return (int) $value; + } +} diff --git a/system/Entity/Cast/IntegerCast.php b/system/Entity/Cast/IntegerCast.php new file mode 100644 index 0000000..b9357d7 --- /dev/null +++ b/system/Entity/Cast/IntegerCast.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +/** + * Class IntegerCast + */ +class IntegerCast extends BaseCast +{ + /** + * {@inheritDoc} + */ + public static function get($value, array $params = []): int + { + return (int) $value; + } +} diff --git a/system/Entity/Cast/JsonCast.php b/system/Entity/Cast/JsonCast.php new file mode 100644 index 0000000..534631b --- /dev/null +++ b/system/Entity/Cast/JsonCast.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +use CodeIgniter\Entity\Exceptions\CastException; +use JsonException; +use stdClass; + +/** + * Class JsonCast + */ +class JsonCast extends BaseCast +{ + /** + * {@inheritDoc} + */ + public static function get($value, array $params = []) + { + $associative = in_array('array', $params, true); + + $tmp = $value !== null ? ($associative ? [] : new stdClass()) : null; + + if (function_exists('json_decode') + && ( + (is_string($value) + && strlen($value) > 1 + && in_array($value[0], ['[', '{', '"'], true)) + || is_numeric($value) + ) + ) { + try { + $tmp = json_decode($value, $associative, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw CastException::forInvalidJsonFormat($e->getCode()); + } + } + + return $tmp; + } + + /** + * {@inheritDoc} + */ + public static function set($value, array $params = []): string + { + if (function_exists('json_encode')) { + try { + $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw CastException::forInvalidJsonFormat($e->getCode()); + } + } + + return $value; + } +} diff --git a/system/Entity/Cast/ObjectCast.php b/system/Entity/Cast/ObjectCast.php new file mode 100644 index 0000000..0a22ed8 --- /dev/null +++ b/system/Entity/Cast/ObjectCast.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +/** + * Class ObjectCast + */ +class ObjectCast extends BaseCast +{ + /** + * {@inheritDoc} + */ + public static function get($value, array $params = []): object + { + return (object) $value; + } +} diff --git a/system/Entity/Cast/StringCast.php b/system/Entity/Cast/StringCast.php new file mode 100644 index 0000000..974567c --- /dev/null +++ b/system/Entity/Cast/StringCast.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +/** + * Class StringCast + */ +class StringCast extends BaseCast +{ + /** + * {@inheritDoc} + */ + public static function get($value, array $params = []): string + { + return (string) $value; + } +} diff --git a/system/Entity/Cast/TimestampCast.php b/system/Entity/Cast/TimestampCast.php new file mode 100644 index 0000000..f9669d5 --- /dev/null +++ b/system/Entity/Cast/TimestampCast.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +use CodeIgniter\Entity\Exceptions\CastException; + +/** + * Class TimestampCast + */ +class TimestampCast extends BaseCast +{ + /** + * {@inheritDoc} + */ + public static function get($value, array $params = []) + { + $value = strtotime($value); + + if ($value === false) { + throw CastException::forInvalidTimestamp(); + } + + return $value; + } +} diff --git a/system/Entity/Cast/URICast.php b/system/Entity/Cast/URICast.php new file mode 100644 index 0000000..bb49e74 --- /dev/null +++ b/system/Entity/Cast/URICast.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +use CodeIgniter\HTTP\URI; + +/** + * Class URICast + */ +class URICast extends BaseCast +{ + /** + * {@inheritDoc} + */ + public static function get($value, array $params = []): URI + { + return $value instanceof URI ? $value : new URI($value); + } +} diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php new file mode 100644 index 0000000..0e751b6 --- /dev/null +++ b/system/Entity/Entity.php @@ -0,0 +1,606 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity; + +use CodeIgniter\Entity\Cast\ArrayCast; +use CodeIgniter\Entity\Cast\BooleanCast; +use CodeIgniter\Entity\Cast\CastInterface; +use CodeIgniter\Entity\Cast\CSVCast; +use CodeIgniter\Entity\Cast\DatetimeCast; +use CodeIgniter\Entity\Cast\FloatCast; +use CodeIgniter\Entity\Cast\IntBoolCast; +use CodeIgniter\Entity\Cast\IntegerCast; +use CodeIgniter\Entity\Cast\JsonCast; +use CodeIgniter\Entity\Cast\ObjectCast; +use CodeIgniter\Entity\Cast\StringCast; +use CodeIgniter\Entity\Cast\TimestampCast; +use CodeIgniter\Entity\Cast\URICast; +use CodeIgniter\Entity\Exceptions\CastException; +use CodeIgniter\I18n\Time; +use DateTime; +use Exception; +use JsonSerializable; +use ReturnTypeWillChange; + +/** + * Entity encapsulation, for use with CodeIgniter\Model + * + * @see \CodeIgniter\Entity\EntityTest + */ +class Entity implements JsonSerializable +{ + /** + * Maps names used in sets and gets against unique + * names within the class, allowing independence from + * database column names. + * + * Example: + * $datamap = [ + * 'class_property_name' => 'db_column_name' + * ]; + * + * @var array + */ + protected $datamap = []; + + /** + * The date fields. + * + * @var list + */ + protected $dates = [ + 'created_at', + 'updated_at', + 'deleted_at', + ]; + + /** + * Array of field names and the type of value to cast them as when + * they are accessed. + * + * @var array + */ + protected $casts = []; + + /** + * Custom convert handlers + * + * @var array + */ + protected $castHandlers = []; + + /** + * Default convert handlers + * + * @var array + */ + private array $defaultCastHandlers = [ + 'array' => ArrayCast::class, + 'bool' => BooleanCast::class, + 'boolean' => BooleanCast::class, + 'csv' => CSVCast::class, + 'datetime' => DatetimeCast::class, + 'double' => FloatCast::class, + 'float' => FloatCast::class, + 'int' => IntegerCast::class, + 'integer' => IntegerCast::class, + 'int-bool' => IntBoolCast::class, + 'json' => JsonCast::class, + 'object' => ObjectCast::class, + 'string' => StringCast::class, + 'timestamp' => TimestampCast::class, + 'uri' => URICast::class, + ]; + + /** + * Holds the current values of all class vars. + * + * @var array + */ + protected $attributes = []; + + /** + * Holds original copies of all class vars so we can determine + * what's actually been changed and not accidentally write + * nulls where we shouldn't. + * + * @var array + */ + protected $original = []; + + /** + * Holds info whenever properties have to be casted + */ + private bool $_cast = true; + + /** + * Allows filling in Entity parameters during construction. + */ + public function __construct(?array $data = null) + { + $this->syncOriginal(); + + $this->fill($data); + } + + /** + * Takes an array of key/value pairs and sets them as class + * properties, using any `setCamelCasedProperty()` methods + * that may or may not exist. + * + * @param array $data + * + * @return $this + */ + public function fill(?array $data = null) + { + if (! is_array($data)) { + return $this; + } + + foreach ($data as $key => $value) { + $this->__set($key, $value); + } + + return $this; + } + + /** + * General method that will return all public and protected values + * of this entity as an array. All values are accessed through the + * __get() magic method so will have any casts, etc applied to them. + * + * @param bool $onlyChanged If true, only return values that have changed since object creation + * @param bool $cast If true, properties will be cast. + * @param bool $recursive If true, inner entities will be cast as array as well. + */ + public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recursive = false): array + { + $this->_cast = $cast; + + $keys = array_filter(array_keys($this->attributes), static fn ($key) => strpos($key, '_') !== 0); + + if (is_array($this->datamap)) { + $keys = array_unique( + [...array_diff($keys, $this->datamap), ...array_keys($this->datamap)] + ); + } + + $return = []; + + // Loop over the properties, to allow magic methods to do their thing. + foreach ($keys as $key) { + if ($onlyChanged && ! $this->hasChanged($key)) { + continue; + } + + $return[$key] = $this->__get($key); + + if ($recursive) { + if ($return[$key] instanceof self) { + $return[$key] = $return[$key]->toArray($onlyChanged, $cast, $recursive); + } elseif (is_callable([$return[$key], 'toArray'])) { + $return[$key] = $return[$key]->toArray(); + } + } + } + + $this->_cast = true; + + return $return; + } + + /** + * Returns the raw values of the current attributes. + * + * @param bool $onlyChanged If true, only return values that have changed since object creation + * @param bool $recursive If true, inner entities will be cast as array as well. + */ + public function toRawArray(bool $onlyChanged = false, bool $recursive = false): array + { + $return = []; + + if (! $onlyChanged) { + if ($recursive) { + return array_map(static function ($value) use ($onlyChanged, $recursive) { + if ($value instanceof self) { + $value = $value->toRawArray($onlyChanged, $recursive); + } elseif (is_callable([$value, 'toRawArray'])) { + $value = $value->toRawArray(); + } + + return $value; + }, $this->attributes); + } + + return $this->attributes; + } + + foreach ($this->attributes as $key => $value) { + if (! $this->hasChanged($key)) { + continue; + } + + if ($recursive) { + if ($value instanceof self) { + $value = $value->toRawArray($onlyChanged, $recursive); + } elseif (is_callable([$value, 'toRawArray'])) { + $value = $value->toRawArray(); + } + } + + $return[$key] = $value; + } + + return $return; + } + + /** + * Ensures our "original" values match the current values. + * + * @return $this + */ + public function syncOriginal() + { + $this->original = $this->attributes; + + return $this; + } + + /** + * Checks a property to see if it has changed since the entity + * was created. Or, without a parameter, checks if any + * properties have changed. + * + * @param string|null $key class property + */ + public function hasChanged(?string $key = null): bool + { + // If no parameter was given then check all attributes + if ($key === null) { + return $this->original !== $this->attributes; + } + + $dbColumn = $this->mapProperty($key); + + // Key doesn't exist in either + if (! array_key_exists($dbColumn, $this->original) && ! array_key_exists($dbColumn, $this->attributes)) { + return false; + } + + // It's a new element + if (! array_key_exists($dbColumn, $this->original) && array_key_exists($dbColumn, $this->attributes)) { + return true; + } + + return $this->original[$dbColumn] !== $this->attributes[$dbColumn]; + } + + /** + * Set raw data array without any mutations + * + * @return $this + */ + public function injectRawData(array $data) + { + $this->attributes = $data; + + $this->syncOriginal(); + + return $this; + } + + /** + * Set raw data array without any mutations + * + * @return $this + * + * @deprecated Use injectRawData() instead. + */ + public function setAttributes(array $data) + { + return $this->injectRawData($data); + } + + /** + * Checks the datamap to see if this property name is being mapped, + * and returns the db column name, if any, or the original property name. + * + * @return string db column name + */ + protected function mapProperty(string $key) + { + if ($this->datamap === []) { + return $key; + } + + if (! empty($this->datamap[$key])) { + return $this->datamap[$key]; + } + + return $key; + } + + /** + * Converts the given string|timestamp|DateTime|Time instance + * into the "CodeIgniter\I18n\Time" object. + * + * @param DateTime|float|int|string|Time $value + * + * @return Time + * + * @throws Exception + */ + protected function mutateDate($value) + { + return DatetimeCast::get($value); + } + + /** + * Provides the ability to cast an item as a specific data type. + * Add ? at the beginning of $type (i.e. ?string) to get NULL + * instead of casting $value if $value === null + * + * @param bool|float|int|string|null $value Attribute value + * @param string $attribute Attribute name + * @param string $method Allowed to "get" and "set" + * + * @return array|bool|float|int|object|string|null + * + * @throws CastException + */ + protected function castAs($value, string $attribute, string $method = 'get') + { + if (empty($this->casts[$attribute])) { + return $value; + } + + $type = $this->casts[$attribute]; + + $isNullable = false; + + if (strpos($type, '?') === 0) { + $isNullable = true; + + if ($value === null) { + return null; + } + + $type = substr($type, 1); + } + + // In order not to create a separate handler for the + // json-array type, we transform the required one. + $type = $type === 'json-array' ? 'json[array]' : $type; + + if (! in_array($method, ['get', 'set'], true)) { + throw CastException::forInvalidMethod($method); + } + + $params = []; + + // Attempt to retrieve additional parameters if specified + // type[param, param2,param3] + if (preg_match('/^(.+)\[(.+)\]$/', $type, $matches)) { + $type = $matches[1]; + $params = array_map('trim', explode(',', $matches[2])); + } + + if ($isNullable) { + $params[] = 'nullable'; + } + + $type = trim($type, '[]'); + + $handlers = array_merge($this->defaultCastHandlers, $this->castHandlers); + + if (empty($handlers[$type])) { + return $value; + } + + if (! is_subclass_of($handlers[$type], CastInterface::class)) { + throw CastException::forInvalidInterface($handlers[$type]); + } + + return $handlers[$type]::$method($value, $params); + } + + /** + * Support for json_encode() + * + * @return array + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->toArray(); + } + + /** + * Change the value of the private $_cast property + * + * @return bool|Entity + */ + public function cast(?bool $cast = null) + { + if ($cast === null) { + return $this->_cast; + } + + $this->_cast = $cast; + + return $this; + } + + /** + * Magic method to all protected/private class properties to be + * easily set, either through a direct access or a + * `setCamelCasedProperty()` method. + * + * Examples: + * $this->my_property = $p; + * $this->setMyProperty() = $p; + * + * @param array|bool|float|int|object|string|null $value + * + * @return void + * + * @throws Exception + */ + public function __set(string $key, $value = null) + { + $dbColumn = $this->mapProperty($key); + + // Check if the field should be mutated into a date + if (in_array($dbColumn, $this->dates, true)) { + $value = $this->mutateDate($value); + } + + $value = $this->castAs($value, $dbColumn, 'set'); + + // if a setter method exists for this key, use that method to + // insert this value. should be outside $isNullable check, + // so maybe wants to do sth with null value automatically + $method = 'set' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $dbColumn))); + + // If a "`_set` + $key" method exists, it is a setter. + if (method_exists($this, '_' . $method)) { + $this->{'_' . $method}($value); + + return; + } + + // If a "`set` + $key" method exists, it is also a setter. + if (method_exists($this, $method) && $method !== 'setAttributes') { + $this->{$method}($value); + + return; + } + + // Otherwise, just the value. This allows for creation of new + // class properties that are undefined, though they cannot be + // saved. Useful for grabbing values through joins, assigning + // relationships, etc. + $this->attributes[$dbColumn] = $value; + } + + /** + * Magic method to allow retrieval of protected and private class properties + * either by their name, or through a `getCamelCasedProperty()` method. + * + * Examples: + * $p = $this->my_property + * $p = $this->getMyProperty() + * + * @return array|bool|float|int|object|string|null + * + * @throws Exception + * + * @params string $key class property + */ + public function __get(string $key) + { + $dbColumn = $this->mapProperty($key); + + $result = null; + + // Convert to CamelCase for the method + $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $dbColumn))); + + // if a getter method exists for this key, + // use that method to insert this value. + if (method_exists($this, '_' . $method)) { + // If a "`_get` + $key" method exists, it is a getter. + $result = $this->{'_' . $method}(); + } elseif (method_exists($this, $method)) { + // If a "`get` + $key" method exists, it is also a getter. + $result = $this->{$method}(); + } + + // Otherwise return the protected property + // if it exists. + elseif (array_key_exists($dbColumn, $this->attributes)) { + $result = $this->attributes[$dbColumn]; + } + + // Do we need to mutate this into a date? + if (in_array($dbColumn, $this->dates, true)) { + $result = $this->mutateDate($result); + } + // Or cast it as something? + elseif ($this->_cast) { + $result = $this->castAs($result, $dbColumn); + } + + return $result; + } + + /** + * Returns true if a property exists names $key, or a getter method + * exists named like for __get(). + */ + public function __isset(string $key): bool + { + if ($this->isMappedDbColumn($key)) { + return false; + } + + $dbColumn = $this->mapProperty($key); + + $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $dbColumn))); + + if (method_exists($this, $method)) { + return true; + } + + return isset($this->attributes[$dbColumn]); + } + + /** + * Unsets an attribute property. + */ + public function __unset(string $key): void + { + if ($this->isMappedDbColumn($key)) { + return; + } + + $dbColumn = $this->mapProperty($key); + + unset($this->attributes[$dbColumn]); + } + + /** + * Whether this key is mapped db column name? + */ + protected function isMappedDbColumn(string $key): bool + { + $dbColumn = $this->mapProperty($key); + + // The $key is a property name which has mapped db column name + if ($key !== $dbColumn) { + return false; + } + + return $this->hasMappedProperty($key); + } + + /** + * Whether this key has mapped property? + */ + protected function hasMappedProperty(string $key): bool + { + $property = array_search($key, $this->datamap, true); + + return $property !== false; + } +} diff --git a/system/Entity/Exceptions/CastException.php b/system/Entity/Exceptions/CastException.php new file mode 100644 index 0000000..e259447 --- /dev/null +++ b/system/Entity/Exceptions/CastException.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Exceptions; + +use CodeIgniter\Exceptions\FrameworkException; +use CodeIgniter\Exceptions\HasExitCodeInterface; + +/** + * CastException is thrown for invalid cast initialization and management. + */ +class CastException extends FrameworkException implements HasExitCodeInterface +{ + public function getExitCode(): int + { + return EXIT_CONFIG; + } + + /** + * Thrown when the cast class does not extends BaseCast. + * + * @return static + */ + public static function forInvalidInterface(string $class) + { + return new static(lang('Cast.baseCastMissing', [$class])); + } + + /** + * Thrown when the Json format is invalid. + * + * @return static + */ + public static function forInvalidJsonFormat(int $error) + { + switch ($error) { + case JSON_ERROR_DEPTH: + return new static(lang('Cast.jsonErrorDepth')); + + case JSON_ERROR_STATE_MISMATCH: + return new static(lang('Cast.jsonErrorStateMismatch')); + + case JSON_ERROR_CTRL_CHAR: + return new static(lang('Cast.jsonErrorCtrlChar')); + + case JSON_ERROR_SYNTAX: + return new static(lang('Cast.jsonErrorSyntax')); + + case JSON_ERROR_UTF8: + return new static(lang('Cast.jsonErrorUtf8')); + + default: + return new static(lang('Cast.jsonErrorUnknown')); + } + } + + /** + * Thrown when the cast method is not `get` or `set`. + * + * @return static + */ + public static function forInvalidMethod(string $method) + { + return new static(lang('Cast.invalidCastMethod', [$method])); + } + + /** + * Thrown when the casting timestamp is not correct timestamp. + * + * @return static + */ + public static function forInvalidTimestamp() + { + return new static(lang('Cast.invalidTimestamp')); + } +} diff --git a/system/Events/Events.php b/system/Events/Events.php new file mode 100644 index 0000000..b240dc2 --- /dev/null +++ b/system/Events/Events.php @@ -0,0 +1,284 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Events; + +use Config\Modules; +use Config\Services; + +/** + * Events + * + * @see \CodeIgniter\Events\EventsTest + */ +class Events +{ + public const PRIORITY_LOW = 200; + public const PRIORITY_NORMAL = 100; + public const PRIORITY_HIGH = 10; + + /** + * The list of listeners. + * + * @var array + */ + protected static $listeners = []; + + /** + * Flag to let us know if we've read from the Config file(s) + * and have all of the defined events. + * + * @var bool + */ + protected static $initialized = false; + + /** + * If true, events will not actually be fired. + * Useful during testing. + * + * @var bool + */ + protected static $simulate = false; + + /** + * Stores information about the events + * for display in the debug toolbar. + * + * @var array> + */ + protected static $performanceLog = []; + + /** + * A list of found files. + * + * @var string[] + */ + protected static $files = []; + + /** + * Ensures that we have a events file ready. + * + * @return void + */ + public static function initialize() + { + // Don't overwrite anything.... + if (static::$initialized) { + return; + } + + $config = config(Modules::class); + $events = APPPATH . 'Config' . DIRECTORY_SEPARATOR . 'Events.php'; + $files = []; + + if ($config->shouldDiscover('events')) { + $files = Services::locator()->search('Config/Events.php'); + } + + $files = array_filter(array_map(static function (string $file) { + if (is_file($file)) { + return realpath($file) ?: $file; + } + + return false; // @codeCoverageIgnore + }, $files)); + + static::$files = array_unique(array_merge($files, [$events])); + + foreach (static::$files as $file) { + include $file; + } + + static::$initialized = true; + } + + /** + * Registers an action to happen on an event. The action can be any sort + * of callable: + * + * Events::on('create', 'myFunction'); // procedural function + * Events::on('create', ['myClass', 'myMethod']); // Class::method + * Events::on('create', [$myInstance, 'myMethod']); // Method on an existing instance + * Events::on('create', function() {}); // Closure + * + * @param string $eventName + * @param callable $callback + * @param int $priority + * + * @return void + */ + public static function on($eventName, $callback, $priority = self::PRIORITY_NORMAL) + { + if (! isset(static::$listeners[$eventName])) { + static::$listeners[$eventName] = [ + true, // If there's only 1 item, it's sorted. + [$priority], + [$callback], + ]; + } else { + static::$listeners[$eventName][0] = false; // Not sorted + static::$listeners[$eventName][1][] = $priority; + static::$listeners[$eventName][2][] = $callback; + } + } + + /** + * Runs through all subscribed methods running them one at a time, + * until either: + * a) All subscribers have finished or + * b) a method returns false, at which point execution of subscribers stops. + * + * @param string $eventName + * @param mixed $arguments + */ + public static function trigger($eventName, ...$arguments): bool + { + // Read in our Config/Events file so that we have them all! + if (! static::$initialized) { + static::initialize(); + } + + $listeners = static::listeners($eventName); + + foreach ($listeners as $listener) { + $start = microtime(true); + + $result = static::$simulate === false ? $listener(...$arguments) : true; + + if (CI_DEBUG) { + static::$performanceLog[] = [ + 'start' => $start, + 'end' => microtime(true), + 'event' => strtolower($eventName), + ]; + } + + if ($result === false) { + return false; + } + } + + return true; + } + + /** + * Returns an array of listeners for a single event. They are + * sorted by priority. + * + * @param string $eventName + */ + public static function listeners($eventName): array + { + if (! isset(static::$listeners[$eventName])) { + return []; + } + + // The list is not sorted + if (! static::$listeners[$eventName][0]) { + // Sort it! + array_multisort(static::$listeners[$eventName][1], SORT_NUMERIC, static::$listeners[$eventName][2]); + + // Mark it as sorted already! + static::$listeners[$eventName][0] = true; + } + + return static::$listeners[$eventName][2]; + } + + /** + * Removes a single listener from an event. + * + * If the listener couldn't be found, returns FALSE, else TRUE if + * it was removed. + * + * @param string $eventName + */ + public static function removeListener($eventName, callable $listener): bool + { + if (! isset(static::$listeners[$eventName])) { + return false; + } + + foreach (static::$listeners[$eventName][2] as $index => $check) { + if ($check === $listener) { + unset( + static::$listeners[$eventName][1][$index], + static::$listeners[$eventName][2][$index] + ); + + return true; + } + } + + return false; + } + + /** + * Removes all listeners. + * + * If the event_name is specified, only listeners for that event will be + * removed, otherwise all listeners for all events are removed. + * + * @param string|null $eventName + * + * @return void + */ + public static function removeAllListeners($eventName = null) + { + if ($eventName !== null) { + unset(static::$listeners[$eventName]); + } else { + static::$listeners = []; + } + } + + /** + * Sets the path to the file that routes are read from. + * + * @return void + */ + public static function setFiles(array $files) + { + static::$files = $files; + } + + /** + * Returns the files that were found/loaded during this request. + * + * @return string[] + */ + public static function getFiles() + { + return static::$files; + } + + /** + * Turns simulation on or off. When on, events will not be triggered, + * simply logged. Useful during testing when you don't actually want + * the tests to run. + * + * @return void + */ + public static function simulate(bool $choice = true) + { + static::$simulate = $choice; + } + + /** + * Getter for the performance log records. + * + * @return array> + */ + public static function getPerformanceLogs() + { + return static::$performanceLog; + } +} diff --git a/system/Exceptions/AlertError.php b/system/Exceptions/AlertError.php new file mode 100644 index 0000000..274bb73 --- /dev/null +++ b/system/Exceptions/AlertError.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +use Error; + +/** + * Error: Action must be taken immediately (system/db down, etc) + */ +class AlertError extends Error +{ +} diff --git a/system/Exceptions/CastException.php b/system/Exceptions/CastException.php new file mode 100644 index 0000000..8dfb295 --- /dev/null +++ b/system/Exceptions/CastException.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +/** + * Cast Exceptions. + * + * @deprecated use CodeIgniter\Entity\Exceptions\CastException instead. + * + * @codeCoverageIgnore + */ +class CastException extends CriticalError implements HasExitCodeInterface +{ + use DebugTraceableTrait; + + public function getExitCode(): int + { + return EXIT_CONFIG; + } + + /** + * @return static + */ + public static function forInvalidJsonFormatException(int $error) + { + switch ($error) { + case JSON_ERROR_DEPTH: + return new static(lang('Cast.jsonErrorDepth')); + + case JSON_ERROR_STATE_MISMATCH: + return new static(lang('Cast.jsonErrorStateMismatch')); + + case JSON_ERROR_CTRL_CHAR: + return new static(lang('Cast.jsonErrorCtrlChar')); + + case JSON_ERROR_SYNTAX: + return new static(lang('Cast.jsonErrorSyntax')); + + case JSON_ERROR_UTF8: + return new static(lang('Cast.jsonErrorUtf8')); + + default: + return new static(lang('Cast.jsonErrorUnknown')); + } + } +} diff --git a/system/Exceptions/ConfigException.php b/system/Exceptions/ConfigException.php new file mode 100644 index 0000000..6eea263 --- /dev/null +++ b/system/Exceptions/ConfigException.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +/** + * Exception for automatic logging. + */ +class ConfigException extends CriticalError implements HasExitCodeInterface +{ + use DebugTraceableTrait; + + public function getExitCode(): int + { + return EXIT_CONFIG; + } + + /** + * @return static + */ + public static function forDisabledMigrations() + { + return new static(lang('Migrations.disabled')); + } +} diff --git a/system/Exceptions/CriticalError.php b/system/Exceptions/CriticalError.php new file mode 100644 index 0000000..b5129bd --- /dev/null +++ b/system/Exceptions/CriticalError.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +use Error; + +/** + * Error: Critical conditions, like component unavailable, etc. + */ +class CriticalError extends Error +{ +} diff --git a/system/Exceptions/DebugTraceableTrait.php b/system/Exceptions/DebugTraceableTrait.php new file mode 100644 index 0000000..b5fb691 --- /dev/null +++ b/system/Exceptions/DebugTraceableTrait.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +use Throwable; + +/** + * This trait provides framework exceptions the ability to pinpoint + * accurately where the exception was raised rather than instantiated. + * + * This is used primarily for factory-instantiated exceptions. + */ +trait DebugTraceableTrait +{ + /** + * Tweaks the exception's constructor to assign the file/line to where + * it is actually raised rather than were it is instantiated. + */ + final public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + + $trace = $this->getTrace()[0]; + + if (isset($trace['class']) && $trace['class'] === static::class) { + [ + 'line' => $this->line, + 'file' => $this->file, + ] = $trace; + } + } +} diff --git a/system/Exceptions/DownloadException.php b/system/Exceptions/DownloadException.php new file mode 100644 index 0000000..5626cfe --- /dev/null +++ b/system/Exceptions/DownloadException.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +use RuntimeException; + +/** + * Class DownloadException + */ +class DownloadException extends RuntimeException implements ExceptionInterface +{ + use DebugTraceableTrait; + + /** + * @return static + */ + public static function forCannotSetFilePath(string $path) + { + return new static(lang('HTTP.cannotSetFilepath', [$path])); + } + + /** + * @return static + */ + public static function forCannotSetBinary() + { + return new static(lang('HTTP.cannotSetBinary')); + } + + /** + * @return static + */ + public static function forNotFoundDownloadSource() + { + return new static(lang('HTTP.notFoundDownloadSource')); + } + + /** + * @return static + */ + public static function forCannotSetCache() + { + return new static(lang('HTTP.cannotSetCache')); + } + + /** + * @return static + */ + public static function forCannotSetStatusCode(int $code, string $reason) + { + return new static(lang('HTTP.cannotSetStatusCode', [$code, $reason])); + } +} diff --git a/system/Exceptions/EmergencyError.php b/system/Exceptions/EmergencyError.php new file mode 100644 index 0000000..db3e25e --- /dev/null +++ b/system/Exceptions/EmergencyError.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +use Error; + +/** + * Error: system is unusable + */ +class EmergencyError extends Error +{ +} diff --git a/system/Exceptions/ExceptionInterface.php b/system/Exceptions/ExceptionInterface.php new file mode 100644 index 0000000..be8ac69 --- /dev/null +++ b/system/Exceptions/ExceptionInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +/** + * Provides a domain-level interface for broad capture + * of all framework-related exceptions. + * + * catch (\CodeIgniter\Exceptions\ExceptionInterface) { ... } + */ +interface ExceptionInterface +{ +} diff --git a/system/Exceptions/FrameworkException.php b/system/Exceptions/FrameworkException.php new file mode 100644 index 0000000..4cafd71 --- /dev/null +++ b/system/Exceptions/FrameworkException.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +use RuntimeException; + +/** + * Class FrameworkException + * + * A collection of exceptions thrown by the framework + * that can only be determined at run time. + */ +class FrameworkException extends RuntimeException implements ExceptionInterface +{ + use DebugTraceableTrait; + + /** + * @return static + */ + public static function forEnabledZlibOutputCompression() + { + return new static(lang('Core.enabledZlibOutputCompression')); + } + + /** + * @return static + */ + public static function forInvalidFile(string $path) + { + return new static(lang('Core.invalidFile', [$path])); + } + + /** + * @return static + */ + public static function forInvalidDirectory(string $path) + { + return new static(lang('Core.invalidDirectory', [$path])); + } + + /** + * @return static + */ + public static function forCopyError(string $path) + { + return new static(lang('Core.copyError', [$path])); + } + + /** + * @return static + */ + public static function forMissingExtension(string $extension) + { + if (strpos($extension, 'intl') !== false) { + // @codeCoverageIgnoreStart + $message = sprintf( + 'The framework needs the following extension(s) installed and loaded: %s.', + $extension + ); + // @codeCoverageIgnoreEnd + } else { + $message = lang('Core.missingExtension', [$extension]); + } + + return new static($message); + } + + /** + * @return static + */ + public static function forNoHandlers(string $class) + { + return new static(lang('Core.noHandlers', [$class])); + } + + /** + * @return static + */ + public static function forFabricatorCreateFailed(string $table, string $reason) + { + return new static(lang('Fabricator.createFailed', [$table, $reason])); + } +} diff --git a/system/Exceptions/HTTPExceptionInterface.php b/system/Exceptions/HTTPExceptionInterface.php new file mode 100644 index 0000000..901f0b6 --- /dev/null +++ b/system/Exceptions/HTTPExceptionInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +/** + * Interface for Exceptions that has exception code as HTTP status code. + */ +interface HTTPExceptionInterface +{ +} diff --git a/system/Exceptions/HasExitCodeInterface.php b/system/Exceptions/HasExitCodeInterface.php new file mode 100644 index 0000000..3380d00 --- /dev/null +++ b/system/Exceptions/HasExitCodeInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +/** + * Interface for Exceptions that has exception code as exit code. + */ +interface HasExitCodeInterface +{ + /** + * Returns exit status code. + */ + public function getExitCode(): int; +} diff --git a/system/Exceptions/ModelException.php b/system/Exceptions/ModelException.php new file mode 100644 index 0000000..39590a6 --- /dev/null +++ b/system/Exceptions/ModelException.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +/** + * Model Exceptions. + */ +class ModelException extends FrameworkException +{ + /** + * @return static + */ + public static function forNoPrimaryKey(string $modelName) + { + return new static(lang('Database.noPrimaryKey', [$modelName])); + } + + /** + * @return static + */ + public static function forNoDateFormat(string $modelName) + { + return new static(lang('Database.noDateFormat', [$modelName])); + } + + /** + * @return static + */ + public static function forMethodNotAvailable(string $modelName, string $methodName) + { + return new static(lang('Database.methodNotAvailable', [$modelName, $methodName])); + } +} diff --git a/system/Exceptions/PageNotFoundException.php b/system/Exceptions/PageNotFoundException.php new file mode 100644 index 0000000..69e93f9 --- /dev/null +++ b/system/Exceptions/PageNotFoundException.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +use Config\Services; +use OutOfBoundsException; + +class PageNotFoundException extends OutOfBoundsException implements ExceptionInterface, HTTPExceptionInterface +{ + use DebugTraceableTrait; + + /** + * HTTP status code + * + * @var int + */ + protected $code = 404; + + /** + * @return static + */ + public static function forPageNotFound(?string $message = null) + { + return new static($message ?? self::lang('HTTP.pageNotFound')); + } + + /** + * @return static + */ + public static function forEmptyController() + { + return new static(self::lang('HTTP.emptyController')); + } + + /** + * @return static + */ + public static function forControllerNotFound(string $controller, string $method) + { + return new static(self::lang('HTTP.controllerNotFound', [$controller, $method])); + } + + /** + * @return static + */ + public static function forMethodNotFound(string $method) + { + return new static(self::lang('HTTP.methodNotFound', [$method])); + } + + /** + * @return static + */ + public static function forLocaleNotSupported(string $locale) + { + return new static(self::lang('HTTP.localeNotSupported', [$locale])); + } + + /** + * Get translated system message + * + * Use a non-shared Language instance in the Services. + * If a shared instance is created, the Language will + * have the current locale, so even if users call + * `$this->request->setLocale()` in the controller afterwards, + * the Language locale will not be changed. + */ + private static function lang(string $line, array $args = []): string + { + $lang = Services::language(null, false); + + return $lang->getLine($line, $args); + } +} diff --git a/system/Exceptions/TestException.php b/system/Exceptions/TestException.php new file mode 100644 index 0000000..dadbbc0 --- /dev/null +++ b/system/Exceptions/TestException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +/** + * Exception for automatic logging. + */ +class TestException extends CriticalError +{ + use DebugTraceableTrait; + + /** + * @return static + */ + public static function forInvalidMockClass(string $name) + { + return new static(lang('Test.invalidMockClass', [$name])); + } +} diff --git a/system/Files/Exceptions/FileException.php b/system/Files/Exceptions/FileException.php new file mode 100644 index 0000000..b877bad --- /dev/null +++ b/system/Files/Exceptions/FileException.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Files\Exceptions; + +use CodeIgniter\Exceptions\DebugTraceableTrait; +use CodeIgniter\Exceptions\ExceptionInterface; +use RuntimeException; + +class FileException extends RuntimeException implements ExceptionInterface +{ + use DebugTraceableTrait; + + /** + * @return static + */ + public static function forUnableToMove(?string $from = null, ?string $to = null, ?string $error = null) + { + return new static(lang('Files.cannotMove', [$from, $to, $error])); + } + + /** + * Throws when an item is expected to be a directory but is not or is missing. + * + * @param string $caller The method causing the exception + * + * @return static + */ + public static function forExpectedDirectory(string $caller) + { + return new static(lang('Files.expectedDirectory', [$caller])); + } + + /** + * Throws when an item is expected to be a file but is not or is missing. + * + * @param string $caller The method causing the exception + * + * @return static + */ + public static function forExpectedFile(string $caller) + { + return new static(lang('Files.expectedFile', [$caller])); + } +} diff --git a/system/Files/Exceptions/FileNotFoundException.php b/system/Files/Exceptions/FileNotFoundException.php new file mode 100644 index 0000000..1c0f4cf --- /dev/null +++ b/system/Files/Exceptions/FileNotFoundException.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Files\Exceptions; + +use CodeIgniter\Exceptions\DebugTraceableTrait; +use CodeIgniter\Exceptions\ExceptionInterface; +use RuntimeException; + +class FileNotFoundException extends RuntimeException implements ExceptionInterface +{ + use DebugTraceableTrait; + + /** + * @return static + */ + public static function forFileNotFound(string $path) + { + return new static(lang('Files.fileNotFound', [$path])); + } +} diff --git a/system/Files/File.php b/system/Files/File.php new file mode 100644 index 0000000..0113112 --- /dev/null +++ b/system/Files/File.php @@ -0,0 +1,194 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Files; + +use CodeIgniter\Files\Exceptions\FileException; +use CodeIgniter\Files\Exceptions\FileNotFoundException; +use CodeIgniter\I18n\Time; +use Config\Mimes; +use ReturnTypeWillChange; +use SplFileInfo; + +/** + * Wrapper for PHP's built-in SplFileInfo, with goodies. + * + * @see \CodeIgniter\Files\FileTest + */ +class File extends SplFileInfo +{ + /** + * The files size in bytes + * + * @var int + */ + protected $size; + + /** + * @var string|null + */ + protected $originalMimeType; + + /** + * Run our SplFileInfo constructor with an optional verification + * that the path is really a file. + * + * @throws FileNotFoundException + */ + public function __construct(string $path, bool $checkFile = false) + { + if ($checkFile && ! is_file($path)) { + throw FileNotFoundException::forFileNotFound($path); + } + + parent::__construct($path); + } + + /** + * Retrieve the file size. + * + * Implementations SHOULD return the value stored in the "size" key of + * the file in the $_FILES array if available, as PHP calculates this based + * on the actual size transmitted. + * + * @return false|int The file size in bytes, or false on failure + */ + #[ReturnTypeWillChange] + public function getSize() + { + return $this->size ?? ($this->size = parent::getSize()); + } + + /** + * Retrieve the file size by unit. + * + * @return false|int|string + */ + public function getSizeByUnit(string $unit = 'b') + { + switch (strtolower($unit)) { + case 'kb': + return number_format($this->getSize() / 1024, 3); + + case 'mb': + return number_format(($this->getSize() / 1024) / 1024, 3); + + default: + return $this->getSize(); + } + } + + /** + * Attempts to determine the file extension based on the trusted + * getType() method. If the mime type is unknown, will return null. + */ + public function guessExtension(): ?string + { + // naively get the path extension using pathinfo + $pathinfo = pathinfo($this->getRealPath() ?: $this->__toString()) + ['extension' => '']; + + $proposedExtension = $pathinfo['extension']; + + return Mimes::guessExtensionFromType($this->getMimeType(), $proposedExtension); + } + + /** + * Retrieve the media type of the file. SHOULD not use information from + * the $_FILES array, but should use other methods to more accurately + * determine the type of file, like finfo, or mime_content_type(). + * + * @return string The media type we determined it to be. + */ + public function getMimeType(): string + { + if (! function_exists('finfo_open')) { + return $this->originalMimeType ?? 'application/octet-stream'; // @codeCoverageIgnore + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($finfo, $this->getRealPath() ?: $this->__toString()); + finfo_close($finfo); + + return $mimeType; + } + + /** + * Generates a random names based on a simple hash and the time, with + * the correct file extension attached. + */ + public function getRandomName(): string + { + $extension = $this->getExtension(); + $extension = empty($extension) ? '' : '.' . $extension; + + return Time::now()->getTimestamp() . '_' . bin2hex(random_bytes(10)) . $extension; + } + + /** + * Moves a file to a new location. + * + * @return File + */ + public function move(string $targetPath, ?string $name = null, bool $overwrite = false) + { + $targetPath = rtrim($targetPath, '/') . '/'; + $name ??= $this->getBasename(); + $destination = $overwrite ? $targetPath . $name : $this->getDestination($targetPath . $name); + + $oldName = $this->getRealPath() ?: $this->__toString(); + + if (! @rename($oldName, $destination)) { + $error = error_get_last(); + + throw FileException::forUnableToMove($this->getBasename(), $targetPath, strip_tags($error['message'])); + } + + @chmod($destination, 0777 & ~umask()); + + return new self($destination); + } + + /** + * Returns the destination path for the move operation where overwriting is not expected. + * + * First, it checks whether the delimiter is present in the filename, if it is, then it checks whether the + * last element is an integer as there may be cases that the delimiter may be present in the filename. + * For the all other cases, it appends an integer starting from zero before the file's extension. + */ + public function getDestination(string $destination, string $delimiter = '_', int $i = 0): string + { + if ($delimiter === '') { + $delimiter = '_'; + } + + while (is_file($destination)) { + $info = pathinfo($destination); + $extension = isset($info['extension']) ? '.' . $info['extension'] : ''; + + if (strpos($info['filename'], $delimiter) !== false) { + $parts = explode($delimiter, $info['filename']); + + if (is_numeric(end($parts))) { + $i = end($parts); + array_pop($parts); + $parts[] = ++$i; + $destination = $info['dirname'] . DIRECTORY_SEPARATOR . implode($delimiter, $parts) . $extension; + } else { + $destination = $info['dirname'] . DIRECTORY_SEPARATOR . $info['filename'] . $delimiter . ++$i . $extension; + } + } else { + $destination = $info['dirname'] . DIRECTORY_SEPARATOR . $info['filename'] . $delimiter . ++$i . $extension; + } + } + + return $destination; + } +} diff --git a/system/Files/FileCollection.php b/system/Files/FileCollection.php new file mode 100644 index 0000000..87f987e --- /dev/null +++ b/system/Files/FileCollection.php @@ -0,0 +1,368 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Files; + +use CodeIgniter\Files\Exceptions\FileException; +use CodeIgniter\Files\Exceptions\FileNotFoundException; +use Countable; +use Generator; +use InvalidArgumentException; +use IteratorAggregate; + +/** + * File Collection Class + * + * Representation for a group of files, with utilities for locating, + * filtering, and ordering them. + * + * @template-implements IteratorAggregate + * @see \CodeIgniter\Files\FileCollectionTest + */ +class FileCollection implements Countable, IteratorAggregate +{ + /** + * The current list of file paths. + * + * @var string[] + */ + protected $files = []; + + // -------------------------------------------------------------------- + // Support Methods + // -------------------------------------------------------------------- + + /** + * Resolves a full path and verifies it is an actual directory. + * + * @throws FileException + */ + final protected static function resolveDirectory(string $directory): string + { + if (! is_dir($directory = set_realpath($directory))) { + $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + + throw FileException::forExpectedDirectory($caller['function']); + } + + return $directory; + } + + /** + * Resolves a full path and verifies it is an actual file. + * + * @throws FileException + */ + final protected static function resolveFile(string $file): string + { + if (! is_file($file = set_realpath($file))) { + $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + + throw FileException::forExpectedFile($caller['function']); + } + + return $file; + } + + /** + * Removes files that are not part of the given directory (recursive). + * + * @param string[] $files + * + * @return string[] + */ + final protected static function filterFiles(array $files, string $directory): array + { + $directory = self::resolveDirectory($directory); + + return array_filter($files, static fn (string $value): bool => strpos($value, $directory) === 0); + } + + /** + * Returns any files whose `basename` matches the given pattern. + * + * @param string[] $files + * @param string $pattern Regex or pseudo-regex string + * + * @return string[] + */ + final protected static function matchFiles(array $files, string $pattern): array + { + // Convert pseudo-regex into their true form + if (@preg_match($pattern, '') === false) { + $pattern = str_replace( + ['#', '.', '*', '?'], + ['\#', '\.', '.*', '.'], + $pattern + ); + $pattern = "#{$pattern}#"; + } + + return array_filter($files, static fn ($value) => (bool) preg_match($pattern, basename($value))); + } + + // -------------------------------------------------------------------- + // Class Core + // -------------------------------------------------------------------- + + /** + * Loads the Filesystem helper and adds any initial files. + * + * @param string[] $files + */ + public function __construct(array $files = []) + { + helper(['filesystem']); + + $this->add($files)->define(); + } + + /** + * Applies any initial inputs after the constructor. + * This method is a stub to be implemented by child classes. + */ + protected function define(): void + { + } + + /** + * Optimizes and returns the current file list. + * + * @return string[] + */ + public function get(): array + { + $this->files = array_unique($this->files); + sort($this->files, SORT_STRING); + + return $this->files; + } + + /** + * Sets the file list directly, files are still subject to verification. + * This works as a "reset" method with []. + * + * @param string[] $files The new file list to use + * + * @return $this + */ + public function set(array $files) + { + $this->files = []; + + return $this->addFiles($files); + } + + /** + * Adds an array/single file or directory to the list. + * + * @param string|string[] $paths + * + * @return $this + */ + public function add($paths, bool $recursive = true) + { + $paths = (array) $paths; + + foreach ($paths as $path) { + if (! is_string($path)) { + throw new InvalidArgumentException('FileCollection paths must be strings.'); + } + + try { + // Test for a directory + self::resolveDirectory($path); + } catch (FileException $e) { + $this->addFile($path); + + continue; + } + + $this->addDirectory($path, $recursive); + } + + return $this; + } + + // -------------------------------------------------------------------- + // File Handling + // -------------------------------------------------------------------- + + /** + * Verifies and adds files to the list. + * + * @param string[] $files + * + * @return $this + */ + public function addFiles(array $files) + { + foreach ($files as $file) { + $this->addFile($file); + } + + return $this; + } + + /** + * Verifies and adds a single file to the file list. + * + * @return $this + */ + public function addFile(string $file) + { + $this->files[] = self::resolveFile($file); + + return $this; + } + + /** + * Removes files from the list. + * + * @param string[] $files + * + * @return $this + */ + public function removeFiles(array $files) + { + $this->files = array_diff($this->files, $files); + + return $this; + } + + /** + * Removes a single file from the list. + * + * @return $this + */ + public function removeFile(string $file) + { + return $this->removeFiles([$file]); + } + + // -------------------------------------------------------------------- + // Directory Handling + // -------------------------------------------------------------------- + + /** + * Verifies and adds files from each + * directory to the list. + * + * @param string[] $directories + * + * @return $this + */ + public function addDirectories(array $directories, bool $recursive = false) + { + foreach ($directories as $directory) { + $this->addDirectory($directory, $recursive); + } + + return $this; + } + + /** + * Verifies and adds all files from a directory. + * + * @return $this + */ + public function addDirectory(string $directory, bool $recursive = false) + { + $directory = self::resolveDirectory($directory); + + // Map the directory to depth 2 to so directories become arrays + foreach (directory_map($directory, 2, true) as $key => $path) { + if (is_string($path)) { + $this->addFile($directory . $path); + } elseif ($recursive && is_array($path)) { + $this->addDirectory($directory . $key, true); + } + } + + return $this; + } + + // -------------------------------------------------------------------- + // Filtering + // -------------------------------------------------------------------- + + /** + * Removes any files from the list that match the supplied pattern + * (within the optional scope). + * + * @param string $pattern Regex or pseudo-regex string + * @param string|null $scope The directory to limit the scope + * + * @return $this + */ + public function removePattern(string $pattern, ?string $scope = null) + { + if ($pattern === '') { + return $this; + } + + // Start with all files or those in scope + $files = $scope === null ? $this->files : self::filterFiles($this->files, $scope); + + // Remove any files that match the pattern + return $this->removeFiles(self::matchFiles($files, $pattern)); + } + + /** + * Keeps only the files from the list that match + * (within the optional scope). + * + * @param string $pattern Regex or pseudo-regex string + * @param string|null $scope A directory to limit the scope + * + * @return $this + */ + public function retainPattern(string $pattern, ?string $scope = null) + { + if ($pattern === '') { + return $this; + } + + // Start with all files or those in scope + $files = $scope === null ? $this->files : self::filterFiles($this->files, $scope); + + // Matches the pattern within the scoped files and remove their inverse. + return $this->removeFiles(array_diff($files, self::matchFiles($files, $pattern))); + } + + // -------------------------------------------------------------------- + // Interface Methods + // -------------------------------------------------------------------- + + /** + * Returns the current number of files in the collection. + * Fulfills Countable. + */ + public function count(): int + { + return count($this->files); + } + + /** + * Yields as an Iterator for the current files. + * Fulfills IteratorAggregate. + * + * @return Generator + * + * @throws FileNotFoundException + */ + public function getIterator(): Generator + { + foreach ($this->get() as $file) { + yield new File($file, true); + } + } +} diff --git a/system/Filters/CSRF.php b/system/Filters/CSRF.php new file mode 100644 index 0000000..23e6e25 --- /dev/null +++ b/system/Filters/CSRF.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Filters; + +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Security\Exceptions\SecurityException; +use Config\Services; + +/** + * CSRF filter. + * + * This filter is not intended to be used from the command line. + * + * @codeCoverageIgnore + * @see \CodeIgniter\Filters\CSRFTest + */ +class CSRF implements FilterInterface +{ + /** + * Do whatever processing this filter needs to do. + * By default it should not return anything during + * normal execution. However, when an abnormal state + * is found, it should return an instance of + * CodeIgniter\HTTP\Response. If it does, script + * execution will end and that Response will be + * sent back to the client, allowing for error pages, + * redirects, etc. + * + * @param array|null $arguments + * + * @return RedirectResponse|void + * + * @throws SecurityException + */ + public function before(RequestInterface $request, $arguments = null) + { + if (! $request instanceof IncomingRequest) { + return; + } + + $security = Services::security(); + + try { + $security->verify($request); + } catch (SecurityException $e) { + if ($security->shouldRedirect() && ! $request->isAJAX()) { + return redirect()->back()->with('error', $e->getMessage()); + } + + throw $e; + } + } + + /** + * We don't have anything to do here. + * + * @param array|null $arguments + * + * @return void + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + } +} diff --git a/system/Filters/DebugToolbar.php b/system/Filters/DebugToolbar.php new file mode 100644 index 0000000..75662d8 --- /dev/null +++ b/system/Filters/DebugToolbar.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Filters; + +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Services; + +/** + * Debug toolbar filter + * + * @see \CodeIgniter\Filters\DebugToolbarTest + */ +class DebugToolbar implements FilterInterface +{ + /** + * We don't need to do anything here. + * + * @param array|null $arguments + */ + public function before(RequestInterface $request, $arguments = null) + { + } + + /** + * If the debug flag is set (CI_DEBUG) then collect performance + * and debug information and display it in a toolbar. + * + * @param array|null $arguments + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + Services::toolbar()->prepare($request, $response); + } +} diff --git a/system/Filters/Exceptions/FilterException.php b/system/Filters/Exceptions/FilterException.php new file mode 100644 index 0000000..04bb36b --- /dev/null +++ b/system/Filters/Exceptions/FilterException.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Filters\Exceptions; + +use CodeIgniter\Exceptions\ConfigException; +use CodeIgniter\Exceptions\ExceptionInterface; + +/** + * FilterException + */ +class FilterException extends ConfigException implements ExceptionInterface +{ + /** + * Thrown when the provided alias is not within + * the list of configured filter aliases. + * + * @return static + */ + public static function forNoAlias(string $alias) + { + return new static(lang('Filters.noFilter', [$alias])); + } + + /** + * Thrown when the filter class does not implement FilterInterface. + * + * @return static + */ + public static function forIncorrectInterface(string $class) + { + return new static(lang('Filters.incorrectInterface', [$class])); + } +} diff --git a/system/Filters/FilterInterface.php b/system/Filters/FilterInterface.php new file mode 100644 index 0000000..1a3363c --- /dev/null +++ b/system/Filters/FilterInterface.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Filters; + +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +/** + * Filter interface + */ +interface FilterInterface +{ + /** + * Do whatever processing this filter needs to do. + * By default it should not return anything during + * normal execution. However, when an abnormal state + * is found, it should return an instance of + * CodeIgniter\HTTP\Response. If it does, script + * execution will end and that Response will be + * sent back to the client, allowing for error pages, + * redirects, etc. + * + * @param array|null $arguments + * + * @return RequestInterface|ResponseInterface|string|void + */ + public function before(RequestInterface $request, $arguments = null); + + /** + * Allows After filters to inspect and modify the response + * object as needed. This method does not allow any way + * to stop execution of other after filters, short of + * throwing an Exception or Error. + * + * @param array|null $arguments + * + * @return ResponseInterface|void + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null); +} diff --git a/system/Filters/Filters.php b/system/Filters/Filters.php new file mode 100644 index 0000000..18001b1 --- /dev/null +++ b/system/Filters/Filters.php @@ -0,0 +1,662 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Filters; + +use CodeIgniter\Exceptions\ConfigException; +use CodeIgniter\Filters\Exceptions\FilterException; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Filters as FiltersConfig; +use Config\Modules; +use Config\Services; + +/** + * Filters + * + * @see \CodeIgniter\Filters\FiltersTest + */ +class Filters +{ + /** + * The original config file + * + * @var FiltersConfig + */ + protected $config; + + /** + * The active IncomingRequest or CLIRequest + * + * @var RequestInterface + */ + protected $request; + + /** + * The active Response instance + * + * @var ResponseInterface + */ + protected $response; + + /** + * Handle to the modules config. + * + * @var Modules + */ + protected $modules; + + /** + * Whether we've done initial processing + * on the filter lists. + * + * @var bool + */ + protected $initialized = false; + + /** + * The processed filters that will + * be used to check against. + * + * @var array + */ + protected $filters = [ + 'before' => [], + 'after' => [], + ]; + + /** + * The collection of filters' class names that will + * be used to execute in each position. + * + * @var array + */ + protected $filtersClass = [ + 'before' => [], + 'after' => [], + ]; + + /** + * Any arguments to be passed to filters. + * + * @var array|null> [name => params] + */ + protected $arguments = []; + + /** + * Any arguments to be passed to filtersClass. + * + * @var array>|null> [classname => arguments] + */ + protected $argumentsClass = []; + + /** + * Constructor. + * + * @param FiltersConfig $config + */ + public function __construct($config, RequestInterface $request, ResponseInterface $response, ?Modules $modules = null) + { + $this->config = $config; + $this->request = &$request; + $this->setResponse($response); + + $this->modules = $modules ?? config(Modules::class); + + if ($this->modules->shouldDiscover('filters')) { + $this->discoverFilters(); + } + } + + /** + * If discoverFilters is enabled in Config then system will try to + * auto-discover custom filters files in Namespaces and allow access to + * the config object via the variable $filters as with the routes file + * + * Sample : + * $filters->aliases['custom-auth'] = \Acme\Blob\Filters\BlobAuth::class; + * + * @deprecated 4.4.2 Use Registrar instead. + */ + private function discoverFilters(): void + { + $locator = Services::locator(); + + // for access by custom filters + $filters = $this->config; + + $files = $locator->search('Config/Filters.php'); + + foreach ($files as $file) { + $className = $locator->getClassname($file); + + // Don't include our main Filter config again... + if ($className === FiltersConfig::class) { + continue; + } + + include $file; + } + } + + /** + * Set the response explicitly. + * + * @return void + */ + public function setResponse(ResponseInterface $response) + { + $this->response = $response; + } + + /** + * Runs through all of the filters for the specified + * uri and position. + * + * @param string $uri URI path relative to baseURL + * + * @return RequestInterface|ResponseInterface|string|null + * + * @throws FilterException + */ + public function run(string $uri, string $position = 'before') + { + $this->initialize(strtolower($uri)); + + foreach ($this->filtersClass[$position] as $className) { + $class = new $className(); + + if (! $class instanceof FilterInterface) { + throw FilterException::forIncorrectInterface(get_class($class)); + } + + if ($position === 'before') { + $result = $class->before( + $this->request, + $this->argumentsClass[$className] ?? null + ); + + if ($result instanceof RequestInterface) { + $this->request = $result; + + continue; + } + + // If the response object was sent back, + // then send it and quit. + if ($result instanceof ResponseInterface) { + // short circuit - bypass any other filters + return $result; + } + // Ignore an empty result + if (empty($result)) { + continue; + } + + return $result; + } + + if ($position === 'after') { + $result = $class->after( + $this->request, + $this->response, + $this->argumentsClass[$className] ?? null + ); + + if ($result instanceof ResponseInterface) { + $this->response = $result; + + continue; + } + } + } + + return $position === 'before' ? $this->request : $this->response; + } + + /** + * Runs through our list of filters provided by the configuration + * object to get them ready for use, including getting uri masks + * to proper regex, removing those we can from the possibilities + * based on HTTP method, etc. + * + * The resulting $this->filters is an array of only filters + * that should be applied to this request. + * + * We go ahead and process the entire tree because we'll need to + * run through both a before and after and don't want to double + * process the rows. + * + * @param string|null $uri URI path relative to baseURL (all lowercase) + * + * @return Filters + */ + public function initialize(?string $uri = null) + { + if ($this->initialized === true) { + return $this; + } + + $this->processGlobals($uri); + $this->processMethods(); + $this->processFilters($uri); + + // Set the toolbar filter to the last position to be executed + if (in_array('toolbar', $this->filters['after'], true) + && ($count = count($this->filters['after'])) > 1 + && $this->filters['after'][$count - 1] !== 'toolbar' + ) { + array_splice($this->filters['after'], array_search('toolbar', $this->filters['after'], true), 1); + $this->filters['after'][] = 'toolbar'; + } + + $this->processAliasesToClass('before'); + $this->processAliasesToClass('after'); + + $this->initialized = true; + + return $this; + } + + /** + * Restores instance to its pre-initialized state. + * Most useful for testing so the service can be + * re-initialized to a different path. + */ + public function reset(): self + { + $this->initialized = false; + + $this->arguments = $this->argumentsClass = []; + + $this->filters = $this->filtersClass = [ + 'before' => [], + 'after' => [], + ]; + + return $this; + } + + /** + * Returns the processed filters array. + */ + public function getFilters(): array + { + return $this->filters; + } + + /** + * Returns the filtersClass array. + */ + public function getFiltersClass(): array + { + return $this->filtersClass; + } + + /** + * Adds a new alias to the config file. + * MUST be called prior to initialize(); + * Intended for use within routes files. + * + * @return $this + */ + public function addFilter(string $class, ?string $alias = null, string $when = 'before', string $section = 'globals') + { + $alias ??= md5($class); + + if (! isset($this->config->{$section})) { + $this->config->{$section} = []; + } + + if (! isset($this->config->{$section}[$when])) { + $this->config->{$section}[$when] = []; + } + + $this->config->aliases[$alias] = $class; + + $this->config->{$section}[$when][] = $alias; + + return $this; + } + + /** + * Ensures that a specific filter is on and enabled for the current request. + * + * Filters can have "arguments". This is done by placing a colon immediately + * after the filter name, followed by a comma-separated list of arguments that + * are passed to the filter when executed. + * + * @param string $name filter_name or filter_name:arguments like 'role:admin,manager' + * + * @return $this + * + * @deprecated Use enableFilters(). This method will be private. + */ + public function enableFilter(string $name, string $when = 'before') + { + // Get arguments and clean name + [$name, $arguments] = $this->getCleanName($name); + $this->arguments[$name] = ($arguments !== []) ? $arguments : null; + + if (class_exists($name)) { + $this->config->aliases[$name] = $name; + } elseif (! array_key_exists($name, $this->config->aliases)) { + throw FilterException::forNoAlias($name); + } + + $classNames = (array) $this->config->aliases[$name]; + + foreach ($classNames as $className) { + $this->argumentsClass[$className] = $this->arguments[$name] ?? null; + } + + if (! isset($this->filters[$when][$name])) { + $this->filters[$when][] = $name; + $this->filtersClass[$when] = array_merge($this->filtersClass[$when], $classNames); + } + + return $this; + } + + /** + * Get clean name and arguments + * + * @param string $name filter_name or filter_name:arguments like 'role:admin,manager' + * + * @return array{0: string, 1: list} [name, arguments] + */ + private function getCleanName(string $name): array + { + $arguments = []; + + if (strpos($name, ':') !== false) { + [$name, $arguments] = explode(':', $name); + + $arguments = explode(',', $arguments); + array_walk($arguments, static function (&$item) { + $item = trim($item); + }); + } + + return [$name, $arguments]; + } + + /** + * Ensures that specific filters are on and enabled for the current request. + * + * Filters can have "arguments". This is done by placing a colon immediately + * after the filter name, followed by a comma-separated list of arguments that + * are passed to the filter when executed. + * + * @params array $names filter_name or filter_name:arguments like 'role:admin,manager' + * + * @return Filters + */ + public function enableFilters(array $names, string $when = 'before') + { + foreach ($names as $filter) { + $this->enableFilter($filter, $when); + } + + return $this; + } + + /** + * Returns the arguments for a specified key, or all. + * + * @return array|string + */ + public function getArguments(?string $key = null) + { + return $key === null ? $this->arguments : $this->arguments[$key]; + } + + // -------------------------------------------------------------------- + // Processors + // -------------------------------------------------------------------- + + /** + * Add any applicable (not excluded) global filter settings to the mix. + * + * @param string|null $uri URI path relative to baseURL (all lowercase) + * + * @return void + */ + protected function processGlobals(?string $uri = null) + { + if (! isset($this->config->globals) || ! is_array($this->config->globals)) { + return; + } + + $uri = strtolower(trim($uri ?? '', '/ ')); + + // Add any global filters, unless they are excluded for this URI + $sets = ['before', 'after']; + + foreach ($sets as $set) { + if (isset($this->config->globals[$set])) { + // look at each alias in the group + foreach ($this->config->globals[$set] as $alias => $rules) { + $keep = true; + if (is_array($rules)) { + // see if it should be excluded + if (isset($rules['except'])) { + // grab the exclusion rules + $check = $rules['except']; + if ($this->checkExcept($uri, $check)) { + $keep = false; + } + } + } else { + $alias = $rules; // simple name of filter to apply + } + + if ($keep) { + $this->filters[$set][] = $alias; + } + } + } + } + } + + /** + * Add any method-specific filters to the mix. + * + * @return void + */ + protected function processMethods() + { + if (! isset($this->config->methods) || ! is_array($this->config->methods)) { + return; + } + + // Request method won't be set for CLI-based requests + $method = strtolower($this->request->getMethod()) ?? 'cli'; + + if (array_key_exists($method, $this->config->methods)) { + $this->filters['before'] = array_merge($this->filters['before'], $this->config->methods[$method]); + } + } + + /** + * Add any applicable configured filters to the mix. + * + * @param string|null $uri URI path relative to baseURL (all lowercase) + * + * @return void + */ + protected function processFilters(?string $uri = null) + { + if (! isset($this->config->filters) || ! $this->config->filters) { + return; + } + + $uri = strtolower(trim($uri, '/ ')); + + // Add any filters that apply to this URI + foreach ($this->config->filters as $alias => $settings) { + // Look for inclusion rules + if (isset($settings['before'])) { + $path = $settings['before']; + + if ($this->pathApplies($uri, $path)) { + // Get arguments and clean name + [$name, $arguments] = $this->getCleanName($alias); + + $this->filters['before'][] = $name; + + $this->registerArguments($name, $arguments); + } + } + + if (isset($settings['after'])) { + $path = $settings['after']; + + if ($this->pathApplies($uri, $path)) { + // Get arguments and clean name + [$name, $arguments] = $this->getCleanName($alias); + + $this->filters['after'][] = $name; + + // The arguments may have already been registered in the before filter. + // So disable check. + $this->registerArguments($name, $arguments, false); + } + } + } + } + + /** + * @param string $name filter alias + * @param array $arguments filter arguments + * @param bool $check if true, check if already defined + */ + private function registerArguments(string $name, array $arguments, bool $check = true): void + { + if ($arguments !== []) { + if ($check && array_key_exists($name, $this->arguments)) { + throw new ConfigException( + '"' . $name . '" already has arguments: ' + . (($this->arguments[$name] === null) ? 'null' : implode(',', $this->arguments[$name])) + ); + } + + $this->arguments[$name] = $arguments; + } + + $classNames = (array) $this->config->aliases[$name]; + + foreach ($classNames as $className) { + $this->argumentsClass[$className] = $this->arguments[$name] ?? null; + } + } + + /** + * Maps filter aliases to the equivalent filter classes + * + * @return void + * + * @throws FilterException + */ + protected function processAliasesToClass(string $position) + { + foreach ($this->filters[$position] as $alias => $rules) { + if (is_numeric($alias) && is_string($rules)) { + $alias = $rules; + } + + if (! array_key_exists($alias, $this->config->aliases)) { + throw FilterException::forNoAlias($alias); + } + + if (is_array($this->config->aliases[$alias])) { + $this->filtersClass[$position] = array_merge($this->filtersClass[$position], $this->config->aliases[$alias]); + } else { + $this->filtersClass[$position][] = $this->config->aliases[$alias]; + } + } + + // when using enableFilter() we already write the class name in $filtersClass as well as the + // alias in $filters. This leads to duplicates when using route filters. + // Since some filters like rate limiters rely on being executed once a request we filter em here. + $this->filtersClass[$position] = array_values(array_unique($this->filtersClass[$position])); + } + + /** + * Check paths for match for URI + * + * @param string $uri URI to test against + * @param array|string $paths The path patterns to test + * + * @return bool True if any of the paths apply to the URI + */ + private function pathApplies(string $uri, $paths) + { + // empty path matches all + if (empty($paths)) { + return true; + } + + // make sure the paths are iterable + if (is_string($paths)) { + $paths = [$paths]; + } + + return $this->checkPseudoRegex($uri, $paths); + } + + /** + * Check except paths + * + * @param string $uri URI path relative to baseURL (all lowercase) + * @param array|string $paths The except path patterns + * + * @return bool True if the URI matches except paths. + */ + private function checkExcept(string $uri, $paths): bool + { + // empty array does not match anything + if ($paths === []) { + return false; + } + + // make sure the paths are iterable + if (is_string($paths)) { + $paths = [$paths]; + } + + return $this->checkPseudoRegex($uri, $paths); + } + + /** + * Check the URI path as pseudo-regex + * + * @param string $uri URI path relative to baseURL (all lowercase) + * @param array $paths The except path patterns + */ + private function checkPseudoRegex(string $uri, array $paths): bool + { + // treat each path as pseudo-regex + foreach ($paths as $path) { + // need to escape path separators + $path = str_replace('/', '\/', trim($path, '/ ')); + // need to make pseudo wildcard real + $path = strtolower(str_replace('*', '.*', $path)); + + // Does this rule apply here? + if (preg_match('#^' . $path . '$#', $uri, $match) === 1) { + return true; + } + } + + return false; + } +} diff --git a/system/Filters/Honeypot.php b/system/Filters/Honeypot.php new file mode 100644 index 0000000..e30e90f --- /dev/null +++ b/system/Filters/Honeypot.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Filters; + +use CodeIgniter\Honeypot\Exceptions\HoneypotException; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Services; + +/** + * Honeypot filter + * + * @see \CodeIgniter\Filters\HoneypotTest + */ +class Honeypot implements FilterInterface +{ + /** + * Checks if Honeypot field is empty, if not then the + * requester is a bot + * + * @param array|null $arguments + * + * @throws HoneypotException + */ + public function before(RequestInterface $request, $arguments = null) + { + if (! $request instanceof IncomingRequest) { + return; + } + + if (Services::honeypot()->hasContent($request)) { + throw HoneypotException::isBot(); + } + } + + /** + * Attach a honeypot to the current response. + * + * @param array|null $arguments + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + Services::honeypot()->attachHoneypot($response); + } +} diff --git a/system/Filters/InvalidChars.php b/system/Filters/InvalidChars.php new file mode 100644 index 0000000..93124fd --- /dev/null +++ b/system/Filters/InvalidChars.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Filters; + +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Security\Exceptions\SecurityException; + +/** + * InvalidChars filter. + * + * Check if user input data ($_GET, $_POST, $_COOKIE, php://input) do not contain + * invalid characters: + * - invalid UTF-8 characters + * - control characters except line break and tab code + * + * @see \CodeIgniter\Filters\InvalidCharsTest + */ +class InvalidChars implements FilterInterface +{ + /** + * Data source + * + * @var string + */ + protected $source; + + /** + * Regular expressions for valid control codes + * + * @var string + */ + protected $controlCodeRegex = '/\A[\r\n\t[:^cntrl:]]*\z/u'; + + /** + * Check invalid characters. + * + * @param array|null $arguments + * + * @return void + */ + public function before(RequestInterface $request, $arguments = null) + { + if (! $request instanceof IncomingRequest) { + return; + } + + $data = [ + 'get' => $request->getGet(), + 'post' => $request->getPost(), + 'cookie' => $request->getCookie(), + 'rawInput' => $request->getRawInput(), + ]; + + foreach ($data as $source => $values) { + $this->source = $source; + $this->checkEncoding($values); + $this->checkControl($values); + } + } + + /** + * We don't have anything to do here. + * + * @param array|null $arguments + * + * @return void + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + } + + /** + * Check the character encoding is valid UTF-8. + * + * @param array|string $value + * + * @return array|string + */ + protected function checkEncoding($value) + { + if (is_array($value)) { + array_map([$this, 'checkEncoding'], $value); + + return $value; + } + + if (mb_check_encoding($value, 'UTF-8')) { + return $value; + } + + throw SecurityException::forInvalidUTF8Chars($this->source, $value); + } + + /** + * Check for the presence of control characters except line breaks and tabs. + * + * @param array|string $value + * + * @return array|string + */ + protected function checkControl($value) + { + if (is_array($value)) { + array_map([$this, 'checkControl'], $value); + + return $value; + } + + if (preg_match($this->controlCodeRegex, $value) === 1) { + return $value; + } + + throw SecurityException::forInvalidControlChars($this->source, $value); + } +} diff --git a/system/Filters/SecureHeaders.php b/system/Filters/SecureHeaders.php new file mode 100644 index 0000000..952926b --- /dev/null +++ b/system/Filters/SecureHeaders.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Filters; + +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +/** + * Add Common Security Headers + * + * @see \CodeIgniter\Filters\SecureHeadersTest + */ +class SecureHeaders implements FilterInterface +{ + /** + * @var array + */ + protected $headers = [ + // https://owasp.org/www-project-secure-headers/#x-frame-options + 'X-Frame-Options' => 'SAMEORIGIN', + + // https://owasp.org/www-project-secure-headers/#x-content-type-options + 'X-Content-Type-Options' => 'nosniff', + + // https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/compatibility/jj542450(v=vs.85)#the-noopen-directive + 'X-Download-Options' => 'noopen', + + // https://owasp.org/www-project-secure-headers/#x-permitted-cross-domain-policies + 'X-Permitted-Cross-Domain-Policies' => 'none', + + // https://owasp.org/www-project-secure-headers/#referrer-policy + 'Referrer-Policy' => 'same-origin', + + // https://owasp.org/www-project-secure-headers/#x-xss-protection + // If you do not need to support legacy browsers, it is recommended that you use + // Content-Security-Policy without allowing unsafe-inline scripts instead. + // 'X-XSS-Protection' => '1; mode=block', + ]; + + /** + * We don't have anything to do here. + * + * @param array|null $arguments + * + * @return void + */ + public function before(RequestInterface $request, $arguments = null) + { + } + + /** + * Add security headers. + * + * @param array|null $arguments + * + * @return void + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + foreach ($this->headers as $header => $value) { + $response->setHeader($header, $value); + } + } +} diff --git a/system/Format/Exceptions/FormatException.php b/system/Format/Exceptions/FormatException.php new file mode 100644 index 0000000..55c36d3 --- /dev/null +++ b/system/Format/Exceptions/FormatException.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Format\Exceptions; + +use CodeIgniter\Exceptions\DebugTraceableTrait; +use CodeIgniter\Exceptions\ExceptionInterface; +use RuntimeException; + +/** + * FormatException + */ +class FormatException extends RuntimeException implements ExceptionInterface +{ + use DebugTraceableTrait; + + /** + * Thrown when the instantiated class does not exist. + * + * @return static + */ + public static function forInvalidFormatter(string $class) + { + return new static(lang('Format.invalidFormatter', [$class])); + } + + /** + * Thrown in JSONFormatter when the json_encode produces + * an error code other than JSON_ERROR_NONE and JSON_ERROR_RECURSION. + * + * @param string $error The error message + * + * @return static + */ + public static function forInvalidJSON(?string $error = null) + { + return new static(lang('Format.invalidJSON', [$error])); + } + + /** + * Thrown when the supplied MIME type has no + * defined Formatter class. + * + * @return static + */ + public static function forInvalidMime(string $mime) + { + return new static(lang('Format.invalidMime', [$mime])); + } + + /** + * Thrown on XMLFormatter when the `simplexml` extension + * is not installed. + * + * @return static + * + * @codeCoverageIgnore + */ + public static function forMissingExtension() + { + return new static(lang('Format.missingExtension')); + } +} diff --git a/system/Format/Format.php b/system/Format/Format.php new file mode 100644 index 0000000..6b4eb73 --- /dev/null +++ b/system/Format/Format.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Format; + +use CodeIgniter\Format\Exceptions\FormatException; +use Config\Format as FormatConfig; + +/** + * The Format class is a convenient place to create Formatters. + * + * @see \CodeIgniter\Format\FormatTest + */ +class Format +{ + /** + * Configuration instance + * + * @var FormatConfig + */ + protected $config; + + /** + * Constructor. + */ + public function __construct(FormatConfig $config) + { + $this->config = $config; + } + + /** + * Returns the current configuration instance. + * + * @return FormatConfig + */ + public function getConfig() + { + return $this->config; + } + + /** + * A Factory method to return the appropriate formatter for the given mime type. + * + * @throws FormatException + */ + public function getFormatter(string $mime): FormatterInterface + { + if (! array_key_exists($mime, $this->config->formatters)) { + throw FormatException::forInvalidMime($mime); + } + + $className = $this->config->formatters[$mime]; + + if (! class_exists($className)) { + throw FormatException::forInvalidFormatter($className); + } + + $class = new $className(); + + if (! $class instanceof FormatterInterface) { + throw FormatException::forInvalidFormatter($className); + } + + return $class; + } +} diff --git a/system/Format/FormatterInterface.php b/system/Format/FormatterInterface.php new file mode 100644 index 0000000..6e8e9bd --- /dev/null +++ b/system/Format/FormatterInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Format; + +/** + * Formatter interface + */ +interface FormatterInterface +{ + /** + * Takes the given data and formats it. + * + * @param array|object|string $data + * + * @return false|string + */ + public function format($data); +} diff --git a/system/Format/JSONFormatter.php b/system/Format/JSONFormatter.php new file mode 100644 index 0000000..3e708e9 --- /dev/null +++ b/system/Format/JSONFormatter.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Format; + +use CodeIgniter\Format\Exceptions\FormatException; +use Config\Format; + +/** + * JSON data formatter + * + * @see \CodeIgniter\Format\JSONFormatterTest + */ +class JSONFormatter implements FormatterInterface +{ + /** + * Takes the given data and formats it. + * + * @param array|bool|float|int|object|string|null $data + * + * @return false|string (JSON string | false) + */ + public function format($data) + { + $config = new Format(); + + $options = $config->formatterOptions['application/json'] ?? JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; + $options |= JSON_PARTIAL_OUTPUT_ON_ERROR; + + $options = ENVIRONMENT === 'production' ? $options : $options | JSON_PRETTY_PRINT; + + $result = json_encode($data, $options, 512); + + if (! in_array(json_last_error(), [JSON_ERROR_NONE, JSON_ERROR_RECURSION], true)) { + throw FormatException::forInvalidJSON(json_last_error_msg()); + } + + return $result; + } +} diff --git a/system/Format/XMLFormatter.php b/system/Format/XMLFormatter.php new file mode 100644 index 0000000..51b3003 --- /dev/null +++ b/system/Format/XMLFormatter.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Format; + +use CodeIgniter\Format\Exceptions\FormatException; +use Config\Format; +use SimpleXMLElement; + +/** + * XML data formatter + * + * @see \CodeIgniter\Format\XMLFormatterTest + */ +class XMLFormatter implements FormatterInterface +{ + /** + * Takes the given data and formats it. + * + * @param array|bool|float|int|object|string|null $data + * + * @return false|string (XML string | false) + */ + public function format($data) + { + $config = new Format(); + + // SimpleXML is installed but default + // but best to check, and then provide a fallback. + if (! extension_loaded('simplexml')) { + throw FormatException::forMissingExtension(); // @codeCoverageIgnore + } + + $options = $config->formatterOptions['application/xml'] ?? 0; + $output = new SimpleXMLElement('', $options); + + $this->arrayToXML((array) $data, $output); + + return $output->asXML(); + } + + /** + * A recursive method to convert an array into a valid XML string. + * + * Written by CodexWorld. Received permission by email on Nov 24, 2016 to use this code. + * + * @see http://www.codexworld.com/convert-array-to-xml-in-php/ + * + * @param SimpleXMLElement $output + * + * @return void + */ + protected function arrayToXML(array $data, &$output) + { + foreach ($data as $key => $value) { + $key = $this->normalizeXMLTag($key); + + if (is_array($value)) { + $subnode = $output->addChild("{$key}"); + $this->arrayToXML($value, $subnode); + } else { + $output->addChild("{$key}", htmlspecialchars("{$value}")); + } + } + } + + /** + * Normalizes tags into the allowed by W3C. + * Regex adopted from this StackOverflow answer. + * + * @param int|string $key + * + * @return string + * + * @see https://stackoverflow.com/questions/60001029/invalid-characters-in-xml-tag-name + */ + protected function normalizeXMLTag($key) + { + $startChar = 'A-Z_a-z' . + '\\x{C0}-\\x{D6}\\x{D8}-\\x{F6}\\x{F8}-\\x{2FF}\\x{370}-\\x{37D}' . + '\\x{37F}-\\x{1FFF}\\x{200C}-\\x{200D}\\x{2070}-\\x{218F}' . + '\\x{2C00}-\\x{2FEF}\\x{3001}-\\x{D7FF}\\x{F900}-\\x{FDCF}' . + '\\x{FDF0}-\\x{FFFD}\\x{10000}-\\x{EFFFF}'; + $validName = $startChar . '\\.\\d\\x{B7}\\x{300}-\\x{36F}\\x{203F}-\\x{2040}'; + + $key = trim($key); + $key = preg_replace("/[^{$validName}-]+/u", '', $key); + $key = preg_replace("/^[^{$startChar}]+/u", 'item$0', $key); + + return preg_replace('/^(xml).*/iu', 'item$0', $key); // XML is a reserved starting word + } +} diff --git a/system/HTTP/CLIRequest.php b/system/HTTP/CLIRequest.php new file mode 100644 index 0000000..01823db --- /dev/null +++ b/system/HTTP/CLIRequest.php @@ -0,0 +1,325 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use Config\App; +use Locale; +use RuntimeException; + +/** + * Represents a request from the command-line. Provides additional + * tools to interact with that request since CLI requests are not + * static like HTTP requests might be. + * + * Portions of this code were initially from the FuelPHP Framework, + * version 1.7.x, and used here under the MIT license they were + * originally made available under. + * + * http://fuelphp.com + * + * @see \CodeIgniter\HTTP\CLIRequestTest + */ +class CLIRequest extends Request +{ + /** + * Stores the segments of our cli "URI" command. + * + * @var array + */ + protected $segments = []; + + /** + * Command line options and their values. + * + * @var array + */ + protected $options = []; + + /** + * Command line arguments (segments and options). + * + * @var array + */ + protected $args = []; + + /** + * Set the expected HTTP verb + * + * @var string + */ + protected $method = 'cli'; + + /** + * Constructor + */ + public function __construct(App $config) + { + if (! is_cli()) { + throw new RuntimeException(static::class . ' needs to run from the command line.'); // @codeCoverageIgnore + } + + parent::__construct($config); + + // Don't terminate the script when the cli's tty goes away + ignore_user_abort(true); + + $this->parseCommand(); + + // Set SiteURI for this request + $this->uri = new SiteURI($config, $this->getPath()); + } + + /** + * Returns the "path" of the request script so that it can be used + * in routing to the appropriate controller/method. + * + * The path is determined by treating the command line arguments + * as if it were a URL - up until we hit our first option. + * + * Example: + * php index.php users 21 profile -foo bar + * + * // Routes to /users/21/profile (index is removed for routing sake) + * // with the option foo = bar. + */ + public function getPath(): string + { + $path = implode('/', $this->segments); + + return ($path === '') ? '' : $path; + } + + /** + * Returns an associative array of all CLI options found, with + * their values. + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Returns an array of all CLI arguments (segments and options). + */ + public function getArgs(): array + { + return $this->args; + } + + /** + * Returns the path segments. + */ + public function getSegments(): array + { + return $this->segments; + } + + /** + * Returns the value for a single CLI option that was passed in. + * + * @return string|null + */ + public function getOption(string $key) + { + return $this->options[$key] ?? null; + } + + /** + * Returns the options as a string, suitable for passing along on + * the CLI to other commands. + * + * Example: + * $options = [ + * 'foo' => 'bar', + * 'baz' => 'queue some stuff' + * ]; + * + * getOptionString() = '-foo bar -baz "queue some stuff"' + */ + public function getOptionString(bool $useLongOpts = false): string + { + if ($this->options === []) { + return ''; + } + + $out = ''; + + foreach ($this->options as $name => $value) { + if ($useLongOpts && mb_strlen($name) > 1) { + $out .= "--{$name} "; + } else { + $out .= "-{$name} "; + } + + if ($value === null) { + continue; + } + + if (mb_strpos($value, ' ') !== false) { + $out .= '"' . $value . '" '; + } else { + $out .= "{$value} "; + } + } + + return trim($out); + } + + /** + * Parses the command line it was called from and collects all options + * and valid segments. + * + * NOTE: I tried to use getopt but had it fail occasionally to find + * any options, where argv has always had our back. + * + * @return void + */ + protected function parseCommand() + { + $args = $this->getServer('argv'); + array_shift($args); // Scrap index.php + + $optionValue = false; + + foreach ($args as $i => $arg) { + if (mb_strpos($arg, '-') !== 0) { + if ($optionValue) { + $optionValue = false; + } else { + $this->segments[] = $arg; + $this->args[] = $arg; + } + + continue; + } + + $arg = ltrim($arg, '-'); + $value = null; + + if (isset($args[$i + 1]) && mb_strpos($args[$i + 1], '-') !== 0) { + $value = $args[$i + 1]; + $optionValue = true; + } + + $this->options[$arg] = $value; + $this->args[$arg] = $value; + } + } + + /** + * Determines if this request was made from the command line (CLI). + */ + public function isCLI(): bool + { + return true; + } + + /** + * Fetch an item from GET data. + * + * @param array|string|null $index Index for item to fetch from $_GET. + * @param int|null $filter A filter name to apply. + * @param array|int|null $flags + * + * @return array|null + */ + public function getGet($index = null, $filter = null, $flags = null) + { + return $this->returnNullOrEmptyArray($index); + } + + /** + * Fetch an item from POST. + * + * @param array|string|null $index Index for item to fetch from $_POST. + * @param int|null $filter A filter name to apply + * @param array|int|null $flags + * + * @return array|null + */ + public function getPost($index = null, $filter = null, $flags = null) + { + return $this->returnNullOrEmptyArray($index); + } + + /** + * Fetch an item from POST data with fallback to GET. + * + * @param array|string|null $index Index for item to fetch from $_POST or $_GET + * @param int|null $filter A filter name to apply + * @param array|int|null $flags + * + * @return array|null + */ + public function getPostGet($index = null, $filter = null, $flags = null) + { + return $this->returnNullOrEmptyArray($index); + } + + /** + * Fetch an item from GET data with fallback to POST. + * + * @param array|string|null $index Index for item to be fetched from $_GET or $_POST + * @param int|null $filter A filter name to apply + * @param array|int|null $flags + * + * @return array|null + */ + public function getGetPost($index = null, $filter = null, $flags = null) + { + return $this->returnNullOrEmptyArray($index); + } + + /** + * This is a place holder for calls from cookie_helper get_cookie(). + * + * @param array|string|null $index Index for item to be fetched from $_COOKIE + * @param int|null $filter A filter name to be applied + * @param mixed $flags + * + * @return array|null + */ + public function getCookie($index = null, $filter = null, $flags = null) + { + return $this->returnNullOrEmptyArray($index); + } + + /** + * @param array|string|null $index + * + * @return array|null + */ + private function returnNullOrEmptyArray($index) + { + return ($index === null || is_array($index)) ? [] : null; + } + + /** + * Gets the current locale, with a fallback to the default + * locale if none is set. + */ + public function getLocale(): string + { + return Locale::getDefault(); + } + + /** + * Checks this request type. + * + * @param string $type HTTP verb or 'json' or 'ajax' + * @phpstan-param string|'get'|'post'|'put'|'delete'|'head'|'patch'|'options'|'json'|'ajax' $type + */ + public function is(string $type): bool + { + return false; + } +} diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php new file mode 100644 index 0000000..500b425 --- /dev/null +++ b/system/HTTP/CURLRequest.php @@ -0,0 +1,702 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\HTTP\Exceptions\HTTPException; +use Config\App; +use Config\CURLRequest as ConfigCURLRequest; +use InvalidArgumentException; + +/** + * A lightweight HTTP client for sending synchronous HTTP requests via cURL. + * + * @see \CodeIgniter\HTTP\CURLRequestTest + */ +class CURLRequest extends OutgoingRequest +{ + /** + * The response object associated with this request + * + * @var ResponseInterface|null + */ + protected $response; + + /** + * The original response object associated with this request + * + * @var ResponseInterface|null + */ + protected $responseOrig; + + /** + * The URI associated with this request + * + * @var URI + */ + protected $baseURI; + + /** + * The setting values + * + * @var array + */ + protected $config; + + /** + * The default setting values + * + * @var array + */ + protected $defaultConfig = [ + 'timeout' => 0.0, + 'connect_timeout' => 150, + 'debug' => false, + 'verify' => true, + ]; + + /** + * Default values for when 'allow_redirects' + * option is true. + * + * @var array + */ + protected $redirectDefaults = [ + 'max' => 5, + 'strict' => true, + 'protocols' => [ + 'http', + 'https', + ], + ]; + + /** + * The number of milliseconds to delay before + * sending the request. + * + * @var float + */ + protected $delay = 0.0; + + /** + * The default options from the constructor. Applied to all requests. + */ + private array $defaultOptions; + + /** + * Whether share options between requests or not. + * + * If true, all the options won't be reset between requests. + * It may cause an error request with unnecessary headers. + */ + private bool $shareOptions; + + /** + * Takes an array of options to set the following possible class properties: + * + * - baseURI + * - timeout + * - any other request options to use as defaults. + */ + public function __construct(App $config, URI $uri, ?ResponseInterface $response = null, array $options = []) + { + if (! function_exists('curl_version')) { + throw HTTPException::forMissingCurl(); // @codeCoverageIgnore + } + + parent::__construct('GET', $uri); + + $this->responseOrig = $response ?? new Response(config(App::class)); + $this->baseURI = $uri->useRawQueryString(); + $this->defaultOptions = $options; + + /** @var ConfigCURLRequest|null $configCURLRequest */ + $configCURLRequest = config(ConfigCURLRequest::class); + $this->shareOptions = $configCURLRequest->shareOptions ?? true; + + $this->config = $this->defaultConfig; + $this->parseOptions($options); + } + + /** + * Sends an HTTP request to the specified $url. If this is a relative + * URL, it will be merged with $this->baseURI to form a complete URL. + * + * @param string $method + */ + public function request($method, string $url, array $options = []): ResponseInterface + { + $this->response = clone $this->responseOrig; + + $this->parseOptions($options); + + $url = $this->prepareURL($url); + + $method = esc(strip_tags($method)); + + $this->send($method, $url); + + if ($this->shareOptions === false) { + $this->resetOptions(); + } + + return $this->response; + } + + /** + * Reset all options to default. + * + * @return void + */ + protected function resetOptions() + { + // Reset headers + $this->headers = []; + $this->headerMap = []; + + // Reset body + $this->body = null; + + // Reset configs + $this->config = $this->defaultConfig; + + // Set the default options for next request + $this->parseOptions($this->defaultOptions); + } + + /** + * Convenience method for sending a GET request. + */ + public function get(string $url, array $options = []): ResponseInterface + { + return $this->request('get', $url, $options); + } + + /** + * Convenience method for sending a DELETE request. + */ + public function delete(string $url, array $options = []): ResponseInterface + { + return $this->request('delete', $url, $options); + } + + /** + * Convenience method for sending a HEAD request. + */ + public function head(string $url, array $options = []): ResponseInterface + { + return $this->request('head', $url, $options); + } + + /** + * Convenience method for sending an OPTIONS request. + */ + public function options(string $url, array $options = []): ResponseInterface + { + return $this->request('options', $url, $options); + } + + /** + * Convenience method for sending a PATCH request. + */ + public function patch(string $url, array $options = []): ResponseInterface + { + return $this->request('patch', $url, $options); + } + + /** + * Convenience method for sending a POST request. + */ + public function post(string $url, array $options = []): ResponseInterface + { + return $this->request('post', $url, $options); + } + + /** + * Convenience method for sending a PUT request. + */ + public function put(string $url, array $options = []): ResponseInterface + { + return $this->request('put', $url, $options); + } + + /** + * Set the HTTP Authentication. + * + * @param string $type basic or digest + * + * @return $this + */ + public function setAuth(string $username, string $password, string $type = 'basic') + { + $this->config['auth'] = [ + $username, + $password, + $type, + ]; + + return $this; + } + + /** + * Set form data to be sent. + * + * @param bool $multipart Set TRUE if you are sending CURLFiles + * + * @return $this + */ + public function setForm(array $params, bool $multipart = false) + { + if ($multipart) { + $this->config['multipart'] = $params; + } else { + $this->config['form_params'] = $params; + } + + return $this; + } + + /** + * Set JSON data to be sent. + * + * @param array|bool|float|int|object|string|null $data + * + * @return $this + */ + public function setJSON($data) + { + $this->config['json'] = $data; + + return $this; + } + + /** + * Sets the correct settings based on the options array + * passed in. + * + * @return void + */ + protected function parseOptions(array $options) + { + if (array_key_exists('baseURI', $options)) { + $this->baseURI = $this->baseURI->setURI($options['baseURI']); + unset($options['baseURI']); + } + + if (array_key_exists('headers', $options) && is_array($options['headers'])) { + foreach ($options['headers'] as $name => $value) { + $this->setHeader($name, $value); + } + + unset($options['headers']); + } + + if (array_key_exists('delay', $options)) { + // Convert from the milliseconds passed in + // to the seconds that sleep requires. + $this->delay = (float) $options['delay'] / 1000; + unset($options['delay']); + } + + if (array_key_exists('body', $options)) { + $this->setBody($options['body']); + unset($options['body']); + } + + foreach ($options as $key => $value) { + $this->config[$key] = $value; + } + } + + /** + * If the $url is a relative URL, will attempt to create + * a full URL by prepending $this->baseURI to it. + */ + protected function prepareURL(string $url): string + { + // If it's a full URI, then we have nothing to do here... + if (strpos($url, '://') !== false) { + return $url; + } + + $uri = $this->baseURI->resolveRelativeURI($url); + + // Create the string instead of casting to prevent baseURL muddling + return URI::createURIString( + $uri->getScheme(), + $uri->getAuthority(), + $uri->getPath(), + $uri->getQuery(), + $uri->getFragment() + ); + } + + /** + * Get the request method. Overrides the Request class' method + * since users expect a different answer here. + * + * @param bool|false $upper Whether to return in upper or lower case. + */ + public function getMethod(bool $upper = false): string + { + return ($upper) ? strtoupper($this->method) : strtolower($this->method); + } + + /** + * Fires the actual cURL request. + * + * @return ResponseInterface + */ + public function send(string $method, string $url) + { + // Reset our curl options so we're on a fresh slate. + $curlOptions = []; + + if (! empty($this->config['query']) && is_array($this->config['query'])) { + // This is likely too naive a solution. + // Should look into handling when $url already + // has query vars on it. + $url .= '?' . http_build_query($this->config['query']); + unset($this->config['query']); + } + + $curlOptions[CURLOPT_URL] = $url; + $curlOptions[CURLOPT_RETURNTRANSFER] = true; + $curlOptions[CURLOPT_HEADER] = true; + $curlOptions[CURLOPT_FRESH_CONNECT] = true; + // Disable @file uploads in post data. + $curlOptions[CURLOPT_SAFE_UPLOAD] = true; + + $curlOptions = $this->setCURLOptions($curlOptions, $this->config); + $curlOptions = $this->applyMethod($method, $curlOptions); + $curlOptions = $this->applyRequestHeaders($curlOptions); + + // Do we need to delay this request? + if ($this->delay > 0) { + usleep((int) $this->delay * 1_000_000); + } + + $output = $this->sendRequest($curlOptions); + + // Set the string we want to break our response from + $breakString = "\r\n\r\n"; + + if (strpos($output, 'HTTP/1.1 100 Continue') === 0) { + $output = substr($output, strpos($output, $breakString) + 4); + } + + if (strpos($output, 'HTTP/1.1 200 Connection established') === 0) { + $output = substr($output, strpos($output, $breakString) + 4); + } + + // If request and response have Digest + if (isset($this->config['auth'][2]) && $this->config['auth'][2] === 'digest' && strpos($output, 'WWW-Authenticate: Digest') !== false) { + $output = substr($output, strpos($output, $breakString) + 4); + } + + // Split out our headers and body + $break = strpos($output, $breakString); + + if ($break !== false) { + // Our headers + $headers = explode("\n", substr($output, 0, $break)); + + $this->setResponseHeaders($headers); + + // Our body + $body = substr($output, $break + 4); + $this->response->setBody($body); + } else { + $this->response->setBody($output); + } + + return $this->response; + } + + /** + * Adds $this->headers to the cURL request. + */ + protected function applyRequestHeaders(array $curlOptions = []): array + { + if (empty($this->headers)) { + return $curlOptions; + } + + $set = []; + + foreach (array_keys($this->headers) as $name) { + $set[] = $name . ': ' . $this->getHeaderLine($name); + } + + $curlOptions[CURLOPT_HTTPHEADER] = $set; + + return $curlOptions; + } + + /** + * Apply method + */ + protected function applyMethod(string $method, array $curlOptions): array + { + $method = strtoupper($method); + + $this->method = $method; + $curlOptions[CURLOPT_CUSTOMREQUEST] = $method; + + $size = strlen($this->body ?? ''); + + // Have content? + if ($size > 0) { + return $this->applyBody($curlOptions); + } + + if ($method === 'PUT' || $method === 'POST') { + // See http://tools.ietf.org/html/rfc7230#section-3.3.2 + if ($this->header('content-length') === null && ! isset($this->config['multipart'])) { + $this->setHeader('Content-Length', '0'); + } + } elseif ($method === 'HEAD') { + $curlOptions[CURLOPT_NOBODY] = 1; + } + + return $curlOptions; + } + + /** + * Apply body + */ + protected function applyBody(array $curlOptions = []): array + { + if (! empty($this->body)) { + $curlOptions[CURLOPT_POSTFIELDS] = (string) $this->getBody(); + } + + return $curlOptions; + } + + /** + * Parses the header retrieved from the cURL response into + * our Response object. + * + * @return void + */ + protected function setResponseHeaders(array $headers = []) + { + foreach ($headers as $header) { + if (($pos = strpos($header, ':')) !== false) { + $title = substr($header, 0, $pos); + $value = substr($header, $pos + 1); + + $this->response->setHeader($title, $value); + } elseif (strpos($header, 'HTTP') === 0) { + preg_match('#^HTTP\/([12](?:\.[01])?) (\d+) (.+)#', $header, $matches); + + if (isset($matches[1])) { + $this->response->setProtocolVersion($matches[1]); + } + + if (isset($matches[2])) { + $this->response->setStatusCode((int) $matches[2], $matches[3] ?? null); + } + } + } + } + + /** + * Set CURL options + * + * @return array + * + * @throws InvalidArgumentException + */ + protected function setCURLOptions(array $curlOptions = [], array $config = []) + { + // Auth Headers + if (! empty($config['auth'])) { + $curlOptions[CURLOPT_USERPWD] = $config['auth'][0] . ':' . $config['auth'][1]; + + if (! empty($config['auth'][2]) && strtolower($config['auth'][2]) === 'digest') { + $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_DIGEST; + } else { + $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC; + } + } + + // Certificate + if (! empty($config['cert'])) { + $cert = $config['cert']; + + if (is_array($cert)) { + $curlOptions[CURLOPT_SSLCERTPASSWD] = $cert[1]; + $cert = $cert[0]; + } + + if (! is_file($cert)) { + throw HTTPException::forSSLCertNotFound($cert); + } + + $curlOptions[CURLOPT_SSLCERT] = $cert; + } + + // SSL Verification + if (isset($config['verify'])) { + if (is_string($config['verify'])) { + $file = realpath($config['verify']) ?: $config['verify']; + + if (! is_file($file)) { + throw HTTPException::forInvalidSSLKey($config['verify']); + } + + $curlOptions[CURLOPT_CAINFO] = $file; + $curlOptions[CURLOPT_SSL_VERIFYPEER] = true; + $curlOptions[CURLOPT_SSL_VERIFYHOST] = 2; + } elseif (is_bool($config['verify'])) { + $curlOptions[CURLOPT_SSL_VERIFYPEER] = $config['verify']; + $curlOptions[CURLOPT_SSL_VERIFYHOST] = $config['verify'] ? 2 : 0; + } + } + + // Proxy + if (isset($config['proxy'])) { + $curlOptions[CURLOPT_HTTPPROXYTUNNEL] = true; + $curlOptions[CURLOPT_PROXY] = $config['proxy']; + } + + // Debug + if ($config['debug']) { + $curlOptions[CURLOPT_VERBOSE] = 1; + $curlOptions[CURLOPT_STDERR] = is_string($config['debug']) ? fopen($config['debug'], 'a+b') : fopen('php://stderr', 'wb'); + } + + // Decode Content + if (! empty($config['decode_content'])) { + $accept = $this->getHeaderLine('Accept-Encoding'); + + if ($accept !== '') { + $curlOptions[CURLOPT_ENCODING] = $accept; + } else { + $curlOptions[CURLOPT_ENCODING] = ''; + $curlOptions[CURLOPT_HTTPHEADER] = 'Accept-Encoding'; + } + } + + // Allow Redirects + if (array_key_exists('allow_redirects', $config)) { + $settings = $this->redirectDefaults; + + if (is_array($config['allow_redirects'])) { + $settings = array_merge($settings, $config['allow_redirects']); + } + + if ($config['allow_redirects'] === false) { + $curlOptions[CURLOPT_FOLLOWLOCATION] = 0; + } else { + $curlOptions[CURLOPT_FOLLOWLOCATION] = 1; + $curlOptions[CURLOPT_MAXREDIRS] = $settings['max']; + + if ($settings['strict'] === true) { + $curlOptions[CURLOPT_POSTREDIR] = 1 | 2 | 4; + } + + $protocols = 0; + + foreach ($settings['protocols'] as $proto) { + $protocols += constant('CURLPROTO_' . strtoupper($proto)); + } + + $curlOptions[CURLOPT_REDIR_PROTOCOLS] = $protocols; + } + } + + // Timeout + $curlOptions[CURLOPT_TIMEOUT_MS] = (float) $config['timeout'] * 1000; + + // Connection Timeout + $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = (float) $config['connect_timeout'] * 1000; + + // Post Data - application/x-www-form-urlencoded + if (! empty($config['form_params']) && is_array($config['form_params'])) { + $postFields = http_build_query($config['form_params']); + $curlOptions[CURLOPT_POSTFIELDS] = $postFields; + + // Ensure content-length is set, since CURL doesn't seem to + // calculate it when HTTPHEADER is set. + $this->setHeader('Content-Length', (string) strlen($postFields)); + $this->setHeader('Content-Type', 'application/x-www-form-urlencoded'); + } + + // Post Data - multipart/form-data + if (! empty($config['multipart']) && is_array($config['multipart'])) { + // setting the POSTFIELDS option automatically sets multipart + $curlOptions[CURLOPT_POSTFIELDS] = $config['multipart']; + } + + // HTTP Errors + $curlOptions[CURLOPT_FAILONERROR] = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true; + + // JSON + if (isset($config['json'])) { + // Will be set as the body in `applyBody()` + $json = json_encode($config['json']); + $this->setBody($json); + $this->setHeader('Content-Type', 'application/json'); + $this->setHeader('Content-Length', (string) strlen($json)); + } + + // version + if (! empty($config['version'])) { + if ($config['version'] === 1.0) { + $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0; + } elseif ($config['version'] === 1.1) { + $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1; + } elseif ($config['version'] === 2.0) { + $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0; + } + } + + // Cookie + if (isset($config['cookie'])) { + $curlOptions[CURLOPT_COOKIEJAR] = $config['cookie']; + $curlOptions[CURLOPT_COOKIEFILE] = $config['cookie']; + } + + // User Agent + if (isset($config['user_agent'])) { + $curlOptions[CURLOPT_USERAGENT] = $config['user_agent']; + } + + return $curlOptions; + } + + /** + * Does the actual work of initializing cURL, setting the options, + * and grabbing the output. + * + * @codeCoverageIgnore + */ + protected function sendRequest(array $curlOptions = []): string + { + $ch = curl_init(); + + curl_setopt_array($ch, $curlOptions); + + // Send the request and wait for a response. + $output = curl_exec($ch); + + if ($output === false) { + throw HTTPException::forCurlError((string) curl_errno($ch), curl_error($ch)); + } + + curl_close($ch); + + return $output; + } +} diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php new file mode 100644 index 0000000..f4970bf --- /dev/null +++ b/system/HTTP/ContentSecurityPolicy.php @@ -0,0 +1,817 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use Config\App; +use Config\ContentSecurityPolicy as ContentSecurityPolicyConfig; + +/** + * Provides tools for working with the Content-Security-Policy header + * to help defeat XSS attacks. + * + * @see http://www.w3.org/TR/CSP/ + * @see http://www.html5rocks.com/en/tutorials/security/content-security-policy/ + * @see http://content-security-policy.com/ + * @see https://www.owasp.org/index.php/Content_Security_Policy + * @see \CodeIgniter\HTTP\ContentSecurityPolicyTest + */ +class ContentSecurityPolicy +{ + /** + * Used for security enforcement + * + * @var array|string + */ + protected $baseURI = []; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $childSrc = []; + + /** + * Used for security enforcement + * + * @var array + */ + protected $connectSrc = []; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $defaultSrc = []; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $fontSrc = []; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $formAction = []; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $frameAncestors = []; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $frameSrc = []; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $imageSrc = []; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $mediaSrc = []; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $objectSrc = []; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $pluginTypes = []; + + /** + * Used for security enforcement + * + * @var string + */ + protected $reportURI; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $sandbox = []; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $scriptSrc = []; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $styleSrc = []; + + /** + * Used for security enforcement + * + * @var array|string + */ + protected $manifestSrc = []; + + /** + * Used for security enforcement + * + * @var bool + */ + protected $upgradeInsecureRequests = false; + + /** + * Used for security enforcement + * + * @var bool + */ + protected $reportOnly = false; + + /** + * Used for security enforcement + * + * @var array + */ + protected $validSources = [ + 'self', + 'none', + 'unsafe-inline', + 'unsafe-eval', + ]; + + /** + * Used for security enforcement + * + * @var array + */ + protected $nonces = []; + + /** + * Nonce for style + * + * @var string + */ + protected $styleNonce; + + /** + * Nonce for script + * + * @var string + */ + protected $scriptNonce; + + /** + * Nonce tag for style + * + * @var string + */ + protected $styleNonceTag = '{csp-style-nonce}'; + + /** + * Nonce tag for script + * + * @var string + */ + protected $scriptNonceTag = '{csp-script-nonce}'; + + /** + * Replace nonce tag automatically + * + * @var bool + */ + protected $autoNonce = true; + + /** + * An array of header info since we have + * to build ourself before passing to Response. + * + * @var array + */ + protected $tempHeaders = []; + + /** + * An array of header info to build + * that should only be reported. + * + * @var array + */ + protected $reportOnlyHeaders = []; + + /** + * Whether Content Security Policy is being enforced. + * + * @var bool + */ + protected $CSPEnabled = false; + + /** + * Constructor. + * + * Stores our default values from the Config file. + */ + public function __construct(ContentSecurityPolicyConfig $config) + { + $appConfig = config(App::class); + $this->CSPEnabled = $appConfig->CSPEnabled; + + foreach (get_object_vars($config) as $setting => $value) { + if (property_exists($this, $setting)) { + $this->{$setting} = $value; + } + } + + if (! is_array($this->styleSrc)) { + $this->styleSrc = [$this->styleSrc]; + } + + if (! is_array($this->scriptSrc)) { + $this->scriptSrc = [$this->scriptSrc]; + } + } + + /** + * Whether Content Security Policy is being enforced. + */ + public function enabled(): bool + { + return $this->CSPEnabled; + } + + /** + * Get the nonce for the style tag. + */ + public function getStyleNonce(): string + { + if ($this->styleNonce === null) { + $this->styleNonce = bin2hex(random_bytes(12)); + $this->styleSrc[] = 'nonce-' . $this->styleNonce; + } + + return $this->styleNonce; + } + + /** + * Get the nonce for the script tag. + */ + public function getScriptNonce(): string + { + if ($this->scriptNonce === null) { + $this->scriptNonce = bin2hex(random_bytes(12)); + $this->scriptSrc[] = 'nonce-' . $this->scriptNonce; + } + + return $this->scriptNonce; + } + + /** + * Compiles and sets the appropriate headers in the request. + * + * Should be called just prior to sending the response to the user agent. + * + * @return void + */ + public function finalize(ResponseInterface $response) + { + if ($this->autoNonce) { + $this->generateNonces($response); + } + + $this->buildHeaders($response); + } + + /** + * If TRUE, nothing will be restricted. Instead all violations will + * be reported to the reportURI for monitoring. This is useful when + * you are just starting to implement the policy, and will help + * determine what errors need to be addressed before you turn on + * all filtering. + * + * @return $this + */ + public function reportOnly(bool $value = true) + { + $this->reportOnly = $value; + + return $this; + } + + /** + * Adds a new base_uri value. Can be either a URI class or a simple string. + * + * base_uri restricts the URLs that can appear in a page's element. + * + * @see http://www.w3.org/TR/CSP/#directive-base-uri + * + * @param array|string $uri + * + * @return $this + */ + public function addBaseURI($uri, ?bool $explicitReporting = null) + { + $this->addOption($uri, 'baseURI', $explicitReporting ?? $this->reportOnly); + + return $this; + } + + /** + * Adds a new valid endpoint for a form's action. Can be either + * a URI class or a simple string. + * + * child-src lists the URLs for workers and embedded frame contents. + * For example: child-src https://youtube.com would enable embedding + * videos from YouTube but not from other origins. + * + * @see http://www.w3.org/TR/CSP/#directive-child-src + * + * @param array|string $uri + * + * @return $this + */ + public function addChildSrc($uri, ?bool $explicitReporting = null) + { + $this->addOption($uri, 'childSrc', $explicitReporting ?? $this->reportOnly); + + return $this; + } + + /** + * Adds a new valid endpoint for a form's action. Can be either + * a URI class or a simple string. + * + * connect-src limits the origins to which you can connect + * (via XHR, WebSockets, and EventSource). + * + * @see http://www.w3.org/TR/CSP/#directive-connect-src + * + * @param array|string $uri + * + * @return $this + */ + public function addConnectSrc($uri, ?bool $explicitReporting = null) + { + $this->addOption($uri, 'connectSrc', $explicitReporting ?? $this->reportOnly); + + return $this; + } + + /** + * Adds a new valid endpoint for a form's action. Can be either + * a URI class or a simple string. + * + * default_src is the URI that is used for many of the settings when + * no other source has been set. + * + * @see http://www.w3.org/TR/CSP/#directive-default-src + * + * @param array|string $uri + * + * @return $this + */ + public function setDefaultSrc($uri, ?bool $explicitReporting = null) + { + $this->defaultSrc = [(string) $uri => $explicitReporting ?? $this->reportOnly]; + + return $this; + } + + /** + * Adds a new valid endpoint for a form's action. Can be either + * a URI class or a simple string. + * + * font-src specifies the origins that can serve web fonts. + * + * @see http://www.w3.org/TR/CSP/#directive-font-src + * + * @param array|string $uri + * + * @return $this + */ + public function addFontSrc($uri, ?bool $explicitReporting = null) + { + $this->addOption($uri, 'fontSrc', $explicitReporting ?? $this->reportOnly); + + return $this; + } + + /** + * Adds a new valid endpoint for a form's action. Can be either + * a URI class or a simple string. + * + * @see http://www.w3.org/TR/CSP/#directive-form-action + * + * @param array|string $uri + * + * @return $this + */ + public function addFormAction($uri, ?bool $explicitReporting = null) + { + $this->addOption($uri, 'formAction', $explicitReporting ?? $this->reportOnly); + + return $this; + } + + /** + * Adds a new resource that should allow embedding the resource using + * ,