Cero contra quinientos sesenta ( Anexo)

marzo 26, 2009 en Ado Express & DataSnap, Advertencia, Artículos, Ayuda, ¿Sabías que...?, ¿Sabías que...? [Delphi], Código, Consejo, Delphi, Entrada Diario

No iba a escribir esta entrada pero me ha pasado algo similar a lo que me ocurrió con los comentarios sobre JediVCL, donde despues de haber acabado el segundo artículo, sentía que quedaban cosas que comentar y redacté un pequeño anexo con el que dejaba lo que me parecía mas importante zanjado. El resto de funciones se podían ver sobre la marcha. Es más cuestión del día a día, a medida que te van surgiendo las dudas. Con este tema, siento que pasa algo similar.

Hemos compartido dos entradas donde comentabamos como se generaban, y en base a que criterios, las sentencias sql que el proveedor (TDataSetProvider) finalmente “ejecuta” (o manda ejecutar). Bueno. Ya visteis que no él, sino que se apoya en una clase que crea a demanda, que se llama TSQLResolver. Concretamente, y para entrar un poco más en detalle se iniciaba todo desde la función CreateResolver

function CreateResolver: TCustomResolver; override;

Las llamadas tanto a CheckResolver que en último extremo pueden crear la clase, como a FreeResolver que se encarga de liberar el objeto instanciado, se lanzan desde la clase ascendente de nuestro TDataSetProvider.

procedure TBaseProvider.CheckResolver;
begin
  if not Assigned(FResolver) then
         FResolver := CreateResolver;
end;

procedure TBaseProvider.FreeResolver;
begin
  FResolver.Free;
  FResolver := nil;
end;

Así que esa pieza clave en la generación de la sentencia SQL, queda habilmente oculta tras nuestro TDataSetProvider, que se alza como un muro casi infranqueable (*). Atrás quedan algunos comentarios compartidos con otros compañeros donde se dejaba caer que en ocasiones podría ser necesario sobrescribir la sentencia SQL, o que les gustaría ver la cadena que se ha generado, etc….

(*) Eso de infranqueable no es del todo cierto ya que es un poco una expresión, sobretodo desde el momento en el que podemos redefinir el método protegido

function CreateResolver: TCustomResolver; override;

Podriamos obtener una instancia de dicha clase y buscar la forma de, en un descendiente de la misma, acceder al punto que deseamos. Todo es posible dedicando horas y paciencia.

Y yo desde luego, no soy demasiado partidario de tocar el codigo fuente, más que nada porque luego, las posibles actualizaciones pueden sobrescribir los cambios que podamos hacer. En este caso, y para mi uso y disfrute, he modificado unas lineas del módulo Provider. El riesgo también puede ser el de generar errores adicionales a los ya existentes (que seguro los hay). Además, no siempre recordamos pasados meses y meses desde que se hizo el cambio, exactamente las lineas que estaban implicadas por lo que cualquier actualización puede hacer que el código fuente empiece a crear problemas.

Bueno… prometo que no lo volveré a hacer. :-)

Os comenté la idea de duplicar un par de procedimientos y reservarlos para el borrado. Concretamente yo en casa me he duplicado UseFieldInWhere y GenWhereSQL y les he añadido Delete para distinguirlos pero es una solución casera.

function UseFieldInWhereDelete(Field: TField;
Mode: TUpdateMode): Boolean; virtual;

procedure GenWhereSQLDelete(Tree: TUpdateTree; SQL: TWideStrings;
 Params: TParams;  GenUpdateMode: TUpdateMode; Alias: string); virtual;

Lo correcto quizás sería cambiar la firma de esos metodos y añadir sucesivamente el parametro UpdateKind (TUpdateKind) que nos hace falta para distinguir desde UseFieldInWhere cuando se está borrando de cuando se está modificando un registro. Yo siempre digo en casos así que cada uno haga de su capa un sayo, que en cristiano viene a decir algo así como cada cual se la pele como bien pueda. ;-)

Sea como sea, entraríamos en una discusión cuasi filosófica entre los que abogarían por coger siempre en el borrado (en el where) los campos de las claves primarias y aquellos que tienen valor, para compararlos (o si prefiere… todos), como forma mas segura de saber que nada ha pasado desde el momento que cargamos en la cache de nuestro TClientDataSet el registro y fue borrado, hasta que se han aplicado los cambios. Si en ese transcurso de tiempo indeterminado el registro es modificado por otro usuario, el borrado fallaría, garantizando que el usuario que ha borrado dicho registro lo hará con el conocimiento de que se ha intentado modificar.

Ahora bien, siempre existirá tambien el que piense que no vale la pena semejante alboroto para tan pocas nueces. Si me basta conocer las claves primarias que intervienen para localizar el registro, qué necesidad puede haber de utlizar en el where de la consulta todos, o parte de los campos, en el borrado de un registro. Todo depende un poco del cristal desde el que observemos y analicemos la realidad. Si nuestra vida dependiera de que un alcaide modificara el campo perdonado para que no se ejecutase nuestra sentencia al ser borrado el registro, sin duda escogeriamos un borrado seguro, pero si fuera el caso que la información a manejar fuera intrascendente prefeririamos ir al grano y olvidarnos de historias.

Yo me he dado la oportunidad de tener lo mejor de ambos mundos por si cambio de opinión. :-)

En primer lugar he creado la propiedad en la parte publica del TDataSetProvider:

property BorradoSeguro: Boolean read FBorradoSeguro write SetBorradoSeguro;

Y puesto que he hecho cambios en el modulo provider ¡que mas me da hacer unos pocos mas!

Veamos…

procedure TSQLResolver.GenDeleteSQL(Tree: TUpdateTree; SQL: TWideStrings;
  Params: TParams; Alias: string);
begin
  with PSQLInfo(Tree.Data)^ do
  begin
    SQL.Clear;
    if Tree.IsNested then
    begin
      Alias := NestAlias;
      SQL.Add(WideFormat('delete the (select %s FROM %s %s',[QuoteFullName(Tree.Name, QuoteChar),
        PSQLInfo(Tree.Parent.Data).QuotedTable, DefAlias]));       { Do not localize }
      GenWhereSQL(Tree.Parent, SQL, Params, upWhereKeyOnly, DefAlias);
      SQL.Add(WideFormat(') %s',[Alias]));
    end else
      SQL.Add(WideFormat('delete from %s %s', [QuotedTable, Alias]));  { Do not localize }

    if GetProvider.BorradoSeguro then
      GenWhereSQL(Tree, SQL, Params, Provider.UpdateMode, Alias)
    else GenWhereSQLDelete(Tree, SQL, Params, Provider.UpdateMode, Alias);

  end;
