Completed
Branch feature/pre-split (2ed6c7)
by Anton
04:25
created

ManyToManySchema::inverseDefinition()   B

Complexity

Conditions 2
Paths 2

Size

Total Lines 38
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

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