Skip to content

Commit 54b241f

Browse files
committed
feat: add database, query builder and migration support
1 parent 486811c commit 54b241f

File tree

9 files changed

+724
-2
lines changed

9 files changed

+724
-2
lines changed

composer.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
"maplephp/prompts": "^1.2",
3030
"maplephp/cache": "^2.0",
3131
"maplephp/log": "^v2.0",
32-
"nikic/fast-route": "^1.3"
32+
"nikic/fast-route": "^1.3",
33+
"doctrine/dbal": "^4.4",
34+
"doctrine/migrations": "^3.9"
3335
},
3436
"require-dev": {
3537
"maplephp/unitary": "^2.0"
@@ -39,7 +41,7 @@
3941
"src/Support/functions.php"
4042
],
4143
"psr-4": {
42-
"MaplePHP\\Core\\": "src"
44+
"MaplePHP\\Core\\": "src/"
4345
}
4446
},
4547
"extra": {

src/Providers/DatabaseProvider.php

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MaplePHP\Core\Providers;
6+
7+
use Psr\Container\ContainerInterface;
8+
use Doctrine\DBAL\DriverManager;
9+
use MaplePHP\Core\Support\ServiceProvider;
10+
use MaplePHP\Core\Support\Database\DB;
11+
use RuntimeException;
12+
13+
class DatabaseProvider extends ServiceProvider
14+
{
15+
private ContainerInterface $container;
16+
17+
/**
18+
* Register a database connection
19+
*/
20+
public function register(ContainerInterface $container): void
21+
{
22+
$this->container = $container;
23+
$config = $container->get('config');
24+
$default = ($config['database']['default'] ?? null);
25+
if (!empty($default)) {
26+
$dbConfig = $this->resolveConnection($config);
27+
$conn = DriverManager::getConnection($dbConfig);
28+
$container->set("db.connection", $conn);
29+
}
30+
}
31+
32+
/**
33+
* Pass connection to DB helper class in boot
34+
*/
35+
public function boot(): void
36+
{
37+
if ($this->container->has("db.connection")) {
38+
DB::boot($this->container->get("db.connection"));
39+
}
40+
}
41+
42+
/**
43+
* Resolve the correct connection config based on the default driver
44+
*/
45+
private function resolveConnection(array $config): array
46+
{
47+
$database = $config['database'];
48+
$default = $database['default'];
49+
$connections = $database['connections'] ?? [];
50+
51+
if (!isset($connections[$default])) {
52+
throw new RuntimeException("Database connection '{$default}' is not defined in config.");
53+
}
54+
55+
$dbConfig = $connections[$default];
56+
57+
if ($dbConfig['driver'] === 'pdo_sqlite' && !($dbConfig['memory'] ?? false)) {
58+
$dbConfig = $this->resolveSQLitePath($dbConfig, $config['dir']);
59+
}
60+
61+
return $dbConfig;
62+
}
63+
64+
/**
65+
* Resolve and touch the SQLite file, returning updated config
66+
*/
67+
private function resolveSQLitePath(array $dbConfig, string $baseDir): array
68+
{
69+
$dbDir = rtrim($baseDir, '/') . '/database';
70+
$basename = basename($dbConfig['file'] ?? 'database.sqlite');
71+
$dbPath = $dbDir . '/' . $basename;
72+
$extention = pathinfo($basename, PATHINFO_EXTENSION);
73+
74+
if ($extention !== "sqlite") {
75+
throw new RuntimeException("The SQLite file ($basename) is missing the extension '.sqlite'.");
76+
}
77+
78+
if (!is_file($dbPath)) {
79+
if (!is_dir($dbDir) && !mkdir($dbDir, 0755, true)) {
80+
throw new RuntimeException("Failed to create database directory: $dbDir");
81+
}
82+
if (file_put_contents($dbPath, '') === false) {
83+
throw new RuntimeException("Failed to create SQLite database file: $dbPath");
84+
}
85+
}
86+
87+
$dbConfig['path'] = $dbPath;
88+
unset($dbConfig['file']);
89+
90+
return $dbConfig;
91+
}
92+
}

src/Router/console.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77

88
use MaplePHP\Core\Router\RouterDispatcher;
99
use MaplePHP\Core\Routing\Serve\ServeController;
10+
use MaplePHP\Core\Routing\Migrations\MigrateController;
1011

1112
$router->cli("/serve", [ServeController::class, "index"]);
1213

14+
$router->cli("/migrate", [MigrateController::class, "index"]);
15+
$router->cli("/migrate:up", [MigrateController::class, "up"]);
16+
$router->cli("/migrate:down", [MigrateController::class, "down"]);
17+
$router->cli("/migrate:fresh", [MigrateController::class, "fresh"]);
18+
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MaplePHP\Core\Routing\Migrations;
6+
7+
use RuntimeException;
8+
use Psr\Http\Message\ResponseInterface;
9+
use Doctrine\DBAL\Schema\Schema;
10+
use Doctrine\DBAL\Schema\AbstractSchemaManager;
11+
use MaplePHP\Core\App;
12+
use MaplePHP\Core\Support\Database\Migrations;
13+
use MaplePHP\Core\Routing\DefaultShellController;
14+
use MaplePHP\Core\Support\Database\DB;
15+
16+
class MigrateController extends DefaultShellController
17+
{
18+
private ?AbstractSchemaManager $schemaManager = null;
19+
20+
/**
21+
* Run ALL migrations (up), or a single one if --name is provided.
22+
*/
23+
public function index(ResponseInterface $response): ResponseInterface
24+
{
25+
if (!$this->confirm("Are you sure you want to run ALL migrations?")) {
26+
return $response;
27+
}
28+
29+
$this->runDirection('up');
30+
31+
return $response;
32+
}
33+
34+
/**
35+
* Run migrations up, or a single one if --name is provided.
36+
*/
37+
public function up(ResponseInterface $response): ResponseInterface
38+
{
39+
if (!$this->confirm("Are you sure you want to run migration UP?")) {
40+
return $response;
41+
}
42+
43+
$this->runDirection('up');
44+
45+
return $response;
46+
}
47+
48+
/**
49+
* Run migrations down, or a single one if --name is provided.
50+
*/
51+
public function down(ResponseInterface $response): ResponseInterface
52+
{
53+
if (!$this->confirm("Are you sure you want to run migration DOWN?")) {
54+
return $response;
55+
}
56+
57+
$this->runDirection('down');
58+
59+
return $response;
60+
}
61+
62+
/**
63+
* Roll back and re-run migrations (down + up), or a single one if --name is provided.
64+
*/
65+
public function fresh(ResponseInterface $response): ResponseInterface
66+
{
67+
if (!$this->confirm("Are you sure you want to refresh migration?")) {
68+
return $response;
69+
}
70+
71+
$this->runDirection('down');
72+
$this->runDirection('up');
73+
74+
return $response;
75+
}
76+
77+
// -------------------------------------------------------------------------
78+
// Private helpers
79+
// -------------------------------------------------------------------------
80+
81+
/**
82+
* Run all migration files in the given direction, or only the one
83+
* matching --name if supplied.
84+
*/
85+
private function runDirection(string $direction): void
86+
{
87+
$name = !empty($this->args['name']) ? strtolower($this->args['name']) : null;
88+
$files = $this->getMigrationFiles();
89+
90+
if ($name !== null) {
91+
$files = array_filter($files, fn(string $file) => strtolower(pathinfo($file, PATHINFO_FILENAME)) === $name);
92+
93+
if (empty($files)) {
94+
throw new RuntimeException("No migration file found matching \"$name\".");
95+
}
96+
}
97+
98+
foreach ($files as $file) {
99+
$this->runMigration($file, $direction);
100+
}
101+
}
102+
103+
/**
104+
* Instantiate, execute, and persist a single migration file.
105+
*/
106+
private function runMigration(string $file, string $direction): void
107+
{
108+
$base = pathinfo($file, PATHINFO_FILENAME);
109+
$class = "\\Migrations\\" . ucfirst($base);
110+
111+
if (!class_exists($class)) {
112+
throw new RuntimeException("Migration class $class does not exist.");
113+
}
114+
115+
$inst = new $class();
116+
if (!($inst instanceof Migrations)) {
117+
throw new RuntimeException("Migration class must extend: " . Migrations::class);
118+
}
119+
120+
$schemaManager = $this->getSchemaManager();
121+
$currentSchema = $schemaManager->introspectSchema();
122+
$targetSchema = clone $currentSchema;
123+
124+
$inst->{$direction}($targetSchema);
125+
126+
$this->executeSchema($currentSchema, $targetSchema);
127+
128+
$label = strtoupper($direction);
129+
$this->command->approve("Executed migration $label: $class");
130+
}
131+
132+
/**
133+
* Diff two schemas and execute the resulting SQL, if any.
134+
*/
135+
private function executeSchema(Schema $fromSchema, Schema $toSchema): void
136+
{
137+
$connection = DB::getConnection();
138+
$diff = $this->getSchemaManager()->createComparator()->compareSchemas($fromSchema, $toSchema);
139+
$statements = $connection->getDatabasePlatform()->getAlterSchemaSQL($diff);
140+
141+
if (empty($statements)) {
142+
return;
143+
}
144+
145+
foreach ($statements as $sql) {
146+
if (isset($this->args['read'])) {
147+
$this->command->message($sql);
148+
} else {
149+
$connection->executeStatement($sql);
150+
}
151+
}
152+
}
153+
154+
/**
155+
* Lazy-load and cache the schema manager.
156+
*/
157+
private function getSchemaManager(): AbstractSchemaManager
158+
{
159+
if ($this->schemaManager === null) {
160+
$this->schemaManager = DB::getConnection()->createSchemaManager();
161+
}
162+
163+
return $this->schemaManager;
164+
}
165+
166+
/**
167+
* Return all migration files, sorted for deterministic order.
168+
*/
169+
private function getMigrationFiles(): array
170+
{
171+
$migDir = App::get()->dir()->migrations();
172+
173+
if (!is_dir($migDir)) {
174+
mkdir($migDir, 0755, true);
175+
}
176+
177+
$files = glob($migDir . "/*.php") ?: [];
178+
sort($files);
179+
180+
return $files;
181+
}
182+
183+
/**
184+
* Show a confirmation prompt and abort with a message if declined.
185+
*/
186+
private function confirm(string $question): bool
187+
{
188+
if ($this->command->confirm($question)) {
189+
return true;
190+
}
191+
192+
$this->command->message("");
193+
$this->command->message($this->command->getAnsi()->yellow("Aborting migrations..."));
194+
$this->command->message("");
195+
196+
return false;
197+
}
198+
}

0 commit comments

Comments
 (0)