Completed
Push — master ( 4ba08f...81a9c5 )
by Anton
02:30
created

SchemaBuilder::pushTable()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 11
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 5
nc 2
nop 1
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
        //Requesting thought DatabaseManager
243
        $schema = $this->manager->database($database)->table($table)->getSchema();
244
245
        if (isset($this->tables[$schema->getDriver()->getName() . '.' . $table])) {
246
            $schema = $this->tables[$schema->getDriver()->getName() . '.' . $table];
247
248
            if ($unique) {
249
                throw new DoubleReferenceException(
250
                    "Table '{$table}' of '{$database} 'been requested by multiple models"
251
                );
252
            }
253
        } else {
254
255
            $this->tables[$schema->getDriver()->getName() . '.' . $table] = $schema;
256
        }
257
258
        $schema = clone $schema;
259
260
        if ($resetState) {
261
            //Emptying our current state (initial not affected)
262
            $schema->setState(null);
263
        }
264
265
        return $schema;
266
    }
267
268
    /**
269
     * Get all defined tables, make sure to call renderSchema() first. Attention, all given tables
270
     * will be returned in detached state.
271
     *
272
     * @return AbstractTable[]
273
     *
274
     * @throws SchemaException
275
     */
276
    public function getTables(): array
277
    {
278
        if (empty($this->tables) && !empty($this->schemas)) {
279
            throw new SchemaException(
280
                "Unable to get tables, no tables are were found, call renderSchema() first"
281
            );
282
        }
283
284
        $result = [];
285
        foreach ($this->tables as $table) {
286
            //Detaching
287
            $result[] = clone $table;
288
        }
289
290
        return $result;
291
    }
292
293
    /**
294
     * Indication that tables in database require syncing before being matched with ORM models.
295
     *
296
     * @return bool
297
     */
298
    public function hasChanges(): bool
299
    {
300
        foreach ($this->getTables() as $table) {
301
            if ($table->getComparator()->hasChanges()) {
302
                return true;
303
            }
304
        }
305
306
        return false;
307
    }
308
309
    /**
310
     * Save every change made to generated tables. Method utilizes default DBAL diff mechanism,
311
     * use getTables() method in order to generate your own migrations.
312
     *
313
     * @param LoggerInterface|null $logger
314
     *
315
     * @throws SchemaException
316
     * @throws DBALException
317
     * @throws QueryException
318
     * @throws DriverException
319
     */
320
    public function pushSchema(LoggerInterface $logger = null)
321
    {
322
        $bus = new SynchronizationPool($this->getTables());
323
        $bus->run($logger);
324
    }
325
326
    /**
327
     * Pack declared schemas in a normalized form, make sure to call renderSchema() first.
328
     *
329
     * @return array
330
     *
331
     * @throws SchemaException
332
     */
333
    public function packSchema(): array
334
    {
335
        if (empty($this->tables) && !empty($this->schemas)) {
336
            throw new SchemaException(
337
                "Unable to pack schema, no defined tables were found, call renderSchema() first"
338
            );
339
        }
340
341
        $result = [];
342
        foreach ($this->schemas as $class => $schema) {
343
            //Table which is being related to model schema
344
            $table = $this->requestTable($schema->getTable(), $schema->getDatabase(), false);
345
346
            $relations = $this->relations->packRelations($schema->getClass(), $this);
347
348
            $result[$class] = [
349
                ORMInterface::R_INSTANTIATOR => $schema->getInstantiator(),
350
351
                //Model role name
352
                ORMInterface::R_ROLE_NAME    => $schema->getRole(),
353
354
                //Primary keys
355
                ORMInterface::R_PRIMARY_KEY  => current($table->getPrimaryKeys()),
356
357
                //Schema includes list of fields, default values and nullable fields
358
                ORMInterface::R_SCHEMA       => $schema->packSchema($this, clone $table),
359
360
                ORMInterface::R_SOURCE_CLASS => $this->getSource($class),
361
362
                //Data location (database name either fetched from schema or default database name used)
363
                ORMInterface::R_DATABASE     => $schema->getDatabase() ?? $this->manager->database()->getName(),
364
                ORMInterface::R_TABLE        => $schema->getTable(),
365
366
                //Pack model specific relations
367
                ORMInterface::R_RELATIONS    => $relations
368
            ];
369
        }
370
371
        return $result;
372
    }
