Completed
Branch feature/pre-split (c69968)
by Anton
21:25
created

ManyToManySchema::packRelation()   B

Complexity

Conditions 1
Paths 1

Size

Total Lines 26
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 12
nc 1
nop 1
dl 0
loc 26
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\Entities\Relations\ManyToManyRelation;
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\RelationDefinition;
17
use Spiral\ORM\Schemas\InversableRelationInterface;
18
use Spiral\ORM\Schemas\Relations\Traits\ForeignsTrait;
19
use Spiral\ORM\Schemas\Relations\Traits\TablesTrait;
20
use Spiral\ORM\Schemas\Relations\Traits\TypecastTrait;
21
use Spiral\ORM\Schemas\SchemaBuilder;
22
23
/**
24
 * ManyToMany relation declares that two records related to each other using pivot table data.
25
 * Relation allow to specify inner key (key in parent record), outer key (key in outer record),
26
 * pivot table name, names of pivot columns to store inner and outer key values and set of
27
 * additional columns. Relation allow specifying default WHERE statement for outer records and
28
 * pivot table separately.
29
 *
30
 * Attention, MANY to MANY can only be used inside same database.
31
 *
32
 * Example (User related to many Tag records):
33
 * - relation will create pivot table named "tag_user_map" (if allowed), where table name generated
34
 *   based on roles of inner and outer tables sorted in ABC order (you can change name)
35
 * - relation will create pivot key named "user_id" related to User primary key
36
 * - relation will create pivot key named "tag_id" related to Tag primary key
37
 * - relation will create unique index on "user_id" and "tag_id" columns if allowed
38
 * - relation will create foreign key "tag_user_map"."user_id" => "users"."id" if allowed
39
 * - relation will create foreign key "tag_user_map"."tag_id" => "tags"."id" if allowed
40
 * - relation will create additional columns in pivot table if any requested
41
 */
