Saltar a contenido

Convenciones

Tipos de dato canónicos

Un concepto del dominio tiene exactamente un tipo. No se admiten dos columnas que representen lo mismo con tipos distintos.

Concepto Tipo Regla
Llave primaria uuid D-01
Instante timestamptz D-02
Fecha de negocio date Resuelta en America/Managua
Hora del día time Sin zona
Rango de minutos del día int4range Minutos desde medianoche, [)
Rango de fechas daterange Vigencias, [)
Dinero numeric(10,2) D-03
Monto derivado o agregado numeric(12,2) Evita desbordar al multiplicar por cantidad
Porcentaje numeric(6,2) Rango 0.00-100.00
Unidades de una oferta smallint Techo de negocio: 50
Contador acumulado integer
Texto acotado varchar(n) n proviene de un requerimiento citable
Texto libre text Sin cota, con CHECK de tamaño
Código estable de catálogo varchar(50) Patrón ^[a-z][a-z0-9_]*$
Booleano boolean NOT NULL con DEFAULT explícito
Punto geográfico geography(Point,4326) D-04
Área geográfica geography(Polygon,4326)
Documento semiestructurado jsonb Solo donde el esquema es heterogéneo por diseño

¿Por qué acotar además con CHECK?

varchar(n) obliga a reescribir la tabla para ampliar el límite. Las longitudes que provienen de un requerimiento se declaran además como restricción de verificación con nombre propio, que puede agregarse NOT VALID y validarse después sin bloquear escrituras. El nombre aparece en el error y permite identificar cuál de las reglas de la tabla se violó. Ver tipos de carácter.

¿Por qué numeric y no float?

Los reportes de RF-A-51 suman miles de montos. double precision acumula error de representación en binario y produce totales que no cuadran contra la suma manual del comercio. numeric es exacto en base decimal. Ver tipos numéricos.


Columnas estándar

Toda tabla de dominio incluye estas columnas. No se repiten en las tablas de campos de cada ficha.

Columna Tipo Nulo Predeterminado Propósito
id uuid no uuidv7() Llave primaria
created_at timestamptz no now() Instante de alta
updated_at timestamptz no now() Mantenido por trg_touch_updatedat

Las tablas append-only no llevan updated_at: una fila que nunca cambia no puede tener fecha de última modificación, y tenerla sería afirmar algo falso sobre la tabla.

Toda tabla lleva además dos protecciones heredadas de su clase base, no declaradas fila por fila: trg_protect_truncate, que bloquea TRUNCATE a nivel de sentencia, y trg_readonly_primarykey, que rechaza cualquier cambio de la llave primaria. Los tres triggers de esta sección son la única excepción al patrón trg_<tabla>_<regla> de Nomenclatura: al vivir en la clase base de la que heredan todas las tablas, se declaran una sola vez con un nombre fijo en vez de uno por tabla.

trg_touch_updatedat se dispara con una condición AnyChange que excluye la propia updated_at. En las seis tablas que llevan columnas generadas almacenadas (invitacion, comercio_horario, ventana_recogida, publicacion, reserva y validacion_reserva) la condición excluye además esas columnas: una columna generada cambia porque cambió su expresión, no porque alguien la escribiera, e incluirla en la condición haría que el trigger se considerara disparado por su propio efecto.

id se genera con uuidv7(), que es ordenable en el tiempo. Eso lo convierte en el desempate natural de toda consulta paginada: una página sin ORDER BY determinista puede repetir u omitir filas entre peticiones, y ordenar por id descendente da orden cronológico inverso sin costo adicional porque recorre la llave primaria. Toda consulta paginada del sistema termina su cláusula de orden con id.


Política de nulabilidad

La regla depende del tipo, porque el nulo no significa lo mismo en todos.

