sql-orm-safety
Prevents ambiguous column errors and silent data bugs from raw SQL in ORM templates.
SKILL.mdskills/sql-orm-safety/SKILL.md
GitHubSQL ORM Safety Skill
Problem it solves
ORMs like Drizzle, Sequelize, and TypeORM let you write raw SQL inside template literals. When these fragments appear in subqueries or complex joins, the developer must manually qualify all column references — the ORM can't resolve unqualified column names in raw SQL context. This creates ambiguous column errors that are invisible during development (single-table queries work fine) and only appear when joins or subqueries involve multiple tables with overlapping column names like id, name, or created_at.
Detection triggers
Activate when:
- Error says
ambiguous column name: <column>in a query using ORM raw SQL - Using
sql()template literals (Drizzle),sequelize.literal(), orQueryInterface.sequelize.literal() - A query works on one table but breaks after adding a second table with shared column names
- Raw SQL returns unexpected data after adding a new table or join
- ORM aggregates produce wrong results when nested inside larger queries
Protocol
1. Identify raw SQL in ORM templates
Search for raw SQL patterns in the codebase:
# Drizzle
grep -rn "sql\`" src/ --include="*.ts"
# Sequelize
grep -rn "\.literal(" src/ --include="*.ts"
# TypeORM
grep -rn "createQueryBuilder\|\.query(" src/ --include="*.ts"
2. Check for missing table qualifiers
In any raw SQL fragment that references columns from multiple tables, every column must be qualified:
-- BAD — ambiguous when joined
SELECT id, name, total FROM (SELECT ...)
-- GOOD — fully qualified
SELECT inventory.id, inventory.name, SUM(stock_movements.quantity) AS total
FROM inventory
JOIN stock_movements ON stock_movements.inventory_id = inventory.id
3. Replace correlated subqueries with separate aggregates
ORM sql() template fragments inside subqueries require manual qualification of every column reference. Instead of embedding complex subqueries, run separate queries and join in application code:
// BAD — sql() template strips table qualification inside subquery
const items = await db.select({
id: inventory.id,
totalSpend: sql`(SELECT SUM(amount) FROM purchases WHERE purchases.inventory_id = ${inventory.id})`
}).from(inventory);
// GOOD — separate aggregate query + Map join
const spends = await db.select({
inventoryId: purchases.inventoryId,
total: sql<number>`SUM(${purchases.amount})`
}).from(purchases).groupBy(purchases.inventoryId);
const spendMap = new Map(spends.map(s => [s.inventoryId, s.total]));
const items = await db.select().from(inventory);
// Join in application code
4. Verify with a multi-table smoke test
After fixing, test with a query that joins the tables involved:
-- This should NOT produce "ambiguous column name"
SELECT * FROM table_a JOIN table_b ON table_a.id = table_b.ref_id;
If it crashes, there are still unqualified columns.
5. Watch for legacy NOT NULL columns
When adding a foreign key to an existing table, legacy display columns (e.g., purchases.supplier as text) remain NOT NULL. Every insert/update must backfill the legacy column from the new FK:
// When creating with supplierId, also populate legacy supplier column
const supplier = await db.query.suppliers.findFirst({
where: eq(suppliers.id, input.supplierId)
});
await db.insert(purchases).values({
...input,
supplier: supplier.name, // backfill legacy NOT NULL column
});
When NOT to use
- Simple single-table queries with no joins
- ORM methods that handle qualification automatically (
.select(),.where()) - Read-only queries against views with pre-qualified columns
Cross-references
- safe-code-modifications — When adding FKs, verify legacy columns are backfilled before removing old code paths.
- debugging-and-error-recovery — Apply structured debugging when ambiguous column errors appear in production but not development.
Lessons learned
Real bugs caught by this skill:
- Raw SQL fragments in ORM subqueries require manual column qualification — missing qualifiers cause "ambiguous column name" errors only after adding a second table
- Legacy
NOT NULLdisplay columns must be backfilled from new FK on every write path — missing one creates constraint violations - Separate aggregate queries + application-code Map joins are more reliable than correlated subqueries in ORM templates