end;

La condición ( if GetProvider.BorradoSeguro then ) y la asignación previa de la propiedad BorradoSeguro me ayudará en esta tarea.

Puestos a cambiar, también se me ocurrió sobre la marcha, incluir un evento en el TDataSetProvider que nos permita acceder a la sentencia SQL. Veamos las lineas añadidas:

  TSobrescribeSQLEvent = procedure(Sender: TObject; var SQL: TWideStringList;Params: TParams;
UpdateKind: TUpdateKind; const ATablename, AliasTablename: String) of Object;

   ...

  TDataSetProvider = class(TBaseProvider)
    ...
  private
    FOnNeedChangeSQLCommand: TSobrescribeSQLEvent;
     ...
  published
    ...
    property BorradoSeguro: Boolean read FBorradoSeguro write SetBorradoSeguro;
    ...
   property OnNeedChangeSQLCommand: TSobrescribeSQLEvent read FOnNeedChangeSQLCommand
write FOnNeedChangeSQLCommand;
  end;

Solo nos quedaría localizar donde se va a ejecutar y previamente a esa instrucción, dejar que se transmita el evento al proveedor, para que el usuario pueda acceder al mismo.

procedure TSQLResolver.InternalDoUpdate(Tree: TUpdateTree; UpdateKind: TUpdateKind);
var
  Alias: string;
  FTableName: string;
  PS2 : IProviderSupport2;
begin
  if (Supports(Tree.Source, IProviderSupport2, PS2) and
 (not (PS2.PSUpdateRecord(UpdateKind, Tree.Delta)))) or 
   (not (Tree.Source as IProviderSUpport).PSUpdateRecord(UpdateKind, Tree.Delta)) then
  begin
    if (PSQLInfo(Tree.Data)^.QuotedTable = '') and not Tree.IsNested then
      DatabaseError(SNoTableName);
    if PSQLInfo(Tree.Data)^.HasObjects then Alias := DefAlias else Alias := '';
    FSQL.Clear;
    FParams.Clear;
    case UpdateKind of
      ukModify: GenUpdateSQL(Tree, FSQL, FParams, Alias);
      ukInsert: GenInsertSQL(Tree, FSQL, FParams);
      ukDelete: GenDeleteSQL(Tree, FSQL, FParams, Alias);
    end;

    if FSQL.Text <> '' then begin
      FTableName:= PSQLInfo(Tree.Data)^.QuotedTable;
      DoSobrescribeSQL(FSQL, FParams, UpdateKind, FTableName, Alias);

      DoExecSQL(FSQL, FParams);
    end;
  end;
end;

Las lineas añadidas son:

      FTableName:= PSQLInfo(Tree.Data)^.QuotedTable;
      DoSobrescribeSQL(FSQL, FParams, UpdateKind, FTableName, Alias);

Yo he usado el método virtual y protegido DoSobrescribeSQL, con la idea de poder sobrescribirlo si me hiciera falta, pero en realidad solo encubre el disparo del evento.

procedure TSQLResolver.DoSobrescribeSQL(var SQL: TWideStringList; Params: TParams;
UpdateKind: TUpdateKind; const ATablename, AliasTablename: String);
begin
  if Assigned(GetProvider.FOnNeedChangeSQLCommand) then
     GetProvider.FOnNeedChangeSQLCommand(Self,
                                          SQL,
                                          Params,
                                          UpdateKind,
                                          ATablename,
                                          AliasTablename);
end;

En cualquier caso, el evento haría recaer sobre el programador la responsabilidad de la manipulación de la cadena, sea para el uso que sea. Incluso puede haber a quien se le ocurra algo similar para elaborar un log de movimientos reales de flujo de datos, por sesión de usuario. ¡Qué se yo lo que puede haber dentro de la cabecita de un programador!, ¡Nada bueno! :-)

Al final, cogí un pequeño ejemplo y puse en práctica el artefacto, haciendo un borrado en la tabla vArticulos, que es la tabla que me servía de muestra en las entradas anteriores.

Podeis ver una imagen de cada ejecución (una con borrado seguro y la otra sin borrado seguro):

Borrado seguro

Borrado con KeysOnly

Y estas son las pocas lineas de código que las han generado:

unit USQLDatos;

interface

uses
  SysUtils, Classes, DB, ADODB, DBClient, TConnect, Provider, WideStrings;

type
  TsqlDatos = class(TDataModule)
    dspArticulos: TDataSetProvider;
    qArticulos: TADOQuery;
    Local: TLocalConnection;
    conexion: TADOConnection;
    qArticulosIdArticulo: TIntegerField;
    qArticulosCodigoAlternativo: TStringField;
    qArticulosDescripcion: TStringField;
    qArticulosCoste: TBCDField;
    qArticulosPorcentajeBeneficio: TFloatField;
    qArticulosPrecioVenta: TBCDField;
    qArticulosFechaUltimaVenta: TDateTimeField;
    procedure DataModuleCreate(Sender: TObject);
  private
    { Private declarations }
public
    { Public declarations }
     procedure OnNeedChangeSQL(Sender: TObject; var SQL: TWideStringList;
Params: TParams; UpdateKind: TUpdateKind; const ATablename, AliasTablename: String);

  end;

var
  sqlDatos: TsqlDatos;

implementation
{$R *.dfm}

uses Dialogs;

procedure TsqlDatos.DataModuleCreate(Sender: TObject);
begin
   dspArticulos.OnNeedChangeSQLCommand:= OnNeedChangeSQL;
   dspArticulos.BorradoSeguro:= True;
end;

procedure TsqlDatos.OnNeedChangeSQL(Sender: TObject; var SQL: TWideStringList; Params: TParams;
  UpdateKind: TUpdateKind; const ATablename, AliasTablename: String);
var
  i: Integer;
  sValores: String;
