From c4a70259789df65021c00058d8c14a7dafdaffdd Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 12 Feb 2025 15:55:28 +0100 Subject: [PATCH] improve migration class --- Migration.php | 94 ++++--------------- Query.php | 55 ++++++++--- Schema.php | 11 ++- Table.php | 211 ++++++++++++++++++++++++------------------ enums/Aggregation.php | 21 +++++ 5 files changed, 212 insertions(+), 180 deletions(-) create mode 100644 enums/Aggregation.php diff --git a/Migration.php b/Migration.php index fbd0136..b5bbb61 100644 --- a/Migration.php +++ b/Migration.php @@ -7,7 +7,7 @@ use InvalidArgumentException; class Migration { - protected Schema $table; + protected Schema $schema; protected array $columnsToAdd = []; protected array $columnsToModify = []; protected array $columnsToDrop = []; @@ -16,11 +16,13 @@ class Migration protected array $foreignKeysToDrop = []; public function __construct(Schema $table) { - $this->table = $table->copy(); + $this->schema = $table->copy(); } - public function addColumn(string $name, Type $type, mixed $default = null, bool $isNullable = false, bool $isUnique = false): Migration { - $this->table->columns[$name] = [ + public function add(string $name, Type $type, mixed $default = null, bool $isNullable = false, bool $isUnique = false): Migration { + if ($this->schema->existsColumn($name)) + throw new InvalidArgumentException("Column '$name' already exists."); + $this->schema->columns[$name] = [ 'name' => $name, 'type' => $type, 'default' => $default, @@ -31,11 +33,9 @@ class Migration return $this; } - public function modifyColumn(string $name, Type $type, mixed $default = null, bool $isNullable = false, bool $isUnique = false): Migration { - if (!isset($this->table->columns[$name])) { - throw new InvalidArgumentException("Column $name does not exist."); - } - $this->table->columns[$name] = [ + public function modify(string $name, Type $type, mixed $default = null, bool $isNullable = false, bool $isUnique = false): Migration { + $this->schema->requireColumn($name); + $this->schema->columns[$name] = [ 'name' => $name, 'type' => $type, 'default' => $default, @@ -46,87 +46,27 @@ class Migration return $this; } - public function dropColumn(string $name): Migration { - if (!isset($this->table->columns[$name])) { - throw new InvalidArgumentException("Column $name does not exist."); - } - unset($this->table->columns[$name]); + public function delete(string $name): Migration { + $this->schema->requireColumn($name); + unset($this->schema->columns[$name]); $this->columnsToDrop[] = $name; return $this; } - public function changePrimaryKey(string $name, bool $autoInc = false): Migration { - if (!isset($this->table->columns[$name])) { - throw new InvalidArgumentException("Column $name does not exist."); - } - $this->table->primaryKey = [ - 'name' => $name, - 'autoInc' => $autoInc - ]; - $this->primaryKey = $name; - return $this; + public function reference(Schema $foreignTable, Propagation $onDelete = Propagation::CASCADE, Propagation $onUpdate = Propagation::CASCADE): Migration { + } - public function addForeignKey(string $column, string $referencedTable, string $referencedColumn, Propagation $onDelete, Propagation $onUpdate): Migration { - if (!isset($this->table->columns[$column])) { - throw new InvalidArgumentException("Column $column does not exist."); - } - $this->table->foreignKeys[$column] = [ - 'referencedTable' => $referencedTable, - 'referencedColumn' => $referencedColumn, - 'onDelete' => $onDelete, - 'onUpdate' => $onUpdate - ]; - $this->foreignKeysToAdd[] = $column; - return $this; - } + public function dereference(Schema $foreignTable): Migration { - public function dropForeignKey(string $name): Migration { - if (!isset($this->table->foreignKeys[$name])) { - throw new InvalidArgumentException("Foreign key $name does not exist."); - } - unset($this->table->foreignKeys[$name]); - $this->foreignKeysToDrop[] = $name; - return $this; } public function toSql(): string { - $sql = "ALTER TABLE `{$this->table->name}` "; - $statements = []; - foreach ($this->columnsToAdd as $name) { - $col = $this->table->columns[$name]; - $statements[] = "ADD COLUMN `{$col['name']}` {$col['type']->toString()}" . - ($col['isNullable'] ? "" : " NOT NULL") . - ($col['isUnique'] ? " UNIQUE" : "") . - ($col['default'] !== null ? " DEFAULT " . (is_string($col['default']) ? "'{$col['default']}'" : $col['default']) : ""); - } - - foreach ($this->columnsToModify as $name) { - $col = $this->table->columns[$name]; - $statements[] = "MODIFY COLUMN `{$col['name']}` {$col['type']->toString()}" . - ($col['isNullable'] ? "" : " NOT NULL") . - ($col['isUnique'] ? " UNIQUE" : "") . - ($col['default'] !== null ? " DEFAULT " . (is_string($col['default']) ? "'{$col['default']}'" : $col['default']) : ""); - } - - foreach ($this->columnsToDrop as $name) { - $statements[] = "DROP COLUMN `$name`"; - } - - foreach ($this->foreignKeysToDrop as $name) { - $statements[] = "DROP FOREIGN KEY `$name`"; - } - - foreach ($this->foreignKeysToAdd as $column) { - $fk = $this->table->foreignKeys[$column]; - $statements[] = "ADD CONSTRAINT `fk_{$column}` FOREIGN KEY (`$column`) REFERENCES `{$fk['referencedTable']}` (`{$fk['referencedColumn']}`) ON DELETE {$fk['onDelete']->toString()} ON UPDATE {$fk['onUpdate']->toString()}"; - } - - return $sql . implode(", ", $statements) . ";"; } public function drop(): null { + global $wpdb; } @@ -134,6 +74,6 @@ class Migration global $wpdb; $sql = $this->toSql(); $wpdb->query($sql); - return $this->table; + return $this->schema; } } diff --git a/Query.php b/Query.php index efeaf10..b5a5a97 100644 --- a/Query.php +++ b/Query.php @@ -1,6 +1,7 @@ schema->existsReference($other)) $foreignKey = $this->schema->foreignKeys[$other->name]; @@ -65,17 +67,20 @@ class Query } public function toSql(): string { + // Merge any aggregations with the standard columns. + $selectColumns = $this->columns; + if ($this->hasAggregations()) + $selectColumns = array_merge($selectColumns, $this->aggregations); + // Build the SELECT clause. - $columns = implode(", ", $this->columns); + $columns = implode(", ", $selectColumns); $primaryTable = $this->schema->name; $sqlStatement = "SELECT $columns FROM $primaryTable"; // Append join clauses, if any. - if ($this->isJoined()) { - foreach ($this->joins as $join) { + if ($this->isJoined()) + foreach ($this->joins as $join) $sqlStatement .= " " . $join['type']->toString() . " NATURAL JOIN " . $join['table']; - } - } // Append the WHERE clause if conditions exist. if ($this->isConditioned()) { @@ -92,18 +97,46 @@ class Query return esc_sql($sqlStatement); } - public function query(): array { + public function aggregate(string $col, string $alias, Aggregation $func): Query { + if ($col != '*') + $this->schema->requireColumn($col); + $this->aggregations[] = strtoupper($func->toString()) . "($col) AS $alias"; + return $this; + } + + public function hasAggregations(): bool { + return !empty($this->aggregations); + } + + public function query(): mixed { global $wpdb; $query = $this->toSql(); $results = $wpdb->get_results($query, ARRAY_A); - return $this->castResults($results); + return $this->formatResults($results); } - protected function castResults(array $results): array { - foreach ($results as &$row) + protected function formatResults(array $results) { + $formatted = []; + + foreach ($results as $row) { + // Apply type casting to each column in each row. foreach ($row as $column => &$value) if (isset($this->columnTypes[$column])) $value = $this->columnTypes[$column]->valCast($value); - return $results; + // Use the primary key for row indexing + $primaryKey = $this->schema->primaryKey(); + $formatted[$row[$primaryKey]] = $row; + } + + if (count($formatted) === 1) { + // Unpack single row results + $row = array_shift($formatted); + if (count($row) === 1) + // Unpack single column results + return array_shift($row); + return $row; + } + + return $formatted; } } diff --git a/Schema.php b/Schema.php index 041b0b0..bfb8a85 100644 --- a/Schema.php +++ b/Schema.php @@ -1,6 +1,7 @@ aggregate('*', 'count', Aggregation::COUNT) + ->query(); + } + public function columnType(string $col) { return $this->columns[$col]['type']; } @@ -46,10 +53,6 @@ class Schema return $this->columns['primary']['name']; } - /** - * Creates a deep copy of this instance. - * @return Schema - */ public function copy(): Schema { $copy = new Schema($this->name); $copy->columns = genericDeepCopy($this->columns); diff --git a/Table.php b/Table.php index 6f438a9..06e957d 100644 --- a/Table.php +++ b/Table.php @@ -2,130 +2,165 @@ namespace DatabaseHelper; use DatabaseHelper\enums\Propagation; -use DatabaseHelper\enums\Charset; -use DatabaseHelper\enums\Collation; use DatabaseHelper\enums\Type; -use DatabaseHelper\enums\Engine; use InvalidArgumentException; -class Table +class Migration { - protected Schema $table; + protected Schema $schema; + protected array $columnsToAdd = []; + protected array $columnsToModify = []; + protected array $columnsToDrop = []; + protected ?array $primaryKey = null; + protected array $foreignKeysToAdd = []; + protected array $foreignKeysToDrop = []; - public function __construct(string $tableName) { - Database::standardizeTableNames($tableName); - $this->table = new Schema($tableName); + public function __construct(Schema $table) { + $this->schema = $table->copy(); } - public function column(string $name, Type $type, mixed $default = null, bool $isNullable = false, bool $isUnique = false): Table { - $this->table->columns[$name] = [ - 'name' => $name, - 'default' => $default, - 'type' => $type, + public function column(string $name, Type $type, mixed $default = null, bool $isNullable = false, bool $isUnique = false): Migration { + if ($this->schema->existsColumn($name)) + throw new InvalidArgumentException("Column '$name' already exists."); + $this->schema->columns[$name] = [ + 'name' => $name, + 'type' => $type, + 'default' => $default, 'isNullable' => $isNullable, - 'isUnique' => $isUnique + 'isUnique' => $isUnique ]; - + $this->columnsToAdd[] = $name; return $this; } - public function primary(string $name, Type $type, bool $autoInc = false): Table { - if(isset($this->primaryKey)) - throw new InvalidArgumentException('Primary column already exists.'); - - $this->table->primaryKey = [ - 'name' => $name, - 'autoInc' => $autoInc + public function modify(string $name, Type $type, mixed $default = null, bool $isNullable = false, bool $isUnique = false): Migration { + $this->schema->requireColumn($name); + $this->schema->columns[$name] = [ + 'name' => $name, + 'type' => $type, + 'default' => $default, + 'isNullable' => $isNullable, + 'isUnique' => $isUnique ]; - - return $this->column($name, $type); + $this->columnsToModify[] = $name; + return $this; } - public function reference(Schema $foreignTable, Propagation $onDelete = Propagation::CASCADE, Propagation $onUpdate = Propagation::CASCADE): Table { - $name = $foreignTable->primaryKey(); - if(isset($this->columns[$name])) - throw new InvalidArgumentException('Column name already exists.'); + public function delete(string $name): Migration { + $this->schema->requireColumn($name); + unset($this->schema->columns[$name]); + $this->columnsToDrop[] = $name; + return $this; + } - $this->table->foreignKeys[$name] = [ - 'name' => $name, - 'table' => $foreignTable->name, + public function reference(Schema $foreignTable, Propagation $onDelete = Propagation::CASCADE, Propagation $onUpdate = Propagation::CASCADE): Migration { + $name = $foreignTable->primaryKey(); + if ($this->schema->existsColumn($name)) + throw new InvalidArgumentException("Column '$name' already exists."); + + $this->schema->foreignKeys[$name] = [ + 'name' => $name, + 'table' => $foreignTable->name, 'onDelete' => $onDelete, 'onUpdate' => $onUpdate ]; - $this->table->columns[$name] = $foreignTable->columns[$name]; + $this->schema->columns[$name] = $foreignTable->columns[$name]; + $this->foreignKeysToAdd[] = $name; return $this; } - public function engine(Engine $engine): Table { - $this->table->engine = $engine; + public function dereference(Schema $foreignTable): Migration { + $name = $foreignTable->primaryKey(); + if ($this->schema->existsReference($foreignTable)) + throw new InvalidArgumentException('Foreign table is not referenced.'); + unset($this->schema->foreignKeys[$name]); + // Also remove the column and mark it for dropping. + if (isset($this->schema->columns[$name])) + $this->delete($name); + $this->foreignKeysToDrop[] = $name; return $this; } - public function charset(Charset $charset): Table { - $this->table->charset = $charset; - return $this; - } - - public function collation(Collation $collation): Table { - $this->table->collation = $collation; - return $this; - } - - /** - * Generates the SQL statement. - * @return string SQL query. - * @throws InvalidArgumentException - */ public function toSql(): string { - $primaryKey = $this->table->primaryKey(); + global $wpdb; + // We assume that the table name in the schema is not prefixed; add the prefix here. + $tableName = $wpdb->prefix . $this->schema->name; + $clauses = []; - $sql = "CREATE TABLE `{$this->table->name}` (\n"; - $sql .= " PRIMARY KEY (`$primaryKey`),\n"; - - foreach ($this->table->columns as $col) { - if ($col['name'] !== $primaryKey) { - $sql .= " `{$col['name']}` {$col['type']->toString()}"; - // Handle nulls, uniqueness, and defaults - if (!$col['isNullable']) - $sql .= " NOT NULL"; - if ($col['isUnique']) - $sql .= " UNIQUE"; - if ($col['default'] !== null) { - $default = is_string($col['default']) ? "'{$col['default']}'" : $col['default']; - $sql .= " DEFAULT $default"; - } - - $sql .= ",\n"; + // Process new columns. + foreach ($this->columnsToAdd as $columnName) { + $col = $this->schema->columns[$columnName]; + $clause = "ADD COLUMN `{$col['name']}` " . $col['type']->toString(); + if (!$col['isNullable']) { + $clause .= " NOT NULL"; } + if ($col['isUnique']) { + $clause .= " UNIQUE"; + } + if ($col['default'] !== null) { + $default = is_string($col['default']) ? "'{$col['default']}'" : $col['default']; + $clause .= " DEFAULT $default"; + } + $clauses[] = $clause; } - // Add secondary constraints if present - foreach ($this->table->foreignKeys as $key) { - $sql .= " FOREIGN KEY (`{$key['name']}`) REFERENCES `{$key['table']}` (`{$key['name']}`)"; - $sql .= " ON DELETE {$key['onDelete']->toString()} ON UPDATE {$key['onUpdate']->toString()},\n"; + // Process modified columns. + foreach ($this->columnsToModify as $columnName) { + $col = $this->schema->columns[$columnName]; + $clause = "MODIFY COLUMN `{$col['name']}` " . $col['type']->toString(); + if (!$col['isNullable']) { + $clause .= " NOT NULL"; + } + if ($col['isUnique']) { + $clause .= " UNIQUE"; + } + if ($col['default'] !== null) { + $default = is_string($col['default']) ? "'{$col['default']}'" : $col['default']; + $clause .= " DEFAULT $default"; + } + $clauses[] = $clause; } - // Close the SQL string and add constraints - $sql = rtrim($sql, ",\n") . "\n) "; - $sql .= "ENGINE={$this->table->engine->toString()} "; - $sql .= "CHARSET={$this->table->charset->toString()} "; - $sql -= "COLLATE={$this->table->collation->toString()};"; + // Process dropped columns. + foreach ($this->columnsToDrop as $columnName) { + $clauses[] = "DROP COLUMN `{$columnName}`"; + } + + // Process foreign keys to add. + foreach ($this->foreignKeysToAdd as $fkName) { + $fk = $this->schema->foreignKeys[$fkName]; + // Here we name the constraint “fk_{$name}”. (There are other acceptable naming schemes.) + $clause = "ADD CONSTRAINT `fk_{$fk['name']}` FOREIGN KEY (`{$fk['name']}`) REFERENCES `{$fk['table']}` (`{$fk['name']}`) ON DELETE " . $fk['onDelete']->toString() . " ON UPDATE " . $fk['onUpdate']->toString(); + $clauses[] = $clause; + } + + // Process foreign keys to drop. + foreach ($this->foreignKeysToDrop as $fkName) { + // Again, we assume the constraint was named “fk_{$name}” + $clauses[] = "DROP FOREIGN KEY `fk_{$fkName}`"; + } + + if (empty($clauses)) { + throw new InvalidArgumentException("No migration operations to perform."); + } + + $sql = "ALTER TABLE `{$tableName}`\n" . implode(",\n", $clauses) . ";"; return esc_sql($sql); } - - public function create(): Schema { + + public function drop(): null { global $wpdb; + $tableName = $wpdb->prefix . $this->schema->name; + $sql = "DROP TABLE IF EXISTS `{$tableName}`;"; + $wpdb->query(esc_sql($sql)); + return null; + } - if (empty($this->primaryKey)) - throw new InvalidArgumentException('A primary key must be defined.'); - $table_name = $wpdb->prefix . $this->table->name; - - if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) { - $sql = $this->toSql(); - require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); - dbDelta($sql); - } - return $this->table; + public function migrate(): Schema { + global $wpdb; + $sql = $this->toSql(); + $wpdb->query($sql); + return $this->schema; } } diff --git a/enums/Aggregation.php b/enums/Aggregation.php new file mode 100644 index 0000000..0a240e7 --- /dev/null +++ b/enums/Aggregation.php @@ -0,0 +1,21 @@ + 'COUNT', + self::SUM => 'SUM', + self::AVG => 'AVG', + self::MIN => 'MIN', + self::MAX => 'MAX', + }; + } +}