[Reading from other records without moving from the current one]
Introducción
Reformar la manera en que trabajan los
conjuntos de datos en Delphi es asignatura pendiente desde hace varios
años. Estos útiles objetos administran
en su interior algo llamado cursor, que es una estructura de control
para recorrido y procesamiento de registros, y heredan gran parte de su
funcionamiento de la clase base abstracta TDataSet.
Además de sus diversas clases descendientes
nativas, algunas de las cuales fueron apareciendo conforme Delphi se expandía
(IBX, TClientDataSet, dbExpress, envolturas ADO,..), con el paso de los años
llegaron muchas otras por iniciativa de la Comunidad para enriquecer el
mosaico: ¿quién no ha descargado un paquete de componentes donde el nombre de
alguno de ellos lleve la palabra Query, DataSet o Table? Sin embargo, la clase base TDataSet sigue
casi como hace diez años, haciendo mucho del trabajo sucio de una manera
estupenda, pero al mismo tiempo imponiendo limitaciones de diseño.
Cada vez estoy más convencido de que Delphi
vendrá de fábrica, uno de estos años, con un marco de persistencia nativo y
bien cimentado en la VCL y sospecho que en la RTL también. Será algo que involucrará muchos cambios en
todo lo que concierne a las clases para acceso a bases de datos, y que
contribuirá de manera significativa a la reinserción de esta herramienta de
programación en las pequeñas y grandes empresas de desarrollo. Curiosamente, lo que más hará destacar a
Delphi esta vez no serán los recursos para interfaces de usuario (ser de lo
mejor en ese terreno ya no es noticia), sino las enfocadas al tratamiento de la
información.
Mientras eso ocurre, hay que encontrar
soluciones al alcance de nuestras capacidades.
Problema
Suponga usted que se encuentra programando
una aplicación de base de datos en Delphi 7, 2010, XE2 o cualquier otro, y ha
visto la necesidad de realizar una acción en la capa cliente como respuesta a
lo que haya ingresado el usuario en un campo.
Digamos que el usuario del programa introduce, mediante un cuadro de
texto, cierto valor para un campo ftInteger llamado Numero, lo cual
dispara un evento desde el que desea ejecutar algo en particular. El control de captura se encuentra asociado
a un conjunto de datos que ya tiene varios registros en memoria, registros que
o bien acaban de ser capturados por ese mismo usuario o fueron traídos de la
base de datos.
Usted desea programar una respuesta ante la
asignación de valor al campo Numero, y para ello se vale del evento
OnDataChange de un componente TDataSource que está enlazado al conjunto de
datos y al cuadro de texto (también podría hacerlo con el evento OnChange del
respectivo objeto TField). Pensemos en
que tal acción requiere forzosamente conocer el valor que los registros
anteriores tienen en ese u otros campos, obviando las razones (comparaciones,
cálculos, etcétera). ¿Cómo obtener en
ese momento los valores que hay en otros registros?
El conjunto de datos se encuentra
posicionado en la fila que está siendo capturada, siendo su estado (propiedad
State) dsEdit si ese registro ya había sido almacenado antes, o dsInsert si va
a ser almacenado por vez primera. Si
dicha fila es la tercera de la lista y en ese momento quiere leer el valor que
hay en algún campo de la primera fila, tendría que comenzar por llamar al
método First con la inoportuna consecuencia de que éste, como cualquier otro
método de navegación (Next, Prior, Last, Locate,...), primero intentará guardar
el registro actual que está siendo ingresado o modificado. Resulta inconveniente porque tal registro
quizá no se haya completado todavía o no se encuentre listo para superar las
validaciones de guardado. Y, suponiendo
que el registro hubiera sido completado y cumpliera con las validaciones
disparadas al ser guardado, ¿qué hay si el usuario decide cancelar su
captura? Evidentemente ya no habría
estado dsEdit o dsInsert que cancelar, ya que por movernos de fila el estado
del conjunto de datos pasaría a dsBrowse, así que una llamada al método Cancel
no tendría ningún efecto. Entonces la
pregunta es: ¿Pueden leerse datos de otros registros sin movernos del actual?
Solución
En este punto, el lector experimentado
estará pensando en las muy diferentes maneras en que suele esquivarse el
problema. Desde las más fatuas como
meter en otras capas de la aplicación todas las reglas y validaciones, hasta
las más drásticas como sustituir el uso de controles data-aware por
controles simples y armazones de variables, pasando por remedios un poco más
sensatos como apoyarse en los cursores de TClientDataSet o en los record
sets de ADO. Sin olvidar las
bibliotecas de terceros que permiten un manejo rico de la información
(DevExpress, RemObjects y otros), ni los raros y plausibles casos donde el programador
inventa su propio data set.
Pero existe una alternativa inherente a
TDataSet, y por tanto aplicable con cualquier clase de conjunto de datos
(exceptuando unidireccionales), que ha sido poco aprovechada como medio para
leer datos de cualquier fila sin necesidad de mover el indicador de
"registro activo". Es un
mecanismo del que se ha ofrecido escasa documentación pública, no obstante
revela sus más íntimos detalles a través del código fuente de la VCL.
Antes de abordarlo, debemos considerar que
leer datos de otros registros sin movernos del actual puede traer beneficios no
solo a la hora de estar capturando información. Un conjunto de datos puede encontrarse en estado dsBrowse y aun
así resultar inadecuado movernos al registro que deseamos consultar (usando
Locate, RecNo, First,...) para leer los campos necesarios, y luego volver al
registro donde estábamos. Aunque
hagamos uso de los acostumbrados métodos DisableControls y EnableControls,
algunos controles visuales pueden no quedar exactamente como los dejamos,
apreciándose en pantalla extraños efectos que podrían confundir al
usuario. Una rejilla, por ejemplo,
volverá a posicionarse en el mismo registro, pero quizá mostrándolo algunas
líneas más arriba o más abajo.
La solución que propongo a continuación no
tiene como objeto reemplazar a ninguna otra, es solamente una sencilla
alternativa que programé tras entender cómo trabajan TDataSet y sus clases
descendientes con lo que se conoce como buffers de registros. La clave está en una clase llamada TDataLink
que antaño Borland implementó en la misma unidad que TDataSet (DB), siendo su
función principal coordinar las operaciones que realizan los objetos data-aware
con las operaciones hechas en los objetos TDataSource y los conjuntos de
datos. Grosso modo, la
interacción de estos elementos es:
Derivado de TDataSet <–> TDataSource
<–> TDataLink o derivado <–> Objeto data-aware (TDBEdit,
TDBGrid,...)
Salvo que, para esta solución, no es
necesario el último eslabón de la cadena. De momento no ahondaré en los
detalles técnicos de TDataLink y los buffers de registros, ya que por ahora
tiene más valor poner el código fuente de la solución junto con unos ejemplos
fáciles de poner en práctica (además de que hoy tengo mucho que barrer y
trapear).
Clase TCursorInspect:
Uses
DB;
Type
TCursorInspect = Class (TDataLink)
Public
SavedActiveRecord :Integer;
Constructor Create (Const ADataSet :TDataSet);
Destructor Destroy; Override;
End;
// Implementación:
Type
TDataSetAccess = Class (TDataSet);
Constructor TCursorInspect.Create (Const ADataSet :TDataSet);
Var
DataSet :TDataSetAccess Absolute ADataSet;
I :Integer;
Begin
Inherited Create;
{ Asociamos este objeto con el conjunto de datos dado (creando y
enlazando el TDataSource intermediario) }
DataSource := TDataSource.Create (Nil);
DataSource.DataSet := ADataSet;
// Indicador para saber si ADataSet está en su última fila "visible"
I := DataSet.BufferCount - DataSet.ActiveRecord;
{ Nos aseguramos de que el conjunto de datos tenga un buffer de registro
por cada fila de su cursor interno (el entero devuelto por RecordCount
tiene que ser verídico), más uno adicional para la fila que se haya
empezado a capturar (si hay tal inserción) }
BufferCount := DataSet.RecordCount + Byte (DataSet.State = dsInsert);
// Guardamos el número de fila donde está el conjunto de datos
SavedActiveRecord := ActiveRecord;
{ Recuperamos la fila que TDataSet suprime al insertar. Sólo sucede
cuando es llamado el método Insert, estando el conjunto de datos
posicionado en la última fila de su lista de buffers (I = 1). }
If (I = 1) And (DataSet.GetBookmarkFlag (
DataSet.Buffers [ActiveRecord]) = bfInserted) Then
Begin
DataSet.InternalLast;
DataSet.CursorPosChanged;
For I := BufferCount - 1 DownTo ActiveRecord + 1 Do
DataSet.GetRecord (DataSet.Buffers [I], gmPrior, False);
End;
End;
Destructor TCursorInspect.Destroy;
Begin
If Active Then
// Regresamos el conjunto de datos a la fila donde se encontraba
ActiveRecord := SavedActiveRecord;
DataSource.Free;
Inherited Destroy;
End;
Ejemplo 1.- Leer toda la columna de un
campo:
procedure TForm1.Button1Click(Sender: TObject);
Var
I :Integer;
begin
{ NOTA: dt1 es un conjunto de datos abierto de cualquier clase (excepto
unidireccional) y mm1 es un cuadro de texto TMemo }
// Con un objeto temporal TCursorInspect asociado a dt1:
With TCursorInspect.Create (dt1) Do
Try
mm1.Clear;
{ Ponemos todos los valores del campo Numero, desde la primera hasta
la última fila, dentro del control mm1. Si dt1 tiene el estado
dsEdit o dsInsert, éste se conserva al hacer el recorrido. }
For I := 0 To BufferCount - 1 Do
Begin
ActiveRecord := I; // Leeremos de la fila I
mm1.Lines.Add ('Número: ' + dt1.FieldByName ('Numero').AsString);
End;
Finally
Free;
End;
end;
Ejemplo 2.- Usando el evento
OnNewRecord, copiar en el nuevo registro un valor del registro inmediato
anterior:
procedure TDataModule1.dt1NewRecord(DataSet: TDataSet);
Var
CodigoPostal :Variant;
begin
With TCursorInspect.Create (DataSet) Do
Try
If ActiveRecord = 0 Then // Si esta es la primera fila
Exit; // No hacer nada, salimos (con escala en el Finally)
ActiveRecord := ActiveRecord - 1; // Leeremos de la fila anterior
CodigoPostal := DataSet ['CodigoPostal'];
Finally
Free;
End;
// Establecemos en la nueva fila el mismo código postal de la anterior
DataSet ['CodigoPostal'] := CodigoPostal;
end;
Ejemplo 3.- Considerando que esto último
puede ser algo común, ¿qué tal una función que simplifique la tarea?:
// Función para leer el valor de un campo de la fila inmediata anterior:
Function PriorRecValue (Const DataSet :TDataSet; Const Field :String)
:Variant;
Begin
With TCursorInspect.Create (DataSet) Do
Try
If ActiveRecord > 0 Then
Begin
ActiveRecord := ActiveRecord - 1;
Result := DataSet [Field];
End
Else
Result := Null;
Finally
Free;
End;
End;
// Uso:
procedure TDataModule1.dt1NewRecord(DataSet: TDataSet);
begin
DataSet ['CodigoPostal'] := PriorRecValue (DataSet, 'CodigoPostal');
end;
Me parece responsable añadir que las dos
principales desventajas que le veo a este mecanismo son la de no poder usarlo
durante el evento TField.OnValidate (por la peculiar manera en que opera dicho
evento) y la de que el consumo de memoria y CPU puede llegar a ser relevante
cuando el conjunto de datos tiene miles de filas. Por otro lado, es pertinente aconsejar que se trate a las
instancias TCursorInspect como objetos temporales que se crean, se usan y se
destruyen sin darle oportunidad a la interfaz de usuario de actualizarse
(prescindiendo de llamadas a Application.ProcessMessages durante su
existencia). Para rematar las contras,
hay que tener presente que la propiedad RecordCount del conjunto de datos debe
estar debidamente actualizada (si usamos IBX será necesario haber alcanzado la
última fila antes de usar esta clase).
Sin embargo tiene sus ventajas también:
1. Su uso es cómodo y sencillo, como puede apreciarse en los ejemplos.
2. Se evita la creación de cursores adicionales, consultas paralelas u
otros objetos que regularmente consumen más recursos.
3. Conserva intactos el conjunto de datos (incluyendo su propiedad
State) y los demás objetos relacionados a éste, siendo en este sentido mejor
que el mecanismo DisableControls...EnableControls.
4. Aunque el estado sea dsInsert, las nuevas filas tienen un lugar
específico en la lista como cualquier otro registro (puede recorrerse el
conjunto de datos incluyendo el registro que aún no ha sido guardado).
5. Con la propiedad heredada ActiveRecord o el campo SavedActiveRecord,
es muy fácil determinar en qué lugar se encuentra la fila activa cuando el
estado es dsInsert; en comparación con la propiedad RecNo del conjunto de
datos, que suele devolver -1 durante ese estado.
6. Para acceder a los datos se utilizan los mismos objetos (conjunto de
datos e instancias TField) que en lecturas normales.
Esta clase, TCursorInspect, está hecha de
manera simple y podría ser mejorada en muchos aspectos. Pero tal como se encuentra cumple con su
deber: facilitar la lectura de cualquier fila de un conjunto de datos, sin que
el usuario o el propio conjunto adviertan movimiento alguno.
No sobra mencionar que el código del
constructor deja entrever un posible mecanismo más por el cual podríamos leer
datos de diferentes filas silenciosamente: el método virtual GetRecord,
del que valdría la pena escribir luego.
Por lo pronto espero que esta solución
apoyada en TDataLink resulte provechosa.
Ha sido probada con éxito en TClientDataSet, IBX y ADO. No debe tener problemas con otros
componentes.
Al González.
Añadir un comentario