Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
}
],
"require": {
"phpnomad/db": "dev-codex/operation-local-handler-bridge#4b0b7709e5945a4290b04c2e3562ef919923ed08 as 2.2.x-dev",
"phpnomad/db": "dev-codex/operation-bridge-retirement-convergence#76d7161702a4368748b6865cd05001dce0b157cc as 2.2.x-dev",
"phpnomad/loader": "^1.0 || ^2.0",
"phpnomad/logger": "^1.0",
"ext-pdo": "*"
Expand Down
10 changes: 5 additions & 5 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions lib/Connections/PdoConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
* Lazily-opened PDO connection for the PDO database strategies.
*
* Accepts the same configuration shape consumers passed to SafeMySQL
* (host / user / pass / db / port / charset), so migrating off the
* abandoned colshrapnel/safemysql backend is a binding swap. An existing
* (host / user / pass / db / port / charset), so switching from the
* SafeMySQL backend is a binding swap. An existing
* PDO instance can also be wrapped directly via fromPdo() — useful for
* tests and for applications that manage their own connection.
*
* @phpstan-consistent-constructor
*/
class PdoConnection
{
Expand All @@ -31,7 +33,6 @@ public function __construct(array $config = [])
public static function fromPdo(PDO $pdo): static
{
// Late static binding preserves the public factory contract for consumer subclasses.
// @phpstan-ignore new.static
$connection = new static();
$connection->pdo = $pdo;

Expand Down
8 changes: 6 additions & 2 deletions lib/MySqlInitializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use PHPNomad\Database\Interfaces\TableCreateStrategy as CoreTableCreateStrategy;
use PHPNomad\Database\Interfaces\TableDeleteStrategy as CoreTableDeleteStrategy;
use PHPNomad\Database\Interfaces\TableExistsStrategy as CoreTableExistsStrategy;
use PHPNomad\Database\Interfaces\TableColumnRetirementStrategy as CoreTableColumnRetirementStrategy;
use PHPNomad\Database\Interfaces\TableUpdateStrategy as CoreTableUpdateStrategy;
use PHPNomad\Loader\Interfaces\HasClassDefinitions;
use PHPNomad\MySql\Integration\Adapters\DatabaseDateAdapter;
Expand All @@ -31,12 +32,15 @@ public function getClassDefinitions(): array
TableCreateStrategy::class => CoreTableCreateStrategy::class,
TableDeleteStrategy::class => CoreTableDeleteStrategy::class,
TableExistsStrategy::class => CoreTableExistsStrategy::class,
TableUpdateStrategy::class => CoreTableUpdateStrategy::class,
TableUpdateStrategy::class => [
CoreTableUpdateStrategy::class,
CoreTableColumnRetirementStrategy::class,
],
QueryStrategy::class => CoreQueryStrategy::class,
DatabaseDateAdapter::class => [
CanConvertDatabaseStringToDateTime::class,
CanConvertToDatabaseDateString::class
]
];
}
}
}
1 change: 1 addition & 0 deletions lib/Strategies/PdoDatabaseStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public function parse(string $query, ...$args): string
}
}

/** @var string $part */
$result .= match ($part) {
'?n' => $this->escapeIdentifier((string) $value),
'?s' => $this->escapeString($value),
Expand Down
173 changes: 171 additions & 2 deletions lib/Strategies/TableUpdateStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
use PHPNomad\Database\Exceptions\TableUpdateFailedException;
use PHPNomad\Database\Factories\Column;
use PHPNomad\Database\Interfaces\Table;
use PHPNomad\Database\Interfaces\TableColumnRetirementStrategy as CoreTableColumnRetirementStrategy;
use PHPNomad\Database\Interfaces\TableUpdateStrategy as CoreTableUpdateStrategy;
use PHPNomad\Utils\Helpers\Arr;
use PHPNomad\MySql\Integration\Interfaces\DatabaseStrategy;

class TableUpdateStrategy implements CoreTableUpdateStrategy
class TableUpdateStrategy implements CoreTableUpdateStrategy, CoreTableColumnRetirementStrategy
{
protected DatabaseStrategy $db;

Expand Down Expand Up @@ -49,6 +50,174 @@ public function syncColumns(Table $table): void
}
}

public function columnExists(Table $table, string $columnName): bool
{
$this->assertValidColumnName($columnName);

try {
return $this->findCurrentColumnName($table->getName(), $columnName) !== null;
} catch (\InvalidArgumentException $e) {
throw $e;
} catch (\Exception $e) {
throw new TableUpdateFailedException($e);
}
}

