diff --git a/src/Console/Command/MigrationCommand.php b/src/Console/Command/MigrationCommand.php index 12500071..be27d1c0 100644 --- a/src/Console/Command/MigrationCommand.php +++ b/src/Console/Command/MigrationCommand.php @@ -50,46 +50,52 @@ public function reset(): void } /** - * Create a migration in both directions + * Run migration action (up, rollback, reset) * - * @param string $type + * @param string $type * @return void * @throws Exception */ private function factory(string $type): void { - $migrations = []; - // We include all migrations files and collect it for make great manage - foreach ($this->getMigrationFiles() as $file) { - $migrations[$file] = explode('.', basename($file))[0]; - } + $migrations = $this->collectMigrationFiles(); - // We create the migration database status - $this->createMigrationTable(); - $action = 'make' . strtoupper($type); + $connection = $this->arg->getParameter("--connection", config("database.default")); + - $this->$action($migrations); + try { + Database::connection($connection); + } catch (Exception $exception) { + throw new MigrationException($exception->getMessage(), (int)$exception->getCode()); + } + + try { + Database::startTransaction(); + // We create the migration database status + $this->createMigrationTable($connection); + + $action = 'make' . ucfirst($type); + if (!method_exists($this, $action)) { + throw new MigrationException("Migration action '$action' not found."); + } + $this->$action($migrations); + Database::commitTransaction(); + } catch (Exception $exception) { + Database::rollbackTransaction(); + throw new MigrationException($exception->getMessage(), (int)$exception->getCode()); + } } /** - * Create the migration status table + * Create the migration status table if it does not exist * * @return void * @throws ConnectionException */ private function createMigrationTable(): void { - $connection = $this->arg->getParameter("--connection", config("database.default")); - - try { - Database::connection($connection); - } catch (Exception $exception) { - echo Color::red("▶ Please check your database configuration on .env.json file\n"); - throw new MigrationException($exception->getMessage(), (int)$exception->getCode()); - } - $adapter = Database::getConnectionAdapter(); $table = $adapter->getTablePrefix() . config('database.migration', 'migrations'); @@ -121,9 +127,9 @@ private function createMigrationTable(): void } /** - * Up migration + * Run all up migrations * - * @param array $migrations + * @param array $migrations * @return void * @throws ConnectionException * @throws QueryBuilderException @@ -153,6 +159,7 @@ protected function makeUp(array $migrations): void (new $migration())->up(); } catch (Exception $exception) { $this->throwMigrationException($exception, $migration); + break; } // Create new migration status @@ -209,7 +216,7 @@ private function printExceptionMessage(string $message, string $migration): void $message = Color::red($message); $migration = Color::yellow($migration); - exit(sprintf("\nOn %s\n\n%s\n\n", $migration, $message)); + echo sprintf("\nOn %s\n\n%s\n\n", $migration, $message); } /** @@ -249,9 +256,9 @@ private function updateMigrationStatus(string $migration, int $batch): void } /** - * Rollback migration + * Rollback all migrations in batch 1 * - * @param array $migrations + * @param array $migrations * @return void * @throws ConnectionException * @throws QueryBuilderException @@ -288,6 +295,7 @@ protected function makeRollback(array $migrations): void (new $migration())->rollback(); } catch (Exception $exception) { $this->throwMigrationException($exception, $migration); + return; } break; @@ -311,9 +319,9 @@ protected function makeRollback(array $migrations): void } /** - * Reset migration + * Reset all migrations * - * @param array $migrations + * @param array $migrations * @return void * @throws ConnectionException * @throws QueryBuilderException @@ -347,6 +355,7 @@ protected function makeReset(array $migrations): void (new $migration())->rollback(); } catch (Exception $exception) { $this->throwMigrationException($exception, $migration); + break; } $this->getMigrationTable()->where('migration', $migration)->delete(); @@ -358,17 +367,31 @@ protected function makeReset(array $migrations): void } /** - * Get migration pattern + * Get migration file paths * * @return array */ private function getMigrationFiles(): array { $file_pattern = $this->setting->getMigrationDirectory() . strtolower("/*.php"); - return glob($file_pattern); } + /** + * Collect migration files as [file => className] + * + * @return array + */ + private function collectMigrationFiles(): array + { + $files = $this->getMigrationFiles(); + $migrations = []; + foreach ($files as $file) { + $migrations[$file] = explode('.', basename($file))[0]; + } + return $migrations; + } + /** * Get migration table * diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php index 86f71c82..93b4a389 100644 --- a/src/Database/Barry/Model.php +++ b/src/Database/Barry/Model.php @@ -22,11 +22,11 @@ use ReflectionClass; /** - * @method select(array|string[] $select) - * @method whereIn(string $primary_key, array $id) - * @method get() - * @method where(string $column, mixed $value) - * @method orderBy(string $latest, string $string) + * @method static select(array|string[] $select): Builder + * @method static whereIn(string $primary_key, array $id): Builder + * @method static all(): Collection + * @method static where(string $column, mixed $value): Builder + * @method static orderBy(string $latest, string $string): Builder */ abstract class Model implements ArrayAccess, JsonSerializable { @@ -188,7 +188,6 @@ public function getConnection(): ?string * Initialize the connection * * @return Builder - * @throws */ public static function query(): Builder { @@ -374,7 +373,6 @@ public static function retrieve( * Delete a record * * @return int - * @throws */ public function delete(): int { @@ -482,7 +480,6 @@ public static function create(array $data): Model * persist aliases on insert action * * @return int - * @throws */ public function persist(): int { @@ -601,7 +598,6 @@ private function transtypeKeyValue(mixed $primary_key_value): string|int|float * * @param array $attributes * @return int|bool - * @throws */ public function update(array $attributes): int|bool { @@ -666,7 +662,6 @@ public static function paginate(int $page_number, int $current = 0, ?int $chunk * Allows to associate listener * * @param callable $cb - * @throws */ public static function deleted(callable $cb): void { @@ -679,7 +674,6 @@ public static function deleted(callable $cb): void * Allows to associate listener * * @param callable $cb - * @throws */ public static function deleting(callable $cb): void { @@ -692,7 +686,6 @@ public static function deleting(callable $cb): void * Allows to associate a listener * * @param callable $cb - * @throws */ public static function creating(callable $cb): void { @@ -705,7 +698,6 @@ public static function creating(callable $cb): void * Allows to associate a listener * * @param callable $cb - * @throws */ public static function created(callable $cb): void { @@ -718,7 +710,6 @@ public static function created(callable $cb): void * Allows to associate a listener * * @param callable $cb - * @throws */ public static function updating(callable $cb): void { @@ -731,7 +722,6 @@ public static function updating(callable $cb): void * Allows to associate a listener * * @param callable $cb - * @throws */ public static function updated(callable $cb): void { @@ -760,7 +750,7 @@ public static function deleteBy(string $column, mixed $value): int * * @param string $name * @param array $arguments - * @return mixed + * @return Builder|Collection|Model|mixed */ public static function __callStatic(string $name, array $arguments) { @@ -1037,9 +1027,9 @@ private function executeDataCasting(string $name): mixed * Parse value to json * * @param string $value - * @return void + * @return mixed */ - private function parseToJson($value) + private function parseToJson($value): mixed { return json_decode( $value, diff --git a/src/Database/Database.php b/src/Database/Database.php index 22c9d254..791cb78b 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -425,6 +425,14 @@ public static function inTransaction(): bool * Validate a transaction */ public static function commit(): void + { + static::commitTransaction(); + } + + /** + * Validate a transaction + */ + public static function commitTransaction(): void { if (static::inTransaction()) { static::$adapter->getConnection()->commit(); @@ -435,6 +443,14 @@ public static function commit(): void * Cancel a transaction */ public static function rollback(): void + { + static::rollbackTransaction(); + } + + /** + * Cancel a transaction + */ + public static function rollbackTransaction(): void { if (static::inTransaction()) { static::$adapter->getConnection()->rollBack(); diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 6c07a4f3..c5f53bed 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -10,7 +10,6 @@ use Bow\Support\Str; use JsonSerializable; use PDO; -use PDOException; use PDOStatement; class QueryBuilder implements JsonSerializable @@ -120,6 +119,20 @@ class QueryBuilder implements JsonSerializable */ protected ?string $last_query = null; + /** + * Lock rows for update + * + * @var bool + */ + protected bool $lock_for_update = false; + + /** + * Lock rows in share mode + * + * @var bool + */ + protected bool $shared_lock = false; + /** * QueryBuilder Constructor * @@ -404,6 +417,20 @@ public function toSql(): string } } + // Adding the lock for update clause + if ($this->lock_for_update) { + $sql .= ' for update'; + + $this->lock_for_update = false; + } + + // Adding the shared lock clause + if ($this->shared_lock) { + $sql .= $this->adapter === 'pgsql' ? ' for share' : ' lock in share mode'; + + $this->shared_lock = false; + } + return $sql; } @@ -1083,6 +1110,30 @@ public function first(): ?object return $this->get(); } + /** + * Lock the selected rows for update + * + * @return QueryBuilder + */ + public function lockForUpdate(): QueryBuilder + { + $this->lock_for_update = true; + + return $this; + } + + /** + * Lock the selected rows in share mode + * + * @return QueryBuilder + */ + public function sharedLock(): QueryBuilder + { + $this->shared_lock = true; + + return $this; + } + /** * Take = Limit * diff --git a/src/Http/Request.php b/src/Http/Request.php index d581aa50..33bf2aad 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -500,7 +500,7 @@ public function wantsJson(): bool */ public function is(string $match): bool { - return (bool)preg_match('@' . addcslashes($match, "/*{()}[]$^") . '@', $this->path()); + return (bool) preg_match('@' . addcslashes($match, "/{()}[]$^") . '@', $this->path()); } /** @@ -511,7 +511,7 @@ public function is(string $match): bool */ public function isReferer(string $match): bool { - return (bool)preg_match('@' . addcslashes($match, "/*{()}[]$^") . '@', $this->referer()); + return (bool) preg_match('@' . addcslashes($match, "/{()}[]$^") . '@', $this->referer()); } /** diff --git a/src/Support/Env.php b/src/Support/Env.php index 572eaa51..f02e14bc 100644 --- a/src/Support/Env.php +++ b/src/Support/Env.php @@ -4,9 +4,7 @@ namespace Bow\Support; -use Bow\Application\Exception\ApplicationException; use ErrorException; -use InvalidArgumentException; /** * Class Env @@ -82,11 +80,11 @@ public function __construct(?string $filename = null) /** * Load env file * - * @param string $filename + * @param ?string $filename * @return void * @throws */ - public static function configure(string $filename) + public static function configure(?string $filename = null): void { if (static::$instance !== null) { return; diff --git a/tests/Database/Query/QueryBuilderTest.php b/tests/Database/Query/QueryBuilderTest.php index 7b2bd0c9..b5231406 100644 --- a/tests/Database/Query/QueryBuilderTest.php +++ b/tests/Database/Query/QueryBuilderTest.php @@ -242,6 +242,123 @@ public function test_where_chain_rows(string $name) $this->assertEquals(is_array($pets), true); } + /** + * @dataProvider connectionNameProvider + */ + public function test_lock_for_update_generates_correct_sql(string $name) + { + $this->createTestingTable($name); + $table = Database::connection($name)->table('pets'); + + $table->lockForUpdate(); + $sql = $table->toSql(); + + $this->assertStringEndsWith('for update', $sql); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_lock_for_update_executes_query(string $name) + { + if ($name === 'sqlite') { + $this->markTestSkipped('SQLite does not support FOR UPDATE locking.'); + } + + $this->createTestingTable($name); + $table = Database::connection($name)->table('pets'); + $table->insert([ + ['id' => 1, 'name' => 'Milou'], + ['id' => 2, 'name' => 'Foli'], + ]); + + Database::connection($name)->startTransaction(); + + $pets = Database::connection($name)->table('pets')->lockForUpdate()->get(); + + Database::connection($name)->rollback(); + + $this->assertIsArray($pets); + $this->assertCount(2, $pets); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_lock_for_update_flag_resets_after_to_sql(string $name) + { + $this->createTestingTable($name); + $table = Database::connection($name)->table('pets'); + + $table->lockForUpdate(); + $table->toSql(); + + $sql = $table->toSql(); + + $this->assertStringNotContainsString('for update', $sql); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_shared_lock_generates_correct_sql(string $name) + { + $this->createTestingTable($name); + $table = Database::connection($name)->table('pets'); + + $table->sharedLock(); + $sql = $table->toSql(); + + if ($name === 'pgsql') { + $this->assertStringEndsWith('for share', $sql); + } else { + $this->assertStringEndsWith('lock in share mode', $sql); + } + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_shared_lock_executes_query(string $name) + { + if ($name === 'sqlite') { + $this->markTestSkipped('SQLite does not support shared locking.'); + } + + $this->createTestingTable($name); + $table = Database::connection($name)->table('pets'); + $table->insert([ + ['id' => 1, 'name' => 'Milou'], + ['id' => 2, 'name' => 'Foli'], + ]); + + Database::connection($name)->startTransaction(); + + $pets = Database::connection($name)->table('pets')->sharedLock()->get(); + + Database::connection($name)->rollback(); + + $this->assertIsArray($pets); + $this->assertCount(2, $pets); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_shared_lock_flag_resets_after_to_sql(string $name) + { + $this->createTestingTable($name); + $table = Database::connection($name)->table('pets'); + + $table->sharedLock(); + $table->toSql(); + + $sql = $table->toSql(); + + $this->assertStringNotContainsString('for share', $sql); + $this->assertStringNotContainsString('lock in share mode', $sql); + } + /** * @return array */