ManyToManySchema   A
last analyzed

Complexity

Total Complexity 12

Size/Duplication

Total Lines 277
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 12

Importance

Changes 0
Metric Value
wmc 12
lcom 1
cbo 12
dl 0
loc 277
rs 10
c 0
b 0
f 0

4 Methods

Rating   Name   Duplication   Size   Complexity  
A inverseDefinition() 0 42 3
B declareTables() 0 84 6
A packRelation() 0 24 1
A pivotTable() 0 15 2
1
<?php
2
/**
3
 * Spiral, Core Components
4
 *
5
 * @author Wolfy-J
6
 */
7
8
namespace Spiral\ORM\Schemas\Relations;
9
10
use Spiral\ORM\Exceptions\DefinitionException;
11
use Spiral\ORM\Exceptions\RelationSchemaException;
12
use Spiral\ORM\Helpers\ColumnRenderer;
13
use Spiral\ORM\ORMInterface;
14
use Spiral\ORM\Record;
15
use Spiral\ORM\Schemas\Definitions\RelationDefinition;
16
use Spiral\ORM\Schemas\InversableRelationInterface;
17
use Spiral\ORM\Schemas\Relations\Traits\ForeignsTrait;
18
use Spiral\ORM\Schemas\Relations\Traits\TypecastTrait;
19
use Spiral\ORM\Schemas\SchemaBuilder;
20
21
/**
22
 * ManyToMany relation declares that two records related to each other using pivot table data.
23
 * Relation allow to specify inner key (key in parent record), outer key (key in outer record),
24
 * pivot table name, names of pivot columns to store inner and outer key values and set of
25
 * additional columns. Relation allow specifying default WHERE statement for outer records and
26
 * pivot table separately.
27
 *
28
 * Attention, MANY to MANY can only be used inside same database.
29
 *
30
 * Example (User related to many Tag records):
31
 * - relation will create pivot table named "tag_user_map" (if allowed), where table name generated
32
 *   based on roles of inner and outer tables sorted in ABC order (you can change name)
33
 * - relation will create pivot key named "user_id" related to User primary key
34
 * - relation will create pivot key named "tag_id" related to Tag primary key
35
 * - relation will create unique index on "user_id" and "tag_id" columns if allowed
36
 * - relation will create foreign key "tag_user_map"."user_id" => "users"."id" if allowed
37
 * - relation will create foreign key "tag_user_map"."tag_id" => "tags"."id" if allowed
38
 * - relation will create additional columns in pivot table if any requested
39
 */
