DEV Community

Pedro Alvarado
Pedro Alvarado

Posted on

Semana 3 — Persistencia con Prisma + Migrations

Modelado de datos Rails-style con Node.js

🎯 Objetivos de la semana

  • Dominar Prisma como ORM principal (equivalente a ActiveRecord)
  • Implementar migraciones y seeding de datos
  • Migrar el CRUD de Posts desde memoria a PostgreSQL
  • Añadir modelo Users con relaciones
  • Comparar patrones Rails vs Node/Prisma
  • Implementar tests con factories

📚 Contenido teórico

¿Por qué Prisma vs TypeORM?

Prisma es más moderno y type-safe, similar a como ActiveRecord evolucionó. TypeORM se siente más familiar para railsistas porque usa decoradores como ActiveRecord.

// TypeORM (más parecido a Rails)
@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @ManyToOne(() => User)
  author: User;
}

// Prisma (más type-safe)
// Schema se define en schema.prisma
model Post {
  id       Int    @id @default(autoincrement())
  title    String
  authorId Int
  author   User   @relation(fields: [authorId], references: [id])
}
Enter fullscreen mode Exit fullscreen mode

Comparación Rails vs Prisma

Rails (ActiveRecord) Prisma Descripción
rails g migration prisma migrate dev Crear migraciones
rake db:migrate prisma migrate deploy Ejecutar migraciones
rails db:seed prisma db seed Poblar datos iniciales
User.where(...) prisma.user.findMany({where:...}) Consultas
has_many :posts Relaciones en schema Definir relaciones
scope :published Query helpers en service Consultas reutilizables

🛠️ Setup inicial de Prisma

1. Instalación y configuración

# Instalar Prisma
npm install prisma @prisma/client
npm install -D prisma

# Inicializar Prisma
npx prisma init
Enter fullscreen mode Exit fullscreen mode

Esto crea:

  • prisma/schema.prisma (equivalente a db/schema.rb)
  • .env con DATABASE_URL

2. Configurar PostgreSQL

# .env
DATABASE_URL="postgresql://username:password@localhost:5432/nest_blog_dev?schema=public"
Enter fullscreen mode Exit fullscreen mode

3. Schema básico (prisma/schema.prisma)

// Equivalente a ActiveRecord migrations
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("users")
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  authorId  Int      @map("author_id")
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("posts")
}
Enter fullscreen mode Exit fullscreen mode

🔄 Migraciones con Prisma

Crear y ejecutar primera migración

# Equivalente a rails g migration CreateUsers
npx prisma migrate dev --name init

# Genera el cliente Prisma (equivalente a bundle install)
npx prisma generate
Enter fullscreen mode Exit fullscreen mode

Modificar schema existente

// Agregar nuevo campo
model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  slug      String   @unique // ← Nuevo campo
  authorId  Int      @map("author_id")
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("posts")
}
Enter fullscreen mode Exit fullscreen mode
# Crear migración para el cambio
npx prisma migrate dev --name add_slug_to_posts
Enter fullscreen mode Exit fullscreen mode

🌱 Seeding (equivalente a db/seeds.rb)

1. Configurar seeding

// package.json
{
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Crear archivo de seed

// prisma/seed.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  // Limpiar datos existentes (equivalente a truncate)
  await prisma.post.deleteMany();
  await prisma.user.deleteMany();

  // Crear usuarios (equivalente a User.create!)
  const john = await prisma.user.create({
    data: {
      name: 'John Doe',
      email: 'john@example.com',
    },
  });

  const jane = await prisma.user.create({
    data: {
      name: 'Jane Smith',
      email: 'jane@example.com',
    },
  });

  // Crear posts con relaciones (equivalente a user.posts.create!)
  await prisma.post.createMany({
    data: [
      {
        title: 'First Post',
        content: 'This is my first post',
        published: true,
        slug: 'first-post',
        authorId: john.id,
      },
      {
        title: 'Draft Post',
        content: 'This is a draft',
        published: false,
        slug: 'draft-post',
        authorId: jane.id,
      },
    ],
  });

  console.log('✅ Seeding completed');
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });
Enter fullscreen mode Exit fullscreen mode
# Ejecutar seeds
npx prisma db seed
Enter fullscreen mode Exit fullscreen mode

🏗️ Integración con NestJS

1. Servicio Prisma

// src/prisma/prisma.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Módulo Prisma

// src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}
Enter fullscreen mode Exit fullscreen mode

3. Actualizar App Module

// src/app.module.ts
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { PostsModule } from './posts/posts.module';
import { UsersModule } from './users/users.module';

