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 comoDEFAULT. - 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 requiereCOALESCE.
NULLes el valor de ausencia.- Aplica a
timestamptz,date,time,uuid,numeric,smallintygeography. - No hay centinela en el dominio que no sea una mentira:
0no es sin monto y1970-01-01no 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_fotocuelga decomercioplantilla_oferta_itemcuelga deplantilla_ofertaplantilla_programacioncuelga deplantilla_ofertaexcepcion_calendariocuelga deplantilla_ofertapublicacion_itemcuelga depublicacion
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 | EXCLUDEbtree_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. UPDATEyDELETEestán bloqueados por un único trigger (trg_append_only).- No lleva tabla de eventos: la tabla ya es su propio historial.
- Admite
INSERTyUPDATEsobre un subconjunto declarado de columnas. DELETEestá bloqueado portrg_protect_delete, y las columnas fuera del subconjunto son de solo lectura.- Es el régimen de
reserva.
- Admite
INSERTyUPDATElibres. DELETEno borra la fila:trg_softdelete_isactivelo reescribe en unUPDATEdeis_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.