From 581b330c26d8864d688d31982b7be129c9b8bcfd Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Tue, 21 Jul 2026 06:11:09 -0400 Subject: [PATCH] Switch default database to SQLite and add phpLiteAdmin Sets sqlite as the default DB_CONNECTION, teaches SqliteMigration how to alter/drop columns via SQLite's table-rebuild pattern (previously unsupported), and fixes container startup so the sqlite file is owned by www-data instead of root (was causing "readonly database" errors on writes). Also adds phpLiteAdmin on port 8081 for browsing the sqlite database, with a fix for nginx dropping the port from absolute-URL redirects. Co-Authored-By: Claude Sonnet 5 --- .env.example | 2 +- Dockerfile | 6 + config/database.php | 2 +- docker-compose.yml | 4 +- docker/nginx/phpliteadmin.conf | 27 +++ docker/phpliteadmin/phpliteadmin.config.php | 5 + docker/start-container.sh | 16 ++ .../Database/Migration/SqliteMigration.php | 157 ++++++++++++++---- readme.md | 24 +++ 9 files changed, 204 insertions(+), 39 deletions(-) create mode 100644 docker/nginx/phpliteadmin.conf create mode 100644 docker/phpliteadmin/phpliteadmin.config.php diff --git a/.env.example b/.env.example index fc22301..9e22b62 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ APP_ENV= APP_HOST= APP_PORT= -DB_CONNECTION=mysql +DB_CONNECTION=sqlite DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=pro-php-mvc diff --git a/Dockerfile b/Dockerfile index 17c4568..d312c0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,14 +8,20 @@ RUN apt-get update \ COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer +RUN git clone --depth 1 https://github.com/phpLiteAdmin/pla.git /var/www/phpliteadmin \ + && rm -rf /var/www/phpliteadmin/.git /var/www/phpliteadmin/docs + WORKDIR /var/www/html COPY . /var/www/html COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf +COPY docker/nginx/phpliteadmin.conf /etc/nginx/conf.d/phpliteadmin.conf COPY docker/php/app.ini /usr/local/etc/php/conf.d/app.ini +COPY docker/phpliteadmin/phpliteadmin.config.php /var/www/phpliteadmin/phpliteadmin.config.php COPY docker/start-container.sh /usr/local/bin/start-container RUN chmod +x /usr/local/bin/start-container \ + && chown -R www-data:www-data /var/www/phpliteadmin \ && mkdir -p /run/php EXPOSE 80 diff --git a/config/database.php b/config/database.php index 956df41..fcdab2e 100644 --- a/config/database.php +++ b/config/database.php @@ -1,7 +1,7 @@ env('DB_CONNECTION', 'mysql'), + 'default' => env('DB_CONNECTION', 'sqlite'), 'mysql' => [ 'type' => 'mysql', 'host' => env('DB_HOST', '127.0.0.1'), diff --git a/docker-compose.yml b/docker-compose.yml index adf8f0c..49ee8a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - db ports: - "8080:80" + - "8081:8081" volumes: - ./:/var/www/html - vendor_data:/var/www/html/vendor @@ -16,13 +17,14 @@ services: APP_ENV: dev APP_HOST: 0.0.0.0 APP_PORT: 80 - DB_CONNECTION: mysql + DB_CONNECTION: sqlite DB_HOST: db DB_PORT: 3306 DB_DATABASE: pro-php-mvc DB_USERNAME: root DB_PASSWORD: root COMPOSER_ALLOW_SUPERUSER: 1 + PHPLITEADMIN_PASSWORD: admin db: image: mysql:8.0 diff --git a/docker/nginx/phpliteadmin.conf b/docker/nginx/phpliteadmin.conf new file mode 100644 index 0000000..86daa31 --- /dev/null +++ b/docker/nginx/phpliteadmin.conf @@ -0,0 +1,27 @@ +server { + listen 8081; + server_name _; + + root /var/www/phpliteadmin; + 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_params strips the port from HTTP_HOST for security; reattach + # nginx's own listening port (not the client-supplied one) so + # phpLiteAdmin's absolute redirects/links keep the :8081. + fastcgi_param HTTP_HOST $host:$server_port; + fastcgi_pass 127.0.0.1:9000; + } + + location ~ /\. { + deny all; + } +} diff --git a/docker/phpliteadmin/phpliteadmin.config.php b/docker/phpliteadmin/phpliteadmin.config.php new file mode 100644 index 0000000..a165a55 --- /dev/null +++ b/docker/phpliteadmin/phpliteadmin.config.php @@ -0,0 +1,5 @@ +query("SELECT name FROM sqlite_master WHERE type='\''table'\'' AND name='\''migrations'\''"); + + exit($statement->fetch() ? 0 : 1); + '; then + php command.php migrate + fi +fi + if [ "${DB_CONNECTION:-mysql}" = "mysql" ]; then php -r ' $host = getenv("DB_HOST") ?: "db"; diff --git a/framework/Database/Migration/SqliteMigration.php b/framework/Database/Migration/SqliteMigration.php index 765704d..52ba7f3 100644 --- a/framework/Database/Migration/SqliteMigration.php +++ b/framework/Database/Migration/SqliteMigration.php @@ -18,6 +18,7 @@ class SqliteMigration extends Migration protected SqliteConnection $connection; protected string $table; protected string $type; + protected array $drops = []; public function __construct(SqliteConnection $connection, string $table, string $type) { @@ -28,52 +29,135 @@ class SqliteMigration extends Migration public function execute() { - $command = $this->type === 'create' ? '' : 'ALTER TABLE'; + if ($this->type === 'create') { + $this->executeCreate(); + return; + } - $fields = array_map(fn($field) => $this->stringForField($field), $this->fields); + $hasAlteredField = array_reduce($this->fields, fn($carry, $field) => $carry || $field->alter, false); - if ($this->type === 'create') { - $fields = join(',' . PHP_EOL, $fields); + if (count($this->drops) > 0 || $hasAlteredField) { + $this->executeRebuild(); + return; + } + + $this->executeAdd(); + } + + private function executeCreate() + { + $fields = join(',' . PHP_EOL, array_map(fn($field) => $this->columnDefinition($field), $this->fields)); + + $query = " + CREATE TABLE \"{$this->table}\" ( + {$fields} + ); + "; + + $this->connection->pdo()->prepare($query)->execute(); + } - $query = " - CREATE TABLE \"{$this->table}\" ( - {$fields} - ); - "; + private function executeAdd() + { + $fields = join(';' . PHP_EOL, array_map( + fn($field) => "ADD COLUMN {$this->columnDefinition($field)}", + $this->fields + )); + + $query = " + ALTER TABLE \"{$this->table}\" + {$fields}; + "; + + $this->connection->pdo()->prepare($query)->execute(); + } + + /** + * SQLite can't alter or drop columns in place, so the standard approach is to + * rebuild the table: create a new table with the desired schema, copy the + * surviving data across, then swap it in for the original. + */ + private function executeRebuild() + { + $pdo = $this->connection->pdo(); + + $existingColumns = $pdo->query("PRAGMA table_info(\"{$this->table}\")")->fetchAll(); + + $alteredFields = []; + foreach ($this->fields as $field) { + if ($field->alter) { + $alteredFields[$field->name] = $field; + } } - if ($this->type === 'alter') { - $fields = join(';' . PHP_EOL, $fields); + $newFields = array_filter($this->fields, fn($field) => !$field->alter); + + $keptColumnNames = []; + $definitions = []; - $query = " - ALTER TABLE \"{$this->table}\" - {$fields}; - "; + foreach ($existingColumns as $column) { + $name = $column['name']; + + if (in_array($name, $this->drops)) { + continue; + } + + $keptColumnNames[] = $name; + + $definitions[] = isset($alteredFields[$name]) + ? $this->columnDefinition($alteredFields[$name]) + : $this->existingColumnDefinition($column); + } + + foreach ($newFields as $field) { + $definitions[] = $this->columnDefinition($field); } - $statement = $this->connection->pdo()->prepare($query); - $statement->execute(); + $tempTable = "{$this->table}_migration_tmp"; + $definitionList = join(',' . PHP_EOL, $definitions); + $columnList = join(', ', array_map(fn($name) => "\"{$name}\"", $keptColumnNames)); + + $pdo->beginTransaction(); + + $pdo->exec("CREATE TABLE \"{$tempTable}\" ({$definitionList})"); + $pdo->exec("INSERT INTO \"{$tempTable}\" ({$columnList}) SELECT {$columnList} FROM \"{$this->table}\""); + $pdo->exec("DROP TABLE \"{$this->table}\""); + $pdo->exec("ALTER TABLE \"{$tempTable}\" RENAME TO \"{$this->table}\""); + + $pdo->commit(); } - private function stringForField(Field $field): string + private function existingColumnDefinition(array $column): string { - $prefix = ''; + $name = $column['name']; + $type = $column['type']; - if ($this->type === 'alter') { - $prefix = 'ADD COLUMN'; + if ($column['pk'] == 1 && $type === 'INTEGER') { + return "\"{$name}\" INTEGER PRIMARY KEY AUTOINCREMENT"; } - if ($field->alter) { - throw new MigrationException('SQLite doesn\'t support altering columns'); + $definition = "\"{$name}\" {$type}"; + + if ($column['notnull'] == 1) { + $definition .= " NOT NULL"; } + if ($column['dflt_value'] !== null) { + $definition .= " DEFAULT {$column['dflt_value']}"; + } + + return $definition; + } + + private function columnDefinition(Field $field): string + { if ($field instanceof BoolField) { - $template = "{$prefix} \"{$field->name}\" INTEGER"; + $template = "\"{$field->name}\" INTEGER"; if (!$field->nullable) { $template .= " NOT NULL"; } - + if ($field->default !== null) { $default = (int) $field->default; $template .= " DEFAULT {$default}"; @@ -83,12 +167,12 @@ class SqliteMigration extends Migration } if ($field instanceof DateTimeField) { - $template = "{$prefix} \"{$field->name}\" TEXT"; + $template = "\"{$field->name}\" TEXT"; if (!$field->nullable) { $template .= " NOT NULL"; } - + if ($field->default === 'CURRENT_TIMESTAMP') { $template .= " DEFAULT CURRENT_TIMESTAMP"; } else if ($field->default !== null) { @@ -99,12 +183,12 @@ class SqliteMigration extends Migration } if ($field instanceof FloatField) { - $template = "{$prefix} \"{$field->name}\" REAL"; + $template = "\"{$field->name}\" REAL"; if (!$field->nullable) { $template .= " NOT NULL"; } - + if ($field->default !== null) { $template .= " DEFAULT {$field->default}"; } @@ -113,16 +197,16 @@ class SqliteMigration extends Migration } if ($field instanceof IdField) { - return "{$prefix} \"{$field->name}\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE"; + return "\"{$field->name}\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE"; } if ($field instanceof IntField) { - $template = "{$prefix} \"{$field->name}\" INTEGER"; + $template = "\"{$field->name}\" INTEGER"; if (!$field->nullable) { $template .= " NOT NULL"; } - + if ($field->default !== null) { $template .= " DEFAULT {$field->default}"; } @@ -131,14 +215,14 @@ class SqliteMigration extends Migration } if ($field instanceof StringField || $field instanceof TextField) { - $template = "{$prefix} \"{$field->name}\" TEXT"; + $template = "\"{$field->name}\" TEXT"; if (!$field->nullable) { $template .= " NOT NULL"; } - + if ($field->default !== null) { - $template .= " DEFAULT '{$field->default}'"; + $template .= " DEFAULT '{$field->default}'"; } return $template; @@ -149,6 +233,7 @@ class SqliteMigration extends Migration public function dropColumn(string $name): static { - throw new MigrationException('SQLite doesn\'t support dropping columns'); + $this->drops[] = $name; + return $this; } } diff --git a/readme.md b/readme.md index 47364f1..676c801 100644 --- a/readme.md +++ b/readme.md @@ -11,3 +11,27 @@ To make requests with cURL, try: ``` curl -X DELETE http://127.0.0.1:8000/old-home ``` + +## Running with Docker + +``` +docker compose up -d --build +``` + +This starts two services: + +- **app** — nginx + php-fpm, served at http://localhost:8080 +- **db** — MySQL 8, exposed on host port `33060` (only used if `DB_CONNECTION=mysql`) + +The default database connection is **SQLite** (`database/database.sqlite`), configured via `DB_CONNECTION` in `.env` / `docker-compose.yml`. On first boot the `app` container automatically creates the SQLite file with the correct permissions and runs migrations. To switch to MySQL instead, set `DB_CONNECTION=mysql` in `docker-compose.yml`. + +To run migrations manually: + +``` +docker compose exec app php command.php migrate +docker compose exec app php command.php migrate --fresh # drops all tables first +``` + +### phpLiteAdmin + +A SQLite admin UI is available at http://localhost:8081, password `admin` (change it via the `PHPLITEADMIN_PASSWORD` environment variable in `docker-compose.yml`). It's a development-only tool — don't expose port 8081 outside your local machine.