40
class ManyToManySchema extends AbstractSchema implements InversableRelationInterface
41
{
42
    use TypecastTrait, ForeignsTrait;
43
44
    /**
45
     * Relation type.
46
     */
47
    const RELATION_TYPE = Record::MANY_TO_MANY;
48
49
    /**
50
     * Options to be packed.
51
     */
52
    const PACK_OPTIONS = [
53
        Record::PIVOT_TABLE,
54
        Record::OUTER_KEY,
55
        Record::INNER_KEY,
56
        Record::THOUGHT_INNER_KEY,
57
        Record::THOUGHT_OUTER_KEY,
58
        Record::RELATION_COLUMNS,
59
        Record::PIVOT_COLUMNS,
60
        Record::WHERE_PIVOT,
61
        Record::WHERE,
62
        Record::MORPH_KEY,
63
        Record::ORDER_BY
64
    ];
65
66
    /**
67
     * Default postfix for pivot tables.
68
     */
69
    const PIVOT_POSTFIX = '_map';
70
71
    /**
72
     * {@inheritdoc}
73
     *
74
     * @invisible
75
     */
76
    const OPTIONS_TEMPLATE = [
77
        //Inner key of parent record will be used to fill "THOUGHT_INNER_KEY" in pivot table
78
        Record::INNER_KEY         => '{source:primaryKey}',
79
80
        //We are going to use primary key of outer table to fill "THOUGHT_OUTER_KEY" in pivot table
81
        //This is technically "inner" key of outer record, we will name it "outer key" for simplicity
82
        Record::OUTER_KEY         => '{target:primaryKey}',
83
84
        //Name field where parent record inner key will be stored in pivot table, role + innerKey
85
        //by default
86
        Record::THOUGHT_INNER_KEY => '{source:role}_{option:innerKey}',
87
88
        //Name field where inner key of outer record (outer key) will be stored in pivot table,
89
        //role + outerKey by default
90
        Record::THOUGHT_OUTER_KEY => '{target:role}_{option:outerKey}',
91
92
        //Set constraints in pivot table (foreign keys)
93
        Record::CREATE_CONSTRAINT => true,
94
95
        //@link https://en.wikipedia.org/wiki/Foreign_key
96
        Record::CONSTRAINT_ACTION => 'CASCADE',
97
98
        //Relation allowed to create indexes in pivot table
99
        Record::CREATE_INDEXES    => true,
100
101
        //Name of pivot table to be declared, default value is not stated as it will be generated
102
        //based on roles of inner and outer records
103
        Record::PIVOT_TABLE       => null,
104
105
        //Relation allowed to create pivot table
106
        Record::CREATE_PIVOT      => true,
107
108
        //Additional set of columns to be added into pivot table, you can use same column definition
109
        //type as you using for your records
110
        Record::PIVOT_COLUMNS     => [],
111
112
        //Set of default values to be used for pivot table
113
        Record::PIVOT_DEFAULTS    => [],
114
115
        //WHERE statement in a form of simplified array definition to be applied to pivot table
116
        //data.
117
        Record::WHERE_PIVOT       => [],
118
119
        //WHERE statement to be applied for data in outer data while loading relation data
120
        //can not be inversed. Attention, WHERE conditions not used in has(), link() and sync()
121
        //methods.
122
        Record::WHERE             => [],
123
124
        //Used when relation is created as inverse of ManyToMorphed relation
125
        Record::MORPH_KEY         => null,
126
127
        //Order
128
        Record::ORDER_BY          => []
129
    ];
130
131
    /**
132
     *{@inheritdoc}
133
     */
134
    public function inverseDefinition(SchemaBuilder $builder, $inverseTo): \Generator
135
    {
136
        if (!is_string($inverseTo)) {
137
            throw new DefinitionException("Inversed relation must be specified as string");
138
        }
139
140
        if (empty($this->definition->targetContext())) {
141
            throw new DefinitionException(sprintf(
142
                "Unable to inverse relation '%s.''%s', unspecified relation target",
143
                $this->definition->sourceContext()->getClass(),
144
                $this->definition->getName()
145
            ));
146
        }
147
148
        /**
149
         * We are going to simply replace outer key with inner key and keep the rest of options intact.
150
         */
151
        $inversed = new RelationDefinition(
152
            $inverseTo,
153
            Record::MANY_TO_MANY,
154
            $this->definition->sourceContext()->getClass(),
155
            [
156
                Record::PIVOT_TABLE       => $this->option(Record::PIVOT_TABLE),
157
                Record::OUTER_KEY         => $this->option(Record::INNER_KEY),
158
                Record::INNER_KEY         => $this->option(Record::OUTER_KEY),
159
                Record::THOUGHT_INNER_KEY => $this->option(Record::THOUGHT_OUTER_KEY),
160
                Record::THOUGHT_OUTER_KEY => $this->option(Record::THOUGHT_INNER_KEY),
161
                Record::CREATE_CONSTRAINT => $this->option(Record::CREATE_CONSTRAINT),
162
                Record::CONSTRAINT_ACTION => $this->option(Record::CONSTRAINT_ACTION),
163
                Record::CREATE_INDEXES    => $this->option(Record::CREATE_INDEXES),
164
                Record::CREATE_PIVOT      => false, //Table creation hes been already handled
165
                Record::PIVOT_COLUMNS     => $this->option(Record::PIVOT_COLUMNS),
166
                Record::WHERE_PIVOT       => $this->option(Record::WHERE_PIVOT),
167
            ]
168
        );
169
170
        //In back order :)
171
        yield $inversed->withContext(
172
            $this->definition->targetContext(),
173
            $this->definition->sourceContext()
174
        );
175
    }
176
177
    /**
178
     * {@inheritdoc}
179
     *
180
     * Note: pivot table will be build from direction of source, please do not attempt to create
181
     * many to many relations between databases without specifying proper database.
182
     */
183
    public function declareTables(SchemaBuilder $builder): array
184
    {
185
        if (!$this->option(Record::CREATE_PIVOT)) {
186
            //No pivot table creation were requested, noting really to do
187
            return [];
188
        }
189
190
        $sourceTable = $this->sourceTable($builder);
191
        $targetTable = $this->targetTable($builder);
192
193
        $sourceContext = $this->definition->sourceContext();
194
        $targetContext = $this->definition->targetContext();
195
196
        if (
197
            $sourceTable->getDriver() != $targetTable->getDriver()
198
            || $sourceContext->getDatabase() != $targetContext->getDatabase()
199
        ) {
200
            //todo: support cross database and cross driver many to many
201
            throw new RelationSchemaException(
202
                "ManyToMany relations can only exists inside same database"
203
            );
204
        }
205
206
        $pivotTable = $builder->requestTable(
207
            $this->pivotTable(),
208
            $sourceContext->getDatabase(),
209
            false,
210
            true
211
        );
212
213
        /*
214
         * Declare columns in map/pivot table.
215
         */
216
        $thoughtInnerKey = $pivotTable->column($this->option(Record::THOUGHT_INNER_KEY));
217
        $thoughtInnerKey->nullable(false);
218
        $thoughtInnerKey->setType($this->resolveType(
219
            $sourceContext->getColumn($this->option(Record::INNER_KEY))
220
        ));
221
222
        $thoughtOuterKey = $pivotTable->column($this->option(Record::THOUGHT_OUTER_KEY));
223
        $thoughtOuterKey->nullable(false);
224
        $thoughtOuterKey->setType($this->resolveType(
225
            $targetContext->getColumn($this->option(Record::OUTER_KEY))
226
        ));
227
228
        /*
229
         * Declare user columns in pivot table.
230
         */
231
        $rendered = new ColumnRenderer();
232
        $rendered->renderColumns(
233
            $this->option(Record::PIVOT_COLUMNS),
234
            $this->option(Record::PIVOT_DEFAULTS),
235
            $pivotTable
236
        );
237
238
        //Map might only contain unique link between source and target
239
        if ($this->option(Record::CREATE_INDEXES)) {
240
            $pivotTable->index([
241
                $thoughtInnerKey->getName(),
242
                $thoughtOuterKey->getName()
243
            ])->unique();
244
        }
245
246
        //There is 2 constrains between map table and source and table
247
        if ($this->isConstrained()) {
248
            $this->createForeign(
249
                $pivotTable,
250
                $thoughtInnerKey,
251
                $sourceContext->getColumn($this->option(Record::INNER_KEY)),
252
                $this->option(Record::CONSTRAINT_ACTION),
253
                $this->option(Record::CONSTRAINT_ACTION)
254
            );
255
256
            $this->createForeign(
257
                $pivotTable,
258
                $thoughtOuterKey,
259
                $targetContext->getColumn($this->option(Record::OUTER_KEY)),
260
                $this->option(Record::CONSTRAINT_ACTION),
261
                $this->option(Record::CONSTRAINT_ACTION)
262
            );
263
        }
264
265
        return [$pivotTable];
266
    }
267
268
    /**
269
     * {@inheritdoc}
270
     */
271
    public function packRelation(SchemaBuilder $builder): array
272
    {
273
        $packed = parent::packRelation($builder);
274
275
        //Let's clarify pivot columns
276
        $schema = &$packed[ORMInterface::R_SCHEMA];
277
278
        //Pivot table location (for now always in context database)
279
        $schema[Record::PIVOT_TABLE] = $this->pivotTable();
280
        $schema[Record::PIVOT_DATABASE] = $this->definition->sourceContext()->getDatabase();
281
282
        $schema[Record::PIVOT_COLUMNS] = array_keys($schema[Record::PIVOT_COLUMNS]);
283
284
        //Ensure that inner keys are always presented
285
        $schema[Record::PIVOT_COLUMNS] = array_merge(
286
            [
287
                $this->option(Record::THOUGHT_INNER_KEY),
288
                $this->option(Record::THOUGHT_OUTER_KEY)
289
            ],
290
            $schema[Record::PIVOT_COLUMNS]
291
        );
292
293
        return $packed;
294
    }
295
296
    /**
297
     * Generate name of pivot table or fetch if from schema.
298
     *
299
     * @return string
300
     */
301
    protected function pivotTable(): string
302
    {
303
        if (!empty($this->option(Record::PIVOT_TABLE))) {
304
            return $this->option(Record::PIVOT_TABLE);
305
        }
306
307
        $source = $this->definition->sourceContext();
308
        $target = $this->definition->targetContext();
309
310
        //Generating pivot table name
311
        $names = [$source->getRole(), $target->getRole()];
312
        asort($names);
313
314
        return implode('_', $names) . static::PIVOT_POSTFIX;
315
    }
316
}
317