begin

   for i := 0 to params.Count - 1 do
    if Not Params[i].IsNull then
       sValores:= sValores + #13#10 +
                              IntToStr(params[i].Index) + '=' + Params[i].AsString;

   case UpdateKind of
     ukModify: ;
     ukInsert: ;
     ukDelete: begin

                  if ATablename = 'vArticulos' then
                       ShowMessage(SQL.Text + #13#10 + sValores);
                //     Otra posibilidad...
                //     SQL.Clear;
                //     SQL.Add('Delete from vArticulos where IDArticulo = 1');

               end;
   end;
end;
end.

Así que despues de probarlo y de comprobar que efectivamente puedo sobrescribir la sentencia SQL me quedo más tranquilo.

Me vienen a la mente numerosas ocasiones que pensé en la necesidad de poder acceder a la cadena SQL. Cuando por ejemplo he necesitado transformar una sentencia de borrado en una actualizacion (marcando por ejemplo un campo que identifique el registro como borrado) y no ejecutar un borrado físico del registro. Con ese pequeño cambio que he hecho me podría valer. Pero también podría valerme para simplemente visualizar en una depuración la sentencia y el valor de los parámetros que realmente estan enviandose a la base de datos, sin necesidad de hacer una traza en una herramienta adicional, que puede no existir.

Espero que os puedan ayudar estos comentarios.

Lo que no es programar

marzo 22, 2009 en Entrada Diario, Mis cosas

Todo no puede ser trabajar. ¿no os parece?

Así que esta mañana he aprovechado para salir a caminar un rato por la sierra en compañia de un amigo. Hemos caminado unas tres horas y he hecho esta foto para compartirla con vosotros. Hacía un tiempo fantastico.

Este era el paisaje desde los Molinos, y se ve mi pueblo al fondo. Y mas allá, muy al fondo y en pequeñito, se distinguen las lagunas del “Hondo”, un humedal que fue reconocido como Paraje Natural en 1988.

http://www.fvmp.es/fvmp3/guia/4.2.guia-tur-entorno.html?codine=3059

Paisaje rural - Vista desde los Molinos

Después, a la vuelta, un buen desayuno para iniciar bien el día . ñam ñam :-)

Y finalmente, como buen creyente, finaliza esta semana con la misa del domingo, en la parroquia de San Cayetano.

Se puede y se debe tener tiempo para todo: para divertirse, para trabajar, para hacer deporte y tambien para Dios.

Y como no… para soñar.

Cero contra quinientos sesenta ( y Parte 2)

marzo 21, 2009 en Ado Express & DataSnap, Advertencia, Artículos, ¿Sabías que...?, ¿Sabías que...? [Delphi], Código, Consejo, Delphi, Entrada Diario

En esta segunda parte de la historia, debería pienso, empezar a explicar el por qué del título, que seguramente os puede haber causado extrañeza. Todo tiene una explicación.

Vereis, en estos casos, cuando existe un error y no tienes una explicación lógica que lo justifique, es cuando hacemos uso de herramientas de depuración que casi nos hacen sentirnos como sabuesos. Nos vemos a media tarde, siguiendo paso a paso la depuración del código o como en esté caso concreto nos paso, haciendo uso de una herramienta que nos permitía husmear entre bastidores, como puede ser el analizador de sql de SQL Server.

Así que ejecutamos el programa y reproducimos el error y voila, aparecía la siguiente ventana (la imagen que ahora veis es la de un ejemplo que puede representar la que teniamos en ese momento Manuel y yo)

Analizador de SQL

Como se que no se puede ver claramente en la imagen, el texto que figura en la linea remarcada es:

exec sp_executesql N’delete from vArticulos
where
IdArticulo = @P1 and
CodigoAlternativo = @P2 and
Descripcion = @P3 and
Coste = @P4 and
PorcentajeBeneficio = @P5 and
PrecioVenta = @P6 and
FechaUltimaVenta = @P7
‘, N’@P1 int,@P2 varchar(20),@P3 varchar(40),@P4 money,@P5 float,@P6 money,@P7 datetime’, 6, ‘arti’, ‘khjk’, $0.0000, 0.000000000000000e+000, $0.0000, ‘Mar 21 2009 12:10AM’

Es decir que no solo estaba enviando en la parte where de la consulta sql solamente la clave primaria de la tabla (en este caso concreto es IDArticulo), sino que comparaba tambien el resto de campos. Nosotros pensabamos en nuestra ignorancia, fruto de haberlo leido en alguna documentación, que esto no era así. Pero claro, pensaréis, Palomo, el protagonista de la historia, debería haber sido ajusticiado y sigue vivito y coleando.

Vamos a ver el por qué:

Si os fijais el campo FechaUltimaVenta se evalua sobre el valor ‘Mar 21 2009 12:10AM’.

Como ha fallado la ejecución de la sentencia vamos a ver de nuevo el contenido del registro por lo que ejecutamos en el analizador de consultas sql la siguiente instrucción:

select IDArticulo, FechaUltimaVenta from vArticulos
where (IDArticulo = 6)

¿Sabeis que valor nos devuelve el campo?

IDArticulo FechaUltimaVenta
—————————————————
6 2009-03-21 00:10:03.560

Muchos de vosotros ya habeis adivinado cual es el problema. Nuestro TDataSetProvider ha elaborado una sentencia de borrado donde ha despreciado los milisegundos (560) enviando la cantidad (0) milisegundos. Cero contra quinientos sesenta es lo mismo que error, porque el registro ha cambiado y no lo encuentra al ser ejecutada la sentencia, y claro, el componente lanza el error lógico que tiene planificado en ese caso “Record not found…”

Así que nos quedamos planchados, tras dos o tres días perdidos dandole vueltas a este error sin encontrar el por qué fallaba para al fin llegar a una conclusión similar a la que tuvimos en la entrada en la que hablabamos de la longitud del campo. Se te queda cara de tonto y te dan ganas de cerrar el entorno, olvidarte de la programación y marcharte al campo a respirar aire sano y saludable.

Ahora bien… ya sabeis que el espiritu que intentamos transmitir desde esta página no es el de quedarnos de brazos cruzados sino el de ser positivos, el de seguir adelante e intentar aprender de nuestra experiencia. En el despacho de Manuel y mio, tenemos una pequeña pizarra que nos sirve para razonar. En la parte superior escribí un lema que me parece interesante recordar día a día: Un viaje de un millar de kilometros empieza por un solo paso.

Así que hoy vamos a intentar dar ese paso, proponiendonos saber que criterio sigue el TDataSetProvider en las actualizaciones segun la selección TUpdateMode deseada.

Veamos…

Hagamos varios puntos de parada en la ejecución. Descubriremos que el responsable de generar la cadena SQL es la clase TSQLResolver. Estas son las lineas claves en la ejecución del borrado.

