Consultas de referencias cruzadas en MySQL

Hace algún tiempo que uso Redmine para gestionar proyectos, la herramienta en general es buena pero a veces necesitaba de reportes diarios de tiempo utilizado por los miembros del proyecto.

En este punto se me ocurrió si podría lograr una consulta de referencias cruzadas (al estilo MS Excel), MySQL no soporta consultas de ese tipo pero pueden simularse:

1. Escribir la consulta principal que contenga las filas más importantes: project identifier, task subject y total_hours, todo en un rango de fechas.

select p.identifier, 
	e.issue_id, i.subject, round(sum(e.hours),2) as total_hours
	from redmine_nsp.time_entries e
	inner join redmine_nsp.issues i on e.issue_id = i.id
	inner join redmine_nsp.projects p on e.project_id = p.id
	where e.spent_on between '2014-07-01' and '2014-07-07'
 group by p.identifier,e.issue_id;

Resultado:

+------------+----------+----------------------------+-------------+
| identifier | issue_id | subject                    | total_hours |
+------------+----------+----------------------------+-------------+
| bg02       |     3223 | Gestion de proyecto        |        0.25 |
| bg04       |     3256 | 1.1 Preparación del entor  |        0.63 |
| emision    |     3251 | Desarrollar la aplicación  |        3.97 |
| nsp00      |     3236 | Preparar propuesta para G  |        2.02 |
| nsp02      |     3234 | Subida al servidor de pro  |        0.52 |
| nsp02      |     3240 | Agregar funcionalidad de   |        0.55 |
| nsp02      |     3241 | Revertir el Documento      |        2.80 |
| nsp02      |     3242 | Agregar Filtros en las vi  |        0.72 |
| nsp02      |     3243 | Reportes de Cargo de entr  |        2.35 |
| nsp02      |     3254 | Control de sesiones        |        3.23 |
| nsp05      |     3252 | Mantenimiento del servido  |        0.18 |
| nsp05      |     3253 | Mantenimiento a redmine    |        0.53 |
| nsp06      |     3203 | Elaborar el visualzador d  |        0.23 |
| nsp06      |     3228 | Evitar que se tome mas de  |        0.25 |
| nsp06      |     3255 | Actualizar el porcentaje   |        1.50 |
| nsp08      |     3239 | Asistencia remota          |        1.38 |
+------------+----------+----------------------------+-------------+

2. Ahora queda averiguar quienes han estado activos en ese mismo rango de tiempo:

select e.user_id, u.login
from redmine_nsp.time_entries e
	inner join redmine_nsp.users u on e.user_id = u.id 
where e.spent_on between '2014-07-01' and '2014-07-07'
group by e.user_id;
+---------+----------+
| user_id | login    |
+---------+----------+
|       1 | user1    |
|       4 | user2    |
|       5 | user3    |
|       6 | user4    |
+---------+----------+

3. La tarea es insertar ese resultado como columnas del primer resultado, de tal modo que tengamos una consulta de referencias cruzadas. He creado un procedimiento (no explico mucho al respecto) que hace esa tarea recibiendo como parámetros las fechas:

DELIMITER $$

CREATE PROCEDURE `get_range_summary`(dDate1 date,dDate2 date)
BEGIN

DECLARE done INT DEFAULT 0;
declare p_user_id int;
declare p_sql text;
declare p_login varchar(255);
declare c_users cursor for
	select e.user_id, u.login
	from redmine_nsp.time_entries e
		inner join redmine_nsp.users u on e.user_id = u.id 
	where e.spent_on between  dDate1 and dDate2 
	group by e.user_id;
DECLARE CONTINUE HANDLER FOR SQLSTATE '02000' SET done = 1;

set p_sql = 'select s.codigo,s.issue_id,s.subject ';
open c_users;

read_loop: LOOP
	FETCH c_users INTO p_user_id, p_login;
    IF done THEN
      LEAVE read_loop;
    END IF;
	set p_sql = concat(p_sql,
      ', (select round(sum(t.hours),2) as total
		from redmine_nsp.time_entries t
		where t.spent_on between \'',dDate1,'\' and \'',dDate2,'\'  
        and t.issue_id = s.issue_id
		and t.user_id = ',p_user_id,') as `',p_login,'` ');

END LOOP;

