|
ClientDataSet y DataSetProvider en aplicaciones cliente/servidor
Copyright © 2004 Pablo Reyes
Los componentes ClientDataSet y DataSetProvider simplifican
enormemente el desarrollo de aplicaciones de bases de datos cliente/servidor al
introducir una capa de abstracción entre la interface de usuario y los
componentes de acceso a datos.
El ClientDataSet es como una pequeña base de datos
relacional que mantiene los datos en memoria. Estos datos los obtiene, en la
mayoría de los casos, de un DataSetProvider. El DataSetProvider cumple dos funciones
principales: proveer datos y resolver actualizaciones. Casi siempre los datos
que provee los obtiene de un DataSet, el cual debe implementar la interface
IProviderSupport. El ClientDataSet mantiene en memoria no sólo los datos que
obtiene del DataSetProvider sino también las modificaciones realizadas por el
usuario. El método ApplyUpdates del ClientDataSet inicia el proceso de
actualización el cual es llevado a cabo por el DataSetProvider.
Muchas veces es necesario personalizar el proceso de actualización
para realizar tareas adicionales necesarias para implementar la lógica de
negocios de nuestra aplicación. En este artículo veremos cómo personalizar el
proceso de actualización.
Nuestro ejemplo
Antes de comenzar a añadir componentes y escribir código
veamos de qué se trata el proyecto que desarrollaremos a modo de ejemplo. El
código fuente puede descargarse aquí.
Vamos a utilizar las tablas Orders.DB, Items.DB y Parts.DB
del alias DBDemos del BDE para ingresar órdenes. Los campos OnHand y OnOrder de
la tabla Parts mantienen, para cada producto, la cantidad disponible y la
cantidad comprometida respectivamente. Cada vez que el usuario inserta,
modifica o elimina una orden debemos actualizar el campo OnOrder de la tabla
Parts para cada una de las líneas que componen la orden. También debemos
verificar que la cantidad remanente (OnHand - OnOrder) sea suficiente para
satisfacer la cantidad ordenada. En caso de que no lo sea, debemos generar una
excepción y la orden debe ser rechazada.
Para simplificar el proyecto actualizaremos sólo los campos
requeridos de la tabla Orders, es decir, los campos OrderNo, CustNo y EmpNo.
Para facilitar las cosas mostraremos los datos de las tablas Customer, Employee
y Parts así podremos, por ejemplo, consultar un número de cliente válido o
verificar la cantidad disponible de un producto antes y después de insertar una
orden.
Manos a la obra
Vamos a comenzar por crear un proyecto nuevo. Seleccionar
del menú File | New | Application.
Colocaremos los componentes de datos en un Data Module, así que seleccionar del
menú File | New | Data Module.
Guardar el proyecto seleccionando del menú File
| Save All.
El Data Module
Añadir los siguientes componentes al Data Module:
De la página BDE, un Database, tres Table y tres Query.
De la página Data Access, un DataSource, tres
ClientDataSet y dos DataSetProvider.
Nombrar los componentes como lo muestra la siguiente imagen
de pantalla del Data Module.

