| @@ -0,0 +1,13 @@ | |||
| APP_ENV= | |||
| APP_HOST= | |||
| APP_PORT= | |||
| DB_CONNECTION=mysql | |||
| DB_HOST=127.0.0.1 | |||
| DB_PORT=3306 | |||
| DB_DATABASE=pro-php-mvc | |||
| DB_USERNAME=root | |||
| DB_PASSWORD= | |||
| EMAIL_TOKEN= | |||
| EMAIL_FROM_NAME= | |||
| EMAIL_FROM_EMAIL= | |||
| @@ -0,0 +1,5 @@ | |||
| .env | |||
| database/*.sqlite | |||
| drivers/* | |||
| vendor | |||
| *.cache | |||
| @@ -0,0 +1,23 @@ | |||
| FROM php:8.2-fpm-bookworm | |||
| RUN apt-get update \ | |||
| && apt-get install -y --no-install-recommends nginx git unzip libsqlite3-dev \ | |||
| && docker-php-ext-install pdo_mysql pdo_sqlite \ | |||
| && rm -rf /var/lib/apt/lists/* \ | |||
| && rm -f /etc/nginx/conf.d/default.conf /etc/nginx/sites-enabled/default | |||
| COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer | |||
| WORKDIR /var/www/html | |||
| COPY . /var/www/html | |||
| COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf | |||
| COPY docker/php/app.ini /usr/local/etc/php/conf.d/app.ini | |||
| COPY docker/start-container.sh /usr/local/bin/start-container | |||
| RUN chmod +x /usr/local/bin/start-container \ | |||
| && mkdir -p /run/php | |||
| EXPOSE 80 | |||
| CMD ["start-container"] | |||
| @@ -0,0 +1,16 @@ | |||
| <?php | |||
| namespace App\Exceptions; | |||
| use Framework\Support\ExceptionHandler; | |||
| use Throwable; | |||
| class Handler extends ExceptionHandler | |||
| { | |||
| public function showThrowable(Throwable $throwable) | |||
| { | |||
| // add in some reporting... | |||
| return parent::showThrowable($throwable); | |||
| } | |||
| } | |||
| @@ -0,0 +1,19 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Products; | |||
| use Framework\Routing\Router; | |||
| class OrderProductController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| secure(); | |||
| // use $data to create a database record... | |||
| $_SESSION['ordered'] = true; | |||
| return redirect($router->route('show-home-page')); | |||
| } | |||
| } | |||
| @@ -0,0 +1,22 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Products; | |||
| use App\Models\Product; | |||
| use Framework\Routing\Router; | |||
| class ShowProductController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| $parameters = $router->current()->parameters(); | |||
| $product = Product::find((int) $parameters['product']); | |||
| return view('products/view', [ | |||
| 'product' => $product, | |||
| 'orderAction' => $router->route('order-product', ['product' => $product->id]), | |||
| 'csrf' => csrf(), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,46 @@ | |||
| <?php | |||
| namespace App\Http\Controllers; | |||
| use App\Models\Product; | |||
| use Framework\Routing\Router; | |||
| class ShowHomePageController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| $cache = app('cache'); | |||
| $products = Product::all(); | |||
| $productsWithRoutes = array_map(function ($product) use ($router, $cache) { | |||
| $key = "route-for-product-{$product->id}"; | |||
| if (!$cache->has($key)) { | |||
| $cache->put($key, $router->route('view-product', ['product' => $product->id])); | |||
| } | |||
| $product->route = $cache->get($key); | |||
| return $product; | |||
| }, $products); | |||
| // app('queue')->push( | |||
| // fn($name) => app('logging')->info("Hello {$name}"), | |||
| // 'Chris', | |||
| // ); | |||
| // app('logging')->info('Send a task into the background'); | |||
| // app('queue')->push( | |||
| // fn($name) => app('email') | |||
| // ->to('cgpitt@gmail.com') | |||
| // ->text("Hello {$name}") | |||
| // ->send(), | |||
| // 'Chris', | |||
| // ); | |||
| return view('home', [ | |||
| 'products' => $productsWithRoutes, | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Users; | |||
| use App\Models\User; | |||
| use Framework\Routing\Router; | |||
| class LogInUserController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| secure(); | |||
| $data = validate($_POST, [ | |||
| 'email' => ['required', 'email'], | |||
| 'password' => ['required', 'min:10'], | |||
| ], 'login_errors'); | |||
| $user = User::where('email', $data['email'])->first(); | |||
| if ($user && password_verify($data['password'], $user->password)) { | |||
| session()->put('user_id', $user->id); | |||
| } | |||
| return redirect($router->route('show-home-page')); | |||
| } | |||
| } | |||
| @@ -0,0 +1,15 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Users; | |||
| use Framework\Routing\Router; | |||
| class LogOutUserController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| session()->forget('user_id'); | |||
| return redirect($router->route('show-home-page')); | |||
| } | |||
| } | |||
| @@ -0,0 +1,34 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Users; | |||
| use App\Models\User; | |||
| use Framework\Routing\Router; | |||
| class RegisterUserController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| secure(); | |||
| $data = validate($_POST, [ | |||
| 'name' => ['required'], | |||
| 'email' => ['required', 'email'], | |||
| 'password' => ['required', 'min:10'], | |||
| ], 'register_errors'); | |||
| $user = new User(); | |||
| $user->name = $data['name']; | |||
| $user->email = $data['email']; | |||
| $user->password = password_hash($data['password'], PASSWORD_BCRYPT); | |||
| $user->save(); | |||
| session()->put('registered', true); | |||
| app('queue')->push(function($user) { | |||
| // send a mail to the user... | |||
| }, $user); | |||
| return redirect($router->route('show-home-page')); | |||
| } | |||
| } | |||
| @@ -0,0 +1,17 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Users; | |||
| use Framework\Routing\Router; | |||
| class ShowRegisterFormController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| return view('users/register', [ | |||
| 'registerAction' => $router->route('register-user'), | |||
| 'logInAction' => $router->route('log-in-user'), | |||
| 'csrf' => csrf(), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,15 @@ | |||
| <?php | |||
| namespace App\Models; | |||
| use Framework\Database\Model; | |||
| class Order extends Model | |||
| { | |||
| protected string $table = 'orders'; | |||
| public function user(): mixed | |||
| { | |||
| return $this->belongsTo(User::class, 'user_id'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| <?php | |||
| namespace App\Models; | |||
| use Framework\Database\Model; | |||
| class Product extends Model | |||
| { | |||
| protected string $table = 'products'; | |||
| public function getNameAttribute($value): string | |||
| { | |||
| return ucwords($value); | |||
| } | |||
| public function setDescriptionAttribute(string $value) | |||
| { | |||
| $limit = 50; | |||
| $ending = '...'; | |||
| if (mb_strwidth($value, 'UTF-8') <= $limit) { | |||
| return $value; | |||
| } | |||
| return rtrim(mb_strimwidth($value, 0, $limit, '', 'UTF-8')) . $ending; | |||
| } | |||
| } | |||
| @@ -0,0 +1,15 @@ | |||
| <?php | |||
| namespace App\Models; | |||
| use Framework\Database\Model; | |||
| class Profile extends Model | |||
| { | |||
| protected string $table = 'profiles'; | |||
| public function user(): mixed | |||
| { | |||
| return $this->belongsTo(User::class, 'user_id'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,20 @@ | |||
| <?php | |||
| namespace App\Models; | |||
| use Framework\Database\TableName; | |||
| use Framework\Database\Model; | |||
| #[TableName('users')] | |||
| class User extends Model | |||
| { | |||
| public function profile(): mixed | |||
| { | |||
| return $this->hasOne(Profile::class, 'user_id'); | |||
| } | |||
| public function orders(): mixed | |||
| { | |||
| return $this->hasMany(Order::class, 'user_id'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,11 @@ | |||
| <?php | |||
| use Framework\Database\Command\MigrateCommand; | |||
| use Framework\Support\Command\ServeCommand; | |||
| use Framework\Queue\Command\WorkCommand; | |||
| return [ | |||
| MigrateCommand::class, | |||
| ServeCommand::class, | |||
| WorkCommand::class, | |||
| ]; | |||
| @@ -0,0 +1,51 @@ | |||
| <?php | |||
| use App\Http\Controllers\ShowHomePageController; | |||
| use App\Http\Controllers\Products\OrderProductController; | |||
| use App\Http\Controllers\Products\ShowProductController; | |||
| use App\Http\Controllers\Users\LogInUserController; | |||
| use App\Http\Controllers\Users\LogOutUserController; | |||
| use App\Http\Controllers\Users\RegisterUserController; | |||
| use App\Http\Controllers\Users\ShowRegisterFormController; | |||
| use Framework\Routing\Router; | |||
| return function(Router $router) { | |||
| $router->errorHandler( | |||
| 404, fn() => 'whoops!' | |||
| ); | |||
| $router->add( | |||
| 'GET', '/', | |||
| [ShowHomePageController::class, 'handle'], | |||
| )->name('show-home-page'); | |||
| $router->add( | |||
| 'GET', '/products/view/{product}', | |||
| [ShowProductController::class, 'handle'], | |||
| )->name('view-product'); | |||
| $router->add( | |||
| 'POST', '/products/order/{product}', | |||
| [OrderProductController::class, 'handle'], | |||
| )->name('order-product'); | |||
| $router->add( | |||
| 'GET', '/register', | |||
| [ShowRegisterFormController::class, 'handle'], | |||
| )->name('show-register-form'); | |||
| $router->add( | |||
| 'POST', '/register', | |||
| [RegisterUserController::class, 'handle'], | |||
| )->name('register-user'); | |||
| $router->add( | |||
| 'POST', '/log-in', | |||
| [LogInUserController::class, 'handle'], | |||
| )->name('log-in-user'); | |||
| $router->add( | |||
| 'GET', '/log-out', | |||
| [LogOutUserController::class, 'handle'], | |||
| )->name('log-out-user'); | |||
| }; | |||
| @@ -0,0 +1,17 @@ | |||
| <?php | |||
| require __DIR__ . '/vendor/autoload.php'; | |||
| $app = \Framework\App::getInstance(); | |||
| $app->bind('paths.base', fn() => __DIR__); | |||
| $app->prepare(); | |||
| $console = new \Symfony\Component\Console\Application(); | |||
| $commands = require __DIR__ . '/app/commands.php'; | |||
| foreach ($commands as $command) { | |||
| $console->add(new $command); | |||
| } | |||
| $console->run(); | |||
| @@ -0,0 +1,36 @@ | |||
| { | |||
| "name": "whoosh/website", | |||
| "description": "A website that sells Whoosh rockets", | |||
| "scripts": { | |||
| "serve": "php command.php serve", | |||
| "test": "vendor/bin/phpunit" | |||
| }, | |||
| "autoload": { | |||
| "psr-4": { | |||
| "App\\": "app", | |||
| "Framework\\": "framework" | |||
| }, | |||
| "files": [ | |||
| "framework/helpers.php" | |||
| ] | |||
| }, | |||
| "config": { | |||
| "process-timeout": 0 | |||
| }, | |||
| "require": { | |||
| "filp/whoops": "^2.1", | |||
| "vlucas/phpdotenv": "^5.2", | |||
| "symfony/console": "^5.1", | |||
| "league/flysystem": "^2.0", | |||
| "opis/closure": "^3.6", | |||
| "monolog/monolog": "^2.2", | |||
| "swiftmailer/swiftmailer": "^6.2", | |||
| "wildbit/swiftmailer-postmark": "^3.3" | |||
| }, | |||
| "require-dev": { | |||
| "phpunit/phpunit": "^9.5", | |||
| "symfony/panther": "^0.9.0", | |||
| "dbrekelmans/bdi": "^0.3.0", | |||
| "symfony/process": "^5.2" | |||
| } | |||
| } | |||
| @@ -0,0 +1,19 @@ | |||
| <?php | |||
| return [ | |||
| 'default' => 'memory', | |||
| 'memory' => [ | |||
| 'type' => 'memory', | |||
| 'seconds' => 31536000, | |||
| ], | |||
| 'file' => [ | |||
| 'type' => 'file', | |||
| 'seconds' => 31536000, | |||
| ], | |||
| 'memcache' => [ | |||
| 'type' => 'memcache', | |||
| 'host' => '127.0.0.1', | |||
| 'port' => 11211, | |||
| 'seconds' => 31536000, | |||
| ], | |||
| ]; | |||
| @@ -0,0 +1,17 @@ | |||
| <?php | |||
| return [ | |||
| 'default' => env('DB_CONNECTION', 'mysql'), | |||
| 'mysql' => [ | |||
| 'type' => 'mysql', | |||
| 'host' => env('DB_HOST', '127.0.0.1'), | |||
| 'port' => env('DB_PORT', '3306'), | |||
| 'database' => env('DB_DATABASE', 'pro-php-mvc'), | |||
| 'username' => env('DB_USERNAME', 'root'), | |||
| 'password' => env('DB_PASSWORD', ''), | |||
| ], | |||
| 'sqlite' => [ | |||
| 'type' => 'sqlite', | |||
| 'path' => env('DB_SQLITE_PATH', __DIR__ . '/../database/database.sqlite'), | |||
| ], | |||
| ]; | |||
| @@ -0,0 +1,13 @@ | |||
| <?php | |||
| return [ | |||
| 'default' => 'postmark', | |||
| 'postmark' => [ | |||
| 'type' => 'postmark', | |||
| 'token' => env('EMAIL_TOKEN'), | |||
| 'from' => [ | |||
| 'name' => env('EMAIL_FROM_NAME'), | |||
| 'email' => env('EMAIL_FROM_EMAIL'), | |||
| ], | |||
| ] | |||
| ]; | |||
| @@ -0,0 +1,24 @@ | |||
| <?php | |||
| return [ | |||
| 'default' => 'local', | |||
| 'local' => [ | |||
| 'type' => 'local', | |||
| 'path' => __DIR__ . '/../storage/app', | |||
| ], | |||
| 's3' => [ | |||
| 'type' => 's3', | |||
| 'key' => '', | |||
| 'secret' => '', | |||
| 'token' => '', | |||
| 'region' => '', | |||
| 'bucket' => '', | |||
| ], | |||
| 'ftp' => [ | |||
| 'type' => 'ftp', | |||
| 'host' => '', | |||
| 'root' => '', | |||
| 'username' => '', | |||
| 'password' => '', | |||
| ], | |||
| ]; | |||
| @@ -0,0 +1,5 @@ | |||
| <?php | |||
| return [ | |||
| 'exceptions' => \App\Exceptions\Handler::class, | |||
| ]; | |||
| @@ -0,0 +1,11 @@ | |||
| <?php | |||
| return [ | |||
| 'default' => 'stream', | |||
| 'stream' => [ | |||
| 'type' => 'stream', | |||
| 'path' => __DIR__ . '/../storage/app.log', | |||
| 'name' => 'App', | |||
| 'minimum' => \Monolog\Logger::DEBUG, | |||
| ], | |||
| ]; | |||
| @@ -0,0 +1,17 @@ | |||
| <?php | |||
| return [ | |||
| // load config first, so the rest can use it... | |||
| \Framework\Provider\ConfigProvider::class, | |||
| \Framework\Provider\CacheProvider::class, | |||
| \Framework\Provider\DatabaseProvider::class, | |||
| \Framework\Provider\EmailProvider::class, | |||
| \Framework\Provider\FilesystemProvider::class, | |||
| \Framework\Provider\LoggingProvider::class, | |||
| \Framework\Provider\QueueProvider::class, | |||
| \Framework\Provider\ResponseProvider::class, | |||
| \Framework\Provider\SessionProvider::class, | |||
| \Framework\Provider\ValidationProvider::class, | |||
| \Framework\Provider\ViewProvider::class, | |||
| ]; | |||
| @@ -0,0 +1,10 @@ | |||
| <?php | |||
| return [ | |||
| 'default' => 'database', | |||
| 'database' => [ | |||
| 'type' => 'database', | |||
| 'table' => 'jobs', | |||
| 'attempts' => 3, | |||
| ], | |||
| ]; | |||
| @@ -0,0 +1,9 @@ | |||
| <?php | |||
| return [ | |||
| 'default' => 'native', | |||
| 'native' => [ | |||
| 'type' => 'native', | |||
| 'prefix' => 'framework_', | |||
| ], | |||
| ]; | |||
| @@ -0,0 +1,18 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class CreateOrdersTable | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->createTable('orders'); | |||
| $table->id('id'); | |||
| $table->int('quantity')->default(1); | |||
| $table->float('price')->nullable(); | |||
| $table->bool('is_confirmed')->default(false); | |||
| $table->dateTime('ordered_at')->default('CURRENT_TIMESTAMP'); | |||
| $table->text('notes'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,13 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class AddDeliveryInstructions | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->alterTable('orders'); | |||
| $table->text('delivery_instructions'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,13 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class ChangeQuantity | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->alterTable('orders'); | |||
| $table->int('quantity')->nullable()->alter(); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,13 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class DropPrice | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->alterTable('orders'); | |||
| $table->dropColumn('price'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,15 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class CreateProductsTable | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->createTable('products'); | |||
| $table->id('id'); | |||
| $table->string('name'); | |||
| $table->text('description'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,31 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class SeedProducts | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $products = [ | |||
| [ | |||
| 'name' => 'Space Tour', | |||
| 'description' => 'Take a trip on a rocket ship. Our tours are out of this world. Sign up now for a journey you won't soon forget.', | |||
| ], | |||
| [ | |||
| 'name' => 'Large Rocket', | |||
| 'description' => 'Need to bring some extra space-baggage? Everyone asking you to bring back a moon rock for them? This is the rocket you want...', | |||
| ], | |||
| [ | |||
| 'name' => 'Small Rocket', | |||
| 'description' => 'Space exploration is expensive. This rocket comes in under budget and atmosphere.', | |||
| ], | |||
| ]; | |||
| foreach ($products as $product) { | |||
| $connection | |||
| ->query() | |||
| ->from('products') | |||
| ->insert(['name', 'description'], $product); | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class CreateUsersTable | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->createTable('users'); | |||
| $table->id('id'); | |||
| $table->string('name'); | |||
| $table->string('email'); | |||
| $table->string('password'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class CreateProfilesTable | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->createTable('profiles'); | |||
| $table->id('id'); | |||
| $table->int('user_id'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,13 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class AddUserId | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->alterTable('orders'); | |||
| $table->int('user_id'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,17 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class CreateJobsTable | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->createTable('jobs'); | |||
| $table->id('id'); | |||
| $table->text('closure'); | |||
| $table->text('params'); | |||
| $table->int('attempts')->default(0); | |||
| $table->bool('is_complete')->default(false); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,41 @@ | |||
| services: | |||
| app: | |||
| build: | |||
| context: . | |||
| dockerfile: Dockerfile | |||
| container_name: pro-php-mvc-app | |||
| restart: unless-stopped | |||
| depends_on: | |||
| - db | |||
| ports: | |||
| - "8080:80" | |||
| volumes: | |||
| - ./:/var/www/html | |||
| - vendor_data:/var/www/html/vendor | |||
| environment: | |||
| APP_ENV: dev | |||
| APP_HOST: 0.0.0.0 | |||
| APP_PORT: 80 | |||
| DB_CONNECTION: mysql | |||
| DB_HOST: db | |||
| DB_PORT: 3306 | |||
| DB_DATABASE: pro-php-mvc | |||
| DB_USERNAME: root | |||
| DB_PASSWORD: root | |||
| COMPOSER_ALLOW_SUPERUSER: 1 | |||
| db: | |||
| image: mysql:8.0 | |||
| container_name: pro-php-mvc-db | |||
| restart: unless-stopped | |||
| ports: | |||
| - "33060:3306" | |||
| environment: | |||
| MYSQL_DATABASE: pro-php-mvc | |||
| MYSQL_ROOT_PASSWORD: root | |||
| volumes: | |||
| - mysql_data:/var/lib/mysql | |||
| volumes: | |||
| mysql_data: | |||
| vendor_data: | |||
| @@ -0,0 +1,23 @@ | |||
| server { | |||
| listen 80; | |||
| server_name _; | |||
| root /var/www/html/public; | |||
| index index.php; | |||
| location / { | |||
| try_files $uri $uri/ /index.php?$query_string; | |||
| } | |||
| location ~ \.php$ { | |||
| include fastcgi_params; | |||
| fastcgi_index index.php; | |||
| fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; | |||
| fastcgi_param DOCUMENT_ROOT $realpath_root; | |||
| fastcgi_pass 127.0.0.1:9000; | |||
| } | |||
| location ~ /\. { | |||
| deny all; | |||
| } | |||
| } | |||
| @@ -0,0 +1,3 @@ | |||
| error_reporting = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED | |||
| display_errors = On | |||
| log_errors = On | |||
| @@ -0,0 +1,53 @@ | |||
| #!/bin/sh | |||
| set -eu | |||
| cd /var/www/html | |||
| if [ ! -f .env ] && [ -f .env.example ]; then | |||
| cp .env.example .env | |||
| fi | |||
| if [ ! -f vendor/autoload.php ]; then | |||
| composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader | |||
| fi | |||
| if [ "${DB_CONNECTION:-mysql}" = "mysql" ]; then | |||
| php -r ' | |||
| $host = getenv("DB_HOST") ?: "db"; | |||
| $port = getenv("DB_PORT") ?: "3306"; | |||
| $database = getenv("DB_DATABASE") ?: "pro-php-mvc"; | |||
| $username = getenv("DB_USERNAME") ?: "root"; | |||
| $password = getenv("DB_PASSWORD") ?: ""; | |||
| for ($attempt = 0; $attempt < 30; $attempt++) { | |||
| try { | |||
| new PDO("mysql:host={$host};port={$port};dbname={$database}", $username, $password); | |||
| exit(0); | |||
| } catch (Throwable $exception) { | |||
| fwrite(STDERR, "Waiting for MySQL...\n"); | |||
| sleep(2); | |||
| } | |||
| } | |||
| fwrite(STDERR, "MySQL did not become ready in time.\n"); | |||
| exit(1); | |||
| '; | |||
| if ! php -r ' | |||
| $host = getenv("DB_HOST") ?: "db"; | |||
| $port = getenv("DB_PORT") ?: "3306"; | |||
| $database = getenv("DB_DATABASE") ?: "pro-php-mvc"; | |||
| $username = getenv("DB_USERNAME") ?: "root"; | |||
| $password = getenv("DB_PASSWORD") ?: ""; | |||
| $pdo = new PDO("mysql:host={$host};port={$port};dbname={$database}", $username, $password); | |||
| $statement = $pdo->query("SHOW TABLES LIKE '\''migrations'\''"); | |||
| exit($statement->fetch() ? 0 : 1); | |||
| '; then | |||
| php command.php migrate | |||
| fi | |||
| fi | |||
| php-fpm -D | |||
| exec nginx -g "daemon off;" | |||
| @@ -0,0 +1,78 @@ | |||
| <?php | |||
| namespace Framework; | |||
| use Dotenv\Dotenv; | |||
| use Framework\Routing\Router; | |||
| use Framework\Http\Response; | |||
| class App extends Container | |||
| { | |||
| private static $instance; | |||
| public static function getInstance() | |||
| { | |||
| if (!static::$instance) { | |||
| static::$instance = new static(); | |||
| } | |||
| return static::$instance; | |||
| } | |||
| private function __construct() {} | |||
| private function __clone() {} | |||
| public function prepare(): static | |||
| { | |||
| $basePath = $this->resolve('paths.base'); | |||
| $this->configure($basePath); | |||
| $this->bindProviders($basePath); | |||
| return $this; | |||
| } | |||
| public function run(): Response | |||
| { | |||
| return $this->dispatch($this->resolve('paths.base')); | |||
| } | |||
| private function configure(string $basePath) | |||
| { | |||
| $dotenv = Dotenv::createImmutable($basePath); | |||
| $dotenv->load(); | |||
| } | |||
| private function bindProviders(string $basePath) | |||
| { | |||
| $providers = require "{$basePath}/config/providers.php"; | |||
| foreach ($providers as $provider) { | |||
| $instance = new $provider; | |||
| if (method_exists($instance, 'bind')) { | |||
| $instance->bind($this); | |||
| } | |||
| } | |||
| } | |||
| private function dispatch(string $basePath): Response | |||
| { | |||
| if (!$this->has(Router::class)) { | |||
| $router = new Router(); | |||
| $routes = require "{$basePath}/app/routes.php"; | |||
| $routes($router); | |||
| $this->bind(Router::class, fn() => $router); | |||
| } | |||
| $response = $this->resolve(Router::class)->dispatch(); | |||
| if (!$response instanceof Response) { | |||
| $response = $this->resolve('response')->content($response); | |||
| } | |||
| return $response; | |||
| } | |||
| } | |||
| @@ -0,0 +1,31 @@ | |||
| <?php | |||
| namespace Framework\Cache\Driver; | |||
| interface Driver | |||
| { | |||
| /** | |||
| * Tell if a value is cached (still) | |||
| */ | |||
| public function has(string $key): bool; | |||
| /** | |||
| * Get a cached value | |||
| */ | |||
| public function get(string $key, mixed $default = null): mixed; | |||
| /** | |||
| * Put a value into the cache, for an optional number of seconds | |||
| */ | |||
| public function put(string $key, mixed $value, int $seconds = null): static; | |||
| /** | |||
| * Remove a single cached value | |||
| */ | |||
| public function forget(string $key): static; | |||
| /** | |||
| * Remove all cached values | |||
| */ | |||
| public function flush(): static; | |||
| } | |||
| @@ -0,0 +1,111 @@ | |||
| <?php | |||
| namespace Framework\Cache\Driver; | |||
| use Framework\App; | |||
| class FileDriver implements Driver | |||
| { | |||
| private array $config = []; | |||
| private array $cached = []; | |||
| public function __construct(array $config) | |||
| { | |||
| $this->config = $config; | |||
| } | |||
| public function has(string $key): bool | |||
| { | |||
| $data = $this->cached[$key] = $this->read($key); | |||
| return isset($data['expires']) and $data['expires'] > time(); | |||
| } | |||
| private function path(string $key): string | |||
| { | |||
| $base = $this->base(); | |||
| $separator = DIRECTORY_SEPARATOR; | |||
| $key = sha1($key); | |||
| return "{$base}{$separator}{$key}.json"; | |||
| } | |||
| private function base(): string | |||
| { | |||
| $base = App::getInstance()->resolve('paths.base'); | |||
| $separator = DIRECTORY_SEPARATOR; | |||
| return "{$base}{$separator}storage{$separator}framework{$separator}cache"; | |||
| } | |||
| private function read(string $key) | |||
| { | |||
| $path = $this->path($key); | |||
| if (!is_file($path)) { | |||
| return []; | |||
| } | |||
| return json_decode(file_get_contents($path), true); | |||
| } | |||
| public function get(string $key, mixed $default = null): mixed | |||
| { | |||
| if ($this->has($key)) { | |||
| return $this->cached[$key]['value']; | |||
| } | |||
| return $default; | |||
| } | |||
| public function put(string $key, mixed $value, int $seconds = null): static | |||
| { | |||
| if (!is_int($seconds)) { | |||
| $seconds = (int) $this->config['seconds']; | |||
| } | |||
| $data = $this->cached[$key] = [ | |||
| 'value' => $value, | |||
| 'expires' => time() + $seconds, | |||
| ]; | |||
| return $this->write($key, $data); | |||
| } | |||
| private function write(string $key, mixed $value): static | |||
| { | |||
| file_put_contents($this->path($key), json_encode($value)); | |||
| return $this; | |||
| } | |||
| public function forget(string $key): static | |||
| { | |||
| unset($this->cached[$key]); | |||
| $path = $this->path($key); | |||
| if (is_file($path)) { | |||
| unlink($path); | |||
| } | |||
| return $this; | |||
| } | |||
| public function flush(): static | |||
| { | |||
| $this->cached = []; | |||
| $base = $this->base(); | |||
| $separator = DIRECTORY_SEPARATOR; | |||
| $files = glob("{$base}{$separator}*.json"); | |||
| foreach ($files as $file){ | |||
| if (is_file($file)) { | |||
| unlink($file); | |||
| } | |||
| } | |||
| return $this; | |||
| } | |||
| } | |||
| @@ -0,0 +1,55 @@ | |||
| <?php | |||
| namespace Framework\Cache\Driver; | |||
| use Memcached; | |||
| class MemcacheDriver implements Driver | |||
| { | |||
| private array $config = []; | |||
| private Memcached $memcache; | |||
| public function __construct(array $config) | |||
| { | |||
| $this->config = $config; | |||
| $this->memcache = new Memcached(); | |||
| $this->memcache->addServer($config['host'], $config['port']); | |||
| } | |||
| public function has(string $key): bool | |||
| { | |||
| return $this->memcache->get($key) !== false; | |||
| } | |||
| public function get(string $key, mixed $default = null): mixed | |||
| { | |||
| if ($value = $this->memcache->get($key)) { | |||
| return $value; | |||
| } | |||
| return $default; | |||
| } | |||
| public function put(string $key, mixed $value, int $seconds = null): static | |||
| { | |||
| if (!is_int($seconds)) { | |||
| $seconds = (int) $this->config['seconds']; | |||
| } | |||
| $this->memcache->set($key, $value, time() + $seconds); | |||
| return $this; | |||
| } | |||
| public function forget(string $key): static | |||
| { | |||
| $this->memcache->delete($key); | |||
| return $this; | |||
| } | |||
| public function flush(): static | |||
| { | |||
| $this->memcache->flush(); | |||
| return $this; | |||
| } | |||
| } | |||
| @@ -0,0 +1,54 @@ | |||
| <?php | |||
| namespace Framework\Cache\Driver; | |||
| class MemoryDriver implements Driver | |||
| { | |||
| private array $config = []; | |||
| private array $cached = []; | |||
| public function __construct(array $config) | |||
| { | |||
| $this->config = $config; | |||
| } | |||
| public function has(string $key): bool | |||
| { | |||
| return isset($this->cached[$key]) && $this->cached[$key]['expires'] > time(); | |||
| } | |||
| public function get(string $key, mixed $default = null): mixed | |||
| { | |||
| if ($this->has($key)) { | |||
| return $this->cached[$key]['value']; | |||
| } | |||
| return $default; | |||
| } | |||
| public function put(string $key, mixed $value, int $seconds = null): static | |||
| { | |||
| if (!is_int($seconds)) { | |||
| $seconds = (int) $this->config['seconds']; | |||
| } | |||
| $this->cached[$key] = [ | |||
| 'value' => $value, | |||
| 'expires' => time() + $seconds, | |||
| ]; | |||
| return $this; | |||
| } | |||
| public function forget(string $key): static | |||
| { | |||
| unset($this->cached[$key]); | |||
| return $this; | |||
| } | |||
| public function flush(): static | |||
| { | |||
| $this->cached = []; | |||
| return $this; | |||
| } | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| <?php | |||
| namespace Framework\Cache\Exception; | |||
| use RuntimeException; | |||
| class DriverException extends RuntimeException | |||
| { | |||
| } | |||
| @@ -0,0 +1,34 @@ | |||
| <?php | |||
| namespace Framework\Cache; | |||
| use Closure; | |||
| use Framework\Cache\Driver\Driver; | |||
| use Framework\Cache\Exception\DriverException; | |||
| use Framework\Support\DriverFactory; | |||
| class Factory implements DriverFactory | |||
| { | |||
| protected array $drivers; | |||
| public function addDriver(string $alias, Closure $driver): static | |||
| { | |||
| $this->drivers[$alias] = $driver; | |||
| return $this; | |||
| } | |||
| public function connect(array $config): Driver | |||
| { | |||
| if (!isset($config['type'])) { | |||
| throw new DriverException('type is not defined'); | |||
| } | |||
| $type = $config['type']; | |||
| if (isset($this->drivers[$type])) { | |||
| return $this->drivers[$type]($config); | |||
| } | |||
| throw new DriverException('unrecognised type'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,80 @@ | |||
| <?php | |||
| namespace Framework; | |||
| use InvalidArgumentException; | |||
| use ReflectionFunction; | |||
| use ReflectionMethod; | |||
| use ReflectionNamedType; | |||
| class Container | |||
| { | |||
| private array $bindings = []; | |||
| private array $resolved = []; | |||
| public function bind(string $alias, callable $factory): static | |||
| { | |||
| $this->bindings[$alias] = $factory; | |||
| $this->resolved[$alias] = null; | |||
| return $this; | |||
| } | |||
| public function resolve(string $alias): mixed | |||
| { | |||
| if (!isset($this->bindings[$alias])) { | |||
| throw new InvalidArgumentException("{$alias} is not bound"); | |||
| } | |||
| if (!isset($this->resolved[$alias])) { | |||
| $this->resolved[$alias] = call_user_func($this->bindings[$alias], $this); | |||
| } | |||
| return $this->resolved[$alias]; | |||
| } | |||
| public function has(string $alias): bool | |||
| { | |||
| return isset($this->bindings[$alias]); | |||
| } | |||
| public function call(array|callable $callable, array $parameters = []): mixed | |||
| { | |||
| $reflector = $this->getReflector($callable); | |||
| $dependencies = []; | |||
| foreach ($reflector->getParameters() as $parameter) { | |||
| $name = $parameter->getName(); | |||
| $type = $parameter->getType(); | |||
| if (isset($parameters[$name])) { | |||
| $dependencies[$name] = $parameters[$name]; | |||
| continue; | |||
| } | |||
| if ($parameter->isDefaultValueAvailable()) { | |||
| $dependencies[$name] = $parameter->getDefaultValue(); | |||
| continue; | |||
| } | |||
| if ($type instanceof ReflectionNamedType) { | |||
| $dependencies[$name] = $this->resolve($type); | |||
| continue; | |||
| } | |||
| throw new InvalidArgumentException("{$name} cannot be resolved"); | |||
| } | |||
| return call_user_func($callable, ...array_values($dependencies)); | |||
| } | |||
| private function getReflector(array|callable $callable): ReflectionMethod|ReflectionFunction | |||
| { | |||
| if (is_array($callable)) { | |||
| return new ReflectionMethod($callable[0], $callable[1]); | |||
| } | |||
| return new ReflectionFunction($callable); | |||
| } | |||
| } | |||
| @@ -0,0 +1,100 @@ | |||
| <?php | |||
| namespace Framework\Database\Command; | |||
| use Framework\Database\Factory; | |||
| use Framework\Database\Connection\Connection; | |||
| use Framework\Database\Connection\MysqlConnection; | |||
| use Framework\Database\Connection\SqliteConnection; | |||
| use Symfony\Component\Console\Command\Command; | |||
| use Symfony\Component\Console\Input\InputArgument; | |||
| use Symfony\Component\Console\Input\InputInterface; | |||
| use Symfony\Component\Console\Input\InputOption; | |||
| use Symfony\Component\Console\Output\OutputInterface; | |||
| class MigrateCommand extends Command | |||
| { | |||
| protected static $defaultName = 'migrate'; | |||
| protected function configure() | |||
| { | |||
| $this | |||
| ->setDescription('Migrates the database') | |||
| ->addOption('fresh', null, InputOption::VALUE_NONE, 'Delete all tables before running the migrations') | |||
| ->setHelp('This command looks for all migration files and runs them'); | |||
| } | |||
| protected function execute(InputInterface $input, OutputInterface $output) | |||
| { | |||
| $current = getcwd(); | |||
| $pattern = 'database/migrations/*.php'; | |||
| $paths = glob("{$current}/{$pattern}"); | |||
| if (count($paths) < 1) { | |||
| $this->writeln('No migrations found'); | |||
| return Command::SUCCESS; | |||
| } | |||
| // $connection = $this->connection(); | |||
| $connection = app('database'); | |||
| if ($input->getOption('fresh')) { | |||
| $output->writeln('Dropping existing database tables'); | |||
| $connection->dropTables(); | |||
| // $connection = $this->connection(); | |||
| $connection = app('database'); | |||
| } | |||
| if (!$connection->hasTable('migrations')) { | |||
| $output->writeln('Creating migrations table'); | |||
| $this->createMigrationsTable($connection); | |||
| } | |||
| foreach ($paths as $path) { | |||
| [$prefix, $file] = explode('_', $path); | |||
| [$class, $extension] = explode('.', $file); | |||
| require $path; | |||
| $output->writeln("Migrating: {$class}"); | |||
| $obj = new $class(); | |||
| $obj->migrate($connection); | |||
| $connection | |||
| ->query() | |||
| ->from('migrations') | |||
| ->insert(['name'], ['name' => $class]); | |||
| } | |||
| return Command::SUCCESS; | |||
| } | |||
| // private function connection(): Connection | |||
| // { | |||
| // $factory = new Factory(); | |||
| // $factory->addConnector('mysql', function($config) { | |||
| // return new MysqlConnection($config); | |||
| // }); | |||
| // $factory->addConnector('sqlite', function($config) { | |||
| // return new SqliteConnection($config); | |||
| // }); | |||
| // $config = require getcwd() . '/config/database.php'; | |||
| // return $factory->connect($config[$config['default']]); | |||
| // } | |||
| private function createMigrationsTable(Connection $connection) | |||
| { | |||
| $table = $connection->createTable('migrations'); | |||
| $table->id('id'); | |||
| $table->string('name'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,45 @@ | |||
| <?php | |||
| namespace Framework\Database\Connection; | |||
| use Framework\Database\Migration\Migration; | |||
| use Framework\Database\QueryBuilder\QueryBuilder; | |||
| use Pdo; | |||
| abstract class Connection | |||
| { | |||
| /** | |||
| * Get the underlying Pdo instance for this connection | |||
| */ | |||
| abstract public function pdo(): Pdo; | |||
| /** | |||
| * Start a new query on this connection | |||
| */ | |||
| abstract public function query(): QueryBuilder; | |||
| /** | |||
| * Start a new migration to add a table on this connection | |||
| */ | |||
| abstract public function createTable(string $table): Migration; | |||
| /** | |||
| * Start a new migration to add a table on this connection | |||
| */ | |||
| abstract public function alterTable(string $table): Migration; | |||
| /** | |||
| * Return a list of table names on this connection | |||
| */ | |||
| abstract public function getTables(): array; | |||
| /** | |||
| * Find out if a table exists on this connection | |||
| */ | |||
| abstract public function hasTable(string $name): bool; | |||
| /** | |||
| * Drop all tables in the current database | |||
| */ | |||
| abstract public function dropTables(): int; | |||
| } | |||
| @@ -0,0 +1,94 @@ | |||
| <?php | |||
| namespace Framework\Database\Connection; | |||
| use Framework\Database\Migration\MysqlMigration; | |||
| use Framework\Database\QueryBuilder\MysqlQueryBuilder; | |||
| use InvalidArgumentException; | |||
| use Pdo; | |||
| class MysqlConnection extends Connection | |||
| { | |||
| private Pdo $pdo; | |||
| private string $database; | |||
| public function __construct(array $config) | |||
| { | |||
| [ | |||
| 'host' => $host, | |||
| 'port' => $port, | |||
| 'database' => $database, | |||
| 'username' => $username, | |||
| 'password' => $password, | |||
| ] = $config; | |||
| if (empty($host) || empty($database) || empty($username)) { | |||
| throw new InvalidArgumentException('Connection incorrectly configured'); | |||
| } | |||
| $this->database = $database; | |||
| $this->pdo = new Pdo("mysql:host={$host};port={$port};dbname={$database}", $username, $password); | |||
| } | |||
| public function pdo(): Pdo | |||
| { | |||
| return $this->pdo; | |||
| } | |||
| public function query(): MysqlQueryBuilder | |||
| { | |||
| return new MysqlQueryBuilder($this); | |||
| } | |||
| public function createTable(string $table): MysqlMigration | |||
| { | |||
| return new MysqlMigration($this, $table, 'create'); | |||
| } | |||
| public function alterTable(string $table): MysqlMigration | |||
| { | |||
| return new MysqlMigration($this, $table, 'alter'); | |||
| } | |||
| public function getTables(): array | |||
| { | |||
| $statement = $this->pdo->prepare('SHOW TABLES'); | |||
| $statement->execute(); | |||
| $results = $statement->fetchAll(PDO::FETCH_NUM); | |||
| $results = array_map(fn($result) => $result[0], $results); | |||
| return $results; | |||
| } | |||
| public function hasTable(string $name): bool | |||
| { | |||
| $tables = $this->getTables(); | |||
| return in_array($name, $tables); | |||
| } | |||
| public function dropTables(): int | |||
| { | |||
| $statement = $this->pdo->prepare(" | |||
| SELECT CONCAT('DROP TABLE IF EXISTS `', table_name, '`') | |||
| FROM information_schema.tables | |||
| WHERE table_schema = '{$this->database}'; | |||
| "); | |||
| $statement->execute(); | |||
| $dropTableClauses = $statement->fetchAll(PDO::FETCH_NUM); | |||
| $dropTableClauses = array_map(fn($result) => $result[0], $dropTableClauses); | |||
| $clauses = [ | |||
| 'SET FOREIGN_KEY_CHECKS = 0', | |||
| ...$dropTableClauses, | |||
| 'SET FOREIGN_KEY_CHECKS = 1', | |||
| ]; | |||
| $statement = $this->pdo->prepare(join(';', $clauses) . ';'); | |||
| return $statement->execute(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,69 @@ | |||
| <?php | |||
| namespace Framework\Database\Connection; | |||
| use Framework\Database\Migration\SqliteMigration; | |||
| use Framework\Database\QueryBuilder\SqliteQueryBuilder; | |||
| use InvalidArgumentException; | |||
| use Pdo; | |||
| class SqliteConnection extends Connection | |||
| { | |||
| private Pdo $pdo; | |||
| private array $config; | |||
| public function __construct(array $config) | |||
| { | |||
| ['path' => $path] = $config; | |||
| if (empty($path)) { | |||
| throw new InvalidArgumentException('Connection incorrectly configured'); | |||
| } | |||
| $this->pdo = new Pdo("sqlite:{$path}"); | |||
| $this->config = $config; | |||
| } | |||
| public function pdo(): Pdo | |||
| { | |||
| return $this->pdo; | |||
| } | |||
| public function query(): SqliteQueryBuilder | |||
| { | |||
| return new SqliteQueryBuilder($this); | |||
| } | |||
| public function createTable(string $table): SqliteMigration | |||
| { | |||
| return new SqliteMigration($this, $table, 'create'); | |||
| } | |||
| public function alterTable(string $table): SqliteMigration | |||
| { | |||
| return new SqliteMigration($this, $table, 'alter'); | |||
| } | |||
| public function getTables(): array | |||
| { | |||
| $statement = $this->pdo->prepare("SELECT name FROM sqlite_master WHERE type = 'table'"); | |||
| $statement->execute(); | |||
| $results = $statement->fetchAll(PDO::FETCH_NUM); | |||
| $results = array_map(fn($result) => $result[0], $results); | |||
| return $results; | |||
| } | |||
| public function hasTable(string $name): bool | |||
| { | |||
| $tables = $this->getTables(); | |||
| return in_array($name, $tables); | |||
| } | |||
| public function dropTables(): int | |||
| { | |||
| file_put_contents($this->config['path'], ''); | |||
| return 1; | |||
| } | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| <?php | |||
| namespace Framework\Database\Exception; | |||
| use PDOException; | |||
| class ConnectionException extends PDOException | |||
| { | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| <?php | |||
| namespace Framework\Database\Exception; | |||
| use PDOException; | |||
| class MigrationException extends PDOException | |||
| { | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| <?php | |||
| namespace Framework\Database\Exception; | |||
| use PDOException; | |||
| class ConnectionException extends PDOException | |||
| { | |||
| } | |||
| @@ -0,0 +1,34 @@ | |||
| <?php | |||
| namespace Framework\Database; | |||
| use Closure; | |||
| use Framework\Database\Connection\Connection; | |||
| use Framework\Database\Exception\ConnectionException; | |||
| use Framework\Support\DriverFactory; | |||
| class Factory implements DriverFactory | |||
| { | |||
| protected array $drivers; | |||
| public function addDriver(string $alias, Closure $driver): static | |||
| { | |||
| $this->drivers[$alias] = $driver; | |||
| return $this; | |||
| } | |||
| public function connect(array $config): Connection | |||
| { | |||
| if (!isset($config['type'])) { | |||
| throw new ConnectionException('type is not defined'); | |||
| } | |||
| $type = $config['type']; | |||
| if (isset($this->drivers[$type])) { | |||
| return $this->drivers[$type]($config); | |||
| } | |||
| throw new ConnectionException('unrecognised type'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| <?php | |||
| namespace Framework\Database\Migration\Field; | |||
| class BoolField extends Field | |||
| { | |||
| public ?bool $default = null; | |||
| public function default(bool $value): static | |||
| { | |||
| $this->default = $value; | |||
| return $this; | |||
| } | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| <?php | |||
| namespace Framework\Database\Migration\Field; | |||
| class DateTimeField extends Field | |||
| { | |||
| public ?string $default = null; | |||
| public function default(string $value): static | |||
| { | |||
| $this->default = $value; | |||
| return $this; | |||
| } | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| <?php | |||
| namespace Framework\Database\Migration\Field; | |||
| abstract class Field | |||
| { | |||
| public string $name; | |||
| public bool $nullable = false; | |||
| public bool $alter = false; | |||
| public function __construct(string $name) | |||
| { | |||
| $this->name = $name; | |||
| } | |||
| public function nullable(): static | |||
| { | |||
| $this->nullable = true; | |||
| return $this; | |||
| } | |||
| public function alter(): static | |||
| { | |||
| $this->alter = true; | |||
| return $this; | |||
| } | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| <?php | |||
| namespace Framework\Database\Migration\Field; | |||
| class FloatField extends Field | |||
| { | |||
| public ?float $default = null; | |||
| public function default(float $value): static | |||
| { | |||
| $this->default = $value; | |||
| return $this; | |||
| } | |||
| } | |||
| @@ -0,0 +1,13 @@ | |||
| <?php | |||
| namespace Framework\Database\Migration\Field; | |||
| use Framework\Database\Exception\MigrationException; | |||
| class IdField extends Field | |||
| { | |||
| public function default() | |||
| { | |||
| throw new MigrationException('ID fields cannot have a default value'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| <?php | |||
| namespace Framework\Database\Migration\Field; | |||
| class IntField extends Field | |||
| { | |||
| public ?int $default = null; | |||
| public function default(int $value): static | |||
| { | |||
| $this->default = $value; | |||
| return $this; | |||
| } | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| <?php | |||
| namespace Framework\Database\Migration\Field; | |||
| class StringField extends Field | |||
| { | |||
| public ?string $default = null; | |||
| public function default(string $value): static | |||
| { | |||
| $this->default = $value; | |||
| return $this; | |||
| } | |||
| } | |||
| @@ -0,0 +1,21 @@ | |||
| <?php | |||
| namespace Framework\Database\Migration\Field; | |||
| use Framework\Database\Exception\MigrationException; | |||
| class TextField extends Field | |||
| { | |||
| public ?string $default = null; | |||
| public function nullable(): static | |||
| { | |||
| throw new MigrationException('Text fields cannot be nullable'); | |||
| } | |||
| public function default(string $value): static | |||
| { | |||
| $this->default = $value; | |||
| return $this; | |||
| } | |||
| } | |||
| @@ -0,0 +1,62 @@ | |||
| <?php | |||
| namespace Framework\Database\Migration; | |||
| use Framework\Database\Connection\Connection; | |||
| use Framework\Database\Migration\Field\BoolField; | |||
| use Framework\Database\Migration\Field\DateTimeField; | |||
| use Framework\Database\Migration\Field\FloatField; | |||
| use Framework\Database\Migration\Field\IdField; | |||
| use Framework\Database\Migration\Field\IntField; | |||
| use Framework\Database\Migration\Field\StringField; | |||
| use Framework\Database\Migration\Field\TextField; | |||
| abstract class Migration | |||
| { | |||
| protected array $fields = []; | |||
| public function bool(string $name): BoolField | |||
| { | |||
| $field = $this->fields[] = new BoolField($name); | |||
| return $field; | |||
| } | |||
| public function dateTime(string $name): DateTimeField | |||
| { | |||
| $field = $this->fields[] = new DateTimeField($name); | |||
| return $field; | |||
| } | |||
| public function float(string $name): FloatField | |||
| { | |||
| $field = $this->fields[] = new FloatField($name); | |||
| return $field; | |||
| } | |||
| public function id(string $name): IdField | |||
| { | |||
| $field = $this->fields[] = new IdField($name); | |||
| return $field; | |||
| } | |||
| public function int(string $name): IntField | |||
| { | |||
| $field = $this->fields[] = new IntField($name); | |||
| return $field; | |||
| } | |||
| public function string(string $name): StringField | |||
| { | |||
| $field = $this->fields[] = new StringField($name); | |||
| return $field; | |||
| } | |||
| public function text(string $name): TextField | |||
| { | |||
| $field = $this->fields[] = new TextField($name); | |||
| return $field; | |||
| } | |||
| abstract public function execute(); | |||
| abstract public function dropColumn(string $name): static; | |||
| } | |||
| @@ -0,0 +1,164 @@ | |||
| <?php | |||
| namespace Framework\Database\Migration; | |||
| use Framework\Database\Connection\MysqlConnection; | |||
| use Framework\Database\Exception\MigrationException; | |||
| use Framework\Database\Migration\Field\Field; | |||
| use Framework\Database\Migration\Field\BoolField; | |||
| use Framework\Database\Migration\Field\DateTimeField; | |||
| use Framework\Database\Migration\Field\FloatField; | |||
| use Framework\Database\Migration\Field\IdField; | |||
| use Framework\Database\Migration\Field\IntField; | |||
| use Framework\Database\Migration\Field\StringField; | |||
| use Framework\Database\Migration\Field\TextField; | |||
| class MysqlMigration extends Migration | |||
| { | |||
| protected MysqlConnection $connection; | |||
| protected string $table; | |||
| protected string $type; | |||
| protected array $drops = []; | |||
| public function __construct(MysqlConnection $connection, string $table, string $type) | |||
| { | |||
| $this->connection = $connection; | |||
| $this->table = $table; | |||
| $this->type = $type; | |||
| } | |||
| public function execute() | |||
| { | |||
| $fields = array_map(fn($field) => $this->stringForField($field), $this->fields); | |||
| $primary = array_filter($this->fields, fn($field) => $field instanceof IdField); | |||
| $primaryKey = isset($primary[0]) ? "PRIMARY KEY (`{$primary[0]->name}`)" : ''; | |||
| if ($this->type === 'create') { | |||
| $fields = join(PHP_EOL, array_map(fn($field) => "{$field},", $fields)); | |||
| $query = " | |||
| CREATE TABLE `{$this->table}` ( | |||
| {$fields} | |||
| {$primaryKey} | |||
| ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; | |||
| "; | |||
| } | |||
| if ($this->type === 'alter') { | |||
| $fields = join(PHP_EOL, array_map(fn($field) => "{$field};", $fields)); | |||
| $drops = join(PHP_EOL, array_map(fn($drop) => "DROP COLUMN `{$drop}`;", $this->drops)); | |||
| $query = " | |||
| ALTER TABLE `{$this->table}` | |||
| {$fields} | |||
| {$drops} | |||
| "; | |||
| } | |||
| $statement = $this->connection->pdo()->prepare($query); | |||
| $statement->execute(); | |||
| } | |||
| private function stringForField(Field $field): string | |||
| { | |||
| $prefix = ''; | |||
| if ($this->type === 'alter') { | |||
| $prefix = 'ADD'; | |||
| } | |||
| if ($field->alter) { | |||
| $prefix = 'MODIFY'; | |||
| } | |||
| if ($field instanceof BoolField) { | |||
| $template = "{$prefix} `{$field->name}` tinyint(4)"; | |||
| if ($field->nullable) { | |||
| $template .= " DEFAULT NULL"; | |||
| } | |||
| if ($field->default !== null) { | |||
| $default = (int) $field->default; | |||
| $template .= " DEFAULT {$default}"; | |||
| } | |||
| return $template; | |||
| } | |||
| if ($field instanceof DateTimeField) { | |||
| $template = "{$prefix} `{$field->name}` datetime"; | |||
| if ($field->nullable) { | |||
| $template .= " DEFAULT NULL"; | |||
| } | |||
| if ($field->default === 'CURRENT_TIMESTAMP') { | |||
| $template .= " DEFAULT CURRENT_TIMESTAMP"; | |||
| } else if ($field->default !== null) { | |||
| $template .= " DEFAULT '{$field->default}'"; | |||
| } | |||
| return $template; | |||
| } | |||
| if ($field instanceof FloatField) { | |||
| $template = "{$prefix} `{$field->name}` float"; | |||
| if ($field->nullable) { | |||
| $template .= " DEFAULT NULL"; | |||
| } | |||
| if ($field->default !== null) { | |||
| $template .= " DEFAULT '{$field->default}'"; | |||
| } | |||
| return $template; | |||
| } | |||
| if ($field instanceof IdField) { | |||
| return "{$prefix} `{$field->name}` int(11) unsigned NOT NULL AUTO_INCREMENT"; | |||
| } | |||
| if ($field instanceof IntField) { | |||
| $template = "{$prefix} `{$field->name}` int(11)"; | |||
| if ($field->nullable) { | |||
| $template .= " DEFAULT NULL"; | |||
| } | |||
| if ($field->default !== null) { | |||
| $template .= " DEFAULT '{$field->default}'"; | |||
| } | |||
| return $template; | |||
| } | |||
| if ($field instanceof StringField) { | |||
| $template = "{$prefix} `{$field->name}` varchar(255)"; | |||
| if ($field->nullable) { | |||
| $template .= " DEFAULT NULL"; | |||
| } | |||
| if ($field->default !== null) { | |||
| $template .= " DEFAULT '{$field->default}'"; | |||
| } | |||
| return $template; | |||
| } | |||
| if ($field instanceof TextField) { | |||
| return "{$prefix} `{$field->name}` text"; | |||
| } | |||
| throw new MigrationException("Unrecognised field type for {$field->name}"); | |||
| } | |||
| public function dropColumn(string $name): static | |||
| { | |||
| $this->drops[] = $name; | |||
| return $this; | |||
| } | |||
| } | |||
| @@ -0,0 +1,154 @@ | |||
| <?php | |||
| namespace Framework\Database\Migration; | |||
| use Framework\Database\Connection\SqliteConnection; | |||
| use Framework\Database\Exception\MigrationException; | |||
| use Framework\Database\Migration\Field\Field; | |||
| use Framework\Database\Migration\Field\BoolField; | |||
| use Framework\Database\Migration\Field\DateTimeField; | |||
| use Framework\Database\Migration\Field\FloatField; | |||
| use Framework\Database\Migration\Field\IdField; | |||
| use Framework\Database\Migration\Field\IntField; | |||
| use Framework\Database\Migration\Field\StringField; | |||
| use Framework\Database\Migration\Field\TextField; | |||
| class SqliteMigration extends Migration | |||
| { | |||
| protected SqliteConnection $connection; | |||
| protected string $table; | |||
| protected string $type; | |||
| public function __construct(SqliteConnection $connection, string $table, string $type) | |||
| { | |||
| $this->connection = $connection; | |||
| $this->table = $table; | |||
| $this->type = $type; | |||
| } | |||
| public function execute() | |||
| { | |||
| $command = $this->type === 'create' ? '' : 'ALTER TABLE'; | |||
| $fields = array_map(fn($field) => $this->stringForField($field), $this->fields); | |||
| if ($this->type === 'create') { | |||
| $fields = join(',' . PHP_EOL, $fields); | |||
| $query = " | |||
| CREATE TABLE \"{$this->table}\" ( | |||
| {$fields} | |||
| ); | |||
| "; | |||
| } | |||
| if ($this->type === 'alter') { | |||
| $fields = join(';' . PHP_EOL, $fields); | |||
| $query = " | |||
| ALTER TABLE \"{$this->table}\" | |||
| {$fields}; | |||
| "; | |||
| } | |||
| $statement = $this->connection->pdo()->prepare($query); | |||
| $statement->execute(); | |||
| } | |||
| private function stringForField(Field $field): string | |||
| { | |||
| $prefix = ''; | |||
| if ($this->type === 'alter') { | |||
| $prefix = 'ADD COLUMN'; | |||
| } | |||
| if ($field->alter) { | |||
| throw new MigrationException('SQLite doesn\'t support altering columns'); | |||
| } | |||
| if ($field instanceof BoolField) { | |||
| $template = "{$prefix} \"{$field->name}\" INTEGER"; | |||
| if (!$field->nullable) { | |||
| $template .= " NOT NULL"; | |||
| } | |||
| if ($field->default !== null) { | |||
| $default = (int) $field->default; | |||
| $template .= " DEFAULT {$default}"; | |||
| } | |||
| return $template; | |||
| } | |||
| if ($field instanceof DateTimeField) { | |||
| $template = "{$prefix} \"{$field->name}\" TEXT"; | |||
| if (!$field->nullable) { | |||
| $template .= " NOT NULL"; | |||
| } | |||
| if ($field->default === 'CURRENT_TIMESTAMP') { | |||
| $template .= " DEFAULT CURRENT_TIMESTAMP"; | |||
| } else if ($field->default !== null) { | |||
| $template .= " DEFAULT '{$field->default}'"; | |||
| } | |||
| return $template; | |||
| } | |||
| if ($field instanceof FloatField) { | |||
| $template = "{$prefix} \"{$field->name}\" REAL"; | |||
| if (!$field->nullable) { | |||
| $template .= " NOT NULL"; | |||
| } | |||
| if ($field->default !== null) { | |||
| $template .= " DEFAULT {$field->default}"; | |||
| } | |||
| return $template; | |||
| } | |||
| if ($field instanceof IdField) { | |||
| return "{$prefix} \"{$field->name}\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE"; | |||
| } | |||
| if ($field instanceof IntField) { | |||
| $template = "{$prefix} \"{$field->name}\" INTEGER"; | |||
| if (!$field->nullable) { | |||
| $template .= " NOT NULL"; | |||
| } | |||
| if ($field->default !== null) { | |||
| $template .= " DEFAULT {$field->default}"; | |||
| } | |||
| return $template; | |||
| } | |||
| if ($field instanceof StringField || $field instanceof TextField) { | |||
| $template = "{$prefix} \"{$field->name}\" TEXT"; | |||
| if (!$field->nullable) { | |||
| $template .= " NOT NULL"; | |||
| } | |||
| if ($field->default !== null) { | |||
| $template .= " DEFAULT '{$field->default}'"; | |||
| } | |||
| return $template; | |||
| } | |||
| throw new MigrationException("Unrecognised field type for {$field->name}"); | |||
| } | |||
| public function dropColumn(string $name): static | |||
| { | |||
| throw new MigrationException('SQLite doesn\'t support dropping columns'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,189 @@ | |||
| <?php | |||
| namespace Framework\Database; | |||
| use Exception; | |||
| use Framework\Database\Connection\Connection; | |||
| use Framework\Database\Connection\MysqlConnection; | |||
| use Framework\Database\Connection\SqliteConnection; | |||
| use Framework\Database\Exception\ConnectionException; | |||
| use ReflectionClass; | |||
| abstract class Model | |||
| { | |||
| protected Connection $connection; | |||
| protected string $table; | |||
| protected array $attributes = []; | |||
| protected array $dirty = []; | |||
| protected array $casts = []; | |||
| public function setConnection(Connection $connection): static | |||
| { | |||
| $this->connection = $connection; | |||
| return $this; | |||
| } | |||
| public function getConnection(): Connection | |||
| { | |||
| if (!isset($this->connection)) { | |||
| $this->connection = app('database'); | |||
| } | |||
| return $this->connection; | |||
| } | |||
| public function setTable(string $table): static | |||
| { | |||
| $this->table = $table; | |||
| return $this; | |||
| } | |||
| public function getTable(): string | |||
| { | |||
| if (!isset($this->table)) { | |||
| $reflector = new ReflectionClass(static::class); | |||
| foreach ($reflector->getAttributes() as $attribute) { | |||
| if ($attribute->getName() == TableName::class) { | |||
| return $attribute->getArguments()[0]; | |||
| } | |||
| } | |||
| throw new Exception('$table is not set and getTable is not defined'); | |||
| } | |||
| return $this->table; | |||
| } | |||
| public static function with(array $attributes = []): static | |||
| { | |||
| $model = new static(); | |||
| $model->attributes = $attributes; | |||
| return $model; | |||
| } | |||
| public static function query(): mixed | |||
| { | |||
| $model = new static(); | |||
| $query = $model->getConnection()->query(); | |||
| return (new ModelCollector($query, static::class)) | |||
| ->from($model->getTable()); | |||
| } | |||
| public static function __callStatic(string $method, array $parameters = []): mixed | |||
| { | |||
| return static::query()->$method(...$parameters); | |||
| } | |||
| public function __get(string $property): mixed | |||
| { | |||
| $getter = 'get' . ucfirst($property) . 'Attribute'; | |||
| $value = null; | |||
| if (method_exists($this, $property)) { | |||
| $relationship = $this->$property(); | |||
| $method = $relationship->method; | |||
| $value = $relationship->$method(); | |||
| } | |||
| if (method_exists($this, $getter)) { | |||
| $value = $this->$getter($this->attributes[$property] ?? null); | |||
| } | |||
| if (isset($this->attributes[$property])) { | |||
| $value = $this->attributes[$property]; | |||
| } | |||
| if (isset($this->casts[$property]) && is_callable($this->casts[$property])) { | |||
| $value = $this->casts[$property]($value); | |||
| } | |||
| return $value; | |||
| } | |||
| public function __set(string $property, $value) | |||
| { | |||
| $setter = 'set' . ucfirst($property) . 'Attribute'; | |||
| array_push($this->dirty, $property); | |||
| if (method_exists($this, $setter)) { | |||
| $this->attributes[$property] = $this->$setter($value); | |||
| return; | |||
| } | |||
| $this->attributes[$property] = $value; | |||
| } | |||
| public function save(): static | |||
| { | |||
| $values = []; | |||
| foreach ($this->dirty as $dirty) { | |||
| $values[$dirty] = $this->attributes[$dirty]; | |||
| } | |||
| $data = [array_keys($values), $values]; | |||
| $query = static::query(); | |||
| if (isset($this->attributes['id'])) { | |||
| $query | |||
| ->where('id', $this->attributes['id']) | |||
| ->update(...$data); | |||
| return $this; | |||
| } | |||
| $query->insert(...$data); | |||
| $this->attributes['id'] = $query->getLastInsertId(); | |||
| $this->dirty = []; | |||
| return $this; | |||
| } | |||
| public function delete(): static | |||
| { | |||
| if (isset($this->attributes['id'])) { | |||
| static::query() | |||
| ->where('id', $this->attributes['id']) | |||
| ->delete(); | |||
| } | |||
| return $this; | |||
| } | |||
| public function hasOne(string $class, string $foreignKey, string $primaryKey = 'id'): mixed | |||
| { | |||
| $model = new $class; | |||
| $query = $class::query()->from($model->getTable())->where($foreignKey, $this->attributes['id']); | |||
| return new Relationship($query, 'first'); | |||
| } | |||
| public function hasMany(string $class, string $foreignKey, string $primaryKey = 'id'): mixed | |||
| { | |||
| $model = new $class; | |||
| $query = $class::query()->from($model->getTable())->where($foreignKey, $this->attributes['id']); | |||
| return new Relationship($query, 'all'); | |||
| } | |||
| public function belongsTo(string $class, string $foreignKey, string $primaryKey = 'id'): mixed | |||
| { | |||
| $model = new $class; | |||
| $query = $class::query()->from($model->getTable())->where($primaryKey, $this->attributes[$foreignKey]); | |||
| return new Relationship($query, 'first'); | |||
| } | |||
| public static function find(int $id): static | |||
| { | |||
| return static::where('id', $id)->first(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,56 @@ | |||
| <?php | |||
| namespace Framework\Database; | |||
| use Framework\Database\QueryBuilder\QueryBuilder; | |||
| class ModelCollector | |||
| { | |||
| private QueryBuilder $builder; | |||
| private string $class; | |||
| public function __construct(QueryBuilder $builder, string $class) | |||
| { | |||
| $this->builder = $builder; | |||
| $this->class = $class; | |||
| } | |||
| public function __call(string $method, array $parameters = []): mixed | |||
| { | |||
| $result = $this->builder->$method(...$parameters); | |||
| // in case it's a fluent method... | |||
| if ($result instanceof QueryBuilder) { | |||
| $this->builder = $result; | |||
| return $this; | |||
| } | |||
| return $result; | |||
| } | |||
| public function first() | |||
| { | |||
| $class = $this->class; | |||
| $row = $this->builder->first(); | |||
| if (!is_null($row)) { | |||
| $row = $class::with($row); | |||
| } | |||
| return $row; | |||
| } | |||
| public function all() | |||
| { | |||
| $class = $this->class; | |||
| $rows = $this->builder->all(); | |||
| foreach ($rows as $i => $row) { | |||
| $rows[$i] = $class::with($row); | |||
| } | |||
| return $rows; | |||
| } | |||
| } | |||
| @@ -0,0 +1,15 @@ | |||
| <?php | |||
| namespace Framework\Database\QueryBuilder; | |||
| use Framework\Database\Connection\MysqlConnection; | |||
| class MysqlQueryBuilder extends QueryBuilder | |||
| { | |||
| protected MysqlConnection $connection; | |||
| public function __construct(MysqlConnection $connection) | |||
| { | |||
| $this->connection = $connection; | |||
| } | |||
| } | |||
| @@ -0,0 +1,307 @@ | |||
| <?php | |||
| namespace Framework\Database\QueryBuilder; | |||
| use Framework\Database\Connection\Connection; | |||
| use Framework\Database\Exception\QueryException; | |||
| use Pdo; | |||
| use PdoStatement; | |||
| abstract class QueryBuilder | |||
| { | |||
| protected string $type; | |||
| protected array $columns; | |||
| protected string $table; | |||
| protected int $limit; | |||
| protected int $offset; | |||
| protected array $values; | |||
| protected array $wheres = []; | |||
| /** | |||
| * Fetch all rows matching the current query | |||
| */ | |||
| public function all(): array | |||
| { | |||
| if (!isset($this->type)) { | |||
| $this->select(); | |||
| } | |||
| $statement = $this->prepare(); | |||
| $statement->execute($this->getWhereValues()); | |||
| return $statement->fetchAll(Pdo::FETCH_ASSOC); | |||
| } | |||
| /** | |||
| * Get the values for the where clause placeholders | |||
| */ | |||
| protected function getWhereValues(): array | |||
| { | |||
| $values = []; | |||
| if (count($this->wheres) === 0) { | |||
| return $values; | |||
| } | |||
| foreach ($this->wheres as $where) { | |||
| if (is_bool($where[2])) { | |||
| $values[$where[0]] = (int) $where[2]; | |||
| continue; | |||
| } | |||
| $values[$where[0]] = $where[2]; | |||
| } | |||
| return $values; | |||
| } | |||
| /** | |||
| * Prepare a query against a particular connection | |||
| */ | |||
| public function prepare(): PdoStatement | |||
| { | |||
| $query = ''; | |||
| if ($this->type === 'select') { | |||
| $query = $this->compileSelect($query); | |||
| $query = $this->compileWheres($query); | |||
| $query = $this->compileLimit($query); | |||
| } | |||
| if ($this->type === 'insert') { | |||
| $query = $this->compileInsert($query); | |||
| } | |||
| if ($this->type === 'update') { | |||
| $query = $this->compileUpdate($query); | |||
| $query = $this->compileWheres($query); | |||
| } | |||
| if ($this->type === 'delete') { | |||
| $query = $this->compileDelete($query); | |||
| $query = $this->compileWheres($query); | |||
| } | |||
| if (empty($query)) { | |||
| throw new QueryException('Unrecognised query type'); | |||
| } | |||
| return $this->connection->pdo()->prepare($query); | |||
| } | |||
| /** | |||
| * Add select clause to the query | |||
| */ | |||
| protected function compileSelect(string $query): string | |||
| { | |||
| $joinedColumns = join(', ', $this->columns); | |||
| $query .= " SELECT {$joinedColumns} FROM {$this->table}"; | |||
| return $query; | |||
| } | |||
| /** | |||
| * Add limit and offset clauses to the query | |||
| */ | |||
| protected function compileLimit(string $query): string | |||
| { | |||
| if (isset($this->limit)) { | |||
| $query .= " LIMIT {$this->limit}"; | |||
| } | |||
| if (isset($this->offset)) { | |||
| $query .= " OFFSET {$this->offset}"; | |||
| } | |||
| return $query; | |||
| } | |||
| /** | |||
| * Add where clauses to the query | |||
| */ | |||
| protected function compileWheres(string $query): string | |||
| { | |||
| if (count($this->wheres) === 0) { | |||
| return $query; | |||
| } | |||
| $query .= ' WHERE'; | |||
| foreach ($this->wheres as $i => $where) { | |||
| if ($i > 0) { | |||
| $query .= ' AND '; | |||
| } | |||
| [$column, $comparator, $value] = $where; | |||
| $query .= " {$column} {$comparator} :{$column}"; | |||
| } | |||
| return $query; | |||
| } | |||
| /** | |||
| * Add insert clause to the query | |||
| */ | |||
| protected function compileInsert(string $query): string | |||
| { | |||
| $joinedColumns = join(', ', $this->columns); | |||
| $joinedPlaceholders = join(', ', array_map(fn($column) => ":{$column}", $this->columns)); | |||
| $query .= " INSERT INTO {$this->table} ({$joinedColumns}) VALUES ({$joinedPlaceholders})"; | |||
| return $query; | |||
| } | |||
| /** | |||
| * Add update clause to the query | |||
| */ | |||
| protected function compileUpdate(string $query): string | |||
| { | |||
| $joinedColumns = ''; | |||
| foreach ($this->columns as $i => $column) { | |||
| if ($i > 0) { | |||
| $joinedColumns .= ', '; | |||
| } | |||
| $joinedColumns = " {$column} = :{$column}"; | |||
| } | |||
| $query .= " UPDATE {$this->table} SET {$joinedColumns}"; | |||
| return $query; | |||
| } | |||
| /** | |||
| * Add delete clause to the query | |||
| */ | |||
| protected function compileDelete(string $query): string | |||
| { | |||
| $query .= " DELETE FROM {$this->table}"; | |||
| return $query; | |||
| } | |||
| /** | |||
| * Fetch the first row matching the current query | |||
| */ | |||
| public function first(): ?array | |||
| { | |||
| if (!isset($this->type)) { | |||
| $this->select(); | |||
| } | |||
| $statement = $this->take(1)->prepare(); | |||
| $statement->execute($this->getWhereValues()); | |||
| $result = $statement->fetchAll(Pdo::FETCH_ASSOC); | |||
| if (count($result) === 1) { | |||
| return $result[0]; | |||
| } | |||
| return null; | |||
| } | |||
| /** | |||
| * Limit a set of query results so that it's possible | |||
| * to fetch a single or limited batch of rows | |||
| */ | |||
| public function take(int $limit, int $offset = 0): static | |||
| { | |||
| $this->limit = $limit; | |||
| $this->offset = $offset; | |||
| return $this; | |||
| } | |||
| /** | |||
| * Indicate which table the query is targetting | |||
| */ | |||
| public function from(string $table): static | |||
| { | |||
| $this->table = $table; | |||
| return $this; | |||
| } | |||
| /** | |||
| * Indicate the query type is a "select" and remember | |||
| * which fields should be returned by the query | |||
| */ | |||
| public function select(mixed $columns = '*'): static | |||
| { | |||
| if (is_string($columns)) { | |||
| $columns = [$columns]; | |||
| } | |||
| $this->type = 'select'; | |||
| $this->columns = $columns; | |||
| return $this; | |||
| } | |||
| /** | |||
| * Insert a row of data into the table specified in the query | |||
| * and return the number of affected rows | |||
| */ | |||
| public function insert(array $columns, array $values): int | |||
| { | |||
| $this->type = 'insert'; | |||
| $this->columns = $columns; | |||
| $this->values = $values; | |||
| $statement = $this->prepare(); | |||
| return $statement->execute($values); | |||
| } | |||
| /** | |||
| * Store where clause data for later queries | |||
| */ | |||
| public function where(string $column, mixed $comparator, mixed $value = null): static | |||
| { | |||
| if (is_null($value) && !is_null($comparator)) { | |||
| array_push($this->wheres, [$column, '=', $comparator]); | |||
| } else { | |||
| array_push($this->wheres, [$column, $comparator, $value]); | |||
| } | |||
| return $this; | |||
| } | |||
| /** | |||
| * Insert a row of data into the table specified in the query | |||
| * and return the number of affected rows | |||
| */ | |||
| public function update(array $columns, array $values): int | |||
| { | |||
| $this->type = 'update'; | |||
| $this->columns = $columns; | |||
| $this->values = $values; | |||
| $statement = $this->prepare(); | |||
| return $statement->execute($this->getWhereValues() + $values); | |||
| } | |||
| /** | |||
| * Get the ID of the last row that was inserted | |||
| */ | |||
| public function getLastInsertId(): string | |||
| { | |||
| return $this->connection->pdo()->lastInsertId(); | |||
| } | |||
| /** | |||
| * Delete a row from the database | |||
| */ | |||
| public function delete(): int | |||
| { | |||
| $this->type = 'delete'; | |||
| $statement = $this->prepare(); | |||
| return $statement->execute($this->getWhereValues()); | |||
| } | |||
| } | |||
| @@ -0,0 +1,15 @@ | |||
| <?php | |||
| namespace Framework\Database\QueryBuilder; | |||
| use Framework\Database\Connection\SqliteConnection; | |||
| class SqliteQueryBuilder extends QueryBuilder | |||
| { | |||
| protected SqliteConnection $connection; | |||
| public function __construct(SqliteConnection $connection) | |||
| { | |||
| $this->connection = $connection; | |||
| } | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| <?php | |||
| namespace Framework\Database; | |||
| use Framework\Database\ModelCollector; | |||
| class Relationship | |||
| { | |||
| public ModelCollector $collector; | |||
| public string $method; | |||
| public function __construct(ModelCollector $collector, string $method) | |||
| { | |||
| $this->collector = $collector; | |||
| $this->method = $method; | |||
| } | |||
| public function __invoke(array $parameters = []): mixed | |||
| { | |||
| return $this->collector->$method(...$parameters); | |||
| } | |||
| public function __call(string $method, array $parameters = []): mixed | |||
| { | |||
| return $this->collector->$method(...$parameters); | |||
| } | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| <?php | |||
| namespace Framework\Database; | |||
| #[Attribute] | |||
| class TableName | |||
| { | |||
| public string $name; | |||
| public function __construct(string $name) | |||
| { | |||
| $this->name = $name; | |||
| } | |||
| } | |||
| @@ -0,0 +1,12 @@ | |||
| <?php | |||
| namespace Framework\Email\Driver; | |||
| interface Driver | |||
| { | |||
| public function to(string $to): static; | |||
| public function subject(string $subject): static; | |||
| public function text(string $text): static; | |||
| public function html(string $html): static; | |||
| public function send(): void; | |||
| } | |||
| @@ -0,0 +1,93 @@ | |||
| <?php | |||
| namespace Framework\Email\Driver; | |||
| use Framework\Email\Exception\CompositionException; | |||
| use Postmark\Transport; | |||
| use Swift_Mailer; | |||
| use Swift_Message; | |||
| class PostmarkDriver implements Driver | |||
| { | |||
| private array $config; | |||
| private Swift_Mailer $mailer; | |||
| private string $to; | |||
| private string $subject; | |||
| private string $text; | |||
| private string $html; | |||
| public function __construct(array $config) | |||
| { | |||
| $this->config = $config; | |||
| } | |||
| public function to(string $to): static | |||
| { | |||
| $this->to = $to; | |||
| return $this; | |||
| } | |||
| public function subject(string $subject): static | |||
| { | |||
| $this->subject = $subject; | |||
| return $this; | |||
| } | |||
| public function text(string $text): static | |||
| { | |||
| $this->text = $text; | |||
| return $this; | |||
| } | |||
| public function html(string $html): static | |||
| { | |||
| $this->html = $html; | |||
| return $this; | |||
| } | |||
| public function send(): void | |||
| { | |||
| if (!isset($this->to)) { | |||
| throw new CompositionException('to required'); | |||
| } | |||
| if (!isset($this->text) && !isset($this->html)) { | |||
| throw new CompositionException('text or email required'); | |||
| } | |||
| $fromName = $this->config['from']['name']; | |||
| $fromEmail = $this->config['from']['email']; | |||
| $subject = $this->subject ?? "Message from {$fromName}"; | |||
| $message = (new Swift_Message($subject)) | |||
| ->setFrom([$fromEmail => $fromName]) | |||
| ->setTo([$this->to]); | |||
| if (isset($this->text) && !isset($this->html)) { | |||
| $message->setBody($this->text, 'text/plain'); | |||
| } | |||
| if (!isset($this->text) && isset($this->html)) { | |||
| $message->setBody($this->html, 'text/html'); | |||
| } | |||
| if (isset($this->text, $this->html)) { | |||
| $message | |||
| ->setBody($this->html, 'text/html') | |||
| ->addPart($this->text, 'text/plain'); | |||
| } | |||
| $this->mailer()->send($message); | |||
| } | |||
| private function mailer() | |||
| { | |||
| if (!isset($this->mailer)) { | |||
| $transport = new Transport($this->config['token']); | |||
| $this->mailer = new Swift_Mailer($transport); | |||
| } | |||
| return $this->mailer; | |||
| } | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| <?php | |||
| namespace Framework\Email\Exception; | |||
| use InvalidArgumentException; | |||
| class CompositionException extends InvalidArgumentException | |||
| { | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| <?php | |||
| namespace Framework\Email\Exception; | |||
| use RuntimeException; | |||
| class DriverException extends RuntimeException | |||
| { | |||
| } | |||
| @@ -0,0 +1,34 @@ | |||
| <?php | |||
| namespace Framework\Email; | |||
| use Closure; | |||
| use Framework\Email\Driver\Driver; | |||
| use Framework\Email\Exception\DriverException; | |||
| use Framework\Support\DriverFactory; | |||
| class Factory implements DriverFactory | |||
| { | |||
| protected array $drivers; | |||
| public function addDriver(string $alias, Closure $driver): static | |||
| { | |||
| $this->drivers[$alias] = $driver; | |||
| return $this; | |||
| } | |||
| public function connect(array $config): Driver | |||
| { | |||
| if (!isset($config['type'])) { | |||
| throw new DriverException('type is not defined'); | |||
| } | |||
| $type = $config['type']; | |||
| if (isset($this->drivers[$type])) { | |||
| return $this->drivers[$type]($config); | |||
| } | |||
| throw new DriverException('unrecognised type'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,44 @@ | |||
| <?php | |||
| namespace Framework\Filesystem\Driver; | |||
| use League\Flysystem\Filesystem; | |||
| abstract class Driver | |||
| { | |||
| protected Filesystem $filesystem; | |||
| public function __construct(array $config) | |||
| { | |||
| $this->filesystem = $this->connect($config); | |||
| } | |||
| abstract protected function connect(array $config): Filesystem; | |||
| public function list(string $path, bool $recursive = false): iterable | |||
| { | |||
| return $this->filesystem->listContents($path, $recursive); | |||
| } | |||
| public function exists(string $path): bool | |||
| { | |||
| return $this->filesystem->fileExists($path); | |||
| } | |||
| public function get(string $path): string | |||
| { | |||
| return $this->filesystem->read($path); | |||
| } | |||
| public function put(string $path, mixed $value): static | |||
| { | |||
| $this->filesystem->write($path, $value); | |||
| return $this; | |||
| } | |||
| public function delete(string $path): static | |||
| { | |||
| $this->filesystem->delete($path); | |||
| return $this; | |||
| } | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| <?php | |||
| namespace Framework\Filesystem\Driver; | |||
| use League\Flysystem\Filesystem; | |||
| use League\Flysystem\Local\LocalFilesystemAdapter; | |||
| class LocalDriver extends Driver | |||
| { | |||
| protected function connect(array $config): Filesystem | |||
| { | |||
| $adapter = new LocalFilesystemAdapter($config['path']); | |||
| return new Filesystem($adapter); | |||
| } | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| <?php | |||
| namespace Framework\Filesystem\Exception; | |||
| use RuntimeException; | |||
| class DriverException extends RuntimeException | |||
| { | |||
| } | |||
| @@ -0,0 +1,34 @@ | |||
| <?php | |||
| namespace Framework\Filesystem; | |||
| use Closure; | |||
| use Framework\Filesystem\Driver\Driver; | |||
| use Framework\Filesystem\Exception\DriverException; | |||
| use Framework\Support\DriverFactory; | |||
| class Factory implements DriverFactory | |||
| { | |||
| protected array $drivers; | |||
| public function addDriver(string $alias, Closure $driver): static | |||
| { | |||
| $this->drivers[$alias] = $driver; | |||
| return $this; | |||
| } | |||
| public function connect(array $config): Driver | |||
| { | |||
| if (!isset($config['type'])) { | |||
| throw new DriverException('type is not defined'); | |||
| } | |||
| $type = $config['type']; | |||
| if (isset($this->drivers[$type])) { | |||
| return $this->drivers[$type]($config); | |||
| } | |||
| throw new DriverException('unrecognised type'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,104 @@ | |||
| <?php | |||
| namespace Framework\Http; | |||
| use InvalidArgumentException; | |||
| class Response | |||
| { | |||
| const REDIRECT = 'REDIRECT'; | |||
| const HTML = 'HTML'; | |||
| const JSON = 'JSON'; | |||
| private string $type = 'HTML'; | |||
| private ?string $redirect = null; | |||
| private mixed $content = ''; | |||
| private int $status = 200; | |||
| private array $headers = []; | |||
| public function content(mixed $content = null): mixed | |||
| { | |||
| if (is_null($content)) { | |||
| return $this->content; | |||
| } | |||
| $this->content = $content; | |||
| return $this; | |||
| } | |||
| public function status(int $status = null): int|static | |||
| { | |||
| if (is_null($status)) { | |||
| return $this->status; | |||
| } | |||
| $this->status = $status; | |||
| return $this; | |||
| } | |||
| public function header(string $key, string $value): static | |||
| { | |||
| $this->headers[$key] = $value; | |||
| return $this; | |||
| } | |||
| public function redirect(string $redirect = null): mixed | |||
| { | |||
| if (is_null($redirect)) { | |||
| return $this->redirect; | |||
| } | |||
| $this->redirect = $redirect; | |||
| $this->type = static::REDIRECT; | |||
| return $this; | |||
| } | |||
| public function json(mixed $content): static | |||
| { | |||
| $this->content = $content; | |||
| $this->type = static::JSON; | |||
| return $this; | |||
| } | |||
| public function type(string $type = null): string|static | |||
| { | |||
| if (is_null($type)) { | |||
| return $this->type; | |||
| } | |||
| $this->type = $type; | |||
| return $this; | |||
| } | |||
| public function send(): void | |||
| { | |||
| foreach ($this->headers as $key => $value) { | |||
| header("{$key}: {$value}"); | |||
| } | |||
| if ($this->type === static::HTML) { | |||
| header('Content-Type: text/html'); | |||
| http_response_code($this->status); | |||
| print $this->content; | |||
| return; | |||
| } | |||
| if ($this->type === static::JSON) { | |||
| header('Content-Type: application/json'); | |||
| http_response_code($this->status); | |||
| print json_encode($this->content); | |||
| return; | |||
| } | |||
| if ($this->type === static::REDIRECT) { | |||
| header("Location: {$this->redirect}"); | |||
| return; | |||
| } | |||
| throw new InvalidArgumentException("{$this->type} is not a recognised type"); | |||
| } | |||
| } | |||
| @@ -0,0 +1,10 @@ | |||
| <?php | |||
| namespace Framework\Logging\Driver; | |||
| interface Driver | |||
| { | |||
| public function info(string $message): static; | |||
| public function warning(string $message): static; | |||
| public function error(string $message): static; | |||
| } | |||
| @@ -0,0 +1,44 @@ | |||
| <?php | |||
| namespace Framework\Logging\Driver; | |||
| use Monolog\Logger; | |||
| use Monolog\Handler\StreamHandler; | |||
| class StreamDriver implements Driver | |||
| { | |||
| private array $config; | |||
| public function __construct(array $config) | |||
| { | |||
| $this->config = $config; | |||
| } | |||
| public function info(string $message): static | |||
| { | |||
| $this->logger()->info($message); | |||
| return $this; | |||
| } | |||
| private function logger() | |||
| { | |||
| if (!isset($this->logger)) { | |||
| $this->logger = new Logger($this->config['name']); | |||
| $this->logger->pushHandler(new StreamHandler($this->config['path'], $this->config['minimum'])); | |||
| } | |||
| return $this->logger; | |||
| } | |||
| public function warning(string $message): static | |||
| { | |||
| $this->logger()->warning($message); | |||
| return $this; | |||
| } | |||
| public function error(string $message): static | |||
| { | |||
| $this->logger()->error($message); | |||
| return $this; | |||
| } | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| <?php | |||
| namespace Framework\Logging\Exception; | |||
| use RuntimeException; | |||
| class DriverException extends RuntimeException | |||
| { | |||
| } | |||
| @@ -0,0 +1,34 @@ | |||
| <?php | |||
| namespace Framework\Logging; | |||
| use Closure; | |||
| use Framework\Logging\Driver\Driver; | |||
| use Framework\Logging\Exception\DriverException; | |||
| use Framework\Support\DriverFactory; | |||
| class Factory implements DriverFactory | |||
| { | |||
| protected array $drivers; | |||
| public function addDriver(string $alias, Closure $driver): static | |||
| { | |||
| $this->drivers[$alias] = $driver; | |||
| return $this; | |||
| } | |||
| public function connect(array $config): Driver | |||
| { | |||
| if (!isset($config['type'])) { | |||
| throw new DriverException('type is not defined'); | |||
| } | |||
| $type = $config['type']; | |||
| if (isset($this->drivers[$type])) { | |||
| return $this->drivers[$type]($config); | |||
| } | |||
| throw new DriverException('unrecognised type'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,38 @@ | |||
| <?php | |||
| namespace Framework\Provider; | |||
| use Framework\Cache\Factory; | |||
| use Framework\Cache\Driver\FileDriver; | |||
| use Framework\Cache\Driver\MemcacheDriver; | |||
| use Framework\Cache\Driver\MemoryDriver; | |||
| use Framework\Support\DriverProvider; | |||
| use Framework\Support\DriverFactory; | |||
| class CacheProvider extends DriverProvider | |||
| { | |||
| protected function name(): string | |||
| { | |||
| return 'cache'; | |||
| } | |||
| protected function factory(): DriverFactory | |||
| { | |||
| return new Factory(); | |||
| } | |||
| protected function drivers(): array | |||
| { | |||
| return [ | |||
| 'file' => function($config) { | |||
| return new FileDriver($config); | |||
| }, | |||
| 'memcache' => function($config) { | |||
| return new MemcacheDriver($config); | |||
| }, | |||
| 'memory' => function($config) { | |||
| return new MemoryDriver($config); | |||
| }, | |||
| ]; | |||
| } | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| <?php | |||
| namespace Framework\Provider; | |||
| use Framework\App; | |||
| use Framework\Support\Config; | |||
| class ConfigProvider | |||
| { | |||
| public function bind(App $app): void | |||
| { | |||
| $app->bind('config', function($app) { | |||
| return new Config(); | |||
| }); | |||
| } | |||
| } | |||
| @@ -0,0 +1,34 @@ | |||
| <?php | |||
| namespace Framework\Provider; | |||
| use Framework\Database\Factory; | |||
| use Framework\Database\Connection\MysqlConnection; | |||
| use Framework\Database\Connection\SqliteConnection; | |||
| use Framework\Support\DriverProvider; | |||
| use Framework\Support\DriverFactory; | |||
| class DatabaseProvider extends DriverProvider | |||
| { | |||
| protected function name(): string | |||
| { | |||
| return 'database'; | |||
| } | |||
| protected function factory(): DriverFactory | |||
| { | |||
| return new Factory(); | |||
| } | |||
| protected function drivers(): array | |||
| { | |||
| return [ | |||
| 'sqlite' => function($config) { | |||
| return new SqliteConnection($config); | |||
| }, | |||
| 'mysql' => function($config) { | |||
| return new MysqlConnection($config); | |||
| }, | |||
| ]; | |||
| } | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| <?php | |||
| namespace Framework\Provider; | |||
| use Framework\Email\Factory; | |||
| use Framework\Email\Driver\PostmarkDriver; | |||
| use Framework\Support\DriverProvider; | |||
| use Framework\Support\DriverFactory; | |||
| class EmailProvider extends DriverProvider | |||
| { | |||
| protected function name(): string | |||
| { | |||
| return 'email'; | |||
| } | |||
| protected function factory(): DriverFactory | |||
| { | |||
| return new Factory(); | |||
| } | |||
| protected function drivers(): array | |||
| { | |||
| return [ | |||
| 'postmark' => function($config) { | |||
| return new PostmarkDriver($config); | |||
| }, | |||
| ]; | |||
| } | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| <?php | |||
| namespace Framework\Provider; | |||
| use Framework\Filesystem\Factory; | |||
| use Framework\Filesystem\Driver\LocalDriver; | |||
| use Framework\Support\DriverProvider; | |||
| use Framework\Support\DriverFactory; | |||
| class FilesystemProvider extends DriverProvider | |||
| { | |||
| protected function name(): string | |||
| { | |||
| return 'filesystem'; | |||
| } | |||
| protected function factory(): DriverFactory | |||
| { | |||
| return new Factory(); | |||
| } | |||
| protected function drivers(): array | |||
| { | |||
| return [ | |||
| 'local' => function($config) { | |||
| return new LocalDriver($config); | |||
| }, | |||
| ]; | |||
| } | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| <?php | |||
| namespace Framework\Provider; | |||
| use Framework\Logging\Factory; | |||
| use Framework\Logging\Driver\StreamDriver; | |||
| use Framework\Support\DriverProvider; | |||
| use Framework\Support\DriverFactory; | |||
| class LoggingProvider extends DriverProvider | |||
| { | |||
| protected function name(): string | |||
| { | |||
| return 'logging'; | |||
| } | |||
| protected function factory(): DriverFactory | |||
| { | |||
| return new Factory(); | |||
| } | |||
| protected function drivers(): array | |||
| { | |||
| return [ | |||
| 'stream' => function($config) { | |||
| return new StreamDriver($config); | |||
| }, | |||
| ]; | |||
| } | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| <?php | |||
| namespace Framework\Provider; | |||
| use Framework\Queue\Factory; | |||
| use Framework\Queue\Driver\DatabaseDriver; | |||
| use Framework\Support\DriverProvider; | |||
| use Framework\Support\DriverFactory; | |||
| class QueueProvider extends DriverProvider | |||
| { | |||
| protected function name(): string | |||
| { | |||
| return 'queue'; | |||
| } | |||
| protected function factory(): DriverFactory | |||
| { | |||
| return new Factory(); | |||
| } | |||
| protected function drivers(): array | |||
| { | |||
| return [ | |||
| 'database' => function($config) { | |||
| return new DatabaseDriver($config); | |||
| }, | |||
| ]; | |||
| } | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| <?php | |||
| namespace Framework\Provider; | |||
| use Framework\App; | |||
| use Framework\Http\Response; | |||
| class ResponseProvider | |||
| { | |||
| public function bind(App $app): void | |||
| { | |||
| $app->bind('response', function($app) { | |||
| return new Response(); | |||
| }); | |||
| } | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| <?php | |||
| namespace Framework\Provider; | |||
| use Framework\Session\Factory; | |||
| use Framework\Session\Driver\NativeDriver; | |||
| use Framework\Support\DriverProvider; | |||
| use Framework\Support\DriverFactory; | |||
| class SessionProvider extends DriverProvider | |||
| { | |||
| protected function name(): string | |||
| { | |||
| return 'session'; | |||
| } | |||
| protected function factory(): DriverFactory | |||
| { | |||
| return new Factory(); | |||
| } | |||
| protected function drivers(): array | |||
| { | |||
| return [ | |||
| 'native' => function($config) { | |||
| return new NativeDriver($config); | |||
| }, | |||
| ]; | |||
| } | |||
| } | |||
Powered by TurnKey Linux.