Página principal
Artículos y trucos
Catálogo de productos
Ejemplos y descargas
Mis libros
Cursos de formación
Investigación y desarrollo
Libros recomendados
Mis páginas favoritas
Acerca del autor
 
En colaboración con Amazon
 
Intuitive Sight

El Misterio de la Cabecera Perdida

Esta es la historia de un bug, pero de un bug tan maldito que ha logrado sobrevivir a unas cuantas versiones del BDE. El relato también podría titularse La Maldición de los Cached Updates, pues su argumento trata acerca de este misterioso recurso del que todos hablan, pero pocos comprenden.

LA ESCENA DEL CRIMEN

Suponga que queremos desarrollar un simple y vulgar sistema de entradas de pedidos. Tenemos un par de tablas: Cabecera y Detalles; y ya puede imaginar sus papeles. El gran problema de la entrada de pedidos con Delphi y C++ Builder es que, si utilizamos controles data-aware como TDBGrid y TDBEdit, estamos introduciendo los datos directamente dentro de la base de datos. Es decir: cada vez que usted o el usuario meta una línea dentro de la rejilla y pase a la siguiente, se producirá una grabación.

Para simplificar la explicación, no entraré en la polémica acerca de si debemos utilizar una tabla o una consulta para la entrada de datos. A lo largo del artículo utilizaré la palabra tabla, pero todo lo que diga será también aplicable a las consultas. Realmente, en este caso en que solamente se insertan registros, utilizaría consultas con las actualizaciones en caché activadas, con objetos TUpdateSQL asociados, y con una cláusula where imposible de satisfacer, para que devolviesen conjuntos de resultado vacíos.

¿Cuál es el problema? Pues que, a mitad de camino, el usuario puede decidir anular el pedido. Hay programadores que, simplemente, eliminan la cabecera y todas las líneas de detalles. Si la base de datos permite borrados en cascada, esta operación es relativamente sencilla. En caso contrario, hay que implementarla a mano. Tengo muchos otros reparos hacia esta opción elemental, pero no es éste el momento adecuado para exponerlos.

Otra opción es delimitar la operación de alta por medio de una transacción. Para anular el pedido basta con hacer un Rollback sobre la base de datos, y se cancelan todos los cambios realizados en Cabecera, Detalles e incluso las modificaciones de las existencias de productos en la tabla de Inventario. Y para confirmar, basta ejecutar el método Commit.

¿Algo malo? Sí: cualquier registro modificado durante la transacción se queda bloqueado hasta el final de la misma. Así que no se le ocurra programar las actualizaciones del inventario como triggers disparados por la tabla de detalles, o no podrá vender el mismo producto concurrentemente desde dos puestos.

La mejor solución es utilizar actualizaciones en caché. A las dos tablas protagonistas de la historia se les cambia su propiedad CachedUpdates a True. De este modo, cualquier cambio en las mismas se refleja en una caché en el lado cliente, y los cambios se aplican posteriormente, al confirmarse el pedido. Si se decide cancelar, basta con vaciar la caché, y no habremos tocado la base de datos en ningún momento.

Casi siempre, el algoritmo de grabación de la caché tiene un aspecto similar al siguiente:

procedure TDataMod.GrabarPedido;
begin
   Database1.StartTransaction;
   try
      Cabecera.ApplyUpdates;
      Detalles.ApplyUpdates;
      Database1.Commit;
   except
      Database1.Rollback;
      raise;
   end;
   Cabecera.CommitUpdates;
   Detalles.CommitUpdates;
end;

El algoritmo de grabación de la caché es un protocolo en dos fases, que viene explicado en casi cualquier libro sobre Delphi o C++ Builder. Aquí solamente analizaremos la siguiente secuencia de operaciones, que produce un error que a muchos programadores toma por sorpresa:

  • La fila de cabecera se graba sin problemas. Recuerde que se trata de un único registro.
  • Cuando se están grabando las filas de detalles se produce un error en el servidor. Por ejemplo, puede que no existan existencias suficientes para un producto determinado. O que la fila correspondiente en la tabla de inventarios esté bloqueada.
  • La llamada a ApplyUpdates sobre la tabla Detalles falla, en consecuencia. Se produce una excepción.
  • La excepción nos conduce a la cláusula except. Allí se cancela la transacción, y el error se vuelve a lanzar.
  • Las llamadas a CommitUpdates no se efectúan. Por lo tanto, cuando el usuario recibe el mensaje de error, las cachés independientes de la cabecera y los detalles siguen conteniendo los valores anteriores al intento de grabación. Además, la base de datos continúa en su estado original. Por lo tanto, el usuario puede tomar alguna medida (modificar alguna cantidad, eliminar algún producto, o simplemente esperar a que el producto deje de estar bloqueado) y reintentar la operación.
  • Pero cuando se repite la grabación, se produce inexorablemente una excepción con el enigmático mensaje: "Master record missing".

LOS MOVILES DEL ASESINO

