Lines 100.00% 16 / 16
Functions and Methods 100.00% 3 / 3
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
ForeignKey 100.00% 16 / 16 100.00% 3 / 3 6 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 resolvedReferences 100.00% 1 / 1 100.00% 1 / 1 1
 resolvedReferencesColumns 100.00% 14 / 14 100.00% 1 / 1 4
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Attributes;
6
7use BlueprintAU\Radiant\Database\Schema\Enums\ForeignKeyAction;
8use BlueprintAU\Radiant\Metadata\MetadataFactory;
9
10/**
11 * Declares a foreign-key constraint over one or more columns (class-level).
12 *
13 * Single-column convenience stays on {@see Column}'s `foreign` flag; use
14 * this attribute for composite FKs — or any FK needing actions/shape the
15 * flag cannot express. `references` accepts EITHER a table name string OR
16 * a model class-string — a model class-string resolves to its table name
17 * through the {@see MetadataFactory} at build time (the same convention the
18 * model itself uses), so a renamed table never breaks the FK silently.
19 *
20 * `referencesColumns` may be LEFT NULL when `references` is a model
21 * class-string: it then defaults to the target model's full primary-key
22 * column list (single or composite — a composite-PK target works out of
23 * the box). With an explicit `referencesColumns` the arity must match
24 * `columns` (mirroring `Blueprint::foreignKey()`); the {@see MetadataFactory}
25 * validates names and arity at build time and emits exactly one
26 * `Blueprint::foreignKey()` call per declaration.
27 *
28 * @phpstan-import-type ForeignKeyReference from \BlueprintAU\Radiant\Attributes\ReferenceResolver
29 */
30#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
31final class ForeignKey
32{
33    /**
34     * Create a foreign-key declaration.
35     *
36     * @param  list<string>  $columns
37     * @param  ForeignKeyReference  $references
38     * @param  list<string>|null  $referencesColumns
39     * @param  ForeignKeyAction|string|null  $onDelete
40     * @param  ForeignKeyAction|string|null  $onUpdate
41     * @param  bool  $deferrable
42     * @param  bool  $initiallyDeferred  Implies `$deferrable`.
43     */
44    public function __construct(
45        public array $columns,
46        public string $references,
47        public array|null $referencesColumns = null,
48        public ForeignKeyAction|string|null $onDelete = null,
49        public ForeignKeyAction|string|null $onUpdate = null,
50        public bool $deferrable = false,
51        public bool $initiallyDeferred = false,
52    ) {
53    }
54
55    /**
56     * The resolved referenced table name.
57     *
58     * @return string
59     * @throws \InvalidArgumentException
60     */
61    public function resolvedReferences(): string
62    {
63        return ReferenceResolver::resolve($this->references);
64    }
65
66    /**
67     * The resolved referenced columns.
68     *
69     * @return list<string>
70     * @throws \InvalidArgumentException
71     */
72    public function resolvedReferencesColumns(): array
73    {
74        if ($this->referencesColumns !== null) {
75            return $this->referencesColumns;
76        }
77
78        if (!str_contains($this->references, '\\')) {
79            return ['id']; // plain table + no columns → the PK convention.
80        }
81
82        // resolve() validated existence + Model-ness; the is_a guard
83        // narrows the string to class-string<Model> for the metadata call.
84        $model = $this->references;
85
86        if (!is_a($model, \BlueprintAU\Radiant\Model::class, true)) {
87            throw new \LogicException(
88                "Reference [{$model}] resolved as a model but is not one."
89            );
90        }
91
92        $metadata = MetadataFactory::for($model);
93
94        return array_map(
95            fn (Column $primaryKey) => $primaryKey->name ?? '',
96            $metadata->primaryKeys,
97        );
98    }
99}