Lines 98.21% 55 / 56
Functions and Methods 90.00% 9 / 10
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
Timestamps 98.21% 55 / 56 90.00% 9 / 10 31 0.00% 0 / 1
 createdAtColumn 100.00% 1 / 1 100.00% 1 / 1 1
 updatedAtColumn 100.00% 1 / 1 100.00% 1 / 1 1
 createdAtColumnName 100.00% 1 / 1 100.00% 1 / 1 1
 updatedAtColumnName 100.00% 1 / 1 100.00% 1 / 1 1
 stampOnInsert 100.00% 9 / 9 100.00% 1 / 1 5
 stampOnUpdate 100.00% 5 / 5 100.00% 1 / 1 2
 stampInsertRows 93.33% 14 / 15 0.00% 0 / 1 8.02
 stampUpdateValues 100.00% 4 / 4 100.00% 1 / 1 3
 isTimestampColumnSet 100.00% 6 / 6 100.00% 1 / 1 3
 writeTimestampColumn 100.00% 13 / 13 100.00% 1 / 1 6
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant;
6
7use BlueprintAU\Radiant\Attributes\Hook;
8use BlueprintAU\Radiant\Attributes\RowHook;
9use BlueprintAU\Radiant\Attributes\WriteHook;
10use BlueprintAU\Radiant\Metadata\MetadataFactory;
11
12/**
13 * Opt-in auto-stamping of `created_at` / `updated_at` for a model.
14 *
15 * Undeclared stamp columns are auto-declared at metadata build — shaped
16 * by the `timestamps()` blueprint helper (NOT NULL datetime), so schema
17 * sync creates them. A user-declared `#[Column]` of the same name wins;
18 * when a resolved column is absent the trait is a silent no-op for that
19 * column, so the trait can sit on a shared base model safely.
20 *
21 * @mixin \BlueprintAU\Radiant\Model
22 * @method static \Carbon\Carbon freshTimestamp() A fresh timestamp for the stamp columns.
23 * @phpstan-require-extends \BlueprintAU\Radiant\Model
24 */
25trait Timestamps
26{
27    /**
28     * The created-at column name.
29     *
30     * Return `null` (the default) to use `created_at`. Override to rename —
31     * the returned name must match a declared `#[Column]` on the model.
32     *
33     * @return string|null
34     */
35    public static function createdAtColumn(): ?string
36    {
37        return null;
38    }
39
40    /**
41     * The updated-at column name.
42     *
43     * Return `null` (the default) to use `updated_at`. Override to rename —
44     * the returned name must match a declared `#[Column]` on the model.
45     *
46     * @return string|null
47     */
48    public static function updatedAtColumn(): ?string
49    {
50        return null;
51    }
52
53    /**
54     * The resolved created-at column name — the override when non-null,
55     * the `created_at` default otherwise.
56     *
57     * @return string
58     */
59    private static function createdAtColumnName(): string
60    {
61        /** @phpstan-ignore nullCoalesce.expr (the trait is re-analyzed per using class — overrides narrowing createdAtColumn() to non-nullable string make the left side look never-null there) */
62        return self::createdAtColumn() ?? 'created_at';
63    }
64
65    /**
66     * The resolved updated-at column name — the override when non-null,
67     * the `updated_at` default otherwise.
68     *
69     * @return string
70     */
71    private static function updatedAtColumnName(): string
72    {
73        /** @phpstan-ignore nullCoalesce.expr (the trait is re-analyzed per using class — overrides narrowing updatedAtColumn() to non-nullable string make the left side look never-null there) */
74        return self::updatedAtColumn() ?? 'updated_at';
75    }
76
77    /**
78     * Stamp the timestamp columns before an INSERT — both columns, each
79     * only when unset (a caller-set value always wins).
80     *
81     * @return null
82     */
83    #[WriteHook(Hook::Insert)]
84    protected function stampOnInsert(): null
85    {
86        $metadata = MetadataFactory::for(static::class);
87
88        $createdAt = self::createdAtColumnName();
89        $updatedAt = self::updatedAtColumnName();
90
91        // One clock read per save — both stamps carry the same instant.
92        $now = $this->freshTimestamp();
93
94        if ($metadata->hasColumn($createdAt) && !$this->isTimestampColumnSet($createdAt)) {
95            $this->writeTimestampColumn($createdAt, $now);
96        }
97
98        if ($metadata->hasColumn($updatedAt) && !$this->isTimestampColumnSet($updatedAt)) {
99            $this->writeTimestampColumn($updatedAt, $now);
100        }
101
102        return null;
103    }
104
105    /**
106     * Bump `updated_at` before an UPDATE — always, even when the hydrated
107     * property already holds a value: the stamp is the point of the
108     * update. Runs before dirty computation so `getDirty()` sees it.
109     *
110     * @return null
111     */
112    #[WriteHook(Hook::Update)]
113    protected function stampOnUpdate(): null
114    {
115        $metadata = MetadataFactory::for(static::class);
116        $updatedAt = self::updatedAtColumnName();
117
118        if ($metadata->hasColumn($updatedAt)) {
119            $this->writeTimestampColumn($updatedAt, $this->freshTimestamp());
120        }
121
122        return null;
123    }
124
125    /**
126     * Stamp the bulk-insert rows — both columns per row, each only when
127     * the row does not carry it (a caller-set value always wins).
128     *
129     * @param  list<array<string, mixed>>  $rows
130     */
131    #[RowHook(Hook::Insert)]
132    protected static function stampInsertRows(array &$rows): void
133    {
134        $metadata = MetadataFactory::for(static::class);
135
136        $createdAt = self::createdAtColumnName();
137        $updatedAt = self::updatedAtColumnName();
138
139        $stampCreated = $metadata->hasColumn($createdAt);
140        $stampUpdated = $metadata->hasColumn($updatedAt);
141
142        if (!$stampCreated && !$stampUpdated) {
143            return;
144        }
145
146        // One clock read per batch — only when a row actually needs it.
147        $now = null;
148
149        foreach ($rows as &$row) {
150            if ($stampCreated && !array_key_exists($createdAt, $row)) {
151                $now ??= static::freshTimestamp();
152                $row[$createdAt] = $now;
153            }
154
155            if ($stampUpdated && !array_key_exists($updatedAt, $row)) {
156                $now ??= static::freshTimestamp();
157                $row[$updatedAt] = $now;
158            }
159        }
160    }
161
162    /**
163     * Stamp the bulk-update values — `updated_at` only when the payload
164     * does not carry it (a caller-set value always wins).
165     *
166     * @param  array<string, mixed>  $values
167     */
168    #[RowHook(Hook::Update)]
169    protected static function stampUpdateValues(array &$values): void
170    {
171        $metadata = MetadataFactory::for(static::class);
172        $updatedAt = self::updatedAtColumnName();
173
174        if ($metadata->hasColumn($updatedAt) && !array_key_exists($updatedAt, $values)) {
175            $values[$updatedAt] = static::freshTimestamp();
176        }
177    }
178
179    /**
180     * Whether the caller has already set a stamp column.
181     *
182     * @param  string  $columnName
183     * @return bool
184     */
185    private function isTimestampColumnSet(string $columnName): bool
186    {
187        $mapping = MetadataFactory::for(static::class)->mappingFor($columnName);
188
189        $property = $mapping->property;
190
191        if ($property === null) {
192            return array_key_exists($columnName, $this->syntheticValues)
193                || array_key_exists($columnName, $this->original);
194        }
195
196        return $property->isInitialized($this);
197    }
198
199    /**
200     * Write a stamp column — through the typed property when the column is
201     * user-declared, through the synthetic store otherwise.
202     *
203     * @param  string  $columnName
204     * @param  \Carbon\Carbon  $value
205     * @return void
206     */
207    private function writeTimestampColumn(string $columnName, \Carbon\Carbon $value): void
208    {
209        $mapping = MetadataFactory::for(static::class)->mappingFor($columnName);
210
211        $property = $mapping->property;
212        if ($property === null) {
213            // Synthetic column — the runtime store is its only writable slot.
214            $this->setAttribute($columnName, $value);
215            return;
216        }
217
218        $propertyType = $mapping->propertyType;
219        if (
220            is_string($propertyType)
221            && $value::class !== $propertyType
222            && is_a($propertyType, \DateTimeInterface::class, true)
223            && !$value instanceof $propertyType
224        ) {
225            $method = new \ReflectionMethod($propertyType, 'createFromInterface');
226            $value = $method->invoke(null, $value);
227        }
228
229        $property->setValue($this, $value);
230    }
231}