lunes, 21 de febrero de 2022

Arrastrar y Soltar: un caso práctico

Descripción del caso práctico

La funcionalidad de la aplicación es bastante simple. En el formulario principal hay un TFlowPanel, dos TPanel (uno rojo y otro azul) y un TImage (con la imagen de una papelera)

El formulario principal de la aplicación.

El usuario puede arrastrar cualquiera de los dos TPanel y soltarlos en el TFlowPanel. Al hacerlo se creará un TPanel (en realidad dos ya que además del arrastrado y soltado se crea otro que cumple la función de separador) dentro del TFlowPanel del mismo color del TPanel arrastrado y soltado. Los TPanel que se vayan creando dentro del TFlowPanel también podrán ser arrastrados y soltados. Si son soltados sobre el mismo TFlowPanel cambiarán su posición. Si son soltados sobre el TImage serán eliminados. Para mejorar la experiencia del usuario la aplicación provee respuesta visual para las operaciones de arrastrar y soltar. Al arrastrar se muestra el valor de la propiedad Caption del TPanel arrastrado (en un caso el nombre del color y en otro la posición del TPanel dentro del TFlowPanel) y al arrastrar sobre el TFlowPanel (o cualquiera de los TPanel que hay dentro de él) se resalta el sitio donde será creado el TPanel si es soltado en ese momento.

TMiDragObject

La aplicación utiliza su propia clase descendiente de TDragControlObject llamada TMiDragObject e implementada en la unidad UDragDrop.pas.

Propiedades nuevas

En primer lugar se agregan unas propiedades para poder personalizar las operaciones de arrastrar y soltar.

  private
    FColor: TColor;
    FDragText: string;
    FExterno: boolean;
  public
    property Color: TColor read FColor write FColor;
    property DragText: string read FDragText write FDragText;
    property Externo: boolean read FExterno write FExterno;

La propiedad Color es para determinar el color del TPanel arrastrado (que será el color del TPanel que se agregará al TFlowPanel).

La propiedad DragText es para especificar el texto que se mostrará junto con el icono de arrastrar.

La propiedad Externo es para determinar si el TPanel arrastrado es uno de los que está fuera del TFlowPanel o uno de los agregados al TFlowPanel. En el primer caso hay que agregar un TPanel al TFlowPanel y en el segundo caso sólo hay que cambiar la posición del TPanel arrastrado y soltado.

Imagen al arrastrar

Es posible mostrar una imagen al arrastrar junto al puntero del ratón. En este caso se utiliza esa imagen para mostrar un texto. Para ello se deben sobrescribir tres métodos:

  protected
    function GetDragImages: TDragImageList; override;
  public
    procedure HideDragImage; override;
    procedure ShowDragImage; override;

El método GetDragImages permite proveer una instancia especial de TImageList (TDragImageList) con la imagen correspondiente. La implementación de este método no hace más que crear una instancia de TDragImageList y agregarle la imagen luego de dibujar sobre ella el valor de la propiedad Caption del TPanel arrastrado. Hay un par de líneas que merecen una explicación:

      vIndex := FDragImageList.AddMasked(oBitmap, clYellow);
      FDragImageList.SetDragImage(vIndex, Round(oBitmap.Width / 2), oBitmap.Height);

Con SetDragImage se establece lo que en inglés se llama HotSpot que son las coordenadas de un punto de la imagen que se convierte en el punto sobre el que se posiciona el puntero del ratón al arrastrar. En este caso ese punto es el centro para el eje X y el borde inferior para el eje Y.

Finalmente HideDragImage y ShowDragImage que se encargan de ocultar y mostrar la imagen. La implementación por defecto hace uso de la instancia de TDragImageList que provee el control arrastrado. En este caso el control arrastrado es un TPanel que no provee ninguna instancia de TDragImageList por lo que modificamos estos dos métodos para hacer uso de la instancia TDragImageList de la propia instancia de TMiDragObject.

TDragControlObjectEx

Delphi provee la clase TDragControlObjectEx que se diferencia de TDragControlObject en que se destruye automáticamente cuando la operación arrastrar y soltar finaliza. En este caso la aplicación se encarga de destruir dicha instancia.

Iniciar una operación arrastrar y soltar

En este caso la aplicación utiliza los eventos OnMouseDown y OnMouseMove para iniciar las operaciones de arrastrar y soltar.

procedure TfrmPrincipal.ExPanelMouseDown(aSender: TObject;
  aButton: TMouseButton; aShift: TShiftState; aX, aY: integer);
