Deja que Postgres te lleve la contraria: lo que aprendí insertando nueve filas
Intenté sembrar los proyectos de este portfolio y la base de datos me rechazó el INSERT dos veces seguidas. Las dos veces tenía razón ella. La lección: introspecciona el esquema antes de adivinarlo.
PostgreSQLSupabaseBases de datosBuenas prácticas
Estaba poblando la tabla projects de este portfolio con nueve filas. Un INSERT de toda la vida. Tardé tres intentos.
No es una historia de frustración: es una historia sobre por qué los constraints de la base de datos son la mejor documentación que tiene un proyecto, y sobre un detalle de la lógica de tres valores de SQL que resolvió el problema de una línea.
Intento 1: la cadena vacía no es "sin valor"
Seis de los nueve proyectos tienen el repositorio privado. Como la columna github era NOT NULL, puse cadena vacía y pensé que en la interfaz ya me ocuparía de ocultar el botón.
ERROR: 23514: new row for relation "projects"
violates check constraint "projects_github_url"
El constraint era este:
CHECK (github ~* '^https?://.+')
Una cadena vacía no es una URL. Correcto. La base de datos me estaba diciendo algo razonable: esta columna guarda URLs; '' no lo es.
Mi modelo mental estaba mal, no el constraint. '' no significa "no hay repositorio". Significa "el repositorio es la cadena vacía", que es una afirmación falsa. Lo que yo quería decir era "no hay dato", y para eso SQL tiene una palabra concreta.
El detalle que lo resuelve en una línea
Aquí está lo interesante. Un CHECK en Postgres no rechaza NULL. La condición se evalúa con lógica de tres valores, NULL ~* '...' da NULL, y un CHECK solo falla cuando el resultado es explícitamente FALSE. Un resultado NULL se considera satisfecho.
Consecuencia práctica: no había que tocar el constraint. Bastaba con permitir la ausencia de dato:
ALTER TABLE public.projects ALTER COLUMN github DROP NOT NULL;
Y meter NULL en lugar de ''. El CHECK sigue exigiendo forma de URL a todo lo que sí tenga valor, y los proyectos sin repo público simplemente no tienen valor. El modelo de datos dice ahora exactamente lo que quiero decir.
Esto se me olvida más veces de las que debería, y es un patrón muy útil: para "opcional pero con formato", la combinación correcta es columna nullable con CHECK de formato. No hace falta el clásico CHECK (x IS NULL OR x ~ ...); el IS NULL OR es redundante.
Intento 2: el constraint que ya te estaba diciendo dónde poner las imágenes
Corregido lo del github, volví a lanzarlo:
ERROR: 23514: new row for relation "projects"
violates check constraint "projects_image_valid"
Mis imágenes eran rutas locales tipo /projects/tasator.svg. Y aquí es donde me di cuenta de que llevaba dos intentos adivinando en vez de mirar. Así que por fin hice lo que tenía que haber hecho al principio:
SELECT conname, pg_get_constraintdef(oid) AS definicion
FROM pg_constraint
WHERE conrelid = 'public.projects'::regclass AND contype = 'c'
ORDER BY conname;
Seis constraints, todos de golpe:
conname
definición
projects_title_check
char_length(title) >= 1 AND char_length(title) <= 100
projects_description_check
char_length(description) >= 1 AND char_length(description) <= 500
projects_technologies_check
cardinality(technologies) >= 1
projects_demo_url
demo ~* '^https?://.+'
projects_github_url
github ~* '^https?://.+'
projects_image_valid
image ~* '^(https?://.+|/placeholder.*)$'
Ahí estaba la respuesta, y era mejor que la que yo iba a inventarme. projects_image_valid acepta dos cosas: una URL http(s), o una ruta local que empiece por /placeholder.
O sea que las rutas locales sí valían. Solo estaban permitidas en un sitio concreto. Moví los SVG de public/projects/ a public/placeholder/, cambié nueve rutas y entró a la primera.
Cero cambios de esquema. La solución que iba a escribir yo —relajar el CHECK para admitir cualquier ruta que empezara por /— habría funcionado, pero habría borrado una decisión de diseño que alguien (yo, meses antes) tomó a propósito.
Lo que ese constraint sabía y yo había olvidado
Y encaja con algo que ya estaba en el código. Este proyecto usa un loader de imágenes propio, porque next.config.ts va con loader: 'custom'. Lo primero que hace es:
export default function supabaseLoader({ src, width, quality }) {
// Si es una ruta local (empieza con /), devolverla tal cual sin procesar
if (src.startsWith('/')) {
return src
}
// ...el resto reescribe las URLs de Supabase Storage al endpoint
// de transformación /render/image/ con width y quality
}
Las rutas locales pasan sin tocar. Las de Supabase Storage se reescriben al endpoint de transformación. Y cualquier otra cosa —una URL de Unsplash, por ejemplo— se trataría por error como si fuera una ruta de bucket y saldría mal.
El constraint estaba codificando esa realidad: las dos únicas fuentes de imagen que este proyecto sabe servir son Storage y public/placeholder/. No era una restricción arbitraria, era el invariante del loader escrito donde no se puede ignorar.
Que además explica por qué la carpeta se llama placeholder y no images: hay un project.image || "/placeholder.svg" como respaldo en el componente de la rejilla, y el CHECK se escribió alrededor de ese prefijo.
Lo que hago diferente ahora
Introspecciona antes de adivinar. Un SELECT sobre pg_constraint cuesta dos segundos y te da las seis reglas juntas. Yo hice dos intentos a ciegas, cada uno con su ciclo de editar, ejecutar y leer el error. Y con nueve filas el error solo delataba la primera; con un lote grande podría haber ido descubriendo constraints de uno en uno durante un rato.
El mensaje de error trae el nombre, no la razón.violates check constraint "projects_github_url" te dice qué regla y en qué fila, pero no qué pide la regla. Ese salto —del nombre a la definición— es exactamente lo que hace pg_get_constraintdef.
Trata los constraints como especificación. De esa tabla salió gratis un montón de información sobre el diseño: las descripciones se recortan a 500 caracteres, ningún proyecto puede quedarse sin tecnologías, la demo es obligatoria y con forma de URL, y las imágenes solo pueden venir de dos sitios. Es un resumen del modelo de datos más fiable que cualquier documento, porque es ejecutable.
Y por eso los dejé copiados como comentario en la cabecera del fichero de seed. La próxima vez que añada un proyecto no voy a acordarme de que las descripciones tienen tope de 500 —la más larga que tengo va por 497, queda poco margen— y me lo va a recordar el fichero antes de que me lo recuerde Postgres.
El resumen
Dos rechazos, dos veces que la base de datos tenía razón:
'' no es "sin valor". Para eso está NULL, y un CHECK lo deja pasar solo. Columna nullable más CHECK de formato es el patrón para "opcional pero bien formado".
Antes de relajar un constraint, lee lo que dice. El mío ya contemplaba mi caso de uso, en una ruta que yo no había mirado.
Poner las reglas de integridad en la base de datos en vez de en el código de la aplicación tiene un coste: te vas a llevar errores como estos. Es un buen trato. La alternativa no es no tener el error, es tener nueve filas mal puestas y enterarte más tarde.