Relaciones Polimórficas en NestJS con PostgreSQL y Drizzle ORM

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.

José Rafael Gutierrez

Soy un desarrollador web con más de 14 años de experiencia, especializado en la creación de sistemas a medida. Apasionado por la tecnología, la ciencia, y la lectura, disfruto resolviendo problemas de...

Suscríbete para Actualizaciones

Proporcione su correo electrónico para recibir notificaciones sobre nuevas publicaciones o actualizaciones.