begin
  if aButton = mbLeft then
    FPunto := Point(aX, aY);
end;

En el evento OnMouseDown se obtiene el punto en el cual el usuario hizo clic y se almacena en el campo FPunto declarado como TPoint.

procedure TfrmPrincipal.ExPanelMouseMove(aSender: TObject; aShift: TShiftState;
  aX, aY: integer);
begin
  if not FArrastrando and (ssLeft in aShift)
    and ( (Abs(FPunto.X - aX) > 5) or (Abs(FPunto.Y - aY) > 5) )
  then
    TControl(aSender).BeginDrag(True);
end;

En el evento OnMouseMove se detecta el momento en el cual el puntero del ratón se ha desplazado al menos cinco puntos para iniciar la operación arrastrar y soltar. El campo FArrastrando sirve para evitar problema al mover el puntero del ratón sobre un control una vez se ha iniciado una operación de arrastrar y soltar.

El método BeginDrag(True) inicia inmediatamente una operación arrastrar y soltar y dispara el evento OnStartDrag.

procedure TfrmPrincipal.ExPanelStartDrag(aSender: TObject;
  var aDragObject: TDragObject);
begin
  FDragObject := TMiDragObject.Create(TPanel(aSender));
  TMiDragObject(FDragObject).Color := TPanel(aSender).Color;
  TMiDragObject(FDragObject).DragText := TPanel(aSender).Caption;
  TMiDragObject(FDragObject).Externo := True;
  aDragObject := FDragObject;
  FArrastrando := True;
end;

En este evento se crea una instancia de TMiDragObject pasando como parámetro del constructor el objeto que lo disparó (en este caso un TPanel externo). Luego se asigna el color, el texto para la imagen y se indica que se trata de un control externo. Se asigna la instancia de TMiDragObject al parámetro aDragObject y finalmente se indica que se ha iniciado una operación arrastrar y soltar.

Con estos tres evento se ha iniciado la operación arrastrar y soltar.

Aceptar el objeto arrastrado

Durante una operación arrastrar y soltar se dispara el evento OnDragOver en los controles sobre los que pasa el puntero del ratón. En este evento se debe indicar si se acepta el objeto arrastrado.

procedure TfrmPrincipal.FlowPanelDragOver(aSender, aSource: TObject;
  aX, aY: integer; aState: TDragState; var aAccept: boolean);
var
  oSeparador: TPanel;
begin
  aAccept := (aSource is TMiDragObject) and (TMiDragObject(aSource).Externo);
  if aAccept then
  begin
    if FlowPanel.ControlCount > 0 then
    begin
      oSeparador := TPanel(GetControlAtIndex((FlowPanel.ControlCount - 1)));
      TMiDragObject(aSource).HideDragImage;
      if aState = dsDragEnter then
        oSeparador.Color := clBlack
      else if aState = dsDragLeave then
        oSeparador.ParentColor := True;
      oSeparador.Update;
      TMiDragObject(aSource).ShowDragImage;
    end;
  end;
end;

En primer lugar se determina si se acepta el objeto arrastrado. Si el objeto arrastrado es aceptado entonces se muestra una respuesta visual que le indica al usuario dónde se colocará el objeto arrastrado si lo suelta en ese momento. Para la respuesta visual se utiliza el separador. El parámetro aState indica si el puntero del ratón acaba de entrar en el control (dsDragEnter), si acaba de salir (dsDragLeave) o si se ha movido luego de haber entrado y antes de haber salido (dsDragMove)

Esta implementación del evento OnDragOver corresponde al TFlowPanel. En este caso la respuesta visual consiste en cambiar el color del último separador. Para obtenerlo se utiliza el método GetControlAtIndex ya que el mismo TFlowPanel no tiene ningún método para hacerlo. Luego se oculta la imagen de arrastrar y soltar, se cambia el color del separador para mostrar la respuesta visual, se actualiza el separador (para que se vuelva a pintar sin esperar el mensaje correspondiente) y finalmente se vuelve a mostrar la imagen de arrastrar y soltar.

La implementación del evento OnDragOver varía dependiendo del control. Más adelante veremos las otras implementaciones.

El objeto arrastrado fue soltado

Cuando el usuario suelta el objeto arrastrado sobre un control se dispara el evento OnDragDrop para el control sobre el cual fue soltado el objeto arrastrado y el evento OnEndDrag para el control que inició la operación arrastrar y soltar.

En el evento OnDragDrop se agregar un TPanel al TFlowPanel.

