Completed
Push — master ( 1fafda...2a69a6 )
by Anton
05:17
created

SchemaBuilder::findRecord()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 11
rs 9.4285
cc 3
eloc 5
nc 3
nop 1
1
<?php
2
/**
3
 * Spiral Framework.
4
 *
5
 * @license   MIT
6
 * @author    Anton Titov (Wolfy-J)
7
 */
8
namespace Spiral\ORM\Entities;
9
10
use Spiral\Core\Component;
11
use Spiral\Database\Entities\Schemas\AbstractTable;
12
use Spiral\Database\Entities\SynchronizationBus;
13
use Spiral\ORM\Configs\ORMConfig;
14
use Spiral\ORM\Entities\Schemas\RecordSchema;
15
use Spiral\ORM\Exceptions\RecordSchemaException;
16
use Spiral\ORM\Exceptions\RelationSchemaException;
17
use Spiral\ORM\Exceptions\SchemaException;
18
use Spiral\ORM\ORM;
19
use Spiral\ORM\Record;
20
use Spiral\ORM\RecordEntity;
21
use Spiral\ORM\Schemas\RelationInterface;
22
use Spiral\Tokenizer\ClassLocatorInterface;
23
24
/**
25
 * Schema builder responsible for static analysis of existed ORM Records, their schemas,
26
 * validations, related tables, requested indexes and etc.
27
 */
