• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nextras / orm / 38978139491

16 Oct 2026 10:50PM UTC coverage: 92.11% (+0.1%) from 92.0%
38978139491

Pull #836

github

web-flow
Merge 702852b98 into d02a3bb2e
Pull Request #836: fix(nette-di): make initializeMetadata work with Nette DI lazy services

103 of 108 new or added lines in 3 files covered. (95.37%)

1 existing line in 1 file now uncovered.

4448 of 4829 relevant lines covered (92.11%)

5.39 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.12
/src/Model/MetadataStorage.php
1
<?php declare(strict_types = 1);
2

3
namespace Nextras\Orm\Model;
4

5

6
use Closure;
7
use Nette\Caching\Cache;
8
use Nextras\Orm\Entity\Embeddable\EmbeddableContainer;
9
use Nextras\Orm\Entity\IEntity;
10
use Nextras\Orm\Entity\Reflection\EntityMetadata;
11
use Nextras\Orm\Entity\Reflection\IMetadataParserFactory;
12
use Nextras\Orm\Entity\Reflection\MetadataValidator;
13
use Nextras\Orm\Exception\InvalidArgumentException;
14
use Nextras\Orm\Exception\InvalidStateException;
15
use Nextras\Orm\Repository\IRepository;
16
use Throwable;
17
use function array_fill_keys;
18
use function array_key_first;
19
use function array_key_last;
20
use function array_keys;
21
use function array_merge;
22
use function array_shift;
23
use function array_unique;
24
use function array_values;
25
use function assert;
26
use function count;
27
use function is_array;
28
use function key;
29
use function md5;
30
use function serialize;
31

32

33
/**
34
 * Holds the entity metadata.
35
 *
36
 * Metadata are loaded lazily and per entity: the storage only registers itself and the metadata of an entity
37
 * (and of its embeddables) are loaded from cache on the first {@see MetadataStorage::get()} miss.
38
 * Metadata of related entities are resolved on the first access to
39
 * {@see \Nextras\Orm\Entity\Reflection\PropertyRelationshipMetadata::$entityMetadata}.
40
 * Parsing and validation of all entities happen only when the cache is (re)built.
41
 */
