Files
gh-dhofheinz-open-plugins-p…/commands/feature/database.md
2025-11-29 18:20:21 +08:00

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