π Plan
Info: Since version 1.3
Note: Plans are part of the hectororm/schema package.
You can find it on Packagist.
See also: Schema introspection and Migration.
The Plan system is the schema migration module of Hector ORM. It provides a fluent, object-oriented API to describe DDL operations (CREATE TABLE, ALTER TABLE, DROP TABLE, β¦) and compile them into executable SQL statements for MySQL/MariaDB and SQLite.
A Plan does not execute SQL β it only produces statements. You decide when and how to execute them through your
own Connection.
Quick start
use Hector\Connection\Connection;
use Hector\Schema\Index;
use Hector\Schema\Plan\Plan;
use Hector\Schema\Plan\Compiler\AutoCompiler;
$connection = new Connection('mysql:host=localhost;dbname=mydb', 'root', 'secret');
$compiler = new AutoCompiler($connection);
$plan = new Plan();
$plan->create('users', function ($table) {
$table->addColumn('id', 'INT', autoIncrement: true);
$table->addColumn('email', 'VARCHAR(255)');
$table->addColumn('name', 'VARCHAR(100)', nullable: true);
$table->addIndex('PRIMARY', ['id'], Index::PRIMARY);
$table->addIndex('idx_email', ['email'], Index::UNIQUE);
});
foreach ($plan->getStatements($compiler) as $sql) {
$connection->execute($sql);
}
Tip: getStatements() returns an iterable (generator). Statements are produced lazily β you can iterate
and execute them one by one without loading everything into memory.
Building a plan
A Plan is an ordered collection of entries. Each entry implements OperationInterface. Table operations (
CreateTable, AlterTable) group sub-operations (columns, indexes, foreign keys, triggers) for a single table; view
operations (CreateView, AlterView, DropView), trigger operations (CreateTrigger, DropTrigger), atomic table
operations (DropTable, MigrateData), foreign key check operations (DisableForeignKeyChecks,
EnableForeignKeyChecks) and raw statements (RawStatement) are standalone entries.
Plan implements Countable and IteratorAggregate.
When a callback is provided to
create()oralter(), the method returns$this(thePlan) for chaining. Without a callback, it returns theCreateTableorAlterTableinstance for direct manipulation.
Creating a table
Use create() with a callback to define columns, indexes and foreign keys in one go:
use Hector\Schema\ForeignKey;
use Hector\Schema\Index;
$plan->create('posts', function ($table) {
$table->addColumn('id', 'INT', autoIncrement: true);
$table->addColumn('title', 'VARCHAR(255)');
$table->addColumn('body', 'TEXT', nullable: true);
$table->addColumn('user_id', 'INT');
$table->addColumn('created_at', 'DATETIME', default: 'CURRENT_TIMESTAMP', hasDefault: true);
$table->addIndex('PRIMARY', ['id'], Index::PRIMARY);
$table->addIndex('idx_user', ['user_id']);
$table->addForeignKey(
name: 'fk_posts_user',
columns: ['user_id'],
referencedTable: 'users',
referencedColumns: ['id'],
onDelete: ForeignKey::RULE_CASCADE,
);
});
Without a callback, create() returns the CreateTable instance directly for chaining:
$table = $plan->create('tags');
$table->addColumn('id', 'INT', autoIncrement: true);
$table->addColumn('label', 'VARCHAR(50)');
$table->addIndex('PRIMARY', ['id'], Index::PRIMARY);
IF NOT EXISTS
$plan->create('users', callback: function ($table) {
$table->addColumn('id', 'INT', autoIncrement: true);
// ...
}, ifNotExists: true);
Altering a table
Use alter() to add, modify, drop or rename columns, indexes and foreign keys on an existing table:
$plan->alter('users', function ($table) {
$table->addColumn('avatar', 'VARCHAR(255)', nullable: true);
$table->modifyColumn('name', 'VARCHAR(200)');
$table->renameColumn('email', 'email_address');
$table->dropColumn('legacy_field');
$table->addIndex('idx_name', ['name']);
$table->dropIndex('idx_old');
$table->addForeignKey(
name: 'fk_users_team',
columns: ['team_id'],
referencedTable: 'teams',
referencedColumns: ['id'],
);
$table->dropForeignKey('fk_users_old');
});
Column placement (MySQL only)
addColumn() and modifyColumn() accept after and first parameters for column positioning:
$plan->alter('users', function ($table) {
$table->addColumn('middle_name', 'VARCHAR(100)', nullable: true, after: 'first_name');
$table->addColumn('row_id', 'INT', first: true);
});
Rename column compatibility
renameColumn() uses the modern RENAME COLUMN syntax by default. When the compiler detects an older server
(MySQL < 8.0, MariaDB < 10.5.2) via AutoCompiler, it automatically falls back to CHANGE COLUMN using the
current column definition from the schema.
Note: The CHANGE COLUMN fallback requires a Schema to introspect the current column definition.
A PlanException is thrown if no schema is provided and the server does not support RENAME COLUMN.
Dropping a table
$plan->drop('old_table');
$plan->drop('maybe_exists', ifExists: true);
Renaming a table
$plan->rename('old_name', 'new_name');
You can also rename a table as part of an alter() call, combined with other operations:
$plan->alter('users', function ($table) {
$table->addColumn('avatar', 'VARCHAR(255)', nullable: true);
$table->renameTable('members');
});
Note: The rename is always emitted as a separate ALTER TABLE ... RENAME TO ... statement after the
other operations, regardless of the order in which you call renameTable().
Migrating data
Copy data from one table to another, optionally mapping columns:
// Copy all columns (SELECT *)
$plan->migrate('source_table', 'target_table');
// Map specific columns
$plan->migrate('source_table', 'target_table', [
'old_name' => 'new_name',
'old_email' => 'email_address',
]);
Note: migrate() generates an INSERT INTO ... SELECT ... statement. Both tables must exist at the time
of execution.
Views
Plans can also manage views with createView(), dropView() and alterView():
// Create a view
$plan->createView('active_users', 'SELECT * FROM users WHERE active = 1');
// Create or replace
$plan->createView('active_users', 'SELECT * FROM users WHERE active = 1', orReplace: true);
// With MySQL-specific algorithm
$plan->createView('active_users', 'SELECT * FROM users WHERE active = 1', algorithm: 'MERGE');
// Alter a view (replace its SELECT statement)
$plan->alterView('active_users', 'SELECT * FROM users WHERE active = 1 AND verified = 1');
// Drop a view
$plan->dropView('old_view', ifExists: true);
Note: On SQLite, OR REPLACE is emulated with DROP VIEW IF EXISTS + CREATE VIEW, and ALTER VIEW is
emulated with DROP VIEW IF EXISTS + CREATE VIEW. The algorithm parameter is silently ignored.
Triggers
Plans can create and drop triggers with createTrigger() and dropTrigger():
use Hector\Schema\Plan\CreateTrigger;
// Create a trigger (standalone)
$plan->createTrigger(
name: 'trg_users_audit',
table: 'users',
timing: CreateTrigger::AFTER,
event: CreateTrigger::INSERT,
body: 'INSERT INTO audit_log (table_name, action) VALUES (\'users\', \'insert\')',
);
// Drop a trigger
$plan->dropTrigger('trg_users_audit', 'users');
Triggers can also be created inside create() or alter() calls:
$plan->create('users', function ($table) {
$table->addColumn('id', 'INT', autoIncrement: true);
$table->addColumn('name', 'VARCHAR(255)');
$table->addIndex('PRIMARY', ['id'], Index::PRIMARY);
$table->createTrigger(
'trg_users_audit',
CreateTrigger::AFTER,
CreateTrigger::INSERT,
'INSERT INTO audit_log (table_name, action) VALUES (\'users\', \'insert\')',
);
});
$plan->alter('users', function ($table) {
$table->dropTrigger('trg_users_audit');
});
Available constants on CreateTrigger:
| Timing constants | Event constants |
|---|---|
CreateTrigger::BEFORE |
CreateTrigger::INSERT |
CreateTrigger::AFTER |
CreateTrigger::UPDATE |
CreateTrigger::INSTEAD_OF |
CreateTrigger::DELETE |
WHEN condition (SQLite only):
SQLite supports an optional WHEN condition on triggers. On MySQL, this parameter is ignored:
$plan->createTrigger(
name: 'trg_status_change',
table: 'users',
timing: CreateTrigger::BEFORE,
event: CreateTrigger::UPDATE,
body: 'INSERT INTO audit_log (action) VALUES (\'status_changed\')',
when: 'NEW.status != OLD.status',
);
Note: On MySQL, the trigger body is emitted directly after FOR EACH ROW. On SQLite, it is wrapped in
BEGIN ... END and IF NOT EXISTS is added to the CREATE TRIGGER statement.
Trigger ordering:
CREATE TRIGGERimplementsPostOperationInterfaceβ it is emitted after structure operations (columns, indexes, etc.), just likeADD FOREIGN KEY.DROP TRIGGERimplementsPreOperationInterfaceβ it is emitted before structure operations, just likeDROP FOREIGN KEY.
Foreign key checks
When performing operations that would violate foreign key constraints β such as dropping a table referenced by another, handling circular foreign keys, or performing large data migrations β you can explicitly disable and re-enable foreign key checks:
use Hector\Schema\Plan\DisableForeignKeyChecks;
use Hector\Schema\Plan\EnableForeignKeyChecks;
$plan = new Plan();
$plan->add(new DisableForeignKeyChecks());
$plan->drop('users'); // Would fail if referenced by other tables
$plan->drop('orders');
$plan->migrate('old_data', 'new_data', ['col_a' => 'col_b']);
$plan->add(new EnableForeignKeyChecks());
These operations leverage the compilerβs automatic ordering:
DisableForeignKeyChecksimplementsPreOperationInterfaceβ it is emitted first, before any other operation (includingDROP FOREIGN KEYandDROP TRIGGER).EnableForeignKeyChecksimplementsPostOperationInterfaceβ it is emitted last, after all other operations (includingADD FOREIGN KEYandCREATE TRIGGER).
This guarantees that FK checks are disabled for the entire plan execution, regardless of where you place the
add() calls in your code.
Generated SQL per driver:
| Driver | Disable | Enable |
|---|---|---|
| MySQL | SET FOREIGN_KEY_CHECKS = 0 |
SET FOREIGN_KEY_CHECKS = 1 |
| SQLite | PRAGMA foreign_keys = OFF |
PRAGMA foreign_keys = ON |
Warning: Disabling foreign key checks silences constraint violations. Use this only when you are certain that the data will be consistent after all operations complete. In most cases, you should let FK checks fail early to detect real data integrity issues.
Tip: On SQLite, the table rebuild mechanism normally emits its own PRAGMA foreign_keys = OFF/ON pair.
When the plan contains DisableForeignKeyChecks/EnableForeignKeyChecks, the rebuild skips these statements
automatically to avoid duplication and premature re-enabling.
Raw SQL statements
For SQL features not covered by the Plan API (fulltext indexes, engine changes, spatial indexes, partitions, stored procedures, etc.), you can inject raw SQL statements that will be executed as-is:
use Hector\Schema\Index;
$plan->create('articles', function ($table) {
$table->addColumn('id', 'INT', autoIncrement: true);
$table->addColumn('title', 'VARCHAR(255)');
$table->addColumn('body', 'TEXT');
$table->addIndex('PRIMARY', ['id'], Index::PRIMARY);
});
// Fulltext index β not supported by the Plan API
$plan->raw('CREATE FULLTEXT INDEX ft_search ON articles (title, body)');
// Engine change
$plan->raw('ALTER TABLE articles ENGINE = InnoDB');
Raw statements bypass the compiler and are emitted verbatim during the structure pass, preserving their position relative to other plan entries.
Driver filter
Raw statements can be scoped to specific database drivers using the drivers parameter. When the compiler does not
match any of the specified drivers, the statement is silently skipped:
// Only executed on MySQL / MariaDB / Vitess
$plan->raw('ALTER TABLE articles ENGINE = InnoDB', drivers: ['mysql']);
// Only executed on SQLite
$plan->raw('PRAGMA journal_mode = WAL', drivers: ['sqlite']);
// Executed on all drivers (default behavior)
$plan->raw('INSERT INTO migrations (name) VALUES (\'v1.3\')');
This is especially useful for mixed-environment migrations where development uses SQLite and production uses MySQL:
$plan->create('articles', function ($table) {
$table->addColumn('id', 'INT', autoIncrement: true);
$table->addColumn('title', 'VARCHAR(255)');
$table->addColumn('body', 'TEXT');
$table->addIndex('PRIMARY', ['id'], Index::PRIMARY);
});
// MySQL-only: fulltext index and engine
$plan->raw('CREATE FULLTEXT INDEX ft_search ON articles (title, body)', drivers: ['mysql']);
$plan->raw('ALTER TABLE articles ENGINE = InnoDB', drivers: ['mysql']);
// SQLite-only: WAL mode for better concurrency
$plan->raw('PRAGMA journal_mode = WAL', drivers: ['sqlite']);
Available driver names (matching Connection\Driver\DriverInfo::getDriver()):
| Driver | Description | Compiler used |
|---|---|---|
mysql |
MySQL | MySQLCompiler |
mariadb |
MariaDB | MySQLCompiler |
vitess |
Vitess | MySQLCompiler |
sqlite |
SQLite | SqliteCompiler |
Note: When drivers is null (the default), the statement is emitted unconditionally β this preserves
full backward compatibility.
Execution order with raw statements:
- Pre-operations (global):
DisableForeignKeyChecks, allDROP FOREIGN KEY,DROP TRIGGERfrom all entries - Structure + Raw (in declaration order): compiled structure operations interleaved with raw statements
- Post-operations (global): all
ADD FOREIGN KEY,CREATE TRIGGER,EnableForeignKeyChecksfrom all entries
This means raw statements are always executed after foreign key drops and before foreign key additions, which is the safest position for structural changes.
Warning: Raw SQL without a driver filter is database-specific and not portable across drivers.
Use drivers: to safely scope vendor-specific statements in multi-environment projects.
TableOperation operations
TableOperation is the abstract base class for CreateTable and AlterTable. It groups sub-operations
(columns, indexes, foreign keys) for a single table. It implements Countable and IteratorAggregate.
Column operations (shared β CreateTable and AlterTable):
| Method | Description |
|---|---|
addColumn($name, $type, $nullable, $default, ...) |
Add a column |
Column operations (ALTER only):
| Method | Description |
|---|---|
dropColumn($column) |
Drop a column |
modifyColumn($column, $type, $nullable, $default, ...) |
Modify a column |
renameColumn($column, $newName) |
Rename a column |
Index operations:
| Method | Description |
|---|---|
addIndex($name, $columns, $type) |
Add an index |
dropIndex($index) |
Drop an index |
Foreign key operations:
| Method | Description |
|---|---|
addForeignKey($name, $columns, $referencedTable, $referencedColumns, ...) |
Add a foreign key |
dropForeignKey($foreignKey) |
Drop a foreign key |
Trigger operations:
| Method | Description |
|---|---|
createTrigger($name, $timing, $event, $body, $when) |
Create a trigger on this table (on CreateTable and AlterTable) |
dropTrigger($name) |
Drop a trigger on this table (ALTER only) |
Table-level operations (ALTER only):
| Method | Description |
|---|---|
renameTable($newName) |
Rename the table (emitted as a separate statement) |
modifyCharset($charset, $collation) |
Modify character set and collation (MySQL only, on ALTER) |
Utility methods:
| Method | Description |
|---|---|
getObjectName(): string |
The table name |
isEmpty(): bool |
Check if the plan has no operations |
count(): int |
Number of operations |
getArrayCopy(): OperationInterface[] |
Get all operations as an array |
Compilers
A Plan is compiled into SQL statements by a CompilerInterface. Three implementations are provided:
| Compiler | Description |
|---|---|
MySQLCompiler |
Generates MySQL / MariaDB DDL |
SqliteCompiler |
Generates SQLite DDL, with automatic table rebuild |
AutoCompiler |
Detects the driver from a Connection and delegates |
All compilers implement CompilerInterface:
| Method | Description |
|---|---|
compile($plan, $schema): iterable<string> |
Compile a plan into SQL statements |
AutoCompiler
The simplest approach β pass your Connection and the correct compiler is resolved automatically:
use Hector\Schema\Plan\Compiler\AutoCompiler;
$compiler = new AutoCompiler($connection);
foreach ($plan->getStatements($compiler) as $sql) {
$connection->execute($sql);
}
| Constructor Parameter | Type | Description |
|---|---|---|
$connection |
Connection |
Used to detect the database driver |
Supported drivers: mysql, mariadb, vitess, sqlite.
Using a specific compiler
use Hector\Schema\Plan\Compiler\MySQLCompiler;
use Hector\Schema\Plan\Compiler\SqliteCompiler;
$compiler = new MySQLCompiler();
// or
$compiler = new SqliteCompiler();
foreach ($plan->getStatements($compiler) as $sql) {
echo $sql . ";\n";
}
Tip: Without a Connection, you can use compilers directly to generate SQL strings β useful for dry-run
migrations, SQL export scripts, or testing.
Schema introspection
When a Schema object is provided to getStatements(), the compiler can introspect the current database state and
adapt its strategy:
use Hector\Schema\Generator\MySQL as MySQLGenerator;
$generator = new MySQLGenerator($connection);
$schema = $generator->generateSchema('mydb');
foreach ($plan->getStatements($compiler, $schema) as $sql) {
$connection->execute($sql);
}
With a schema, the compiler will:
- Check index existence before adding β if an index already exists, it emits a
DROP INDEX+CREATE INDEXpair instead of failing - Use table rebuild for SQLite when the operation requires it (e.g.,
MODIFY COLUMN,ADD/DROP FOREIGN KEY)
Without a schema, all operations are compiled optimistically β the compiler assumes the database supports them natively.
Warning: The schema represents the database state before the plan is executed. It may become stale after execution. If you need to run multiple plans sequentially, regenerate the schema between each one.
Operation ordering
Plan automatically reorders operations to avoid constraint violations and dependency issues:
- Pre-operations (global):
DisableForeignKeyChecks,DROP FOREIGN KEY,DROP TRIGGERβ from all entries - Structure + Raw (in declaration order):
CREATE/ALTER/DROP TABLE, columns, indexes, data migrations, views, and raw SQL statements - Post-operations (global):
ADD FOREIGN KEY,CREATE TRIGGER,EnableForeignKeyChecksβ from all entries
This happens transparently β you donβt need to worry about the order in which you add operations to the plan. Raw statements are interleaved with structure operations in the order you declared them.
use Hector\Schema\Index;
$plan = new Plan();
// These can be added in any order β FK ordering is automatic
$plan->create('orders', function ($table) {
$table->addColumn('id', 'INT', autoIncrement: true);
$table->addColumn('user_id', 'INT');
$table->addIndex('PRIMARY', ['id'], Index::PRIMARY);
$table->addForeignKey('fk_order_user', ['user_id'], 'users', ['id']);
});
$plan->raw('CREATE FULLTEXT INDEX ft_order_ref ON orders (reference)');
$plan->create('users', function ($table) {
$table->addColumn('id', 'INT', autoIncrement: true);
$table->addColumn('name', 'VARCHAR(100)');
$table->addIndex('PRIMARY', ['id'], Index::PRIMARY);
});
// Execution order:
// 1. CREATE TABLE orders (structure)
// 2. CREATE FULLTEXT INDEX ft_order_ref ON orders (raw β in position)
// 3. CREATE TABLE users (structure)
// 4. ALTER TABLE orders ADD CONSTRAINT fk_order_user ... (post β last)
foreach ($plan->getStatements($compiler) as $sql) {
$connection->execute($sql);
}
SQLite: table rebuild
SQLite has limited ALTER TABLE support β it cannot modify columns, add or drop foreign keys. When these operations are
detected and a Schema is available, the SqliteCompiler automatically generates a table rebuild sequence:
PRAGMA foreign_keys = OFFCREATE TABLEa temporary table with the new schemaINSERT INTO ... SELECT ...to migrate data from the original tableDROP TABLEthe original tableALTER TABLE ... RENAME TO ...the temporary table to the original name- Recreate non-primary indexes
PRAGMA foreign_keys = ON
This is fully transparent β you write the same Plan for both MySQL and SQLite.
// Works on both MySQL and SQLite
$plan = new Plan();
$plan->alter('users', function ($table) {
$table->modifyColumn('name', 'VARCHAR(200)');
$table->addForeignKey('fk_team', ['team_id'], 'teams', ['id']);
});
// On MySQL: ALTER TABLE users MODIFY COLUMN ..., ADD CONSTRAINT ...
// On SQLite: Full table rebuild sequence (7 statements)
foreach ($plan->getStatements($compiler, $schema) as $sql) {
$connection->execute($sql);
}
Warning: Table rebuild requires a Schema. Without it, the SQLite compiler will attempt native ALTER TABLE
statements that may fail.
Complete example: migration script
This example demonstrates how operations can be declared in any order β the compiler automatically reorders them to avoid constraint violations:
use Hector\Connection\Connection;
use Hector\Schema\ForeignKey;
use Hector\Schema\Index;
use Hector\Schema\Generator\MySQL as MySQLGenerator;
use Hector\Schema\Plan\Compiler\AutoCompiler;
use Hector\Schema\Plan\CreateTrigger;
use Hector\Schema\Plan\Plan;
$connection = new Connection('mysql:host=localhost;dbname=mydb', 'root', 'secret');
$generator = new MySQLGenerator($connection);
$schema = $generator->generateSchema('mydb');
$compiler = new AutoCompiler($connection);
$plan = new Plan();
// 1. Alter posts β drop old FK, add new FK and a trigger
$plan->alter('posts', function ($table) {
$table->dropForeignKey('fk_posts_author');
$table->addColumn('category_id', 'INT', nullable: true);
$table->addForeignKey(
name: 'fk_posts_category',
columns: ['category_id'],
referencedTable: 'categories',
referencedColumns: ['id'],
onDelete: ForeignKey::RULE_SET_NULL,
);
$table->createTrigger(
'trg_posts_audit',
CreateTrigger::AFTER,
CreateTrigger::INSERT,
'INSERT INTO audit_log (table_name, action) VALUES (\'posts\', \'insert\')',
);
});
// 2. Create the categories table (referenced by posts FK above)
$plan->create('categories', function ($table) {
$table->addColumn('id', 'INT', autoIncrement: true);
$table->addColumn('name', 'VARCHAR(100)');
$table->addIndex('PRIMARY', ['id'], Index::PRIMARY);
$table->addIndex('idx_name', ['name'], Index::UNIQUE);
});
// 3. MySQL-only: fulltext index on posts
$plan->raw('CREATE FULLTEXT INDEX ft_search ON posts (title, body)', drivers: ['mysql']);
// 4. Drop an old table
$plan->drop('legacy_posts', ifExists: true);
// Execute
foreach ($plan->getStatements($compiler, $schema) as $sql) {
$connection->execute($sql);
}
The compiler produces SQL in this order:
MySQL
-- Pass 1 β Pre-operations: FK drops first
ALTER TABLE `posts` DROP FOREIGN KEY `fk_posts_author`;
-- Pass 2 β Structure (in declaration order)
ALTER TABLE `posts` ADD COLUMN `category_id` INT NULL DEFAULT NULL;
CREATE TABLE `categories` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `idx_name` (`name`)
);
CREATE FULLTEXT INDEX ft_search ON posts (title, body);
DROP TABLE IF EXISTS `legacy_posts`;
-- Pass 3 β Post-operations: FK additions and trigger creations last
ALTER TABLE `posts` ADD CONSTRAINT `fk_posts_category` FOREIGN KEY (`category_id`)
REFERENCES `categories` (`id`) ON DELETE SET NULL;
CREATE TRIGGER `trg_posts_audit` AFTER INSERT ON `posts`
FOR EACH ROW INSERT INTO audit_log (table_name, action) VALUES ('posts', 'insert');
SQLite (with schema)
-- Pass 2 β Structure (in declaration order)
-- The ALTER on posts triggers a full table rebuild (drop FK + add FK):
PRAGMA foreign_keys = OFF;
CREATE TABLE "__htemp_xxx_posts" (
"id" INT NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" VARCHAR(255) NOT NULL,
"body" TEXT NULL,
"author_id" INT NOT NULL,
"category_id" INT NULL DEFAULT NULL,
CONSTRAINT "fk_posts_category" FOREIGN KEY ("category_id")
REFERENCES "categories" ("id") ON DELETE SET NULL
);
INSERT INTO "__htemp_xxx_posts" ("id", "title", "body", "author_id")
SELECT "id", "title", "body", "author_id" FROM "posts";
DROP TABLE "posts";
ALTER TABLE "__htemp_xxx_posts" RENAME TO "posts";
PRAGMA foreign_keys = ON;
CREATE TABLE "categories" (
"id" INT NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" VARCHAR(100) NOT NULL
);
CREATE UNIQUE INDEX "idx_name" ON "categories" ("name");
-- raw('CREATE FULLTEXT INDEX ...', drivers: ['mysql']) β skipped
DROP TABLE IF EXISTS "legacy_posts";
-- Pass 3 β Post-operations: trigger creation last
CREATE TRIGGER IF NOT EXISTS "trg_posts_audit" AFTER INSERT ON "posts"
FOR EACH ROW BEGIN
INSERT INTO audit_log (table_name, action) VALUES ('posts', 'insert');
END
Tip: Notice how the categories table is created before the FK that references it, even though
alter('posts') was declared first. The 3-pass ordering ensures drops happen first, then structure,
then additions β regardless of the declaration order in the Plan.
The MySQL-only raw() statement is silently skipped on SQLite thanks to the drivers: filter.