Schema Consistency Checker
Enforce schema consistency and best practices across your database.
Consistency Rules 1. Naming Conventions // naming-rules.ts export const NAMING_RULES = { tables: { pattern: /^[A-Z][a-zA-Z0-9]$/, // PascalCase examples: ["User", "OrderItem", "ProductCategory"], }, columns: { pattern: /^[a-z][a-zA-Z0-9]$/, // camelCase examples: ["id", "firstName", "createdAt"], }, indexes: { pattern: /^idx_[a-z_]+$/, // idx_table_column examples: ["idx_users_email", "idx_orders_user_id"], }, foreignKeys: { pattern: /^fk_[a-z_]+$/, // fk_table_column examples: ["fk_orders_user_id", "fk_products_category_id"], }, constraints: { pattern: /^(chk|unq)[a-z]+$/, // chk_ or unq_prefix examples: ["chk_age_positive", "unq_users_email"], }, };
- Type Consistency -- ❌ Bad: Inconsistent types for IDs CREATE TABLE users ( id INTEGER PRIMARY KEY, email TEXT );
CREATE TABLE orders ( id BIGINT PRIMARY KEY, -- ❌ Different ID type user_id TEXT -- ❌ Wrong type for FK );
-- ✅ Good: Consistent types CREATE TABLE users ( id BIGINT PRIMARY KEY, email TEXT );
CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id BIGINT REFERENCES users(id) );
- Nullability Patterns -- ❌ Bad: Inconsistent NULL handling CREATE TABLE users ( id BIGINT PRIMARY KEY, email TEXT, -- ❌ No NOT NULL on critical field name TEXT, -- ❌ Should be NOT NULL phone TEXT NULL, -- ⚠️ Explicit NULL unnecessary created_at TIMESTAMP -- ❌ Missing NOT NULL );
-- ✅ Good: Clear nullability CREATE TABLE users ( id BIGINT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, phone TEXT, -- Optional field created_at TIMESTAMP NOT NULL DEFAULT NOW() );
- Missing Constraints -- ❌ Bad: Missing constraints CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id BIGINT, -- ❌ Missing FK status TEXT, -- ❌ No CHECK constraint total DECIMAL(10,2), -- ❌ No CHECK for positive created_at TIMESTAMP );
-- ✅ Good: Proper constraints CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'shipped', 'delivered')), total DECIMAL(10,2) NOT NULL CHECK (total >= 0), created_at TIMESTAMP NOT NULL DEFAULT NOW() );
Audit Script // scripts/audit-schema.ts import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
interface Violation { severity: "error" | "warning" | "info"; category: string; table: string; column?: string; message: string; recommendation: string; }
async function auditSchema(): Promise
// Get schema metadata
const tables = await prisma.$queryRawSELECT
table_name,
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_schema = 'public'
ORDER BY table_name, ordinal_position;
// Check 1: Naming conventions
tables.forEach((col) => {
// Table naming
if (!/^[A-Z][a-zA-Z0-9]*$/.test(col.table_name)) {
violations.push({
severity: "warning",
category: "naming",
table: col.table_name,
message: Table name '${col.table_name}' doesn't follow PascalCase convention,
recommendation: Rename to PascalCase (e.g., 'UserProfile', 'OrderItem'),
});
}
// Column naming
if (!/^[a-z][a-zA-Z0-9]*$/.test(col.column_name)) {
violations.push({
severity: "warning",
category: "naming",
table: col.table_name,
column: col.column_name,
message: `Column '${col.column_name}' doesn't follow camelCase convention`,
recommendation: `Rename to camelCase (e.g., 'firstName', 'createdAt')`,
});
}
});
// Check 2: Missing NOT NULL on critical fields
const criticalFields = [
"email",
"name",
"user_id",
"created_at",
"updated_at",
];
tables.forEach((col) => {
if (
criticalFields.some((f) => col.column_name.includes(f)) &&
col.is_nullable === "YES"
) {
violations.push({
severity: "error",
category: "nullability",
table: col.table_name,
column: col.column_name,
message: Critical field '${col.column_name}' allows NULL,
recommendation: Add NOT NULL constraint,
});
}
});
// Check 3: Type consistency for IDs
const idTypes = new Map
const primaryIdType = Array.from(idTypes.values())[0];
idTypes.forEach((type, table) => {
if (type !== primaryIdType) {
violations.push({
severity: "error",
category: "type-consistency",
table,
column: "id",
message: ID type '${type}' inconsistent with primary type '${primaryIdType}',
recommendation: Standardize all IDs to ${primaryIdType},
});
}
});
// Check 4: Missing indexes on foreign keys
const foreignKeys = await prisma.$queryRawSELECT
tc.table_name,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY';
const indexes = await prisma.$queryRawSELECT
tablename,
indexname,
indexdef
FROM pg_indexes
WHERE schemaname = 'public';
foreignKeys.forEach((fk) => { const hasIndex = indexes.some( (idx) => idx.tablename === fk.table_name && idx.indexdef.includes(fk.column_name) );
if (!hasIndex) {
violations.push({
severity: "warning",
category: "performance",
table: fk.table_name,
column: fk.column_name,
message: `Foreign key '${fk.column_name}' has no index`,
recommendation: `CREATE INDEX idx_${fk.table_name}_${fk.column_name} ON "${fk.table_name}"("${fk.column_name}")`,
});
}
});
// Check 5: Missing timestamps
const tablesGrouped = tables.reduce((acc, col) => {
if (!acc[col.table_name]) acc[col.table_name] = [];
acc[col.table_name].push(col.column_name);
return acc;
}, {} as Record
Object.entries(tablesGrouped).forEach(([table, columns]) => {
if (!columns.includes("created_at")) {
violations.push({
severity: "info",
category: "audit",
table,
message: Table missing 'created_at' timestamp,
recommendation: Add: created_at TIMESTAMP NOT NULL DEFAULT NOW(),
});
}
if (!columns.includes("updated_at") && !columns.includes("updatedAt")) {
violations.push({
severity: "info",
category: "audit",
table,
message: Table missing 'updated_at' timestamp,
recommendation: Add: updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
});
}
});
return violations; }
// Generate report async function generateReport() { const violations = await auditSchema();
console.log("📊 Schema Audit Report\n");
console.log(Total violations: ${violations.length}\n);
// Group by severity
const grouped = violations.reduce((acc, v) => {
if (!acc[v.severity]) acc[v.severity] = [];
acc[v.severity].push(v);
return acc;
}, {} as Record
// Print by severity (["error", "warning", "info"] as const).forEach((severity) => { const items = grouped[severity] || []; if (items.length === 0) return;
console.log(
`\n${
{ error: "❌ Errors", warning: "⚠️ Warnings", info: "ℹ️ Info" }[
severity
]
} (${items.length})\n`
);
items.forEach((v, i) => {
console.log(
`${i + 1}. [${v.category}] ${v.table}${v.column ? `.${v.column}` : ""}`
);
console.log(` Message: ${v.message}`);
console.log(` Fix: ${v.recommendation}\n`);
});
});
// Exit code based on errors process.exit(grouped.error?.length > 0 ? 1 : 0); }
generateReport();
Recommended Schema Standards // schema.prisma with best practices
model User { // 1. ID: Consistent type (Int or String/cuid) id Int @id @default(autoincrement())
// 2. Critical fields: NOT NULL email String @unique name String
// 3. Optional fields: Clearly nullable phone String? bio String?
// 4. Audit timestamps: Always include createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
// 5. Relations: Proper foreign keys orders Order[]
// 6. Indexes: On frequently queried fields @@index([email]) @@index([createdAt]) }
model Order { id Int @id @default(autoincrement())
// Foreign key with clear naming userId Int user User @relation(fields: [userId], references: [id])
// Enum for status (type safety) status OrderStatus @default(PENDING)
// Decimal for money total Decimal @db.Decimal(10, 2)
// Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
// Indexes on foreign keys @@index([userId]) @@index([status]) @@index([createdAt]) }
enum OrderStatus { PENDING PAID SHIPPED DELIVERED CANCELLED }
Auto-fix Migrations // scripts/fix-schema.ts async function generateFixMigrations(violations: Violation[]) { const migrations: string[] = [];
violations.forEach((v) => {
if (v.category === "nullability" && v.column) {
migrations.push(
ALTER TABLE "${v.table}" ALTER COLUMN "${v.column}" SET NOT NULL;
);
}
if (
v.category === "performance" &&
v.recommendation.startsWith("CREATE INDEX")
) {
migrations.push(v.recommendation + ";");
}
if (v.category === "audit" && v.message.includes("created_at")) {
migrations.push(
`ALTER TABLE "${v.table}" ADD COLUMN "created_at" TIMESTAMP NOT NULL DEFAULT NOW();`
);
}
});
console.log("-- Auto-generated fixes\n"); migrations.forEach((m) => console.log(m)); }
Best Practices Run regularly: Weekly schema audits Enforce in CI: Fail builds on errors Document standards: Team agreement on conventions Gradual adoption: Fix incrementally Use enums: For status fields Always timestamp: created_at and updated_at Index foreign keys: Performance best practice Output Checklist Naming violations report Type consistency checks Nullability issues identified Missing constraints flagged Performance issues (missing indexes) Recommended fixes generated Auto-fix migrations provided Schema standards documented