El nulo se admite solo donde no existe un valor de ausencia honesto:

  • Nunca nulos. Un booleano nulo es una enumeración de tres valores mal declarada.
  • Si hacen falta tres estados, es un estado y va a su tabla de catálogo.
  • Nunca nulos. El valor de ausencia es '', [] o {}, declarado como DEFAULT.
  • Dos valores vacíos obligan a escribir col IS NOT NULL AND col <> '' en cada predicado, y basta olvidar una mitad para que la consulta mienta.
  • El nulo se propaga por concatenación y comparación, así que una sola columna nulable envenena toda expresión derivada que la use.
  • Los índices y restricciones parciales usan length(col) > 0, que es exacto y no requiere COALESCE.
  • NULL es el valor de ausencia.
  • Aplica a timestamptz, date, time, uuid, numeric, smallint y geography.
  • No hay centinela en el dominio que no sea una mentira: 0 no es sin monto y 1970-01-01 no es sin fecha.
  • La ficha de la tabla declara si el nulo significa todavía no aplica o no aplica en esta variante.

Nomenclatura

Prefijo por tipo de objeto, seguido del nombre de la tabla y de los segmentos que lo identifican. Los segmentos colapsan los guiones bajos internos del nombre de columna (created_at => createdat) y omiten el sufijo _id de las llaves foráneas, para respetar el límite de 63 bytes de los identificadores de PostgreSQL.

Objeto Patrón Ejemplo
Unicidad unq_<tabla>_<columnas> unq_reserva_codigo
Verificación chk_<tabla>_<regla> chk_reserva_cantidad_positiva
Exclusión excl_<tabla>_<regla> excl_ventanarecogida_solape
Índice B-tree idx_<tabla>_<columnas> idx_reserva_comercio_fechaoperativa
Índice GIN gin_<tabla>_<columna> gin_comercio_nombrecomercial
Índice GiST gist_<tabla>_<columna> gist_comercio_ubicacion
Trigger trg_<tabla>_<regla> trg_reserva_transicion_valida

Las restricciones se nombran siempre de forma explícita. Un nombre generado por PostgreSQL (reserva_cantidad_check1) aparece en el mensaje de error que ve el usuario final a través de la API y no dice nada sobre la regla violada.

Los triggers de solo lectura son la segunda excepción al patrón, además de los cinco de la clase base: se llaman trg_readonly_snapshot en las seis tablas que congelan un snapshot (documento_legal, publicacion, reserva, valoracion, incidencia_descargo, validacion_reserva) sin el nombre de la tabla. Es deliberado: un trigger de esta clase no impone una regla propia, aplica el mismo régimen genérico con otra lista de columnas, y el nombre uniforme hace evidente que son el mismo mecanismo. Los nombres de trigger son únicos por tabla en PostgreSQL, así que repetirlos no colisiona.

Convención de nombres de tabla

Singular, snake_case, en español. Las tablas puente llevan los nombres de ambos extremos en orden de dependencia (e.g. usuario_comercio). Las tablas de catálogo de estado llevan el prefijo estado_ seguido de la entidad que gobiernan (estado_reserva), descrito en Estados.


Llaves primarias

Toda tabla de dominio usa una llave primaria uuid sustituta, por decisión transversal.

Las relaciones donde la llave natural sería la única candidata se expresan con un índice único sobre la columna correspondiente, que da exactamente la misma garantía sin propagar columnas a las tablas que la referencian.

Relación Declaración Restricción
1:1 Llave foránea con índice único unq_<tabla>_<relacion>
Par único de dos entidades UNIQUE (columna_a, columna_b) unq_<tabla>_<a>_<b>
Unicidad condicionada Índice único parcial unq_<tabla>_<columnas>_<caso>

Llaves foráneas

La acción referencial se elige por la naturaleza de la relación, no por uniformidad.

Situación ON DELETE Justificación
Referencia a un catálogo RESTRICT D-13
Referencia a un estado RESTRICT Un estado referenciado por una fila histórica es parte del historial
Referencia a una entidad de negocio RESTRICT D-12
Actor de una acción auditada RESTRICT La bitácora debe seguir nombrando a quien actuó
Fila hija sin identidad propia fuera de la madre CASCADE Su existencia carece de sentido si la madre desaparece
Adjunto opcional de una fila que persiste SET NULL Retirar la imagen no puede arrastrar a la entidad que la usaba