procedure TfrmPrincipal.FlowPanelDragDrop(aSender, aSource: TObject;
  aX, aY: integer);
begin
  DoAgregar(TMiDragObject(aSource).Color, FlowPanel.ControlCount + 1);
end;

Se utiliza el método DoAgregar para agregar el panel con el color y en la posición indicada. En este caso la posición es luego del último control dentro del TFlowPanel.

En el evento OnEndDrag es poco lo que se hace.

procedure TfrmPrincipal.ExPanelEndDrag(aSender, aTarget: TObject;
  aX, aY: integer);
begin
  FArrastrando := False;
  FreeAndNil(FDragObject);
end;

Se indica que ya no hay una operación arrastrar y soltar en curso y se destruye la instancia de TMiDragObject (que fue creada en el evento OnStartDrag). Esto último no sería necesario si se utilizara la clase TDragControlObjectEx.

DoAgregar

Este método se encarga de agregar un TPanel (y un separador) al TFlowPanel.

procedure TfrmPrincipal.DoAgregar(aColor: TColor; aIndex: integer);
var
  oPanel: TPanel;
begin
  if FlowPanel.ControlCount = 0 then
    DoAgregarSeparador(0);
 
  oPanel := TPanel.Create(Self);
  oPanel.BevelInner := bvNone;
  oPanel.BevelOuter := bvNone;
  oPanel.Width := 32;
  oPanel.Height := 32;
  oPanel.ParentBackground := False;
  oPanel.Color := aColor;
  if FlowPanel.ControlCount = 1 then
    oPanel.Caption := '1'
  else
    oPanel.Caption := IntToStr(Round((FlowPanel.ControlCount + 1) / 2));
  oPanel.Parent := FlowPanel;
  FlowPanel.SetControlIndex(oPanel, aIndex);
  oPanel.OnDragOver := InPanelDragOver;
  oPanel.OnDragDrop := InPanelDragDrop;
  oPanel.OnStartDrag := InPanelStartDrag;
  oPanel.OnEndDrag := InPanelEndDrag;
  oPanel.OnMouseDown := InPanelMouseDown;
  oPanel.OnMouseMove  := InPanelMouseMove;
  oPanel.ControlStyle := oPanel.ControlStyle + [csDisplayDragImage];
 
  DoAgregarSeparador(aIndex + 1);
end;

Si es el primer control agregado al TFlowPanel entonces se agrega un separador al principio. Luego se crear una instancia de TPanel, se configura, se agrega al TFlowPanel y se asignan manejadores para los eventos involucrados en operaciones arrastrar y soltar. Finalmente se crea un separador.

Algunas líneas merecen explicación.

  oPanel.ControlStyle := oPanel.ControlStyle + [csDisplayDragImage];

Esta línea habilita el control (el TPanel) para que muestre la imagen de arrastrar y soltar cuando el puntero del ratón pase sobre él. Algo similar se hace en la clase TMiDragObject para obtener el mismo resultado sobre otros controles.

Otros eventos

Hasta aquí se ha explicado una operación arrastrar y soltar completa. Sin embargo la funcionalidad de la aplicación requiere de más código que no se explica detalladamente por razones de espacio. Sin embargo ese código resuelve particularidades que no son fundamentales para comprender las operaciones arrastrar y soltar sino más bien aspectos específicos de la funcionalidad de la aplicación.

Conclusiones

Las operaciones arrastrar y soltar pueden dotar a una aplicación de una facilidad de uso a prueba de usuarios principiantes. De hecho es una de las funcionalidades más intuitivas de cara al usuario. La implementación básica es sencilla y Delphi nos da casi todo resuelto. Sin embargo para darle un toque más profesional es necesario recurrir a pequeños trucos que no siempre están bien documentados.

En este caso se han explicado sólo algunos aspectos de las operaciones arrastrar y soltar. Espero poder abordar otros aspectos en futuros artículos.

How to accept dropped files from the explorer?

 {This way you can drag and drop files to a specific control in a Delphi form.
 Just create a project and add a ListBox component to Form1.}

{1. First, a procedure to handle the message but without handling it.}

interface

procedure 
WMDROPFILES(var Msg: TMessage);

implementation

procedure 
TForm1.WMDROPFILES(var Msg: TWMDropFiles);
var
  
pcFileName: PChar;
  i, iSize, iFileCount: integer;
begin
  
pcFileName := ''; // to avoid compiler warning message
  
iFileCount := DragQueryFile(Msg.wParam, $FFFFFFFF, pcFileName, 255);
  for i := 0 to iFileCount - 1 do
  begin
    