set @sql = concat(p_sql,' ,s.total_hours from 
	(select p.identifier, e.issue_id, i.subject, 
         round(sum(e.hours),2) as total_hours
	from redmine_nsp.time_entries e
	inner join redmine_nsp.issues i on e.issue_id = i.id
	inner join redmine_nsp.projects p on e.project_id = p.id
	where e.spent_on between \'',dDate1,'\' and \'',dDate2,'\' group by p.identifier,e.issue_id) as s');


close c_users;

PREPARE stmt1 FROM @sql;
EXECUTE stmt1;
DEALLOCATE PREPARE stmt1; 

END

Al probar con la llamada: call get_range_summary(‘2014-07-01′,’2014-07-07’):

+---------+----------+----------------------------+-------+--------+---------+----------+-------------+
| identif | issue_id | subject                    | user1 | user2  |  user3  |   user4  | total_hours |
+---------+----------+----------------------------+-------+--------+---------+----------+-------------+
| bg02    |       23 | Gestion de proyecto        |  NULL |   0.25 |    NULL |     NULL |        0.25 |
| bg04    |       56 | 1.1 Preparación del entor  |  NULL |   0.63 |    NULL |     NULL |        0.63 |
| emision |       51 | Desarrollar la aplicación  |  NULL |   NULL |    3.97 |     NULL |        3.97 |
| nsp00   |       36 | Preparar propuesta para G  |  NULL |   2.02 |    NULL |     NULL |        2.02 |
| nsp02   |       34 | Subida al servidor de pro  |  NULL |   0.52 |    NULL |     NULL |        0.52 |
| nsp02   |       40 | Agregar funcionalidad de   |  NULL |   NULL |    0.55 |     NULL |        0.55 |
| nsp02   |       41 | Revertir el Documento      |  1.40 |   NULL |    1.40 |     NULL |        2.80 |
| nsp02   |       42 | Agregar Filtros en las vi  |  NULL |   NULL |    0.72 |     NULL |        0.72 |
| nsp02   |       43 | Reportes de Cargo de entr  |  NULL |   NULL |    NULL |     2.35 |        2.35 |
| nsp02   |       54 | Control de sesiones        |  NULL |   NULL |    NULL |     3.23 |        3.23 |
| nsp05   |       52 | Mantenimiento del servidor |  NULL |   0.18 |    NULL |     NULL |        0.18 |
| nsp05   |       53 | Mantenimiento a redmine    |  NULL |   0.53 |    NULL |     NULL |        0.53 |
| nsp06   |        3 | Elaborar el visualzador de |  NULL |   0.23 |    NULL |     NULL |        0.23 |
| nsp06   |       28 | Evitar que se tome mas de  |  NULL |   0.25 |    NULL |     NULL |        0.25 |
| nsp06   |       55 | Actualizar el porcentaje d |  NULL |   1.50 |    NULL |     NULL |        1.50 |
| nsp08   |       39 | Asistencia remota          |  NULL |   1.38 |    NULL |     NULL |        1.38 |
+---------+----------+----------------------------+-------+--------+---------+----------+-------------+

Es todo! Luego con ese resultado es fácil crear un pequeño reporte diario/semanal sobre las horas utilizadas por cada usuario.

MySQL, el extraño caso de un campo timestamp

Hace un tiempo descubrí una característica (tal vez sea un bug) sobre los campos timestamp de MySQL. Es probable que este documentado en alguna parte que todavía no he leído:

Cuando se añade un campo timestamp a una tabla, MySQL agrega mágicamente algunas características al nuevo campo creado como un «trigger» y la fecha actual como valor por defecto.

Aquí esta el script donde se produce el caso:

-- CREANDO UNA TABLA CUALQUIERA E INSERTANDO DATOS 
mysql> create table t(
    -> id int not null primary key auto_increment,
    -> val varchar(50)
    -> );
Query OK, 0 rows affected (0.15 sec)

mysql> insert into t (val) values ("foo") ,("var");
Query OK, 2 rows affected (0.08 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql> select * from t;
+----+------+
| id | val  |
+----+------+
|  1 | foo  |
|  2 | var  |
+----+------+
2 rows in set (0.00 sec)


-- AGREGANDO UN CAMPO TIMESTAMP Y MAS DATOS
mysql> alter table t add ts_field timestamp;
Query OK, 2 rows affected (0.35 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql> insert into t (val) values ("foo 2") ,("var 2");
Query OK, 2 rows affected (0.06 sec)
Records: 2  Duplicates: 0  Warnings: 0

-- HE AQUI LA MAGIA:
mysql> select * from t;
+----+-------+---------------------+
| id | val   | ts_field            |
+----+-------+---------------------+
|  1 | foo   | 0000-00-00 00:00:00 |
|  2 | var   | 0000-00-00 00:00:00 |
|  3 | foo 2 | 2013-01-09 23:20:01 |    <---
|  4 | var 2 | 2013-01-09 23:20:01 |    <---
+----+-------+---------------------+
4 rows in set (0.00 sec)

¿¡Pero que acaba de pasar!?
no lo sé.

La nueva estructura de la tabla es:

CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `val` varchar(50) DEFAULT NULL,
  `ts_field` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=latin1

Esto sólo pasa cuando en la tabla no existe otro campo timestamp aún.

Ahora, es útil? tal vez. Es un BUG? tal vez.

El "extraño caso" se repite en MySQL 5.1 y 5.5.

Update: Es una característica documentada en https://dev.mysql.com/doc/refman/5.5/en/timestamp-initialization.html , me disculpo por el post que finalmente resultó ser una chorrada, un lapsus, un horror.

MySQL Vista global de las bases de datos

Cansado de tener que averiguar manualmente  cuanto espacio ocupan mis bases de datos, acabo de crear un procedimiento almacenado para tener una vista global de las bases de datos que tenemos en nuestro servidor MySQL.

En MySQL no hay disponible  un comando que nos permita tener una resumen global de las bases de datos, algo que se parezca a SHOW TABLE STATUS pero para todas las bases de datos. El comando SHOW DATABASES sólo lista las bases actuales pero no nos dice cuanto espacio ocupan o cuantas tablas hay, etc.

El procedimiento que escribí esta basado en la base de datos INFORMATION_SCHEMA, que contiene mucha información de todas las bases de datos existentes.

Ya antes en un post anterior mencioné que podemos tener todas estas «herramientas» en una base de datos llamada tools, es por eso que recomiendo que éste procedimiento este en la misma base de datos tools (pero de todos modos puedes elegir otro lugar)

Al llamar al procedimiento si tenemos una vista global con información que nos será de utilidad para tas tareas de mantenimiento.

 

mysql> call tools.sp_overview();
+------------------------------+---------+--------+----------+---------+
| Database                     | Charset | Tables | Routines | Size Mb |
+------------------------------+---------+--------+----------+---------+
| database1                    | utf8    |     43 |       28 |     7.0 |
| database2                    | latin1  |     43 |       28 |   205.0 |
| database3                    | utf8    |    116 |        0 |   126.2 |
| database4                    | utf8    |     99 |        0 |     0.3 |
| database5                    | utf8    |    165 |        0 |    77.4 |
| database6                    | utf8    |    121 |        2 |   719.4 |
| database7                    | utf8    |    122 |        0 |    91.3 |
| database8                    | utf8    |    116 |        0 |    89.7 |
| database9                    | utf8    |    124 |        0 |     4.5 |
| database10                   | utf8    |    113 |        0 |   147.7 |
| database11                   | latin1  |    119 |        3 |   436.4 |
| database12                   | latin1  |    122 |        0 |   439.1 |
| database13                   | latin1  |    122 |        4 |   452.7 |
| database14                   | utf8    |    115 |        0 |   273.0 |
| database15                   | utf8    |    122 |        0 |   265.5 |
| database16                   | utf8    |      0 |        0 |    NULL |
| database17                   | latin1  |     26 |        0 |     0.9 |
| database18                   | latin1  |      9 |        0 |     0.0 |
| database19                   | latin1  |     22 |        0 |     0.3 |
+------------------------------+---------+--------+----------+---------+
19 rows in set (3.01 sec)

 

Advierto que la primera vez el procedimiento es muy lento, y puede demorar varios segundos.

Aquí tienen El código fuente del procedimiento:

 

DELIMITER $$

DROP PROCEDURE IF EXISTS tools.sp_overview$$
CREATE PROCEDURE tools.sp_overview()
BEGIN

    SELECT s.SCHEMA_NAME as `Database`, s.DEFAULT_CHARACTER_SET_NAME as `Charset`,
        count(t.TABLE_NAME) as `Tables`,

        (SELECT count(*) from information_schema.ROUTINES as r
            WHERE r.routine_schema = s.SCHEMA_NAME) as `Routines`,

         round(sum(t.DATA_LENGTH + t.INDEX_LENGTH) / 1048576 ,1) as `Size Mb`

        FROM information_schema.SCHEMATA AS s
            LEFT JOIN information_schema.TABLES t on s.schema_name = t.table_schema
        WHERE s.SCHEMA_NAME not in ('information_schema', 'performance_schema')

    GROUP BY s.SCHEMA_NAME;
END$$
DELIMITER ;

Valores auto_increment personalizados

En el Perú los formatos numéricos de las facturas es 001-000033 , la serie (001) cambia eventualmente pero el valor auto-incrementado es el mismo:

001-000034
001-000035
001-000036
...

Para manejar esto en MySQL he preparado una solución basada en otro articulo mio:

  1. Crear la tabla para guardar los números actuales:
    create table _sequence
    (
        seq_name varchar(50) not null primary key,
        seq_group varchar(10) not null,
        seq_val int unsigned not null
    );
  2. Crear una función para obtener el valor siguiente e incrementarlo:
    delimiter //
    drop function if exists getNextCustomSeq//
    create function getNextCustomSeq
    (
        sSeqName varchar(50),
        sSeqGroup varchar(10)
    ) returns varchar(20)
    begin
        declare nLast_val int; 
    
        set nLast_val =  (select seq_val
                              from _sequence
                              where seq_name = sSeqName
                                    and seq_group = sSeqGroup);
        if nLast_val is null then
            set nLast_val = 1;
            insert into _sequence (seq_name,seq_group,seq_val)
            values (sSeqName,sSeqGroup,nLast_Val);
        else
            set nLast_val = nLast_val + 1;
            update _sequence set seq_val = nLast_val
            where seq_name = sSeqName and seq_group = sSeqGroup;
        end if; 
    
        set @ret = (select concat(sSeqGroup,'-',lpad(nLast_val,6,'0')));
        return @ret;
    end// 
    
    delimiter ;
  3. Crear un procedimiento almacenado para modificar el valor actual:
    delimiter //
    drop procedure if exists sp_setSeqCustomVal//
    create procedure sp_setCustomVal(sSeqName varchar(50),  
                  sSeqGroup varchar(10), nVal int unsigned)
    begin
        if (select count(*) from _sequence  
                where seq_name = sSeqName  
                    and seq_group = sSeqGroup) = 0 then
            insert into _sequence (seq_name,seq_group,seq_val)
            values (sSeqName,sSeqGroup,nVal);
        else
            update _sequence set seq_val = nVal
            where seq_name = sSeqName and seq_group = sSeqGroup;
        end if;
    end//
    delimiter ;

Haciendo algunas pruebas:

  1. Crear una tabla:
    create table custom_autonums
    (
       id int not null primary key auto_increment,
       seq_1 varchar(20), -- custom sequence 1
       seq_2 varchar(20), -- custom sequence 2
       unique(seq_1),
       unique(seq_2)
    );
  2. Crear un trigger para la tabla:
    delimiter //
    drop trigger if exists custom_autonums_bi//
    
    create trigger custom_autonums_bi before insert on custom_autonums
    for each row
    begin
       set new.seq_1 = getNextCustomSeq("seq_1","001");
       set new.seq_2 = getNextCustomSeq("seq_2","DBA");
    end//
    
    delimiter ;
  3. Insertando algunos valores:
    insert into custom_autonums (id) values (null),(null),(null);
    select * from custom_autonums;
    +----+------------+------------+
    | id | seq_1      | seq_2      |
    +----+------------+------------+
    |  4 | 001-000001 | DBA-000001 |
    |  5 | 001-000002 | DBA-000002 |
    |  6 | 001-000003 | DBA-000003 |
    +----+------------+------------+
    3 rows in set (0.00 sec)
  4. Alterando los valores actuales:
    call sp_setCustomVal('seq_1','001',675);
    
    insert into custom_autonums (id) values (null),(null),(null);
    select * from custom_autonums;
    +----+------------+------------+
    | id | seq_1      | seq_2      |
    +----+------------+------------+
    |  4 | 001-000001 | DBA-000001 |
    |  5 | 001-000002 | DBA-000002 |
    |  6 | 001-000003 | DBA-000003 |
    |  7 | 001-000676 | DBA-000004 |
    |  8 | 001-000677 | DBA-000005 |
    |  9 | 001-000678 | DBA-000006 |
    +----+------------+------------+
    6 rows in set (0.00 sec)

Enjoy!

Simulando secuencias en MySQL

Quienes hemos usado PostgresSQL solemos fastidiarnos con el código que hay que escribir para tener un campo auto-numérico, pero también hemos llegado a extrañar esa característica cuando usamos MySQL.

Como sabemos MySQL, usa la propiedad AUTO_INCREMENT para campos numéricos que usamos en claves primarias.

Pero en que situaciones podemos necesitar una secuencia? pues les doy algunas ideas:

  • Cuando queremos tener mas de un campo auto-numérico en una tabla.
  • Cuando necesitamos un contador general que podemos utilizar en mas de una tabla.
  • No se me ocurren mas, pero estoy seguro que en algún momento podemos darle un uso.

Manos a la obra:

Necesitaremos una tabla para guardar el valor actual de la secuencia:

create table _sequence
(
	seq_name varchar(50) not null primary key,
	seq_val int unsigned not null
);

Vamos a necesitar también una función para obtener el valor siguiente de la secuencia, incluyendo casos como:

  • Si no existe la secuencia, crearla (para qué complicarnos).
  • Actualizar el nuevo valor para la siguiente llamada
  •  

    He aquí la función getNextSeq que recibe como parámetro, el nombre de la secuencia:

    /*Funcion que devuelve el siguiente valor de una secuencia*/
    delimiter //
    drop function if exists getNextSeq//
     
    create function getNextSeq(sSeqName varchar(50)) returns int unsigned
    begin
        declare nLast_val int;
    
        set nLast_val =  (select seq_val 
                              from _sequence 
                              where seq_name = sSeqName);
        if nLast_val is null then
            set nLast_val = 1;
            insert into _sequence (seq_name,seq_val) 
            values (sSeqName,nLast_Val);
        else
            set nLast_val = nLast_val + 1;
            update _sequence set seq_val = nLast_val 
            where seq_name = sSeqName;
        end if;
    
        return nLast_val;
    end//
     
    delimiter ;

     

    Ahora necesitamos (a manera de mantenimiento) un procedimiento para modificar el valor de una secuencia:

    /*Procedimiento que crea/establece el valor de una secuencia*/
    delimiter //
    drop procedure if exists sp_setSeqVal//
    
    create procedure sp_setSeqVal(sSeqName varchar(50), nVal int unsigned)
    begin
        if (select count(*) from _sequence where seq_name = sSeqName) = 0 then
            insert into _sequence (seq_name,seq_val) 
            values (sSeqName,nVal);
        else
            update _sequence set seq_val = nVal 
            where seq_name = sSeqName;
        end if;
    end//
    
    delimiter ;
    

     

    Es todo!, ahora sólo necesitamos hacer algunas pruebas, para ello vamos a crear una tabla en la cual vamos a tener dos campos auto-generados con nuestra secuencia.

    create table multiple_autonums
    (
       id int not null primary key auto_increment,
       seq_1 int, -- Secuencia 1
       seq_2 int, -- Secuencia 2
       unique(seq_1),
       unique(seq_2)
    );

    Para que la magia ocurra debemos crear también un trigger antes de insertar:

    /*trigger ejecutado antes de insertar que obtiene
      los siguientes valores de las secuencias*/
    
    delimiter //
    drop trigger if exists multiple_autonums_ai//
    
    create trigger multiple_autonums_bi before insert on multiple_autonums
    for each row
    begin
       set new.seq_1 = getNextSeq("seq_1");
       set new.seq_2 = getNextSeq("seq_2");
    end//
    
    delimiter ;
    

     

    Hagamos algunas pruebas

    -- insertando valores para ver que pasa.
    insert into multiple_autonums (id) values (null),(null),(null);
    
    -- Resultado de la primera inserción
    select * from multiple_autonums;
    +----+-------+-------+
    | id | seq_1 | seq_2 |
    +----+-------+-------+
    |  1 |     1 |     1 |
    |  2 |     2 |     2 |
    |  3 |     3 |     3 |
    +----+-------+-------+
    3 rows in set (0.00 sec)
    
    -- Modificando las secuencias
    call sp_setSeqVal("seq_1",47);
    call sp_setSeqVal("seq_2",9786);
    
    -- Insertando nuevamente los valores
    insert into multiple_autonums (id) values (null),(null),(null);
    
    -- Viendo que ha pasado
    select * from multiple_autonums;
    +----+-------+-------+
    | id | seq_1 | seq_2 |
    +----+-------+-------+
    |  1 |     1 |     1 |
    |  2 |     2 |     2 |
    |  3 |     3 |     3 |
    |  4 |    48 |  9787 |
    |  5 |    49 |  9788 |
    |  6 |    50 |  9789 |
    +----+-------+-------+
    

     

    Que les pareció?

    Me acabo de dar cuenta que hay algunas condiciones que no he tomado en cuenta, pero de todas formas sirve. Incluso pueden modificar las funciones para hacer mas complejo el calculo del valor siguiente, se me ocurre por ejemplo que podemos generar numeros de facturas al estilo: [serie]-[numero] ejem: 001-00485

    Espero les sirva.