ON UPDATE no se declara en ninguna llave foránea: las llaves primarias son inmutables por trg_readonly_primarykey, de modo que no hay actualización que propagar.

Las llaves foráneas se declaran DEFERRABLE INITIALLY DEFERRED. La verificación ocurre al cerrar la transacción y no al ejecutar cada sentencia, lo que permite escribir padre e hijo en el orden que imponga la lógica sin reordenar las sentencias ni calcular dependencias.

El precio es que una violación referencial aparece en el COMMIT y no en la sentencia que la causó, de modo que el error no señala qué escritura estuvo mal. Cuando eso importa (p. ej., al construir la respuesta de un error de escritura compuesta) la transacción adelanta la verificación con SET CONSTRAINTS ALL IMMEDIATE, que aplica hasta el final de la transacción y se revierte con ella.

Nombres por papel

Una llave foránea se nombra por el papel que cumple la fila referenciada, no por la tabla a la que apunta. comercio.logo_id y usuario.foto_id apuntan ambas a media, y llamarlas logo_media_id y foto_media_id repetiría en el nombre lo que la restricción ya declara.

La excepción es cuando el papel no distingue: motivo_id se llama así porque el papel es ser el motivo. Cuando una tabla referencia dos veces el mismo catálogo, ambos nombres llevan el papel completo: valoracion.motivo_moderacion_id frente a solicitud_afiliacion.motivo_rechazo_id.

Cascadas del modelo

Solo cinco relaciones son CASCADE, y todas comparten la misma forma: la fila hija es un fragmento de la madre, carece de identidad de negocio propia y nadie más la referencia.

  • comercio_foto cuelga de comercio
  • plantilla_oferta_item cuelga de plantilla_oferta
  • plantilla_programacion cuelga de plantilla_oferta
  • excepcion_calendario cuelga de plantilla_oferta
  • publicacion_item cuelga de publicacion

En todos los demás casos la relación es entre entidades con vida propia, y el borrado del padre debe fallar en lugar de arrastrar historia.

Indexación

Toda llave foránea lleva un índice B-tree sobre su columna. PostgreSQL indexa automáticamente el lado referenciado, nunca el referenciante, y sin ese índice cada verificación de la restricción recorre la tabla hija completa. Las fichas solo listan índices de llave foránea cuando tienen columnas adicionales o una condición parcial.


Criterios de invariantes

Criterio único aplicado en todo el modelo. Ante la duda, gana la fila más alta de la tabla: el mecanismo más barato y más difícil de evadir.

Tipo de regla Mecanismo ¿Por qué no el siguiente?
Depende solo de columnas de la propia fila CHECK Costo nulo en escritura, imposible de evadir
Unicidad, incluso condicional Índice único, parcial si aplica El motor la garantiza bajo concurrencia
No solapamiento de rangos EXCLUDE
btree_gist
Único mecanismo correcto bajo concurrencia;
ver restricciones de exclusión
Existencia de una fila relacionada Llave foránea
Depende de otras filas de la misma tabla Trigger CHECK no puede consultar otras filas
Depende de una columna de otra tabla Trigger D-10
Depende de un parámetro configurable Trigger que lee parametro_sistema RF-A-37
Inmutabilidad de filas o columnas Trigger BEFORE que lanza error D-07
Derivación a partir de otra tabla Trigger BEFORE que escribe la columna Una columna generada no puede referirse a otra fila; ver Triggers
Orquestación con efectos externos Servicio de aplicación en la transacción Correo y SMS no pueden participar de la transacción

Las dos filas de trigger que preceden a la última son la razón por la que el modelo tiene una página propia para ellos. El contrato común, las familias de las que se derivan y el catálogo completo están en Triggers.

Predicados de índice parcial

El predicado de un índice solo admite expresiones inmutables sobre columnas de la propia fila. No puede consultar otra tabla ni llamar a now().

