Completed
Branch feature/pre-split (b39aa5)
by Anton
03:31
created

ManyToMorphedSchema::inverseDefinition()   B

Complexity

Conditions 3
Paths 3

Size

Total Lines 42
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 25
nc 3
nop 2
dl 0
loc 42
rs 8.8571
c 0
b 0
f 0
1
<?php
2
/**
3
 * Spiral, Core Components
4
 *
5
 * @author Wolfy-J
6
 */
7
8
namespace Spiral\ORM\Schemas\Relations;
9
10
use Doctrine\Common\Inflector\Inflector;
11
use Spiral\ORM\Exceptions\DefinitionException;
12
use Spiral\ORM\Exceptions\RelationSchemaException;
13
use Spiral\ORM\Helpers\ColumnRenderer;
14
use Spiral\ORM\ORMInterface;
15
use Spiral\ORM\Record;
16
use Spiral\ORM\Schemas\Definitions\RelationContext;
17
use Spiral\ORM\Schemas\Definitions\RelationDefinition;
18
use Spiral\ORM\Schemas\InversableRelationInterface;
19
use Spiral\ORM\Schemas\Relations\Traits\ForeignsTrait;
20
use Spiral\ORM\Schemas\Relations\Traits\MorphedTrait;
21
use Spiral\ORM\Schemas\Relations\Traits\TypecastTrait;
22
use Spiral\ORM\Schemas\SchemaBuilder;
23
24
/**
25
 * ManyToMorphed relation declares relation between parent record and set of outer records joined by
26
 * common interface. Relation allow to specify inner key (key in parent record), outer key (key in
27
 * outer records), morph key, pivot table name, names of pivot columns to store inner and outer key
28
 * values and set of additional columns. Relation DOES NOT to specify WHERE statement for outer
29
 * records. However you can specify where conditions for PIVOT table.
30
 *
31
 * You can declare this relation using same syntax as for ManyToMany except your target class
32
 * must be an interface.
33
 *
34
 * Attention, be very careful using morphing relations, you must know what you doing!
35
 * Attention #2, relation like that can not be preloaded!
36
 *
37
 * Example [Tag related to many TaggableInterface], relation name "tagged", relation requested to be
38
 * inversed using name "tags":
39
 * - relation will walk should every record implementing TaggableInterface to collect name and
40
 *   type of outer keys, if outer key is not consistent across records implementing this interface
41
 *   an exception will be raised, let's say that outer key is "id" in every record
42
 * - relation will create pivot table named "tagged_map" (if allowed), where table name generated
43
 *   based on relation name (you can change name)
44
 * - relation will create pivot key named "tag_ud" related to Tag primary key
45
 * - relation will create pivot key named "tagged_id" related to primary key of outer records,
46
 *   singular relation name used to generate key like that
47
 * - relation will create pivot key named "tagged_type" to store role of outer record
48
 * - relation will create unique index on "tag_id", "tagged_id" and "tagged_type" columns if allowed
49
 * - relation will create additional columns in pivot table if any requested
50
 *
51
 * Using in records:
52
 * You can use inversed relation as usual ManyToMany, however in Tag record relation access will be
53
 * little bit more complex - every linked record will create inner ManyToMany relation:
54
 * $tag->tagged->users->count(); //Where "users" is plural form of one outer records
55
 *
56
 * You can defined your own inner relation names by using MORPHED_ALIASES option when defining
57
 * relation.
58
 *
59
 * Attention, relation do not support WHERE statement on outer records.
60
 *
61
 * @see BelongsToMorhedSchema
62
 * @see ManyToManySchema
63
 */
