diff --git a/src/Phaseolies/Auth/Security/Authenticate.php b/src/Phaseolies/Auth/Security/Authenticate.php index 1b4e3748..4e413fd6 100644 --- a/src/Phaseolies/Auth/Security/Authenticate.php +++ b/src/Phaseolies/Auth/Security/Authenticate.php @@ -354,7 +354,7 @@ public function logout(): void if ($user && $user->getRememberToken()) { $user->setRememberToken(null); - $user::withoutHook(); + $user->withoutHook(); $user->save(); } diff --git a/src/Phaseolies/Database/Entity/Builder.php b/src/Phaseolies/Database/Entity/Builder.php index 94401621..5c8eed29 100644 --- a/src/Phaseolies/Database/Entity/Builder.php +++ b/src/Phaseolies/Database/Entity/Builder.php @@ -135,6 +135,16 @@ class Builder */ protected bool $takeWithoutEncryption = true; + /** + * @var bool + */ + protected bool $withoutHooks = false; + + /** + * @var bool + */ + protected bool $withHooks = false; + /** * @var bool */ @@ -163,6 +173,103 @@ public function __construct(PDO $pdo, string $table, string $modelClass, int $ro $this->connectionName = $connectionName; } + /** + * Skip model hooks for the next write on every model this builder + * creates or loads. + * + * @return self + */ + public function withoutHook(): self + { + $this->withoutHooks = true; + + return $this; + } + + /** + * Run model hooks for update(), delete(), restore() and forceDelete() + * by writing each matching model on its own. + * + * @return self + */ + public function withHooks(): self + { + $this->withHooks = true; + + return $this; + } + + /** + * Run the callback on each matching model, in chunks by primary key. + * Returns false if any callback returned false. + * + * @param \Closure(Model): bool $callback + * @return bool + */ + private function eachModelWithHooks(\Closure $callback): bool + { + $ok = true; + + $this->chunkById(500, function ($models) use ($callback, &$ok) { + foreach ($models as $model) { + if ($callback($model) === false) { + $ok = false; + } + } + }); + + return $ok; + } + + /** + * Update the first model matching the attributes, or create it. + * + * @param array $attributes + * @param array $values + * @return Model + */ + public function updateOrCreate(array $attributes, array $values = []): Model + { + $model = $this->firstMatching($attributes); + + if ($model) { + $model->fill($values); + $model->save(); + + return $model; + } + + return $this->create(array_merge($attributes, $values)); + } + + /** + * Retrieve the first model matching the attributes, or create it. + * + * @param array $attributes + * @param array $values + * @return Model + */ + public function firstOrCreate(array $attributes, array $values = []): Model + { + return $this->firstMatching($attributes) + ?? $this->create(array_merge($attributes, $values)); + } + + /** + * @param array $attributes + * @return Model|null + */ + private function firstMatching(array $attributes): ?Model + { + $query = clone $this; + + foreach ($attributes as $field => $value) { + $query->where($field, $value); + } + + return $query->first(); + } + /** * Create a new model instance and save it to the database * @@ -174,6 +281,10 @@ public function create(array $attributes): Model $modelClass = $this->modelClass; $model = new $modelClass(); + if ($this->withoutHooks) { + $model->withoutHook(); + } + if ($this->connectionName !== null) { $model->setConnectionName($this->connectionName); } @@ -984,6 +1095,10 @@ private function fetchLazy(): \Generator while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $model = app($this->modelClass, [$row]); + if ($this->withoutHooks) { + $model->withoutHook(); + } + if ($this->connectionName !== null) { $model->setConnectionName($this->connectionName); } @@ -2119,6 +2234,10 @@ public function first() $model = new $this->modelClass($result); + if ($this->withoutHooks) { + $model->withoutHook(); + } + if ($this->connectionName !== null) { $model->setConnectionName($this->connectionName); } @@ -2582,6 +2701,12 @@ public function upsert(array $values, array|string $uniqueBy, ?array $updateColu */ public function update(array $attributes): bool { + if ($this->withHooks) { + return $this->eachModelWithHooks( + fn (Model $model) => $model->update($attributes) + ); + } + $class = $this->modelClass; if (app($class)->usesTimestamps()) { @@ -2641,6 +2766,12 @@ public function from(string $table): self */ public function delete(): bool { + if ($this->withHooks) { + return $this->eachModelWithHooks( + fn (Model $model) => $model->delete() + ); + } + if ($this->shouldSoftDelete()) { $now = (string) now(); @@ -2692,6 +2823,10 @@ public function useRaw(string $sql, array $bindings = []): Collection $models = array_map(function ($item) { $model = new $this->modelClass($item); + if ($this->withoutHooks) { + $model->withoutHook(); + } + if ($this->connectionName !== null) { $model->setConnectionName($this->connectionName); } diff --git a/src/Phaseolies/Database/Entity/Model.php b/src/Phaseolies/Database/Entity/Model.php index 6b84390c..eb71d56b 100644 --- a/src/Phaseolies/Database/Entity/Model.php +++ b/src/Phaseolies/Database/Entity/Model.php @@ -14,6 +14,9 @@ use Phaseolies\Database\Contracts\Support\Jsonable; use Phaseolies\Database\Entity\Attributes\Hook; +/** + * @method static \Phaseolies\Database\Entity\Builder withoutHook() + */ abstract class Model implements Jsonable, \ArrayAccess, \JsonSerializable, \Stringable { use InteractsWithModelQueryProcessing, InteractsWithTemporal, InteractsWithCasting, InteractsWithWatches, InteractsWithComputedProperties, InteractsWithSoftDeletes; @@ -770,11 +773,13 @@ public function offsetUnset($offset): void public function delete(): bool { if (!isset($this->attributes[$this->primaryKey])) { + $this->hooksSuppressed = false; + return false; } try { - if (self::$isHookShouldBeCalled && $this->fireBeforeHooks('deleted') === false) { + if (! $this->hooksSuppressed && $this->fireBeforeHooks('deleted') === false) { return false; } @@ -788,13 +793,13 @@ public function delete(): bool $result = $this->usesSoftDeletes() ? $query->forceDelete() : $query->delete(); } - if ($result && self::$isHookShouldBeCalled) { + if ($result && ! $this->hooksSuppressed) { $this->fireAfterHooks('deleted'); } return $result; } finally { - self::$isHookShouldBeCalled = true; + $this->hooksSuppressed = false; } } @@ -1247,6 +1252,14 @@ public function decrement(string $column, int $amount = 1, array $extra = []): i */ public function __call($method, $parameters) { + // withoutHook() skips hooks for the next write on this instance only + // and returns the instance, so $model->withoutHook()->save() is safe. + if ($method === 'withoutHook') { + $this->hooksSuppressed = true; + + return $this; + } + return $this->newQuery()->$method(...$parameters); } } diff --git a/src/Phaseolies/Database/Entity/Query/InteractsWithModelQueryProcessing.php b/src/Phaseolies/Database/Entity/Query/InteractsWithModelQueryProcessing.php index be45a3ff..8728110d 100644 --- a/src/Phaseolies/Database/Entity/Query/InteractsWithModelQueryProcessing.php +++ b/src/Phaseolies/Database/Entity/Query/InteractsWithModelQueryProcessing.php @@ -10,9 +10,11 @@ trait InteractsWithModelQueryProcessing { /** + * Whether hooks are skipped for the next write on this instance. + * * @var bool */ - protected static bool $isHookShouldBeCalled = true; + protected bool $hooksSuppressed = false; /** * Creates and returns a new query builder instance for the model. @@ -35,18 +37,6 @@ public static function query(?string $connection = null): Builder ); } - /** - * Disable the execution of model hooks for the current instance. - * - * @return \Phaseolies\Database\Entity\Builder - */ - public static function withoutHook(): Builder - { - self::$isHookShouldBeCalled = false; - - return static::query(); - } - /** * Retrieves all records from the model's table. * @@ -167,7 +157,7 @@ public function save(): bool if ($isUpdatable) { $this->assertCreatableIsDefined(); - if (self::$isHookShouldBeCalled && $this->fireBeforeHooks('updated') === false) { + if (! $this->hooksSuppressed && $this->fireBeforeHooks('updated') === false) { return false; } @@ -191,7 +181,7 @@ public function save(): bool ->where($this->primaryKey, $this->attributes[$this->primaryKey]) ->update($dirtyAttributes); - if (self::$isHookShouldBeCalled && $response) { + if (! $this->hooksSuppressed && $response) { $this->pruneNonColumnDirtyAttributes($this->getDirtyAttributes()); $this->fireAfterHooks('updated'); $this->firePropertyWatches($dirtyAttributes); @@ -201,7 +191,7 @@ public function save(): bool return $response; } - if (self::$isHookShouldBeCalled && $this->fireBeforeHooks('created') === false) { + if (! $this->hooksSuppressed && $this->fireBeforeHooks('created') === false) { return false; } @@ -224,13 +214,13 @@ public function save(): bool // model via getKey() instead of null. $this->attributes[$this->primaryKey] = $id; - if (self::$isHookShouldBeCalled) { + if (! $this->hooksSuppressed) { $this->fireAfterHooks('created'); } return true; } finally { - self::$isHookShouldBeCalled = true; + $this->hooksSuppressed = false; } } @@ -478,6 +468,8 @@ public static function match(array|callable $filters): Builder public function update(array $attributes): bool { if (!isset($this->attributes[$this->primaryKey])) { + $this->hooksSuppressed = false; + return false; } @@ -489,7 +481,7 @@ public function update(array $attributes): bool $this->setAttribute($key, $value); } - if (self::$isHookShouldBeCalled && $this->fireBeforeHooks('updated') === false) { + if (! $this->hooksSuppressed && $this->fireBeforeHooks('updated') === false) { return false; } @@ -499,6 +491,8 @@ public function update(array $attributes): bool $dirty = array_intersect_key($dirty, array_flip($this->creatable)); if (empty($dirty)) { + $this->hooksSuppressed = false; + return true; } @@ -514,14 +508,14 @@ public function update(array $attributes): bool if ($result) { $this->pruneNonColumnDirtyAttributes($this->getDirtyAttributes()); - if (self::$isHookShouldBeCalled) { + if (! $this->hooksSuppressed) { $this->fireAfterHooks('updated'); $this->firePropertyWatches($dirty); } $this->originalAttributes = $this->attributes; } } finally { - self::$isHookShouldBeCalled = true; + $this->hooksSuppressed = false; } return $result; diff --git a/src/Phaseolies/Database/Entity/Query/InteractsWithSoftDeleteScope.php b/src/Phaseolies/Database/Entity/Query/InteractsWithSoftDeleteScope.php index ca76f8b8..a7c1ce36 100644 --- a/src/Phaseolies/Database/Entity/Query/InteractsWithSoftDeleteScope.php +++ b/src/Phaseolies/Database/Entity/Query/InteractsWithSoftDeleteScope.php @@ -96,6 +96,12 @@ public function restore(): bool $this->trashed = 'only'; } + if ($this->withHooks) { + return $this->eachModelWithHooks( + fn ($model) => $model->restore() + ); + } + return $this->writeSoftDeleteState(null, (string) now()); } @@ -132,6 +138,12 @@ public function forceDelete(): bool { $this->assertSoftDeletable(__FUNCTION__); + if ($this->withHooks) { + return $this->eachModelWithHooks( + fn ($model) => $model->forceDelete() + ); + } + $this->forceDeleting = true; try { diff --git a/src/Phaseolies/Database/Entity/SoftDeletes/InteractsWithSoftDeletes.php b/src/Phaseolies/Database/Entity/SoftDeletes/InteractsWithSoftDeletes.php index ae87788c..d8d0a22a 100644 --- a/src/Phaseolies/Database/Entity/SoftDeletes/InteractsWithSoftDeletes.php +++ b/src/Phaseolies/Database/Entity/SoftDeletes/InteractsWithSoftDeletes.php @@ -89,11 +89,13 @@ public function restore(): bool $this->assertUsesSoftDeletes(__FUNCTION__); if (!isset($this->attributes[$this->primaryKey])) { + $this->hooksSuppressed = false; + return false; } try { - if (self::$isHookShouldBeCalled && $this->fireBeforeHooks('restored') === false) { + if (! $this->hooksSuppressed && $this->fireBeforeHooks('restored') === false) { return false; } @@ -107,14 +109,14 @@ public function restore(): bool if ($result) { $this->syncSoftDeleteAttributes(null, $now); - if (self::$isHookShouldBeCalled) { + if (! $this->hooksSuppressed) { $this->fireAfterHooks('restored'); } } return $result; } finally { - self::$isHookShouldBeCalled = true; + $this->hooksSuppressed = false; } } diff --git a/tests/Model/EntityModelHookTest.php b/tests/Model/EntityModelHookTest.php index 0aca8cc2..39d6bc1b 100644 --- a/tests/Model/EntityModelHookTest.php +++ b/tests/Model/EntityModelHookTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Model; use Phaseolies\Database\Database; +use Phaseolies\Database\Entity\Hooks\HookHandler; use PHPUnit\Framework\TestCase; use PDO; use Tests\Support\Model\MockHook; @@ -148,4 +149,239 @@ public function testAfterUpdatedWithoutHook(): void $this->assertFalse(MockHook::$wasCalledAfterUpdated, 'after_updated hook should have fired'); } + + public function testChainedWithoutHookUpdateOnlyTouchesThatRecord(): void + { + MockHook::$wasCalledAfterUpdated = false; + + $hook = MockHook::find(1); + $hook->withoutHook()->update(['name' => 'Only Me']); + + $this->assertFalse(MockHook::$wasCalledAfterUpdated); + $this->assertSame('Only Me', MockHook::find(1)->name); + $this->assertSame('Jane Smith', MockHook::find(2)->name); + $this->assertSame('Bob Wilson', MockHook::find(3)->name); + } + + public function testChainedWithoutHookDeleteOnlyRemovesThatRecord(): void + { + MockHook::find(1)->withoutHook()->delete(); + + $this->assertSame(2, MockHook::query()->count()); + $this->assertNull(MockHook::find(1)); + } + + public function testChainedWithoutHookSaveWorks(): void + { + MockHook::$wasCalledAfterUpdated = false; + + $hook = MockHook::find(1); + $hook->name = 'Saved'; + $hook->withoutHook()->save(); + + $this->assertFalse(MockHook::$wasCalledAfterUpdated); + $this->assertSame('Saved', MockHook::find(1)->name); + } + + public function testWithoutHookIsResetAfterOneWrite(): void + { + MockHook::$wasCalledAfterUpdated = false; + + $hook = MockHook::find(1); + $hook->withoutHook()->update(['name' => 'Quiet']); + $this->assertFalse(MockHook::$wasCalledAfterUpdated); + + $hook->name = 'Loud'; + $hook->save(); + $this->assertTrue(MockHook::$wasCalledAfterUpdated); + } + + public function testWithoutHookIsResetWhenUpdateReturnsEarly(): void + { + MockHook::$wasCalledAfterUpdated = false; + + $hook = MockHook::find(1); + $hook->withoutHook()->update([]); // nothing dirty, returns early + + $hook->name = 'Loud'; + $hook->save(); + + $this->assertTrue(MockHook::$wasCalledAfterUpdated); + } + + public function testWithoutHookDoesNotLeakToOtherModelOperations(): void + { + MockHook::$wasCalledAfterCreated = false; + + // A read through the builder must not switch hooks off globally + MockHook::withoutHook()->where('id', 1)->get(); + + MockHook::create(['name' => 'After Read']); + + $this->assertTrue(MockHook::$wasCalledAfterCreated); + } + + public function testWithoutHookBuilderDoesNotLeakAfterBulkUpdate(): void + { + MockHook::$wasCalledAfterUpdated = false; + + MockHook::withoutHook()->where('id', 1)->update(['name' => 'Bulk']); + + $hook = MockHook::find(2); + $hook->name = 'Normal'; + $hook->save(); + + $this->assertTrue(MockHook::$wasCalledAfterUpdated); + } + + public function testWithoutHookFindThenSaveSkipsHooksOnce(): void + { + MockHook::$wasCalledAfterUpdated = false; + + $hook = MockHook::withoutHook()->find(1); + $hook->name = 'Imported'; + $hook->save(); + + $this->assertFalse(MockHook::$wasCalledAfterUpdated); + } + + public function testWithoutHookUpdateOrCreateAndFirstOrCreate(): void + { + MockHook::$wasCalledAfterCreated = false; + MockHook::$wasCalledAfterUpdated = false; + + MockHook::withoutHook()->updateOrCreate(['name' => 'John Doe'], ['name' => 'John Doe']); + MockHook::withoutHook()->updateOrCreate(['name' => 'Brand New']); + MockHook::withoutHook()->firstOrCreate(['name' => 'Another New']); + + $this->assertFalse(MockHook::$wasCalledAfterCreated); + $this->assertFalse(MockHook::$wasCalledAfterUpdated); + $this->assertSame(5, MockHook::query()->count()); + } + + public function testBuilderUpdateWithoutWithHooksSkipsHooks(): void + { + MockHook::$wasCalledAfterUpdated = false; + + MockHook::query()->where('id', '>', 0)->update(['name' => 'Bulk']); + + $this->assertFalse(MockHook::$wasCalledAfterUpdated); + $this->assertSame('Bulk', MockHook::find(3)->name); + } + + public function testBuilderUpdateWithHooksFiresHooksPerModel(): void + { + $seen = []; + HookHandler::register(MockHook::class, [ + 'before_updated' => function ($model) use (&$seen) { + $seen[] = $model->getKey(); + }, + ]); + MockHook::$wasCalledAfterUpdated = false; + + try { + $result = MockHook::query() + ->where('id', '>', 1) + ->withHooks() + ->update(['name' => 'Hooked']); + } finally { + unset(HookHandler::$hooks[MockHook::class]); + } + + $this->assertTrue($result); + $this->assertTrue(MockHook::$wasCalledAfterUpdated); + $this->assertEqualsCanonicalizing([2, 3], array_map('intval', $seen)); + $this->assertSame('John Doe', MockHook::find(1)->name); + $this->assertSame('Hooked', MockHook::find(2)->name); + $this->assertSame('Hooked', MockHook::find(3)->name); + } + + public function testBuilderUpdateWithHooksReachesRowsThatLeaveTheWhereClause(): void + { + // The update changes the column the WHERE uses, so rows drop out of + // the result set while it is being processed. None may be skipped. + for ($i = 0; $i < 1200; $i++) { + $this->pdo->exec("INSERT INTO hooks (name) VALUES ('pending')"); + } + + MockHook::query()->where('name', 'pending')->withHooks()->update(['name' => 'done']); + + $this->assertSame(0, MockHook::query()->where('name', 'pending')->count()); + $this->assertSame(1200, MockHook::query()->where('name', 'done')->count()); + } + + public function testBuilderUpdateWithHooksStopsWhereAHookThrows(): void + { + HookHandler::register(MockHook::class, [ + 'before_updated' => function ($model) { + if ((int) $model->getKey() === 2) { + throw new \RuntimeException('not this one'); + } + }, + ]); + + try { + MockHook::query()->withHooks()->update(['name' => 'Changed']); + $this->fail('Expected the hook exception.'); + } catch (\RuntimeException $e) { + $this->assertSame('not this one', $e->getMessage()); + } finally { + unset(HookHandler::$hooks[MockHook::class]); + } + + // Rows are written one by one and there is no implicit transaction: + // row 1 was saved, row 2 threw, row 3 was never reached. + $this->assertSame('Changed', MockHook::find(1)->name); + $this->assertSame('Jane Smith', MockHook::find(2)->name); + $this->assertSame('Bob Wilson', MockHook::find(3)->name); + } + + public function testBuilderDeleteWithHooksFiresHooksPerModel(): void + { + $seen = []; + HookHandler::register(MockHook::class, [ + 'before_deleted' => function ($model) use (&$seen) { + $seen[] = (int) $model->getKey(); + }, + ]); + + try { + MockHook::query()->where('id', '<', 3)->withHooks()->delete(); + } finally { + unset(HookHandler::$hooks[MockHook::class]); + } + + $this->assertEqualsCanonicalizing([1, 2], $seen); + $this->assertSame(1, MockHook::query()->count()); + } + + public function testBuilderDeleteWithoutWithHooksSkipsHooks(): void + { + $fired = false; + HookHandler::register(MockHook::class, [ + 'before_deleted' => function () use (&$fired) { + $fired = true; + }, + ]); + + try { + MockHook::query()->where('id', 1)->delete(); + } finally { + unset(HookHandler::$hooks[MockHook::class]); + } + + $this->assertFalse($fired); + $this->assertSame(2, MockHook::query()->count()); + } + + public function testWithHooksAndWithoutHookTogetherSkipHooks(): void + { + MockHook::$wasCalledAfterUpdated = false; + + MockHook::withoutHook()->withHooks()->update(['name' => 'Quiet']); + + $this->assertFalse(MockHook::$wasCalledAfterUpdated); + $this->assertSame('Quiet', MockHook::find(1)->name); + $this->assertSame('Quiet', MockHook::find(3)->name); + } } diff --git a/tests/Model/SoftDelete/SoftDeleteBehavior.php b/tests/Model/SoftDelete/SoftDeleteBehavior.php index c1766566..9e600693 100644 --- a/tests/Model/SoftDelete/SoftDeleteBehavior.php +++ b/tests/Model/SoftDelete/SoftDeleteBehavior.php @@ -685,4 +685,34 @@ public function testWithoutHookStillSoftDeletes(): void $this->assertFalse($fired); $this->assertNotNull($this->rawRow('articles', 1)['deleted_at']); } + + public function testBuilderRestoreAndForceDeleteWithHooksFireHooksPerModel(): void + { + $events = []; + HookHandler::register(MockSoftArticle::class, [ + 'before_restored' => function ($m) use (&$events) { + $events[] = 'restored:' . $m->getKey(); + }, + 'before_deleted' => function ($m) use (&$events) { + $events[] = 'deleted:' . $m->getKey(); + }, + ]); + + try { + MockSoftArticle::find(1)->delete(); + MockSoftArticle::find(2)->delete(); + $events = []; + + MockSoftArticle::query()->onlyTrashed()->withHooks()->restore(); + $this->assertEqualsCanonicalizing(['restored:1', 'restored:2'], $events); + $this->assertNull($this->rawRow('articles', 1)['deleted_at']); + + $events = []; + MockSoftArticle::query()->where('id', 3)->withHooks()->forceDelete(); + $this->assertSame(['deleted:3'], $events); + $this->assertFalse($this->rawRow('articles', 3)); + } finally { + unset(HookHandler::$hooks[MockSoftArticle::class]); + } + } }