Skip to content

Commit ac05063

Browse files
committed
Merge #59 remote-tracking branch 'php-openapi/58-create-migration-for-column-position-change-if-a-field-position-is-changed-in-spec'
* php-openapi/58-create-migration-for-column-position-change-if-a-field-position-is-changed-in-spec: (26 commits) Modify variable name Add more tests + refactor `setColumnsPositions()` to remove unwanted migrations Refactor Add docs + refactor Fix bug Change tests and add few more Fix bugs + fix failing tests 2 Fix bugs + fix failing tests Fix other failing tests Fix failing tests Fix bugs + fix failing tests + add more tests + refactor Refactor Fix bugs Fix bugs + fix failing tests + add more tests Resolve TODOs + add tests Implement for many test cases Implementation in progress + add more tests Refactor Add more tests 2 Add more tests ...
2 parents c52db9c + 7cb8ab8 commit ac05063

File tree

15 files changed

+1610
-67
lines changed

15 files changed

+1610
-67
lines changed

src/db/ColumnSchema.php

+20
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,24 @@ class ColumnSchema extends \yii\db\ColumnSchema
2525
* ```
2626
*/
2727
public $xDbType;
28+
29+
/**
30+
* Used only for MySQL/MariaDB
31+
* @var array|null
32+
* [
33+
* index => int # position: starts from 1
34+
* after => ?string # after column
35+
* before => ?string # before column
36+
* ]
37+
* If `before` is null then column is last
38+
* If `after` is null then column is first
39+
* If both are null then table has only 1 column
40+
*/
41+
public ?array $fromPosition = null;
42+
public ?array $toPosition = null;
43+
44+
/**
45+
* From `$this->fromPosition` and `$this->toPosition` we can check if the position is changed or not. This is done in `BaseMigrationBuilder::setColumnsPositions()`
46+
*/
47+
public bool $isPositionChanged = false;
2848
}

src/lib/migrations/BaseMigrationBuilder.php

+19-43
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ public function buildSecondary(?ManyToManyRelation $relation = null):MigrationMo
171171
{
172172
$this->migration = Yii::createObject(MigrationModel::class, [$this->model, false, $relation, []]);
173173
$this->newColumns = $relation->columnSchema ?? $this->model->attributesToColumnSchema();
174+
$this->setColumnsPositions();
174175
$wantNames = array_keys($this->newColumns);
175176
$haveNames = $this->tableSchema->columnNames;
176177
$columnsForCreate = array_map(
@@ -184,7 +185,7 @@ function (string $missingColumn) {
184185
function (string $unknownColumn) {
185186
return $this->tableSchema->columns[$unknownColumn];
186187
},
187-
array_diff($haveNames, $wantNames)
188+
array_reverse(array_diff($haveNames, $wantNames), true)
188189
);
189190

190191
$columnsForChange = array_intersect($wantNames, $haveNames);
@@ -220,6 +221,7 @@ function (string $unknownColumn) {
220221
} else {
221222
$this->buildRelations();
222223
}
224+
223225
return $this->migration;
224226
}
225227

@@ -249,12 +251,12 @@ protected function buildColumnsDrop(array $columns):void
249251
{
250252
foreach ($columns as $column) {
251253
$tableName = $this->model->getTableAlias();
254+
$position = $this->findPosition($column, true);
252255
if ($column->isPrimaryKey && !$column->autoIncrement) {
253256
$pkName = 'pk_' . $this->model->tableName . '_' . $column->name;
254257
$this->migration->addDownCode($this->recordBuilder->addPrimaryKey($tableName, [$column->name], $pkName))
255258
->addUpCode($this->recordBuilder->dropPrimaryKey($tableName, [$column->name], $pkName));
256259
}
257-
$position = $this->findPosition($column, true);
258260
$this->migration->addDownCode($this->recordBuilder->addDbColumn($tableName, $column, $position))
259261
->addUpCode($this->recordBuilder->dropColumn($tableName, $column->name));
260262
}
@@ -513,47 +515,6 @@ public function isDefaultValueChanged(
513515
return false;
514516
}
515517

516-
/**
517-
* Given a column, compute its previous column name present in OpenAPI schema
518-
* @return ?string
519-
* `null` if column is added at last
520-
* 'FIRST' if column is added at first position
521-
* 'AFTER <columnName>' if column is added in between e.g. if 'email' is added after 'username' then 'AFTER username'
522-
*/
523-
public function findPosition(ColumnSchema $column, bool $forDrop = false): ?string
524-
{
525-
$columnNames = array_keys($forDrop ? $this->tableSchema->columns : $this->newColumns);
526-
527-
$key = array_search($column->name, $columnNames);
528-
if ($key > 0) {
529-
$prevColName = $columnNames[$key-1];
530-
531-
if (!isset($columnNames[$key+1])) { // if new col is added at last then no need to add 'AFTER' SQL part. This is checked as if next column is present or not
532-
return null;
533-
}
534-
535-
// in case of `down()` code of migration, putting 'after <colName>' in add column statmenet is erroneous because <colName> may not exist.
536-
// Example: From col a, b, c, d, if I drop c and d then their migration code will be generated like:
537-
// `up()` code
538-
// drop c
539-
// drop d
540-
// `down()` code
541-
// add d after c (c does not exist! Error!)
542-
// add c
543-
if ($forDrop) {
544-
return null;
545-
}
546-
547-
548-
return self::POS_AFTER . ' ' . $prevColName;
549-
550-
// if no `$columnSchema` is found, previous column does not exist. This happens when 'after column' is not yet added in migration or added after currently undertaken column
551-
} elseif ($key === 0) {
552-
return self::POS_FIRST;
553-
}
554-
555-
return null;
556-
}
557518

558519
public function modifyDesiredFromDbInContextOfDesired(ColumnSchema $desired, ColumnSchema $desiredFromDb): void
559520
{
@@ -574,4 +535,19 @@ public function modifyDesiredInContextOfDesiredFromDb(ColumnSchema $desired, Col
574535
}
575536
$desired->dbType = $desiredFromDb->dbType;
576537
}
538+
539+
/**
540+
* Only for MySQL and MariaDB
541+
* Given a column, compute its previous column name present in OpenAPI schema
542+
* @param ColumnSchema $column
543+
* @param bool $forDrop
544+
* @param bool $forAlter
545+
* @return ?string
546+
* `null` if column is added at last
547+
* 'FIRST' if column is added at first position
548+
* 'AFTER <columnName>' if column is added in between e.g. if 'email' is added after 'username' then 'AFTER username'
549+
*/
550+
abstract public function findPosition(ColumnSchema $column, bool $forDrop = false, bool $forAlter = false): ?string;
551+
552+
abstract public function setColumnsPositions();
577553
}

src/lib/migrations/MigrationRecordBuilder.php

+19-11
Original file line numberDiff line numberDiff line change
@@ -118,28 +118,32 @@ public function addDbColumn(string $tableAlias, ColumnSchema $column, ?string $p
118118
/**
119119
* @throws \yii\base\InvalidConfigException
120120
*/
121-
public function alterColumn(string $tableAlias, ColumnSchema $column):string
121+
public function alterColumn(string $tableAlias, ColumnSchema $column, ?string $position = null):string
122122
{
123123
if (property_exists($column, 'xDbType') && is_string($column->xDbType) && !empty($column->xDbType)) {
124-
$converter = $this->columnToCode($tableAlias, $column, true, false, true, true);
124+
$converter = $this->columnToCode($tableAlias, $column, true, false, true, true, $position);
125125
return sprintf(
126126
ApiGenerator::isPostgres() ? self::ALTER_COLUMN_RAW_PGSQL : self::ALTER_COLUMN_RAW,
127127
$tableAlias,
128128
$column->name,
129129
ColumnToCode::escapeQuotes($converter->getCode())
130130
);
131131
}
132-
$converter = $this->columnToCode($tableAlias, $column, true);
132+
$converter = $this->columnToCode($tableAlias, $column, true, false, false, false, $position);
133133
return sprintf(self::ALTER_COLUMN, $tableAlias, $column->name, $converter->getCode(true));
134134
}
135135

136136
/**
137137
* @throws \yii\base\InvalidConfigException
138138
*/
139-
public function alterColumnType(string $tableAlias, ColumnSchema $column, bool $addUsing = false):string
140-
{
139+
public function alterColumnType(
140+
string $tableAlias,
141+
ColumnSchema $column,
142+
bool $addUsing = false,
143+
?string $position = null
144+
):string {
141145
if (property_exists($column, 'xDbType') && is_string($column->xDbType) && !empty($column->xDbType)) {
142-
$converter = $this->columnToCode($tableAlias, $column, false, false, true, true);
146+
$converter = $this->columnToCode($tableAlias, $column, false, false, true, true, $position);
143147
$this->isBuiltInType = $converter->isBuiltinType;
144148
return sprintf(
145149
ApiGenerator::isPostgres() ? self::ALTER_COLUMN_RAW_PGSQL : self::ALTER_COLUMN_RAW,
@@ -148,7 +152,7 @@ public function alterColumnType(string $tableAlias, ColumnSchema $column, bool $
148152
rtrim(ltrim($converter->getAlterExpression($addUsing), "'"), "'")
149153
);
150154
}
151-
$converter = $this->columnToCode($tableAlias, $column, false);
155+
$converter = $this->columnToCode($tableAlias, $column, false, false, false, false, $position);
152156
$this->isBuiltInType = $converter->isBuiltinType;
153157
return sprintf(self::ALTER_COLUMN, $tableAlias, $column->name, $converter->getAlterExpression($addUsing));
154158
}
@@ -157,10 +161,14 @@ public function alterColumnType(string $tableAlias, ColumnSchema $column, bool $
157161
* This method is only used in Pgsql
158162
* @throws \yii\base\InvalidConfigException
159163
*/
160-
public function alterColumnTypeFromDb(string $tableAlias, ColumnSchema $column, bool $addUsing = false) :string
161-
{
164+
public function alterColumnTypeFromDb(
165+
string $tableAlias,
166+
ColumnSchema $column,
167+
bool $addUsing = false,
168+
?string $position = null
169+
) :string {
162170
if (property_exists($column, 'xDbType') && is_string($column->xDbType) && !empty($column->xDbType)) {
163-
$converter = $this->columnToCode($tableAlias, $column, true, false, true, true);
171+
$converter = $this->columnToCode($tableAlias, $column, true, false, true, true, $position);
164172
$this->isBuiltInType = $converter->isBuiltinType;
165173
return sprintf(
166174
ApiGenerator::isPostgres() ? self::ALTER_COLUMN_RAW_PGSQL : self::ALTER_COLUMN_RAW,
@@ -169,7 +177,7 @@ public function alterColumnTypeFromDb(string $tableAlias, ColumnSchema $column,
169177
rtrim(ltrim($converter->getAlterExpression($addUsing), "'"), "'")
170178
);
171179
}
172-
$converter = $this->columnToCode($tableAlias, $column, true);
180+
$converter = $this->columnToCode($tableAlias, $column, true, false, false, false, $position);
173181
$this->isBuiltInType = $converter->isBuiltinType;
174182
return sprintf(self::ALTER_COLUMN, $tableAlias, $column->name, $converter->getAlterExpression($addUsing));
175183
}

src/lib/migrations/MysqlMigrationBuilder.php

+149-4
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@
1414
use yii\db\ColumnSchema;
1515
use yii\db\IndexConstraint;
1616
use yii\db\Schema;
17-
use \Yii;
1817
use yii\helpers\ArrayHelper;
19-
use yii\helpers\VarDumper;
2018

2119
final class MysqlMigrationBuilder extends BaseMigrationBuilder
2220
{
@@ -25,15 +23,22 @@ final class MysqlMigrationBuilder extends BaseMigrationBuilder
2523
*/
2624
protected function buildColumnChanges(ColumnSchema $current, ColumnSchema $desired, array $changed):void
2725
{
26+
$positionCurrent = $positionDesired = null;
27+
if (in_array('position', $changed, true)) {
28+
$positionDesired = $this->findPosition($desired, false, true);
29+
$positionCurrent = $this->findPosition($desired, true, true);
30+
$key = array_search('position', $changed, true);
31+
unset($changed[$key]);
32+
}
2833
$newColumn = clone $current;
2934
foreach ($changed as $attr) {
3035
$newColumn->$attr = $desired->$attr;
3136
}
3237
if (static::isEnum($newColumn)) {
3338
$newColumn->dbType = 'enum'; // TODO this is concretely not correct
3439
}
35-
$this->migration->addUpCode($this->recordBuilder->alterColumn($this->model->getTableAlias(), $newColumn))
36-
->addDownCode($this->recordBuilder->alterColumn($this->model->getTableAlias(), $current));
40+
$this->migration->addUpCode($this->recordBuilder->alterColumn($this->model->getTableAlias(), $newColumn, $positionDesired))
41+
->addDownCode($this->recordBuilder->alterColumn($this->model->getTableAlias(), $current, $positionCurrent));
3742
}
3843

3944
protected function compareColumns(ColumnSchema $current, ColumnSchema $desired):array
@@ -68,6 +73,11 @@ protected function compareColumns(ColumnSchema $current, ColumnSchema $desired):
6873
}
6974
}
7075
}
76+
77+
if (property_exists($desired, 'isPositionChanged') && $desired->isPositionChanged) {
78+
$changedAttributes[] = 'position';
79+
}
80+
7181
return $changedAttributes;
7282
}
7383

@@ -159,4 +169,139 @@ public function modifyDesiredInContextOfCurrent(ColumnSchema $current, ColumnSch
159169
$desired->size = $current->size;
160170
}
161171
}
172+
173+
/**
174+
* {@inheritDoc}
175+
*/
176+
public function findPosition(ColumnSchema $column, bool $forDrop = false, bool $forAlter = false): ?string
177+
{
178+
$columnNames = array_keys($forDrop ? $this->tableSchema->columns : $this->newColumns);
179+
180+
$key = array_search($column->name, $columnNames);
181+
if ($key > 0) {
182+
$prevColName = $columnNames[$key - 1];
183+
if (($key === count($columnNames) - 1) && !$forAlter) {
184+
return null;
185+
}
186+
187+
if (array_key_exists($prevColName, $forDrop ? $this->tableSchema->columns : $this->newColumns)) {
188+
if ($forDrop && !$forAlter) {
189+
// if the previous column is the last one in the want names then no need for AFTER
190+
$cols = array_keys($this->newColumns);
191+
if ($prevColName === array_pop($cols)) {
192+
return null;
193+
}
194+
}
195+
if ($forAlter && $forDrop) {
196+
if (!array_key_exists($prevColName, $this->newColumns)) {
197+
return null;
198+
}
199+
}
200+
return self::POS_AFTER . ' ' . $prevColName;
201+
}
202+
return null;
203+
204+
// if no `$columnSchema` is found, previous column does not exist. This happens when 'after column' is not yet added in migration or added after currently undertaken column
205+
} elseif ($key === 0) {
206+
return self::POS_FIRST;
207+
}
208+
209+
return null;
210+
}
211+
212+
public function setColumnsPositions()
213+
{
214+
$i = 0;
215+
$haveColumns = $this->tableSchema->columns;
216+
$wantNames = array_keys($this->newColumns);
217+
$haveNames = array_keys($haveColumns);
218+
219+
// Part 1/2 compute from and to position
220+
foreach ($this->newColumns as $name => $column) {
221+
/** @var \cebe\yii2openapi\db\ColumnSchema $column */
222+
$column->toPosition = [
223+
'index' => $i + 1,
224+
'after' => $i === 0 ? null : $wantNames[$i - 1],
225+
'before' => $i === (count($wantNames) - 1) ? null : $wantNames[$i + 1],
226+
];
227+
228+
if (isset($haveColumns[$name])) {
229+
$index = array_search($name, $haveNames) + 1;
230+
$column->fromPosition = [
231+
'index' => $index,
232+
'after' => $haveNames[$index - 2] ?? null,
233+
'before' => $haveNames[$index] ?? null,
234+
];
235+
}
236+
237+
$i++;
238+
}
239+
240+
// Part 2/2 compute is position is really changed
241+
242+
// check if only new columns are added without any explicit position change
243+
$namesForCreate = array_diff($wantNames, $haveNames);
244+
$wantNamesWoNewCols = array_values(array_diff($wantNames, $namesForCreate));
245+
if ($namesForCreate && $haveNames === $wantNamesWoNewCols) {
246+
return;
247+
}
248+
// check if only existing columns are deleted without any explicit position change
249+
$namesForDrop = array_diff($haveNames, $wantNames);
250+
$haveNamesWoDropCols = array_values(array_diff($haveNames, $namesForDrop));
251+
if ($namesForDrop && $wantNames === $haveNamesWoDropCols) {
252+
return;
253+
}
254+
// check both above simultaneously
255+
if ($namesForCreate && $namesForDrop && ($wantNamesWoNewCols === $haveNamesWoDropCols)) {
256+
return;
257+
}
258+
259+
$takenIndices = $nonRedundantIndices = []; # $nonRedundantIndices are the wanted ones which are created by moving of one or more columns. Example: if a column is moved from 2nd to 8th position then we will consider only one column is moved ignoring index/position change(-1) of 4rd to 8th column (4->3, 5->4 ...). So migration for this unwanted indices changes won't be generated. `$takenIndices` might have redundant indices
260+
foreach ($this->newColumns as $column) {
261+
/** @var \cebe\yii2openapi\db\ColumnSchema $column */
262+
263+
if (!$column->fromPosition || !$column->toPosition) {
264+
continue;
265+
}
266+
if (is_int(array_search([$column->toPosition['index'], $column->fromPosition['index']], $takenIndices))) {
267+
continue;
268+
}
269+
if ($column->fromPosition === $column->toPosition) {
270+
continue;
271+
}
272+
if ($column->fromPosition['index'] === $column->toPosition['index']) {
273+
continue;
274+
}
275+
276+
$column->isPositionChanged = true;
277+
$takenIndices[] = [$column->fromPosition['index'], $column->toPosition['index']];
278+
279+
// -------
280+
if (($column->fromPosition['before'] !== $column->toPosition['before']) &&
281+
($column->fromPosition['after'] !== $column->toPosition['after'])
282+
) {
283+
$nonRedundantIndices[] = [$column->fromPosition['index'], $column->toPosition['index']];
284+
}
285+
}
286+
287+
foreach ($this->newColumns as $column) {
288+
/** @var \cebe\yii2openapi\db\ColumnSchema $column */
289+
290+
if (!isset($column->toPosition['index'], $column->fromPosition['index'])) {
291+
continue;
292+
}
293+
$condition = (abs($column->toPosition['index'] - $column->fromPosition['index']) === count($nonRedundantIndices));
294+
if (($column->fromPosition['before'] === $column->toPosition['before'])
295+
&& $condition
296+
) {
297+
$column->isPositionChanged = false;
298+
continue;
299+
}
300+
if (($column->fromPosition['after'] === $column->toPosition['after'])
301+
&& $condition
302+
) {
303+
$column->isPositionChanged = false;
304+
}
305+
}
306+
}
162307
}

0 commit comments

Comments
 (0)