procedure TSQLResolver.GenDeleteSQL(Tree: TUpdateTree; SQL: TWideStrings;
  Params: TParams; Alias: string);
begin
  with PSQLInfo(Tree.Data)^ do
  begin
    SQL.Clear;
    if Tree.IsNested then
    begin
      Alias := NestAlias;
      SQL.Add(WideFormat('delete the (select %s FROM %s %s',
                         [QuoteFullName(Tree.Name, QuoteChar),
                           PSQLInfo(Tree.Parent.Data).QuotedTable, DefAlias]));
      GenWhereSQL(Tree.Parent, SQL, Params, upWhereKeyOnly, DefAlias);
      SQL.Add(WideFormat(') %s',[Alias]));
    end else
      SQL.Add(WideFormat('delete from %s %s', [QuotedTable, Alias]));
      GenWhereSQL(Tree, SQL, Params, Provider.UpdateMode, Alias);
  end;
end;
procedure TSQLResolver.GenWhereSQL(Tree: TUpdateTree; SQL: TWideStrings;
  Params: TParams; GenUpdateMode: TUpdateMode; Alias: string);

  function AddField(Field: TField; InObject: Boolean): Boolean;
  var
    i: Integer;
    BindText: WideString;
  begin
    Result := False;
    with PSQLInfo(Tree.Data)^ do
    begin
      if Field.DataType = ftADT then
      begin
        for i := 0 to TObjectField(Field).FieldCount - 1 do
          if AddField(TObjectField(Field).Fields[i], True) then
            Result := True;
      end else
      if UseFieldInWhere(Field, GenUpdateMode) and
            (Field.DataSize < dsMaxStringSize) then
      begin
        Result := True;
        if InObject then
        begin
          if VarIsNull(Field.OldValue) then
            BindText := WideFormat(' %s.%s is null and', [Alias,
              QuoteFullName(Field.FullName, QuoteChar)])
          else
          begin
            BindText := WideFormat(' %s.%s = ? and',[Alias,
              QuoteFullName(Field.FullName, QuoteChar)]);
            TParam(Params.Add).AssignFieldValue(Field, Field.OldValue);
          end;
        end else
        begin
          if VarIsNull(Field.OldValue) or (not IsSQLBased and
             (Field.DataType = ftString) and (Length(Field.OldValue) = 0)) then
            BindText := WideFormat(' %s%s%s%1:s is null and',
              [PSQLInfo(Tree.Data)^.QuotedTableDot, QuoteChar, Field.Origin])
          else
          begin
            BindText := WideFormat(' %s%s%s%1:s = ? and',
              [PSQLInfo(Tree.Data)^.QuotedTableDot, QuoteChar, Field.Origin]);
            TParam(Params.Add).AssignFieldValue(Field, Field.OldValue);
          end;
        end;
        SQL.Add(BindText);
      end;
    end;
  end;

var
  I: Integer;
  TempStr: WideString;
  Added: Boolean;
begin
  with PSQLInfo(Tree.Data)^ do
  begin
    SQL.Add('where');
    Added := False;
    for I := 0 to Tree.Delta.FieldCount - 1 do
      if AddField(Tree.Delta.Fields[I], Alias = NestAlias) then
        Added := True;
    if not Added then
      DatabaseError(SNoKeySpecified);
    { Remove last ' and'}
    TempStr := SQL[SQL.Count-1];
    SQL[SQL.Count-1] := Copy(TempStr, 1, Length(TempStr) - 4);
  end;
end;
function TSQLResolver.UseFieldInWhere(Field: TField; Mode: TUpdateMode): Boolean;
const
  ExcludedTypes = [ftDataSet, ftADT, ftArray, ftReference, ftCursor, ftUnknown];
begin
  with Field do
  begin
    Result := not (DataType in ExcludedTypes) and not IsBlob and
      (FieldKind = fkData) and (Tag <> tagSERVERCALC);
    if Result then
      case Mode of
        upWhereAll:
          Result := pfInWhere in ProviderFlags;
        upWhereChanged:
          Result := ((pfInWhere in ProviderFlags) and
                             not VarIsClear(NewValue)) or
                             (pfInKey in ProviderFlags);
        upWhereKeyOnly:
          Result := pfInKey in ProviderFlags;
      end;
  end;
end;

Es decir:

La implementación del procedimiento GenDeleteSQL, hará la llamada a la GenWhereSQL, que será la responsable de generar la parte final de la cadena sql, decidiendo que campos deben enlazarse en la condicion where. Dentro de la implementación de GenWhereSQL se evalua AddField para cada campo y finalmente, será esta función la que llame a UseFieldInWhere que decidirá si el campo debe ser incluido en función de la relacion ProviderFlags (en Tfield) / UpdateMode (en TDataSetProvider)

Así que todas las sentencias, tanto de inserción, de actualización como de borrado van a ser filtradas a través de UseFieldInWhere. Lo cual nos llevaría a considerar si realmente queremos que en los borrados se evaluen solo las claves primarias añadir quizas un procedimiento nuevo

function TSQLResolver.UseFieldInWhereDelete(Field: TField; Mode: TUpdateMode): Boolean;
const
  ExcludedTypes = [ftDataSet, ftADT, ftArray, ftReference, ftCursor, ftUnknown];
begin
  with Field do
  begin
    Result := not (DataType in ExcludedTypes) and not IsBlob and
      (FieldKind = fkData) and (Tag <> tagSERVERCALC);
    if Result then
      case Mode of
        upWhereAll:
          Result := pfInWhere in ProviderFlags;
        upWhereChanged,  upWhereKeyOnly:
          Result := pfInKey in ProviderFlags;
      end;
  end;
end;

Y utilizarlo solo en las llamadas a GenDeleteSQL mientas que en el resto, en GenInsertSQL y GenUpdateSQL dejar que invocara a la existente.

Y respecto al tipo DateTime de SQL Server, quizás podría evitarnos el error la selección de SmallDateTime que, como Delphi, no va a considerar los milisegundos. O seguir utilizando el tipo Datetime pero evitar asignaciones de funciones que implicarán la gestión de los milisegundos, como GetDate( ) y tenerlo en cuenta. Eso sí, en el caso de que optemos por SmallDateTime habrá que llevar cuidado porque tan solo nos alcanzaría hasta el año 2080 (creo recordar) fecha en la que nuestra aplicación fallecería de muerte natural. ;-)

