Lines 100.00% 16 / 16
Functions and Methods 100.00% 5 / 5
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
ClassMetadata 100.00% 16 / 16 100.00% 5 / 5 7 100.00% 1 / 1
 __construct 100.00% 5 / 5 100.00% 1 / 1 2
 mappingFor 100.00% 4 / 4 100.00% 1 / 1 1
 hasColumn 100.00% 2 / 2 100.00% 1 / 1 2
 isMtiChild 100.00% 1 / 1 100.00% 1 / 1 1
 tableFor 100.00% 4 / 4 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Metadata;
6
7use BlueprintAU\Radiant\Attributes\Check;
8use BlueprintAU\Radiant\Attributes\Column;
9use BlueprintAU\Radiant\Attributes\ForeignKey;
10use BlueprintAU\Radiant\Attributes\Index;
11use BlueprintAU\Radiant\Model;
12use BlueprintAU\Radiant\SoftDeletes;
13use BlueprintAU\Radiant\Attributes\Unique;
14
15/**
16 * A model class's cached metadata: table name, columns, keys, constraints.
17 *
18 * Built once per class by {@see MetadataFactory} and cached — class metadata
19 * is immutable, so the static cache is justified. `$tableName` is null for
20 * any class with no `#[Column]` properties of its own — abstract
21 * intermediates and concrete organizational bases alike (rule 4);
22 * `MetadataFactory::tables()` skips them. Constraints on such a class are
23 * still collected — a rule-4 class's constraints travel with its merged
24 * columns into the descendant's table.
25 */
26final class ClassMetadata
27{
28    /**
29     * The column → owning-table map (computed by the factory at build).
30     *
31     * @var array<string, string>
32     */
33    private readonly array $tablePartitions;
34
35    /**
36     * The DB column name → mapping hash map (precomputed in the
37     * constructor).
38     *
39     * @var array<string, PropertyMapping>
40     */
41    private readonly array $columnsByDbName;
42
43    /**
44     * Create class metadata.
45     *
46     * @param  string|null  $tableName
47     * @param  PropertyMapping[]  $properties
48     * @param  list<Column>  $primaryKeys
49     * @param  list<Unique>  $uniques
50     * @param  list<Index>  $indexes
51     * @param  list<ForeignKey>  $foreignKeys
52     * @param  list<Check>  $checks
53     * @param  string|null  $softDeleteColumn  The column name when the class uses {@see SoftDeletes}.
54     * @param  class-string<Model>|null  $parentModel
55     * @param  array<string, string>  $tablePartitions
56     * @param  list<array{trait: class-string, condition: \BlueprintAU\Radiant\ScopeCondition}>  $traitScopes
57     * @param  list<array{trait: class-string, hook: \BlueprintAU\Radiant\Attributes\Hook, method: string}>  $writeHooks
58     * @param  list<array{trait: class-string, hook: \BlueprintAU\Radiant\Attributes\Hook, method: string}>  $rowHooks
59     */
60    public function __construct(
61        public readonly ?string $tableName,
62        public readonly array $properties,
63        public readonly array $primaryKeys,
64        public readonly array $uniques = [],
65        public readonly array $indexes = [],
66        public readonly array $foreignKeys = [],
67        public readonly array $checks = [],
68        public readonly ?string $softDeleteColumn = null,
69        public readonly string|null $parentModel = null,
70        array $tablePartitions = [],
71        public readonly array $traitScopes = [],
72        public readonly array $writeHooks = [],
73        public readonly array $rowHooks = [],
74    ) {
75        // Eager precompute: the class is built once per process (the
76        // MetadataFactory cache), so deriving the lookup map here costs
77        // nothing and makes the instance truly readonly — no `??=` write can
78        // ever race a concurrent reader (Fiber/Swoole re-entrancy on the
79        // shared metadata cache).
80        $columnsByDbName = [];
81
82        foreach ($properties as $mapping) {
83            $columnsByDbName[$mapping->columnName] = $mapping;
84        }
85
86        $this->columnsByDbName = $columnsByDbName;
87        $this->tablePartitions = $tablePartitions;
88    }
89
90    /**
91     * The mapping for a DB column name — O(1) via the hash map.
92     *
93     * @param  string  $columnName
94     * @return PropertyMapping
95     * @throws \InvalidArgumentException
96     */
97    public function mappingFor(string $columnName): PropertyMapping
98    {
99        return $this->columnsByDbName[$columnName]
100            ?? throw new \InvalidArgumentException(
101                'Unknown column [' . $columnName . '] on model [' . ($this->tableName ?? 'no table') . '].'
102            );
103    }
104
105    /**
106     * Whether a DB column name exists on this class — O(1).
107     *
108     * @param  string  $columnName
109     * @return bool
110     */
111    public function hasColumn(string $columnName): bool
112    {
113        return isset($this->columnsByDbName[$columnName])
114            || array_key_exists($columnName, $this->columnsByDbName);
115    }
116
117    /**
118     * Whether this class is a multi-table-inheritance child.
119     *
120     * @return bool
121     */
122    public function isMtiChild(): bool
123    {
124        return $this->parentModel !== null;
125    }
126
127    /**
128     * The table that owns a given column — the partition map.
129     *
130     * @param  string  $columnName
131     * @return string
132     * @throws \InvalidArgumentException
133     */
134    public function tableFor(string $columnName): string
135    {
136        return $this->tablePartitions[$columnName]
137            ?? throw new \InvalidArgumentException(
138                'Unknown column [' . $columnName . '] on model [' . ($this->tableName ?? 'no table') . '].'
139            );
140    }
141}