Lines 100.00% 58 / 58
Functions and Methods 100.00% 9 / 9
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
SoftDeletes 100.00% 58 / 58 100.00% 9 / 9 21 100.00% 1 / 1
 excludeTrashed 100.00% 1 / 1 100.00% 1 / 1 1
 softDelete 100.00% 18 / 18 100.00% 1 / 1 3
 deletedAtColumn 100.00% 1 / 1 100.00% 1 / 1 1
 softDeleteColumn 100.00% 1 / 1 100.00% 1 / 1 1
 forceDelete 100.00% 4 / 4 100.00% 1 / 1 2
 restore 100.00% 15 / 15 100.00% 1 / 1 4
 trashed 100.00% 1 / 1 100.00% 1 / 1 1
 softDeletePropertyType 100.00% 3 / 3 100.00% 1 / 1 1
 writeDeletedAtColumn 100.00% 14 / 14 100.00% 1 / 1 7
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant;
6
7use BlueprintAU\Radiant\Attributes\Hook;
8use BlueprintAU\Radiant\Attributes\ModelScope;
9use BlueprintAU\Radiant\Attributes\WriteHook;
10use BlueprintAU\Radiant\Database\Query\Enums\WhereOperator;
11use BlueprintAU\Radiant\Exceptions\StaleRowException;
12use BlueprintAU\Radiant\Exceptions\WriteVetoException;
13use BlueprintAU\Radiant\Metadata\MetadataFactory;
14
15/**
16 * Opt-in soft-delete behaviour for a model.
17 *
18 * A user-facing trait — models apply it themselves
19 * (`class User extends Model { use SoftDeletes; }`). `delete()` becomes an
20 * UPDATE setting the delete timestamp; `forceDelete()` performs the real
21 * DELETE. The backing column is guaranteed by the {@see \BlueprintAU\Radiant\Metadata\MetadataFactory},
22 * which auto-declares it from {@see SoftDeletes::deletedAtColumn()} when
23 * the class uses the trait (a user-declared column of that name wins).
24 *
25 * Soft deletes are portable: the trait only uses `update()` and `whereKey()`
26 * — both in the portable subset — so it works on any
27 * `ConnectionInterface` (CSV included).
28 *
29 * The API boundary is deliberate: this trait owns ROW behavior (the
30 * scope, the delete hook, forceDelete/restore/trashed). The QUERY-side
31 * vocabulary (withTrashed/onlyTrashed/withoutScope) lives on
32 * {@see ModelQueryBuilder} — those methods must return a builder to stay
33 * fluent and manipulate the builder's traitScope where-markers, which a
34 * model-side trait method cannot do.
35 *
36 * @mixin \BlueprintAU\Radiant\Model
37 * @phpstan-require-extends \BlueprintAU\Radiant\Model
38 */
39trait SoftDeletes
40{
41    /**
42     * The trait's read scope: exclude soft-deleted rows from every query.
43     *
44     * @return list<ScopeCondition>
45     */
46    #[ModelScope]
47    public static function excludeTrashed(): array
48    {
49        return [new ScopeCondition(self::softDeleteColumn(), WhereOperator::Null)];
50    }
51
52    /**
53     * Claim the delete() path and perform the soft delete.
54     *
55     * The `?bool` return is the delete's outcome: `true` = soft-deleted,
56     * `null` = never claims failure. An unsaved model throws
57     * {@see \LogicException}; 0 affected rows throws a
58     * {@see StaleRowException}.
59     *
60     * @return bool|null
61     * @throws \LogicException
62     * @throws StaleRowException
63     */
64    #[WriteHook(Hook::Delete)]
65    protected function softDelete(): ?bool
66    {
67        if (!$this->exists) {
68            // An unsaved model has no row to soft-delete — a caller
69            // logic error, not a veto. Fail fast the same way an UPDATE
70            // with an unresolved key would.
71            throw new \LogicException(
72                'The model [' . static::class . '] was never saved — there is no row to delete.'
73            );
74        }
75
76        $stamp = $this->freshTimestamp();
77
78        $affected = $this->newQuery()
79            ->withTrashed()
80            ->whereKey($this->getKeyForRefresh())
81            ->update([self::softDeleteColumn() => $stamp]);
82
83        if ($affected === 0) {
84            // The row is gone (stale instance) — the delete cannot
85            // silently no-op.
86            throw new StaleRowException(static::class, 'delete');
87        }
88
89        // The snapshot lives in the ENCODED (bindable) space — raw bytes,
90        // not a Carbon object. Storing the raw timestamp here would make
91        // the next getDirty() compare Carbon against the encoded string,
92        // flag deleted_at dirty forever, and have every subsequent save()
93        // re-write a column that did not change.
94        $encodedStamp = MetadataFactory::for(static::class)
95            ->mappingFor(self::softDeleteColumn())
96            ->column
97            ->encode($stamp, $this->softDeletePropertyType());
98
99        $this->writeDeletedAtColumn($stamp);
100        $this->original[self::softDeleteColumn()] = $encodedStamp;
101
102        return true;
103    }
104
105    /**
106     * The column holding the soft-delete timestamp.
107     *
108     * Return `null` (the default) to use `deleted_at`. Override to rename —
109     * the returned name must match a declared `#[Column]` on the model.
110     *
111     * @return string|null
112     */
113    public static function deletedAtColumn(): ?string
114    {
115        return null;
116    }
117
118    /**
119     * The resolved soft-delete column name — the override when non-null,
120     * the `deleted_at` default otherwise.
121     *
122     * @return string
123     */
124    private static function softDeleteColumn(): string
125    {
126        /** @phpstan-ignore nullCoalesce.expr (the trait is re-analyzed per using class — overrides narrowing deletedAtColumn() to non-nullable string make the left side look never-null there) */
127        return self::deletedAtColumn() ?? 'deleted_at';
128    }
129
130    /**
131     * Permanently delete the model — the real DELETE.
132     *
133     * A `deleting` listener returning false vetoes via a thrown
134     * {@see WriteVetoException}; 0 affected rows throws a
135     * {@see StaleRowException}. The `#[WriteHook(Hook::Destroy)]`
136     * observers run before the DELETE; the hard DELETE itself is
137     * unclaimable.
138     *
139     * @return void
140     * @throws \LogicException
141     * @throws WriteVetoException
142     * @throws StaleRowException
143     */
144    public function forceDelete(): void
145    {
146        if (!$this->fireLifecycle('deleting')) {
147            throw WriteVetoException::listener(static::class, 'deleting');
148        }
149
150        $this->performDelete();
151
152        $this->fireLifecycle('deleted');
153    }
154
155    /**
156     * Restore a soft-deleted model — clear the delete timestamp.
157     *
158     * A `restoring` listener returning false vetoes via a thrown
159     * {@see WriteVetoException}; 0 affected rows throws a
160     * {@see StaleRowException}.
161     *
162     * @return void
163     * @throws \LogicException
164     * @throws WriteVetoException
165     * @throws StaleRowException
166     */
167    public function restore(): void
168    {
169        if (!$this->exists) {
170            // An unsaved model has no row to restore.
171            throw new \LogicException(
172                'The model [' . static::class . '] was never saved — there is no row to restore.'
173            );
174        }
175
176        if (!$this->fireLifecycle('restoring')) {
177            throw WriteVetoException::listener(static::class, 'restoring');
178        }
179
180        $affected = $this->newQuery()
181            ->withTrashed()
182            ->whereKey($this->getKeyForRefresh())
183            ->update([self::softDeleteColumn() => null]);
184
185        if ($affected === 0) {
186            // The row is gone (stale instance) — the restore cannot
187            // silently no-op.
188            throw new StaleRowException(static::class, 'restore');
189        }
190
191        $this->writeDeletedAtColumn(null);
192        $this->original[self::softDeleteColumn()] = null;
193
194        $this->fireLifecycle('restored');
195    }
196
197    /**
198     * Whether the model is soft-deleted.
199     *
200     * @return bool
201     */
202    public function trashed(): bool
203    {
204        return ($this->original[self::softDeleteColumn()] ?? $this->attribute(self::softDeleteColumn())) !== null;
205    }
206
207    /**
208     * The delete column's declared property type — the second argument to
209     * the column's codec when encoding the snapshot stamp.
210     *
211     * @return string|null
212     */
213    private function softDeletePropertyType(): ?string
214    {
215        return MetadataFactory::for(static::class)
216            ->mappingFor(self::softDeleteColumn())
217            ->propertyType;
218    }
219
220    /**
221     * Write the delete column's value — through the typed property when the
222     * column is user-declared, through the synthetic store otherwise.
223     *
224     * @param  mixed  $value
225     * @return void
226     */
227    private function writeDeletedAtColumn(mixed $value): void
228    {
229        $mapping = MetadataFactory::for(static::class)->mappingFor(self::softDeleteColumn());
230
231        $property = $mapping->property;
232        if ($property === null) {
233            // Synthetic column — the runtime store is its only writable slot.
234            $this->setAttribute(self::softDeleteColumn(), $value);
235            return;
236        }
237
238        $propertyType = $mapping->propertyType;
239        if (
240            $value instanceof \DateTimeInterface
241            && is_string($propertyType)
242            && $value::class !== $propertyType
243            && is_a($propertyType, \DateTimeInterface::class, true)
244            && !$value instanceof $propertyType
245        ) {
246            $method = new \ReflectionMethod($propertyType, 'createFromInterface');
247            $value = $method->invoke(null, $value);
248        }
249
250        $property->setValue($this, $value);
251    }
252}