Pero bueno, cualquier idea que se nos ocurra haría que Palomo Confiado dejará de tener buena estrella… ¿Tú que harías? :-)

Cero contra quinientos sesenta. (Parte 1)

marzo 21, 2009 en Ado Express & DataSnap, Artículos, Ayuda, ¿Sabías que...?, ¿Sabías que...? [Delphi], Código, Consejo, Delphi, Entrada Diario

Parece un título un tanto extraño pero harto de buscar el más apropiado, o al menos el que me parecía mas ingenioso, he acabado escogiendo el mas evidente en la historia que narra esta entrada.

Este es el protagonista:

Error TDatasetProvider

Como podeis ver, se trata una notificacion de error, que es lo que se puede desprender de la imagen.

Un dato mas: la aplicación utiliza AdoExpress y DataSnap, accediendo a un servidor de SQL Server.

Con ese supuesto, quizás la intuición nos llevaría a suponer que el problema surge en una actualizacion, es decir, en un Update (más que nada por la caché de datos y el sistema de conciliacion). O quizás a causa de un trigger, un disparador del gestor de datos, que ha podido alterar el contenido del registro (se supone que lanzado desde otro proceso). O la mas probable, a causa de la interacción de otro usuario, que ha decidido caprichosamente modificar la misma ficha de un cliente o de un artículo, suponiendo que fuera este el cambio. Son ideas que a uno le pueden venir a la mente intentando encontrar un sentido al mensaje de error.

El problema es que no era una actualización sino un borrado (ese dato ya lo aporto ahora) y eso ya es un poco mas dificil de digerir. Manuel trataba de borrar la ficha de un cliente, en una de esas tardes en las que parte del tiempo se nos va en la depuración del codigo que hemos escrito. Y la aplicación siempre se obstinaba en enviarnos un mensaje que tenía poco sentido o ninguno. Porque entre otras cosas, solo quedabamos en la empresa a esas horas el y yo, y no había nada que pudiera afectar al registro desde el momento en el que el presionaba el ok del formulario hasta que se rascaba la cabeza.

Bueno. Cierto es que tengo que admitir que tenía poco sentido porque pensabamos, lo habiamos leído y eramos capaces de perjurar que era así, que si optabamos por un modo de actualización upWhereChanged en nuestro proveedor TDataSetProvider, toda vez que en una sentiencia de actualizacion iban a compararse los campos marcados como clave primaria y aquellos que habían sido modificados, en una sentencia de borrado tan solo iban a compararse los que contenian el flag pfInKey, o lo que es lo mismo, los que el usuario había marcado como clave primaria.

Ese es el problema… ¡uno da por supuesto cosas que no siempre son verdad!…

Cuenta una leyenta urbana que en el presidio de Ula-Ula los presos esperaban con paciencia en el corredor de la muerte. Uno de ellos, el protagonista de esta historia se llamaba Palomo Confiado. Y cuentan además que habían pasado los años y desde su llegada al penal, ya nunca habían podido ejecutar a nadie. El director del presidio tenía una regla inmutable: Para que una ejecución se llevara a efecto, tenía que eliminar la ficha del recluso de su aplicación. Así que cada vez que tenía que cumplir con su cargo y enviar un recluso al otro mundo, ejecutaba su aplicación y tras localizar la ficha del reo, simplemente bastaba pulsar el botón de suprimir y se ponía en marcha el mecanismo que iniciaba el tramite de la ejecución.

Hasta la llegada de Palomo al presidio todo funcionaba bien. Los presos iban desfilando por el corredor de la muerte, animando las aburridas tardes de la ciudad. Pero… siempre hay un pero. Palomo Confiado cambió todo de la noche a la mañana porque había nacido con buena estrella.

Un día le dijo Palomo al programador del presidio, que por cierto, también programaba en Delphi con AdoExresss y DataSnap:

-Para que quieres pasar trabajo asignando la fecha de alta cada vez que ingresa un nuevo preso. No. No te preocupes que de eso ya se encarga tu gestor de datos. Olvidate de asignar el valor desde Delphi y limítate a poner como valor por defecto el retorno de la función GetDate(), en el campo FECHAALTA de tu tabla de PRESOS.

Tabla de presos

Aquella tarde el programador siguió el consejo de Palomo y desde aquel momento ya no pudieron ejecutar a nadie más… ;-)

Casi desde el techo de Madrid…

marzo 16, 2009 en Delphi, Enlace interesante, Entrada Diario, Noticias, Noticias Delphi

Casi desde el techo de Madrid… decía en la entrada anterior. Hablabamos del Hotel Eurostar Tower Madrid, un lujoso hotel de cinco estrellas que sirvió de marco para la presentación de Delphi 2009. Un viernes 13 de Marzo. Un día en el que predominaba el buen tiempo, lejos de los días de viento y de frío que habiamos sufrido en España. La planta 28, donde existen múltiples salones y salas, que se destinan en estos hoteles de alto standing, para alojar actos que sean relevantes, y la altura quizás ayuda a realzarlos si cabe aun mas. Y desgraciadamente, una audiencia escasa, que deslucía un tanto la presentación.

 
Entrada del hotel

 
Es de agradecer el esfuerzo que han hecho y estan haciendo, para que el acto fuera significativo y con transcendencia, pero posiblemente, de saber que iban a asisitir pele, mele y poca ropa dicho esto sin ningun tipo de maldad sino como frase al uso en estos casos, se hubieran planteado quizas otro marco menos llamativo y mas casero (y mas económico). :-) La puerta del hotel era un muestrario de Mercedes de alta gama, cortados del mismo patrón. Mientras entraba por la recepción, atravesando una puerta de esas que giraban sin cesar, empleados uniformados del hotel se acercaban gentilmente para orientarte si te veían asi como perdido. A un “chaval” de pueblo como yo, se le nota la boina hasta el entrecejo, y quizás por esa razón, se me acercó rápidamente uno de los botones (¿se llama todavía así este tipo de servicio de los hoteles?) para indicarme el camino hacia el salón en el que iba a celebrarse el seminario. (*) [Por cierto... algún día os contaré como entramos en el Hotel Reyes Católicos, de 5 estrellas, en la Plaza del Obradoiro, tres amigos, al final del Camino de Santiago, con la ropa de peregrino, tenderetes de ropa interior incluido colgando de las mochilas para que se secaran, y cómo esos botones, vestidos de epoca, gentilmente nos sacaban casi a empujones... jajajaja (ver anecdota)]