28
class SchemaBuilder extends Component
29
{
30
    /**
31
     * @var RecordSchema[]
32
     */
33
    private $records = [];
34
35
    /**
36
     * @var AbstractTable[]
37
     */
38
    private $tables = [];
39
40
    /**
41
     * @var ORMConfig
42
     */
43
    protected $config = null;
44
45
    /**
46
     * @invisible
47
     * @var ORM
48
     */
49
    protected $orm = null;
50
51
    /**
52
     * @param ORMConfig             $config
53
     * @param ORM                   $orm
54
     * @param ClassLocatorInterface $locator
55
     */
56
    public function __construct(ORMConfig $config, ORM $orm, ClassLocatorInterface $locator)
57
    {
58
        $this->config = $config;
59
        $this->orm = $orm;
60
61
        //Locating all models and sources
62
        $this->locateRecords($locator)->locateSources($locator);
63
64
        //Casting relations
65
        $this->castSchemas();
66
    }
67
68
    /**
69
     * Check if Record class known to schema builder.
70
     *
71
     * @param string $class
72
     * @return bool
73
     */
74
    public function hasRecord($class)
75
    {
76
        return isset($this->records[$class]);
77
    }
78
79
    /**
80
     * Instance of RecordSchema associated with given class name.
81
     *
82
     * @param string $class
83
     * @return RecordSchema
84
     * @throws SchemaException
85
     * @throws RecordSchemaException
86
     */
87
    public function record($class)
88
    {
89
        if ($class == RecordEntity::class || $class == Record::class) {
90
            //No need to remember schema for abstract Document
91
            return new RecordSchema($this, RecordEntity::class);
92
        }
93
94
        if (!isset($this->records[$class])) {
95
            throw new SchemaException("Unknown record class '{$class}'.");
96
        }
97
98
        return $this->records[$class];
99
    }
100
101
    /**
102
     * @return RecordSchema[]
103
     */
104
    public function getRecords()
105
    {
106
        return $this->records;
107
    }
108
109
    /**
110
     * Check if given table was declared by one of record or relation.
111
     *
112
     * @param string $database Table database.
113
     * @param string $table    Table name without prefix.
114
     * @return bool
115
     */
116
    public function hasTable($database, $table)
117
    {
118
        return isset($this->tables[$database . '/' . $table]);
119
    }
120
121
    /**
122
     * Request table schema. Every non empty table schema will be synchronized with it's databases
123
     * when executeSchema() method will be called.
124
     *
125
     * Attention, every declared table will be synced with database if their initiator allows such
126
     * operation.
127
     *
128
     * @param string $database Table database.
129
     * @param string $table    Table name without prefix.
130
     * @return AbstractTable
131
     */
132
    public function declareTable($database, $table)
133
    {
134
        $database = $this->resolveDatabase($database);
135
136
        if (isset($this->tables[$database . '/' . $table])) {
137
            return $this->tables[$database . '/' . $table];
138
        }
139
140
        $schema = $this->orm->database($database)->table($table)->schema();
141
142
        return $this->tables[$database . '/' . $table] = $schema;
143
    }
144
145
    /**
146
     * Perform schema reflection to database(s). All declared tables will created or altered. Only
147
     * tables linked to non abstract records and record with active schema parameter will be
148
     * executed.
149
     *
150
     * SchemaBuilder will not allow (SchemaException) to create or alter tables columns declared
151
     * by abstract or records with ACTIVE_SCHEMA constant set to false. ActiveSchema still can
152
     * declare foreign keys and indexes (most of relations automatically request index or foreign
153
     * key), but they are going to be ignored.
154
     *
155
     * Due principals of database schemas and ORM component logic no data or columns will ever be
156
     * removed from database. In addition column renaming will cause creation of another column.
157
     *
158
     * Use database migrations to solve more complex database questions. Or disable ACTIVE_SCHEMA
159
     * and live like normal people.
160
     *
161
     * @throws SchemaException
162
     * @throws \Spiral\Database\Exceptions\SchemaException
163
     * @throws \Spiral\Database\Exceptions\QueryException
164
     * @throws \Spiral\Database\Exceptions\DriverException
165
     */
166
    public function synchronizeSchema()
167
    {
168
        //As aternative you can get access to TableSchemas and generate needed migrations
169
        //@todo Phinx exporter module is needed
170
        $bus = new SynchronizationBus($this->getTables());
171
        $bus->syncronize();
172
    }
173
174
    /**
175
     * Resolve real database name using it's alias.
176
     *
177
     * @see DatabaseProvider
178
     * @param string|null $alias
179
     * @return string
180
     */
181
    public function resolveDatabase($alias)
182
    {
183
        return $this->orm->database($alias)->getName();
184
    }
185
186
    /**
187
     * Get all mutators associated with field type.
188
     *
189
     * @param string $type Field type.
190
     * @return array
191
     */
192
    public function getMutators($type)
193
    {
194
        return $this->config->getMutators($type);
195
    }
196
197
    /**
198
     * Get mutator alias if presented. Aliases used to simplify schema (accessors) definition.
199
     *
200
     * @param string $alias
201
     * @return string|array
202
     */
203
    public function mutatorAlias($alias)
204
    {
205
        return $this->config->mutatorAlias($alias);
206
    }
207
208
    /**
209
     * Normalize record schema in lighter structure to be saved in ORM component memory.
210
     *
211
     * @return array
212
     * @throws SchemaException
213
     */
214
    public function normalizeSchema()
215
    {
216
        $result = [];
217
        foreach ($this->records as $record) {
218
            if ($record->isAbstract()) {
219
                continue;
220
            }
221
222
            $schema = [
223
                ORM::M_ROLE_NAME   => $record->getRole(),
224
                ORM::M_SOURCE      => $record->getSource(),
225
                ORM::M_TABLE       => $record->getTable(),
226
                ORM::M_DB          => $record->getDatabase(),
227
                ORM::M_PRIMARY_KEY => $record->getPrimaryKey(),
228
                ORM::M_HIDDEN      => $record->getHidden(),
229
                ORM::M_SECURED     => $record->getSecured(),
230
                ORM::M_FILLABLE    => $record->getFillable(),
231
                ORM::M_COLUMNS     => $record->getDefaults(),
232
                ORM::M_NULLABLE    => $record->getNullable(),
233
                ORM::M_MUTATORS    => $record->getMutators(),
234
                ORM::M_VALIDATES   => $record->getValidates(),
235
                ORM::M_RELATIONS   => $this->packRelations($record)
236
            ];
237
238
            ksort($schema);
239
            $result[$record->getName()] = $schema;
240
        }
241
242
        return $result;
243
    }
244
245
    /**
246
     * Create appropriate instance of RelationSchema based on it's definition provided by ORM Record
247
     * or manually. Due internal format first definition key will be stated as definition type and
248
     * key value as record/entity definition relates too.
249
     *
250
     * @param RecordSchema $record
251
     * @param string       $name
252
     * @param array        $definition
253
     * @return RelationInterface
254
     * @throws SchemaException
255
     */
256
    public function relationSchema(RecordSchema $record, $name, array $definition)
257
    {
258
        if (empty($definition)) {
259
            throw new SchemaException("Relation definition can not be empty.");
260
        }
261
262
        reset($definition);
263
264
        //Relation type must be provided as first in definition
265
        $type = key($definition);
266
267
        //We are letting ORM to resolve relation schema using container
268
        $relation = $this->orm->relationSchema($type, $this, $record, $name, $definition);
269
270
        if ($relation->hasEquivalent()) {
271
            //Some relations may declare equivalent relation to be used instead,
272
            //used for Morphed relations
273
            return $relation->createEquivalent();
274
        }
275
276
        return $relation;
277
    }
278
279
    /**
280
     * Get list of tables to be updated, method must automatically check if table actually allowed
281
     * to be updated.
282
     *
283
     * @return AbstractTable[]
284
     */
285
    public function getTables()
286
    {
287
        $tables = [];
288
        foreach ($this->tables as $table) {
289
            //We can only alter table columns if record allows us
290
            $record = $this->findRecord($table);
291
292
            if (empty($record)) {
293
                $tables[] = $table;
294
295
                //Potentially pivot table, no related records
296
                continue;
297
            }
298
299
            if ($record->isAbstract() || !$record->isActive() || empty($table->getColumns())) {
300
                //Abstract tables might declare table schema, but we are going to ignore it
301
                continue;
302
            }
303
304
            $tables[] = $table;
305
        }
306
307
        return $tables;
308
    }
309
310
    /**
311
     * Locate every available Record class.
312
     *
313
     * @param ClassLocatorInterface $locator
314
     * @return $this
315
     * @throws SchemaException
316
     */
317
    protected function locateRecords(ClassLocatorInterface $locator)
318
    {
319
        //Table names associated with records
320
        $tables = [];
321
        foreach ($locator->getClasses(RecordEntity::class) as $class => $definition) {
322
            if ($class == RecordEntity::class || $class == Record::class) {
323
                continue;
324
            }
325
326
            $this->records[$class] = $record = new RecordSchema($this, $class);
327
328
            if (!$record->isAbstract()) {
329
                //See comment near exception
330
                continue;
331
            }
332
333
            //Record associated tableID (includes resolved database name)
334
            $tableID = $record->getTableID();
335
336
            if (isset($tables[$tableID])) {
337
                //We are not allowing multiple records talk to same database, unless they one of them
338
                //is abstract
339
                throw new SchemaException(
340
                    "Record '{$record}' associated with "
341
                    . "same source table '{$tableID}' as '{$tables[$tableID]}'."
342
                );
343
            }
344
345
            $tables[$tableID] = $record;
346
        }
347
348
        return $this;
349
    }
350
351
    /**
352
     * Locate ORM entities sources.
353
     *
354
     * @param ClassLocatorInterface $locator
355
     * @return $this
356
     */
357 View Code Duplication
    protected function locateSources(ClassLocatorInterface $locator)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
358
    {
359
        foreach ($locator->getClasses(RecordSource::class) as $class => $definition) {
360
            $reflection = new \ReflectionClass($class);
361
362
            if ($reflection->isAbstract() || empty($record = $reflection->getConstant('RECORD'))) {
363
                continue;
364
            }
365
366
            if ($this->hasRecord($record)) {
367
                //Associating source with record
368
                $this->record($record)->setSource($class);
369
            }
370
        }
371
372
        return $this;
373
    }
374
375
    /**
376
     * SchemaBuilder will request every located RecordSchema to declare it's schemas and relations.
377
     * In addition this methods will create inversed set of relations.
378
     *
379
     * @throws SchemaException
380
     * @throws RelationSchemaException
381
     * @throws RecordSchemaException
382
     */
383
    protected function castSchemas()
384
    {
385
        $inversedRelations = [];
386
        foreach ($this->records as $record) {
387
            if ($record->isAbstract()) {
388
                //Abstract records can not declare relations or tables
389
                continue;
390
            }
391
392
            $record->castSchema();
393
            $record->castRelations();
394
395
            foreach ($record->getRelations() as $relation) {
396
                if ($relation->isInversable()) {
397
                    //Relation can be automatically inversed
398
                    $inversedRelations[] = $relation;
399
                }
400
            }
401
        }
402
403
        /**
404
         * We have to perform inversion after every generic relation was defined. Sometimes records
405
         * can define inversed relation by themselves.
406
         *
407
         * @var RelationInterface $relation
408
         */
409
        foreach ($inversedRelations as $relation) {
410
            if ($relation->isInversable()) {
411
                //We have to check inversion again in case if relation name already taken
412
                $relation->inverseRelation();
413
            }
414
        }
415
    }
416
417
    /**
418
     * Find record related to given table. This operation is required to catch if some
419
     * relation/schema declared values in passive (no altering) table. Might return if no records
420
     * find (pivot or user specified tables).
421
     *
422
     * @param AbstractTable $table
423
     * @return RecordSchema|null
424
     */
425
    private function findRecord(AbstractTable $table)
426
    {
427
        foreach ($this->getRecords() as $record) {
428
            if ($record->tableSchema() === $table) {
429
                return $record;
430
            }
431
        }
432
433
        //No associated record were found
434
        return null;
435
    }
436
437
    /**
438
     * Normalize and pack every declared record relation schema.
439
     *
440
     * @param RecordSchema $record
441
     * @return array
442
     */
443
    private function packRelations(RecordSchema $record)
444
    {
445
        $result = [];
446
        foreach ($record->getRelations() as $name => $relation) {
447
            $result[$name] = $relation->normalizeSchema();
448
        }
449
450
        return $result;
451
    }
452
}
453