
Database Schema Designer
- 99 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
Database Schema Designer is a Claude skill that designs normalized relational schemas from requirements and generates migrations, types, RLS policies, and ERDs for PostgreSQL, MySQL, and SQLite.
About
Database Schema Designer turns feature requirements into normalized relational schemas for PostgreSQL, MySQL, and SQLite. It extracts entities and relationships, applies normalization, adds cross-cutting concerns like timestamps and audit trails, and generates migrations, types, seed data, RLS policies, and ERDs. A developer uses it when designing tables for a new feature, reviewing a schema, planning a breaking migration, or adding multi-tenancy.
- Designs normalized schemas (1NF-3NF) from natural-language requirements and generates DDL
- Emits Drizzle, Prisma, TypeORM, and Alembic migrations plus TypeScript/Zod and Python/Pydantic types
- Handles multi-tenancy with Row-Level Security, soft deletes, audit trails, and index strategies
Database Schema Designer by the numbers
- 99 all-time installs (skills.sh)
- Ranked #330 of 911 Databases skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
database-schema-designer capabilities & compatibility
- Capabilities
- doc drift detector
- Works with
- postgres · mysql
- Use cases
- database · api development
- Pricing
- Free
What database-schema-designer says it does
Design normalized relational database schemas from requirements and generate migrations, TypeScript/Python types, seed data, Row-Level Security policies, index strategies, and ERD diagrams.
Supports PostgreSQL, MySQL, and SQLite with Drizzle, Prisma, TypeORM, and Alembic.
Row-Level Security policies for multi-tenant isolation
npx skills add https://github.com/borghei/claude-skills --skill database-schema-designerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 99 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Design normalized relational database schemas and generate migrations, types, and RLS policies from requirements.
Who is it for?
Developers modeling tables for a new feature or adding multi-tenancy, audit trails, and RLS to a relational schema.
Skip if: NoSQL/document data modeling or analytics query tuning; it targets relational DDL and migrations.
When should I use this skill?
Designing tables for a new feature, reviewing an existing schema, planning a breaking migration, or adding multi-tenancy.
What you get
A normalized schema with forward/rollback migrations, generated types, RLS policies, indexes, and an ERD.
- DDL / schema definition
- forward and rollback migrations
- generated TypeScript/Zod or Python/Pydantic types
By the numbers
- 4 supported ORMs (Drizzle, Prisma, TypeORM, Alembic)
- 3 supported databases (PostgreSQL, MySQL, SQLite)
- normalization 1NF through 3NF
Files
Database Schema Designer
Tier: POWERFUL Category: Engineering / Data Architecture Maintainer: Claude Skills Team
Overview
Design normalized relational database schemas from requirements and generate migrations, TypeScript/Python types, seed data, Row-Level Security policies, index strategies, and ERD diagrams. Handles multi-tenancy, soft deletes, audit trails, optimistic locking, polymorphic associations, and temporal data patterns. Supports PostgreSQL, MySQL, and SQLite with Drizzle, Prisma, TypeORM, and Alembic.
Keywords
database schema, schema design, normalization, migration, ERD, row-level security, indexing, multi-tenancy, soft deletes, audit trail, Drizzle, Prisma, PostgreSQL
Core Capabilities
1. Schema Design from Requirements
- Extract entities and relationships from natural language requirements
- Apply normalization rules (1NF through 3NF with denormalization guidance)
- Add cross-cutting concerns: timestamps, soft deletes, audit, versioning
- Generate complete DDL with constraints, defaults, and comments
2. Migration Planning
- Generate forward and rollback migrations
- Plan zero-downtime migrations for large tables
- Handle column additions, type changes, and data backfills
- Support Drizzle, Prisma, TypeORM, Alembic, and raw SQL
3. Index Strategy
- Composite indexes for common query patterns
- Partial indexes for filtered queries (e.g., active records only)
- Covering indexes to eliminate table lookups
- GIN/GiST indexes for full-text search and JSONB
- Index bloat detection and maintenance
4. Type Generation
- TypeScript interfaces and Zod schemas from DB schema
- Python dataclasses and Pydantic models
- Enums as string unions (not database enums for migration safety)
5. Security
- Row-Level Security policies for multi-tenant isolation
- Column-level encryption for PII
- Audit logging with before/after JSON snapshots
When to Use
- Designing tables for a new feature
- Reviewing an existing schema for normalization or performance issues
- Adding multi-tenancy to a single-tenant schema
- Planning a breaking schema migration
- Generating ERD documentation for a service
Schema Design Process
Step 1: Requirements to Entities
Given requirements like:
"Users can create workspaces. Each workspace has projects. Projects contain tasks with assignees, labels, and due dates. We need audit trails and multi-tenant isolation."
Extract entities:
User, Workspace, WorkspaceMember, Project, Task, TaskAssignment,
Label, TaskLabel (junction), AuditLogStep 2: Identify Relationships
User 1──* WorkspaceMember *──1 Workspace
Workspace 1──* Project
Project 1──* Task
Task *──* User (via TaskAssignment)
Task *──* Label (via TaskLabel)
User 1──* AuditLogStep 3: Add Cross-Cutting Concerns
Every table gets:
id— CUID2 or UUIDv7 (sortable, non-sequential)created_at— TIMESTAMPTZ, server-side defaultupdated_at— TIMESTAMPTZ, updated on every write
Tenant-scoped tables additionally get:
workspace_id— FK to workspaces, included in every query- RLS policy enforcing workspace isolation
Auditable tables additionally get:
created_by_id— FK to usersupdated_by_id— FK to usersdeleted_at— TIMESTAMPTZ for soft deletesversion— INTEGER for optimistic locking
Step 4: Full Schema (Drizzle ORM)
import {
pgTable, text, timestamp, integer, boolean, uniqueIndex, index, pgEnum
} from 'drizzle-orm/pg-core'
import { createId } from '@paralleldrive/cuid2'
// Enums as pgEnum for type safety, but string columns also acceptable
export const taskStatusEnum = pgEnum('task_status', ['todo', 'in_progress', 'in_review', 'done'])
export const taskPriorityEnum = pgEnum('task_priority', ['low', 'medium', 'high', 'urgent'])
export const memberRoleEnum = pgEnum('member_role', ['owner', 'admin', 'member', 'viewer'])
// ──── WORKSPACES ────
export const workspaces = pgTable('workspaces', {
id: text('id').primaryKey().$defaultFn(createId),
name: text('name').notNull(),
slug: text('slug').notNull(),
plan: text('plan').notNull().default('free'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (t) => [
uniqueIndex('workspaces_slug_idx').on(t.slug),
])
// ──── USERS ────
export const users = pgTable('users', {
id: text('id').primaryKey().$defaultFn(createId),
email: text('email').notNull(),
name: text('name'),
avatarUrl: text('avatar_url'),
passwordHash: text('password_hash'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (t) => [
uniqueIndex('users_email_idx').on(t.email),
])
// ──── WORKSPACE MEMBERS ────
export const workspaceMembers = pgTable('workspace_members', {
id: text('id').primaryKey().$defaultFn(createId),
workspaceId: text('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
role: memberRoleEnum('role').notNull().default('member'),
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull(),
}, (t) => [
uniqueIndex('workspace_members_unique').on(t.workspaceId, t.userId),
index('workspace_members_workspace_idx').on(t.workspaceId),
index('workspace_members_user_idx').on(t.userId),
])
// ──── PROJECTS ────
export const projects = pgTable('projects', {
id: text('id').primaryKey().$defaultFn(createId),
workspaceId: text('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
status: text('status').notNull().default('active'),
ownerId: text('owner_id').notNull().references(() => users.id),
createdById: text('created_by_id').references(() => users.id),
updatedById: text('updated_by_id').references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (t) => [
index('projects_workspace_idx').on(t.workspaceId),
index('projects_workspace_status_idx').on(t.workspaceId, t.status),
])
// ──── TASKS ────
export const tasks = pgTable('tasks', {
id: text('id').primaryKey().$defaultFn(createId),
projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
status: taskStatusEnum('status').notNull().default('todo'),
priority: taskPriorityEnum('priority').notNull().default('medium'),
position: integer('position').notNull().default(0),
dueDate: timestamp('due_date', { withTimezone: true }),
version: integer('version').notNull().default(1),
createdById: text('created_by_id').notNull().references(() => users.id),
updatedById: text('updated_by_id').references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (t) => [
index('tasks_project_idx').on(t.projectId),
index('tasks_project_status_idx').on(t.projectId, t.status),
index('tasks_due_date_idx').on(t.dueDate).where(sql`deleted_at IS NULL`),
])
// ──── AUDIT LOG ────
export const auditLog = pgTable('audit_log', {
id: text('id').primaryKey().$defaultFn(createId),
workspaceId: text('workspace_id').notNull().references(() => workspaces.id),
userId: text('user_id').notNull().references(() => users.id),
action: text('action').notNull(), // 'create' | 'update' | 'delete'
entityType: text('entity_type').notNull(), // 'task' | 'project' | etc.
entityId: text('entity_id').notNull(),
before: text('before'), // JSON snapshot
after: text('after'), // JSON snapshot
ipAddress: text('ip_address'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (t) => [
index('audit_log_workspace_idx').on(t.workspaceId),
index('audit_log_entity_idx').on(t.entityType, t.entityId),
index('audit_log_user_idx').on(t.userId),
index('audit_log_created_idx').on(t.createdAt),
])Row-Level Security (PostgreSQL)
-- Enable RLS on tenant-scoped tables
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
-- Create application role
CREATE ROLE app_user;
-- Projects: users can only see projects in their workspace
CREATE POLICY projects_workspace_isolation ON projects
FOR ALL TO app_user
USING (
workspace_id IN (
SELECT wm.workspace_id FROM workspace_members wm
WHERE wm.user_id = current_setting('app.current_user_id')::text
)
);
-- Tasks: access through project's workspace membership
CREATE POLICY tasks_workspace_isolation ON tasks
FOR ALL TO app_user
USING (
project_id IN (
SELECT p.id FROM projects p
JOIN workspace_members wm ON wm.workspace_id = p.workspace_id
WHERE wm.user_id = current_setting('app.current_user_id')::text
)
);
-- Soft delete filter: never show deleted records to app users
CREATE POLICY tasks_hide_deleted ON tasks
FOR SELECT TO app_user
USING (deleted_at IS NULL);
-- Set user context at request start (in middleware)
-- SELECT set_config('app.current_user_id', $1, true);Index Strategy Decision Framework
Query Pattern → Index Type
─────────────────────────────────────────────────────
WHERE col = value → B-tree (default)
WHERE col1 = v1 AND col2 = v2 → Composite B-tree (col1, col2)
WHERE col = value AND deleted_at IS NULL → Partial index with WHERE clause
WHERE col IN (v1, v2, v3) → B-tree (handles IN efficiently)
WHERE col LIKE 'prefix%' → B-tree (prefix match only)
WHERE col LIKE '%substring%' → GIN with pg_trgm extension
WHERE jsonb_col @> '{"key": "val"}' → GIN on JSONB column
WHERE to_tsvector(col) @@ query → GIN on tsvector
ORDER BY col DESC LIMIT N → B-tree DESC
SELECT a, b WHERE a = v → Covering index INCLUDE(b)Index Anti-Patterns
| Anti-Pattern | Why It Hurts | Fix |
|---|---|---|
| Index on every column | Write overhead, storage bloat | Index only queried columns |
| No index on foreign keys | Slow JOINs and CASCADE deletes | Always index FK columns |
| Missing partial index for soft deletes | Full table scan on WHERE deleted_at IS NULL | Add WHERE deleted_at IS NULL to index |
| Composite index in wrong order | Index unused for prefix queries | Put most selective / equality column first |
| No index maintenance | Bloated indexes, slow queries | Schedule REINDEX CONCURRENTLY |
Zero-Downtime Migration Patterns
Adding a NOT NULL Column to a Large Table
-- WRONG: locks table for duration of ALTER
ALTER TABLE tasks ADD COLUMN assignee_id TEXT NOT NULL;
-- RIGHT: three-phase migration
-- Phase 1: Add nullable column (instant, no lock)
ALTER TABLE tasks ADD COLUMN assignee_id TEXT;
-- Phase 2: Backfill in batches (no lock)
UPDATE tasks SET assignee_id = created_by_id
WHERE assignee_id IS NULL AND id > $last_processed_id
LIMIT 10000;
-- Repeat until all rows backfilled
-- Phase 3: Add NOT NULL constraint (brief lock, but validates existing data)
ALTER TABLE tasks ALTER COLUMN assignee_id SET NOT NULL;Renaming a Column Safely
-- WRONG: ALTER TABLE RENAME COLUMN breaks all running code instantly
-- RIGHT: expand-contract pattern
-- Phase 1: Add new column, write to both
ALTER TABLE tasks ADD COLUMN assignee_user_id TEXT;
-- Deploy code that writes to BOTH old_name and new_name
-- Phase 2: Backfill
UPDATE tasks SET assignee_user_id = old_assignee WHERE assignee_user_id IS NULL;
-- Phase 3: Switch reads to new column
-- Deploy code that reads from new_name only
-- Phase 4: Drop old column (after all deployments use new name)
ALTER TABLE tasks DROP COLUMN old_assignee;ERD Generation (Mermaid)
erDiagram
Workspace ||--o{ WorkspaceMember : has
Workspace ||--o{ Project : contains
User ||--o{ WorkspaceMember : joins
User ||--o{ Task : creates
Project ||--o{ Task : contains
Task ||--o{ TaskAssignment : has
Task ||--o{ TaskLabel : has
Label ||--o{ TaskLabel : tags
User ||--o{ TaskAssignment : assigned
Workspace {
text id PK
text name
text slug UK
text plan
}
User {
text id PK
text email UK
text name
}
Project {
text id PK
text workspace_id FK
text name
text status
}
Task {
text id PK
text project_id FK
text title
enum status
enum priority
int version
timestamp deleted_at
}Common Pitfalls
- No index on foreign keys — every FK column needs an index for JOIN and CASCADE performance
- Soft deletes without partial index —
WHERE deleted_at IS NULLwithout index causes full table scans - Sequential integer IDs exposed in URLs — reveals entity count; use CUID2 or UUIDv7 instead
- Adding NOT NULL to a large table — locks the table; use the three-phase pattern above
- Database enums for status fields — altering enums requires migration; use text with CHECK constraint
- No optimistic locking — concurrent updates silently overwrite each other; add a
versioncolumn - RLS not tested — always test RLS policies with a non-superuser role in staging
- Missing updated_at trigger — without a trigger, updated_at only updates when application code remembers to set it
Best Practices
1. Timestamps on every table — created_at and updated_at as TIMESTAMPTZ with server defaults 2. Soft deletes for user-facing data — deleted_at instead of hard DELETE for audit and recovery 3. CUID2 or UUIDv7 as primary keys — sortable, non-sequential, globally unique 4. Index every foreign key column — required for JOIN performance and CASCADE operations 5. Partial indexes for filtered queries — WHERE deleted_at IS NULL saves significant scan time 6. RLS over application-level filtering — the database enforces tenancy, not just application code 7. Version column for optimistic locking — WHERE version = $expected_version prevents lost updates 8. Audit log with JSON snapshots — store before/after state for compliance and debugging
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Migration locks table for minutes | Adding NOT NULL column or index on large table without batching | Use the three-phase migration pattern: add nullable, backfill in batches, then set NOT NULL |
| RLS policies silently return empty results | current_setting('app.current_user_id') not set before query | Verify middleware calls set_config at the start of every request; add a test that queries as a non-superuser role |
| Composite index not used by query planner | Columns in the WHERE clause do not match the index prefix order | Reorder index columns so equality predicates come first, then range predicates; run EXPLAIN ANALYZE to confirm |
| Soft-deleted records appear in API responses | Application queries missing WHERE deleted_at IS NULL filter | Add a default scope or database view that excludes soft-deleted rows; prefer RLS policy for enforcement |
| Optimistic locking conflicts spike after deploy | New code path writes without incrementing the version column | Audit all UPDATE statements to include SET version = version + 1 and WHERE version = $expected |
| Foreign key CASCADE deletes are slow | Missing index on the child table's FK column | Add a B-tree index on every FK column; verify with EXPLAIN on a DELETE of the parent row |
| CUID2/UUIDv7 IDs cause index bloat over time | Text-based IDs are wider than integers, increasing B-tree page splits | Schedule REINDEX CONCURRENTLY during low-traffic windows; monitor pg_stat_user_indexes for bloat ratio |
Success Criteria
- Schema passes 3NF validation — no transitive dependencies remain unless documented as intentional denormalization for read performance
- All foreign key columns are indexed — zero FK columns without a corresponding B-tree index, verified via
pg_indexesquery - Zero-downtime migrations verified — every migration executes without
ACCESS EXCLUSIVElocks exceeding 5 seconds on tables with 100K+ rows - RLS policies tested with non-superuser role — at least one integration test per tenant-scoped table confirms cross-tenant data isolation
- Type generation matches schema — generated TypeScript interfaces or Pydantic models have zero drift from the current DDL, validated in CI
- Query performance meets SLA — 95th percentile query latency under 50ms for indexed queries on tables up to 10M rows
- Audit log captures all mutations — every INSERT, UPDATE, and DELETE on auditable tables produces a corresponding audit_log entry with before/after snapshots
Scope & Limitations
This skill covers:
- Relational schema design for PostgreSQL, MySQL, and SQLite including normalization through 3NF
- Migration generation and zero-downtime migration planning for Drizzle, Prisma, TypeORM, and Alembic
- Row-Level Security policies, index strategy, and type generation (TypeScript and Python)
- Cross-cutting patterns: multi-tenancy, soft deletes, audit trails, optimistic locking, and temporal data
This skill does NOT cover:
- NoSQL or document database design (MongoDB, DynamoDB, Cassandra) — see
senior-data-engineerfor broader data store guidance - Query optimization and execution plan analysis beyond index recommendations — see
performance-profilerfor runtime profiling - Database infrastructure provisioning, replication, or failover configuration — see
senior-cloud-architectfor cloud database setup - Application-layer ORM patterns, connection pooling, or caching strategies — see
senior-backendfor backend architecture decisions
Integration Points
| Skill | Integration | Data Flow |
|---|---|---|
migration-architect | Hands off generated DDL and migration files for sequencing across services | Schema Designer produces migrations, Migration Architect orchestrates cross-service rollout order |
api-design-reviewer | Schema entities map directly to API resource models and endpoint structure | Schema entities and relationships feed into REST/GraphQL resource definitions and validation rules |
senior-backend | Generated types and ORM schemas plug into repository and service layers | TypeScript interfaces and Pydantic models from schema become the backend's data access contracts |
performance-profiler | Index strategy recommendations are validated against real query execution plans | Schema Designer proposes indexes, Performance Profiler confirms effectiveness with EXPLAIN ANALYZE data |
senior-secops | RLS policies and column encryption align with security compliance requirements | Security requirements flow in, RLS policies and encryption specifications flow out for audit verification |
observability-designer | Audit log schema provides the foundation for operational dashboards and alerting | Audit log table structure feeds into observability pipelines for change tracking and anomaly detection |
#!/usr/bin/env python3
"""Parse SQL DDL and generate Mermaid ER diagrams showing table relationships.
Reads CREATE TABLE statements and outputs a Mermaid erDiagram block with:
- All tables and their columns (with PK/FK/UK annotations)
- Relationships inferred from REFERENCES clauses
- Nullable vs required relationship cardinality
Supports PostgreSQL, MySQL, and SQLite DDL syntax.
"""
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
@dataclass
class Column:
name: str
data_type: str
is_pk: bool = False
is_fk: bool = False
is_unique: bool = False
nullable: bool = True
references_table: Optional[str] = None
references_column: Optional[str] = None
@dataclass
class Table:
name: str
columns: List[Column] = field(default_factory=list)
@dataclass
class Relationship:
from_table: str
to_table: str
from_column: str
to_column: str
nullable: bool
label: str
def parse_ddl(sql: str) -> Tuple[List[Table], List[Relationship]]:
"""Parse SQL DDL into tables and relationships."""
sql = re.sub(r'--[^\n]*', '', sql)
sql = re.sub(r'/\*.*?\*/', '', sql, flags=re.DOTALL)
tables: List[Table] = []
relationships: List[Relationship] = []
table_map: Dict[str, Table] = {}
table_pattern = re.compile(
r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?'
r'(?:"?(\w+)"?\.)?'
r'"?(\w+)"?'
r'\s*\((.*?)\)\s*;',
re.IGNORECASE | re.DOTALL
)
for match in table_pattern.finditer(sql):
_schema, tname, body = match.group(1), match.group(2), match.group(3)
table = Table(name=tname)
# Track unique constraints at table level
unique_cols = set()
unique_match = re.findall(r'UNIQUE\s*\(\s*"?(\w+)"?\s*\)', body, re.IGNORECASE)
for u in unique_match:
unique_cols.add(u)
pk_cols = set()
pk_match = re.search(r'PRIMARY\s+KEY\s*\(([^)]+)\)', body, re.IGNORECASE)
if pk_match:
pk_cols = {c.strip().strip('"') for c in pk_match.group(1).split(',')}
parts = _split_comma_top_level(body)
for part in parts:
part = part.strip()
upper = part.upper()
# Skip table-level constraints
if re.match(r'(PRIMARY\s+KEY|UNIQUE|INDEX|KEY|CONSTRAINT|CHECK|FOREIGN\s+KEY)\s', upper):
# But extract FOREIGN KEY ... REFERENCES
fk_match = re.match(
r'(?:CONSTRAINT\s+\w+\s+)?FOREIGN\s+KEY\s*\(\s*"?(\w+)"?\s*\)\s*'
r'REFERENCES\s+"?(\w+)"?\s*\(\s*"?(\w+)"?\s*\)',
part, re.IGNORECASE
)
if fk_match:
fk_col, ref_table, ref_col = fk_match.group(1), fk_match.group(2), fk_match.group(3)
# Mark existing column as FK
for col in table.columns:
if col.name == fk_col:
col.is_fk = True
col.references_table = ref_table
col.references_column = ref_col
relationships.append(Relationship(
from_table=tname, to_table=ref_table,
from_column=fk_col, to_column=ref_col,
nullable=col.nullable,
label=fk_col.replace('_id', '').replace('_', ' ')
))
break
continue
# Column definition
col_match = re.match(r'"?(\w+)"?\s+(\w[\w\s()]*)', part, re.IGNORECASE)
if not col_match:
continue
cname = col_match.group(1)
ctype = col_match.group(2).strip().split()[0] # first word of type
is_pk = bool(re.search(r'PRIMARY\s+KEY', part, re.IGNORECASE)) or cname in pk_cols
is_unique = bool(re.search(r'\bUNIQUE\b', part, re.IGNORECASE)) or cname in unique_cols
nullable = not bool(re.search(r'NOT\s+NULL', part, re.IGNORECASE)) and not is_pk
ref_match = re.search(
r'REFERENCES\s+"?(\w+)"?\s*(?:\(\s*"?(\w+)"?\s*\))?',
part, re.IGNORECASE
)
ref_table = ref_match.group(1) if ref_match else None
ref_col = ref_match.group(2) if ref_match and ref_match.group(2) else 'id'
col = Column(
name=cname, data_type=_normalize_type(ctype),
is_pk=is_pk, is_fk=bool(ref_table), is_unique=is_unique,
nullable=nullable, references_table=ref_table, references_column=ref_col
)
table.columns.append(col)
if ref_table:
relationships.append(Relationship(
from_table=tname, to_table=ref_table,
from_column=cname, to_column=ref_col,
nullable=nullable,
label=cname.replace('_id', '').replace('_', ' ')
))
tables.append(table)
table_map[tname] = table
return tables, relationships
def _split_comma_top_level(s: str) -> List[str]:
"""Split on commas not inside parentheses."""
parts = []
depth = 0
current = []
for ch in s:
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
elif ch == ',' and depth == 0:
parts.append(''.join(current))
current = []
continue
current.append(ch)
if current:
parts.append(''.join(current))
return parts
def _normalize_type(t: str) -> str:
"""Normalize SQL types to short display names."""
mapping = {
'CHARACTER': 'varchar', 'VARCHAR': 'varchar', 'TEXT': 'text',
'INTEGER': 'int', 'INT': 'int', 'BIGINT': 'bigint',
'SERIAL': 'serial', 'BIGSERIAL': 'bigserial',
'BOOLEAN': 'bool', 'BOOL': 'bool',
'TIMESTAMP': 'timestamp', 'TIMESTAMPTZ': 'timestamptz',
'DATE': 'date', 'TIME': 'time',
'NUMERIC': 'numeric', 'DECIMAL': 'decimal',
'FLOAT': 'float', 'DOUBLE': 'double',
'REAL': 'real', 'UUID': 'uuid', 'JSONB': 'jsonb', 'JSON': 'json',
}
return mapping.get(t.upper(), t.lower())
def _table_display_name(name: str) -> str:
"""Convert snake_case to PascalCase for Mermaid display."""
return ''.join(word.capitalize() for word in name.split('_'))
def generate_mermaid(tables: List[Table], relationships: List[Relationship]) -> str:
"""Generate Mermaid ER diagram syntax."""
lines = ["erDiagram"]
# Deduplicate relationships by (from_table, to_table, from_column)
seen = set()
for rel in relationships:
key = (rel.from_table, rel.to_table, rel.from_column)
if key in seen:
continue
seen.add(key)
from_name = _table_display_name(rel.from_table)
to_name = _table_display_name(rel.to_table)
# Determine cardinality:
# parent (to_table) has one, child (from_table) has many
# nullable FK = zero-or-more, non-nullable = one-or-more
if rel.nullable:
# to_table ||--o{ from_table : "label"
connector = "||--o{"
else:
connector = "||--|{"
lines.append(f" {to_name} {connector} {from_name} : \"{rel.label}\"")
lines.append("")
# Table definitions
for table in tables:
display = _table_display_name(table.name)
lines.append(f" {display} {{")
for col in table.columns:
annotations = []
if col.is_pk:
annotations.append("PK")
if col.is_fk:
annotations.append("FK")
if col.is_unique and not col.is_pk:
annotations.append("UK")
ann_str = ",".join(annotations)
if ann_str:
lines.append(f" {col.data_type} {col.name} {ann_str}")
else:
lines.append(f" {col.data_type} {col.name}")
lines.append(" }")
return '\n'.join(lines)
def generate_summary(tables: List[Table], relationships: List[Relationship]) -> Dict:
"""Generate a structured summary of the schema."""
return {
"table_count": len(tables),
"relationship_count": len(relationships),
"tables": [
{
"name": t.name,
"column_count": len(t.columns),
"columns": [
{
"name": c.name,
"type": c.data_type,
"pk": c.is_pk,
"fk": c.is_fk,
"unique": c.is_unique,
"nullable": c.nullable,
**({"references": f"{c.references_table}.{c.references_column}"} if c.references_table else {})
}
for c in t.columns
]
}
for t in tables
],
"relationships": [
{
"from": f"{r.from_table}.{r.from_column}",
"to": f"{r.to_table}.{r.to_column}",
"nullable": r.nullable
}
for r in relationships
]
}
def format_human(tables: List[Table], relationships: List[Relationship], mermaid: str) -> str:
"""Format output for human reading."""
lines = []
lines.append("ERD Generator Report")
lines.append("=" * 50)
lines.append(f"Tables: {len(tables)}")
lines.append(f"Relationships: {len(relationships)}")
lines.append("")
lines.append("Mermaid ER Diagram:")
lines.append("-" * 50)
lines.append(f"```mermaid")
lines.append(mermaid)
lines.append("```")
lines.append("")
lines.append("Table Summary:")
lines.append("-" * 50)
for t in tables:
pk_cols = [c.name for c in t.columns if c.is_pk]
fk_cols = [c.name for c in t.columns if c.is_fk]
lines.append(f" {t.name} ({len(t.columns)} columns)")
if pk_cols:
lines.append(f" PK: {', '.join(pk_cols)}")
if fk_cols:
lines.append(f" FK: {', '.join(fk_cols)}")
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(
description="Parse SQL DDL and generate Mermaid ER diagrams showing table relationships."
)
parser.add_argument("file", help="Path to SQL DDL file")
parser.add_argument("--json", action="store_true", dest="json_output",
help="Output results as JSON (includes Mermaid diagram and structured summary)")
parser.add_argument("-o", "--output", help="Write Mermaid diagram to file (raw .mmd)")
args = parser.parse_args()
try:
with open(args.file, 'r') as f:
sql = f.read()
except FileNotFoundError:
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(2)
except IOError as e:
print(f"Error reading file: {e}", file=sys.stderr)
sys.exit(2)
tables, relationships = parse_ddl(sql)
if not tables:
print("Warning: No CREATE TABLE statements found.", file=sys.stderr)
sys.exit(0)
mermaid = generate_mermaid(tables, relationships)
if args.output:
try:
with open(args.output, 'w') as f:
f.write(mermaid + '\n')
print(f"Mermaid diagram written to {args.output}")
except IOError as e:
print(f"Error writing output file: {e}", file=sys.stderr)
sys.exit(2)
if args.json_output:
summary = generate_summary(tables, relationships)
summary["mermaid"] = mermaid
print(json.dumps(summary, indent=2))
elif not args.output:
print(format_human(tables, relationships, mermaid))
elif args.output and not args.json_output:
# Already wrote file, print summary
print(f"Tables: {len(tables)}, Relationships: {len(relationships)}")
sys.exit(0)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""Compare two SQL schema files and generate migration SQL (ALTER statements).
Performs a structural diff between an 'old' and 'new' SQL DDL schema and produces:
- ALTER TABLE ADD COLUMN for new columns
- ALTER TABLE DROP COLUMN for removed columns
- ALTER TABLE ALTER COLUMN for type changes
- CREATE TABLE for new tables
- DROP TABLE for removed tables
- CREATE INDEX / DROP INDEX for index changes
- Rollback SQL for reversing the migration
Supports PostgreSQL syntax. Designed for review before execution.
"""
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple, Set
@dataclass
class Column:
name: str
data_type: str
nullable: bool
is_pk: bool
default: Optional[str]
references: Optional[str]
full_definition: str # original DDL fragment for comparison
@dataclass
class Index:
name: str
table: str
columns: List[str]
unique: bool
where_clause: Optional[str]
original: str
@dataclass
class Table:
name: str
columns: Dict[str, Column] = field(default_factory=dict)
pk_columns: List[str] = field(default_factory=list)
original_ddl: str = ""
@dataclass
class Migration:
description: str
up_sql: str
down_sql: str
risk: str # "low", "medium", "high"
def parse_schema(sql: str) -> Tuple[Dict[str, Table], Dict[str, Index]]:
"""Parse SQL DDL into tables and indexes."""
sql = re.sub(r'--[^\n]*', '', sql)
sql = re.sub(r'/\*.*?\*/', '', sql, flags=re.DOTALL)
tables: Dict[str, Table] = {}
indexes: Dict[str, Index] = {}
# Parse CREATE TABLE
table_pattern = re.compile(
r'(CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?'
r'(?:"?(\w+)"?\.)?'
r'"?(\w+)"?'
r'\s*\((.*?)\)\s*;)',
re.IGNORECASE | re.DOTALL
)
for match in table_pattern.finditer(sql):
full_ddl = match.group(1)
tname = match.group(3)
body = match.group(4)
table = Table(name=tname, original_ddl=full_ddl)
pk_cols = set()
pk_match = re.search(r'PRIMARY\s+KEY\s*\(([^)]+)\)', body, re.IGNORECASE)
if pk_match:
pk_cols = {c.strip().strip('"') for c in pk_match.group(1).split(',')}
parts = _split_comma_top_level(body)
for part in parts:
part = part.strip()
upper = part.upper()
if re.match(r'(PRIMARY\s+KEY|UNIQUE|INDEX|KEY|CONSTRAINT|CHECK|FOREIGN\s+KEY)\s', upper):
continue
col_match = re.match(r'"?(\w+)"?\s+(.+)', part, re.IGNORECASE)
if not col_match:
continue
cname = col_match.group(1)
rest = col_match.group(2).strip()
# Extract type (first token, possibly with parenthesized args)
type_match = re.match(r'(\w+(?:\s*\([^)]*\))?)', rest)
ctype = type_match.group(1).upper() if type_match else rest.split()[0].upper()
is_pk = bool(re.search(r'PRIMARY\s+KEY', part, re.IGNORECASE)) or cname in pk_cols
nullable = not bool(re.search(r'NOT\s+NULL', part, re.IGNORECASE)) and not is_pk
default_match = re.search(r'DEFAULT\s+((?:\'[^\']*\'|\S+))', part, re.IGNORECASE)
ref_match = re.search(r'REFERENCES\s+"?(\w+)"?', part, re.IGNORECASE)
col = Column(
name=cname,
data_type=ctype,
nullable=nullable,
is_pk=is_pk,
default=default_match.group(1) if default_match else None,
references=ref_match.group(1) if ref_match else None,
full_definition=rest,
)
table.columns[cname] = col
if is_pk:
table.pk_columns.append(cname)
tables[tname] = table
# Parse CREATE INDEX
idx_pattern = re.compile(
r'(CREATE\s+(UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?'
r'"?(\w+)"?\s+ON\s+"?(\w+)"?\s*(?:USING\s+\w+\s*)?\(([^)]+)\)'
r'(?:\s+WHERE\s+(.+?))?)\s*;',
re.IGNORECASE | re.DOTALL
)
for match in idx_pattern.finditer(sql):
original = match.group(1) + ';'
unique = bool(match.group(2))
idx_name = match.group(3)
tname = match.group(4)
cols = [c.strip().strip('"') for c in match.group(5).split(',')]
where = match.group(6).strip() if match.group(6) else None
indexes[idx_name] = Index(
name=idx_name, table=tname, columns=cols,
unique=unique, where_clause=where, original=original
)
return tables, indexes
def _split_comma_top_level(s: str) -> List[str]:
parts = []
depth = 0
current = []
for ch in s:
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
elif ch == ',' and depth == 0:
parts.append(''.join(current))
current = []
continue
current.append(ch)
if current:
parts.append(''.join(current))
return parts
def diff_schemas(
old_tables: Dict[str, Table], old_indexes: Dict[str, Index],
new_tables: Dict[str, Table], new_indexes: Dict[str, Index]
) -> List[Migration]:
"""Compare two schemas and generate migrations."""
migrations: List[Migration] = []
old_names = set(old_tables.keys())
new_names = set(new_tables.keys())
# New tables
for tname in sorted(new_names - old_names):
table = new_tables[tname]
migrations.append(Migration(
description=f"Create table '{tname}'",
up_sql=table.original_ddl,
down_sql=f"DROP TABLE IF EXISTS {tname};",
risk="low"
))
# Dropped tables
for tname in sorted(old_names - new_names):
table = old_tables[tname]
migrations.append(Migration(
description=f"Drop table '{tname}'",
up_sql=f"DROP TABLE IF EXISTS {tname};",
down_sql=table.original_ddl,
risk="high"
))
# Modified tables
for tname in sorted(old_names & new_names):
old_t = old_tables[tname]
new_t = new_tables[tname]
old_cols = set(old_t.columns.keys())
new_cols = set(new_t.columns.keys())
# Added columns
for cname in sorted(new_cols - old_cols):
col = new_t.columns[cname]
parts = [f"ALTER TABLE {tname} ADD COLUMN {cname} {col.data_type}"]
if not col.nullable:
if col.default:
parts.append(f"NOT NULL DEFAULT {col.default}")
else:
# Suggest safe pattern for NOT NULL without default
parts = [
f"-- Phase 1: Add nullable column",
f"ALTER TABLE {tname} ADD COLUMN {cname} {col.data_type};",
f"-- Phase 2: Backfill data (adjust value as needed)",
f"-- UPDATE {tname} SET {cname} = <default_value> WHERE {cname} IS NULL;",
f"-- Phase 3: Set NOT NULL after backfill",
f"-- ALTER TABLE {tname} ALTER COLUMN {cname} SET NOT NULL;",
]
migrations.append(Migration(
description=f"Add column '{cname}' to '{tname}' (NOT NULL, needs backfill)",
up_sql='\n'.join(parts),
down_sql=f"ALTER TABLE {tname} DROP COLUMN IF EXISTS {cname};",
risk="medium"
))
continue
else:
if col.default:
parts.append(f"DEFAULT {col.default}")
if col.references:
parts.append(f"REFERENCES {col.references}")
up_sql = ' '.join(parts) + ';'
migrations.append(Migration(
description=f"Add column '{cname}' to '{tname}'",
up_sql=up_sql,
down_sql=f"ALTER TABLE {tname} DROP COLUMN IF EXISTS {cname};",
risk="low"
))
# Removed columns
for cname in sorted(old_cols - new_cols):
col = old_t.columns[cname]
rebuild_parts = [f"{cname} {col.data_type}"]
if not col.nullable:
rebuild_parts.append("NOT NULL")
if col.default:
rebuild_parts.append(f"DEFAULT {col.default}")
migrations.append(Migration(
description=f"Drop column '{cname}' from '{tname}'",
up_sql=f"ALTER TABLE {tname} DROP COLUMN IF EXISTS {cname};",
down_sql=f"ALTER TABLE {tname} ADD COLUMN {' '.join(rebuild_parts)};",
risk="high"
))
# Modified columns (type or nullability changes)
for cname in sorted(old_cols & new_cols):
old_c = old_t.columns[cname]
new_c = new_t.columns[cname]
stmts_up = []
stmts_down = []
if old_c.data_type != new_c.data_type:
stmts_up.append(
f"ALTER TABLE {tname} ALTER COLUMN {cname} TYPE {new_c.data_type} "
f"USING {cname}::{new_c.data_type};"
)
stmts_down.append(
f"ALTER TABLE {tname} ALTER COLUMN {cname} TYPE {old_c.data_type} "
f"USING {cname}::{old_c.data_type};"
)
if old_c.nullable and not new_c.nullable:
stmts_up.append(f"ALTER TABLE {tname} ALTER COLUMN {cname} SET NOT NULL;")
stmts_down.append(f"ALTER TABLE {tname} ALTER COLUMN {cname} DROP NOT NULL;")
elif not old_c.nullable and new_c.nullable:
stmts_up.append(f"ALTER TABLE {tname} ALTER COLUMN {cname} DROP NOT NULL;")
stmts_down.append(f"ALTER TABLE {tname} ALTER COLUMN {cname} SET NOT NULL;")
if old_c.default != new_c.default:
if new_c.default:
stmts_up.append(f"ALTER TABLE {tname} ALTER COLUMN {cname} SET DEFAULT {new_c.default};")
else:
stmts_up.append(f"ALTER TABLE {tname} ALTER COLUMN {cname} DROP DEFAULT;")
if old_c.default:
stmts_down.append(f"ALTER TABLE {tname} ALTER COLUMN {cname} SET DEFAULT {old_c.default};")
else:
stmts_down.append(f"ALTER TABLE {tname} ALTER COLUMN {cname} DROP DEFAULT;")
if stmts_up:
risk = "medium" if any('TYPE' in s for s in stmts_up) else "low"
migrations.append(Migration(
description=f"Alter column '{cname}' in '{tname}'",
up_sql='\n'.join(stmts_up),
down_sql='\n'.join(stmts_down),
risk=risk
))
# Index changes
old_idx_names = set(old_indexes.keys())
new_idx_names = set(new_indexes.keys())
for iname in sorted(new_idx_names - old_idx_names):
idx = new_indexes[iname]
migrations.append(Migration(
description=f"Create index '{iname}' on '{idx.table}'",
up_sql=idx.original,
down_sql=f"DROP INDEX IF EXISTS {iname};",
risk="low"
))
for iname in sorted(old_idx_names - new_idx_names):
idx = old_indexes[iname]
migrations.append(Migration(
description=f"Drop index '{iname}' from '{idx.table}'",
up_sql=f"DROP INDEX IF EXISTS {iname};",
down_sql=idx.original,
risk="medium"
))
return migrations
def format_sql(migrations: List[Migration], include_rollback: bool = True) -> str:
"""Format migrations as executable SQL."""
if not migrations:
return "-- No schema differences found."
lines = []
lines.append("-- Migration Script")
lines.append("-- Generated by migration_diffr.py")
lines.append(f"-- Changes: {len(migrations)}")
lines.append("")
# Group by risk
high = [m for m in migrations if m.risk == "high"]
medium = [m for m in migrations if m.risk == "medium"]
low = [m for m in migrations if m.risk == "low"]
if high:
lines.append("-- ============================================")
lines.append("-- HIGH RISK (review carefully, may lose data)")
lines.append("-- ============================================")
for m in high:
lines.append(f"")
lines.append(f"-- {m.description}")
lines.append(m.up_sql)
if medium:
lines.append("")
lines.append("-- ============================================")
lines.append("-- MEDIUM RISK (may require backfill or lock)")
lines.append("-- ============================================")
for m in medium:
lines.append(f"")
lines.append(f"-- {m.description}")
lines.append(m.up_sql)
if low:
lines.append("")
lines.append("-- ============================================")
lines.append("-- LOW RISK (safe, additive changes)")
lines.append("-- ============================================")
for m in low:
lines.append(f"")
lines.append(f"-- {m.description}")
lines.append(m.up_sql)
if include_rollback:
lines.append("")
lines.append("")
lines.append("-- ============================================")
lines.append("-- ROLLBACK SCRIPT")
lines.append("-- ============================================")
for m in reversed(migrations):
lines.append(f"")
lines.append(f"-- Rollback: {m.description}")
lines.append(m.down_sql)
return '\n'.join(lines)
def format_human(migrations: List[Migration]) -> str:
"""Format a human-readable summary."""
lines = []
lines.append("Migration Diff Report")
lines.append("=" * 50)
lines.append(f"Total changes: {len(migrations)}")
risk_counts = {"high": 0, "medium": 0, "low": 0}
for m in migrations:
risk_counts[m.risk] += 1
lines.append(f"Risk breakdown: {risk_counts['high']} high, {risk_counts['medium']} medium, {risk_counts['low']} low")
lines.append("")
if not migrations:
lines.append("Schemas are identical. No migration needed.")
return '\n'.join(lines)
for i, m in enumerate(migrations, 1):
risk_label = {"high": "[HIGH] ", "medium": "[MEDIUM]", "low": "[LOW] "}
lines.append(f" {i}. {risk_label[m.risk]} {m.description}")
lines.append("")
lines.append("SQL Migration:")
lines.append("-" * 50)
lines.append(format_sql(migrations))
return '\n'.join(lines)
def format_json(migrations: List[Migration]) -> str:
"""Format as JSON."""
risk_counts = {"high": 0, "medium": 0, "low": 0}
for m in migrations:
risk_counts[m.risk] += 1
return json.dumps({
"total_changes": len(migrations),
"risk_summary": risk_counts,
"migrations": [
{
"description": m.description,
"risk": m.risk,
"up": m.up_sql,
"down": m.down_sql,
}
for m in migrations
],
"up_sql": format_sql(migrations, include_rollback=False),
"down_sql": format_sql(
migrations, include_rollback=False
).replace("Migration Script", "Rollback Script") if migrations else "",
}, indent=2)
def main():
parser = argparse.ArgumentParser(
description="Compare two SQL schema files and generate migration ALTER statements."
)
parser.add_argument("old_schema", help="Path to the current/old SQL schema file")
parser.add_argument("new_schema", help="Path to the target/new SQL schema file")
parser.add_argument("--json", action="store_true", dest="json_output",
help="Output results as JSON")
parser.add_argument("--no-rollback", action="store_true",
help="Omit rollback SQL from output")
parser.add_argument("-o", "--output", help="Write migration SQL to file")
args = parser.parse_args()
try:
with open(args.old_schema, 'r') as f:
old_sql = f.read()
except FileNotFoundError:
print(f"Error: File not found: {args.old_schema}", file=sys.stderr)
sys.exit(2)
except IOError as e:
print(f"Error reading file: {e}", file=sys.stderr)
sys.exit(2)
try:
with open(args.new_schema, 'r') as f:
new_sql = f.read()
except FileNotFoundError:
print(f"Error: File not found: {args.new_schema}", file=sys.stderr)
sys.exit(2)
except IOError as e:
print(f"Error reading file: {e}", file=sys.stderr)
sys.exit(2)
old_tables, old_indexes = parse_schema(old_sql)
new_tables, new_indexes = parse_schema(new_sql)
migrations = diff_schemas(old_tables, old_indexes, new_tables, new_indexes)
if args.output:
try:
sql_out = format_sql(migrations, include_rollback=not args.no_rollback)
with open(args.output, 'w') as f:
f.write(sql_out + '\n')
print(f"Migration SQL written to {args.output}")
except IOError as e:
print(f"Error writing output file: {e}", file=sys.stderr)
sys.exit(2)
if args.json_output:
print(format_json(migrations))
elif not args.output:
print(format_human(migrations))
# Exit code: 0 = no changes, 1 = changes found (useful in CI)
sys.exit(0 if not migrations else 1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""Validate SQL DDL schemas for normalization violations, missing indexes,
naming conventions, and common anti-patterns.
Parses CREATE TABLE statements and checks for:
- Missing indexes on foreign key columns
- Missing timestamp columns (created_at, updated_at)
- Naming convention violations (snake_case enforcement)
- Missing primary keys
- Soft-delete columns without partial indexes
- Sequential integer PKs exposed (suggests CUID2/UUIDv7)
- Missing NOT NULL on foreign keys
- Tables without any indexes
"""
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from enum import Enum
from typing import List, Dict, Optional, Tuple
class Severity(str, Enum):
ERROR = "error"
WARNING = "warning"
INFO = "info"
@dataclass
class Finding:
table: str
severity: str
rule: str
message: str
suggestion: str
@dataclass
class Column:
name: str
data_type: str
nullable: bool
is_pk: bool
default: Optional[str]
references: Optional[str] # referenced table
@dataclass
class Index:
name: str
columns: List[str]
unique: bool
where_clause: Optional[str]
@dataclass
class Table:
name: str
columns: Dict[str, Column] = field(default_factory=dict)
indexes: List[Index] = field(default_factory=list)
primary_key: Optional[List[str]] = None
def parse_ddl(sql: str) -> List[Table]:
"""Parse SQL DDL into structured Table objects."""
sql = re.sub(r'--[^\n]*', '', sql)
sql = re.sub(r'/\*.*?\*/', '', sql, flags=re.DOTALL)
tables: List[Table] = []
table_map: Dict[str, Table] = {}
# Extract CREATE TABLE blocks
table_pattern = re.compile(
r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?'
r'(?:"?(\w+)"?\.)?' # optional schema
r'"?(\w+)"?' # table name
r'\s*\((.*?)\)\s*;',
re.IGNORECASE | re.DOTALL
)
for match in table_pattern.finditer(sql):
_schema, tname, body = match.group(1), match.group(2), match.group(3)
table = Table(name=tname)
# Split body on commas, respecting parentheses depth
parts = _split_comma_top_level(body)
for part in parts:
part = part.strip()
upper = part.upper()
# Table-level PRIMARY KEY
pk_match = re.match(r'PRIMARY\s+KEY\s*\(([^)]+)\)', part, re.IGNORECASE)
if pk_match:
table.primary_key = [c.strip().strip('"') for c in pk_match.group(1).split(',')]
continue
# Table-level UNIQUE or INDEX (inline)
if re.match(r'(UNIQUE|INDEX|KEY|CONSTRAINT)\s', upper):
continue
# Column definition
col_match = re.match(
r'"?(\w+)"?\s+(\w[\w\s()]*?)(?:\s+(NOT\s+NULL|NULL|PRIMARY\s+KEY|DEFAULT\s+.+?|REFERENCES\s+\w+(?:\s*\([^)]*\))?))*\s*$',
part, re.IGNORECASE
)
if col_match:
cname = col_match.group(1)
ctype = col_match.group(2).strip()
is_pk = bool(re.search(r'PRIMARY\s+KEY', part, re.IGNORECASE))
nullable = not bool(re.search(r'NOT\s+NULL', part, re.IGNORECASE)) and not is_pk
default_match = re.search(r'DEFAULT\s+(\S+)', part, re.IGNORECASE)
ref_match = re.search(r'REFERENCES\s+"?(\w+)"?', part, re.IGNORECASE)
col = Column(
name=cname,
data_type=ctype,
nullable=nullable,
is_pk=is_pk,
default=default_match.group(1) if default_match else None,
references=ref_match.group(1) if ref_match else None,
)
table.columns[cname] = col
if is_pk:
table.primary_key = [cname]
tables.append(table)
table_map[tname] = table
# Extract CREATE INDEX statements
idx_pattern = re.compile(
r'CREATE\s+(UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?'
r'"?(\w+)"?\s+ON\s+"?(\w+)"?\s*(?:USING\s+\w+\s*)?\(([^)]+)\)'
r'(?:\s+WHERE\s+(.+?))?;',
re.IGNORECASE | re.DOTALL
)
for match in idx_pattern.finditer(sql):
unique = bool(match.group(1))
idx_name = match.group(2)
tname = match.group(3)
cols = [c.strip().strip('"') for c in match.group(4).split(',')]
where = match.group(5).strip() if match.group(5) else None
idx = Index(name=idx_name, columns=cols, unique=unique, where_clause=where)
if tname in table_map:
table_map[tname].indexes.append(idx)
return tables
def _split_comma_top_level(s: str) -> List[str]:
"""Split on commas that are not inside parentheses."""
parts = []
depth = 0
current = []
for ch in s:
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
elif ch == ',' and depth == 0:
parts.append(''.join(current))
current = []
continue
current.append(ch)
if current:
parts.append(''.join(current))
return parts
def validate(tables: List[Table]) -> List[Finding]:
"""Run all validation rules against parsed tables."""
findings: List[Finding] = []
for table in tables:
_check_naming(table, findings)
_check_primary_key(table, findings)
_check_timestamps(table, findings)
_check_fk_indexes(table, findings)
_check_fk_nullable(table, findings)
_check_soft_delete_index(table, findings)
_check_sequential_pk(table, findings)
_check_no_indexes(table, findings)
return findings
def _check_naming(table: Table, findings: List[Finding]):
"""Enforce snake_case naming for tables and columns."""
snake = re.compile(r'^[a-z][a-z0-9]*(_[a-z0-9]+)*$')
if not snake.match(table.name):
findings.append(Finding(
table=table.name, severity=Severity.WARNING, rule="naming_convention",
message=f"Table name '{table.name}' is not snake_case.",
suggestion=f"Rename to '{_to_snake(table.name)}'."
))
for cname in table.columns:
if not snake.match(cname):
findings.append(Finding(
table=table.name, severity=Severity.WARNING, rule="naming_convention",
message=f"Column '{cname}' is not snake_case.",
suggestion=f"Rename to '{_to_snake(cname)}'."
))
def _to_snake(name: str) -> str:
s = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', name)
s = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s)
return s.lower().replace(' ', '_').replace('-', '_')
def _check_primary_key(table: Table, findings: List[Finding]):
if not table.primary_key:
findings.append(Finding(
table=table.name, severity=Severity.ERROR, rule="missing_primary_key",
message=f"Table '{table.name}' has no PRIMARY KEY defined.",
suggestion="Add a PRIMARY KEY column (e.g., id TEXT PRIMARY KEY or id UUID PRIMARY KEY)."
))
def _check_timestamps(table: Table, findings: List[Finding]):
cols = set(table.columns.keys())
if 'created_at' not in cols:
findings.append(Finding(
table=table.name, severity=Severity.WARNING, rule="missing_created_at",
message=f"Table '{table.name}' is missing a 'created_at' column.",
suggestion="Add: created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()"
))
if 'updated_at' not in cols:
findings.append(Finding(
table=table.name, severity=Severity.WARNING, rule="missing_updated_at",
message=f"Table '{table.name}' is missing an 'updated_at' column.",
suggestion="Add: updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()"
))
def _check_fk_indexes(table: Table, findings: List[Finding]):
indexed_cols = set()
for idx in table.indexes:
if idx.columns:
indexed_cols.add(idx.columns[0])
for cname, col in table.columns.items():
if col.references and cname not in indexed_cols and not col.is_pk:
findings.append(Finding(
table=table.name, severity=Severity.ERROR, rule="missing_fk_index",
message=f"Foreign key column '{cname}' has no index.",
suggestion=f"CREATE INDEX idx_{table.name}_{cname} ON {table.name} ({cname});"
))
def _check_fk_nullable(table: Table, findings: List[Finding]):
for cname, col in table.columns.items():
if col.references and col.nullable:
findings.append(Finding(
table=table.name, severity=Severity.INFO, rule="nullable_fk",
message=f"Foreign key column '{cname}' is nullable.",
suggestion="Consider adding NOT NULL if the relationship is mandatory."
))
def _check_soft_delete_index(table: Table, findings: List[Finding]):
if 'deleted_at' not in table.columns:
return
has_partial = any(
idx.where_clause and 'deleted_at' in idx.where_clause.lower()
for idx in table.indexes
)
if not has_partial:
findings.append(Finding(
table=table.name, severity=Severity.WARNING, rule="soft_delete_no_partial_index",
message=f"Table '{table.name}' has soft deletes but no partial index filtering deleted rows.",
suggestion="Add a partial index: WHERE deleted_at IS NULL on frequently queried columns."
))
def _check_sequential_pk(table: Table, findings: List[Finding]):
if not table.primary_key:
return
for pk_col_name in table.primary_key:
col = table.columns.get(pk_col_name)
if col and col.data_type.upper() in ('SERIAL', 'BIGSERIAL', 'INT', 'INTEGER', 'BIGINT'):
if col.is_pk:
findings.append(Finding(
table=table.name, severity=Severity.INFO, rule="sequential_pk",
message=f"Primary key '{pk_col_name}' uses sequential integer type '{col.data_type}'.",
suggestion="Consider CUID2 or UUIDv7 for non-guessable, sortable IDs (especially if exposed in URLs)."
))
def _check_no_indexes(table: Table, findings: List[Finding]):
if not table.indexes and len(table.columns) > 2:
findings.append(Finding(
table=table.name, severity=Severity.WARNING, rule="no_indexes",
message=f"Table '{table.name}' has no indexes besides the primary key.",
suggestion="Add indexes on columns used in WHERE clauses, JOINs, and ORDER BY."
))
def format_human(findings: List[Finding], tables: List[Table]) -> str:
"""Format findings for human-readable output."""
lines = []
lines.append(f"Schema Validation Report")
lines.append(f"{'=' * 50}")
lines.append(f"Tables analyzed: {len(tables)}")
error_count = sum(1 for f in findings if f.severity == Severity.ERROR)
warn_count = sum(1 for f in findings if f.severity == Severity.WARNING)
info_count = sum(1 for f in findings if f.severity == Severity.INFO)
lines.append(f"Findings: {error_count} errors, {warn_count} warnings, {info_count} info")
lines.append("")
if not findings:
lines.append("No issues found. Schema looks good!")
return '\n'.join(lines)
severity_order = {Severity.ERROR: 0, Severity.WARNING: 1, Severity.INFO: 2}
sorted_findings = sorted(findings, key=lambda f: (severity_order.get(f.severity, 3), f.table))
icons = {Severity.ERROR: "[ERROR]", Severity.WARNING: "[WARN] ", Severity.INFO: "[INFO] "}
for f in sorted_findings:
icon = icons.get(f.severity, " ")
lines.append(f"{icon} {f.table}: {f.message}")
lines.append(f" -> {f.suggestion}")
lines.append("")
return '\n'.join(lines)
def format_json(findings: List[Finding], tables: List[Table]) -> str:
"""Format findings as JSON."""
return json.dumps({
"tables_analyzed": len(tables),
"summary": {
"errors": sum(1 for f in findings if f.severity == Severity.ERROR),
"warnings": sum(1 for f in findings if f.severity == Severity.WARNING),
"info": sum(1 for f in findings if f.severity == Severity.INFO),
},
"findings": [
{
"table": f.table,
"severity": f.severity,
"rule": f.rule,
"message": f.message,
"suggestion": f.suggestion,
}
for f in findings
]
}, indent=2)
def main():
parser = argparse.ArgumentParser(
description="Validate SQL DDL schemas for normalization violations, missing indexes, and naming conventions."
)
parser.add_argument("file", help="Path to SQL DDL file to validate")
parser.add_argument("--json", action="store_true", dest="json_output",
help="Output results as JSON")
parser.add_argument("--strict", action="store_true",
help="Exit with code 1 on any warning (not just errors)")
args = parser.parse_args()
try:
with open(args.file, 'r') as f:
sql = f.read()
except FileNotFoundError:
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(2)
except IOError as e:
print(f"Error reading file: {e}", file=sys.stderr)
sys.exit(2)
tables = parse_ddl(sql)
if not tables:
print("Warning: No CREATE TABLE statements found in the file.", file=sys.stderr)
sys.exit(0)
findings = validate(tables)
if args.json_output:
print(format_json(findings, tables))
else:
print(format_human(findings, tables))
error_count = sum(1 for f in findings if f.severity == Severity.ERROR)
warn_count = sum(1 for f in findings if f.severity == Severity.WARNING)
if error_count > 0:
sys.exit(1)
if args.strict and warn_count > 0:
sys.exit(1)
sys.exit(0)
if __name__ == '__main__':
main()
Related skills
FAQ
Which ORMs and databases does it support?
PostgreSQL, MySQL, and SQLite with Drizzle, Prisma, TypeORM, and Alembic migrations.
Does it handle multi-tenant isolation?
Yes. It adds workspace_id foreign keys and Row-Level Security policies enforcing workspace isolation, plus soft deletes, audit trails, and optimistic locking.