¿Cuál es la dificultad? El BDE obliga precisamente a un protocolo de aplicación en dos fases para que este tipo de problemas no se produzca:

  • ApplyUpdates solamente debe grabar la caché de la tabla o consulta, pero no debe vaciarla prematuramente. Así contemplamos la posibilidad de que se produzca algún otro fallo más adelante, antes de cerrar la transacción.
  • CommitUpdates es el responsable de vaciar realmente la caché. El método de Delphi llama directamente a una función del BDE, y no debe generar excepciones. Es por eso que podemos situar las llamadas a este método fuera de la instrucción try/except.

De modo que al reintentar la grabación, la caché de la cabecera debe contener aún las modificaciones realizadas al conjunto de datos; en este caso, el nuevo registro a insertar. Al menos en teoría. Porque en la práctica, el BDE no vacía la caché, pero marca internamente todos sus registros como "aplicados", aunque solamente hayamos ejecutado ApplyUpdates y no CommitUpdates. Se trata de un error del BDE, no de la VCL. Si fuese esto último, quizás podríamos realizar las correcciones en el código fuente, pero no es el caso.

SE COMPLICA LA TRAMA

El diagnóstico ha sido fácil, pero la solución será complicada. Lo que primero se nos ocurre: ¿cuál es el problema, que la tabla de cabecera se queda marcada como "limpia"? Pues ensuciémosla. Para detectar los errores durante la grabación de la tabla de detalles debemos interceptar el evento OnUpdateError de Detalles:

procedure TDataMod.DetallesUpdateError(DataSet: TDataSet;
   E: EDatabaseError; UpdateKind: TUpdateKind;
   var UpdateAction: TUpdateAction);
begin
   Cabecera.Edit;
   Cabecera['UnCampo'] := Cabecera['UnCampo'];
end;

Este código tan simple logra que la propiedad Modified de la tabla Cabecera cambie a True. La próxima vez que se intente la grabación habrá un registro en la caché de la tabla maestra marcado como modificado ... por lo cual el BDE generará una instrucción update, como si ya existiese dicho registro. En otras palabras, estamos en las mismas.

HOLMES LO ACLARA TODO

¿Cómo lograr que una modificación pueda grabarse como una inserción? Existe una forma muy sencilla, y consiste en tomar el control de la generación de instrucciones de actualización mediante un objeto TUpdateSQL y el evento OnUpdateRecord de la tabla de cabecera. Colocamos un componente TUpdateSQL en el módulo de datos, y lo asignamos en la propiedad UpdateObject de la tabla de cabecera.

Como sabemos, este componente tiene tres propiedades InsertSQL, ModifySQL y DeleteSQL, que indican las instrucciones que tienen que ejecutarse sobre el servidor cuando se graba la caché. En nuestro caso, solamente estamos considerando las altas de pedidos, por lo que vamos únicamente a llenar la primera de las tres propiedades. Aunque lo podemos hacer a mano, es preferible hacer doble clic sobre el TUpdateSQL y dejar que el editor del componente genere las tres instrucciones por nosotros. No importa que se generen dos instrucciones adicionales que no utilizaremos. La sentencia SQL que queda en InsertSQL es similar a la siguiente:

insert into Cabecera(Campo1, Campo2, ...)
values (:Campo1, :Campo2, ...)

Ahora, simplemente copie la instrucción de inserción dentro de la propiedad ModifySQL. De esta forma, la actualización que erróneamente se genera tras la corrección efectuada en la sección anterior, se traduce de todos modos en una inserción.

Está claro que he simplificado un poco el trabajo al asumir que Cabecera y Detalles se utilizan solamente para altas, y no para modificaciones. De todos modos, si queremos asegurarnos que este "desvío" se produce sólo en caso de error, podemos añadir una variable privada al módulo de datos:

type
   TDataMod = class(TDataModule)
      // ...
   private
      FErrorPending: Boolean;
   end;

Esta variable se pondrá a True cuando se produzca un error en la grabación de los detalles:

procedure TDataMod.DetallesUpdateError(DataSet: TDataSet;
   E: EDatabaseError; UpdateKind: TUpdateKind;
   var UpdateAction: TUpdateAction);
begin
   Cabecera.Edit;
   Cabecera['UnCampo'] := Cabecera['UnCampo'];
   FErrorPending := True;
end;

Por supuesto, al iniciar el proceso de entrada de pedidos se debe asignar False a la variable. Entonces podemos dejar las tres instrucciones SQL originales del TUpdateSQL, y en su lugar interceptaremos el evento OnUpdateRecord de la tabla de cabecera:

procedure TDataMod.Table1UpdateRecord(DataSet: TDataSet;
   UpdateKind: TUpdateKind; var UpdateAction: TUpdateAction);
begin
   if (UpdateKind = ukModify) and FErrorPending then
      UpdateSQL1.Apply(ukInsert)
   else
      UpdateSQL1.Apply(UpdateKind);
   UpdateAction := uaApplied;
end;

EL CINE QUEDA VACIO MIENTRAS PASAN LOS CREDITOS...

Visto en retrospectiva, parece fácil resolver el problema planteado. Pero personalmente me costó su tiempo confirmar que, en efecto, se trataba de un bug interno del BDE y no un fallo de la VCL. Debo también agradecer a Philip Cain, por haberme indicado la solución anterior en el grupo de noticias de Borland, y a José Luis Freire, que publicó inicialmente este artículo.

(...se enciende la luz, y el encargado de la limpieza sacude las palomitas de maíz de las butacas...)