34. Seguimiento del historial de ediciones usando triggers¶
Un requisito común para bases de datos en producción es la capacidad de rastrear el historial: ¿cómo han cambiado los datos entre dos fechas, quién hizo los cambios y dónde ocurrieron? Algunos sistemas GIS rastrean los cambios incluyendo gestión de cambios en la interfaz del cliente, pero eso agrega mucha complejidad a las herramientas de edición.
Usando la base de datos y el sistema de triggers, es posible añadir seguimiento de historial a cualquier tabla, mientras se mantiene un acceso simple de «edición directa» a la tabla principal.
El seguimiento de historial funciona manteniendo una tabla de historial que registra, para cada edición:
Si un registro fue creado, cuándo se añadió y por quién.
Si un registro fue eliminado, cuándo se eliminó y por quién.
Si un registro fue actualizado, añadiendo un registro de eliminación (para el estado antiguo) y un registro de creación (para el nuevo estado).
34.1. Usando TSTZRANGE¶
La tabla de historial utiliza una característica específica de PostgreSQL: el tipo «timestamp range» para almacenar el rango de tiempo en el cual un registro de historial fue el registro «activo». Se espera que todos los rangos de tiempo en la tabla de historial para una característica en particular no se superpongan, pero sí sean adyacentes.
El rango para un nuevo registro comenzará en now()
y tendrá un extremo abierto, de modo que el rango cubra todo el tiempo desde el actual hacia el futuro.
SELECT tstzrange(current_timestamp, NULL);
tstzrange
------------------------------------
["2021-06-01 14:49:40.910074-07",)
De manera similar, el rango de tiempo para un registro eliminado se actualizará para incluir el tiempo actual como el extremo final del rango de tiempo.
Buscar en rangos de tiempo es mucho más simple que buscar en un par de marcas de tiempo, debido a la forma en que un rango abierto de tiempo abarca todo el tiempo desde el inicio hasta el infinito. El operador «contains» @>
para rangos es el que usaremos.
-- Does the range of "ten minutes ago to the future" include now?
-- It should! :)
--
SELECT tstzrange(current_timestamp - '10m'::interval, NULL) @> current_timestamp;
Los rangos pueden indexarse de manera muy eficiente usando un índice GIST, al igual que los datos espaciales, como mostraremos a continuación. Esto hace que las consultas de historial sean muy eficientes.
34.2. Construyendo la tabla de historial¶
Usando esta información es posible reconstruir el estado de la tabla de edición en cualquier punto en el tiempo. En este ejemplo, añadiremos seguimiento de historial a nuestra tabla nyc_streets.
Primero, añade una nueva tabla nyc_streets_history. Esta es la tabla que usaremos para almacenar toda la información histórica de edición. Además de todos los campos de nyc_streets, añadimos cinco campos más.
hid la clave primaria para la tabla de historial
created_by el usuario de base de datos que hizo que el registro fuera creado
deleted_by el usuario de base de datos que hizo que el registro fuera marcado como eliminado
valid_range el rango de tiempo dentro del cual el registro estuvo «activo»
Observa que en realidad no eliminamos ningún registro en la tabla de historial, solo marcamos el momento en que dejaron de ser parte del estado actual de la tabla de edición.
DROP TABLE IF EXISTS nyc_streets_history; CREATE TABLE nyc_streets_history ( hid SERIAL PRIMARY KEY, gid INTEGER, id FLOAT8, name VARCHAR(200), oneway VARCHAR(10), type VARCHAR(50), geom GEOMETRY(MultiLinestring,26918), valid_range TSTZRANGE, created_by VARCHAR(32), deleted_by VARCHAR(32) ); CREATE INDEX nyc_streets_history_geom_x ON nyc_streets_history USING GIST (geom); CREATE INDEX nyc_streets_history_tstz_x ON nyc_streets_history USING GIST (valid_range);
A continuación, importamos el estado actual de la tabla activa, nyc_streets, en la tabla de historial, para tener un punto de partida desde el cual rastrear la historia. Observa que llenamos el tiempo de creación y el usuario de creación, pero dejamos el final del rango de tiempo y la información de «deleted_by» en NULL.
INSERT INTO nyc_streets_history (gid, id, name, oneway, type, geom, valid_range, created_by) SELECT gid, id, name, oneway, type, geom, tstzrange(now(), NULL), current_user FROM nyc_streets;
Ahora necesitamos tres triggers en la tabla activa, para las acciones INSERT, DELETE y UPDATE. Primero creamos las funciones de trigger, luego las vinculamos a la tabla como triggers.
Para un insert, solo añadimos un nuevo registro en la tabla de historial con el tiempo/usuario de creación.
CREATE OR REPLACE FUNCTION nyc_streets_insert() RETURNS trigger AS $$ BEGIN INSERT INTO nyc_streets_history (gid, id, name, oneway, type, geom, valid_range, created_by) VALUES (NEW.gid, NEW.id, NEW.name, NEW.oneway, NEW.type, NEW.geom, tstzrange(current_timestamp, NULL), current_user); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER nyc_streets_insert_trigger AFTER INSERT ON nyc_streets FOR EACH ROW EXECUTE PROCEDURE nyc_streets_insert();
Para una eliminación, solo marcamos el registro de historial actualmente activo (el que tiene NULL en el tiempo de eliminación) como eliminado.
CREATE OR REPLACE FUNCTION nyc_streets_delete() RETURNS trigger AS $$ BEGIN UPDATE nyc_streets_history SET valid_range = tstzrange(lower(valid_range), current_timestamp), deleted_by = current_user WHERE valid_range @> current_timestamp AND gid = OLD.gid; RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE TRIGGER nyc_streets_delete_trigger AFTER DELETE ON nyc_streets FOR EACH ROW EXECUTE PROCEDURE nyc_streets_delete();
Para una actualización, primero marcamos el registro de historial activo como eliminado, luego insertamos un nuevo registro para el estado actualizado.
CREATE OR REPLACE FUNCTION nyc_streets_update() RETURNS trigger AS $$ BEGIN UPDATE nyc_streets_history SET valid_range = tstzrange(lower(valid_range), current_timestamp), deleted_by = current_user WHERE valid_range @> current_timestamp AND gid = OLD.gid; INSERT INTO nyc_streets_history (gid, id, name, oneway, type, geom, valid_range, created_by) VALUES (NEW.gid, NEW.id, NEW.name, NEW.oneway, NEW.type, NEW.geom, tstzrange(current_timestamp, NULL), current_user); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER nyc_streets_update_trigger AFTER UPDATE ON nyc_streets FOR EACH ROW EXECUTE PROCEDURE nyc_streets_update();
34.3. Editando la tabla¶
Ahora que la tabla de historial está habilitada, podemos hacer ediciones en la tabla principal y ver cómo las entradas de log aparecen en la tabla de historial.
Observa el poder de este enfoque basado en base de datos para el historial: sin importar qué herramienta se use para hacer las ediciones, ya sea la línea de comandos SQL, una herramienta web basada en JDBC o una herramienta de escritorio como QGIS, el historial se rastrea de manera consistente.
34.3.1. Ediciones SQL¶
Cambiemos las dos calles llamadas «Cumberland Walk» al más elegante «Cumberland Wynde»:
Actualizar las dos calles hará que las calles originales sean marcadas como eliminadas en la tabla de historial, con un tiempo de eliminación de now, y dos nuevas calles con el nuevo nombre añadidas, con un tiempo de creación de now. Puedes inspeccionar los registros históricos:
34.4. Consultando la tabla de historial¶
Ahora que tenemos una tabla de historial, ¿para qué sirve? ¡Es útil para viajar en el tiempo! Para viajar a un tiempo particular T, necesitas construir una consulta que incluya:
Todos los registros creados antes de T, y que no hayan sido eliminados; y también
Todos los registros creados antes de T, pero eliminados después de T.
Podemos usar esta lógica para crear una consulta, o una vista, del estado de los datos en el pasado. Como presumiblemente todas tus ediciones de prueba ocurrieron en los últimos minutos, creemos una vista de la tabla de historial que muestre el estado de la tabla hace 10 minutos, antes de que empezaras a editar (es decir, los datos originales).
-- Records with a valid range that includes 10 minutes ago
-- are the ones valid at that moment.
CREATE OR REPLACE VIEW nyc_streets_ten_min_ago AS
SELECT * FROM nyc_streets_history
WHERE valid_range @> (now() - '10min'::interval)
We can also create views that show just what a particular user has added, for example:
CREATE OR REPLACE VIEW nyc_streets_postgres AS
SELECT * FROM nyc_streets_history
WHERE created_by = 'postgres';