public function retireColumns(Table $table, string ...$columnNames): void
{
if ($columnNames === []) {
throw new \InvalidArgumentException('At least one column must be named for retirement.');
}

foreach ($columnNames as $columnName) {
$this->assertValidColumnName($columnName);
}

try {
$targets = [];
foreach ($columnNames as $columnName) {
$currentName = $this->findCurrentColumnName($table->getName(), $columnName);
if ($currentName !== null) {
$targets[$currentName] = $currentName;
}
}

foreach ($table->getColumns() as $column) {
foreach ($columnNames as $columnName) {
if ($this->identifiersEqual($column->getName(), $columnName)) {
throw new \InvalidArgumentException('A declared column cannot be retired.');
}
}
}

if ($targets === []) {
return;
}

$this->assertNoColumnDependencies($table->getName(), $targets);

$drops = array_map(
fn (string $columnName): string => 'DROP COLUMN ' . $this->db->parse('?n', $columnName),
array_values($targets)
);
$query = $this->db->parse('ALTER TABLE ?n ', $table->getName()) . implode(', ', $drops);
$this->db->query($query);
} catch (\InvalidArgumentException $e) {
throw $e;
} catch (\Exception $e) {
throw new TableUpdateFailedException($e);
}
}

private function assertValidColumnName(string $columnName): void
{
if ($columnName === '' || str_contains($columnName, "\0")) {
throw new \InvalidArgumentException('Column names must be non-empty and cannot contain NUL.');
}
}

private function findCurrentColumnName(string $tableName, string $columnName): ?string
{
$rows = $this->metadataRows($this->db->query($this->db->parse(
'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS '
. 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?s AND COLUMN_NAME = ?s',
$tableName,
$columnName
)));

foreach ($rows as $row) {
$persistedName = $row['COLUMN_NAME'] ?? null;
if (!is_string($persistedName)) {
throw new \UnexpectedValueException('Column metadata did not contain a valid name.');
}

return $persistedName;
}

return null;
}

private function identifiersEqual(string $left, string $right): bool
{
$rows = $this->metadataRows($this->db->query($this->db->parse(
'SELECT candidate = ?s AS identifiers_equal FROM ('
. 'SELECT COLUMN_NAME AS candidate FROM INFORMATION_SCHEMA.COLUMNS WHERE 1 = 0 '
. 'UNION ALL SELECT ?s) AS identifier_semantics',
$right,
$left
)));
$value = $rows[0]['identifiers_equal'] ?? null;

if ($value !== 0 && $value !== 1 && $value !== '0' && $value !== '1') {
throw new \UnexpectedValueException('Failed to compare column identifiers.');
}

return (string) $value === '1';
}

/** @param array<string, string> $targets persisted name => persisted name */
private function assertNoColumnDependencies(string $tableName, array $targets): void
{
$statistics = $this->metadataRows($this->db->query($this->db->parse(
'SELECT INDEX_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.STATISTICS '
. 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?s',
$tableName
)));

foreach ($statistics as $statistic) {
if (!array_key_exists('COLUMN_NAME', $statistic)) {
throw new \UnexpectedValueException('Index metadata did not contain a column identity.');
}
$columnName = $statistic['COLUMN_NAME'] ?? null;
if ($columnName !== null && !is_string($columnName)) {
throw new \UnexpectedValueException('Index metadata contained a malformed column identity.');
}
if (is_string($columnName) && isset($targets[$columnName])) {
throw new \InvalidArgumentException('An indexed column cannot be retired implicitly.');
}
if ($columnName === null) {
throw new \InvalidArgumentException('An unresolved functional index prevents column retirement.');
}
}

$foreignKeys = $this->metadataRows($this->db->query($this->db->parse(
'SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME '
. 'FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() '
. 'AND (TABLE_NAME = ?s OR (REFERENCED_TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME = ?s))',
$tableName,
$tableName
)));

foreach ($foreignKeys as $foreignKey) {
$localColumn = $foreignKey['COLUMN_NAME'] ?? null;
$referencedColumn = $foreignKey['REFERENCED_COLUMN_NAME'] ?? null;
if (!is_string($localColumn)
|| ($referencedColumn !== null && !is_string($referencedColumn))) {
throw new \UnexpectedValueException('Foreign-key metadata contained a malformed column identity.');
}
if ((is_string($localColumn) && isset($targets[$localColumn]))
|| (is_string($referencedColumn) && isset($targets[$referencedColumn]))) {
throw new \InvalidArgumentException('A foreign-key column cannot be retired implicitly.');
}
}
}

/** @return list<array<string, mixed>> */
private function metadataRows($result): array
{
if (!is_array($result)) {
throw new \UnexpectedValueException('Metadata query did not return rows.');
}

foreach ($result as $row) {
if (!is_array($row)) {
throw new \UnexpectedValueException('Metadata query returned a malformed row.');
}
}

return array_values($result);
}

/**
* Attributes that declare a key rather than describe the column.
*
Expand Down Expand Up @@ -98,7 +267,7 @@ protected function convertColumnToSql(Column $column, bool $includeKeys = true):
if (!$includeKeys) {
$columnAttributes = Arr::filter(
$columnAttributes,
static fn($attribute): bool => !in_array(
static fn ($attribute): bool => !in_array(
strtoupper(trim((string) $attribute)),
static::KEY_ATTRIBUTES,
true
Expand Down
Loading
Loading