¿Qué iba diciendo?. Se me ha ido la cabeza otra vez y empezaba a contar otras historias… Perdón.

Ahhh…. sí… ya estaba frente a uno de los ascensores. Tenía que subir a la planta 28. Otro compañero, que casualmente y a la misma hora, iba al mismo sitio y en el mismo ascensor (luego me diría que se llamaba Juan Labrador), me comentaba los problemas que había tenido para llegar al Hotel. Quizás la hora no acompañaba y las calles principales de Madrid se congestionaban con escolares y autobuses, con padres que se desplazan a los colegios, y con empleados llanos y corrientes que como yo, se dirigían a su trabajo. Creo que hice bien escogiendo el tren para desplazarme. Este compañero, con el que pude compartir cambio de impresiones durante el seminario, tenía cara de buena persona, muy majo.

Así que nos fuimos sentando los asistentes en aquella sala de la planta circular, a medida que ibamos llegando. Una joven muy simpatica y risueña (** [cuando me miraba no podia evitar reir y yo me miraba a ver si es que me había dejado la boina en la cabeza ;-) ]) nos iba localizando en el listado de inscripciones, que no tendría mas de un par de hojas y aun así, se demoraron varios (bastantes) minutos la presentación para ver si el local de llenaba. Al final no tuvieron mas remedio que iniciar el seminario. Un buen numero de sillas quedaban vacías. No me atrevo a dar una cifra de los asistentes, pero creedme que muy pocos. Recuerdo que la presentación de Delphi 2007 había llenado un salón inmenso y la asistencia era mucho mayor. Nada que ver con la que estaban asistiendo a esta presentación. Y Juan Labrador me refirió que él sí asistió al evento anterior a Delphi 2007, y que incluso habían quedado gente de pié porque se desbordaron las previsiones de asistencia.

Yo se que algunos pensarán que no es demasiado elegante comentarlo. Incluso, que hago un flaco favor resaltando que la asistencia era escasa… Yo no lo veo así, desde luego, porque ciertamente, aunque no fue un acto masificado tuvo algunos detalles que lo hicieron muy interesante, y casi quedó como algo que se celebrara en familia. Lo cual, favoreció en mi opinión la calidad de la información que se nos aportaba. Además, la calidad técnica del personal de Danysoft y concretamente del mantenedor del acto, estaba fuera de duda e hizo amena las horas que duró. Incluso tuvimos la anecdota de que tuvo que improvisar con la musica de fondo de un jazz en toda regla, combinada con sus explicaciones sobre las novedades de Delphi 2009. :-)

Por parte de la compañía Embarcadero, Mark Barringer, como cabeza de los servicios técnicos de la empresa (Head of Thecnical Services) en Londres, nos iba a exponer en dos sesiones partidas por el descanso de las once lo que viene a ser, el quien es y que nos ofrece Embarcadero.

Los primeros minutos de su presentación fueron tambien para presentar (valga la redundancia) la compañia. No puedo recordar los detalles concretos del powerpoint que acompañó esta presentación, pero si ojeais este pdf del que os incluyo el link, podreis ver mas o menos lo que nos transmitió:

 
http://www.embarcadero.com/company/Embarcadero_Corporate-Overview.pdf

Embarcadero. Su posición como empresa.

 

Es decir, la idea de una empresa sólida y capitalizada, que iba a combinar toda su trayectoria y experiencia en diseño y arquitectura de base de datos, de su modelado y de su análisis de rendimiento para todo el ciclo de desarrollo de nuestros proyectos. Eso sí, se hizo durante toda esa primera parte de la sesión, bastante inciso en su característica u orientación multiplataforma. Esa era quizás una de las ideas centrales que mas se querían remarcar.

Productos de Embarcadero

Ya en la segunda parte, Mark Barringer, nos mostró detalles mas concretos de lo que iban a aportar estas herramientas de modelado de datos, con un pequeño ejemplo que lo abordaba y que incidía en el análisis de rendimiento de las consultas sql. El hecho de que su disertación fuera en inglés y que no tenga a mano ninguna diapositiva de lo que nos mostró, me impide que pueda ahondar en esos detalles, ya que practicamente tuve que centrarme en escuchar e intentar entender lo que nos comentaba. ¡Iba yo sobrado como para poder tomar muchos apuntes! jajaja :-)

En cualquier caso, paradójicamente, y a pesar de ser una compañía tan conocida y tan importante dentro del concierto mundial, nadie de los asistentes levantó la mano cuando nos preguntaron si conociamos sus productos o los habiamos utlizado alguna vez: EREstudio, DBArtisan, RapidSQL, DBOptimizer,… lo cual nos llevó a elucubrar que realmente sean conocidos en otros paises y no demasiado por el mercado español.

Vamos a parar un momento estos comentarios. Dejadme un segundo que tome aire para seguir reflexionando…

Yo me preguntaba mienstras escuchaba al flemático ingles, un señor muy agradable que estaba bastante lejos de ser un adolescente y por supuesto, tambien bastante lejos de la idea que nos transmitía Codegear en la presentación de Delphi 2007, invitando a su equipo de jovenes ingenieros, transmitiendo la idea de renovación. En este caso, creo que Mark Barringer representaba mas a la experiencia, a la solidez, a los valores tradicionales, con ese aire britanico que, siempre en mi opinión, nos transmitía a los asistentes. Y me preguntaba mientras le escuchaba, qué aportaban realmente esos productos de Embarcadero y por qué confiar en ellos. Es decir, me hacía la pregunta (a mi mismo) hasta que punto existía la necesidad de unos sobre los otros, o quien necesitaba a quien. Porque hasta el día de hoy, eso que se nos quería de alguna forma presentar como “gran aportación” ya lo teníamos desde otros mundos ajenos a Delphi. Herramientas como las de Rational Software de IBM en el diseño del modelado logico y fisico de las bases de datos no son nuevas (Por cierto, allá por el 2001, hace 8 años, recibía un curso sobre el desarrollo con Rational Rose y todo lo que se pudo comentar ahora sobre el modelado ya se veía entonces…). Y claro, yo me decia a mi mismo, si entonces -hace 8 años- ya existían y no parece que hayan sido la panacea del mundo, que pueden aportarme ahora que no me hayan aportado antes… ummmmmmm…

Creo que es lícito que me pueda hacer esta reflexión.