42
class ManyToManySchema extends AbstractSchema implements InversableRelationInterface
43
{
44
    use TablesTrait, TypecastTrait, ForeignsTrait;
45
46
    /**
47
     * Relation type.
48
     */
49
    const RELATION_TYPE = Record::MANY_TO_MANY;
50
51
    /**
52
     * Options to be packed.
53
     */
54
    const PACK_OPTIONS = [
55
        Record::PIVOT_TABLE,
56
        Record::OUTER_KEY,
57
        Record::INNER_KEY,
58
        Record::THOUGHT_INNER_KEY,
59
        Record::THOUGHT_OUTER_KEY,
60
        Record::RELATION_COLUMNS,
61
        Record::PIVOT_COLUMNS,
62
        Record::WHERE_PIVOT,
63
        Record::WHERE
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
125
    /**
126
     *{@inheritdoc}
127
     */
128
    public function inverseDefinition(string $inverseTo): RelationDefinition
129
    {
130
        if (empty($this->definition->targetContext())) {
131
            throw new DefinitionException(sprintf(
132
                "Unable to inverse relation '%s.''%s', unspecified relation target",
133
                $this->definition->sourceContext()->getClass(),
134
                $this->definition->getName()
135
            ));
136
        }
137
138
        /**
139
         * We are going to simply replace outer key with inner key and keep the rest of options intact.
140
         */
141
        $inversed = new RelationDefinition(
142
            $inverseTo,
143
            Record::MANY_TO_MANY,
144
            $this->definition->sourceContext()->getClass(),
145
            [
146
                Record::PIVOT_TABLE       => $this->option(Record::PIVOT_TABLE),
147
                Record::OUTER_KEY         => $this->option(Record::INNER_KEY),
148
                Record::INNER_KEY         => $this->option(Record::OUTER_KEY),
149
                Record::THOUGHT_INNER_KEY => $this->option(Record::THOUGHT_OUTER_KEY),
150
                Record::THOUGHT_OUTER_KEY => $this->option(Record::THOUGHT_INNER_KEY),
151
                Record::CREATE_CONSTRAINT => $this->option(Record::CREATE_CONSTRAINT),
152
                Record::CONSTRAINT_ACTION => $this->option(Record::CONSTRAINT_ACTION),
153
                Record::CREATE_INDEXES    => $this->option(Record::CREATE_INDEXES),
154
                Record::CREATE_PIVOT      => false, //Table creation hes been already handled
155
                Record::PIVOT_COLUMNS     => $this->option(Record::PIVOT_COLUMNS),
156
                Record::WHERE_PIVOT       => $this->option(Record::WHERE_PIVOT),
157
            ]
158
        );
159
160
        //In back order :)
161
        return $inversed->withContext(
162
            $this->definition->targetContext(),
163
            $this->definition->sourceContext()
164
        );
165
    }
166
167
    /**
168
     * {@inheritdoc}
169
     *
170
     * Note: pivot table will be build from direction of source, please do not attempt to create
171
     * many to many relations between databases without specifying proper database.
172
     */
173
    public function declareTables(SchemaBuilder $builder): array
174
    {
175
        if (!$this->option(Record::CREATE_PIVOT)) {
176
            //No pivot table creation were requested, noting really to do
177
            return [];
178
        }
179
180
        $sourceTable = $this->sourceTable($builder);
181
        $targetTable = $this->targetTable($builder);
182
183
        $sourceContext = $this->definition->sourceContext();
184
        $targetContext = $this->definition->targetContext();
185
186
        //todo: support cross database and cross driver many to many
187
        if (
188
            $sourceTable->getDriver() != $targetTable->getDriver()
189
            || $sourceContext->getDatabase() != $sourceContext->getDatabase()
190
        ) {
191
            throw new RelationSchemaException(
192
                "ManyToMany relations can only exists inside same database"
193
            );
194
        }
195
196
        $pivotTable = $builder->requestTable(
197
            $this->pivotTable(),
198
            $sourceContext->getDatabase(),
199
            false,
200
            true
201
        );
202
203
        /*
204
         * Declare columns in map/pivot table.
205
         */
206
        $innerKey = $pivotTable->column($this->option(Record::THOUGHT_INNER_KEY));
207
        $innerKey->nullable(false);
208
        $innerKey->setType($this->resolveType(
209
            $sourceContext->getColumn($this->option(Record::INNER_KEY))
210
        ));
211
212
        $outerKey = $pivotTable->column($this->option(Record::THOUGHT_OUTER_KEY));
213
        $outerKey->nullable(false);
214
        $outerKey->setType($this->resolveType(
215
            $targetContext->getColumn($this->option(Record::OUTER_KEY))
216
        ));
217
218
        /*
219
         * Declare user columns in pivot table.
220
         */
221
        $rendered = new ColumnRenderer();
222
        $rendered->renderColumns(
223
            $this->option(Record::PIVOT_COLUMNS),
224
            $this->option(Record::PIVOT_DEFAULTS),
225
            $pivotTable
226
        );
227
228
        //Map might only contain unique link between source and target
229
        if ($this->option(Record::CREATE_INDEXES)) {
230
            $pivotTable->index([$innerKey->getName(), $outerKey->getName()])->unique();
231
        }
232
233
        //There is 2 constrains between map table and source and table
234
        if ($this->isConstrained()) {
235
            $this->createForeign(
236
                $pivotTable,
237
                $innerKey,
238
                $sourceContext->getColumn($this->option(Record::INNER_KEY)),
239
                $this->option(Record::CONSTRAINT_ACTION),
240
                $this->option(Record::CONSTRAINT_ACTION)
241
            );
242
243
            $this->createForeign(
244
                $pivotTable,
245
                $outerKey,
246
                $targetContext->getColumn($this->option(Record::OUTER_KEY)),
247
                $this->option(Record::CONSTRAINT_ACTION),
248
                $this->option(Record::CONSTRAINT_ACTION)
249
            );
250
        }
251
252
        return [$pivotTable];
253
    }
254
255
    /**
256
     * {@inheritdoc}
257
     */
258
    public function packRelation(AbstractTable $table): array
259
    {
260
        $packed = parent::packRelation($table);
261
262
        //Let's clarify pivot columns
263
        $schema = $packed[ORMInterface::R_SCHEMA];
264
265
        //Pivot table location (for now always in context database)
266
        $schema[Record::PIVOT_TABLE] = $this->pivotTable();
267
        $schema[ManyToManyRelation::PIVOT_DATABASE] = $this->definition->sourceContext()->getDatabase();
268
269
        $schema[Record::PIVOT_COLUMNS] = array_keys($schema[Record::PIVOT_COLUMNS]);
270
271
        //Ensure that inner keys are always presented
272
        $schema[Record::PIVOT_COLUMNS] = array_merge(
273
            [
274
                $this->option(Record::THOUGHT_INNER_KEY),
275
                $this->option(Record::THOUGHT_OUTER_KEY)
276
            ],
277
            $schema[Record::PIVOT_COLUMNS]
278
        );
279
280
        $packed[ORMInterface::R_SCHEMA] = $schema;
281
282
        return $packed;
283
    }
284
285
    /**
286
     * Generate name of pivot table or fetch if from schema.
287
     *
288
     * @return string
289
     */
290
    protected function pivotTable(): string
291
    {
292
        if (!empty($this->option(Record::PIVOT_TABLE))) {
293
            return $this->option(Record::PIVOT_TABLE);
294
        }
295
296
        $source = $this->definition->sourceContext();
297
        $target = $this->definition->targetContext();
298
299
        //Generating pivot table name
300
        $names = [$source->getRole(), $target->getRole()];
301
        asort($names);
302
303
        return implode('_', $names) . static::PIVOT_POSTFIX;
304
    }
305
}