Skip to content

Commit 5abc573

Browse files
authored
Add support for database views and triggers (#957)
* Add support for database views and triggers (#347) This implements support for creating and managing database views and triggers through CakePHP migrations, addressing issue #347. - Create and drop database views in migrations - Support for OR REPLACE syntax (MySQL, PostgreSQL) - Materialized views support (PostgreSQL only) - Database-agnostic API with adapter-specific implementations - Create and drop database triggers in migrations - Support for BEFORE/AFTER/INSTEAD OF timing - Support for INSERT/UPDATE/DELETE events - Support for multiple events per trigger - FOR EACH ROW vs FOR EACH STATEMENT options **Value Objects:** - `Migrations\Db\Table\View` - Represents a database view - `Migrations\Db\Table\Trigger` - Represents a database trigger **Actions:** - `Migrations\Db\Action\CreateView` - Action for creating views - `Migrations\Db\Action\DropView` - Action for dropping views - `Migrations\Db\Action\CreateTrigger` - Action for creating triggers - `Migrations\Db\Action\DropTrigger` - Action for dropping triggers **Core:** - `AbstractAdapter` - Added abstract methods for view/trigger support - `Table` - Added createView(), dropView(), createTrigger(), dropTrigger() - `BaseMigration` - Added convenience methods for easy migration usage **Adapters:** - `MysqlAdapter` - MySQL-specific view/trigger SQL generation - `PostgresAdapter` - PostgreSQL implementation with materialized views - `SqliteAdapter` - SQLite-specific syntax handling - `SqlserverAdapter` - SQL Server implementation ```php // Create a view $this->createView( 'active_users', 'SELECT * FROM users WHERE status = "active"' ); // Create a materialized view (PostgreSQL) $this->createView( 'user_stats', 'SELECT user_id, COUNT(*) FROM posts GROUP BY user_id', ['materialized' => true] ); // Create a trigger $this->createTrigger( 'users', 'log_changes', 'INSERT', "INSERT INTO audit_log VALUES (NEW.id, NOW())", ['timing' => 'AFTER'] ); // Drop view/trigger $this->dropView('active_users'); $this->dropTrigger('users', 'log_changes'); ``` Added comprehensive tests for view and trigger functionality in MysqlAdapterTest covering creation, querying, and deletion. Added example migration file demonstrating various view and trigger scenarios with database-specific considerations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix AbstractAdapterTest by adding stub implementations for view/trigger methods The anonymous test class extending AbstractAdapter needs to implement the new abstract methods for view and trigger support. * Fixes. * Fixes. * Fix missing imports and add return types after merge - Add missing `use Exception` and `use Migrations\Db\Table` imports to AbstractAdapter - Add `: void` return types to algorithm/lock test methods in MysqlAdapterTest - Fix alphabetical ordering of use statements
1 parent 9cdbada commit 5abc573

File tree

17 files changed

+1318
-14
lines changed

17 files changed

+1318
-14
lines changed
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
use Migrations\BaseMigration;
5+
6+
/**
7+
* Example migration demonstrating views and triggers support.
8+
*
9+
* This migration shows how to create and drop database views and triggers
10+
* using the CakePHP Migrations plugin.
11+
*/
12+
class ViewsAndTriggersExample extends BaseMigration
13+
{
14+
/**
15+
* Change Method.
16+
*
17+
* Write your reversible migrations using this method.
18+
*
19+
* More information on writing migrations is available here:
20+
* https://book.cakephp.org/migrations/4/en/index.html
21+
*/
22+
public function change(): void
23+
{
24+
// Create a users table
25+
$users = $this->table('users');
26+
$users->addColumn('username', 'string', ['limit' => 100])
27+
->addColumn('email', 'string', ['limit' => 255])
28+
->addColumn('status', 'string', ['limit' => 20, 'default' => 'active'])
29+
->addColumn('created', 'datetime')
30+
->create();
31+
32+
// Create a posts table
33+
$posts = $this->table('posts');
34+
$posts->addColumn('user_id', 'integer')
35+
->addColumn('title', 'string', ['limit' => 255])
36+
->addColumn('body', 'text')
37+
->addColumn('published', 'boolean', ['default' => false])
38+
->addColumn('created', 'datetime')
39+
->addForeignKey('user_id', 'users', 'id', ['delete' => 'CASCADE'])
40+
->create();
41+
42+
// Create a view showing active users with their post counts
43+
// Note: Views are created through a dummy table object
44+
$this->createView(
45+
'active_users_with_posts',
46+
'SELECT u.id, u.username, u.email, COUNT(p.id) as post_count
47+
FROM users u
48+
LEFT JOIN posts p ON u.id = p.user_id
49+
WHERE u.status = \'active\'
50+
GROUP BY u.id, u.username, u.email'
51+
);
52+
53+
// Create a materialized view (PostgreSQL only)
54+
// On other databases, this will create a regular view
55+
$this->createView(
56+
'published_posts_summary',
57+
'SELECT user_id, COUNT(*) as published_count
58+
FROM posts
59+
WHERE published = 1
60+
GROUP BY user_id',
61+
['materialized' => true]
62+
);
63+
64+
// Create an audit log table for triggers
65+
$auditLog = $this->table('audit_log');
66+
$auditLog->addColumn('table_name', 'string', ['limit' => 100])
67+
->addColumn('action', 'string', ['limit' => 20])
68+
->addColumn('record_id', 'integer')
69+
->addColumn('created', 'datetime')
70+
->create();
71+
72+
// Create a trigger to log user insertions
73+
// Note: The trigger definition syntax varies by database
74+
75+
// For MySQL:
76+
$this->createTrigger(
77+
'users',
78+
'log_user_insert',
79+
'INSERT',
80+
"INSERT INTO audit_log (table_name, action, record_id, created)
81+
VALUES ('users', 'INSERT', NEW.id, NOW())",
82+
['timing' => 'AFTER']
83+
);
84+
85+
// For PostgreSQL, you would need to create a function first:
86+
// $this->execute("
87+
// CREATE OR REPLACE FUNCTION log_user_insert_func()
88+
// RETURNS TRIGGER AS $$
89+
// BEGIN
90+
// INSERT INTO audit_log (table_name, action, record_id, created)
91+
// VALUES ('users', 'INSERT', NEW.id, NOW());
92+
// RETURN NEW;
93+
// END;
94+
// $$ LANGUAGE plpgsql;
95+
// ");
96+
//
97+
// $this->createTrigger(
98+
// 'users',
99+
// 'log_user_insert',
100+
// 'INSERT',
101+
// 'log_user_insert_func()', // Function name for PostgreSQL
102+
// ['timing' => 'AFTER']
103+
// );
104+
105+
// Create a trigger for updates with multiple events
106+
$this->createTrigger(
107+
'posts',
108+
'log_post_changes',
109+
['UPDATE', 'DELETE'],
110+
"INSERT INTO audit_log (table_name, action, record_id, created)
111+
VALUES ('posts', 'CHANGE', OLD.id, NOW())",
112+
['timing' => 'BEFORE']
113+
);
114+
}
115+
116+
/**
117+
* Migrate Up.
118+
*
119+
* If you need more control, you can use up() and down() methods instead.
120+
*/
121+
public function up(): void
122+
{
123+
// Example of creating a view in up() method
124+
$this->createView(
125+
'simple_user_list',
126+
'SELECT id, username FROM users'
127+
);
128+
}
129+
130+
/**
131+
* Migrate Down.
132+
*/
133+
public function down(): void
134+
{
135+
// Drop views
136+
$this->dropView('simple_user_list');
137+
$this->dropView('active_users_with_posts');
138+
$this->dropView('published_posts_summary', ['materialized' => true]);
139+
140+
// Drop triggers
141+
$this->dropTrigger('users', 'log_user_insert');
142+
$this->dropTrigger('posts', 'log_post_changes');
143+
144+
// Drop tables
145+
$this->table('audit_log')->drop()->save();
146+
$this->table('posts')->drop()->save();
147+
$this->table('users')->drop()->save();
148+
}
149+
}

src/BaseMigration.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,66 @@ public function shouldExecute(): bool
498498
return true;
499499
}
500500

501+
/**
502+
* Creates a view.
503+
*
504+
* This is a convenience method that creates a dummy table to associate the view with.
505+
* Views are not directly associated with tables, but the Table class is used to
506+
* manage the migration actions.
507+
*
508+
* @param string $viewName View name
509+
* @param string $definition SQL SELECT statement for the view
510+
* @param array<string, mixed> $options View options
511+
* @return void
512+
*/
513+
public function createView(string $viewName, string $definition, array $options = []): void
514+
{
515+
$table = $this->table($viewName);
516+
$table->createView($viewName, $definition, $options)->save();
517+
}
518+
519+
/**
520+
* Drops a view.
521+
*
522+
* @param string $viewName View name
523+
* @param array<string, mixed> $options View options
524+
* @return void
525+
*/
526+
public function dropView(string $viewName, array $options = []): void
527+
{
528+
$table = $this->table($viewName);
529+
$table->dropView($viewName, $options)->save();
530+
}
531+
532+
/**
533+
* Creates a trigger on a table.
534+
*
535+
* @param string $tableName Table name
536+
* @param string $triggerName Trigger name
537+
* @param string|array<string> $event Event(s) that fire the trigger (INSERT, UPDATE, DELETE)
538+
* @param string $definition Trigger body/definition
539+
* @param array<string, mixed> $options Trigger options
540+
* @return void
541+
*/
542+
public function createTrigger(string $tableName, string $triggerName, string|array $event, string $definition, array $options = []): void
543+
{
544+
$table = $this->table($tableName);
545+
$table->createTrigger($triggerName, $event, $definition, $options)->save();
546+
}
547+
548+
/**
549+
* Drops a trigger from a table.
550+
*
551+
* @param string $tableName Table name
552+
* @param string $triggerName Trigger name
553+
* @return void
554+
*/
555+
public function dropTrigger(string $tableName, string $triggerName): void
556+
{
557+
$table = $this->table($tableName);
558+
$table->dropTrigger($triggerName)->save();
559+
}
560+
501561
/**
502562
* Makes sure the version int is within range for valid datetime.
503563
* This is required to have a meaningful order in the overview.

src/Db/Action/CreateTrigger.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* MIT License
6+
* For full license information, please view the LICENSE file that was distributed with this source code.
7+
*/
8+
9+
namespace Migrations\Db\Action;
10+
11+
use Migrations\Db\Table\TableMetadata;
12+
use Migrations\Db\Table\Trigger;
13+
14+
class CreateTrigger extends Action
15+
{
16+
/**
17+
* Constructor
18+
*
19+
* @param \Migrations\Db\Table\TableMetadata $table The table metadata
20+
* @param \Migrations\Db\Table\Trigger $trigger The trigger to create
21+
*/
22+
public function __construct(
23+
TableMetadata $table,
24+
protected Trigger $trigger,
25+
) {
26+
parent::__construct($table);
27+
}
28+
29+
/**
30+
* Gets the trigger
31+
*
32+
* @return \Migrations\Db\Table\Trigger
33+
*/
34+
public function getTrigger(): Trigger
35+
{
36+
return $this->trigger;
37+
}
38+
}

src/Db/Action/CreateView.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* MIT License
6+
* For full license information, please view the LICENSE file that was distributed with this source code.
7+
*/
8+
9+
namespace Migrations\Db\Action;
10+
11+
use Migrations\Db\Table\TableMetadata;
12+
use Migrations\Db\Table\View;
13+
14+
class CreateView extends Action
15+
{
16+
/**
17+
* Constructor
18+
*
19+
* @param \Migrations\Db\Table\TableMetadata $table The table metadata
20+
* @param \Migrations\Db\Table\View $view The view to create
21+
*/
22+
public function __construct(
23+
TableMetadata $table,
24+
protected View $view,
25+
) {
26+
parent::__construct($table);
27+
}
28+
29+
/**
30+
* Gets the view
31+
*
32+
* @return \Migrations\Db\Table\View
33+
*/
34+
public function getView(): View
35+
{
36+
return $this->view;
37+
}
38+
}

src/Db/Action/DropTrigger.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* MIT License
6+
* For full license information, please view the LICENSE file that was distributed with this source code.
7+
*/
8+
9+
namespace Migrations\Db\Action;
10+
11+
use Migrations\Db\Table\TableMetadata;
12+
13+
class DropTrigger extends Action
14+
{
15+
/**
16+
* Constructor
17+
*
18+
* @param \Migrations\Db\Table\TableMetadata $table The table metadata
19+
* @param string $triggerName The name of the trigger to drop
20+
*/
21+
public function __construct(
22+
TableMetadata $table,
23+
protected string $triggerName,
24+
) {
25+
parent::__construct($table);
26+
}
27+
28+
/**
29+
* Gets the trigger name
30+
*
31+
* @return string
32+
*/
33+
public function getTriggerName(): string
34+
{
35+
return $this->triggerName;
36+
}
37+
}

src/Db/Action/DropView.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* MIT License
6+
* For full license information, please view the LICENSE file that was distributed with this source code.
7+
*/
8+
9+
namespace Migrations\Db\Action;
10+
11+
use Migrations\Db\Table\TableMetadata;
12+
13+
class DropView extends Action
14+
{
15+
/**
16+
* Constructor
17+
*
18+
* @param \Migrations\Db\Table\TableMetadata $table The table metadata
19+
* @param string $viewName The name of the view to drop
20+
* @param bool $materialized Whether this is a materialized view (PostgreSQL only)
21+
*/
22+
public function __construct(
23+
TableMetadata $table,
24+
protected string $viewName,
25+
protected bool $materialized = false,
26+
) {
27+
parent::__construct($table);
28+
}
29+
30+
/**
31+
* Gets the view name
32+
*
33+
* @return string
34+
*/
35+
public function getViewName(): string
36+
{
37+
return $this->viewName;
38+
}
39+
40+
/**
41+
* Gets whether this is a materialized view
42+
*
43+
* @return bool
44+
*/
45+
public function getMaterialized(): bool
46+
{
47+
return $this->materialized;
48+
}
49+
}

0 commit comments

Comments
 (0)