From f37849d8a764b50e3cbd10ff91c151949f2899e4 Mon Sep 17 00:00:00 2001 From: Abdul Majid <43958759+abdulmajidcse@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:47:01 +0600 Subject: [PATCH] (feat) ORM: soft deletes via #[SoftDeletes] Mark a model with #[SoftDeletes] (optionally #[SoftDeletes(column: 'removed_at')]) and delete() stamps the column instead of removing the row. Every query on the model excludes trashed rows unless withTrashed() or onlyTrashed() is used. - Model: delete(), forceDelete(), restore(), trashed(), isForceDeleting() - Builder: withTrashed(), onlyTrashed(), withoutTrashed(), restore(), forceDelete(); bulk delete() and purge() soft-delete on soft-deletable models - The constraint is added when the WHERE clause is compiled, after wrapping the user's conditions in parentheses so an OR cannot bypass it; nested where groups do not repeat it - Key-based writes on a model (save, update, increment, decrement, delete) ignore the constraint so a trashed model can still be saved - present/absent/ifExists/whereLinked subqueries, including nested and many-to-many chains, exclude trashed related rows and honour withTrashed() or onlyTrashed() inside the callback - New before_restored / after_restored hooks; #[Temporal] records a 'restored' snapshot, and restoreTo() carries the soft delete state of the snapshot - Fix Builder::fresh() return type (it returns a Model, not a Builder) --- .../Database/Entity/Attributes/Hook.php | 3 +- .../Entity/Attributes/SoftDeletes.php | 30 + src/Phaseolies/Database/Entity/Builder.php | 113 ++- .../Entity/Casts/InteractsWithCasting.php | 1 + src/Phaseolies/Database/Entity/Model.php | 19 +- .../InteractsWithModelQueryProcessing.php | 2 + .../Query/InteractsWithNestedRelations.php | 21 +- .../Query/InteractsWithSoftDeleteScope.php | 264 +++++++ .../SoftDeletes/InteractsWithSoftDeletes.php | 177 +++++ .../Entity/SoftDeletes/SoftDeleteManager.php | 77 ++ .../Temporal/InteractsWithTemporal.php | 15 +- tests/Model/SoftDelete/SoftDeleteBehavior.php | 688 ++++++++++++++++++ .../Model/SoftDelete/SoftDeleteMysqlTest.php | 17 + .../Model/SoftDelete/SoftDeletePgsqlTest.php | 17 + .../Model/SoftDelete/SoftDeleteSqliteTest.php | 13 + .../SoftDelete/SoftDeleteTemporalTest.php | 146 ++++ tests/Support/Model/MockSoftArticle.php | 31 + tests/Support/Model/MockSoftAuthor.php | 19 + tests/Support/Model/MockSoftLabel.php | 16 + tests/Support/Model/MockSoftNote.php | 16 + .../Support/Model/MockSoftTemporalRecord.php | 18 + 21 files changed, 1677 insertions(+), 26 deletions(-) create mode 100644 src/Phaseolies/Database/Entity/Attributes/SoftDeletes.php create mode 100644 src/Phaseolies/Database/Entity/Query/InteractsWithSoftDeleteScope.php create mode 100644 src/Phaseolies/Database/Entity/SoftDeletes/InteractsWithSoftDeletes.php create mode 100644 src/Phaseolies/Database/Entity/SoftDeletes/SoftDeleteManager.php create mode 100644 tests/Model/SoftDelete/SoftDeleteBehavior.php create mode 100644 tests/Model/SoftDelete/SoftDeleteMysqlTest.php create mode 100644 tests/Model/SoftDelete/SoftDeletePgsqlTest.php create mode 100644 tests/Model/SoftDelete/SoftDeleteSqliteTest.php create mode 100644 tests/Model/SoftDelete/SoftDeleteTemporalTest.php create mode 100644 tests/Support/Model/MockSoftArticle.php create mode 100644 tests/Support/Model/MockSoftAuthor.php create mode 100644 tests/Support/Model/MockSoftLabel.php create mode 100644 tests/Support/Model/MockSoftNote.php create mode 100644 tests/Support/Model/MockSoftTemporalRecord.php diff --git a/src/Phaseolies/Database/Entity/Attributes/Hook.php b/src/Phaseolies/Database/Entity/Attributes/Hook.php index 9551d3cc..16670c19 100644 --- a/src/Phaseolies/Database/Entity/Attributes/Hook.php +++ b/src/Phaseolies/Database/Entity/Attributes/Hook.php @@ -25,7 +25,8 @@ * booting, booted, * before_created, after_created, * before_updated, after_updated, - * before_deleted, after_deleted + * before_deleted, after_deleted, + * before_restored, after_restored (#[SoftDeletes] models only) * * The 'when' parameter (optional): * - A string → name of a public/protected method on the model that returns bool diff --git a/src/Phaseolies/Database/Entity/Attributes/SoftDeletes.php b/src/Phaseolies/Database/Entity/Attributes/SoftDeletes.php new file mode 100644 index 00000000..9acdfb57 --- /dev/null +++ b/src/Phaseolies/Database/Entity/Attributes/SoftDeletes.php @@ -0,0 +1,30 @@ +pdo, $this->table, $this->modelClass, $this->rowPerPage, $this->connectionName); + // The outer query applies the soft delete constraint once + $nestedQuery->withoutSoftDeleteScope(); + $callback($nestedQuery); if (!empty($nestedQuery->conditions)) { @@ -581,18 +588,25 @@ private function buildManyToManySubquery(Model $model, mixed $relatedModel, ?cal $quote = fn($identifier) => $this->quoteIdentifier($identifier); + $relatedQuery = $this->buildRelatedSubqueryScope($relatedModel, $callback); + $softDeleteConstraint = $relatedQuery->compileSoftDeleteConstraint($relatedTable); + // Build subquery with JOIN to access related model columns $subquery = "SELECT 1 FROM {$quote($pivotTable)}"; - // Add JOIN to related table if we have a callback - if ($callback) { + // Join the related table when its columns are needed + if ($callback || $softDeleteConstraint !== null) { $subquery .= " INNER JOIN {$quote($relatedTable)} ON {$quote($pivotTable)}.{$quote($relatedKey)} = {$quote($relatedTable)}.{$quote($relatedPrimaryKey)}"; } $subquery .= " WHERE {$quote($pivotTable)}.{$quote($foreignKey)} = {$quote($this->table)}.{$quote($localKey)}"; if ($callback) { - $subquery = $this->addCallbackConditions($subquery, $relatedModel, $callback, $relatedTable); + $subquery = $this->addCallbackConditions($subquery, $relatedQuery, $relatedTable); + } + + if ($softDeleteConstraint !== null) { + $subquery .= " AND {$softDeleteConstraint}"; } return $subquery; @@ -614,30 +628,51 @@ private function buildDirectRelationshipSubquery(Model $model, mixed $relatedMod $quote = fn($identifier) => $this->quoteIdentifier($identifier); + $relatedQuery = $this->buildRelatedSubqueryScope($relatedModel, $callback); + $subquery = "SELECT 1 FROM {$quote($relatedTable)} WHERE {$quote($relatedTable)}.{$quote($foreignKey)} = {$quote($this->table)}.{$quote($localKey)}"; if ($callback) { - $subquery = $this->addCallbackConditions($subquery, $relatedModel, $callback, $relatedTable); + $subquery = $this->addCallbackConditions($subquery, $relatedQuery, $relatedTable); + } + + if (($softDeleteConstraint = $relatedQuery->compileSoftDeleteConstraint($relatedTable)) !== null) { + $subquery .= " AND {$softDeleteConstraint}"; } return $subquery; } + /** + * Build a query for the related model and run the user's callback on it, + * so the subquery can read back its conditions and trashed mode. + * + * @param mixed $relatedModel + * @param callable|null $callback + * @return self + */ + private function buildRelatedSubqueryScope(mixed $relatedModel, ?callable $callback): self + { + $relatedQuery = $relatedModel::query($this->connectionName); + + if ($callback) { + $callback($relatedQuery); + } + + return $relatedQuery; + } + /** * Add callback conditions to the subquery * * @param string $subquery - * @param mixed $relatedModel - * @param callable $callback + * @param self $subQueryBuilder * @param string $relatedTable * @return string */ - private function addCallbackConditions(string $subquery, mixed $relatedModel, callable $callback, string $relatedTable): string + private function addCallbackConditions(string $subquery, self $subQueryBuilder, string $relatedTable): string { - $subQueryBuilder = $relatedModel::query($this->connectionName); - $callback($subQueryBuilder); - $quote = fn($identifier) => $this->quoteIdentifier($identifier); $escapeValue = fn($value) => $this->escapeValue($value); @@ -1379,9 +1414,9 @@ public function load($relations, ?callable $callback = null): self * Reload the current model instance with fresh attributes from the database. * * @param string|array $relations - * @return $this|null + * @return Model|null */ - public function fresh($relations = []): ?self + public function fresh($relations = []): ?Model { $model = $this->first(); @@ -1389,7 +1424,10 @@ public function fresh($relations = []): ?self return null; } - $freshModel = $this->getModel()->newQuery() + $freshQuery = $this->getModel()->newQuery(); + $freshQuery->trashed = $this->trashed; + + $freshModel = $freshQuery ->where($model->getKeyName(), $model->getKey()) ->embed($relations) ->first(); @@ -1417,6 +1455,8 @@ public function find($id) /** * Delete records by their primary keys. * + * On a soft-deletable model the rows are soft-deleted instead. + * * @param mixed ...$ids * @return int * @throws PDOException @@ -1433,13 +1473,31 @@ public function purge(...$ids): int $primaryKey = $model->getKeyName(); $placeholders = implode(',', array_fill(0, count($ids), '?')); - $sql = "DELETE FROM {$this->table} WHERE {$primaryKey} IN ({$placeholders})"; + $bindings = $ids; + + if ($this->shouldSoftDelete()) { + $column = $this->getSoftDeleteColumn(); + $sets = ["{$column} = ?"]; + $now = (string) now(); + $bindings = [$now]; + + if ($model->usesTimestamps()) { + $sets[] = 'updated_at = ?'; + $bindings[] = $now; + } + + $sql = "UPDATE {$this->table} SET " . implode(', ', $sets) + . " WHERE {$primaryKey} IN ({$placeholders}) AND {$column} IS NULL"; + $bindings = array_merge($bindings, $ids); + } else { + $sql = "DELETE FROM {$this->table} WHERE {$primaryKey} IN ({$placeholders})"; + } try { $stmt = $this->pdo->prepare($sql); - foreach ($ids as $index => $id) { - $stmt->bindValue($index + 1, $id, $this->getPdoParamType($id)); + foreach (array_values($bindings) as $index => $value) { + $stmt->bindValue($index + 1, $value, $this->getPdoParamType($value)); } $stmt->execute(); @@ -2464,6 +2522,18 @@ public function update(array $attributes): bool $attributes['updated_at'] = now(); } + return $this->performUpdate($attributes); + } + + /** + * Run an UPDATE for the given attributes against the matched rows, + * without touching updated_at. + * + * @param array $attributes + * @return bool + */ + protected function performUpdate(array $attributes): bool + { $setClause = implode(', ', array_map(fn($key) => "$key = ?", array_keys($attributes))); $setBindings = array_values($attributes); @@ -2505,6 +2575,12 @@ public function from(string $table): self */ public function delete(): bool { + if ($this->shouldSoftDelete()) { + $now = (string) now(); + + return $this->writeSoftDeleteState($now, $now); + } + $sql = "DELETE FROM {$this->table}"; [$whereSql, $whereBindings] = $this->buildWhereClause(); @@ -3293,6 +3369,7 @@ public function reset(): self $this->offset = null; $this->joins = []; $this->eagerLoad = []; + $this->trashed = 'exclude'; return $this; } diff --git a/src/Phaseolies/Database/Entity/Casts/InteractsWithCasting.php b/src/Phaseolies/Database/Entity/Casts/InteractsWithCasting.php index fb2faaeb..af98f564 100644 --- a/src/Phaseolies/Database/Entity/Casts/InteractsWithCasting.php +++ b/src/Phaseolies/Database/Entity/Casts/InteractsWithCasting.php @@ -21,6 +21,7 @@ trait InteractsWithCasting 'lastLocalKey', 'lastRelatedKey', 'lastPivotTable', + 'forceDeleting', ]; /** diff --git a/src/Phaseolies/Database/Entity/Model.php b/src/Phaseolies/Database/Entity/Model.php index fa3cb881..6b84390c 100644 --- a/src/Phaseolies/Database/Entity/Model.php +++ b/src/Phaseolies/Database/Entity/Model.php @@ -9,13 +9,14 @@ use Phaseolies\Database\Temporal\InteractsWithTemporal; use Phaseolies\Database\Entity\Watches\InteractsWithWatches; use Phaseolies\Database\Entity\Computed\InteractsWithComputedProperties; +use Phaseolies\Database\Entity\SoftDeletes\InteractsWithSoftDeletes; use Phaseolies\Database\Database; use Phaseolies\Database\Contracts\Support\Jsonable; use Phaseolies\Database\Entity\Attributes\Hook; abstract class Model implements Jsonable, \ArrayAccess, \JsonSerializable, \Stringable { - use InteractsWithModelQueryProcessing, InteractsWithTemporal, InteractsWithCasting, InteractsWithWatches, InteractsWithComputedProperties; + use InteractsWithModelQueryProcessing, InteractsWithTemporal, InteractsWithCasting, InteractsWithWatches, InteractsWithComputedProperties, InteractsWithSoftDeletes; /** * The name of the database table associated with the model. @@ -762,6 +763,8 @@ public function offsetUnset($offset): void /** * Delete the model from the database. * + * A #[SoftDeletes] model is soft-deleted; use forceDelete() to remove the row. + * * @return bool */ public function delete(): bool @@ -775,9 +778,15 @@ public function delete(): bool return false; } - $result = $this->newQuery() - ->where($this->primaryKey, $this->attributes[$this->primaryKey]) - ->delete(); + if ($this->usesSoftDeletes() && !$this->forceDeleting) { + $result = $this->performSoftDelete(); + } else { + $query = $this->newQuery() + ->withoutSoftDeleteScope() + ->where($this->primaryKey, $this->attributes[$this->primaryKey]); + + $result = $this->usesSoftDeletes() ? $query->forceDelete() : $query->delete(); + } if ($result && self::$isHookShouldBeCalled) { $this->fireAfterHooks('deleted'); @@ -1188,6 +1197,7 @@ public function toArray(): array public function increment(string $column, int $amount = 1, array $extra = []): int { $result = $this->newQuery() + ->withoutSoftDeleteScope() ->where($this->getKeyName(), '=', $this->getKey()) ->increment($column, $amount, $extra); @@ -1213,6 +1223,7 @@ public function increment(string $column, int $amount = 1, array $extra = []): i public function decrement(string $column, int $amount = 1, array $extra = []): int { $result = $this->newQuery() + ->withoutSoftDeleteScope() ->where($this->getKeyName(), '=', $this->getKey()) ->decrement($column, $amount, $extra); diff --git a/src/Phaseolies/Database/Entity/Query/InteractsWithModelQueryProcessing.php b/src/Phaseolies/Database/Entity/Query/InteractsWithModelQueryProcessing.php index 9baf1c68..be45a3ff 100644 --- a/src/Phaseolies/Database/Entity/Query/InteractsWithModelQueryProcessing.php +++ b/src/Phaseolies/Database/Entity/Query/InteractsWithModelQueryProcessing.php @@ -187,6 +187,7 @@ public function save(): bool } $response = $this->newQuery() + ->withoutSoftDeleteScope() ->where($this->primaryKey, $this->attributes[$this->primaryKey]) ->update($dirtyAttributes); @@ -507,6 +508,7 @@ public function update(array $attributes): bool try { $result = $this->newQuery() + ->withoutSoftDeleteScope() ->where($this->primaryKey, $this->attributes[$this->primaryKey]) ->update($dirty); diff --git a/src/Phaseolies/Database/Entity/Query/InteractsWithNestedRelations.php b/src/Phaseolies/Database/Entity/Query/InteractsWithNestedRelations.php index 4a35c812..9f42b1fe 100644 --- a/src/Phaseolies/Database/Entity/Query/InteractsWithNestedRelations.php +++ b/src/Phaseolies/Database/Entity/Query/InteractsWithNestedRelations.php @@ -51,6 +51,7 @@ private function buildNestedRelationshipExistsSubquery(Model $model, array $rela $currentModel = $model; $previousTable = $this->table; $previousKey = null; + $relatedTables = []; foreach ($relations as $index => $relation) { if (!method_exists($currentModel, $relation)) { @@ -66,6 +67,7 @@ private function buildNestedRelationshipExistsSubquery(Model $model, array $rela $relatedModelInstance = new $relatedModel(); $relatedTable = $relatedModelInstance->getTable(); $relatedPrimaryKey = $relatedModelInstance->getKeyName(); + $relatedTables[] = [$relatedModel, $relatedTable]; if ($index === 0) { if ($relationType === 'bindToMany') { @@ -115,9 +117,17 @@ private function buildNestedRelationshipExistsSubquery(Model $model, array $rela $subquery .= " WHERE {$subqueryParts['where']}"; + // Exclude soft-deleted rows on every table in the chain but the + // last, whose constraint follows the callback's trashed mode + array_pop($relatedTables); + foreach ($relatedTables as [$relatedModel, $relatedTable]) { + $subquery .= $this->relatedSoftDeleteClause($relatedModel, $relatedTable); + } + + $subQueryBuilder = $currentModel->query($this->connectionName); + // Apply callback conditions on the final table if ($callback && $previousTable) { - $subQueryBuilder = $currentModel->query($this->connectionName); $callback($subQueryBuilder); foreach ($subQueryBuilder->conditions as $condition) { @@ -140,6 +150,10 @@ private function buildNestedRelationshipExistsSubquery(Model $model, array $rela } } + if (($softDeleteConstraint = $subQueryBuilder->compileSoftDeleteConstraint($previousTable)) !== null) { + $subquery .= " AND {$softDeleteConstraint}"; + } + return $subquery; } @@ -192,6 +206,7 @@ protected function buildNestedRelationshipSubquery(Model $model, array $relation $joins = []; $lastForeignKey = null; $lastLocalKey = null; + $softDeleteClauses = []; // Process each relation in the chain foreach ($relations as $index => $relation) { @@ -209,6 +224,7 @@ protected function buildNestedRelationshipSubquery(Model $model, array $relation $relatedModelInstance = new $relatedModel(); $relatedTable = $relatedModelInstance->getTable(); $relatedPrimaryKey = $relatedModelInstance->getKeyName(); + $softDeleteClauses[] = $this->relatedSoftDeleteClause($relatedModel, $relatedTable); if ($relationType === 'bindToMany') { // Many-to-many relationship @@ -266,6 +282,9 @@ protected function buildNestedRelationshipSubquery(Model $model, array $relation $subquery .= $this->buildConditionClause($columnQualified, $operator, $value, $escapeValue); + // Exclude soft-deleted rows on every related table in the chain + $subquery .= implode('', $softDeleteClauses); + $subquery .= ' LIMIT 1'; return $subquery; diff --git a/src/Phaseolies/Database/Entity/Query/InteractsWithSoftDeleteScope.php b/src/Phaseolies/Database/Entity/Query/InteractsWithSoftDeleteScope.php new file mode 100644 index 00000000..ca76f8b8 --- /dev/null +++ b/src/Phaseolies/Database/Entity/Query/InteractsWithSoftDeleteScope.php @@ -0,0 +1,264 @@ +assertSoftDeletable(__FUNCTION__); + + $this->trashed = 'include'; + + return $this; + } + + /** + * Return only soft-deleted rows. + * + * @return self + * @throws \LogicException + */ + public function onlyTrashed(): self + { + $this->assertSoftDeletable(__FUNCTION__); + + $this->trashed = 'only'; + + return $this; + } + + /** + * Exclude soft-deleted rows from the results (the default). + * + * @return self + * @throws \LogicException + */ + public function withoutTrashed(): self + { + $this->assertSoftDeletable(__FUNCTION__); + + $this->trashed = 'exclude'; + + return $this; + } + + /** + * Turn the soft delete constraint off for this query. + * + * Used internally for queries that target a single row by its key, + * such as saving or deleting a model that may already be trashed. + * + * @internal + * @return self + */ + public function withoutSoftDeleteScope(): self + { + $this->trashed = 'include'; + + return $this; + } + + /** + * Restore the soft-deleted rows matched by the query. + * + * @return bool + * @throws \LogicException + */ + public function restore(): bool + { + $this->assertSoftDeletable(__FUNCTION__); + + if ($this->trashed === 'exclude') { + $this->trashed = 'only'; + } + + return $this->writeSoftDeleteState(null, (string) now()); + } + + /** + * Write the soft delete column on the matched rows, touching + * updated_at with the given timestamp when the model uses timestamps. + * + * Taking the timestamp from the caller lets a model mirror the exact + * values that were written. + * + * @internal + * @param string|null $deletedAt + * @param string $timestamp + * @return bool + */ + public function writeSoftDeleteState(?string $deletedAt, string $timestamp): bool + { + $attributes = [$this->getSoftDeleteColumn() => $deletedAt]; + + if ($this->getModel()->usesTimestamps()) { + $attributes['updated_at'] = $timestamp; + } + + return $this->performUpdate($attributes); + } + + /** + * Permanently delete the rows matched by the query. + * + * @return bool + * @throws \LogicException + */ + public function forceDelete(): bool + { + $this->assertSoftDeletable(__FUNCTION__); + + $this->forceDeleting = true; + + try { + return $this->delete(); + } finally { + $this->forceDeleting = false; + } + } + + /** + * Determine whether delete() on this query should stamp rows + * instead of removing them. + * + * @return bool + */ + protected function shouldSoftDelete(): bool + { + return !$this->forceDeleting && $this->getSoftDeleteColumn() !== null; + } + + /** + * Get the soft delete column of the query's model, if it has one. + * + * @return string|null + */ + public function getSoftDeleteColumn(): ?string + { + return SoftDeleteManager::column($this->modelClass); + } + + /** + * Build the soft delete constraint for this query. + * + * With no table the column is qualified exactly as the query's own + * FROM clause names its table (honouring an alias), so the reference + * resolves on every driver. A table passed in comes from a hand-built + * subquery that quotes its identifiers, so it is quoted to match. + * + * @param string|null $table + * @return string|null + */ + protected function compileSoftDeleteConstraint(?string $table = null): ?string + { + $column = $this->getSoftDeleteColumn(); + + if ($column === null || $this->trashed === 'include') { + return null; + } + + $qualified = $table === null + ? $this->tableReference() . '.' . $column + : $this->quoteIdentifier("{$table}.{$column}"); + + return $this->trashed === 'only' + ? "{$qualified} IS NOT NULL" + : "{$qualified} IS NULL"; + } + + /** + * Get the name the query's FROM clause refers to its table by: + * the alias for "table as alias" or "table alias", else the table. + * + * @return string + */ + protected function tableReference(): string + { + $table = trim($this->table); + + if (preg_match('/^\S+\s+(?:as\s+)?(\S+)$/i', $table, $matches)) { + return $matches[1]; + } + + return $table; + } + + /** + * Build the default soft delete constraint for a related model used + * inside a hand-written subquery, as an " AND ..." fragment. + * + * @param string $modelClass + * @param string $table + * @return string + */ + protected function relatedSoftDeleteClause(string $modelClass, string $table): string + { + $column = SoftDeleteManager::column($modelClass); + + if ($column === null) { + return ''; + } + + return ' AND ' . $this->quoteIdentifier("{$table}.{$column}") . ' IS NULL'; + } + + /** + * Build the WHERE clause, appending the soft delete constraint to + * the user's conditions. + * + * The user's conditions are wrapped in parentheses so that an OR + * among them cannot bypass the constraint. + * + * @return array [sql, bindings] + */ + protected function buildWhereClause(): array + { + [$sql, $bindings] = $this->buildConditionWhereClause(); + + $constraint = $this->compileSoftDeleteConstraint(); + + if ($constraint === null) { + return [$sql, $bindings]; + } + + return [$sql === '' ? $constraint : "({$sql}) AND {$constraint}", $bindings]; + } + + /** + * @param string $method + * @return void + * @throws \LogicException + */ + protected function assertSoftDeletable(string $method): void + { + if ($this->getSoftDeleteColumn() === null) { + throw new \LogicException( + "Cannot call {$method}() on {$this->modelClass}: the model is not marked #[SoftDeletes]." + ); + } + } +} diff --git a/src/Phaseolies/Database/Entity/SoftDeletes/InteractsWithSoftDeletes.php b/src/Phaseolies/Database/Entity/SoftDeletes/InteractsWithSoftDeletes.php new file mode 100644 index 00000000..ae87788c --- /dev/null +++ b/src/Phaseolies/Database/Entity/SoftDeletes/InteractsWithSoftDeletes.php @@ -0,0 +1,177 @@ +getSoftDeleteColumn(); + + return $column !== null && ($this->attributes[$column] ?? null) !== null; + } + + /** + * Determine whether the model is currently being force deleted. + * + * @return bool + */ + public function isForceDeleting(): bool + { + return $this->forceDeleting; + } + + /** + * Permanently delete the model, bypassing soft deletes. + * + * Fires the before_deleted and after_deleted hooks; isForceDeleting() + * returns true while they run. + * + * @return bool + * @throws \LogicException + */ + public function forceDelete(): bool + { + $this->assertUsesSoftDeletes(__FUNCTION__); + + $this->forceDeleting = true; + + try { + return $this->delete(); + } finally { + $this->forceDeleting = false; + } + } + + /** + * Restore a soft-deleted model. + * + * Fires the before_restored and after_restored hooks. + * + * @return bool + * @throws \LogicException + */ + public function restore(): bool + { + $this->assertUsesSoftDeletes(__FUNCTION__); + + if (!isset($this->attributes[$this->primaryKey])) { + return false; + } + + try { + if (self::$isHookShouldBeCalled && $this->fireBeforeHooks('restored') === false) { + return false; + } + + $now = (string) now(); + + $result = $this->newQuery() + ->withoutSoftDeleteScope() + ->where($this->primaryKey, $this->attributes[$this->primaryKey]) + ->writeSoftDeleteState(null, $now); + + if ($result) { + $this->syncSoftDeleteAttributes(null, $now); + + if (self::$isHookShouldBeCalled) { + $this->fireAfterHooks('restored'); + } + } + + return $result; + } finally { + self::$isHookShouldBeCalled = true; + } + } + + /** + * Stamp the soft delete column on the model's row. + * + * @return bool + */ + protected function performSoftDelete(): bool + { + $now = (string) now(); + + $result = $this->newQuery() + ->withoutSoftDeleteScope() + ->where($this->primaryKey, $this->attributes[$this->primaryKey]) + ->writeSoftDeleteState($now, $now); + + if ($result) { + $this->syncSoftDeleteAttributes($now, $now); + } + + return $result; + } + + /** + * Mirror the values a soft delete or restore wrote onto the in-memory + * model, so it matches the row without a reload. + * + * @param string|null $deletedAt + * @param string $timestamp + * @return void + */ + private function syncSoftDeleteAttributes(?string $deletedAt, string $timestamp): void + { + $attributes = [$this->getSoftDeleteColumn() => $deletedAt]; + + if ($this->usesTimestamps()) { + $attributes['updated_at'] = $timestamp; + } + + foreach ($attributes as $key => $value) { + $this->attributes[$key] = $value; + $this->originalAttributes[$key] = $value; + } + } + + /** + * @param string $method + * @return void + * @throws \LogicException + */ + private function assertUsesSoftDeletes(string $method): void + { + if (!$this->usesSoftDeletes()) { + throw new \LogicException( + "Cannot call {$method}() on " . static::class . ': the model is not marked #[SoftDeletes].' + ); + } + } +} diff --git a/src/Phaseolies/Database/Entity/SoftDeletes/SoftDeleteManager.php b/src/Phaseolies/Database/Entity/SoftDeletes/SoftDeleteManager.php new file mode 100644 index 00000000..7d3f5d4a --- /dev/null +++ b/src/Phaseolies/Database/Entity/SoftDeletes/SoftDeleteManager.php @@ -0,0 +1,77 @@ + + */ + private static array $columns = []; + + /** + * Determine whether the given model class is soft-deletable. + * + * @param string $class + * @return bool + */ + public static function isSoftDeletable(string $class): bool + { + return self::column($class) !== null; + } + + /** + * Get the soft delete column for the given model class. + * + * The attribute is read once per class and cached; parent classes + * are honoured so a soft-deletable base model passes it down. + * + * @param string $class + * @return string|null + */ + public static function column(string $class): ?string + { + if (array_key_exists($class, self::$columns)) { + return self::$columns[$class]; + } + + $column = null; + + if (!class_exists($class)) { + return self::$columns[$class] = null; + } + + $reflection = new \ReflectionClass($class); + + do { + $attributes = $reflection->getAttributes(SoftDeletes::class); + + if (!empty($attributes)) { + $column = $attributes[0]->newInstance()->column; + break; + } + } while ($reflection = $reflection->getParentClass()); + + return self::$columns[$class] = $column; + } + + /** + * Reset the internal attribute cache. Useful in tests. + * + * @param string|null $class + * @return void + */ + public static function resetCache(?string $class = null): void + { + if ($class !== null) { + unset(self::$columns[$class]); + } else { + self::$columns = []; + } + } +} diff --git a/src/Phaseolies/Database/Temporal/InteractsWithTemporal.php b/src/Phaseolies/Database/Temporal/InteractsWithTemporal.php index ae8d7006..2b278764 100644 --- a/src/Phaseolies/Database/Temporal/InteractsWithTemporal.php +++ b/src/Phaseolies/Database/Temporal/InteractsWithTemporal.php @@ -12,7 +12,7 @@ trait InteractsWithTemporal { /** - * Register after_created / after_updated / after_deleted hooks that + * Register after_created / after_updated / after_deleted / after_restored hooks that * automatically snapshot this model if it carries #[Temporal]. * * Invoked once per class during the first model construction. @@ -29,6 +29,7 @@ protected function registerTemporalHooks(): void 'after_created' => static fn(Model $model) => TemporalManager::snapshot($model, 'created'), 'after_updated' => static fn(Model $model) => TemporalManager::snapshot($model, 'updated'), 'after_deleted' => static fn(Model $model) => TemporalManager::snapshot($model, 'deleted'), + 'after_restored' => static fn(Model $model) => TemporalManager::snapshot($model, 'restored'), ]); } @@ -189,7 +190,17 @@ public function restoreTo(string $datetime): bool return false; } - return $rewound->save(); + if (!$rewound->save()) { + return false; + } + + // save() only writes creatable columns, so carry the snapshot's + // soft delete state over explicitly when it differs from now. + if ($this->usesSoftDeletes() && $rewound->trashed() !== $this->trashed()) { + return $rewound->trashed() ? $rewound->delete() : $rewound->restore(); + } + + return true; } /** diff --git a/tests/Model/SoftDelete/SoftDeleteBehavior.php b/tests/Model/SoftDelete/SoftDeleteBehavior.php new file mode 100644 index 00000000..c1766566 --- /dev/null +++ b/tests/Model/SoftDelete/SoftDeleteBehavior.php @@ -0,0 +1,688 @@ + [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ], + 'articles' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'author_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'title', 'type' => 'string'], + ['name' => 'views', 'type' => 'integer', 'nullable' => true, 'default' => 0], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ['name' => 'updated_at', 'type' => 'datetime', 'nullable' => true], + ['name' => 'deleted_at', 'type' => 'datetime', 'nullable' => true], + ], + 'notes' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'article_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'body', 'type' => 'string'], + ['name' => 'removed_at', 'type' => 'datetime', 'nullable' => true], + ], + 'labels' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ['name' => 'deleted_at', 'type' => 'datetime', 'nullable' => true], + ], + 'article_label' => [ + ['name' => 'article_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'label_id', 'type' => 'integer', 'nullable' => true], + ], + ]; + } + + protected function seedData(): array + { + $stamp = '2024-01-01 00:00:00'; + + return [ + 'authors' => [ + ['name' => 'Ann'], + ['name' => 'Ben'], + ['name' => 'Cid'], + ], + 'articles' => [ + ['author_id' => 1, 'title' => 'Alpha', 'views' => 10, 'created_at' => $stamp, 'updated_at' => $stamp], + ['author_id' => 1, 'title' => 'Beta', 'views' => 20, 'created_at' => $stamp, 'updated_at' => $stamp], + ['author_id' => 2, 'title' => 'Gamma', 'views' => 30, 'created_at' => $stamp, 'updated_at' => $stamp], + ['author_id' => 2, 'title' => 'Delta', 'views' => 40, 'created_at' => $stamp, 'updated_at' => $stamp], + ], + 'notes' => [ + ['article_id' => 1, 'body' => 'first'], + ['article_id' => 1, 'body' => 'second'], + ['article_id' => 3, 'body' => 'third'], + ], + 'labels' => [ + ['name' => 'php'], + ['name' => 'sql'], + ['name' => 'legacy'], + ], + 'article_label' => [ + ['article_id' => 1, 'label_id' => 1], + ['article_id' => 1, 'label_id' => 2], + ['article_id' => 2, 'label_id' => 3], + ['article_id' => 3, 'label_id' => 3], + ], + ]; + } + + private function rawRow(string $table, int $id): array|false + { + $stmt = $this->pdo->prepare("SELECT * FROM {$table} WHERE id = ?"); + $stmt->execute([$id]); + + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + private function bindUrlGenerator(): void + { + Container::getInstance()->bind('url', fn() => new UrlGenerator('http://localhost')); + } + + private function ids(iterable $models): array + { + $ids = []; + foreach ($models as $model) { + $ids[] = (int) $model->id; + } + sort($ids); + + return $ids; + } + + // ========================================================================= + // Deleting and querying + // ========================================================================= + + public function testDeleteStampsTheRowInsteadOfRemovingIt(): void + { + $article = MockSoftArticle::find(1); + + $this->assertTrue($article->delete()); + $this->assertTrue($article->trashed()); + $this->assertNotNull($article->deleted_at); + + $row = $this->rawRow('articles', 1); + $this->assertNotFalse($row); + $this->assertNotNull($row['deleted_at']); + $this->assertNotSame('2024-01-01 00:00:00', (string) $row['updated_at']); + } + + public function testModelMirrorsTheExactValuesWritten(): void + { + Carbon::setTestNow(Carbon::parse('2026-03-04 05:06:07')); + + try { + $article = MockSoftArticle::find(1); + $article->delete(); + + $row = $this->rawRow('articles', 1); + $this->assertSame('2026-03-04 05:06:07', $article->deleted_at); + $this->assertSame('2026-03-04 05:06:07', $article->updated_at); + $this->assertSame('2026-03-04 05:06:07', substr((string) $row['deleted_at'], 0, 19)); + $this->assertSame('2026-03-04 05:06:07', substr((string) $row['updated_at'], 0, 19)); + $this->assertSame([], $article->getDirtyAttributes()); + + Carbon::setTestNow(Carbon::parse('2026-03-05 00:00:00')); + $article->restore(); + + $row = $this->rawRow('articles', 1); + $this->assertNull($article->deleted_at); + $this->assertNull($row['deleted_at']); + $this->assertSame('2026-03-05 00:00:00', $article->updated_at); + $this->assertSame('2026-03-05 00:00:00', substr((string) $row['updated_at'], 0, 19)); + $this->assertSame([], $article->getDirtyAttributes()); + } finally { + Carbon::setTestNow(); + } + } + + public function testModelWithoutTimestampsDoesNotTouchUpdatedAt(): void + { + MockSoftNote::find(1)->delete(); + + $this->assertArrayNotHasKey('updated_at', MockSoftNote::withTrashed()->find(1)->getAttributes()); + $this->assertNotNull($this->rawRow('notes', 1)['removed_at']); + } + + public function testDeletingAModelWithoutAKeyIsANoOp(): void + { + $this->assertFalse((new MockSoftArticle())->delete()); + $this->assertSame(4, MockSoftArticle::count()); + } + + public function testTrashedRowsAreExcludedByDefault(): void + { + MockSoftArticle::find(1)->delete(); + + $this->assertNull(MockSoftArticle::find(1)); + $this->assertSame([2, 3, 4], $this->ids(MockSoftArticle::all())); + $this->assertSame(3, MockSoftArticle::count()); + $this->assertSame(3, MockSoftArticle::query()->count()); + $this->assertFalse(MockSoftArticle::where('id', 1)->exists()); + $this->assertSame(['Beta', 'Delta', 'Gamma'], MockSoftArticle::orderBy('title')->get()->pluck('title')->all()); + } + + public function testWithTrashedAndOnlyTrashed(): void + { + MockSoftArticle::find(1)->delete(); + MockSoftArticle::find(3)->delete(); + + $this->assertSame([1, 2, 3, 4], $this->ids(MockSoftArticle::withTrashed()->get())); + $this->assertSame([1, 3], $this->ids(MockSoftArticle::onlyTrashed()->get())); + $this->assertSame([2, 4], $this->ids(MockSoftArticle::onlyTrashed()->withoutTrashed()->get())); + $this->assertSame(1, (int) MockSoftArticle::withTrashed()->find(1)->id); + $this->assertTrue(MockSoftArticle::withTrashed()->find(1)->trashed()); + $this->assertSame(2, MockSoftArticle::onlyTrashed()->count()); + } + + public function testOrConditionsCannotBypassTheConstraint(): void + { + MockSoftArticle::find(1)->delete(); + + $articles = MockSoftArticle::where('id', 1)->orWhere('id', 2)->get(); + + $this->assertSame([2], $this->ids($articles)); + } + + public function testNestedWhereAppliesTheConstraintOnce(): void + { + MockSoftArticle::find(1)->delete(); + + $query = MockSoftArticle::where(function ($q) { + $q->where('id', 1)->orWhere('id', 2); + }); + + $this->assertSame(1, substr_count($query->toSql(), 'deleted_at')); + $this->assertSame([2], $this->ids($query->get())); + } + + public function testLimitAndOffsetSkipTrashedRows(): void + { + MockSoftArticle::find(2)->delete(); + + $page = MockSoftArticle::orderBy('id')->limit(2)->offset(1)->get(); + + $this->assertSame([3, 4], $this->ids($page)); + } + + public function testOrWhereGroupAppliesTheConstraintOutsideTheGroup(): void + { + MockSoftArticle::find(1)->delete(); + + $query = MockSoftArticle::where('id', 3)->orWhere(function ($q) { + $q->where('id', 1)->where(function ($inner) { + $inner->where('views', 10)->orWhere('views', 20); + }); + }); + + $this->assertSame(1, substr_count($query->toSql(), 'deleted_at')); + $this->assertSame([3], $this->ids($query->get())); + } + + public function testAggregatesAndGroupedCountsSkipTrashedRows(): void + { + MockSoftArticle::find(4)->delete(); + + $this->assertEquals(60, MockSoftArticle::query()->sum('views')); + $this->assertEquals(30, MockSoftArticle::query()->max('views')); + $this->assertEquals(20, MockSoftArticle::query()->avg('views')); + $this->assertSame(2, MockSoftArticle::query()->groupBy('author_id')->count()); + $this->assertSame(2, MockSoftArticle::where('author_id', 2)->withTrashed()->count()); + } + + public function testChunkingAndCursorPaginationSkipTrashedRows(): void + { + MockSoftArticle::find(2)->delete(); + + $chunked = []; + MockSoftArticle::query()->chunkById(2, function ($chunk) use (&$chunked) { + foreach ($chunk as $article) { + $chunked[] = (int) $article->id; + } + }); + $this->assertSame([1, 3, 4], $chunked); + + $this->bindUrlGenerator(); + + $page = MockSoftArticle::query()->cursorPaginate(10); + $this->assertSame([1, 3, 4], $this->ids($page['data'])); + } + + public function testPaginationCountsOnlyLiveRows(): void + { + MockSoftArticle::find(4)->delete(); + $this->bindUrlGenerator(); + + $page = MockSoftArticle::query()->paginate(2); + + $this->assertSame(3, $page['total']); + $this->assertSame([1, 2], $this->ids($page['data'])); + } + + public function testBulkUpdateAndIncrementSkipTrashedRows(): void + { + MockSoftArticle::find(1)->delete(); + + MockSoftArticle::where('author_id', 1)->update(['title' => 'Bulk']); + MockSoftArticle::where('author_id', 1)->increment('views', 1); + + $this->assertSame('Alpha', $this->rawRow('articles', 1)['title']); + $this->assertSame(10, (int) $this->rawRow('articles', 1)['views']); + $this->assertSame('Bulk', $this->rawRow('articles', 2)['title']); + $this->assertSame(21, (int) $this->rawRow('articles', 2)['views']); + } + + public function testTableAliasIsUsedToQualifyTheColumn(): void + { + $sql = MockSoftArticle::query()->from('articles as a')->where('a.id', 1)->toSql(); + + $this->assertStringContainsString('a.deleted_at IS NULL', $sql); + $this->assertStringNotContainsString('articles as a.deleted_at', $sql); + + MockSoftArticle::find(1)->delete(); + + $this->assertNull(MockSoftArticle::query()->from('articles as a')->where('a.id', 1)->first()); + $this->assertNotNull(MockSoftArticle::query()->from('articles a')->where('a.id', 2)->first()); + } + + public function testSavingAndIncrementingATrashedModelStillPersists(): void + { + MockSoftArticle::find(1)->delete(); + + $article = MockSoftArticle::withTrashed()->find(1); + $article->title = 'Renamed'; + $this->assertTrue($article->save()); + $this->assertSame(1, $article->increment('views', 5)); + + $row = $this->rawRow('articles', 1); + $this->assertSame('Renamed', $row['title']); + $this->assertSame(15, (int) $row['views']); + $this->assertNotNull($row['deleted_at']); + } + + public function testFreshKeepsTheTrashedMode(): void + { + MockSoftArticle::find(1)->delete(); + + $this->assertNotNull(MockSoftArticle::withTrashed()->where('id', 1)->fresh()); + $this->assertNull(MockSoftArticle::where('id', 1)->fresh()); + } + + // ========================================================================= + // Restoring and force deleting + // ========================================================================= + + public function testRestoreBringsTheModelBack(): void + { + $article = MockSoftArticle::find(1); + $article->delete(); + + $this->assertTrue($article->restore()); + $this->assertFalse($article->trashed()); + $this->assertNull($this->rawRow('articles', 1)['deleted_at']); + $this->assertNotNull(MockSoftArticle::find(1)); + } + + public function testBulkRestoreTargetsTrashedRows(): void + { + MockSoftArticle::where('author_id', 1)->delete(); + $this->assertSame(2, MockSoftArticle::count()); + + $this->assertTrue(MockSoftArticle::where('id', 1)->restore()); + + $this->assertSame([1, 3, 4], $this->ids(MockSoftArticle::all())); + $this->assertSame('2024-01-01 00:00:00', (string) $this->rawRow('articles', 3)['updated_at']); + } + + public function testRestoringALiveModelKeepsItLive(): void + { + $article = MockSoftArticle::find(1); + + $this->assertTrue($article->restore()); + $this->assertFalse($article->trashed()); + $this->assertNull($this->rawRow('articles', 1)['deleted_at']); + } + + public function testDeletingATrashedModelRestampsIt(): void + { + Carbon::setTestNow(Carbon::parse('2026-01-01 00:00:00')); + + try { + MockSoftArticle::find(1)->delete(); + + Carbon::setTestNow(Carbon::parse('2026-02-01 00:00:00')); + MockSoftArticle::withTrashed()->find(1)->delete(); + + $this->assertSame('2026-02-01 00:00:00', substr((string) $this->rawRow('articles', 1)['deleted_at'], 0, 19)); + $this->assertSame(1, MockSoftArticle::onlyTrashed()->count()); + } finally { + Carbon::setTestNow(); + } + } + + public function testBulkSoftDeleteDoesNotRestampTrashedRows(): void + { + Carbon::setTestNow(Carbon::parse('2026-01-01 00:00:00')); + + try { + MockSoftArticle::find(1)->delete(); + + Carbon::setTestNow(Carbon::parse('2026-02-01 00:00:00')); + MockSoftArticle::where('author_id', 1)->delete(); + + $this->assertSame('2026-01-01 00:00:00', substr((string) $this->rawRow('articles', 1)['deleted_at'], 0, 19)); + $this->assertSame('2026-02-01 00:00:00', substr((string) $this->rawRow('articles', 2)['deleted_at'], 0, 19)); + } finally { + Carbon::setTestNow(); + } + } + + public function testForceDeleteRemovesTheRow(): void + { + $article = MockSoftArticle::find(1); + + $this->assertTrue($article->forceDelete()); + $this->assertFalse($this->rawRow('articles', 1)); + $this->assertSame(3, MockSoftArticle::withTrashed()->count()); + } + + public function testForceDeleteRemovesAnAlreadyTrashedModel(): void + { + MockSoftArticle::find(1)->delete(); + + $this->assertTrue(MockSoftArticle::withTrashed()->find(1)->forceDelete()); + $this->assertFalse($this->rawRow('articles', 1)); + } + + public function testBulkDeleteSoftDeletesAndBulkForceDeleteRemoves(): void + { + $this->assertTrue(MockSoftArticle::where('author_id', 2)->delete()); + $this->assertSame([3, 4], $this->ids(MockSoftArticle::onlyTrashed()->get())); + $this->assertNotFalse($this->rawRow('articles', 3)); + + $this->assertTrue(MockSoftArticle::onlyTrashed()->forceDelete()); + $this->assertFalse($this->rawRow('articles', 3)); + $this->assertFalse($this->rawRow('articles', 4)); + $this->assertSame([1, 2], $this->ids(MockSoftArticle::withTrashed()->get())); + } + + public function testPurgeSoftDeletesLiveRowsOnly(): void + { + MockSoftArticle::find(1)->delete(); + $before = $this->rawRow('articles', 1)['deleted_at']; + + $this->assertSame(1, MockSoftArticle::query()->purge(1, 2)); + $this->assertSame((string) $before, (string) $this->rawRow('articles', 1)['deleted_at']); + $this->assertNotNull($this->rawRow('articles', 2)['deleted_at']); + $this->assertSame([3, 4], $this->ids(MockSoftArticle::all())); + } + + public function testCustomColumnIsHonoured(): void + { + MockSoftNote::find(1)->delete(); + + $this->assertNotNull($this->rawRow('notes', 1)['removed_at']); + $this->assertSame([2, 3], $this->ids(MockSoftNote::all())); + $this->assertSame([1], $this->ids(MockSoftNote::onlyTrashed()->get())); + } + + // ========================================================================= + // Hooks + // ========================================================================= + + public function testDeleteAndRestoreHooksFire(): void + { + $events = []; + $record = function (string $event) use (&$events) { + return function (Model $model) use (&$events, $event) { + $events[] = $event . ($model->isForceDeleting() ? ':force' : ''); + }; + }; + + HookHandler::register(MockSoftArticle::class, [ + 'before_deleted' => $record('before_deleted'), + 'after_deleted' => $record('after_deleted'), + 'before_restored' => $record('before_restored'), + 'after_restored' => $record('after_restored'), + ]); + + try { + $article = MockSoftArticle::find(1); + $article->delete(); + $article->restore(); + $article->forceDelete(); + } finally { + unset(HookHandler::$hooks[MockSoftArticle::class]); + } + + $this->assertSame([ + 'before_deleted', + 'after_deleted', + 'before_restored', + 'after_restored', + 'before_deleted:force', + 'after_deleted:force', + ], $events); + } + + // ========================================================================= + // Relationships + // ========================================================================= + + public function testLazyAndEagerRelationsExcludeTrashedRelated(): void + { + MockSoftArticle::find(1)->delete(); + + $author = MockSoftAuthor::find(1); + $this->assertSame([2], $this->ids($author->articles)); + + $authors = MockSoftAuthor::query()->embed('articles')->orderBy('id')->get(); + $this->assertSame([2], $this->ids($authors[0]->articles)); + $this->assertSame([3, 4], $this->ids($authors[1]->articles)); + + $authors = MockSoftAuthor::query()->embedCount('articles')->orderBy('id')->get(); + $this->assertSame(1, (int) $authors[0]->articles_count); + $this->assertSame(2, (int) $authors[1]->articles_count); + } + + public function testManyToManyExcludesTrashedRelated(): void + { + MockSoftLabel::find(2)->delete(); + + $article = MockSoftArticle::query()->embed('labels')->where('id', 1)->first(); + $this->assertSame([1], $this->ids($article->labels)); + + $article = MockSoftArticle::query()->embedCount('labels')->where('id', 1)->first(); + $this->assertSame(1, (int) $article->labels_count); + } + + public function testPresentAndAbsentIgnoreTrashedRelated(): void + { + MockSoftArticle::find(3)->delete(); + MockSoftArticle::find(4)->delete(); + + $this->assertSame([1], $this->ids(MockSoftAuthor::query()->present('articles')->get())); + $this->assertSame([2, 3], $this->ids(MockSoftAuthor::query()->absent('articles')->get())); + + $withTrashed = MockSoftAuthor::query()->present('articles', fn($q) => $q->withTrashed())->get(); + $this->assertSame([1, 2], $this->ids($withTrashed)); + + $onlyTrashed = MockSoftAuthor::query()->present('articles', fn($q) => $q->onlyTrashed())->get(); + $this->assertSame([2], $this->ids($onlyTrashed)); + } + + public function testPresentManyToManyIgnoresTrashedRelated(): void + { + MockSoftLabel::find(3)->delete(); + + $this->assertSame([1], $this->ids(MockSoftArticle::query()->present('labels')->get())); + + $named = MockSoftArticle::query()->present('labels', fn($q) => $q->where('name', 'legacy'))->get(); + $this->assertSame([], $this->ids($named)); + } + + public function testOrPresentIfExistsAndWhereLinkedIgnoreTrashedRelated(): void + { + MockSoftArticle::find(3)->delete(); + MockSoftArticle::find(4)->delete(); + + $this->assertSame([1], $this->ids(MockSoftAuthor::query()->ifExists('articles')->get())); + $this->assertSame([2, 3], $this->ids(MockSoftAuthor::query()->ifNotExists('articles')->get())); + $this->assertSame([1, 3], $this->ids(MockSoftAuthor::where('id', 3)->orPresent('articles')->get())); + $this->assertSame([], $this->ids(MockSoftAuthor::query()->whereLinked('articles', 'title', 'Gamma')->get())); + $this->assertSame([1], $this->ids(MockSoftAuthor::query()->whereLinked('articles', 'title', 'Alpha')->get())); + } + + public function testNestedRelationsThroughManyToManyIgnoreTrashedRows(): void + { + // Label 3 is attached to articles 2 (Ann) and 3 (Ben) + $this->assertSame([1, 2], $this->ids(MockSoftAuthor::query()->present('articles.labels')->get())); + + MockSoftLabel::find(1)->delete(); + MockSoftLabel::find(2)->delete(); + MockSoftArticle::find(3)->delete(); + + // Ann reaches label 3 through live article 2; Ben's article 3 is trashed + $this->assertSame([1], $this->ids(MockSoftAuthor::query()->present('articles.labels')->get())); + $this->assertSame([1], $this->ids(MockSoftAuthor::query()->whereLinked('articles.labels', 'name', 'legacy')->get())); + $this->assertSame([], $this->ids(MockSoftAuthor::query()->whereLinked('articles.labels', 'name', 'php')->get())); + } + + public function testNestedEagerLoadingSkipsTrashedRows(): void + { + MockSoftArticle::find(2)->delete(); + MockSoftNote::find(2)->delete(); + + $author = MockSoftAuthor::query()->embed('articles.notes')->where('id', 1)->first(); + + $this->assertSame([1], $this->ids($author->articles)); + $this->assertSame([1], $this->ids($author->articles[0]->notes)); + } + + public function testBelongsToTrashedParentResolvesToNull(): void + { + $note = MockSoftNote::find(1); + MockSoftArticle::find(1)->delete(); + + $article = MockSoftArticle::query()->embed('author')->withTrashed()->where('id', 1)->first(); + + $this->assertSame('Ann', $article->author->name); + $this->assertNull(MockSoftArticle::find((int) $note->article_id)); + } + + public function testNestedPresentAndWhereLinkedIgnoreTrashedRows(): void + { + // Article 3 holds Ben's only note; trashing the article hides it + MockSoftArticle::find(3)->delete(); + + $this->assertSame([1], $this->ids(MockSoftAuthor::query()->present('articles.notes')->get())); + $this->assertSame([], $this->ids(MockSoftAuthor::query()->whereLinked('articles.notes', 'body', 'third')->get())); + + // Trashing Ann's notes hides them at the end of the chain + MockSoftNote::query()->where('article_id', 1)->delete(); + + $this->assertSame([], $this->ids(MockSoftAuthor::query()->present('articles.notes')->get())); + $this->assertSame([], $this->ids(MockSoftAuthor::query()->whereLinked('articles.notes', 'body', 'first')->get())); + } + + // ========================================================================= + // Models without #[SoftDeletes] + // ========================================================================= + + public function testModelsWithoutTheAttributeAreUnaffected(): void + { + $this->assertFalse((new MockSoftAuthor())->usesSoftDeletes()); + $this->assertStringNotContainsString('deleted_at', MockSoftAuthor::where('id', 1)->toSql()); + + $this->assertTrue(MockSoftAuthor::find(3)->delete()); + $this->assertFalse($this->rawRow('authors', 3)); + } + + public function testSoftDeleteMethodsThrowOnModelsWithoutTheAttribute(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('not marked #[SoftDeletes]'); + + MockSoftAuthor::withTrashed(); + } + + public function testModelRestoreThrowsOnModelsWithoutTheAttribute(): void + { + $this->expectException(\LogicException::class); + + MockSoftAuthor::find(1)->restore(); + } + + public function testModelForceDeleteThrowsOnModelsWithoutTheAttribute(): void + { + try { + MockSoftAuthor::find(1)->forceDelete(); + $this->fail('Expected a LogicException.'); + } catch (\LogicException) { + $this->assertNotFalse($this->rawRow('authors', 1)); + } + } + + /** + * @return array + */ + public static function builderOnlyMethods(): array + { + return [ + 'onlyTrashed' => ['onlyTrashed'], + 'withoutTrashed' => ['withoutTrashed'], + 'restore' => ['restore'], + 'forceDelete' => ['forceDelete'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('builderOnlyMethods')] + public function testBuilderMethodsThrowOnModelsWithoutTheAttribute(string $method): void + { + try { + MockSoftAuthor::query()->$method(); + $this->fail('Expected a LogicException.'); + } catch (\LogicException $e) { + $this->assertStringContainsString("{$method}()", $e->getMessage()); + $this->assertSame(3, MockSoftAuthor::count()); + } + } + + public function testWithoutHookStillSoftDeletes(): void + { + $fired = false; + HookHandler::register(MockSoftArticle::class, [ + 'before_deleted' => function () use (&$fired) { + $fired = true; + }, + ]); + + try { + MockSoftArticle::withoutHook()->find(1)->delete(); + } finally { + unset(HookHandler::$hooks[MockSoftArticle::class]); + } + + $this->assertFalse($fired); + $this->assertNotNull($this->rawRow('articles', 1)['deleted_at']); + } +} diff --git a/tests/Model/SoftDelete/SoftDeleteMysqlTest.php b/tests/Model/SoftDelete/SoftDeleteMysqlTest.php new file mode 100644 index 00000000..a8dec873 --- /dev/null +++ b/tests/Model/SoftDelete/SoftDeleteMysqlTest.php @@ -0,0 +1,17 @@ +pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->pdo->exec( + 'CREATE TABLE soft_temporal_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + created_at TEXT NULL, + updated_at TEXT NULL, + deleted_at TEXT NULL + )' + ); + + foreach (TemporalManager::createHistoryTableSql('soft_temporal_records_history', 'sqlite', true) as $statement) { + $this->pdo->exec($statement); + } + + $this->setConnections(['default' => $this->pdo, 'sqlite' => $this->pdo]); + + // Hooks register once per class on first construction, so boot the + // class first, then clear the registry and register them exactly once. + $model = new MockSoftTemporalRecord(); + HookHandler::$hooks = []; + TemporalManager::resetCache(); + \Closure::bind(fn() => $this->registerTemporalHooks(), $model, MockSoftTemporalRecord::class)(); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + HookHandler::$hooks = []; + TemporalManager::resetCache(); + $this->setConnections([]); + Container::forgetInstance(); + $this->pdo = null; + } + + public function testSoftDeleteAndRestoreAreRecordedInHistory(): void + { + $this->travelTo('2026-01-01 10:00:00'); + $record = MockSoftTemporalRecord::create(['title' => 'Draft']); + + $this->travelTo('2026-01-02 10:00:00'); + $record->delete(); + + $this->travelTo('2026-01-03 10:00:00'); + $record->restore(); + + $history = $record->history(); + $actions = []; + foreach ($history as $entry) { + $actions[] = $entry->__action; + } + + $this->assertSame(['created', 'deleted', 'restored'], $actions); + $this->assertNotNull($history[1]->deleted_at); + $this->assertNull($history[2]->deleted_at); + } + + public function testTimeTravelSeesTheSoftDeleteWindow(): void + { + $this->travelTo('2026-01-01 10:00:00'); + $record = MockSoftTemporalRecord::create(['title' => 'Draft']); + + $this->travelTo('2026-01-02 10:00:00'); + $record->delete(); + + $this->travelTo('2026-01-03 10:00:00'); + $record->restore(); + + $this->assertCount(1, MockSoftTemporalRecord::at('2026-01-01 12:00:00')->get()); + $this->assertCount(0, MockSoftTemporalRecord::at('2026-01-02 12:00:00')->get()); + $this->assertCount(1, MockSoftTemporalRecord::at('2026-01-03 12:00:00')->get()); + } + + public function testRestoreToBeforeTheSoftDeleteBringsTheRowBack(): void + { + $this->travelTo('2026-01-01 10:00:00'); + $record = MockSoftTemporalRecord::create(['title' => 'Draft']); + + $this->travelTo('2026-01-02 10:00:00'); + $record->delete(); + $this->assertNull(MockSoftTemporalRecord::find($record->id)); + + $this->travelTo('2026-01-03 10:00:00'); + $trashed = MockSoftTemporalRecord::withTrashed()->find($record->id); + + $this->assertTrue($trashed->restoreTo('2026-01-01 12:00:00')); + + $restored = MockSoftTemporalRecord::find($record->id); + $this->assertNotNull($restored); + $this->assertNull($restored->deleted_at); + $this->assertSame('restored', $restored->history()->last()->__action); + } + + public function testRestoreToALiveStateLeavesALiveRecordAlone(): void + { + $this->travelTo('2026-01-01 10:00:00'); + $record = MockSoftTemporalRecord::create(['title' => 'Draft']); + + $this->travelTo('2026-01-02 10:00:00'); + $live = MockSoftTemporalRecord::find($record->id); + $live->title = 'Final'; + $live->save(); + + $this->travelTo('2026-01-03 10:00:00'); + $this->assertTrue(MockSoftTemporalRecord::find($record->id)->restoreTo('2026-01-01 12:00:00')); + + $current = MockSoftTemporalRecord::find($record->id); + $this->assertSame('Draft', $current->title); + $this->assertNull($current->deleted_at); + } + + private function travelTo(string $datetime): void + { + Carbon::setTestNow(Carbon::parse($datetime, 'UTC')); + } + + private function setConnections(array $connections): void + { + $reflection = new \ReflectionClass(Database::class); + $reflection->getProperty('connections')->setValue(null, $connections); + $reflection->getProperty('transactions')->setValue(null, []); + } +} diff --git a/tests/Support/Model/MockSoftArticle.php b/tests/Support/Model/MockSoftArticle.php new file mode 100644 index 00000000..3701f184 --- /dev/null +++ b/tests/Support/Model/MockSoftArticle.php @@ -0,0 +1,31 @@ +bindTo(MockSoftAuthor::class, 'id', 'author_id'); + } + + public function notes() + { + return $this->linkMany(MockSoftNote::class, 'article_id', 'id'); + } + + public function labels() + { + return $this->bindToMany(MockSoftLabel::class, 'article_id', 'label_id', 'article_label'); + } +} diff --git a/tests/Support/Model/MockSoftAuthor.php b/tests/Support/Model/MockSoftAuthor.php new file mode 100644 index 00000000..1fb23485 --- /dev/null +++ b/tests/Support/Model/MockSoftAuthor.php @@ -0,0 +1,19 @@ +linkMany(MockSoftArticle::class, 'author_id', 'id'); + } +} diff --git a/tests/Support/Model/MockSoftLabel.php b/tests/Support/Model/MockSoftLabel.php new file mode 100644 index 00000000..b8e9496a --- /dev/null +++ b/tests/Support/Model/MockSoftLabel.php @@ -0,0 +1,16 @@ +