25 KiB
25 KiB
Database Layer Operation
Implement database layer only: migrations, models, schemas, indexes, and validation for a feature.
Parameters
Received: $ARGUMENTS (after removing 'database' operation name)
Expected format: description:"database changes needed" [migration:"migration_name"] [orm:"prisma|typeorm|sequelize"]
Workflow
1. Understand Database Requirements
Parse the requirements and clarify:
- What tables/collections need to be created or modified?
- What are the relationships between entities?
- What queries will be frequently executed (for index design)?
- What are the data validation requirements?
- Are there any data migration needs (existing data to transform)?
2. Analyze Existing Database Structure
Examine current database setup:
# Find existing migrations
find . -path "*/migrations/*" -o -path "*/prisma/migrations/*"
# Find existing models
find . -path "*/models/*" -o -path "*/entities/*" -o -name "schema.prisma"
# Check ORM configuration
find . -name "ormconfig.js" -o -name "datasource.ts" -o -name "schema.prisma"
Identify:
- ORM being used (Prisma, TypeORM, Sequelize, Mongoose, etc.)
- Database type (PostgreSQL, MySQL, MongoDB, etc.)
- Naming conventions for tables and columns
- Migration strategy and tooling
- Existing relationships and constraints
3. Design Database Schema
Schema Design Template
For SQL Databases:
-- Example: E-commerce Product Catalog
-- Main products table
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
price DECIMAL(10, 2) NOT NULL,
currency VARCHAR(3) DEFAULT 'USD',
stock_quantity INTEGER NOT NULL DEFAULT 0,
category_id UUID REFERENCES categories(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
deleted_at TIMESTAMP, -- Soft delete
-- Constraints
CONSTRAINT price_positive CHECK (price >= 0),
CONSTRAINT stock_non_negative CHECK (stock_quantity >= 0),
CONSTRAINT slug_format CHECK (slug ~* '^[a-z0-9-]+$')
);
-- Categories table
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
parent_id UUID REFERENCES categories(id) ON DELETE CASCADE,
description TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Product images table (one-to-many)
CREATE TABLE product_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
url VARCHAR(500) NOT NULL,
alt_text VARCHAR(255),
display_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
-- Product tags (many-to-many)
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) UNIQUE NOT NULL,
slug VARCHAR(50) UNIQUE NOT NULL
);
CREATE TABLE product_tags (
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (product_id, tag_id)
);
-- Indexes for performance
CREATE INDEX idx_products_category_id ON products(category_id);
CREATE INDEX idx_products_slug ON products(slug);
CREATE INDEX idx_products_created_at ON products(created_at DESC);
CREATE INDEX idx_products_price ON products(price);
CREATE INDEX idx_products_stock ON products(stock_quantity) WHERE stock_quantity > 0;
CREATE INDEX idx_products_deleted_at ON products(deleted_at) WHERE deleted_at IS NULL;
CREATE INDEX idx_categories_parent_id ON categories(parent_id);
CREATE INDEX idx_categories_slug ON categories(slug);
CREATE INDEX idx_product_images_product_id ON product_images(product_id);
CREATE INDEX idx_product_tags_product_id ON product_tags(product_id);
CREATE INDEX idx_product_tags_tag_id ON product_tags(tag_id);
-- Full-text search index
CREATE INDEX idx_products_search ON products USING GIN(to_tsvector('english', name || ' ' || COALESCE(description, '')));
For NoSQL (MongoDB):
// Product schema
{
_id: ObjectId,
name: String,
slug: String, // indexed, unique
description: String,
price: {
amount: Number,
currency: String
},
stockQuantity: Number,
category: {
id: ObjectId,
name: String, // denormalized for performance
slug: String
},
images: [
{
url: String,
altText: String,
displayOrder: Number
}
],
tags: [String], // indexed for queries
createdAt: Date,
updatedAt: Date,
deletedAt: Date // soft delete
}
// Indexes
db.products.createIndex({ slug: 1 }, { unique: true })
db.products.createIndex({ "category.id": 1 })
db.products.createIndex({ price.amount: 1 })
db.products.createIndex({ tags: 1 })
db.products.createIndex({ createdAt: -1 })
db.products.createIndex({ name: "text", description: "text" }) // Full-text search
Index Strategy
When to add indexes:
- Primary keys (always)
- Foreign keys (for JOIN performance)
- Columns used in WHERE clauses
- Columns used in ORDER BY
- Columns used in GROUP BY
- Columns used for full-text search
Composite indexes for queries with multiple conditions:
-- Query: SELECT * FROM products WHERE category_id = ? AND price > ? ORDER BY created_at DESC
CREATE INDEX idx_products_category_price_created ON products(category_id, price, created_at DESC);
Partial indexes for specific conditions:
-- Only index active (non-deleted) products
CREATE INDEX idx_active_products ON products(created_at) WHERE deleted_at IS NULL;
4. Create Migration Scripts
Example: TypeORM Migration
// migrations/1704124800000-AddProductCatalog.ts
import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm';
export class AddProductCatalog1704124800000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Create categories table
await queryRunner.createTable(
new Table({
name: 'categories',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'gen_random_uuid()',
},
{
name: 'name',
type: 'varchar',
length: '100',
isNullable: false,
},
{
name: 'slug',
type: 'varchar',
length: '100',
isUnique: true,
isNullable: false,
},
{
name: 'parent_id',
type: 'uuid',
isNullable: true,
},
{
name: 'description',
type: 'text',
isNullable: true,
},
{
name: 'created_at',
type: 'timestamp',
default: 'now()',
},
{
name: 'updated_at',
type: 'timestamp',
default: 'now()',
},
],
}),
true
);
// Create products table
await queryRunner.createTable(
new Table({
name: 'products',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'gen_random_uuid()',
},
{
name: 'name',
type: 'varchar',
length: '255',
isNullable: false,
},
{
name: 'slug',
type: 'varchar',
length: '255',
isUnique: true,
isNullable: false,
},
{
name: 'description',
type: 'text',
isNullable: true,
},
{
name: 'price',
type: 'decimal',
precision: 10,
scale: 2,
isNullable: false,
},
{
name: 'currency',
type: 'varchar',
length: '3',
default: "'USD'",
},
{
name: 'stock_quantity',
type: 'integer',
default: 0,
},
{
name: 'category_id',
type: 'uuid',
isNullable: true,
},
{
name: 'created_at',
type: 'timestamp',
default: 'now()',
},
{
name: 'updated_at',
type: 'timestamp',
default: 'now()',
},
{
name: 'deleted_at',
type: 'timestamp',
isNullable: true,
},
],
}),
true
);
// Add foreign keys
await queryRunner.createForeignKey(
'categories',
new TableForeignKey({
columnNames: ['parent_id'],
referencedColumnNames: ['id'],
referencedTableName: 'categories',
onDelete: 'CASCADE',
})
);
await queryRunner.createForeignKey(
'products',
new TableForeignKey({
columnNames: ['category_id'],
referencedColumnNames: ['id'],
referencedTableName: 'categories',
onDelete: 'SET NULL',
})
);
// Create indexes
await queryRunner.createIndex(
'products',
new TableIndex({
name: 'idx_products_category_id',
columnNames: ['category_id'],
})
);
await queryRunner.createIndex(
'products',
new TableIndex({
name: 'idx_products_slug',
columnNames: ['slug'],
})
);
await queryRunner.createIndex(
'products',
new TableIndex({
name: 'idx_products_price',
columnNames: ['price'],
})
);
await queryRunner.createIndex(
'categories',
new TableIndex({
name: 'idx_categories_parent_id',
columnNames: ['parent_id'],
})
);
// Add check constraints
await queryRunner.query(
`ALTER TABLE products ADD CONSTRAINT price_positive CHECK (price >= 0)`
);
await queryRunner.query(
`ALTER TABLE products ADD CONSTRAINT stock_non_negative CHECK (stock_quantity >= 0)`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Drop in reverse order
await queryRunner.dropTable('products');
await queryRunner.dropTable('categories');
}
}
Example: Prisma Migration
// prisma/schema.prisma
model Category {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(100)
slug String @unique @db.VarChar(100)
parentId String? @map("parent_id") @db.Uuid
description String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children Category[] @relation("CategoryHierarchy")
products Product[]
@@index([parentId])
@@index([slug])
@@map("categories")
}
model Product {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(255)
slug String @unique @db.VarChar(255)
description String? @db.Text
price Decimal @db.Decimal(10, 2)
currency String @default("USD") @db.VarChar(3)
stockQuantity Int @default(0) @map("stock_quantity")
categoryId String? @map("category_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
images ProductImage[]
tags ProductTag[]
@@index([categoryId])
@@index([slug])
@@index([price])
@@index([createdAt(sort: Desc)])
@@index([stockQuantity], where: stockQuantity > 0)
@@map("products")
}
model ProductImage {
id String @id @default(uuid()) @db.Uuid
productId String @map("product_id") @db.Uuid
url String @db.VarChar(500)
altText String? @map("alt_text") @db.VarChar(255)
displayOrder Int @default(0) @map("display_order")
createdAt DateTime @default(now()) @map("created_at")
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@index([productId])
@@map("product_images")
}
model Tag {
id String @id @default(uuid()) @db.Uuid
name String @unique @db.VarChar(50)
slug String @unique @db.VarChar(50)
products ProductTag[]
@@map("tags")
}
model ProductTag {
productId String @map("product_id") @db.Uuid
tagId String @map("tag_id") @db.Uuid
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([productId, tagId])
@@index([productId])
@@index([tagId])
@@map("product_tags")
}
# Generate migration
npx prisma migrate dev --name add_product_catalog
# Apply migration to production
npx prisma migrate deploy
5. Create/Update Models
TypeORM Models
// entities/Product.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
ManyToOne,
OneToMany,
ManyToMany,
JoinTable,
JoinColumn,
Index,
Check,
} from 'typeorm';
import { Category } from './Category.entity';
import { ProductImage } from './ProductImage.entity';
import { Tag } from './Tag.entity';
@Entity('products')
@Check('"price" >= 0')
@Check('"stock_quantity" >= 0')
export class Product {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'varchar', length: 255, unique: true })
@Index()
slug: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'decimal', precision: 10, scale: 2 })
@Index()
price: number;
@Column({ type: 'varchar', length: 3, default: 'USD' })
currency: string;
@Column({ type: 'integer', default: 0, name: 'stock_quantity' })
stockQuantity: number;
@Column({ type: 'uuid', name: 'category_id', nullable: true })
@Index()
categoryId: string | null;
@CreateDateColumn({ name: 'created_at' })
@Index()
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt: Date | null;
// Relations
@ManyToOne(() => Category, (category) => category.products, {
onDelete: 'SET NULL',
})
@JoinColumn({ name: 'category_id' })
category: Category;
@OneToMany(() => ProductImage, (image) => image.product, {
cascade: true,
})
images: ProductImage[];
@ManyToMany(() => Tag, (tag) => tag.products)
@JoinTable({
name: 'product_tags',
joinColumn: { name: 'product_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'tag_id', referencedColumnName: 'id' },
})
tags: Tag[];
}
// entities/Category.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { Product } from './Product.entity';
@Entity('categories')
export class Category {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'varchar', length: 100, unique: true })
@Index()
slug: string;
@Column({ type: 'uuid', name: 'parent_id', nullable: true })
@Index()
parentId: string | null;
@Column({ type: 'text', nullable: true })
description: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@ManyToOne(() => Category, (category) => category.children, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'parent_id' })
parent: Category | null;
@OneToMany(() => Category, (category) => category.parent)
children: Category[];
@OneToMany(() => Product, (product) => product.category)
products: Product[];
}
Validation
Add validation decorators if using class-validator:
import { IsString, IsNumber, Min, IsOptional, IsUUID, MaxLength, Matches } from 'class-validator';
export class CreateProductDto {
@IsString()
@MaxLength(255)
name: string;
@IsString()
@MaxLength(255)
@Matches(/^[a-z0-9-]+$/, { message: 'Slug must contain only lowercase letters, numbers, and hyphens' })
slug: string;
@IsString()
@IsOptional()
description?: string;
@IsNumber()
@Min(0)
price: number;
@IsString()
@MaxLength(3)
@IsOptional()
currency?: string;
@IsNumber()
@Min(0)
stockQuantity: number;
@IsUUID()
@IsOptional()
categoryId?: string;
}
6. Test Database Operations
// entities/__tests__/Product.entity.test.ts
import { DataSource } from 'typeorm';
import { Product } from '../Product.entity';
import { Category } from '../Category.entity';
import { createTestDataSource } from '../../test/utils';
describe('Product Entity', () => {
let dataSource: DataSource;
let productRepository: ReturnType<typeof dataSource.getRepository<Product>>;
let categoryRepository: ReturnType<typeof dataSource.getRepository<Category>>;
beforeAll(async () => {
dataSource = await createTestDataSource();
productRepository = dataSource.getRepository(Product);
categoryRepository = dataSource.getRepository(Category);
});
afterAll(async () => {
await dataSource.destroy();
});
beforeEach(async () => {
await productRepository.delete({});
await categoryRepository.delete({});
});
describe('Creation', () => {
it('should create product with valid data', async () => {
const product = productRepository.create({
name: 'Test Product',
slug: 'test-product',
price: 99.99,
stockQuantity: 10,
});
await productRepository.save(product);
expect(product.id).toBeDefined();
expect(product.name).toBe('Test Product');
expect(product.price).toBe(99.99);
});
it('should enforce unique slug constraint', async () => {
await productRepository.save({
name: 'Product 1',
slug: 'duplicate-slug',
price: 10,
stockQuantity: 1,
});
await expect(
productRepository.save({
name: 'Product 2',
slug: 'duplicate-slug',
price: 20,
stockQuantity: 2,
})
).rejects.toThrow();
});
it('should enforce price check constraint', async () => {
await expect(
productRepository.save({
name: 'Invalid Product',
slug: 'invalid-price',
price: -10,
stockQuantity: 1,
})
).rejects.toThrow(/price_positive/);
});
});
describe('Relations', () => {
it('should set category relationship', async () => {
const category = await categoryRepository.save({
name: 'Electronics',
slug: 'electronics',
});
const product = await productRepository.save({
name: 'Laptop',
slug: 'laptop',
price: 999,
stockQuantity: 5,
categoryId: category.id,
});
const loaded = await productRepository.findOne({
where: { id: product.id },
relations: ['category'],
});
expect(loaded?.category?.name).toBe('Electronics');
});
it('should cascade delete images', async () => {
const product = await productRepository.save({
name: 'Product with Images',
slug: 'product-images',
price: 50,
stockQuantity: 1,
images: [
{ url: 'https://example.com/image1.jpg', displayOrder: 0 },
{ url: 'https://example.com/image2.jpg', displayOrder: 1 },
],
});
await productRepository.delete(product.id);
// Images should be deleted automatically
// Verify by checking the images table is empty
});
});
describe('Soft Delete', () => {
it('should soft delete product', async () => {
const product = await productRepository.save({
name: 'Product to Delete',
slug: 'product-delete',
price: 10,
stockQuantity: 1,
});
await productRepository.softDelete(product.id);
const found = await productRepository.findOne({
where: { id: product.id },
});
expect(found).toBeNull();
// Can still find with withDeleted
const deleted = await productRepository.findOne({
where: { id: product.id },
withDeleted: true,
});
expect(deleted).toBeDefined();
expect(deleted?.deletedAt).toBeDefined();
});
});
describe('Queries', () => {
beforeEach(async () => {
// Seed test data
await productRepository.save([
{ name: 'Product A', slug: 'product-a', price: 10, stockQuantity: 5 },
{ name: 'Product B', slug: 'product-b', price: 20, stockQuantity: 0 },
{ name: 'Product C', slug: 'product-c', price: 30, stockQuantity: 10 },
]);
});
it('should find products by price range', async () => {
const products = await productRepository.find({
where: {
price: Between(15, 35),
},
});
expect(products).toHaveLength(2);
});
it('should find in-stock products', async () => {
const products = await productRepository
.createQueryBuilder('product')
.where('product.stock_quantity > 0')
.getMany();
expect(products).toHaveLength(2);
});
it('should order by created date', async () => {
const products = await productRepository.find({
order: { createdAt: 'DESC' },
});
expect(products[0].name).toBe('Product C');
});
});
});
Output Format
# Database Layer: {Feature Name}
## Schema Design
### Tables Created/Modified
- {table_name}: {description}
### Relationships
- {relationship_description}
### Indexes
- {index_name}: {purpose}
## Migration Scripts
### Up Migration
\`\`\`sql
{migration_sql}
\`\`\`
### Down Migration
\`\`\`sql
{rollback_sql}
\`\`\`
## Models/Entities
### {ModelName}
\`\`\`typescript
{model_code}
\`\`\`
## Validation
### DTOs
\`\`\`typescript
{validation_code}
\`\`\`
## Testing
### Test Results
- {test_description}: {status}
## Migration Commands
\`\`\`bash
# Run migration
{migration_command}
# Rollback migration
{rollback_command}
\`\`\`
## Performance Considerations
- {performance_note}
Error Handling
- If ORM unclear: Ask which ORM is used or detect from codebase
- If database type unclear: Suggest common options or auto-detect
- If migration fails: Provide rollback instructions
- If constraints fail: Explain the constraint and suggest fixes