SchemaBuilder::getRelations()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
1
<?php
2
/**
3
 * Spiral, Core Components
4
 *
5
 * @author Wolfy-J
6
 */
7
8
namespace Spiral\ORM\Schemas;
9
10
use Psr\Log\LoggerInterface;
11
use Spiral\Database\DatabaseManager;
12
use Spiral\Database\Exceptions\DBALException;
13
use Spiral\Database\Exceptions\DriverException;
14
use Spiral\Database\Exceptions\QueryException;
15
use Spiral\Database\Helpers\SynchronizationPool;
16
use Spiral\Database\Schemas\Prototypes\AbstractTable;
17
use Spiral\ORM\Exceptions\DefinitionException;
18
use Spiral\ORM\Exceptions\DoubleReferenceException;
19
use Spiral\ORM\Exceptions\SchemaException;
20
use Spiral\ORM\ORMInterface;
21
use Spiral\ORM\Schemas\Definitions\RelationContext;
22
23
class SchemaBuilder
24
{
25
    /**
26
     * @var DatabaseManager
27
     */
28
    private $manager;
29
30
    /**
31
     * @var RelationBuilder
32
     */
33
    private $relations;
34
35
    /**
36
     * @var AbstractTable[]
37
     */
38
    private $tables = [];
39
40
    /**
41
     * @var SchemaInterface[]
42
     */
43
    private $schemas = [];
44
45
    /**
46
     * Class names of sources associated with specific class.
47
     *
48
     * @var array
49
     */
50
    private $sources = [];
51
52
    /**
53
     * @param DatabaseManager $manager
54
     * @param RelationBuilder $relations
55
     */
56
    public function __construct(DatabaseManager $manager, RelationBuilder $relations)
57
    {
58
        $this->manager = $manager;
59
        $this->relations = $relations;
60
    }
61
62
    /**
63
     * Add new model schema into pool.
64
     *
65
     * @param SchemaInterface $schema
66
     *
67
     * @return self|$this
68
     */
69
    public function addSchema(SchemaInterface $schema): SchemaBuilder
70
    {
71
        $this->schemas[$schema->getClass()] = $schema;
72
73
        return $this;
74
    }
75
76
    /**
77
     * @param string $class
78
     *
79
     * @return bool
80
     */
81
    public function hasSchema(string $class): bool
82
    {
83
        return isset($this->schemas[$class]);
84
    }
85
86
    /**
87
     * @param string $class
88
     *
89
     * @return SchemaInterface
90
     *
91
     * @throws SchemaException
92
     */
93
    public function getSchema(string $class): SchemaInterface
94
    {
95
        if (!$this->hasSchema($class)) {
96
            throw new SchemaException("Unable to find schema for class '{$class}'");
97
        }
98
99
        return $this->schemas[$class];
100
    }
101
102
    /**
103
     * All available document schemas.
104
     *
105
     * @return SchemaInterface[]
106
     */
107
    public function getSchemas(): array
108
    {
109
        return $this->schemas;
110
    }
111
112
    /**
113
     * Associate source class with entity class. Source will be automatically associated with given
114
     * class and all classes from the same collection which extends it.
115
     *
116
     * @param string $class
117
     * @param string $source
118
     *
119
     * @return SchemaBuilder
120
     *
121
     * @throws SchemaException
122
     */
123
    public function addSource(string $class, string $source): SchemaBuilder
124
    {
125
        if (!$this->hasSchema($class)) {
126
            throw new SchemaException("Unable to add source to '{$class}', class is unknown to ORM");
127
        }
128
129
        $this->sources[$class] = $source;
130
131
        return $this;
132
    }
133
134
    /**
135
     * Check if given entity has associated source.
136
     *
137
     * @param string $class
138
     *
139
     * @return bool
140
     */
141
    public function hasSource(string $class): bool
142
    {
143
        return array_key_exists($class, $this->sources);
144
    }
145
146
    /**
147
     * Get source associated with specific class, if any.
148
     *
149
     * @param string $class
150
     *
151
     * @return string|null
152
     */
153
    public function getSource(string $class)
154
    {
155
        if (!$this->hasSource($class)) {
156
            return null;
157
        }
158
159
        return $this->sources[$class];
160
    }
161
162
    /**
163
     * Get all associated sources.
164
     *
165
     * @return array
166
     */
167
    public function getSources(): array
168
    {
169
        return $this->sources;
170
    }
171
172
    /**
173
     * Get all created relations.
174
     *
175
     * @return RelationInterface[]
176
     */
177
    public function getRelations(): array
178
    {
179
        return $this->relations->getRelations();
180
    }
181
182
    /**
183
     * Process all added schemas and relations in order to created needed tables, indexes and etc.
184
     * Attention, this method will return new instance of SchemaBuilder without affecting original
185
     * object. You MUST call this method before calling packSchema() method.
186
     *
187
     * Attention, this methods DOES NOT write anything into database, use pushSchema() to push
188
     * changes into database using automatic diff generation. You can also access list of
189
     * generated/changed tables via getTables() to create your own migrations.
190
     *
191
     * @see packSchema()
192
     * @see pushSchema()
193
     * @see getTables()
194
     *
195
     * @return SchemaBuilder
196
     *
197
     * @throws SchemaException
198
     */
199
    public function renderSchema(): SchemaBuilder
200
    {
201
        //Declaring tables associated with records
202
        $this->renderModels();
203
204
        //Defining all relations declared by our schemas
205
        $this->renderRelations();
206
207
        //Inverse relations (if requested)
208
        $this->relations->inverseRelations($this);
209
210
        //Rendering needed columns, FKs and indexes needed for our relations (if relation is ORM specific)
211
        foreach ($this->relations->declareTables($this) as $table) {
212
            $this->pushTable($table);
213
        }
214
215
        return $this;
216
    }
217
218
    /**
219
     * Request table schema by name/database combination. Attention, you can only save table by
220
     * pushing it back using pushTable() method.
221
     *
222
     * @see pushTable()
223
     *
224
     * @param string      $table
225
     * @param string|null $database
226
     * @param bool        $resetState When set to true current table state will be reset in order
227
     *                                to allow model to redefine it's schema.
228
     * @param bool        $unique     Set to true (default), to throw an exception when table
229
     *                                already referenced by another model.
230
     *
231
     * @return AbstractTable          Unlinked.
232
     *
233
     * @throws DoubleReferenceException When two records refers to same table and unique option
234
     *                                  set.
235
     */
236
    public function requestTable(
237
        string $table,
238
        string $database = null,
239
        bool $unique = false,
240
        bool $resetState = false
241
    ): AbstractTable {
242
        $driver = $this->manager->database($database)->getDriver();
243
244
        if (isset($this->tables[$driver->getName() . '.' . $table])) {
245
            $schema = $this->tables[$driver->getName() . '.' . $table];
246
247
            if ($unique) {
248
                throw new DoubleReferenceException(
249
                    "Table '{$table}' of '{$database} 'been requested by multiple models"
250
                );
251
            }
252
        } else {
253
            $schema = $this->manager->database($database)->table($table)->getSchema();
254
            $this->tables[$driver->getName() . '.' . $table] = $schema;
255
        }
256
257
        $schema = clone $schema;
258
259
        if ($resetState) {
260
            //Emptying our current state (initial not affected)
261
            $schema->setState(null);
262
        }
263
264
        return $schema;
265
    }
266
267
    /**
268
     * Get all defined tables, make sure to call renderSchema() first. Attention, all given tables
269
     * will be returned in detached state.
270
     *
271
     * @return AbstractTable[]
272
     *
273
     * @throws SchemaException
274
     */
275
    public function getTables(): array
276
    {
277
        if (empty($this->tables) && !empty($this->schemas)) {
278
            throw new SchemaException(
279
                "Unable to get tables, no tables are were found, call renderSchema() first"
280
            );
281
        }
282
283
        $result = [];
284
        foreach ($this->tables as $table) {
285
            //Detaching
286
            $result[] = clone $table;
287
        }
288
289
        return $result;
290
    }
291
292
    /**
293
     * Indication that tables in database require syncing before being matched with ORM models.
294
     *
295
     * @return bool
296
     */
297
    public function hasChanges(): bool
298
    {
299
        foreach ($this->getTables() as $table) {
300
            if ($table->getComparator()->hasChanges()) {
301
                return true;
302
            }
303
        }
304
305
        return false;
306
    }
307
308
    /**
309
     * Save every change made to generated tables. Method utilizes default DBAL diff mechanism,
310
     * use getTables() method in order to generate your own migrations.
311
     *
312
     * @param LoggerInterface|null $logger
313
     *
314
     * @throws SchemaException
315
     * @throws DBALException
316
     * @throws QueryException
317
     * @throws DriverException
318
     */
319
    public function pushSchema(LoggerInterface $logger = null)
320
    {
321
        $bus = new SynchronizationPool($this->getTables());
322
        $bus->run($logger);
323
    }
324
325
    /**
326
     * Pack declared schemas in a normalized form, make sure to call renderSchema() first.
327
     *
328
     * @return array
329
     *
330
     * @throws SchemaException
331
     */
332
    public function packSchema(): array
333
    {
334
        if (empty($this->tables) && !empty($this->schemas)) {
335
            throw new SchemaException(
336
                "Unable to pack schema, no defined tables were found, call renderSchema() first"
337
            );
338
        }
339
340
        $result = [];
341
        foreach ($this->schemas as $class => $schema) {
342
            //Table which is being related to model schema
343
            $table = $this->requestTable($schema->getTable(), $schema->getDatabase(), false);
344
345
            $relations = $this->relations->packRelations($schema->getClass(), $this);
346
347
            $result[$class] = [
348
                ORMInterface::R_INSTANTIATOR => $schema->getInstantiator(),
349
350
                //Model role name
351
                ORMInterface::R_ROLE_NAME    => $schema->getRole(),
352
353
                //Primary keys
354
                ORMInterface::R_PRIMARY_KEY  => current($table->getPrimaryKeys()),
355
356
                //Schema includes list of fields, default values and nullable fields
357
                ORMInterface::R_SCHEMA       => $schema->packSchema($this, clone $table),
358
359
                ORMInterface::R_SOURCE_CLASS => $this->getSource($class),
360
361
                //Data location (database name either fetched from schema or default database name used)
362
                ORMInterface::R_DATABASE     => $this->manager->database($schema->getDatabase())->getName(),
363
                ORMInterface::R_TABLE        => $schema->getTable(),
364
365
                //Pack model specific relations
366
                ORMInterface::R_RELATIONS    => $relations
367
            ];
368
        }
369
370
        return $result;
371
    }
372
373
    /**
374
     * Walk thought schemas and define structure of associated tables.
375
     *
376
     * @throws SchemaException
377
     * @throws DBALException
378
     */
379
    protected function renderModels()
380
    {
381
        foreach ($this->schemas as $schema) {
382
            /**
383
             * Attention, this method will request table schema from DBAL and will empty it's state
384
             * so schema can define it from ground zero!
385
             */
386
            $table = $this->requestTable(
387
                $schema->getTable(),
388
                $schema->getDatabase(),
389
                true,
390
                true
391
            );
392
393
            //Render table schema
394
            $table = $schema->declareTable($table);
395
396
            //Working with indexes
397
            foreach ($schema->getIndexes() as $index) {
398
                $table->index($index->getColumns())->unique($index->isUnique());
399
                $table->index($index->getColumns())->setName($index->getName());
400
            }
401
402
            /*
403
             * Attention, this is critical section:
404
             *
405
             * In order to work efficiently (like for real), ORM does require every table
406
             * to have 1 and only 1 primary key, this is crucial for things like in memory
407
             * cache, transaction command priority pipeline, multiple queue commands for one entity
408
             * and etc.
409
             *
410
             * It is planned to support user defined PKs in a future using unique indexes and record
411
             * schema.
412
             *
413
             * You are free to select any name for PK field.
414
             */
415
            if (count($table->getPrimaryKeys()) !== 1) {
416
                throw new SchemaException(
417
                    "Every record must have singular primary key (primary, bigPrimary types)"
418
                );
419
            }
420
421
            //And put it back :)
422
            $this->pushTable($table);
423
        }
424
    }
425
426
    /**
427
     * Walk thought all record schemas, fetch and declare needed relations using relation manager.
428
     *
429
     * @throws SchemaException
430
     * @throws DefinitionException
431
     */
432
    protected function renderRelations()
433
    {
434
        foreach ($this->schemas as $schema) {
435
            foreach ($schema->getRelations() as $name => $relation) {
436
437
                //Source context defines where relation comes from
438
                $sourceContext = RelationContext::createContent(
439
                    $schema,
440
                    $this->requestTable($schema->getTable(), $schema->getDatabase())
441
                );
442
443
                //Target context might only exist if relation points to another record in ORM,
444
                //in some cases it might point outside of ORM scope
445
                $targetContext = null;
446
447
                if ($this->hasSchema($relation->getTarget())) {
448
                    $target = $this->getSchema($relation->getTarget());
449
450
                    $targetContext = RelationContext::createContent(
451
                        $target,
452
                        $this->requestTable($target->getTable(), $target->getDatabase())
453
                    );
454
                }
455
456
                $this->relations->registerRelation(
457
                    $this,
458
                    $relation->withContext($sourceContext, $targetContext)
459
                );
460
            }
461
        }
462
    }
463
464
    /**
465
     * Update table state.
466
     *
467
     * @param AbstractTable $schema
468
     *
469
     * @throws SchemaException
470
     */
471
    protected function pushTable(AbstractTable $schema)
472
    {
473
        //We have to make sure that local table name is used
474
        $table = substr($schema->getName(), strlen($schema->getPrefix()));
475
476
        if (empty($this->tables[$schema->getDriver()->getName() . '.' . $table])) {
477
            throw new SchemaException("AbstractTable must be requested before pushing back");
478
        }
479
480
        $this->tables[$schema->getDriver()->getName() . '.' . $table] = $schema;
481
    }
482
}