diff --git a/docs/examples/ViewsAndTriggersExample.php b/docs/examples/ViewsAndTriggersExample.php new file mode 100644 index 00000000..1c381735 --- /dev/null +++ b/docs/examples/ViewsAndTriggersExample.php @@ -0,0 +1,149 @@ +table('users'); + $users->addColumn('username', 'string', ['limit' => 100]) + ->addColumn('email', 'string', ['limit' => 255]) + ->addColumn('status', 'string', ['limit' => 20, 'default' => 'active']) + ->addColumn('created', 'datetime') + ->create(); + + // Create a posts table + $posts = $this->table('posts'); + $posts->addColumn('user_id', 'integer') + ->addColumn('title', 'string', ['limit' => 255]) + ->addColumn('body', 'text') + ->addColumn('published', 'boolean', ['default' => false]) + ->addColumn('created', 'datetime') + ->addForeignKey('user_id', 'users', 'id', ['delete' => 'CASCADE']) + ->create(); + + // Create a view showing active users with their post counts + // Note: Views are created through a dummy table object + $this->createView( + 'active_users_with_posts', + 'SELECT u.id, u.username, u.email, COUNT(p.id) as post_count + FROM users u + LEFT JOIN posts p ON u.id = p.user_id + WHERE u.status = \'active\' + GROUP BY u.id, u.username, u.email' + ); + + // Create a materialized view (PostgreSQL only) + // On other databases, this will create a regular view + $this->createView( + 'published_posts_summary', + 'SELECT user_id, COUNT(*) as published_count + FROM posts + WHERE published = 1 + GROUP BY user_id', + ['materialized' => true] + ); + + // Create an audit log table for triggers + $auditLog = $this->table('audit_log'); + $auditLog->addColumn('table_name', 'string', ['limit' => 100]) + ->addColumn('action', 'string', ['limit' => 20]) + ->addColumn('record_id', 'integer') + ->addColumn('created', 'datetime') + ->create(); + + // Create a trigger to log user insertions + // Note: The trigger definition syntax varies by database + + // For MySQL: + $this->createTrigger( + 'users', + 'log_user_insert', + 'INSERT', + "INSERT INTO audit_log (table_name, action, record_id, created) + VALUES ('users', 'INSERT', NEW.id, NOW())", + ['timing' => 'AFTER'] + ); + + // For PostgreSQL, you would need to create a function first: + // $this->execute(" + // CREATE OR REPLACE FUNCTION log_user_insert_func() + // RETURNS TRIGGER AS $$ + // BEGIN + // INSERT INTO audit_log (table_name, action, record_id, created) + // VALUES ('users', 'INSERT', NEW.id, NOW()); + // RETURN NEW; + // END; + // $$ LANGUAGE plpgsql; + // "); + // + // $this->createTrigger( + // 'users', + // 'log_user_insert', + // 'INSERT', + // 'log_user_insert_func()', // Function name for PostgreSQL + // ['timing' => 'AFTER'] + // ); + + // Create a trigger for updates with multiple events + $this->createTrigger( + 'posts', + 'log_post_changes', + ['UPDATE', 'DELETE'], + "INSERT INTO audit_log (table_name, action, record_id, created) + VALUES ('posts', 'CHANGE', OLD.id, NOW())", + ['timing' => 'BEFORE'] + ); + } + + /** + * Migrate Up. + * + * If you need more control, you can use up() and down() methods instead. + */ + public function up(): void + { + // Example of creating a view in up() method + $this->createView( + 'simple_user_list', + 'SELECT id, username FROM users' + ); + } + + /** + * Migrate Down. + */ + public function down(): void + { + // Drop views + $this->dropView('simple_user_list'); + $this->dropView('active_users_with_posts'); + $this->dropView('published_posts_summary', ['materialized' => true]); + + // Drop triggers + $this->dropTrigger('users', 'log_user_insert'); + $this->dropTrigger('posts', 'log_post_changes'); + + // Drop tables + $this->table('audit_log')->drop()->save(); + $this->table('posts')->drop()->save(); + $this->table('users')->drop()->save(); + } +} diff --git a/src/BaseMigration.php b/src/BaseMigration.php index b5df6c62..18e02a17 100644 --- a/src/BaseMigration.php +++ b/src/BaseMigration.php @@ -498,6 +498,66 @@ public function shouldExecute(): bool return true; } + /** + * Creates a view. + * + * This is a convenience method that creates a dummy table to associate the view with. + * Views are not directly associated with tables, but the Table class is used to + * manage the migration actions. + * + * @param string $viewName View name + * @param string $definition SQL SELECT statement for the view + * @param array $options View options + * @return void + */ + public function createView(string $viewName, string $definition, array $options = []): void + { + $table = $this->table($viewName); + $table->createView($viewName, $definition, $options)->save(); + } + + /** + * Drops a view. + * + * @param string $viewName View name + * @param array $options View options + * @return void + */ + public function dropView(string $viewName, array $options = []): void + { + $table = $this->table($viewName); + $table->dropView($viewName, $options)->save(); + } + + /** + * Creates a trigger on a table. + * + * @param string $tableName Table name + * @param string $triggerName Trigger name + * @param string|array $event Event(s) that fire the trigger (INSERT, UPDATE, DELETE) + * @param string $definition Trigger body/definition + * @param array $options Trigger options + * @return void + */ + public function createTrigger(string $tableName, string $triggerName, string|array $event, string $definition, array $options = []): void + { + $table = $this->table($tableName); + $table->createTrigger($triggerName, $event, $definition, $options)->save(); + } + + /** + * Drops a trigger from a table. + * + * @param string $tableName Table name + * @param string $triggerName Trigger name + * @return void + */ + public function dropTrigger(string $tableName, string $triggerName): void + { + $table = $this->table($tableName); + $table->dropTrigger($triggerName)->save(); + } + /** * Makes sure the version int is within range for valid datetime. * This is required to have a meaningful order in the overview. diff --git a/src/Db/Action/CreateTrigger.php b/src/Db/Action/CreateTrigger.php new file mode 100644 index 00000000..fdb592f8 --- /dev/null +++ b/src/Db/Action/CreateTrigger.php @@ -0,0 +1,38 @@ +trigger; + } +} diff --git a/src/Db/Action/CreateView.php b/src/Db/Action/CreateView.php new file mode 100644 index 00000000..51ed0d5d --- /dev/null +++ b/src/Db/Action/CreateView.php @@ -0,0 +1,38 @@ +view; + } +} diff --git a/src/Db/Action/DropTrigger.php b/src/Db/Action/DropTrigger.php new file mode 100644 index 00000000..2077c9dd --- /dev/null +++ b/src/Db/Action/DropTrigger.php @@ -0,0 +1,37 @@ +triggerName; + } +} diff --git a/src/Db/Action/DropView.php b/src/Db/Action/DropView.php new file mode 100644 index 00000000..eba4324e --- /dev/null +++ b/src/Db/Action/DropView.php @@ -0,0 +1,49 @@ +viewName; + } + + /** + * Gets whether this is a materialized view + * + * @return bool + */ + public function getMaterialized(): bool + { + return $this->materialized; + } +} diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index f9f652cc..a9098c0b 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -30,10 +30,14 @@ use Migrations\Db\Action\ChangeColumn; use Migrations\Db\Action\ChangeComment; use Migrations\Db\Action\ChangePrimaryKey; +use Migrations\Db\Action\CreateTrigger; +use Migrations\Db\Action\CreateView; use Migrations\Db\Action\DropForeignKey; use Migrations\Db\Action\DropIndex; use Migrations\Db\Action\DropPartition; use Migrations\Db\Action\DropTable; +use Migrations\Db\Action\DropTrigger; +use Migrations\Db\Action\DropView; use Migrations\Db\Action\RemoveColumn; use Migrations\Db\Action\RenameColumn; use Migrations\Db\Action\RenameTable; @@ -47,6 +51,8 @@ use Migrations\Db\Table\Index; use Migrations\Db\Table\PartitionDefinition; use Migrations\Db\Table\TableMetadata; +use Migrations\Db\Table\Trigger; +use Migrations\Db\Table\View; use Migrations\MigrationInterface; use Migrations\SeedInterface; use PDOException; @@ -1646,6 +1652,41 @@ public function changeComment(TableMetadata $table, $newComment): void */ abstract protected function getChangeCommentInstructions(TableMetadata $table, ?string $newComment): AlterInstructions; + /** + * Returns the instructions to create a view. + * + * @param \Migrations\Db\Table\View $view The view to create + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getCreateViewInstructions(View $view): AlterInstructions; + + /** + * Returns the instructions to drop a view. + * + * @param string $viewName The name of the view to drop + * @param bool $materialized Whether this is a materialized view (PostgreSQL only) + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropViewInstructions(string $viewName, bool $materialized = false): AlterInstructions; + + /** + * Returns the instructions to create a trigger. + * + * @param string $tableName The name of the table for the trigger + * @param \Migrations\Db\Table\Trigger $trigger The trigger to create + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getCreateTriggerInstructions(string $tableName, Trigger $trigger): AlterInstructions; + + /** + * Returns the instructions to drop a trigger. + * + * @param string $tableName The name of the table for the trigger + * @param string $triggerName The name of the trigger to drop + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropTriggerInstructions(string $tableName, string $triggerName): AlterInstructions; + /** * {@inheritDoc} * @@ -1778,6 +1819,35 @@ public function executeActions(TableMetadata $table, array $actions): void )); break; + case $action instanceof CreateView: + /** @var \Migrations\Db\Action\CreateView $action */ + $instructions->merge($this->getCreateViewInstructions($action->getView())); + break; + + case $action instanceof DropView: + /** @var \Migrations\Db\Action\DropView $action */ + $instructions->merge($this->getDropViewInstructions( + $action->getViewName(), + $action->getMaterialized(), + )); + break; + + case $action instanceof CreateTrigger: + /** @var \Migrations\Db\Action\CreateTrigger $action */ + $instructions->merge($this->getCreateTriggerInstructions( + $table->getName(), + $action->getTrigger(), + )); + break; + + case $action instanceof DropTrigger: + /** @var \Migrations\Db\Action\DropTrigger $action */ + $instructions->merge($this->getDropTriggerInstructions( + $table->getName(), + $action->getTriggerName(), + )); + break; + default: throw new InvalidArgumentException( sprintf("Don't know how to execute action `%s`", get_class($action)), diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index 8e2ec4a4..fdcbe5f8 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -23,6 +23,8 @@ use Migrations\Db\Table\Partition; use Migrations\Db\Table\PartitionDefinition; use Migrations\Db\Table\TableMetadata; +use Migrations\Db\Table\Trigger; +use Migrations\Db\Table\View; /** * MySQL Adapter. @@ -1509,4 +1511,65 @@ protected function executeAlterSteps(string $tableName, AlterInstructions $instr $this->execute($instruction); } } + + /** + * {@inheritDoc} + */ + protected function getCreateViewInstructions(View $view): AlterInstructions + { + $sql = sprintf( + 'CREATE %sVIEW %s AS %s', + $view->getReplace() ? 'OR REPLACE ' : '', + $this->quoteTableName($view->getName()), + $view->getDefinition(), + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + */ + protected function getDropViewInstructions(string $viewName, bool $materialized = false): AlterInstructions + { + $sql = sprintf( + 'DROP VIEW IF EXISTS %s', + $this->quoteTableName($viewName), + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + */ + protected function getCreateTriggerInstructions(string $tableName, Trigger $trigger): AlterInstructions + { + $events = is_array($trigger->getEvent()) ? $trigger->getEvent() : [$trigger->getEvent()]; + $eventStr = implode(' OR ', $events); + + $sql = sprintf( + 'CREATE TRIGGER %s %s %s ON %s FOR EACH ROW %s', + $this->quoteColumnName($trigger->getName()), + $trigger->getTiming(), + $eventStr, + $this->quoteTableName($tableName), + $trigger->getDefinition(), + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + */ + protected function getDropTriggerInstructions(string $tableName, string $triggerName): AlterInstructions + { + $sql = sprintf( + 'DROP TRIGGER IF EXISTS %s', + $this->quoteColumnName($triggerName), + ); + + return new AlterInstructions([], [$sql]); + } } diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php index f45ef186..5876c9a9 100644 --- a/src/Db/Adapter/PostgresAdapter.php +++ b/src/Db/Adapter/PostgresAdapter.php @@ -23,6 +23,8 @@ use Migrations\Db\Table\Partition; use Migrations\Db\Table\PartitionDefinition; use Migrations\Db\Table\TableMetadata; +use Migrations\Db\Table\Trigger; +use Migrations\Db\Table\View; use RuntimeException; class PostgresAdapter extends AbstractAdapter @@ -1515,4 +1517,79 @@ public function getAdapterType(): string // compatibility. return 'pgsql'; } + + /** + * {@inheritDoc} + */ + protected function getCreateViewInstructions(View $view): AlterInstructions + { + $viewType = $view->getMaterialized() ? 'MATERIALIZED VIEW' : 'VIEW'; + $replace = ''; + + if ($view->getReplace() && !$view->getMaterialized()) { + $replace = 'OR REPLACE '; + } + + $sql = sprintf( + 'CREATE %s%s %s AS %s', + $replace, + $viewType, + $this->quoteTableName($view->getName()), + $view->getDefinition(), + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + */ + protected function getDropViewInstructions(string $viewName, bool $materialized = false): AlterInstructions + { + $viewType = $materialized ? 'MATERIALIZED VIEW' : 'VIEW'; + $sql = sprintf( + 'DROP %s IF EXISTS %s', + $viewType, + $this->quoteTableName($viewName), + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + */ + protected function getCreateTriggerInstructions(string $tableName, Trigger $trigger): AlterInstructions + { + $events = is_array($trigger->getEvent()) ? $trigger->getEvent() : [$trigger->getEvent()]; + $eventStr = implode(' OR ', $events); + + $forEach = $trigger->getForEach() ? 'FOR EACH ROW' : 'FOR EACH STATEMENT'; + + $sql = sprintf( + 'CREATE TRIGGER %s %s %s ON %s %s EXECUTE FUNCTION %s', + $this->quoteColumnName($trigger->getName()), + $trigger->getTiming(), + $eventStr, + $this->quoteTableName($tableName), + $forEach, + $trigger->getDefinition(), + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + */ + protected function getDropTriggerInstructions(string $tableName, string $triggerName): AlterInstructions + { + $sql = sprintf( + 'DROP TRIGGER IF EXISTS %s ON %s', + $this->quoteColumnName($triggerName), + $this->quoteTableName($tableName), + ); + + return new AlterInstructions([], [$sql]); + } } diff --git a/src/Db/Adapter/SqliteAdapter.php b/src/Db/Adapter/SqliteAdapter.php index ae2ff599..ac562067 100644 --- a/src/Db/Adapter/SqliteAdapter.php +++ b/src/Db/Adapter/SqliteAdapter.php @@ -21,6 +21,8 @@ use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; use Migrations\Db\Table\TableMetadata; +use Migrations\Db\Table\Trigger; +use Migrations\Db\Table\View; use PDOException; use RuntimeException; use const FILTER_VALIDATE_BOOLEAN; @@ -1733,4 +1735,67 @@ protected function getUpsertClause(?InsertMode $mode, ?array $updateColumns, ?ar return ' ON CONFLICT (' . implode(', ', $quotedConflictColumns) . ') DO UPDATE SET ' . implode(', ', $updates); } + + /** + * {@inheritDoc} + */ + protected function getCreateViewInstructions(View $view): AlterInstructions + { + $sql = sprintf( + 'CREATE VIEW IF NOT EXISTS %s AS %s', + $this->quoteTableName($view->getName()), + $view->getDefinition(), + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + */ + protected function getDropViewInstructions(string $viewName, bool $materialized = false): AlterInstructions + { + $sql = sprintf( + 'DROP VIEW IF EXISTS %s', + $this->quoteTableName($viewName), + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + */ + protected function getCreateTriggerInstructions(string $tableName, Trigger $trigger): AlterInstructions + { + $events = is_array($trigger->getEvent()) ? $trigger->getEvent() : [$trigger->getEvent()]; + $eventStr = implode(' OR ', $events); + + $forEach = $trigger->getForEach() ? 'FOR EACH ROW' : ''; + + $sql = sprintf( + 'CREATE TRIGGER %s %s %s ON %s %s BEGIN %s END', + $this->quoteColumnName($trigger->getName()), + $trigger->getTiming(), + $eventStr, + $this->quoteTableName($tableName), + $forEach, + $trigger->getDefinition(), + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + */ + protected function getDropTriggerInstructions(string $tableName, string $triggerName): AlterInstructions + { + $sql = sprintf( + 'DROP TRIGGER IF EXISTS %s', + $this->quoteColumnName($triggerName), + ); + + return new AlterInstructions([], [$sql]); + } } diff --git a/src/Db/Adapter/SqlserverAdapter.php b/src/Db/Adapter/SqlserverAdapter.php index 14602abc..a58b5adb 100644 --- a/src/Db/Adapter/SqlserverAdapter.php +++ b/src/Db/Adapter/SqlserverAdapter.php @@ -21,6 +21,8 @@ use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; use Migrations\Db\Table\TableMetadata; +use Migrations\Db\Table\Trigger; +use Migrations\Db\Table\View; use Migrations\MigrationInterface; /** @@ -1139,4 +1141,76 @@ protected function getInsertPrefix(?InsertMode $mode = null): string return parent::getInsertPrefix($mode); } + + /** + * {@inheritDoc} + */ + protected function getCreateViewInstructions(View $view): AlterInstructions + { + $drop = ''; + if ($view->getReplace()) { + $drop = sprintf( + "IF OBJECT_ID('%s', 'V') IS NOT NULL DROP VIEW %s; ", + $view->getName(), + $this->quoteTableName($view->getName()), + ); + } + + $sql = sprintf( + '%sCREATE VIEW %s AS %s', + $drop, + $this->quoteTableName($view->getName()), + $view->getDefinition(), + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + */ + protected function getDropViewInstructions(string $viewName, bool $materialized = false): AlterInstructions + { + $sql = sprintf( + "IF OBJECT_ID('%s', 'V') IS NOT NULL DROP VIEW %s", + $viewName, + $this->quoteTableName($viewName), + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + */ + protected function getCreateTriggerInstructions(string $tableName, Trigger $trigger): AlterInstructions + { + $events = is_array($trigger->getEvent()) ? $trigger->getEvent() : [$trigger->getEvent()]; + $eventStr = implode(', ', $events); + + $sql = sprintf( + 'CREATE TRIGGER %s ON %s %s %s AS %s', + $this->quoteColumnName($trigger->getName()), + $this->quoteTableName($tableName), + $trigger->getTiming(), + $eventStr, + $trigger->getDefinition(), + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + */ + protected function getDropTriggerInstructions(string $tableName, string $triggerName): AlterInstructions + { + $sql = sprintf( + "IF OBJECT_ID('%s', 'TR') IS NOT NULL DROP TRIGGER %s", + $triggerName, + $this->quoteColumnName($triggerName), + ); + + return new AlterInstructions([], [$sql]); + } } diff --git a/src/Db/Plan/Plan.php b/src/Db/Plan/Plan.php index f2c36fe7..0d7e65b9 100644 --- a/src/Db/Plan/Plan.php +++ b/src/Db/Plan/Plan.php @@ -16,9 +16,13 @@ use Migrations\Db\Action\ChangeComment; use Migrations\Db\Action\ChangePrimaryKey; use Migrations\Db\Action\CreateTable; +use Migrations\Db\Action\CreateTrigger; +use Migrations\Db\Action\CreateView; use Migrations\Db\Action\DropForeignKey; use Migrations\Db\Action\DropIndex; use Migrations\Db\Action\DropTable; +use Migrations\Db\Action\DropTrigger; +use Migrations\Db\Action\DropView; use Migrations\Db\Action\RemoveColumn; use Migrations\Db\Action\RenameColumn; use Migrations\Db\Action\RenameTable; @@ -77,6 +81,13 @@ class Plan */ protected array $columnRemoves = []; + /** + * List of view and trigger operations + * + * @var \Migrations\Db\Plan\AlterTable[] + */ + protected array $viewsAndTriggers = []; + /** * Constructor * @@ -100,6 +111,7 @@ protected function createPlan(array $actions): void $this->gatherTableMoves($actions); $this->gatherIndexes($actions); $this->gatherConstraints($actions); + $this->gatherViewsAndTriggers($actions); $this->resolveConflicts(); } @@ -114,6 +126,7 @@ protected function updatesSequence(): array $this->tableUpdates, $this->constraints, $this->indexes, + $this->viewsAndTriggers, $this->columnRemoves, $this->tableMoves, ]; @@ -129,6 +142,7 @@ protected function inverseUpdatesSequence(): array return [ $this->constraints, $this->tableMoves, + $this->viewsAndTriggers, $this->indexes, $this->columnRemoves, $this->tableUpdates, @@ -490,4 +504,32 @@ protected function gatherConstraints(array $actions): void $this->constraints[$name]->addAction($action); } } + + /** + * Collects all view and trigger creation and drops from the given intent + * + * @param \Migrations\Db\Action\Action[] $actions The actions to parse + * @return void + */ + protected function gatherViewsAndTriggers(array $actions): void + { + foreach ($actions as $action) { + if ( + !($action instanceof CreateView) + && !($action instanceof DropView) + && !($action instanceof CreateTrigger) + && !($action instanceof DropTrigger) + ) { + continue; + } + $table = $action->getTable(); + $name = $table->getName(); + + if (!isset($this->viewsAndTriggers[$name])) { + $this->viewsAndTriggers[$name] = new AlterTable($table); + } + + $this->viewsAndTriggers[$name]->addAction($action); + } + } } diff --git a/src/Db/Table.php b/src/Db/Table.php index fd7a0a7a..0efad2b0 100644 --- a/src/Db/Table.php +++ b/src/Db/Table.php @@ -19,10 +19,14 @@ use Migrations\Db\Action\ChangeComment; use Migrations\Db\Action\ChangePrimaryKey; use Migrations\Db\Action\CreateTable; +use Migrations\Db\Action\CreateTrigger; +use Migrations\Db\Action\CreateView; use Migrations\Db\Action\DropForeignKey; use Migrations\Db\Action\DropIndex; use Migrations\Db\Action\DropPartition; use Migrations\Db\Action\DropTable; +use Migrations\Db\Action\DropTrigger; +use Migrations\Db\Action\DropView; use Migrations\Db\Action\RemoveColumn; use Migrations\Db\Action\RenameColumn; use Migrations\Db\Action\RenameTable; @@ -997,6 +1001,87 @@ public function save(): void } } + /** + * Creates a view. + * + * @param string $viewName View name + * @param string $definition SQL SELECT statement for the view + * @param array $options View options + * @return $this + */ + public function createView(string $viewName, string $definition, array $options = []) + { + $view = new Table\View( + $viewName, + $definition, + $options['replace'] ?? false, + $options['materialized'] ?? false, + ); + + $action = new Action\CreateView($this->table, $view); + $this->actions->addAction($action); + + return $this; + } + + /** + * Drops a view. + * + * @param string $viewName View name + * @param array $options View options + * @return $this + */ + public function dropView(string $viewName, array $options = []) + { + $action = new Action\DropView( + $this->table, + $viewName, + $options['materialized'] ?? false, + ); + $this->actions->addAction($action); + + return $this; + } + + /** + * Creates a trigger on this table. + * + * @param string $triggerName Trigger name + * @param string|array $event Event(s) that fire the trigger (INSERT, UPDATE, DELETE) + * @param string $definition Trigger body/definition + * @param array $options Trigger options + * @return $this + */ + public function createTrigger(string $triggerName, string|array $event, string $definition, array $options = []) + { + $trigger = new Table\Trigger( + $triggerName, + $options['timing'] ?? Table\Trigger::BEFORE, + $event, + $definition, + $options['forEach'] ?? true, + ); + + $action = new Action\CreateTrigger($this->table, $trigger); + $this->actions->addAction($action); + + return $this; + } + + /** + * Drops a trigger from this table. + * + * @param string $triggerName Trigger name + * @return $this + */ + public function dropTrigger(string $triggerName) + { + $action = new Action\DropTrigger($this->table, $triggerName); + $this->actions->addAction($action); + + return $this; + } + /** * Executes all the pending actions for this table * @@ -1018,9 +1103,29 @@ protected function executeActions(bool $exists): void } // If the table does not exist, the last command in the chain needs to be - // a CreateTable action. + // a CreateTable action - unless we're ONLY creating views/triggers. if (!$exists) { - $this->actions->addAction(new CreateTable($this->table)); + $actions = $this->actions->getActions(); + $hasTableActions = false; + $hasViewOrTriggerActions = false; + + foreach ($actions as $action) { + if ( + $action instanceof CreateView + || $action instanceof DropView + || $action instanceof CreateTrigger + || $action instanceof DropTrigger + ) { + $hasViewOrTriggerActions = true; + } else { + $hasTableActions = true; + } + } + + // Only skip CreateTable if we have ONLY view/trigger actions (and at least one) + if (!$hasViewOrTriggerActions || $hasTableActions || count($actions) === 0) { + $this->actions->addAction(new CreateTable($this->table)); + } } $plan = new Plan($this->actions); diff --git a/src/Db/Table/Trigger.php b/src/Db/Table/Trigger.php new file mode 100644 index 00000000..a4e6286c --- /dev/null +++ b/src/Db/Table/Trigger.php @@ -0,0 +1,183 @@ + $event The event(s) that fire the trigger (INSERT, UPDATE, DELETE). + * @param string $definition The trigger body/definition. + * @param bool $forEach Whether to fire for each row (true) or statement (false). + */ + public function __construct( + protected string $name = '', + protected string $timing = self::BEFORE, + protected string|array $event = self::INSERT, + protected string $definition = '', + protected bool $forEach = true, + ) { + } + + /** + * Sets the trigger name. + * + * @param string $name Name + * @return $this + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Gets the trigger name. + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Sets the trigger timing (BEFORE, AFTER, or INSTEAD OF). + * + * @param string $timing Timing + * @return $this + */ + public function setTiming(string $timing) + { + $this->timing = $timing; + + return $this; + } + + /** + * Gets the trigger timing. + * + * @return string + */ + public function getTiming(): string + { + return $this->timing; + } + + /** + * Sets the trigger event(s). + * + * @param string|array $event Event(s) + * @return $this + */ + public function setEvent(string|array $event) + { + $this->event = $event; + + return $this; + } + + /** + * Gets the trigger event(s). + * + * @return string|array + */ + public function getEvent(): string|array + { + return $this->event; + } + + /** + * Sets the trigger definition/body. + * + * @param string $definition Definition + * @return $this + */ + public function setDefinition(string $definition) + { + $this->definition = $definition; + + return $this; + } + + /** + * Gets the trigger definition. + * + * @return string + */ + public function getDefinition(): string + { + return $this->definition; + } + + /** + * Sets whether to fire for each row (true) or per statement (false). + * + * @param bool $forEach For each row flag + * @return $this + */ + public function setForEach(bool $forEach) + { + $this->forEach = $forEach; + + return $this; + } + + /** + * Gets whether to fire for each row. + * + * @return bool + */ + public function getForEach(): bool + { + return $this->forEach; + } +} diff --git a/src/Db/Table/View.php b/src/Db/Table/View.php new file mode 100644 index 00000000..ac45da39 --- /dev/null +++ b/src/Db/Table/View.php @@ -0,0 +1,128 @@ +name = $name; + + return $this; + } + + /** + * Gets the view name. + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Sets the view definition (SELECT statement). + * + * @param string $definition Definition + * @return $this + */ + public function setDefinition(string $definition) + { + $this->definition = $definition; + + return $this; + } + + /** + * Gets the view definition. + * + * @return string + */ + public function getDefinition(): string + { + return $this->definition; + } + + /** + * Sets whether to replace the view if it exists. + * + * @param bool $replace Replace flag + * @return $this + */ + public function setReplace(bool $replace) + { + $this->replace = $replace; + + return $this; + } + + /** + * Gets whether to replace the view if it exists. + * + * @return bool + */ + public function getReplace(): bool + { + return $this->replace; + } + + /** + * Sets whether this is a materialized view (PostgreSQL only). + * + * @param bool $materialized Materialized flag + * @return $this + */ + public function setMaterialized(bool $materialized) + { + $this->materialized = $materialized; + + return $this; + } + + /** + * Gets whether this is a materialized view. + * + * @return bool + */ + public function getMaterialized(): bool + { + return $this->materialized; + } +} diff --git a/tests/TestCase/Db/Adapter/DefaultAdapterTrait.php b/tests/TestCase/Db/Adapter/DefaultAdapterTrait.php index 61543f28..e92a5965 100644 --- a/tests/TestCase/Db/Adapter/DefaultAdapterTrait.php +++ b/tests/TestCase/Db/Adapter/DefaultAdapterTrait.php @@ -9,6 +9,8 @@ use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; use Migrations\Db\Table\TableMetadata; +use Migrations\Db\Table\Trigger; +use Migrations\Db\Table\View; trait DefaultAdapterTrait { @@ -187,4 +189,24 @@ protected function getDropCheckConstraintInstructions(string $tableName, string { return new AlterInstructions(); } + + protected function getCreateViewInstructions(View $view): AlterInstructions + { + return new AlterInstructions(); + } + + protected function getDropViewInstructions(string $viewName, bool $materialized = false): AlterInstructions + { + return new AlterInstructions(); + } + + protected function getCreateTriggerInstructions(string $tableName, Trigger $trigger): AlterInstructions + { + return new AlterInstructions(); + } + + protected function getDropTriggerInstructions(string $tableName, string $triggerName): AlterInstructions + { + return new AlterInstructions(); + } } diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 1dce2e49..c6ea2fee 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -2790,7 +2790,111 @@ public function testInsertOrSkipWithoutDuplicates() $this->assertCount(2, $rows); } - public function testAddColumnWithAlgorithmInstant() + public function testCreateView(): void + { + // Create a base table + $table = new Table('users', [], $this->adapter); + $table->addColumn('name', 'string') + ->addColumn('email', 'string') + ->create(); + + // Insert some data + $table->insert([ + ['name' => 'Alice', 'email' => 'alice@example.com'], + ['name' => 'Bob', 'email' => 'bob@example.com'], + ])->save(); + + // Create a view + $viewTable = new Table('user_emails', [], $this->adapter); + $viewTable->createView('user_emails', 'SELECT name, email FROM users') + ->save(); + + // Query the view + $rows = $this->adapter->fetchAll('SELECT * FROM user_emails'); + $this->assertCount(2, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('alice@example.com', $rows[0]['email']); + } + + public function testDropView(): void + { + // Create a base table + $table = new Table('users', [], $this->adapter); + $table->addColumn('name', 'string')->create(); + + // Create a view + $viewTable = new Table('user_names', [], $this->adapter); + $viewTable->createView('user_names', 'SELECT name FROM users')->save(); + + // Verify view exists + $rows = $this->adapter->fetchAll('SELECT * FROM user_names'); + $this->assertIsArray($rows); + + // Drop the view + $viewTable->dropView('user_names')->save(); + + // Verify view is dropped + $this->expectException(PDOException::class); + $this->adapter->fetchAll('SELECT * FROM user_names'); + } + + public function testCreateTrigger(): void + { + // Create tables + $table = new Table('users', [], $this->adapter); + $table->addColumn('name', 'string') + ->addColumn('created_count', 'integer', ['default' => 0]) + ->create(); + + $logTable = new Table('user_log', [], $this->adapter); + $logTable->addColumn('action', 'string')->create(); + + // Create a trigger + $table->createTrigger( + 'log_user_insert', + 'INSERT', + "INSERT INTO user_log (action) VALUES ('user_created')", + ['timing' => 'AFTER'], + )->save(); + + // Insert data to trigger the trigger + $table->insert(['name' => 'Alice', 'created_count' => 0])->save(); + + // Verify trigger fired + $rows = $this->adapter->fetchAll('SELECT * FROM user_log'); + $this->assertCount(1, $rows); + $this->assertEquals('user_created', $rows[0]['action']); + } + + public function testDropTrigger(): void + { + // Create table + $table = new Table('users', [], $this->adapter); + $table->addColumn('name', 'string')->create(); + + $logTable = new Table('user_log', [], $this->adapter); + $logTable->addColumn('action', 'string')->create(); + + // Create a trigger + $table->createTrigger( + 'log_user_insert', + 'INSERT', + "INSERT INTO user_log (action) VALUES ('user_created')", + ['timing' => 'AFTER'], + )->save(); + + // Drop the trigger + $table->dropTrigger('log_user_insert')->save(); + + // Insert data - trigger should not fire + $table->insert(['name' => 'Bob'])->save(); + + // Verify trigger did not fire + $rows = $this->adapter->fetchAll('SELECT * FROM user_log'); + $this->assertCount(0, $rows); + } + + public function testAddColumnWithAlgorithmInstant(): void { $table = new Table('users', [], $this->adapter); $table->addColumn('email', 'string') @@ -2804,7 +2908,7 @@ public function testAddColumnWithAlgorithmInstant() $this->assertTrue($this->adapter->hasColumn('users', 'status')); } - public function testAddColumnWithAlgorithmAndLock() + public function testAddColumnWithAlgorithmAndLock(): void { $table = new Table('products', [], $this->adapter); $table->addColumn('name', 'string') @@ -2822,7 +2926,7 @@ public function testAddColumnWithAlgorithmAndLock() $this->assertTrue($this->adapter->hasColumn('products', 'price')); } - public function testChangeColumnWithAlgorithm() + public function testChangeColumnWithAlgorithm(): void { $table = new Table('items', [], $this->adapter); $table->addColumn('description', 'string', ['limit' => 100]) @@ -2842,7 +2946,7 @@ public function testChangeColumnWithAlgorithm() } } - public function testBatchedOperationsWithSameAlgorithm() + public function testBatchedOperationsWithSameAlgorithm(): void { $table = new Table('batch_test', [], $this->adapter); $table->addColumn('col1', 'string') @@ -2862,7 +2966,7 @@ public function testBatchedOperationsWithSameAlgorithm() $this->assertTrue($this->adapter->hasColumn('batch_test', 'col3')); } - public function testBatchedOperationsWithConflictingAlgorithmsThrowsException() + public function testBatchedOperationsWithConflictingAlgorithmsThrowsException(): void { $table = new Table('conflict_test', [], $this->adapter); $table->addColumn('col1', 'string') @@ -2882,7 +2986,7 @@ public function testBatchedOperationsWithConflictingAlgorithmsThrowsException() ->update(); } - public function testBatchedOperationsWithConflictingLocksThrowsException() + public function testBatchedOperationsWithConflictingLocksThrowsException(): void { $table = new Table('lock_conflict_test', [], $this->adapter); $table->addColumn('col1', 'string') @@ -2904,7 +3008,7 @@ public function testBatchedOperationsWithConflictingLocksThrowsException() ->update(); } - public function testInvalidAlgorithmThrowsException() + public function testInvalidAlgorithmThrowsException(): void { $table = new Table('invalid_algo', [], $this->adapter); $table->addColumn('col1', 'string') @@ -2918,7 +3022,7 @@ public function testInvalidAlgorithmThrowsException() ])->update(); } - public function testInvalidLockThrowsException() + public function testInvalidLockThrowsException(): void { $table = new Table('invalid_lock', [], $this->adapter); $table->addColumn('col1', 'string') @@ -2932,7 +3036,7 @@ public function testInvalidLockThrowsException() ])->update(); } - public function testAlgorithmInstantWithExplicitLockThrowsException() + public function testAlgorithmInstantWithExplicitLockThrowsException(): void { $table = new Table('instant_lock_test', [], $this->adapter); $table->addColumn('col1', 'string') @@ -2948,7 +3052,7 @@ public function testAlgorithmInstantWithExplicitLockThrowsException() ])->update(); } - public function testAlgorithmConstantsAreDefined() + public function testAlgorithmConstantsAreDefined(): void { $this->assertEquals('DEFAULT', MysqlAdapter::ALGORITHM_DEFAULT); $this->assertEquals('INSTANT', MysqlAdapter::ALGORITHM_INSTANT); @@ -2956,7 +3060,7 @@ public function testAlgorithmConstantsAreDefined() $this->assertEquals('COPY', MysqlAdapter::ALGORITHM_COPY); } - public function testLockConstantsAreDefined() + public function testLockConstantsAreDefined(): void { $this->assertEquals('DEFAULT', MysqlAdapter::LOCK_DEFAULT); $this->assertEquals('NONE', MysqlAdapter::LOCK_NONE); @@ -2964,7 +3068,7 @@ public function testLockConstantsAreDefined() $this->assertEquals('EXCLUSIVE', MysqlAdapter::LOCK_EXCLUSIVE); } - public function testAlgorithmWithMixedCase() + public function testAlgorithmWithMixedCase(): void { $table = new Table('mixed_case', [], $this->adapter); $table->addColumn('col1', 'string')