iSize := DragQueryFile(Msg.wParam, i, nil, 0) + 1;
    pcFileName := StrAlloc(iSize);
    DragQueryFile(Msg.wParam, i, pcFileName, iSize);
    if FileExists(pcFileName) then
      
AddFile(pcFileName); // method to add each file
    
StrDispose(pcFileName);
  end;
  DragFinish(Msg.wParam);
end;


{2. Second, a WindowProc method to replace ListBox1 WindowProc default method
 and a variable to store ListBox1 WindowProc default method.}

interface

procedure 
LBWindowProc(var Message: TMessage);

implementation

var
  
OldLBWindowProc: TWndMethod;

procedure TForm1.LBWindowProc(var Message: TMessage);
begin
  if Message
.Msg = WM_DROPFILES then
    
WMDROPFILES(Message); // handle WM_DROPFILES message
  
OldLBWindowProc(Message);
  // call default ListBox1 WindowProc method to handle all other messages
end;

{3. In Form1 OnCreate event, initialize all.}

procedure TForm1.FormCreate(Sender: TObject);
begin
  
OldLBWindowProc := ListBox1.WindowProc; // store defualt WindowProc
  
ListBox1.WindowProc := LBWindowProc; // replace default WindowProc
  
DragAcceptFiles(ListBox1.Handle, True); // now ListBox1 accept dropped files
end;


{4. In Form1 OnDestroy event, uninitialize all. Not necesary but a good practice.}

procedure TForm1.FormDestroy(Sender: TObject);
begin
  
ListBox1.WindowProc := OldLBWindowProc;
  DragAcceptFiles(ListBox1.Handle, False);
end;


{5. To complete source code, the AddFile method.}

interface

procedure 
AddFile(sFileName: string);

implementation

procedure 
TForm1.AddFile(sFileName: string);
begin
  
ListBox1.Items.Add(sFilename);
end;

{6. Do not forget to add ShellAPI unit to the uses clause.}

Complete code


unit Unit1;

interface

uses
  
Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs,
  StdCtrls;

type
  
TForm1 = class(TForm)
    ListBox1: TListBox;
    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
  private
    
{ Private declarations }
    
procedure WMDROPFILES(var Msg: TMessage);
    procedure LBWindowProc(var Message: TMessage);
    procedure AddFile(sFileName: string);
  public
    
{ Public declarations }
  
end;

var
  
Form1: TForm1;

implementation

{$R *.DFM}

uses
  
ShellAPI;

var
  
OldLBWindowProc: TWndMethod;

procedure TForm1.AddFile(sFileName: string);
begin
  
ListBox1.Items.Add(sFilename);
end;

procedure TForm1.FormCreate(Sender: TObject);
begin
  
OldLBWindowProc := ListBox1.WindowProc; // store defualt WindowProc
  
ListBox1.WindowProc := LBWindowProc; // replace default WindowProc
  
DragAcceptFiles(ListBox1.Handle, True); // now ListBox1 accept dropped files
end;

procedure TForm1.FormDestroy(Sender: TObject);
begin
  
ListBox1.WindowProc := OldLBWindowProc;
  DragAcceptFiles(ListBox1.Handle, False);
end;

procedure TForm1.LBWindowProc(var Message: TMessage);
begin
  if Message
.Msg = WM_DROPFILES then
    
WMDROPFILES(Message); // handle WM_DROPFILES message
  
OldLBWindowProc(Message);
  // call default ListBox1 WindowProc method to handle all other messages
end;

procedure TForm1.WMDROPFILES(var Msg: TMessage);
var
  
pcFileName: PChar;
  i, iSize, iFileCount: integer;
begin
  
pcFileName := ''; // to avoid compiler warning message
  
iFileCount := DragQueryFile(Msg.wParam, $FFFFFFFF, pcFileName, 255);
  for i := 0 to iFileCount - 1 do
  begin
    
iSize := DragQueryFile(Msg.wParam, i, nil, 0) + 1;
    pcFileName := StrAlloc(iSize);
    DragQueryFile(Msg.wParam, i, pcFileName, iSize);
    if FileExists(pcFileName) then
      
AddFile(pcFileName); // method to add each file
    
StrDispose(pcFileName);
  end;
  DragFinish(Msg.wParam);
end;

end.

 

domingo, 20 de febrero de 2022

ClientDataSet y DataSetProvider en aplicaciones cliente/servidor

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 negocio 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 valido 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 grafico 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 este 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 parametros
  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 validos 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.