Quizás tenga la ventaja de que pueda estar todo integrado y que existan vínculos que permitan traspasar la información entre las distintas herramientas, dentro de los niveles de estudio fijados. Es posible.

Vosotros que no sois tontos, ya sabeis eso de que: En esta vida ” prometer y prometer hasta meter, y una vez ya metido, adios a lo prometido”.

El que asisitiera poca gente a la presentación creo no guarda para nada relación con la gestión del partner español. Creo que el tema está más relacionado con el contexto general y la crisis económica que viven las empresas, que se muestran cautas en sus inversiones y optimizan sus recursos. Es la hora de la austeridad en el gasto ante las dificultades para encontrar la financiación del día a día y de los nuevos proyectos. Todos los sectores estan afectados por la recesión y no hace falta ser analista financiero para ver como crece el paro día a día, como el consumo ha caído y las unidades familiares empiezan a apostar por el ahorro ante la incertidumbre del medio plazo y del empleo. Así que en este panorama, las empresas del software también empiezan a sentir de alguna forma esta presión y la necesidad de rentabilizar los proyectos. Si a esto le unes el “problema” real de la piratería que afecta al software mundial, van a tener que tener mucha imaginación para vender, para convencer al empresario de que realmente necesita ese producto y de que no es una mera operación de marketing de moda, que sigue la politica de nuestro refranero: prometer hasta meter y una vez metido, adios a lo prometido.

Haced la prueba. Buscad en internet Delphi 2009 y vereis que casi con seguridad será posible descargarse versiones craqueadas del nuevo entorno, que posiblemente ya incluiran todos los updates. Pero no solo de Delphi sino de cualquier producto que se os ocurra y del que pueda haber demanda efectiva. ¡Dios me libre que no quiero escandalizar a nadie!. Esta es la vida real. El mundo que tenemos. Revisad los informes anuales sobre uso fraudulento de software en el que incurren miles y miles y miles de empresas y usuarios. Un fenomeno imparable. Un verdadero negocio que se abre paso dentro de un ciclo de recesión económica. Una relajación del concepto de fraude que penetra en todas las capas sociales y de negocio. Nadie está a favor de la pirateria (en todas sus modalidades) pero ciertamente, nuestra sociedad es bastante hipocrita en ese sentido y encuentra siempre argumentos para justificar lo que parece razonable. Recuerdo que los años en los que participé activamente en el foro de Delphi, cualquier correo que abordara temas similares era precedido de una lluvia de respuestas en uno y otro sentido, capaces de aniquilar cualquier raciocinio desde las apasionadas posturas.

Así que en este panorama, las empresas como Embarcadero tienen un reto importante para garantizar la supervivencia de nuestras herramientas. Y es la de encontar formas de licenciamiento nuevas, quizás como la surgida dentro del esquema “All-access”. Tu eliges las licencias (el numero) y que nivel vas a necesitar, y vas a tener disponibles todas las herramientas que necesites del ciclo de desarrollo. No es una mala idea, la verdad. La pena es que faltó tiempo para que Jose García (otro miembro de Danysoft) nos pudiese exponer con mas detalle como iba a gestionarse esto, de que precios podíamos estar hablando, etc… Esta parte, ocupó tan solo los ultimos 10 minutos. No quisieron ni realmente dio tiempo a entrar en detalles. Hubiera sido interesante que se hubieran facilitado precios de referencia para ver que realmente es mas rentable este sistema de licenciamiento frente al tradicional.

 
http://info.danysoft.com/free/All-Access_info.pdf

 

Al hilo de esto, y aunque no tenga nada que ver, quisiera que quien tenga algo de curiosidad revise el nuevo sistema de licenciamiento de Velneo, lo que esta compañía llama el desarrollo en la nube de forma coloquial. Es también una idea nueva dentro del sistema de licenciamiento que hasta ahora mantenía.

http://blog.velneo.es/1372/productos-velneo-v7-2009/

Y bueno… nos queda la parte central del seminario, mantenida por Luis de forma bastante amena y “brillante”. Le falto bailar. jejeje. Sobretodo en las intervenciones no preparadas al hilo de las preguntas de varios asistentes. Es ahí donde realmente se nota lo que uno sabe, ya que todos somos capaces de memorizar un pliego y soltar un discurso que ni siquiera en ocasiones dominas. Se notaba que sabía de lo que hablaba y casi, podía poner el simil de la cebolla para compararlo, que bastaba rascar un poco para tener otra capa. A poco que le preguntaban, iba soltando explicaciones y detalles de nivel mas técnico. Ese fue el caso de un compañero que abordó su inquietud por la metodología de la simulaciones de rendimiento y depuración. O cuando se comentó al hilo de otra pregunta, el estado actual de la programación en procesamiento paralelo.

Yo tomé en este caso, dado que iba mas sobrado de tiempo que durante la intervención de Mark Barringer, varias ideas que me parecieron destacables:

    Mejoras en los perfiles de compilación.
    Mejoras en el explorador de proyectos.
    Cambios la extensión de Object Pascal: Genéricos y Anónimos.
    Unicode.
    Cambios en TObject.
    Integración en versiones architect de E/R.
    y DataSnap 2009.

Hubieron muchas más ideas expuestas a través del powerpoint. Yo me quedé con estás porque me parecieron destacables. De hecho, gran parte de la primera sesión de Luis se centró en el tema de Unicode, que es uno de los ejes centrales de Delphi 2009. La pregunta que hizo a los asistentes para saber cuantos de nosotros usaban unicode en sus aplicaciones, dentro del marco de internacionalización del software, se quedó con una unica persona levantando la mano. No parece que sea una mejora que vaya a hacer mas brillante nuestras aplicaciones. Es mas bien una deuda pendiente que había con el entorno, que no aporta nada espectacular, pero que abre la posiblidad en el caso de necesitarlo, de este soporte internacional. Y como cualquier cambio trascendente, va a traer algunos peros… eso lo comentabamos con referencia al tratamiento de los strings. Por ejemplo, comentaba Luis de forma anecdótica que la asignación de las variables de este tipo, al contrario que en versiones anteriores, mantenía una copia de cada fragmento alojado en memoria, de forma que el consumo en desarrollos que hace uso intensivo de éstos, podía perjudicar de forma grave el rendimiento. Aparecían en ese sentido una nueva clase que paliaban este problema y que permitia mantener referencias a los distintas cadenas y el orden de aparición en el texto final. Creo recordar que era el la clase TStringBuilder pero no me hagais mucho caso). A todo esto se le unen el problema que ya mencionaba en una entrada anterior acerca del cambio de estructura en la cadena string unicode y de los cambios en la exportación del api de windows y el uso por defecto de las funciones unicode en lugar de las versiones ansistring.