En consecuencia, los índices parciales del modelo se apoyan en marcas temporales nulables, nunca en el estado. Cada entidad con ciclo de vida lleva las marcas que registran sus transiciones, y el subconjunto vivo se identifica con IS NULL sobre la marca de cierre.

CREATE UNIQUE INDEX unq_reserva_usuario_comercio_activa
ON reserva (usuario_id, comercio_id)
WHERE finalizada_at IS NULL;

Triggers que dereferencian llaves foráneas

Un trigger BEFORE se ejecuta antes de que se verifique la llave foránea de la fila que lo dispara, porque esa verificación está diferida al cierre de la transacción. Un trigger que lee la fila referenciada para copiar datos o validar coherencia no puede asumir que esa fila existe.

Todos ellos comprueban la existencia como primer paso y lanzan una violación referencial con la columna explícita. Sin esa comprobación, un identificador inexistente produce el error que devuelva la consulta interna del trigger, que no nombra la columna y llega al cliente como un conflicto genérico.

Formato de las fichas de trigger

Cada trigger se documenta con seis campos, en este orden: nombre, evento, momento, nivel, regla que impone y requerimiento que la origina. Un trigger sin requerimiento asociado es un trigger que hay que justificar o borrar.

La ficha de cada trigger vive en la página del módulo al que pertenece su tabla. Triggers define el contrato que todas respetan y clasifica el catálogo completo, distinguiendo lo que ya está implementado de lo que está diseñado y pendiente.

Nombres generados por Django

Las restricciones se declaran con %(class)s, que Django expande al nombre de la clase del modelo en minúsculas y sin separadores, no al nombre de la tabla.


Columnas generadas

PostgreSQL 18 admite las dos formas y el modelo usa cada una donde corresponde.

La forma la decide si la columna se indexa:

  • Se calcula en cada lectura y no ocupa almacenamiento.
  • Es la forma de toda derivación aritmética que solo se lee: monto_total, cantidad_disponible, entrega_parcial, telefono_ultimos4.
  • No puede indexarse ni aparecer en el predicado de un índice.
  • Se materializa en la escritura.
  • Obligatoria cuando la columna se indexa o se usa en un predicado: descuento_pct, franja, finalizada_at.
  • Su costo se paga una vez por escritura, no una vez por lectura.

En ambos casos la expresión debe ser inmutable y solo puede referirse a columnas de la propia fila, nunca a otra columna generada. Una derivación que necesite datos de otra tabla no es columna generada: es una columna normal que escribe el trigger de inserción y protege un trigger de solo lectura. Lo mismo aplica cuando la expresión, aunque solo lea la propia fila, no es inmutable en el sentido que PostgreSQL exige: to_char sobre un timestamptz depende del TimeZone de la sesión y PostgreSQL la clasifica STABLE, no IMMUTABLE, así que semana_iso de campana tampoco es columna generada, sino una columna normal que deriva trg_campana_semana_iso. Ver columnas generadas.


Auditoría por tabla

Toda tabla se clasifica en exactamente uno de estos tres regímenes:

  • Admite INSERT.
  • UPDATE y DELETE están bloqueados por un único trigger (trg_append_only).
  • No lleva tabla de eventos: la tabla ya es su propio historial.
  • Admite INSERT y UPDATE sobre un subconjunto declarado de columnas.
  • DELETE está bloqueado por trg_protect_delete, y las columnas fuera del subconjunto son de solo lectura.
  • Es el régimen de reserva.
  • Admite INSERT y UPDATE libres.
  • DELETE no borra la fila: trg_softdelete_isactive lo reescribe en un UPDATE de is_active = false, así que la fila nunca desaparece.
  • Lleva una tabla de eventos gemela que registra inserción, actualización y borrado con el contexto de la petición.
  • Es el régimen por defecto de las entidades de negocio y de las tablas de estado (estado_<entidad>).

La tabla de eventos del tercer régimen es append-only, replica las columnas de la tabla rastreada y agrega el instante del evento, su etiqueta y el contexto de la petición en jsonb. Va indexada por el identificador de la fila original, por el instante y por el actor extraído del contexto.