373
374
    /**
375
     * Walk thought schemas and define structure of associated tables.
376
     *
377
     * @throws SchemaException
378
     * @throws DBALException
379
     */
380
    protected function renderModels()
381
    {
382
        foreach ($this->schemas as $schema) {
383
            /**
384
             * Attention, this method will request table schema from DBAL and will empty it's state
385
             * so schema can define it from ground zero!
386
             */
387
            $table = $this->requestTable(
388
                $schema->getTable(),
389
                $schema->getDatabase(),
390
                true,
391
                true
392
            );
393
394
            //Render table schema
395
            $table = $schema->declareTable($table);
396
397
            //Working with indexes
398
            foreach ($schema->getIndexes() as $index) {
399
                $table->index($index->getColumns())->unique($index->isUnique());
400
                $table->index($index->getColumns())->setName($index->getName());
401
            }
402
403
            /*
404
             * Attention, this is critical section:
405
             *
406
             * In order to work efficiently (like for real), ORM does require every table
407
             * to have 1 and only 1 primary key, this is crucial for things like in memory
408
             * cache, transaction command priority pipeline, multiple queue commands for one entity
409
             * and etc.
410
             *
411
             * It is planned to support user defined PKs in a future using unique indexes and record
412
             * schema.
413
             *
414
             * You are free to select any name for PK field.
415
             */
416
            if (count($table->getPrimaryKeys()) !== 1) {
417
                throw new SchemaException(
418
                    "Every record must have singular primary key (primary, bigPrimary types)"
419
                );
420
            }
421
422
            //And put it back :)
423
            $this->pushTable($table);
424
        }
425
    }
426
427
    /**
428
     * Walk thought all record schemas, fetch and declare needed relations using relation manager.
429
     *
430
     * @throws SchemaException
431
     * @throws DefinitionException
432
     */
433
    protected function renderRelations()
434
    {
435
        foreach ($this->schemas as $schema) {
436
            foreach ($schema->getRelations() as $name => $relation) {
437
438
                //Source context defines where relation comes from
439
                $sourceContext = RelationContext::createContent(
440
                    $schema,
441
                    $this->requestTable($schema->getTable(), $schema->getDatabase())
442
                );
443
444
                //Target context might only exist if relation points to another record in ORM,
445
                //in some cases it might point outside of ORM scope
446
                $targetContext = null;
447
448
                if ($this->hasSchema($relation->getTarget())) {
449
                    $target = $this->getSchema($relation->getTarget());
450
451
                    $targetContext = RelationContext::createContent(
452
                        $target,
453
                        $this->requestTable($target->getTable(), $target->getDatabase())
454
                    );
455
                }
456
457
                $this->relations->registerRelation(
458
                    $this,
459
                    $relation->withContext($sourceContext, $targetContext)
460
                );
461
            }
462
        }
463
    }
464
465
    /**
466
     * Update table state.
467
     *
468
     * @param AbstractTable $schema
469
     *
470
     * @throws SchemaException
471
     */
472
    protected function pushTable(AbstractTable $schema)
473
    {
474
        //We have to make sure that local table name is used
475
        $table = substr($schema->getName(), strlen($schema->getPrefix()));
476
477
        if (empty($this->tables[$schema->getDriver()->getName() . '.' . $table])) {
478
            throw new SchemaException("AbstractTable must be requested before pushing back");
479
        }
480
481
        $this->tables[$schema->getDriver()->getName() . '.' . $table] = $schema;
482
    }
483
}