// Generate database-agnostic Wheels migrations for creating tables, altering schemas, and managing database changes. Use when creating or modifying database schema, adding tables, columns, indexes, or foreign keys. Prevents database-specific SQL and ensures cross-database compatibility.
| name | Wheels Migration Generator |
| description | Generate database-agnostic Wheels migrations for creating tables, altering schemas, and managing database changes. Use when creating or modifying database schema, adding tables, columns, indexes, or foreign keys. Prevents database-specific SQL and ensures cross-database compatibility. |
Activate automatically when:
NEVER use database-specific functions like DATE_SUB(), NOW(), CURDATE()!
WRONG:
execute("INSERT INTO posts (publishedAt) VALUES (DATE_SUB(NOW(), INTERVAL 1 DAY))"); ❌ MySQL only!
CORRECT:
var pastDate = DateAdd("d", -1, Now());
execute("INSERT INTO posts (publishedAt) VALUES (TIMESTAMP '#DateFormat(pastDate, "yyyy-mm-dd")# #TimeFormat(pastDate, "HH:mm:ss")#')"); ✅ Cross-database!
component extends="wheels.migrator.Migration" {
function up() {
transaction {
try {
// Your migration code here
} catch (any e) {
local.exception = e;
}
if (StructKeyExists(local, "exception")) {
transaction action="rollback";
Throw(
errorCode="1",
detail=local.exception.detail,
message=local.exception.message,
type="any"
);
} else {
transaction action="commit";
}
}
}
function down() {
// Rollback code here
}
}
component extends="wheels.migrator.Migration" {
function up() {
transaction {
try {
// Create table
t = createTable(name="posts", force=false);
// String columns
t.string(columnNames="title", allowNull=false, limit=200);
t.string(columnNames="slug", allowNull=false, limit=200);
// Text columns
t.text(columnNames="content", allowNull=false);
t.text(columnNames="excerpt", allowNull=true);
// Integer columns
t.integer(columnNames="viewCount", default=0);
t.integer(columnNames="userId", allowNull=false);
// Boolean columns
t.boolean(columnNames="published", default=false);
// DateTime columns
t.datetime(columnNames="publishedAt", allowNull=true);
// Timestamps (createdAt, updatedAt)
t.timestamps();
// Create the table
t.create();
// Add indexes
addIndex(table="posts", columnNames="slug", unique=true);
addIndex(table="posts", columnNames="userId");
addIndex(table="posts", columnNames="published,publishedAt");
// Add foreign key
addForeignKey(
table="posts",
referenceTable="users",
column="userId",
referenceColumn="id",
onDelete="cascade"
);
} catch (any e) {
local.exception = e;
}
if (StructKeyExists(local, "exception")) {
transaction action="rollback";
Throw(
errorCode="1",
detail=local.exception.detail,
message=local.exception.message,
type="any"
);
} else {
transaction action="commit";
}
}
}
function down() {
dropTable("posts");
}
}
component extends="wheels.migrator.Migration" {
function up() {
transaction {
try {
// Add column
addColumn(
table="posts",
columnType="string",
columnName="metaDescription",
limit=300,
allowNull=true
);
// Change column
changeColumn(
table="posts",
columnName="title",
columnType="string",
limit=255, // Changed from 200
allowNull=false
);
// Rename column
renameColumn(
table="posts",
oldColumnName="summary",
newColumnName="excerpt"
);
// Remove column
removeColumn(table="posts", columnName="oldField");
// Add index
addIndex(table="posts", columnNames="metaDescription");
} catch (any e) {
local.exception = e;
}
if (StructKeyExists(local, "exception")) {
transaction action="rollback";
Throw(
errorCode="1",
detail=local.exception.detail,
message=local.exception.message,
type="any"
);
} else {
transaction action="commit";
}
}
}
function down() {
removeColumn(table="posts", columnName="metaDescription");
// Reverse other changes...
}
}
component extends="wheels.migrator.Migration" {
function up() {
transaction {
try {
// CORRECT: Use CFML date functions
var now = Now();
var day1 = DateAdd("d", -7, now);
var day2 = DateAdd("d", -6, now);
var day3 = DateAdd("d", -5, now);
// Format dates for SQL
var nowFormatted = "TIMESTAMP '#DateFormat(now, "yyyy-mm-dd")# #TimeFormat(now, "HH:mm:ss")#'";
var day1Formatted = "TIMESTAMP '#DateFormat(day1, "yyyy-mm-dd")# #TimeFormat(day1, "HH:mm:ss")#'";
var day2Formatted = "TIMESTAMP '#DateFormat(day2, "yyyy-mm-dd")# #TimeFormat(day2, "HH:mm:ss")#'";
// Insert data
execute("
INSERT INTO posts (title, slug, content, published, publishedAt, createdAt, updatedAt)
VALUES (
'Getting Started with HTMX',
'getting-started-with-htmx',
'<p>HTMX is a modern approach to building web applications...</p>',
1,
#day1Formatted#,
#day1Formatted#,
#day1Formatted#
)
");
execute("
INSERT INTO posts (title, slug, content, published, publishedAt, createdAt, updatedAt)
VALUES (
'Tailwind CSS Best Practices',
'tailwind-css-best-practices',
'<p>Tailwind provides utility-first CSS...</p>',
1,
#day2Formatted#,
#day2Formatted#,
#day2Formatted#
)
");
} catch (any e) {
local.exception = e;
}
if (StructKeyExists(local, "exception")) {
transaction action="rollback";
Throw(
errorCode="1",
detail=local.exception.detail,
message=local.exception.message,
type="any"
);
} else {
transaction action="commit";
}
}
}
function down() {
execute("DELETE FROM posts WHERE slug IN ('getting-started-with-htmx', 'tailwind-css-best-practices')");
}
}
// String (VARCHAR)
t.string(columnNames="name", limit=255, allowNull=false, default="");
// Text (TEXT/CLOB)
t.text(columnNames="description", allowNull=true);
// Integer
t.integer(columnNames="count", default=0, allowNull=false);
// Big Integer
t.biginteger(columnNames="largeNumber");
// Float
t.float(columnNames="rating", default=0.0);
// Decimal
t.decimal(columnNames="price", precision=10, scale=2);
// Boolean
t.boolean(columnNames="active", default=true);
// Date
t.date(columnNames="birthDate");
// DateTime
t.datetime(columnNames="publishedAt");
// Time
t.time(columnNames="startTime");
// Binary
t.binary(columnNames="fileData");
// UUID
t.string(columnNames="uuid", limit=36);
// Timestamps (adds createdAt and updatedAt)
t.timestamps();
🔴 CRITICAL DISCOVERY: The CLI generator wheels g migration creates migrations with string boolean values instead of actual booleans, causing silent failures.
Problem Generated by CLI:
// ❌ CLI generates this - STRING values that don't work!
t = createTable(name='users', force='false', id='true', primaryKey='id');
Symptoms:
✅ SOLUTION: Simplify to Use Defaults
// Remove all explicit boolean parameters - let Wheels use defaults
t = createTable(name='users'); // That's it!
t.string(columnNames='username', allowNull=false, limit='50');
t.timestamps();
t.create();
Why This Works:
createTable() has correct default behavior'false', 'true') break the logicMANDATORY Post-CLI-Generation Fix:
// 1. Find this pattern in generated migration:
t = createTable(name='tablename', force='false', id='true', primaryKey='id');
// 2. Replace with:
t = createTable(name='tablename');
Rule:
✅ MANDATORY: After CLI generation, remove force/id/primaryKey parameters from createTable()
❌ NEVER use string boolean values: 'false', 'true'
✅ Use actual booleans IF needed: false, true (but defaults are better)
🔴 LESSON LEARNED: When migrations fail or you need to iterate, always reset before running latest.
Standard Development Workflow:
# 1. Generate migration
wheels g migration CreateUsersTable
# 2. Edit migration file (fix CLI-generated issues!)
# 3. ALWAYS reset before running during development
wheels dbmigrate reset # Drops all tables, clean slate
wheels dbmigrate latest # Run all migrations fresh
# 4. If migration fails, fix it then:
wheels dbmigrate reset # Reset again
wheels dbmigrate latest # Try again
Why Reset is Important:
Production Workflow (Different!):
# In production, NEVER reset!
wheels dbmigrate latest # Only run new migrations
❌ WRONG ORDER - Causes Index Conflicts:
addIndex(table="likes", columnNames="userId"); // ❌ Creates duplicate
addIndex(table="likes", columnNames="tweetId");
addIndex(table="likes", columnNames="userId,tweetId", unique=true);
✅ CORRECT ORDER - Composite First:
// Composite index FIRST - it covers queries on the first column too!
addIndex(table="likes", columnNames="userId,tweetId", unique=true);
// Then add index for second column only
addIndex(table="likes", columnNames="tweetId");
Why: A composite index on (userId, tweetId) can be used for queries filtering by userId alone, making a separate userId index redundant.
Problem: Multiple foreign keys to the same table generate duplicate constraint names in H2:
// ❌ Both try to create "FK_FOLLOWS_USERS" - conflict!
addForeignKey(table="follows", referenceTable="users", column="followerId")
addForeignKey(table="follows", referenceTable="users", column="followingId")
Solution A: Explicit Key Names (Preferred for Production)
addForeignKey(
table="follows",
referenceTable="users",
column="followerId",
referenceColumn="id",
keyName="FK_follows_follower", // Explicit unique name
onDelete="cascade"
);
addForeignKey(
table="follows",
referenceTable="users",
column="followingId",
referenceColumn="id",
keyName="FK_follows_following", // Different unique name
onDelete="cascade"
);
Solution B: Skip Foreign Keys (Acceptable for Development)
// Rely on application-layer validation instead
// Indexes provide query performance, foreign keys are optional
addIndex(table="follows", columnNames="followerId,followingId", unique=true);
addIndex(table="follows", columnNames="followingId");
// Note: Foreign keys omitted to avoid H2 naming conflicts
// Application validates referential integrity
When migrations fail mid-transaction (common during development):
// Use force=true to drop and recreate if table exists
t = createTable(name="likes", force=true); // Drops existing table first
When to use:
For many-to-many relationships (e.g., likes, follows):
t = createTable(name="likes", force=true);
t.integer(columnNames="userId", allowNull=false);
t.integer(columnNames="tweetId", allowNull=false);
t.datetime(columnNames="createdAt", allowNull=false); // Track when relationship created
t.create();
// IMPORTANT: Composite unique index FIRST
addIndex(table="likes", columnNames="userId,tweetId", unique=true);
addIndex(table="likes", columnNames="tweetId"); // For reverse lookups
// Simple index
addIndex(table="posts", columnNames="title");
// Unique index
addIndex(table="posts", columnNames="slug", unique=true);
// Composite index
addIndex(table="posts", columnNames="published,publishedAt");
// Remove index
removeIndex(table="posts", indexName="idx_posts_title");
// Add foreign key
addForeignKey(
table="posts",
referenceTable="users",
column="userId",
referenceColumn="id",
onDelete="cascade", // Options: cascade, setNull, setDefault, restrict
onUpdate="cascade"
);
// Remove foreign key
removeForeignKey(table="posts", keyName="fk_posts_userId");
component extends="wheels.migrator.Migration" {
function up() {
transaction {
try {
// Create join table for many-to-many
t = createTable(name="postTags", force=false);
t.integer(columnNames="postId", allowNull=false);
t.integer(columnNames="tagId", allowNull=false);
t.timestamps();
t.create();
// Add indexes
addIndex(table="postTags", columnNames="postId");
addIndex(table="postTags", columnNames="tagId");
addIndex(table="postTags", columnNames="postId,tagId", unique=true);
// Add foreign keys
addForeignKey(
table="postTags",
referenceTable="posts",
column="postId",
referenceColumn="id",
onDelete="cascade"
);
addForeignKey(
table="postTags",
referenceTable="tags",
column="tagId",
referenceColumn="id",
onDelete="cascade"
);
} catch (any e) {
local.exception = e;
}
if (StructKeyExists(local, "exception")) {
transaction action="rollback";
Throw(
errorCode="1",
detail=local.exception.detail,
message=local.exception.message,
type="any"
);
} else {
transaction action="commit";
}
}
}
function down() {
dropTable("postTags");
}
}
When generating a migration:
addColumn(
table="posts",
columnType="datetime",
columnName="deletedAt",
allowNull=true
);
addIndex(table="posts", columnNames="deletedAt");
// Add column for search
addColumn(
table="posts",
columnType="text",
columnName="searchContent",
allowNull=true
);
// Create search index (database-specific, document it)
// For PostgreSQL: CREATE INDEX ... USING GIN
// For MySQL: CREATE FULLTEXT INDEX
addColumn(table="posts", columnType="integer", columnName="version", default=1);
addColumn(table="posts", columnType="integer", columnName="lockVersion", default=0);
# Create new migration
wheels g migration CreatePostsTable
# Run pending migrations
wheels dbmigrate latest
# Run single migration
wheels dbmigrate up
# Rollback last migration
wheels dbmigrate down
# Show migration status
wheels dbmigrate info
Generated by: Wheels Migration Generator Skill v1.0 Framework: CFWheels 3.0+ Last Updated: 2025-10-20