@Module({
  imports: [PrismaModule, PostsModule, UsersModule],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

📝 Migrar Posts CRUD a PostgreSQL

1. Actualizar Posts Service

// src/posts/posts.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePostDto, UpdatePostDto } from './dto';

@Injectable()
export class PostsService {
  constructor(private prisma: PrismaService) {}

  // Equivalente a Post.all
  async findAll() {
    return this.prisma.post.findMany({
      include: {
        author: {
          select: { id: true, name: true, email: true }
        }
      },
      orderBy: { createdAt: 'desc' }
    });
  }

  // Equivalente a Post.published (scope en Rails)
  async findPublished() {
    return this.prisma.post.findMany({
      where: { published: true },
      include: { author: { select: { name: true } } }
    });
  }

  // Equivalente a Post.find(id)
  async findOne(id: number) {
    return this.prisma.post.findUnique({
      where: { id },
      include: { author: true }
    });
  }

  // Equivalente a Post.create!
  async create(createPostDto: CreatePostDto) {
    const { title, content, authorId } = createPostDto;

    return this.prisma.post.create({
      data: {
        title,
        content,
        slug: this.generateSlug(title),
        authorId,
      },
      include: { author: true }
    });
  }

  // Equivalente a post.update!
  async update(id: number, updatePostDto: UpdatePostDto) {
    return this.prisma.post.update({
      where: { id },
      data: updatePostDto,
      include: { author: true }
    });
  }

  // Equivalente a post.destroy!
  async remove(id: number) {
    return this.prisma.post.delete({
      where: { id }
    });
  }

  private generateSlug(title: string): string {
    return title.toLowerCase()
      .replace(/[^a-z0-9 -]/g, '')
      .replace(/\s+/g, '-')
      .replace(/-+/g, '-');
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Crear Users Service

// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto';

@Injectable()
export class UsersService {
  constructor(private prisma: PrismaService) {}

  async create(createUserDto: CreateUserDto) {
    return this.prisma.user.create({
      data: createUserDto
    });
  }

  async findAll() {
    return this.prisma.user.findMany({
      include: {
        posts: {
          select: { id: true, title: true, published: true }
        }
      }
    });
  }

  async findOne(id: number) {
    return this.prisma.user.findUnique({
      where: { id },
      include: { posts: true }
    });
  }

  // Equivalente a User.find_by(email: email)
  async findByEmail(email: string) {
    return this.prisma.user.findUnique({
      where: { email }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

📊 Consultas complejas: Rails vs Prisma

1. Consulta con filtros múltiples

# Rails
Post.joins(:author)
    .where(published: true)
    .where('users.name ILIKE ?', '%john%')
    .order(created_at: :desc)
    .limit(10)
Enter fullscreen mode Exit fullscreen mode
// Prisma
await prisma.post.findMany({
  where: {
    published: true,
    author: {
      name: {
        contains: 'john',
        mode: 'insensitive'
      }
    }
  },
  include: { author: true },
  orderBy: { createdAt: 'desc' },
  take: 10
});
Enter fullscreen mode Exit fullscreen mode

2. Agregaciones

# Rails
User.joins(:posts)
    .group('users.id')
    .having('COUNT(posts.id) > ?', 5)
    .select('users.*, COUNT(posts.id) as posts_count')
Enter fullscreen mode Exit fullscreen mode
// Prisma
await prisma.user.findMany({
  include: {
    posts: true,
    _count: {
      select: { posts: true }
    }
  }
}).then(users => 
  users.filter(user => user._count.posts > 5)
);

// O usando agregación cruda
await prisma.$queryRaw`
  SELECT u.*, COUNT(p.id) as posts_count
  FROM users u
  LEFT JOIN posts p ON u.id = p.author_id
  GROUP BY u.id
  HAVING COUNT(p.id) > 5
`;
Enter fullscreen mode Exit fullscreen mode

3. Transacciones

# Rails
ActiveRecord::Base.transaction do
  user = User.create!(name: 'John')
  Post.create!(title: 'Hello', author: user)
end
Enter fullscreen mode Exit fullscreen mode
// Prisma
await prisma.$transaction(async (tx) => {
  const user = await tx.user.create({
    data: { name: 'John', email: 'john@example.com' }
  });

  await tx.post.create({
    data: {
      title: 'Hello',
      authorId: user.id,
      slug: 'hello'
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

🧪 Testing con Factories

1. Configurar factories

npm install -D @faker-js/faker
Enter fullscreen mode Exit fullscreen mode
// src/test/factories/user.factory.ts
import { faker } from '@faker-js/faker';
import { PrismaService } from '../../prisma/prisma.service';

export class UserFactory {
  constructor(private prisma: PrismaService) {}

  async create(overrides = {}) {
    const userData = {
      name: faker.person.fullName(),
      email: faker.internet.email(),
      ...overrides
    };

    return this.prisma.user.create({
      data: userData
    });
  }

  async createMany(count: number, overrides = {}) {
    const users = [];
    for (let i = 0; i < count; i++) {
      users.push(await this.create(overrides));
    }
    return users;
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/test/factories/post.factory.ts
import { faker } from '@faker-js/faker';
import { PrismaService } from '../../prisma/prisma.service';

export class PostFactory {
  constructor(private prisma: PrismaService) {}

  async create(overrides = {}) {
    const title = faker.lorem.sentence();
    const postData = {
      title,
      content: faker.lorem.paragraphs(2),
      slug: title.toLowerCase().replace(/\s+/g, '-').slice(0, -1),
      published: faker.datatype.boolean(),
      ...overrides
    };

    return this.prisma.post.create({
      data: postData
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Tests de integración

// src/posts/posts.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { PostsService } from './posts.service';
import { PrismaService } from '../prisma/prisma.service';
import { UserFactory, PostFactory } from '../test/factories';

describe('PostsService', () => {
  let service: PostsService;
  let prisma: PrismaService;
  let userFactory: UserFactory;
  let postFactory: PostFactory;

  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [PostsService, PrismaService],
    }).compile();

    service = module.get<PostsService>(PostsService);
    prisma = module.get<PrismaService>(PrismaService);
    userFactory = new UserFactory(prisma);
    postFactory = new PostFactory(prisma);
  });

  beforeEach(async () => {
    // Limpiar base de datos entre tests
    await prisma.post.deleteMany();
    await prisma.user.deleteMany();
  });

  afterAll(async () => {
    await prisma.$disconnect();
  });

  describe('findPublished', () => {
    it('should return only published posts', async () => {
      const user = await userFactory.create();

      await postFactory.create({ 
        authorId: user.id, 
        published: true 
      });

      await postFactory.create({ 
        authorId: user.id, 
        published: false 
      });

      const result = await service.findPublished();

      expect(result).toHaveLength(1);
      expect(result[0].published).toBe(true);
    });
  });

  describe('create', () => {
    it('should create a post with generated slug', async () => {
      const user = await userFactory.create();

      const createPostDto = {
        title: 'My New Post',
        content: 'Content here',
        authorId: user.id
      };

      const result = await service.create(createPostDto);

      expect(result.slug).toBe('my-new-post');
      expect(result.author.id).toBe(user.id);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

💻 Ejercicios prácticos

Ejercicio 1: Implementar categorías

Añade un modelo Category con relación many-to-many con Post.

Schema:

model Category {
  id    Int                @id @default(autoincrement())
  name  String             @unique
  posts PostOnCategory[]
}

model PostOnCategory {
  post       Post     @relation(fields: [postId], references: [id])
  postId     Int
  category   Category @relation(fields: [categoryId], references: [id])
  categoryId Int

  @@id([postId, categoryId])
}
Enter fullscreen mode Exit fullscreen mode

Ejercicio 2: Soft deletes

Implementa eliminación suave para Posts (equivalente a acts_as_paranoid en Rails).

Pista:

model Post {
  // ... otros campos
  deletedAt DateTime? @map("deleted_at")
}
Enter fullscreen mode Exit fullscreen mode

Ejercicio 3: Consulta compleja

Implementa una consulta que traiga:

  • Posts publicados
  • Del último mes
  • Con al menos 2 categorías
  • Ordenados por fecha de creación

🎯 Entregables de la semana

  1. Script de migración completo que cree usuarios y posts con relaciones
  2. 3 consultas complejas implementadas tanto en concepto Rails como en Prisma
  3. Suite de tests usando factories para User y Post services
  4. Documentación comparando patrones ActiveRecord vs Prisma

🚀 Rutina diaria recomendada

Día 1-2: Setup Prisma + migraciones básicas
Día 3-4: Implementar services con Prisma + seeding
Día 5: Consultas complejas y comparación con Rails
Día 6-7: Tests con factories + documentación


📚 Recursos adicionales

Al finalizar esta semana tendrás un manejo sólido de persistencia en Node.js que te permitirá replicar cualquier patrón que manejabas en Rails con ActiveRecord.

Top comments (0)