64
class ManyToMorphedSchema extends AbstractSchema implements InversableRelationInterface
65
{
66
    use TypecastTrait, ForeignsTrait, MorphedTrait;
67
68
    /**
69
     * Relation type.
70
     */
71
    const RELATION_TYPE = Record::MANY_TO_MORPHED;
72
73
    /**
74
     * Size of string column dedicated to store outer role name. Used in polymorphic relations.
75
     * Even simple relations might include morph key (usually such relations created via inversion
76
     * of polymorphic relation).
77
     *
78
     * @see RecordSchema::getRole()
79
     */
80
    const MORPH_COLUMN_SIZE = 32;
81
82
    /**
83
     * Options to be packed.
84
     */
85
    const PACK_OPTIONS = [
86
        Record::PIVOT_TABLE,
87
        Record::OUTER_KEY,
88
        Record::INNER_KEY,
89
        Record::THOUGHT_INNER_KEY,
90
        Record::THOUGHT_OUTER_KEY,
91
        Record::RELATION_COLUMNS,
92
        Record::PIVOT_COLUMNS,
93
        Record::WHERE_PIVOT,
94
        Record::MORPH_KEY
95
    ];
96
97
    /**
98
     * Default postfix for pivot tables.
99
     */
100
    const PIVOT_POSTFIX = '_map';
101
102
    /**
103
     * {@inheritdoc}
104
     *
105
     * @invisible
106
     */
107
    const OPTIONS_TEMPLATE = [
108
        //Inner key of parent record will be used to fill "THOUGHT_INNER_KEY" in pivot table
109
        Record::INNER_KEY         => '{source:primaryKey}',
110
111
        //We are going to use primary key of outer table to fill "THOUGHT_OUTER_KEY" in pivot table
112
        //This is technically "inner" key of outer record, we will name it "outer key" for simplicity
113
        Record::OUTER_KEY         => ORMInterface::R_PRIMARY_KEY,
114
115
        //Name field where parent record inner key will be stored in pivot table, role + innerKey
116
        //by default
117
        Record::THOUGHT_INNER_KEY => '{source:role}_{option:innerKey}',
118
119
        //Name field where inner key of outer record (outer key) will be stored in pivot table,
120
        //role + outerKey by default
121
        Record::THOUGHT_OUTER_KEY => '{relation:name}_id',
122
123
        //Declares what specific record pivot record linking to
124
        Record::MORPH_KEY         => '{relation:name}_type',
125
126
        //Set constraints (foreign keys) by default, attention only set for source table
127
        Record::CREATE_CONSTRAINT => true,
128
129
        //@link https://en.wikipedia.org/wiki/Foreign_key
130
        Record::CONSTRAINT_ACTION => 'CASCADE',
131
132
        //Relation allowed to create indexes in pivot table
133
        Record::CREATE_INDEXES    => true,
134
135
        //Name of pivot table to be declared, default value is not stated as it will be generated
136
        //based on roles of inner and outer records
137
        Record::PIVOT_TABLE       => '{relation:name}_map',
138
139
        //Relation allowed to create pivot table
140
        Record::CREATE_PIVOT      => true,
141
142
        //Additional set of columns to be added into pivot table, you can use same column definition
143
        //type as you using for your records
144
        Record::PIVOT_COLUMNS     => [],
145
146
        //Set of default values to be used for pivot table
147
        Record::PIVOT_DEFAULTS    => [],
148
149
        //WHERE statement in a form of simplified array definition to be applied to pivot table
150
        //data.
151
        Record::WHERE_PIVOT       => [],
152
    ];
153
154
    /**
155
     * {@inheritdoc}
156
     */
157
    public function inverseDefinition(SchemaBuilder $builder, $inverseTo): \Generator
158
    {
159
        if (!is_string($inverseTo)) {
160
            throw new DefinitionException("Inversed relation must be specified as string");
161
        }
162
163
        foreach ($this->findTargets($builder) as $schema) {
164
            /**
165
             * We are going to simply replace outer key with inner key and keep the rest of options intact.
166
             */
167
            $inversed = new RelationDefinition(
168
                $inverseTo,
169
                Record::MANY_TO_MANY,
170
                $this->definition->sourceContext()->getClass(),
171
                [
172
                    Record::PIVOT_TABLE       => $this->option(Record::PIVOT_TABLE),
173
                    Record::OUTER_KEY         => $this->option(Record::INNER_KEY),
174
                    Record::INNER_KEY         => $this->findOuter($builder)->getName(),
175
                    Record::THOUGHT_INNER_KEY => $this->option(Record::THOUGHT_OUTER_KEY),
176
                    Record::THOUGHT_OUTER_KEY => $this->option(Record::THOUGHT_INNER_KEY),
177
                    Record::CREATE_CONSTRAINT => false,
178
                    Record::CREATE_INDEXES    => $this->option(Record::CREATE_INDEXES),
179
                    Record::CREATE_PIVOT      => false, //Table creation hes been already handled
180
                    //We have to include morphed key in here
181
                    Record::PIVOT_COLUMNS     => $this->option(Record::PIVOT_COLUMNS) + [
182
                            $this->option(Record::MORPH_KEY) => 'string'
183
                        ],
184
                    Record::WHERE_PIVOT       => $this->option(Record::WHERE_PIVOT),
185
                    Record::MORPH_KEY         => $this->option(Record::MORPH_KEY)
186
                ]
187
            );
188
189
            //In back order :)
190
            yield $inversed->withContext(
191
                RelationContext::createContent(
192
                    $schema,
193
                    $builder->requestTable($schema->getTable(), $schema->getDatabase())
194
                ),
195
                $this->definition->sourceContext()
196
            );
197
        }
198
    }
199
200
    /**
201
     * {@inheritdoc}
202
     *
203
     * Note: pivot table will be build from direction of source, please do not attempt to create
204
     * many to many relations between databases without specifying proper database.
205
     */
206
    public function declareTables(SchemaBuilder $builder): array
207
    {
208
        $sourceContext = $this->definition->sourceContext();
209
210
        if (!interface_exists($target = $this->definition->getTarget())) {
211
            throw new RelationSchemaException("Morphed relations can only be pointed to an interface");
212
        }
213
214
        if (!$this->option(Record::CREATE_PIVOT)) {
215
            //No pivot table creation were requested, noting really to do
216
            return [];
217
        }
218
219
        $outerKey = $this->findOuter($builder);
220
        if (empty($outerKey)) {
221
            throw new RelationSchemaException("Unable to build morphed relation, no outer record found");
222
        }
223
224
        //Make sure all tables has same outer
225
        $this->verifyOuter($builder, $outerKey);
226
227
        $pivotTable = $builder->requestTable(
228
            $this->option(Record::PIVOT_TABLE),
229
            $sourceContext->getDatabase(),
230
            false,
231
            true
232
        );
233
234
        /*
235
         * Declare columns in map/pivot table.
236
         */
237
        $thoughtInnerKey = $pivotTable->column($this->option(Record::THOUGHT_INNER_KEY));
238
        $thoughtInnerKey->nullable(false);
239
        $thoughtInnerKey->setType($this->resolveType(
240
            $sourceContext->getColumn($this->option(Record::INNER_KEY))
241
        ));
242
243
        $thoughtOuterKey = $pivotTable->column($this->option(Record::THOUGHT_OUTER_KEY));
244
        $thoughtOuterKey->nullable(false);
245
        $thoughtOuterKey->setType($this->resolveType($outerKey));
246
247
        //Morph key
248
        $thoughtMorphKey = $pivotTable->column($this->option(Record::MORPH_KEY));
249
        $thoughtMorphKey->nullable(false);
250
        $thoughtMorphKey->string(static::MORPH_COLUMN_SIZE);
251
252
        /*
253
         * Declare user columns in pivot table.
254
         */
255
        $rendered = new ColumnRenderer();
256
        $rendered->renderColumns(
257
            $this->option(Record::PIVOT_COLUMNS),
258
            $this->option(Record::PIVOT_DEFAULTS),
259
            $pivotTable
260
        );
261
262
        //Map might only contain unique link between source and target
263
        if ($this->option(Record::CREATE_INDEXES)) {
264
            $pivotTable->index([
265
                $thoughtInnerKey->getName(),
266
                $thoughtOuterKey->getName(),
267
                $thoughtMorphKey->getName()
268
            ])->unique();
269
        }
270
271
        //There is only 1 constrain
272
        if ($this->isConstrained()) {
273
            $this->createForeign(
274
                $pivotTable,
275
                $thoughtInnerKey,
276
                $sourceContext->getColumn($this->option(Record::INNER_KEY)),
277
                $this->option(Record::CONSTRAINT_ACTION),
278
                $this->option(Record::CONSTRAINT_ACTION)
279
            );
280
        }
281
282
        return [$pivotTable];
283
    }
284
285
    /**
286
     * {@inheritdoc}
287
     */
288
    public function packRelation(SchemaBuilder $builder): array
289
    {
290
        $packed = parent::packRelation($builder);
291
        $schema = &$packed[ORMInterface::R_SCHEMA];
292
293
        //Must be resolved thought builder (can't be defined manually)
294
        $schema[Record::OUTER_KEY] = $this->findOuter($builder)->getName();
295
296
        //Clarifying location
297
        $schema[Record::PIVOT_DATABASE] = $this->definition->sourceContext()->getDatabase();
298
        $schema[Record::PIVOT_COLUMNS] = array_keys($schema[Record::PIVOT_COLUMNS]);
299
300
        //Ensure that inner keys are always presented
301
        $schema[Record::PIVOT_COLUMNS] = array_merge(
302
            [
303
                $this->option(Record::THOUGHT_INNER_KEY),
304
                $this->option(Record::THOUGHT_OUTER_KEY),
305
                $this->option(Record::MORPH_KEY)
306
            ],
307
            $schema[Record::PIVOT_COLUMNS]
308
        );
309
310
        //Model-role mapping
311
        foreach ($this->findTargets($builder) as $outer) {
312
            /*
313
             * //Must be pluralized
314
             * $tag->tagged->posts->count();
315
             */
316
            $role = Inflector::pluralize($outer->getRole());
317
318
            //Role => model mapping
319
            $schema[Record::MORPHED_ALIASES][$role] = $outer->getClass();
320
        }
321
322
        return $packed;
323
    }
324
}