Como dije antes, vamos a utilizar el alias DBDemos del BDE.
Asignarle a la propiedad DatabaseName
del componente DB_DBDemos el valor "_DBDemos" y a la propiedad
LoginPrompt el valor "False".
Asignarle a la propiedad DatabaseName
de los componentes Table y Query el valor "_DBDemos".
Utilizaremos los componentes Table para acceder a las tablas
Customer, Employee y Parts respectivamente. Por lo tanto, asignarle a la
propiedad TableName del componente
TBLCustomer el valor "Customer.db", a TBLEmployee
"Employee.db" y a TBLParts "Parts.db".
La sentencia SQL para acceder a la tabla Orders, que
corresponde al valor de la propiedad SQL
del componente QRYOrders, es la siguiente:
SELECT
OrderNo,
CustNo,
EmpNo
FROM
Orders
ORDER BY
OrderNo DESC
Sólo vamos a actualizar los campos
requeridos. La sentencia SQL para acceder a la tabla Items, que corresponde al
valor de la propiedad SQL del
componente QRYItems, es la siguiente:
SELECT
OrderNo,
ItemNo,
PartNo,
Qty
FROM
Items
WHERE
OrderNo = :OrderNo
ORDER BY
ItemNo
Asignarle a la propiedad DataSet
del componente DSOrders el valor "QRYOrders" y a la propiedad
DataSource del componente QRYItems el
valor "DSOrders". Esto nos permite establecer la relación
maestro/detalle entre las tablas Orders e Items. También tenemos que configurar
el parámetro OrderNo de la sentencia SQL para acceder a la tabla Items. Es
posible acceder a los parámetros de un componente Query por medio de su
propiedad Params. Asignarle a la
propiedad DataType del parámetro
OrderNo el valor "ftFloat" y a la propiedad
ParamType el valor "ptInput".
La sentencia SQL para acceder a la tabla Parts, que
corresponde al valor de la propiedad SQL
del componente QRYParts, es la siguiente:
SELECT
PartNo,
Description,
OnHand,
OnOrder
FROM
Parts
WHERE
PartNo = :PartNo
Tenemos que configurar el parámetro PartNo de la sentencia
SQL para acceder a la tabla Parts. Asignarle a la propiedad
DataType del parámetro PartNo el valor
"ftFloat" y a la propiedad ParamType
el valor "ptInput".
Asignarle a la propiedad DataSet
del componente DSPOrders el valor "QRYOrders" y a la misma propiedad
del componente DSPParts el valor "QRYParts". Asignarle a la propiedad
ProviderName del componente CDSOrders
el valor "DSPOrders". El DataSetProvider detecta la relación
maestro/detalle entre QRYOrders y QRYItems y crea una estructura en la cual
añade un campo al maestro que contiene los registros del detalle. El nombre de
este campo lo obtiene del nombre del DataSet detalle. Crear campos persistentes
para el componente CDSOrders. Además de los campos de la tabla Orders aparece
el campo QRYItems. Este es un campo especial de tipo TDataSetField. Asignarle a
la propiedad DataSetField del
componente CDSItems el valor "QRYItems". Crear también campos
persistentes para el componente CDSItems (es posible que esto demore más de lo
normal). Por último, asignarle a la propiedad
ProviderName del componente CDSParts el valor "DSPParts"
y crear campos persistentes para el componente CDSParts.
El siguiente gráfico muestra todas las relaciones que hemos
establecido.

Asegurarse que la propiedad Active de todos los DataSet y del Database tengan el valor
"False". Guardar el proyecto. Ahora pasemos al formulario principal.
El formulario principal
Añadir a la cláusula uses
de la unidad del formulario principal la unidad del Data Module. Añadir los
siguientes componentes al formulario principal:
- De la página Win32, un PageControl.
Asignarle a la propiedad Align
del PageControl el valor "alClient". Crear 4 páginas para el
PageControl haciendo clic con el botón derecho del ratón sobre el PageControl y
seleccionando del menú contextual la opción New Page. Asignarle a la
propiedad Caption de cada una de
ellas los valores "Orders", "Customer",
"Employee" y "Parts" respectivamente. Ahora, añadir los
siguientes componentes:
- En la primera página:
- De la página Additional, un ScrollBox y un Splitter.
- De la página Data Controls, un DBGrid y un DBNavigator.
- En cada una de las tres páginas restantes:
- De la página Data Controls, un DBGrid y un DBNavigator.
Vamos a configurar los componentes que hemos añadido en cada
página. Comencemos por lo más fácil. En cada una de las últimas tres páginas
hemos añadido un DBGrid y un DBNavigator. Pues bien, asignarle a la propiedad Align del DBNavigator el valor
"alTop" y a la misma propiedad del DBGrid el valor
"alClient".
En la primera página hemos añadido más componentes.
Asignarle a la propiedad Align del
ScrollBox y del Splitter el valor "alTop". Asignarle a la propiedad
Align del DBNavigator el valor
"alBottom" y a la misma propiedad del DBGrid el valor
"alClient". Añadir dentro del ScrollBox los siguientes componentes:
- De la página Standard, un Button.
- De la página Data Controls, un DBNavigator.
- Arrastrar y soltar dentro del ScrollBox los campos del CDSOrders del Data Module.
Asignarle a la propiedad Align
del DBNavigator que hemos añadido dentro del ScrollBox el valor
"alBottom" y a la propiedad Caption
del Button el valor "ApplyUpdates". Al haber arrastrado y soltado los
campos del CDSOrders, Delphi ha añadido por nosotros un componente DataSource.
Nombrar esta componente como "DSOrders". Finalmente añadir los
siguientes componentes:
- De la página Data Access, cuatro DataSource.
Nombrarlos "DSItems", "DSCustomer",
"DSEmployee" y "DSParts" respectivamente. Relacionar el
componente DSItems con el componente CDSItems por medio de la propiedad
DataSet del primero. Relacionar
DSCustomer con TBLCustomer, DSEmployee con TBLEmployee y DSParts con TBLparts.
Por último, relacionar el DBGrid y el DBNavigator de la
primera página con el componente DSItems por medio de la propiedad
DataSource de los dos primeros.
Relacionar de la misma forma los componentes DBGrid y DBNavigator de las tres
páginas restantes con su correspondiente DataSource.
El formulario principal, con la primera página seleccionada,
debería verse así.

