Skip to content

Commit

Permalink
Fix up and unify multi-column foreign key handling.
Browse files Browse the repository at this point in the history
  • Loading branch information
ndm2 committed Aug 28, 2023
1 parent 8ca989d commit 563266e
Show file tree
Hide file tree
Showing 8 changed files with 1,030 additions and 158 deletions.
40 changes: 18 additions & 22 deletions src/Phinx/Db/Adapter/MysqlAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -815,9 +815,6 @@ public function getPrimaryKey(string $tableName): array
*/
public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool
{
if (is_string($columns)) {
$columns = [$columns]; // str to array
}
$foreignKeys = $this->getForeignKeys($tableName);
if ($constraint) {
if (isset($foreignKeys[$constraint])) {
Expand All @@ -827,8 +824,10 @@ public function hasForeignKey(string $tableName, $columns, ?string $constraint =
return false;
}

$columns = array_map('mb_strtolower', (array)$columns);

foreach ($foreignKeys as $key) {
if ($columns == $key['columns']) {
if (array_map('mb_strtolower', $key['columns']) === $columns) {
return true;
}
}
Expand Down Expand Up @@ -909,32 +908,29 @@ protected function getDropForeignKeyByColumnsInstructions(string $tableName, arr
{
$instructions = new AlterInstructions();

foreach ($columns as $column) {
$rows = $this->fetchAll(sprintf(
"SELECT
CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE REFERENCED_TABLE_SCHEMA = DATABASE()
AND REFERENCED_TABLE_NAME IS NOT NULL
AND TABLE_NAME = '%s'
AND COLUMN_NAME = '%s'
ORDER BY POSITION_IN_UNIQUE_CONSTRAINT",
$tableName,
$column
));
$columns = array_map('mb_strtolower', $columns);

foreach ($rows as $row) {
$instructions->merge($this->getDropForeignKeyInstructions($tableName, $row['CONSTRAINT_NAME']));
$matches = [];
$foreignKeys = $this->getForeignKeys($tableName);
foreach ($foreignKeys as $name => $key) {
if (array_map('mb_strtolower', $key['columns']) === $columns) {
$matches[] = $name;
}
}

if (empty($instructions->getAlterParts())) {
if (empty($matches)) {
throw new InvalidArgumentException(sprintf(
"No foreign key on column(s) '%s' exists",
implode(',', $columns)
'No foreign key on column(s) `%s` exists',
implode(', ', $columns)
));
}

foreach ($matches as $name) {
$instructions->merge(
$this->getDropForeignKeyInstructions($tableName, $name)
);
}

return $instructions;
}

Expand Down
60 changes: 26 additions & 34 deletions src/Phinx/Db/Adapter/PostgresAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -909,9 +909,6 @@ public function getPrimaryKey(string $tableName): array
*/
public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool
{
if (is_string($columns)) {
$columns = [$columns]; // str to array
}
$foreignKeys = $this->getForeignKeys($tableName);
if ($constraint) {
if (isset($foreignKeys[$constraint])) {
Expand All @@ -921,9 +918,12 @@ public function hasForeignKey(string $tableName, $columns, ?string $constraint =
return false;
}

if (is_string($columns)) {
$columns = [$columns];
}

foreach ($foreignKeys as $key) {
$a = array_diff($columns, $key['columns']);
if (empty($a)) {
if ($key['columns'] === $columns) {
return true;
}
}
Expand Down Expand Up @@ -952,7 +952,7 @@ protected function getForeignKeys(string $tableName): array
JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage AS ccu ON ccu.constraint_name = tc.constraint_name
WHERE constraint_type = 'FOREIGN KEY' AND tc.table_schema = %s AND tc.table_name = %s
ORDER BY kcu.position_in_unique_constraint",
ORDER BY kcu.ordinal_position",
$this->getConnection()->quote($parts['schema']),
$this->getConnection()->quote($parts['table'])
));
Expand All @@ -962,6 +962,10 @@ protected function getForeignKeys(string $tableName): array
$foreignKeys[$row['constraint_name']]['referenced_table'] = $row['referenced_table_name'];
$foreignKeys[$row['constraint_name']]['referenced_columns'][] = $row['referenced_column_name'];
}
foreach ($foreignKeys as $name => $key) {
$foreignKeys[$name]['columns'] = array_values(array_unique($key['columns']));
$foreignKeys[$name]['referenced_columns'] = array_values(array_unique($key['referenced_columns']));
}

return $foreignKeys;
}
Expand Down Expand Up @@ -999,37 +1003,25 @@ protected function getDropForeignKeyByColumnsInstructions(string $tableName, arr
{
$instructions = new AlterInstructions();

$parts = $this->getSchemaName($tableName);
$sql = 'SELECT c.CONSTRAINT_NAME
FROM (
SELECT CONSTRAINT_NAME, array_agg(COLUMN_NAME::varchar) as columns
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = %s
AND TABLE_NAME IS NOT NULL
AND TABLE_NAME = %s
AND POSITION_IN_UNIQUE_CONSTRAINT IS NOT NULL
GROUP BY CONSTRAINT_NAME
) c
WHERE
ARRAY[%s]::varchar[] <@ c.columns AND
ARRAY[%s]::varchar[] @> c.columns';

$array = [];
foreach ($columns as $col) {
$array[] = "'$col'";
$matches = [];
$foreignKeys = $this->getForeignKeys($tableName);
foreach ($foreignKeys as $name => $key) {
if ($key['columns'] === $columns) {
$matches[] = $name;
}
}

$rows = $this->fetchAll(sprintf(
$sql,
$this->getConnection()->quote($parts['schema']),
$this->getConnection()->quote($parts['table']),
implode(',', $array),
implode(',', $array)
));
if (empty($matches)) {
throw new InvalidArgumentException(sprintf(
'No foreign key on column(s) `%s` exists',
implode(', ', $columns)
));
}

foreach ($rows as $row) {
$newInstr = $this->getDropForeignKeyInstructions($tableName, $row['constraint_name']);
$instructions->merge($newInstr);
foreach ($matches as $name) {
$instructions->merge(
$this->getDropForeignKeyInstructions($tableName, $name)
);
}

return $instructions;
Expand Down
87 changes: 57 additions & 30 deletions src/Phinx/Db/Adapter/SQLiteAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,38 @@ public function quoteColumnName($columnName): string
return '`' . str_replace('`', '``', $columnName) . '`';
}

/**
* Generates a regular expression to match identifiers that may or
* may not be quoted with any of the supported quotes.
*
* @param string $identifier The identifier to match.
* @param bool $spacedNoQuotes Whether the non-quoted identifier requires to be surrounded by whitespace.
* @return string
*/
protected function possiblyQuotedIdentifierRegex(string $identifier, bool $spacedNoQuotes = true): string
{
$identifiers = [];
$identifier = preg_quote($identifier, '/');

$hasTick = str_contains($identifier, '`');
$hasDoubleQuote = str_contains($identifier, '"');
$hasSingleQuote = str_contains($identifier, "'");

$identifiers[] = '`' . ($hasTick ? str_replace('`', '``', $identifier) : $identifier) . '`';
$identifiers[] = '"' . ($hasDoubleQuote ? str_replace('"', '""', $identifier) : $identifier) . '"';
$identifiers[] = "'" . ($hasSingleQuote ? str_replace("'", "''", $identifier) : $identifier) . "'";

if (!$hasTick && !$hasDoubleQuote && !$hasSingleQuote) {
if ($spacedNoQuotes) {
$identifiers[] = "\s+$identifier\s+";
} else {
$identifiers[] = $identifier;
}
}

return '(' . implode('|', $identifiers) . ')';
}

/**
* @param string $tableName Table name
* @param bool $quoted Whether to return the schema name and table name escaped and quoted. If quoted, the schema (if any) will also be appended with a dot
Expand Down Expand Up @@ -1489,21 +1521,17 @@ public function hasForeignKey(string $tableName, $columns, ?string $constraint =
{
if ($constraint !== null) {
return preg_match(
"/,?\sCONSTRAINT\s" . preg_quote($this->quoteColumnName($constraint)) . ' FOREIGN KEY/',
"/,?\s*CONSTRAINT\s*" . $this->possiblyQuotedIdentifierRegex($constraint) . '\s*FOREIGN\s+KEY/is',
$this->getDeclaringSql($tableName)
) === 1;
}

$columns = array_map('strtolower', (array)$columns);
$foreignKeys = $this->getForeignKeys($tableName);
$columns = array_map('mb_strtolower', (array)$columns);

foreach ($foreignKeys as $key) {
$key = array_map('strtolower', $key);
if (array_diff($key, $columns) || array_diff($columns, $key)) {
continue;
foreach ($this->getForeignKeys($tableName) as $key) {
if (array_map('mb_strtolower', $key) === $columns) {
return true;
}

return true;
}

return false;
Expand Down Expand Up @@ -1671,18 +1699,27 @@ protected function getDropForeignKeyInstructions(string $tableName, string $cons
*/
protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions
{
if (!$this->hasForeignKey($tableName, $columns)) {
throw new InvalidArgumentException(sprintf(
'No foreign key on column(s) `%s` exists',
implode(', ', $columns)
));
}

$instructions = $this->beginAlterByCopyTable($tableName);

$instructions->addPostStep(function ($state) use ($columns) {
$sql = '';

foreach ($columns as $columnName) {
$search = sprintf(
"/,[^,]*\(%s(?:,`?(.*)`?)?\) REFERENCES[^,]*\([^\)]*\)[^,)]*/",
$this->quoteColumnName($columnName)
);
$sql = preg_replace($search, '', $state['createSQL'], 1);
}
$search = sprintf(
"/,[^,]+?\(\s*%s\s*\)\s*REFERENCES[^,]*\([^\)]*\)[^,)]*/is",
implode(
'\s*,\s*',
array_map(
fn ($column) => $this->possiblyQuotedIdentifierRegex($column, false),
$columns
)
),
);
$sql = preg_replace($search, '', $state['createSQL']);

if ($sql) {
$this->execute($sql);
Expand All @@ -1691,18 +1728,8 @@ protected function getDropForeignKeyByColumnsInstructions(string $tableName, arr
return $state;
});

$instructions->addPostStep(function ($state) use ($columns) {
$newState = $this->calculateNewTableColumns($state['tmpTableName'], $columns[0], $columns[0]);

$selectColumns = $newState['selectColumns'];
$columns = array_map([$this, 'quoteColumnName'], $columns);
$diff = array_diff($columns, $selectColumns);

if (!empty($diff)) {
throw new InvalidArgumentException(sprintf(
'The specified columns don\'t exist: ' . implode(', ', $diff)
));
}
$instructions->addPostStep(function ($state) {
$newState = $this->calculateNewTableColumns($state['tmpTableName'], false, false);

return $newState + $state;
});
Expand Down
Loading

0 comments on commit 563266e

Please sign in to comment.