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) |
|
|
|
|
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
|
|
|
|
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.