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])
}
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
Esto crea:
-
prisma/schema.prisma
(equivalente adb/schema.rb
) -
.env
con DATABASE_URL
2. Configurar PostgreSQL
# .env
DATABASE_URL="postgresql://username:password@localhost:5432/nest_blog_dev?schema=public"
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")
}
🔄 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
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")
}
# Crear migración para el cambio
npx prisma migrate dev --name add_slug_to_posts
🌱 Seeding (equivalente a db/seeds.rb)
1. Configurar seeding
// package.json
{
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}
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();
});
# Ejecutar seeds
npx prisma db seed
🏗️ 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();
}
}
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 {}
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 {}
📝 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, '-');
}
}
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 }
});
}
}
📊 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)
// Prisma
await prisma.post.findMany({
where: {
published: true,
author: {
name: {
contains: 'john',
mode: 'insensitive'
}
}
},
include: { author: true },
orderBy: { createdAt: 'desc' },
take: 10
});
2. Agregaciones
# Rails
User.joins(:posts)
.group('users.id')
.having('COUNT(posts.id) > ?', 5)
.select('users.*, COUNT(posts.id) as posts_count')
// 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
`;
3. Transacciones
# Rails
ActiveRecord::Base.transaction do
user = User.create!(name: 'John')
Post.create!(title: 'Hello', author: user)
end
// 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'
}
});
});
🧪 Testing con Factories
1. Configurar factories
npm install -D @faker-js/faker
// 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;
}
}
// 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
});
}
}
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);
});
});
});
💻 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])
}
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")
}
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
- Script de migración completo que cree usuarios y posts con relaciones
- 3 consultas complejas implementadas tanto en concepto Rails como en Prisma
- Suite de tests usando factories para User y Post services
- 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)