Relaciones Polimórficas en NestJS con PostgreSQL y Drizzle ORM
José Rafael Gutierrez
hace 1 mes
Introducción
En una base de datos, no es raro que una misma entidad esté relacionada con varios tipos de entidades similares. Por ejemplo, un comentario de un usuario podría estar asociado a una foto, un artículo o un archivo de audio. La solución más intuitiva para esta situación sería crear una tabla de comentarios específica para cada tipo de contenido, como FotoComentario
y ArticuloComentario
. Sin embargo, este enfoque conlleva duplicación de código, ya que la estructura de un comentario sobre una foto es similar a la de un comentario sobre un artículo.
Para simplificar este diseño y evitar duplicaciones, podemos usar relaciones polimórficas, un patrón de diseño que permite que una tabla única se asocie con varias tablas diferentes, permitiendo que una entidad, como un comentario, esté vinculada a diversos tipos de entidades.
Implementación de Relaciones Polimórficas
Enfoque Erróneo
Una solución simple para implementar una asociación polimórfica sería usar una única columna en la tabla de comentarios, como targetId
, que apunte al ID de una foto o un artículo. Sin embargo, este enfoque no es confiable, ya que PostgreSQL trata targetId
como un número ordinario y no puede garantizar que apunte a un registro válido en las tablas de fotos o artículos. Por lo tanto, si se elimina una foto o un artículo, deberíamos recordar eliminar manualmente todos los comentarios relacionados.
import { serial, text, pgTable, integer } from 'drizzle-orm/pg-core';
export const photos = pgTable('photos', {
id: serial('id').primaryKey(),
imageUrl: text('photo_url').notNull(),
});
export const articles = pgTable('articles', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content').notNull(),
});
export const comments = pgTable('comments', {
id: serial('id').primaryKey(),
content: text('content').notNull(),
targetId: integer('target_id'),
});
export const databaseSchema = {
articles,
photos,
comments,
};
Una Solución Mejorada
Para mejorar la integridad de los datos, podemos dividir targetId
en dos columnas separadas: photoId
y articleId
. Con Drizzle ORM, estas columnas pueden estar relacionadas con sus respectivas tablas y definidas como claves foráneas. Esta estrategia crea restricciones independientes para cada relación, y PostgreSQL puede asegurar que al menos una de estas columnas sea nula en cada fila.
import { serial, text, pgTable, integer } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const photos = pgTable('photos', {
id: serial('id').primaryKey(),
imageUrl: text('photo_url').notNull(),
});
export const articles = pgTable('articles', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content').notNull(),
});
export const comments = pgTable('comments', {
id: serial('id').primaryKey(),
content: text('content').notNull(),
photoId: integer('photo_id').references(() => photos.id),
articleId: integer('article_id').references(() => articles.id),
});
export const commentsRelations = relations(comments, ({ one }) => ({
photo: one(photos, {
fields: [comments.photoId],
references: [photos.id],
}),
article: one(articles, {
fields: [comments.articleId],
references: [articles.id],
}),
}));
export const databaseSchema = {
articles,
photos,
comments,
commentsRelations,
};
En la migración de SQL generada por Drizzle ORM, se incluye la restricción para que las columnas photoId
y articleId
permitan valores nulos. Esto permite que un comentario esté relacionado con una foto o un artículo, pero nunca con ambos al mismo tiempo. La función num_nonnulls
de PostgreSQL garantiza que exactamente una de las columnas tenga un valor no nulo:
ALTER TABLE "comments"
ADD CONSTRAINT check_if_only_one_is_not_null CHECK
(num_nonnulls("photo_id", "article_id") = 1);
Validación de Datos
Para asegurar que el usuario proporcione solo una de las claves foráneas (ya sea photoId
o articleId
), podemos crear un decorador personalizado en class-validator
. Este decorador verifica que uno de los campos tenga un valor entero válido y que solo uno de ellos esté presente.
import {
IsNotEmpty,
IsString,
registerDecorator,
ValidationArguments,
} from 'class-validator';
const idKeys: (keyof CreateCommentDto)[] = ['photoId', 'articleId'];
export function ContainsValidForeignKeys() {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'containsValidForeignKeys',
target: object.constructor,
propertyName: propertyName,
options: {
message: `You need to provide exactly one of the following properties: ${idKeys.join(', ')}`,
},
validator: {
validate(value: unknown, validationArguments: ValidationArguments) {
const comment = validationArguments.object as CreateCommentDto;
if (value && !Number.isInteger(value)) {
return false;
}
const includedIdKeys = idKeys.filter((key) => comment[key]);
return includedIdKeys.length === 1;
},
},
});
};
}
export class CreateCommentDto {
@IsString()
@IsNotEmpty()
content: string;
@ContainsValidForeignKeys()
photoId?: number;
@ContainsValidForeignKeys()
articleId?: number;
}
Este decorador asegura que el usuario proporcione solo un photoId
o un articleId
para cada comentario. Así, si un usuario intenta crear un comentario sin ninguna referencia válida o con ambas, se generará un mensaje de error adecuado.
Manejo de Errores en el Servicio
Para mejorar la experiencia del usuario, implementamos un manejo de errores que captura violaciones de restricciones y envía mensajes apropiados:
import { BadRequestException, Injectable } from '@nestjs/common';
import { DrizzleService } from '../database/drizzle.service';
import { databaseSchema } from '../database/database-schema';
import { CreateCommentDto } from './dto/create-comment.dto';
import { isDatabaseError } from '../database/databse-error';
import { PostgresErrorCode } from '../database/postgres-error-code.enum';
@Injectable()
export class CommentsService {
constructor(private readonly drizzleService: DrizzleService) {}
async create(comment: CreateCommentDto) {
try {
const createdComments = await this.drizzleService.db
.insert(databaseSchema.comments)
.values(comment)
.returning();
return createdComments.pop();
} catch (error) {
if (!isDatabaseError(error)) {
throw error;
}
if (error.code === PostgresErrorCode.ForeignKeyViolation) {
throw new BadRequestException('Provide a valid foreign key');
}
if (error.code === PostgresErrorCode.CheckViolation) {
throw new BadRequestException('Provide exactly one foreign key');
}
throw error;
}
}
}
Conclusión
En este artículo, hemos implementado una asociación polimórfica usando Drizzle ORM y PostgreSQL para gestionar comentarios vinculados a fotos o artículos, garantizando la integridad de la base de datos. Este patrón de diseño permite simplificar el esquema al reducir el número de tablas, ofreciendo flexibilidad en la relación de entidades. Sin embargo, es importante utilizar esta estrategia con cuidado, ya que puede dificultar la depuración y el mantenimiento para desarrolladores no familiarizados con ella.