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 @@ -47,7 +47,7 @@
"php": ">=8.0",
"phpnomad/auth": "^1.0",
"phpnomad/asset": "^1.0",
"phpnomad/db": "dev-codex/operation-local-handler-bridge as 2.2.x-dev",
"phpnomad/db": "dev-codex/operation-bridge-retirement-convergence#76d7161702a4368748b6865cd05001dce0b157cc as 2.2.x-dev",
"phpnomad/datastore": "^2.0",
"phpnomad/event": "^1.0",
"phpnomad/email": "^1.0",
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.

186 changes: 175 additions & 11 deletions lib/Strategies/TableUpdateStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
use PHPNomad\Database\Factories\Column;
use PHPNomad\Database\Factories\Index;
use PHPNomad\Database\Interfaces\Table;
use PHPNomad\Database\Interfaces\TableColumnRetirementStrategy as CoreTableColumnRetirementStrategy;
use PHPNomad\Database\Interfaces\TableUpdateStrategy as CoreTableUpdateStrategy;
use PHPNomad\Datastore\Exceptions\DatastoreErrorException;
use PHPNomad\Integrations\WordPress\Traits\CanModifyWordPressDatabase;
use PHPNomad\Utils\Helpers\Arr;

class TableUpdateStrategy implements CoreTableUpdateStrategy
class TableUpdateStrategy implements CoreTableUpdateStrategy, CoreTableColumnRetirementStrategy
{
use CanModifyWordPressDatabase;

Expand All @@ -36,6 +37,177 @@ 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);

global $wpdb;
$drops = array_map(
fn(string $columnName): string => 'DROP COLUMN ' . $wpdb->prepare('%i', $columnName),
array_values($targets)
);
$this->wpdbQuery(
'ALTER TABLE ' . $wpdb->prepare('%i', $table->getName()) . ' ' . implode(', ', $drops)
);
} 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
{
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare(
'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS '
. 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = %s',
$tableName,
$columnName
), ARRAY_A);
$this->assertMetadataSucceeded($rows);

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

return $row['COLUMN_NAME'];
}

return null;
}

private function identifiersEqual(string $left, string $right): bool
{
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare(
'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
), ARRAY_A);
$this->assertMetadataSucceeded($rows);
$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
{
global $wpdb;
$statistics = $wpdb->get_results($wpdb->prepare(
'SELECT INDEX_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.STATISTICS '
. 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$tableName
), ARRAY_A);
$this->assertMetadataSucceeded($statistics);

foreach ($statistics as $statistic) {
if (!is_array($statistic) || !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 = $wpdb->get_results($wpdb->prepare(
'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
), ARRAY_A);
$this->assertMetadataSucceeded($foreignKeys);

foreach ($foreignKeys as $foreignKey) {
if (!is_array($foreignKey)) {
throw new \UnexpectedValueException('Foreign-key metadata contained a malformed row.');
}
$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.');
}
}
}

private function assertMetadataSucceeded($results): void
{
global $wpdb;
if (!is_array($results) || $wpdb->last_error !== '') {
throw new DatastoreErrorException('Failed to inspect table metadata.');
}
}

protected function convertColumnToSql(Column $column): string
{
// Get the column name and type
Expand All @@ -59,9 +231,10 @@ protected function getCurrentColumns(string $tableName): array
global $wpdb;
$query = 'SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = %s';
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s';

$results = $wpdb->get_results($wpdb->prepare($query, $tableName), ARRAY_A);
$this->assertMetadataSucceeded($results);
$columns = [];

foreach ($results as $row) {
Expand Down Expand Up @@ -121,15 +294,6 @@ protected function buildSyncColumnsQuery(Table $table): ?string
}
}

$newColumnNames = Arr::pluck($newColumns, 'name');

// Drop columns that no longer exist in the new definition
foreach ($currentColumns as $currentColumnName => $currentColumnData) {
if (!in_array($currentColumnName, $newColumnNames)) {
$queries[] = 'DROP COLUMN ' . $wpdb->prepare('%i', $currentColumnName);
}
}

$args = Arr::process($queries)
->whereNotEmpty()
->setSeparator(",\n ")
Expand Down
6 changes: 5 additions & 1 deletion lib/Strategies/WordPressInitializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
use PHPNomad\Database\Interfaces\TableDeleteStrategy as CoreTableDeleteStrategyAlias;
use PHPNomad\Database\Interfaces\TableExistsStrategy as CoreTableExistsStrategyAlias;
use PHPNomad\Database\Interfaces\TableUpdateStrategy as CoreTableUpdateStrategy;
use PHPNomad\Database\Interfaces\TableColumnRetirementStrategy as CoreTableColumnRetirementStrategy;
use PHPNomad\Datastore\Events\RecordCreated;
use PHPNomad\Datastore\Events\RecordDeleted;
use PHPNomad\Di\Interfaces\CanSetContainer;
Expand Down Expand Up @@ -103,7 +104,10 @@ public function getClassDefinitions(): array
WordPressOperationDatabaseProviderFactory::class => CoreOperationDatabaseProviderFactory::class,
DefaultCacheTtlProvider::class => HasDefaultTtl::class,
TableCreateStrategy::class => CoreTableCreateStrategyAlias::class,
TableUpdateStrategy::class => CoreTableUpdateStrategy::class,
TableUpdateStrategy::class => [
CoreTableUpdateStrategy::class,
CoreTableColumnRetirementStrategy::class,
],
TableDeleteStrategy::class => CoreTableDeleteStrategyAlias::class,
TableExistsStrategy::class => CoreTableExistsStrategyAlias::class,
TranslationStrategy::class => CoreTranslationStrategyAlias::class,
Expand Down
Loading
Loading