Guardar el proyecto. Escribir el siguiente código para el
evento OnCreate del formulario principal:
procedure TForm1.FormCreate(Sender: TObject);
begin
DataModule2 := TDataModule2.Create(Self);
with DataModule2 do
begin
CDSOrders.Open;
CDSItems.Open;
TBLCustomer.Open;
TBLEmployee.Open;
TBLParts.Open;
end;
end;
En el código para este evento simplemente creamos una
instancia del Data Module y abrimos todos los DataSet que necesitamos.
Asegurarse que el Data Module no esté en la lista Auto-create forms en las
opciones del proyecto (Project | Options y luego la página Forms).
Escribir el siguiente código para el evento OnDestroy
del formulario principal:
procedure TForm1.FormDestroy(Sender: TObject);
begin
with DataModule2 do
begin
CDSOrders.Close;
CDSItems.Close;
TBLCustomer.Close;
TBLEmployee.Close;
TBLParts.Close;
end;
end;
El código de este evento es también muy simple. Sólo
cerramos todos los DataSet que abrimos en el código del evento OnCreate.
Guardar el proyecto, compilarlo y ejecutarlo. Lo primero que
observamos (al menos en mi caso es así) es que el formulario principal tarda
mucho en aparecer. Esto se debe a que el CDSOrders está obteniendo todos los
registros disponibles incluyendo los detalles. Podemos obtener un tiempo de
respuesta mejor asignándole a la propiedad PacketRecords
del componente CDSOrders el valor "2". De esta forma obtendrá de a 2
registros cada vez hasta que no hayan más registros disponibles.
Escribir el siguiente código para el evento OnClick del Button:
procedure TForm1.Button1Click(Sender: TObject);
begin
(DSOrders.DataSet as TClientDataSet).ApplyUpdates(0);
end;
Para que este código compile es necesario añadir la unidad
DBClient a la cláusula uses.
Hasta aquí hemos resuelto la lógica visual. Pasemos ahora a
resolver la lógica de datos en el Data Module. Debemos personalizar el proceso
de actualización del CDSOrders para actualizar CDSParts. Escribir el siguiente
código para el evento AfterUpdateRecord
del DSPOrders:
procedure TDataModule2.DSPOrdersAfterUpdateRecord(Sender: TObject;
SourceDS: TDataSet; DeltaDS: TCustomClientDataSet;
UpdateKind: TUpdateKind);
var
oPartNo, oQty: TField;
begin
if SourceDS = QRYItems then
begin
oPartNo := DeltaDS.FieldByName('PartNo');
oQty := DeltaDS.FieldByName('Qty');
case UpdateKind of
ukInsert: ActualizarParts(oPartNo.NewValue, oQty.NewValue);
ukDelete: ActualizarParts(oPartNo.OldValue, oQty.OldValue * -1);
ukModify: // El usuario puede modificar el producto y la cantidad
begin
if oPartNo.NewValue = Unassigned then
begin
if not (oQty.NewValue = Unassigned) then
ActualizarParts(oPartNo.OldValue, oQty.NewValue - oQty.OldValue);
end
else
begin
ActualizarParts(oPartNo.OldValue, oQty.OldValue * -1);
if VarIsNull(oQty.Value) then
ActualizarParts(oPartNo.NewValue, oQty.OldValue)
else
ActualizarParts(oPartNo.NewValue, oQty.NewValue);
end;
end;
end;
end;
end;
En primer lugar, verificamos que la tabla para la cual se ha
actualizado un registro sea la que necesitamos, en este caso, QRYItems. Luego,
dependiendo del tipo de actualización, llamamos al método
ActualizarParts para que se encargue de actualizar la tabla Parts.
Este método está declarado como private
en la clase del Data Module y su implementación es la siguiente:
procedure TDataModule2.ActualizarParts(APartNo, AQty: double);
begin
CDSParts.Close;
CDSParts.FetchParams; // Obtener parámetros
CDSParts.Params.ParamByName('PartNo').AsFloat := APartNo;
CDSParts.Open;
try
if (CDSPartsOnHand.Value - (CDSPartsOnOrder.Value + AQty)) < 0 then
raise Exception.CreateFmt('No es posible procesar esta orden' + #13#10 +
'Part: %.0f / %s' + #13#10 + 'OnHand: %f - OnOrder: %f = %f' + #13#10 +
'Cantidad ordenada: %f',
[CDSPartsPartNo.Value, CDSPartsDescription.Value,
CDSPartsOnHand.Value, CDSPartsOnOrder.Value,
CDSPartsOnHand.Value - CDSPartsOnOrder.Value, AQty]);
CDSParts.Edit;
CDSPartsOnOrder.Value := CDSPartsOnOrder.Value + AQty; // Actualizar OnOrder
CDSParts.Post;
if CDSParts.ApplyUpdates(0) > 0 then
raise Exception.CreateFmt('No es posible procesar esta orden' + #13#10 +
'Error al actualizar Part.OnOrder para %.0f / %s',
[CDSPartsPartNo.Value, CDSPartsDescription.Value]);
finally
CDSParts.Close;
end;
end;
En este código hacemos lo siguiente. Primero cerramos
CDSParts, luego obtenemos los parámetros (en este caso es sólo uno, PartNo), le
asignamos el valor correspondiente y abrimos CDSParts. Inmediatamente después
de esto verificamos la cantidad remanente. Aquí no es necesario verificar si el
producto existe ya que la tabla Items tiene esta imposición y en este punto ya
se ha actualizado. Si el producto no existiera entonces este código nunca sería
ejecutado. Si la cantidad remanente no es suficiente, generamos una excepción.
Si lo es, actualizamos el campo OnOrder y aplicamos las actualizaciones de
CDSParts, luego de lo cual lo cerramos. Si por algún motivo ocurre un error al
actualizar CDSParts el resultado del método ApplyUpdates sería mayor a cero. En ese
caso generamos una excepción.
Debemos hacer algo más, aunque sólo sea para nuestros ojos.
A pesar de que estamos generando una excepción nunca veremos el mensaje
correspondiente ya que los componentes DataSerProvider y ClientDataSet
involucrados se encargan de capturarla y transformarla en eventos. Escribir el
siguiente código para el evento OnReconcileError
de CDSOrders:
procedure TDataModule2.CDSOrdersReconcileError(
DataSet: TCustomClientDataSet; E: EReconcileError;
UpdateKind: TUpdateKind; var Action: TReconcileAction);
begin
Action := HandleReconcileError(DataSet, UpdateKind, E);
end;
Para que este código compile es necesario añadir a la
cláusula uses la unidad RecError.
Ahora sí estamos en condiciones de hacer pruebas y ver lo que pasa. Guardar el
proyecto, compilarlo y ejecutarlo.
Las pruebas
Según los datos de mis tablas, para el producto "900 /
Dive kayak" el valor para el campo OnHand es "24" y para el
campo OnOrder "16". Vamos a insertar una orden para pedir 9 unidades
de este producto. La siguiente imagen de pantalla muestra la orden insertada
antes de aplicar las actualizaciones.