Los cambios en TObject venían un poco en la linea de poder obtener el modulo desde el que es instanciado el objeto. Tambien se comentó algo acerca de la obtención de información a través de varias funciones declaradas ya a este nivel y que nos permitirán acceder a la clase que ha generado errores de ejecución. Era un acercamiento a parte de la información RTTI que se puede obtener y que no siempre utilizamos por desconocimiento.

Y fuera ya de estos detalles, se habló sobre las nuevas mejoras de DataSnap 2009, que se independiza del modelo DCOM, del que ciertamente quedaba ligado en versiones anteriores. La versión de DBX4 tiene algunas novedades que a mi, personalmente me gustaron mucho y que tienen que ver con la ejecución de metodos remotos en servidores.

Todos estos novedades, pueden tener cabida en próximas entradas, para verlos con un poco más de detalle.

Eso fue la segunda parte de la sesión, tras el cafe. El mantenedor de Danysoft nos presentó de una forma sencilla la creación de un servidor remoto, que exportaba tanto datos como varios procedimientos, simulando la conexion activa del mismo. De forma simultanea, y durante esa sesion creó dos clientes, uno con Delphi 2009 y otro con Delphi Prism que se conectaban al servidor con la idea de que vieramos que efectivamente podiamos acceder a este servidor tanto desde clientes nativos como de punto net. Creo que todos nos percatamos de la sencillez de los pasos y de la potencia que nos podía aportar, sobretodo a los que ya hemos conocido o trabajamos actualmente con DataSnap.

De Delphi Prism no me queda la misma sensación que de nuestro Delphi. Yo lo veo un poco como un engendro. A mi, particularmente no me gusta demasiado. Delphi por fuera y Visual Studio por dentro. Hasta ellos sienten una sensación extraña al reconocer que sobrevive una capa sobre la otra. Delphi punto net, esa tercera personalidad de RAD Studio 2007, como lo entendíamos muchos de los que nos actualizamos allá por punto net 1.0 ha quedado mas muerto que un cantal, como dicen en mi pueblo. Volvemos a eso de prometer y prometer y una vez conseguido, nos olvidamos de lo prometido. jajaja. Yo al menos pensaba para mi mientras escuchaba a Luis: -Si tengo que morir al final a Visual Studio de forma encubierta, por qué no trabajar directamente sobre Visual Studio… ¡Vamos, digo yo!. Son pensamientos que a uno se le escapan mientra escucha atentos esos razonamientos y justificaciones…

Podéis ampliar la información en estos dos enlaces:

 
http://info.danysoft.com/free/Delphi_and_Unicode.pdf

http://info.danysoft.com/free/New_Delphi_Coding_Styles_and_Architectures.pdf

 

Otra cosa: publicamente se comentó que próximamente vamos a disponer de la traducción del libro de Marco Cantú al castellano. Me alegré enormemente de ésto, ya que pienso que la comunidad hispana necesita tambien de estos proyectos que pueden no ser rentables. Comentaron que están trabajando en ello y que posiblemente esté disponible antes del verano.

Y ya para acabar esta entrada, no quise marcharme de la presentación sin satisfacer tres curiosidades, por lo que me acerqué al final del acto y conversé un par de minutos con Luis de forma muy prudente:

1.- ¿Y el soporte para trabajo en equipo con versiones de control? ¿Para cuando?
2.-¿No se va a mejorar el interfaz de gestión de Blackfish desde el entorno?
y 3.- ¿Cuando vamos a tener Delphi en castellano?

Las dos primeras son de momento una incognita. De la tercera me miraba sorprendido y yo casi comprendía su gesto, que quería decir algo así como “estamos haciendo todo lo que podemos”, “en un año hemos avanzado mucho”…

Como decía en la entrada, en este entorno de crisis y recesión, donde se buscan formulas nuevas de fidelización de clientes, nuevos conceptos entorno al licenciamiento de nuestras aplicaciones, donde se intenta dar mayor valor añadido a nuestros servicios, quizás fuera ese un verdadero punto de encuentro con la comunidad hispana, fuera ya de los parches que puedan suponer la traduccion de un libro, que sin duda es de agradecer o de generar un porcentaje algo mayor de recursos en castellano. Estoy seguro que muchas empresas pagarían incluso (sin duda) una cuota de soporte real, en su idioma nativo, siempre que fuera éste a un precio razonable.

Y ya para despedirme un breve comentario y una imagen:

 
Salvia

 

Esta tarde paseaba por el campo y con el móvil hice esta fotografía para agregarla a la entrada. Si alguien conoce esta pequeña planta bastante silvestre habrá adivinado que es Salvia. La idea era expresar con la imagen una idea que me había sugerido el viaje y la presentación en Madrid. La foto no transmite nada, pero cuando tienes las hojas de Salvia en las manos, entre los dedos y aspiras su olor tan caracteristico descubres los matices. En cierta forma, con estas presentaciones pasa algo parecido. La foto no nos transmite mas que la imagen, el marketing. ;-)

La proxima entrada…

marzo 14, 2009 en Delphi, Entrada Diario

La próxima entrada es un pequeño comentario sobre como fue la presentación de Delphi 2009 en Madrid. Estoy en ello. A ver si esta noche da tiempo a prepararla aunque sea sin demasiado detalle, (le solicité a uno de los compañeros de Danysoft si podían facilitarnos los powerpoints de la presentación para poder comentarlos).

Finalmente me desplacé y pude asistir. En principio tenía que ir con mi compañero de trabajo pero fue víctima de una gripe repentina un par de días antes, que hizo peligrar la expedición. :-)

Las vistas desde la planta 28 del Hotel Eurostar Tower Madrid son impresionantes. Bueno… las cuatro torrres lo son. Hice una foto en el descanso del seminario.

 
Vista desde la planta 28 del Hotel

Antes de la hora de inicio, unos diez o quince minutos antes, paseaba tranquilamente, por la Castellana. Sin prisas, caminé bajos las cuatro torres, observando el ir incesante de los vehiculos. Era una hora ciertamente conflictiva para los conductores. Para mi, tenía una avenida larga por la que caminar y ninguna preocupación que me quitara el sueño.

 
Las dos torres contiguas.