42
class MetadataStorage
43
{
44
        /** @var array<class-string<mixed>, EntityMetadata> */
45
        private static array $metadata = [];
46

47
        /** @var array<int, Closure(): void> */
48
        private static array $loaders = [];
49

50
        /** @var array<int, MetadataStorage> storages which may provide not yet loaded metadata */
51
        private static array $pending = [];
52

53
        /** @var array<string, true>|null class names (entities and embeddables) provided by this storage */
54
        private array|null $index = null;
55

56
        /** @var array<string, true> */
57
        private array $loadedClasses = [];
58

59
        private int|null $pendingKey = null;
60

61
        private Cache|null $metadataCache = null;
62

63
        private string|null $cacheKey = null;
64

65

66
        public static function get(string $className): EntityMetadata
67
        {
68
                while (!isset(self::$metadata[$className])) {
6 ✔
69
                        foreach (self::$pending as $storage) {
6 ✔
70
                                if ($storage->loadClass($className)) {
6 ✔
71
                                        return self::$metadata[$className];
6 ✔
72
                                }
73
                        }
74

75
                        if (self::$loaders === []) break;
6 ✔
76

77
                        $key = array_key_first(self::$loaders);
6 ✔
78
                        $loader = self::$loaders[$key];
6 ✔
79
                        unset(self::$loaders[$key]);
6 ✔
80
                        try {
81
                                $loader();
6 ✔
NEW
82
                        } catch (Throwable $e) {
×
83
                                // keep the loader so the next call reports the same error instead of "called too early"
NEW
84
                                self::$loaders = [$key => $loader] + self::$loaders;
×
NEW
85
                                throw $e;
×
86
                        }
87
                }
88

89
                if (!isset(self::$metadata[$className])) {
6 ✔
90
                        if (self::$metadata === [] && self::$pending === []) {
6 ✔
91
                                throw new InvalidStateException("MetadataStorage::get() called too early. You have to instantiate your model first.");
6 ✔
92
                        }
93
                        throw new InvalidArgumentException("Entity metadata for '{$className}' does not exist.");
6 ✔
94
                }
95
                return self::$metadata[$className];
6 ✔
96
        }
97

98

99
        /**
100
         * Registers a callback which creates a metadata storage (e.g. by calling {@see MetadataStorage::register()}).
101
         * Pending loaders are run one by one on a {@see MetadataStorage::get()} miss, until the requested
102
         * metadata are found. Every loader is run at most once (unless it throws). Intended for DI integrations.
103
         * @param Closure(): void $loader
104
         */
105
        public static function registerLoader(Closure $loader): void
106
        {
107
                self::$loaders[] = $loader;
6 ✔
108
        }
6 ✔
109

110

111
        /**
112
         * @param array<class-string<IEntity>, class-string<IRepository<IEntity>>> $entityClassesMap
113
         */
114
        public function __construct(
5 ✔
115
                private readonly array $entityClassesMap,
1 ✔
116
                private readonly Cache $cache,
1 ✔
117
                private readonly IMetadataParserFactory $metadataParserFactory,
1 ✔
118
                private readonly IRepositoryLoader $repositoryLoader,
1 ✔
119
        )
120
        {
121
                foreach ($entityClassesMap as $className => $_) {
6 ✔
122
                        if (!isset(self::$metadata[$className])) {
6 ✔
123
                                self::$pending[] = $this;
6 ✔
124
                                $this->pendingKey = array_key_last(self::$pending);
6 ✔
125
                                return;
6 ✔
126
                        }
127
                }
128
                // all entities are already loaded (e.g. by another instance of the same model), nothing to do
129
        }
6 ✔
130

131

132
        /**
133
         * Returns true if some of this storage's metadata have not been loaded yet. Calling it on a lazy ghost
134
         * (Nette DI's lazy services) initializes the service, i.e. registers the storage for lazy loading.
135
         */
136
        public function register(): bool
137
        {
138
                return $this->pendingKey !== null;
6 ✔
139
        }
140

141

142
        /**
143
         * Loads (and validates when not cached yet) metadata of all this storage's entities.
144
         */
145
        public function load(): void
146
        {
147
                if ($this->pendingKey === null) return;
6 ✔
148
                foreach ($this->getIndex() as $className => $_) {
6 ✔
149
                        $this->loadClass($className);
6 ✔
150
                }
151
        }
6 ✔
152

153

154
        /**
155
         * Returns all Nextras' metadata loaded in runtime until now, including all this storage's metadata.
156
         *
157
         * In other words, two independent ORM instances share the static storage, and the data
158
         * returned in this method are only those that have been already loaded.
159
         *
160
         * @return array<class-string<mixed>, EntityMetadata>
161
         */
162
        public function getLoadedMetadata(): array
163
        {
164
                $this->load();
6 ✔
165
                return self::$metadata;
6 ✔
166
        }
167

168

169
        /**
170
         * Loads metadata of the class if this storage provides them.
171
         */
172
        private function loadClass(string $className): bool
173
        {
174
                $index = $this->getIndex();
6 ✔
175
                if (!isset($index[$className])) return false;
6 ✔
176

177
                if (!isset(self::$metadata[$className])) {
6 ✔
178
                        $metadata = $this->getCache()->load($this->getCacheKey() . '.' . $className);
6 ✔
179
                        if (!$metadata instanceof EntityMetadata) {
6 ✔
NEW
180
                                $metadata = $this->build()[$className];
×
181
                        }
182

183
                        foreach ($metadata->getProperties() as $property) {
6 ✔
184
                                if ($property->wrapper === EmbeddableContainer::class) {
6 ✔
185
                                        $type = key($property->types);
6 ✔
186
                                        assert($type !== null);
187
                                        $property->args[EmbeddableContainer::class]['metadata'] = self::get((string) $type);
6 ✔
188
                                }
189
                        }
190

191
                        /** @var class-string $className */
192
                        self::$metadata[$className] ??= $metadata;
6 ✔
193
                }
194

195
                $this->loadedClasses[$className] = true;
6 ✔
196
                if (count($this->loadedClasses) === count($index) && $this->pendingKey !== null) {
6 ✔
197
                        unset(self::$pending[$this->pendingKey]);
6 ✔
198
                        $this->pendingKey = null;
6 ✔
199
                }
200
                return true;
6 ✔
201
        }
202

203

204
        /**
205
         * @return array<string, true>
206
         */
207
        private function getIndex(): array
208
        {
209
                if ($this->index === null) {
6 ✔
210
                        $index = $this->getCache()->load($this->getCacheKey() . '.index');
6 ✔
211
                        if (is_array($index)) {
6 ✔
212
                                /** @var array<string, true> $index */
213
                                $this->index = $index;
6 ✔
214
                        } else {
215
                                $this->index = array_fill_keys(array_keys($this->build()), true);
6 ✔
216
                        }
217
                }
218
                return $this->index;
6 ✔
219
        }
220

221

222
        /**
223
         * Parses and validates all entities and stores each entity's metadata into a separate cache item,
224
         * together with an index of all provided class names.
225
         * @return array<string, EntityMetadata>
226
         */
227
        private function build(): array
228
        {
229
                /** @var array<string, EntityMetadata> $metadata */
230
                $metadata = [];
6 ✔
231
                /** @var array<string, list<string>> $files */
232
                $files = [];
6 ✔
233
                $toProcess = array_keys($this->entityClassesMap);
6 ✔
234
                $annotationParser = $this->metadataParserFactory->create($this->entityClassesMap);
6 ✔
235

236
                while (($className = array_shift($toProcess)) !== null) {
6 ✔
237
                        if (isset($metadata[$className])) continue;
6 ✔
238
                        $fileDependencies = [];
6 ✔
239
                        $metadata[$className] = $annotationParser->parseMetadata($className, $fileDependencies);
6 ✔
240
                        $files[$className] = $fileDependencies ?? [];
6 ✔
241
                        foreach ($metadata[$className]->getProperties() as $property) {
6 ✔
242
                                if ($property->wrapper === EmbeddableContainer::class) {
6 ✔
243
                                        assert($property->args !== null);
244
                                        $toProcess[] = $property->args[EmbeddableContainer::class]['class'];
6 ✔
245
                                }
246
                        }
247
                }
248

249
                $validator = new MetadataValidator();
6 ✔
250
                $validator->validate($metadata, $this->repositoryLoader);
6 ✔
251

252
                $cache = $this->getCache();
6 ✔
253
                $cacheKey = $this->getCacheKey();
6 ✔
254
                foreach ($metadata as $className => $entityMetadata) {
6 ✔
255
                        $cache->save($cacheKey . '.' . $className, $entityMetadata, [Cache::Files => $files[$className]]);
6 ✔
256
                }
257
                $cache->save($cacheKey . '.index', array_fill_keys(array_keys($metadata), true), [
6 ✔
258
                        Cache::Files => array_values(array_unique(array_merge(...array_values($files)))),
6 ✔
259
                ]);
260

261
                return $metadata;
6 ✔
262
        }
263

264

265
        private function getCache(): Cache
266
        {
267
                return $this->metadataCache ??= $this->cache->derive('orm.metadata');
6 ✔
268
        }
269

270

271
        private function getCacheKey(): string
272
        {
273
                return $this->cacheKey ??= md5(serialize($this->entityClassesMap));
6 ✔
274
        }
275
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc