Lines 100.00% 27 / 27
Functions and Methods 100.00% 1 / 1
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
ReferenceResolver 100.00% 27 / 27 100.00% 1 / 1 7 100.00% 1 / 1
 resolve 100.00% 27 / 27 100.00% 1 / 1 7
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Attributes;
6
7use BlueprintAU\Radiant\Metadata\MetadataFactory;
8
9/**
10 * Resolves a foreign-key reference to its table name.
11 *
12 * A reference is EITHER a plain table name (`roles`) OR a model
13 * class-string (`App\Models\Role`) whose table resolves through the
14 * {@see MetadataFactory} — the same convention the model itself uses, so a
15 * renamed table never breaks the FK silently. Shared by
16 * {@see ForeignKey::resolvedReferences()} (class-level composite
17 * constraints) and the `Blueprint` schema layer (single-column `foreign:`
18 * column flags) — one resolution rule, one place.
19 *
20 * The two forms are disambiguated by the presence of a `\`: no valid SQL
21 * table name contains a backslash, and every namespaced class-string does.
22 * A backslash-free input that names an EXISTING class is rejected — PHP's
23 * `::class` constant never emits a leading backslash, so a global-namespace
24 * class-string (`DateTimeImmutable::class`) would otherwise slip through
25 * the gate and masquerade as a table name.
26 *
27 * @phpstan-type ForeignKeyReference class-string<\BlueprintAU\Radiant\Model>|string
28 */
29final class ReferenceResolver
30{
31    /**
32     * Resolve a reference to its table name.
33     *
34     * @param  ForeignKeyReference  $reference
35     * @return string
36     * @throws \InvalidArgumentException
37     */
38    public static function resolve(string $reference): string
39    {
40        if (!str_contains($reference, '\\')) {
41            // A backslash-free input is a table name — unless it names an
42            // existing class. PHP's `::class` constant has NO leading
43            // backslash, so a global-namespace class-string would land here
44            // and masquerade as a table (the FK would compile against a
45            // nonexistent table and fail downstream with a confusing SQL
46            // error). Reject it here, where the message can say why.
47            if (class_exists($reference) && !is_a($reference, \BlueprintAU\Radiant\Model::class, true)) {
48                throw new \InvalidArgumentException(
49                    "A foreign key references [{$reference}], which is a class but not a "
50                    . 'Radiant model; a foreign key must reference a table name or a '
51                    . 'model class-string.'
52                );
53            }
54
55            return $reference;
56        }
57
58        if (!class_exists($reference)) {
59            throw new \InvalidArgumentException(
60                "A foreign key references [{$reference}], which is neither a table name "
61                . 'nor an existing model class-string.'
62            );
63        }
64
65        if (!is_a($reference, \BlueprintAU\Radiant\Model::class, true)) {
66            throw new \InvalidArgumentException(
67                "A foreign key references [{$reference}], which is a class but not a "
68                . 'Radiant model; a foreign key must reference a table name or a '
69                . 'model class-string.'
70            );
71        }
72
73        $table = MetadataFactory::for($reference)->tableName;
74
75        if ($table === null) {
76            throw new \InvalidArgumentException(
77                "A foreign key references model [{$reference}], which owns no table "
78                . '(no columns); a foreign key must reference a table-owning model or a '
79                . 'plain table name.'
80            );
81        }
82
83        return $table;
84    }
85}