Estos valores son válidos para los datos de mis tablas.
Hemos añadido la posibilidad de consultar todos los valores que necesitamos por
lo que para vosotros debería ser muy fácil encontrar valores válidos según los
datos de vuestras tablas. Al aplicar las actualizaciones obtengo el siguiente
mensaje de error:

Si hacemos clic en el botón "OK", cerramos la
aplicación y volvemos a ejecutarla podremos ver como los datos no han cambiado,
es decir, las tablas Orders, Items y Parts no han sido modificadas. Esto se
debe a que el componente DataSetProvider aplica las actualizaciones en el
contexto de una transacción y si algo sale mal cancela todos los cambios
realizados. En nuestro ejemplo, la línea de código en la que generamos la
excepción es posterior a la grabación de las tablas Orders e Items, pero todo
ello ocurre en el contexto de una transacción que, al generar una excepción, es
finalizada rechazando todos los cambios.
Podemos hacer todo tipo de pruebas para verificar el
correcto funcionamiento del proceso de actualización. Por ejemplo, podemos
insertar una orden para un cliente o vendedor inexistente, para un producto
inexistente, añadir varios ítems que no generen ningún error y un último ítem
que sí lo haga, etc. En cualquier caso veremos que la operación de
actualización es atómica, es decir, o se graba todo o no se graba nada.
Conclusiones
Debido a que el componente ClientDataSet almacena los
cambios en memoria, desarrollar aplicaciones como las de este ejemplo es muy
fácil. No tenemos que preocuparnos por las reglas de integridad referencial de
la base de datos mientras el usuario inserta una orden. Eso sí, tenemos que
controlar la concurrencia al momento de actualizar los cambios.
Por su parte, el componente DataSetProvider se encarga de
resolver las actualizaciones generando automáticamente sentencias SQL para
INSERT, DELETE y UPDATE. También es lo suficientemente listo como para resolver
las actualizaciones en el orden correcto, es decir, si insertamos, primero el
maestro y luego el detalle, y si eliminamos, primero el detalle y luego el
maestro. Además, nos olvidamos completamente de gestionar transacciones ya que
el DataSetProvider lo hace por nosotros.
El proceso de actualización del DataSetProvider puede ser
totalmente personalizado a través de sus propiedades y eventos. Esta
personalización puede ser tan simple como indicarle al DataSetProvider que no
actualice un campo, parcial, como la de este ejemplo, o completa, al punto de
implementar nuestro propio proceso de actualización.
|