para maite, por lograr que oiga música cuando sólo hay silencio. ian · 2019-07-14 · nes de las...

466
Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian

Upload: others

Post on 19-Jul-2020

0 views

Category:

Documents


0 download

TRANSCRIPT

Page 1: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Para Maite, por lograr que oiga Música

cuando sólo hay Silencio.

Ian

Page 2: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada
Page 3: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

INDICE

PRÓLOGO 11

INTRODUCCIÓN 12

¿POR QUÉ OTRO LIBRO? 12 AGRADECIMIENTOS 13 AGRADECIMIENTOS MUY ESPECIALES 13

1. DE DÓNDE VENIMOS, ADÓNDE VAMOS… 15

BASES DE DATOS RELACIONALES 15 SOMBRA Y SUSTANCIA 16 EL DESAJUSTE DE IMPEDANCIAS 17 CONJUNTOS O CURSORES 18 CACHÉ LOCAL Y ACTUALIZACIONES 19 INTERFACES DE PROGRAMACIÓN PARA BASES DE DATOS 20 CONEXIÓN Y DESCONEXIÓN 21 EL MOVIMIENTO HACIA LAS TRES CAPAS 23 MITOS Y LEYENDAS 25 LA ESTRUCTURA DE ESTE LIBRO 27 PARA SEGUIR LOS EJEMPLOS... 29

I. TRANSACT SQL 31

2. SERVIDORES Y BASES DE DATOS 33

LA ARQUITECTURA DE SQL SERVER 33 DOS NIVELES DE IDENTIFICACIÓN 35 DOS MODELOS DE SEGURIDAD 36 BASES DE DATOS Y FICHEROS 37 PARTICIONES Y EL REGISTRO DE TRANSACCIONES 39 GRUPOS DE FICHEROS 40 VARIOS FICHEROS EN EL MISMO GRUPO 41 LAS BASES DE DATOS DEL CATÁLOGO 41 CREAR... O REEMPLAZAR 42

3. FUNDAMENTOS DE TRANSACT SQL 44

GUIONES SQL 44 SINTAXIS BÁSICA DE TRANSACT SQL 45 TIPOS DE DATOS BÁSICOS 47 VALORES NULOS 48 FUNCIONES ESPECIALES PARA VALORES NULOS 50 TIPOS DE DATOS DEFINIDOS POR EL USUARIO 51

Page 4: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

4 La Cara Oculta de C#

REGLAS Y VALORES POR OMISIÓN 52

4. TABLAS 54

DOS TIPOS DE TABLAS 54 ... Y DOS TIPOS DE ÍNDICES 55 CREACIÓN DE TABLAS Y DEFINICIONES DE COLUMNAS 57 EL ATRIBUTO IDENTITY 58 ¿IDENTIDADES O TABLAS DE CONTADORES? 60 TABLAS TEMPORALES 61 VERIFICACIÓN DE CONDICIONES 62 CLAVES PRIMARIAS 63 ALGUNOS MITOS PERSISTENTES 64 CLAVES ALTERNATIVAS 64 RESTRICCIONES CON NOMBRES 65 INTEGRIDAD REFERENCIAL DECLARATIVA 67 ACCIONES REFERENCIALES 69 BUCLES Y PUNTEROS VACÍOS 70 CREACIÓN DE ÍNDICES SECUNDARIOS 71 COLUMNAS CALCULADAS E ÍNDICES 72

5. CONSULTAS Y ACTUALIZACIONES 75

EL LENGUAJE DE CONSULTAS 75 LA CONDICIÓN DE SELECCIÓN 76 ELIMINACIÓN DE DUPLICADOS 77 ORDENANDO LOS RESULTADOS 77 LIMITANDO EL NÚMERO DE REGISTROS 79 GRUPOS Y FUNCIONES ESTADÍSTICAS 80 LA CLÁUSULA HAVING 83 PRODUCTOS CARTESIANOS 83 ENCUENTROS NATURALES 84 SINÓNIMOS PARA TABLAS 86 LA SINTAXIS ESPECIAL DEL ENCUENTRO NATURAL 87 SUBCONSULTAS BASADAS EN SELECCIONES SINGULARES 88 LOS OPERADORES IN Y EXISTS 89 SUBCONSULTAS CORRELACIONADAS 91 SUBCONSULTAS COMO TABLAS VIRTUALES 91 ENCUENTROS EXTERNOS 94 INSTRUCCIONES PARA ACTUALIZACIONES 95 MODIFICACIONES BASADAS EN ENCUENTROS 96 MODIFICAR Y LEER 97

6. PROCEDIMIENTOS ALMACENADOS 100

¿POR QUÉ SON IMPORTANTES LOS PROCEDIMIENTOS? 100 CÓMO NOS COMUNICAMOS CON UN PROCEDIMIENTO 102 SINTAXIS BÁSICA: CREACIÓN 103

Page 5: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Indice 5

TRASPASO DE PARÁMETROS 104 EJECUCIÓN DESDE TRANSACT SQL 104 INSTRUCCIONES PERMITIDAS 106 PROCEDIMIENTOS DE SELECCIÓN 108 UNA VARIANTE DE SELECCIÓN 109 UNA, NINGUNA, MUCHAS... 110 SELECCIÓN SIN TABLAS 111 PROCEDIMIENTOS Y TABLAS TEMPORALES 112 SIMULACIÓN DE LISTAS VARIABLES DE PARÁMETROS 114

7. CURSORES 117

¿PARA QUÉ SIRVE UN CURSOR? 117 LA DECLARACIÓN DEL CURSOR 118 APERTURA Y CIERRE 118 EL BUCLE ARQUETÍPICO 119 CUANDO EL ORDEN ES IMPORTANTE 120 DOS TIPOS DE DECLARACIONES 121 GLOBAL, LOCAL 122 CURSORES ACTUALIZABLES 123 ACTUALIZACIONES EN LA FILA ACTIVA 124 CURSORES BIDIRECCIONALES 125 VARIABLES DE CURSOR 125

8. FUNCIONES DEFINIDAS POR EL USUARIO 127

FUNCIONES ESCALARES 127 EJECUTANDO FUNCIONES ESCALARES 129 FUNCIONES DE TABLAS EN LÍNEA 129 ENSAMBLANDO UN RESULTADO 130 EXPRESIONES COMUNES DE TABLAS 132

9. TRANSACCIONES SQL 134

E PLURIBUS UNUM 134 INICIO Y FIN DE TRANSACCIÓN 135 TRANSACCIONES IMPLÍCITAS 136 AISLAMIENTO DE TRANSACCIONES 137 EN EL PRINCIPIO ERA EL CAOS 139 EXPERIENCIAS PARANORMALES EN LA CATEDRAL 140 QUE NADIE MUEVA LA ALFOMBRA BAJO MIS PIES 141 NO SE ADMITEN FANTASMAS 142 ¿TRANSACCIONES ANIDADAS? 143 EL CONTADOR DE TRANSACCIONES 144 CÓMO SE DETECTA UN ERROR 145 CANCELACIONES PARCIALES 147

10. TRIGGERS 149

¿PARA QUÉ NECESITAMOS TRIGGERS? 149

Page 6: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

6 La Cara Oculta de C#

TRIGGERS EN TRANSACT SQL 150 INTEGRIDAD REFERENCIAL MEDIANTE TRIGGERS 152 TRIGGERS ANIDADOS Y TRIGGERS RECURSIVOS 154 TRIGGERS “INSTEAD OF” 155 EL CONTADOR DE FILAS AFECTADAS 156

II. ADO.NET 159

11. FUNDAMENTOS DE ADO.NET 161

¿QUÉ ES .NET? 161 C#: UN LENGUAJE CON BEMOLES 164 ADO.NET 165 PROVEEDORES DE DATOS .NET 166 PREVIENDO LA DIVISIÓN EN CAPAS 168 A PEQUEÑA ESCALA 171

12. CONJUNTOS DE DATOS EN MEMORIA 172

CONJUNTOS DE DATOS 172 CÓMO SE CONFIGURA UN CONJUNTO DE DATOS 173 TABLAS Y COLUMNAS 174 CONFIGURACIÓN DE COLUMNAS 175 COLUMNAS CALCULADAS 176 MOSTRANDO LA TABLA EN UNA REJILLA 177 FILAS Y CONJUNTOS DE FILAS 180 CLAVES PRIMARIAS Y VALORES ÚNICOS 181 PERSISTENCIA 182 PERSONALIZACIÓN DEL FORMATO DE PERSISTENCIA 185 DOCUMENTOS XML 186

13. ACTUALIZACIONES EN MEMORIA 190

ACTUALIZACIONES SOBRE TABLAS 190 VERSIONES DE FILAS 191 INSERCIONES 192 BORRADOS 193 MODIFICACIONES SOBRE FILAS 194 UN REPASO A LAS VERSIONES DE FILAS 195 EVENTOS DURANTE LA ACTUALIZACIÓN 196 ASOCIACIÓN DE ERRORES A FILAS Y COLUMNAS 197 DESHACIENDO LOS CAMBIOS 199

14. JERARQUÍAS DE DATOS 201

RELACIONES 201 REJILLAS Y JERARQUÍAS 203 CLAVES EXTERNAS Y RESTRICCIONES 205 ACCIONES REFERENCIALES EN ADO.NET 207

Page 7: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Indice 7

NAVEGACIÓN SOBRE LA JERARQUÍA 207 EXPRESIONES QUE APROVECHAN LA JERARQUÍA 209 EL MÉTODO COMPUTE 211

15. BÚSQUEDAS, FILTROS Y ORDENACIÓN 213

BÚSQUEDAS POR LA CLAVE PRIMARIA 213 SELECCIÓN MEDIANTE FILTROS 214 ORDENANDO LAS FILAS 215 FILTRADO POR VERSIONES 216 VISTAS DE DATOS 216 CONSTRUCCIÓN Y MANEJO DE VISTAS DE DATOS 218 ACTUALIZACIONES SOBRE VISTAS DE DATOS 219

16. CONTROLES ENLAZADOS A DATOS 221

ENLACE A DATOS 221 ¿A QUÉ PODEMOS ENLAZAR NUESTROS CONTROLES? 222 ENLACE SIMPLE EN TIEMPO DE DISEÑO 224 INSTRUCCIONES DE ENLACE A DATOS 226 EL CONTEXTO DE ENLACE 228 MOVIMIENTO DEL CURSOR 229 UN CONTROL PARA LA NAVEGACIÓN 231 EVENTOS DE FORMATO 236 SELECCIONES DESPLEGABLES 237 REJILLAS DE DATOS 240 CONFIGURACIÓN DE REJILLAS 242 ESTILOS DE COLUMNAS 243 CONFIGURACIÓN EN TIEMPO DE EJECUCIÓN 244 CREACIÓN DE NUEVAS CLASES DE COLUMNAS 246 SINCRONIZACIÓN DE VENTANAS 249

17. CONEXIONES 252

CONEXIONES BÁSICAS 252 CLASES DE CONEXIÓN 253 VÍNCULOS DE DATOS EN OLE DB 254 CADENAS DE CONEXIÓN PARA EL CLIENTE SQL 256 LA CLASE DE CONEXIÓN DE OLE DB 258 EL EXPLORADOR DE SERVIDORES 259 LA CLASE DE CONEXIÓN DEL CLIENTE SQL 260 PROPIEDADES DINÁMICAS 261 LA CACHÉ DE CONEXIONES 263 EVENTOS DE LAS CLASES DE CONEXIÓN 265 TRANSACCIONES EXPLÍCITAS EN .NET 266 INFORMACIÓN SOBRE EL ESQUEMA 268 PROGRAMACIÓN CON SQLDMO 269

Page 8: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

8 La Cara Oculta de C#

18. COMANDOS Y CONSULTAS 272

HAZ LO QUE TE DIGO 272 CUANDO EL COMANDO ES UNA CONSULTA 274 ACCESO A COLUMNAS 276 CONEXIONES MUY OCUPADAS 277 DISPARATES EXCEPCIONALES 278 VARIACIONES SOBRE UNA CONSULTA 280 RECUPERANDO MÁS DE UNA CONSULTA 281 UNA FILA, UNA COLUMNA 282 CONSULTAS CON PARÁMETROS 283 PARÁMETROS EN OLE DB, ORACLE Y ODBC 284 EJECUTANDO PROCEDIMIENTOS ALMACENADOS 284 LECTURA SECUENCIAL DE BLOBS 287 COMANDOS Y TRANSACCIONES EXPLÍCITAS 289 MICROSOFT APPLICATION BLOCKS 290

19. ADAPTADORES DE DATOS 292

ESTRUCTURA DE LOS ADAPTADORES 292 CARGA DE DATOS SOBRE TABLAS EN MEMORIA 294 LECTURA DEL ESQUEMA 295 CUANDO LA TABLA YA EXISTE 297 CONSULTAS CON PARÁMETROS 298 LOTES DE CONSULTAS 299 RELACIONES MAESTRO/DETALLES Y ADAPTADORES 300 LOS DATOS MÁS RECIENTES 302 DESACTIVANDO TEMPORALMENTE LAS RESTRICCIONES 304

20. CONJUNTOS DE DATOS CON TIPOS 306

LA FUERZA DE LA ENCAPSULACIÓN 306 ESQUEMAS XML 308 EL EDITOR DE ESQUEMAS 311 ANOTACIONES EN EL ESQUEMA 314 DEFINICIÓN DE ESQUEMAS MEDIANTE ADAPTADORES 316 AÑADIENDO REGLAS DE NEGOCIO 317

21. ACTUALIZACIONES BÁSICAS 320

ACTUALIZACIONES EN VISUAL STUDIO 320 CONFIGURACIÓN DE ADAPTADORES 322 APLICANDO LOS CAMBIOS 324 LA METÁFORA DEL PROCESADOR DE TEXTO 325 PARÁMETROS ACOTADOS Y VERSIONES DE FILAS 326 ELOGIO DE LA LOCURA 328 LOS PROBLEMAS DEL BLOQUEO PESIMISTA 329 CONTROL OPTIMISTA DE CONCURRENCIA 330 CONTROL DE CONCURRENCIA POR MARCAS DE TIEMPO 333

Page 9: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Indice 9

OTRAS VARIANTES DE BLOQUEOS OPTIMISTAS 333 EL CONSTRUCTOR DE COMANDOS 335

22. ACTUALIZACIONES COMPLEJAS 337

EL ALGORITMO DE ACTUALIZACIÓN 337 IDENTIDADES 339 RELECTURA DE FILAS ACTUALIZADAS 342 PROPAGACIÓN Y MEZCLA DE DATOS 345 GRABACIÓN DE RELACIONES MAESTRO/DETALLES 346 EL ORDEN DE ACTUALIZACIÓN 348 LOS EVENTOS DEL ADAPTADOR 353 CONTROL DE ERRORES Y TRANSACCIONES 355 FALLOS EN BLOQUEOS OPTIMISTAS 357

III. PROGRAMACIÓN REMOTA 361

23. .NET REMOTING 363

UN EJEMPLO MUY SENCILLO 363 EL PRIMER SERVIDOR REMOTO 364 EL PRIMER CLIENTE REMOTO 368 SERIALIZACIÓN POR VALOR Y POR REFERENCIA 369 MODELOS DE ACTIVACIÓN Y EJECUCIÓN 371 ACTIVACIÓN EN EL CLIENTE 372 EL MODELO MÁS ADECUADO 375 CANALES Y PUERTOS 377 FICHEROS DE CONFIGURACIÓN REMOTA 378 TIEMPO DE VIDA 381 APLICACIONES DE SERVICIOS 383 HOSPEDAJE EN I.I.S. 386 LA BALADA DEL CLIENTE SOLITARIO 389

24. SERVICIOS WEB 392

¿QUÉ SON LOS SERVICIOS WEB? 392 SOAP, WSDL, UDDI... 393 MENSAJES, LLAMADAS Y UNA GRAN MENTIRA 394 UN EJEMPLO SENCILLO DE CLIENTE 396 ACCESO AL SERVICIO MEDIANTE UN PROXY 398 SERVIDORES BASADOS EN ASP.NET 399 SERVICIOS WEB EN VISUAL STUDIO 403 ATRIBUTOS PARA SERVICIOS WEB 407 TÉCNICAS DE CACHÉ 408 CABECERAS SOAP 410 ESPACIOS DE NOMBRES PARA SERVICIOS 412 EJECUCIÓN ASÍNCRONA 413 LLAMADAS ASÍNCRONAS A SERVICIOS WEB 416

Page 10: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

10 La Cara Oculta de C#

DISPARA Y OLVIDA 417

25. SERVICIOS DE COMPONENTES 418

LOS LÍMITES DE LA ENCAPSULACIÓN 418 PROGRAMACIÓN ORIENTADA A ASPECTOS 419 LOS SERVICIOS DE COM+ 420 LOS PROBLEMAS DE LA AGREGACIÓN 422 TRANSACCIONES DECLARATIVAS 424 VOTACIONES Y CONFIRMACIÓN AUTOMÁTICA 427 REGISTRO DE CLASES DENTRO DE COM+ 428 ACTIVACIÓN POR DEMANDA 430 LA CACHÉ DE OBJETOS 432

26. SERVIDORES DE CAPA INTERMEDIA 434

UN SERVIDOR EN TIEMPO DE DISEÑO 434 ENCAPSULAMIENTO DEL ACCESO A SQL 435 EL SERVIDOR UNIVERSAL DE DATOS 437 PAGINACIÓN MEDIANTE COOKIES 440 PAGINACIÓN MEDIANTE CABECERAS SOAP 443 SEGURIDAD BASADA EN TOKENS 444 PROTECCIÓN DE LAS COMUNICACIONES 446 CIFRADO ASIMÉTRICO 447 CIFRADO SIMÉTRICO 449 COLAS DE MENSAJES 451 CLASES DE MENSAJERÍA EN .NET 453 CORREO ELECTRÓNICO 456

¿Y...? 457

INDICE ALFABÉTICO 459

Page 11: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Prólogo

DEBIDO A MI ACTIVIDAD COMO ARTICULISTA estoy acostumbrado a ver todo lo que aparece sobre programación en el mercado editorial. Para elaborar una de las seccio-nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada mes. Esto tiene su parte buena y su parte mala. Como en botica, me encuentro de todo: los hay buenos y entretenidos, buenos aunque “sesudos” o aburridos y, por supuesto, malos y además aburridos (normalmente ambas cosas van de la mano: todavía no he encontrado uno que sea divertido a pesar de ser poco didáctico). Sin duda el libro que tiene delante pertenece a la primera categoría.

A lo largo de su medio millar de páginas, “La Cara Oculta de C#” presenta al pro-gramador con todos los conceptos útiles para crear aplicaciones empresariales orientadas a datos con C# y la plataforma .NET. Se estudia todo lo necesario para crear este tipo de desarrollos, desde la capa de datos (SQL Server) hasta la de pre-sentación, pasando por la creación de componentes de negocios basada en compo-nentes. Por supuesto un libro que pretenda ser realista y práctico no puede dejar de lado la comunicación entre todas estas capas. Con las enseñanzas del libro seremos capaces de crear aplicaciones distribuidas cuyos componentes residen en diferentes máquinas repartidas por un edificio, una red corporativa o por todo el planeta.

Y además es un libro muy ameno. Cualquier cosa que escriba Ian Marteens lo es. Es muy complicado redactar un libro técnico sin resultar aburrido o cuando menos excesivamente sistemático. La propia naturaleza de los temas es casi la que te con-duce a ello. Sin embargo Ian, con su gran cultura y sentido del humor, lo consigue. Sólo hay que leer el primer párrafo de este volumen para darse cuenta: “Con fre-cuencia me visita un sueño, en el que encuentro la Biblioteca de Todas las Respues-tas, siempre escondida en la sombra de una callejuela…”. No me negará que esto es literatura. Lo cierto es que tardé más de lo que pensaba en escribir este prólogo por-que me costaba desengancharme de la lectura. En ocasiones incluso me he llegado a reír (no sólo sonreír) con los comentarios e historias que se cuentan. Pero no se deje engañar por mí: se trata éste de un libro muy serio, fruto de la experiencia con el que aprenderá mucho, que es fundamentalmente de lo que se trata.

José Manuel Alarcón Aguín (www.krasis.com) es ingeniero industrial y especialista en consultoría de empresas. Ha publicado más de doscientos artículos

en revistas técnicas y es autor de varios libros sobre informática e ingeniería.

Page 12: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Introducción

ON FRECUENCIA ME VISITA UN SUEÑO, en el que encuentro la Biblioteca de Todas las Respuestas, siempre escondida en la sombra de una callejuela de To-

ledo, Zaragoza o Pamplona; la ciudad cambia cada noche, pero los demás detalles permanecen. Tras sobornar al guardia con una moneda de cobre oxidada, logro en-trar y pasearme entre sus infinitas estanterías. Hojeo un libro tomado al azar: descu-bro el nombre de la ciudad donde se refugió Caín tras asesinar a su hermano, y el de la extraña raza a la cual pertenecían sus habitantes. En otro tomo encuentro la de-mostración original del Teorema de Fermat, escrita en los márgenes y tan sencilla como respirar. También me sorprende conocer que, en un remoto valle entre mon-tañas de la lejana Asia, sobrevive un misterioso país asolado por dragones, y que hay un rey dispuesto a dar la mano de su hija al valeroso caballero que mate a la Serpiente de Fuego y restaure la paz en su reino.

Finalmente despierto. No puedo recordar las respuestas. La sensación de pérdida me acompañará durante el resto del día...

¿Por qué otro libro?

Escribo este libro principalmente porque la Biblioteca de Todas las Respuestas sólo puede construirse trozo a trozo. Y también por dinero, por supuesto, aunque no es lo más importante, créame. Hay muchas otras formas mejores de ganarlo al alcance de un informático.

Es cierto que hay, en estos momentos, abundancia de libros sobre C# y la plataforma .NET, pero la mayoría de ellos padecen uno de dos problemas. El primero de ellos: necesariamente, los libros de la primera generación de una herramienta de desarrollo suelen pecar de muy generales o de muy especializados. O intentan explicar el Uni-verso en trescientas páginas, incluyendo el funcionamiento de los quarks y los cajeros automáticos, o dedican ochocientas a la descripción de la psicología sexual de la hormiga pocha (si es que existe tal especie). Sí, es verdad que La Cara Oculta es un libro especializado, pero hay especializaciones prácticas. He intentado combinar los temas que he considerado básicos que un programador necesita para escribir aplica-ciones de bases de datos: programación SQL en el servidor de datos, programación con ADO.NET para la capa intermedia y para la presentación, y elementos de pro-gramación remota, como .NET Remoting y Servicios Web para unir estas capas. Es el lector quién deberá decidir si me he equivocado o no.

El otro problema de la primera generación de libros sobre .NET es que han sido escritos, en su mayoría, por programadores con experiencia previa en Visual Basic o Java... o en cosas aún peores que prefiero no imaginar. Nunca es tarde para arrepen-tirse de un pecado, pero muchos de estos libros arrastran técnicas malvadas fruto de la larga cohabitación con el Lado Oscuro de la Fuerza. ¿No me cree? Abra cualquier

C

Page 13: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Introducción 13

libro que tenga a mano, y cuente las instrucciones try/finally, o su variante especiali-zada using, que encuentre. Descubrirá la existencia de un mundo idílico en el que nunca ocurren excepciones y en el que los programas disfrutan de recursos infinitos.

Este humilde autor ha tenido la suerte de programar durante todos estos años en Delphi, un lenguaje diseñado por Anders Hejlsberg, el creador de C#. Hay una con-tinuidad entre estos dos lenguajes en muchos aspectos del diseño, y es mucho más sencillo saltar a C# desde Delphi que desde cualquier otro punto de partida.

Agradecimientos

En parte, este libro es un humilde homenaje al trabajo de Anders Hejlsberg, y al grupo de talentos quizás menos conocidos que trabajaron con él primero en Delphi, y ahora en C# y la plataforma .NET. Soy un mal bicho que, lamentablemente, perdió sus héroes en su lejana adolescencia. Pero no dejo de reconocer y admirarme de que el trabajo de un puñado de personas me haya ofrecido una buena forma de ganarme el sustento durante tantos años, además de una buena ración de momentos de alegría intelectual.

Quiero reconocer y agradecer el trabajo de revisión del manuscrito original, por Carmelo Nieto, un viejo amigo (que no es lo mismo que un amigo viejo). Carmelo se ha enfrentado a un problema importante: sólo un par de semanas para leerse el libro de arriba abajo. A pesar del poco tiempo, sus consejos me han ayudado a darle una estructura más sólida a la línea argumental. Cualquier error que haya persistido es responsabilidad mía, que he seguido escribiendo y añadiendo erratas sobre el material ya revisado.

Maite y María del Carmen han sido otra vez cómplices necesarias de este acto delic-tivo. Sin la organización casi prusiana de mis actividades por parte de María, no ha-bría encontrado el tiempo necesario para sentarme a escribir. Y gracias al apoyo y comprensión por parte de Maite no he abandonado este proyecto en ninguna de las muchas ocasiones en que me pudo el cansancio y el desánimo.

Agradecimientos muy especiales

Me enteré de la existencia de las gallinas de Jericó gracias a Portae Lucis, una traduc-ción parcial, publicada en 1516, de un oscuro tratado cabalístico llamado Schaare ora. Luego, la curiosidad me hizo buscar más pistas, y éstas acudieron en tropel: una breve mención en la Historia Natural de Plinio, un informe sobre las excavaciones arqueológicas en un yacimiento canadiense pre-Clovis, un intrigante y poco conocido pasaje de William Blake... De las ruinas de Jericó a las civilizaciones del valle de Ha-rappa, desde la primera Babilonia hasta nuestros días, estas aves de la Mesopotamia han estado presentes, siempre discretamente, en cada avance científico o humanís-tico, en cada acontecimiento histórico, incluso en el origen de monarquías y naciones. Hay autores que han llegado a sugerir que el gallo, el símbolo de la nación francesa, es en realidad el hijo de una gallina de Jericó...

Page 14: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

14 La Cara Oculta de C#

A estas sufridas pero sublimes amigas y compañeras de la Humanidad, dedico este libro. Sin ellas, nunca habría sido escrito.

Ian Marteens www.marteens.com

Madrid, octubre de 2003

Page 15: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

1

De dónde venimos,

adónde vamos…

L PRIMER CAPÍTULO DE UN LIBRO SUELE SER el último que se escribe, y el pre-sente libro no es la excepción. Cuando alguien se sienta a escribir, tiene unas

cuantas ideas de referencia en mente, que forman la base de lo que quiere explicar. Esas ideas son tan evidentes para el autor que se vuelven tan transparentes como el aire; son omnipresentes, pero es fácil olvidarlas.

Al terminar el libro, miras y compruebas que es bueno, que todo se sostiene... pero queda por explicar la base. Vuelves a sentarte, respiras hondo, y escribes el primer capítulo.

Bases de datos relacionales

Tampoco es necesario comenzar por el Génesis. Las bases de datos relacionales lle-van con nosotros más de tres décadas; los sistemas comerciales, más de veinte años. Y durante todos esos veinte años, distintos modelos alternativos, el principal de los cuales son las bases de datos orientadas a objetos, han intentado destronarlas... en vano. ¿Cuál es el secreto de las relaciones? ¿En qué fallan los aspirantes?

Recapitulemos: las bases de datos relacionales permiten modelar una parte impor-tante de la realidad que nos rodea usando una construcción matemática muy sencilla: la relación. Una relación es un conjunto de tuplos o filas, que a su vez son listas de valores. Todas las filas de una misma relación deben estar formadas por el mismo número de valores. Esto hace que, hasta cierto punto, podamos entender una rela-ción como una matriz, con un número de columnas fijas y un número variable de filas.

Lo sorprendente es que con estos ladrillos tan básicos podamos modelar tantas co-sas. Por supuesto, para representar entidades complejas tenemos que forzar un poco las cosas. Algo tan simple como una factura requiere al menos dos relaciones. Los modelos “orientados a objetos” nos ofrecen más posibilidades en este sentido. La potencia del modelo relacional, por lo tanto, no está en la facilidad de modelado, sino algo completamente distinto.

E

Page 16: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

16 La Cara Oculta de C#

Ese “algo” viene dado precisamente por la simplicidad de los ladrillos básicos, que facilitó inicialmente el análisis y diseño matemático del llamado cálculo relacional1. Con las operaciones de este cálculo, podemos combinar y desintegrar relaciones a placer, y gracias a él disponemos de una poderosa herramienta para la búsqueda de infor-mación. En contraste, a pesar del esfuerzo realizado durante muchos años, no ha sido posible crear una técnica similar para los modelos basados en objetos. Sólo re-cientemente han surgido propuestas viables para bases de datos XML, que utilizan un modelo más complicado que el relacional puro, pero menos complejo que el de las bases de datos orientadas a objetos.

Sombra y sustancia

Esto que escribiré ahora es mi opinión particular. Hay otro motivo por el que segui-mos trabajando con bases de datos relacionales. Y tiene que ver con una de las ca-racterísticas de éstas que suele pasar inadvertida: las bases de datos relacionales mo-delan entidades mediante valores. Por el contrario, los sistemas orientados a objetos aprovechan al máximo el concepto de identidad.

En nuestra escala dentro del universo, dos balones de fútbol, por muy parecidos que sean, son siempre distinguibles entre sí. En el universo cuántico, sin embargo, no es posible distinguir entre dos electrones2. La teoría relacional se parece al mundo cuántico: no podemos diferenciar dos registros de una misma tabla que tengan los mismos valores en sus columnas, al menos teóricamente. Si utilizamos expresiones relacionales puras, no tenemos forma de eliminar sólo uno de ellos.

NO

TA

Estas presuntas “limitaciones” han sido siempre vistas como un obstáculo, y cada sis-tema ha ideado formas de “superarlas”. Oracle, por ejemplo, soporta un identificador invariante de fila para cada registro: podríamos eliminar uno de los registros duplicados haciendo uso de ese identificador. En SQL Server, en contraste, podríamos eliminar uno de ellos limitando el número de filas procesadas por una instrucción delete, mediante la variable global rowcount. Y en SQL Server 2003, la misma instrucción ha sido ampliada para permitir este tipo de restricción con menos líneas de código.

Mi tesis es que esta “limitación” es lo que ha permitido el gran éxito comercial de las bases de datos relacionales en la pasada década, cuando se planteó el reto de crear sistemas de procesamiento de información distribuidos a gran escala. Para compren-derlo, hay que analizar cómo funcionan las interfaces de acceso para la programación en estos sistemas. Todas estas interfaces copian registros seleccionados de una base de datos en la memoria local de las estaciones de trabajo. En ningún momento se asume que lo que tiene el ordenador cliente sea el propio registro, materializado por arte de magia como un avatar. Esto hace que el programador acepte con naturalidad que su registro puede haber sido copiado simultáneamente desde otros puestos. Si modifi-camos la copia localmente, a la hora de grabar debemos tener en cuenta la posibili-

1 Edgar Frank Codd (1923-2003) 2 Este hecho físico sigue siendo un misterio. Una curiosa interpretación sostiene que todos los electrones son en realidad un único electrón que viaja en el tiempo en los dos sentidos, y que por este motivo parece estar en tantos lugares a la vez.

Page 17: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

De dónde venimos, adónde vamos… 17

dad de que el registro leído originalmente pueda haber sido modificado entre tanto. En ese caso, la interfaz de acceso debe encargarse de conciliar las dos variantes crea-das, y dejar como versión definitiva una sola de ellas, o una mezcla inteligente de ambas.

NO

TA

Aquí debo distinguir entre una base de datos relacional “verdadera”, que utiliza el cálculo relacional como parte esencial de su interfaz de programación, y las bases de datos pseudo relacionales, como dBase y Paradox... que ya sabemos que fracasaron cuando abandonaron el estrecho mundo de las aplicaciones para un solo puesto.

Esto no quiere decir que, al soportar el concepto de identidad, las bases de datos orientadas a objetos sean automáticamente inadecuadas para sistemas con mucha carga. Por el contrario, la mayoría de los sistemas existentes y experimentales inclu-yen técnicas para el trabajo con versiones de objetos. Pero la implementación de estas técnicas es costosa, y las consecuencias para el modelo de programación no siempre se comprenden bien.

El desajuste de impedancias

De todos modos, la programación con bases de datos relacionales no está exenta de dificultades. Precisamente, la más importante se produce al combinar el modelo de datos basado en valores, de semántica declarativa, con un lenguaje de programación de tipo tradicional. A un lado de la frontera, tenemos objetos persistentes estructura-dos como tablas y filas; al otro lado, hay clases y objetos. Por lo tanto, siempre debe haber una capa de código que “traduzca” la información de la base de datos a una forma que sea aceptable para nuestros lenguajes de programación. Este problema se conoce como impedance mismatch, o desajuste de impedancias.

NO

TA

La impedancia, por cierto, es una medida de la resistencia de un circuito al paso de la corriente alterna, y el nombre se debe a las anomalías que se producen al conectar dos circuitos con diferentes impedancias en sus puertos de comunicación: un altavoz a la salida de un amplificador, una guitarra eléctrica con la entrada del amplificador. Aclaro esto porque ya he tropezado con algún purista pedante al que la palabra impedancia no le “sonaba” conocida y pretendía que la “tradujese”.

La explicación anterior es bastante abstracta, y necesitaremos un ejemplo para com-prender las implicaciones. Veamos cómo un programador adicto a Java suele desa-rrollar sus aplicaciones. Una parte importante de su trabajo será la definición de cla-ses que implementen la funcionalidad conocida en inglés como CRUD: crear, recupe-rar, modificar y borrar (create/retrieve/update/delete). Si en la base de datos existe una tabla llamada Clientes, nuestro programador creará una clase Cliente, que encapsulará como mínimo las cuatro operaciones básicas mencionadas, probablemente con la ayuda de varias clases auxiliares. Además, esta clase incluirá algunas reglas de negocio: no podremos, por ejemplo, asignar una fecha de nacimiento posterior a la fecha actual, y no se admitirán nombres propios como C3P0 o R2D2.

Tengo que hacer dos observaciones sobre esta breve historia:

Page 18: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

18 La Cara Oculta de C#

1 Esta forma de programar, aunque parece de lo más ortodoxa, no ayuda mucho en la programación de sistemas grandes. Es más, puede ser nociva para la efi-ciencia. Pero este problema no es el que nos ocupa ahora mismo. Volveremos a ocuparnos de él varias veces a lo largo del libro.

2 Respecto al desajuste de impedancias: nuestro programador acaba de crearse un problema de mantenimiento. Cada vez que cambie el esquema de la tabla de clientes, tendrá que propagar laboriosamente los cambios a las clases que se ocu-pan de la persistencia.

Puede que le sorprenda que me queje del esfuerzo necesario para la propagación de cambios. Es un incordio conocido por todos; es más, nos hemos acostumbrado tanto que lo consideramos una carga aceptable, como la declaración de la renta o el perrito ladrador de la vecina. Es cierto: es un problema menor... pero lo que ahora importa es que tiene su origen en el desajuste de impedancias.

Conjuntos o cursores

El desajuste de impedancias tiene más consecuencias, por supuesto. Una relación es un tipo de conjunto, y el cálculo relacional trabaja con eficiencia y expresividad con estos conjuntos. En contraste, en la mayoría de los lenguajes tradicionales, los con-juntos son tipos de datos conflictivos, casi siempre ciudadanos de segunda, con sus derechos civiles recortados. Si nuestra aplicación pide a un servidor el conjunto de clientes cuya última compra tuvo lugar hace más de seis meses, ¿cómo podría el ser-vidor entregarnos esa información?

La mayoría de los sistemas relacionales lo resuelven mediante cursores: una técnica de programación que consiste en evaluar la consulta, y recuperar los registros del resul-tado uno por uno. El siguiente fragmento de código, escrito en C#, muestra la crea-ción y recuperación de registros de un cursor:

SqlDataReader rd = cmd.ExecuteReader();

while (rd.Read()) {

// … utilizar el registro recién leído …

}

No importan ahora los detalles: el objeto cmd está conectado a un servidor SQL y ha sido configurado antes con una consulta select. La ejecución del método Execute-Reader crea un cursor, que en la jerga de ADO.NET se conoce como data reader, o lector de datos. Luego, cada vez que se llama al método Read del lector, dentro del bucle, se recuperan los valores de un registro, para que hagamos “algo” con ellos.

NO

TA

...¿y me pregunta usted cuál es el problema que causa el desajuste de impedancias en este ejemplo? Piense que, mientras los datos eran manejados por el servidor SQL, como tablas o como conjuntos de filas, a éste le era muy sencillo combinarlos. Una vez transfe-ridos al lado cliente, se hace muy difícil ejecutar las operaciones del cálculo relacional con esta representación tan primitiva.

Una de las características comunes a todos los mecanismos de recuperación por cur-sores es que la implementación más eficiente se logra cuando el cursor sólo puede

Page 19: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

De dónde venimos, adónde vamos… 19

avanzar al próximo registro; es decir, cuando no podemos repetir la lectura de un registro ni saltarnos lecturas. Un cursor eficiente, además, no debe permitir modifica-ciones: una vez que tenemos una copia de un registro en el lado cliente, la única forma de modificar el registro original, en la base de datos, consiste en ejecutar ins-trucciones de actualización explícitas, probablemente generadas por la aplicación que actúa como cliente.

De todos modos, muchos sistemas ofrecen también cursores que se pueden mover libremente, y que en ocasiones aceptan modificaciones directas sobre el registro ac-tivo. Pero estos cursores tan potentes exigen muchos recursos por parte del servidor de bases de datos, y sólo son aceptables en condiciones de concurrencia mínima, con muy pocos usuarios.

Caché local y actualizaciones

Resumamos. Una interfaz de programación típica para un servidor de bases de datos relacionales debe implementar dos técnicas básicas:

1 Recuperación de registros mediante cursores. El cursor más eficiente suele mo-verse en una sola dirección, y no debe permitir modificaciones directas sobre el registro activo.

2 Ejecución de otras instrucciones: modificaciones, borrados e inserciones, princi-palmente, pero también se deben incluir sentencias para crear y administrar ta-blas, índices, procedimientos y otros objetos de la base de datos.

Este es el mínimo absoluto, y realmente existen interfaces de acceso que se ajustan estrictamente a esta especificación, como la primera versión de JDBC. Pero una apli-cación interactiva necesita, por lo general, de más funcionalidad:

1 La posibilidad de almacenar los registros recuperados en una lista o colección. Esto es esencial para implementar controles que muestren más de un registro simultáneamente, como las ya habituales rejillas de datos. Si la biblioteca de cla-ses no ofrece esta posibilidad, el programador tendrá que reinventar la rueda en cada aplicación.

2 Una vez que los registros recuperados se almacenan en una lista o colección, es aconsejable que exista algún modo de modificar esta copia de los datos origina-les. El sistema debe proporcionar la funcionalidad necesaria para conciliar o sin-cronizar las modificaciones locales con la base de datos de origen.

El primero de estos requisitos es el más sencillo de satisfacer: todos los lenguajes modernos soportan técnicas más o menos cómodas de representación de listas y colecciones. Además, no siempre es necesario ofrecer esta funcionalidad. Por ejem-plo, una aplicación para Internet, que debe producir páginas HTML puede arreglár-selas sin una caché local de más de un registro.

Más complicada, sin embargo, es la implementación del segundo de los requisitos. La siguiente imagen ilustra la situación:

Page 20: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

20 La Cara Oculta de C#

La pila de bloques de la izquierda representa los registros leídos en un paso anterior, una vez almacenados en la caché local. El usuario, con la ayuda de controles de edi-ción, modifica uno o más registros. La mayoría de las aplicaciones interactivas hacen creer al usuario que está cambiado el verdadero registro original... cuando evidente-mente se trata de una copia local. A partir de esas modificaciones, la interfaz de pro-gramación debe ser capaz de generar de manera automática las sentencias SQL nece-sarias para que sean enviadas a la base de datos y modifiquen el registro real.

Interfaces de programación para bases de datos

Casi todas las interfaces de acceso a bases de datos actuales ofrecen el conjunto de funciones que acabamos de describir. Estas interfaces son las más conocidas dentro del universo Windows:

• ODBC (Open Database Connectivity): creada por Microsoft, ya se considera obso-leta. Es una de las interfaces más populares, sin embargo, y casi todos los siste-mas de bases de datos ofrecen compatibilidad con la misma.

• BDE (Borland Database Engine): una interfaz similar a ODBC, desarrollada por Borland, y también obsoleta. Su relativa popularidad se la debe a Delphi, un en-torno de programación orientado a componentes que la sigue incluyendo como opción de acceso a datos.

• OLE DB: Es el sustituto de ODBC, también diseñado por Microsoft. Mientras ODBC consistía en un conjunto de funciones, OLE DB es orientado a objetos, o para ser más exactos, está basado en tipos de interfaz, una técnica relativa-mente reciente dentro de la P.O.O.

• ADO (ActiveX Data Objects): En realidad, ADO es un grupo de clases que encap-sulan de forma más asequible la mayor parte de la funcionalidad de OLE DB. Para programar con OLE DB hace falta un lenguaje de programación que so-porte seriamente la programación orientada a objetos. Visual Basic 6, por ejem-plo, no cumplía con los requisitos necesarios. Por este motivo, Microsoft creó ADO, como una técnica realmente más sencilla de acceso, que podía utilizarse sin grandes aspavientos incluso en lenguajes interpretados como JScript o VB Script (no confundir con Visual Basic 6).

Page 21: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

De dónde venimos, adónde vamos… 21

• ADO.NET: A pesar de la similitud del nombre con ADO, es un sistema comple-tamente diferente, disponible solamente dentro de la plataforma .NET. La mayor parte de este libro está dedicada al estudio de ADO.NET, por lo que me aho-rraré las descripciones preliminares.

Por supuesto, cada fabricante de sistemas de bases de datos ofrece su propia interfaz de acceso: por ejemplo, Oracle incluye OCI y Oracle Objects for OLE. Y tendríamos que considerar las interfaces de programación basadas en Java. Pero en general, la funcionalidad implementada por todos estos sistemas se basa en los requerimientos básicos que hemos venido explicando:

1 Una función muy sencilla, que recibe una cadena con una instrucción SQL y la envía al servidor para que sea ejecutada.

2 Un cursor unidireccional, de sólo lectura, implementado casi siempre con un par de funciones. La primera recibe una cadena con una instrucción SQL de con-sulta, y devuelve un puntero a una estructura interna utilizada por la segunda función. Cada vez que ejecutemos la segunda función, obtendremos uno de los registros evaluados durante la evaluación de la consulta, mientras queden regis-tros por leer, claro.

3 Una estructura de datos que nos permita almacenar esos registros en una caché local, para poder navegar sobre ellos.

4 La posibilidad de modificar las copias locales de los registros en caché, y de sin-cronizar posteriormente los cambios con la base de datos.

Conexión y desconexión

Al autor, y supongo que a muchos de sus lectores, le gustan las historias que conver-gen a un desenlace obligado. Cuando leo sobre Música, por ejemplo, disfruto con esas historias que presentan una continuidad entre los estilos históricos: de la pre-sunta monofonía inicial a la polifonía de la Edad Media tardía y del Renacimiento, de ahí a la revolución armónica y, como colofón lógico... la dodecafonía, la música con-creta, los conciertos con acompañamiento de helicópteros y las lamentables cancio-nes de... uf, a lo mejor a usted le cae bien el personaje, así que lo dejaremos aquí.

Naturalmente, estas historias suelen ser falsas. Exigen simplificar excesivamente la realidad, o hacen pasar como elección lógica y racional lo que muchas veces ha sido un accidente histórico. Y la historia que estoy contando en este capítulo no se libra de estos defectos.

Al explicar el mecanismo usado por las interfaces de acceso que he presentado he dejado de lado algunas alternativas de diseño que, en su momento, fracasaron. Sin embargo, muchas de estas alternativas siguen ejerciendo su seducción sobre los pro-gramadores desprevenidos. En realidad, al programador medio le cuesta aceptar que debe trabajar con una copia local de los datos, y que existe la posibilidad de que las modificaciones que realice sobre esa copia no puedan aplicarse a la base de datos por culpa de los cambios realizados desde otra estación de trabajo. ¿No podríamos “en-

Page 22: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

22 La Cara Oculta de C#

gañarle” un poco más, y hacerle creer que las modificaciones las realiza directamente sobre el mismísimo registro original? La pregunta encierra parte de la respuesta: cualquier técnica que lo hiciera sería una simulación. Y queda por demostrar que se trata de una ilusión cargada de peligros.

Para empezar, tendríamos que implementar un mecanismo que se conoce como bloqueo pesimista. Aunque en capítulos posteriores estudiaremos estos bloqueos en detalle, puedo adelantarle algunos de los problemas que provocan: la edición de re-gistros se convierte en un cuello de botella, el tráfico en red aumenta de forma es-pectacular, y un fallo en una estación de trabajo puede provocar una catástrofe, si ésta deja registros bloqueados.

Otra alternativa de diseño desechada es el uso de cursores bidireccionales imple-mentados directamente en el servidor. ¿No es acaso un desperdicio de recursos el tener una copia local de los registros en cada estación de trabajo? ¿Por qué no hacer que la técnica del cursor que hemos visto antes permita avanzar y retroceder la posi-ción del registro activo en la lista de registros evaluada? Hay unas cuantas razones para no hacerlo así. En primer lugar, esto significaría una carga de trabajo considera-ble sobre el servidor SQL, que tendría que mantener esos cursores bidireccionales para cada cliente conectado. Y provocaríamos también un tráfico excesivo en la red.

En el fondo, hay un problema común a ambas alternativas:

• Tendríamos que mantener una conexión constantemente abierta entre el servi-dor y cada uno de sus clientes.

El mantenimiento de una conexión permanente tiene unos cuantos inconvenientes:

• Obliga al servidor a mantener estructuras de datos adicionales para cada cliente, que malgastan recursos. Esto es aceptable solamente para una instalación con pocos usuarios.

• Al mantener referencias a los registros físicos en el servidor, mientras el usuario decide qué hacer con sus datos, hay que bloquear el acceso a los registros involu-crados, y se produce el temido cuello de botella.

Para rematar, las conexiones a un servidor SQL tienen una característica negativa:

• El protocolo de intercambio de datos entre servidor y clientes que utilizan la mayoría de los sistemas SQL es demasiado verboso y, en consecuencia, poco apto para conexiones remotas a través de Internet.

Esta revelación sorprende a muchos, aunque es fácil de explicar. Suponga que hemos creado un procedimiento almacenado que ejecuta cuatro instrucciones en su interior. Puede parecer que, para ejecutarlo y recibir cualquier posible respuestas, sólo serían necesarios dos mensajes enviados a través de la red: el mensaje que activa el proce-dimiento en el servidor, y el mensaje que avisa sobre el fin de ejecución, que es el que enviaría al cliente cualquier resultado calculado. Pero SQL Server tiene otra opinión

Page 23: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

De dónde venimos, adónde vamos… 23

al respecto: cada vez que se ejecuta una de las instrucciones encapsuladas dentro del procedimiento, el servidor envía una notificación al cliente.

Otros sistemas envían periódicamente mensajes de tipo ping a los clientes conectados, para comprobar si siguen ahí. Es lógico, porque si hay recursos bloqueados por di-chos clientes, sería inaceptable que un cliente fallase y dejase sus recursos sin liberar hasta la consumación de los siglos.

El movimiento hacia las tres capas

Las aplicaciones “tradicionales” cliente/servidor pueden considerarse como aplica-ciones estructuradas en dos capas (two tiers), tanto en el sentido físico como en el lógico. Una de las capas está constituida por el software del servidor, que incluye los procedimientos almacenados y las reglas que hayan sido programadas dentro de la base de datos. Y en la otra capa tenemos, previsiblemente, la aplicación encargada de presentar los datos al usuario final.

Los avances en las técnicas de programación y en los propios sistemas operativos han propiciado la sustitución de los sistemas en dos capas por aplicaciones fragmen-tadas en un número superior de capas. El más popular de estos modelos es el mo-delo en tres capas, que físicamente se puede representar de la siguiente manera:

Hay dos tipos de consideraciones que justifican el uso de este modelo. La que se esgrime con mayor frecuencia es la argumentación metodológica, que divide la lógica de una aplicación de bases de datos en tres módulos:

1 La capa de almacenamiento, que se ocupa de la representación física de los datos con los que trabaja la aplicación.

2 Las reglas de negocios, que establecen las operaciones y restricciones aplicables a esos datos.

3 La capa de presentación, que muestra los datos al usuario final y le permite la actualización de los mismos.

Impecable, ¿verdad? Sin embargo, esta división ideal de papeles, en mi humilde opi-nión, no basta para justificar la separación física de los módulos mencionados. Para

Page 24: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

24 La Cara Oculta de C#

empezar, el argumento desprende un sospechoso tufo que me trae a la memoria la fracasada teoría del cliente tonto, tan querida por Sun y Oracle: si la verificación de las restricciones pertenece en exclusiva a la capa intermedia, la funcionalidad de la capa de presentación podría ser muy limitada. En la práctica, sin embargo, dejar todas las verificaciones a cargo de la capa intermedia sería un fracaso. Para empezar, provo-caríamos un tráfico excesivo en la red, porque las operaciones incorrectas que po-drían ser rechazadas en la misma capa de presentación tendrían que enviarse a la capa intermedia, y algo parecido ocurriría con el flujo de datos de la capa de almacena-miento a la capa intermedia. Mi objetivo como informático es modelar sistemas complejos mediante aplicaciones escalables; en ningún caso tengo en mis prioridades intentar echar a Microsoft del mercado mediante razonamientos retorcidos.

Es por esta razón que considero que el principal argumento para dividir una aplica-ción en tres capas tiene mucho que ver con la arquitectura física Esta es la primera premisa:

1 Un servidor SQL típico puede dar servicio a un número relativamente limitado de clientes simultáneos.

En consecuencia, si queremos que un sistema atienda a un número elevado de usua-rios, no podemos permitir que se conecten directamente al servidor SQL, y esto significa que tendremos que utilizar intermediarios entre el servidor de datos y las esta-ciones de trabajo. Una segunda premisa nos confirma que vamos en la dirección correcta:

2 La comunicación entre un servidor SQL y sus clientes es poco eficiente, y re-quiere bastante ancho de banda.

Por lo tanto, comienza a cobrar sentido la propuesta de agrupar al servidor SQL con los intermediarios que hemos propuesto antes en una red local veloz, y dejar que los usuarios finales se conecten a los intermediarios mediante conexiones basadas en medios más lentos y baratos, incluyendo conexiones a través de Internet y todo tipo de redes de área global. Nuestro sistema escalable ideal comienza a parecerse al plano de un fuerte asediado por los apaches:

Page 25: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

De dónde venimos, adónde vamos… 25

Observe que, para que nuestro “invento” funcione, no pueden existir tantas instan-cias en la capa intermedia como usuarios aparezcan. Si mantuviésemos esa condición no habríamos logrado nada. Cada uno de los módulos de la capa intermedia debe estar preparado para ser usado por más de un cliente remoto. Para lograrlo, el cliente remoto no debe almacenar información privada en la capa intermedia que sobreviva de llamada a llamada. Viene entonces una jugada obligada:

3 Los módulos de la capa intermedia deben implementarse como objetos sin estado interno.

Hemos llegado a esta conclusión partiendo de la necesidad de compartir instancias de la capa intermedia entre clientes remotos, aunque la explicación se refuerza con otro argumento: es evidente que un objeto sin estado puede consumir menos recursos que uno equivalente, pero que mantenga su estado interno. He usado cursivas para el verbo poder porque que no siempre sucede así. En la tercera parte de este libro estu-diaremos cómo evitar algunos peligros en este apartado.

Finalmente, cerramos el círculo y regresamos al punto de partida:

4 En un sistema divido en capas como el que estamos describiendo, es fácil y pro-vechoso implementar la gran mayoría de las reglas de negocio en la capa inter-media. Así se simplifica el diseño y mantenimiento del sistema.

¡Sí, es la misma argumentación metodológica que vimos al empezar la sección! Pero ahora se trata de una agradable consecuencia de nuestro modelo, no de la justifica-ción inicial del mismo. ¿Una distinción escolástica? Ni mucho menos: bajo esta nueva perspectiva no tenemos por qué sentirnos culpables si implementamos algunas reglas en el servidor SQL, por medio de procedimientos almacenados y triggers. Ni nos debemos incomodar si duplicamos algunas reglas sencillas en la capa de presenta-ción, para detectar ciertos errores lo antes posible, y ahorrar así tiempo al cliente y carga innecesaria de trabajo a las restantes capas del sistema.

Mitos y leyendas

En el capítulo 11, que encabeza la parte dedicada al estudio de ADO.NET, veremos cómo esta interfaz de programación encaja a la perfección en nuestro modelo ideal de aplicación escalable. Mientras tanto, quiero advertir sobre algunos mitos que cir-culan por el mundo de la programación y que han sido la causa principal de infeli-cidad conyugal para muchas familias. Este es el primero y más importante:

• Mito: La salvación está en los persistence frameworks. ¡Aleluya!

Al tratar sobre el desajuste de impedancias describí uno de estos sistemas, pero no mencioné su nombre. Un persistence framework es una biblioteca de clases que permiten almacenar objetos de determinadas clases en una base de datos, que en nuestro caso sería de tipo relacional. La idea se remota a la época en que Smalltalk hacía de las suyas, y originalmente se trataba de un remedio contra el desajuste de impedancias.

Page 26: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

26 La Cara Oculta de C#

Usted definía en su aplicación una colección de “raíces de persistencia”, que en reali-dad eran variables de referencia a objetos. La regla era sencilla: cualquier objeto al-canzable directa o indirectamente desde una de las raíces de persistencia se convertía automáticamente en un objeto persistente, que se almacenaba en disco cuando se ce-rraba la aplicación. Por una parte, la mayoría de estos prototipos eran sistemas para un solo usuario, y por la otra, no se ponía demasiado énfasis en la forma en que el objeto se almacenaba en el medio persistente. Se suponía que las bases de datos orientadas a objetos estaban a la vuelta de la esquina...

Cuando quedó claro que habría modelo relacional para rato, contrataron otro actor para el reparto: un concepto conocido como object/relational mapping. Ahora, un sub-sistema de la aplicación debía traducir el formato de las clases persistentes en esque-mas relacionales, y viceversa. Se supone que ObjectSpace, una interfaz de programa-ción basada en ADO.NET que aparecerá con la próxima versión de la plataforma, será un sistema de este tipo, que utilizará indicaciones en formato XML como ayuda para establecer la traducción entre clases y relaciones.

¿Qué aportan las plataformas de persistencia a la programación moderna con bases de datos? En mi opinión, muy poca cosa, y los peligros asociados a su mal uso so-brepasan cualquier hipotético beneficio. Analicemos qué sucedería en un sistema que tuviese que trabajar, entre otras entidades, con datos de productos:

• Un producto no debería simularse como un objeto de la clase Productos que se ejecutaría en el contexto del servidor, y al que las aplicaciones accederían remo-tamente. De hacerlo así, el consumo de recursos en el servidor sería excesivo, y los problemas provocados por el acceso concurrente provocarían un cuello de botella para el sistema.

• Como consecuencia, los objetos de la clase Productos se diseñarían para ser transmitidos como valores a través de la red, y para ser ejecutados en el contexto del cliente. La funcionalidad implementada por la clase Productos tendría que ser muy elemental, y la parte del león del código se la llevarían las clases remotas que gestionasen la lectura y escritura de productos... desde fuera de la propia clase interesada. Este problema lo estudiaremos en la tercera parte del libro.

• Ahora la clase Productos se utiliza solamente por su “estructura”, no por la poca funcionalidad que ofrece: básicamente, verificaciones que no exigen el acceso a otras tablas, y quizás, las reglas de inicialización. El precio que pagamos es tener una capa adicional de software que duplica el esquema relacional. Cualquier cambio en el esquema debe propagarse laboriosamente a las clases de entidades.

NO

TA

Un sistema como ObjectSpace, simplificaría el problema de la propagación de cambios estructurales. En este sentido, las facilidades que ofrece la plataforma .NET para la emi-sión dinámica de código pueden significar, a la larga, una diferencia importante.

Por último, los sistemas de clases persistentes son víctimas frecuentes de un síndro-me conocido como no-lo-hice-yo. En más de una ocasión he visto proyectos de este tipo, en los que los autores se han enrollado las mangas para partir desde cero. Reco-

Page 27: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

De dónde venimos, adónde vamos… 27

nozco que un proyecto así puede ser muy gratificante para el ego... pero me gustaría escuchar la opinión de quienes tienen que financiar el experimento.

• Mito: Los controles data bounded o data aware son la fuente de todo mal.

Este mito tiene mucho que ver con el anterior, pero también puede manifestarse de forma autónoma. La técnica llamada data binding permite automatizar el uso de datos provenientes de una base de datos por parte de los controles de edición y presenta-ción; como puede imaginar, se trata de una técnica completamente inocua. Su único inconveniente es que se popularizó gracias a entornos de desarrollo RAD como Visual Basic y Delphi, que ofrecían inicialmente interfaces de acceso a bases de datos muy poco flexibles. Por ejemplo, ADO era capaz de generar las instrucciones de sincronización de la base de datos automáticamente a partir de los cambios reali-zados por el usuario. Pero si el programador quería ocuparse personalmente de gene-rar esas instrucciones, ADO no se lo permitía. El problema era de ADO, por su-puesto, pero el frustrado programador echaba parte de la culpa al data binding.

Además de ser otra manifestación del virus no-lo-hice-yo, también hay algo de maso-quismo y de desprecio al dolor en el odio al data binding... porque la alternativa con-siste en escribir miles de línea de código adicional, que al final funcionan de forma parecida al sistema que pretendían sustituir. A su debido tiempo, veremos que la implementación del data binding en la plataforma .NET es razonablemente buena, potente y flexible como para calificar de insensatez los intentos de ignorarla.

La estructura de este libro

He intentado ser todo lo objetivo posible al poner por escrito las opiniones y expli-caciones que acaba de leer. Pero sé que me equivoco con frecuencia, y me he esfor-zado en cerrar la menor cantidad de puertas posibles. Si no le ha convencido mi ar-gumento contra las plataformas de persistencia, por ejemplo, de todas maneras en-contrará aquí las técnicas necesarias para implementar la suya... y espero que le sirvan también algunos de los consejos para evitar ciertos peligros.

La idea primaria de la serie “La Cara Oculta...” es condensar en un solo libro todo lo necesario para escribir aplicaciones de bases de datos: desde la programación en el servidor SQL hasta las técnicas que permiten dividir un sistema en capas físicas. Partiendo de estas ideas, he dividido el libro en tres partes:

1 Transact SQL La plataforma .NET permite programar con cualquier sistema de base de datos que tenga un controlador ODBC o, preferiblemente, OLE DB; por supuesto, es mejor aún si existe un proveedor .NET nativo. Pero he preferido centrarme en un solo sistema para no complicar la lectura del libro. Naturalmente, el sistema elegido ha sido SQL Server: el motor MSDE viene incluido con Visual Studio, e incluso con el gratuito Web Matrix, existe un proveedor nativo .NET para este

Page 28: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

28 La Cara Oculta de C#

sistema... y no menos importante, la próxima versión del producto se integrará con la plataforma .NET.

Esta primera parte, en consecuencia, presenta el actual modelo de programación dentro de una base de datos de SQL Server 2000. Me he ocupado, en especial, de aquellas técnicas que son específicas para este sistema: extensiones de las sentencias básicas del lenguaje de definición y manipulación de datos, los tipos de datos especiales, el uso de identidades, los cursores en el lado servidor... y el extraño modelo de triggers, que sigue siendo el punto flaco de SQL Server.

2 ADO.NET El plato fuerte es la segunda parte, por supuesto, que se ocupa de ADO.NET y de su integración en Windows Forms por medio de las técnicas de enlace de datos. Primero estudiaremos los conjuntos de datos en memoria, que es el meca-nismo de caché local propuesto por ADO.NET. Pasaremos entonces al estudio del data binding en aplicaciones escritas para Windows Forms, y concluiremos con los capítulos más complicados e interesantes: los que tienen que ver con la recu-peración de registros y su posterior actualización.

Me hubiera gustado completar el cuadro con las técnicas de enlace a datos en aplicaciones escritas en ASP.NET. Pero la programación para Internet, a pesar de todos los avances, sigue siendo bastante complicada, y hubiéramos necesitado el doble de páginas. Si está interesado en la programación con ASP.NET, puede que le interese el próximo libro del autor, Intuitive ASP.NET, que deberá estar disponible dentro de muy poco.

3 Programación remota En esta parte estudiaremos dos técnicas diferentes para el acceso remoto. La primera, .NET Remoting, es la más expresiva y eficiente, pero sólo funciona cuando el servidor y sus clientes están implementados dentro de la plataforma. La segunda técnica, los servicios Web, es más popular. Es más lenta y limita nuestra libertad al elegir tipos de datos, pero es compatible con una amplia lista de sistemas operativos y de lenguajes de programación. En todo caso, necesita-remos algunas extensiones muy útiles que ofrece .NET para crear sistemas real-mente funcionales.

Veremos, además, cómo podemos complementar estas técnicas de acceso re-moto con los servicios corporativos ofrecidos por COM+: transacciones decla-rativas, caché de objetos, activación por demanda, etc. Estos servicios pueden ahorrarnos mucho código, y constituyen una de las ventajas menos reconocida de elegir Windows como sistema operativo servidor. Al final de esta parte he in-cluido un capítulo con técnicas y consejos prácticos para el diseño y programa-ción de servidores de capa intermedia.

Está claro que hubiera sido más atractivo incluir unos cuantos temas adicionales: ya he mencionado las aplicaciones para Internet. Pero el libro resultante habría tardado otros seis meses en terminarse, y más vale pájaro en mano, ¿no es cierto?

Page 29: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

De dónde venimos, adónde vamos… 29

NO

TA

Si es la primera vez que trabaja con C# y la plataforma .NET, le aconsejo que eche un vistazo a algún libro de tipo introductorio. Quiero publicar próximamente un libro de intro-ducción al lenguaje C#, titulado Intuitive C#. El libro estará disponible de forma gratuita y en formato electrónico en www.marteens.com, mi página personal. En esa página tam-bién encontrará ejemplos adicionales, trucos y consejos, e incluso cursos a distancia sobre estos temas.

Para seguir los ejemplos...

En principio, necesitará muy poco para seguir los ejemplos del libro. Puede descargar el SDK de .NET (el kit de desarrollo de software) de forma gratuita desde las pági-nas de Microsoft. Esta descarga incluye todas las clases y ensamblados necesarios, además de los compiladores de C# y Visual Basic.NET. Estos compiladores se deben ejecutar desde la línea de comandos, pero existen varios entornos de desarrollo gra-tuitos que puede aprovechar para acelerar su trabajo. Asegúrese de descargar el SDK que corresponde a la versión 1.1 de la plataforma, o cualquier actualización posterior.

Para la parte de SQL Server, puede utilizar también el MSDE, un motor de datos reducido y basado en SQL Server que se distribuye gratuitamente con algunos pro-ductos de Microsoft. En particular, puede descargarlo junto con Web Matrix, un entorno de desarrollo sencillo y gratuito para crear aplicaciones en ASP.NET, desde la misma página de Microsoft. La principal limitación del MSDE es la ausencia de las herramientas gráficas de administración que sí tiene el producto completo. Pero puede utilizar herramientas gratuitas de terceros, o incluso adquirir la licencia de SQL Server para desarrolladores, que en estos momentos tiene un precio ridículamente bajo.

Lo mismo se aplica al entorno de desarrollo para C#. Por un pequeño precio puede adquirir Visual C# Standard, una versión de Visual Studio.NET que solamente in-cluye soporte para C# y bases de datos locales del MSDE. Esta es la versión más asequible de Visual Studio. Si necesita trabajar con otras bases de datos, o si prefiere ahorrar tiempo de desarrollo, es preferible que adquiera una versión más completa de esta herramienta.

Page 30: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada
Page 31: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Transact SQL

Parte

Transact SQL

Como un borracho, intento atrapar

el reflejo de la Luna

Li Po

Page 32: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada
Page 33: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

2

Servidores y bases de datos

ARA ENTRAR EN MATERIA, VAMOS A estudiar la configuración de un servidor de SQL Server y de las bases de datos que éste puede controlar. Estudiaremos los

cambios necesarios en la configuración de las comunicaciones en red, el ajuste de las propiedades generales de cada servidor y trataremos sobre los modelos de seguridad que soporta nuestro sistema de bases de datos preferido.

La arquitectura de SQL Server

Las dos palabras más utilizadas en este libro son: servidor y bases de datos. Parece trivial, pero su significado exacto varía de acuerdo al sistema de bases de datos. En general, con servidor nos referiremos al software que se encuentra en ejecución en un ordena-dor determinado, mientras utilizaremos base de datos para referirnos a una entidad lógica representada mediante ficheros que es gestionada por el software mencionado.

En SQL Server 7, un mismo servidor puede atender directamente varias bases de datos ubicadas físicamente en el propio ordenador. Por lo tanto, una conexión a una base de datos de SQL Server debe especificar dos parámetros: el nombre del servi-dor y el nombre de la base de datos.

Observe la flecha bidireccional que he colocado entre las dos bases de datos. La he añadido para indicar que, al ser gestionadas por una misma instancia de la aplicación servidora, dos bases de datos de SQL Server pueden intercambiar información di-rectamente entre sí. Se puede, por ejemplo, insertar datos provenientes de una de ellas en la otra sin necesidad de programar ningún intermediario a la medida, y tam-bién podemos relacionar sus datos en una misma consulta.

P

Page 34: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

34 La Cara Oculta de C#

¿Qué sucede con otros sistemas? El modelo de Oracle, por ejemplo, permite que un servidor gestione una sola base de datos. Dentro de la misma máquina pueden existir y funcionar simultáneamente dos bases de datos... pero con el coste añadido de tener dos instancias del software servidor activas a la vez, una por cada base. Una de las consecuencias es que para conectarnos a una base de datos de Oracle basta con que digamos el nombre del servidor.

La comunicación directa entre bases de datos se logra “enlazando” servidores explí-citamente. El sistema es menos directo que el utilizado por SQL Server, pero es igual de sencillo.

El concepto de servidor en InterBase, el sistema de bases de datos de Borland, es similar al de SQL Server: un mismo servidor puede manejar simultáneamente varias bases de datos. Dos bases de datos en un mismo servidor de InterBase, sin embargo, tienen menos posibilidades de comunicación en comparación con SQL Server. Si necesitamos trasvasar datos de una base de datos a otra, necesitaremos algún tipo de almacenamiento externo, o tendremos que escribir un par de líneas de código en algún lenguaje de propósito general.

Sin embargo, el modelo más potente de servidor es el soportado por SQL Server 2000 y 2003:

Page 35: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores y bases de datos 35

En una misma máquina podemos implementar varias instancias simétricas del servi-dor. Una de las ventajas de este modelo es la posibilidad de ejecutar dos versiones diferentes de este producto en un servidor, o incluso dos instancias de la misma ver-siones, pero configuradas para idiomas diferentes.

Dos niveles de identificación

Comenzaremos por explicar el modelo de seguridad de SQL Server. Lo primero que tenemos que comprender es que, desde una perspectiva práctica, se trata de un sis-tema organizado en dos niveles fundamentales.

El primero de estos niveles tiene que ver con el inicio de sesión, es decir, con el esta-blecimiento de una conexión con el servidor. En este nivel, SQL Server nos exige que proporcionemos un nombre de login, traducido como inicio de sesión en la versión en castellano, cada vez que intentemos acceder a un servidor para cualquier propó-sito. Un poco más adelante estudiaremos en qué consisten estos logins, y cómo po-demos integrarlos con las cuentas del propio Windows.

Pero los logins se asocian realmente a permisos para la conexión, no a permisos sobre operaciones en bases de datos concretas. En este segundo nivel, SQL Server nos obliga a definir usuarios (users) que varían para cada una de las bases de datos gestio-nada por un mismo servidor. Existe, por lo tanto, una especie de matriz cuyas di-mensiones son, por una parte, los logins, y por la otra las bases de datos, y en cada celda se almacena un nombre de usuario.

Por ejemplo, puede que en cierto servidor deba conectarme como ian; no hablaré de contraseñas en este momento. Hay cinco bases de datos en este servidor, y el admi-nistrador no me da derechos de conexión para tres de ellas. Siguiendo mi símil de la matriz, esto lo lograría dejando tres celdas en blanco.

En una de las dos bases de datos restantes, al inicio de sesión ian le corresponde el usuario programador. Este usuario tiene determinados derechos sobre ciertas tablas, procedimientos, vistas y demás objetos de la base de datos. Si maite es otro inicio de sesión reconocido por el sistema, ya no puede ser reconocido como programador por esa misma base de datos.

Page 36: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

36 La Cara Oculta de C#

NO

TA

Se deduce entonces que no es buena idea darle nombre a los usuarios de acuerdo al papel que van a jugar en la base de datos. Para este propósito existe un tercera clase de objetos en SQL Server: los perfiles de usuarios (roles). Existen perfiles predefinidos que se relacionan con el sistema o con operaciones generales sobre bases de datos. Tam-bién podemos crear nuestros propios perfiles. Por ahora, nos bastará saber que los per-files de usuarios sirven para agrupar permisos, y que un usuario puede pertenecer a varios de estos perfiles si así lo decidimos. Los perfiles de usuarios no juegan papel al-guno en la identificación y autenticación de usuarios.

Sin embargo, he sido yo quien ha creado la quinta base de datos. El servidor me asocia automáticamente con el usuario especial dbo (database owner, o propietario de la base de datos) de esa base de datos concreta. Así disfruto de todos los permisos imaginables sobre cualquier objeto que se cree en mí base de datos. Incluso los que sean creados por maite, si es que le permito utilizarla.

De todos modos, el administrador de la base de datos, que se identifica con el inicio de sesión especial sa, goza de los mismos privilegios que yo sobre todos los objetos gestionados por su servidor. Lo que sucede en realidad es que el inicio de sesión sa se identifica automáticamente con el usuario dbo en cualquier base de datos. Se trata de una excepción a la regla antes mencionada que exige que cada inicio de sesión dentro de una base de datos dada se asocie a un usuario diferente.

Dos modelos de seguridad

Seguimos teniendo una asignatura pendiente. ¿Cómo se identifica una persona ante SQL Server cuando quiere iniciar una sesión? Existen dos vías:

1 SQL Server gestiona su propia lista de logins, o inicios de sesión. La validación de un inicio de sesión la realiza SQL Server comprobando la contraseña que debe proporcionar el usuario obligatoriamente al intentar una conexión con el servi-dor.

2 SQL Server confía la verificación de identidades a Windows. Supone que el sis-tema operativo ya ha comprobado que el usuario es quien dice ser al arrancar el ordenador. Por lo tanto, SQL Server utiliza para su lista de logins a un conjunto de usuarios del sistema operativo que debemos autorizar previamente.

Al último modelo, se le conoce como modelo de seguridad integrada. El primero es el modelo de seguridad mixto, porque puede coexistir de todos modos con el segundo. So-lamente en las versiones anteriores a la 7 existía la posibilidad de trabajar en exclusiva con logins de SQL Server.

NO

TA

Si quiere utilizar el modelo de seguridad integrada no es obligatorio, pero sí muy reco-mendable, que su red soporte el modelo de dominios o tenga acceso a un directorio activo. Aunque es posible tener seguridad integrada sobre una red basada en grupos de trabajo, las interacciones entre las cuentas de usuario del sistema operativo y los inicios de sesión que reconocerá SQL Server se complican bastante. Además, una red con dominios bien implementados es generalmente más eficiente que cualquier otra arqui-tectura de red.

Page 37: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores y bases de datos 37

El modelo de seguridad de un servidor puede modificarse, como hemos adelantado, en el diálogo de las propiedades del servidor, en la página Security. Cuando elegimos el modo de seguridad integrada, los nombres de los inicios de sesión corresponden a los nombres de cuentas del sistema operativo más el nombre del dominio o equipo en el cual se ha definido cada una. El administrador de sistema (sa) debe indicar explíci-tamente a SQL Server cuáles usuarios de Windows pueden iniciar sesiones en ese servidor.

Como esta tarea puede ser tediosa, sobre todo si existen muchas cuentas en un do-minio dado, SQL Server también permite autorizar el acceso a grupos completos de usuarios. Aquí “grupo” se refiere a los grupos de usuarios de Windows. Para no au-torizar el acceso a usuarios puntuales de un grupo autorizado, existe la posibilidad adicional de denegar explícitamente el acceso a cuentas individuales, o incluso a gru-pos completos.

Bases de datos y ficheros

Una base de datos de SQL Server está constituida por uno o más ficheros. Como mínimo, se necesita un fichero de datos primario y, aunque el mismo fichero prima-rio puede albergarlo, es conveniente dedicar un fichero separado para el denominado registro de transacciones, o log file. Pero también es posible utilizar más ficheros para da-tos o para el registro de transacciones; podemos incluso añadirlos a la base de datos después de que ésta ha sido creada.

Cada fichero de SQL Server tiene las siguientes propiedades:

• name: contiene un nombre lógico para el fichero.

• filename: el nombre del fichero físico, el único parámetro que no es opcional.

• size: el tamaño inicial del fichero. Más adelante veremos las unidades empleadas para indicar el tamaño de un fichero.

• maxsize: como cada fichero puede crecer según sea necesario, podemos indicar un tamaño máximo. Además de especificar una cifra concreta, podemos utilizar la opción unlimited.

Page 38: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

38 La Cara Oculta de C#

• filegrowth: el tamaño o porcentaje en el cual crece el fichero cada vez que se expande automáticamente.

Los ficheros de datos de SQL Server se dividen siempre en páginas físicas de 8KB de longitud. El factor de crecimiento, filegrowth, siempre se redondea al próximo múl-tiplo de 64KB, que corresponde al espacio ocupado por 8 páginas. En jerga técnica, a estos grupos de 8 páginas se les denomina extensiones.

Para crear la base de datos podemos utilizar alguna de las herramientas gráficas de SQL Server, o crear instrucciones en modo texto para que sean ejecutadas por el servidor. La siguiente imagen muestra el diálogo de creación de bases de datos del Administrador Corporativo:

Personalmente, encuentro más interesante escribir a mano las instrucciones de crea-ción. ¿Motivo?, pues que posiblemente tendrá que ejecutarlas varias veces durante el desarrollo y la distribución de su aplicación. Además, así queda documentada inme-diatamente la estructura de la base de datos.

Para crear una base de datos sencilla ejecutaríamos una sentencia parecida a ésta:

create database Contabilidad

on primary (

name = Datos,

filename ='c:\conta\contadat.mdf',

size = 10MB, maxsize = 1000MB,

filegrowth = 20%)

log on (

name = LogFile,

filename ='d:\conta\contalog.ldf')

Page 39: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores y bases de datos 39

Para especificar el tamaño inicial y el máximo de un fichero podemos utilizar los sufijos KB y MB para indicar kilobytes y megabytes. Para el factor de crecimiento de un fichero se puede especificar una cantidad absoluta, con KB y MB, pero también es correcto utilizar el sufijo % para un porcentaje. En cualquiera de los casos, si se omite la unidad de medida, se asume MB.

En el listado anterior, por ejemplo, pedimos 10 megas para el tamaño inicial del fi-chero de datos, en vez de 1MB, el valor por omisión. De este modo, evitamos rees-tructuraciones costosas durante la carga inicial de datos.

NO

TA

El hecho de que los ficheros de una base de datos crezcan automáticamente no sirve de excusa para un error frecuente durante las cargas masivas de datos. Si vamos a alimen-tar una base de datos con un número elevado de registros, la técnica que nos ahorrará más tiempo consiste simplemente en modificar el tamaño de los ficheros existentes para evitar el crecimiento dinámico.

Particiones y el registro de transacciones

La posibilidad de utilizar distintos ficheros, situados casi siempre en diferentes discos físicos, es una de las características que distinguen a un sistema de bases de datos profesional de uno para aficionados. El objetivo de esta técnica, conocida como parti-cionamiento, es aprovechar el paralelismo que permite la existencia de controladores físicos diferentes para cada unidad o grupo de unidades de disco.

Como veremos, son muchas las puertas que nos abre el particionamiento, por lo cual debemos escoger con mucho cuidado si tenemos la posibilidad. Supongamos que tenemos dos discos duros en nuestro servidor, una situación bastante común. ¿Cómo repartimos los ficheros? Este es un caso de manual: el fichero de datos debe ir en uno de los discos, y el fichero del registro de transacciones (log file) en el otro.

El registro de transacciones es una de las estructuras de datos característica de los sistemas relacionales inspirados en el jurásico System R de IBM. Es como un auditor con mala leche: cada vez que se modifica cualquier registro de la base de datos, nuestro ceñudo vigilante realiza un apunte en el registro de transacciones. De esta manera, SQL Server puede devolver la base de datos a su estado original si se aborta una transacción o se cae el sistema. También se puede utilizar este fichero para reali-zar copias de seguridad diferenciales, llevando a la cinta solamente los cambios reali-zados desde la última copia.

Si los datos y el registro de transacciones están en discos diferentes, la carga de las lecturas y escrituras durante las modificaciones se distribuye equitativamente entre ambos discos. Y si se estropea el disco de datos, podemos restaurar la base de datos a partir de la última copia de seguridad, repitiendo las actualizaciones apuntadas en el registro de transacciones.

Page 40: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

40 La Cara Oculta de C#

Grupos de ficheros

Pero las técnicas de particionamiento no terminan aquí. SQL Server permite agrupar los ficheros de datos en grupos. A estos grupos se le asignan nombres lógicos, que pueden utilizarse en las instrucciones de creación de tablas e índices para indicar en qué espacio físico deben residir sus datos.

Cuando utilizamos la sintaxis simplificada de creación de bases de datos, como en nuestro primer ejemplo, que no contiene aparentemente indicación alguna sobre grupos de ficheros, en realidad estamos utilizando el grupo por omisión, denomi-nado primary. El siguiente listado nos enseña cómo crear una base de datos con un grupo adicional al primario, y cómo situar una tabla en dicho grupo.

create database CONTABILIDAD

on primary

( name = Datos_1,

filename = 'c:\mssql\data\conta1.mdf')

filegroup DatosEstaticos

( name = Datos_2,

filename = 'd:\mssql\data\conta2.ndf')

log on

( name = LogConta,

filename = 'd:\mssql\data\contalog.ldf')

go

use CONTABILIDAD

go

create table Provincias (

Codigo tinyint not null primary key,

Nombre varchar(15) not null unique,

check (Codigo between 0 and 100),

check (Nombre <> '')

)

on DatosEstaticos -- ¡¡¡ESTA PARTE ES LA QUE NOS INTERESA!!!

go

¿Por qué he creado un grupo para los datos estáticos? He seguido asumiendo que tenemos solamente dos discos duros. Al situar el registro de transacciones en el se-gundo disco, hemos mejorado el tiempo de las operaciones de escritura, pero no hemos avanzado nada en lo que concierne a las consultas, que seguramente serán más frecuentes.

Por todo lo anterior, he creado un grupo DatosEstaticos para que contenga aquellas tablas de nuestra aplicación poco propensas a cambios: la tabla de códigos postales y provincias, las de conversión de moneda; incluso los datos de proveedores o bancos con los que trabajamos, si forman un conjunto estable. Cuando evaluemos un en-cuentro (join) entre alguna de las sedentarias tablas de DatosEstaticos y las ajetreadas tablas del grupo primario, volveremos a dividir las operaciones de entrada y salida en partes iguales, entre los dos discos del servidor.

Page 41: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores y bases de datos 41

Varios ficheros en el mismo grupo

Ya sabemos cómo colocar tablas en distintos discos físicos utilizando varios grupos de ficheros. Pero cada grupo puede contener más de un fichero, como muestra la siguiente instrucción:

create database CONTABILIDAD

on primary

( name = Datos_1,

filename = 'c:\mssql\data\conta1.mdf')

filegroup DatosEstaticos

( name = Datos_2_1,

filename = 'd:\mssql\data\conta21.ndf')

( name = Datos_2_2,

filename = 'd:\mssql\data\conta22.ndf')

log on

( name = LogConta,

filename = 'd:\mssql\data\contalog.ldf')

Se nos puede ocurrir con toda lógica: ¿para qué necesitamos varios ficheros dentro de un mismo grupo? La respuesta está en el algoritmo que utilizan los ficheros de un mismo grupo para crecer:

• Si es necesario expandir un grupo de ficheros, posiblemente porque una tabla o índice ha crecido, el espacio necesario se distribuye proporcionalmente entre los ficheros del grupo.

Esta es una forma algo primitiva de particionamiento vertical de una tabla. Los regis-tros se repartirán sistemáticamente entre los diferentes ficheros del grupo al que per-tenece la tabla, siguiendo un criterio pseudo aleatorio. Puestos a pedir, sería preferible dividir los registros de acuerdo a una condición lógica (clientes de distintas provin-cias, por ejemplo), pero “algo” es siempre mejor que “nada”.

¿Tiene sentido tomarse el trabajo de definir grupos de ficheros, en cualquiera de sus variantes, si solamente tenemos uno o dos discos duros? En mi opinión, sí. Cuando la base de datos es para uso de un solo cliente, quizás nosotros mismos, debemos ser optimistas y pensar que los Reyes Magos pueden regalarnos un segundo disco. Si vamos a instalar la aplicación en varios lugares, es conveniente diseñar un sistema flexible de particiones. Puede que en algunas instalaciones debamos conformarnos con un simple disco en el servidor, pero puede que en otras tengamos todos los me-dios de hardware que se nos ocurra pedir.

Las bases de datos del catálogo

Todo SQL Server recién instalado comienza su existencia con cuatro bases de datos ya registradas:

• master: Es la raíz de toda la información almacenada en SQL Server. Entre otras cosas, contiene información sobre las bases de datos registradas en un ser-

Page 42: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

42 La Cara Oculta de C#

vidor. No basta copiar los ficheros de una base de datos a otro servidor para que éste pueda trabajar con ellos: si no hay una referencia a estos ficheros en la base de datos master, es como si no existieran.

• model: La base de datos model se utiliza como plantilla para crear nuevas bases de datos. Si añadimos una tabla a model, todas las bases de datos que se creen a partir de ese momento tendrán también esa tabla. Lo mismo sucede con los pro-cedimientos almacenados, los tipos de datos definidos por el usuario, y los pro-pios usuarios.

• tempdb: Contiene todas las estructuras de datos temporales que SQL Server necesita, además de las tablas y procedimientos almacenados temporales que un usuario puede crear.

• msdb: Esta base de datos es utilizada principalmente por MS SQL Agent.

Las versiones completas instalan, además, dos bases de datos de ejemplo: pubs y Northwind. La instalación del MSDE, sin embargo, no incluye estos ejemplos, pero se pueden crear mediante ficheros de scripts incluidos en la instalación. Como utilizare-mos estas dos bases de datos en muchos de los ejemplos de este libro, explicaré la forma de hacerlo. En primer lugar, debe localizar los siguientes ficheros en el CD de instalación:

instnwnd.sql

instpubs.sql

Estos ficheros contienen las instrucciones SQL necesarias para crear las bases de datos mencionadas, y para insertar los datos de ejemplo. Si estuviese trabajando con la versión completa de SQL Server, podría ejecutar ambos ficheros con el Analizador de consultas... que no se instala con el MSDE. Tendrá que activar una ventana de comandos del sistema operativo, y ejecutar la aplicación osql.exe:

osql -E -iInstNwnd.sql -oc:\InstNwnd.txt

La opción –E indica que osql.exe se conectará al servidor utilizando la seguridad inte-grada; no he mencionado el nombre del servidor asumiendo que se trata de una ins-tancia local. La opción –i especifica el nombre del fichero con las instrucciones SQL, para el que tendrá que indicar la ruta correcta. Y la última opción sirve para enviar los mensajes generados durante la ejecución a un fichero de salida.

Crear... o reemplazar

Durante las fases iniciales de cualquier proyecto, las bases de datos asociadas sufren constantes cambios. Lo mejor, cuando todavía no hay demasiados datos, es eliminar antes la base de datos obsoleta para crear entonces el nuevo esquema relacional. Borrar una base de datos es muy simple, porque la siguiente instrucción se encarga de eliminar tanto el registro dentro del servidor como los ficheros físicos:

drop database nombreBaseDatos

Page 43: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores y bases de datos 43

Pero no podemos limitarnos a incluir una instrucción para eliminar la base de datos, sin comprobar primero su existencia. El siguiente script muestra cómo hacerlo:

use MASTER

go

if exists (select * from sysdatabases where name = 'ACCOUNT')

drop database ACCOUNT

go

/* DATABASE CREATION */

create database ACCOUNT

on primary

( name = PrimaryData,

filename = 'C:\SqlData\AccountData.mdf',

size = 2 ),

filegroup transdata

( name = OrderData,

filename = 'C:\SqlData\AccountOrders.ndf' )

log on

( name = LogFile,

filename = 'C:\SqlData\AccountLog.ldf' )

go

use ACCOUNT

go

Sabiendo que vamos a dinamitar el edificio, nos movemos a un sitio seguro, acti-vando la base de datos master, que todo servidor posee. Para comprobar si existe una base de datos llamada account buscamos este nombre dentro de la tabla sysdatabases... de la base de datos master, evidentemente. Y sólo al recibir una respuesta afirmativa, apretamos el gatillo.

El resto es bastante convencional. Observe, no obstante, la inclusión de líneas con el identificador go para dividir los grupos de instrucciones. Explicaré el significado de go en el siguiente capítulo.

Page 44: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

3

Fundamentos de Transact SQL

RANSACT SQL ES EL NOMBRE OFICIAL DEL lenguaje que sirve para crear y ma-nipular bases de datos y sus objetos en SQL Server y, como su nombre indica, es

un dialecto de SQL estándar. En este capítulo veremos las características generales de este lenguaje, y presentaremos las construcciones más elementales: las que tienen que ver con los tipos de datos básicos y expresiones.

Guiones SQL

Hasta el momento hemos supuesto que las instrucciones de creación de bases de datos, tablas e índices se tecleaban y ejecutaban interactivamente en el Analizador de Consultas. Pero cuando la complejidad aumenta, es preferible echar mano de guiones (scripts). En SQL Server, un guión es una secuencia de instrucciones almacenadas en un fichero de texto del sistema operativo. El destino de estos guiones es que sus ins-trucciones sean ejecutadas en forma secuencial por el servidor, ya sea por medio del Analizador de Consultas, o enviando las instrucciones con la ayuda de alguna de las interfaces de programación.

Hay una importante diferencia en la forma en que se envían sentencias a SQL Server respecto a lo que sucede en otros sistemas de bases de datos: mientras que lo común es ejecutar instrucciones de una en una, SQL Server puede procesar varias instruc-ciones consecutivas de una misma vez. Dicho de otra forma: si queremos realizar un borrado y una actualización desde una aplicación, no necesitamos ejecutar dos con-sultas mediante dos llamadas. Nos bastará con escribir las instrucciones delete y update una a continuación de la otra, y enviarlas al servidor con una sola operación.

delete from Tabla

where Clave = 1234

update Tabla

set Valor = 5678

where Clave = 9876

¿Quiere decir esto que no necesitamos ningún tipo de separador dentro de un guión con instrucciones para SQL Server? De ningún modo. Hay instrucciones que SQL Server no puede procesar a la misma vez. El caso más importante son las sentencias de creación de objetos. No podemos pretender crear una tabla e inmediatamente realizar un select o un insert sobre la misma. El fallo está en que, si enviamos las

T

Page 45: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Fundamentos de Transact SQL 45

dos instrucciones a la vez, SQL Server deberá compilar ambas antes de su ejecución, y fracasará al intentar encontrarle sentido a un insert sobre una tabla que aún no existe.

En particular, SQL Server exige que las instrucciones de creación de procedimientos almacenados siempre vayan a la cabeza de un grupo de instrucciones. Esto implica, que no se pueden crear dos procedimientos almacenados a la vez. Sin embargo, sí podemos crear varias tablas en un mismo envío, o tablas e índices, siempre que res-petemos el principio de no referirnos a objetos por crear.

Sintaxis básica de Transact SQL

Desde el punto de vista de su estructura lexical y sintáctica, Transact SQL se parece al proverbial perro verde. He aquí algunas reglas generales:

1 Los identificadores pueden comenzar con una letra, un carácter de subrayado, o con los caracteres @ ó #. Los restantes caracteres del identificador pueden ser le-tras, dígitos, los caracteres especiales ya mencionados o un signo de dólar. Preci-samente, daría un dólar si alguien me puede justificar que se permita un dólar en medio de un identificador y no al principio, ¿qué más da?

2 Los identificadores que comienzan con @ se utilizan para nombrar variables locales y parámetros de procedimientos.

3 En cambio, los identificadores que comienzan con # y ## tienen un significado especial. En su debido momento veremos que se usan para definir tablas y pro-cedimientos almacenados temporales.

Page 46: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

46 La Cara Oculta de C#

4 Da lo mismo utilizar mayúsculas o minúsculas y, como sucede con los lenguajes decentes, los espacios en blanco y los cambios de línea sólo tienen valor como separadores de los restantes elementos sintácticos.

5 ¡No hay puntos y comas!

¿Y qué pasa si usted necesita que una tabla se llame From? En tal caso, lo primero es suplicarle que se lo piense dos veces, porque no se me ocurre justificación alguna para esa “necesidad” (iba a escribir necedad). Si no he logrado convencerle, puede utilizar identificadores delimitados:

create table [From] (

[Where] integer not null,

[Select] varchar(30) not null,

primary key ([Select])

)

go

select [Select]

from [From]

where [Where] < 0

go

Los caracteres encerrados entre corchetes se tratan como identificadores; sin los cor-chetes, serían tratados como palabras claves. En realidad, los caracteres encerrados entre corchetes pueden ser de cualquier tipo, como en este otro ejemplo de identifi-cador delimitado, que contiene espacios en blancos:

[Esto es un identificador]

De hecho, cuando pedimos a SQL que genere las instrucciones necesarias para vol-ver a crear un objeto ya existente, en el resultado se utilizan identificadores delimita-dos a diestra y siniestra:

CREATE TABLE [dbo].[CLIENTES] (

[IDCliente] [T_IDENTIFIER] IDENTITY (1, 1) NOT NULL,

[Nombre] [T_NOMBRE] NOT NULL,

[Apellidos] [T_APELLIDO] NOT NULL,

[DNI] [T_DNI] NOT NULL

)

GO

El generador del guión, para no romperse la cabeza averiguando cuáles nombres de entidades son identificadores correctos o no, utiliza identificadores delimitados me-cánicamente. También existen identificadores delimitados en SQL estándar, pero deben ir encerrados entre dobles comillas:

create table "Una tabla muy extraña" ( … )

SQL Server también soporta esa variedad de identificadores. Aquí, sin embargo, debe moverse con cuidado, porque existe una opción de configuración para desactivar los identificadores delimitados por comillas dobles. Además, en busca de la compatibili-dad con su oscuro pasado, SQL Server puede aceptar constantes de cadenas encerra-das por dobles comillas, en determinados contextos.

Page 47: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Fundamentos de Transact SQL 47

Antes dije que Transact SQL no utiliza puntos y comas. Es más exacto decir que Transact SQL no necesita puntos y comas para separar instrucciones o terminarlas. Por este motivo, Transact SQL se ve obligado a que todas sus instrucciones tengan que comenzar con una palabra reservada diferente. Incluso la sencilla operación de asignación debe cumplir con dicha regla. La siguiente instrucción no es correcta sintácticamente:

@i = @i + 1 /* NO */

La forma adecuada de escribirla es:

set @i = @i + 1 /* SI */

Algo parecido sucede con las llamadas a procedimientos, que deben ir precedidas por la palabra reservada execute:

execute UnProcedimiento /* SI */

Sin embargo, si la instrucción execute es la primera de su grupo, podemos omitir la palabra clave:

UnProcedimiento

execute OtroProcedimiento

go

MasProcedimientos

go

La primera instrucción del lote anterior puede prescindir de execute, pero no la segunda. Al utilizar go, la llamada a MasProcedimientos vuelve a ser la primera de su propio lote, y podemos también eliminar execute. ¿Me permite un consejo? Escriba siempre execute, y evitará problemas al copiar y pegar instrucciones de un sitio a otro.

Por último, tenemos dos tipos de comentarios:

-- Este es un comentario tonto, hasta el final de la línea

/* Este es otro comentario tonto que puede

ocupar cuantas líneas nos venga en ganas */

Tipos de datos básicos

Veamos ahora los tipos de datos básicos con los que puede tratar SQL Server. Estos tipos se utilizan principalmente para las definiciones de columnas, al crear tablas, pero también sirven para declarar parámetros y variables globales en procedimientos, almacenados, triggers, funciones, etc. Los tipos básicos son:

1 Tipos numéricos enteros Incluye los tipos integer, smallint, tinyint y bit. Ocupan respectivamente 4 bytes, 2 bytes, 1 byte y 1 bit. En particular, el tipo bit se emplea para representar valores

Page 48: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

48 La Cara Oculta de C#

lógicos de forma muy eficiente. SQL Server 2000 ofrece, además, el tipo bigint, que es un entero de 64 bytes.

2 Tipos numéricos reales Están los tipos flotantes, float y real; float es equivalente al double de C# (8 bytes), mientras que real es equivalente al float de C# (4 bytes). Y están los tipos “exac-tos”: numeric y decimal, que son completamente equivalentes entre sí, y que per-miten especificar la escala y la precisión.

3 Moneda El tipo money ocupa 8 bytes, y permite representar valores monetarios con cuatro dígitos decimales de precisión. El tipo smallmoney es una versión reducida, que sólo ocupa 4 bytes, manteniendo los cuatro dígitos decimales de precisión.

4 Cadenas alfanuméricas Los tipos char y varchar almacenan cadenas de longitud fija y variable. Si necesi-tamos cadenas Unicode, debemos recurrir a nchar, nvarchar y ntext; la n vale por national. En total, una columna de estos tipos puede ocupar hasta 8000 bytes (4000 solamente si es Unicode).

5 Fecha y hora No existen tipos por separado para fechas y horas en SQL Server 2000, aunque la versión 2003 sí los incluirá. Mientras tanto, el tipo datetime es el de mayor capa-cidad, y ocupa 8 bytes. Su rango de representación va desde el 1 de enero de 1753 hasta el 31 de diciembre de 9999. El tipo smalldatetime ocupa solamente 4 bytes, pero abarca un intervalo menor: desde el 1 de enero de 1900 hasta el 6 de junio de 2079. La precisión de datetime permite representar milisegundos, mien-tras que con smalldatetime sólo podemos representar segundos.

6 Tipos binarios y de longitud variable Los tipos binary y varbinary almacenan valores sin interpretar de hasta 255 bytes de longitud. Para representaciones de mayor tamaño debemos recurrir a text e image.

7 Rarezas y miscelánea El tipo uniqueidentifier permite almacenar identificadores únicos (GUID), con el formato de los identificadores COM. Si se desea obtener un número único a ni-vel de toda la base de datos, se puede utilizar timestamp, muy útil en determinadas implementaciones del bloqueo optimista. El tipo cursor permite almacenar una referencia a un cursor, pero no puede utilizarse en declaraciones de columnas. SQL Server 2000 introduce sql_variant, que permite almacenar cualquier otro valor escalar, y el tipo especial table, que al igual que cursor, sólo puede usarse en variables de memoria, nunca en columnas de una tabla.

Valores nulos

Los tipos de datos de un sistema de bases de datos SQL tienen una característica que los hace muy diferentes a los tipos aparentemente equivalentes en los lenguajes de programación tradicionales: la mayoría de ellos incluyen el valor especial null dentro

Page 49: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Fundamentos de Transact SQL 49

de su dominio de valores posibles. Tomemos como ejemplo el tipo tinyint: como ocupa un byte, podemos pensar que admite 256 valores diferentes. Pero no es así, porque admite 257 valores diferentes: los 256 “tradicionales” más el valor especial nulo. La excepción a esta regla es el tipo numérico bit, que no permite valores nulos para tener una representación física eficiente.

El valor nulo no es un capricho: representa el valor “desconocido”. Suponga que debemos almacenar la edad de una persona en una tabla. La edad es un valor no negativo; puede ser un entero igual a 1, 20, 40... o no conocerse. Se puede resolver el problema utilizando algún valor especial para indicar el valor desconocido, digamos que -1. Claro, el valor especial escogido no debe formar parte del dominio posible de valores, y el número elegido cumple con la condición. Pero, ¿qué pasaría si inten-tásemos operaciones con valores desconocidos? Por ejemplo, queremos averiguar la edad media de las personas representadas en la tabla: tenemos un individuo de 20 años, uno de 40 años... pero no conocemos la edad del tercero, y almacenamos un -1, de acuerdo a nuestro convenio. Si sumamos mecánicamente, obtendremos que la edad media está cerca de los 20 años, lo que es evidentemente falso.

Pongamos ahora que representamos una edad desconocida con el valor especial null. ¿Cambian algo las cosas? Sí, porque SQL impone una regla muy importante para las operaciones que involucran nulos:

• Cualquier expresión en la que intervenga un nulo en cualquiera de sus operan-dos, se evalúa a nulo.

Es decir, nulo más cualquier otro valor es igual a nulo. Nulo multiplicado por cual-quier cosa vale nulo. En el caso de la edad media, la suma de 20 más 40 más nulo es igual a nulo; luego dividimos nulo entre tres y obtenemos nulo. Respuesta: la edad media es desconocida, ¡y es cierto! Muy distinto sería pedir la media de edad de las personas cuya edad conocemos: la media calculada sería de 30 años.

Sin embargo, toda regla tiene sus excepciones, y nuestra receta de cálculo no se aplica cuando hay operaciones lógicas en juego. Las siguientes tablas nos muestran el com-portamiento de las operaciones and y or en SQL:

AND false null true OR false null true

false false false false false false null true null false null null null null null true true false null true true true true true

Estas reglas no son arbitrarias, sino que cumplen con los requisitos de la llamada lógica ternaria de Lukasiewicz, en honor del matemático polaco que la descubrió3. Las operaciones lógicas así implementadas siguen el sentido común: a usted le piden, por ejemplo, que pulse un botón si se enciende una bombilla que tiene ante sus ojos, o si se enciende otra bombilla escondida tras una pared. La bombilla que tiene delante se

3 Fue también el creador de la notación polaca, muy útil en la teoría de lenguajes formales.

Page 50: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

50 La Cara Oculta de C#

ilumina, pero no puede ver el estado de la otra. Es decir, tiene que aplicar la opera-ción or entre un valor true y un valor desconocido, es decir, null. Y está claro que en esta situación debe pulsar el botón. Por lo tanto:

true or null es igual a true

Puede comprobar que todas las operaciones de las dos tablas anteriores se justifican mediante ejemplos similares; ahorrará algo de tiempo en la verificación recordando que los operadores and y or son conmutativos.

Hay una consecuencia algo incómoda de las reglas de operación con nulos. Quere-mos comprobar si determinada variable, o columna, contiene un valor nulo, e ino-centemente intentamos averiguarlo con una simple comparación:

si x = null, entonces …

Pero una comparación no constituye una excepción a la primera regla: cualquier valor comparado con nulo, genera un valor nulo, desconocido. No conocemos la edad de Pepe, y alguien nos pregunta si Pepe tiene más de treinta años. Respuesta: no lo sé. Por este motivo, SQL nos ofrece una operación especial para saber cuándo una va-riable o columna contiene un nulo:

si x is null, entonces …

La negación tiene una sintaxis curiosa:

si x is not null, entonces …

Es extraño ver tres palabras reservadas consecutivas en un lenguaje de programación moderno... pero hay que tener en cuenta que SQL tuvo su origen en los setenta, en la lejana era en que los gigantescos ordenadores de IBM asolaban la faz de la Tierra.

Funciones especiales para valores nulos

Todo veneno tiene su antídoto, y los valores nulos no son la excepción. Transact SQL ofrece tres funciones que permiten convertir nulos en valores no nulos, y vice-versa. La primer de ellas es nullif, y necesita dos argumentos:

declare @x int

set @x = 1

print nullif(@x, 1)

print nullif(@x, 2)

Para evaluar la función, se comparan los dos argumentos y, si son iguales, se devuelve un nulo. Si son distintos, el resultado es el primer argumento. A primera vista parece un comportamiento extraño, pero tiene su justificación. Suponga que tiene en sus manos una tabla de clientes creada por un programador que detesta los nulos. Hay una columna para la fecha de nacimiento del cliente, y para no usar nulos, el progra-mador ha decidido sustituirlos con el 31 de diciembre de 1800, o alguna fecha igual de absurda. Usted, como es natural, sabe que esta decisión provocará anomalías

Page 51: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Fundamentos de Transact SQL 51

cuando tenga que utilizar aritmética de fechas o calcular estadísticas sobre esa co-lumna, y quiere que todas las columnas que tengan el valor especial conviertan ese valor en nulo. En tal caso, puede usar la función nullif:

nullif(fecha, cast('18001231' as datetime))

Todas las fechas normales mantendrán su valor; cuando la fecha sea el 31 de diciem-bre de 1800, nullif devolverá un nulo. De paso, la expresión anterior muestra cómo se puede escribir una “constante” de fechas de manera que no se vea afectada por la configuración de idioma del servidor.

La función isnull, por el contrario, transforma valores nulos en no nulos:

isnull(Comisiones, 0) + isnull(Intereses, 0)

Si el primer parámetro de la función no es nulo, isnull devuelve ese mismo valor; en caso contrario, se devuelve el valor del segundo argumento. Si alguien ha creado una tabla con valores nulos para las columnas de Comisiones e Intereses, puede que nos interese convertir los nulos en ceros para evitar problemas con las sumas y otras operaciones similares.

Por último, la función coalesce es una extensión de isnull que acepta un número arbitrario de parámetros. La función devuelve el primero de ellos que no represente un valor nulo.

Tipos de datos definidos por el usuario

Si antes he hablado de tipos “básicos” es porque SQL Server permite la definición de tipos por parte del programador. Es un recurso muy útil, pero no es tan potente como debería ser, y la forma en que se usa tampoco es elegante. Para definir un tipo de datos mediante código, debemos ejecutar instrucciones como ésta:

execute sp_addtype T_PORCENTAJE, 'numeric(5,2)', 'not null'

En vez de proporcionar una instrucción especial para la creación de tipos, SQL Ser-ver nos obliga a utilizar un procedimiento almacenado predefinido. En este ejemplo, T_PORCENTAJE es el nombre del tipo que vamos a definir. No hay ningún conve-nio para los nombres de tipos, pero me he acostumbrado a distinguir los nombres de tipos mediante una letra T y un carácter de subrayado.

A continuación, se indica el tipo que se utilizará como base. No podemos utilizar otro tipo definido por nosotros anteriormente y, como ve, hay que encerrar el nom-bre del tipo básico entre comillas simples, especialmente cuando se usan tipos como numeric y varchar, que van acompañados por una precisión o escala. En nuestro ejemplo, T_PORCENTAJE se define como un valor numérico con dos decimales de precisión, y cinco dígitos en total, incluyendo los decimales.

Una vez definido el nuevo tipo, podemos utilizarlo para crear columnas en tablas, o como tipo para los parámetros de procedimientos almacenados. Esto no significa, sin

Page 52: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

52 La Cara Oculta de C#

embargo, que podamos modificar posteriormente las características del tipo para que el cambio se propague a todas las entidades que lo usan: los tipos de datos, una vez creados, no se pueden modificar. De todos modos, este recurso nos ayuda a ser con-sistentes durante el diseño de la base de datos, y a no crear por error columnas de porcentajes con distinto número de decimales.

NO

TA

Si crea un tipo de datos en la base de datos master, del catálogo de SQL Server, el tipo creado estará disponible automáticamente en todas las bases de datos del servidor. Si lo crea en model, por el contrario, podrá utilizarse en todas las bases de datos que se creen a partir de ese instante.

El último parámetro de sp_addtype indica si las columnas o variables declaradas con el nuevo tipo admitirán o no valores nulos... al menos, mientras no incluyamos una indicación explícita. Por ejemplo, podemos declarar una columna de este modo:

Porcentaje T_PORCENTAJE

No hay una indicación explícita sobre valores nulos, por lo que se utiliza la propia definición del tipo: no se admitirán nulos. Sin embargo, más adelante veremos que al definir una columna podemos incluir una cláusula null o not null:

Porcentaje T_PORCENTAJE null

Ahora, aunque el tipo no permite nulos, la columna sí los admitirá, gracias a la indi-cación explícita.

Por último, puede borrar un tipo de datos definido por el usuario mediante el proce-dimiento almacenado sp_droptype, siempre que no esté siendo utilizado por alguna tabla o procedimiento.

Reglas y valores por omisión

Hay otro par de construcciones, bastante engorrosas, asociadas a los tipos definidos por el usuario: las reglas y valores por omisión. Para crear una regla tenemos una instrucción especial:

create rule R_PORCENT_POSITIVO as

@x >= 0

El elemento principal de una regla es una expresión lógica, que puede incluir cons-tantes, operadores... y una variable, cuyo nombre debe comenzar con una arroba. Da lo mismo el nombre que usemos, siempre que lo utilicemos consistentemente dentro de la propia regla.

Para que una regla tenga alguna utilidad hay que “enlazarla” a un tipo de datos o a una columna. Volvemos a la sintaxis chapucera, porque el enlace se realiza mediante un procedimiento almacenado:

execute sp_bindrule 'r_porcent_positivo', 't_porcentaje'

Page 53: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Fundamentos de Transact SQL 53

También podemos enlazar reglas dentro del Administrador Corporativo. Esto es útil, sobre todo, cuando tenemos que enlazar la regla a columnas, como en la siguiente imagen:

Algo parecidas son las reglas para valores por omisión:

create default d_porcent_cero as 0

Podemos también enlazar estos valores por omisión a tipos de datos o a columnas:

execute sp_bindefault d_porcent_cero, 't_porcentaje'

-- Observe que es sp_bindefault, no sp_binddefault

No obstante, debo pedirle que modere su entusiasmo, porque estas reglas y valores por omisión tienen un uso muy limitado en SQL Server. Si la estrambótica sintaxis no fuese suficientemente disuasoria, debe saber que para cada tipo de datos o co-lumna sólo podemos asociar una regla, aunque una regla sí que puede asociarse a varios tipos y columnas. La propia documentación de SQL Server aconseja definir las restricciones y valores iniciales de las columnas en las propias definiciones de tablas, entre otros motivos, porque así quedan mejor documentadas.

Page 54: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

4

Tablas

A SABEMOS CÓMO CREAR UNA base de datos vacía, y también conocemos los tipos de datos nativos de SQL Server. Por lo tanto, podemos comenzar a crear

tablas e índices, y a definir restricciones sobre ellas. En este capítulo estudiaremos tanto la organización física de las tablas de usuarios, como las opciones lógicas dis-ponibles durante la creación de tablas e índices.

Dos tipos de tablas

Existen dos tipos de tablas en SQL Server, de acuerdo a su organización física. El tipo más sencillo se conoce en inglés con el nombre de heap, o montón. En este mo-delo, cada registro de la tabla se sitúa por completo dentro de una página (no se puede situar un registro a caballo entre dos páginas), pero sus vecinos de página no guardan relación alguna con él. Y digo esto porque en el segundo modelo, llamado cluster o grupo, los registros aparecen ordenados físicamente, de acuerdo a su clave primaria. La propia estructura que organiza los registros en cluster actúa como índice para las columnas de la clave primaria.

Para entenderlo, veamos un esquema de la estructura de un índice “normal”, junto con los registros de la tabla asociada.

Nodo raíz

Páginas del índice

Páginas de datos

Tenga en cuenta, en primer lugar, que SQL Server utiliza una estructura de datos en disco, llamada B+ tree, para la implementación de sus índices. Un B+ tree es un árbol

Y

Page 55: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Tablas 55

cuyas hojas se encuentran todas en el mismo nivel de profundidad. Además, todos los punteros a páginas de datos se sitúan únicamente en el nivel de las hojas. Observe cómo los punteros del nivel final del índice apuntan aleatoriamente a los registros de datos, que pueden encontrarse arbitrariamente en cualquier página. En este tipo de tablas existe una clara separación entre páginas de datos y páginas del índice.

Por el contrario, en las tablas organizadas en cluster, también llamadas tablas agrupa-das, no se produce una separación tan radical entre el índice y los datos, como muestra la siguiente imagen siguiente.

Nodo raíz

Páginas del índice

Páginas de datos

Ahora las páginas de datos son realmente las hojas, o páginas terminales, de la es-tructura del índice. Claro está, existe una diferencia de formato importante con el índice normal antes visto: en las páginas intermedias se almacena solamente la com-binación de columnas que actúa como clave de registro, mientras que en las hojas se almacena el registro completo.

Si necesitamos recorrer la tabla según el orden determinado por un índice, lograría-mos más velocidad moviéndonos con un índice agrupado, de tipo cluster. Pero, ¿es tan importante, o frecuente, el recorrido ordenado? SQL Server implementa algunos joins mediante el algoritmo de mezcla ordenada: si se van a unir dos tablas, se orde-nan por las columnas que realizan el join y entonces se avanza la posición de la fila activa de ambas tablas simultáneamente, comparando en cada iteración cuál de las dos tablas va más “atrasada” en el proceso. El disponer de índices agrupados acelera enormemente este proceso.

... y dos tipos de índices

Otro aspecto físico interesante es cómo se organizan los índices no agrupados de una tabla agrupada. ¿Cuál es el problema, por qué tiene que haber una diferencia con la organización en heap? En la estructura “libre”, la posición de un registro dentro de la base de datos puede convertirse en un invariante: una vez añadido el registro, esta posición no tiene por qué variar.

Page 56: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

56 La Cara Oculta de C#

En realidad el invariante es un valor conocido como row identifier, o RID, que consiste en la concatenación del número de la página en la que se encuentra el registro más un valor conocido como número de slot o ranura, que sirve para localizar el registro dentro de la página. La idea consiste en que la cabecera de la página alberga un vec-tor de punteros a los registros que contiene. Si decimos que cierto registro ocupa el slot número tres, estamos diciendo que en la tercera entrada del vector encontraremos la posición donde realmente está el registro.

Un registro que contenga campos de longitud variable, como los de tipo varchar, puede crecer o encogerse varias veces durante su vida. Mientras haya espacio para el registro en su página original, podemos mover arbitrariamente su posición dentro de la misma, siempre que retoquemos el vector de la cabecera de página para que el RID del registro siga siendo el mismo. ¿Qué pasa si un registro crece tanto que te-nemos que moverlo a otra página? Nada importante: para mantener el mismo RID, insertamos en la página original un puntero a la nueva posición. Así, cuando utilice-mos la combinación página/slot encontraremos dicho puntero y, siguiéndolo, locali-zaremos el escurridizo registro.

Volvamos al índice sobre una tabla no agrupada: los punteros a registros pueden implementarse como RIDs, sin ningún tipo de dificultad. Ahora bien, si la tabla está asociada a un índice agrupado no podemos garantizar la invariancia de los RIDs. Para mantener el orden físico de los registros puede ser necesario cambiar de página varias veces a un registro.

La solución consiste en no implementar los punteros a registros de una tabla agru-pada mediante RIDs. Por el contrario, lo que se almacena como puntero es la clave primaria del registro. Si necesitamos buscar un registro en un índice secundario de una tabla agrupada, recorremos el árbol hasta dar con la página terminal en la cual encontraremos la clave primaria del registro. Entonces realizamos una segunda bús-queda, ahora sobre la clave primaria, y esta vez sí daremos con el registro buscado.

Page 57: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Tablas 57

Creación de tablas y definiciones de columnas

Para crear tablas en una base de datos SQL se utiliza la instrucción create table. No existe ningún otro mecanismo más directo: algunas interfaces de acceso a datos pue-den ofrecer funciones especiales para esta tarea, pero en cualquier caso, estarían ba-sadas en el envío de instrucciones SQL literales al servidor. Lo mismo sucede con las herramientas gráficas de diseño relacional, que actúan sobre la base de datos en-viando también instrucciones SQL.

La sintaxis completa de create table es algo compleja, pero a grandes rasgos, se puede resumir así:

create table NombreTabla (

columna_o_restricción [ , columna_o_restricción ]…

)

[on grupo_ficheros | on default]

[textimage_on grupo_ficheros | textimage_on default]

Las dos últimas cláusulas son opcionales y específicas de SQL Server. La primera de ellas sirve para que la tabla se cree en una partición determinada, en vez de la parti-ción primaria. Algo parecido hace la cláusula textimage_on, que sirve para ubicar el contenido de las columnas de tipo texto, imagen y binario en una partición, o grupo de ficheros, aparte.

La parte más sustanciosa es la lista de definiciones de columnas y restricciones que se sitúa entre los paréntesis; cada definición se separa de la siguiente mediante una coma. Dejaremos la presentación de las restricciones a nivel de tabla para más ade-lante, y nos ocuparemos ahora de las definiciones de columnas y de las restricciones que se pueden definir junto a una columna. Este es el formato básico:

nombre_columna tipo_datos restricciones_de_columna

La restricción más común, a nivel de columna, es la que impide o permite el almace-namiento de nulos en la misma. Por ejemplo:

create table Paises (

IDPais integer not null,

Pais varchar(40) not null,

FormatoCP varchar(20) null

)

Las dos primeras columnas de la tabla anterior, el código numérico y el nombre del país, no admiten valores nulos. En cambio, puede que desconozcamos el formato de los códigos postales, o que no existan, para determinado país. El estándar de SQL establece que, si no indicamos la cláusula not null, la columna debe aceptar nulos. Sin embargo, en SQL Server las cosas no están tan claras, pues se puede invertir el criterio mediante opciones del servidor. Para estar seguros, lo mejor es dejar claro, explícitamente, la actitud de la columna frente a los nulos.

También se puede indicar un valor por omisión para la columna, mediante una cláu-sula default:

Page 58: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

58 La Cara Oculta de C#

create table Paises (

IDPais integer not null,

Pais varchar(40) not null,

FormatoCP varchar(20) null default '#####'

)

La existencia de valores por omisión nos permite omitir columnas, al insertar regis-tros mediante la instrucción insert. En el ejemplo anterior, el valor por omisión es una constante de cadenas, como corresponde al tipo de la columna, pero podíamos haber también indicado un nulo como valor por omisión:

FormatoCP varchar(20) null default null

De hecho, si no usamos una cláusula default, se asume que el nulo será el valor por omisión. Además de constantes, podemos utilizar ciertas funciones sin parámetros, como las siguientes:

Función Significado user El nombre del usuario que está conectado newid() Genera un nuevo GUID para columnas uniqueidentifier current_timestamp El día y la hora actual getdate() Igual que la anterior, pero requiere los paréntesis

No podemos declarar un valor por omisión para las columnas de tipo timestamp, ni para columnas a las que hayamos aplicado el atributo identity, que será estudiado en la siguiente sección.

NO

TA

Para indicar la organización física de los registros de la tabla se utiliza la definición de la clave primaria, si es que existe, o de las claves alternativas. Una tabla sin clave primaria ni alternativa, algo nada recomendable, organiza sus registros en heap. Si tiene clave primaria, a menos que indiquemos lo contrario, los registros se organizarán en cluster. Volveremos a esto a su debido momento, cuando estudiemos las restricciones de tablas.

El atributo identity

Otro recurso específico de SQL Server es el atributo identity, que se aplica a colum-nas de tipo numérico:

create table Colores (

Codigo integer not null identity(0,1),

Descripcion varchar(30) not null,

primary key (Codigo),

unique (Descripcion)

)

En la tabla anterior, el valor del código del color es generado por el sistema, par-tiendo de cero, y con incrementos de uno en uno; si no especificamos el valor inicial y el incremento, SQL Server comienza a contar a partir de uno. Además, el valor de la columna en una fila no puede ser alterado, una vez creada. Gracias a estas caracte-rísticas, el atributo identity suele utilizarse para definir claves primarias: columnas cuyo valor no se repite nunca en una misma tabla, y que sirven para identificar regis-tros unívocamente.

Page 59: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Tablas 59

Cuando una tabla contiene columnas con el atributo de identidad, la columna no debe incluirse en una instrucción insert. Las dos instrucciones que muestro a conti-nuación son aceptables:

insert into Colores values('Azul')

insert into Colores(Descripcion) values('Rojo')

No existe forma de conocer a priori cuál será el valor asignado a los registros ante-riores, al menos hasta haber ejecutado la inserción... a no ser que seamos el único usuario conectado al servidor. Podríamos preguntar por el valor máximo de la co-lumna que contiene el código de color, pero:

1 Las inserciones fallidas también avanzan el contador de identidad de la tabla. 2 Siempre se nos puede adelantar otro usuario, insertando un registro y recibiendo

el valor que esperábamos para nuestro propio registro.

Sin embargo, es sencillo averiguar el número que nos ha correspondido a posteriori, utilizando la variable global @@identity:

insert into Colores values('Hormiga pálida')

select @@identity

No se preocupe, que no han desaparecido líneas en la instrucción anterior. En SQL Server se puede omitir la cláusula from de un select. La sentencia en cuestión de-vuelve el último valor de identidad asignado por nuestra conexión. Lo explicaré con un ejemplo:

1 Hay dos personas conectadas a la base de datos: usted y yo. Usted inserta el co-lor 'Hormiga pálido' y, aunque todavía no lo sabe, SQL Server le ha asignado la clave 1234.

2 Entonces, desde la segunda conexión, soy yo quien añade otro color, digamos que sea 'Gris putrefacto', y se me asigna la clave 1235.

3 Usted pregunta por el valor de @@identity, y SQL Server le responderá acerta-damente que es 1234, porque el 1235 se asignó a través de otra conexión.

No obstante, debemos tener mucho cuidado con el uso de @@identity, no por proble-mas creados por otros usuarios, sino por nosotros mismos. En el capítulo 10 estudia-remos un recurso conocido como trigger: código en Transact SQL que el sistema ejecuta automáticamente tras una inserción, modificación o borrado de registros. Supongamos que tenemos dos tablas, a las que llamaremos A y B, por simplificar, y que ambas tienen como clave primaria una columna con el atributo de identidad. Hemos programado un trigger que, cada vez que insertamos un registro en la tabla A, crea automáticamente otro registro en B. Veamos qué sucede si preguntamos el valor de @@identity tras insertar un registro en A:

insert into A(…) values (…)

select @@identity

Page 60: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

60 La Cara Oculta de C#

El valor devuelto por @@identity es el valor asignado a la clave primaria de B, en vez del valor asignado al registro de A. En SQL Server 7 no existe forma alguna de evitar este problemas, a no ser que renunciemos a este tipo de triggers, o al uso de identi-dades. Pero en SQL Server 2000 se introdujo la función scope_identity:

insert into A(…) values (…)

select scope_identity()

La función hace casi lo mismo que la variable global... excepto que se tiene en cuenta el “nivel” desde el que se ejecuta la instrucción. En este caso, se ignorarían los posi-bles valores de identidad asignados dentro de hipotéticos triggers, garantizando que el valor devuelto correspondiese al último registro insertado explícitamente.

¿Identidades o tablas de contadores?

¿Existe algún motivo para habernos complicado la vida con la identidades? Desde tiempos prehistóricos, los primates hemos utilizado una sencilla técnica para asignar valores únicos a una clave primaria de tipo entero: utilizar una segunda tabla, con una sola fila y una columna. Cada vez que se necesita un nuevo registro en la tabla princi-pal, se lee el valor de la fila única de la tabla auxiliar, o tabla de contadores, se asigna como clave primaria en el registro insertado, y se regresa a la tabla de contadores para incrementar el valor de la celda. Todo esto sucedía mientras las hordas de pro-gramadores de Clipper galopaban por las estepas.

Bueno, ¿y qué hay de malo en ello? Nada: es una técnica correcta... pero sólo debe-mos aplicarla en determinadas circunstancias, lo mismo que el uso de identidades. Veamos en qué se diferencian entonces.

Para empezar, hay que tener mucho cuidado con el orden de operaciones en la téc-nica de la tabla de contadores. Todavía no hemos estudiado las técnicas de aisla-miento de transacciones, por lo que supondré que nuestra aplicación se ejecuta en el nivel de aislamiento ideal, en el que cada lectura impone un bloqueo compartido sobre el registro leído hasta el fin de la transacción. Bajo esta suposición, la técnica de la tabla de contadores funciona correctamente porque, una vez que una aplicación lee el valor actual del contador, ninguna otra aplicación puede modificar ese valor hasta que la primera de ellas termine la transacción. Así se garantiza que el valor que hemos recibido sea único, sin importar la carga de concurrencia sobre el sistema.

La parte negativa consiste en que logramos esa garantía a costa de la eficiencia: he-mos transformado la tabla de contadores en un cuello de botella, porque solamente se puede hacer uso de ella desde una conexión, cada vez. Si todas nuestras tablas tienen una clave primaria automática, como sucede en mis propias aplicaciones, sería suicida obtener todas estas claves mediante tablas de contadores.

Este problema de contención, porque ese es el nombre técnico del fenómeno, no se produce cuando las claves se asignan mediante el atributo de identidad. Es cierto que también hay que proteger la zona de memoria que almacena internamente el valor

Page 61: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Tablas 61

siguiente de la columna de identidad, pero el tiempo en que se realiza esta operación es insignificante comparado con el tiempo en que la tabla de contadores se queda bloqueada: recuerde que el bloqueo de la tabla de contadores dura hasta el final de la transacción.

Las identidades tienen también su punto débil: al no depender de las transacciones, se pueden producir “huecos” en la secuencia. Supongamos que inicio una transac-ción, y que grabo un registro de colores. A continuación, pero todavía dentro de la transacción, intento ejecutar otra instrucción, que por desgracia falla. La transacción se anula, pero el contador de identidades no puede retornar a su valor inicial. Su-ponga que otro usuario pide una identidad en un momento muy específico: después que mi registro haya obtenido la suya, pero antes de que se anule mi transacción. Si se restaurase el contador interno de la identidad, ¿qué pasaría con el valor obtenido por el segundo usuario? Está claro que eso no sucedería con una tabla de contadores: la propia tabla puede participar en la transacción, y por otra parte, mientras no con-firmemos o anulemos la transacción, los restantes usuarios no pueden acceder a la tabla de contadores.

Resumiendo, es recomendable usar el atributo de identidad para generar claves si:

• Es una operación frecuente, y no queremos que se convierta en un cuello de botella para el sistema.

• No importa que se creen huecos en la secuencia.

Por el contrario, use tablas de contadores sólo si:

• La semántica del modelo no admite huecos en la secuencia de claves.

Tablas temporales

SQL Server permite que creemos tablas utilizando para sus nombres identificadores que comienzan con una almohadilla (#) o dos (##). Estas tablas no se crean en la base de datos activa, sino que siempre van a parar a la base de datos tempdb, que siempre que sea posible se almacena en memoria dinámica. Es un detalle a tener en cuenta, que puede afectar a los tipos de datos que se pueden utilizar para sus colum-nas. En el siguiente ejemplo, la creación de la tabla #temporal provoca un error:

use MiBaseDatos

go

execute sp_addtype T_IDENTIFIER, integer, 'not null'

go

create table #temporal (

ident T_IDENTIFIER,

equiv integer

)

go

Page 62: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

62 La Cara Oculta de C#

El problema consiste en que el tipo de datos T_IDENTIFIER se ha definido (co-rrectamente) dentro de la base de datos MiBaseDatos. Pero aunque no se indique nada al respecto, #temporal va a parar a tempdb, que no sabe qué es un T_IDENTIFIER. La solución, como puede imaginar, es abandonar la idea de utilizar ese tipo de datos y sustituirlo con el equivalente predefinido por SQL Server.

Las características que hacen especial a una tabla temporal son su tiempo de vida y su “alcance”: cuáles conexiones pueden ver la tabla y trabajar con ella. Hay dos tipos de tablas temporales, aquellas cuyo nombre comienza con una sola almohadilla, y aque-llas que utilizan dos. Una tabla que utiliza una sola almohadilla:

• Solamente es visible desde la conexión que la crea.

• Otra conexión puede crear una tabla temporal con el mismo nombre, pero físi-camente corresponden a objetos diferentes.

• Su vida termina, como máximo, cuando la conexión que la ha creado se cierra. Naturalmente, podemos ejecutar antes una instrucción drop table.

Por otra parte, si el nombre de la tabla comienza con dos almohadillas consecutivas:

• Puede ser vista desde otras conexiones.

• Esas otras conexiones pueden trabajar con la tabla, realizando búsquedas y ac-tualizaciones sobre la misma.

• La tabla se destruye inmediata y automáticamente cuando no quedan conexiones abiertas que la utilicen.

El carácter “local” del primer tipo de tablas temporales es posible gracias a que SQL Server añade un sufijo numérico relacionado con la conexión al nombre de la tabla que ha indicado el usuario, y utiliza ese nuevo nombre para el objeto físico que se crea. Por lo tanto, no hay posibilidad alguna de colisión entre nombres de tablas temporales locales.

Esto también garantiza que la tabla se “destruya”, suceda lo que suceda con la cone-xión. Si la conexión se cierra y vuelve a abrir, el número de sesión asignado por el sistema será diferentes, y aunque SQL Server tuviese algún fallo interno y no se des-truyese físicamente la tabla temporal, sería imposible volver a utilizar el mismo ob-jeto físico. Por último, recuerde que tempdb se crea desde cero cada vez que se inicia el servicio de SQL Server.

Verificación de condiciones

Cuando asignamos un tipo de datos a una columna, ya estamos restringiendo el conjunto de valores que puede almacenar. Naturalmente, se trata de una restricción muy básica. Podemos ir más allá añadiendo cláusulas check a la creación de la tabla: condiciones que siempre deben cumplirse, tanto cuando insertamos un registro como cuando lo modificamos. Veamos un ejemplo:

Page 63: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Tablas 63

create table FORMATO_LINEA (

/* … más columnas y definiciones … */

LongitudLinea smallint default 80 not null,

InicioImporte smallint not null,

FinImporte smallint not null,

/* … otras verificaciones … */

check (LongitudLinea > 0),

check (InicioImporte between 1 and LongitudLinea),

check (FinImporte between InicioImporte + 1 and LongitudLinea)

)

He situado por claridad las verificaciones al final de la instrucción. En realidad, po-demos definir una cláusula check en la propia definición de la columna, pero pre-fiero agruparlas al final. Observe que existen condiciones que afectan a una sola columna, y otras que afectan a varias simultáneamente. Note también que podíamos haber planteado una única y gigantesca condición uniendo las condiciones indivi-duales con el operador and. Naturalmente, es preferible dividir las verificaciones en grupos de fácil interpretación para el desarrollador.

Una importante limitación de las restricciones check en SQL Server es que los ope-randos de la condición que se verifica deben ser constantes o columnas pertenecien-tes a una misma fila. Para establecer restricciones que exijan el manejo de otros re-gistros o tablas tendríamos que recurrir a triggers.

Claves primarias

Las cláusulas check se quedan cortas cuando se trata de implementar verificaciones que afectan a más de un registro simultáneamente. Las verificaciones más populares de este otro tipo son las restricciones de unicidad: los valores de cierta combinación de columnas no pueden repetirse a lo largo de toda la tabla. La combinación de la clave primaria puede consistir en una sola columna, pero también puede mencionar a varias, como se muestra en el siguiente listado.

create table BANCOS (

NumBanco smallint not null,

/* ¡Observe que no es char(4)! */

Nombre varchar(40) not null,

NIF varchar(15) not null,

/* Clave primaria simple */

primary key (NumBanco),

/* Algunas verificaciones */

check (NumBanco between 0 and 9999)

)

create table SUCURSALES (

NumBanco smallint not null,

NumSucursal smallint not null,

Direccion varchar(40) not null,

/* Una clave compuesta: */

/* Puede repetirse el banco, puede repetirse la sucursal */

/* pero no pueden repetirse ambos números a la vez */

primary key (NumBanco, NumSucursal),

check (NumSucursal between 0 and 9999)

)

Page 64: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

64 La Cara Oculta de C#

SQL Server crea automáticamente un índice único para las columnas de la clave pri-maria, para facilitar la implementación de la verificación. Aquí surge la pregunta: ¿cómo especificar si queremos un índice agrupado o uno normal? Tenemos que saber, en primer lugar, que la cláusula primary key intenta crear su índice en forma agrupada, si no existe otro índice de este tipo sobre la tabla. Claro, las claves prima-rias pueden añadirse después de la creación de la tabla, utilizando la instrucción alter table. En cualquier caso, podemos añadir a la especificación de clave primaria uno de los modificadores clustered o nonclustered para dejar bien claras nuestras inten-ciones:

primary key nonclustered (IDUsuario)

Algunos mitos persistentes

Circulan por ahí varias ideas equivocadas que se resisten a morir. Ciertos analistas fósiles insisten en no utilizar claves primarias en sus bases de datos, en teoría para ganar velocidad. Con la tecnología moderna, se trata de un evidente disparate, que además habla muy poco a favor de las habilidades de análisis y diseño de esta tribu.

Otros programadores, entre los que me incluyo, huyen de las claves primarias com-puestas como de la peste. En sustitución, emplean claves artificiales enteras y sim-ples. La idea básica es poder utilizar la clave artificial como una especie de identidad del registro, que no tiene nada que ver con los valores concretos que éste almacene. Así, la clave funciona más o menos como un falso puntero, y permite plasmar con mayor facilidad los diseños orientados a objetos. Se simplifica también la tarea de coordinar la caché de la estación de trabajo con la base de datos, pues al ser la clave primaria un valor interno y transparente para el usuario, no hay que estar modifi-cando claves, con todos los problemas que esto conlleva.

La tabla de sucursales quedaría ahora con este aspecto:

create table SUCURSALES (

IDSucursal integer not null,

NumBanco smallint not null,

NumSucursal smallint not null,

Direccion varchar(40) not null,

/* Una clave artificial */

primary key (IDSucursal),

/* ¡Una clave alternativa! */

unique (NumBanco, NumSucursal),

check (NumSucursal between 0 and 9999)

)

Claves alternativas

La novedad del listado anterior, sin embargo, es el uso de la cláusula unique. Estas cláusulas dicen casi lo mismo que las claves primarias: no se pueden repetir los valo-res de la combinación de columnas. Hay una pequeña diferencia: las claves primarias no admiten valores nulos, pero las alternativas unique los admiten, aunque sólo una

Page 65: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Tablas 65

vez por tabla. La otra diferencia es la distinta prioridad para decidir si se implemen-tan con un índice agrupado o uno normal. Ganan siempre las claves primarias, si no indicamos explícitamente lo contrario.

Si quisiéramos que el índice de una restricción unique fuese un índice agrupado, tendríamos que utilizar las cláusulas clustered y nonclustered:

create table SUCURSALES (

IDSucursal integer not null,

NumBanco smallint not null,

NumSucursal smallint not null,

/* … más columnas */

primary key nonclustered (IDSucursal),

unique clustered (NumBanco, NumSucursal)

)

Podríamos omitir la cláusula nonclustered de la clave primaria. SQL Server entende-ría que, por pedir un índice agrupado para la restricción unique, no se puede tener a la par un índice de este mismo tipo para la clave primaria.

La principal consecuencia de cambiar el criterio de agrupación de los índices es que, con la definición anterior, los registros de sucursales estarían ordenados físicamente de acuerdo a su número de banco y sucursal. Si nuestra aplicación lanza con frecuencia consultas que piden, digamos, las sucursales de determinado banco, este tipo de or-ganización sería la más eficiente. En cambio, penalizaríamos las búsquedas por el identificador de la sucursal, que probablemente sería utilizando para los encuentros naturales con otras tablas que hicieran referencia a una sucursal.

En mi opinión, es difícil conocer de antemano si es mejor agrupar el índice primario o uno de los asociados a restricciones de unicidad. Lo mejor, por lo tanto, es no preocuparse demasiado por este problema al diseñar la base de datos. Luego, con la aplicación ya en funcionamiento, podemos volver a la tabla y evaluar, con la ayuda del profiler de SQL Server, si merece la pena cambiar el criterio de agrupación de los registros de la tabla.

Restricciones con nombres

¿Cómo puede saber una aplicación cliente, cuando se produce una violación de uni-cidad, cuál es la restricción que ha fallado? La mayoría de los programadores esperan que mencione algún código numérico secreto que, tras ser manipulado en una noche de luna nueva, devuelva el nombre de las columnas cuya unicidad hemos profanado...

La pregunta tiene su justificación. Cuando insertamos un registro que viola una res-tricción de unicidad, contando entre ellas la restricción asociada a la clave primaria, la aplicación cliente recibe un código de error. En dependencia de la interfaz de acceso empleada, puede que el error recibido tenga el mismo valor que el código utilizado por SQL Server, o no. Pero en cualquier caso, el código recibido es siempre el mismo, sin importar si ha fallado un unique o una primary key. Es cierto que junto

Page 66: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

66 La Cara Oculta de C#

con el código, el programador recibirá un mensaje descriptivo... que será demasiado técnico para presentar al usuario, y que incluso puede estar en inglés, en dependencia de los idiomas instalados en el servidor.

La solución a este problema es muy simple: cuando cree una restricción, del tipo que sea, asígnele explícitamente un nombre. Por ejemplo:

create table SUCURSALES (

IDSucursal integer not null,

NumBanco smallint not null,

NumSucursal smallint not null,

/* … más columnas */

constraint PK_SUCURSALES

primary key nonclustered (IDSucursal),

constraint UQ_SUCURSALES

unique clustered (NumBanco, NumSucursal)

)

Si insertamos un registro con un valor en IDSucursal que ya se encontraba en la tabla, recibiremos un mensaje como el siguiente:

Violation of PRIMARY KEY constraint 'PK_SUCURSALES'.

Cannot insert duplicate key in object 'SUCURSALES'.

Y si la restricción que violamos es la de la clave alternativa, recibiremos:

Violation of UNIQUE KEY constraint 'UQ_SUCURSALES'.

Cannot insert duplicate key in object 'SUCURSALES'.

En ambos casos, el nombre de la restricción se incluye en el mensaje de error, y esto se repetirá para las violaciones de los restantes tipos de restricciones. Por lo tanto, ¿por qué no definimos todas las restricciones con nombres lo suficientemente ex-céntricos para que no puedan confundirse? Como ha visto en el ejemplo, podemos bautizar las restricciones de claves primarias con el prefijo PK y el nombre de la tabla. Si hay una sola restricción unique, podemos nombrarla con el prefijo UQ y, nuevamente, el nombre de la tabla; por supuesto, si hay más de una restricción unique, quizás debamos añadir también los nombres de las columnas... No importa el sistema concreto de nombres que empleemos: lo importante es que cada posible fallo vaya asociado a un nombre de restricción lo suficientemente extravagante. Una aplicación cliente tendrá sólo que buscar el nombre de restricción dentro del mensaje de error, y con la ayuda de una lista, podrá traducirla en un mensaje comprensible para el más tonto de los usuarios.

Dicho sea de paso, hay más ventajas al utilizar nombres explícitos para las restriccio-nes. Una de esas ventajas es que nos será más fácil eliminar o modificar una restric-ción ya existente. Por ejemplo, para eliminar la restricción unique de la tabla de su-cursales, podríamos ejecutar:

alter table SUCURSALES

drop constraint UQ_SUCURSALES

Page 67: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Tablas 67

Si no hubiésemos dado un nombre explícito a la restricción, tendríamos que utilizar el Administrador Corporativo, u otra herramienta similar, para localizar la restricción y ver qué nombre interno le asignó SQL Server al ser creada.

Integridad referencial declarativa

Las claves primarias, según hemos visto, pueden servir para dotar a los registros de una base de datos relacional de una “identidad” artificial que nos permitirá simular la existencia de “punteros” a los mismos. Siguiendo este símil, las claves externas (foreign keys) constituyen el mecanismo que permite almacenar “punteros” a otros registros. A este mecanismo se le denomina integridad referencial declarativa, cuando queremos que los demás sepan que hemos pasado por la escuela.

He incluido en el siguiente listado tres ejemplos de sintaxis para la definición de cla-ves externas. La clave externa sobre la tabla de clientes no tiene nada especial, ex-cepto la sintaxis utilizada para la definición; ya he advertido que prefiero situar todas las restricciones al final de la instrucción.

create table SUCURSALES (

NumBanco smallint not null,

NumSucursal smallint not null,

Direccion varchar(40) not null,

/* No he utilizado la clave artificial esta vez */

/* ¿Motivo? Complicar las cosas un poco … */

primary key (NumBanco, NumSucursal),

foreign key (NumBanco) references BANCOS(NumBanco),

check (NumSucursal between 0 and 9999)

)

create table CUENTAS (

NumBanco smallint not null,

NumSucursal smallint not null,

NumCuenta numeric(10) not null,

/* ¡Una clave externa definida junto a la columna! */

IDCliente integer not null

references CLIENTES(IdCliente),

/* Detesto las claves con tantas columnas */

primary key (NumBanco, NumSucursal, NumCuenta),

/* Una clave externa compuesta */

foreign key (NumBanco, NumSucursal)

references SUCURSALES(NumBanco, NumSucursal)

)

Las cláusulas foreign key hacen corresponder cierta combinación de columnas en la tabla de origen con otra combinación de columnas de la tabla destino. Las columnas del destino tienen obligatoriamente que haber sido utilizadas para definir una clave primaria o alternativa (unique). Siguiendo la idea de los falsos “punteros”, es mejor que la referencia siempre se haga a una clave primaria.

Analicemos la relación entre las tablas de bancos y sucursales. Existen algunas obli-gaciones para las sucursales:

Page 68: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

68 La Cara Oculta de C#

• No se puede crear una sucursal con un número de banco inexistente.

• No se puede modificar el número de banco de una sucursal ya creada y susti-tuirlo por un valor inexistente. Pero sí se puede cambiar una sucursal de banco, actualizando con otro valor “correcto”.

En este caso particular, también existen obligaciones para los bancos:

• No se puede borrar un banco si ya tiene sucursales.

• No se puede corregir un número de banco si existen sucursales asociadas.

Más adelante veremos que en SQL Server 2000 es posible modificar estas últimas dos reglas, mediante las acciones referenciales.

Otra característica de la integridad referencial de SQL Server que hay que resaltar es que el sistema no se crea automáticamente índices secundarios sobre las claves exter-nas para facilitar la verificaciones que afectan a las mismas. Supongamos que se eje-cuta una instrucción delete para borrar un registro de cliente. De acuerdo a las reglas anteriores, el sistema debería comprobar si hay cuentas asociadas a ese cliente, verifi-cando si existen filas en Cuentas con determinado valor en su columna IDCliente:

exists (select * from CUENTAS

where IDCliente = 1234)

Es fácil comprender que esta comprobación es más eficiente cuando existe un índice sobre la columna IDCliente de CUENTAS. SQL Server no lo crea, por lo que debe-mos ocuparnos de ello:

create index CuentasXCliente on Cuentas(IDCliente)

Si tan útiles son los índices secundarios sugeridos por las claves externas, ¿por qué SQL Server no los crea automáticamente? La respuesta está en la misma tabla Cuen-tas. Suponga que lo que vamos a borrar es una sucursal. SQL Server debe comprobar si existen cuentas en esa sucursal, buscando en Cuentas los registros que tengan de-terminado valor en sus columnas NumBanco y NumSucursal:

exists (select * from CUENTAS

where NumBanco = 1234 and

NumSucursal = 1234)

Puede comprobar que NumBanco y NumSucursal forman un prefijo de la clave prima-ria de la tabla de cuentas. Pero SQL Server sí ha creado automáticamente un índice para las tres columnas de esta clave primaria, y ese índice puede aprovecharse tam-bién para optimizar la búsqueda que necesitamos. Si hubiésemos creado otro índice secundario, sólo para la clave externa, sería redundante. No notaríamos un aumento significativo de la velocidad de estas búsquedas, y sin embargo, penalizaríamos las actualizaciones sobre Cuentas, al tener que mantener más índices actualizados.

Page 69: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Tablas 69

Acciones referenciales

En el ejemplo de la anterior sección, no podíamos borrar un banco que tuviese su-cursales, ni una sucursal que tuviese cuentas... pero tampoco aquellos clientes que tuviesen cuentas. ¿Es esto correcto, desde el punto de vista semántico? Depende mucho de nuestro modelo de datos. Por ejemplo, puede que en determinados mo-delos, al eliminar un cliente debamos eliminar también sus cuentas; este sería un mo-delo bastante radical. En otros casos podríamos desear un enfoque más conservador: “borrar” un cliente significaría simplemente marcarlo como inactivo, asignando un valor especial en alguna de sus columnas. En tal situación, lo más recomendable sería marcar también como inactivas las cuentas asociadas: no aparecerían en las consultas más comunes, pero sus datos no se perderían del todo. Un enfoque intermedio con-sistiría en eliminar realmente el registro del cliente, y asignar un valor especial en la referencia IDCliente de sus cuentas asociadas, que significase algo así como “un cliente que ya no está”.

Por otra parte, tendríamos casos como el de una factura y sus líneas de detalles. Mu-cha gente considera que eliminar una factura es un pecado contable mortal. Pero suponga que existe esa posibilidad, por un momento: ¿qué tendríamos que hacer con las líneas de detalles pertenecientes a la factura que se va a borrar? La respuesta más razonable es también borrarlas.

Para ayudarnos a modelar todas estas situaciones, el estándar SQL-92 define cuatro opciones que podrían especificarse al definir una restricción de integridad referencial:

1 no action: No se puede borrar una sucursal que tenga cuentas asociadas. 2 cascade: Al borrar una sucursal, se borran automáticamente las cuentas aso-

ciadas. 3 set null: Al borrar una sucursal, se modifica la columna NumSucursal en los re-

gistros de cuentas, para que contengan un valor nulo. 4 set default: Similar a la acción anterior, pero en vez de sustituir con nulos, se

sustituye el valor de la columna que hace la referencia con un valor distinto, es-pecificado por omisión.

De estas cuatro acciones, SQL Server 7 sólo implementa no action, que es también la opción que se asume por omisión. Pero SQL Server 2000 permite no action y cascade, y SQL Server 2003 añade las dos restantes posibilidades. Estas opciones se asocian a las dos operaciones que podrían poner en peligro la referencia entre tablas: los borrados y actualizaciones de la clave primaria a la que se hace referencia. Por ejemplo, la relación entre cuentas y sucursales de la sección anterior podría ser defi-nida de la siguiente manera:

foreign key (NumBanco, NumSucursal)

references SUCURSALES(NumBanco, NumSucursal)

on delete no action on update cascade

Page 70: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

70 La Cara Oculta de C#

Al eliminar una sucursal, sucede lo mismo que antes: la operación no se permite si hay cuentas en la sucursal. Pero ahora lo hemos dejado claro, al mencionar la opción no action explícitamente. En cambio, si modificamos el identificador numérico de la sucursal o del banco, SQL Server se ocupará, de forma transparente para nosotros, de actualizar las referencias correspondientes dentro de la tabla Cuentas (on update cascade).

NO

TA

Puede que parezca muy importante tener a mano un recurso como la propagación de actualizaciones en cascada. De hecho, es muy sencillo simular la propagación de borra-dos mediante triggers, pero es más complicado propagar los cambios en una clave prima-ria. Sin embargo, un sistema tan potente como Oracle no permite propagar cambios en la clave primaria de forma declarativa. ¿Por qué nadie se queja? En mis propias aplicacio-nes suelo utilizar claves primarias artificiales, que son asignadas automáticamente en el servidor. La integridad referencial, por lo tanto, se implementa a través de esas claves artificiales. Como consecuencia, un usuario nunca modifica una clave primaria, y la nece-sidad de propagar esos cambios que nunca se producen, desaparece por completo.

En el caso de las facturas y sus líneas de detalles, definiríamos la relación entre deta-lles y facturas como muestro a continuación:

create table DETALLES (

/* … */

foreign key (IDFactura) references FACTURAS(IDFactura)

on delete cascade

)

En general, el borrado en cascada se utiliza en la simulación de relaciones de pertenen-cia. Observe que en el ejemplo anterior no he mencionado lo que sucedería con las actualizaciones. Al no utilizar una acción referencial explícita, SQL Server asume no action.

¿Y si quisiéramos que, al borrar un empleado, todas las facturas que ha rellenado reciban un valor nulo en su referencia al empleado? En SQL Server 2000, esto no se puede indicar declarativamente, y tendríamos que programar ese comportamiento con la ayuda de triggers. Sin embargo, con SQL Server 2003 basta con indicar la ac-ción referencial set null.

Bucles y punteros vacíos

Una cuenta “apunta” a una sucursal, una sucursal apunta a un banco, una línea de detalles se refiere a una cabecera de pedido... Tenemos una tabla de empleados, y queremos saber quién es el jefe directo de cada cuál. En este caso, un empleado... ¡apunta a otro empleado! ¿Es posible eso? Analice esta instrucción:

create table EMPLEADOS (

IDEmpleado integer not null primary key,

Nombre varchar(20) not null,

Apellidos varchar(30) not null,

Jefe integer null

references EMPLEADOS(IdEmpleado)

)

Page 71: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Tablas 71

La columna Jefe ha sido definida esta vez de forma tal que acepte valores nulos, y que haga referencia a la propia tabla que estamos definiendo. ¿Para qué queremos nulos ahora? La respuesta es que habrá algún empleado que será el gran cacique y que no tendrá jefe visible (excluyendo a su media naranja). Este afortunado personaje será distinguido con un valor nulo en su columna Jefe, que desempeñará un papel similar al de un puntero vacío.

Es también posible definir ciclos de referencias más complejos, que atraviesen varias tablas. Por ejemplo, un empleado apunta a un departamento, y el departamento tiene un jefe que es un empleado. El siguiente listado muestra una posible solución al pro-blema de la circularidad de las referencias mutuas:

create table EMPLEADOS (

IDEmpleado integer not null primary key,

Nombre varchar(20) not null,

Apellidos varchar(30) not null,

IDDpto integer not null

)

create table DPTOS (

IDDpto integer not null primary key,

Nombre varchar(30) not null unique,

IDJefe integer

references EMPLEADOS(IDEmpleado)

)

go

alter table EMPLEADOS add constraint

foreign key (IDDpto)

references DPTOS(IDDpto)

go

No podemos definir una referencia a departamentos mientras creamos la tabla de empleados, porque todavía no ha sido creada la primera. Y si invertimos el orden de creación seguiremos en las mismas. Por lo tanto, creamos la tabla de empleados sin su clave externa y creamos inmediatamente la de departamentos, a la que sí podemos añadir la integridad referencial. Entonces ya podemos volver a la tabla de empleados y añadir la referencia que faltaba al departamento.

Es necesario, sin embargo, que se dé cuenta de un problema que sigue existiendo: he dejado que el jefe de un departamento pueda ser un valor nulo. ¿Por qué? Pues por-que la misma circularidad existe, no ya entre las definiciones de tablas, sino entre los propios registros que las poblarán. El problema del huevo y la gallina marsupial, pero pasado por la tecnología digital.

Creación de índices secundarios

Hay ocasiones en que es indispensable crear índices secundarios. Ya hemos visto que cuando definimos claves externas, es muy probable que necesitamos un índice para la combinación de columnas en el origen. La sintaxis para crear índices en SQL Server es la que muestro a continuación, en su forma simplificada:

Page 72: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

72 La Cara Oculta de C#

create [unique][clustered | nonclustered] index nombreIndice

on tabla ( columnas )

[with opciones]

on grupoFicheros

He omitido deliberadamente algunos detalles sobre las opciones de creación. La op-ción más interesante es fillfactor, que indica el porcentaje de las páginas de índice que se intentará llenar antes de dividir la página en dos; rarezas de los árboles balan-ceados. Esta opción puede ajustarse para aquellos índices en los que se realizan mu-chas modificaciones. Dejando más espacio vacío, disminuimos por lo general el nú-mero de grabaciones físicas. Para un índice relativamente estático, es preferible un factor de llenado mayor.

SQL Server puede aprovechar un índice secundario no sólo para la búsqueda de un valor. Supongamos que los registros de la tabla Clientes son relativamente anchos, que tenemos un índice secundario sobre la columna Pais, y que debemos ejecutar la si-guiente consulta:

select Pais, Ciudad

from CLIENTES

where Pais = 'España'

Bajo determinadas circunstancias, si sometemos esta consulta al analizador de índices de SQL Server, puede que nos recomiende crear un índice secundario que incluya, además del país, la ciudad del cliente. A primera vista parece extraño, porque la con-dición de búsqueda sólo hace referencia al país. Pero la explicación es que al añadir la ciudad a la clave del índice no será necesario buscar el valor de esta columna en los registros de la tabla... ¡sino en la propia copia de la ciudad presente en la clave del índice! Esta estrategia de evaluación puede ahorrar unas cuantas lecturas de páginas desde el disco duro.

Columnas calculadas e índices

Cuando creamos una tabla, SQL Server nos permite definir columnas calculadas me-diante expresiones basadas en las restantes columnas de la tabla. Por ejemplo:

create table CLIENTES (

/* … */

FechaNacimiento datetime not null,

Mes as datepart(m, FechaNacimiento),

/* … */

)

Una columna calculada, en circunstancias normales, no ocupa espacio dentro del formato físico del registro, sino que su valor se evalúa cada vez que es necesario. Observe que no debemos indicar un tipo de datos para la columna, porque SQL Server lo deduce a partir de la expresión.

En general, si necesitamos una columna de este tipo, es preferible que el cálculo se realice en la aplicación cliente, no en el servidor. Así le quitamos carga a la CPU de

Page 73: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Tablas 73

éste, y al evitar que el valor calculado viaje desde el servidor al cliente, también dis-minuimos el tráfico por la red. En el capítulo 12 veremos cómo trabajar con colum-nas calculadas definidas en ADO.NET.

No obstante, SQL Server 2000 soporta una técnica relacionada con las columnas calculadas que puede hacernos cambiar de opinión: a partir de esta versión podemos crear índices sobre columnas calculadas. Como podrá imaginar, para que esto sea posible, la columna calculada debe “materializarse”. SQL Server modificará el for-mato de registros a nuestras espaldas, para añadir una columna física sobre la que se creará entonces el índice deseado. Cada vez que modifiquemos un registro, SQL Server volverá a calcular el valor de la columna para esa fila. La única forma que teníamos de lograr esto en versiones anteriores era crear una columna física desde el primer momento, y programar su mantenimiento automático por medio de triggers.

¿Merece la pena utilizar este recurso? Le propongo un ejemplo para que juzgue usted mismo. Tenemos una tabla de productos, y la columna que contiene el nombre de producto ha sido declarada como un varchar(128), porque sabemos que nuestros registros tendrán nombres kilométricos. Vamos a crear un índice para los nombres de productos, pero una clave tan ancha permitirá muy pocas entradas por página, y las búsquedas no serán todo lo rápidas que podríamos desear. De repente suenan trompetas y guitarras eléctricas, porque SuperMarteens acude al rescate. Vamos a añadir una columna calculada a la tabla de productos:

NombreProducto varchar(128) not null,

CkNombreProducto as checksum(NombreProducto),

/* … */

La función checksum es una función de tipo entero introducida por SQL Server 2000. Su propósito es calcular una suma de control a partir del valor de una columna, o incluso de una fila entera. Para crear una suma de control a partir de una cadena, checksum ignora las diferencias entre mayúsculas y minúsculas, y trabaja con la representación de la cadena en Unicode. Si quisiéramos una suma de control binaria, podríamos utilizar la función alternativa binary_checksum.

La columna calculada que hemos creado actúa como si se tratase de un “resumen” del nombre del producto. ¿Qué tal si creamos un índice sobre la nueva columna?

create index ProductosXCk on PRODUCTOS(CkNombreProducto)

Ahora podremos realizar búsquedas más eficientes a partir del nombre de un pro-ducto:

select *

from Productos

where CkNombreProducto = checksum('Pistola hiperprotónica') and

NombreProducto = 'Pistola hiperprotónica'

Si la tabla tiene un número de filas suficiente para que SQL Server considere que merece la pena, el evaluador de consultas utilizará el índice creado sobre la columna calculada para localizar el producto que deseamos. Es teóricamente posible que dos

Page 74: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

74 La Cara Oculta de C#

descripciones diferentes den como resultado la misma suma de control, por lo que es necesario eliminar los falsos positivos con una comparación adicional.

Por cierto, si tuviésemos dudas sobre el índice que elegiría SQL Server, podríamos forzar el uso de nuestro índice mediante una sutil indicación en la cláusula from de la consulta:

select *

from Productos with (index(ProductosXCk))

where CkNombreProducto = checksum('Pistola hiperprotónica') and

NombreProducto = 'Pistola hiperprotónica'

Pero me estoy adelantando al contenido del capítulo siguiente...

Page 75: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

5

Consultas y actualizaciones

UCHOS LIBROS SE HAN ESCRITO SOBRE el lenguaje SQL, y cualquier progra-mador ha escrito al menos una consulta en algún momento de su vida. Si Tran-

sact SQL se ajustase más o menos al estándar, este capítulo no sería necesario. Pero entre Transact SQL y el estándar debe haber algo parecido a una orden judicial de alejamiento, para bien y para mal. En este capítulo daremos un rápido repaso al len-guaje de manipulación de datos de Transact SQL, y prestaremos especial atención a las numerosas extensiones que algún día pueden salvarnos la vida.

El lenguaje de consultas

A grandes rasgos, la estructura de la instrucción select es la siguiente:

select [distinct] lista-de-expresiones

from lista-de-tablas

[where condición-de-selección]

[group by lista-de-columnas]

[having condición-de-selección-de-grupos]

[order by lista-de-columnas]

[union instrucción-de-selección]

¿Qué se supone que hace una instrucción select? En principio, una sentencia select no “hace”: más bien, define. La instrucción define un conjunto virtual de filas y co-lumnas, o lo que es igual, define una tabla virtual. Lo que se hace con esta “tabla virtual” es ya otra cosa, y depende de la aplicación que le estemos dando. Si estamos en un intérprete como el Analizador de Consultas, puede ser que la ejecución de un select se materialice mostrando en pantalla los resultados, página a página, o quizás en salvar el resultado en un fichero de texto.

A pesar de la multitud de secciones de una selección completa, el formato básico de select es muy sencillo, y se reduce a las tres primeras secciones:

select lista-de-expresiones

from lista-de-tablas

[where condición-de-selección]

La cláusula from indica de dónde se extrae la información de la consulta, en la cláu-sula where opcional se dice qué filas deseamos en el resultado, y con select especifi-camos los campos o expresiones de estas filas que queremos obtener. Muchas veces

M

Page 76: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

76 La Cara Oculta de C#

se dice que la cláusula where limita la tabla “a lo largo”, pues elimina filas de la misma, mientras que la cláusula select es una selección “horizontal”.

NO

TA

Para los ejemplos de este capítulo, al igual que hacemos en casi todo el libro, utilizare-mos la base de datos Northwind, que viene como ejemplo en SQL Server y en el motor MSDE. Esta base de datos simula un sistema bastante sencillo de entrada de pedidos.

La condición de selección

La forma más simple de instrucción select es la que extrae de una sola tabla el con-junto de filas que satisfacen cierta condición. Por ejemplo:

select *

from Customers

where Country <> 'France'

Esta consulta simple debe devolver todos los datos de los clientes que no son france-ses. El asterisco que sigue a la cláusula select es la alternativa a listar todos los nom-bres de columnas de la tabla que se encuentra en la cláusula from, como en este ejemplo, completamente equivalente al anterior:

select CustomerID, CompanyName, ContactName, ContactTitle,

Address, City, Region, PostalCode, Country, Phone, Fax

from Customers

where Country <> 'France'

En ambos ejemplos, la condición se ha basado en una simple igualdad. La condición de búsqueda de la cláusula where admite los seis operadores de comparación (=, <>, <, >, <=, >=) y la creación de condiciones compuestas mediante el uso de los operadores lógicos and, or y not. La prioridad de los operadores lógicos y relacio-nales es la misma que en C#:

select *

from Customers

where Country <> 'France' and

ContactTitle = 'Owner'

Como es posible que algunas de las columnas utilizadas en la cláusula where conten-gan valores nulos, debemos enfrentarnos a la posibilidad de que la propia condición se evalúe como nula. La regla de interpretación es sencilla: en la cláusula where, un resultado nulo es equivalente a uno falso. Esto es completamente coherente con la interpretación de los nulos como valores desconocidos. La tabla Customers tiene una columna llamada Region, que permite almacenar nulos. Para solicitar los clientes de California, escribiríamos la siguiente consulta:

select *

from Customers

where Region = 'CA'

Cuando la consulta tenga que examinar un cliente cuya región contenga un nulo, es decir, un cliente del que no se conozca la región, comparará dicho valor con la cons-

Page 77: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Consultas y actualizaciones 77

tante de cadena 'CA', y obtendrá un valor lógico nulo. Aplicando la regla de inter-pretación, se rechaza ese registro, lo cual es correcto. Digamos ahora que queremos los clientes que no están en California:

select *

from Customers

where Region <> 'CA'

Cuando tengamos entre manos un cliente con región nula, la condición volverá a evaluarse como nula, y el registro será rechazado... y nuevamente, éste es el compor-tamiento correcto. Si no sabemos cuál es la región del cliente, no podemos afirmar ni que esté en California, ni que no esté.

Eliminación de duplicados

Normalmente, no solemos guardar filas duplicadas en una tabla, por razones obvias. Pero es bastante frecuente que el resultado de una consulta contenga filas duplicadas. El operador distinct se puede utilizar, en la cláusula select, para corregir esta situa-ción. Por ejemplo, si queremos conocer en qué ciudades residen nuestros clientes podemos preguntar lo siguiente:

select City

from Customers

Pero en este caso obtenemos 91 ciudades, algunas de ellas duplicadas. Para obtener las 69 diferentes ciudades de la base de datos tecleamos:

select distinct City

from Customers

Por cierto, a efectos de la cláusula distinct, todos los valores nulos son iguales. Para comprobarlo, pida la lista de las distintas regiones de la tabla de clientes.

Ordenando los resultados

Teóricamente, las tablas de una base de datos relacional almacenan relaciones. Una relación se debe comportar como un conjunto matemático, y para los conjuntos matemáticos no se aplican directamente conceptos como orden o elementos repetidos. En SQL estándar, pero también en Transact SQL, esta distinción se nota en algunas restricciones aparentemente absurdas. Por ejemplo, más adelante veremos que po-demos definir relaciones virtuales mediante expresiones relacionales basadas en nuestras tablas físicas. En SQL, estas expresiones se llaman vistas (views), y se definen mediante sentencias select. Pues bien, veremos que la sentencia select de una vista no puede incluir cláusulas de ordenación, como las que estudiaremos en esta sección. ¿Motivo?, pues que una vista define una relación, y las relaciones no se ordenan... Claro, estas son consideraciones teóricas. Entréguele usted a un contable una lista de gastos sin ordenar, y aprenderá el significado de la palabra bronca, de forma práctica.

Page 78: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

78 La Cara Oculta de C#

Para ordenar el resultado de una consulta, SQL ofrece la cláusula order by, que en casi todos los casos se sitúa al final de la instrucción. La cláusula consiste en una lista de nombres de columnas separados por comas:

select *

from Orders

order by CustomerID, OrderDate

En este ejemplo, los pedidos se ordenan en primer lugar por el código del cliente; de este modo, todos los pedidos de un mismo cliente aparecerán de forma contigua. Dentro de cada grupo de pedidos pertenecientes a un mismo cliente, estos se vuel-ven a ordenar, pero esta vez, utilizando a fecha del pedido. En ambos casos, se utiliza el criterio ascendente de ordenación, pero podemos invertir el criterio, incluso utili-zando un sentido de ordenación por separado para cada columna:

select *

from Orders

order by CustomerID, OrderDate desc

La cláusula desc de este segundo ejemplo sólo se aplicaría a la columna OrderDate. Los pedidos seguirían ordenándose primeramente por el código de cliente, en el orden alfabético habitual. Pero dentro de cada grupo de pedidos de un mismo cliente, aparecerían primero los pedidos más recientes. Para invertir el orden en las dos columnas, tendríamos que repetir la cláusula desc:

select *

from Orders

order by CustomerID desc, OrderDate desc

Hay que tener en cuenta también que muchos dialectos SQL no permiten usar en la cláusula order by una columna que no aparezca en la cláusula select, como en esta instrucción:

select OrderID, CustomerID, Freight

from Orders

order by OrderDate desc

Sin embargo, para Transact SQL esto no significa problema alguno.

La cláusula order by permite también utilizar números en vez de nombres de colum-nas. Esto es necesario si se utilizan expresiones en la cláusula select y se quiere orde-nar por alguna de estas expresiones. Si quisiéramos saber cuáles son los pedidos que han tardado más en ser enviados, podríamos escribir lo siguiente:

select OrderID, OrderDate, datediff(day, OrderDate, ShippedDate)

from Orders

order by 3 desc

Como el tiempo que ha tardado un pedido en ser enviado no se almacena en una columna directamente, hemos tenido que utilizar la función datediff de Transact SQL para calcularlo. Como consecuencia, para poder hacer referencia a la columna calcu-

Page 79: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Consultas y actualizaciones 79

lada en la cláusula order by, hemos tenido que utilizar la posición de la misma dentro de la cláusula select. En este caso, se trataba de la tercera columna.

No se debe abusar de los números de columnas, pues esta técnica puede desaparecer en SQL-3 y hace menos legible la consulta. Una forma alternativa de ordenar por columnas calculadas es utilizar sinónimos para las columnas:

select OrderID, OrderDate,

datediff(day, OrderDate, ShippedDate) as Dias

from Orders

order by Dias desc

Limitando el número de registros

El resultado de una sentencia select puede contener un número impredecible de registros, pero en muchos casos a usted solamente le interesa un puñado de filas representativas del resultado. Por ejemplo: queremos veinte clientes que no sean franceses. O, si calculamos las ventas por cada cliente y ordenamos el resultado de acuerdo a este valor, puede que necesitemos sólo los cinco clientes que más han comprado.

Lamentablemente, no existe un estándar en SQL para expresar la condición anterior, y cada sistema que implementa algo parecido lo hace a su aire. SQL Server ofrece una cláusula en la sentencia select para elegir un grupo inicial de filas:

select top 20 *

from Customers

where Country <> 'France'

Incluso nos deja recuperar un porcentaje del conjunto resultado:

select top 20 percent *

from Customers

where Country <> 'France'

En el caso anterior, no se trata de los veinte primeros clientes, sino de la quinta parte de la cantidad total de clientes.

¿Cuáles veinte clientes son los que devuelve la primera consulta de esta sección? No haga cábalas, porque depende del algoritmo de recorrido de filas que elija el compi-lador de Transact SQL. Para que una consulta que limita el número de registros tenga algún sentido real, debemos incluir un criterio de ordenación. Por ejemplo, así cono-ceremos cuáles son las cinco órdenes con los mayores gastos de envío:

select top 5 *

from Orders

order by Freight desc

Y aquí puede surgir un problema. Para comprobarlo, vamos a ejecutar una consulta muy parecida, los trece pedidos con menores gastos de envío:

Page 80: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

80 La Cara Oculta de C#

select top 13 *

from Orders

order by Freight asc

El pedido que menos ha costado enviar en Northwind ha costado dos céntimos o centavos, y el decimotercero ha costado 56 céntimos. El problema está en que, al establecer el corte en ese punto, nos estamos perdiendo el hecho de que el siguiente registro, el decimocuarto, también tiene unos gastos de envío de 56 céntimos. Hay un empate entre estos dos pedidos. Si queremos tener en cuenta estos posibles empates, podemos añadir la cláusula with ties a la consulta:

select top 13 with ties *

from Orders

order by Freight asc

Ahora, aunque pedimos trece registros, la consulta devolverá catorce.

SQL Server 2003 amplía la sintaxis de la cláusula top. En las versiones anteriores, no se pueden escribir consultas que reciban como parámetro el número de filas que que-remos recuperar; el número que sigue a la palabra top debe ser siempre una cons-tante numérica. En SQL Server 2003 se permiten expresiones en la cláusula top, siempre que vayan encerradas entre paréntesis:

select top (@maxfilas + 1) *

from Orders

order by OrderID

En este ejemplo, @maxfilas puede ser un parámetro o una variable local de Transact SQL. Para lograr un efecto parecido en SQL Server 7 y 2000, tendríamos que recu-rrir a un sucio truco:

set rowcount @maxfilas

select *

from Orders

order by OrderID

set rowcount 0

La opción rowcount es una peligrosa reliquia heredada de SQL Server 6.5, y sirve para limitar el número de filas procesadas durante una operación. En este ejemplo, le asignamos un valor, asegurándonos al terminar la consulta que restauramos su valor original, que es cero. Observe que, incluso en este caso, no podemos utilizar una expresión.

Grupos y funciones estadísticas

Supongo que se preguntará como pude averiguar que habían dos pedidos con 56 céntimos en los gastos de envío. Hay 830 pedidos en la tabla Orders, y aunque hubiese ordenado la lista de registros por la columna Freight, habría sido muy tedioso exami-nar el resultado a simple vista, en busca de valores duplicados en dicha columna. Es cierto que el primer duplicado se encuentra entre las filas decimotercera y decimo-

Page 81: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Consultas y actualizaciones 81

cuarta, pero ¿qué garantías teníamos, antes de empezar, de que íbamos a encontrar al menos un duplicado?

La respuesta está en el uso de la cláusula group by, que permite la detección y trans-formación de grupos de filas con valores repetidos. Digamos, por ejemplo, que sos-pechamos que hay valores repetidos en la columna Freight de la tabla de pedidos, e intentamos escribir la siguiente consulta (¡errónea, como veremos!):

select * /* ¡Cláusula select incorrecta! */

from Orders

group by Freight

La consulta es rechazada por el compilador, que se queja entre otras cosas de que la columna OrderID, por mencionar sólo una entre muchas, no puede aparecer en la cláusula select porque no está entre las columnas agrupadas por la cláusula group by, pero tampoco está siendo utilizada por una función “agregada”. El problema se comprende mejor examinando algunas filas de la tabla:

OrderID CustomerID Freight 10.307 LONEP 0,56 10.849 KOENE 0,56

Las dos filas tienen el mismo valor en Freight, la columna por la que estamos agru-pando. El objetivo de la agrupación es convertir estas dos filas en una sola fila, en el resultado. Si pedimos el valor de la columna Freight en el resultado, es decir, en la cláusula select, no habrá problemas: SQL interpretará inequívocamente que quere-mos el valor 0,56. En cambio, si pedimos el valor de OrderID, ¿cuál de los dos valores debe aparecer, 10.307 o 10.849? Este es el motivo por el que se genera el error men-cionado.

Ahora bien, la operación de agrupar filas sería muy poco útil si sólo pudiéramos mostrar en el resultado las mismas columnas mencionadas en group by. ¡De hecho, para eso nos habría bastando ejecutar un distinct! ¿Qué tal si indicamos a SQL algún criterio para elegir el valor que nos interesa de todos los identificadores de pedidos de un grupo dado? Esto lo podemos lograr utilizando ciertas funciones especiales de SQL, cuya característica principal es que, en vez de recibir un valor escalar como argumento, reciben todo un conjunto de valores. Estas funciones reciben en inglés el nombre de aggregate functions, o funciones de agregados, aunque en castellano prefiero referirme a ellas como funciones estadísticas o de conjuntos. Son cinco las soportadas por SQL estándar:

Función Devuelve... count Cantidad de valores no nulos en el grupo min El valor mínimo de la columna dentro del grupo max El valor máximo de la columna dentro del grupo sum La suma de los valores de la columna dentro del grupo avg La media de los valores de la columna dentro del grupo

Page 82: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

82 La Cara Oculta de C#

A esta lista, SQL Server añade otras cuatro funciones: var, varp, stdev y stdevp, que calculan la varianza y la desviación estándar4.

Volviendo a lo nuestro, aunque SQL no acepta una columna OrderID desnuda en la cláusula select, sí que la permite siempre que la metamos dentro de una función de conjunto:

select Freight, min(OrderID), max(OrderID)

from Orders

group by Freight

Pero ni usted ni yo necesitamos el mínimo y el máximo identificador de pedido para cada grupo. Lo que necesitamos saber es cuántas filas tiene cada grupo, y eso nos lo puede calcular la siguiente instrucción:

select Freight, count(OrderID), count(*)

from Orders

group by Freight

order by 2 desc

Estamos viendo dos usos diferentes de la función count. En el primero de ellos pasamos un nombre de columna, y en el segundo, un simple asterisco. En este ejem-plo, ambas expresiones son equivalentes... porque la columna OrderID no admite valores nulos. Cuando pasamos una columna a count, en vez de un asterisco, el valor calculado es la cantidad de valores no nulos para esa columna dentro de cada grupo.

Las funciones estadísticas también pueden usarse sin que haya una cláusula group by en la consulta, aunque en ese caso tendremos algunas limitaciones lógicas:

select count(*), count(ShipRegion), count(distinct ShipRegion)

from Orders

En este caso, se considera que todas las filas devueltas por la combinación de las cláusulas from y where pertenecen a un mismo grupo. La limitación a la que hacía referencia es que en este tipo de consultas, todas las columnas de la cláusula select deben estar dentro de funciones estadísticas.

El ejemplo anterior, además, nos permite comprobar el comportamiento de los nulos con las funciones estadísticas y el uso de la opción distinct dentro de las mismas. El resultado de la ejecución de la consulta debe ser:

----------- ----------- -----------

830 323 19

La primera expresión cuenta el número total de filas de la tabla Orders. La segunda expresión calcula cuántas filas de pedidos tienen una región de envío no nula. Por

4 Sí, conozco la diferencia entre las funciones con p y sin p, pero es tan aburrida como las clases de Estadística que tuve que soportar en cierta universidad de cuyo nombre no quiero acordarme...

Page 83: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Consultas y actualizaciones 83

último, la tercera expresión devuelve el número de regiones no nulas diferentes alma-cenadas dentro de la tabla.

La cláusula having

En una instrucción select, lo primero que se evalúa es la cláusula from, porque in-dica qué tablas participan en la consulta. Luego se eliminan las filas que no satisfacen la cláusula where y, si hay un group by presente, se agrupan las filas resultantes. Hay una posibilidad adicional: después de agrupar se pueden descartar filas consolidadas de acuerdo a otra condición, esta vez expresada en una cláusula having. En having sólo pueden aparecer columnas agrupadas o funciones estadísticas aplicadas al resto de las columnas:

select Freight, count(*)

from Orders

group by Freight

having count(*) > 1

order by 2 desc

NO

TA

Una regla importante de optimización: si en la cláusula having existen condiciones que implican solamente a las columnas mencionadas en la cláusula group by, estas condi-ciones deben moverse a la cláusula where. Por ejemplo, si queremos eliminar de la con-sulta del ejemplo a las compañías cuyos nombres terminan con las siglas 'S.L.' debemos hacerlo en where, no en group by. ¿Para qué esperar a agrupar para eliminar filas que podían haberse descartado antes?

Gracias a esta cláusula, nuestra consulta sólo muestra, ¡finalmente!, los valores de gastos de envío que aparecen en más de un pedido. Es cierto que se trata de una consulta algo artificial, pero aquí tiene otro ejemplo, cortado con el mismo patrón:

select CustomerID, count(*)

from Orders

group by CustomerID

having count(*) > 1

order by 2 desc

Esta vez, mostramos los clientes que han realizado más de un pedido. En realidad, sólo estamos mostrando los códigos o identificadores de esos clientes. Para recuperar la información completa sobre esos clientes, necesitamos “combinar” el contenido de la tabla de pedidos con el de la tablas de clientes... y a la técnica necesaria dedica-remos las secciones que vienen a continuación.

Productos cartesianos

Como para casi todas las cosas, la gran virtud del modelo relacional es, a la vez, su mayor debilidad. Me refiero a que cualquier modelo del “mundo real” puede repre-sentarse atomizándolo en relaciones: objetos matemáticos simples y predecibles, de fácil implementación en un ordenador. Para reconstruir el modelo original, en cam-bio, necesitamos una operación conocida como “encuentro natural” (natural join).

Page 84: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

84 La Cara Oculta de C#

Comencemos con algo más sencillo, como los productos cartesianos. Un producto cartesiano es una operación matemática entre conjuntos, la cual produce todas las parejas posibles de elementos, perteneciendo el primer elemento de la pareja al pri-mer conjunto, y el segundo elemento de la pareja al segundo conjunto. Esta es la operación habitual que efectuamos mentalmente cuando nos ofrecen el menú en un restaurante. Los dos conjuntos son el de los primeros platos y el de los segundos platos. Desde la ventana de la habitación donde escribo puedo ver el menú del me-són de la esquina:

Primer plato Segundo plato Macarrones a la boloñesa Escalope a la milanesa Judías verdes con jamón Pollo a la parrilla Crema de champiñones Chuletas de cordero

Si PrimerPlato y SegundoPlato fuesen tablas de una base de datos, la instrucción

select *

from PrimerPlato, SegundoPlato

devolvería el siguiente conjunto de filas:

Primer plato Segundo plato Macarrones a la boloñesa Escalope a la milanesa Macarrones a la boloñesa Pollo a la parrilla Macarrones a la boloñesa Chuletas de cordero Judías verdes con jamón Escalope a la milanesa Judías verdes con jamón Pollo a la parrilla Judías verdes con jamón Chuletas de cordero Crema de champiñones Escalope a la milanesa Crema de champiñones Pollo a la parrilla Crema de champiñones Chuletas de cordero

Es fácil ver que, incluso con tablas pequeñas, el tamaño del resultado de un producto cartesiano es enorme. Si a este ejemplo “real” le añadimos el hecho también “real” de que el mismo mesón ofrece al menos tres tipos diferentes de postres, elegir nues-tro menú significa seleccionar entre 27 posibilidades distintas. Por eso siempre pido café al terminar con el segundo plato.

Encuentros naturales

No todas las combinaciones de platos hacen una buena comida. Para descartar los bodrios indigestos tenemos la cláusula where: para eliminar aquellas combinaciones que no satisfacen ciertos criterios. ¿Volvemos al mundo de las facturas y órdenes de compra? En la base de datos Northwind, la información sobre pedidos está en la tabla Orders, mientras que los datos sobre cliente se encuentran en Customers. Queremos obtener la lista de pedidos, junto con los datos del cliente que realizó cada uno de ellos. El problema está en que la tabla Orders solamente almacena el código del cliente, en el campo CustomerID. Los nombres de clientes se encuentran en el campo CompanyName de la tabla Customers, donde además volvemos a encontrar el código de cliente, CustomerID. Así que partimos de un producto cartesiano entre las dos tablas,

Page 85: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Consultas y actualizaciones 85

en el cual mostramos los nombres de clientes, los números y las fechas de los pedi-dos:

select CompanyName, OrderID, OrderDate

from Customers, Orders

Como tenemos 91 clientes y 830 pedidos, esta consulta aparentemente inocente ge-nera nada menos que unas 75.530 filas. La última vez que hice algo así fue en mi época de estudiante, en el aula de ordenadores de mi universidad, para demostrarle a una profesora de Filosofía lo ocupado que estaba en ese momento.

En realidad, de esas 75.530 filas nos sobran unas 74.700, pues solamente son válidas las combinaciones en las que coinciden los códigos de cliente. La instrucción que necesitamos es:

select CompanyName, OrderID, OrderDate

from Customers, Orders

where Customers.CustomerID = Orders.CustomerID

Esto es un encuentro natural, un producto cartesiano restringido mediante la igualdad de los valores de dos columnas de las tablas básicas. Da la “casualidad” de que las columnas que se comparan tienen el mismo nombre, aunque pertenecen a diferentes tablas, pero en otros casos podrían tener nombres distintos. Está claro entonces que ambas columnas deben tener algún otro tipo de vínculo común. Y es así en realidad: hay una relación de integridad referencial para la columna CustomerID de la tabla de pedidos, que tiene que coincidir con algún valor almacenado en la columna Customer-ID de la tabla de clientes.

La siguiente imagen, creada con la herramienta de edición de diagramas del Admi-nistrador Corporativo de SQL Server, muestra dónde hay que ir a buscar dicha rela-ción:

NO

TA

El ejemplo anterior, además, ilustra un detalle importante: cuando queremos utilizar en la instrucción el nombre de los campos OrderDate y CompanyName los escribimos tal y como son. Sin embargo, cuando hacemos referencia a CustumerID hay que aclarar a cuál de las tablas pertenece. Esta técnica se conoce como cualificación de columnas.

Page 86: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

86 La Cara Oculta de C#

¿Un ejemplo más complejo? Suponga que deseamos un listado en el que aparezca cada empleado junto a los productos que ha vendido. Los nombres y apellidos de los empleados se almacenan en la tabla Employees, mientras que los nombres de produc-tos se guardan en Products. El problema está en que no hay una relación directa entre empleados y productos, sino una muy indirecta, como muestra el diagrama:

En términos prácticos, esto quiere decir que tenemos que añadir también las tablas intermedias a la consulta:

select employees.FirstName + ' ' + employees.LastName,

ProductName

from employees, orders, [order details], products

where employees.CustomerID = orders.CustomerID and

orders.OrderID = [Order details].OrderID and

[Order details].ProductID = products.ProductID

Observe que hay tres “vínculos” entre las cuatro tablas del diagrama; no estoy con-tando la autoreferencia de la tabla de empleados, porque no la necesitamos. Esos tres enlaces tienen un equivalente en las tres comparaciones que aparecen en la cláusula where de la consulta final.

Sinónimos para tablas

Es posible utilizar dos o más veces una misma tabla en una misma consulta. Si ha-cemos esto tendremos que utilizar sinónimos para distinguir entre los distintos usos de la tabla en cuestión. Esto será necesario al calificar los campos que utilicemos. Un sinónimo es simplemente un nombre que colocamos a continuación del nombre de una tabla en la cláusula from, y que en adelante se usa como sustituto del nombre de la tabla.

Por ejemplo, si quisiéramos averiguar si hemos introducido por error dos veces a la misma compañía en la tabla de clientes, pudiéramos utilizar la instrucción:

select distinct C1.CompanyName

from Customers C1, Customers C2

where C1.CustomerID < C2.CustomerID and

C1.CompanyName = C2.CompanyName

Page 87: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Consultas y actualizaciones 87

En esta consulta, C1 y C2 se utilizan como sinónimos de la primera y segunda apari-ción, respectivamente, de la tabla Customers. La lógica de la consulta es sencilla: busca-mos todos los pares que comparten el mismo nombre de compañía y eliminamos aquellos que tienen el mismo código de compañía. Pero en vez de utilizar una desi-gualdad en la comparación de códigos, utilizamos el operador “menor que”, para eliminar la aparición de pares dobles en el resultado previo a la aplicación del opera-dor distinct. Claro, estamos aprovechando la unicidad del campo CustomerID.

¿Otro ejemplo? Queremos mostrar los empleados junto con los jefes de sus depar-tamentos:

select e2.FirstName + ' ' + e2.LastName as Jefe,

e1.FirstName + ' ' + e1.LastName as Empleado

from Employees as e1, Employees as e2

where e1.ReportsTo = e2.EmployeeID

order by 1, 2

En este caso, además, hemos usado sinónimos para las columnas resultantes de la consulta. La palabra clave as, tanto en las columnas como en las tablas, es superflua, y sólo se ha incluido buscando más claridad.

La sintaxis especial del encuentro natural

La última consulta de la sección anterior también puede escribirse así:

select e2.FirstName + ' ' + e2.LastName as Jefe,

e1.FirstName + ' ' + e1.LastName as Empleado

from Employees as e1 inner join Employees as e2

on e1.ReportsTo = e2.EmployeeID

order by 1, 2

En vez de separar las dos tablas con una coma, las hemos enlazados con el operador inner join. Este es un operador ternario, porque inmediatamente después de la se-gunda tabla debe venir el tercer argumento, en forma de una cláusula on. El conte-nido de la cláusula es la condición que define el encuentro entre las tablas.

Antes de explicar el motivo tras esta extraña sintaxis, veamos un ejemplo más com-plicado, como el listado de empleados y los productos que han vendido:

select employees.FirstName + ' ' + employees.LastName,

ProductName

from employees inner join

orders on

employees.CustomerID = orders.CustomerID inner join

[order details] on

orders.OrderID = [Order details].OrderID inner join

products on

[Order details].ProductID = products.ProductID

No haga demasiado caso a la forma en que he indentado la consulta: mi objetivo ha sido que cada una de las cuatro tablas involucradas se escriba en una línea separada para poderlas identificar con facilidad. La presencia de la cláusula on complica el

Page 88: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

88 La Cara Oculta de C#

formato, pero he logrado que cada una de las tres condiciones vaya también en una línea aparte. Usted es libre de escribir la consulta como le dé la gana.

¿Para qué sirve esta peculiar sintaxis? Hay un par de razones:

• El operador inner join nos permite separar las condiciones exigidas por la lógica del encuentro natural, de las restantes condiciones de la cláusula where, que casi siempre actúan como un filtro posterior al encuentro. Observe que en los dos ejemplos mostrados, la cláusula where ha desaparecido por completo.

• El operador inner join es asociativo, lo que significa que al escribir nuestra ca-dena de cuatro tablas enlazadas en tres encuentros no hemos necesitado parénte-sis para precisar el orden de evaluación. Da lo mismo unir empleados con pedi-dos, luego con detalles y finalmente con productos, que comenzar uniendo deta-lles con productos, luego con pedidos y terminar con empleados. Más adelante, sin embargo, estudiaremos otra operación entre tablas, llamada encuentro externo. Algunas variantes de esta operación pueden incluso violar la conmutatividad. Al disponer de un operador para los encuentros naturales, podemos controlar mejor el orden de ejecución y evitar ambigüedades.

Subconsultas basadas en selecciones singulares

Si nos piden el coste total de los gastos de envío de los pedidos realizados por una compañía determinada, digamos que Pericles Comidas clásicas, podemos resolverlo eje-cutando dos instrucciones diferentes. Con la primera de ellas obtendríamos el código de la compañía:

select Customers.CustomerID

from Customers

where Customers.CompanyName = 'Pericles Comidas clásicas'

Suponga que encontramos el código PERIC. Con este valor en la mano, ejecutamos la siguiente instrucción:

select sum(Orders.Freight)

from Orders

where Orders.CustomerID = 'PERIC'

Aprovechando la técnica de los lotes de consulta de Transact SQL, incluso podría-mos enviar las dos instrucciones al servidor en una misma operación, usando varia-bles para almacenar el resultado intermedio:

declare @customerID nchar(5)

select @customerID = Customers.CustomerID

from Customers

where Customers.CompanyName = 'Pericles Comidas clásicas'

select sum(Orders.Freight)

from Orders

where Orders.CustomerID = @customerID

Page 89: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Consultas y actualizaciones 89

A pesar de esta pequeña mejora, sigue siendo una técnica incómoda y poco elegante. La alternativa es utilizar la primera instrucción como una expresión dentro de la se-gunda, del siguiente modo:

select sum(Orders.Freight)

from Orders

where Orders.CustomerID = (

select Customers.CustomerID

from Customers

where Customers.CompanyName = 'Pericles Comidas clásicas')

Para que la subconsulta anterior pueda funcionar correctamente, hemos asumido que el conjunto de datos retornado por la subconsulta producirá una sola fila. Es una apuesta arriesgada que puede fallar por dos motivos: puede que la subconsulta no de-vuelva ningún valor o puede que devuelva más de uno. Si no se devuelve ningún va-lor, se considera que la subconsulta devuelve el valor null. Si devuelve dos o más valores, el intérprete señalará un error en tiempo de ejecución.

A este tipo de subconsulta que debe retornar un solo valor se le denomina selección singular, en inglés, singleton select. Las selecciones únicas también pueden utilizarse con otros operadores de comparación, además de la igualdad. Así por ejemplo, la si-guiente consulta retorna información sobre los empleados contratados después que Nancy Davolio:

select *

from Employees E1

where E1.HireDate > (

select E2.HireDate

from Employees E2

where E2.FirstName = 'Nancy' and

E2.LastName = 'Davolio')

Si está preguntándose acerca de la posibilidad de cambiar el orden de los operandos, ni lo sueñe. La sintaxis de SQL es muy rígida, y no permite este tipo de virtuosismos.

Los operadores in y exists

En el ejemplo anterior garantizábamos la singularidad de la subconsulta gracias a la cláusula where, que especificaba una búsqueda sobre una clave única. Sin embargo, también se pueden aprovechar las situaciones en que una subconsulta devuelve un conjunto de valores. En este caso, el operador a utilizar cambia. Por ejemplo, si que-remos los pedidos correspondientes a compañías suecas, podemos utilizar la ins-trucción:

select *

from Orders

where Orders.CustomerID in (

select Customers.CustomerID

from Customers

where Country = 'Sweden')

Page 90: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

90 La Cara Oculta de C#

El nuevo operador es el operador in, y la expresión es verdadera si el operando iz-quierdo se encuentra en la lista de valores retornada por la subconsulta. Esta consulta puede evaluarse en dos fases. Durante la primera fase se evalúa el select interno:

select Customers.CustomerID

from Customers

where Country = 'Sweden'

El resultado de esta consulta consiste en una serie de códigos: aquellos que corres-ponden a las compañías suecas. En Northwind, estos códigos son FOLKO y BERGS. Entonces puede ejecutarse la segunda fase de la consulta, con la siguiente instruc-ción, equivalente a la original:

select *

from Orders

where Orders.CustomerID in ('FOLKO', 'BERGS')

Este otro ejemplo utiliza la negación del operador in. Si queremos las compañías que no nos han comprado nada, hay que utilizar la siguiente instrucción:

select *

from Customers

where Customers.CustomerID not in (

select Orders.CustomerID

from Orders)

Otra forma de plantearse las consultas anteriores es utilizando el operador exists. Este operador se aplica a una subconsulta y devuelve verdadero en cuanto localiza una fila que satisface las condiciones de la instrucción select. El primer ejemplo de este epígrafe puede también escribirse así:

select *

from Orders

where exists (

select *

from Customers

where Country = 'Sweden' and

Orders.CustomerID = Customers.CustomerID)

Observe el asterisco en la cláusula select de la subconsulta. Como lo que nos inte-resa es saber si existen filas que satisfacen la expresión, nos da lo mismo qué valor se está retornando. El segundo ejemplo del operador in se convierte en la siguiente instrucción al echar mano de exists:

select *

from Customers

where not exists (

select *

from Orders

where Orders.CustomerID = Customers.CustomerID)

Page 91: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Consultas y actualizaciones 91

Subconsultas correlacionadas

Preste atención al siguiente detalle: la última subconsulta de la sección anterior tiene una referencia a una columna perteneciente a la tabla definida en la cláusula from más externa. Esto quiere decir que no podemos explicar el funcionamiento de la instrucción dividiéndola en dos fases, como con las selecciones únicas: la ejecución de la subconsulta y la simplificación de la instrucción externa. En este caso, para cada fila retornada por la cláusula from externa, que solo contiene la tabla Customers, hay que volver a evaluar la subconsulta teniendo en cuenta los valores actuales: los de la columna CustomerID de la tabla de clientes. A este tipo de subconsultas se les deno-mina subconsultas correlacionadas.

Si tuviéramos que mostrar los clientes que han pedido algo desde Colchester, podría-mos ejecutar la siguiente consulta, basada en una subselección correlacionada:

select *

from Customers

where 'Colchester' in (

select distinct ShipCity

from Orders

where Orders.CustomerID = Customers.CustomerID)

Otra subconsulta correlacionada: queremos los clientes que no han comprado nada aún. Ya vimos como hacerlo utilizando el operador not in ó el operador not exists. Esta es otra alternativa:

select *

from Customers

where 0 = (

select count(*)

from Orders

where Orders.CustomerID = Customers.CustomerID)

Sin embargo, en la mayoría de los sistemas de bases de datos, esta consulta es más lenta que las otras dos soluciones. Informalmente, las soluciones basadas en in o exists sólo necesitan localizar un pedido para rechazar el cliente que está exami-nando. En cambio, la consulta anterior obliga a contar los pedidos para cada cliente.

NO

TA

El concepto de subconsulta correlacionada es importante porque algunos sistemas de bases de datos no pueden actualizar vistas en cuya definición se han utilizado subcon-sultas de este tipo.

Subconsultas como tablas virtuales

¿Qué tipo de elementos son los que aparecen en una cláusula from? Tablas, eviden-temente. Pero, ¿no son las tablas, acaso, una forma especial de relación? ¿Por qué no podemos usar, en vez de un tabla, una expresión relacional cualquiera? El único mo-tivo que lo impide es que muchos compiladores SQL son realmente malos. No es el caso del compilador de Transact SQL.

Page 92: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

92 La Cara Oculta de C#

Suponga que debemos imprimir un listado con todos los registros de una tabla, y que debemos dividir la tabla en páginas, cada una con un determinado número de regis-tros. Para concretar, digamos que se trata de la tabla Customers, que el listado va a estar ordenador por el identificador del cliente, CustomerID, y que cada página debe contener diez registros. ¿Podemos escribir una consulta que devuelva solamente los registros de la tercera página?

Con la cláusula top, podemos recuperar los primeros treinta registros con facilidad:

select top 30 *

from CUSTOMERS

order by CustomerID asc

Pero en el resultado anterior se nos han colado veinte registros espurios: los que corresponden a la primera y la segunda página. ¿Podríamos recuperar solamente los diez últimos registros de la consulta anterior? Observe que no pido los diez últimos registros de la tabla, sino los de un resultado parcial; es decir, de una expresión rela-cional. Todo esto huele sospechosamente a manipulación de la cláusula from... y no se equivoca:

select top 10 *

from (select top 30 * from CUSTOMERS order by CustomerID asc) C

order by C.CustomerID desc

En la porción subrayada de la anterior consulta se puede reconocer la primera con-sulta de esta sección. Simplemente la hemos encerrado entre paréntesis y le hemos asignado un sinónimo, C en este caso. La evaluación de esta consulta, como un todo, comenzaría por calcular la expresión relacional: los primeros treinta registros. A con-tinuación, se seleccionarían los diez últimos registros de ese resultado parcial... y he aquí la tercera página de registros. Por supuesto, esta explicación sólo pretende dar una idea sobre cómo puede evaluarse la consulta. En la práctica, el optimizador de SQL Server puede cambiar bastante el algoritmo.

Hay dos problemas, de todos modos, en la consulta a la que hemos llegado. Uno de ellos podría ocurrir cuando nos aproximásemos al final de la tabla. La tabla Customers, por ejemplo, tiene 91 registros. Supongamos que las páginas deben tener 20 registros. Si pedimos la quinta página usando el algoritmo explicado, obtendríamos en realidad los últimos veinte registros... que contendrían nueve registros de la cuarta página. Si queremos dar un uso práctico a esta técnica, tenemos que conocer de antemano el número total de registros de la tabla. Para todas las páginas, excepto la última, pode-mos utilizar la técnica anterior de forma directa. Para la última página, la instrucción sería diferente:

select *

from (select top 11 * from CUSTOMERS order by CustomerID desc) C

order by C.CustomerID asc

Esta vez no hay cláusula top en la consulta exterior. Sabiendo que hay 91 registros en total, la última página debe tener 11 registros: el resto de la división entera del total entre la cantidad de registros por página. Ordenamos descendentemente la tabla, y

Page 93: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Consultas y actualizaciones 93

con la ayuda de top nos quedamos con esos once últimos registros. ¿Para qué, en-tonces, necesitamos la consulta exterior?

Esto nos lleva al segundo problema de la técnica: en la consulta original, los registros aparecen en orden inverso, porque la consulta exterior los ordena descendentemente. Es lo mismo que sucede en la consulta que devuelve la última página; en ese caso, añadimos una consulta exterior para devolver al resultado el orden ascendente. Lo mismo podríamos hacer con la consulta general:

select *

from (select top 10 *

from (select top 30 *

from CUSTOMERS

order by CustomerID asc) C1

order by C1.CustomerID desc) C2

order by C2.CustomerID asc

Lamentablemente, algún problema interno del compilador de SQL Server hace que la consulta más externa no surta efecto alguno. He descubierto que el problema se soluciona cuando en ese nivel se mencionan explícitamente los nombres de las co-lumnas:

select CustomerID, CompanyName, … Phone, Fax

from (select top 10 *

from (select top 30 *

from CUSTOMERS

order by CustomerID asc) C1

order by C1.CustomerID desc) C2

order by C2.CustomerID asc

Otra posibilidad sería dejar los registros ordenados en forma descendente. Si la con-sulta va a ser utilizada por una aplicación, probablemente a ésta le sea muy sencillo invertir el orden de los registros. Así evitaríamos, de pasada, sobrecargar innecesaria-mente el servidor SQL.

Incluso hay una tercera vía: conociendo el total de registros, es factible plantear el algoritmo a la inversa: ¿necesitamos la tercera página, de diez registros, de una tabla con 91 registros? Podemos pedir los últimos 71 registros (haga sus cuentas), para luego invertir su orden y quedarnos con los 10 primeros:

select top 10 CustomerID, CompanyName, … Phone, Fax

from (select top 71 * from CUSTOMERS order by CustomerID desc) C

order by C.CustomerID asc

Note que he tenido, otra vez, que incluir explícitamente los nombres de columnas.

NO

TA

¿Es esta una técnica eficiente? Sólo puedo responder por mis propios experimentos. Para comprobarlo, utilicé una tabla con unos 18.000 registros y con muchas columnas. Me sorprendió la rapidez con la que el servidor evaluó todas las consultas de esta sec-ción. Es cierto que 18.000 registros no es mucho, pero se trataba de registros de longitud considerable. Puede que a partir de cierto tamaño crítico, las cosas cambien. Pero eso lo tendrá que determinar usted mismo experimentando con sus propios datos.

Page 94: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

94 La Cara Oculta de C#

Encuentros externos

Cuando relacionamos dos tablas, como Customers y Orders, mediante un encuentro natural, sólo mostramos las filas que tienen el valor de una columna en común. No hay forma de mostrar los clientes que no tienen un pedido asociado a su código... y solamente esos. Podríamos utilizar la operación de diferencia entre conjuntos para lograr este objetivo, como veremos en breve. Podríamos evaluar todos los clientes, y a ese conjunto restarle el de los clientes que sí tienen pedidos. Pero esta operación, por lo general, se implementa de forma menos eficiente que la alternativa que mos-traremos a continuación.

¿Cómo funciona un encuentro natural? La técnica de explicación más sencilla con-siste en recorrer mediante un bucle la primera tabla, supongamos que sea Customers. Para cada fila de esa tabla tomaríamos su columna CustumerID y buscaríamos, posi-blemente con un índice, las filas correspondientes de Orders que contengan ese mismo valor en la columna del mismo nombre. ¿Qué pasa si no hay ninguna fila en Orders que satisfaga dicha condición? Si se trata de un encuentro natural, común y corriente, no se muestran los datos de ese cliente. Pero si se trata de la extensión de esta operación, conocida como encuentro externo (outer join), se muestra aunque sea una vez la fila correspondiente al cliente. Un encuentro muestra, sin embargo, pares de filas, ¿qué valores podemos esperar en la fila de pedidos? En ese caso, se considera que todas las columnas de la tabla de pedidos tienen valores nulos. Si tuviésemos dos tablas como las siguientes:

CUSTOMERS ORDERS CustomerID CompanyName OrderID CustomerID GALED Galería del Gastrónomo 10.366 GALED MARTB Marteens’ Burgers 10.426 GALED

el resultado de un encuentro externo como el que hemos descrito, de acuerdo a la columna CustomerID, sería el siguiente:

Customers.CustomerID CompanyName OrderID Orders.CustomerID GALED Galería del Gastrónomo 10.366 GALED GALED Galería del Gastrónomo 10.426 GALED MARTB Marteens’ Burgers null null

Con este resultado en la mano, es fácil descubrir quién es el tacaño que no nos ha pedido nada todavía, dejando solamente las filas que tengan valores nulos para al-guna de las columnas de la segunda tabla.

La consulta que necesitamos se puede escribir usando la siguiente sintaxis:

select *

from CUSTOMERS left outer join ORDERS

on CUSTOMERS.CustomerID = ORDERS.CustomerID

where Orders.OrderID is null

¡La extraña sintaxis de los encuentros internos, aunque transfigurada, ataca de nuevo! Aquí podemos ver una de sus ventajas: están limpiamente separadas la condición que

Page 95: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Consultas y actualizaciones 95

relaciona las tablas por el código de cliente, del filtro que selecciona aquellas filas cuyo identificador de pedido es nulo.

NO

TA

El encuentro externo que hemos explicado es, en realidad, un encuentro externo por la iz-quierda, pues todas las filas de la primera tabla pasan al resultado final, aunque no exis-tan filas correspondientes en la segunda. Naturalmente, también existe un encuentro externo por la derecha y un encuentro externo simétrico.

La inmensa mayoría de los casos prácticos de uso de encuentros externos tienen que ver con la generación de informes. Pongamos por caso que tenemos una tabla de clientes y una tabla relacionada de teléfonos, asumiendo que un cliente puede tener asociado un número de teléfono, varios o ninguno. Si queremos listar los clientes y sus números de teléfono y utilizamos un encuentro natural, aquellos clientes de los que desconocemos el teléfono no aparecerán en el listado. Sería necesario recurrir a un encuentro externo por la izquierda.

Instrucciones para actualizaciones

El objetivo del álgebra relacional es la creación de nuevas relaciones con la ayuda de operadores relacionales. Por esto, es curioso ver que su principal aplicación práctica, al menos en SQL, sea el lenguaje de consultas, en vez del sublenguaje de actualiza-ción de relaciones, que históricamente ha sido el menos potente de los dos. Proba-blemente esto se debe a que las relaciones con las que trabaja el álgebra relacional son relaciones “virtuales”, mientras que para actualizar datos tenemos que lidiar con relaciones almacenadas físicamente.

Desde siempre, SQL ha ofrecido tres instrucciones de actualización: update, para las modificaciones, insert, para crear registros, y delete, para enviarlos al paraíso de Turing, si se han portado bien en vida. La sintaxis básica, establecida por SQL están-dar, es muy sencilla. Por ejemplo, ésta es la de las modificaciones:

update tabla

set columna1 = expresión

1,

columna2 = expresión

2,

columna3 = expresión

3 …

where condición

La sintaxis de delete es aún más simple:

delete from tabla

where condición

Y las inserciones sólo se complican al existir dos variantes de la correspondiente instrucción. La primera de ellas sirve para crear una sola fila:

insert into tabla(columna1, columna

2, columna

3 …)

values (expresión1, expresión

2, expresión

3 …)

La segunda sirve para copiar en una tabla registros provenientes de una consulta:

Page 96: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

96 La Cara Oculta de C#

insert into tabla(columna1, columna

2, columna

3 …)

consulta-select

En SQL Server, además, podemos insertar en una tabla registros devueltos por un procedimiento almacenado:

insert into tabla(columna1, columna

2, columna

3 …)

execute procedimiento parámetros-procedimiento

Del mismo modo, SQL Server añade mejoras a las instrucciones básicas de borrado y modificación. Dedicaremos el resto de este capítulo a presentar las más útiles.

Modificaciones basadas en encuentros

Las primeras implementaciones de SQL limitaban excesivamente las posibilidades de la instrucción update. Una de esas restricciones exigía que no se incluyesen subcon-sultas en la cláusula where de la instrucción. Incluso según la primera versión del es-tándar de SQL, un compilador podía alegar ser “compatible con el estándar” aunque no permitiese este tipo de condiciones de búsqueda. A medida que fue pasando el tiempo, las implementaciones mejoraron, y la mayoría de los sistemas de bases de datos pasaron a permitir subconsultas, no sólo en la cláusula where, sino también en la cláusula set. Eche un vistazo al siguiente ejemplo:

update dbo.Products

set UnitsOnOrder = UnitsOnOrder + (

select Quantity

from dbo.[Order Details]

where ProductID = dbo.Products.ProductID and

OrderID = 9867)

where ProductID in (

select ProductID

from dbo.[Order Details]

where OrderID = 9867)

Esta instrucción busca los productos incluidos en el pedido número 9.867, y para ello se apoya en una subconsulta dentro de la condición de búsqueda. Para cada pro-ducto encontrado, se modifica el número de unidades bajo pedido, y para ello vuelve a consultar la tabla de detalles de pedidos. Ejecutamos nuevamente la misma subcon-sulta, sólo que esta vez la empleamos para devolver el número de unidades del pro-ducto dentro del pedido mencionado.

¿Dos subconsultas casi idénticas? Veamos cómo SQL Server 2000 implementa la instrucción anterior:

Page 97: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Consultas y actualizaciones 97

Para obtener el gráfico anterior, he utilizado el Analizador de Consultas. He tecleado la consulta, pero en vez de evaluarla normalmente, he ejecutado el comando de menú Display Estimated Execution Plan dentro del menú Query. Aunque es difícil apreciar los detalles de la imagen, se ve con claridad que la instrucción requiere dos bucles anida-dos (nested loops). Podemos explicar el algoritmo, de forma aproximada, diciendo que SQL Server recorre las filas de productos, y para cada producto busca en la tabla de detalles un registro asociado. Luego, vuelve a buscar dentro de los detalles, pero esta vez su objetivo es averiguar la cantidad de unidades vendidas del producto. Sin em-bargo, el registro de detalles es el mismo devuelto por la primera búsqueda...

Es posible que alguna versión futura de SQL Server reconozca esta oportunidad de mejorar el algoritmo: precisamente, una de las ventajas de disponer de un lenguaje de consultas declarativo como SQL es no tener que preocuparnos por los detalles de implementación. Por fortuna, para los que no queremos envejecer esperando, SQL Server nos ofrece una extensión de la sintaxis de update que nos permite resolver el problema eficientemente:

update dbo.Products

set UnitsOnOrder = UnitsOnOrder + det.Quantity

from dbo.[Order Details] det

where dbo.Products.ProductID = det.ProductID and

det.OrderID = 9876

Como puede ver, a la sentencia update le ha brotado una cláusula from que no existe en el estándar. Para entender qué es lo que está pasando, veamos qué nos dice el plan de ejecución de la instrucción:

Esta vez, sólo ha quedado un bucle anidado entre detalles y productos. SQL Server recorre la tabla de productos, que es la que mencionamos al principio de update. Para cada productos, localizamos el registro de detalles asociado, si es que existe. En caso afirmativo, el valor de la columna Quantity del registro encontrado se utiliza directamente para modificar la columna UnitsOnOrder del producto.

Modificar y leer

El truco que acabamos de ver se basa en evitar la búsqueda de un registro que ya ha sido leído. Hay otro truco parecido, aunque no se aplica a instrucciones aisladas, sino a grupos de instrucciones, por lo que será más útil cuando hayamos estudiado los procedimientos almacenados.

Suponga que estamos escribiendo una aplicación de facturación, con soporte para varias tiendas. La información sobre tiendas se guarda en una tabla llamada shops. Cada registro de esta tabla almacena en una columna llamada NextOrder el número

Page 98: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

98 La Cara Oculta de C#

correspondiente al siguiente pedido de la tienda correspondiente. Cada vez que se crea un pedido, tenemos que leer el valor de NextOrder para la tienda y copiarlo en una variable de Transact SQL, y acto seguido tenemos que incrementar ese valor. Esta es la técnica habitual:

declare @orderNo integer

// Leemos el número que le corresponde al nuevo pedido

select @orderNo = NextOrder

from dbo.shops

where ShopID = 34

// Incrementamos el contador en el registro de la tienda

update dbo.shops

set NextOrder = NextOrder + 1

where ShopID = 34

Aquí estamos usando un par extensiones de Transact SQL que veremos en detalle en el capítulo sobre procedimientos almacenados. Primero declaramos una variable local llamada @orderNo, de tipo entero. La siguiente instrucción es una variante de select que permite copiar el valor de una columna en un registro determinado en una varia-ble de tipo adecuado. A continuación, actualizamos la columna NextOrder en el re-gistro de la tienda. Se supone que el valor recuperado en @orderNo se utiliza más ade-lante para crear el registro del nuevo pedido.

¿Ve cuál es el problema? Primero leemos un registro de la tabla de tiendas, seleccio-nándolo mediante la condición de la cláusula where. Sin embargo, a continuación debemos volver a buscar dicho registro, esta vez para actualizarlo. Son dos lecturas independientes, aunque el hecho no es tan grave gracias a la existencia de una caché de datos en el servidor. De todos modos, SQL Server nos permite sacar factor co-mún nuevamente, fundiendo la selección y la actualización en una sola sentencia:

update dbo.shops

set @orderNo = NextOrder,

NextOrder = NextOrder + 1

where ShopID = 34

Observe que la primera asignación de la cláusula set no tiene la forma habitual. En vez de asignar una expresión a una columna, estamos asignando la expresión a una variable. El efecto es el que puede imaginar: cuando se localiza el registro mediante la cláusula where, primero se guarda en la variable el valor de NextOrder, y a continua-ción, incrementamos el valor de la columna. Nos ha bastado con una sola lectura.

Un detalle importante: el orden en que escribimos las asignaciones no es significa-tivo. SQL Server asignará primero las variables, y sólo después modificará las colum-nas. La siguiente instrucción es completamente equivalente a la anterior:

update dbo.shops

set @orderNo = NextOrder,

NextOrder = NextOrder + 1

where ShopID = 34

Page 99: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Consultas y actualizaciones 99

Ahora bien, existe otra variante de la misma técnica. Supongamos que en la tabla shops no guardamos el número del siguiente pedido, sino el del último pedido. Está claro que podemos apañarnos con el truco que ya conocemos: obtenemos el valor almacenado en la columna, aunque ahora tendremos que sumarle uno antes de usarlo. Pero SQL Server nos ofrece otra posibilidad:

update dbo.shops

set @orderNo = NextOrder = NextOrder + 1

where ShopID = 34

Extraño, ¿verdad? Esta vez, se actualiza la columna en primer lugar, y sólo después se copia su valor modificado en la variable.

Page 100: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

6

Procedimientos almacenados

NA DE LAS DIFERENCIAS MÁS NOTABLES ENTRE LOS SISTEMAS de bases de datos SQL y las bases de datos de escritorio, como Access o el ya obsoleto

dBase, es que los primeros soportan la creación y ejecución de procedimientos alma-cenados: instrucciones agrupadas bajo un nombre simbólico, que elevan el nivel de abstracción dentro de las bases de datos y mejoran el rendimiento global. A este importante recurso dedicaremos el presente capítulo.

¿Por qué son importantes los procedimientos?

Un procedimiento almacenado (stored procedure) es simplemente un bloque de instrucciones SQL cuya definición reside en la base de datos, y que debe ser ejecutado en el propio servidor. Las instrucciones permitidas en estos bloques incluyen la mayoría de las operaciones de manipulación de datos de Transact SQL: recuperación de registros, inserción, borrado y modificación. También se añaden instrucciones para controlar el flujo de ejecución. Con esta técnica se pueden superar algunas de las limitaciones de los sistemas puramente relacionales.

Hay una larga lista de motivos para usar procedimientos almacenados, pero sólo mencionaré los más evidentes:

• Los procedimientos almacenados elevan el nivel de abstracción y ahorran tiempo de desarrollo.

En un entorno cliente/servidor es típico que varias aplicaciones diferentes tra-bajen con las mismas bases de datos. Si centralizamos en la propia base de datos la imposición de las reglas de consistencia, no tendremos que volverlas a pro-gramar de una aplicación a otra. Además, evitamos los riesgos de una mala codi-ficación de estas reglas, con la consiguiente pérdida de consistencia.

• Los procedimientos almacenados ayudan a mantener la consistencia de la base de datos.

Las instrucciones básicas de actualización, update, insert y delete, pueden combinarse arbitrariamente si dejamos que el usuario tenga acceso ilimitado a las mismas. No toda combinación de actualizaciones cumplirá con las reglas de con-

U

Page 101: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Procedimientos almacenados 101

sistencia de la base de datos. Hemos visto que algunas de estas reglas se pueden expresar declarativamente durante la definición del esquema relacional. El mejor ejemplo son las restricciones de integridad referencial. Pero, ¿cómo expresar de-clarativamente que para cada artículo presente en un pedido, debe existir un re-gistro correspondiente en la tabla de movimientos de un almacén? Una posible solución es prohibir el uso directo de las instrucciones de actualización, revo-cando permisos de acceso al público, y permitir la modificación de datos sola-mente a partir de procedimientos almacenados.

• Los procedimientos almacenados permiten superar las limitaciones del lenguaje de consultas.

SQL no es un lenguaje completo. Un área típica en la que falla es en la definición de clausuras transitivas. Tomemos como ejemplo una tabla con dos columnas: Ob-jeto y Parte. Esta tabla contiene pares como los siguientes:

Objeto Parte Cuerpo humano Cabeza Cuerpo humano Tronco Cabeza Ojos Cabeza Boca Boca Dientes

¿Puede indicarme una consulta que liste todas las partes incluidas en la cabeza? Lo que falla es la posibilidad de expresar algoritmos recursivos (aunque SQL Server 2003 trae novedades al respecto). Para resolver esta situación, los procedi-mientos almacenados pueden implementarse de forma tal que devuelvan con-juntos de datos, en vez de valores escalares. En el cuerpo de estos procedimien-tos se pueden realizar entonces las llamadas recursivas.

• Los procedimientos almacenados pueden reducir el tráfico en la red.

Un procedimiento almacenado se ejecuta en el servidor, que es precisamente donde se encuentran los datos. Por lo tanto, no tenemos que explorar una tabla de arriba a abajo desde un ordenador cliente para extraer el promedio de ventas por empleado durante el mes pasado. Además, por regla general el servidor es una máquina más potente que las estaciones de trabajo, por lo que puede que ahorremos tiempo de ejecución para una petición de información. No conviene, sin embargo, abusar de esta última posibilidad, porque una de las ventajas de una red consiste en distribuir el tiempo de procesador.

• Los procedimientos almacenados se ejecutan más eficientemente que las mismas instrucciones que los componen cuando se ejecutan independientemente.

Siempre que ejecutamos una consulta o cualquier otro tipo de instrucción, SQL Server calcula el plan de ejecución óptimo, y traduce la instrucción a un código interpretable interno. Lo mismo sucede cuando se ejecuta un procedimiento al-macenado. La diferencia consiste en que, al existir un nombre simbólico para el procedimiento almacenado, SQL Server puede detectar la presencia en memoria

Page 102: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

102 La Cara Oculta de C#

de un procedimiento almacenado ya optimizado y compilado, y evitar repetir es-tas operaciones, que suelen consumir tiempo y recursos.

Cómo nos comunicamos con un procedimiento

Está claro que, para que la ejecución de procedimientos en el servidor sea un recurso potente, debemos tener algún medio para transmitir información al procedimiento y para recuperarla, después de su ejecución. Y no hace falta ser Sherlock Holmes para comprender que la mayor parte de esta comunicación tiene lugar por medio de dis-tintas variedades de parámetros.

El modelo de comunicación con procedimientos de Transact SQL, sin embargo, es algo más complicado. Aunque presentaremos todos estos mecanismos gradualmente, quiero que se haga una idea por adelantado de la diversidad de recursos disponibles:

1 Un procedimiento puede recibir información de entrada a través de los paráme-tros de entrada y de entrada y salida declarados en su definición.

2 A su vez, el procedimiento puede devolver información al contexto que lo llama a través de parámetros de entrada y salida. No existen parámetros de salida pu-ros, hablando con propiedad, en Transact SQL.

3 Todos los procedimientos, lo queramos o no, soportan un valor de retorno, siempre de tipo entero. Es posible, sin embargo, dejar este valor de retorno sin asignar, por lo que hay que consultar la documentación del procedimiento para saber si obtendremos algún valor con sentido por medio del valor de retorno.

Estos tres mecanismos son similares a los de cualquier lenguaje de programación más o menos normal. Pero ahora es cuando se complican las cosas:

4 Un procedimiento almacenado puede devolver uno o más conjuntos de registros, o recordsets, es decir, un conjunto de filas evaluado por una consulta.

Los parámetros de salida permiten que el procedimiento devuelva valores escalares como resultado de su ejecución, pero ¿y si necesitásemos devolver una lista de regis-tros? Este es el motivo de la existencia de este recurso. No es, sin embargo, una téc-nica que desborde elegancia, sobre todo porque está diseñada teniendo en mente la interfaz de programación de acceso, no la lógica interna del propio Transact SQL. Por ejemplo, no existe una forma sencilla de mezclar los resultados de la ejecución de dos procedimientos, excepto si utilizamos tablas temporales.

NO

TA

Una de las aplicaciones práctica de la devolución de conjuntos de registros, en las ver-siones anteriores a SQL Server 2000, era la implementación de “vistas parámetricas”. Una vista normal, como ya sabe, no admite parámetros. Esta técnica, sin embargo, puede implementarse más elegantemente a partir de SQL Server 2000 mediante las funciones de tablas definidas por el usuario, que también estudiaremos más adelante.

Todavía nos queda un mecanismo de comunicación:

Page 103: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Procedimientos almacenados 103

5 Un procedimiento almacenado puede devolver mensajes “informativos” al con-texto que lo ejecuta directa o indirectamente. La forma más popular de hacerlo es usar la instrucción print.

Afortunadamente, el principal uso de esta última técnica es la emisión de mensajes durante la depuración.

Sintaxis básica: creación

Para crear un procedimiento almacenado se usa la instrucción create procedure. Esta instrucción no puede mezclarse con otras en un mismo batch o lote de instruc-ciones. Por este motivo, cuando se crean procedimientos mediante un guión SQL, siempre se añade un terminador go al final de cada definición de procedimiento.

A grandes rasgos, ésta es la sintaxis básica de la instrucción de creación de procedi-mientos almacenados:

create procedure Nombre

Parámetros

[with encryption] as

Instrucciones

He omitido un par de cláusulas para centrarnos en las más interesantes. Para mí, la opción with encryption entra dentro de esa categoría. Normalmente, el código fuente del procedimiento se guarda dentro de las tablas del catálogo, y se puede ex-traer con las herramientas clientes o incluso mediante simples consultas. Al usar esta opción, el código fuente se cifra de modo que sólo SQL Server es capaz de desci-frarlo. Así puede proteger su propiedad intelectual, o al menos, poner a salvo el có-digo de sus procedimientos de las miradas de un usuario demasiado curioso.

Veamos un ejemplo sencillo de procedimiento:

create procedure Consolidar @fecha datetime as

insert into Consolidacion

select @fecha, sum(Importe)

from Transacciones

where Fecha = @fecha

delete from Transacciones

where Fecha = @fecha

Lo primero que llama la atención es que, siguiendo con la excéntrica costumbre de Transact SQL, no hay un marcador que indique dónde termina la definición del pro-cedimiento almacenado. Como he dicho antes, estas declaraciones no se deben enviar al servidor junto con ningún otro tipo de instrucciones. Por lo tanto, si se incluye esta definición dentro de un fichero de guión, se considera que el procedimiento abarca hasta la línea anterior al terminador go, o hasta el fin de fichero: lo que ocurra primero. Mi costumbre personal es encerrar las instrucciones del procedimiento entre las palabras claves begin y end:

Page 104: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

104 La Cara Oculta de C#

create procedure Consolidar @fecha datetime as

begin

insert into Consolidacion

select @fecha, sum(Importe)

from Transacciones

where Fecha = @fecha

delete from Transacciones

where Fecha = @fecha

end

NO

TA

Al definir procedimientos almacenados, debe evitar el uso del prefijo sp, porque SQL Server trata estos procedimientos de forma diferente, al considerarlos procedimientos del sistema. Normalmente, un procedimiento almacenado reside dentro de una base de da-tos específica, y sólo puede ejecutarse estando activa otra base de datos si se cualifica completamente el nombre del procedimiento. La excepción son los procedimientos del sistema, que pueden ejecutarse alegremente desde cualquier base de datos del mismo servidor. Si ejecutamos un procedimiento almacenado cuyo nombre comienza con sp, SQL Server intentará localizarlo primero dentro de la base de datos maestra, master, y sólo si falla, proseguirá la búsqueda en la base de datos activa.

Traspaso de parámetros

Concentrémonos ahora en la cabecera de la declaración del procedimiento:

create procedure Consolidar @fecha datetime as

-- … etcétera …

La declaración de los parámetros comienza, de forma algo abrupta, inmediatamente después del nombre del procedimiento. La lista de parámetros no se encierra entre paréntesis, como suele ocurrir en otros lenguajes de programación. El fin de la lista de parámetros viene marcado por la palabra clave as o, en el caso en que se guarda el código fuente cifrado, por with. La otra rareza manifiesta consiste en que los nom-bres de parámetros deben comenzar obligatoriamente con el carácter @.

La sintaxis de declaración de cada parámetro independiente es la siguiente:

nombre_parámetro tipo [varying] [= valor_por_omisión] [output]

Si no se incluye la palabra clave output, el parámetro es de entrada; en caso contra-rio, es de entrada y salida. La otra cláusula opcional, varying, sólo se utiliza con un tipo muy especial de parámetro de salida, los parámetros de cursor, que dejaremos para más adelante. Por último, observe que se puede indicar un valor por omisión para el parámetro.

Ejecución desde Transact SQL

Un procedimiento almacenado puede ser ejecutado directamente desde una aplica-ción cliente, o desde otro código Transact SQL. En este capítulo nos limitaremos a la segunda posibilidad; la ejecución desde aplicaciones será estudiada en la parte que trata sobre ADO.NET.

Page 105: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Procedimientos almacenados 105

Suponga que tenemos un procedimiento almacenado con la siguiente declaración, para calcular las cuotas de un préstamo:

create procedure CuotasPrestamo

@financiado money,

@plazo smallint = 12,

@interes numeric(4,2),

@cuotaInicial money output,

@cuotaRegular money output as

/* … instrucciones … */

Se trata de un procedimiento con tres parámetros de entrada y dos de salida. Para el segundo parámetro, que indica el plazo en meses del préstamo, hemos señalado un valor por omisión de 12 meses.

Veamos ahora cómo se podría ejecutar este procedimiento, dentro de Transact SQL:

declare @inicial money, @final money

execute CuotasPrestamo 100000, 6, 14,

@inicial output, @final output

Hemos tenido que declarar dos variables para recibir los valores de los parámetros de salida, y podemos ver que los nombres de variables deben comenzar también con @. Pero lo que debe recordar siempre es que debe repetir la cláusula output para poder recibir valores de los parámetros de salida. Si no incluye output, no habrá queja por parte de Transact SQL, pero el traspaso de parámetros funcionará en una sola direc-ción: hacia el procedimiento. Cualquier valor asignado dentro del procedimiento sobre dicho parámetro, será ignorado al terminar la ejecución del procedimiento.

Antes mostré cómo declarar un valor por omisión para el parámetro del plazo. Una de las formas de aprovechar el parámetro por omisión es la siguiente:

execute CuotasPrestamo 100000, 6, default,

@inicial output, @final output

En este caso, pasamos la palabra clave default para pedir que se use el valor por omisión del parámetro que corresponde a esa posición; también sería no escribir nada, dejando dos comas consecutivas:

execute CuotasPrestamo 100000, 6,, @inicial output, @final output

En todos estos ejemplos de ejecución del procedimiento, hemos asociado los valores que pasamos a los parámetros declarados por su posición en la lista de parámetros. Pero Transact SQL también permite la asociación de parámetros por nombre:

execute CuotasPrestamo 100000,

@cuotaInicial = @inicial output,

@cuotaFinal = @final output,

@interes = 14

En realidad, el ejemplo anterior es una combinación de los dos métodos de asocia-ción de parámetros. El primer parámetro pasado no menciona el nombre del pará-

Page 106: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

106 La Cara Oculta de C#

metro formal, lo que quiere decir que lo estamos pasando todavía por posición, y que corresponde al importe financiado. De ahí en adelante, rompemos el paso y asociamos los restantes parámetros por su nombre. Observe que no se ha respetado la posición relativa, por no ser necesario, y que no hemos mencionado el plazo del préstamo, gracias a la existencia de un valor por omisión.

¿Y si el procedimiento devolviese un valor de retorno útil? Debo aclarar que es im-posible saber si un procedimiento devuelve algo en su valor de retorno, si sólo exa-minamos su cabecera: sería necesario leer su implementación, o la documentación asociada. Supongamos, no obstante, que CuotasPrestamo devuelve un cero cuando ha podido calcular correctamente las cuotas, y un código de error distinto de cero si ha encontrado problemas. En tal caso, podríamos ejecutar dicho procedimiento como muestro a continuación:

declare @inicial money, @final money, @resultado integer

execute @resultado = CuotasPrestamo 100000, 6, 14,

@inicial output, @final output

if (@resultado <> 0)

print 'Problemas'

La variable que recibe el valor de retorno se escribe antes del nombre del procedi-miento. Al regresar del procedimiento, he comparado el valor de retorno con cero, para saber si hubo problemas y mostrar un mensaje informativo mediante la instruc-ción print. Recuerde que esta instrucción se utiliza sobre todo durante la depuración. En un caso real, utilizaríamos técnicas, que estudiaremos más adelante, para avisar del problema a la aplicación que ejecuta el código anterior.

Instrucciones permitidas

Hagamos un rápido repaso de las instrucciones que pueden incluirse dentro de un procedimiento almacenado. Tenemos, en primer lugar, todas las instrucciones de manipulación de datos de SQL, y la mayoría de las instrucciones de definición y control de datos. Hay excepciones, sin embargo: dentro de un procedimiento alma-cenado no podemos crear otros procedimientos, tablas, vistas o triggers, aunque sí es posible crear tablas temporales, como veremos en ejemplos posteriores.

Podemos declarar y usar variables temporales, mediante la instrucción declare. Note que he dicho “instrucción”, en vez de declaración. El motivo es que declare es una instrucción más, que puede incluirse en cualquier parte del cuerpo del procedimiento. Muchos programadores, sin embargo, prefieren simular la sintaxis utilizada en otros lenguajes de programación basados en SQL, como el PL-SQL de Oracle. Siguiendo esta regla, un procedimiento podría escribirse de acuerdo al siguiente esqueleto:

create procedure NombreProcedimiento

lista_parámetros as

declare

@var_local_1 integer,

@var_local_2 varchar(30)

Page 107: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Procedimientos almacenados 107

begin

-- … instrucciones …

end

Para asignar valores a una variable, se puede usar la instrucción set:

declare @plazo_minimo integer

set @plazo_minimo = 3

La razón de ser de la palabra clave set es la necesidad de que cada instrucción co-mience con un prefijo fácilmente reconocible. Esto es consecuencia de la no existen-cia de separadores o terminadores de instrucciones. Tal como muestra el ejemplo anterior, sólo se puede asignar una variable en cada set. Dentro de poco estudiare-mos una variante de select que permite asignar más de una variable a la vez, si fuese necesario.

La instrucción condicional if tiene sus peculiaridades sintácticas:

if condición

instrucción_o_bloque

[else

instrucción_o_bloque]

A primera vista, se parece a la condicional de los lenguajes inspirados en C, porque no es necesario utilizar la palabra clave then, como en Pascal. En cada rama de la instrucción se admite solamente una instrucción simple, y si queremos incluir más de una instrucción, debemos agruparlas dentro de un bloque begin/end. En esto se parece también a la condicional de Pascal y C, y se diferencia de Basic o Eiffel. Sin embargo, hay una diferencia importante respecto a C: ¡no es necesario encerrar la condición entre paréntesis!

-- Transact SQL

if @@i = 0

instruccion

// C#

if (i == 0)

instruccion

Esto es posible gracias a que, en Transact SQL, la instrucción que sigue a la condi-ción puede identificarse fácilmente mediante su primera partícula sintáctica. En cual-quier caso, prefiero encerrar las condiciones en paréntesis, aunque no sea necesario.

Algo parecido ocurre con la instrucción while:

while condición

instrucción_o_bloque

En este caso, podemos utilizar las instrucciones break y continue, por si tenemos que abandonar el bucle a mitad de camino.

Por último, también a imitación de C, podemos utilizar la instrucción return para abandonar el procedimiento activo. Esta instrucción puede incluir una expresión de tipo entero, si es que queremos que el procedimiento devuelva un valor de retorno.

Page 108: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

108 La Cara Oculta de C#

Procedimientos de selección

Está claro el papel que tendría una instrucción SQL de actualización, como update, si la utilizáramos dentro de un procedimiento almacenado. Pero, ¿qué sucede si in-cluimos una consulta select? Eche un vistazo al siguiente procedimiento, que puede crear en la base de datos pubs:

create procedure Autores

@patron varchar(30) = '%' as

begin

select *

from authors

where au_lname like @patron or

au_fname like @patron

end

El procedimiento Autores tiene una sola instrucción en su interior, y es precisamente una consulta select, que devuelve los registros de la tabla de autores cuyos nombres o apellidos se ajustan a determinado patrón. El patrón se pasa como parámetro de entrada, y para los olvidadizos, hemos indicado un valor por omisión para el mismo.

El procedimiento, sin embargo, no tiene parámetros de salida. Si lo ejecutamos desde el Analizador de Consultas de SQL Server, veremos que el efecto de la ejecución es idéntico al que obtendríamos ejecutando directamente la consulta:

execute Autores 'R%'

En su momento veremos que, utilizando ADO.NET, podemos ejecutar este proce-dimiento desde una aplicación, por medio de la clase SqlCommand, y obtendríamos también los registros de authors que se amoldasen al patrón pasado como parámetro.

NO

TA

No es necesario limitarnos a una sola instrucción de selección dentro del procedimiento almacenado. De hecho, un procedimiento de selección puede tener varias consultas independientes en su cuerpo, e incluso puede tener instrucciones de otros tipos y pará-metros de salida. Estos casos serán presentados mediante ejemplos a lo largo del libro. Le adelanto que los procedimientos de selección son muy útiles para la grabación avan-zada de datos con ADO.NET.

¿Para qué querríamos este recurso? Hay varias razones:

• Informalmente, el procedimiento funciona como una vista con parámetros. En este ejemplo, la consulta es muy simple, pero si la consulta fuese más compleja, es evidente que nos interesaría encapsularla dentro de una vista o un objeto pa-recido. Si tuviésemos que usar parámetros, la vista quedaría descartada.

• Recuerde que SQL Server puede reaprovechar el resultado de la compilación de un procedimiento almacenado. Si vamos a ejecutar varias veces una consulta, nos convendría envolverla dentro de un procedimiento... siempre que podamos pre-verlo.

Page 109: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Procedimientos almacenados 109

Debe saber, no obstante, que hay limitaciones en el uso de este tipo de procedimien-tos. Por ejemplo, no es posible, al menos desde Transact SQL, reordenar el resultado devuelto por un procedimiento almacenado, ni combinarlo por medio de uniones o encuentros con resultados procedentes de otros procedimientos o consultas. Por este motivo, en ocasiones es preferible utilizar funciones de tablas definidas por el usua-rio, un recurso añadido a Transact SQL en SQL Server 2000.

Una variante de selección

Como acabo de explicar, podemos incluir consultas dentro de un procedimiento almacenado. Cuando se ejecuta el procedimiento, los registros que resultan de la evaluación de la consulta se devuelven al cliente de la conexión, como si éste hubiese evaluado directamente la consulta.

Existe, sin embargo, una variante de la instrucción select que incluso se utiliza con mayor frecuencia en Transact SQL. Piense un momento: podemos crear filas, bo-rrarlas y modificarlas, pero ¿hemos visto algún mecanismo para traer los datos de un registro a memoria? Ese es el papel de la variante de select que voy a presentarle ahora. Eche un vistazo al siguiente fragmento de código:

declare @titulo varchar(80), @precio money

select @titulo = title,

@precio = price

from titles

where title_id = 'BU1032'

La particularidad de esta consulta está en que las expresiones de su cláusula select se asignan, o eso parece, a variables. Hay que tener un poco de cuidado, por cierto, para distinguir una asignación de valores sobre variables de la siguiente consulta:

select titulo = title,

precio = price

from titles

where title_id = 'BU1032'

Esta última instrucción es una consulta “normal”, como las que hemos visto hasta ahora, sólo que estamos “traduciendo” los nombres de columnas en el resultado.

Volvamos a la instrucción que nos interesa. La consulta debe devolver, a lo sumo, un único registro, porque en la cláusula where pedimos que la clave primaria tenga un valor determinado. El valor concreto que estamos buscando, existe; si no existiese, el resultado no contendría registros. En ese caso, las variables sobre las que estamos asignando el precio y el título mantendrían su valor original. Luego veremos cómo manejar estos casos especiales.

Existen más formas de garantizar que una consulta devuelva a lo sumo un registro. Por ejemplo, podemos utilizar funciones estadísticas para plegar una relación, como en el siguiente ejemplo:

Page 110: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

110 La Cara Oculta de C#

select @total = sum(qty)

from sales

where title_id = 'BU1032'

La tabla sales contiene las ventas de libros en la base de datos pubs. Para cada libro pueden existir varios registros. Pero he utilizado la función sum para resumir el total de unidades vendidas en un único registro.

Una, ninguna, muchas...

En los ejemplos que he mostrado de selección sobre variables, hemos recurrido a varios trucos para garantizar que la consulta devuelva a lo sumo un registro. Pero existirán ocasiones en las que tendremos que arriesgarnos. Por ejemplo:

select @telefono = phone

from authors

where au_fname = 'Meander'

La instrucción busca y recupera, si es que existe, el teléfono de un buen señor lla-mado Meandro. Lo normal es que haya sólo un desgraciado con ese nombre, cuando más... pero el nombre de autor no es una clave primaria. Así que nos arriesgamos a tener que felicitar a dos personas en el día de San Meandro (porque para tener ese nombre y no tener pensamientos parricidas, hay que ser un santo). ¿Cómo se com-porta Transact SQL cuando una selección sobre variables devuelve más de una fila? Es mejor que resumamos el comportamiento de esta clase de select respecto al nú-mero de filas del resultado:

1 Si hay una sola fila en el resultado, estupendo: no hay por qué preocuparse. 2 Si la consulta está vacía, las variables no se alteran, y conservan su valor original. 3 Si la consulta devuelve más de una fila, las variables reciben el valor de la última

fila del resultado. Esto es casi equivalente a decir que el resultado es indetermi-nado porque, de no haber una cláusula order by, es imposible predecir siempre el orden de recorrido de las filas.

Preocupante, ¿verdad? En SQL estándar, el último caso provocaría una excepción, y el caso de la consulta vacía podría detectarse como un error, o al menos una adver-tencia, tomando las medidas apropiadas. Aquí, sin embargo, Transact SQL se calla la boca y hace lo que estima mejor, sin advertencia alguna.

¿Es posible saber cuántas filas devolvió la consulta? Una técnica sencilla nos permite manejar el caso más frecuente, en el que sabemos que habrá como máximo una fila. Sabemos que si no se devuelven filas, las variables no se alteran. Y podemos aprove-char un hecho que no he mencionado: las variables locales de Transact SQL contie-nen un nulo inmediatamente después de ser declaradas. Por lo tanto, podríamos comprobar el valor de una de las variables después de la selección:

declare @titulo varchar(80), @precio money

select @titulo = title,

@precio = price

Page 111: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Procedimientos almacenados 111

from titles

where title_id = 'BU1032'

if (@titulo is null)

print 'No se ha encontrado el libro'

Mucho mejor es comprobar el valor de una variable global, llamada @@rowcount, de la cual se mantiene una copia independiente para cada conexión concurrente:

select @telefono = phone

from authors

where au_fname = 'Meander'

declare @rows integer

set @rows = @@rowcount

if (@rows = 0)

print 'No hay meandros'

else if (@rows > 1)

print 'Llueven los meandros'

else

print 'El teléfono de Mr. Meandro: ' + @telefono

Observe que el valor de @@rowcount se salva en una variable temporal. El motivo es que el contenido de dicha variable es sumamente volátil: casi cualquier instrucción SQL afecta su valor. Si desea comprobarlo, ejecute este otro ejemplo

select @telefono = phone

from authors

where au_lname = 'Ringer'

if (@@rowcount = 0)

print 'No hay timbres'

else if (@@rowcount > 1)

print 'Llueven los timbres'

else

print 'El teléfono de Mr. Timbre: ' + @telefono

Debe ejecutarse la tercera instrucción print. Sin embargo, hay más de una persona con el apellido Ringer en la tabla del ejemplo. Lo que sucede es que al probar la se-gunda condición, @@rowcount ya ha sido alterado, y su valor es cero

Selección sin tablas

Existe otra variante más de la instrucción select, que puede utilizarse tanto para devolver un conjunto de resultados como para copiar valores en variables, aunque se emplea con más frecuencia para este último propósito. La variante consiste en no utilizar una cláusula from. Por ejemplo, si estamos usando el Analizador de Consul-tas y queremos comprobar el funcionamiento de la función dateadd, podemos escribir lo siguiente:

select getdate(), dateadd(day, 1, getdate()),

dateadd(month, 1, getdate())

Page 112: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

112 La Cara Oculta de C#

La instrucción devolverá una fila con tres columnas, que contendrán la fecha de hoy, la de mañana, y la fecha de un mes después de la actual. Es más común utilizar la instrucción para asignar más de una variable con la misma instrucción:

select @plazo = 12, @interes = 14

Recuerde que la alternativa sería utilizar dos instrucciones set:

set @plazo = 12

set @interes = 14

Sin embargo, mi recomendación es utilizar set siempre que no sea excesivamente engorroso. Y el motivo es fácil de comprender: incluso esta variante de select modi-fica, y puede “desfigurar”, el contenido de @@rowcount. Haga la siguiente prueba:

select * from authors where au_lname = 'Ringer'

select 'Número de Ringers: ', @@rowcount

select @@rowcount

La segunda instrucción mostrará que hay dos Ringer’s, pero al volver a preguntar por el contenido de @@rowcount, comprobaremos que su valor es ahora igual a uno, porque la instrucción select inmediatamente anterior ha devuelto realmente una sola fila.

Procedimientos y tablas temporales

Suponga que tenemos una tabla como la siguiente:

create table PIEZAS (

IDPieza integer not null primary key,

Nombre varchar(30) not null unique,

Pertenece integer null references PIEZAS(IDPieza)

)

Con sólo mirar la restricción de integridad referencial que se aplica al campo Pertenece podemos oler los problemas. La tabla almacena nombres de piezas, y asume que existen relaciones jerárquicas entre ellas. Por ejemplo, la pastilla 'c3po' es parte de la placa 'r2d2', la placa 'r2d2' es un componente del equipo 'foobar' y así sucesivamente, hasta que tropezamos con alguna pieza que no pertenece a ningún ente superior en complejidad; en ese caso, Pertenece valdrá null. Una visión muy simplista del mundo, pero aceptémosla, en aras del ejemplo.

¿Quiere problemas? Pida cuáles son todas las piezas que componen un 'foobar'. Si no existe un número fijo y predeterminado de niveles de profundidad, estamos ante una consulta recursiva, o más técnicamente, ante una clausura transitiva. Y SQL estándar no permite expresar este tipo de consultas directamente, aunque Oracle y DB2 ofrecen extensiones especiales para este propósito.

La solución nos exigirá utilizar una tabla temporal dentro del procedimiento almace-nado. Recordemos que una tabla temporal se crea asignándole un nombre que co-mience con # o ##. Si se utiliza una sola almohadilla, la tabla será visible solamente para la conexión que la crea; con dos almohadillas, otras conexiones podrán ver la

Page 113: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Procedimientos almacenados 113

tabla y trabajar con ella. En este ejemplo nos interesa una tabla temporal que sólo sea visible desde nuestra conexión.

Normalmente, una tabla temporal privada se destruye automáticamente cuando se cierra la conexión. Sin embargo, cuando la tabla se crea dentro de un procedimiento almacenado, la destrucción tiene lugar al terminar el procedimiento. Para nosotros, es una excelente noticia.

Nuestro procedimiento creará la tabla temporal, y la llenará por pasos, utilizando un algoritmo que identificará generaciones de registros. Al terminar el procedimiento, de-volveremos el contenido de la tabla mediante un select, y la tabla temporal, como es lógico, se destruirá automáticamente. Esta es la solución:

create procedure PiezasRecursivas @idpieza integer as

begin

/* Crear la tabla temporal */

create table #buffer (

posicion integer identity(1,1),

idpieza integer,

nombre varchar(30)

)

/* Inicializarla */

insert into #buffer(idpieza, nombre)

select @idpieza, nombre

from piezas

where idpieza = @idpieza

/* Llenarla en un bucle */

declare @inicio integer, @fin integer

set @inicio = 1

set @fin = 1

while (@inicio <= @fin)

begin

insert into #buffer(idpieza, nombre)

select idpieza, nombre

from piezas

where pertenece in (

select idpieza

from #buffer

where posicion between @inicio and @fin)

set @inicio = @fin + 1

set @fin = @@identity

end

/* Devolver su contenido */

select idpieza, nombre

from #buffer

end

La tabla temporal contiene, además del identificador y nombre de la pieza, una clave primaria entera, basada en el atributo identity. Los registros que insertemos se irán numerando de forma ascendente, y de esta manera, la tabla temporal funcionará aproximadamente como un vector. Las variables locales @inicio y @fin delimitan un rango de claves dentro de ese vector virtual. En cada paso, ese rango corresponderá al último grupo, o generación, de registros añadidos.

Page 114: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

114 La Cara Oculta de C#

La inicialización de la tabla consiste en insertar la pieza que hemos señalado como raíz del árbol de composición. Por lo tanto, las variables de inicio y fin se inicializan ambas a 1 antes de meternos en el bucle. En este último, añadimos a la tabla aquellos registros que tienen como “padre” a uno de los registros del rango activo. Así crea-mos la siguiente generación de registros, y actualizamos las variables del rango para reflejar la nueva situación. Llegará un momento en que ya no se añadirán registros a la tabla temporal, y eso significará que debemos concluir el bucle. Finalmente, devol-vemos el contenido de la tabla temporal; eso sí, quitando la columna de la posición del resultado.

NO

TA

A partir de SQL Server 2000, las funciones de tablas definidas por el usuario ofrecen una manera más elegante de programar algoritmos como el que acabamos de mostrar, y SQL Server 2003 añade consultas recursivas (CTE) a nuestro arsenal.

Simulación de listas variables de parámetros

¿Le importa si me desvió un poco del tema? Quiero mostrarle un par de instruccio-nes escritas en C#:

Console.WriteLine("Hola, Mundo");

Console.WriteLine("El precio de {0} es {1:C}", producto, precio);

El método estático WriteLine, de la clase Console, sirve para mostrar un mensaje de texto en la consola de una aplicación, como indica su nombre. La forma más sencilla de ejecutarlo consiste en pasarle la cadena a mostrar, como en la primera instrucción. En la segunda instrucción, pasamos como primer parámetro una cadena con marcas especiales, que deben sustituirse antes de mostrar el texto en la consola. Para cada marca incluida en la cadena, el programador debe suministrar un parámetro, que determinará el texto que sustituirá a la marca. En el ejemplo, la cadena tiene dos marcas, y por lo tanto, WriteLine necesita dos parámetros adicionales.

La gran pregunta es: ¿cuántos parámetros tiene la definición de este método? Poten-cialmente, puede tener tantos parámetros como se nos ocurra. En concreto, éste es el prototipo de WriteLine:

public static void WriteLine(string, params object[]);

Hay sólo dos parámetros en la definición: una cadena, y un vector de objetos. El vector, sin embargo, está marcado con la palabra clave params. Este es el meca-nismo sintáctico que nos permite creer en la ilusión de que WriteLine admite un nú-mero arbitrario de parámetros. La segunda instrucción del ejemplo anterior es tradu-cida internamente por C# en la siguiente manera, creando un vector para el segundo parámetro:

Console.WriteLine("El precio de {0} es {1:C}",

new object[] { producto, precio });

Se trata de una solución sintáctica muy elegante. Desafortunadamente, los lenguajes de programación tradicionales han tenido dificultades para definir procedimientos o

Page 115: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Procedimientos almacenados 115

funciones con un número variable de parámetros. En algunos lenguajes, como Pas-cal, simplemente está prohibido declarar procedimientos con un número variable de parámetros... aunque algunos procedimiento predefinidos suyos soporten esta téc-nica. En otros lenguajes, como C y C++, la implementación está ligada al formato binario interno utilizado para el traspaso de parámetros. Adiós a la elegancia.

Transact SQL no permite crear procedimientos con un número variable de paráme-tros, al menos si los definimos en el propio Transact SQL. Existe una vía de escape, si creamos el procedimiento en algún lenguaje tradicional, principalmente C/C++, como un procedimiento almacenado “extendido”. No es mi intención entrar en los detalles de esta técnica, porque quiero presentar una alternativa muy diferente.

Supongamos que estamos desarrollando una tienda para Internet, y que nos toca programar la búsqueda de productos por palabras claves. El usuario de la tienda puede teclear una, dos, tres palabras claves, o todas las que se le antoje. Sería muy sencillo crear un procedimiento almacenado que, recibiendo una palabra clave en un parámetro de cadena, devuelva las filas de productos correspondientes. Podríamos crear una segunda variante, que aceptase dos palabras, y luego una que admitiese tres, y luego... sería hora de pensar en otra cosa.

Una alternativa sería pasar todas las palabras juntas en una misma cadena. A fin de cuentas, el usuario habrá tecleado las palabras claves de esta forma. Pero eso implica-ría descomponer la cadena en Transact SQL: no es tarea imposible, pero sí bastante engorrosa.

¿Existe alguna estructura de datos en Transact SQL que simule algo parecido a una lista? Lo más cercano a una lista, es una tabla. Le propongo pasar las palabras claves dentro de una tabla; se entiende que estoy hablando de una tabla temporal. Para pre-cisar, digamos que la definición de las tablas implicadas es la siguiente:

create table PRODUCTOS (

IDProducto integer not null,

Nombre varchar(80) not null,

/* … etcétera … */

)

create table PALABRAS (

IDProducto integer not null,

Palabra varchar(80) not null,

/* … etcétera … */

)

Cada producto, en este esquema simplificado, tiene uno o más productos asociados en la tabla de palabras. Con estas suposiciones, el procedimiento de búsqueda podría programarse de este modo:

create procedure BuscarProductos as

/* ¡Observe que no hay parámetros "explícitos"! */

select *

from PRODUCTOS

where IDProducto in (

Page 116: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

116 La Cara Oculta de C#

select IDProducto

from PALABRAS p1

where exists (

select *

from #palabras_claves p2

where p1.Palabra like p2.Palabra + '%'))

go

Admito que es un procedimiento algo enrevesado, pero lo que ahora nos interesa es la referencia que éste hace a una misteriosa tabla llamada #palabras_claves. El proce-dimiento espera que, en el momento en que sea ejecutado, exista una tabla temporal con ese nombre, y que contenga una palabra clave tecleada por el usuario por cada una de sus filas.

Para poder ejecutar este procedimiento almacenado desde una aplicación progra-mada con ADO.NET, tendremos que generar una llamada compleja, con varias ins-trucciones en su interior. Por ejemplo, si el usuario quiere buscar los productos aso-ciados con “cara” o con “oculta”, tendríamos que enviar al servidor las siguientes instrucciones, generadas en C#:

create table #palabras_claves (Palabra varchar(80))

insert into #palabras_claves values('cara')

insert into #palabras_claves values('oculta')

execute BuscarProductos

drop table #palabras_claves

Como ve, el efecto neto será parecido a tener un procedimiento que admita tantos parámetros como se nos ocurra.

Debo advertirle, no obstante, que esta solución debe adoptarse solamente cuando se hayan agotado las alternativas. Como el procedimiento utiliza una tabla temporal definida externamente, cada vez que se ejecute BuscarProductos, SQL Server se sentirá obligado a compilarlo. En una tienda real, mi solución preferida sería generar la con-sulta completamente, no importa su complejidad. El método mostrado sirve sola-mente para encapsular parte de la complejidad dentro del procedimiento, pero el precio que se paga es la recompilación del procedimiento en cada búsqueda, amén del coste asociado a la creación y destrucción de la tabla temporal.

Page 117: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

7

Cursores

O CABE DUDA DE QUE SQL OFRECE INSTRUCCIONES muy completas para el análisis y modificación de datos. Desgraciadamente, a veces éstas se quedan

cortas frente a nuestras necesidades, o es tan complicado ceñirse a las reglas del álge-bra relacional que suspiramos y clamamos al cielo por ayuda. Y del cielo descienden – trompetas, ángeles y tramoya celestial, por favor – los cursores.

¿Para qué sirve un cursor?

Se llama cursor a un mecanismo implementado por la mayoría de las bases de datos importantes para programar recorridos sobre consultas. Su origen se encuentra en el casi olvidado SQL Incrustado (Embedded SQL), una antigua técnica basada en el uso de preprocesadores que permitía intercalar instrucciones SQL dentro de un programa escrito en C, Pascal o algún otro lenguaje tradicional. En SQL Incrustado, los curso-res tendían un puente entre el carácter relacional, algebraico y declarativo de SQL y las características procedimentales y secuenciales de los lenguajes tradicionales.

Cuando los sistemas de bases de datos SQL añadieron la posibilidad de programar procedimientos almacenados en el servidor, los diseñadores se dieron cuenta de la potencia que los cursores podían añadir a este nuevo modelo de programación en el lado servidor. En cualquier caso, la maquinaria estaba ahí y sólo hubo que adaptarla a su nueva aplicación.

NO

TA

La palabra cursor, por desgracia, tiene más de un significado en la jerga de bases de datos. Aquí vamos a referirnos exclusivamente a cursores que se definen dentro de Tran-sact SQL, para ser utilizados dentro de procedimientos almacenados o triggers.

Hay que saber diferenciar entre aplicaciones correctas de los cursores y algunos usos vergonzantes. Siempre que sea posible, debemos intentar sustituir el código que uti-liza cursores por las instrucciones orientadas a conjuntos equivalentes: update, in-sert, delete. Es posible realizar esta sustitución con mayor frecuencia de lo que puede parecer a primera vista.

Por el contrario, hay situaciones en las que es preferible usar cursores. En la mayoría de estos casos, la necesidad surge porque tenemos que ejecutar algún procedimiento almacenado para cada fila perteneciente al resultado de una consulta. Si el cuerpo del

N

Page 118: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

118 La Cara Oculta de C#

procedimiento que queremos ejecutar para cada fila contiene una sola instrucción SQL, es muy probable que no necesitemos el cursor. En cambio, el cursor es impres-cindible cuando el cuerpo del procedimiento aumenta su complejidad. Una de mis aplicaciones tiene que mantener dinámicamente una tabla de amortización para préstamos. El código correspondiente, desarrollado en un par de horas en Transact SQL, ocupa poco más de una página y se basa en un cursor. Por curiosidad he inten-tado obtener una versión que no utilice cursores explícitamente. ¿El resultado? Tuve que introducir columnas adicionales en una tabla bastante sensible al tamaño, y re-torcer la lógica de la operación... todo para terminar con más líneas de código y un tiempo de ejecución muy similar al inicial.

La declaración del cursor

Los cursores se declaran, se abren, se utilizan, se cierran y se destruyen... y a eso se le llama un ciclo de vida. Así que comencemos por la declaración de cursores, utili-zando las variantes más sencillas. El siguiente listado contiene un ejemplo muy sim-ple de declaración de un cursor dentro de un procedimiento almacenado:

create procedure ActualizarInventario @pedido integer as

begin

declare MiCursor cursor for

select *

from [Order Details]

where OrderID = @pedido

order by ProductID asc

/* … más instrucciones … */

end

La declaración del cursor puede contener referencias a variables o parámetros dispo-nibles en el contexto en que se produce. De hecho, casi todas los ejemplos de curso-res que se me ocurren utilizan variables para limitar las filas procesadas. El cursor del listado anterior recupera las líneas de detalles que pertenecen al pedido cuyo identi-ficador se pasa como parámetro de ActualizaInventario.

Una declaración de cursor no provoca ningún cambio en las tablas a las que hace referencia: para eso tendríamos que abrir el cursor. Sin embargo, con la declaración sí se crea una estructura dentro del servidor que tendrá que ser liberada mediante una instrucción explícita, deallocate, que consideraremos más adelante.

Apertura y cierre

El estándar ANSI incluye dos instrucciones para “abrir” y “cerrar” el cursor, que se llaman open y close, como era de esperar. Cuando se ejecuta la instrucción open, SQL Server determina, al menos teóricamente, el conjunto de filas que formará parte del cursor. Es posible que el cursor se ejecute de forma asíncrona, esto es, que las filas vayan recuperándose a medida que las necesitemos, en un hilo paralelo. En cual-quier caso, SQL Server se ocupará de que los detalles de sincronización sean transpa-rentes para el programador. Si el servidor decide evaluar todo el cursor antes de de-

Page 119: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Cursores 119

volver el control al procedimiento que llama a open, la variable global @@cursor_rows contendrá el número de filas que forman el cursor. Observe el uso de dos arrobas al principio del nombre de la variable.

La instrucción close, por su parte, deshace el efecto de open. Un cursor cerrado puede volver a abrirse en cualquier momento, lo que quiere decir que, aún cerrado, un cursor consume siempre algo de memoria: la necesaria para contener la informa-ción necesaria para la apertura. Por este motivo es que existe la instrucción deallocate, la que debemos llamar cuando queremos deshacernos definitivamente del cursor. Una vez ejecutado deallocate, nuestro programa debe pasar nuevamente sobre una instrucción declare cursor para volver a utilizar el cursor.

La siguiente versión de ActualizarInventario es muy parecida a la que vimos en la sec-ción anterior, pero ahora incluye la apertura y el cierre del cursor:

create procedure ActualizarInventario @pedido integer as

begin

declare MiCursor cursor for

select *

from [Order Details]

where OrderID = @pedido

order by ProductID asc

open MiCursor

/* … recorrido sobre el cursor … */

close MiCursor

deallocate MiCursor

end

El bucle arquetípico

El siguiente paso consiste en recorrer el conjunto de filas definido por el cursor. Los cursores más sencillos sólo pueden recorrerse en una dirección: hacia delante. Pero SQL Server, como veremos, también permite cursores bidireccionales, en los que la posición del cursor salta como una rana loca. Por lo pronto, el bucle más sencillo para un cursor declarado con las opciones más simples, tiene el siguiente aspecto:

open MiCursor

fetch MiCursor into variables

while (@@fetch_status = 0)

begin

/* … hacer algo … */

fetch MiCursor into variables

end

close MiCursor

La clave del algoritmo de recorrido está en la instrucción fetch. Esta instrucción, en su variante más sencilla, hace referencia a un cursor e intenta leer la próxima fila den-tro de una conjunto de variables. Es obligatorio mencionar en la cláusula into tantas variables como columnas contenga la declaración del cursor. Es algo bastante incó-

Page 120: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

120 La Cara Oculta de C#

modo, sobre todo cuando el cursor tiene muchas columnas o expresiones en la cláu-sula select, pero no existe otra forma de hacerlo.

La lectura de la fila puede triunfar o fracasar, y el estado final puede conocerse a través de una variable global, @@fetch_status, de tipo entero. Si la operación tiene éxito, la variable toma el valor cero. Cuando llegamos al final de un cursor, SQL Server asigna -1 a @@fetch_status. Y si el cursor es de un tipo especial que presentaremos dentro de poco (cursores dinámicos), puede que encontremos el valor -2 cuando una fila del cursor ha sido borrada por otra conexión.

En general, basta con verificar que @@fetch_status sea igual a cero para controlar el fin del bucle, sin complicarnos con la interpretación de los restantes valores.

Cuando el orden es importante

Vamos a completar la implementación de ActualizarInventario: para cada fila de deta-lles del pedido que indiquemos, retocaremos el valor de la columna UnitsOnOrder en el registro del producto que corresponda a la línea de detalles.

create procedure ActualizarInventario @pedido integer as

begin

declare MiCursor cursor for

select ProductID, Quantity

from [Order Details]

where OrderID = @pedido

order by ProductID asc

declare @productID int, @quantity smallint

open MiCursor

fetch MiCursor into @productID, @quantity

while (@@fetch_status = 0)

begin

update Products

set UnitsOnOrder = UnitsOnOrder + @quantity

where ProductID = @productID

fetch MiCursor into @productID, @quantity

end

close MiCursor

deallocate MiCursor

end

No, no sonría sarcásticamente, porque el ejemplo es bueno. Es cierto que podríamos sustituir el cuerpo del procedimiento por una variante más sencilla:

create procedure ActualizarInventario @pedido integer as

update Products

set UnitsOnOrder = UnitsOnOrder + det.Quantity

from [Order Details] det

where Products.ProductID = det.ProductID

Sin embargo, hay una diferencia importante entre las dos variantes: si utilizamos un cursor, podemos garantizar que las actualizaciones en la tabla de productos se reali-

Page 121: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Cursores 121

cen en determinado orden: observe que en la declaración del cursor se incluye una cláusula order by para ordenar las filas de detalles por el código de producto. Su-pongamos que dos usuarios ejecutan simultáneamente ActualizarInventario, sobre pedidos diferentes. En el primer pedido se ha vendido una camisa y un pantalón, y en el segundo, un pantalón y una camisa. A través de la conexión correspondiente al primer usuario se actualizan las existencias de la camisa, mientras que el segundo usuario actualiza el registro del pantalón. Cuando el primer usuario intenta modificar el registro del pantalón, lo encuentra bloqueado, y esperará a que desaparezca el blo-queo para continuar. Pero el bloqueo sólo desaparecerá cuando el segundo usuario termine su transacción... ¡pero ese usuario estará esperando a que la primera cone-xión libere el registro de la camisa!

Esta situación se conoce en Informática como abrazo mortal (deadlock). SQL Server resolvería el problema abortando una de las dos ejecuciones, con un mensaje pare-cido al siguiente:

Server: Msg 1205, Level 13, State 50, Procedure Deadlock, Line 8

Your transaction (process ID #7) was deadlocked with another

process and has been chosen as the deadlock victim.

Rerun your transaction.

Si no queremos arriesgarnos, hay una solución muy sencilla a nuestro alcance: que las actualizaciones siempre se ejecuten en determinado orden, no importa cuál. Pero no tenemos forma de controlar el orden en que se actualizan los registros de productos cuando utilizamos una sentencia update. En cambio, el uso de un cursor ordenado garantiza que las actualizaciones se ejecutarán de forma ordenada.

Dos tipos de declaraciones

Ahora que vamos a presentar algunas mejoras sobre el esquema básico de cursores, debemos aclarar que SQL Server soporta dos sintaxis excluyentes para la declaración de cursores. Una corresponde a la establecida en el estándar del 92; la otra, por su-puesto, es de la cosecha de Microsoft. No los culpo por esta “infracción” del están-dar: la especificación del ANSI SQL-92 ofrece muy pocas posibilidades de extensión, y se basa en implementaciones que a estas alturas se consideran obsoletas.

La sintaxis más sencilla es la estándar, que se caracteriza porque las opciones del cursor se sitúan antes de la palabra clave cursor:

declare NombreCursor [insensitive] [scroll] cursor

for Consulta

La declaración al estilo Microsoft ofrece más opciones, y todas ellas se escriben des-pués de cursor:

declare NombreCursor cursor

[ local | global ]

[ forward_only | scroll ]

[ static | keyset | dynamic | fast_forward ]

[ read_only | scroll_locks | optimistic ]

Page 122: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

122 La Cara Oculta de C#

[ type_warning ]

for Consulta

Una advertencia: no se pueden mezclar opciones de estilos diferentes.

Global, local

La opción más sencilla de entender, pero que más problemas puede causar, es la elección entre cursores locales y globales. La sintaxis de Microsoft es la que ofrece esta opción, pero los cursores estándar se ven afectados por un valor por omisión, a nivel de la base de datos, para esta propiedad.

¿Qué es un cursor global? Suponga que declaramos un cursor dentro de un procedi-miento almacenado, y que lo abrimos antes de terminar. ¿Sería posible pasar este cursor a otro procedimiento? Aunque a partir de la versión 7 podemos hacerlo me-diante parámetros y variables de tipo cursor, en las versiones más primitivas de SQL Server no existía esa posibilidad. La solución ofrecida por Microsoft en esas versio-nes consistía en hacer “globales” los nombres de cursores. Intente ejecutar esta ins-trucción dos veces, utilizando el Analizador de Consultas:

declare CursorGlobal cursor for

select * from Customers

La primera vez todo irá bien, pero el segundo intento fallará: SQL Server se quejará de que ya existe un cursor con ese nombre. El cursor ha sobrevivido, pasando de un lote de consultas al siguiente. Para evitar este problema tendríamos que destruir el cursor con deallocate. Esta variante no produce errores si se ejecuta dos veces:

declare CursorGlobal cursor for

select * from Customers

deallocate CursorGlobal

Haga también la prueba con esta otra variante:

declare CursorLocal cursor local for

select * from Customers

Esta vez, la presencia explícita de la opción local hace que SQL Server destruya au-tomáticamente el cursor cuando el lote de consultas termina su ejecución.

Supongo que habrá notado que en el ejemplo de cursor global no incluí explícita-mente la opción global. Esto se debe a que, por omisión, SQL Server asume que sus cursores son globales. Para modificar este comportamiento debemos cambiar una opción de la base de datos mediante la instrucción alter database:

alter database Northwind

set cursor_default local

Es muy importante comprender este detalle del funcionamiento de los cursores. Uno de los errores más frecuentes al programar en Transact SQL consiste en olvidar la

Page 123: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Cursores 123

llamada a deallocate para un cursor declarado dentro de un procedimiento almace-nado. Es muy probable que una segunda llamada al mismo procedimiento falle cuando tenga que declarar el cursor. Mi recomendación es usar cursores locales siempre que sea posible.

Cursores actualizables

Una de las justificaciones que presenté para el uso de cursores era la posibilidad de modificar un conjunto de filas sin tener que utilizar las reducidas prestaciones de las instrucciones a nivel de conjunto de SQL. En el ejemplo de la actualización del in-ventario, recorríamos la tabla de detalles, pero actualizábamos registros de productos. Ahora explicaré cómo puede utilizarse un cursor para actualizar los registros de donde provienen sus datos.

Primero hay que lograr que el cursor sea actualizable. Curiosamente, ¡en SQL Server todo cursor lo es de forma automática! Por el contrario, para obtener un cursor no actualizable tenemos que indicarlo explícitamente. Esta es la forma de hacerlo si utilizamos la sintaxis de SQL estándar:

declare CursorSoloLectura insensitive cursor for

select /* etcétera, etcétera */

Si recurrimos a la sintaxis extendida de SQL Server, la cláusula correspondiente debe aparecer después de la palabra cursor:

declare CursorSoloLectura cursor read_only for

select /* etcétera, etcétera */

¿Para qué querríamos un cursor con capacidades recortadas? El motivo principal puede ser la concurrencia. Un cursor “insensible” se implementa copiando los regis-tros del cursor en una tabla temporal. De esta forma, no es necesario bloquear los registros originales mientras el cursor esté activo.

NO

TA

Aunque sería lógico suponer que la implementación de read_only hiciera algo parecido a la de insensitive, la documentación indica que la copia temporal sólo tiene lugar cuando, adicionalmente, el cursor se declara static read_only.

Es probable que haya oído mencionar la existencia de una cláusula for update como parte del estándar. SQL Server la implementa, pero le da su propia interpretación. La siguiente forma declaración permite cambios en cualquier columna del cursor, así que es como si no hubiésemos utilizado for update:

declare ClientesMorosos cursor for

select *

from CLIENTES c

where 0 < (select sum(Impagos) from PRODUCTOS p

where p.IDCliente = c.IDCliente)

for update

Page 124: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

124 La Cara Oculta de C#

Sin embargo, esta otra declaración se utiliza para limitar las actualizaciones a una o más columnas:

declare ClientesMorosos cursor for

select *

from CLIENTES c

where 0 < (

select sum(Impagos) from PRODUCTOS p

where p.IDCliente = c.IDCliente)

for update of Estado, Recargos

He dejado para el final una condición obvia para poder actualizar un cursor: la ins-trucción select de la declaración debe ser actualizable por sí misma. Si contiene gru-pos, funciones de conjuntos o el operador distinct, es evidente que no habrá forma de averiguar a qué registros físicos correspondería la fila activa del cursor. Un cursor tampoco puede ser actualizado si el usuario no tiene permisos de escritura sobre las tablas afectadas.

Actualizaciones en la fila activa

Todas las explicaciones anteriores nos ayudan a comprender cuándo se pueden reali-zar cambios a través de un cursor. Pero todavía no hemos dicho cómo se efectúan esos cambios.

Existen dos instrucciones de actualización basadas en cursores, y son las viejas cono-cidas update y delete. ¡Vaya chiste!, pensará usted. Pero en realidad se trata de va-riantes de estas instrucciones en las que se sustituye la condición where por una cláusula especial:

open ClientesMorosos

fetch ClientesMorosos into /* … variables locales … */

while (@@fetch_status = 0)

begin

execute CalcularRecargo @IDCliente, @recargos output

update CLIENTES

set Estado = 'MOROSO',

Recargos = @recargos

where current of ClientesMorosos

fetch ClientesMorosos into /* … variables locales … */

end

close ClientesMorosos

deallocate ClientesMorosos

La cláusula where current of ClientesMorosos es la que indica que la actualización del estado del cliente se debe realizar solamente sobre la fila activa del cursor mencio-nado. Lo mismo es aplicable a la instrucción delete: si utilizamos la cláusula anterior, se borra solamente la fila activa del cursor.

NO

TA

Observe que, a pesar de la presencia de la cláusula where current of, es necesario indicar redundantemente el nombre de la tabla que queremos actualizar.

Page 125: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Cursores 125

Cursores bidireccionales

La otra característica interesante de los cursores de SQL Server es que si lo desea-mos, pueden recorrerse en cualquier dirección. La instrucción fetch puede ir acom-pañada de modificadores para leer no sólo el siguiente registro, sino también el re-gistro anterior, saltarse cierto número de registros o incluso ir a determinada fila indicando su posición absoluta.

No es tan frecuente encontrar ejemplos de uso justificado de cursores bidireccionales como lo es para los cursores “normales”. La principal utilidad de los primeros es la implementación de interfaces de programación para aplicaciones clientes. La versión “clásica” de ADO utilizaba cursores bidireccionales para implementar cursores en el servidor. ADO.NET, de momento, no implementa este tipo de cursores remotos, pero es muy probable que sí lo haga en su versión 2.

Primero la sintaxis. Si usamos la estándar, tenemos que añadir la palabra scroll a la declaración de un cursor para que sea bidireccional:

declare CursorPlanes scroll cursor for

select DiaSemana, DescrTarea

from PlanesSemanales

where Empleado = @empleado

order by DiaSemana asc

Para la sintaxis extendida, la declaración equivalente es:

declare CursorPlanes cursor scroll for

select DiaSemana, DescrTarea

from PlanesSemanales

where Empleado = @empleado

order by DiaSemana asc

Con los cursores bidireccionales se admiten las siguientes variantes de fetch:

/* Navegación simple */

fetch next from CursorPlanes into variables

fetch prior from CursorPlanes into variables

fetch first from CursorPlanes into variables

fetch last from CursorPlanes into variables

/* Movimiento absoluto */

fetch absolute valorEntero from CursorPlanes into variables

/* Movimiento relativo */

fetch relative valorEntero from CursorPlanes into variables

Si no indicamos la dirección del movimiento, SQL Server asume que queremos la siguiente fila.

Variables de cursor

¿Ha tenido alguna vez que programar dos algoritmos de recorrido similares que, sin embargo, deben funcionar con conjuntos de filas diferentes? Observe el siguiente listado en pseudo código:

Page 126: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

126 La Cara Oculta de C#

if (condicion)

begin

declare C1 cursor for

/* … etc … */

/* Algoritmo de recorrido */

open C1

/* … etc … */

end

else

begin

declare C1 cursor for

/* … etc … */

/* Algoritmo de recorrido */

open C1

/* … etc … */

end

Suponga que las dos secciones designadas “algoritmo de recorrido” son idénticas... excepto porque una hace referencia al cursor C1 y la otra a C2. ¿No le gustaría poder extraer el factor común para simplificar el código? Eso precisamente es lo que nos permiten hacer las variables de cursor. Esta es la declaración de una de ellas:

declare @cur cursor

A continuación asociamos un cursor concreto a la variable:

if (condicion)

set @cur = cursor for select /* … etc … */

else

set @cur = cursor for select /* … etc … */

Y ya podemos utilizar la variable de cursor como si se tratase de un cursor definido estáticamente:

open @cur

fetch @cur into variables

while (@@fetch_status = 0)

begin

/* … hacer algo con las variables recuperadas … */

fetch @cur into variables

end

deallocate @cur

¿Se da cuenta de que hemos tenido que escribir el bucle de recorrido una sola vez?

Page 127: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

8

Funciones definidas por el usuario

AS FUNCIONES DEFINIDAS POR EL USUARIO SON uno de los lujos introducidos por SQL Server 2000. Digo que se trata de un lujo porque este recurso no está

presente en muchos de los sistemas de bases de datos que crecen bajo el sol de este siglo XXI... o se logran a base de mucho sacrificio e inseguridad. Algunos sistemas, por ejemplo, obligan a que el programador defina sus funciones en una DLL inde-pendiente, utilizando lenguajes y compiladores tradicionales. Sólo hay un peligro relacionado con las funciones de usuarios en Transact SQL: ¡es una técnica adictiva! Así que ándese con cuidado, si no quiere terminar sus días programando en Java y bebiendo leche de soja, en uno de esos centros de desintoxicación...

Funciones escalares

Hay dos tipos, o puede que tres, de funciones definidas por el usuario en SQL Server 2000, y comenzaremos, como puede suponer, por el tipo más sencillo: funciones que pueden recibir parámetros, o no, y que devuelven a cambio un valor de tipo simple o escalar. Esta tonta función es un buen ejemplo, desde el punto de vista meramente sintáctico:

create function factorial (@i integer) returns bigint as

begin

if (@i < 2) return 1

return @i * dbo.factorial(@i - 1)

end

Incluso en un ejemplo tan simple, pueden surgir algunos problemas, si nos dejamos guiar por la inercia y descuidamos ciertos detalles:

1 La lista de parámetros debe ir encerrada entre paréntesis, a diferencia de lo que ocurre en las declaraciones de procedimientos almacenados.

2 La cláusula returns de la cabecera es, obviamente, una novedad. 3 No podemos ahorrarnos las palabras claves begin y end, incluso cuando tuvié-

semos una sola instrucción en el cuerpo de la función. 4 En relación con lo anterior: la última instrucción del cuerpo de la función debe

ser un return. Intente reescribir la función factorial utilizando una parte else, y SQL Server se quejará.

L

Page 128: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

128 La Cara Oculta de C#

5 Quizás el rasgo más extraño sea que, incluso dentro del cuerpo de la función, hay que añadir el nombre del propietario como prefijo para poder llamar a la función. Observe que en la cláusula recursiva me he visto obligado a mencionar al dbo, o database owner, para que SQL Server aceptase mi función.

Hay otra restricción importante, que en este caso no tiene que ver directamente con la sintaxis:

6 En la definición de una función de usuario no se pueden utilizar lo que SQL Server 2000 denomina funciones no deterministas.

Las funciones no deterministas son aquellas funciones predefinidas que pueden de-volver valores diferentes incluso cuando se les pasan los mismos parámetros de en-trada. La función predefinida len, que calcula la longitud de una cadena, es determi-nista, porque siempre devuelve la misma longitud para una misma cadena. Es así como debería ser siempre, ¿no? Puede que en matemática sí, pero en la vida real no. Funciones no deterministas son, por ejemplo, getdate, que devuelve la fecha y la hora, o rand, que devuelve un número aleatorio... o @@connections, que a pesar de su extraña sintaxis, es considerada como función por SQL Server.

NO

TA

Esta restricción tiene mucho sentido, porque permite que SQL Server tenga mayor liber-tad cuando debe generar código optimizado para instrucciones que utilizan una función de usuario.

Conociendo esta limitación, le sorprenderá saber que podemos crear funciones como la siguiente:

create function ComprasCliente(@idcliente integer)

returns money as

begin

return (

select coalesce(sum(precio*cantidad), 0)

from detalles

where idpedido in (

select idpedido

from pedidos

where idcliente = @idcliente))

end

ComprasCliente recibe un identificador de cliente, y devuelve el resultado de una con-sulta escalar que calcula el importe total de todas sus compras. A diferencia del facto-rial, este tipo de funciones resultan realmente útiles para la programación de aplica-ciones. Sin embargo, ¿no estamos violando acaso el requisito del determinismo? Esta función no es una función matemática “pura”. Se denominan así las funciones cuyos resultados dependen exclusivamente de sus argumentos. Una función no determi-nista no puede ser pura, por definición. Pero la función ComprasCliente no es pura, porque su resultado depende de los registros almacenados en la base de datos en un momento dado. ¿Por qué entonces SQL Server no se queja?

Page 129: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Funciones definidas por el usuario 129

La explicación la tiene la escala de tiempo que adoptemos: SQL Server exige el de-terminismo para garantizar que pueda jugar con el instante concreto en que se evalúe cada uso de la función. Si yo llamo ahora a la función, razona el optimizador de con-sultas, debe devolverme el mismo valor que si la llamo con los mismos parámetros dentro de tres milésimas de segundo. Pero eso de las “tres milésimas” es engañoso: lo que realmente nos importa es que el resultado sea el mismo durante el tiempo que dure la evaluación de la consulta que ha utilizado la función. Durante ese tiempo, los registros de la base de datos no deben ser modificados: ¡eso es parte de los requeri-mientos de serializabilidad de las transacciones!

Ejecutando funciones escalares

Vamos ahora a la parte práctica: ¿cómo puedo ejecutar una función escalar? Si lo que desea es, simplemente, conocer cierto valor, puede echar mano de select:

select dbo.ComprasCliente(1)

Vuelvo a llamarle la atención sobre la necesidad de incluir, como prefijo, el nombre del propietario de la función, para evitar errores sintácticos.

En general, podemos ejecutar funciones definidas por el usuario en casi cualquier contexto en que sea válida una expresión del tipo de retorno de la misma: en una cláusula select, como parte de una condición en una cláusula where, en instruccio-nes de procedimientos almacenados y triggers, e incluso en restricciones check o en la definición de columnas calculadas.

Funciones de tablas en línea

Antes mencioné que existían dos o tres tipos de funciones definidas por el usuario. Ya conocemos las funciones escalares. La otra posibilidad es definir funciones que devuelvan tablas, o relaciones, o conjuntos de registros... en fin, como prefiera lla-marlo. De este tipo de funciones existen dos subtipos (de ahí el impreciso “dos o tres”):

1 Funciones de tablas en línea, que se basan en una sola expresión relacional. 2 Funciones de tablas con múltiples instrucciones, que en vez de tener una defini-

ción declarativa, construyen su resultado mediante varias instrucciones SQL.

Este es un ejemplo de función de tablas en línea:

create function ProductosComprados(@idcliente integer)

returns table return

select *

from productos

where idproducto in (

select d.idpedido

from detalles d inner join pedidos p

on d.idpedido = p.idpedido

where p.idcliente = @idcliente)

Page 130: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

130 La Cara Oculta de C#

Sintácticamente, la primera diferencia está en la cláusula returns, que en vez de men-cionar un tipo escalar para el valor de retorno, utiliza la palabra clave table. La se-gunda diferencia consiste en que no se pueden utilizar begin y end para el cuerpo de la función; como ésta debe basarse en una sola instrucción select, no es necesario usar bloques de agrupación de instrucciones. Observe que, para marear un poco la perdiz, he omitido la palabra clave as, que podría situarse entre table y return, en la segunda línea.

Conceptualmente, una función de tabla en línea es muy parecida a una vista, sólo que la función nos permite indicar parámetros. Sin este tipo de funciones, tendríamos que crear vistas sin restricciones, como la siguiente:

create view ProductosClientes as

select distinct pr.idproducto, pe.idcliente

from productos pr inner join

detalles de on pr.idproducto = de.idproducto inner join

pedidos pe on de.idpedido = pe.idpedido

Cuando necesitásemos ver los productos adquiridos por el cliente cuyo código es 1, tendríamos que ejecutar lo siguiente:

select *

from Productos

where IDProducto in (

select IDProducto

from ProductosClientes

where IDCliente = 1)

Largo, ¿verdad? Sin embargo, es preferible a tener que plantear de nuevo el encuen-tro natural entre las tres tablas de la vista. Pero lo realmente cómodo es poder usar la función de tablas que acabamos de crear:

select * from ProductosComprados(1)

Observe que ya no es necesario indicar el propietario de la función, y que esta se utiliza como si se tratase de una tabla, en la cláusula from de una instrucción select.

Ensamblando un resultado

El otro tipo de funciones de tablas se distingue sintácticamente porque menciona una variable en su cláusula returns, para hacer referencia a la tabla, o más exacta-mente, relación, que vamos a devolver:

create function NombreFuncion ( … parámetros … )

returns @tabla table (

… columnas de la tabla a devolver …

) as

begin

… instrucciones …

return

end

Page 131: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Funciones definidas por el usuario 131

Como puede ver, la misma función debe definir cuáles son las columnas de la tabla que devolverá como resultado. Al comenzar la ejecución de la función, se crea una tabla “local” con la estructura declarada en la cabecera de la función. Inicialmente la tabla no contiene registros, y es responsabilidad de las instrucciones del cuerpo de la función añadir registros, o modificarlos si fuese necesario.

La mejor manera de mostrar las posibilidades de este tipo de funciones es mediante un ejemplo. En el capítulo 6, sobre procedimientos almacenados, mostramos un ejemplo que calculaba la clausura transitiva de una tabla. Teníamos la siguiente tabla, como base:

create table PIEZAS (

IDPieza integer not null primary key,

Nombre varchar(30) not null unique,

Pertenece integer null references PIEZAS(IDPieza)

)

Para obtener las piezas que dependen recursivamente de una pieza determinada, utilizábamos un procedimiento almacenado, que devolvía el resultado como un con-junto de datos, y que internamente recurría a tablas temporales para ensamblar ese resultado. Sin embargo, al usar un procedimiento almacenado de selección, tenemos una importante limitación: no existe una forma fácil de combinar este conjunto de resultados con otras tablas. Por ejemplo, no podríamos agrupar las filas obtenidas por algún criterio (es cierto que en el ejemplo de las piezas no existe tampoco esa posibilidad lógica), o evaluar un encuentro natural con una hipotética tabla de pedi-dos... a menos que guardásemos el resultado del procedimiento en otra tabla tempo-ral. Demasiado complicado, creo.

En cambio, veremos que no tenemos esas limitaciones si volvemos a escribir el pro-cedimiento como una función de tablas:

create function PiezasRecursivas(@idpieza integer)

returns @rslt table (idpieza integer, nombre varchar(30)) as

begin

/* Declaramos una tabla local */

declare @buffer table (

posicion integer identity(1,1),

idpieza integer,

nombre varchar(30)

)

/* La inicializamos con una semilla */

insert into @buffer(idpieza, nombre)

select @idpieza, nombre

from piezas

where idpieza = @idpieza

/* Y la llenamos en un bucle */

declare @inicio integer, @fin integer

set @inicio = 1

set @fin = 1

while (@inicio <= @fin)

begin

insert into @buffer(idpieza, nombre)

Page 132: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

132 La Cara Oculta de C#

select idpieza, nombre

from piezas

where pertenece in (

select idpieza

from @buffer

where posicion between @inicio and @fin)

set @inicio = @fin + 1

set @fin = @@identity

end

/* Devolver su contenido */

insert into @rslt

select idpieza, nombre

from @buffer

return

end

Seguimos necesitando un almacenamiento temporal para guardar los resultados par-ciales, pero esta vez no hemos utilizado una tabla temporal, sino una tabla declarada como si fuese una variable local. Desde el punto de vista práctico, ambas construc-ciones son casi equivalentes... excepto que nos está explícitamente prohibido utilizar tablas temporales dentro de una función de tablas. Puede imaginar el motivo: garan-tizar el determinismo de la función.

La nueva función puede ejecutarse mediante instrucciones como la siguiente:

select * from PiezasRecursivas(1)

Por supuesto, he mostrado la forma más simple de uso. Nada nos impide utilizar las restantes cláusulas admitidas en una consulta, o añadir otra tabla a la cláusula from.

Expresiones comunes de tablas

La técnica que presentaré para cerrar este capítulo no podrá probarla... de momento. Se trata de un nuevo mecanismo introducido en SQL Server 2003 para escribir con-sultas recursivas. La descripción de esta sección se basa en los anuncios públicos de Microsoft durante la fase beta de esta versión. Por lo tanto, le advierto que puede haber diferencias respecto al producto final.

El nuevo recurso se llama Common Table Expressions, es decir, expresiones comunes de tablas (CTE) y, como he dicho, sintácticamente es un tipo especial de consulta, que puede ser utilizada dentro de procedimientos almacenados, triggers o funciones defi-nidas por el usuario. Eche un vistazo al siguiente ejemplo:

with PiezasRecursivas as (

select *

from Piezas

where IDPieza = 1

union all

select p.*

from Piezas p join PiezasRecursivas pr

on p.Pertenece = pr.IDPieza )

select *

from PiezasRecursivas

Page 133: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Funciones definidas por el usuario 133

Hay dos partes en el nivel sintáctico superior: primero definimos PiezasRecursivas, como un tipo especial de vista, e inmediatamente después lo utilizamos en una ins-trucción select. Realmente, el alcance de PiezasRecursivas termina en esa consulta, y no podemos volver a mencionarla más adelante, a no ser que volvamos a definirla.

Centrémonos ahora en la definición de PiezasRecursivas. Es una típica definición re-cursiva, que sigue el patrón de la inducción matemática. Primero se define una semi-lla, o relación base. En nuestro caso:

select *

from Piezas

where IDPieza = 1

A continuación, viene la consulta que crea una nueva generación de filas a partir de una generación existente. Las nuevas piezas vienen de la tabla Piezas, en el encuentro natural de la segunda consulta, y se eligen aquellos registros relacionados con una pieza elegida en alguna de las operaciones anteriores. Como puede ver, la lógica es muy similar a la aplicada en la sección anterior, excepto que esta vez hemos tenido que escribir menos. Espero, además, que la consulta sea más fácil de leer, compren-der y mantener.

NO

TA

La sintaxis de las nuevas Common Table Expressions es muy parecida. si no idéntica, a la sintaxis de las consultas recursivas de DB2.

Page 134: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

9

Transacciones SQL

REO RECORDAR QUE FUE EN UN LIBRO o en un artículo para programadores; estoy casi seguro de que no lo leí en algún libro de bases de datos teórico o de

“propósito general”. El autor, con aires doctorales, explicaba que la palabra transaction nace de la contracción de la frase transformation action: acción de transformación. Muy bonito, muy adecuado para el párrafo o capítulo en cuestión, pero lamentablemente es un estupendo disparate. Una búsqueda rápida en cualquier diccionario con etimo-logías le hubiese aclarado al ilustrado escritor que transacción deriva del latín transactus, participio pasado de transigere, que significa simplemente llevar a cabo.

¿A qué vienen estos latinajos al principio del capítulo? Para ser sinceros, la desdi-chada etimología que acabo de destrozar me tuvo en duda mucho tiempo, y antes de tirar del diccionario y aclararme se me ocurrió la peregrina idea de comenzar esta página hablando de las transacciones como acciones de lo que ya usted sabe. Le tengo terror a mis ideas. La última vez que tuve una pasé mucho miedo sacándomela de la cabeza; es que hay ideas explosivas, y aquella podía estallarme en las manos. Abrí con mucho cuidado la ventana de mi séptima planta, comprobé que no me observaba la vecina del edificio de al lado, dejé caer la idea a la calle y cerré la ventana apresuradamente. No oí el golpe en el asfalto. Debe haber caído sobre algún desa-fortunado peatón, que ahora la considerará suya.

E pluribus unum

Aunque vamos a tratar sobre transacciones, nos veremos obligados a considerar a la vez varios aspectos importantes de la explotación de una base de datos: el control de errores y el acceso concurrente, entre otros. A medida que avancemos en nuestra explicación descubriremos el porqué de esta mezcolanza. Sin embargo, podemos iniciar el estudio de las transacciones tomando como punto de partida una de las características que éstas garantizan: la atomicidad de un conjunto de operaciones.

Comencemos por algo sencillo: hay que modificar el salario de todos los trabajadores de un departamento, esperemos que sea al alza.

update EMPLEADOS

set Salario = Salario * 2

C

Page 135: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Transacciones SQL 135

where IDDept = (select IDDept

from DEPARTAMENTOS

where Nombre = 'Informática')

Es normal que una instrucción de este tipo afecte a todo un grupo de registros, y también lo es que se cumpla la Ley de Murphy y que el proceso falle por algún mo-tivo a mitad del camino. Espero que no sea necesario convencerle de que deben mo-dificarse todos los registros del departamento o ninguno. Como en griego átomos quiere decir indivisible, es costumbre afirmar que la instrucción de modificación anterior debe ejecutarse atómicamente.

A veces no queda tan claro el carácter atómico de un conjunto de instrucciones. El ejemplo anterior estaba basado en una sola instrucción SQL, pero recuerde que en SQL Server podemos remitir al servidor grupos de instrucciones como el siguiente:

update EMPLEADOS

set Salario = Salario * 2

where IDDept = (select IDDept

from DEPARTAMENTOS

where Nombre = 'Informática')

update EMPLEADOS

set Salario = Salario / 2

where IDDept = (select IDDept

from DEPARTAMENTOS

where Nombre = 'Comercial')

¿Qué sucede si la primera actualización finaliza con éxito y ocurre un fallo a mitad de la segunda? Teóricamente, todas las posibilidades están abiertas; en la práctica, SQL Server trata las dos instrucciones de forma independiente. Mi objetivo al presentar estos ejemplos es hacerle comprender la necesidad de instrucciones especiales que actúen como “marcas” y que permitan agrupar varias sentencias SQL consecutivas, de forma tal que el grupo resultante se comporte de forma atómica ante la posibilidad de un fallo o error de ejecución.

Inicio y fin de transacción

Transact SQL utiliza la siguiente sentencia para indicarle al servidor que las instruc-ciones que se van a ejecutar a partir de ese momento, deben ser agrupadas en una transacción:

begin transaction

Así de fácil; incluso podemos abreviar transaction en un cariñoso tran. A partir del momento en que ejecutamos la instrucción anterior, cualquier modificación que realicemos sobre la base de datos tendrá un carácter temporal, no definitivo... a no ser que ejecutemos otra instrucción relacionada:

commit transaction

¿Qué quiero decir con “temporal” y “no definitivo”? Que si se interrumpe el sumi-nistro eléctrico o, lo que es más probable, se cuelga el ordenador por la razón que

Page 136: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

136 La Cara Oculta de C#

sea, cuando volvamos a poner en marcha el servidor encontraremos que los cambios que presuntamente habíamos efectuado han desaparecido sin dejar huellas. Más ade-lante explicaré como se produce tal magia.

Existe otra forma menos radical de descartar los cambios efectuados dentro de una transacción. Sólo tenemos que ejecutar la siguiente instrucción:

rollback transaction

NO

TA

Muchos programadores relacionan los conceptos de “error” y de rollback: si se produce un error, se ejecuta un rollback, y si lanzamos un rollback se produce un error... o al me-nos, se termina el conjunto de instrucciones que estamos ejecutando. Sepa que esa relación es completamente falsa en SQL Server, en los dos sentidos. Un error no provoca cambios en el estado de la transacción activa, si es que existe alguna. Si quiere deshacer todos los cambios efectuados al recibir un error, tendrá que hacerlo manualmente; luego explicaré cómo y dónde. En la otra dirección: usted puede lanzar un rollback con toda la tranquilidad de un yogui, que su aplicación no va a generar una excepción al retomar el control.

Quiero adelantarle también que existe una variante menos drástica de rollback que permite deshacer los cambios solamente hasta un punto fijado con anterioridad. Más adelante veremos como utilizar la instrucción save transaction para marcar estos puntos especiales.

Transacciones implícitas

Si no hacemos nada raro, SQL Server funcionará normalmente en modo de confirma-ción automática. Esto sólo significa que, cómo he explicado antes, cada instrucción de modificación será tratada como una transacción individual, y que un error en una instrucción provoca la cancelación de todos los cambios parciales realizados por la misma.

Pero también podemos activar un modo alternativo de funcionamiento para nuestra conexión, de manera tal que SQL Server utilice transacciones implícitas. Para activar este modo hay que ejecutar la siguiente instrucción:

set implicit_transactions on

Una vez activadas las transacciones implícitas, existe un conjunto de instrucciones que automáticamente inician una transacción si no existe ya una activa; el catálogo en cuestión incluye a nuestros tres fieles mosqueteros insert, delete y update, pero también a las instrucciones de apertura de cursores, de truncamiento de tablas y va-rias sentencias más del lenguaje de definición y control de datos.

Esto no nos libra de la obligación de confirmar o cancelar transacciones de manera explícita. Para confirmar tenemos que ejecutar commit, al igual que antes. La dife-rencia está en que después de la confirmación volverá a iniciarse una transacción, en cuanto se ejecute cualquiera de las instrucciones que tienen esa capacidad. Y lo mismo sucederá si cancelamos una transacción.

Page 137: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Transacciones SQL 137

NO

TA

En mi opinión, no es buena idea utilizar transacciones implícitas. El cargo principal contra ellas es el riesgo de crear transacciones de larga duración, que son nefastas para siste-mas con mucha carga.

Aislamiento de transacciones

Además de garantizar el noble objetivo de la atomicidad, las transacciones tienen un segundo propósito que se manifiesta cuando hay más de un usuario mareando en una misma base de datos: un usuario debe poder actuar, dentro de lo posible, como si la base de datos fuese de su exclusiva propiedad. Debemos propiciar la ilusión de que cada usuario se encuentra campando a sus anchas, completamente solo, en me-dio de la Gran Catedral del Culto Binario, a pesar de que otros adoradores idólatras comparten espacio con nuestro ingenuo creyente. Una aclaración muy importante: tampoco estamos obligados a permitirle al usuario que haga todo lo que le venga en ganas. A veces, con tal de que no se desvanezca la ilusión, tendremos que interponer en su camino barreras infranqueables de cristal transparente.

¿Hasta qué punto podemos llegar en la ilusión de aislamiento? Todo depende del precio que aceptemos pagar por ello. Mientras mayor sea el nivel de aislamiento, más barreras de cristal necesitaremos. Si son muchos los visitantes de la Catedral, puede incluso que llegue a ser imposible dar dos pasos seguidos. ¿Podemos formalizar este concepto de aislamiento? Resulta que sí, que el concepto de serializabilidad, estable-cido hace ya mucho tiempo por los teóricos de la Informática, nos viene como anillo al dedo para describir cómo, en un mundo feliz, deberían comportarse las transac-ciones concurrentes.

Cuando varias transacciones se ejecutan al mismo tiempo sobre una base de datos, nos encontramos ante un caso que puede representarse con el siguiente diagrama:

Transacción 1

Transacción 2

Transacción 3

Eje del tiempo

Mi intención como “artista” ha sido mostrar transacciones cuyas acciones ocurren en paralelo; al menos en algunos momentos. Simplificaré el ejemplo suponiendo que el servidor tiene un único procesador central. Es obvio entonces que, aunque cada transacción se ve a sí misma como un bloque monolítico, sus acciones individuales se fragmentarán para poder distribuirlas de forma más o menos uniforme, de modo que el procesador en cualquier momento solamente ejecutará una de ellas.

Page 138: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

138 La Cara Oculta de C#

Eje del tiempo

Está claro que esta división aparentemente caótica de las acciones individuales puede llevar a resultados impredecibles. El principio de serializabilidad se limita a pedir que el resultado final de la distribución del tiempo del procesador debe ser idéntico al que obtendríamos si las transacciones, desde un primer momento, se hubiesen ejecutado una detrás de otra:

Transacción 1

Transacción 3

Transacción 2

Eje del tiempo

Debo hacer dos observaciones muy importantes sobre lo que en realidad establece el principio de serializabilidad:

• Se dice que el efecto debe ser el mismo, pero no que debamos ejecutar realmente toda una transacción antes de pasar a la siguiente. El servidor de datos seguirá troceando cada transacción que ejecute. A primera vista, esto parece una estupi-dez, pues el tiempo total requerido para ejecutar las tres transacciones en forma monolítica es exactamente el mismo que el consumido por las acciones rebana-das. Debe comprender, sin embargo, que la meta es que el tiempo de espera de cada transacción sea aceptable para cada una de ellas. Si ejecutamos las transac-ciones en forma serial pero monolítica, tal como se muestra en el anterior dia-grama, el usuario de la transacción 1 se irá muy contento a casa, porque su tran-sacción no ha tenido que esperar nada. El usuario 3, por el contrario, tendrá que ser atendido por el equipo médico de urgencias, porque su tensión arterial se disparará durante la larga espera.

• El principio no se inmiscuye en el orden teórico exacto en el que se les da servi-cio a las transacciones. En el primer diagrama, por ejemplo, las transacciones lle-gaban en el orden 1-2-3. En el diagrama serializado, sin embargo, las transaccio-nes podrían ejecutarse teóricamente en el orden 3-2-1.

La explicación que aquí daremos a las técnicas que garantizan el aislamiento entre transacciones estará basada en el uso de bloqueos. Existen muchas otras técnicas posi-

Page 139: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Transacciones SQL 139

bles, y algunas de ellas han llegado a ser famosas, como la Arquitectura Multigeneracio-nal, porque han sido adoptadas por otros sistemas de bases de datos; InterBase en este caso. Comenzaremos ilustrando las interacciones no deseadas que pueden pro-ducirse entre transacciones concurrentes, y desplegando el catálogo de soluciones, desde las menos hasta las más restrictivas.

En el principio era el caos

Estoy escribiendo una carta en mi escritorio. No hay nadie más en la habitación, al menos que yo pueda ver. Abro el cajón donde guardo los sobres, y cuando voy a plegar el papel, me doy cuenta de que una mano invisible (¡tiene que ser un fan-tasma!) ha cambiado el sentido de la misiva.

Parece parte de un mal cuento de terror, pero es la impresión que tendríamos si un registro modificado por una aplicación pudiese ser “retocado” por una segunda tran-sacción antes de que la primera confirmase las actualizaciones.

Puedo contarle otra historia horripilante. Estoy escribiendo una carta a mi jefe, di-ciéndole lo que pienso de él: que es un mentiroso compulsivo, un hipócrita de mucho cuidado, que estoy harto de que fume al lado mío, y que no tiene ni zorra idea de cómo funciona el negocio. Una vez escrita la carta, considero que me he desahogado lo suficiente como para no reventar, rompo la carta en minúsculos fragmentos que arrojo a la papelera. En ese momento entra el hipócrita mentiroso hecho un energú-meno, y me dice que tras leer mi carta no le dejo más opción que el despido5...

¿Telepatía? Sería la única explicación si modificásemos un registro dentro de una transacción, decidiésemos deshacer los cambios y descubriésemos que otra transac-ción ha podido leer el valor temporalmente asignado al registro, a pesar de que nunca dijimos que fuera un valor definitivo. Este problema se conoce en Informática con el nombre de lecturas sucias (dirty reads).

Para su tranquilidad, sepa que el nivel de aislamiento por omisión de SQL Server no permite que sucedan estas cosas. Incluso el nivel más bajo evita que alguien pueda modificar desde una transacción un valor asignado en otra. Pero existe la posibilidad de permitir la lectura de valores que no son definitivos. Bastaría con ejecutar la si-guiente instrucción:

set transaction isolation level read uncommitted

La instrucción solamente afectaría a la conexión desde la cual se ejecutase.

Existen motivos excepcionales para utilizar este nivel de aislamiento tan bajo:

• Si nuestra aplicación solamente tendrá un usuario.

5 Sólo es real la primera parte de la historia. De cada quince palabras que pronunciaba, sola-mente eran ciertas las preposiciones, los artículos y las conjunciones.

Page 140: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

140 La Cara Oculta de C#

• Las modificaciones que se producen son pequeñas, de acuerdo a algún criterio, y las aplicaciones deben poder leer siempre cualquier registro. Por ejemplo, si se trata de vigilar las cotizaciones de determinadas acciones en la bolsa, y no existe la posibilidad de que algún usuario travieso escriba valores irracionales.

• Si es más importante la velocidad que la consistencia.

De todos modos, no es aconsejable utilizar el nivel de aislamiento más bajo. Incluso para los casos especiales que he mencionado, existen otros trucos que explicaremos más adelante.

Experiencias paranormales en la catedral

Existe una técnica muy sencilla para evitar las lecturas sucias: cada vez que se modi-fica un registro, debemos bloquearlo, y el bloqueo debe permanecer activo hasta que finalice la transacción. Debe ser, además, un bloqueo exclusivo, que impida la lectura y la modificación del registro por cualquier otra transacción.

Esto es lo que hace SQL Server por omisión. El nivel de aislamiento correspon-diente se conoce como lecturas confirmadas (read committed), y si queremos asegurarnos de que es el nivel activo, debemos ejecutar la siguiente instrucción:

set transaction isolation level read committed

Pero seguimos teniendo experiencias paranormales. Estamos en la catedral, y mata-mos el tedio observando los vitrales. Hay una bonita vidriera de Santa Rita (lo que se da no se quita) en la tercera ventana de la izquierda. Luego dirigimos la vista a un tapiz que muestra el suplicio y muerte de San Vito (el del baile famoso), pero cuando volvemos a observar la tercera ventana de la izquierda, milagrosamente ha sido sus-tituida por una escena en la que las Spice Girls bailan y hacen como que cantan ante un boquiabierto San Pedro, que no atina a decidir si las envía de cabeza al Infierno, o si las deja entrar y de paso les pide un autógrafo6...

De vuelta a la realidad informática, suponga que lanzamos la siguiente instrucción:

select sum(Balance)

from CUENTAS

La tabla Cuentas es muy grande, y la transacción tarda un par de minutos en terminar. Paralelamente, alguien ejecuta el siguiente par de instrucciones dentro de una tran-sacción:

update CUENTAS

set Balance = Balance – 10000

where IDCliente = 34

update CUENTAS

6 Supongo que en el cielo también estará el viejo Lennon cantando con su lira aquello de “...imagine there’s no Heaven...”

Page 141: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Transacciones SQL 141

set Balance = Balance + 10000

where IDCliente = 3400

El caos se produce si, en el momento en que se ejecutan las dos actualizaciones, la instrucción que suma los balances ha leído ya el registro del cliente número 34, pero aún no ha llegado al del cliente 3.400. ¿Hay algo, hasta el momento, que impida la modificación del registro 34? Nada por ahora. Por lo tanto, la primera instrucción va a notificar 10.000 (¿dólares, euros, rupias marcianas?) más que los existentes. Si antes de terminar la ejecución de la consulta sobre la suma, la primera transacción regre-sara al registro 34, encontraría una imagen muy diferente de la que vio cuando pasó por allí por primera vez.

Que nadie mueva la alfombra bajo mis pies

Este problema también tiene un nombre: lecturas no repetibles (not repeatable reads)... y una solución: cada vez que lea un registro, deberá bloquearlo en modo compartido y mantener el bloqueo hasta el fin de la transacción. Un bloqueo “compartido”, o de lectura, permite que otras transacciones puedan leer valores del registro, pero no deja que lo modifiquen. Para activar el tercer nivel en SQL Server hay que teclear:

set transaction isolation level repeatable read

La activación del tercer nivel disminuye la capacidad del servidor para atender mu-chas peticiones al mismo tiempo. Establecer un bloqueo lleva algo de tiempo y cada uno de ellos consume recursos. Sin embargo, es la única forma de garantizar un mí-nimo de coherencia.

No piense, por ejemplo, que solamente las instrucciones de lectura que afectan a muchos registros son susceptibles de retornar resultados erróneos. Analice la si-guiente secuencia de instrucciones:

declare @contador integer

select @contador = ValorActual

from CONTADORES

update CONTADORES

set ValorActual = ValorActual + 1

Como comprenderá, esta secuencia se puede encontrar de forma más o menos disi-mulada en muchas aplicaciones que utilizan Transact SQL. Y las lecturas no repeti-bles pueden hacer que funcione mal. Imagine que una transacción lee el valor que le corresponde en la tabla Contadores. Pero justo en ese momento, el sistema operativo le concede sus quince milisegundos de fama a otra transacción que tiene las mismas ambiciones. La segunda transacción tiene tiempo suficiente para leer el mismo valor que leyó la primera, y para incrementar el valor actual. Entonces regresa la segunda transacción, e inocentemente incrementa la misma columna. Aparentemente, todo está bien, pues ValorActual ha aumentado en dos... pero las dos transacciones han devuelto el mismo contador a sus respectivas aplicaciones. En este caso daría lo

Page 142: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

142 La Cara Oculta de C#

mismo si la instrucción update se escribiese en la siguiente forma alternativa (el resultado sería incluso peor):

update CONTADORES

set ValorActual = @contador + 1

¿Tiene esto algo que ver con las lecturas no repetibles? ¡Claro que sí, aunque no sea evidente a simple vista! Si la primera transacción releyese el valor actual cuando re-toma el control, antes de la actualización, comprobaría que alguien ha movido la alfombra bajo sus pies. La moraleja que intento comunicar es que puede ser compli-cado saber si nuestra aplicación es susceptible a problemas de concurrencia, y que lo mejor es diseñarla para que funcione siempre con el mayor nivel de aislamiento posi-ble.

NO

TA

Existen también trucos para poder funcionar con niveles de aislamiento inferiores y man-tener la coherencia. Los veremos más adelante.

No se admiten fantasmas

Hay un cuarto nivel, por encima de las lecturas repetibles, y hay una anomalía que justifica la existencia de ese nivel adicional. Suponga que necesitamos saber cuánto dinero tiene cada cuenta como media. Y admitamos que no es nuestro día más bri-llante, que nuestras capacidades mentales funcionan peor que de costumbre, y que escribimos el siguiente fragmento de disparate:

declare @total integer, @suma money

select @total = count(*)

from CUENTAS

select @suma = sum(Balance)

from CUENTAS

select @suma/@total Media

¿Qué pasaría si alguien crease una cuenta después de terminar la primera selección y antes de comenzar con la segunda? Me respondo yo mismo: que obtendríamos una media distorsionada. Las lecturas repetibles no tienen nada que ver con este pro-blema. No es asunto de las modificaciones, sino de las inserciones. Estas filas ino-portunas se denominan filas fantasmas (phantom rows).

Para impedir la aparición de filas fantasmas, SQL utiliza bloqueos de rango, que impiden la creación de filas con determinadas características de acuerdo a las instrucciones que vaya a ejecutar una transacción. El nivel correspondiente se activa con la si-guiente instrucción:

set transaction isolation level repeatable read

Page 143: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Transacciones SQL 143

NO

TA

He dado una explicación de los niveles de concurrencia basándome en su implementa-ción mediante bloqueos. No obstante, existen otras técnicas para aislar transacciones que no utilizan necesariamente bloqueos. El caso más destacado es la técnica utilizada por InterBase: versiones de registros, que es incluso más eficiente. Sin embargo, esta técnica tiene también implicaciones considerables en la estructura física de la base de datos que hasta el momento no se han podido resolver satisfactoriamente. Por ejemplo, la creación de réplicas o de copias de seguridad diferenciales no es algo que se resuelva elegantemente en InterBase, al menos de momento.

¿Transacciones anidadas?

He tenido cuidado de encerrar el título de la sección entre signos de interrogación... porque la respuesta es no. Menciono las transacciones anidadas sólo porque cierta característica de SQL Server puede llevarnos a pensar que el sistema las soporta.

Cierre los ojos. Está usted trabajando para una importante compañía financiera. Cierto día en el que la creatividad es tanta que se le derrama por los oídos, usted programa un maravilloso procedimiento almacenado que modifica sutilmente las condiciones de pago de un contrato financiero para que la empresa gane más pasta, a costa de algún desprevenido cliente:

create procedure IngenieriaFinanciera @idProducto integer as

/* ¿Creía de verdad que le iba a dar una pista? */

Como lo más probable es que el procedimiento en cuestión tenga que retocar infor-mación en varios registros pertenecientes a distintas tablas, usted decide incluir di-rectamente el control de transacciones dentro de la rutina. Está en su derecho para proceder de este modo.

Supongamos ahora que la empresa descubre la existencia del procedimiento y que, al ser el gerente un codicioso de mucho cuidado, decide ejecutar un proceso todas las noches que le apriete las clavijas a varios préstamos elegidos por azar. Y quiere que todo el nuevo proceso se comporte de forma atómica. Hay que crear un nuevo pro-cedimiento almacenado, con el nombre en clave AmorAlProjimo, que debe abrir un cursor con las víctimas de esa noche, y debe ejecutar IngenieriaFinanciera sobre el pro-ducto elegido. Por supuesto, antes del inicio del bucle debemos iniciar una transac-ción y confirmarla al terminar éste.

En realidad, podríamos crear un procedimiento auxiliar, con algún nombre predeci-ble como IngenieriaFinancieraInterna, que contenga el núcleo del algoritmo pero sin incluir transacciones, y hacer que la verdadera IngenieriaFinanciera y el nuevo AmorAl-Projimo llamen a la rutina auxiliar. Pero eso llevaría más tiempo. Además, en el mundo real7 cotidianamente ocurre que reutilizamos procedimientos de los que no siempre recordamos su implementación. La pregunta es: ¿cómo se comporta SQL Server

7 Porque está claro que cualquier semejanza con personas o instituciones existentes es sólo fruto de la enfermiza fantasía del escritor.

Page 144: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

144 La Cara Oculta de C#

cuando ya existe una transacción y algún desaprensivo vuelve a ejecutar la instrucción begin transaction... ?

El contador de transacciones

... nada, no se derrumban los cimientos del edificio. SQL Server se limita a incre-mentar el valor de un variable global, @@trancount, para indicar cuántas veces se ha iniciado una transacción dentro de una misma conexión. La variable mencionada pertenece a la conexión, efectivamente, lo que quiere decir que cada proceso tendrá asignado un valor diferente dentro de la misma.

Cada begin transaction ejecutado incrementa el valor de @@trancount. Cuando se ejecuta un commit transaction, en cambio, se decrementa esa misma variable. Si el valor final de la misma es cero, la transacción se confirma definitivamente; en caso contrario, no sucede nada más, excepto el decremento en el valor de @@trancount. En cambio, un rollback, independientemente de la “profundidad” actual, siempre aborta la transacción activa, y devuelve el valor de @@trancount a cero.

NO

TA

El objetivo inicial de estas reglas era permitir la composición de procedimientos almace-nados que utilizasen transacciones explícitas. Con el paso del tiempo, y con la evolución del sistema operativo, este problema ha perdido parte de su razón de ser. La tendencia actual es dejar el manejo de las transacciones a un motor transaccional como COM+, que nos permite declarar transacciones... en vez de programarlas.

Hay un detalle curioso que nos da una pista sobre el funcionamiento interno de SQL Server. Ejecute el siguiente grupo de instrucciones en cualquier base de datos:

create table PRUEBA (valor varchar(30))

go

create trigger AI_PRUEBA on PRUEBA for insert as

print @@trancount

go

print @@trancount

insert into PRUEBA values ('Uno')

print @@trancount

go

Todavía no hemos tropezado con los triggers, pero el de este ejemplo es muy senci-llo: cada vez que insertemos un registro en la tabla de pruebas, se disparará el trigger anterior para mostrar el valor del contador de transacciones. ¿El resultado? La ins-trucción print que se ejecuta antes de la inserción mostrará un 0. Luego se mostrará un 1, debido al print del trigger, y finalmente aparecerá otro 0, correspondiente al print final. Esto significa que, en modo de confirmación automático, SQL Server crea una transacción interna para proteger la ejecución de ciertas instrucciones.

Pruebe ahora este otro grupo de instrucciones:

begin transaction

print @@trancount

insert into PRUEBA values ('Dos')

Page 145: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Transacciones SQL 145

print @@trancount

rollback

go

En este otro ejemplo, el valor del contador de transacciones será siempre 1, porque SQL Server, al detectar la existencia de una transacción explícita, considera innecesa-rio crear una transacción interna para proteger la inserción.

Cómo se detecta un error

Otra tonta y perversa idea consiste en creer que SQL interrumpe la ejecución de un grupo de sentencias cuando se produce cualquier error. Métase esta idea en su ca-beza, y si es necesario, tatúe la frase en alguna neurona libre:

“Un error en SQL Server no tiene por qué interrumpir el flujo de ejecución”

Y como una neurona tiene más de una sinapsis, aproveche y grabe también lo si-guiente:

“Un error en SQL Server no aborta automáticamente una transacción”

... a no ser que estemos ejecutando un trigger. Pero eso lo veremos en otro momento.

Vamos a experimentar un poco. Active el Analizador de Consultas y ponga en uso la base de datos de ejemplo pubs. Cree un procedimiento almacenado como el siguiente:

create procedure Prueba

@id1 varchar(11),

@id2 varchar(11) as

begin

delete from authors where au_id = @id1

delete from authors where au_id = @id2

end

El objetivo de Prueba es eliminar dos autores, a partir de sus identificadores. Vamos a añadir un autor a la tabla correspondiente:

insert into authors(au_id, au_lname, au_fname, phone, contract)

values ('111-11-1111', 'Marteens', 'Ian', '111 111-1111', 1)

La tabla de autores es referida desde una tabla llamada titleauthor, que almacena la relación entre libros y autores. No podemos borrar un autor que haya escrito un libro; es por ese motivo que hemos creado un autor que sí podamos eliminar.

La prueba en sí, consiste en ejecutar el procedimiento Prueba; el primer identificador corresponde a un autor que sí tiene libros, y el segundo es el nuevo autor sin libros:

execute Prueba '172-32-1176', '111-11-1111'

Al ejecutar el procedimiento, verá que la primera instrucción falla, y se muestra un mensaje en el Analizador de Consultas. Sin embargo, ese fallo no impide que se eje-

Page 146: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

146 La Cara Oculta de C#

cute la segunda instrucción... con éxito total. Si pide las filas de authors, verá que el infortunado Sr. Marteens ya no se encuentra en catálogo. ¿Y si ejecutamos el proce-dimiento desde una aplicación escrita en C#? Hágalo, y verá que C# genera una ex-cepción a partir del error detectado... pero la excepción sólo se dispara después de terminar el método, cuando Marteens ya ha sido borrado.

Esto no es problema cuando escribimos aplicaciones en C#, siempre y cuando haya-mos tenido la precaución de encerrar la llamada al procedimiento dentro de una transacción explícita. En pseudo código:

Iniciar transacción

try

{

Ejecutar procedimiento

Confirmar transacción

}

catch (Exception e)

{

Anular transacción

throw;

}

Un patrón similar se aplicaría si la operación estuviese implementada como un mé-todo de una clase registrada en COM+:

try

{

Ejecutar procedimiento

SetCommit;

}

catch (Exception e)

{

SetAbort;

throw;

}

También podríamos detectar el problema, y tomar las medidas necesarias, dentro del propio procedimiento, comprobando el valor de la variable especial @@error:

create procedure Prueba

@id1 varchar(11),

@id2 varchar(11) as

begin

delete from authors where au_id = @id1

if (@@error <> 0) return

delete from authors where au_id = @id2

end

Por supuesto, en este caso tan específico, el problema se resolvería sustituyendo las dos instrucciones delete por una sola instrucción:

delete from authors

where au_id in (@id1, @id2)

Page 147: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Transacciones SQL 147

Transact SQL sí garantiza la atomicidad de una instrucción. En este último ejemplo, tenemos una sola instrucción delete, que va a eliminar hasta dos autores. Si al inten-tarlo con uno de ellos, da lo mismo si se trata del primero o del segundo, se produce un error, se deshacen los cambios parciales que la instrucción haya tenido tiempo de realizar.

Cancelaciones parciales

Por último, SQL Server permite realizar cancelaciones parciales dentro de una tran-sacción. La instrucción save transaction, al ser ejecutada, marca un punto dentro del registro de transacciones al que podemos regresar más adelante, si las cosas salen mal, con una variante de la instrucción rollback.

Lo cierto es que me cuesta imaginar un uso real para esta instrucción. Sé que a usted se le ocurrirán de repente unos cuantos... pero tenga en cuenta que estoy pensando en aplicaciones que disparan sentencias SQL desde un lenguaje de programación tradicional. En este tipo de aplicaciones las transacciones son tan breves que no tiene mucho sentido fragmentarlas. La única vez que he utilizado save transaction fue dentro un proceso muy largo escrito íntegramente en Transact SQL, que tenía que ejecutarse todas las noches sobre una base de datos de préstamos e hipotecas.

El proceso en cuestión debía recorrer todos los préstamos activos y actualizar sus estados: si tocaba vencimiento, había que generar el recibo correspondiente; si se cumplía un año, en el caso de una hipoteca, había que verificar si el contrato estipu-laba una revisión del tipo de interés. Los datos habían sido convertidos al formato de SQL Server desde un sistema de almacenamiento bastante peculiar, por otra em-presa, y había todo tipo de inconsistencias en la base de datos. En consecuencia, durante las pruebas era común que, de unas decenas de miles de registros, el proceso fallara con dos o tres préstamos. Mientras se corregían estos fallos, encerramos el procesamiento de cada producto financiero dentro de una transacción individual.

El resultado fue desastroso: dejamos el sistema funcionando a las 12:00 de la madru-gada, y todavía seguía machacando bits a las 8:00 de la mañana siguiente. Fue enton-ces que recordé la existencia de save transaction, y modifiqué el bucle principal de esta manera:

begin transaction

while (quedan_registros)

begin

save transaction inicio_bucle

procesar_registro

if (@@error <> 0)

rollback transaction inicio_bucle

siguiente_registro

end

commit work

Page 148: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

148 La Cara Oculta de C#

Al comenzar cada paso del bucle, se ejecuta save transaction para marcar un punto al que la base de datos puede regresar con seguridad. Supongo que en un servidor como SQL Server, que utiliza un fichero de registro de transacciones (transaction log), el efecto de esta instrucción será guardar el tamaño de ese fichero en el momento en que es ejecutada. Si después de procesar el registro, encontramos que se ha produ-cido un error, la instrucción rollback devolverá la base de datos al estado en que se encontraba al ejecutarse la última instrucción save transaction. Observe que en esta variante de rollback es obligatorio incluir la segunda palabra clave, transaction, además de indicar el nombre del punto de cancelación parcial.

Si le he contado esta historia es porque tuvo un final feliz: el tiempo de procesa-miento se redujo considerablemente. No me pregunte en cuánto, porque no lo re-cuerdo. Ante este tipo de preguntas, los programadores solemos reaccionar como los pescadores aficionados: ellos exageran el tamaño de su captura y nosotros, la veloci-dad de ejecución.

Page 149: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

10

Triggers

ECUERDA ESA VIEJA LEYENDA QUE NARRA cómo un grupo de informáticos chiflados, con un matemático al frente del equipo, diseñó el lenguaje SQL con

el propósito de que cualquier persona, incluyendo ejecutivos y comerciales, pudiese interrogar con facilidad una base de datos relacional? No lo lograron. Abandonaron el proyecto y se dedicaron a enseñar a contar a un chimpancé. Hace poco vi un docu-mental sobre el mono, y al parecer esta vez sí que tuvieron éxito.

¿Para qué necesitamos triggers?

Un ejecutivo no sabrá mucho sobre cómo consultar la base de datos... pero esa espe-cie animal tiene una sorprendente habilidad para cargarse los datos. Usted ha creado una estupenda y útil aplicación que maneja una base de datos de SQL Server. Las reglas de empresa se cumplen y verifican gracias a la aplicación. Pero en estos días, cualquier tontaina puede agenciarse, gracias a Office, un intérprete simple para inte-rrogar y modificar la base de datos. El ejecutivo retoca manualmente el valor de tal fila y columna... sin percatarse de que tendría que arreglar tres o cuatro tablas rela-cionadas para que el cambio tenga sentido.

¿La solución? Idealmente, sería excelente si las reglas de consistencia de los datos estuviesen íntimamente vinculadas a las instrucciones de actualización de bajo nivel: insertar, borrar, modificar. En ese caso, la modificación hecha por el ejecutivo dispa-raría el código necesario para que, de manera automática, la base de estado siguiese en un estado coherente respecto a las reglas de negocio.

Esa es la teoría: disponer de un medio para exigir el cumplimiento de reglas de con-sistencia más potentes y complicadas que las que podemos expresar con las cláusulas declarativas de integridad referencial, verificaciones y demás. Y ese medio son los triggers: una técnica que, o se ignora, o se abusa de ella. En la práctica, un sistema con demasiados triggers, o con código demasiado complejo en los mismos, puede mostrar efectos secundarios no previstos por el programador. Además, como expli-caré en este capítulo, los triggers de SQL Server no son exactamente lo que se llama-ría “un recurso de uso intuitivo”.

¿R

Page 150: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

150 La Cara Oculta de C#

Triggers en Transact SQL

Los triggers que implementa Transact SQL son muy diferentes a los de la mayoría de los restantes servidores. Esta es la sintaxis general de la operación de creación de triggers:

create trigger NombreTrigger on Tabla

[with encryption]

for {insert,update,delete}

[with append] [not for replication]

as InstruccionSQL

Podemos definir triggers que se disparen al realizar inserciones, modificaciones y borrados, y aunque no se deduce de la sintaxis, podemos declarar más de un trigger para cada uno de estos eventos. De hecho, la cláusula with append, que teórica-mente sirve para que el nuevo trigger se añada a los triggers existentes en vez de sustituirlos, es innecesaria a partir de SQL Server 7... a no ser que hayamos activado una opción especial de compatibilidad con la versión 6.5.

La principal debilidad de este sistema de triggers tiene que ver con en el momento en que disparan. Un trigger decente debe ejecutarse antes o después de una operación sobre una fila individual. Los de Transact SQL, en contraste, se disparan solamente después de una instrucción que puede afectar a una o más filas. Si ejecutamos la si-guiente instrucción, el posible trigger asociado se disparará únicamente cuando se hayan borrado todos los registros correspondientes:

delete from Clientes

where Planeta <> 'Tierra'

El siguiente ejemplo muestra como mover los registros borrados a una tabla que actúe como copia de seguridad:

create trigger GuardarBorrados

on Clientes for delete as

insert into CopiaSeguridad select * from deleted

La tabla deleted, cuyo contenido copiamos en la tabla CopiaSeguridad, es una tabla tem-poral que contiene todas las filas borradas por la operación. En una inserción, hay una tabla inserted con los registros añadidos. Y en una modificación, deleted contiene los registros modificados con sus valores originales, mientras que inserted contiene las nuevas versiones de esos mismos registros:

Tabla en memoria insert delete update

inserted ✓ ✓ deleted ✓ ✓

Estas tablas se almacenan en la memoria del servidor. Si durante el procesamiento del trigger se realizan modificaciones secundarias en la tabla base, el trigger no vuelve a ejecutarse... al menos, mientras no activemos la opción de triggers recursivos que estudiaremos más adelante.

Page 151: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Triggers 151

Como es fácil de comprender, es algo difícil trabajar con inserted y deleted. En con-traste, los sistemas que soportan triggers para filas, como Oracle y DB2, permiten trabajar con variables especiales new y old que almacenan los valores nuevos y los originales del registro modificado, y son más fáciles de programar. El siguiente trig-ger de Transact SQL modifica las existencias de una tabla de inventarios cada vez que creamos una línea de pedido:

create trigger NuevoDetalle on Detalles for insert as

begin

if @@rowcount = 1

update Articulos

set Pedidos = Pedidos + Cantidad

from inserted

where Articulos.Codigo = inserted.RefArticulo

else

update Articulos

set Pedidos = Pedidos +

(select sum(Cantidad)

from inserted

where inserted.RefArticulo = Articulos.Codigo)

where Codigo in

(select RefArticulo

from inserted)

end

La variable global predefinida @@rowcount indica cuántas filas se han visto afectadas por la última operación. Tenemos que utilizarla al principio del trigger, porque las instrucciones del propio trigger pueden cambiar su valor. La alternativa sería poner a salvo el contenido de @@rowcount en una variable local, pero la asignación también debería situarse al inicio del código.

Observe también la sintaxis peculiar de la primera de las instrucciones update. La instrucción en cuestión es equivalente a la siguiente:

update Articulos

set Pedidos = Pedidos +

(select Cantidad

from Inserted) /* ¡Una selección singular! */

where Articulos.Codigo =

(select RefArticulo

from Inserted) /* ¡Subconsulta repetida! */

Ya hemos visto este tipo de transformaciones, en el capítulo 5. Tenga en cuenta que la equivalencia es correcta, en este caso, porque sabemos que la instrucción se ejecu-tará cuando haya sólo una fila en Inserted. Sin embargo, no podemos simplificar el segundo caso usando la misma técnica. La culpa la tiene la posibilidad de que inser-temos dos filas de detalles de pedidos que hagan referencia a un mismo artículo. Tenemos que mantener al menos la subconsulta de la cláusula set para poder calcular la suma de las cantidades. ¿Complicado, verdad? Esta es la clase de quebraderos de cabezas que produce el modelo de triggers de SQL Server.

¿Hasta qué punto nos afecta la mala conducta de los triggers de Transact-SQL? La verdad es que no demasiado, especialmente si las actualizaciones de tablas corres-

Page 152: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

152 La Cara Oculta de C#

ponden a cambios realizados en una aplicación interactiva con la ayuda de ADO.NET. La mayoría de las modificaciones automáticas generadas por esta inter-faz de acceso afectarán a una sola fila por vez, por lo que @@rowcount siempre será uno para estas operaciones. En este trigger, un poco más complejo, se asume implí-citamente que las inserciones de pedidos tienen lugar de una en una:

create trigger NuevoPedido on Pedidos for insert as

begin

declare @UltimaFecha datetime, @FechaVenta datetime,

@Num int, @CodPed int, @CodCli int

select @CodPed = Codigo, @CodCli = RefCliente,

@FechaVenta = FechaVenta

from inserted

select @Num = ProximoNumero

from Numeros holdlock

update Numeros

set ProximoNumero = ProximoNumero + 1

update Pedidos

set Numero = @Num

where Codigo = @CodPed

select @UltimaFecha = UltimoPedido

from Clientes

where Codigo = @CodCli

if (@UltimaFecha < @FechaVenta)

update Clientes

set UltimoPedido = @FechaVenta

where Codigo = @CodCli

end

El propósito de este trigger es retocar los registros de pedidos que insertemos: le asignamos al registro un número de pedido proveniente de una tabla de contadores, y modificamos la fecha del último pedido realizado en el registro del cliente asociado. El uso de holdlock garantiza que no hayan huecos en la secuencia de valores asigna-dos al número de pedido, incluso cuando el nivel de aislamiento de la transacción no garantice las lecturas repetibles. La indicación holdlock indica al servidor que blo-quee los registros leídos por la consulta hasta el fin de la transacción activa.

Integridad referencial mediante triggers

Antes de que SQL Server 2000 añadiese acciones referenciales a las cláusulas de inte-gridad referencial declarativa, la única forma de lograr borrados o actualizaciones en cascada con Transact SQL consistía en programar triggers. Estos, para mayor des-gracia, eran bastante complejos. Como los triggers se disparan al final de la opera-ción, antes de su ejecución se verifican las restricciones de integridad aplicables. Si queríamos implementar un borrado en cascada, teníamos que eliminar la restricción declarativa de clave externa, y asumir también la verificación de la misma. Por suerte, los tiempos han cambiado para mejor, pero he decidido mostrarle el código necesa-rio de todos modos, para que se haga una idea de los problemas que puede encontrar en otros usos de este recurso de programación.

Page 153: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Triggers 153

Partamos de la relación existente entre cabeceras de pedidos y líneas de detalles, y supongamos que no hemos declarado la cláusula foreign key en la declaración de la tabla de detalles. El siguiente trigger se encarga de comprobar que no se inserte un detalle que no corresponda a un pedido existente, y que no se pueda modificar pos-teriormente la referencia al pedido con un valor incorrecto:

create trigger VerificarPedido on Detalles for update, insert as

if exists(select * from Inserted

where Inserted.RefPedido not in

(select Codigo from Pedidos))

begin

raiserror('Código de pedido incorrecto', 16, 1)

rollback tran

end

Aquí estamos introduciendo el procedimiento raiserror (sí, con una sola 'e'), que sirve para informar del error al sistema. El primer argumento es el mensaje de error. El segundo es la severidad; si es 10 o menor, no se produce realmente un error. En cuanto al tercer parámetro, un código de estado, no tiene importancia para SQL Server en estos momentos. A continuación de esta llamada, se deshacen los cambios efectuados hasta ese momento y se interrumpe el flujo de ejecución.

NO

TA

La documentación de SQL Server recomienda como posible alternativa que el trigger solamente deshaga los cambios incorrectos, en vez de anular todas las modificaciones. Pero, en mi humilde opinión, esta técnica puede ser peligrosa. Prefiero considerar atómi-cas a todas las operaciones lanzadas desde el cliente: que se ejecute todo o nada.

El trigger que propaga los borrados es sencillo:

create trigger BorrarPedido on Pedidos

for delete as

delete from Detalles

where RefPedido in (select Codigo from deleted)

Sin embargo, el que detecta la modificación de la clave primaria de los pedidos es sumamente complicado. Cuando se produce esta modificación, nos encontramos de repente con dos tablas, inserted y deleted. Si solamente se ha modificado un registro, podemos establecer fácilmente la conexión entre las filas de ambas tablas, para saber qué nuevo valor corresponde a qué viejo valor. Pero si se han modificado varias filas, esto es imposible en general. Así que vamos a prohibir las modificaciones en la tabla maestra:

create trigger ModificarPedido on Pedidos

for update as

if update(Codigo)

begin

raiserror('No se puede modificar la clave primaria', 16, 1)

rollback tran

end

Le dejo al lector que implemente la propagación de la modificación en el caso es-pecial en que ésta haya afectado solamente a una fila de la tabla maestra.

Page 154: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

154 La Cara Oculta de C#

Triggers anidados y triggers recursivos

¿Qué sucede si durante la ejecución de un trigger se modifica alguna otra tabla, y esa otra tabla también tiene un trigger asociado? Todo depende de cómo esté configu-rado el servidor. Ejecute el siguiente comando en el Analizador de Consultas:

-- Pregunta por el estado de los triggers anidados

sp_configure 'nested triggers'

El procedimiento devolverá el valor actual de dicha opción, que puede ser 0 ó 1. Si la opción está activa, el trigger de la segunda tabla afectada también se dispara. El nú-mero de niveles de anidamiento puede llegar a un máximo de 16. Para cambiar el valor de la opción, teclee lo siguiente:

-- Activa los triggers anidados

sp_configure 'nested triggers', '1'

Recuerde que nested triggers afecta a todas las bases de datos de un mismo servidor, así que tenga mucho cuidado con lo que hace.

Es muy diferente lo que sucede cuando la tabla modificada por el trigger es la propia tabla para la cual se define éste. Una de las consecuencias de que los triggers de SQL Server se disparen después de terminada la operación, es que si queremos modificar automáticamente el valor de alguna columna estamos obligados a ejecutar una ins-trucción adicional sobre la tabla:

create trigger MantenerVersionMayusculas

on Clientes

for insert, update as

if update(Nombre)

update Clientes

set NombreMay = upper(Nombre)

where Codigo in (select Codigo from inserted)

Si modificamos el nombre de uno o más clientes, la versión en mayúsculas del nom-bre de cliente debe actualizarse mediante una segunda instrucción update. ¿Y ahora qué, se vuelve a disparar el trigger? No si la versión de SQL Server es la 6.5. Pero si estamos trabajando con una versión posterior, volvemos a depender del estado de una opción de la base de datos, que se modifica mediante el procedimiento almace-nado sp_dboption:

-- Activa los triggers recursivos

sp_dboption 'facturacion', 'recursive triggers', 'true'

En cualquier caso, si el trigger vuelve a ejecutarse de forma recursiva no pasará nada malo, al menos en el ejemplo anterior, pues en la segunda activación la columna mo-dificada es NombreMay, en vez de Nombre. Pero hay que tener mucho cuidado con otros casos, en que puede producirse una recursión potencialmente infinita. SQL Server limita a 32 los niveles de anidamiento de un trigger recursivo.

Page 155: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Triggers 155

Triggers “instead of”

SQL Server 2000 introdujo un tipo muy interesante de trigger, que en vez de defi-nirse de acuerdo al momento en que se dispara y el tipo de operación que lo activa, utiliza en su cabecera la frase instead of, que puede traducirse como en vez de. Eche un vistazo a la siguiente instrucción:

create trigger IOD_Clientes

on CLIENTES

instead of delete as

begin

declare @idCliente integer

declare clis insensitive cursor for

select IDCliente from deleted

open clis

fetch clis into @id

while (@@fetch_status = 0)

begin

execute EliminarCliente @id

fetch clis into @id

end

close clis

deallocate clis

end

El trigger mostrado define qué significa eliminar un cliente. Es algo complicado de leer, pero esto se debe a que es posible que intentemos borrar más de un cliente con una sola instrucción delete, por lo que es posible que la tabla temporal deleted, que es donde seguimos recibiendo las filas “borradas”, tenga más de un registro. Lo de “borradas” va entre comillas, porque en el momento en que se dispara el trigger se trata todavía de filas por borrar.

El trigger abre un cursor que devuelve las claves primarias de las filas a borrar, y para cada una de ellas llama a un procedimiento almacenado, EliminarCliente, que será el verdadero responsable de eliminar la fila. ¿Qué debe hacer EliminarCliente? Por ejem-plo, podríamos implementar alguna variante de la integridad referencial, eliminando todas las filas de una tabla de detalles, y probablemente, al terminar con los detalles, tengamos que borrar la fila de clientes... un momento, ¿he dicho borrar? Sí, pero tranquilícese: ese borrado “anidado” no vuelve a disparar el trigger recursivamente, con independencia del estado de la opción que activa los triggers recursivos.

Este recurso es muy útil para establecer reglas de empresa de cumplimiento obligato-rio, e implementadas como procedimientos almacenados. Pero cuando realmente brillan los triggers instead of es cuando los asociamos a una vista. Con este meca-nismo podríamos hacer que una vista no actualizable, desde el punto de vista de SQL, pudiera ser actualizada, porque seríamos nosotros quienes daríamos un sentido semántico a las operaciones correspondientes.

Page 156: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

156 La Cara Oculta de C#

El contador de filas afectadas

Hay que tener mucho cuidado con las instrucciones que ejecutamos dentro de un trigger, sobre todo si vamos a utilizar ADO.NET como interfaz de acceso a la base de datos. Cuando ejecutamos sentencias SQL dentro del trigger, estamos actuali-zando también el valor de la variable de conexión @@rowcount, y eso puede confundir

a sistemas como ADO.NET, e incluso a ADO “clásico”, que aprovechan la informa-ción sobre el número de filas actualizadas para implementar su técnica de bloqueos optimistas.

Supongamos que existe una tabla Clientes, y que hemos leído desde la misma un con-junto de registros para navegar sobre ellos, y quizás actualizarlos. El conjunto de datos se obtiene mediante una consulta SQL típica que nosotros mismos proporcio-namos, y las filas recuperadas van a parar a una caché. En esta caché es donde el usuario modifica el registro del cliente cuya clave primaria (el identificador de cliente) es 34, por decir un número. En base a las modificaciones realizadas, ADO.NET “deduce” que tiene que enviar la siguiente instrucción al servidor para efectuar la actualización:

update CLIENTES

set MalPagador = 1

where IDCliente = 34 and

MalPagador = 0

ADO.NET está interesado en saber cuántas filas han sido modificadas al ejecutar la instrucción anterior. Sabemos que, como máximo, se puede modificar una sola fila porque estamos incluyendo la clave primaria en la condición de búsqueda. Pero tam-bién es posible que no encontremos ninguna fila que cumpla la condición; esto ocu-rrirá si alguien se nos adelanta y modifica la clasificación del cliente, o si este señor ha hartado tanto a alguien que ha decidido eliminarlo de la base de datos. Si ocurre esto último, habrá llegado el momento de lanzar un mensaje de error. El mecanismo des-crito se conoce como bloqueo optimista, aunque como comprenderá no hay un solo bloqueo en un radio de 500 metros.

¿Qué sucedería si existe un trigger asociado a la operación update sobre la tabla de clientes? Todo depende de lo que hagamos. Por ejemplo, el siguiente trigger no debe ocasionar problemas:

create trigger ControlarCredito on Clientes

for update as

if update(MalPagador)

update Clientes

set Credito = case MalPagador

when 1 then 0 else Credito end

where IDCliente in (select IDCliente from inserted)

Digo que es un trigger seguro porque preserva el valor de @@rowcount. Si la instruc-ción original toca cinco filas, la instrucción dentro del trigger modificará también el mismo número de filas.

Page 157: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Triggers 157

Sin embargo, el siguiente trigger es inseguro, a pesar de ser más eficiente:

create trigger ControlarCredito on Clientes

for update as

if update(MalPagador)

update Clientes

set Credito = 0

where IDCliente in (

select IDCliente

from inserted

where MalPagador = 1)

Si cambiamos el estado de un cliente asignando 0 a MalPagador desde una aplicación cliente basada en ADO.NET, se producirá el error: Row cannot be located for updating, etc, etc. Los “etcéteras” son aportación mía.

Por suerte para nosotros, Transact SQL nos proporciona una instrucción que, usada con juicio, puede incluso acelerar la ejecución de determinados triggers y procedi-mientos almacenados:

set nocount on

Cuando se activa esta opción, la conexión deja de enviar notificaciones al cliente sobre el número de filas afectadas por cada operación atómica dentro del lote de consultas. Imagine un procedimiento almacenado que ejecuta una instrucción up-date dentro de un bucle que se repite cientos de veces. Cada vez que termine de ejecutar una de estas instrucciones, el servidor enviará uno de los mensajes mencio-nados al cliente. Como comprenderá, si el cliente se encuentra al otro lado de una línea lenta, escondido tras cortafuegos y enrutadores, la ejecución del procedimiento puede resultar siendo un suplicio.

En estos casos conviene activar la opción nocount al principio del procedimiento, y desactivarla antes de retornar. Hay que ser muy cuidadosos en esto, porque el estado de nocount no se restaura al recibirse el siguiente lote de instrucciones. Y no pode-mos permitirnos el lujo de dejar nocount apagado para siempre porque ADO y ADO.NET necesitan esta información.

En cuanto al trigger anterior, podemos aplicarle la misma técnica:

create trigger ControlarCredito on Clientes

for update as

if update(MalPagador)

begin

set nocount on

update Clientes

set Credito = 0

where IDCliente in (

select IDCliente

from inserted

where MalPagador = 1)

set nocount off

end

Page 158: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

158 La Cara Oculta de C#

No sólo evitamos problemas con los bloqueos optimistas de la interfaz de acceso a datos, sino que también aceleramos un poco la ejecución de la operación.

Page 159: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

ADO.NET

Parte

ADO.NET

Run, rabbit run Dig that hole, forget the sun

And when, at last, the work is done Don’t sit down, it’s time to start another one...

Page 160: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada
Page 161: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

11

Fundamentos de ADO.NET

BANDONAMOS EL SERVIDOR SQL Y NOS SITUAMOS en el lado cliente, para ver cómo se recuperan y modifican los datos almacenados en el servidor. Toda esta

parte está dedicada al estudio de ADO.NET, la interfaz de acceso a datos de la plata-forma .NET. Primero estudiaremos un mecanismo de caché local basado en conjun-tos de datos que simulan la estructura relacional del servidor. Luego veremos cómo mostrar y modificar esta información por medio de controles visuales. Y los últimos capítulos nos mostrarán cómo traer datos a la caché local para luego enviar los cam-bios de vuelta al servidor.

¿Qué es .NET?

.NET es la interfaz de programación de aplicaciones (API), de la familia de sistemas operativos de Microsoft... Bueno, estoy exagerando un poco, pero no demasiado, porque ya existen anuncios oficiales en esa dirección. De momento, es un nuevo entorno de ejecución de aplicaciones cuya característica más revolucionaria es el uso extensivo, en tiempo de ejecución, de metainformación, obtenida durante la compila-ción de la aplicación.

NO

TA

¿Conoce la historia budista sobre los tres ciegos que querían saber qué era un elefante? Fueron a ver al emperador, y éste consintió en que visitaran la habitación del elefante blanco sagrado. A una señal del cuidador, los ciegos saltaron sobre el animal y asieron distintas partes de su anatomía. El que agarró la trompa anunció a sus camaradas que un elefante era un bicho parecido a una serpiente; el que se enganchó a una pata, protestó afirmando que sería más exacto compararlo con un barril. El tercero tuvo la mala fortuna de aterrizar sobre uno de los colmillos. Ante su silencio, los otros dos creyeron que había alcanzado la iluminación, y que por eso callaba.

.NET sólo se parece a un elefante en que por su culpa han caído más árboles que los que podría derribar un paquidermo enfurecido persiguiendo a un abogado, pero es igual de complicado describirlo en un solo párrafo. Quizás sea mejor enumerar sus características principales:

1 En la base de todo el edificio, hay un conjunto de clases llamado Common Lan-guage Runtime, o CLR, que puede ser utilizado por cualquier lenguaje que se ajuste a ciertos requisitos. Estas clases pueden ser enlazadas en tiempo de ejecución a las aplicaciones que las usan.

A

Page 162: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

162 La Cara Oculta de C#

2 La compatibilidad del CLR con varios lenguajes es posible gracias a que, tanto las aplicaciones como las bibliotecas de clase de la propia CLR, se compilan a un lenguaje intermedio conocido como IL. Este lenguaje es independiente del pro-cesador.

3 El lenguaje IL nunca se interpreta. En tiempo de carga y ejecución, los frag-mentos de código que se necesitan se convierten dinámicamente en código na-tivo, apropiado para la plataforma de ejecución concreta. Alternativamente, la conversión podría programarse para ser ejecutada durante la instalación, pero más adelante veremos que no siempre es buena idea.

4 La conversión del código IL en código nativo aprovecha la información deta-llada sobre tipos de datos recopilada en tiempo de compilación. Esta meta-información es también utilizada por otros subsistemas del CLR, como el reco-lector de basura y los mecanismos de acceso remoto.

En estos momentos, los lenguajes ofrecidos por Microsoft para .NET son C#, Vi-sual Basic.NET, una extraña mutación de C++ llamada C++ with Managed Extensions, y J#. Este último es el sucesor de Visual J++, el Java con esteroides que le costó a Microsoft aquel famoso disgusto judicial con los soleados abogados de Sun. Pero cualquier compañía con tiempo, talento y ganas puede desarrollar su propio lenguaje compatible con el CLR. Quizás la más exitosa e interesante de estas iniciativas sea una versión de Eiffel para la plataforma .NET (www.eiffel.com). Otro esfuerzo digno de mención es Delphi.NET, por parte de Borland (www.borland.com), aunque en el momento en que escribo este capítulo es muy pronto para valorarlo.

NO

TA

Para mí, es gratificante ver a Eiffel en esta lista, a pesar de tratarse de un lenguaje no muy popular. A principios de los 90s, este autor formaba parte de un grupo de investiga-ción cuyo objetivo era producir un compilador de Eiffel, primero para el entorno DOS, y luego para las primeras versiones de Windows. Ciertas características de Eiffel me hicie-ron llegar a la decisión de generar, como resultado de la compilación, ficheros en un código intermedio “orientado a objetos”, que se convertiría en código ejecutable en el momento del enlace. Así podrían implementarse eficientemente algunas verificaciones de tipo especiales, a nivel de sistema, y podría optimizarse el código generado conociendo todas las clases que finalmente se utilizarían en la aplicación. Aquello no llegó a más por falta de presupuesto... aunque también fue decisiva la ausencia de una infraestructura como la que ofrecen las versiones modernas de Windows. Sé que puede parecer “auto-bombo” descarado (bueno, en parte...), pero mi intención es mostrar que algunas de las ideas presentes en .NET “estaban ahí”, que son consecuencias lógicas de la evolución de técnicas y conceptos ya conocidos.

Pero también es útil precisar qué no es la plataforma .NET, refutando algunas ideas inexactas que circulan por ahí:

• Mito: La iniciativa .NET es la respuesta de Microsoft a Java, y C# es una versión microsófica de Java, más o menos descarada.

Si este mito fuese verdad, tendría un corolario preocupante: como Mr. Marteens siempre ha repudiado todo lo que tiene que ver con Java, debe haber alguna razón oculta para que ahora muestre tanto entusiasmo por un sistema equivalente. No hay

Page 163: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Fundamentos de ADO.NET 163

tal “cara” oculta, y por lo tanto, estoy muy interesado en demostrar que Java y C# tienen enfoques muy diferentes en muchos aspectos importantes.

Para empezar, la historia de Java se parece al descubrimiento de América: Colón quería desembarcar en la China, pero terminó en Bahamas. Java fue diseñado como un lenguaje para el control de artilugios de estos que venden en las tiendas de todo a cien, pasó por una etapa en que se proclamaba como la solución ideal para mejorar las capacidades de Internet en el lado cliente (applets) y ha terminado funcionando en el sitio más impredecible: en el lado servidor, donde más que la portabilidad, importa la eficiencia. Hombre, también es cierto que los mamíferos fuimos diseñados para servir de postre a los dinosaurios, antes del meteorito, quiero decir, y sin embargo, conozco algunos especímenes humanos que le provocarían nauseas a un tiranosaurio famélico. Casi todos, abogados.

Lo poco que hay de deliberado en el diseño de Java no es muy halagador. Java fue inventado por un señor que odiaba la programación y que consideraba que además de estúpidos, los restantes programadores eran gente peligrosa, sobre todo cuando tenían un puntero a registro en la mano. ¿Cree que exagero? La obsesión por elimi-nar los punteros del lenguaje llegó al extremo de prohibir el traspaso de parámetros a métodos por referencia. Un programador de Java que quiere simular un parámetro por referencia, debe meter un valor dentro de un vector con un solo elemento... por-que a pesar de todos los esfuerzos de los pergeñadores de Java, los vectores siempre se pasan por referencia.

¿Seguimos? Sepa que en Java no existen los tipos enumerativos, y tiene que simular-los con constantes y mucha disciplina: en caso contrario, puede confundir fácilmente los valores pensados para ser pasados al parámetro venenoRatones con las constantes admitidas por alimentoSuegras.

Sobre todo es lamentable la renuencia de Java a incluir soporte explícito para propie-dades y eventos. La ausencia de propiedades hace que el diseño de componentes sea una tortura china, y que los entornos visuales de desarrollo las pasen canutas. La falta de un soporte específico para eventos obliga a usar el mecanismo más retorcido que una mente humana haya ideado jamás... aparte de que no contribuye precisamente a la velocidad de ejecución.

Si, es posible que C# haya copiado alguna que otra idea de Java, pero en la misma medida en que Java saqueó desvergonzadamente a C++. Donde las dan, las toman.

• Mito: La plataforma .NET está basada en el uso de un intérprete.

A diferencia de lo que ocurre en Java, el código IL siempre se convierte en código eje-cutable, nunca se interpreta. Es más, su interpretación directa sería muy ineficiente, porque las instrucciones básicas no están desglosadas por tipos de datos. La adición, por ejemplo, es la misma para enteros que para valores reales. En el momento en que se traduce el código IL a código nativo, se elige la implementación adecuada te-

Page 164: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

164 La Cara Oculta de C#

niendo en cuenta los tipos de datos verdaderos que el traductor deduce que encon-trará en la pila de ejecución.

• Mito: Los programas que se ejecutan en el entorno de la plataforma .NET son muy lentos, pero eso es aceptable al tratarse de aplicaciones “de gestión”.

El mito sobre la lentitud se alimenta en parte del mito sobre el intérprete. Ya sabe-mos que no hay tal intérprete. Queda por saber cuál es la calidad del código gene-rado, y aquí puede que nos aguarde una sorpresa. Suponga que comparamos el ren-dimiento de una aplicación que se transforma dinámicamente a código nativo, res-pecto a una versión de la misma aplicación, pero traducida en el momento de su instalación. ¿Cuál se ejecutará más rápidamente? No necesariamente la versión pre-compilada. El uso del traductor dinámico puede producir código más compacto, que genere menos fallos de páginas: algo sumamente importante para la arquitectura actual de los procesadores. Y esto no es una especulación, sino un comportamiento bien documentado.

Por supuesto, una aplicación escrita en ensamblador por un programador perfecto, de esos que no existen, siempre se ejecutará más de prisa. Pero, ¿cuánto tiempo adi-cional necesitaremos para su desarrollo? Respecto a la comparación con Java, puede echar un vistazo a cualquiera de las muchas pruebas documentadas en Internet. Le advierto que si ha estado trabajando todos estos años con Java, se va a deprimir...

• Mito: .NET es un sistema que funciona en un único tipo de procesador, sobre un único sistema operativo.

La plataforma .NET está disponible actualmente para sistemas que se ejecutan sobre procesadores de Intel de 32 y 64 bits, para Opteron de AMD, y para los PDAs com-patibles con Pocket PC. Recuerde que en todos estos casos, la principal diferencia no está en las clases, sino en el componente que debe traducir de IL a código nativo. En cuanto a sistemas operativos, es cierto que la situación es más complicada. Pero existen proyectos avanzados para migrar la plataforma a Macintosh, e incluso a Linux.

C#: un lenguaje con bemoles

Todos los lenguajes de la plataforma .NET son iguales, pero algunos son más iguales que otros, y de ellos mi preferido es C#. Como el nombre indica, está inspirado en la sintaxis de C/C++. El carácter # del nombre, que en España llamamos “almohadi-lla”, puede interpretarse como cuatro signos + densamente empaquetados... o como el símbolo que en notación musical se utiliza para las notas sostenidas8. C# debe pro-nunciarse en inglés como see sharp, es decir, Do Sostenido... aunque la frase puede interpretarse también como “ver con claridad”.

8 El que una nota sea un “sostenido” significa que su frecuencia original debe multiplicarse aproximadamente por la duodécima raíz de dos.

Page 165: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Fundamentos de ADO.NET 165

Estas son algunas de sus características más notables:

1 Es un lenguaje orientado a objetos “puro”, no un híbrido como C++ o Delphi, lo que simplifica mucho su aprendizaje. En este sentido se parece algo a Java.

2 Sin embargo, no es un lenguaje “reduccionista”, como Java. Su objetivo no es demostrar que el mundo se puede construir sólo con clases y objetos, sino que lo usemos para ganarnos la vida escribiendo aplicaciones... y que disfrutemos al ha-cerlo.

3 Es un lenguaje con soporte para componentes. O si lo prefiere, es un lenguaje RAD (de rapid application development), lo que lo sitúa junto a lenguajes como Del-phi y el antiguo Visual Basic. Esto significa que C# soporta construcciones espe-ciales como las propiedades y eventos, que aportan información semántica adi-cional al compilador, y que simplifican la creación y uso de componentes. Java siempre ha despreciado esta posibilidad, y lo ha pagado con creces, por cierto.

4 C# permite asociar atributos extensibles a las principales construcciones sintácti-cas: tipos en general, clases e interfaces, propiedades, métodos y eventos. Estos atributos, junto con información sobre tipos extraída directamente de las decla-raciones de clases, son la base de muchos de los subsistemas de tiempo de ejecu-ción: la recolección de basura, la serialización de objetos, tanto para la programa-ción remota como para soporte del desarrollo visual, las verificaciones de seguri-dad...

Podría seguir enumerando las virtudes de C#, pero para ser sincero, incluso más que el conjunto actual de características, me gusta el espíritu de superación y rigor del equipo que lo diseña y desarrolla. Mientras que Java ha tardado casi una década en introducir la genericidad en el lenguaje, la segunda versión de C# ya incluye este re-curso, además de otras golosinas como iteradores, tipos parciales, métodos anóni-mos, etc. El propio Delphi, el lenguaje desarrollado por Anders Hejlsberg para Bor-land, quedó casi congelado después de que este señor abandonara esta empresa para ir a trabajar a Microsoft9. La política del if ain’t broken, don’t fix it (si no está roto, no lo arregles) puede parecer una receta de sentido común. Pero cuando se trata de tecnología avanzada, se convierte en un suicidio.

ADO.NET

Cuando Microsoft desveló las líneas generales de su interfaz de acceso a datos para la plataforma .NET, muchos programadores se sintieron desconcertados... especial-mente, la multitud de fieles que habían seguido los bandazos de Microsoft de escollo a escollo; quiero decir, de interfaz a interfaz. Un rápido repaso a cualquier libro sobre ASP clásico o Visual Basic 6 nos revelará montones de siglas ya caducas: ODBC, DAO, RDO, OLE DB, ADO. Lo que sorprendió no fue tanto el cambio de interfaz de programación, como que la nueva interfaz no se pareciera a nada conocido.

9 En realidad, Delphi 4 añadió la sobrecarga de métodos y los vectores abiertos como tipo de datos, pero a partir de entonces, el lenguaje casi no ha experimentado mejoras.

Page 166: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

166 La Cara Oculta de C#

Efectivamente, si hay algo similar a ADO.NET es DataSnap: la técnica de acceso en tres capas implementada en Borland Delphi. Esto no significa que ADO.NET sea una copia o que se haya inspirado solamente en DataSnap; algunas características del ADO clásico, como los conjuntos de datos desconectados, iban en esta dirección. Y ocurre también que las últimas versiones del propio DataSnap aprovecharon, con toda seguridad, ideas esbozadas durante el largo período de pruebas de ADO.NET.

El caso es que los primeros libros sobre programación para bases de datos en C#, escritos por programadores con experiencia en ADO clásico, presentaban fallos al evaluar la importancia de determinadas opciones, o no sabían explicar los motivos detrás del diseño de ciertas técnicas. Por ejemplo, no conozco ningún libro que con-tenga ejemplos realmente útiles de lo que podemos hacer con los eventos que se disparan durante la grabación de datos por medio de adaptadores.

¿Qué es lo que hace a ADO.NET tan diferente? Este sistema divide sus clases en dos grandes grupos:

1 Las clases desconectadas, que implementan una réplica parcial de una base de datos en memoria, en el lado cliente.

2 Las clases conectadas, que implementan el acceso real a la base de datos SQL, tanto para recuperar información como para modificarla.

Si la aplicación no es de tipo interactivo, nos bastaría usar el grupo de clases conecta-das. Estas nos permiten establecer conexiones, ejecutar sentencias SQL y crear cur-sores unidireccionales de sólo lectura. Curiosamente, las aplicaciones ASP.NET se ajustan a este patrón: para crear una tabla HTML a partir del resultado de una con-sulta nos basta con recorrer el correspondiente cursor en una sola pasada.

Si por el contrario, necesitamos mostrar el resultado de una consulta sobre una rejilla, edición mediante controles o navegación en las dos direcciones, tenemos que echar mano de las clases desconectadas, para leer los registros y almacenarlos en una espe-cie de caché local. Todas las modificaciones interactivas se realizan sobre la caché local, aunque podemos sincronizar su estado con el de la base de datos.

La parte interesante de esta arquitectura es que, mientras el usuario analiza y modifica sus datos, no hace falta mantener abierta la conexión con la base de datos. Los recur-sos que de otra manera consumiría ese usuario quedan disponibles para otras cone-xiones. ¿Hay una parte negativa? En sistemas similares, el precio a pagar es la poca flexibilidad a la hora de sincronizar la base de datos con la caché local. Sin embargo, ADO.NET escapa a este dilema dividiendo el algoritmo de conciliación en módulos y procesos parciales que podemos modificar, o incluso sustituir, a voluntad.

Proveedores de datos .NET

Independientemente del sistema de bases de datos con el que trabajemos, las clases que implementan la caché local son siempre las mismas. Con las clases conectadas ocurre todo lo contrario: hay un conjunto diferente de clases para cada tipo de servi-

Page 167: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Fundamentos de ADO.NET 167

dor, aunque todas ellas deben implementar una misma familia de tipos de interfaz básicos.

Lo típico en otras interfaces de programación era tener un grupo de clases o funcio-nes fijas, que a su vez delegaban los detalles físicos del servidor en módulos internos, invisibles para el programador. Este diagrama, extraído de La Cara Oculta de Delphi 6 con permiso del autor, muestra la arquitectura del Borland Database Engine (BDE), un sistema típico de acceso universal a servidores SQL:

gds32.dll ntwdblib.dll oci.dll

Núcleo del BDE

Interfaces nativas

SQL Links

sqlint32.dll sqlmss32.dll sqlora32.dll

InterBase SQL Server Oracle

Controladores paraDBase y Paradox

DBase Paradox

Es este modelo, el programador debía usar siempre las mismas funciones, sin im-portar el tipo de servidor. Internamente, el sistema pasaba la responsabilidad a fun-ciones residentes en otras DLLs, que se cargaban sólo en el momento en que eran necesarias. ADO.NET elige la senda contraria. Para permitir el acceso a un tipo de-terminado de servidor, era necesario escribir una de estas DLLs controladoras.

En ADO.NET, por el contrario, si queremos añadir un tipo de servidor a la lista de servidores soportados por la plataforma, sólo tenemos que crear un conjunto de clases, tomando como patrón algunas clases y tipos de interfaz definidos dentro del espacio de nombres System.Data.Common. Los ensamblados donde se almacenan estos conjuntos de clases de acceso específicas se conocen como proveedores de datos.

Por poner un ejemplo, si un programador tiene que trabajar con SQL Server, debe utilizar las clases del proveedor implementado en System.Data.SqlClient:

Cada módulo ha sido etiquetado con el espacio de nombres correspondiente:

• System.Data: Es un ensamblado común, que contiene las clases desconectadas.

Page 168: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

168 La Cara Oculta de C#

• System.Data.Common: Define los tipos de interfaz que deben implementar todas las clases conectadas, y algunas clases bases que aportan algoritmos comu-nes a todos los tipos de servidores.

• System.Data.SqlClient: Es el proveedor de datos .NET para SQL Server. Este proveedor en particular es muy eficiente, porque accede directamente a los ser-vidores desde código .NET puro.

Algunas clases de los proveedores de datos, como SqlDataAdapter, aprovechan la implementación de clases bases comunes; en este caso, de la clase DbDataAdapter. Pero la mayoría de las clases descienden directamente de clases muy elementales como Component, y sólo utilizan los tipos de interfaz definidos en System.Data.Common. Por ejemplo:

public sealed class SqlCommand: Component, IDbCommand, ICloneable

De esta manera, la mayoría de las llamadas a métodos de una clase conectada sólo tienen que atravesar una capa de software, mientras que en los sistemas tradicionales tendríamos al menos dos DLLs involucradas. Otro beneficio importante es la facili-dad con la que se accede a características especiales de un servidor determinado. En un sistema estratificado y basado en la delegación sobre controladores, tendríamos que utilizar alguna función genérica de “escape”... o abrir un agujero en la encapsula-ción del controlador para poder llamar directamente a las funciones específicas. En el modelo de ADO.NET, en cambio, basta añadir un método a una clase conectada.

En el capítulo 17 veremos los proveedores de datos actualmente soportados por la plataforma .NET.

Previendo la división en capas

Veamos ahora cómo funcionaría una aplicación escrita en ADO.NET. Aunque le parezca extraño, vamos a comenzar por una aplicación “completa”, dividida en tres capas físicas:

• La capa de presentación es la aplicación que muestra los datos al usuario y per-mite que éste los modifique.

• La capa intermedia es la que sirve estos datos a la capa de presentación, extra-yéndolos del servidor SQL. Cuando el usuario decida grabar sus cambios, la capa de presentación enviará los datos de vuelta a la capa intermedia, donde se verifi-carán las reglas de negocio antes de modificar la base de datos.

• La capa de almacenamiento es, simplemente, el servidor SQL. Este servidor verificará algunas condiciones mediante reglas y triggers, y se ocupará de la im-plementación de algunas operaciones mediante procedimientos almacenados.

Como he hablado de capas físicas, tenemos que aclarar la ubicación de cada una de estas capas. La capa de almacenamiento, en el modelo ideal, estará alojada en un único servidor, que contendrá la base de datos y el servicio SQL. El ordenador co-

Page 169: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Fundamentos de ADO.NET 169

rrespondiente debe ser lo más potente que nos permita el presupuesto. Alrededor de esta abeja reina revolotearán los zánganos... perdón, los servidores de capa interme-dia. Si el tamaño de la instalación lo exige, podemos tener varios de estos servidores alrededor de un mismo servidor SQL. Si tenemos que atender a varias sucursales remotas con un número similar de usuarios en cada una de ellas, podemos reservar un servidor de capa intermedia para cada sucursal. Físicamente, los ordenadores de esta capa no tienen por qué ser tan potentes como el servidor SQL. Todos ellos, incluyendo al servidor central, deben estar conectados en red local a una velocidad aceptable.

En el interior de los servidores de capa intermedia estará ejecutándose un módulo programado por nosotros. En la tercera parte del libro veremos que el módulo puede implementarse mediante alguna de estas variantes:

• Una aplicación de servicios que aloje clases preparadas para el acceso remoto. Los usuarios remotos se conectarán a un zócalo TCP/IP mantenido por el pro-pio servicio.

• Como alternativa, esas mismas clases pueden ser exportadas por Internet Infor-mation Services. Los clientes remotos utilizarían HTTP como mecanismo de conexión. La ventaja principal al usar I.I.S. es que podemos aprovechar los me-canismos de seguridad de éste.

• Otra variante sería implementar la capa intermedia como servicios Web, también alojados dentro de Internet Information Services. Es un sistema más lento, pero su principal ventaja es la compatibilidad con otros lenguajes y sistemas operati-vos. En este caso, la conexión también se haría por medio de HTTP.

Finalmente, tenemos varios enjambres de abejas obreras y alguna que otra avispa, la mayoría de ellas situadas en ubicaciones remotas. Los ordenadores de estos usuarios se conectarían a través de Internet, o incluso mediante algún otro tipo de conexión como frame relay, a la batería de servidores de capa intermedia. Estos ordenadores no tienen idea de la existencia de un servidor SQL al final de la cadena, porque sola-mente se les permite la comunicación con la capa intermedia.

Veamos ahora qué sucede cuando un usuario quiere editar la lista de empleados al-macenada en la base de datos:

Desde la capa de presentación se dispara una llamada a un método provisto por no-sotros en la capa intermedia. Este método utiliza una instancia de un adaptador de datos, una de las clases conectadas, para enviar la consulta necesaria al servidor SQL.

Page 170: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

170 La Cara Oculta de C#

Con la respuesta obtenida, el adaptador ensambla un conjunto de datos, que es una de las clases desconectadas, y el conjunto de datos se devuelve a la capa de presentación. Esto es posible gracias a que lo importante del conjunto de datos es su contenido, no su identidad. Una representación en XML del conjunto de datos viaja desde la capa intermedia hasta el ordenador del usuario final, donde se reconstituye el objeto origi-nal como si se tratase de puré de patatas deshidratadas.

A partir de ese momento, el usuario podrá navegar sobre su copia local de los regis-tros, y modificar algunos de ellos:

Durante esta fase, no es necesario mantener las conexiones entre capas. Los recursos que de otra manera seguirían reservados, quedan a disposición de otras personas. Nuestro usuario podría desconectarse físicamente de la red, o incluso más: podría guardar su copia de los datos en su disco duro, y apagar el ordenador. Al día si-guiente, o a las nueve semanas, podría leer los datos de vuelta a la memoria, y pedir a la capa intermedia que propagase sus cambios al servidor SQL:

La ruta de actualización es similar a la de lectura. La capa de presentación ejecuta un método remoto de la capa intermedia, pasando esta vez una copia del conjunto de datos, o mejor aún, el subconjunto de éste que sólo contiene las modificaciones. En la capa intermedia, el método utilizaría el ya conocido adaptador de datos para gene-rar instrucciones de actualización a partir de los cambios: update, delete, insert.

Por supuesto, pueden producirse errores de todo tipo: el usuario ha intentado violar alguna restricción o regla de negocio, o los datos que quiere modificar ya han sido actualizados por otro usuario. En estos casos, la capa intermedia debe transmitir suficiente información a la capa de presentación para que ésta, con la ayuda del usua-rio, determine si va a corregir el error o cancelar la operación. Una vez terminada la actualización, nos volvemos a desconectar.

Page 171: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Fundamentos de ADO.NET 171

A pequeña escala

La descripción de la sección anterior corresponde a un sistema complejo, de gran escala. No obstante, ADO.NET no pone objeciones si mezclamos los componentes de las capas de presentación y de negocio en un mismo módulo físico. Si todos los usuarios residen, junto con el servidor SQL, en una misma red local y hay un número relativamente pequeño de ellos, podemos simplificar la aplicación y probablemente aumentar su rendimiento.

Además, la descripción del proceso de lectura, actualización y grabación sigue siendo la misma, excepto un par de detalles:

1 El conjunto de datos puede pedir directamente sus datos al correspondiente adaptador de datos. Lo mismo ocurriría durante la grabación.

2 El adaptador, en ambos casos, puede trabajar directamente con el conjunto de datos asociado a la edición, en vez de trabajar con copias, como en el caso de la división en tres capas.

¿Cuál es el número máximo de usuarios que puede soportar este tipo de sistemas divididos en dos capas? Todo depende del tipo de servidor y de las especificaciones del ordenador que actúe como tal, pero a mí se me enciende la bombilla de peligro cuando hay más de veinte usuarios en danza. Esta es una estimación hipocondríaca, porque la mayoría de los servidores comerciales aguantan perfectamente cincuenta o más usuarios simultáneos.

Para ser honestos, tengo un motivo adicional para desconfiar de este tipo de simplifi-caciones. Casi siempre que el cliente propone unificar capas está pensando en acortar las horas de desarrollo y ahorrarse algo de pasta. Por supuesto, como ADO.NET viene ya preparado para trabajar en varias capas, no hay tal ahorro de código, o es insignificante. Peor aún: si el negocio de nuestro contratista va bien, es muy probable que en poco tiempo deba abrir otra sucursal o departamento... ubicado casi siempre en el otro extremo del mapa. También puede ocurrírsele la idea de conectarse desde casa, aprovechando su flamante conexión ADSL. Es mejor pensar a lo grande desde el primer momento, para evitar problemas cuando crezca la carga sobre el sistema.

Page 172: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

12

Conjuntos de datos en memoria

A PIEDRA ANGULAR DE LA ARQUITECTURA de ADO.NET es un componente modesto que implementa una base de datos en miniatura en la memoria diná-

mica del cliente. Me refiero a la clase DataSet, con sus correspondientes clases auxilia-res: DataTable, para almacenar tablas individuales, DataRelation, para representar rela-ciones entre tablas, DataRow y DataColumn, para filas y columnas, y las distintas clases que permiten establecer restricciones.

La tarea principal de DataSet es servir de caché, en la capa de presentación, de los datos proporcionados por el servidor SQL. En este capítulo, sin embargo, nos cen-traremos en las características de esta clase que no dependen de la información recu-perada desde la capa SQL.

Conjuntos de datos

Estructuralmente, un conjunto de datos es una base de datos en miniatura, que alma-cena toda su información en la memoria dinámica. Esta es la estructura de un con-junto de datos típico:

Como puede ver, la clase DataSet administra dos colecciones de objetos. La primera colección está disponible en la propiedad Tables, y contiene objetos de tipo DataTable; la segunda colección reside en la propiedad Relations, y almacena instancias de la clase DataRelation. Un objeto de la clase DataTable, como ya sugiere su nombre, imple-

L

Page 173: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos en memoria 173

menta una tabla en memoria. Por otra parte, la clase DataRelation sirve para repre-sentar relaciones maestro/detalles entre las tablas en memoria. Más adelante veremos que existe una clase ForeignKeyConstraint para representar la restricción de integridad referencial que puede servir como base de la relación maestro/detalles. No debemos confundir las relaciones maestro/detalles, que tienen que ver con la navegación, con las claves externas, que tienen que ver con la integridad de los datos.

La clase DataTable también tiene una estructura compleja:

Cada tabla, según el diagrama, almacena en su propiedad Columns una lista de defini-ciones de columnas, de la clase DataColumn, mientras que su propiedad Constraints apunta a una lista de restricciones. Cada restricción es un objeto de la clase Unique-Constraint o de ForeignKeyConstraint, que ya hemos mencionado. Ambas clases descien-den de la clase abstracta Constraint.

Finalmente, la tabla hace referencia, mediante su propiedad Rows, a una colección de filas, en la que cada fila es un objeto de la clase DataRow.

Cómo se configura un conjunto de datos

La compleja estructura de la clase DataSet hace difícil la configuración de los con-juntos de datos: la creación de la instancia es sólo el primer paso de una larga lista de operaciones. Existen varias formas de configurar estos objetos:

1 Escribir manualmente el código necesario para crear las tablas, columnas, restric-ciones y relaciones que formarán parte del conjunto de datos. Es la técnica más laboriosa, pero será la técnica que usaremos en este capítulo. Nuestro actual ob-jetivo es familiarizarnos con todas estas clases.

2 Podemos ayudarnos con el Inspector de Propiedades, como muestra la imagen de la siguiente página, para definir gráficamente los objetos que formarán parte del conjunto de datos.

3 En vez de crear un objeto de la clase DataSet para luego añadirle los restantes componentes, podemos crear una clase derivada de DataSet, y encapsular en su interior los pasos necesarios para configurar el conjunto de datos. De esta ma-nera es más fácil reutilizar el conjunto de datos, tanto dentro del proyecto, como en otros proyectos.

Page 174: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

174 La Cara Oculta de C#

Estas clases derivadas de DataSet se conocen en inglés con el nombre de strongly typed datasets, y las vamos a llamar conjuntos de datos con tipos. Hay dos vías para crear una clase especializada basada en DataSet:

1 En su debido momento, estudiaremos una serie de componentes llamados adap-tadores de datos, que tienen en común la implementación de la interfaz IDbData-Adapter. Se utilizan para leer registros de una base de datos y copiarlos dentro de la memoria de un conjunto de datos. Podemos crear una clase de conjunto de datos con la ayuda de las instrucciones SQL almacenadas en uno o más adapta-dores.

2 La técnica más potente consiste en describir la estructura que deseamos para el conjunto de datos en XML, utilizando un lenguaje llamado XML Schema Defini-tion. La aplicación xsd.exe, que viene incluida en el SDK de la plataforma .NET, puede crear una clase heredera de DataSet partiendo de una definición de es-quema en XML. La definición de esquema, además, puede extraerse de una base de datos con la ayuda de las herramientas ofrecidas por Visual Studio.

Tablas y columnas

Estos son los constructores de la clase DataSet:

public DataSet();

public DataSet(string nombre);

Ambos constructores crean un conjunto de datos vacío, inicialmente sin tablas. El segundo constructor asigna, además, un nombre al conjunto de datos. El nombre se almacena en la propiedad DataSetName de la instancia, y sólo es necesario cuando el contenido del conjunto de datos se traduce a XML, para dar un nombre al nodo raíz de la jerarquía de etiquetas que se genera.

Page 175: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos en memoria 175

Si partimos de un conjunto de datos recién creado, el siguiente paso en su configura-ción es añadirle tablas. Podemos crear la tabla por separado para luego añadirla a la propiedad Tables del conjunto de datos:

dsClientes = new DataSet();

DataTable tbClientes = new DataTable("Clientes");

dsClientes.Tables.Add(tbClientes);

También se puede utilizar una de las versiones sobrecargadas del método Add de la lista de tablas, para ahorrar la llamada al constructor de DataTable:

DataSet dsClientes = new DataSet();

DataTable tbClientes = dsClientes.Tables.Add("Clientes");

Como muestran los dos ejemplos, una tabla también tiene un nombre asociado, que se almacena en su propiedad TableName. El nombre sirve para localizar una tabla determinada dentro del conjunto de datos:

DataTable tabla = dsClientes.Tables["Clientes"];

Naturalmente, también podemos localizar una tabla por su posición dentro de la colección de tablas:

DataTable tabla = dsClientes.Tables[0];

Un conjunto de datos con una tabla sin columnas no sirve para mucho. Veamos cómo se pueden añadir columnas a la tabla:

DataColumn c1 = tbClientes.Columns.Add("Codigo", typeof(int));

Hay tantas variantes del método Add de la lista de columnas como para aburrir al mismísimo Zaratustra y su coro de gallinas de Jericó. La versión que casi siempre utilizaremos es la mostrada en el ejemplo anterior, que nos permite indicar el nombre de la columna y su tipo asociado.

Configuración de columnas

La variante que he mostrado de Add sólo permite configurar el nombre de la co-lumna y su tipo de datos. Pero hay muchas más propiedades que deben ser configu-radas en una columna. Por este motivo, Add devuelve la referencia al objeto recién creado, para que cambiemos los valores de otras propiedades. Por ejemplo, si quisié-ramos que la columna Codigo se comportase como un campo entero con el atributo de identidad, al estilo SQL Server, modificaríamos algunas propiedades adicionales del objeto DataColumn:

DataColumn c1 = tbClientes.Columns.Add("Codigo", typeof(int));

c1.AutoIncrement = true;

c1.AutoIncrementSeed = -1;

c1.AutoIncrementStep = -1;

c1.AllowDBNull = false;

c1.ReadOnly = true;

Page 176: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

176 La Cara Oculta de C#

NO

TA

Cuando trabajemos con campos autoincrementales recuperados desde SQL Server, nos será muy útil que los valores que se creen en memoria sean negativos. De esta manera, no se producirán conflictos entre los valores autoincrementales “reales”, leídos de la base de datos SQL, y los valores “temporales” o “ficticios”, generados dentro del conjunto de datos en memoria.

Veamos cómo podríamos crear dos columnas para almacenar nombres y apellidos de los clientes:

DataColumn c2, c3;

c2 = tbClientes.Columns.Add("Nombre", typeof(string));

c2.AllowDBNull = false;

c2.MaxLength = 30;

c3 = tbClientes.Columns.Add("Apellidos", typeof(string));

c3.AllowDBNull = false;

c3.MaxLength = 50;

La propiedad AllowDBNull, que también modificamos en la primera columna, con-trola si la columna debe aceptar valores nulos o no. Esta vez, además, hemos tocado el valor de MaxLength; en el caso del tipo entero, no fue necesario hacerlo.

Columnas calculadas

Otra posibilidad a nuestro alcance es crear columnas calculadas, que en vez de alma-cenar físicamente su valor, lo calculan por medio de una expresión que puede involu-crar valores de otras columnas de la misma tabla. En el capítulo 14 veremos que incluso es posible utilizar columnas de tablas dependientes en una expresión, siempre que hayamos creado relaciones entre las tablas.

Para crear una columna calculada, debemos usar una variante del método Add, de la colección de columnas, que recibe tres parámetros. El primero, igual que antes, es el nombre de la columna, el segundo corresponde al tipo deseado, y el tercero contiene la expresión que asociaremos a la nueva columna:

tbClientes.Columns.Add("NombreCompleto", typeof(string),

"Apellidos + ', ' + Nombre");

Un ejemplo típico de uso de este recurso es añadir, a una tabla de detalles de pedidos, una columna calculada que represente el importe total del detalle:

tbDetalles.Columns.Add("SubTotal", typeof(decimal),

"Precio * Cantidad * (100 – Descuento) / 100");

Una vez creada la columna, la expresión se puede leer, o incluso modificar, mediante la propiedad Expression de la clase DataColumn.

¿Cuál es la sintaxis de las expresiones admitidas? El evaluador de expresiones para las columnas calculadas es el mismo que se ocupa de los filtros, un recurso que estudia-remos en el capítulo 15. Es cierto que la evaluación de una expresión de filtro debe dar como resultado un valor lógico, pero por lo demás, la sintaxis es idéntica.

Page 177: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos en memoria 177

Básicamente, podemos utilizar las cinco operaciones aritméticas: suma, resta, multi-plicación, división y módulo, con los mismos operadores y precedencia que en C#. Las cadenas pueden concatenarse con el operador de la suma. Pero gran parte de la potencia de las expresiones de columnas se logra mediante las siguientes funciones reconocidas por el evaluador:

Función Sintaxis convert convert(valor, nuevo_tipo) len len(expresion_cadena) isnull isnull(valor, valor_si_es_nulo) iif iif(condicion, valor_verdadero, valor_falso) trim trim(expresion_cadena) substring substring(cadena, inicio, longitud)

Como puede ver, algunas la mayoría de las funciones están inspiradas en las funcio-nes con el mismo nombre de Transact SQL. Y tenemos también la función iif, para permitir la evaluación condicional.

Mostrando la tabla en una rejilla

Para poder experimentar con las técnicas aprendidas, deberíamos disponer de algún método para visualizar el contenido de un conjunto de datos. Lo más sencillo será usar un control DataGrid... aunque existen multitud de opciones avanzadas para este control que tendremos que dejar para el capítulo apropiado.

Vamos a explicar cómo configurar una rejilla en Visual Studio de manera que poda-mos ver y editar el contenido de las tablas sencillas que estamos creando. Si no tiene acceso a este entorno de desarrollo, no importa, porque voy a incluir el código gene-rado por Visual Studio, y lo único que necesitará es copiarlo en su proyecto.

Normalmente, comenzaríamos por añadir un DataSet sobre la bandeja de compo-nentes pero, como vamos a configurarlo manualmente, he preferido crearlo también a mano. Por lo tanto, el primer componente que traeremos de la caja de herramien-tas, para dejarlo caer sobre el formulario principal, será un DataGrid:

Para que la rejilla ocupe completamente el área interior del formulario, cambie su propiedad Dock para que valga Fill. Con esto, hemos terminado con la parte visual del ejemplo.

Page 178: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

178 La Cara Oculta de C#

Cargue ahora el fichero que contiene el código del formulario (MAY+F7, en Visual Studio), y busque el constructor del formulario, que debe ser como el siguiente:

public MainForm()

{

//

// Required for Windows Form Designer support

//

InitializeComponent();

//

// TODO: Add any constructor code after InitializeComponent call

//

}

Visual Studio ha generado una llamada, desde el constructor, a un método especial llamado InitializeComponent. Localice ahora una zona, también dentro del fichero de código, con el siguiente aspecto:

En esta zona, Visual Studio “esconde” el código generado automáticamente por el diseñador visual. Haga clic sobre el icono de la izquierda, para mostrar el contenido de InitializeComponent, que debe parecerse a lo siguiente:

private void InitializeComponent()

{

this.dataGrid1 = new System.Windows.Forms.DataGrid();

((System.ComponentModel.ISupportInitialize)

(this.dataGrid1)).BeginInit();

this.SuspendLayout();

// dataGrid1

this.dataGrid1.DataMember = "";

this.dataGrid1.Dock = System.Windows.Forms.DockStyle.Fill;

this.dataGrid1.HeaderForeColor =

System.Drawing.SystemColors.ControlText;

this.dataGrid1.Name = "dataGrid1";

this.dataGrid1.Size = new System.Drawing.Size(524, 266);

this.dataGrid1.TabIndex = 0;

// MainForm

this.AutoScaleBaseSize = new System.Drawing.Size(5, 13);

this.ClientSize = new System.Drawing.Size(524, 266);

this.Controls.AddRange(new System.Windows.Forms.Control[] {

this.dataGrid1});

this.Name = "MainForm";

this.Text = "Memory database";

this.Load += new System.EventHandler(this.MainForm_Load);

((System.ComponentModel.ISupportInitialize)

(this.dataGrid1)).EndInit();

this.ResumeLayout(false);

}

No es mi intención explicar aquí todos los detalles del código generado por Visual Studio. He incluido la implementación de InitializeComponent por si no dispone usted de ningún entorno de desarrollo, y tiene que configurar la rejilla sin ayuda.

Page 179: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos en memoria 179

Ha llegado el momento de añadir el conjunto de datos y de configurarlo. Añada la siguiente declaración de variable dentro de la clase MainForm:

private System.Data.DataSet dataSet;

La referencia completamente cualificada a la clase, incluyendo el espacio de nombres, nos permite cierta independencia respecto a las cláusulas using situadas al inicio del fichero. De todos modos, es conveniente añadir la siguiente cláusula using al fichero:

using System.Data;

La inicialización del conjunto de datos tendrá lugar dentro del constructor del for-mulario, inmediatamente después de la llamada a InitializeComponent:

public MainForm()

{

//

// Required for Windows Form Designer support

//

InitializeComponent();

dataSet = new DataSet();

DataTable tbClientes = dataSet.Tables.Add("Clientes");

DataColumn c1, c2, c3, c4;

c1 = tbClientes.Columns.Add("Codigo", typeof(int));

c1.AllowDBNull = false;

c1.ReadOnly = true;

c1.AutoIncrement = true;

c1.AutoIncrementSeed = 0;

c1.AutoIncrementStep = -1;

c2 = tbClientes.Columns.Add("Nombre", typeof(string));

c2.AllowDBNull = false;

c2.MaxLength = 30;

c3 = tbClientes.Columns.Add("Apellidos", typeof(string));

c3.AllowDBNull = false;

c3.MaxLength = 35;

c4 = tbClientes.Columns.Add("NombreCompleto",

typeof(string), "Apellidos + ', ' + Nombre");

// Enlazar la tabla a la rejilla

dataGrid1.SetDataBinding(tbClientes, "");

}

Ya hemos visto las instrucciones que crean el conjunto de datos y sus columnas. La novedad principal es la instrucción que enlaza la única tabla del conjunto de datos con la rejilla de datos:

Page 180: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

180 La Cara Oculta de C#

dataGrid1.SetDataBinding(tbClientes, "");

En teoría, esta instrucción sustituye dos asignaciones sobre propiedades:

dataGrid1.DataSource = tbClientes;

dataGrid1.DataMember = "";

Lo normal es que, cuando el conjunto de datos se configura en tiempo de diseño, estas dos asignaciones se especifiquen con la ayuda del Inspector de Propiedades. Al haber creado el conjunto de datos en tiempo de ejecución, tenemos que ocuparnos del enlace de la rejilla. Por otra parte, la llamada a SetDataBinding es más segura que la asignación equivalente, porque garantiza que la rejilla se actualice inmediatamente.

Filas y conjuntos de filas

Y ahora viene la gran sorpresa que pilla desprevenidos a los programadores con experiencia en otras interfaces de acceso a datos. ¿Cuáles son los métodos del con-junto de datos que permiten mover la posición del cursor? No hay tales métodos, porque ni la clase DataSet ni DataTable ofrecen algo que se parezca remotamente a un registro activo o a una posición del cursor.

Lo que la clase DataTable ofrece es una colección de filas. Cada fila es un objeto de la clase DataRow, y la propiedad Rows de cada tabla contiene una colección de estos objetos. Para recorrer todas las filas de una tabla, por ejemplo, se utiliza el siguiente patrón algorítmico:

foreach (DataRow row in tbClientes.Rows) {

// … lo que sea …

}

C# traduce la instrucción foreach en el siguiente bucle equivalente:

IEnumerator enum = ((IEnumerable) tbClientes.Rows).GetEnumerator;

while (enum.MoveNext()) {

DataRow row = (DataRow) enum.Current;

// … lo que sea …

}

¿Cómo es posible que, en la aplicación del ejemplo anterior, manejásemos una fila activa dentro de la rejilla? ¿Sería responsabilidad de este control el mantenimiento de una fila activa, externa a la propia clase DataSet? Tampoco, eso sería ir al otro ex-tremo. El mantenimiento de una posición de cursor es responsabilidad del subsis-tema de Windows Forms conocido como data binding. Tendremos que esperar un par de capítulos para explicar su funcionamiento, pero lo que importa ahora es saber que ni la tabla ni el conjunto de datos tienen una posición de cursor intrínseca.

Para acceder a los valores de cada campo individual de una fila, se utiliza el indizador de la clase DataRow, que tiene nada menos que seis versiones sobrecargadas. Comen-cemos por las tres primeras:

Page 181: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos en memoria 181

public object this[string] { get; set; }

public object this[int] { get; set; }

public object this[DataColumn] { get; set; }

La primera versión, la más sencilla, permite pasar un nombre de columna como ca-dena, y devuelve el valor dentro de un objeto.

foreach (DataRow row in tbClientes.Rows)

Console.WriteLine(row["Nombre"]);

Esta versión del indizador es la menos eficiente, porque obliga a buscar internamente la columna que tiene el nombre dado. Para mejorar la velocidad, tenemos una versión del indizador a la que podemos pasar la posición de la columna. Por ejemplo:

int cn1 = tbClientes.Columns.IndexOf("Nombre");

int cn2 = tbClientes.Columns.IndexOf("Apellidos");

foreach (DataRow row in tbClientes.Rows)

Console.WriteLine(

row[cn1].ToString() + " " + row[cn2].ToString());

Sin embargo, un poco más eficiente es usar como índice el propio objeto DataColumn cuyo valor queremos obtener para la fila:

foreach (DataRow row in tbClientes.Rows)

foreach (DataColumn col in tbClientes.Columns)

Console.WriteLine(row[col]);

Claves primarias y valores únicos

¿Qué tal si añadimos una clave primaria a la tabla de clientes? En el ejemplo que estamos desarrollando, la clave primaria más apropiada estaría formada por una sola columna: la de tipo autoincremental, pero en el caso más general, podría estar for-mada por varias columnas. Por este motivo, la propiedad PrimaryKey de DataTable, que es la propiedad que necesitamos, es un vector de objetos DataColumn:

public DataColumn[] PrimaryKey { get; set; }

Una forma de asignar la clave primaria sería la siguiente:

tbClientes.PrimaryKey = new DataColumn[] { c1 };

La variable c1 debe estar declarada como DataColumn, y debe apuntar a la primera columna de la tabla. Si hubiésemos perdido esa referencia, podríamos buscar la co-lumna en tiempo de ejecución, aunque sería algo más lento:

tbClientes.PrimaryKey =

new DataColumn[] { tbClientes.Columns["Codigo"] };

Existen unas cuantas variantes más. La clase DataTable almacena todas sus restric-ciones en una colección llamada Constraints: claves primarias, claves únicas y, como veremos en breve, claves externas. Y la propiedad Constraints tiene un método Add, sobrecargado para que haya una versión para casi toda necesidad. Podríamos añadir la clave primaria con esta otra instrucción:

Page 182: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

182 La Cara Oculta de C#

tbClientes.Constraints.Add("PK", c1, true);

El primer parámetro es el nombre que daremos a la restricción, el segundo contiene la columna que marcaremos como clave primaria, y en el tercer parámetro indicamos que queremos una clave primaria, no una restricción de unicidad más. No se preo-cupe, porque existe otra versión de Add que admite un vector de columnas en el segundo parámetro, para las tablas con clave primaria compuesta.

Está claro que también podemos usar Constraints.Add para crear restricciones adicio-nales de unicidad. Pero, para variar, vamos a utilizar otra de las muchas versiones de este método para exigir que la combinación de nombre y apellidos no se repita en distintos registros:

UniqueConstraint uniqueConst = new UniqueConstraint(

new DataColumn[] { c2, c3 }, false);

tbClientes.Constraints.Add(uniqueConst);

Hemos creado explícitamente un objeto para la restricción, por medio de la clase UniqueConstraint y lo hemos añadido directamente a la lista de restricciones. No le hemos asignado un nombre a la restricción, pero existe una versión del constructor de UniqueConstraint que nos permite pasar un nombre para el objeto en uno de sus parámetros.

Y hay más, porque la clase DataColumn tiene un propiedad llamada Unique, de tipo lógico. Si asignamos true en esta propiedad, se crea automáticamente una restricción de unicidad para esa columna solamente. El nombre de la restricción se generará au-tomáticamente, y la restricción no se marcará como clave primaria.

Persistencia

¿Qué sentido tiene la existencia una base de datos en memoria? Como las hojas en otoño, sus registros desaparecen al morir la aplicación... aunque en el barrio de Ma-drid donde vivo, es más probable que se los cargue uno de los frecuentes picos de tensión en el suministro eléctrico. ¡Ah, si mi compañía eléctrica se gastase menos dinero en publicidad (en definitiva, todavía disfruta del monopolio) y más en mejorar la calidad del servicio! El otoño me hace desvariar, está claro...

Regresando a lo que nos ocupa: aunque lo normal es que los registros de un DataSet vengan de una base de datos, y que los cambios se reflejen de vuelta en la misma, también es posible guardar el contenido de un conjunto de datos en un fichero, o leer filas desde un fichero. Y lo mismo puede decirse del propio esquema relacional. A modo de aperitivo, sírvase usted de estos métodos de DataSet:

Page 183: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos en memoria 183

public string GetXml();

public string GetXmlSchema();

El primero de los métodos devuelve una cadena en formato XML con una repre-sentación lineal de las filas encontradas en el conjunto de datos, en cualquiera de sus tablas. Por ejemplo, si pedimos el valor de GetXml para el conjunto de datos que hemos venido utilizando, éste será el resultado, suponiendo que ya hayamos introdu-cido tres filas mediante la rejilla:

<?xml version="1.0" standalone="yes"?>

<NewDataSet>

<Clientes>

<Codigo>-1</Codigo>

<Nombre>Ian</Nombre>

<Apellidos>Marteens</Apellidos>

<NombreCompleto>Marteens, Ian</NombreCompleto>

</Clientes>

<Clientes>

<Codigo>-2</Codigo>

<Nombre>Albert</Nombre>

<Apellidos>Einstein</Apellidos>

<NombreCompleto>Einstein, Albert</NombreCompleto>

</Clientes>

<Clientes>

<Codigo>-3</Codigo>

<Nombre>Isaac</Nombre>

<Apellidos>Newton</Apellidos>

<NombreCompleto>Newton, Isaac</NombreCompleto>

</Clientes>

</NewDataSet>

Se trata de una representación más o menos directa del contenido de las tablas del conjunto de datos. Sin embargo, ¿se atrevería usted a deducir el formato o esquema relacional de las tablas a partir de la cadena con los datos? Para empezar, no hay forma segura de saber si el campo Codigo contiene números o cadenas de caracteres arbitrarias, ni podemos garantizar cuál es la longitud máxima de las columnas que almacenan el nombre y los apellidos. Por último, parece bastante probable que NombreCompleto sea una columna calculada pero, ¿quién nos garantiza que un cuarto registro no nos depare alguna sorpresa?

Por todas estas razones, es útil disponer de una descripción del conjunto de datos, también en el formato de moda: en XML. Ese es el propósito del método GetXml-Schema: devolver, dentro de una cadena de caracteres, una representación en formato XML de la estructura de un conjunto de datos: sus tablas, sus columnas, las relacio-nes entre tablas, las restricciones... Esa descripción se basa en un lenguaje basado en XML, conocido como XML Schema Definition, o XSD. No voy a entrar ahora en más detalles, pero más adelante dedicaremos el capítulo 20 a XSD, y a su aplicación en la creación de conjuntos de datos con tipos (strongly typed datasets).

Si quisiéramos guardar el contenido de un conjunto de datos en disco, podríamos ejecutar el método GetXml, y quizás también GetXmlSchema, para escribir los valores devueltos en un fichero. Pero hay una forma más fácil, y es utilizar el siguiente mé-todo, que también pertenece a la clase DataSet:

Page 184: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

184 La Cara Oculta de C#

public void WriteXml(string Fichero, XmlWriteMode modo);

En realidad, este método tiene otras siete versiones sobrecargadas: unas permiten escribir el texto en formato XML sobre varios soportes (flujos de datos, escritores), y otras omiten el segundo parámetro, asumiendo un valor por omisión para el mismo. Hay tres modos posibles de escritura:

1 IgnoreSchema: Se copia solamente el texto XML correspondiente a los datos, sin incluir el esquema.

2 WriteSchema: Además de los datos, se incluye el esquema relacional, en XSD. 3 DiffGram: El formato DiffGram permite representar información adicional con

los cambios que haya sufrido el contenido del conjunto de datos. Si el contenido original del conjunto de datos viene de una base de datos, por ejemplo, en el DiffGram se incluirían los valores originales de las filas modificadas, además de los valores actuales, después de la modificación. Las filas añadidas o eliminadas aparecerían con la marca correspondiente.

La versión de WriteXml con un solo parámetro copia sólo los datos:

public void WriteXml(string Fichero);

Para probar el funcionamiento de este método, podemos añadir un botón, al que llamaremos bnGuardar, al ejemplo que hemos creado a lo largo del capítulo, e inter-ceptar su evento Click en el siguiente modo:

private void bnGuardar_Click(object sender, System.EventArgs e)

{

using (SaveFileDialog fd = new SaveFileDialog())

{

fd.Filter = "XML files|*.xml|All files|*.*";

fd.DefaultExt = "xml";

if (fd.ShowDialog() == DialogResult.OK)

dataSet.WriteXml(fd.FileName, XmlWriteMode.WriteSchema);

}

}

No olvide que la opción WriteSchema escribe tanto los datos como el esquema. Por su parte, para leer un fichero XML y restaurar los datos, el esquema o ambos a la vez, se utiliza el método ReadXml:

public XmlReadMode ReadXml(string Fichero, XmlReadMode modo);

El modo de lectura ofrece más posibilidades. Auto, que es el valor asumido por omi-sión, intenta inferir el modo de lectura más apropiado de acuerdo a la estructura del fichero leído; por supuesto, este modo puede ralentizar la lectura. Los modos explí-citos son ReadSchema, IgnoreSchema, InferSchema, que permite crear o extender el es-quema del conjunto de datos a partir de los datos en formato XML, DiffGram, para leer ficheros guardados con la opción correspondiente de escritura, y Fragment, que espera el formato XML generado por las consultas sobre SQL Server 2000 con la opción for xml.

Page 185: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos en memoria 185

Para restaurar el conjunto de datos a partir del fichero XML, en el ejemplo que he-mos desarrollado, podríamos usar el siguiente método:

private void bnLeer_Click(object sender, System.EventArgs e)

{

using (OpenFileDialog fd = new OpenFileDialog())

{

fd.Filter = "XML files|*.xml|All files|*.*";

if (fd.ShowDialog() == DialogResult.OK)

dataSet.ReadXml(fd.FileName);

}

}

Observe que podríamos ahorrarnos la creación manual de columnas y restricciones cuando tuviésemos a mano un fichero con el esquema relacional deseado. Más ade-lante veremos cómo crear estos ficheros de definición de esquemas con herramientas visuales, dentro de Visual Studio.

NO

TA

Si sólo quiere guardar o leer el esquema del conjunto de datos, sin importar los datos en sí, debe usar los métodos WriteXmlSchema y ReadXmlSchema, también pertenecientes a la clase DataSet.

Personalización del formato de persistencia

Voy a repetir un fragmento del texto XML que mostré en la sección anterior:

<?xml version="1.0" standalone="yes"?>

<NewDataSet>

<Clientes>

<Codigo>-1</Codigo>

<Nombre>Ian</Nombre>

<Apellidos>Marteens</Apellidos>

<NombreCompleto>Marteens, Ian</NombreCompleto>

</Clientes>

</NewDataSet>

A mí, al menos, me molestan varias cosas en esta representación:

1 ¡Odio XML! Pero no puedo hacer gran cosa en ese sentido... ¿Quiere un motivo racional? Mire qué desperdicio de espacio, con esas etiquetas repetidas, con los diez caracteres de la palabra “Apellidos” repetidos por partida doble en cada re-gistro... Por suerte, podremos hacer algo contra este desperdicio.

2 ¿NewDataSet? ¿Por qué? 3 La columna NombreCompleto no debería aparecer en el listado, porque es una

columna calculada

Comencemos por el nombre de la raíz. Si no le gusta NewDataSet, puede cambiarlo asignando un nombre más apropiado en la propiedad DataSetName del conjunto de datos:

dataSet.DataSetName = "Clientes";

Page 186: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

186 La Cara Oculta de C#

Hasta donde he podido averiguar, el valor de DataSetName sólo se utiliza en la gene-ración de XML.

La solución al problema de la columna calculada es diferente. Tenemos que modifi-car el valor de la propiedad ColumnMapping de la columna:

dataSet.Tables["Clientes"].Columns["NombreCompleto"].ColumnMapping =

MappingType.Hidden;

El tipo de la propiedad ColumnMapping es el tipo enumerativo MappingType, que ofrece más valores interesantes:

dataSet.Tables["Clientes"].Columns["Codigo"].ColumnMapping =

MappingType.Attribute;

El valor por omisión de ColumnMapping es Element. Al cambiarlo a Attribute, estamos modificando la representación de la columna dentro de la fila. Sumando todos estos cambios, el conjunto de datos mostrado al principio de la sección se representaría ahora así:

<?xml version="1.0" standalone="yes"?>

<Clientes>

<Clientes Codigo="-1">

<Nombre>Ian</Nombre>

<Apellidos>Marteens</Apellidos>

</Clientes>

</Clientes>

El valor de la columna código se incluye ahora dentro de la misma etiqueta que deli-mita cada registro de cliente. Evidentemente, esto significa un ahorro de espacio considerable.

Documentos XML

Cuando se trabaja con XML, lo más interesante sucede cuando el contenido XML ya ha sido analizado sintácticamente y se encuentra en memoria, almacenado en forma de árbol... o en cualquier otra estructura de datos que permita ejecutar eficientemente ciertos algoritmos:

• La búsqueda de uno o más nodos. La especificación XPath se encarga de definir la sintaxis de las consultas admitidas.

• La transformación del documento XML, a través de las hojas de estilo extendi-das definidas por la especificación XSLT.

De no ser por estas posibilidades, XML sería un formato de intercambio tan tonto y pasivo como CSV (comma separated values) y similares. No es que la búsqueda de in-formación con XPath sea el nirvana; prefiero seguir buscando registros con los mé-todos que estudiaremos en el capítulo 15. Reconozco, en cambio, que XSLT puede ser útil en determinadas ocasiones. Es una técnica elegante que puede utilizarse para generar páginas HTML a partir de contenido XML y de plantillas XSLT.

Page 187: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos en memoria 187

NO

TA

De todos modos, mi opinión es que el diseño de páginas HTML comerciales debe ser responsabilidad de especialistas en diseño gráfico, y para esta gente es bastante compli-cado hincarle el diente al concepto de XSLT, que se basa en transformaciones algebrai-cas. Pero he prometido al lector no cerrar puertas, aunque no me atraiga lo que hay tras ellas, por lo que veremos qué es lo que necesitamos para acceder directamente a la información de un conjunto de datos en formato XML.

Es cierto que, con los métodos estudiados en las secciones anteriores, podemos con-vertir el contenido de un conjunto de datos en XML, y viceversa. Sin embargo, para trabajar con XPath y XSLT necesitaríamos cargar ese contenido en una estructura de datos más apropiada. Podríamos utilizar la clase XmlDocument, pero esto nos obligaría a ejecutar demasiados pasos: primero obtendríamos el texto XML, luego crearíamos el documento XML y cargaríamos el texto antes creado, y sólo entonces estaríamos en condiciones de buscar nodos o transformar el contenido.

Para abreviar el proceso, .NET ofrece la clase XmlDataDocument, que podemos enla-zar explícitamente a un conjunto de datos para crear un vínculo bidireccional entre el conjunto de datos y el documento XML. Cualquier cambio realizado en el conjunto de datos será inmediatamente visible en el documento XML, y cualquier modifica-ción en el documento se aplicará también sobre el conjunto de datos.

Abra el proyecto que hemos venido usando, y añada la siguiente variable a la clase del formulario principal:

private System.Xml.XmlDataDocument dataDoc = null;

Necesitaremos incluir dos espacios de nombres, para la clase XmlDataDocument y para XslTransform, una clase que necesitaremos más adelante:

using System.Xml;

using System.Xml.Xsl;

Busque el método InitializeComponents, y añada la siguiente instrucción para crear el documento XML después de que el conjunto de datos haya sido creado e iniciali-zado:

dataDoc = new XmlDataDocument(dataSet);

Añada entonces un comando de menú, o un botón, y asocie el siguiente código a su evento Click:

OpenFileDialog od = new OpenFileDialog();

od.Filter = "Transformaciones|*.xsl;*.xslt";

if (od.ShowDialog() == DialogResult.OK)

{

XslTransform xsl = new XslTransform();

xsl.Load(od.FileName);

using (System.IO.TextWriter tw = new System.IO.StringWriter())

{

xsl.Transform(dataDoc, null, tw, null);

MessageBox.Show(tw.ToString());

}

}

Page 188: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

188 La Cara Oculta de C#

La transformación XSLT estará almacenada en un fichero externo a la aplicación. Permitiremos que el usuario seleccione una plantilla, para crear el objeto de trans-formación y configurarlo con el contenido de la plantilla elegida. Luego crearemos un escritor de texto: un objeto que será utilizado internamente por la transformación para escribir información sobre una cadena de caracteres. Si quisiéramos que el re-sultado de la transformación fuese a parar a un fichero, crearíamos una instancia de StreamWriter. En realidad, la transformación exige un objeto de cualquier clase des-cendiente de TextWriter, que es una clase abstracta.

La instrucción más importante del ejemplo es ésta:

xsl.Transform(dataDoc, null, tw, null);

En el primer parámetro pasamos una implementación de la interfaz IXPathNavigable, que en este ejemplo es nuestro documento XML. El siguiente parámetro, para el que pasamos un puntero vacío, podría contener parámetros adicionales para la transfor-mación. Finalmente pasamos el escritor de texto y otro puntero nulo, que en ejem-plos más complicados podría apuntar a un XmlResolver, para tratar las referencias a elementos XML externos. Una vez ejecutada la transformación, el método ToString del escritor de texto nos devuelve el resultado como una cadena, para mostrarla en un cuadro de mensajes.

Para que pueda probar la aplicación, aquí tiene una plantilla sencilla:

<xsl:stylesheet

xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">

<xsl:template match="NewDataSet">

<HTML>

<BODY>

<TABLE>

<xsl:apply-templates select="Clientes"/>

</TABLE>

</BODY>

</HTML>

</xsl:template>

<xsl:template match="Clientes">

<TR>

<TD><xsl:value-of select="Codigo"/></TD>

<TD><xsl:value-of select="Nombre"/></TD>

<TD><xsl:value-of select="Apellidos"/></TD>

</TR>

</xsl:template>

</xsl:stylesheet>

En este libro no vamos a explicar la vida y milagros de XSLT, por ser una especifica-ción bastante compleja10. En el ejemplo tenemos dos plantillas: una que se aplica para el nodo raíz, NewDataSet, y otra que se aplica a cada nodo Clientes. La primera de ellas se aplica una sola vez, y define la estructura global del fichero HTML, y la se-gunda se aplica una vez para cada registro de cliente, y en cada aplicación se genera

10 XSLT y XPath se explican detalladamente en el libro Intuitive ASP.NET.

Page 189: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos en memoria 189

una fila para una tabla HTML. Para que se pueda hacer una idea, he aquí un ejemplo de contenido XML proveniente del conjunto de datos que estamos usando:

<NewDataSet>

<Clientes>

<Codigo>0</Codigo>

<Nombre>Ian</Nombre>

<Apellidos>Marteens</Apellidos>

<NombreCompleto>Marteens, Ian</NombreCompleto>

</Clientes>

<Clientes>

<Codigo>-1</Codigo>

<Nombre>Albert</Nombre>

<Apellidos>Einstein</Apellidos>

<NombreCompleto>Einstein, Albert</NombreCompleto>

</Clientes>

<Clientes>

<Codigo>-2</Codigo>

<Nombre>Isaac</Nombre>

<Apellidos>Newton</Apellidos>

<NombreCompleto>Newton, Isaac</NombreCompleto>

</Clientes>

</NewDataSet>

La transformación del contenido anterior generaría el siguiente código HTML:

<HTML>

<BODY>

<TABLE>

<TR><TD>0</TD><TD>Ian</TD><TD>Marteens</TD></TR>

<TR><TD>-1</TD><TD>Albert</TD><TD>Einstein</TD></TR>

<TR><TD>-2</TD><TD>Isaac</TD><TD>Newton</TD></TR>

</TABLE>

</BODY>

</HTML>

NO

TA

Está claro que los ejemplos de transformaciones XSLT son más llamativos cuando el contenido XML de entrada tiene más niveles de profundidad, como ocurrirá cuando defi-namos varias tablas dentro de un conjunto de datos y establezcamos relaciones entre ellas. Por otra parte, aquí estamos generando texto HTML, y sería más espectacular mostrar el resultado dentro de un navegador. Podríamos importar la clase ActiveX que implementa la navegación en Internet Explorer, pero no he querido que nos complique-mos ahora con este tipo de detalles.

Page 190: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

13

Actualizaciones en memoria

ARA SER SINCEROS, HEMOS ESTADO ACTUALIZANDO filas de conjuntos de datos durante todo el capítulo anterior. Pero ha sido la rejilla de datos quien se ha en-

cargado de llamar a los métodos necesarios. En este capítulo explicaré cómo se mo-difican estos datos en memoria, y los estados por los que pasa una fila cuando se producen estos cambios. Todo lo que aprendamos aquí nos será útil cuando estu-diemos cómo se las arregla ADO.NET para reproducir estos cambios sobre la bases de datos desde la que se leyeron los registros.

Actualizaciones sobre tablas

A primera vista, podría parecer que la actualización de tablas debería ser similar a la modificación de los elementos de un vector o colección. Esto es: localizamos una tabla, asignamos un valor a la columna... y ya está. Nada más falso. El objetivo último de los conjuntos de datos en memoria es actuar como copia local del contenido de una base de datos de verdad. Las modificaciones que apliquemos a la copia local deben ser repetidas, en algún momento, sobre la base de datos de origen. Por lo tanto, es muy importante que cualquier cambio que tenga lugar sobre las tablas de un conjunto de datos deje una huella bien clara.

NO

TA

Los métodos WriteXml y ReadXml, vistos en el capítulo anterior, nos han dado una pista sobre este problema. Recuerde que estos método soportan un formato llamado DiffGram, en el que, más que representar el contenido actual del conjunto de datos, se almacenan los datos originales y los cambios necesarios para llegar al contenido actual.

En primer lugar, cada fila tiene una propiedad llamada RowState, que pertenece al tipo enumerativo DataRowState y que puede tomar uno, y sólo uno, de los siguientes valores:

Valor Significado Unchanged La fila no ha cambiado desde el instante t0 Added La fila es nueva, aunque puede haber sufrido cambios posteriores Modified La fila ya existía en el instante t0, y ha sufrido cambios desde entonces Deleted La fila ha sido marcada para ser borrada Detached La fila no está incluida en la propiedad Rows de una tabla

¿El instante t0? Bueno, es que quería llamar la atención y me he puesto solemne. Re-flexione: ¿cómo es posible que existan filas en el estado Unchanged? Un conjunto de

P

Page 191: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones en memoria 191

datos se crea sin filas, y sólo después de creado es que se le añaden las filas. Por lo tanto, toda fila debería estar en el estado Added... a no ser que exista algún truco que no haya aún mencionado. Pongamos como ejemplo que el conjunto de datos se crea vacío, y que recibe tanto el esquema como sus datos mediante una llamada a Read-Xml, como en el capítulo anterior. Internamente, después de crear las columnas, el método ReadXml irá componiendo filas y añadiéndolas a las tablas apropiadas, que-dando las filas en el estado Added. Sin embargo, antes de que el método termine, “algo” sucede que hace borrón y cuenta nueva, y pasa todas las filas añadidas al es-tado Unchanged. Es el solemne instante t0 que he mencionado antes...

Desvelemos el misterio: para pasar una fila al estado Unchanged se utiliza el método AcceptChanges, un método definido en las clases DataRow, DataTable y DataSet. En todas ellas el prototipo es el mismo:

public void AcceptChanges();

Otro método relacionado es el siguiente:

public void RejectChanges();

RejectChanges también define un instante t0, pero descartando cualquier cambio su-frido por la fila desde el instante de referencia anterior.

Es muy importante que comprenda que una fila sólo puede encontrarse en uno de los cinco estados antes enumerados. Si efectuamos más de un tipo de cambio sobre una fila, estos se combinan. Por ejemplo:

• Si añadimos una fila, y luego la modificamos, el estado seguirá siendo Added. Efectivamente, el efecto de las dos operaciones es equivalente a haber insertado la fila con los valores finales.

• Si añadimos una fila y luego la borramos, la fila simplemente desaparece de nuestra vista. Si nos quedamos con una referencia a la fila, el estado que obten-dremos será Detached.

• Si borramos una fila, no podemos realizar más operaciones sobre ella. Tampoco tiene sentido hablar de una inserción después de una modificación: estaríamos hablando de dos filas diferentes.

• Una modificación seguida de un borrado, es un borrado.

• Y si hay más de dos tipos consecutivos de actualización, puede razonar por in-ducción. Por ejemplo, si se producen tres tipos de cambios, primero se combina-rían los dos primeros, y luego, el cambio resultante con el último cambio. Ele-mental, Watson...

Versiones de filas

Hay otro concepto importante para poder reproducir los cambios aplicados a una fila: la versión de la fila. Es más fácil comprender de qué estamos hablando exami-nando lo que sucede en una modificación de una fila existente. Por ejemplo, reco-

Page 192: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

192 La Cara Oculta de C#

rriendo la colección de fila de una tabla, descubrimos que cierta fila ha sido modifi-cada. Sabemos con facilidad cuál es el valor actual de una columna dada:

tbClientes["Apellidos"]

Pero nos interesa saber también cuál era el valor original, quizás para generar una sentencia update que reproduzca la actualización en la base de datos. Para obtener el valor original de la columna, se utiliza una variante sobrecargada del indizador de la clase DataRow:

tbClientes["Apellidos", DataRowVersion.Original]

Como ve, podemos pasar un segundo parámetro al indizador: una de las constantes pertenecientes al tipo enumerativo DataRowVersion.

Valor Significado Current El valor actual de la columna o fila Default Igual que Current, excepto dentro de un bloque BeginEdit/EndEdit Original El valor original de la columna o fila Proposed Valores propuestos dentro de un bloque BeginEdit/EndEdit

Sin embargo, aquí nos falta conocer un poco más sobre las modificaciones para po-der explicar el uso de estos valores, como sugiere la referencia a esos desconocidos métodos BeginEdit y EndEdit. Un poco de paciencia, por favor.

Inserciones

Es muy fácil añadir filas a una tabla mediante código, y es por ello que trataremos las inserciones en primer lugar. Para insertar una fila, primero hay que crear un objeto DataRow ejecutando el método NewRow de la tabla. Es importante que la tabla sobre la que ejecutamos NewRow sea la misma en la que vamos a insertar la fila posterior-mente, porque este método debe inicializar la estructura interna de la fila. Luego, debemos asignar valores para cada columna de la tabla que lo requiera. Finalmente, la fila se añade a la colección Rows de la tabla:

DataRow r = tbClientes.NewRow();

r["Nombre"] = "Ian";

r["Apellidos"] = "Marteens";

tbClientes.Rows.Add(r);

Tenga en cuenta que, una vez ejecutado el método Add, el objeto pasa a formar parte del contenido de la tabla. Lo que se añade no es una copia de los valores del objeto, sino el mismísimo objeto. ¿Se lo demuestro?

DataRow r = tbClientes.NewRow();

r["Nombre"] = "Ian";

r["Apellidos"] = "Marteens";

tbClientes.Rows.Add(r);

// Intentamos modificar y volver a añadir,

// utilizando el mismo objeto de fila

r["Nombre"] = "Sherlock";

r["Apellidos"] = "Holmes";

Page 193: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones en memoria 193

// Esta instrucción provocará una excepción

tbClientes.Rows.Add(r);

La segunda llamada a Add falla, y el mensaje nos advierte de que esa fila ya pertenece a la tabla. Por supuesto, la solución consiste en crear una segunda fila con NewRow, si queremos realmente añadir dos registros:

DataRow r = tbClientes.NewRow();

r["Nombre"] = "Ian";

r["Apellidos"] = "Marteens";

tbClientes.Rows.Add(r);

// Creamos un nuevo objeto de fila, aunque estemos…

// … refiriéndonos a él con la misma variable

r = tbClientes.NewRow();

r["Nombre"] = "Sherlock";

r["Apellidos"] = "Holmes";

// Ahora todo irá sobre ruedas

tbClientes.Rows.Add(r);

Observe que estamos utilizando la misma variable r para referirnos a la segunda fila. No hay nada malo en ello: por una parte, la fila original ya ha pasado a formar parte de la tabla. E incluso si no fuese así, la recolección de basura nos evitaría que la fila presuntamente perdida, ¡que no lo está!, vagase eternamente por el purgatorio de los objetos perdidos.

Otra técnica muy sencilla para insertar filas consiste en ejecutar una versión del mé-todo Add de DataRowCollection que acepta como parámetro un vector de objetos:

tbClientes.Rows.Add(new object[]{null, "Ian", "Marteens"});

El primer elemento del vector es nulo porque la primera columna de la tabla es un campo autoincremental. Por supuesto, esta versión de Add sólo debe utilizarse para pruebas rápidas, principalmente porque es muy frágil respecto a cambios en la canti-dad o en el orden de las columnas de la tabla.

Cuando se crea una nueva fila, podemos inicializar algunas de sus columnas automá-ticamente si al crear éstas hemos asignado un valor en la propiedad DefaultValue, definida dentro de la clase DataColumn:

public object DefaultValue { get; set; }

Sinceramente, hubiera sido preferible disponer de una propiedad DefaultExpression. Con los recursos existentes, no se puede asignar la hora actual en columnas de tipo fecha y hora, al inicializarse una fila. De todos modos, más adelante veremos que se pueden utilizar eventos para corregir este problema.

Borrados

También es sencillo eliminar una fila de una tabla en memoria. Incluso tenemos dos posibilidades: marcar la fila como borrada, pero dejándola dentro de la colección de filas, o eliminar la fila de la colección. Para marcar la fila, se utiliza su método Delete:

Page 194: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

194 La Cara Oculta de C#

DataRow dr = tbClientes.Rows[0];

dr.Delete();

Para eliminarla de la colección, debemos utilizar el método Remove de la colección:

DataRow dr = tbClientes.Rows[0];

tbClientes.Remove(dr);

Tenga en cuenta que si añade una columna y después ejecuta sobre ella el método Delete, en realidad estará quitando la fila de la colección Rows.

Modificaciones sobre filas

Un poco más complicada es la modificación de una fila existente. En realidad, tene-mos una forma muy simple de modificar una fila. Suponga que queremos modificar el cliente cuyo código es el -1:

DataRow dr = tbClientes.Rows.Find(-1);

if (dr != null)

dr["Nombre"] = "Mr. " + dr["Nombre"];

Solamente hemos tenido que asignar un nuevo valor mediante el indizador de la clase DataRow. Automáticamente, la fila cambiará el valor de RowState al estado apropiado, y se encargará de recordar el valor original de la columna.

NO

TA

Todavía no he presentado el método Find de la colección de filas, pero es fácil de expli-car: sirve para buscar una fila dada su clave primaria. Si la encuentra, devuelve la refe-rencia al objeto, y si no, devuelve una referencia nula.

Pero esta técnica de modificación tan sencilla no es suficiente en determinadas cir-cunstancias. En una aplicación interactiva, es común mostrar los resultados de una búsqueda sobre una rejilla de sólo lectura, o un control similar. Cuando el usuario hace doble clic sobre una fila, debe aparecer un cuadro de diálogo con controles individuales para cada columna. Si no tenemos cuidado, los controles enlazados a columnas modificarán cada columna por separado... y una vez modificada una co-lumna, nos sería complicado arrepentirnos y regresar al valor anterior de la misma. Pero eso es precisamente lo que se espera que suceda si el usuario cancela la ejecu-ción del diálogo.

¿No podríamos volver al estado Original de la fila? No exactamente. Puede que la fila ya haya sufrido modificaciones desde el momento en que se leyó desde la base de datos. Regresar a los valores originales significaría descartar todos los posibles cam-bios efectuados en operaciones anteriores.

La solución a este problema consiste en permitir una versión tentativa adicional en cada fila. Para activar esa versión adicional, tenemos que ejecutar el método BeginEdit de la clase DataRow. Para desactivar la versión adicional, podemos seguir dos cami-nos: ejecutar EndEdit para aceptar los cambios, o ejecutar CancelEdit para descartar-los. El patrón que debemos seguir para llamar a estos métodos es el siguiente:

Page 195: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones en memoria 195

tbClientes.BeginEdit();

try {

// … modificar valores de columnas …

tbClientes.EndEdit();

}

catch {

tbClientes.CancelEdit();

throw;

}

Para comprender cómo funcionan estos métodos, lo más indicado es seguir la pista de una fila mediante un ejemplo sencillo. Supongamos que leemos una fila con datos de un cliente, con los siguientes valores:

<cliente codigo="-1" nombre="Ian" apellidos="Marteens" />

Podemos incluso suponer que hemos creado la fila manualmente, siempre que haya-mos tenido la precaución de ejecutar AcceptChanges sobre ella, de modo que su estado, almacenado en RowState, sea Unchanged. Preste atención a las aventuras de esta fila:

• Alguien cambia el nombre, de Ian a Juan. Me importa un pimiento si usa Begin-Edit, o si modifica directamente la columna. La fila pasará al estado Modified, y sus valores visibles serán los siguientes:

<cliente codigo="-1" nombre="Juan" apellidos="Marteens" />

• Ahora sí llamamos a BeginEdit, y modificamos tentativamente el apellido a Martí-nez. La fila permanece en el estado Modified, y si preguntamos por los valores actuales, obtendremos:

<cliente codigo="-1" nombre="Juan" apellidos="Martínez" />

• Sin embargo, llamamos a EndEdit, porque no nos convence el último cambio. El estado de la fila seguirá siendo Modified, y la fila mostrará los siguientes valores:

<cliente codigo="-1" nombre="Juan" apellidos="Marteens" />

No hemos regresado al nombre original, que sería Ian Marteens, sino a Juan Marteens. Si quisiéramos regresar al verdadero estado original, tendríamos que llamar al método RejectChanges.

Un repaso a las versiones de filas

Y ya estamos en condiciones de explicar todas las versiones de filas que podemos consultar. La versión Original no plantea problemas: si llamamos al indizador de Data-Row con Original como segundo parámetro o índice, obtendríamos el valor inicial de la columna:

dataRow["Nombre", DataRowVersion.Original]

Por cierto, si pedimos el valor original de una fila cuyo estado es Added, provocaría-mos una excepción, porque el estado original no existe para una fila nueva.

Page 196: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

196 La Cara Oculta de C#

La versión Current, por su parte, es la que veríamos mostrada en un control enlazado a la columna (o en una rejilla)... excepto que, si estamos dentro de un bloque BeginEdit/EndEdit, lo que veríamos en el control sería el valor correspondiente a la versión Proposed. la versión Current sería temporalmente “invisible”. Proposed, por su parte, sólo tendría sentido dentro de dicho tipo de bloque, y devolvería los nuevos valores tentativos asignados a las columnas.

Finalmente, Default es el valor que se asume cuando se llama al indizador con un solo parámetro:

dataRow["Nombre"]

Normalmente, Default devuelve los mismos valores que Current, pero si hemos lla-mado a BeginEdit, el valor devuelto es el de Proposed.

Eventos durante la actualización

Cuando se producen cambios en un conjunto de datos en memoria, la tabla afectada dispara algunos eventos:

Evento Se dispara... ColumnChanging Antes de modificar el valor de una columna ColumnChanged Una vez modificado el valor de una columna RowChanging Antes de modificar o insertar una fila RowChanged Una vez que se ha modificado o insertado la fila RowDeleting Antes de eliminar una fila RowDeleted Una vez que la fila ha sido eliminada

Al igual que en ADO clásico, estos eventos se ejecutan antes o después de un suceso. Por convenio, los eventos que utilizan el gerundio se disparan antes, y los que usan el verbo en participio pasado, son los que se disparan después. Por ejemplo, Column-Changing se dispara antes de que la columna reciba un nuevo valor, mientras que su pareja, ColumnChanged, se dispara cuando la columna ya tiene asignado ese valor. Tome nota de que, aunque el objeto que provoca el disparo del evento es una co-lumna, el evento pertenece a la tabla que la contiene. Algo parecido sucede con los cuatro eventos restantes: el origen de los eventos está en las filas, pero están defini-dos en la tabla.

Por lo general, los eventos previos a una operación se utilizan para controlar si se cumplen ciertas condiciones, y en caso negativo, abortar el curso normal de la opera-ción lanzando una excepción. Los eventos posteriores a la operación suelen usarse para dejar constancia de que la operación ha tenido lugar. Sin embargo, esta parte de ADO.NET está muy mal programada, especialmente en lo que respecta al trata-miento de excepciones.

Por ejemplo, se nos puede ocurrir utilizar el evento ColumnChanging para evitar valo-res incorrectos en una columna dada, como en este ejemplo:

Page 197: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones en memoria 197

// ATENCION: No funciona bien del todo

// Este método responde al evento ColumnChanging

private void ColumnaModificada(object sender,

DataColumnChangeEventArgs e)

{

if (e.Column.ColumnName == "Apellidos" &&

e.ProposedValue.ToString() == "Drácula")

throw new Exception("No se admiten vampiros");

}

El problema está en que el usuario no llega ver el mensaje, porque el código interno de ADO.NET, incomprensiblemente, se “traga” la excepción. Es cierto que se inte-rrumpe la asignación sobre la columna, y se conserva el valor inicial. Pero si estamos editando sobre una rejilla, el cursor se mueve y puede incluso abandonar la fila sin encontrar resistencia alguna. Este es uno de los motivos por los que detesto editar registros directamente sobre una rejilla de datos.

NO

TA

El problema descrito es una demostración fehaciente de cuánto se puede embotar el intelecto de una persona tras años de viciosa programación en Java o Visual Basic “clá-sico”... y un ejemplo de la cara oculta y perversa del trabajo en equipo. Anders Hejlsberg, el creador de C#, es un evangelista convencido del principio: “no captures una excepción si no tienes una solución”. Para disfrute de los observadores cínicos, alguien del equipo de ADO.NET no tenía tan clara esa regla y le ha colado este gol en propia puerta...

No existe, en estos momentos, una solución elegante para este problema. Tendremos que mostrar el mensaje de error nosotros mismos, y eso significa mezclar operacio-nes de presentación visual con reglas de negocios. Tenga en cuenta que la instrucción throw no muestra por sí misma nada en pantalla; si esta parte de ADO.NET estu-viese bien programada, el mensaje de error aparecería cuando el bucle de mensajes, de forma centralizada, capturase la excepción. Y seguiríamos teniendo problemas con el movimiento del registro activo en una rejilla de datos... de modo que tendría-mos nuevamente que mezclar el manejo del control con las reglas de negocio.

Asociación de errores a filas y columnas

ADO.NET ofrece un mecanismo adicional para el control de errores que, aunque sé que a muchos le parecerá poco, permite disimular un poco los problemas antes ex-plicados. Analice el siguiente método, que debe ejecutarse en respuesta al evento ColumnChanged de la tabla de clientes:

private void ColumnaModificada(object sender,

DataColumnChangeEventArgs e)

{

if (e.Column.ColumnName == "Apellidos" &&

e.ProposedValue.ToString() == "Drácula")

{

string msg = "No se admiten vampiros.";

e.Row.SetColumnError(e.Column, msg);

MessageBox.Show(msg, "Error", MessageBoxButtons.OK,

MessageBoxIcon.Exclamation);

}

}

Page 198: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

198 La Cara Oculta de C#

Hemos eliminado la instrucción que lanzaba la instrucción, y la hemos sustituido por una llamada al método estático Show de la clase MessageBox, para mostrar un mensaje en pantalla. Esto significa que sí admitiremos, al menos de forma temporal, que uno de nuestros clientes tiene un apellido prohibido. Pero antes de mostrar el mensaje, ejecutamos esta instrucción:

e.Row.SetColumnError(e.Column, msg);

El método SetColumnError, de la clase DataRow, permite asociar un mensaje de error a una columna de una fila determinada. Cuando mostremos la fila en una rejilla, la columna que contiene el error mostrará un icono informativo como en la siguiente imagen:

Si pasamos el cursor sobre el icono, aparecerá el mensaje de error asociado. El men-saje puede recuperarse desde la aplicación llamando al método GetColumnError, tam-bién perteneciente a DataRow, y lo más interesante es que estos errores se copian en la representación del conjunto de datos en formato XML. Y si queremos saber si una fila tiene algún mensaje de error en alguna de sus columnas, podemos verificar el valor de su propiedad HasErrors.

Bueno, ¿y para qué nos sirven estos errores? Si se fija en la imagen anterior, verá que la fila activa no es la fila que contiene el error; la presencia del error no ha impedido ni la asignación del valor incorrecto, ni el cambio de fila activa en la rejilla. Cuando se asocian errores a columnas o filas, la idea es permitir la acumulación de varios erro-res. ¿Qué sentido tiene exigir al usuario que todo lo que teclee sea correcto en todo momento? Si el usuario no conoce el apellido del cliente, puede seguir añadiendo y retocando registros, siempre que, antes de guardar los cambios, se ocupe de eliminar o corregir los registros con problemas. Nuestra responsabilidad es verificar, antes de guardar el contenido del conjunto de datos como fichero XML, o antes de sincroni-zar los cambios con la base de datos, si existen errores en el conjunto de datos. En ese momento utilizaríamos la propiedad HasErrors, definida en la tabla y en el con-junto de datos, y si ésta valiese true, no permitiríamos la operación.

NO

TA

Se puede asignar también un error a la fila en sí, mediante la propiedad RowError, de la clase DataRow. Cuando una rejilla muestra una fila con errores, también muestra un icono de advertencia, esta vez en la columna fija del borde izquierdo.

Page 199: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones en memoria 199

Deshaciendo los cambios

Los eventos relacionados con las actualizaciones en memoria pueden aprovecharse para mantener una estructura de datos que nos permita deshacer las operaciones realizadas, una a una. Para ello, debemos almacenar en una pila los cambios que se vayan aplicando sobre la tabla. Para demostrar la técnica, vamos a crear un compo-nente que implemente la manera más sencilla de seguir la pista a estos cambios. Crea-remos una clase auxiliar, a la que llamaremos ChangeLog, y haremos que descienda de la clase Component, del espacio de nombres System.ComponentModel:

public class ChangeLog: System.ComponentModel.Component

{

protected Stack filas = new Stack();

protected bool undoing = false;

private DataTable dataTable = null;

protected DataRowChangeEventHandler rowChangeDelegate;

public ChangeLog()

{

rowChangeDelegate =

new DataRowChangeEventHandler(DataRowChanged);

}

public bool CanUndo()

{

return filas.Count > 0;

}

// …

}

La clase internamente contiene una pila de objetos, y cada instancia debe conectarse a una tabla. Cuando asociemos una tabla a una instancia de ChangeLog, ésta reaccio-nará añadiendo un método de escucha para los eventos RowChanged y RowDeleted de la primera. En el momento en que se rompa el enlace entre la tabla y el componente de control de cambios, deberemos retirar el manejador de los eventos mencionados. Esta operación es responsabilidad de la propiedad DataTable:

public System.Data.DataTable DataTable {

get { return dataTable; }

set {

if (dataTable != value) {

if (dataTable != null) {

dataTable.RowChanged -= rowChangeDelegate;

dataTable.RowDeleted -= rowChangeDelegate;

}

dataTable = value;

if (dataTable != null) {

dataTable.RowChanged += rowChangeDelegate;

dataTable.RowDeleted += rowChangeDelegate;

}

}

}

}

Page 200: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

200 La Cara Oculta de C#

La implementación del método que se llama en respuesta a estos eventos es muy sencilla, porque se limita a añadir la fila afectada a la pila interna:

protected void DataRowChanged(

object sender, DataRowChangeEventArgs e) {

if (! undoing) filas.Push(e.Row);

}

La variable undoing se utiliza en este método y en el método Undo para evitar la recur-sividad infinita. Este último método es quien se encarga de deshacer el último cam-bio realizado sobre la tabla:

public void Undo() {

if (filas.Count == 0) return;

undoing = true;

try {

DataRow dataRow = (DataRow) filas.Pop();

dataRow.RejectChanges();

}

finally {

undoing = false;

}

}

Para probar la clase, puede crear una instancia de la misma durante la inicialización de un formulario, y asignar en la propiedad DataTable de la nueva instancia una refe-rencia a una tabla. No olvide añadir un botón para que llame al método Undo al ser pulsado. Le advierto que la implementación de la clase es la más sencilla que se me ocurrió, por lo que encontrará algunas anomalías en su funcionamiento. Por ejemplo, si realiza dos cambios en una misma fila, en momentos diferentes, al deshacer el segundo cambio estará deshaciendo también el primero. La solución consistiría en afinar el almacenamiento de cambios. Deberíamos interceptar también el evento ColumnChanged y almacenar, junto con la referencia a la fila, el valor original de la columna que se ha modificado.

Otro problema de diseño es que, para este tipo de componentes, resulta imprescin-dible mover la posición de la fila activa al lugar donde se han deshecho los cambios, para no confundir al usuario. Pero para añadir esta característica necesitamos conocer detalles importantes de las técnicas de enlace a datos, que hemos dejado para el ca-pítulo 16.

Finalmente, en la vida real habría que contemplar la posibilidad de tener varias tablas en un mismo conjunto de datos, como estudiaremos en el próximo capítulo. En tal caso, deberíamos mantener una lista de cambios común para todas las tablas del conjunto de datos, con el objetivo de respetar el orden relativo de las modificaciones.

Page 201: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

14

Jerarquías de datos

EMOS VISTO QUE UN CONJUNTO DE DATOS PUEDE contener simultáneamente varias tablas. Estas tablas, normalmente, no tienen una existencia indepen-

diente, pues lo habitual es establecer distintos tipos de relaciones entre ellas. De esta manera, se puede representar información con estructura jerárquica: pedidos y líneas de detalles, clientes y sus pedidos...

Relaciones

Partiremos del conjunto de datos con la tabla de clientes del capítulo anterior, pero complicaremos un poco el panorama. ¿Qué tal si queremos guardar varias direccio-nes para cada cliente? Para ello, comenzaremos por definir una segunda tabla dentro del mismo conjunto de datos:

// Creamos la tabla de direcciones asociadas al cliente

DataTable tbDirs = dataSet.Tables.Add("Direcciones");

DataColumn c5, c6, c7, c8;

// El código del registro de dirección

c5 = tbDirs.Columns.Add("Codigo", typeof(int));

c5.AllowDBNull = false;

c5.ReadOnly = true;

c5.AutoIncrement = true;

c5.AutoIncrementStep = -1;

c5.AutoIncrementSeed = 0;

// Este es el código del cliente

c6 = tbDirs.Columns.Add("Cliente", typeof(int));

c6.AllowDBNull = false;

// El código postal

c7 = tbDirs.Columns.Add("C.P.", typeof(string));

c7.AllowDBNull = false;

c7.MaxLength = 15;

// El nombre de la ciudad

c8 = tbDirs.Columns.Add("Ciudad", typeof(string));

c8.AllowDBNull = false;

c8.MaxLength = 30;

Si nos ceñimos a las instrucciones anteriores, obtendremos como resultado dos ta-blas independientes. Pero eso lo resolveremos ya:

// Relacionar clientes y restricciones

dataSet.Relations.Add("DirsXCli", c1, c6);

H

Page 202: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

202 La Cara Oculta de C#

Al igual que la propiedad Tables del conjunto de datos contiene una colección de objetos DataTable, la propiedad Relations contiene una colección de objetos pertene-cientes a la clase DataRelation. El método Add crea y añade una definición de relación a la colección. Esta versión exige que pasemos un nombre para la relación, la co-lumna maestra y la columna correspondiente en la tabla de detalles. Observe que no ha sido necesario indicar las tablas involucradas en la relación, porque se pueden identificar a partir de las propias columnas. En este ejemplo, todavía teníamos a mano las variables donde almacenamos las referencias a las columnas cuando las creamos. De no tener estas variables a mano, podríamos haber creado la relación mediante esta instrucción, bastante más extensa, pero quizás más comprensible:

// … si no nos quedamos con las referencias a las columnas …

dataSet.Relations.Add("DirsXCli",

dataSet.Tables["Clientes"].Columns["Codigo"],

dataSet.Tables["Direcciones"].Columns["Cliente"]);

NO

TA

Es posible crear relaciones sin asignarles un nombre, pero no es aconsejable. El nombre de la relación puede utilizarse más adelante para localizar las filas de detalles que corres-ponden a un registro maestro, para mostrar en una rejilla independiente sólo los registros de detalles de un registro maestro activo... e incluso para destruir la relación cuando se harte de trabajar con ella.

El tipo de relación más común, al menos en mis proyectos, utiliza una sola columna en cada tabla; recuerde, de los capítulos sobre Transact SQL, que no me gustan las claves compuestas. Pero si no me ha hecho caso, y ha creado una tabla de cuentas bancarias con la clave compuesta, sepa que también se permiten relaciones que afec-ten a más de una columna:

DataColumn m1, m2, d1, d2;

// Columna de la tabla maestra (Sucursales)

m1 = cuentasDS.Tables["Sucursales"].Columns["Banco"];

m2 = cuentasDS.Tables["Sucursales"].Columns["Sucursal"];

// Columna de la tabla de detalles (Cuentas)

d1 = cuentasDS.Tables["Cuentas"].Columns["Banco"];

d2 = cuentasDS.Tables["Cuentas"].Columns["Sucursal"];

// Creamos la relación, pasando vectores de columnas

cuentasDS.Relations.Add("CuentasXSucursal",

new DataColumn[] { m1, m2 },

new DataColumn[] { d1, d2 });

Otro tipo de “complicación”, que esta vez sí contaría con mi bendición, consiste en tener más de una relación con la misma tabla maestra, o varios niveles de relaciones: la tabla de clientes podría relacionarse con la tabla de direcciones y la tabla de pedi-dos. La tabla de pedidos, a su vez, estaría relacionada con la tabla de detalles... y así sucesivamente, hasta que los monjes de una pagoda cercana a Hanoi terminen su insensato juego de mover discos de una torre a otra, se manifieste Maitreya, el Budha de los tiempos finales y esta Era de Tinieblas llegue a su fin, con las gallinas de Jericó cantando a coro, en el fondo del escenario, un aria de una ópera de Wagner.

Page 203: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Jerarquías de datos 203

Rejillas y jerarquías

¿Qué tal se vería la relación sobre una rejilla? En los capítulos anteriores enlazábamos la rejilla directamente con una tabla del conjunto de datos por medio de instrucciones como la siguiente:

dataGrid1.SetDataBinding(tbClientes, "");

Si respetamos esa configuración, el resultado se parecerá al siguiente:

La rejilla sigue mostrando los registros de clientes, pero ahora cada fila tiene un árbol asociado... sí, es un árbol, aunque ello no pueda deducirse de este sencillo caso. Al expandir el árbol, se mostrarían enlaces a las tablas dependientes; observe que el enlace se identifica mediante el nombre de la relación. Si pulsásemos alguno de estos detalles, podríamos manipular los registros de direcciones correspondientes:

En primer lugar, compruebe que solamente se muestran las direcciones del cliente cuyo enlace hemos seguido. Hay también una nueva fila, diferente a las demás, que muestra los valores de la fila maestra. Finalmente, en la esquina superior derecha del control hay unos dibujos crípticos, que más bien parecen escritura cuneiforme:

Muestra/oculta la fila maestraRegresar a la fila maestra

En realidad se trata de botones: uno para esconder o revelar la fila que representa al registro maestro, y otro para regresar a la vista inicial con los registros de clientes.

Page 204: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

204 La Cara Oculta de C#

Vamos a experimentar un poco. Cambiemos la instrucción de enlace a datos:

dataGrid1.SetDataBinding(dataSet, "");

En vez de enlazar directamente la rejilla a una de las tablas, la hemos enlazado al conjunto de datos “en general”. Y el resultado es éste:

Si pulsamos el enlace Clientes, volveremos a la vista que ya conocemos: todos los registros de clientes, con enlaces a sus direcciones. Pero si seguimos el enlace de las direcciones, veremos todos los registros de direcciones, sin importar el cliente. Ade-más, no tendríamos un enlace para encontrar el cliente, aunque es algo comprensible: la relación entre las tablas no es simétrica.

Debo reconocer que, aunque esta rejilla es muy potente, no creo que sea un buen control para dejar en manos de un usuario como los suyos o los míos... y no es un comentario peyorativo sobre el C.I. de la sufrida tropa: confieso que yo mismo me siento a veces perdido navegando al estilo DataGrid. ¿Podríamos configurar dos reji-llas, por separado? En una de ellas veríamos los registros de clientes, y en la segunda, las direcciones que correspondiesen al cliente activo en la primera de ellas:

Este no es el momento para entrar en las sutilezas del enlace a datos en C#, pero voy a adelantarle los pasos necesarios. Como es lógico, necesitamos una segunda rejilla, a la que llamaremos dataGrid2. La propiedad Dock de la primera rejilla valía Fill, para que ocupase toda el área libre que pudiera. En la propiedad Dock de la segunda, asig-naremos el valor Bottom, para anclarla a la parte inferior del formulario. Si está de vena artística, puede añadir un control Splitter, para controlar el tamaño de la rejilla de direcciones. Debe cambiar también Dock en el splitter o separador a Bottom.

Page 205: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Jerarquías de datos 205

Para evitar esos confusos enlaces en la rejilla superior, hay que cambiar a false el valor de la propiedad AllowNavigation del control. Finalmente, estas son las instruc-ciones necesarias para establecer el modo deseado de enlace a datos en tiempo de ejecución:

dataGrid1.SetDataBinding(dataSet, "Clientes");

dataGrid1.SetDataBinding(dataSet, "Clientes.DirsXCli");

El truco consiste en indicar un DataMember compuesto en la rejilla de detalles. Ob-serve que me he visto obligado a cambiar la forma de enlazar la primera rejilla: en vez de asignar directamente la tabla en la propiedad DataSource, me he escapado por un desvío, indicando el conjunto de datos completo en DataSource y afinando la puntería con la ayuda de DataMember. No puedo explicar el motivo todavía, pero debe saber que ADO.NET es muy tiquismiquis con estas cosas, y si cambiamos el enlace de la rejilla superior, es muy probable que las rejillas pierdan la coordinación. Al sistema de enlace a datos de Windows Forms hay que explicarle con mucha clari-dad cuál es la relación existente entre los dos controles, y si no utilizamos parámetros literalmente iguales, puede que no nos comprenda.

Claves externas y restricciones

Hagamos un experimento: cree un registro de cliente, y añádale una dirección. Si-túese entonces en el registro de dirección y modifique el valor de la columna Cliente. con un número que no represente a ningún cliente. Como nuestras claves primarias son valores secuenciales descendentes, cualquier número positivo nos vendrá bien. Prepárese entonces a recibir un mensaje de error como éste:

¿Otro experimento? Intercepte el evento Load del formulario:

private void MainForm_Load(object sender, System.EventArgs e)

{

string rslt = "";

foreach (DataTable tab in dataSet.Tables)

foreach (Constraint cons in tab.Constraints)

rslt += String.Format("{0}.{1}: {2}\n",

tab.TableName, cons.ConstraintName,

cons.GetType().Name);

MessageBox.Show(rslt);

}

El código anterior recorre las tablas del conjunto de datos, y para cada tabla, recorre su colección de restricciones. Para cada restricción encontrada, se añade una línea a la variable local rslt con el nombre de la tabla, el de la colección, y el de la clase concreta

Page 206: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

206 La Cara Oculta de C#

a la que pertenece la colección. Luego se muestra en pantalla el contenido de la va-riable acumuladora. Este es el resultado:

No sé si usted lo ha hecho por su cuenta, pero yo no he creado ninguna de estas restricciones... al menos explícitamente. Lo que sí hemos creado es una relación, que no es lo mismo que una restricción y, claro está, ha sido la relación la que ha creado las dos restricciones: una restricción de unicidad en la tabla maestra, la de clientes, y una clave externa en la tabla de direcciones. En el primer caso, la restricción de unicidad ha recibido un nombre generado automáticamente. En el caso de la clave externa, el nombre que recibe la restricción es el mismo que hemos dado a la relación. Tenga en cuenta que si hubiésemos creado una restricción de unicidad o una clave primaria en la tabla de clientes antes de definir la relación, esta última habría podido reutilizar la clave única existente. Para demostrarlo, añada la siguiente instrucción en el cons-tructor del formulario, antes de crear la relación entre las tablas:

tbClientes.Constraints.Add("PK", c1, true);

Las restricciones que ahora encontraremos serán éstas:

¿Por qué se ha creado una restricción a partir de la relación? La respuesta está en la variante del método Add que apliquemos sobre la propiedad Relations del conjunto de datos. La variante que hemos utilizado hasta el momento tiene el siguiente prototipo:

public virtual DataRelation Add(string nombre,

DataColumn columnaMaestra, DataColumn columnaDetalle);

Cuando usamos esta versión de Add, se asume que queremos crear también la res-tricción. Si quisiéramos evitarlo, deberíamos usar otra versión:

public virtual DataRelation Add(string nombre,

DataColumn columnaMaestra, DataColumn columnaDetalle,

bool crearRestricciones);

Puede que nos interese no crear las restricciones en algunos casos. Las restricciones se activan cuando hay modificaciones en las tablas involucradas. Pero si vamos a

Page 207: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Jerarquías de datos 207

utilizar un conjunto de datos sólo para lecturas, no tiene sentido perder el tiempo añadiendo reglas que no vamos a aprovechar.

Acciones referenciales en ADO.NET

Otra de las muchas posibilidades que nos ofrece ADO.NET es crear manualmente restricciones que simulen claves externas. La clase ForeignKeyConstraint, que desciende de la clase abstracta Constraint, se utiliza para definir este tipo de claves. Ya sabemos que se puede crear una relación sin que se cree una restricción. Lo inverso es también cierto: podemos definir restricciones de claves externas sin forzar una relación entre las tablas. Podríamos tener una tabla de clientes y una de direcciones sin relación entre ellas, pero con una restricción de clave externa definida. No podríamos navegar de forma fácil de una tabla a la otra, pero al editar una dirección se comprobaría si el cambio podría afectar a la restricción.

De todos modos, no es común tener restricciones sin la correspondiente relación. El motivo más frecuente para crear manualmente la restricción de clave externa es la configuración de algunas de las propiedades de la restricción, en concreto, aquellas propiedades que simulan las acciones referenciales que ya presentamos en los capí-tulos sobre Transact SQL. Las tres propiedades pertenecen a tipos enumerativos, y son las siguientes:

Propiedad Valores aceptados AcceptRejectRule AcceptRejectRule.None, AcceptRejectRule.Cascade DeleteRule Rule.None, Rule.Cascade, Rule.SetNull, Rule.SetDefault UpdateRule Rule.None, Rule.Cascade, Rule.SetNull, Rule.SetDefault

El papel de UpdateRule y DeleteRule es fácil de explicar y comprender, porque es idén-tico al de las acciones referenciales en el estándar de SQL. Ambas propiedades tienen la constante Cascade como valor por omisión.

La propiedad AcceptRejectRule, por su parte, determina lo que sucede cuando llama-mos a los métodos AcceptChanges o RejectChanges sobre una fila maestra; recuerde que estos métodos confirman o descartan los cambios realizados sobre una fila, una tabla o todo el conjunto de datos. Si el valor de esta propiedad es None, su valor por omi-sión, la confirmación o rechazo no se propaga a las tablas o filas de detalles. Para que tenga lugar la propagación tenemos que asignar Cascade en la propiedad mencionada.

Navegación sobre la jerarquía

Comenzamos este capítulo presentando la técnica para crear relaciones entre tablas de un mismo conjunto de datos. Luego, pegamos un salto y vimos cómo representar o navegar sobre la relación mediante un DataGrid. Pero todavía no hemos visto cómo se las arregla la rejilla para localizar las direcciones asociadas a un cliente de-terminado, o para encontrar el cliente al que pertenece cierta dirección.

Page 208: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

208 La Cara Oculta de C#

Para localizar las filas de detalles asociadas a una fila maestra, contamos con el mé-todo GetChildRows, declarado en la clase DataRow: El método en cuestión tiene cuatro versiones sobrecargadas, pero sólo nos ocuparemos por el momento de dos de ellas:

public DataRow[] GetChildRows(string relacion);

public DataRow[] GetChildRows(DataRelation relacion);

En un caso, pasamos el nombre de la relación como una cadena de caracteres, y en el otro, pasamos directamente el objeto que define la relación. En ambos casos, se de-vuelve un vector con las filas dependientes asociadas a la fila maestra sobre la que hemos aplicado el método. Vamos a demostrar cómo se pueden mostrar todas las filas de clientes y direcciones dentro de un control RichTextBox. Para no complicar-nos excesivamente la vida, es recomendable tener un método auxiliar a mano, como el siguiente:

private string Fila(DataRow dr) {

string rslt = "";

foreach (DataColumn dc in dr.Table.Columns) {

if (rslt != "") rslt += ", ";

rslt += dr[dc];

}

return rslt;

}

Pero el código que nos interesa es el siguiente:

foreach (DataRow dr1 in dataSet.Tables["Clientes"].Rows)

{

richTextBox1.AppendText(Fila(dr1) + "\n");

foreach (DataRow dr2 in dr1.GetChildRows("DirsXCli"))

richTextBox1.AppendText(" " + Fila(dr2) + "\n");

}

Si quiere probarlo, añada un RichTextBox y un botón al formulario, y copie el bucle anterior dentro de la respuesta al evento Click del botón.

NO

TA

Antes mencioné que existían cuatro versiones de GetChildRows, pero sólo he mostrado dos de ellas. Las dos versiones restantes permiten pasar un valor DataRowVersion, para devolver solamente las filas que estén en determinado estado de actualización.

Si GetChildRows nos permite navegar “hacia abajo”, la operación inversa, que sería navegar de una fila de detalles a su fila maestra, se consigue con el método GetParent-Row. Nuevamente hay cuatro variantes del método:

public DataRow GetParentRow(string);

public DataRow GetParentRow(DataRelation);

public DataRow GetParentRow(string, DataRowVersion);

public DataRow GetParentRow(DataRelation, DataRowVersion);

Si nos pidiesen localizar un cliente dada su dirección, podríamos comenzar la bús-queda por la tabla de direcciones, sin tener en cuenta la relación. Al encontrar el registro de dirección, podríamos usar GetParentRow para llegar al registro del cliente, que era nuestro objetivo original.

Page 209: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Jerarquías de datos 209

Expresiones que aprovechan la jerarquía

¿A que da gusto tener funciones como GetChildRows y GetParentRow... sobre todo cuando podemos combinarlas con características del lenguaje tan útiles como el bu-cle foreach? Pero no se lance como un poseso a usar estos métodos a diestra y si-niestra. En muchos casos, será más sencillo aprovechar la potencia que ofrece el evaluador de expresiones de ADO.NET.

Para poder mostrar ejemplos que merezcan la pena, voy a utilizar un esquema rela-cional más complejo que el que hemos venido usando; en realidad, voy a copiar di-cho esquema de la base de datos Northwind. Para no tener que crear manualmente todas las columnas, he creado ficheros XML con el esquema y con las filas necesa-rias, para que los descargue de la página de ejemplos del libro.

De todos modos, aquí tiene la descripción de las tablas que he copiado:

Observe que la tabla Order Details, cuyo nombre he cambiado a Details dentro del conjunto de datos, tiene dos “padres”: los productos y los pedidos. Estas son las relaciones con las que he conectado las cuatro tablas:

dataSet.Relations.Add( "CustomerOrders",

dataSet.Tables["Customers"].Columns["CustomerID"],

dataSet.Tables["Orders"].Columns["CustomerID"]);

dataSet.Relations.Add( "OrderDetails",

dataSet.Tables["Orders"].Columns["OrderID"],

dataSet.Tables["Details"].Columns["OrderID"]);

dataSet.Relations.Add( "ProductDetails",

dataSet.Tables["Products"].Columns["ProductID"],

dataSet.Tables["Details"].Columns["ProductID"]);

NO

TA

Tiene usted razón: es una burrada digna de Platero traerse todas las filas de estas cuatro tablas nada más iniciar la aplicación. Pero le suplico que tenga piedad de mí: se trata sólo de un ejemplo, y no estaba en mis manos mostrar, en este capítulo, cómo restringir el conjunto de filas a recuperar. Para hacerlo necesitaríamos estar trabajando ya con las clases “conectadas” de ADO.NET.

Y vamos ya con las columnas que añadiremos al ejemplo. Para ir haciendo boca, suponga que queremos saber cuántas líneas de detalles hay en cada pedido; la co-lumna debe añadirse a la tabla de pedidos, por supuesto. En ese caso, podemos crear una columna como la siguiente:

Page 210: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

210 La Cara Oculta de C#

dataSet.Tables["Orders"].Columns.Add("Items", typeof(int),

"count(Child.OrderID)");

La expresión utiliza, en primer lugar, la función estadística count, con el mismo sig-nificado que tendría en Transact SQL. La función debe recibir como parámetro una columna de la tabla de detalles... pero estamos en la tabla de pedidos, ¿verdad? Para eso tenemos la posibilidad de agregar el prefijo Child para cualificar un nombre de columna:

Child.OrderID

Claro, hay una sola tabla que es “hija” de la tabla de pedidos, pero ¿y si hubiera va-rias? En ese caso, Child podría utilizarse como si se tratase de una función, pasando como parámetro la relación específica que nos interesa:

Child(OrderDetails).OrderID

Estas son las funciones de conjuntos que reconoce el evaluador de expresiones:

Funciones Significado count Cantidad min, max Mínimo, máximo sum, avg Suma, promedio stdev, var Desviación estándar, varianza

Demasiado bueno para ser cierto... y efectivamente, tengo una mala noticia: no se le ocurra crear una columna Total en la tabla de pedidos con esta expresión:

// ¡¡¡Expresión incorrecta!!!

sum(Child.UnitPrice * Child.Quantity * (1 - Child.Discount ))

Por desgracia, las funciones estadísticas sólo permiten una columna como argu-mento, y por suerte, la gente lista como usted y como yo, siempre encontramos un rodeo para no embarrarnos los pies. Divide y vencerás: ¿qué tal si primero creamos un Subtotal en la tabla de detalles?

dataSet.Tables["Details"].Columns.Add("SubTotal", typeof(decimal),

"UnitPrice * Quantity * (1 - Discount)");

Luego, podemos crear la columna que nos interesa en la tabla de pedidos:

dataSet.Tables["Orders"].Columns.Add("ItemsTotal", typeof(decimal),

"sum(Child.SubTotal)");

A estas alturas, usted se estará preguntando para qué demonios tuvimos que traer la tabla de productos. Bien, mire este truco:

dataSet.Tables["Details"].Columns.Add("Product", typeof(string),

"Parent(ProductDetails).ProductName");

En la tabla de detalles sólo teníamos los códigos de productos, y sería preferible conocer el nombre de los productos vendidos. Pero tenemos una tabla de productos a mano, y en su momento creamos una relación que tenía a Products como padre, y a

Page 211: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Jerarquías de datos 211

Details como hija. Al igual que el evaluador permite el prefijo Child, también soporta el prefijo Parent. En este ejemplo, la tabla de detalles actúa como hija en dos relacio-nes diferentes, y nos vemos obligados a precisar a cuál de los padres nos referimos:

Parent(ProductDetails).ProductName

Preste atención a un bonito detalle en la imagen anterior: a pesar de que la columna Product es una columna calculada, podemos ordenar el contenido de la tabla por la misma. Por supuesto, todavía no he explicado como ordenar registros en un con-junto de datos...

El método Compute

Es muy conveniente poder utilizar funciones de conjuntos, como mostramos en la sección anterior. Sin embargo, la técnica que conocemos sólo nos permite calcular el valor de una de estas funciones cuando la tabla sobre la que se aplica es una tabla de detalles, porque nos obliga a crear una columna calculada en la correspondiente tabla maestra. Aunque esto no es técnicamente obligatorio, hacer lo contrario va en contra del sentido común. Suponga que estamos mostrando, sobre una rejilla, la tabla de todos los pedidos. Podemos añadir una columna calculada para esta tabla:

dataSet.Tables["Orders"].Columns.Add(

"FreightAvg", typeof(string), "avg(freight)");

La columna debe calcular la media del campo Freight, que almacena el coste de envío de cada pedido. Lo normal sería que la tabla de pedidos fuese un detalle de la tabla de clientes (¡en nuestro ejemplo no lo es!), y que añadiésemos esta columna en la tabla maestra de clientes, con esta expresión:

avg(Child.Freight)

Por supuesto, en ese caso estaríamos calculando la media del coste por separado, para cada cliente, no la media de todos los envíos. Eso es lo que hemos querido evi-tar creando la columna calculada directamente sobre la tabla de pedidos... pero la si-guiente imagen muestra lo que ocurrirá si no tenemos cuidado:

Page 212: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

212 La Cara Oculta de C#

Efectivamente: la media se calcula correctamente, pero el valor de la columna se muestra en cada fila, como era de esperar. Para estos casos tenemos una alternativa: utilizar el método Compute de la clase DataTable.

public object Compute(string expresion, string filtro);

Podemos interceptar el evento ColumnChanged de la tabla, para que cada vez que cambie el valor de la columna Freight, se vuelva a calcular el valor medio de esta co-lumna y se muestre el resultado en donde le parezca más apropiado:

private void tbOrders_ColumnChanged(

object sender, DataColumnChangeEventArgs e)

{

if (e == null || e.Column.ColumnName == "Freight") {

decimal media = (decimal)

dataSet.Tables["Orders"].Compute("avg(freight)", "");

dataGrid.CaptionText =

String.Format("Coste medio de envío: {0:C}", media);

}

}

Para nuestro ejemplo, he decidido mostrar el coste medio en la barra de títulos de la rejilla, y el resultado es el que aparece en la siguiente imagen:

Observe que he modificado los gastos de envío de algunos pedidos para que el coste medio sea menor.

Page 213: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

15

Búsquedas, filtros y ordenación

NA APLICACIÓN BIEN PROGRAMADA SÓLO LEE del servidor las filas que estric-tamente necesita el usuario. Por el contrario, al trabajar con bases de datos de

escritorio era común recuperar una tabla entera de golpe, para luego filtrar dinámi-camente sus registros, o cambiar el orden de ellos según fuese necesario. Trasladar esta metodología de trabajo al mundo de las bases de datos SQL es una receta segura para el fracaso.

No obstante, las operaciones de filtrado y ordenación dinámicos siguen teniendo sentido en este mundo moderno. Aunque sólo tengamos veinte filas en una rejilla, el ordenar su contenido puede servir de ayuda al usuario para ciertos patrones en los datos. En este capítulo estudiaremos los recursos de ADO.NET que permiten buscar filas, ordenarlas y filtrarlas una vez que las hemos leído dentro de un conjunto de datos en memoria.

Búsquedas por la clave primaria

Sabemos que todas las filas de un conjunto de datos están disponibles en la propie-dad Rows de la clase DataTable. Sabemos que podemos acceder a cualquiera de estas filas indexando la propiedad con un número entero, con la posición de la fila dentro de la colección. Pero, ¿qué utilidad puede tener este tipo de acceso, excepto cuando queremos recorrer todas las filas, de arriba abajo? En el modelo relacional, lo más importante es poder acceder a un registro a partir de su clave primaria. Y esa es la función del método Find, la clase DataRowCollection, buscar una fila dado el valor de su clave primaria.

Como hay claves primarias simples y compuestas, existen dos versiones de Find:

public DataRow Find(object);

public DataRow Find(object[]);

En ambos casos, el método debe devolver la fila, si es que logra encontrarla, o el puntero vacío null, en caso contrario. En la primera variante, Find acepta cualquier valor, al especificar el parámetro con el tipo object. Recuerde que, cuando pasemos un tipo simple, como un valor entero, tendrá lugar el boxing, o empaquetamiento, y el valor será encapsulado dentro de una clase especial que replique las operaciones admitidas por el tipo escalar original.

U

Page 214: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

214 La Cara Oculta de C#

Por último, la segunda versión de Find permite lidiar con claves compuestas, al admi-tir una lista de objetos o valores arbitrarios. Suponiendo que tblSucursales contiene una tabla de oficinas o sucursales bancarias, y que su clave primaria está formada por un código de banco y otro de sucursal, la siguiente instrucción buscaría la sucursal a partir de dichos códigos:

DataRow row;

row = tblSucursales.Rows.Find(new object[] {codBanco, codSucursal});

Es fácil ver el peligro de esta variante de la operación: si hay claves compuestas, de-bemos estar muy seguros del orden de las columnas que forman la misma.

Si lo que desea es saber si exista una fila con determinada clave primaria, puede usar el método Contains, de la colección de filas, que en vez de devolver la referencia a la fila, devuelve un valor de tipo lógico:

if (tblSucursales.Rows.Contains(

new object[] {codBanco, codSucursal}) {

// … la sucursal indicada existe …

}

Selección mediante filtros

Es poco probable que una operación de búsqueda iniciada por un usuario haga refe-rencia a una clave primaria. En mis propias aplicaciones, por ejemplo, todas las tablas tienen una clave primaria artificial, de tipo numérico y autoincremental. El usuario no tiene necesidad alguna de conocer cuál es el identificador de determinada fila. Es más probable que busque productos con cierto nombre, o con un precio determinado. Este razonamiento es correcto incluso en el caso en que el usuario ha exigido la existencia de un código de producto... porque ese código, al menos de acuerdo con mi estilo de diseño, sería una columna distinta de la clave primaria, marcada con el atributo unique.

Para estos casos, las tablas ofrecen el método Select; esta vez, el método se aplica directamente sobre un DataTable, en vez de actuar sobre su colección de filas. El método tiene unas cuantas versiones sobrecargadas, y todas ellas devuelven un vector con referencias a las filas que cumplen con los criterios deseados. La versión más sencilla de Select, por ejemplo, no tiene argumentos, y devuelve todas las filas de la tabla. La siguiente versión, en orden creciente de complejidad, es más útil, porque recibe, en su único parámetro, una cadena de caracteres con una condición de bús-queda:

public DataRow[] Select(string filtro);

Las expresiones de filtros se basan en la misma sintaxis de las expresiones asociadas a columnas calculadas. Naturalmente, la principal diferencia es que se exige que una expresión de filtro devuelva un valor lógico, y para ello, más tarde o más temprano, tendremos que utilizar operadores de comparación. Los ejemplos más simples de

Page 215: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Búsquedas, filtros y ordenación 215

filtros son los que se limitan a los seis operadores básicos de comparación, junto con los tres operadores lógicos elementales:

(City = 'Paris' or Country <> 'France') and ContactTitle = 'Owner'

Es curioso, sin embargo, que no haya una forma directa de averiguar si una columna contiene un nulo o no. Para ello, tenemos que utilizar la función isnull:

IsNull(Region, 'Imposible') = 'Imposible'

La función isnull del lenguaje de filtros se comporta igual que la función del mismo nombre en Transact SQL. Si el primer argumento no es nulo, ese será el valor de-vuelto; en caso contrario, se retorna el valor del segundo argumento. En el ejemplo anterior, tuvimos que idear un valor “imposible”, de modo que si el resultado de isnull es ese valor, sabemos a ciencia cierta que la columna Region contiene un nulo.

También podemos usar el operador like, aunque con algunas restricciones:

ContactName like 'Mar%'

La restricción que he mencionado consiste en que el comodín % sólo puede ser usado al principio o al final del patrón de búsqueda.

Las constantes de fechas deben encerrarse entre almohadillas y, muy importante, deben escribirse en formato americano, poniendo el mes antes que el día:

OrderDate >= #01/01/2000#

OrderDate >= #7/15/1996#

Ordenando las filas

La siguiente versión del método Select permite, además de filtrar registros, ordenar el resultado respecto al valor de una o más columnas:

public DataRow[] Select(string filtro, string orden);

Nuevamente, el primer parámetro es una expresión de filtro. El segundo parámetro es una cadena que debe contener una lista de nombres de columnas separados por comas:

DataRow[] filas = tbClientes.Select("", "Apellidos, Nombre");

Podemos indicar también el criterio descendente de ordenación para una o más co-lumnas, añadiendo la cláusula desc (da lo mismo si en mayúsculas o minúsculas) des-pués del nombre de la columna, al igual que sucede en la cláusula order by de una consulta SQL:

DataRow[] filas = tbClientes.Select("", "Codigo DESC");

Page 216: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

216 La Cara Oculta de C#

Filtrado por versiones

La última versión sobrecargada de Select es la más potente, porque nos permite utili-zar un filtro adicional, que tiene en cuenta el estado de actualización de las filas:

public DataRow[] Select(string filtro, string orden,

DataViewRowState estados);

Más adelante volveremos a encontrar el tipo DataViewRowState, cuando estudiemos la clase DataView. Este tipo enumerativo no sólo indica el estado en que deben encon-trarse las filas a seleccionar; también nos permite elegir la versión de las filas que nos interesa. Estos son los valores declarados para este tipo:

Constante Significado None No se devuelven filas Unchanged Sólo se devuelven las filas que no han sufrido cambios Added Recupera las filas nuevas Deleted Recupera las filas borradas ModifiedCurrent Recupera la versión actual de las filas modificadas CurrentRows Recupera las filas que normalmente veríamos en una rejilla ModifiedOriginal Recupera la versión original de las filas modificadas OriginalRows Recupera las filas originales, incluyendo las borradas

Estas constantes pueden combinarse en forma binaria, gracias al atributo Flags in-cluido en la declaración de DataViewRowState. De hecho, algunas de las constantes de la tabla son combinaciones de constantes primarias:

OriginalRows == ModifiedOriginal | Deleted | Unchanged

Vistas de datos

A pesar de la potencia del método Select de DataTable, éste tiene un inconveniente: devuelve un vector de filas. Es cierto que podemos utilizar vectores para el enlace a datos, pero un vector no es una tabla. Cada vez que tuviésemos que cambiar el crite-rio de orden o el filtro, tendríamos que regresar al objeto de tabla original, ejecutar Select nuevamente, y sustituir el viejo vector por el nuevo.

Una solución más elegante es la que ofrece la clase DataView, permitiendo que el posible filtro y el criterio de orden formen parte de su propio estado, y que puedan cambiarse en cualquier momento. Además, los objetos de la clase DataView soportan el enlace a datos avanzado. De hecho, cuando una rejilla DataGrid se conecta a una tabla, la conexión realmente se establece a través de la vista de datos por omisión de la tabla.

Las vistas de datos resuelven también un problema que incomoda bastante al pro-gramador. Hasta ahora, hemos visto que podemos recorrer el conjunto de filas de una tabla iterando sobre la propiedad Rows de la misma. Sin embargo, cuando bo-rramos una fila, o más bien, cuando marcamos una fila para que sea borrada, ésta

Page 217: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Búsquedas, filtros y ordenación 217

permanece dentro de la colección. ¿Resultado? Eche un vistazo a este fragmento de código, que en apariencia es bastante inocente:

// Aceptamos los cambios(¡MUY IMPORTANTE!)

dataSet.AcceptChanges();

// Borramos la primera fila, si existe

if (dataSet.Tables[0].Rows.Count > 0)

dataSet.Tables[0].Rows[0].Delete();

// Intentamos recorrer la colección de filas

string rslt = "";

foreach (DataRow dr in dataSet.Tables[0].Rows)

rslt += dr[0].ToString() + "\n";

// Si había al menos una fila antes de ejecutar este fragmento…

// …nunca llegaremos a este punto

MessageBox.Show(rslt);

En el ejemplo, borramos la primera fila de la tabla, e intentamos iterar sobre la colec-ción de filas resultante. El error se producirá en el primer paso del bucle, porque al evaluar el indizador de la fila, mediante la expresión dr[0], provocaremos una excep-ción. Efectivamente, la fila está marcada como borrada, y las filas borradas no tienen una versión “activa” de los datos. Para evitar este problema, tendríamos que haber preguntado por el estado de la fila, para omitirla en el listado, o para pedir la versión “original” de los datos de la fila:

// Primera variante

foreach (DataRow dr in dataSet.Tables[0].Rows)

if (dr.RowState != DataRowState.Deleted)

rslt += dr[0].ToString() + "\n";

// Segunda variante

foreach (DataRow dr in dataSet.Tables[0].Rows)

if (dr.RowState =! DataRowState.Deleted)

rslt += dr[0].ToString() + "\n";

else

rslt += dr[0, DataRowVersion.Original].ToString() + "\n";

Pero, de todos modos, podríamos tener problemas con las filas que estuviesen en alguno de los restantes estados.

NO

TA

¿Se ha fijado en que al principio del ejemplo he llamado a AcceptChanges? Con toda probabilidad, las filas del ejemplo habrán sido tecleadas antes de ejecutar el fragmento de código anterior, y su estado inicial más probable es Added. Si borramos una fila en dicho estado, la fila simplemente dejaría de existir, y el código no provocaría la excepción anunciada. La llamada a AcceptChanges pasa todas las filas al estado Unchanged.

La solución que ofrecen las vistas de datos es que pidamos solamente las filas que estén en determinado estado. Es más: la versión de las filas seleccionadas que será “visible” mediante la vista de datos será la más apropiada al conjunto de estados que hemos solicitado al crear el objeto.

Page 218: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

218 La Cara Oculta de C#

Construcción y manejo de vistas de datos

La clase DataView tiene tres constructores, y éste es el más completo, e importante, de los tres:

public DataView(DataTable table, string RowFilter, string Sort,

DataViewRowState RowState);

Está claro el significado del primer parámetro: es la tabla sobre la que se basará la vista de datos. En RowFilter debemos pasar el filtro y en Sort las columnas por las que vamos a ordenar las filas. Finalmente, RowState actúa como filtro adicional, indicando los estados posibles de las filas que deseamos mostrar: las filas modificadas, las filas añadidas, las originales, todas excepto las eliminadas... Todos estos parámetros, inclu-yendo la tabla base, pueden ser modificados después de la construcción de la instan-cia, a través de las propiedades Table, RowFilter, Sort y RowStateFilter.

Internamente, las vistas de datos mantienen un índice en memoria para implementar la ordenación dinámica y la búsqueda de filas respecto a ese orden. El índice se construye en el momento de la construcción del objeto, incluso cuando no hemos indicado criterio alguno de ordenación. En ese caso, se construye un índice en me-moria basado en la clave primaria, o en lo que se considere como el orden por omi-sión más apropiado. Por este motivo, es muy importante utilizar un constructor para la clase que permita indicar el criterio de ordenación, sobre todo cuando queremos ordenar las filas. Si creamos la vista y luego modificamos el orden mediante la pro-piedad Sort, habremos creado un índice inicial para luego destruirlo y crear otro. Un verdadero desperdicio de tiempo y espacio.

Veamos ahora cómo recorrer las filas de la vista de datos. Hay dos diferencias im-portantes respecto a la operación de recorrer las filas de una tabla:

1 En el iterador se utiliza directamente la propia instancia. Al iterar sobre tablas, teníamos, en contraste, que utilizar la propiedad Rows.

2 El iterador sobre vistas de datos no devuelve objetos DataRow, sino objetos per-tenecientes a la clase DataRowView:

string rslt = "";

DataView dv = new DataView(tbClientes,

"", "Apellidos, Nombre", DataViewRowState.OriginalRows);

foreach (DataRowView dvr in dv)

rslt += dvr["Apellidos"] + ", " + dvr["Nombre"] + "\n";

MessageBox.Show(rslt);

Observe que, para recuperar los valores almacenados en las columnas de DataRow-View, no ha sido necesario indicar la versión. ¿Qué habíamos pedido, las filas origi-nales de la tabla? Eso quiere decir que la versión más natural de las filas devueltas es la que contiene los valores originales.

También existen métodos para localizar registros mediante una vista de datos. Por ejemplo, tenemos un método Find. A diferencia del método que ya conocemos, que

Page 219: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Búsquedas, filtros y ordenación 219

se aplica a la colección de filas, el nuevo Find busca por el criterio de ordenación, no por la clave primaria, y se aplica directamente al objeto DataView. Además, en vez de retornar la fila encontrada, devuelve la posición de la misma dentro del DataView.

DataView dv = new DataView(tbClientes,

"", "Apellidos, Nombre", DataViewRowState.CurrentRows);

int pos = dv.Find(new object[]{ "Marteens", "Ian" });

if (pos == -1)

MessageBox.Show("No encontrado");

else

MessageBox.Show("Código: " + dv[pos]["Codigo"]);

Y si podemos encontrar más de una fila, como sucede cuando el criterio de ordena-ción no está basado en una clave primaria o única, tenemos FindRows, que devuelve un vector de objetos de la clase DataRowView.

Actualizaciones sobre vistas de datos

Cuando presenté la clase DataView, un par de secciones atrás, mencioné que las reji-llas de datos las utilizaban como fuente de información. La causa de este comporta-miento es ahora más clara: es mucho más sencillo tratar con las versiones de filas si usamos vistas de datos, es más fácil cambiar el criterio de ordenación en una vista de datos... Para que el cuadro sea perfecto, las vistas de datos deberían dar también un soporte adecuado a las actualizaciones. Y realmente es así.

Hay tres propiedades, todas de tipo lógico e inicializadas a true, que permiten o im-piden los distintos tipos de actualizaciones sobre un DataView:

public bool AllowNew { get; set; }

public bool AllowDelete { get; set; }

public bool AllowEdit { get; set; }

Cuando AllowNew contiene true, podemos ejecutar el método AddNew sobre la vista de datos, para obtener una nueva “vista de fila”, de la clase DataRowView. La fila creada sólo se añadirá a la tabla subyacente cuando ejecutemos el método EndEdit, esta vez sobre la propia vista de fila:

DataRowView drv = tbClientes.DefaultView.AddNew();

// … modificar columnas …

drv.EndEdit();

Por el contrario, para modificar una fila existente, la iniciativa debe partir de la propia vista de fila, mediante una llamada al método BeginEdit:

DataRowView drv = tbClientes.DefaultView[0];

drv.BeginEdit();

try {

// … modificar columnas …

drv.EndEdit();

}

catch {

drv.CancelEdit();

Page 220: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

220 La Cara Oculta de C#

throw;

}

Si todo ha ido bien, llamamos a EndEdit para que los valores nuevos pasen de la versión “propuesta” a la versión “actual” de la fila. Si hay problemas, llamamos al método CancelEdit, de la propia fila, para que se descarten los cambios realizados después de la llamada a BeginEdit.

Y, como es habitual, el borrado de filas es la operación más sencilla. Basta con una llamada al método Delete de la vista de fila:

tbClientes.DefaultView[0].Delete();

Por supuesto, este método sólo marca la fila, y hace falta todavía que llamemos a AcceptChanges para que se elimine el objeto de la colección de filas de la tabla. Lo que sí garantiza Delete es la desaparición del objeto de la vista de datos, cuando la propie-dad RowStateFilter de la vista no incluye las filas borradas.

Para facilitar aún más las cosas a un control interesado en una vista de datos, cada operación realizada sobre un DataView dispara un único evento central:

public virtual event ListChangedEventHandler ListChanged;

El argumento pasado al evento indica qué tipo de operación ha tenido lugar, y la posición de la fila que se ha visto afectada. Las operaciones señaladas no se limitan a las inserciones, borrados y modificaciones de filas, sino que incluyen cambios en el esquema relacional o incluso una reordenación de las filas.

Page 221: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

16

Controles enlazados a datos

N LOS EJEMPLOS QUE HEMOS VISTO HASTA ahora, hemos tenido que encargar-nos de todos los detalles necesarios para mostrar los datos en los controles usa-

dos. El programador que intentase ganarse la vida de esta manera lo tendría muy difícil... a no ser que fuese, accidentalmente, el sobrino del dueño del negocio. Para evitar estos pequeños dramas personales, los controles de .NET ofrecen una caracte-rística llamada data binding, o enlace a datos.

Enlace a datos

La idea detrás del enlace a datos es sencilla: un control puede indicar declarativamente, mediante asignaciones en propiedades, su interés en recibir notificaciones prove-nientes de ciertas fuentes de datos. La fuente de datos se compromete a emitir esas notificaciones cada vez que su estado interno varíe, para que cada uno de los botones interesados pueda determinar si debe o no redibujarse para reflejar el cambio en la fuente de datos. A la inversa, algunos controles permiten que las manipulaciones efectuadas en su propio estado interno, se propaguen al estado interno de la fuente de datos.

La idea no es nueva, ni mucho menos. Uno de sus ilustres ancestros es la arquitectura MVC (modelo/vista/controlador), original de Smalltalk. Aunque no hay un patrón atómico para describir el enlace a datos, sí hay unos cuantos patrones bien docu-mentados en su composición; el principal de ellos es el patrón Observer.

SUBJECT

Attach(Observer) Detach(Observer) Notify()

observers OBSERVER

Update()

foreach (Observer o in observers) o.Update();

Por último, muchos entornos de desarrollo modernos, como Delphi o el propio Visual Basic 6, implementan técnicas para enlazar controles a datos, aunque debe

E

Page 222: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

222 La Cara Oculta de C#

saber que el enlace a datos en .NET es mucho más elegante, potente y flexible que el de esos entornos.

Hay unas cuantas variantes de enlace a datos en Windows Forms, y las resumo en la siguiente tabla, junto con los controles que las implementan:

Unidireccional Bidireccional

Simple Label TextBox

Complejo ListBox, ComboBox DataGrid

Como puede ver, una de las dimensiones usadas en la clasificación distingue entre enlace unidireccional y bidireccional. Es una forma algo pedante de separar los con-troles que sólo reflejan el estado del origen de datos, y los que permiten también su modificación. O en menos palabras: existen controles enlazados a datos de sólo lec-tura y de lectura/escritura.

También se puede distinguir entre enlace simple o complejo. El enlace simple es aquel en que el control refleja el estado de un único elemento escalar del origen de datos; si el origen de datos fuese una tabla, estaríamos hablando de controles que solamente mostrasen una columna perteneciente a una sola fila de una sola tabla... es decir, el caso más común. Todo lo que no entre en esta casilla, va a parar al cajón de sastre del enlace complejo: las rejillas de datos, controles de listas, combos, etc.

¿A qué podemos enlazar nuestros controles?

Sin duda habrá notado el cuidado que he puesto al repetir “origen/fuente de datos”, en vez de hablar directamente de tablas y conjuntos de datos. El motivo está en que Windows Forms permite que sus controles reciban datos no sólo de tablas, sino de cualquier estructura, con tal de que ésta satisfaga unos requisitos mínimos. El factor común a todos los orígenes de datos es la necesidad de implementar la interfaz IList, del espacio de nombres System.Collections:

public interface IList: ICollection, IEnumerable { … }

Incluso un simple vector es un candidato aceptable para el enlace a datos, como vamos a demostrar ahora mismo. Inicie un nuevo proyecto y, en algún lugar del mismo, defina una estructura o una clase como la siguiente:

public struct Cliente {

private static int ultimo = 0;

private int codigo;

private string nombre;

private string apellidos;

public int Codigo {

get { return codigo; }

}

public string Nombre {

get { return nombre; }

set { nombre = value; }

}

Page 223: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 223

public string Apellidos {

get { return apellidos; }

set { apellidos = value; }

}

public Cliente(string aNombre, string aApellidos) {

codigo = --ultimo;

nombre = aNombre;

apellidos = aApellidos;

}

}

Como ve, es una estructura muy sencilla: hay una propiedad de sólo lectura, para devolver un código de cliente generado automáticamente, hay dos propiedades de lectura y escritura, para almacenar un nombre y unos apellidos. Y hay un constructor que necesita que le pasemos el nombre y los apellidos del cliente que vamos a crear.

Traiga entonces un control DataGrid al formulario. No hace falta que cambiemos nada de momento: a lo sumo, cambie su propiedad Dock o Anchor para que el tamaño de la rejilla se adapte al de la ventana. Luego, regrese al fichero de código y cree una variable privada dentro de la clase del formulario:

private Cliente[] clientes;

Para concluir, modifique el constructor del formulario de esta manera:

public MainForm()

{

InitializeComponent();

// Crear y poblar el vector de clientes

clientes = new Cliente[] {

new Cliente("Ian", "Marteens"),

new Cliente("Albert", "Einstein"),

new Cliente("Erwin", "Schrödinger"),

new Cliente("Arnold", "Schwarzenegger")

};

// Enlazar la rejilla al vector

dataGrid1.SetDataBinding(clientes, "");

}

El resultado será parecido al de la siguiente imagen:

Nadie ha dicho a la rejilla, al menos explícitamente, el número de columnas a mos-trar, ni su orden. Eso significa que la rejilla ha utilizado reflexión, es decir, información

Page 224: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

224 La Cara Oculta de C#

sobre el tipo Cliente en tiempo de ejecución, para deducir las piezas que faltaban en el rompecabezas.

Por cierto, la rejilla aparentará permitir la edición de registros... pero sólo se tratará de una apariencia. La estructura que ha actuado como fuente de datos sólo implementa la interfaz mínima exigible: IList. Si quisiéramos más potencia, tendríamos que crear una clase de colección que implementase otras interfaces, como IBindingList o IEditableObject. Claro está, a partir de determinado punto, será más ventajoso para nosotros crear directamente un conjunto de datos en memoria y almacenar la infor-mación dentro de una de sus tablas.

¿Es tan importante poder enlazar un control como la rejilla a estructuras de datos tan simples como un vector? Sí, lo es. Como debe saber, una de las manías de algunos programadores que han crecido jugando con la Programación Orientada a Objetos, es crear persistence frameworks, es decir, bibliotecas de clases para representar en memo-ria las entidades encontradas en las bases de datos típicas. Tengo sentimientos en-contrados hacia esta técnica, pues como casi todo en esta vida, tiene sus pros y sus contras. Una de las dificultadas mayores con la que tropezaban estos programadores kamikazes era que, si metían los datos de un cliente en una clase Cliente creada a mano, perdían toda la infraestructura de enlace a datos que podía ofrecerles su en-torno de desarrollo... y eso significaba, inevitablemente, muchas horas adicionales de programación y de lucha contra los correspondientes errores.

Con esta técnica tan potente de enlace a datos, sin embargo, ha desaparecido un escollo importante para el uso de esta clase de sistemas. Lo cual no significa que hayan comenzado a gustarme. Pero de eso hablaremos en su momento...

Enlace simple en tiempo de diseño

Para entrar en materia, creemos un nuevo proyecto con un formulario vacío. En la caja de herramientas, seleccione un DataSet y arrástrelo sobre el formulario. Visual Studio le preguntará si quiere crear un conjunto de datos con tipos o uno genérico. Pida uno genérico, cierre el cuadro de diálogo, y cambie el nombre del objeto para que sea dataSet.

Nuestro actual objetivo es crear un conjunto de datos en tiempo de diseño, haciendo uso del Inspector de Propiedades. El motivo: quiero mostrarle cómo se activa el enlace a datos en tiempo de diseño, porque es más sencillo así que no escribiendo todas las instrucciones a mano. Pero para poder activar el enlace de forma visual, el conjunto de datos debe tener creada su estructura relacional también en tiempo de diseño. Lo más común, en la práctica, es usar conjuntos de datos con tipos... que todavía no hemos estudiado.

Seleccione el conjunto de datos y haga doble clic sobre su propiedad Tables, en el Inspector de Propiedades. Debe aparecer un diálogo modal con la lista de tablas. Pulse el botón Add para añadir la única tabla de este ejemplo. En la rejilla de la dere-

Page 225: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 225

cha del diálogo cambie el valor de la propiedad Name a tbClientes, y modifique el valor de TableName a Clientes.

Active entonces el diálogo correspondiente a la propiedad Columns de la tabla. Cree tres columnas y configúrelas de esta forma:

• ColumnName=Codigo, DataType=System.Int32, AllowDBNull=false, AutoIncrement=true, AutoIncrementStep = -1, ReadOnly=true

• ColumnName=Nombre, DataType=System.String, MaxLength=30

• ColumnName=Apellidos, DataType=System.String, MaxLength=40

Cierre entonces el editor de columnas, y despliegue el editor de la propiedad Primary-Key de la tabla. Marque la columna Codigo, y cierre el diálogo de propiedades de la tabla, porque ya hemos preparado el conjunto de datos.

Vamos a añadir ahora los controles al formulario. Traiga tres componentes TextBox, y seleccione el primero. Active el Inspector de Propiedades, y busque una propiedad titulada DataBindings, encerrada entre paréntesis. Expándala y verá que una de las propiedades anidadas que aparecen se titula Text, igual que la propiedad del control. Despliegue el editor de esa propiedad, para escoger una columna como origen de datos del control.

El editor de la propiedad muestra un árbol con los conjuntos de datos y tablas pre-sentes en el formulario, y las columnas definidas en cada tabla. Como puede ver en las imágenes anteriores, puede especificar una misma columna en dos formas dife-rentes. En la imagen de la izquierda, ha seleccionado el conjunto de datos, la tabla y luego la columna; en la de la derecha, ha comenzado por la tabla directamente, y

Page 226: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

226 La Cara Oculta de C#

luego ha elegido la columna. Vamos a seleccionar, para este primer control, la co-lumna Codigo, utilizando el método de la izquierda, es decir, comenzando por el con-junto de datos. Como resultado, el valor que el Inspector de Datos mostrará al lado de Text será:

dataSet - Clientes.Apellidos

Si hubiésemos utilizado el sistema alternativo, el valor mostrado sería:

tbClientes - Apellidos

Recuerde que estamos modificando una propiedad Text que aparece dentro de un grupo especial titulado DataBindings. Si buscamos la propiedad Text en el primer nivel del Inspector, veremos algo parecido a lo siguiente:

La propiedad sigue manteniendo su valor original, pero hay un pequeño dibujo, con el conocido símbolo de bases de datos: un tambor magnético, reliquia de la época en la que el brillo de los 5U4 iluminaba las fuentes de alimentación11. Para terminar la configuración de los controles, repita la operación, para asignar las columnas Nombre y Apellidos a los dos cuadros de texto restantes. Y asegúrese de utilizar la misma téc-nica de selección de columnas en todo momento.

Instrucciones de enlace a datos

Vamos a echar un vistazo al código de inicialización de los controles generado por Visual Studio. Active el editor de código, expanda la región de código generada por el diseñador de ventanas, en especial, la que corresponde al método InitializeComponents, y localice la inicialización de propiedades del objeto textBox1. Encontrará un grupo de instrucciones parecido al siguiente:

// textBox1

this.textBox1.DataBindings.Add(

new System.Windows.Forms.Binding(

"Text", this.dataSet, "Clientes.Codigo"));

this.textBox1.Location = new System.Drawing.Point(132, 20);

11 Ja, ja, esta vez la referencia sí que es esotérica... Un 5U4 era un válvula, técnicamente un diodo, que dejaba pasar la corriente en una sola dirección. Era utilizado en las fuentes de alimentación para convertir corriente alterna en corriente directa.

Page 227: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 227

this.textBox1.Name = "textBox1";

this.textBox1.Size = new System.Drawing.Size(80, 20);

this.textBox1.TabIndex = 0;

this.textBox1.Text = "";

La instrucción que nos interesa, la que está relacionada con el enlace a datos, es la primera. El control tiene una propiedad llamada DataBindings, que es una colección de objetos de la clase Binding. Un binding, o enlace, actúa como intermediario entre el origen de los datos y un control.

Enlace con… Propiedad Significado Origen de datos DataSource La raíz del origen de datos BindingMemberInfo Un elemento particular del origen de datos

Control Control El control enlazado PropertyName Una propiedad del control enlazado

En el caso del control es evidente que se utiliza información de tipos en tiempo de ejecución, o reflexión, para localizar y modificar la propiedad del control que se quiere enlazar. Lo mismo sucede, aunque no esté tan claro a simple vista, en la locali-zación del elemento concreto del origen de datos que se va a visualizar. En este caso, el elemento concreto es una columna de una tabla, pero recuerde que en el ejemplo de visualización de vectores, las columnas de la rejilla se asociaban a propiedades públicas de los objetos del vector.

En nuestro caso, el objeto de enlace que se añade a DataBindings se crea mediante la siguiente llamada al constructor de la clase Binding:

new Binding("Text", this.dataSet, "Clientes.Codigo"));

El control asociado al enlace será fijado más adelante, cuando se añada el enlace a la colección de enlaces del control. El primer parámetro es la propiedad del control que vamos a utilizar, y los otros dos parámetros indican el origen de datos y un elemento concreto dentro de ese origen o fuente de datos.

NO

TA

¿Por qué hay toda una lista de enlaces por cada control? Gracias a ello, podemos con-trolar más de una propiedad del control mediante un conjunto de datos. Supongamos que tenemos a nuestra disposición un descendiente de TextBox que permite validar los ca-racteres admitidos mediante una expresión regular. Digamos que el control tiene una propiedad Expression en la que se almacena la expresión regular de validación. Podría-mos enlazar entonces la propiedad Text a una columna que contenga códigos postales, y la propiedad Text a otra columna que indique el formato admitido para el código postal de cada fila, por separado.

Para poder probar el enlace a datos, debemos añadir al menos un par de filas al con-junto de datos. Vuelva al editor de código, y localice el constructor del formulario. Añada entonces las siguientes instrucciones inmediatamente después de la llamada a InitializeComponents:

public FormDatos() {

// Required for Windows Form Designer support

InitializeComponent();

tbClientes.Rows.Add(new object[]{ null, "Ian", "Marteens" });

Page 228: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

228 La Cara Oculta de C#

tbClientes.Rows.Add(new object[]{ null, "Albert", "Einstein" });

}

Cuando ejecute la aplicación, verá una ventana como ésta:

Estamos mostrando la primera fila, pero ¿qué debemos hacer para poder navegar a los restantes registros de la tabla?

El contexto de enlace

Hemos dado de narices con un problema importante: ni las tablas, ni los conjuntos de datos, ni las vistas de datos, y ya puestos, ni los vectores de clientes, ninguna de estas estructuras define algo que se parezca a un registro activo, o una posición del cursor. ¿Dónde se mantiene esta posición del registro activo? ¿Por qué todos los controles de un formulario, a pesar de haber sido enlazados a la tabla por separado, se mueven simultáneamente por las mismas filas de la tabla?

La respuesta está en un objeto mantenido por el formulario en su propiedad Binding-Context. Este objeto es el que coordina la presentación de todas las fuentes de datos enlazadas a algún control de la ventana. El siguiente diagrama simplificado muestra la estructura de datos que está en juego:

El papel de BindingContext es el mantenimiento de una colección de objetos derivados de la clase BindingManagerBase; para cada origen de datos diferente utilizado dentro del formulario, se crea automáticamente uno de estos objetos, y se añade a la colec-ción mantenida por el BindingContext. Y es dentro del BindingManagerBase donde se mantiene la posición del registro activo... y sí, ya sé que el diagrama menciona una clase llamada CurrencyManager. La explicación es que BindingManagerBase es una clase abstracta. CurrencyManager es la clase derivada más frecuente en el enlace a datos so-bre formularios visuales.

Page 229: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 229

NO

TA

El nombre de la clase CurrencyManager puede inducir a error. Currency, en inglés, se refiere normalmente a la moneda. En este caso, en cambio, currency tiene que ver más bien con current, y CurrencyManager podría traducirse aproximadamente como “gestor o administrador de posición”. Un poco forzado, pero es lo que hay...

Los objetos CurrencyManager se crean automáticamente cuando creamos instancias de la clase Binding y las enlazamos a controles visuales. Ahora bien, ¿cómo podemos obtener el CurrencyManager, o gestor de posición, de una tabla determinada? La téc-nica tiene algo de peligro. En principio, nos basta con utilizar el indizador del con-texto de enlace (binding context) del formulario, utilizando como índice el objeto que ha servidor como origen de datos, y cualquier otro cualificador que hayamos usado para afinar la selección. Por ejemplo, al enlazar textBox1 con la columna Codigo de la tabla de clientes, creamos el siguiente enlace:

new Binding("Text", this.dataSet, "Clientes.Codigo"));

Si quisiéramos obtener el gestor de posición, tendríamos que escribir esto:

this.BindingContext[dataSet, "Clientes"]

El prefijo this es innecesario en este contexto, pero lo he introducido para dejar bien claro que BindingContext es una propiedad del formulario. Como el segundo argu-mento del constructor de Binding es el conjunto de datos, ese mismo objeto es el que tenemos que pasar como primer parámetro del indizador. El último parámetro del constructor hacía referencia a la columna Codigo de la tabla Clientes. Esto nos obliga a pasar un segundo parámetro al indizador de BindingContext, con el nombre de la tabla. El nombre de la columna se deja fuera.

El peligro está en que también podríamos haber enlazado el control mediante esta instrucción alternativa:

new Binding("Text", this.tbClientes, "Codigo"));

En ese caso, para recuperar el BindingManagerBase estaríamos obligados a escribir la llamada al indizador de esta otra forma:

this.BindingContext[tbClientes, ""]

Si nos equivocamos y utilizamos la primer expresión que presentamos, se crearía calladamente un objeto apropiado... uno que no tendría ningún efecto sobre los con-troles enlazados a datos. Según mi propia experiencia, este es el error más frecuente cuando se escribe código relacionado con el enlace a datos.

Movimiento del cursor

Veamos cómo podemos aplicar los nuevos conocimientos para cambiar el registro activo en el ejemplo que hemos presentado antes. Lo primero será añadir y configu-rar cuatro botones en algún lugar del formulario, como en la siguiente imagen:

Page 230: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

230 La Cara Oculta de C#

Para reducir la posibilidad de equivocarnos, vamos a crear una propiedad privada dentro de la clase del formulario, para que nos devuelva el gestor de posición de la tabla de clientes:

private BindingManagerBase CMan {

get { return BindingContext[dataSet, "Clientes"]; }

}

Aunque en realidad el objeto que devuelve la propiedad es un CurrencyManager, en este ejemplo no necesitaremos la funcionalidad adicional ofrecida por esta clase deri-vada, y nos da exactamente lo mismo declarar el tipo de la propiedad tal y como lo hemos hecho.

Y ahora, la parte sustanciosa. Estos son los manejadores del evento Click de los cua-tro botones de navegación:

private void bnFirst_Click(object sender, System.EventArgs e) {

CMan.Position = 0;

}

private void bnPrev_Click(object sender, System.EventArgs e) {

CMan.Position -= 1;

}

private void bnNext_Click(object sender, System.EventArgs e) {

CMan.Position += 1;

}

private void bnLast_Click(object sender, System.EventArgs e) {

CMan.Position = CMan.Count - 1;

}

Aquí hemos utilizado solamente dos propiedad públicas de BindingManagerBase:

public abstract int Count { get; }

public abstract int Position { get; set; }

Como es evidente, Count devuelve el número de filas de la fuente de datos, y la pro-piedad Position nos permite controlar la posición de la fila activa. Las filas comienzan a contarse desde cero, y es interesante comprobar que esta propiedad está protegida contra desbordamientos de rango. Observe que para movernos al siguiente registro, siempre incrementamos la posición. Si ya estamos en el último registro, no impor-tará, porque el CurrencyManager detectará esta situación y no modificará el valor actual de la propiedad Position.

Page 231: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 231

Un control para la navegación

Se supone que la programación basada en componentes nos evitaría tener que crear estos botones de navegación a mano. Visual Studio debería traer de fábrica un con-trol de navegación listo para el microondas. Pero esto es ladrar a la Luna, o más bien, pedirle peras a una gallina de Jericó. Seamos positivos, ¿por qué no creamos nuestro propio control de navegación para no tener que repetir el código tecleado en el ejemplo anterior en cada ventana de navegación del proyecto?

Para no complicarlo demasiado, vamos a crear un control de usuario. Un control de usuario es una clase que desciende de UserControl, una clase declarada en Windows Forms. Estos controles tienen la considerable ventaja de poder ser editados con el diseñador de formularios de Visual Studio, como si se tratase de formularios, y esta posibilidad los hace apropiados para crear controles “compuestos”, sobre los cuales se agrupan o combinan varios controles más simples. Este será nuestro caso, porque el control de navegación estará formado por cuatro botones y una etiqueta:

Para crear el esqueleto de un control de usuario, dentro de un proyecto existente, ejecute el comando de menú Fichero|Agregar nuevo elemento, y en el diálogo que aparecerá, pulse el icono titulado Control de usuario. Como

respuesta, Visual Studio añadirá una superficie de diseño al proyecto, y un fichero de código con una clase derivada de UserControl:

public class Navigator : System.Windows.Forms.UserControl

{

// …

}

Debemos añadir cuatro botones Button, como los mostrados en la anterior imagen del control, y una etiqueta Label, en la que mostraremos el total de registros y la posi-ción actual. Mis botones se llaman bnFirst, bnPrev, bnNext y bnLast, y para la etiqueta he conservado su nombre original: label1.

Vamos también a declarar dos variables privadas dentro de la clase del navegador:

private object dataSource = null;

private string dataMember = "";

Más adelante, crearemos un par de propiedades públicas basadas en estas variables, que utilizaremos para localizar el gestor de posición que queremos controlar. El si-guiente paso es crear un manejador común para los eventos Click de los cuatro boto-nes de navegación. Cada vez que alguien haga clic sobre uno de estos botones, se deberá ejecutar el siguiente código:

Page 232: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

232 La Cara Oculta de C#

private void UpdatePosition(object sender, System.EventArgs e)

{

if (dataSource != null)

{

BindingManagerBase cm =

BindingContext[dataSource, dataMember];

if (sender == bnFirst)

cm.Position = 0;

else if (sender == bnPrev)

cm.Position -= 1;

else if (sender == bnNext)

cm.Position += 1;

else if (sender == bnLast)

cm.Position = cm.Count - 1;

}

}

Hasta aquí, el control es relativamente sencillo. Sin embargo, queremos que la eti-queta que ubicamos en el control refleje el número de registros y la posición de la fila activa. Es cierto que podríamos actualizar la etiqueta como parte de las tareas del método UpdatePosition, pero ¿qué pasaría si la posición activa cambiase por iniciativa de algún otro control? Por ejemplo, podría ser que estuviésemos visualizando regis-tros mediante un DataGrid, que puede cambiar la fila activa sin necesidad de nuestro navegador.

Para resolver esta situación, haremos que la etiqueta se actualice dentro de la res-puesta al evento PositionChanged del gestor de posición. Este evento se dispara cada vez que cambia la posición controlada por el BindingManagerBase, o cuando cambia el número de filas en la fuente de datos. El método de respuesta debe ser el siguiente:

private void UpdateButtons(object sender, EventArgs e)

{

if (dataSource != null) {

BindingManagerBase cm =

BindingContext[dataSource, dataMember];

bnFirst.Enabled = bnPrev.Enabled = cm.Position > 0;

bnLast.Enabled = bnNext.Enabled = cm.Position < cm.Count - 1;

}

}

Ahora bien, ¿dónde, o más bien, cuándo debemos enlazar este método al evento PositionChanged del controlador de posición? Cuando sepamos cuál es el controlador de posición que utilizaremos. Y eso sólo lo sabremos en el momento en que haya-mos asignado las variables dataSource y dataMember; quiero decir, cuando ambas varia-bles estén correctamente asignadas. En estos casos, debemos hacer que el control Navigator implemente la interfaz ISupportInitialize:

public class Navigator :

System.Windows.Forms.UserControl, ISupportInitialize {

// …

public void BeginInit() {

}

public void EndInit() {

}

}

Page 233: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 233

Esta interfaz ofrece los métodos BeginInit y EndInit. Cuando Visual Studio encuentra un componente que implementa ISupportInitialize en una superficie de diseño, protege el código de inicialización de propiedades del objeto entre llamadas a los dos méto-dos antes mencionados:

((ISupportInitialize) navigator1).BeginInit();

navigator1.DataSource = loQueSea;

navigator1.DataMember = "qué más da";

((ISupportInitialize) navigator1).EndInit();

Seremos nosotros quienes postergaremos cualquier operación importante dentro del componente mientras no se haya ejecutado EndInit. De esta manera, dará igual cuál de las propiedades del componente se asigne antes o después. Para nuestro control, haremos que estos métodos actualicen una variable entera con el número de veces que han sido llamados:

private int initCount = 0;

Aprovecharemos para optimizar la asociación del método UpdateButtons al evento PositionChanged del gestor de posición. Normalmente, para asignar un evento se utili-zar una instrucción como la siguiente:

receptor.evento += new TipoDelegado(manejador);

Claro, si añadimos y quitamos el manejador varias veces, tendremos que crear unas cuantas instancias del delegado. Para evitarlo, vamos a declarar una variable privada de tipo EventHandler:

private EventHandler eh = null;

La instancia del delegado que dará respuesta al evento la crearemos una sola vez, y la mantendremos en esta especie de caché. Veamos ahora la implementación final que le daremos a BeginInit:

public void BeginInit() {

if (initCount++ == 0 && dataSource != null && eh != null)

BindingContext[dataSource, dataMember].PositionChanged -= eh;

}

BeginInit siempre incrementará el contador initCount. Si es la primera llamada, o la llamada más externa, comprobamos si podemos pedir el gestor de posición actual, al que probablemente dejaremos de referirnos. Si ya le hemos asignado nuestro dele-gado, que hemos almacenado en la variable eh, eliminaremos el delegado de la cadena mantenida por el evento.

La implementación de EndEdit sigue los mismos pasos pero a la inversa:

public void EndInit()

{

if (--initCount == 0 && dataSource != null)

{

if (eh == null)

eh = new EventHandler(UpdateButtons);

Page 234: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

234 La Cara Oculta de C#

BindingContext[dataSource, dataMember].PositionChanged += eh;

UpdateButtons(null, null);

}

}

Si después de decrementar el contador encontramos un cero, quiere decir que ya se han asignado todas las propiedades necesarias. Comprobamos si podemos obtener un gestor de posición y, en caso afirmativo, procedemos a enlazarle un delegado que apunte al método UpdateButtons. Finalmente, llamamos manualmente a UpdateButtons, para garantizar que la inicialización adecuada del estado de los cuatro botones y de la etiqueta del navegador.

Finalmente podemos implementar las propiedades públicas DataSource y DataMember. Solamente mostraré la implementación de la primera de ellas; la implementación de la segunda es muy parecida:

public object DataSource

{

get { return dataSource; }

set

{

if (dataSource != value)

{

BeginInit();

dataSource = value;

dataMember = "";

EndInit();

}

}

}

Las llamadas pareadas a BeginInit y EndInit sirven para forzar el disparo del evento que actualiza la etiqueta con la posición. Claro, si la propiedad se asigna dentro de un bloque ya protegido por BeginInit y EndInit, no se producirá el disparo hasta que el último EndInit cierre el paréntesis abierto por el primer BeginInit. De todas formas, no está de más añadir un método público como el siguiente:

public void SetDataBinding(object dataSource, string dataMember)

{

BeginInit();

DataSource = dataSource;

DataMember = dataMember;

EndInit();

}

Cuando llegue a este punto, compile el proyecto. Luego, seleccione el formulario principal y examine los componentes de la caja de herramientas de Visual Studio, en concreto, los de la página Windows Forms. Al final de la lista, debe encontrar un com-ponente llamado Navigator... ¡el que acabamos de desarrollar! Claro, el componente sólo estará disponible dentro de este proyecto, pero podríamos moverlo a un pro-yecto de tipo control library, si quisiéramos compartirlo. Lo que importa ahora es que traiga un navegador al formulario. Selecciónelo, y eche un vistazo al Inspector de Propiedades:

Page 235: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 235

Hay malas noticias: el Inspector de Objetos no sabe qué hacer para editar los valores de DataSource. La culpa la tiene el tipo de la propiedad, que es un object. No puedo entrar en la solución, porque rebasa el objetivo del libro, pero sepa que para resolver el problema hay que definir un editor especializado para esta propiedad. Para enlazar el navegador, añada la siguiente instrucción en el constructor del formulario:

navigator1.SetDataBinding(tbClientes, "");

Y ya puede contemplar el resultado:

Si sustituimos los controles individuales por una rejilla de datos, podremos com-probar que nuestro navegador puede seguir sin problemas los cambios de fila activa originados en la rejilla.

Observe que la fila seleccionada en la rejilla es la primera, y que por ese motivo, los dos botones de navegación hacia atrás aparecen inactivos.

Page 236: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

236 La Cara Oculta de C#

NO

TA

No he querido robarle toda la diversión. Si quiere, puede intentar mover el control a un ensamblado aparte, para poder usarlo en proyectos diferentes. En el área de descargas del libro encontrará una versión más completa de este control.

Eventos de formato

Hasta ahora, hemos aceptado sumisamente que los controles TextBox muestren el contenido de las columnas a las que están asociados con el formato que les dé la gana. Pero nunca es tarde para retomar el control. Cuando usamos el enlace simple a datos, podemos aprovecharnos de dos eventos disparados por la clase Binding:

public event ConvertEventHandler Format;

public event ConvertEventHandler Parse;

Recuerde que es a esta clase a la que pertenecen los objetos creados en el enlace sim-ple con instrucciones como la siguiente:

textBox4.DataBindings.Add(new System.Windows.Forms.Binding(

"Text", this.dataSet, "Products.UnitPrice"));

Lo ideal sería poder atrapar directamente el objeto Binding en el momento en que se crea, para añadir los métodos necesarios a las cadenas de delegados de sus dos eventos:

Binding binding;

binding = new System.Windows.Forms.Binding(

"Text", this.dataSet, "Products.UnitPrice");

binding.Format += new ConvertEventHandler(FormatearPrecio);

textBox4.DataBindings.Add(binding);

Desgraciadamente, si hemos generado el enlace a datos en tiempo de diseño, con la ayuda de Visual Studio, no habrá forma de modificar el código generado para que añada los manejadores de eventos deseados. La solución es añadir los manejadores de eventos a posteriori, una vez inicializados los controles del formulario, como mostraremos ahora.

Suponga que queremos mostrar el contenido de una columna de la tabla de produc-tos con el formato de monedas. Primero, creamos dos métodos como los siguientes:

private void FormatPrice(object sender, ConvertEventArgs e)

{

e.Value = ((decimal) e.Value).ToString("c");

}

private void ParsePrice(object sender, ConvertEventArgs e)

{

e.Value = Decimal.Parse(e.Value.ToString(),

System.Globalization.NumberStyles.Currency);

}

El primero convierte el valor almacenado en la columna, que es de tipo Decimal en una cadena de caracteres. Para ello, aprovechamos el método ToString de esta misma clase, con el indicador de formato de moneda. El segundo método realiza la conver-

Page 237: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 237

sión a la inversa: recibe una cadena de caracteres, que probablemente contiene sepa-radores de millares y el símbolo nacional de la moneda, y la convierte en un valor de tipo Decimal.

Supondremos también que la columna se mostrará en el control textBox4. En ese caso, para recuperar la referencia al objeto de clase Binding utilizamos la propiedad DataBindings del control, indicando como índice el nombre de la propiedad enlazada al origen de datos. La localización del objeto y la adición de los manejadores de eventos puede llevarse a cabo dentro del propio constructor del formulario, una vez que ha terminado la inicialización de componentes en InitializeComponent:

public MainForm()

{

InitializeComponent();

Binding b = textBox4.DataBindings["Text"];

b.Format += new ConvertEventHandler(FormatPrice);

b.Parse += new ConvertEventHandler(ParsePrice);

}

Selecciones desplegables

El siguiente diagrama muestra dos tablas de la base de datos Northwind: la tabla de productos y la de categoría de productos, además de la relación que existe entre ellas:

Cada producto puede pertenecer a una sola categoría: bebidas, condimentos, pro-ductos lácteos, y para indicar cuál es esa categoría, la tabla Products contiene un

Page 238: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

238 La Cara Oculta de C#

campo de tipo numérico, llamado CategoryID. Si mostrásemos los registros de pro-ductos mediante controles TextBox, como en los ejemplos anteriores, veríamos para cada producto el identificador numérico de su categoría, que no nos diría nada, con toda probabilidad.

En el capítulo sobre las jerarquías de tablas, vimos un caso parecido: las líneas de detalles de un pedido almacenaban el código del producto ordenado; también en aquel caso habría sido mejor mostrar el nombre del producto. En ese momento ex-pliqué una técnica para mostrar el nombre de producto dentro de los registros de detalles: debíamos crear una columna calculada, que hiciera referencia a la tabla de productos mediante la relación existente entre detalles y productos. Por supuesto, con esta técnica solamente podíamos “ver” el nombre del producto, pero no modifi-car el valor de la columna que hacía referencia al identificador del producto.

Ahora veremos una técnica alternativa, que exige el uso de un control ComboBox, como en la siguiente imagen:

La aplicación, cuyo código fuente completo encontrará en los ejemplos del libro, asume que tenemos una tabla de productos y una tabla de categorías, ambas en me-moria. Las dos tablas del ejemplo existen dentro de un mismo conjunto de datos, aunque no es obligatorio.

La técnica que estudiaremos se ocupa internamente de varias tareas elementales. Como puede ver en la imagen, la lista de elementos del combo, que he mostrado desplegada, debe llenarse con los nombres de categorías, que saldrán de una columna de la tabla de categorías. Por otra parte, el combo debe ser capaz de traducir el có-digo de categoría en un nombre de categoría; en vez de un 2, debe mostrar Condi-ments, que es el nombre de la categoría correspondiente, como en la imagen. Por último, si el usuario despliega la lista y elige cualquier otra categoría, el combo debe realizar la traducción inversa, de nombre a código, y modificar el valor de la columna CategoryID en la tabla de productos.

Page 239: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 239

NO

TA

Debe tener muy claro que con esta técnica no podemos actualizar, al menos de forma directa, el contenido de la tabla de categorías. Si descubrimos de repente que nos falta una categoría, el combo no nos ayudará a crearla. Si nos encontramos con un error de ortografía en un nombre de categoría, no podremos usar el combo para corregirlo.

La clase ComboBox ofrece dos subsistemas para poder implementar este efecto. Por una parte, tenemos un mecanismo sencillo de enlace a datos, como el que hemos descrito para TextBox; la única diferencia es que la propiedad que se enlaza no es Text, sino SelectedValue, como veremos en breve.

Por otra parte, este mismo control publica tres propiedades relacionadas con la tabla de referencia:

Propiedad Propósito DataSource La tabla que se va a utilizar como referencia DisplayMember La columna de esa tabla que contiene las descripciones ValueMember La columna de la misma tabla que contiene los códigos

Veamos cómo se aplica la técnica a nuestro ejemplo concreto:

Como ve, en la propiedad DataSource del combo se asigna una referencia a la tabla de categorías. La columna CategoryID se asigna a ValueMember, y DisplayMember hace referencia a la columna CategoryName. Estas tres son propiedades “normales” del control, o dicho con otras palabras, no se agrupan dentro de los Data Bindings.

Ahora sí que debemos configurar el enlace a datos. Observe que un ComboBox in-cluye más propiedades en este grupo que un TextBox. Esto se debe a que el diseñador de la clase ha marcado más propiedades con el atributo Bindable:

[Bindable(true)] // Aparecerá en DataBindings

[Browsable(false)] // No aparecerá en el Inspector de Propiedades

public object SelectedValue { get; set; }

Para nuestro ejemplo, precisamente, debemos enlazar la columna CategoryID, esta vez de la tabla de productos, a la propiedad SelectedValue. El código generado por Visual Studio para inicializar el combo será el siguiente:

Page 240: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

240 La Cara Oculta de C#

this.comboBox1.DataBindings.Add(

new System.Windows.Forms.Binding(

"SelectedValue", this.dataSet, "Products.CategoryID"));

this.comboBox1.DataSource = this.tbCategories;

this.comboBox1.DisplayMember = "CategoryName";

// …

this.comboBox1.ValueMember = "CategoryID";

Resumamos las condiciones que nos han llevado a usar un combo para ver y editar el contenido del campo CategoryID, de la tabla de productos. En primer lugar, queremos una traducción: almacenamos un código, pero queremos ver una descripción. Y en segundo lugar, la traducción tiene lugar con la ayuda de una segunda tabla de refe-rencia. Además, la tabla de referencia debe tener un número razonablemente pe-queño de registros. No deberíamos usar esta técnica para editar el identificador de producto en una línea de un pedido, especialmente si la tabla de productos, que en este caso es la que hace de referencia, es grande.

También es importante distinguir otro de los usos de los combos. Suponga que te-nemos que modificar la ciudad donde vive el cliente. No existe una tabla de ciudades, sino que tecleamos directamente el nombre de la ciudad, y lo almacenamos como una cadena de caracteres. Es decir: no hay traducción de códigos. Para facilitar la edición, sin embargo, podemos añadir directamente, en la propiedad Items del combo, los nombres de ciudades que más vamos a usar. En este caso, el enlace a datos se haría sobre la propiedad Text. La lista de valores desplegable es, en principio, una lista estática, aunque podamos mejorar su funcionalidad inicializándola a partir de otra tabla, un fichero XML, o cualquier medio persistente que se nos ocurra.

Rejillas de datos

Las rejillas son los controles de los que más se abusa en las aplicaciones de bases de datos. En este libro se abusa de las rejillas... aunque con un fin pedagógico. En ejem-plos anteriores ya hemos utilizado la clase DataGrid, y sabemos incluso cómo se comportan sus instancias cuando hay relaciones entre tablas de un mismo conjunto de datos. De todas maneras, incluiré aquí un breve resumen sobre las características de las rejillas, para su comodidad.

Comencemos por las propiedades que enlazan la rejilla con su fuente de datos. Si configuramos el enlace en tiempo de diseño, debemos utilizar estas dos propiedades:

public object DataSource { get; set; }

public string DataMember { get; set; }

Si el enlace se establece en tiempo de ejecución, se utiliza SetDataBinding, que elimina los posibles problemas con el orden de asignación de las propiedades anteriores:

public void SetDataBinding(object dataSource, string dataMember);

Como ve, en estas dos variantes el tipo del origen de datos es object, la clase raíz del universo .NET. Esto se debe a que el control no sólo se admite conjuntos de datos, tablas y vistas de datos, sino cualquier otro objeto que implemente la interfaz IList-

Page 241: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 241

Source o IList. Pero aquí nos centraremos en el uso de conjuntos de datos y sus es-tructuras asociadas. Incluso en este caso, las alternativas de enlace pueden confundir al novato. Para un conjunto de datos con una sola tabla en su interior, tendremos dos formas de configurar la rejilla:

dataGrid1.SetDataBinding(dataSet1, dataSet1.Tables[0].TableName);

dataGrid1.SetDataBinding(dataSet1.Tables[0], null);

Es importante, como he dicho al presentar la técnica del enlace simple, ser consis-tente con la forma en que enlazamos otros controles, para evitar tener por error administradores de posición independientes.

Cuando hay dos o más tablas relacionadas, hay más variantes:

◼ dataGrid1.DataSource = dataSet1;

• dataGrid1.DataMember = null;

Inicialmente se muestra un árbol colapsado con las dos tablas del conjunto de datos a modo de enlaces. Al pulsar sobre uno de estos enlaces, se visualizan todos los registros de la tabla elegida. Si la tabla elegida es la maestra, en la nueva vista se incluyen enlaces para visitar los registros asociados a determinada fila maestra.

• dataGrid1.DataMember = dataSet1.Tables[x].TableName;

Si la tabla elegida es la maestra, se incluyen los enlaces para navegar a las filas de-pendientes. Para eliminar los enlaces, se puede asignar false en la propiedad AllowNavigation de la rejilla. Si la tabla elegida es la de detalles, se muestran todos los registros de ésta, sin tener en cuenta la fila maestra a la que pertenecen.

• dataGrid1.DataMember = dataSet1.Relations[0].RelationName;

Se muestran solamente las filas de detalles asociadas al registro activo de la tabla maestra.

◼ dataGrid1.DataSource = dataSet1.Tables[x];

• dataGrid1.DataMember = null;

Se muestran todos los registros de la tabla elegida. Si la tabla tiene detalles, se muestran los enlaces correspondientes, excepto cuando AllowNavigation ha sido desactivada.

• dataGrid1.DataMember = dataSet1.Relations[0].RelationName;

Se muestran las filas de detalles asociadas al registro maestro activo. La tabla asignada en DataSource debe ser la maestra.

Por descontado, esta técnica de navegación por enlaces no está diseñada para que la utilice un usuario típico. Aparte de esto, la rejilla tiene otras excentricidades. Por

Page 242: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

242 La Cara Oculta de C#

ejemplo, podemos cambiar en tiempo de ejecución el ancho de una columna, pero no podemos arrastrar columnas para cambiar el orden relativo. Para eliminar una fila, tenemos que pulsar el ratón sobre una de las cabeceras de filas, es decir, sobre el margen izquierdo de la rejilla; una vez seleccionada toda la fila, la combinación de teclas CTRL+SUPR la elimina... sin pedirle confirmación al pobre usuario. Para rematar la faena, la rejilla no ofrece indicaciones visuales al paso del ratón, como ya es habi-tual en los controles modernos. Por ejemplo, si la rejilla permite pulsar el ratón sobre una cabecera de columna, para ordenar por la columna asociada, debería redibujar esa cabecera cuando el ratón entre o salga del área controlada, como sucede en el control de listas de Windows XP.

NO

TA

Sinceramente, si tiene que crear una aplicación comercial, es recomendable que se gaste algo de dinero en controles profesionales de terceras compañías. Puede probar con la suite XtraGrid™, de Developer Express™ (www.devexpress.com). La rejilla incluida tiene muchas posibilidades de configuración, y el único problema que encontrará es evitar la tentación de incluir tantas características que terminen por confundir al usuario final.

Configuración de rejillas

Aunque un DataGrid puede funcionar automáticamente con sólo establecer sus pro-piedades DataSource y DataMember, lo normal es que tengamos algo más de trabajo en el Inspector de Propiedades. Puede que nos interese, por ejemplo, cambiar los colo-res y el estilo general de la rejilla. Para ello, el menú local de la rejilla, en tiempo de diseño, contiene un comando titulado Formato automático, que nos permite elegir en una lista de estilos predefinidos:

Este comando actúa sobre propiedades independientes que controlan el color de distintas regiones de la rejilla. Lamentablemente, no existe soporte en tiempo de ejecución para una operación parecida: si queremos cambiar el estilo, a petición del usuario, tendremos que asignar los colores nuevos, propiedad por propiedad.

Hay que recordar que una misma rejilla puede mostrar distintas tablas, y esto com-plica aún más su configuración. La clase DataGrid ofrece una propiedad llamada

Page 243: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 243

TableStyles, que contiene una colección de objetos DataGridTableStyle. Cada uno de estos objetos contiene también una serie de propiedades relacionadas con los colores del control, el tamaño preferido para las columnas y, lo más importante de todo, una propiedad llamada MappingName, que debe corresponder al nombre de alguna de las tablas del conjunto de datos asociado. Cuando la rejilla tiene que mostrar una tabla determinada, busca en su colección TableStyles, para ver si alguno de sus elementos menciona el nombre de la tabla en su MappingName. Si no encuentra un estilo apro-piado, se utilizan los colores especificados directamente en el control.

A su vez, dentro de cada DataGridTableStyle se almacena una colección de estilos de columnas, que tenemos que configurar explícitamente si queremos ajustar las propie-dades de las columnas. De momento, no existe ningún asistente que nos ayude en esta tarea, una de las más ingratas en la actual versión de Visual Studio.

Hay dos clases predefinidas para los estilos de columnas, ambas derivadas de la clase base DataGridColumnStyle:

• DataGridTextBoxColumn: las columnas más habituales, que muestran cadenas de caracteres en sus celdas, y que se editan mediante un TextBox especializado que no tiene borde visible.

• DataGridBoolColumn: estas columnas se usan con datos de tipo lógico, y muestran en sus celdas controles CheckBox editables.

En verdad, no hay mucho. Se echan de menos columnas que puedan mostrar varias líneas de texto, o imágenes, o que pueden editarse mediante listas desplegables. Más adelante veremos que, con un poco de esfuerzo, podemos suplir estas carencias.

Estilos de columnas

Independientemente de su tipo concreto, todos los estilos de columnas ofrecen un núcleo básico de propiedades. La principal de ellas se llama, nuevamente, Mapping-Name, y debemos asignar en ella el nombre de una de las columnas de la tabla. Es

Page 244: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

244 La Cara Oculta de C#

curioso que Visual Studio no permita crear más de un estilo de columna para una misma columna.

El comportamiento de la propiedad HeaderText es bastante majadero. Esta propiedad contiene el texto que se debe mostrar en la cabecera de la columna, pero debemos asignarlo manualmente para evitar una cabecera sin texto. Sería de esperar que Visual Studio configurase automáticamente HeaderText una vez que estuviese asignado MappingName. Podría utilizar el propio nombre de la columna, o ir más lejos y extraer el valor de la propiedad Caption del objeto DataColumn asociado. Otras propiedades que podemos configurar son Alignment, ReadOnly y Width.

En el caso de las columnas de texto, la propiedad Format especifica cómo se con-vierte el valor de la columna de datos antes de ser dibujado dentro de la celda. Los posibles valores de Format dependen del tipo de datos de la columna asociada. Por ejemplo, una columna de tipo numérico puede utilizar el carácter C para indicar que se trata de valor monetario, N para incluir separadores de millares, o recurrir a P para mostrar porcentajes.

Las cadenas de formato estándar de C# pueden delegar detalles del formato en la información global en la “cultura” de la aplicación. Una aplicación que se ejecute en un sistema operativo configurado para España mostrará los precios en euros. Ahora bien, podemos mostrar precios en distintas monedas o, más en general, aplicar dis-tintas configuraciones de culturas a distintas columnas. Para ello, tenemos que mani-pular la propiedad FormatInfo de la columna de texto. Por desgracia, esta propiedad no está disponible en tiempo de diseño.

Configuración en tiempo de ejecución

Ahora que ya conocemos los problemas relacionados con la configuración de rejillas, conviene que nos preguntemos: ¿merece la pena utilizar los recursos en tiempo de diseño de Visual Studio para configurar un DataGrid? Mi opinión es que no... pero hay otras consideraciones que debe conocer. Mientras más capas de configuración redundantes se añaden a un sistema, más frágil se vuelve éste. Y en este caso no fal-tan capas, precisamente:

El diseño de una aplicación comienza por la base de datos, en la que definimos tablas y vistas, con sus correspondientes columnas. Ya dentro de la aplicación, esas colum-nas no se propagan automáticamente a nuestro código. Si añadimos columnas, o modificamos las propiedades de alguna de ellas, tendremos que modificar los con-juntos de datos que hayamos creado, para reflejar los cambios en los objetos de clase DataColumn. Para agravar el problema, si utilizamos rejillas con estilos de columnas configurados en tiempo de diseño, tendremos que mantener la sincronización de estas colecciones de diseños con las columnas reales, en la base de datos original.

Page 245: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 245

No se trata solamente de que tengamos que propagar cambios a través de varias capas, sino que esa propagación debe efectuarse a todo lo ancho, o largo si así lo pre-fiere, del proyecto. Por omisión, no existe ningún tipo de estructura central que con-tenga nuestras decisiones sobre el formato de columnas en rejillas. Cada cambio nos obligará a abrir cada formulario para averiguar si se verá afectado o no.

Y un último argumento: los cambios no sólo pueden originarse por modificaciones en tiempo de desarrollo en el esquema relacional. Una de mis aplicaciones sirve para administrar una tienda en Internet, y una de sus ventanas se ocupa de las palabras claves que se asocian a productos para su clasificación y búsqueda. Un registro de palabra clave tiene un par de columnas, como máximo, y en consecuencia, la rejilla donde mostraba las búsquedas de palabras había sido configurada con el mismo número de columnas. Un buen día se me ocurrió buscar las palabras claves más usa-das, es decir, aquellas que se asociaban a más productos. Dicho y hecho, pero ahora, aunque la nueva consulta devolvía una columna adicional con información útil sobre el número de productos asociados a cada palabra, no tenía dónde mostrarla.

NO

TA

Sé que por llevarme la contraria, usted dirá que debería haber previsto esa información, ya sea físicamente en la tabla, como una columna mantenida por triggers, o en las res-tantes consultas, como columna calculada. ¡Pero se trata precisamente de todo lo contra-rio! Efectuar esa “propagación inversa” habría sido igual de costoso. Y habríamos aña-dido aún más rigidez a la aplicación. Recuerde: el roble se yergue frente a la tormenta, y ésta lo derriba. La caña se dobla, pero al pasar la tempestad, vuelve a erguirse altiva...

Mi propuesta consiste en combinar dos técnicas. Primero, configure siempre las co-lumnas de una rejilla en tiempo de ejecución, en base a la estructura de la consulta cuyo resultado quiera mostrar en un momento dado. En segundo lugar, utilice reglas centralizadas para esta configuración. Almacene en un diccionario de datos, preferi-blemente local a la estación de trabajo, reglas generales sobre el tratamiento de tipos de datos. Luego, incluya reglas más específicas para las columnas de algunas tablas. Finalmente, intente que este diccionario de datos pueda ser configurado también por las acciones del usuario. Si el usuario aumenta el ancho de la columna Nombre de pro-ducto, recuérdelo.

Un sistema de este tipo suele requerir bastante trabajo inicial, aunque los beneficios a la larga compensen la inversión. En este libro sólo puedo mostrarle cómo crear esti-los para tablas y columnas en tiempo de ejecución. Y usted deberá extender el algo-ritmo para su sistema concreto. La técnica tiene su truco: no podemos añadir a la propiedad TableStyles de un DataGrid un estilo de tabla sin columnas. Por lo tanto, primero debemos crear el estilo de tabla sin llegar a conectarlo. A continuación, de-bemos añadirle los estilos de columnas. Y sólo entonces podemos añadir el estilo a la colección TableStyles. Este es el código que necesitamos:

private static void ConfigurarRejilla(

DataGrid rejilla, DataTable tabla)

{

DataGridTableStyle ts = new DataGridTableStyle();

ts.MappingName = tabla.TableName;

Page 246: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

246 La Cara Oculta de C#

foreach (DataColumn dc in tabla.Columns)

{

DataGridColumnStyle cs;

if (dc.DataType == typeof(bool))

cs = new DataGridBoolColumn();

else

cs = new DataGridTextBoxColumn();

cs.MappingName = dc.ColumnName;

cs.HeaderText = dc.Caption != "" ? dc.Caption : dc.ColumnName;

if (dc.DataType==typeof(int) || dc.DataType==typeof(decimal))

cs.Alignment = HorizontalAlignment.Right;

if (dc.AutoIncrement)

cs.ReadOnly = true;

ts.GridColumnStyles.Add(cs);

}

rejilla.TableStyles.Clear();

rejilla.TableStyles.Add(ts);

}

Recuerde que sólo se trata de una base: no puede esperar milagros de este sencillo método. Necesitará configurar los anchos de columnas, de acuerdo al tipo de datos y la longitud máxima de columna, cuando esté disponible. También deberá contemplar más tipos de datos para configurar la alineación. En lo que atañe al formato de pre-sentación, es muy probable que no lo pueda tener en cuenta hasta que agregue un diccionario de datos. Si encuentra una columna de tipo decimal, por ejemplo, no tendrá forma de decidir genéricamente si se trata de un precio, de un porcentaje... o de un error del diseñador de la base de datos, que confundió el tipo de la columna.

Creación de nuevas clases de columnas

Una de las omisiones más importantes de DataGrid, en mi humilde opinión, es la de una clase de columnas que permita la edición mediante combos. En su forma más simple, esta columna nos permitiría elegir un valor de una lista de valores fijos, espe-cificados en tiempo de diseño; en su forma más compleja, podríamos llenar la lista de valores desde una tabla auxiliar, o traducir el valor “real” almacenado en la columna en una descripción textual.

Por fortuna, es relativamente fácil crear nuevos tipos de columnas para DataGrid. En esta sección le mostraré cómo programar la variante más sencilla de columna con edición basada en combos. Para empezar, tenemos que crear una clase que descienda de DataGridColumnStyle, a la que llamaremos DataGridComboColumn:

public class DataGridComboColumn : DataGridColumnStyle

{

private ComboBox combo = new ComboBox();

private EventHandler ehTextChanged;

private bool editando = false;

public DataGridComboColumn() : base()

{

combo.Visible = false;

ehTextChanged = new EventHandler(ComboTextChanged);

}

Page 247: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 247

public ComboBox.ObjectCollection Items

{

get { return combo.Items; }

}

// …

}

La variable privada combo siempre apuntará a una instancia de la clase ComboBox, que inicialmente estará escondida. De ese combo nos interesa sobre todo su propiedad Items, la colección de elementos que aparecerá en la lista desplegable durante la edi-ción. Por este motivo, hacemos pública una propiedad también llamada Items en la clase de columna, para exportar la colección del combo interno. He aprovechado para declarar otra variable privada, editando, que nos indicará si estamos modificando el valor mostrado en el combo en un instante dado, y un puntero a delegado, al que he bautizado ehTextChanged. Este delegado se inicializa en el constructor para que apunte a un método de esta clase, ComboTextChanged. El delegado se añadirá y elimi-nará automáticamente de la lista de receptores del evento TextChanged del combo.

La ayuda del SDK deja bien claro qué tenemos que hacer para crear una clase deri-vada de DataGridColumnStyle: cuáles son los métodos virtuales que debemos redefinir y qué papel desempeña cada uno de ellos. Por ejemplo, tenemos que dar una imple-mentación apropiada a tres métodos relacionados con el tamaño de las celdas de nuestro tipo de columna. Los métodos mencionados establecen el tamaño preferido por el control, la altura mínima aceptada, y la altura preferida:

protected const int DEF_HEIGHT = 17;

protected override Size GetPreferredSize(Graphics g, object value)

{

return new Size(100, DEF_HEIGHT);

}

protected override int GetMinimumHeight()

{

return DEF_HEIGHT;

}

protected override int GetPreferredHeight(Graphics g, object value)

{

return DEF_HEIGHT;

}

NO

TA

Podemos encontrarnos con problemas al intentar reducir la altura de un combo por de-bajo de cierto tamaño, especialmente si estamos usando los temas de Windows XP. En realidad, deberíamos modificar la clase del combo para que el control no mostrase su borde, como sucede con el control de edición que muestran las columnas de texto.

Otro grupo de métodos virtuales se encarga de lo que sucede cuando comienza la edición de una celda, y cuando se aceptan o rechazan los cambios. Para iniciar el proceso de edición tenemos el método Edit:

protected override void Edit(

CurrencyManager source, int rowNum, Rectangle bounds,

bool readOnly, string instantText, bool cellIsVisible)

Page 248: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

248 La Cara Oculta de C#

{

combo.Text = (string) GetColumnValueAtRow(source, rowNum);

if (cellIsVisible) {

combo.Bounds = bounds;

combo.TextChanged += ehTextChanged;

}

combo.Visible = cellIsVisible;

if (cellIsVisible)

DataGridTableStyle.DataGrid.Invalidate(bounds);

}

Cuando se inicia el modo de edición de una celda de la columna, copiamos el valor de la celda en el combo, y añadimos a éste el receptor para el evento TextChanged que inicializamos en el constructor. La implementación del receptor, ComboTextChanged, es la siguiente:

private void ComboTextChanged(object sender, EventArgs e)

{

editando = true;

base.ColumnStartedEditing(combo);

}

Ahora tenemos que redefinir los métodos que se llaman cuando se aborta o con-firma la edición:

protected override void Abort(int rowNum)

{

editando = false;

combo.TextChanged -= ehTextChanged;

Invalidate();

}

protected override bool Commit(

CurrencyManager dataSource, int rowNum)

{

combo.Bounds = Rectangle.Empty;

combo.TextChanged -= ehTextChanged;

if (editando) {

editando = false;

try {

SetColumnValueAtRow(dataSource, rowNum, combo.Text);

}

catch (Exception) {

Abort(rowNum);

return false;

}

Invalidate();

}

return true;

}

Observe cómo borramos el manejador de eventos de la lista de delegados del evento TextChanged del combo.

Aunque no lo parezca, la parte más fácil de la clase es la redefinición de los tres mé-todos relacionados con la pintura. Estos métodos deberán dibujar el contenido de la celda cuando el editor no esté visible. Podemos, por lo tanto, limitarnos a repetir el

Page 249: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 249

comportamiento de una columna de texto normal, mostrando el valor de la columna como un texto estático. Dos de los métodos Paint pueden ser implementados me-diante llamadas al método que tiene más parámetros:

protected override void Paint(Graphics g, Rectangle bounds,

CurrencyManager source, int rowNum)

{

Paint(g, bounds, source, rowNum, false);

}

protected override void Paint(Graphics g, Rectangle bounds,

CurrencyManager source, int rowNum, bool alignToRight)

{

Paint(g, bounds, source, rowNum,

Brushes.Red, Brushes.Blue, alignToRight);

}

Y esta es la implementación final del método que dibuja el valor estático de una co-lumna combo:

protected override void Paint(Graphics g, Rectangle bounds,

CurrencyManager source, int rowNum,

Brush backBrush, Brush foreBrush, bool alignToRight)

{

Rectangle rect = bounds;

g.FillRectangle(backBrush,rect);

rect.Offset(0, 2);

rect.Height -= 2;

g.DrawString((string) GetColumnValueAtRow(source, rowNum),

DataGridTableStyle.DataGrid.Font, foreBrush, rect, format);

}

Por último, nos queda por redefinir un método que la columna utilizará para adjudi-carse la paternidad del combo cuando lo estime oportuno:

protected override void SetDataGridInColumn(DataGrid value)

{

base.SetDataGridInColumn(value);

if (combo.Parent != null)

combo.Parent.Controls.Remove(combo);

if (value != null)

value.Controls.Add(combo);

}

La mala noticia es que no he encontrado la manera de registrar una nueva clase de columnas para que pueda ser utilizada en tiempo de diseño por el editor de la rejilla de datos. Para probar el funcionamiento de la clase, deberá configurar una rejilla en tiempo de ejecución, como hemos visto antes, eligiendo alguna de las columnas de la tabla para que sea editada mediante un combo.

Sincronización de ventanas

Los contextos de enlace funcionan a nivel de ventana, como mínimo, y en ocasiones, a nivel de controles contenedores. Si una misma tabla en memoria se muestra en dos ventanas diferentes, cada una de estas ventanas puede mantener dos posiciones inde-

Page 250: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

250 La Cara Oculta de C#

pendientes sobre dicha tabla. Lo complicado será, en realidad, mantener sincroniza-das las dos posiciones...

Sin embargo, estamos hablando de una situación bastante frecuente. Estamos mos-trando una tabla sobre una rejilla, y al hacer doble clic sobre una de las filas, quere-mos que aparezca un cuadro de diálogo para editar en su interior el contenido de la fila. Los controles del diálogo deben enlazarse a una fuente de datos que no reside en el mismo formulario, y como consecuencia, ya no podemos establecer el enlace en tiempo de diseño. Además, la posición del currency manager del diálogo debe ser la misma que la posición del cursor en el contexto de enlace original.

Obviamente, la solución consiste en añadir código para resolver estos problemas en tiempo de ejecución, y las variantes tienen que ver con la forma en que organizamos las tareas, de modo que no tengamos que repetir demasiado trabajo para cada diá-logo modal de edición. Suponga que vamos a editar la información sobre un cliente en un formulario llamado EditCust. Podemos definir un método privado en la clase correspondiente que nos esconda los detalles de la creación del formulario y de su sincronización con un CurrencyManager ajeno:

public static bool Lanzar(CurrencyManager cm)

{

using (EditCust f = new EditCust()) {

f.Enlazar(cm);

return f.ShowDialog() == DialogResult.OK;

}

}

Esta vía, sin embargo, sería difícil de implementar en una clase base de una jerarquía de herencia, porque en el método hacemos referencia al tipo de la clase a crear, y esto sólo podría generalizarse recurriendo a la reflexión sobre tipos. No es imposible, pero sí innecesariamente complicado. Es mejor introducir un constructor que reciba como parámetro la referencia al CurrencyManager, aunque también es cierto que esto nos generará trabajo adicional para que al diseñador de formularios de Visual Studio no le entre el vértigo.

protected CurrencyManager cm;

public BaseDialogos(CurrencyManager cm)

{

this.cm = cm;

InitializeComponent();

this.Load += new EventHandler(Enlazar);

}

protected abstract void Enlazar(object sender, System.EventArgs e);

Como ve, el nuevo constructor almacena la referencia al CurrencyManager en una va-riable local de la clase. Al finalizar la configuración de los componentes añadidos en tiempo de diseño, el formulario base añade un método abstracto a la lista de delega-dos del evento Load del formulario.

Page 251: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Controles enlazados a datos 251

En un formulario derivado, tendríamos la responsabilidad de suministrar una imple-mentación para Enlazar:

protected override void Enlazar(object sender, System.EventArgs e)

{

DataRowView drv = cm.Current as DataRowView;

DataView dv = drv.DataView;

textBox1.DataBindings.Add(new Binding("Text", dv, "IDCliente"));

textBox2.DataBindings.Add(new Binding("Text", dv, "Nombre"));

textBox3.DataBindings.Add(new Binding("Text", dv, "Apellidos"));

textBox4.DataBindings.Add(new Binding("Text", dv, "DNI"));

BindingContext[dv].Position = cm.Position;

}

La propiedad Current del currency manager nos proporciona una vista de fila a partir de la cual podemos encontrar la vista de datos con la que estaba trabajando el formula-rio original. Entonces podemos crear nuestros propios enlaces a datos, para los con-troles del formulario derivado. Finalmente, a través del contexto de enlace local al-canzamos el currency manager también local, y hacemos que su posición coincida con la posición del cursor en el formulario que contiene la rejilla.

De paso, podemos aprovechar que el formulario base “conoce” con cuál fila está trabajando, para automatizar la aceptación o rechazo de los cambios al cerrar un cuadro de diálogo modal. Primero, tenemos que añadir una instrucción al construc-tor del formulario base:

public BaseDialogos(CurrencyManager cm)

{

this.cm = cm;

((DataRowView) cm.Current).BeginEdit();

InitializeComponent();

this.Load += new EventHandler(Enlazar);

}

La llamada a BeginEdit, sobre la fila que vamos a editar, hace posible que más adelante podamos rechazar todos los cambios ocurridos a partir de este momento. Esto se realizará durante la respuesta al evento Closing del formulario:

private void BaseDialogos_Closing(sender object,

System.ComponentModel.CancelEventArgs e)

{

if (! Modal) return;

DataRowView drv = (DataRowView) cm.Current;

if (DialogResult = DialogResult.OK)

drv.EndEdit();

else

drv.CancelEdit();

}

Page 252: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

17

Conexiones

A SABEMOS CÓMO MONTAR UN MODELO en miniatura de una base de datos relacional utilizando la memoria dinámica, y hemos aprendido a mostrar y editar

esta información por medio de controles visuales. Nos queda saber cómo copiar en este modelo la información proveniente de un sistema de bases de datos “real”, y necesitaremos varios capítulos para ello. En este primer capítulo, mostraremos cómo se establecen las conexiones con servidores y bases de datos, y veremos cómo recu-perar información sobre el esquema relacional de estas últimas.

Conexiones básicas

A diferencia de lo que sucede con las clases desconectadas de ADO.NET, existen varios juegos de clases conectadas, de acuerdo al tipo de servidor con el que deben funcionar. Para mantener cierto orden, ADO.NET exige que, independientemente del origen de los datos, todas las clases conectadas implementen una funcionalidad básica mínima. Por ejemplo, todas las clases que establecen una conexión con un servidor deben suministrar dos métodos llamados Open y Close, para abrir y cerrar la conexión.

Ahora bien, esos métodos no se definen en una hipotética clase abstracta, que podría llamarse DbConnection. Como sucede en los sistemas modernos, a las clases de cone-xión concretas se les exige que implementen un tipo de interfaz llamado IDbConnection. Estos son los métodos y propiedades definidos por IDbConnection:

Como puede ver, la interfaz declara un método con dos variantes, BeginTransaction, para iniciar transacciones, aunque no parece haber un mecanismo para la confirma-

Y

Page 253: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conexiones 253

ción o anulación; luego veremos por qué. Hay un método Open para abrir la cone-xión, y un método Close para cerrarla... y esto explica la existencia de una propiedad State, que pertenece al tipo enumerativo ConnectionState:

[Flags]

public enum ConnectionState {

Closed = 0, Open = 1,

Connecting = 2, Executing = 4, Fetching = 8, Broken = 16 }

NO

TA

A pesar de tanta exuberancia, ADO.NET sólo usa Closed y Open en su actual versión. El atributo Flags aplicado al tipo enumerativo, indica que estas constantes pueden combi-narse, como si fueran opciones independientes.

La consecuencia principal de todo esto es que, si queremos escribir código que pueda trabajar con cualquier tipo de servidor o proveedor de datos .NET, debemos utilizar un conjunto de tipos de interfaz básicos: IDbConnection para las conexiones, IDbCom-mand para ejecutar instrucciones, IDbTransaction para las transacciones...

Clases de conexión

Veamos ahora las clases concretas de conexiones. Los dos tipos de conexiones más populares en ADO.NET son las conexiones directas a SQL Server, y las conexiones que se realizan a través de proveedores de datos OLE DB. Las clases del primer grupo están definidas dentro del siguiente espacio de nombres:

System.Data.SqlClient

La clase de conexión concreta para SQL Server se llama SqlConnection. Todas las cla-ses definidas dentro de System.Data.SqlClient son clases implementadas completa-mente dentro de ADO.NET. En contraste, las clases de acceso a OLE DB utilizan la compatibilidad COM, y realizan llamadas a las clases ActiveX implementadas origi-nalmente por OLE DB. Se supone que una clase .NET pura ofrece un mejor rendi-miento y mucha más seguridad que una clase que deba realizar llamadas a objetos COM, o a funciones definidas dentro de una DLL en código nativo.

La clase de conexión en el mundo de OLE DB se llama OleDbConnection, y está decla-rada en este otro espacio de nombres:

System.Data.OleDb

En teoría, con este proveedor podríamos acceder a todas las fuentes de datos sopor-tadas por OLE DB, pero hay restricciones. En este momento, solamente se han cer-tificado los proveedores para SQL Server, Oracle y Access. Quizás no sea la omisión más importante, pero sí la que más llama la atención: no se debe utilizar el proveedor .NET para OLE DB con el adaptador de OLE DB para ODBC. No obstante, si lo que queremos es utilizar ODBC, tenemos una OdbcConnection en el siguiente espacio de nombres:

System.Data.Odbc

Page 254: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

254 La Cara Oculta de C#

E incluso existe una clase OracleConnection, creada por Microsoft, en el siguiente espa-cio de nombres:

System.Data.OracleClient

Estos dos últimos proveedores de datos aparecieron oficialmente en la versión 1.1 de la plataforma. Con esta versión apareció también un proveedor para SQL Server CE, una versión reducida de SQL Server para dispositivos móviles. El espacio de nom-bres correspondiente es:

System.Data.SqlServerCe

Existen también proveedores de datos proporcionados por otros fabricantes. Por ejemplo, Borland distribuye su propio Borland Data Provider para el acceso a su base de datos SQL propietaria: InterBase.

En este libro utilizaremos casi siempre el proveedor .NET nativo para SQL Server. Quitando algunos pequeños detalles, que explicaremos cuando aparezcan, los ejem-plos creados para este proveedor de datos pueden extrapolarse a las restantes interfa-ces de acceso.

Vínculos de datos en OLE DB

Vamos a comenzar dando un rodeo a primera vista extraño. Cree un fichero vacío en cualquier directorio, con el nombre que desee, y cambie su extensión a .udl. Ense-guida comprobará que se trata de una extensión registrada, porque cambiará el icono que lo identifica. Haga doble clic sobre el fichero, y verifique que se muestra el si-guiente diálogo de propiedades:

Las siglas UDL corresponden a Universal Data Link, o Enlace Universal de Datos, y el componente de Windows que registra la extensión es OLE DB. Un fichero UDL contiene una lista de parámetros que OLE DB puede utilizar posteriormente para

Page 255: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conexiones 255

establecer conexiones con cualquiera de las fuentes de datos soportadas. Y estamos hablando ahora de estas cadenas de conexión porque éstas se utilizan también para establecer conexiones en ADO.NET, cuando utilizamos el proveedor OLE DB. El asistente que hemos visto es muy útil para construir las cadenas de conexión, y más adelante veremos que el propio Visual Studio lo utiliza para registrar conexiones en la ventana del Explorador de Servidores.

Para configurar una cadena de conexión, en la primera página del asistente elegimos el proveedor OLE DB que vamos a usar. Por omisión, el asistente aparece con el proveedor ODBC seleccionado. Sin embargo, el proveedor de ADO.NET para OLE DB no puede trabajar con el proveedor nativo de ODBC. Tampoco es que nos per-damos gran cosa, porque se trata de un proveedor relativamente lento. Para este ejemplo, debe seleccionar el proveedor de Microsoft para SQL Server, y pasar a la siguiente página.

En la segunda página se muestran los parámetros básicos de la conexión, que depen-den de la elección realizada en la página anterior. Primero, elegimos el nombre del servidor. Si se trata de un servidor local, podemos dejar este parámetro en blanco. En la imagen del asistente aparece seleccionado un servidor remoto llamado christine. Si hay varias instancias configuradas en el servidor, tendremos que elegir una de ellas.

NO

TA

La versión Standard de Visual C# no permite trabajar, en tiempo de diseño, con servido-res remotos. Sólo permite el acceso a una instancia local del MSDE. Pero esto no quiere decir que la aplicación generada tenga prohibido el acceso en tiempo de ejecución a los servidores remotos. Sólo tenemos que cambiar el nombre del servidor en la cadena de conexión, en tiempo de ejecución.

A continuación, hay que indicar el modelo de seguridad que utilizará la conexión. Siempre que sea posible, debemos elegir la seguridad integrada, para evitar que el nombre de usuario y la contraseña queden a la vista de los curiosos. Finalmente, elegimos una de las bases de datos del servidor. Las restantes páginas del asistente sirven para configurar parámetros avanzados, pero podemos dejarlos con sus valores por omisión sin remordimientos.

Después de cerrar el asistente, abra el fichero UDL con el Bloc de Notas. Encontrará en su interior la siguiente cadena, escrita en una sola línea:

Provider=SQLOLEDB.1;Integrated Security=SSPI;

Persist Security Info=False;Initial Catalog=Northwind;

Data Source=christine

El formato es muy sencillo: una lista de parámetros separados por puntos y comas. Estos son los nombres de los parámetros comunes a todos los proveedores:

Parámetro Valor Provider El nombre del proveedor OLE DB; SQLOLEDB para SQL Server Data Source El nombre del servidor User ID La clave de usuario Password La contraseña

Page 256: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

256 La Cara Oculta de C#

En el caso de las conexiones con seguridad integrada de SQL Server, no se utilizan los parámetros User ID y Password. Estos otros parámetros son específicos para las conexiones con el proveedor de SQL Server:

Parámetro Valor Initial Catalog La base de datos que se debe activar inicialmente Integrated Security Activación de la seguridad integrada Application Name El nombre de la aplicación que hace la conexión Workstation ID Un identificador para el ordenador que actúa como cliente

Observe que algunos parámetros no han recibido un valor explícito en la cadena de conexión del ejemplo.

Si quisiéramos conectarnos a Oracle, deberíamos haber elegido el proveedor para Oracle de Microsoft, o el de la propia Oracle; cada uno tiene sus luces y sus sombras. Por supuesto, los parámetros de la segunda página cambiarían:

Escogiendo el proveedor Oracle de Microsoft, una configuración como la de la ima-gen se convertiría en la siguiente cadena de conexión:

Provider=MSDAORA.1;Password=tiger;User ID=scott;

Data Source=chrissie;Persist Security Info=True

El parámetro Persist Security Info indica que la contraseña se ha guardado en la propia cadena. Como comprenderá, esto plantea un problema de seguridad importante.

Cadenas de conexión para el cliente SQL

He comenzado por las cadenas de conexión de OLE DB porque, aunque no son idénticas, las cadenas de conexión del cliente nativo para SQL Server son muy simila-res. Este es un ejemplo mínimo de cadena de conexión correcta para este proveedor de datos:

server=(local);database=pubs;integrated security=yes

El parámetro server indica el nombre del servidor donde se está ejecutando la instan-cia de SQL Server. Observe que he indicado explícitamente que se trata del servidor local, y que para evitar confusiones con un posible ordenador llamado local, he ence-rrado esa cadena entre paréntesis. El parámetro database, evidentemente, corresponde al nombre de la base de datos concreta a la que vamos a conectarnos. Por último, al indicar el valor yes para el parámetro integrated security, hemos pedido que se utilice

Page 257: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conexiones 257

nuestra propia cuenta de usuario del sistema operativo para validar la conexión. Así evitamos tener que pasar un nombre de usuario y una contraseña de forma explícita. Estas son las principales equivalencias entre los parámetros de los dos proveedores:

OLE DB SQL Client Provider Desaparece Data Source Server, Data Source, Network Address, Address, Addr Initial Catalog Database, Initial Catalog Integrated Security Integrated Security, Trusted_connection User ID User ID, Uid Password Password, Pwd

Como puede ver, los nombres de la mayoría de los parámetros tienen sinónimos. Por ejemplo, en vez de server, podíamos haber escrito data source, o network address, o in-cluso address o addr. El parámetro initial catalog es un sinónimo aceptable de database. En vez de integrated security, podríamos haber escrito trusted_connection, y el proveedor de SQL Server no se habría enfadado. Y las misteriosas siglas sspi pueden sustituir a yes para configurar la seguridad integrada.

Mi consejo es que aprenda de memoria solamente el grupo de parámetros mínimos que vaya a utilizar. Siempre habrá una referencia a mano cuando aparezca algún pa-rámetro extravagante. De todos modos, no está de más conocer algunos de los res-tantes parámetros.

Por ejemplo, si no va a utilizar la seguridad integrada, puede indicar el nombre del usuario en el parámetro User ID, y la contraseña en Password. El primer parámetro también puede abreviarse a uid, y el segundo a pwd. Si su aplicación pide un nombre y contraseña al usuario para crear una cadena de conexión, compruebe que el usuario no le pasa de extranjis parámetros adicionales en estos valores. Supongamos que usted parte de una cadena de conexión inicial como la siguiente:

server=(local);database=mibd;uid=usuario;pwd=

Usted espera que el usuario teclee la contraseña, para añadirla a la cadena de cone-xión. Un usuario listillo podría teclear, literalmente:

micontraseña;database=otrabd

Está claro que, si no toma medidas, obtendrá una cadena de conexión con dos valo-res diferentes para el parámetro database. Y resulta que el proveedor de datos nativo para SQL Server utiliza siempre el último valor de la cadena en estos casos. Como consecuencia, el usuario se meterá en una base de datos no prevista por usted. Y cosas peores podrían ocurrir.

Hay otros dos parámetros que son muy útiles para seguir la pista de las instrucciones que llegan al servidor SQL, con la ayuda del profiler que éste ofrece. El primero de los parámetros es Workstation ID, en el que podemos asignar el nombre del ordenador desde donde se realiza la conexión. El segundo parámetro se llama Application name, y

Page 258: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

258 La Cara Oculta de C#

podemos asignarle un valor que identifique la aplicación a la que pertenece la cone-xión.

La clase de conexión de OLE DB

Aunque la mayoría de nuestros ejemplos utilizarán el proveedor de datos nativo de SQL Server, vamos a ser justos, aunque sea por una vez, con el proveedor OLE DB. La primera clase de conexión que estudiaremos será OleDbConnection, y primero ve-remos cómo crear instancias de esta clase en tiempo de ejecución. Hay dos variantes públicas del constructor: la primera no recibe parámetros, y la cadena de conexión debe asignarse posteriormente en la propiedad ConnectionString. La otra variante, más concisa, permite pasar la cadena de conexión como parámetro:

string cadenaConexion =

"Provider=SQLOLEDB;Data Source=;Initial Catalog=Northwind;" +

"Integrated Security=SSPI";

// Variante #1

OleDbConnection conn1 = new OleDbConnection(cadenaConexion);

// Variante #2

OleDbConnection conn2 = new OleDbConnection();

conn2.ConnectionString = cadenaConexion;

// Y ya podríamos abrir las conexiones

conn1.Open(); conn2.Open();

Pero en la mayoría de los casos, es más sencillo crear el objeto de conexión en tiempo de diseño, arrastrando el componente OleDbConnection desde la página Datos del Cuadro de Herramientas:

Si arrastramos el componente sobre una superficie de diseño que no corresponde a un control, como es el caso de una clase de componentes simple, o un servicio den-tro de una aplicación de servicios, la conexión aparecerá directamente dentro de esa superficie:

Pero cuando se arrastra un OleDbConnection sobre la superficie de diseño de un for-mulario o de un control, el icono que representa a la conexión se sitúa en un panel que queda debajo del formulario, y que recibe el nombre de Bandeja de Componentes, o Component Tray.

Page 259: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conexiones 259

Una vez que hemos creado el componente en tiempo de diseño, la única propiedad que tenemos que configurar es ConnectionString. Podemos escribir directamente la cadena de conexión, en el cuadro de edición de la propiedad, en el Inspector de Pro-piedades, o podemos desplegar una lista de posibles conexiones:

La lista de conexiones se obtiene de un útil asistente de Visual Studio que presentaré a continuación.

El Explorador de Servidores

Para simplificar la creación de conexiones y otros objetos de acceso a datos, Visual Studio ofrece una ventana llamada Explorador de Servidores. En ella se muestra un ár-bol con dos nodos principales: uno para las Conexiones de datos, y otro para Servidores. El nodo con los servidores no está disponible en la versión Standard.

Dentro del nodo Conexiones de datos podemos registrar cadenas de conexión que re-presenten conexiones con bases de datos existentes. El comando Agregar conexión, del menú local de la ventana, hace que aparezca el popular asistente de configuración de vínculos de datos de OLE DB.

Page 260: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

260 La Cara Oculta de C#

Si usamos la versión Standard, volvemos a encontrar restricciones. Al intentar crear una conexión a un SQL Server remoto, o al pretender utilizar un proveedor de datos que no sea el de SQL Server, recibiremos un mensaje de error:

NO

TA

¿Es ésta una limitación importante? Si quiere desarrollar aplicaciones para Oracle, DB2 o cualquier otro tipo de servidor, es mejor que se actualice a una versión igual o más po-tente que la Profesional. Pero si vamos a utilizar SQL Server solamente, podemos desa-rrollar la aplicación utilizando el servidor local del MSDE. Siempre podremos, en tiempo de ejecución, cambiar el nombre del servidor en la cadena de conexión.

Una vez creada una conexión, podemos explorar la información relacional de la base de datos correspondiente, que se organiza en forma de árbol. Podemos obtener in-formación sobre las tablas, vistas y procedimientos almacenados y sobre las colum-nas o parámetros de estos. Cada nodo nos permitirá las previsibles operaciones: por ejemplo, desde un nodo de tabla podemos pedir que se muestren las filas de la misma dentro de una rejilla de datos.

La clase de conexión del cliente SQL

Pero la operación más común será arrastrar un nodo del árbol sobre la superficie de diseño del entorno de desarrollo, para crear objetos. Si arrastramos el propio nodo que representa a la conexión, lo que sucede a continuación es interesante. Aunque hayamos definido la cadena de conexión con la ayuda de OLE DB, si la base de da-tos pertenece a SQL Server, Visual Studio transforma la cadena de conexión y crea un componente de la clase SqlConnection. He creado en mi ordenador una conexión basada en la siguiente cadena de parámetros:

Provider=SQLOLEDB.1;Integrated Security=SSPI;

Persist Security Info=False;Initial Catalog=Northwind;

Use Procedure for Prepare=1;Auto Translate=True;

Page 261: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conexiones 261

Packet Size=4096;Workstation ID=NAROA;

Use Encryption for Data=False;

Tag with column collation when possible=False

Al arrastrar el nodo sobre un formulario, Visual Studio creó una SqlConnection con la siguiente cadena de conexión:

initial catalog=Northwind;integrated security=SSPI;

persist security info=False;workstation id=NAROA;

packet size=4096

NO

TA

Puede que exista alguna forma de configurar el Explorador de Servidores para que cree instancias de las clases asociadas al proveedor OLE DB, pero no la he encontrado hasta el momento.

En cuanto a la clase SqlConnection, en sí, no tiene mayor misterio. Tiene dos cons-tructores, al igual que OleDbConnection, y podemos cambiar la cadena de conexión mediante su propiedad ConnectionString.

Propiedades dinámicas

Está claro que, incluso limitándonos a los elementos ya presentados, cualquier pro-gramador con un mínimo de habilidad podría arreglárselas para almacenar la cadena de conexión en algún medio externo a la aplicación. De esta manera, podría cambiar la configuración de la conexión sin necesidad de recompilar. Pero ADO.NET nos ayuda a llegar más lejos con menos esfuerzo.

Si tiene Visual Studio a mano, en cualquiera de sus variantes, cree un nuevo proyecto vacío, y arrastre sobre el formulario principal alguna de las conexiones registradas en el Explorador de Servidores. Como ya sabe, Visual Studio creará un componente perteneciente a la clase SqlConnection, y lo configurará con la cadena de conexión registrada. Este es el Inspector de Propiedades con la configuración del nuevo com-ponente. Las propiedades se han ordenado alfabéticamente:

La primera línea del Inspector se titula DynamicProperties, y si la expandimos, encon-traremos que Visual Studio ha “repetido” la entrada correspondiente a la propiedad ConnectionString... aunque su valor parece ser diferente. Si hace doble clic sobre el valor de la propiedad, debe aparecer un cuadro de diálogo como el siguiente:

Page 262: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

262 La Cara Oculta de C#

Si cierra el diálogo pulsando Aceptar y examina nuevamente el Inspector de Propie-dades, verá que ahora hay una pequeña marca al lado del nombre de la propiedad ConnectionString:

Para averiguar qué cambios se han producido, debemos ir al código fuente del for-mulario y analizar el contenido del método InitializeComponent, dentro de la región de código mantenida por Visual Studio. Dentro de este método, encontraremos esta primera línea de código:

System.Configuration.AppSettingsReader configurationAppSettings =

new System.Configuration.AppSettingsReader();

// …

El formulario está creando un objeto de la clase AppSettingsReader, con un construc-tor sin parámetros. Esta clase permite abrir un fichero .config asociado a la aplicación, y leer valores asociados a claves alfanuméricas.

Un poco más adelante encontraremos una instrucción parecida a la siguiente, dentro del grupo de instrucciones que sirven para configurar el componente de conexión:

this.sqlConn.ConnectionString =

((string)(configurationAppSettings.GetValue(

"sqlConn.ConnectionString", typeof(string))));

El contenido del fichero de configuración debe parecerse a lo siguiente:

Page 263: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conexiones 263

<?xml version="1.0" encoding="Windows-1252"?>

<configuration>

<appSettings>

<add key="sqlConn.ConnectionString"

value="initial catalog=pubs;integrated security=SSPI;

persist security info=False;workstation id=NAROA;

packet size=4096" />

</appSettings>

</configuration>

Por lo tanto, si modificamos el fichero de configuración, podemos cambiar la cadena de conexión sin necesidad de volver a compilar la aplicación.

NO

TA

A primera vista, puede parecer una mala idea almacenar información en un fichero de configuración en vez de usar el registro de Windows para ello. Y puede que sea cierto. Pero piense cuál de las dos técnicas prefiere si tiene que mantener una aplicación Web para terceros, en un servidor de Internet remoto, cuando la empresa solamente le auto-riza el acceso remoto mínimo al servidor mediante FTP...

Las conexiones basadas en OleDbConnection tienen una posibilidad adicional para mover la cadena de conexión fuera de la aplicación compilada. Podemos crear un fichero UDL y ubicarlo en el mismo directorio que la aplicación. Para utilizar la ca-dena de conexión almacenada dentro del fichero debemos modificar el contenido de la propiedad ConnectionString del componente:

file name=nombre_fichero.udl

Cuando no se especifica una ruta absoluta, OLE DB asume la ruta relativa al directo-rio de la aplicación. También podemos usar una ruta absoluta, aunque está claro que será más difícil de mantener.

La caché de conexiones

¿Hay algo tan complicado en abrir y cerrar una conexión que requiera toda una sec-ción de este libro? Lo complicado no es cómo hacerlo... sino cuándo hacerlo. Quiero decir, ¿cuál es el tiempo de vida adecuado para una conexión abierta? ¿Debemos abrir las conexiones al cargar la aplicación, y cerrarlas solamente al terminar? O por el contrario, ¿debemos abrir y cerrar la conexión para cada operación individual?

Primero, distingamos el caso de los sistemas cliente/servidor tradicionales: una apli-cación en una estación de trabajo se conecta directamente a un servidor SQL re-moto. Está claro que establecer la conexión cuesta lo suyo al cliente, y que una vez abierta, intentará mantenerla en ese estado. Sin embargo, eso es perjudicial para el servidor, que tendrá que mantener conexiones para cada cliente. Seamos sinceros: con este análisis pretendo convencer de que las cachés de conexiones son una estu-penda idea. Pues bien, en este escenario concreto, es imposible mejorar las cosas, ni siquiera con el mecanismo de caché que explicaré en breve.

Todo cambia en un sistema dividido en capas. En la arquitectura más común de este tipo, las conexiones a la base de datos siempre se establecen en la capa intermedia. Y

Page 264: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

264 La Cara Oculta de C#

aquí aparece la primera oportunidad para introducir mejoras: si las instancias del servidor de capa intermedia se ejecutan dentro de un mismo espacio de procesos, de modo que puedan compartir objetos entre ellas, se puede crear una caché de cone-xiones. Cada objeto de la capa intermedia debe colaborar indicando cuándo necesita realmente la conexión.

Podemos resumir la explicación:

• En una arquitectura cliente/servidor tradicional, no existen trucos mágicos. Te-nemos que escoger entre mantener abiertas las conexiones el mayor tiempo po-sible, y sobrecargar el servidor, o cerrar las conexiones cuando no las estemos usando, y ralentizar el funcionamiento global de la aplicación.

• En un sistema dividido en capas, con un servidor de capa intermedia responsa-ble del acceso SQL, tiene mucho sentido usar una caché de conexiones. Las co-nexiones sólo deben estar abiertas el tiempo mínimo requerido por operación.

Casi todos los proveedores de acceso a datos de .NET ofrecen algún mecanismo de caché, o como se dice en inglés, connection pooling. OLE DB y ODBC tienen cachés nativas de conexiones, y el proveedor especial de SQL Server implementa su propio caché dentro del código de la propia plataforma. La primera versión del proveedor de Borland para InterBase, en cambio, no implementa una caché de conexiones.

Sin importar el sistema concreto que utilicemos, hay unas cuantas reglas que deben cumplirse para que la caché de conexiones funcione como esperamos. Ya he men-cionado la primera regla: sólo se pueden reutilizar conexiones dentro de un mismo espacio de procesos. Una conexión abierta por la aplicación A no puede ser reciclada en beneficio de una aplicación B, ni siquiera para otra instancia independiente de la misma aplicación A dentro del mismo ordenador.

La regla más importante: para que la caché de conexiones funcione, los parámetros de conexión deben ser idénticos. Lógico, ¿verdad? Pero observe que hablo sobre parámetros, no sobre cadenas de conexión, y la culpa la tiene la seguridad integrada. Que dos conexiones se configuren con la misma cadena no quiere decir que sean intercambiables: si ambas utilizan seguridad integrada, pero son creadas por usuarios diferentes, está claro que no podemos permitir que un usuario disfrute de los permi-sos del otro. Ni nosotros, ni el sistema operativo.

Aparte de esto, cada proveedor de acceso ofrece algún mecanismo para controlar el funcionamiento de la caché. En el caso del proveedor de SQL Server, la configura-ción se establece en la cadena de conexión, y estos son los parámetros que nos inte-resan:

• Pooling Por omisión, vale true. Indica si esta conexión debe participar en la caché de co-nexiones.

Page 265: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conexiones 265

• MaxPoolSize Por omisión, 100. Es el número máximo de conexiones en la caché.

• MinPoolSize Por omisión, 0. Es el número mínimo de conexiones que se deben mantener en la caché.

• Connection reset Por omisión, true. Indica si se deben volver a inicializar las conexiones que se extraen de la caché. La explicación, un poco más adelante.

• Connection lifetime Por omisión, vale 0. Es el tiempo máximo que puede permanecer una conexión abierta en la caché. Transcurrido el intervalo, se cierra y destruye la conexión. El valor por omisión representa el intervalo infinito.

Hay que tener cuidado con la opción Connection reset. Como ya sabe, SQL Server mantiene algunas estructuras cuya identidad y tiempo de vida están ligados a una conexión. Dicho en cristiano: Transact SQL ofrece objetos como las tablas y proce-dimientos almacenados temporales, que se destruyen en la mayoría de los casos cuando el usuario que los ha creado cierra su conexión. Cuando una transacción pasa a la caché de conexiones, esos objetos siguen “vivos”, y pueden provocar sorpresas desagradables al programador que cree comprar una conexión recién salida de la fábrica, cuando en realidad le están dando chatarra reciclada. Por este motivo, cuando una conexión se reutiliza, la caché ejecuta una llamada al procedimiento almacenado sp_reset_connection, que no aparece documentado en la ayuda de SQL Server. Este procedimiento hace lo que anuncia: destruir las estructuras temporales ligadas a la conexión y devolver a su estado inicial otras variables y parámetros globales.

No obstante, este mecanismo de seguridad tiene su lado negativo: hace falta un viaje de ida y vuelta al servidor para ejecutar el procedimiento mencionado. Si usted no usa tablas y procedimientos temporales, o si está seguro de que estos siempre se destruyen explícitamente, puede ahorrar algo de tiempo asignando false en el pará-metro Connection reset de la cadena de conexión.

NO

TA

Cuando los servicios de capa intermedia se implementan sobre la infraestructura de los servicios COM+, es posible activar también una caché de objetos (object pooling). Incluso en el caso en que no utilicemos COM+, es posible implementar este tipo de caché ma-nualmente. Debemos comprender que este mecanismo puede sustituir a la caché de conexiones, pero que en realidad, es una técnica independiente que podemos combinar con ésta.

Eventos de las clases de conexión

Todas las clases de conexión de OLE DB disparan tres eventos. El primero de ellos es Disposed, que se activa cuando llamamos al método Dispose de la conexión, ya sea de forma directa o indirecta. Algo más útil resulta el evento StateChange, que se dis-para cuando el valor de la propiedad State de la conexión. Como en estos momentos,

Page 266: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

266 La Cara Oculta de C#

ADO.NET sólo soporta los estados Open y Closed, el evento no es ni remotamente interesante.

Algo más útil es el evento InfoMessage, que permite interceptar los mensajes de ad-vertencia y de información lanzados por el servidor. SQL Server trata de manera similar los mensajes de error, los de advertencia y los simplemente informativos; sólo los distingue de acuerdo a un valor numérico denominado nivel de severidad. Un men-saje informativo tiene la severidad más baja: cero. A medida que se va incrementando la severidad, el servidor responde de diferente manera al mensaje: a partir de cierto nivel, se produce una excepción, y los niveles de severidad más elevados pueden forzar el cierre de una conexión, e incluso pueden detener el servicio. Oracle puede enviar también advertencias al cliente de una conexión, como las que muestra SQL Plus cuando creamos un procedimiento almacenado o un trigger con errores de com-pilación.

El siguiente ejemplo muestra cómo lanzar un mensaje informativo desde Transact SQL mediante la instrucción print, y cómo recuperarlo mediante InfoMessage:

private void RecibirMensaje(

object sender, SqlInfoMessageEventArgs e)

{

MessageBox.Show(e.Message);

}

private void button1_Click(object sender, System.EventArgs e)

{

using (SqlConnection conn = new SqlConnection(

"trusted_connection=yes;database=Northwind"))

{

conn.InfoMessage +=

new SqlInfoMessageEventHandler(RecibirMensaje);

SqlCommand cmd = new SqlCommand(

"print 'Houston, tenemos un problema'", conn);

conn.Open();

cmd.ExecuteNonQuery();

conn.InfoMessage -=

new SqlInfoMessageEventHandler(RecibirMensaje);

}

}

He utilizado la propiedad Message del argumento del evento por simplicidad, pero si necesitamos más detalles sobre el mensaje, podríamos recorrer la colección Errors del objeto recibido por el manejador de eventos.

Transacciones explícitas en .NET

Nos queda por presentar el mecanismo de activación de transacciones explícitas, que son las transacciones activadas por el propio programador dentro del código fuente, en contraste con las transacciones automáticas o declarativas que manejan los servi-cios de COM+. Estas últimas las veremos en la tercera parte de este libro, al estudiar los servicios de componentes.

Page 267: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conexiones 267

Las transacciones en ADO.NET disponen de una clase propia. Existe un tipo de interfaz, IDbTransaction, que modela las características generales de una transacción, con independencia del servidor físico que la implementa:

public interface IDbTransaction: IDisposable

{

IDbConnection Connection { get; }

IsolationLevel IsolationLevel { get; }

void Commit();

void Rollback();

}

En la práctica, lo normal es trabajar con las clases concretas soportadas por cada tipo de proveedor: SqlTransaction, OleDbTransaction, OdbcTransaction... Para crear una tran-sacción, hay que ejecutar el método BeginTransaction de la correspondiente conexión. Lo curioso es que existe un método con este nombre en la interfaz IDbConnection:

public interface IDbConnection: IDisposable {

// …

IDbTransaction BeginTransaction();

IDbTransaction BeginTransaction(IsolationLevel);

}

Sin embargo, si examinamos una clase como SqlConnection, veremos una definición diferente para BeginTransaction:

public class SqlConnection: Component, IDbConnection, ICloneable {

// …

public SqlTransaction BeginTransaction();

public SqlTransaction BeginTransaction(IsolationLevel);

// Estas versiones permiten asignar un nombre a la transacción

public SqlTransaction BeginTransaction(string);

public SqlTransaction BeginTransaction(IsolationLevel, string);

// …

}

El tipo de retorno de BeginTransaction ha cambiado misteriosamente, y ahora devuelve directamente un objeto de la clase SqlTransaction, en vez de un puntero genérico a la interfaz IDbTransaction. Lo que sucede en realidad es que SqlConnection implementa la interfaz IDbConnection mediante la técnica conocida como implementación explícita, que genera métodos privados para la interfaz. Los métodos públicos de SqlConnection que sí podemos ver han sido añadidos directamente a la clase. Es casi seguro que internamente, los métodos de implementación de la interfaz llamen a estos métodos públicos.

De todos modos, la existencia de las interfaces genéricas de conexión y transacciones nos permiten escribir código genérico como el siguiente:

public void OperacionTransaccional(IDbConnection conn) {

// Iniciar la transacción por medio de la conexión

IDbTransaction trans = conn.BeginTransaction();

try {

// Ejecutar las operaciones correspondientes

// …

Page 268: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

268 La Cara Oculta de C#

// Si todo ha ido bien, confirmar

trans.Commit();

}

catch (Exception e) {

// Ha ocurrido un error: anulamos la transacción

trans.Rollback();

throw;

}

}

Cuando se trata de transacciones, es cuestión de vida o muerte capturar las posibles excepciones que ocurran después de iniciada la transacción.

Información sobre el esquema

¿Es ADO.NET la interfaz de programación perfecta? Espero no haberlo afirmado, porque ahora vamos a examinar un área en la que esta interfaz se queda corta: la recuperación de información sobre el esquema relacional de una base de datos; esas funciones que nos informan cuántas tablas y procedimientos almacenados tiene una base de datos o cuántas columnas e índices tiene una tabla.

Para ser sinceros, el cliente OLE DB ofrece un método en el componente de cone-xión llamado GetOleDbSchemaTable:

public DataTable GetOleDbSchemaTable(

Guid esquema, object[] restricciones);

No se preocupe por ver un GUID, o Global Unique Identifier, en el primer parámetro del método. Es cierto que estos son los números de 128 bits que COM utiliza para casi todo. Pero para usar con GetOleDbSchemaTable tenemos una serie de constantes de este tipo declaradas como campos estáticos de sólo lectura dentro de la clase au-xiliar OleDbSchemaGuid. Por ejemplo, si ejecutamos la siguiente llamada, obtendremos una tabla en memoria con un registro por cada tabla encontrada en la conexión:

oleDbConnection1.Open();

System.Data.DataTable t = oleDbConnection1.GetOleDbSchemaTable(

System.Data.OleDb.OleDbSchemaGuid.Tables, null);

Ahora bien, si no ponemos un filtro, el resultado del método contendrá todas las tablas de la base de datos asociada a la conexión, sin importar si se trata de tablas del sistema o definidas por el usuario. Para obtener sólo las tablas definidas por el usua-rio tendríamos que pasar un vector de restricciones en el segundo parámetro del método. Como el tipo de tabla viene en la cuarta columna del resultado, el vector que pasemos deberá tener al menos cuatro elementos, y los tres primeros deben contener

Page 269: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conexiones 269

un puntero nulo, porque no vamos a restringir el valor de la columna correspon-diente en el resultado:

oleDbConnection1.Open();

System.Data.DataTable t = oleDbConnection1.GetOleDbSchemaTable(

System.Data.OleDb.OleDbSchemaGuid.Tables,

new object[]{null, null, null, "TABLE"});

De todas maneras, GetOleDbSchemaTable es un recurso importado directamente desde OLE DB, y las restantes clases de conexiones existentes en la versión 1.1 de la plata-forma no ofrecen de momento nada parecido.

Programación con SQLDMO

Si vamos a trabajar con SQL Server, podemos utilizar una vieja interfaz disponible en este producto para el acceso y manipulación de la información de esquema: SQL Data Manipulation Objects, más conocida como SQLDMO. No es una interfaz que destaque por su rapidez ni por su sencillez de uso, pero resulta muy conveniente para programar algunas tareas de administración como las copias de seguridad. SQLDMO presenta un problema importante, además: no está disponible directamente dentro de la plataforma .NET. Pero ese es un motivo adicional para presentarla, porque aprovecharé para explicar cómo proceder en estos casos.

Cree un proyecto vacío, seleccione el nodo de Referencias en el Explorador de Solu-ciones, active el menú de contexto del nodo y ejecute el comando Agregar referencia:

Vamos a aprovechar que SQLDMO se presenta como una biblioteca de clases Acti-veX para que Visual Studio cree un envoltorio que nos permitirá utilizar sus clases y tipos de interfaz. En el diálogo que aparece, seleccione la segunda página, localice la entrada titulada Microsoft SQLDMO Object Library y añádala al proyecto como refe-rencia. Visual Studio, como he dicho antes, se ocupará de los detalles necesarios para

Page 270: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

270 La Cara Oculta de C#

crear un nuevo espacio de nombres dentro del proyecto, dentro del cual estarán to-das las clases que necesitamos.

El objetivo de nuestro ejemplo será relativamente simple: mostrar las bases de datos y las tablas alojadas dentro de una instancia de SQL Server, que para simplificar asu-miremos que es local. Primero, declaramos una variable privada en la clase del for-mulario, y la inicializamos como muestro a continuación:

private SQLDMO.SQLServer sqlServer = new SQLDMO.SQLServerClass();

El objeto que acabamos de crear es una cáscara vacía, al menos mientras no lo co-nectemos a un servidor. Eso es lo que haremos en la respuesta al evento Load del formulario:

private void MainForm_Load(object sender, System.EventArgs e)

{

sqlServer.LoginSecure = true;

sqlServer.Connect("", "", "");

TreeNode n = treeView.Nodes.Add("Servidor");

foreach (SQLDMO.Database db in sqlServer.Databases) {

TreeNode n1 = n.Nodes.Add(db.Name);

n1.Tag = "DataBase";

n1.Nodes.Add("");

}

}

La propiedad LoginSecure indica que utilizaremos la seguridad integrada, por lo que no tenemos que suministrar un nombre de usuario y una contraseña a los dos últimos parámetros del método Connect. Es más, como vamos a conectarnos a una instancia local, el primer parámetro también es una cadena vacía.

Después de establecida la conexión, hemos añadido un nodo principal al árbol, y lo hemos titulado Servidor. Luego hemos recorrido la colección almacenada en la pro-piedad Databases del objeto que representa al servidor, y por cada base de datos en-contrada hemos añadido un hijo al nodo principal. Observe dos detalles: en la pro-piedad Tag de cada nodo de base de datos hemos asignado una referencia a la cadena "DataBase", y para cada uno de estos nodos hemos creado también un hijo con texto vacío.

La propiedad Tag de los nodos existe precisamente para que asignemos en ella lo que nos venga en gana. En este ejemplo, la cadena en Tag nos permitirá identificar los nodos asociados a bases de datos para los que todavía no conocemos su lista de ta-

Page 271: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conexiones 271

blas. Los nodos hijos con texto vacío se añaden para garantizar que cada nodo de ba-ses de datos tenga inicialmente un botón de expansión a su izquierda. Las tablas se asocian en el momento en que se expande por primera vez uno de estos nodos, en respuesta al evento BeforeExpand del árbol:

private void treeView_BeforeExpand(

object sender, System.Windows.Forms.TreeViewCancelEventArgs e)

{

if ((string)e.Node.Tag == "DataBase")

{

SQLDMO.Database db = (SQLDMO.Database)

sqlServer.Databases.Item(e.Node.Text, null);

e.Node.Nodes.Clear();

e.Node.Tag = null;

foreach (SQLDMO.Table tb in db.Tables)

if (! tb.SystemObject) e.Node.Nodes.Add(tb.Name);

}

}

Para obtener el objeto que representa a la base de datos, volvemos a la propiedad Databases del objeto que representa al servidor, y para obtener las tablas, iteramos sobre la propiedad Tables del objeto obtenido. Antes quitamos el nodo ficticio creado durante la inicialización, y la cadena asociada a Tag, que ya no necesitaremos más. El resultado debe parecerse a la siguiente imagen:

NO

TA

SQL Server 2003 sustituirá SQLDMO por una nueva colección de clases llamada SQL Server Management Objects, o SMO. En líneas generales, la nueva interfaz se parece bastante a la antigua, pero es mucho más eficiente y, como cabe esperar, añade un buen puñado de clases para soportar las nuevas características introducidas en el motor de esta versión.

Page 272: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

18

Comandos y consultas

NA VEZ QUE HEMOS ESTABLECIDO UNA conexión con una base de datos, el si-guiente paso es enviar instrucciones al servidor, y recuperar registros mediante

consultas. Las clases que estudiaremos en este capítulo nos ayudarán en esta tarea. Tenga presente que muchas aplicaciones tienen más que suficiente con la funcionali-dad ofrecida por estas clases, especialmente las aplicaciones dedicadas a la impresión de informes y muchas aplicaciones basadas en HTML.

Haz lo que te digo

No es lo mismo pedirle al servidor que ejecute una instrucción, en plan “divide entre dos el salario de todos los abogados contratados”, que pedirle la lista de abogados contratados. En el primer caso, esperamos solamente una confirmación del tipo: “todo fue bien, excepto las dos costillas rotas de un abogado rebelde”. En el segundo caso, el servidor debe, precisamente, devolver una lista. Una lista no es una estructura de datos simple, y admite mil formas de representación. Para comenzar por lo más simple, primero intentaremos reducirle la paga a nuestras sanguijuelas.

Para enviar instrucciones al servidor, sean del tipo que sean, necesitamos una clase que implemente la interfaz IDbCommand. La clase concreta que utilicemos dependerá del proveedor de datos que vayamos a usar. Estas son nuestras opciones:

• Para SqlConnection, la clase es SqlCommand.

• Para OleDbConnection, la clase es OleDbCommand.

• Para OdbcConnection, tenemos OdbcCommand.

• Para OracleConnection, necesitamos la clase OracleCommand.

Naturalmente, comenzaremos nuestro estudio por SqlCommand, que ofrece la forma más eficiente de ejecutar comandos en SQL Server. Como es típico en ADO.NET, hay más de un constructor para la clase. Suponga que ya hemos creado un compo-nente de conexión, y que el comando SQL a ejecutar está almacenado en una cadena de caracteres. El siguiente método nos permitiría ejecutar dicho comando a través de la conexión existente:

int EjecutarComando(SqlConnection conexion, string instruccion) {

SqlCommand comando = new SqlCommand();

U

Page 273: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Comandos y consultas 273

comando.Connection = conexion;

comando.CommandText = instruccion;

return comando.ExecuteNonQuery();

}

Creamos un objeto de la clase SqlCommand, para luego asignar sus propiedades Con-nection y CommandText. Finalmente, enviamos la instrucción al servidor mediante el método ExecuteNonQuery, y propagamos el valor devuelto por el mismo. ¿El valor devuelto? Es el número de registros modificados por la operación. Con este método, ExecuteNonQuery, podríamos ejecutar instrucciones como la siguiente:

update NOMINA

set Salario = Salario / 2

where Ocupacion = 'Abogado'

El valor de retorno corresponderá al número de abogados que hayamos sumido en la más profunda depresión. Pero también podemos ejecutar instrucciones como la siguiente:

create procedure DeprimirAbogados

@factorDepresion integer as

begin

update NOMINA

set Salario = Salario / 2

where Ocupacion = 'Abogado'

end

No estoy hablando de ejecutar el procedimiento, ¡sino de ejecutar la instrucción que crea el procedimiento! Es una instrucción SQL válida, ¿por qué no íbamos a poder ejecutarla? La única diferencia consiste en que, esta vez, no se modifican registros... al menos directamente, porque con toda seguridad se producen cambios en las tablas del sistema. En todo caso, ExecuteNonQuery devuelve -1, si no detecta algún problema sintáctico.

Retornemos a los constructores, ¡porque existen tres más! Podemos ahorrarnos la asignación de la propiedad CommandText si pasamos la instrucción como parámetro al constructor:

SqlCommand cmd = new SqlCommand(instruccion);

cmd.Connection = conexion;

Podemos también pasar la conexión como parámetro:

SqlCommand cmd = new SqlCommand(instruccion, conexion);

Y, por último, tenemos una versión del constructor con tres parámetros, para ejecutar el comando dentro de una transacción local explícita:

SqlTransaction trans =

conexion.BeginTransaction(IsolationLevel.Serializable);

try {

SqlCommand cmd = new SqlCommand(instruccion, conexion, trans);

cmd.ExecuteNonQuery();

trans.Commit();

}

Page 274: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

274 La Cara Oculta de C#

catch (Exception e) {

trans.Rollback();

throw; // ¡¡No se "trague" la excepción, por favor!!

}

Observe que al final del bloque catch volvemos a lanzar la excepción. Si no lo ha-cemos así, el código encargado de ejecutar el fragmento anterior no tendrá forma de saber si el comando ha funcionado correctamente o no.

Cuando el comando es una consulta

Si el comando es una consulta, es decir, una instrucción select que define un con-junto de registros, necesitamos un mecanismo más potente que ExecuteNonQuery para devolver la lista potencial de registros asociados a la consulta. La forma más eficiente de recibir esa lista es por medio de un cursor unidireccional de sólo lectura: una téc-nica sencilla que consiste básicamente en ejecutar la consulta e ir pidiendo, uno a uno, los registros devueltos por el servidor, hasta que no quede ninguno. Decimos que el cursor es unidireccional porque no existe forma alguna de volver a pedir un registro ya recibido, a no ser que volvamos a evaluar la consulta. Y es de sólo lectura porque lo que obtenemos es, siempre, una copia de los registros reales, sin relación con la ubicación física de los mismos.

Este tipo de funcionalidad se encuentra, en un nivel más o menos profundo y es-condido, dentro de todas las interfaces de programación SQL. Por ejemplo, ésta era la única vía que la primera versión de JDBC, el estándar de facto de Java para el acceso a datos, ofrecía para recuperar el resultado de una consulta. Este tipo de mecanismo no permite implementar, al menos directamente, las técnicas de interacción a las que ya estamos habituados, como la navegación y edición sobre rejillas. Está claro que los cursores unidireccionales deben ser complementados con otras técnicas, y el com-plemento adecuado en ADO.NET son las clases desconectadas: en capítulos poste-riores aprenderemos a guardar dentro de un objeto de tipo DataSet una copia de los registros devueltos como consecuencia de la ejecución de una consulta.

Si queremos ejecutar una consulta, debemos utilizar el método ExecuteReader, de la correspondiente clase de comandos. En dependencia de la clase de comandos, el método debe devolver un objeto de una clase que implemente, como mínimo, la interfaz IDataReader. En el caso de SqlCommand, la clase devuelta es SqlDataReader.

Veamos primero un ejemplo completo, para explicar posteriormente los detalles. Cree un formulario con un control ListBox en su interior, e intercepte el evento Load del formulario. Durante la respuesta a este evento, vamos a crear una conexión, luego un comando, y a partir de éste obtendremos un SqlDataReader que utilizaremos para rellenar el control de listas con información extraída de la base de datos. Este es el código necesario:

private void MainForm_Load(object sender, System.EventArgs e)

{

SqlConnection conn = new SqlConnection(

"server=(local);database=pubs;integrated security=yes;");

Page 275: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Comandos y consultas 275

SqlCommand cmd = new SqlCommand("select * from authors", conn);

conn.Open();

SqlDataReader rd = cmd.ExecuteReader();

while (rd.Read())

listBox1.Items.Add(rd["au_fname"] + " " + rd["au_lname"]);

rd.Close();

conn.Close();

}

Y este debe ser el aspecto de la aplicación en funcionamiento:

Analicemos ahora el esqueleto lógico del ejemplo. Primero creamos un componente de conexión, para utilizarlo como segundo parámetro al construir el objeto de co-mando; el primer parámetro del constructor de SqlCommand es, naturalmente, la ins-trucción select que queremos ejecutar. Entonces abrimos la conexión: los objetos de comando nunca abren una conexión cerrada, sino que prefieren generar una excep-ción. Es importante tener esto muy claro, porque hay otros tipos de clases, como Sql-DataAdapter, que intentarían abrir su conexión asociada si fuese necesario.

Una vez abierta la conexión, llamamos a ExecuteReader para crear el lector de datos:

SqlDataReader rd = cmd.ExecuteReader();

La clase SqlDataReader no tiene constructores públicos, lo que significa que la única forma de crear sus instancias es llamando a ExecuteReader. En este primer ejemplo no hemos utilizado parámetros en la llamada, pero más adelante veremos cómo refinar el funcionamiento del lector pasando ciertos indicadores a ExecuteReader.

Ahora que ya tenemos el lector de datos, iniciamos el bucle de lectura. Observe que hay que ejecutar primero el método Read del lector para acceder a la posible primera fila del resultado:

while (rd.Read())

/* … hacer algo … */;

Esto es razonable, porque el lector de datos está imitando la lógica de funciona-miento de un cursor de Transact SQL, y el método Read equivale más o menos a la instrucción fetch. Luego veremos cómo acceder a los valores de columnas una vez recuperada una fila.

Page 276: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

276 La Cara Oculta de C#

Para terminar, cerramos el lector de datos y la conexión. Más adelante veremos que es sumamente importante garantizar el cierre correcto del lector.

NO

TA

El código del ejemplo que acabamos de ver es un desastre, tanto por su estilo como por sus consecuencias prácticas. Para empezar, si se dispara una excepción, es muy posible que la conexión, o incluso el propio cursor, retengan los recursos de la base de datos... al menos hasta el momento en que el recolector de basura haga que los objetos correspon-dientes pasen a mejor vida.

Acceso a columnas

Esta es la instrucción que en nuestro ejemplo extraía los valores del registro activo del lector para insertarlos en la lista:

listBox1.Items.Add(rd["au_fname"] + " " + rd["au_lname"]);

Evidentemente, rd no es un descendiente directo de la clase Array, por lo que pode-mos sospechar que estamos tratando con un indizador de C#... que es lo que real-mente sucede. En realidad, la clase SqlDataReader ofrece dos versiones sobrecargadas de un indizador de sólo lectura:

public virtual object this[string] {get;}

public virtual object this[int] {get;}

La versión que hemos usado es la primera, la que acepta una cadena de caracteres. Se supone que la cadena corresponde al nombre de una de las columnas de la consulta, y esto implica que cada vez que pedimos el valor de esta propiedad, la clase interna-mente tiene que buscar la columna a partir de su nombre. En la segunda versión, por el contrario, se utiliza la posición de la columna en el resultado, y tanto la intuición como la práctica demuestran que es una forma más eficiente de obtener valores del registro activo.

Debemos prestar atención también al tipo de retorno del indizador, que es un objeto, en abstracto. Por lo tanto, el indizador puede devolver cualquier tipo de datos esca-lar... siempre que ejecute antes la operación conocida como boxing, o empaqueta-miento. Para poder concatenar los valores de las dos columnas utilizadas en el ejem-plo, C# se ve obligado a ejecutar la operación inversa de unboxing, o desempaqueta-miento. No es que sean operaciones excesivamente lentas, pero nos asalta la sospecha de que podemos mejorar bastante el rendimiento de los accesos a columnas.

¿Qué tal si comenzamos por mejorar la búsqueda del nombre de columna, sacando esta operación del bucle? Si conociéramos con exactitud la posición de las columnas con el nombre y los apellidos, ésta sería tarea trivial, pero no es el caso. Por lo tanto, utilizaremos el método GetOrdinal para que realice la búsqueda, antes de comenzar el bucle:

SqlDataReader rd = cmd.ExecuteReader();

int nombre = rd.GetOrdinal("au_fname");

int apellidos = rd.GetOrdinal("au_lname");

Page 277: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Comandos y consultas 277

while (rd.Read())

listBox1.Items.Add(rd[nombre] + " " + rd[apellidos]);

NO

TA

Si GetOrdinal no encuentra la columna, se dispara una excepción. Enfrentadas a este hecho, las almas sensibles se lanzan a proteger el código con instrucciones try/catch. Y yo le pregunto: ¿qué haría usted en la cláusula catch? ¿Hay alguna “solución” a este error... que no sea mostrar el mensaje al usuario, algo que ya hace C#? Este no es el problema de manejo de excepciones que comenté al mostrar el ejemplo por vez primera.

El siguiente paso será evitar las operaciones de boxing y unboxing. Esta vez necesita-mos conocer de antemano el tipo de las columnas con las que vamos a trabajar. En este ejemplo se trata de cadenas de caracteres, por lo que podemos utilizar la función GetString para obtener directamente las cadenas de caracteres:

SqlDataReader rd = cmd.ExecuteReader();

int nombre = rd.GetOrdinal("au_fname");

int apellidos = rd.GetOrdinal("au_lname");

while (rd.Read())

listBox1.Items.Add(

rd.GetString[nombre] + " " +

rd.GetString[apellidos]);

Otro detalle que solemos olvidar: ¿cómo sabemos si el valor de determinada co-lumna es un nulo? Es que si intentamos utilizar alguno de los métodos GetXXX sobre una columna que almacenase un nulo, se generaría una excepción. Es cierto que podríamos aprovechar esa excepción para detectar nulos, pero sería un uso in-justificado de este mecanismo. Más eficiente, en este caso, es preguntar antes de in-tentarlo, y para ello tenemos el método IsDBNull:

public virtual bool IsDBNull(int i);

Incluso en el caso en que no conociéramos los tipos de datos de las columnas, po-dríamos utilizar otros miembros para obtener esta información. Por ejemplo, la pro-piedad FieldCount devuelve el número de columnas de la consulta, la función Get-FieldType averigua el tipo de datos de una columna, dada su posición, y tenemos in-cluso una función llamada GetDataTypeName para devolver el nombre del tipo como una cadena de caracteres. Y si quisiéramos información más detallada sobre la es-tructura del cursor, podríamos utilizar el método GetSchemaTable:

public DataTable GetSchemaTable();

Como puede ver, el método crea una tabla en memoria con una descripción detallada del esquema relacional de la consulta.

Conexiones muy ocupadas

Vamos a experimentar con una variante sencilla de nuestro ejemplo de lectura de registros. El experimento consistirá en activar, a la misma vez, dos lectores de datos que comparten conexión:

SqlConnection conn = new SqlConnection(

"server=(local);database=pubs;integrated security=yes;");

Page 278: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

278 La Cara Oculta de C#

SqlCommand cmd1 = new SqlCommand("select * from authors", conn);

SqlCommand cmd2 = new SqlCommand("select * from titles", conn);

conn.Open();

SqlDataReader rd1 = cmd1.ExecuteReader();

SqlDataReader rd2 = cmd2.ExecuteReader();

Ahora tenemos dos comandos, a los que hacemos referencia mediante las variables cmd1 y cmd2, y queremos obtener dos lectores de datos que obtendrán sus registros de consultas diferentes ejecutadas sobre la misma conexión... Lástima que cuando intente ejecutar este ejemplo, recibirá este estrepitoso mensaje de excepción:

¡Solamente podemos tener un lector activo por conexión! Y no se trata de un error, sino de una “característica” de la interfaz de acceso de bajo nivel a SQL Server. En realidad, no se trata sólo de un conflicto entre lectores de datos: si intentásemos ejecutar cualquier otro tipo de instrucción estando activo un lector, también se pro-duciría un error.

Este es un problema que siempre ha estado presente en la historia de SQL Server12, pero que en ocasiones ha sido enmascarado por ciertas interfaces de acceso. Por ejemplo, en ADO nunca se produce este error, porque ADO, calladamente, crea un clon de la conexión cuando intentamos abrir un segundo cursor. Con ADO.NET, Microsoft decidió no tomar este tipo de iniciativas, para simplificar la implementa-ción del proveedor de acceso .NET y para mejorar la velocidad de ejecución. Por lo tanto, lo único que nos queda, además del derecho al pataleo, es ocuparnos de cerrar siempre los lectores de datos lo antes posible.

Disparates excepcionales

Regresemos al ejemplo de carga de registros en una lista. Abstrayéndonos de los pequeños detalles, el algoritmo utilizado se parecía a esto:

SqlDataReader rd = cmd.ExecuteReader();

while (rd.Read()) {

// … lo que sea …

}

rd.Close();

12 Por cierto, SQL Server no es el único sistema de bases de datos que tiene esta característica.

Page 279: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Comandos y consultas 279

Ahora ya sabemos lo importante que es llamar a Close una vez que hayamos termi-nado con el lector. Sin embargo, antes de que podamos ejecutar Close pueden pasar muchas cosas, entre ellas, que se produzca una excepción y que se aborte la ejecución del algoritmo sin pasar por Close.

Recordemos que no es posible destruir objetos explícitamente en C#, porque esa tarea corresponde al recolector de basura. Olvidémonos por un momento de la ex-cepción y supongamos que, simplemente, el programador ha olvidado la llamada a Close y ha salido del método donde se creaba el lector de datos. Como la única refe-rencia a este objeto era la variable rd, local al método, al terminar su ejecución no quedarán referencias sobre el lector, y se convertirá en basura... ante la inmisericorde mirada del Supremo Recolector de Basura, que todo lo ve y lo oye. ¿Qué pasará con esta piltrafa cibernética, que está malgastando una preciosa conexión, cuando el gran Recolector la envíe al infierno con un soplido? Pues que se ejecutará el destructor de la clase: si el programador de SqlDataReader ha sido cuidadoso, este destructor debe-ría comprobar si la conexión asociada al lector está abierta, para entonces llamar a Close.

Por desgracia, no sabemos cuándo tendrá lugar el segundo Diluvio Universal que barrerá de la faz de la memoria RAM a los objetos pecadores. Puede ocurrir que, antes de que el destructor del objeto se ejecute, el programador intente abrir otra consulta usando la misma conexión. Y ya ha visto la debacle que esto provocaría.

NO

TA

Esta es una de las causas por las que no creo en la recolección automática de basura. Esta técnica asume que el recurso más importante de un entorno de programación es la memoria dinámica usada por los objetos, cuando en realidad es todo lo contrario. El pro-gramador suele confiarse, y entonces ocurren cosas inesperadas.

Por suerte para todos, existe una técnica muy sencilla que garantiza la ejecución de Close, pase lo que pase, y consiste en echar mano de la instrucción try/finally:

SqlDataReader rd = cmd.ExecuteReader();

try {

while (rd.Read()) {

// … lo que sea …

}

}

finally {

rd.Close();

}

Con esto podríamos dar por zanjado el asunto, pero quiero presentarle una forma más compacta de lograr el mismo objetivo: la instrucción using.

using (SqlDataReader rd = cmd.ExecuteReader()) {

while (rd.Read()) {

// … lo que sea …

}

}

Page 280: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

280 La Cara Oculta de C#

En la cabecera de la instrucción, que aparece encerrada entre paréntesis inmediata-mente después de la palabra clave using, se debe crear una instancia de una clase que implemente el tipo de interfaz IDisposable:

interface IDisposable {

void Dispose();

}

Tenemos suerte, porque ese es el caso de la clase SqlDataReader. La instrucción using es traducida internamente por C# de la siguiente manera:

{

SqlDataReader rd = cmd.ExecuteReader();

try {

while (rd.Read()) {

// … lo que sea …

}

}

finally {

if (rd != null)

((IDisposable) rd).Dispose();

}

}

Como puede ver, en la cláusula de finalización se ejecuta el método Dispose del objeto protegido por using. Y el método Dispose de la clase SqlDataReader es quien se en-carga, a su vez, de llamar al método Close.

Variaciones sobre una consulta

Antes mencioné la existencia de una versión del método ExecuteReader con un pará-metro, aunque hasta el momento hemos venido utilizando la variante sin parámetros. El prototipo alternativo de este método es el siguiente:

public SqlDataReader ExecuteReader(CommandBehavior behavior);

CommandBehavior es un tipo enumerativo, marcado con el atributo Flags, para indicar que sus constantes pueden combinarse entre sí. El simple listado de las constantes definidas para CommandBehavior arroja luz sobre varias características interesantes de los lectores de datos. Tenemos, por ejemplo, la constante CloseConnection; si la usamos al crear el lector, la llamada a Close sobre este objeto cerraría también la conexión asociada:

using (SqlDataReader rd =

cmd.ExecuteReader(CommandBehavior.CloseConnection)) {

while (rd.Read()) {

// … lo que sea …

}

}

// Al llegar a este punto, la conexión estará cerrada

Con ello, ahorramos una instrucción... aunque en ejemplos reales es poco probable que una conexión haya sido establecida para su uso exclusivo por parte de un lector

Page 281: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Comandos y consultas 281

de datos. Más interesantes resultan las constantes que influyen sobre la forma de acceso al resultado de la evaluación de la consulta. Por ejemplo, si usamos SchemaOnly el lector devolverá correctamente el número de columnas y los métodos que devuel-ven información sobre el esquema relacional de la consulta funcionarán correcta-mente. Pero no se devolverán filas de datos; el método Read devolverá false desde la primera llamada.

Tenemos incluso una constante SingleRow, para tratar más eficientemente el caso en que la consulta sólo devuelve una fila. Por ejemplo:

SqlCommand cmd = new SqlCommand(

"select * from authors where au_id = \'172-32-1176\'", sqlConn);

using (SqlDataReader rd =

cmd.ExecuteReader(CommandBehavior.SingleRow)) {

while (rd.Read()) {

// … lo que sea …

}

}

Una advertencia: especificar SingleRow sólo surte efecto si el proveedor .NET ha tomado medidas para mejorar este caso. En el caso de que estuviésemos trabajando con las clases de acceso de OLE DB, el proveedor asociado tendría que haber im-plementado la interfaz especial IRow. Pero ante la duda, no se pierde nada si pedimos la optimización, aunque el proveedor luego no pueda complacernos.

Recuperando más de una consulta

Hemos visto, al presentar Transact SQL, que SQL Server permite que enviemos más de una instrucción en cada petición de datos al servidor. La forma más simple de aprovechar esta característica sería incluir dos instrucciones select en un mismo lote:

select *

from dbo.PAISES

select *

from dbo.FORMASPAGO

Pero también podríamos devolver dos conjuntos de resultados desde el interior de un procedimiento almacenado de selección:

create procedure DatosDeReferencia as

begin

select *

from dbo.PAISES

select *

from dbo.FORMASPAGO

end

¿Podemos aprovechar esta posibilidad al trabajar con lectores de datos? ¡Por su-puesto que sí! Para ello, tenemos el método NextResult, cuyo uso puede apreciarse en el siguiente ejemplo:

Page 282: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

282 La Cara Oculta de C#

using (SqlDataReader rd = cmd.ExecuteReader()) {

do {

while (rd.Read()) {

// … lo que sea …

}

} while (rd.NextResult());

}

Precisamente, otra de las constantes de CommandBehavior se llama SingleResult, y puede utilizarse para indicar al lector de datos que no pierda el tiempo buscando conjuntos de resultados adicionales una vez que haya recuperado completamente el primero.

Una fila, una columna

Otro caso especial de consulta es aquel en que el resultado contiene una sola fila y una sola columna. Este es el ejemplo clásico:

select count(*)

from authors

En esta instrucción, la presencia de la función de conjunto garantiza que la consulta devuelva, al menos, una fila. Para instrucciones como ésta, la interfaz IDbCommand ha previsto el método ExecuteScalar, que como su nombre indica, devuelve el valor de la celda directamente, sin necesidad de instanciar un lector de datos:

public object ExecuteScalar();

Como el valor devuelto puede pertenecer a cualquier tipo almacenable en una base de datos SQL, el tipo de retorno de ExecuteScalar es object. Para obtener el valor real tendríamos que forzar una conversión de tipo, para que C# realizase el unboxing o desempaquetamiento del valor.

El siguiente método permite averiguar el número de filas de una tabla cuyo nombre pasamos como parámetro:

public int NumeroFilas(SqlConnection sqlConn, string tabla)

{

SqlCommand cmd = new SqlCommand(

"select count(*) from " + tabla, sqlConn);

bool wasOpen = sqlConn.State & ConnectionState.Open != 0;

if (! wasOpen)

sqlConn.Open();

try

{

return (int) cmd.ExecuteScalar();

}

finally

{

if (! wasOpen)

sqlConn.Close();

}

}

Observe que no hemos necesitado un lector de datos.

Page 283: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Comandos y consultas 283

Consultas con parámetros

Veamos ahora cómo podemos pasar parámetros sustituibles a las instrucciones que ejecutamos por medio de objetos de comando. Hace poco presentábamos un ejem-plo en el que pedíamos los datos de un autor conociendo su clave primaria:

select * from authors where au_id = '172-32-1176'

La clave primaria de la tabla authors es una cadena de caracteres, por lo que tenemos que encerrar la constante entre comillas simples, según la norma de Transact SQL. De hecho, la instrucción con la que creamos el comando era la siguiente:

SqlCommand cmd = new SqlCommand(

"select * from authors where au_id = '172-32-1176'", sqlConn);

Aunque no hemos tenido que utilizar el carácter de escape para las comillas, tendría-mos problemas cuando la constante contuviese comillas simples. ¿Y si tenemos que utilizar una constante de fecha? ¿Recuerda usted cuál es el delimitador de fechas de Transact SQL, y en qué orden relativo se escriben el día y el mes? Le confieso que yo no; siempre tengo que buscar la ayuda, llegado el momento.

En circunstancias como ésta, es preferible escribir una consulta con parámetros:

SqlCommand cmd = new SqlCommand(

"select * from authors where au_id = @au_id", sqlConn);

El símbolo @au_id actuará como un comodín, y antes de ejecutar el comando debe-mos instruir a éste para que sustituya el comodín por un valor concreto. Será el ob-jeto de comando el que se encargará de poner las comillas alrededor de las cons-tantes de cadenas, de formatear adecuadamente las constantes de fecha y hora, y en general, de todos esos sucios detalles lexicales.

Pero antes, debemos darle una pista al comando sobre nuestras intenciones. Debe-mos añadir definiciones de parámetros dentro de la propiedad Parameters del co-mando:

cmd.Parameters.Add("@au_id", SqlDbType.VarChar, 11).Value =

"172-32-1176";

Con la instrucción anterior, hemos identificado el tipo del parámetro, su longitud máxima y, de paso, le hemos asignado un valor inicial. Podríamos haber dejado la asignación del valor para más adelante, siempre que lo hiciéramos antes de ejecutar el comando. Tome nota también de que la propiedad Value del parámetro es de tipo object, y que acepta cualquier valor escalar que necesitemos asignarle.

Una vez creada la colección de parámetros, podemos acceder a cada uno de ellos para cambiar el valor asociado, en cualquier momento:

SqlCommand cmd = new SqlCommand(

"select count(*) from titleauthor where au_id = @au_id",

sqlConn);

Page 284: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

284 La Cara Oculta de C#

cmd.Parameters.Add("@au_id", SqlDbType.VarChar, 11);

// Contamos los libros escritos por 172-32-1176

cmd.Parameters[0].Value = "172-32-1176";

int libros = (int) cmd.ExecuteScalar();

// Y sumamos los libros escritos por 111-11-1111

cmd.Parameters["@au_id"].Value = "111-11-1111";

libros += (int) cmd.ExecuteScalar();

En el primer caso, nos referimos al parámetro por su posición, y en el segundo, por su nombre.

Parámetros en OLE DB, Oracle y ODBC

En el ejemplo de la sección anterior, como ha sido costumbre hasta el momento, he utilizado las clases de acceso directo a SQL Server: SqlCommand, SqlConnection, SqlParameter... Normalmente, no hay diferencias importantes en el manejo de estas clases respecto a otros tipos de proveedores de datos .NET. Pero el manejo de pará-metros es una de las pocas excepciones a la regla.

En realidad, el bicho raro es el proveedor de acceso directo a SQL Server, porque exige el uso de parámetros con nombres; la explicación es que este tipo de paráme-tros es manejado con mayor eficiencia por las interfaces de programación de más bajo a nivel. En cambio, los restantes proveedores de datos no pueden utilizar este tipo de parámetros, y deben limitarse a la asociación de parámetros por posición.

Veamos cómo se configurarían parámetros con el proveedor de OLE DB:

OleDbCommand cmd = new OleDbCommand(

"select count(*) from titleauthor where au_id = ?", conn);

cmd.Parameters.Add("au_id", OleDbType.VarChar, 11);

// Contamos los libros escritos por 172-32-1176

cmd.Parameters[0].Value = "172-32-1176";

int libros = (int) cmd.ExecuteScalar();

// Y sumamos los libros escritos por 111-11-1111

cmd.Parameters["au_id"].Value = "111-11-1111";

libros += (int) cmd.ExecuteScalar();

En primer lugar, en la instrucción SQL se utilizan signos de interrogación como marcadores de parámetros, en vez de los nombres precedidos por el carácter de arroba del cliente SQL. Sin embargo, cuando se define la colección de parámetros, también se usa un nombre de parámetro, aunque es cierto que sin la arroba inicial. Posteriormente, podemos indexar la colección de parámetros por posición o por los nombres simbólicos asociados durante su definición.

Ejecutando procedimientos almacenados

Otra de las posibilidades de los objetos de comando es la ejecución de procedimien-tos almacenados, tanto para realizar modificaciones como para devolver resultados de consultas. Para demostrar el uso de las técnicas disponibles, aprovecharemos dos procedimientos almacenados de la base de datos Northwind, de SQL Server.

Page 285: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Comandos y consultas 285

El primero de ellos ya viene creado, y esta es su definición, con algunos retoques:

create procedure SalesByCategory

@CategoryName nvarchar(15),

@OrdYear nvarchar(4) = '1998' as

begin

if @OrdYear not in ('1996', '1997', '1998')

set @OrdYear = '1998'

select ProductName,

TotalPurchase = round(sum(convert(decimal(14,2),

OD.Quantity * (1-OD.Discount) * OD.UnitPrice)), 0)

from [Order Details] OD, Orders O, Products P, Categories C

where OD.OrderID = O.OrderID and

OD.ProductID = P.ProductID and

P.CategoryID = C.CategoryID and

C.CategoryName = @CategoryName and

substring(convert(nvarchar(22), O.OrderDate, 111),

1, 4) = @OrdYear

group by ProductName

order by ProductName

end

Se trata de un procedimiento de selección con dos parámetros de entrada, que dada una categoría de productos y un año, devuelve las ventas totales para cada producto. La enrevesada maniobra para extraer el año de la fecha de venta se debe, probable-mente, a que una versión de esta base de datos también se utiliza en Access.

Por culpa también de la compatibilidad con Access, en esta base de datos no existen procedimientos que se limiten a modificar información. Por este motivo, crearemos nuestro propio procedimiento para el otro ejemplo:

create procedure NuevoEmpleado

@nombre varchar(10),

@apellidos varchar(20),

@total integer output as

begin

insert into employees (FirstName, LastName)

values (@nombre, @apellidos)

select @total = count(*)

from employees

return @@identity

end

El procedimiento recibe dos parámetros de entrada, y devuelve en un parámetro de salida el número total de empleados al finalizar la operación. Además, en el paráme-tro de retorno se devuelve el identificador asignado al registro recién creado, por medio del atributo de identidad.

Volviendo a los objetos de comando, la mejor manera de hacer referencia a un pro-cedimiento almacenado del tipo que sea, es modificar la propiedad CommandType para que valga StoredProcedure, y asignar entonces, en la propiedad CommandText, el nombre del procedimiento:

Page 286: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

286 La Cara Oculta de C#

SqlCommand cmd = new SqlCommand();

cmd.Connection = sqlConn;

cmd.CommandType = CommandType.StoredProcedure;

cmd.CommandText = "NuevoEmpleado";

Luego, tenemos que crear la lista de parámetros, de forma similar a como lo hicimos para las consultas con parámetros. Esta vez, la diferencia consiste en que hay que indicar cuidadosamente el “sentido” de los parámetros: si se trata de un parámetro de entrada, salida o de entrada y salida. Es cierto que no existen parámetros de salida específicos en SQL Server, pero otros sistemas sí lo soportan. En el caso de SQL Server, además, puede que necesitemos trabajar también con el parámetro de re-torno, como nos sucederá con NuevoEmpleado:

cmd.Parameters.Add("@RETURN_VALUE", SqlDbType.Int).Direction =

ParameterDirection.ReturnValue;

cmd.Parameters.Add("@nombre", SqlDbType.VarChar, 10).Direction =

ParameterDirection.Input;

cmd.Parameters.Add("@apellidos", SqlDbType.VarChar, 10).Direction =

ParameterDirection.Input;

cmd.Parameters.Add("@total", SqlDbType.Int).Direction =

ParameterDirection.InputOutput;

El método Add de la colección de parámetros no permite indicar la dirección del mismo, por lo que tenemos que realizar la asignación a continuación, sobre el objeto de parámetro devuelto por Add. Si estuviésemos trabajando con algún entorno de desarrollo, podríamos dejar que el propio editor de propiedades averiguase por no-sotros la lista de parámetros, como se muestra en el siguiente diálogo de propiedades de Visual Studio:

Una vez que hemos completado la lista de parámetros, es muy sencillo asignar valo-res concretos a los parámetros de entrada y ejecutar el procedimiento. Sólo debemos

Page 287: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Comandos y consultas 287

tener algo de cuidado con los parámetros de entrada y salida, y proporcionarles tam-bién un valor de entrada, aunque después sea ignorado por el procedimiento:

cmd.Parameters["@nombre"].Value = "Jack";

cmd.Parameters["@apellidos"].Value = "Ripper";

cmd.Parameters["@total"].Value = 0;

sqlConn.Open();

try {

cmd.ExecuteNonQuery();

}

finally {

sqlConn.Close();

}

MessageBox.Show(String.Format("Identificador: {0}\nTotal: {1}",

cmd.Parameters[0].Value, cmd.Parameters[3].Value));

NO

TA

A pesar de que el uso de nombres para los parámetros de procedimientos sugiere lo contrario, la asociación de parámetros formales con los parámetros reales tiene lugar por posición. El parámetro de retorno debe definirse siempre primero, por ejemplo.

La ejecución de procedimientos que devuelven conjuntos de resultados discurre por cauces similares. Pero hay que tener bien presente el orden de ejecución de las ins-trucciones del procedimiento:

• No se debe consultar el valor de los parámetros de salida ni del parámetro de retorno mientras haya un lector de datos abierto.

Tenga en cuenta que la devolución de los conjuntos de resultados tiene lugar antes de que el procedimiento almacenado termine su ejecución.

Lectura secuencial de blobs

Tenemos pendiente la recuperación de campos blobs mediante un lector de datos. Como el lector ya sabe, los campos blobs almacenan volúmenes potencialmente grandes de información, y entre otros usos sirven para representar imágenes y do-cumentos de gran tamaño. Si el tamaño real del contenido de un campo blob es sufi-cientemente pequeño, podríamos leerlo en una sola operación. Supondremos, por lo tanto, que se trata de blobs realmente grandes.

En este caso, puede que nos convenga ajustar un poco el funcionamiento del propio lector de datos. Ya sabemos que el lector nos suministrará las filas del resultado de manera secuencial: una vez que avanzamos a la fila siguiente, no existe forma de regresar a la fila anterior. Pero podemos acceder a las columnas de esa fila en el or-den que más nos convenga. Sin embargo, esto se debe a las precauciones que toma el lector de datos sin que nos demos cuenta. En realidad, la forma más eficiente de recuperar los resultados de una consulta exige un orden secuencial para la lectura de cada columna por separado. SqlDataReader, y las clases similares, almacenan los valo-res de las columnas en una estructura intermedia, antes de permitirnos el acceso a esos valores.

Page 288: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

288 La Cara Oculta de C#

Si vamos a trabajar con campos blob que consumen mucha memoria, este trabajo interno realizado por el lector es negativo: gastaremos memoria innecesaria para la estructura interna de la fila mantenida por el lector, entre otras cosas. Por este mo-tivo, cuando la consulta contiene campos blob de gran tamaño, es recomendable usar la constante SequentialAccess al crear el lector de datos:

SqlDataReader rd;

rd = cmd.ExecuteReader(CommandBehavior.SequentialAccess);

El precio que pagamos por esta optimización es la obligación de leer las columnas en orden estrictamente secuencial.

Cuando nos toque leer el contenido del campo blob, tendremos que usar el siguiente método del lector de datos:

public virtual long GetBytes(int columna, long posicion,

byte[] buffer, int posBuffer, int bytesLeer);

GetBytes nos permite leer un fragmento del blob sobre un buffer en memoria, que debe declararse como un vector de bytes. El parámetro posicion se refiere a una posi-ción dentro del blob, y posBuffer es la posición dentro del buffer donde copiaremos el fragmento. Por último, bytesLeer es el tamaño del fragmento que queremos leer, y el método devuelve el número de bytes reales que ha logrado leer.

Para ver cómo encajan todos estos elementos, veamos cómo leer una imagen alma-cenada en una tabla de SQL Server para mostrarla en un control PictureBox. Esta vez utilizaremos la base de datos pubs, también de los ejemplos de SQL Server. La tabla pub_info contiene un campo llamado logo, que es de tipo image. Para no complicarnos más con el manejo de parámetros, la sentencia SQL que usaremos buscará directa-mente una fila por medio de su clave primaria:

select pub_id, logo from pub_info where pub_id = '9952'

Este es el código que debe ejecutarse para leer y mostrar el logotipo:

private void bnImagen_Click(object sender, System.EventArgs e)

{

// El lector de datos cerrará la conexión

sqlConn.Open();

using (SqlDataReader rd = cmd.ExecuteReader(

CommandBehavior.SequentialAccess |

CommandBehavior.CloseConnection))

using (MemoryStream ms = new MemoryStream())

{

if (rd.Read()) {

// Debemos saltarnos el primer campo

rd.GetValue(0);

byte[] buffer = new byte[1024];

long leido, pos = 0;

while ((leido = rd.GetBytes(1, pos, buffer, 0, 1024))

> 0) {

ms.Write(buffer, 0, (int) leido);

Page 289: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Comandos y consultas 289

pos += leido;

}

}

ms.Position = 0;

pictureBox1.Image = new Bitmap(ms);

}

}

}

El método anterior lee trozos de la imagen, de 1024 bytes, y los va escribiendo en un flujo de datos en memoria, o memory stream. Al terminar la lectura, el flujo de datos creado se utiliza para crear un mapa de bits, que finalmente asignamos al control de imágenes para obtener un resultado parecido al siguiente:

No son imágenes por las que merezca la pena despeinarse, pero las he visto peores.

Comandos y transacciones explícitas

En el capítulo anterior vimos que para iniciar una transacción, debíamos ejecutar el método BeginTransaction del componente de conexión. Este método nos devolvía como resultado un puntero a un objeto de transacción, y para confirmar o anular la misma teníamos que ejecutar uno de sus métodos Commit o Rollback.

En la mayoría de los sistemas clásicos de bases de datos, una transacción se asociaba a una sola conexión, y viceversa, una conexión sólo podía tener una transacción ac-tiva, como máximo. Con este modelo, se simplificaba mucho la ejecución de coman-dos. Como un comando siempre se ejecuta dentro de una conexión, si la conexión tenía una transacción en marcha podíamos asumir que el comando se ejecutaría den-tro de ese contexto; si la conexión no tenía transacciones activas, el comando apro-vechaba la transacción implícita, o simplemente no se ejecutaba dentro de transac-ción alguna.

Pero la Informática ha cambiado mucho, y el modelo clásico de transacciones ya no se sostiene. Lo más común es ver transacciones que involucran a varios servidores y, por lo tanto, a varias conexiones. Y también puede ocurrir que determinados servi-dores admitan más de una transacción para una misma conexión. De momento, SQL Server no permite más de una transacción por conexión, pero quién sabe...

Page 290: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

290 La Cara Oculta de C#

Previendo ese momento, ADO.NET proporciona un mecanismo para asociar tran-sacciones y comandos que a primera vista parece algo absurdo... bueno, también a segunda y a tercera vista, pero nos consuela pensar que estamos trabajando por un futuro mejor. La técnica consiste en asignar la referencia a la transacción en la pro-piedad Transaction del comando. Si el comando pertenece a la clase SqlCommand, el tipo de su propiedad Transaction es SqlTransaction; si el comando pertenece a la clase OleDbCommand, el tipo de Transaction será OleDbTransaction, y así sucesivamente.

También podemos utilizar los tipos de interfaces comunes, y escribir un método auxiliar que nos simplifique los detalles mecánicos necesarios para ejecutar un co-mando dentro de una transacción:

protected static int EjecutarComando(

IDbCommand cmd, IsolationLevel nivelAislamiento)

{

IDbTransaction trans;

trans = cmd.Connection.BeginTransaction(nivelAislamiento);

try {

cmd.Transaction = trans;

int result = cmd.ExecuteNonQuery();

trans.Commit();

return result;

}

catch {

trans.Rollback();

throw;

}

finally {

cmd.Transaction = null;

}

}

Este método crea una nueva transacción. Si estuviésemos interesados en aprovechar una transacción ya existente, no necesitaríamos tanto código, por supuesto.

Por último, ¿qué sucede si, habiendo iniciado una transacción en la conexión, el valor de la propiedad Transaction de un comando es el puntero nulo, en el momento en que ejecutamos ese comando? Puede parecer lógico que el comando se ejecute dentro de una transacción “implícita”, o que no utilice transacciones, pero no: lo que ocurre es que provocaremos una excepción, al menos cuando trabajamos con SQL Server.

Microsoft Application Blocks

¿Cuántas líneas de código necesitamos para crear una conexión, asociarle un objeto de comandos y obtener un lector de datos a partir de este último? Como mínimo, harán falta tres o cuatro instrucciones. En el siguiente fragmento, he subrayado las dos variables que tendrían que suministrarnos para ejecutar este algoritmo:

SqlConnection conn = new SqlConnection(connectionString);

SqlCommand cmd = new SqlCommand(commandText, conn);

conn.Open();

SqlDataReader reader =

cmd.ExecuteReader(CommandBehaviour.CloseConnection);

Page 291: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Comandos y consultas 291

En realidad no es mucho código, si lo comparamos con algoritmos similares escritos en otros lenguajes, para otras interfaces de acceso a datos. Además, la relativa com-plejidad del fragmento anterior se debe a que no hemos aprovechado los recursos de diseño de Visual Studio, y hemos decidido crear e inicializar todos los objetos ma-nualmente.

Sin embargo, ésta será una situación bastante frecuente, sobre todo cuando desarro-llemos aplicaciones sin estado (stateless). Volveremos a este asunto en el último capítulo del libro: cuando se programan servicios Web para la capa intermedia, por ejemplo, no merece la pena que el servicio tenga que crear decenas de objetos de los cuales sólo utilizaremos tres o cuatro en cada llamada. En ese capítulo aprenderemos que una de las soluciones es encapsular grupos de objetos de acceso a datos en com-ponentes locales del proyecto. La otra solución es más antigua: utilizar rutinas para encapsular determinados algoritmos que se repiten con frecuencia.

Ese es el propósito de las bibliotecas de clases llamadas Microsoft Application Blocks. Se trata de ensamblados que podemos descargar desde la Web de Microsoft, que con-tienen clases auxiliares para reducir el volumen de código de nuestras aplicaciones. En este momento, nos interesa el Data Application Block, que contiene dos clases: Sql-Helper y SqlHelperParameterCache. Como el nombre de las clases indica, ambas trabajan con el proveedor .NET nativo para SQL Server, y todos los recursos públicos de ambas son métodos estáticos.

Con la ayuda de SqlHelper, por ejemplo, el código que he mostrado antes se reduciría a una sola instrucción:

SqlDataReader reader = SqlHelper.ExecuteReader(

connectionString, CommandType.Text, commandText);

No quiero extenderme demasiado con estas clases, entre otras cosas porque son muy fáciles de utilizar y vienen muy bien documentadas. Es cierto que nosotros mismos podemos desarrollar clases como éstas dentro de un proyecto, para ahorrarnos la distribución de un ensamblado adicional. Pero incluso en este caso, le convendrá descargar el application block, para examinar el estilo de programación recomendado por Microsoft.

Page 292: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

19

Adaptadores de datos

A LLEGADO EL MOMENTO DE UNIR LOS dos mundos: el de las clases conecta-das y el de las desconectadas. Los artífices de este encuentro serán las clases

que implementan la interfaz IDbDataAdapter, conocidas como adaptadores de datos. En este capítulo estudiaremos una de las facetas de estos adaptadores: la que permite llenar un conjunto de datos en memoria con información proveniente de consultas y procedimientos almacenados, y dejaremos para capítulos posteriores la explicación del proceso de actualización de la base de datos mediante estos intermediarios.

Estructura de los adaptadores

Suponga que tenemos en una mano un objeto de comando, digamos para concretar que se trata de un SqlCommand, y en la otra, un conjunto de datos en memoria: un objeto de la clase DataSet. La instrucción asociada al comando, por descontado, es una consulta SQL. ¿Qué deberíamos hacer para leer el resultado de la consulta y almacenarlo dentro del conjunto de datos? Podríamos obtener un lector de datos a través del comando, recorrerlo, e ir creando las filas en el conjunto de datos, en la tabla que corresponda. Si esto le parece una buena idea, permítame expresarle mi pesar por todo el tiempo que debe haber estado programando en Java. Ha estado demasiados años paseando por el Lado Oscuro de la Fuerza, y eso deja huellas.

En ADO.NET existen clases que se ocupan limpiamente de estos asuntos. Todas ellas tienen en común que implementan la interfaz IDbDataAdapter; en realidad, y al menos en la versión actual de ADO.NET, las clases de adaptadores existentes des-cienden todas de una clase abstracta común, DbDataAdapter... que a su vez imple-menta la interfaz IDbDataAdapter.

Las clases de adaptadores, en la versión 1.1 de la plataforma, son:

Clase Tipo de servidor SqlDataAdapter SQL Server OleDbDataAdapter Cualquiera compatible con OLE DB OdbcDataAdapter Cualquiera compatible con ODBC OracleDataAdapter Oracle SqlCeDataAdapter SQL Server CE

H

Page 293: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Adaptadores de datos 293

Por supuesto, cada fabricante puede suministrar sus propias clases de adaptadores. Para trabajar con InterBase, el sistema de bases de datos de Borland, deberá utilizar la clase BdpDataAdapter, definida dentro del espacio de nombres Borland.Data.Provider.

Los adaptadores de datos se encargan de dos tareas:

• Como hemos mencionado, permiten llenar un conjunto de datos o una tabla con filas obtenidas mediante un comando SQL.

• A la inversa, permiten generar y ejecutar comandos de actualización para sincro-nizar la base de datos con los cambios realizados sobre el conjunto de datos en memoria.

En este capítulo sólo nos ocuparemos de la primera tarea: extraer filas de una base de datos, para insertarlas en un DataSet. Para la lectura de la base de datos, un adaptador se basa en la propiedad llamada SelectCommand, que debe apuntar a un componente de comando:

La otra cara de la moneda, la relación entre el adaptador y la tabla o el conjunto de datos que debe recibir las filas, no es tan clara, sin embargo. Para empezar, el adapta-dor no cuenta con propiedad alguna que apunte al conjunto de datos o tabla. Tam-poco existe una propiedad en un conjunto de datos que haga referencia al adaptador. Simplemente, no existe una relación “permanente” entre adaptadores y tablas o conjuntos de datos.

Antes de explicar el motivo de este aparente divorcio, eche un vistazo al siguiente diagrama, que representa un típico sistema dividido en capas:

En este tipo de aplicaciones, el acceso a la base de datos SQL es responsabilidad de un servidor de capa intermedia, en el que se implementan las reglas de negocio. Los adaptadores de datos de ADO.NET deben ubicarse en esta capa. Cuando una aplica-ción quiere mostrar registros, debe pedirlos a la capa intermedia. El adaptador debe

Page 294: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

294 La Cara Oculta de C#

crear entonces un conjunto de datos y enviarlo a su cliente remoto. Cuando tratemos con .NET Remoting y los servicios Web, veremos que lo que se envía al cliente es una copia del objeto, no una referencia remota. Lo que ahora nos importa es que, para que el envío remoto sea factible, el conjunto de datos no debe hacer referencia a objetos ubicados en el servidor. Si esa referencia existiese, nos veríamos obligados a elegir entre dos males: enviar también una copia del objeto referido y aumentar el tráfico en la red, o enviar una referencia remota... y seguir realizando llamadas a tra-vés de la red cada vez que necesitásemos el valor de una propiedad del objeto, y cuando ejecutáramos alguno de sus métodos.

Carga de datos sobre tablas en memoria

Veamos, con un ejemplo sencillo, cómo tiene lugar la carga de registros sobre un conjunto de datos. Necesitaremos instanciar tres objetos sobre un formulario:

1 Una conexión, de tipo SqlConnection. a la que llamaremos sqlConn. En este ejem-plo, asumiré que la conexión hace referencia a la base de datos Northwind, de los ejemplos de SQL Server.

2 Un conjunto de datos, cuyo nombre será dataSet. Créelo como un conjunto de datos sin tipo.

3 Una rejilla de datos, dataGrid1. Debemos asignar una referencia a dataSet en su propiedad DataSource.

Toda la aventura tiene lugar durante la respuesta al evento Load del formulario:

private void MainForm_Load(object sender, System.EventArgs e)

{

SqlDataAdapter da;

da = new SqlDataAdapter("select * from customers", sqlConn);

da.Fill(dataSet);

dataGrid1.DataMember = "Table";

}

En primer lugar, tenemos la creación del adaptador de datos:

da = new SqlDataAdapter("select * from customers", sqlConn);

Esta es sólo una de las muchas versiones sobrecargadas del constructor del adapta-dor. El primer parámetro es la consulta SQL que queremos ejecutar, y el segundo, evidentemente, la conexión con la base de datos. Ambos parámetros se usan para crear un componente de comando, un SqlCommand. También podríamos haber cons-truido el comando antes, y pasar la referencia al constructor del adaptador:

cmd = new SqlCommand("select * from customers", sqlConn);

da = new SqlDataAdapter(cmd);

La instrucción más importante del ejemplo es la segunda:

da.Fill(dataSet);

Page 295: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Adaptadores de datos 295

He aquí la lista de acciones que desencadena el método Fill:

1 Se comprueba el estado de la conexión, y si no está abierta, se abre. 2 Se ejecuta el comando al que hace referencia la propiedad SelectCommand del

adaptador, y se obtiene un SqlDataReader como resultado. 3 El adaptador asocia automáticamente un nombre al resultado de la consulta. El

nombre utilizado es el almacenado en la constante DefaultSourceTableName, que por omisión contiene la cadena "Table".

4 El adaptador buscará una tabla con el nombre Table dentro del conjunto de da-tos. Si no existe tal tabla, la crea. Esta operación tiene muchas variantes, pero las presentaremos más adelante.

5 Se recorre cada fila devuelta por el lector de datos, y para cada una de ellas se crea una fila en la tabla seleccionada.

6 Al terminar el recorrido, el estado de todas las filas añadidas es Unchanged. Esto se controla mediante el valor de la propiedad AcceptChangesDuringFill, del adapta-dor. Por omisión, su valor es true.

7 Finalmente, la conexión vuelve al estado en que estaba antes de ejecutarse Fill.

Resumiendo: al terminar Fill, nuestro conjunto de datos va a disponer de una tabla interna llamada Table, con una copia de los registros devueltos por la consulta. La rejilla de datos, si hace memoria, estaba enlazada solamente al conjunto de datos. Para que muestre directamente el contenido de la tabla, asignamos ahora el nombre de la misma en su propiedad DataMember:

dataGrid1.DataMember = "Table";

Recuerde que ya habíamos asignado el conjunto de datos en la propiedad DataSource de la rejilla, en tiempo de diseño.

Lectura del esquema

Sin embargo, tuvimos que postergar hasta el momento de la ejecución la asignación de la propiedad DataMember de la rejilla. Nuestro conjunto de datos no tiene estruc-tura alguna en tiempo de diseño, y eso indica que Fill es capaz de definir esquemas y crear tablas dentro de un conjunto de datos. Veamos ahora con más detalles como transcurre esta fase de la operación de llenado.

El comportamiento de Fill viene determinado por el valor de una propiedad del adaptador llamada MissingSchemaAction. El valor de esta propiedad es consultado cuando no existe una tabla con el nombre adecuado, o cuando no existen las colum-nas necesarias dentro de la tabla encontrada. Hay cuatro posibles valores:

1 MissingSchemaAction.Add: Es el valor por omisión, e indica que el adaptador debe crear la tabla, si esta no existiese, y añadir las columnas necesarias, de acuerdo al esquema relacional de la consulta. Eso sí, el adaptador nunca crearía una clave primaria dentro de la tabla en memoria.

Page 296: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

296 La Cara Oculta de C#

2 MissingSchemaAction.AddKey: Igual al caso anterior, pero el adaptador puede averi-guar por nosotros la composición de la clave primaria. Tenga en cuenta que para obtener esta información adicional, el proveedor .NET se verá obligado a con-sultar las tablas del catálogo, y eso implica algo más de tiempo. En particular, en SQL Server se solicita esta información, con la ayuda de Transact SQL, inclu-yendo la cláusula for browse en la consulta a ejecutar.

3 MissingSchemaAction.Error: Si falta la tabla o cualquiera de sus columnas, se pro-duce una excepción.

4 MissingSchemaAction.Ignore: Se ignoran las columnas que falten en el esquema de la tabla en memoria.

Para probar el funcionamiento de MissingSchemaAction, vamos a añadir un método auxiliar a la clase del ejemplo anterior. El método, al que llamaremos ClavePrimaria, convertirá un vector de columnas en su representación textual:

private static string ClavePrimaria(DataColumn[] pk) {

// StringBuilder pertenece a System.Text

StringBuilder s = new StringBuilder();

foreach (DataColumn dc in pk) {

if (s.Length != 0) s.Append(",");

s.Append(dc.ColumnName);

}

return s.Length > 0 ? s.ToString() : "None";

}

NO

TA

Las cadenas de caracteres son tipos inmutables en C#, al igual que en Java. Cuando se concatenan dos cadenas y se guarda el resultado en una de ellas, estamos creando un tercer objeto independiente y descartando una referencia a uno de ellos. Por este motivo, cuando se trata de crear una cadena por concatenaciones sucesivas, como en este ejemplo, es más eficiente usar un StringBuilder, que es una clase que implementa la concatenación al final con un algoritmo más apropiado. En este ejemplo, la diferencia en velocidad no es crítica, pero debemos acostumbrarnos a esta técnica.

Modifique el código asociado al evento Load del formulario, para mostrar en un cua-dro de diálogo, al terminar la carga de filas, la clave primaria de la tabla en memoria creada con Fill:

SqlDataAdapter da;

da = new SqlDataAdapter("select * from customers", sqlConn);

da.MissingSchemaAction = MissingSchemaAction.AddWithKey;

// Pruebe también con el valor por omisión:

// da.MissingSchemaAction = MissingSchemaAction.Add;

da.Fill(dataSet, "clientes");

MessageBox.Show(ClavePrimaria(dataSet.Tables[0].PrimaryKey));

Si asignamos AddWithKey en MissingSchemaAction, el diálogo mostrará que la clave primaria es CustomerID; si dejamos el valor inicial Add, aparecerá un diálogo en blanco. Observe que en esta variante de la carga he utilizado otra versión de Fill:

da.Fill(dataSet, "clientes");

Page 297: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Adaptadores de datos 297

Con esta variante estoy obligando al adaptador a depositar las filas de la consulta en una tabla determinada. Es cierto que, antes de llamar a Fill, el conjunto de datos no tiene una tabla llamada clientes, pero la tabla que creará automáticamente el adaptador recibirá ese nombre.

NO

TA

Debe saber que existe un método en el adaptador llamado FillSchema, que sólo crea la estructura de la tabla asociada al adaptador, pero no trae registros. Este método no debe utilizarse en tiempo de ejecución: es menos eficiente traer primero el esquema y luego los registros, que ejecutar ambas operaciones de golpe. FillSchema ha sido diseñado para ser utilizado por herramientas en tiempo de diseño.

Cuando la tabla ya existe

En el ejemplo que hemos estado examinando, el adaptador crea una tabla basándose en el esquema relacional de la consulta que ejecuta. La única variante que hemos visto hasta el momento es la posibilidad de indicar, en la propia llamada a Fill, el nombre de la tabla que vamos a crear. Ahora veremos las alternativas:

• La tabla puede existir antes de ejecutar Fill. Esta es la metodología recomendada porque ofrece más facilidades en tiempo de diseño, y por ser más eficiente, en tiempo de ejecución. Cuando estudiemos los conjuntos de datos con tipos, en-contraremos más argumentos a favor de esta técnica.

• Puede que la tabla ya exista, pero que sus columnas tengan nombres diferentes que los de la consulta. O puede que queramos que el adaptador cree la tabla, pero con nombres diferentes para las columnas o para la propia tabla.

La primera variante, utilizar un conjunto de datos con su estructura ya creada, es fácil de implementar; realmente, no hay que hacer nada más. Es más interesante saber cómo podemos modificar los nombres de las columnas que se crean o se buscan. Para ello, se utiliza la propiedad TableMappings, del adaptador de datos:

public DataTableMappingCollection TableMappings { get; }

Cada elemento de esta colección consiste en un nombre para la tabla de origen, el nombre de la tabla destino, y una colección de correspondencias para las columnas; cada una de estas correspondencias, a su vez, contiene un nombre original y el nom-bre en el destino. Supongamos que el adaptador se basa en la siguiente consulta:

select c.CompanyName, count(*) total

from customers c inner join orders od

on c.customerid = od.customerid

group by c.CompanyName

NO

TA

Antes de mostrar las posibles correspondencias, observe que he tenido que asignar un sinónimo, total, a la columna que devuelve el número de pedidos. Sin este cambio, ten-dríamos que confiar en el sistema de asignación de nombres para columnas de expresio-nes que utilice el servidor SQL, y esa es una decisión muy peligrosa.

Con la consulta anterior, podríamos crear correspondencias como en este ejemplo:

Page 298: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

298 La Cara Oculta de C#

SqlDataAdapter da;

DataTableMapping tm;

da = new SqlDataAdapter(sqlCmd);

tm = TableMapping da.TableMappings.Add("Table", "Totales");

tm.ColumnMapping.Add("CompanyName", "Cliente");

tm.ColumnMapping.Add("Total", "Pedidos");

¡Que ni le pase por la cabeza utilizar la colección de correspondencias para traducir los nombres de columnas al castellano! Es una pésima idea. En primer lugar, porque la traducción de una aplicación no debe afectar al código fuente; debe hacerse me-diante ensamblados especiales de recursos. Por otra parte, es muy probable que los nombres de columnas traducidos contengan espacios en blancos y acentos. ¿Se da cuenta de que tendrá que bregar luego con estos incómodos nombres de columnas cuando tenga que programar filtros o generar las instrucciones de actualización?

En realidad, la única aplicación sensata de TableMappings, al menos cuando se especi-fican correspondencias entre nombres de columnas, es cuando tenemos un conjunto de datos ya creado, quizás proveniente de otra aplicación o de un servicio Web de terceros, y el esquema de nuestra base de datos no se adapta al del conjunto de datos. Dentro de poco presentaré otro uso para TableMappings, pero le adelanto que sola-mente involucrará nombres de tablas.

Consultas con parámetros

En contados casos nuestros adaptadores trabajarán con consultas tan sencillas como la del ejemplo que hemos estado analizando. La primera complicación con la que tropezaremos es el uso de consultas con parámetros. Por suerte para nosotros, es muy sencillo usar un adaptador con parámetros, porque estos parámetros pertenece-rán en realidad al comando almacenado en la propiedad SelectCommand, y ya sabemos, desde el capítulo anterior, cómo trabajar con ellos.

Supongamos que nos conectamos a un servidor mediante el proveedor .NET para SQL Server, y que queremos consultar los clientes de un determinado país:

select *

from customers

where Country = @country

Quizás lo más sencillo sea crear un SqlCommand en tiempo de diseño, configurar su propiedad CommandText y dejar que Visual Studio detecte el parámetro y genere el código necesario para la inicialización del comando. Una vez que tuviésemos el co-mando, crearíamos el adaptador mediante el constructor que recibe como parámetro un SqlCommand, y lo único que nos quedaría sería asignar un valor al parámetro antes de llamar a Fill. Por ejemplo:

// La variable sqlDataAdapter hace referencia …

// … a un adaptador ya construido

sqlDataAdapter.SelectCommand.Parameters["@country"].Value =

textBox1.Text;

sqlDataAdapter.Fill(dataSet, "clientes");

Page 299: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Adaptadores de datos 299

Alguien podría alegar que, llegados a este punto, igual nos convendría generar direc-tamente la consulta, en vez de complicarnos con parámetros. Recuerde, no obstante, que el parámetro nos evita enfrentarnos a problemas como el entrecomillar o no las constantes y el orden de las partes de una constante de fechas.

La clase DbDataAdapter, de la que descienden los adaptadores de datos, ofrece un atajo para acceder a los parámetros del objeto asignado en SelectCommand:

public virtual IDataParameter[] GetFillParameters();

Como se trata de un método definido en una clase base, los parámetros se almacenan en el vector de respuesta mediante la interfaz común IDataParameter, en vez de repre-sentarse con su tipo exacto, que en el caso de SqlDataAdapter, por ejemplo, sería Sql-Parameter. En el caso de un adaptador que sólo utiliza un parámetro en su comando de lectura, no es rentable utilizar este método auxiliar:

IDataParameters[] params = sqlDataAdapter.GetFillParameters();

params[0].Value = textBox1.Text;

Observe que, de todos modos, GetFillParameters nos obliga a localizar los parámetros por su posición.

Lotes de consultas

Hasta el momento, las consultas que hemos pasado al adaptador han sido consultas sencillas, que solamente devuelven un conjunto de resultados. Sabemos, sin embargo, que SQL Server nos permite escribir procedimientos almacenados que devuelven más de un conjunto de datos, o incluso lotes de consultas como el siguiente:

select *

from customers

where CustomerID = @customerID

select *

from orders

where CustomerID = @customerID

En el capítulo anterior vimos cómo un data reader se las arreglaba en estos casos reco-rriendo los conjuntos de resultados mediante el método NextResult. Los adaptadores también pueden funcionar en estas condiciones, creando o llenando una tabla para cada uno de los conjuntos de resultados que encuentre.

Por omisión, el primer conjunto de resultados se intentará almacenar en una tabla llamada Table, que como recordará, es el valor por omisión de DefaultSourceTableName. A partir de ese punto, las tablas se distinguirán por un sufijo numérico: Table1, Table2, y así sucesivamente. Está claro que sería bastante pesado trabajar con estos nombres por omisión. El truco al que recurriremos consiste en modificar las correspondencias de tablas, o table mappings, añadiendo las traducciones necesarias en la propiedad TableMappings del adaptador de datos.

Page 300: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

300 La Cara Oculta de C#

private void MainForm_Load(object sender, System.EventArgs e)

{

sqlCmd.Parameters[0].Value = "ALFKI";

using (SqlDataAdapter da = new SqlDataAdapter(sqlCmd))

{

da.TableMappings.Add("Table", "Clientes");

da.TableMappings.Add("Table1", "Pedidos");

da.Fill(dataSet);

}

// Crear una relación entre las dos tablas en memoria

DataTable clientes = dataSet.Tables["Clientes"];

DataTable pedidos = dataSet.Tables["Pedidos"];

dataSet.Relations.Add("Pedidos",

clientes.Columns["CustomerID"],

pedidos.Columns["CustomerID"]);

}

Al igual que hicimos en la sección anterior, he creado el objeto de comando sqlCmd en tiempo de diseño. Para que se haga una idea más completa, este es el código gene-rado por Visual Studio para el comando:

// sqlCmd

this.sqlCmd.CommandText =

"SELECT * FROM Customers WHERE customerID = @customerID;" +

"SELECT * FROM Orders WHERE CustomerID = @customerID";

this.sqlCmd.Connection = this.sqlConn;

this.sqlCmd.Parameters.Add(

new System.Data.SqlClient.SqlParameter("@customerID", ""));

Tenga en cuenta que lo más probable es que estas situaciones surjan al trabajar con procedimientos almacenados como el siguiente:

create procedure ClientesPedidos

@customerID nchar(5) as

begin

select *

from customers

where CustomerID = @customerID

select *

from orders

where CustomerID = @customerID

end

Pero es igual de sencillo, como vimos en el capítulo anterior, configurar un objeto de comando para obtener registros por medio de un procedimiento almacenado.

Relaciones maestro/detalles y adaptadores

Comandos como los que acabamos de presentar son comunes cuando hay que ma-nejar relaciones maestro/detalles. Si regresa al código del ejemplo, verá cómo, una vez que todos los registros están en memoria, creamos una relación entre las dos tablas que se han creado:

DataTable clientes = dataSet.Tables["Clientes"];

DataTable pedidos = dataSet.Tables["Pedidos"];

Page 301: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Adaptadores de datos 301

dataSet.Relations.Add("Pedidos", clientes.Columns["CustomerID"],

pedidos.Columns["CustomerID"]);

Claro, en este ejemplo particular la relación no tiene demasiada importancia, al me-nos para la navegación, porque la consulta maestra de clientes sólo devuelve un re-gistro, como máximo.

Al utilizar un solo comando para traer tanto las filas maestras como los detalles, es-tamos ahorrando tiempo de ejecución, y simplificando el código fuente. Para empe-zar, hemos necesitado asignar valores a un único parámetro. Sin embargo, cuando expliquemos las actualizaciones mediante adaptadores, veremos que es muy compli-cado utilizar un adaptador que lee más de un conjunto de resultados para modificar registros a la vuelta. La solución será sencilla, en cambio, si usamos adaptadores dife-rentes para la lectura y para las actualizaciones.

La otra forma de leer relaciones maestro/detalles mediante adaptadores consiste en utilizar un adaptador para cada consulta. Por ejemplo, configuraríamos un adaptador llamado daClientes con la siguiente consulta:

select *

from customers

where Country = @country

Para complicar un poco el ejemplo, la consulta maestra puede devolver ahora más de un registro. La consulta de detalles se configuraría con otro adaptador, daPedidos, al que se asociaría la siguiente instrucción:

select *

from orders

where CustomerID in (

select CustomerID

from customers

where Country = @country)

Tenga en cuenta que, en dependencia del tipo de servidor, puede que haya diferen-cias entre usar subconsultas, como acabamos de hacer, o recurrir a encuentros natu-rales, como el siguiente:

select od.*

from orders od inner join customers cu

on od.CustomerID = cu.CustomerID

where cu.Country = @country

En el caso de la base de datos Northwind, de SQL Server, la diferencia en tiempo de ejecución es imperceptible, sobre todo porque hay pocos registros. Pero los planes de ejecución son claramente distintos. Mi impresión informal es que los encuentros naturales suelen ejecutarse en menos tiempo, aunque es más fácil entender una ins-trucción basada en subconsultas.

Para leer las dos consultas dentro de un mismo conjunto de datos, al que llamaremos dataSet, haríamos más o menos lo siguiente:

Page 302: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

302 La Cara Oculta de C#

sqlConn.Open();

try

{

daClientes.Fill(dataSet, "clientes");

daPedidos.Fill(dataSet, "pedidos");

}

finally

{

sqlConn.Close();

}

dataSet.Relations.Add("PedidosXCliente",

dataSet.Tables["clientes"].Columns["CustomerID"],

dataSet.Tables["pedidos"].Columns["CustomerID"]);

El único detalle a destacar es que hemos abierto la conexión explícitamente, antes de llamar a los dos métodos Fill. De lo contrario, cada llamada abriría y cerraría la cone-xión. Es cierto que las consecuencias en velocidad no serían tan graves, al haber en juego una caché de conexiones, pero siempre será mejor no cerrar la conexión si hay que abrirla obligatoriamente a continuación.

Los datos más recientes

Casi al principio del capítulo, hemos visto que un adaptador podía indagar, si así se lo pedíamos, sobre la composición de la clave primaria de los registros recuperados. ¿Qué utilidad puede tener esta característica? A fin de cuentas, si la propia base de datos mantiene la restricción de unicidad generada por la clave primaria, es imposible que durante la ejecución de Fill, se viole esta condición dentro del conjunto de datos en memoria... a no ser que el conjunto de datos no esté vacío cuando llamemos a Fill.

Es ahí donde está la respuesta: podemos ejecutar Fill sin necesidad de limpiar pre-viamente el contenido del conjunto de datos. Si la nueva operación devuelve registros diferentes, estupendo. Pero si se ejecuta la misma instrucción, es muy probable que obtengamos los mismos registros que ya se encuentran en memoria, y puede que algún registro nuevo, insertado desde otro ordenador, o incluso desde otro proceso. En este caso, si la tabla en memoria tiene una clave primaria, ésta se utiliza para con-ciliar los registros ya presentes con los recién llegados. El resultado de la segunda llamada a Fill es actualizar el contenido de la tabla en memoria con las versiones más recientes de los registros, según la base de datos.

Hay que tener mucho cuidado con esta técnica. Por ejemplo, ¿qué sucede si alguien ha borrado uno de los registros leídos en la primera llamada a Fill? Lamentable-mente, el registro fantasma no se borra; luego comprenderemos por qué. Por este motivo, si lo que queremos es recuperar la versión más reciente de los registros de una tabla en memoria, lo mejor es limpiar la tabla, o el conjunto de datos, y volver a ejecutar la consulta. De esta manera, además, evitamos la pequeña penalización que provoca la búsqueda de registros en memoria dada su clave primaria.

¿Quiere esto decir que nunca debemos ejecutar Fill sobre un conjunto de datos con registros? Tampoco es eso. En realidad, con algunas modificaciones, la técnica expli-

Page 303: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Adaptadores de datos 303

cada nos será muy útil cuando queremos la versión más reciente de uno de los regis-tros de una tabla en memoria.

Vamos a suponer que estamos mostrando, en una rejilla de datos, el contenido de la tabla de clientes de Northwind:

select * from customers

Para simplificar asumiré que el adaptador de datos se configura en tiempo de diseño, y que la tabla en memoria que éste genera, tiene asignada su correspondiente clave primaria. Para garantizar esto último, asigne en la propiedad MissingSchemaAction del adaptador el valor AddWithKey.

Debemos añadir un botón al formulario, como se muestra en la imagen anterior. Queremos que, cuando pulsemos el botón, se lea la versión más reciente del registro seleccionado en la rejilla, y para ello nos bastará el siguiente manejador de eventos:

private void bnRefresh_Click(object sender, System.EventArgs e)

{

// Obtenemos la clave primaria del registro activo

DataRowView drv = (DataRowView)

BindingContext[dataSet, "Clientes"].Current;

string custID = (string) drv["CustomerID"];

// Recuperamos su versión más reciente

using (SqlDataAdapter da = new SqlDataAdapter(

"select * from customers where CustomerID = @customerID",

sqlConn))

{

da.SelectCommand.Parameters.Add("@customerID", custID);

da.Fill(dataSet, "Clientes");

}

}

Las dos primeras instrucciones nos sirven para averiguar el valor de la clave primaria en el registro seleccionado:

Page 304: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

304 La Cara Oculta de C#

DataRowView drv = (DataRowView)

BindingContext[dataSet, "Clientes"].Current;

string custID = (string) drv["CustomerID"];

A continuación, creamos un adaptador auxiliar, asociado a una consulta que devuelva solamente un registro:

select *

from customers

where CustomerID = @customerID

Sólo nos queda crear el parámetro, asociarle el valor de la clave primaria de la fila activa, y ejecutar el método Fill de este adaptador auxiliar. Si nadie ha borrado el registro a nuestras espaldas, el adaptador buscará la versión “antigua” dentro de la tabla, y la sustituirá por la nueva versión... que dicho sea de paso, puede tener los mismos valores.

¿Y si alguien ha eliminado el registro, mientras tanto? No he contemplado este caso en el ejemplo. Pero le dejaré encargado, como experimento, la revisión del valor de retorno de Fill. Este método devuelve el número de filas leídas por el adaptador. Si descubrimos que ha devuelto un cero, puede eliminar la fila ejecutando su método Delete.

if (da.Fill(dataSet, "Clientes") == 0)

drv.Delete();

Desactivando temporalmente las restricciones

La existencia de una clave primaria nos ha resultado útil en la sección anterior. Pero en algunos casos, puede que en la mayoría, esta restricción puede resultar un estorbo. Supongamos que queremos leer una consulta, y que no pensamos modificar el con-junto de datos obtenido. Pongamos por caso también que siempre llamaremos a Fill sobre un conjunto de datos vacío. ¿Qué sentido tendría, entonces, verificar que no hay conflictos con las filas que leamos? Ninguno, porque la propia base de datos nos garantiza la unicidad de las claves.

En cambio, lo que sí ocurrirá es que la ejecución de Fill tardará más, si hemos defi-nido una clave primaria manualmente, como veremos que sucede en los conjuntos de datos con tipo. Para cada fila que se lea, el adaptador comprobará laboriosamente si hay algún conflicto con su clave.

Para evitar este problema debemos utilizar la propiedad EnforceConstraints del con-junto de datos, para desactivar la verificación de restricciones temporalmente, du-rante la ejecución del método Fill del adaptador:

bool saveConstraints = dataSet.EnforceConstraints;

dataSet.EnforceConstraints = false;

try

{

sqlDataAdapter.Fill(dataSet);

}

Page 305: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Adaptadores de datos 305

finally

{

dataSet.EnforceConstraints = saveConstraints;

}

Cuando volvamos a activar EnforceConstraints, las restricciones existentes se compro-barán. A pesar de esto, siempre será más eficiente comprobar la unicidad de la clave primaria de golpe respecto a la verificación fila por fila.

Page 306: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

20

Conjuntos de datos con tipos

NA DE LAS CARACTERÍSTICAS MÁS INTERESANTES de ADO.NET es la posibili-dad de crear clases que faciliten el acceso a los conjuntos de datos en memoria,

aprovechando el conocimiento por anticipado del esquema relacional. En este capí-tulo veremos qué se encapsula dentro de estas clases, qué parte es la que queda como interfaz pública, por qué esta técnica nos permite programar con mayor eficiencia y seguridad, y cuáles son sus límites... porque seguro que los hay.

La fuerza de la encapsulación

Hasta el momento, hemos utilizado conjuntos de datos genéricos en nuestros ejem-plos. Un conjunto de datos genérico es una estructura de datos muy flexible, que puede soportar muchos cambios en el esquema relacional sin graves consecuencias para el código de la aplicación. Esta flexibilidad tiene un precio: es muy fácil que nos equivoquemos acerca de la disponibilidad de una columna, su tipo de datos o su longitud máxima. La alternativa tradicional a los conjuntos de datos genéricos es el uso de clases persistentes para representar objetos de negocio. Las equivocaciones respecto al esquema relacional desaparecen, pero cualquier mínimo cambio en la base de datos nos obliga a revisar todas las clases afectadas.

Los conjuntos de datos con tipos (strongly typed datasets) ofrecen otra solución, a me-dio camino entre los extremos antes mencionados:

• Un conjunto de datos con tipos es una instancia de una clase generada mediante asistentes, que desciende por herencia de la clase genérica DataSet. Todas las propiedades y métodos de los conjuntos de datos genéricos siguen disponibles en la nueva clase.

• La clase del conjunto de datos con tipos añade propiedades, métodos y eventos especiales para facilitar el acceso a los objetos almacenados dentro de la instan-cia: tablas, columnas, filas, relaciones. Incluso se añaden clases anidadas para re-presentar estos objetos internos con mayor exactitud.

Para hacernos una idea rápida, compararemos un fragmento de código que utilice los recursos genéricos de DataSet, con un algoritmo equivalente que aproveche las facili-dades de un conjunto de datos con tipos. Vamos a asumir que, por medios que luego

U

Page 307: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos con tipos 307

explicaré, tenemos una clase llamada ClientesDS, que desciende de DataSet, y una instancia de la misma a la que haremos referencia mediante la variable clientesDS1. Supondremos que el conjunto de datos contiene una tabla con registros provenientes de la tabla Customers de Northwind. Nuestra tarea será añadir elementos a un control ListView, configurado con dos columnas: una para el identificador del cliente, y otra para el nombre de la compañía.

Si nos limitásemos a los recursos genéricos de la clase DataSet, necesitaríamos las siguientes instrucciones:

foreach (DataRow r in clientesDS1.Tables[0].Rows)

{

ListViewItem it;

it = listView1.Items.Add(r["CustomerID"].ToString());

it.SubItems.Add(r["CompanyName"].ToString());

}

El bucle anterior es un campo minado. Para empezar, podemos equivocarnos en el índice que le pasamos a la propiedad vectorial Tables. He supuesto que la tabla que nos interesa es la primera... pero eso puede cambiar si creamos más tablas dentro del conjunto de datos. Es verdad que podemos utilizar el nombre de la tabla como ín-dice, pero entonces podríamos equivocarnos con la ortografía. Peor aún es la forma en que accedemos a los valores de los campos, porque además de tener que indizar por el nombre de la columna, tenemos que convertir el valor devuelto, de tipo ob-ject, en una cadena de caracteres.

Esta es la alternativa que ofrece el conjunto de datos con tipos:

foreach (ClientesDS.CustomersRow r in clientesDS1.Customers)

{

ListViewItem it;

it = listView1.Items.Add(r.CustomerID);

it.SubItems.Add(r.CompanyName);

}

Para empezar, tenemos una propiedad Customers que hace referencia a la tabla de clientes, sin posibilidad de equivocación. En la primera variante, para iterar sobre las filas teníamos que obtener la propiedad Rows de la tabla; en la segunda variante, po-demos iterar directamente sobre la tabla de clientes. Lo mejor de todo es que los

Page 308: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

308 La Cara Oculta de C#

objetos devueltos por el iterador pertenecen a la clase CustomersRow, anidada dentro de la definición de ClientesDS. En el ejemplo original, en cambio, estábamos obliga-dos a trabajar con la clase genérica DataRow.

Las ventajas de usar CustomersRow se comprenden al examinar las instrucciones del interior del bucle. Se acabaron las búsquedas de columnas a ciegas dentro de la fila genérica, porque la nueva clase de filas ofrece una propiedad CustomerID, que de-vuelve una cadena de caracteres: ya no necesitamos la conversión explícita de tipos. Y lo mismo sucede con CompanyName, otra de las propiedades que permiten el acceso directo a los valores de columnas en un registro de cliente.

Pero hay mucho más. Si quisiéramos localizar un cliente dado su identificador, po-dríamos utilizar el siguiente código:

ClientesDS.CustomersRow cr;

cr = clientesDS1.Customers.FindByCustomerID("BLAUS");

Compare con el código necesario para un conjunto de datos genérico:

DataRow dr;

dr = clientesDS1.Tables[0].Rows.Find("BLAUS");

En este caso hemos tenido suerte con el código genérico, porque la clave primaria contiene una sola columna. Recuerde, no obstante, que cuando la clave es com-puesta, tenemos que pasar a Find un vector de objetos. El conjunto de datos con tipos ofrecería en este caso un método Find con dos parámetros, y los tipos de los parámetros corresponderían a los tipos de las columnas de la clave.

La misma simplificación se logra en las operaciones de inserción, modificación, na-vegación sobre relaciones maestro/detalles, e incluso en la detección de valores nulos en las columnas.

Esquemas XML

Veamos ahora cómo se crean estas clases tan útiles. En realidad, en un entorno de desarrollo como Visual Studio, existen muchas maneras de generar conjuntos de datos con tipos, pero todas ellas tienen algo en común: el punto de partida es un fichero XML que describe el esquema del conjunto de datos. A partir de este fichero XML se genera un fichero de código fuente con las clases en C#, gracias a la ayuda de una aplicación de línea de comandos llamada xsd.exe:

Page 309: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos con tipos 309

El fichero XML inicial recibe, por lo general, la extensión .xsd, y su contenido debe ajustarse a una especificación formal conocida en inglés con el nombre de XML Schema. Para que se haga una idea sobre este lenguaje, le mostraré el fichero de es-quema que ha servido para generar la clase ClientesDS que he utilizado en los ejem-plos anteriores. Le advierto que el contenido del fichero es complicado, entre otras cosas porque el conjunto de datos contiene también una tabla de pedidos, configu-rada como tabla dependiente de la de clientes. Este es nuestro pequeño monstruo, con una pizca de maquillaje:

<?xml version="1.0" standalone="yes" ?>

<xs:schema id="ClientesDS"

targetNamespace="http://www.tempuri.org/ClientesDS.xsd"

xmlns:mstns="http://www.tempuri.org/ClientesDS.xsd"

xmlns="http://www.tempuri.org/ClientesDS.xsd"

xmlns:xs="http://www.w3.org/2001/XMLSchema"

xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"

attributeFormDefault="qualified" elementFormDefault="qualified">

<xs:element name="ClientesDS" msdata:IsDataSet="true"

msdata:Locale="es-ES">

<xs:complexType>

<xs:choice maxOccurs="unbounded">

<xs:element name="Customers">

<xs:complexType>

<xs:sequence>

<xs:element name="CustomerID" type="xs:string" />

<xs:element name="CompanyName" type="xs:string" />

<xs:element name="ContactName" type="xs:string"

minOccurs="0" />

… más columnas de clientes …

<xs:element name="Fax" type="xs:string"

minOccurs="0" />

</xs:sequence>

</xs:complexType>

</xs:element>

<xs:element name="Orders">

<xs:complexType>

<xs:sequence>

<xs:element name="OrderID" msdata:ReadOnly="true"

msdata:AutoIncrement="true" type="xs:int" />

<xs:element name="CustomerID" type="xs:string"

minOccurs="0" />

<xs:element name="EmployeeID" type="xs:int"

minOccurs="0" />

… más columnas de pedidos …

<xs:element name="ShipCountry" type="xs:string"

minOccurs="0" />

</xs:sequence>

</xs:complexType>

</xs:element>

</xs:choice>

</xs:complexType>

<xs:unique name="Constraint1" msdata:PrimaryKey="true">

<xs:selector xpath=".//mstns:Customers" />

<xs:field xpath="mstns:CustomerID" />

</xs:unique>

<xs:unique name="Orders_Constraint1"

msdata:ConstraintName="Constraint1" msdata:PrimaryKey="true">

<xs:selector xpath=".//mstns:Orders" />

Page 310: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

310 La Cara Oculta de C#

<xs:field xpath="mstns:OrderID" />

</xs:unique>

<xs:keyref name="CustomerOrders" refer="mstns:Constraint1">

<xs:selector xpath=".//mstns:Orders" />

<xs:field xpath="mstns:CustomerID" />

</xs:keyref>

</xs:element>

</xs:schema>

No se asuste, porque no hay necesidad alguna de escribir estas abominaciones a mano; yo mismo confieso que odio XML de todo corazón. Sólo conozco una cosa más pedante que un lenguaje basado en XML: la descripción de un protocolo TCP/IP. Estas son cosas que, una vez programadas por algún buen samaritano, los demás deberíamos poder reutilizar sin revolcarnos en el barro. ¡Hay libros dedicados a describir el lenguaje XSD! Me cuesta trabajo aceptar que existan seres humanos que se exciten (intelectualmente, quiero decir) con estas cochinadas.

En cualquier caso, no nos vendrá mal aprender a identificar la estructura de una defi-nición de esquema como la anterior. A grandes rasgos, el fichero describe la sintaxis a la que debería ajustarse el contenido de un conjunto de datos, una vez convertido a XML. En este ejemplo, se aceptarían cadenas XML como la siguiente:

<ClientesDS xmlns="http://www.tempuri.org/ClientesDS.xsd">

<Customers>

<CustomerID>ALFKI</CustomerID>

<CompanyName>Alfreds Futterkiste</CompanyName>

<ContactName>Maria Anders</ContactName>

<!-- más columnas -->

</Customer>

<!-- más clientes -->

<Orders>

<OrderID>11077</OrderID>

<CustomerID>RATTC</CustomerID>

<EmployeeID>1</EmployeeID>

<!-- más columnas -->

</Orders>

<!-- más pedidos -->

</ClientesDS>

Veamos ahora cuáles fragmentos del esquema corresponden a cuáles estructuras en el contenido XML anterior. La raíz del árbol viene representada por esta etiqueta, junto a su correspondiente etiqueta de cierre:

<xs:element name="ClientesDS" msdata:IsDataSet="true"

msdata:Locale="es-ES">

Esto significa el nodo principal de las cadenas aceptadas debe ser una etiqueta titu-lada ClientesDS. Su contenido se describe a continuación:

<xs:choice maxOccurs="unbounded">

<xs:element name="Customers">

<xs:element name="Orders">

Page 311: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos con tipos 311

La etiqueta xs:choice indica que debemos escoger entre un nodo Customers y un nodo Orders. El atributo maxOccurs, que vale unbounded, indica que la elección se puede re-petir hasta la náusea. Traduciendo: dentro de la etiqueta ClientesDS pueden habitar cuantas etiquetas Customers y Orders se nos ocurran. El contenido de los nodos Cus-tomers debe ser:

<xs:sequence>

<xs:element name="CustomerID" type="xs:string" />

La etiqueta xs:sequence indica que debemos incluir, respetando el orden de definición, uno de cada uno de los nodos que se definen a continuación, que ya son los nodos correspondientes a columnas. Algunos de los nodos que corresponden a columnas tienen un cero asignado en el atributo minOccurs. Eso significa que la columna puede omitirse, porque admite valores nulos:

<xs:element name="ContactName" type="xs:string" minOccurs="0" />

Al final de la definición del esquema, se indican tres restricciones:

• La clave primaria de la tabla Customers (Constraint1).

• La clave primaria de la tabla Orders (Orders_Constraint1).

• La relación de integridad referencial que vincula el campo CustomerID de Orders con la clave primaria de Customers (CustomerOrders).

Si parece complicado, es porque realmente lo es.

El editor de esquemas

La forma decididamente masoquista de crear un esquema XML consiste en escribir el fichero xsd con el Bloc de Notas. Si lo prefiere, puede hacerlo de rodillas sobre cristales rotos, o acostado sobre el asfalto en el agosto madrileño. Con esta última opción, el riesgo no consiste en morir atropellado, porque Madrid es una ciudad desierta en verano, sino en convertirse en parte del pavimento por fusión térmica.

Page 312: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

312 La Cara Oculta de C#

Si puede usar Visual Studio, es mucho más práctico añadir un nuevo fichero a un proyecto y escoger, en el diálogo que aparece, el icono DataSet. Visual Studio nos presentará, como respuesta, un editor gráfico para ayudarnos a crear el esquema:

Podemos dejar caer sobre la superficie del editor, objetos provenientes de la caja de herramientas, específicamente de la página XML Schema:

Pero eso requeriría un conocimiento más que superficial de la sintaxis XSD. Por este motivo, la técnica recomendada es arrastrar tablas y columnas desde la ventana del Explorador de Servidores. Para probarlo, cree un proyecto vacío, ejecute el comando de menú Proyecto|Agregar nuevo elemento, y seleccione el icono DataSet. Podríamos utili-zar también el icono XML Schema. La única diferencia estaría en la declaración del elemento que actúa como raíz de la jerarquía.

Una vez que esté activo el editor de esquemas, seleccione la ventana del Explorador de Servidores, y expanda el nodo correspondiente a la base de datos Northwind. Bus-

Page 313: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos con tipos 313

que la tabla Customers, arrástrela y déjela caer sobre el editor. En respuesta, el editor creará un elemento con el siguiente aspecto:

Como ve, el editor ha detectado automáticamente la clave primaria de la tabla aña-dida. Acto seguido, añada también la tabla Orders al editor. Después de este segundo paso, tendremos dos definiciones de tablas dentro del conjunto de datos, pero de momento, son tablas independientes. Para establecer la relación entre ellas, seleccione el icono que corresponde a la tabla de clientes, y pulse el botón derecho del ratón. En el menú emergente, localice y ejecute el comando Agregar|Nueva relación, para que aparezca el siguiente diálogo:

Me he tomado la libertad de retocar un poco la imagen para que quepa en la página, pero he dejado intactos todos los controles importantes. Debemos indicar cuál ele-mento debe ser el padre (Customers), y cuál el hijo (Orders). Cuando cierre el diálogo, el editor habrá añadido una relación entre los elementos, como la siguiente:

Page 314: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

314 La Cara Oculta de C#

Anotaciones en el esquema

Echemos un vistazo al aspecto de las clases generadas para el conjunto de datos. Si activa la ventana del Explorador de Soluciones, verá inmediatamente el fichero xsd creado, como un nodo más del proyecto, pero el fichero de extensión cs, que debe contener las clases en C#, no aparecerá por ningún lugar. Pulse el botón Mostrar todos los archivos, de la barra de herramientas local de la ventana, y aparecerá el fichero bus-cado como hijo del fichero xsd.

No quiero incluir aquí el listado completo del fichero, porque es enorme, pero sí me interesa mostrarle el nombre de algunas de las entidades generadas. Con la ayuda combinada del fichero y de la vista de clases he recopilado los siguientes nombres:

Nombre Significado ClientesDS La clase para el conjunto de datos CustomersDataTable Una clase anidada para la tabla de clientes OrdersDataTable Una clase anidada para la tabla de pedidos Customers Propiedad de ClientesDS, de tipo CustomersDataTable Orders Propiedad de ClientesDS, de tipo OrdersDataTable CustomersRow Una clase anidada para las filas de clientes OrdersRow Una clase anidada para las filas de pedidos GetOrdersRow Método de la fila de clientes, para obtener pedidos asociados CustomersRow Propiedad de la fila de pedidos, para obtener el cliente

Hay muchas más entidades generadas, por supuesto. Por ejemplo, se definen tipos especializados de eventos, como OrdersRowChanged o CustomersRowDeleted, y propieda-des, dentro de las clases descendientes de DataRow, para ofrecer acceso directo a los valores de las columnas. Pero lo que más me interesa ahora es señalarle algunas in-consistencias en los nombres generados. Por ejemplo, el tipo que representa un re-gistro de cliente se llama CustomersRow: en plural, y con la coletilla “Row” a sus espal-das. ¿No podríamos haber llamado a esta clase Customer, a secas?

ADO.NET es el sistema definitivo para programadores inconformistas. ¿No le gus-tan los nombres creados automáticamente por xsd para las clases, propiedades y métodos de su conjunto de datos? ¡Cámbielos! Es cierto que modificar directamente el fichero de código es una causa perdida, porque Visual Studio lo sobrescribirá cada

Page 315: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos con tipos 315

vez que detecte cambios en el fichero de esquema y regenere las clases asociadas. Pero podemos indicar nuestras preferencias sobre nombres en el propio fichero de esquema, por medio de anotaciones en atributos XML.

Seleccione el texto XML en el editor de esquemas con la ayuda de los botones situa-dos en la parte inferior izquierda de la ventana de edición. Lo primero es añadir, en el nodo principal del esquema, una referencia a un espacio de nombres XML que va-mos a necesitar:

<xs:schema id="ClientesDS"

targetNamespace="http://www.tempuri.org/ClientesDS.xsd"

xmlns:mstns="http://www.tempuri.org/ClientesDS.xsd"

xmlns="http://www.tempuri.org/ClientesDS.xsd"

xmlns:xs="http://www.w3.org/2001/XMLSchema"

xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"

xmlns:codegen="urn:schemas-microsoft-com:xml-msprop"

attributeFormDefault="qualified"

elementFormDefault="qualified">

Gracias a la nueva referencia, el compilador XML no se pondrá histérico cuando encuentre los atributos con el prefijo codegen que vamos a sembrar por aquí y por allá. Localice el elemento que corresponde al nodo Customers, y modifíquelo en la forma que muestro a continuación:

<xs:element name="Customers"

codegen:typedName="Customer"

codegen:typedPlural="Customers">

Cuando guarde el fichero xsd, Visual Studio actualizará el código generado en C#. Gracias a la pequeña modificación que acabamos de realizar, la clase CustomersRow pasará a llamarse Customer. La clase CustomersDataTable seguirá manteniendo su nom-bre original, porque el nombre plural que hemos indicado para la entidad sigue siendo el mismo que se asumió inicialmente. Por supuesto, debemos repetir la opera-ción sobre el elemento Orders.

Podemos añadir anotaciones al elemento xs:keyref que define la relación entre clientes y sus pedidos:

<xs:keyref name="CustomersOrders" refer="ClientesDSKey1"

codegen:typedParent="Customer"

codegen:typedChildren="Orders">

La anotación typedParent modifica el nombre de una propiedad de la clase de las filas de pedidos (Order); su nombre original era CustomersRow, y ahora ha pasado a llamarse Customer, más apropiado porque la propiedad apunta a una sola fila de cliente. Del mismo modo, typedChildren modifica el nombre de un método de las filas de clientes que devuelve un vector con los pedidos asociados al cliente.

Por último, también podemos introducir anotaciones para las columnas:

<xs:element name="ContactName" type="xs:string" minOccurs="0"

codegen:typedName="Contact"/>

Page 316: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

316 La Cara Oculta de C#

El cambio afecta a un par de declaraciones pertenecientes a la clase Customer:

public string Contact {}

public bool IsContactNull { }

Y si quisiéramos modificar la representación de los valores nulos para algunas co-lumnas, podríamos utilizar la anotación codegen:nullValue.

NO

TA

En este ejemplo, las anotaciones typedParent y typedChildren sobran, porque su efecto ya se logra al cambiar los nombres de las entidades relacionadas con clientes y pedidos. Además, he hecho algo poco ortodoxo: para obtener las filas de los pedidos de un cliente, C# utiliza un método, no una propiedad. Sin embargo, he preferido llamar Orders a dicho método, cuando el nombre que estipula el convenio de notación es GetOrders.

Definición de esquemas mediante adaptadores

Existen más vías para definir esquemas y crear clases especializadas derivadas de DataSet. Suponga que hemos creado y configurado dos adaptadores de datos. El primero de ellos devuelve registros de la tabla de productos, y el segundo, de la tabla de categorías de productos. Si pulsa el botón derecho del ratón sobre cualquiera de estos adaptadores, en el menú de contexto verá un comando titulado Generar conjunto de datos. Al ejecutarlo, aparecerá el siguiente diálogo:

Aunque hayamos seleccionado un solo adaptador, en el diálogo aparecerán todos los adaptadores disponibles, y las tablas que estos alimentan. Podemos seleccionar un subconjunto de las tablas para incluirlas en la clase de conjuntos de datos que se definirá a continuación. Debemos activar la opción Agregar este conjunto de datos al dise-ñador si, además de crear la clase, queremos crear una instancia de la misma en el formulario activo. Las definiciones de esquemas creadas con esta técnica tampoco incluyen inicialmente las relaciones entre tablas. Si tiene tiempo, puede practicar defi-niendo la relación que tiene a la tabla de categorías como entidad maestra, y a los productos como detalles.

Page 317: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos con tipos 317

Por cierto, aún no he explicado cómo se crean instancias de conjuntos de datos con tipos... pero es que se trata de una operación muy sencilla. Basta con dejar caer un componente DataSet sobre un formulario. Visual Studio nos presentará el siguiente asistente:

En realidad, con Visual Studio es más difícil crear un conjunto de datos genérico que uno especializado.

Añadiendo reglas de negocio

Los conjuntos de datos con tipos disminuyen la frecuencia con la que cometemos errores de programación. No obstante, uno de los beneficios buscados por el pro-gramador que desarrolla clases persistentes es la posibilidad de que la clase verifique el cumplimiento de determinadas reglas de negocio. Los conjuntos de datos con tipos, por desgracia, no nos permiten encapsular reglas de negocio, excepto las más sencillas de todas.

En principio, podríamos retocar la clase generada para el conjunto de datos, pero perderíamos los cambios en cuanto Visual Studio considerase necesario regenerar el código fuente de la clase. La solución está en crear una nueva clase que herede del conjunto de datos con tipos, para añadir las reglas de negocio que estimemos opor-tunas. Para comprobarlo, ejecute el comando Agregar clase, del menú Proyecto, y modi-fique la cláusula de herencia tal como muestro a continuación:

public class DsClientes: ClientesDS {

public DsClientes(): base() {

InicializarReglas();

}

protected DsClientes(

SerializationInfo info, StreamingContext context):

base(info, context) {

InicializarReglas();

}

}

Page 318: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

318 La Cara Oculta de C#

Además de heredar de la clase original, ClientesDS, nuestra clase debe definir dos constructores: un constructor público sin parámetros, y un constructor protegido con dos parámetros, para restaurar instancias a partir de su representación seriali-zada. La implementación de ambos es muy sencilla, porque sólo tenemos que llamar al constructor heredado correspondiente e inicializar las reglas de negocio a conti-nuación, mediante una llamada a un método InicializarReglas que tendremos que defi-nir a renglón seguido.

Antes vamos a ponernos de acuerdo sobre la regla que vamos a implementar. En general, podríamos establecer reglas de inicialización complejas interceptando los eventos del tipo RowChanging: en XSD sólo podemos indicar valores por omisión para las columnas cuando se trata de valores constantes. El otro uso, bastante co-mún, es comprobar que se cumplan ciertas condiciones al actualizar columnas o filas. Para hacer algo que se salga de lo habitual, implementaremos la siguiente regla:

• Las direcciones en Colombia no tienen códigos postales (si no me equivoco).

Cuando alguien modifique el valor de la columna Country de la tabla de clientes, asig-naremos una cadena vacía en la columna PostalCode de la misma fila. Necesitamos interceptar el evento ColumnChanged de la tabla de clientes:

protected virtual void InicializarReglas()

{

Customers.ColumnChanged +=

new System.Data.DataColumnChangeEventHandler(

Customers_ColumnChanged);

}

Por último, tenemos que programar el método añadido a la cadena de delegados del evento interceptado:

private void Customers_ColumnChanged(object sender,

System.Data.DataColumnChangeEventArgs e)

{

if (e.Column == Customers.CountryColumn)

{

// Obtener la referencia a la fila que se ha modificado

Customer cust = (Customer)e.Row;

// Ignorar diferencias entre mayúsculas y minúsculas

if (cust.Country.ToUpper() == "COLOMBIA")

cust.PostalCode = "";

}

}

Observe que estamos aprovechando las ventajas que nos ofrece el conjunto de datos con tipos utilizado como clase base. Por ejemplo, Customers apunta a la tabla de clientes, CountryColumn apunta al objeto DataColumn que representa a la columna del país, Customer en singular es una clase derivada de DataRow para representar las filas de clientes, y así sucesivamente. En un ejemplo real, tendríamos que interceptar tam-bién el evento ColumnChanging, para evitar que se asignen códigos postales a una di-rección situada en Colombia.

Page 319: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Conjuntos de datos con tipos 319

Pero lo mejor está por llegar. Regrese al formulario principal del proyecto, active el cuadro de herramientas y añada un conjunto de datos sobre el formulario:

Como puede ver, Visual Studio detecta la presencia de nuestra clase derivada, y nos permite añadir instancias de la misma sobre la superficie de diseño.

NO

TA

Siempre habrá un purista que se queje porque no hemos añadido las reglas redefiniendo métodos virtuales. Es verdad que el código generado por Microsoft para los conjuntos de datos con tipos no es un buen ejemplo de cómo programar clases extensibles. De todos modos, es una tontería encapricharse con una técnica difícil de implementar cuando podemos lograr los mismos resultados interceptando eventos. La causa de la queja, casi siempre, es la envidia cochina de los adictos a Java, que por su contumacia en el pecado no disponen aún de un mecanismo de eventos decente.

Page 320: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

21

Actualizaciones básicas

IN LUGAR A DUDAS, LA TÉCNICA DE actualización de datos en ADO.NET es, a la vez, la parte más difícil y la más interesante de esta interfaz de programación.

ADO.NET ofrece una flexibilidad nunca alcanzada por otros sistemas. El referente más cercano podría ser DataSnap, un conjunto de clases creado por Borland para Delphi y C++ Builder, pero incluso DataSnap tiene muchas partes que escapan al control del usuario. Eso no sucede en ADO.NET, pero la necesidad de controlar tantos detalles es lo que complica el aprendizaje de la técnica.

Por este motivo, he reservado dos capítulos para explicar la actualización de datos en ADO.NET. En este primer capítulo, estudiaremos los casos más sencillos, y en el siguiente, las técnicas necesarias para tratar con relaciones maestro/detalles, claves primarias asignadas en el servidor y el control de errores.

Actualizaciones en Visual Studio

Cree un proyecto nuevo en Visual Studio, y traiga un SqlDataAdapter a la superficie de diseño del formulario principal. Como respuesta, aparecerá un asistente para con-figurar el nuevo componente. En la primera página se le pedirá que elija una cone-xión. Las conexiones que se muestran en el combo son las registradas en el Explora-dor de Servidores. Si no existe una conexión apropiada, puede crear una nueva pul-sando el botón Nueva conexión, que se muestra a la derecha del combo:

Lo más interesante es que el asistente puede aprovechar los componentes de cone-xión que podamos haber creado antes, si alguno de ellos apunta a la base de datos que hemos seleccionado. Para este ejemplo seleccionaremos Northwind.

A continuación, debemos elegir el contenido de los comandos internos que se crea-rán para el adaptador:

S

Page 321: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones básicas 321

Las opciones son tres: crear directamente sentencias DML, encapsular esas senten-cias en nuevos procedimientos almacenados, o utilizar procedimientos almacenados ya existentes en la base de datos. Dejaremos las dos últimas técnicas para más ade-lante, y elegiremos Usar instrucciones SQL.

Ya en la tercera página del asistente, asumiendo que hemos seleccionado crear direc-tamente las instrucciones de actualización, debemos suministrar la consulta que se utilizará para leer registros:

Finalmente, también en la tercera página, podemos pulsar el botón Advanced Options para configurar más opciones:

La primera de las opciones “avanzadas” es fácil de entender: si quitamos la marca de la casilla, no se generarán instrucciones de actualización, y el adaptador solamente se utilizará para leer registros y almacenarlos en un conjunto de datos. Las otras dos op-ciones sólo tienen sentido cuando generamos instrucciones de actualización, y de-

Page 322: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

322 La Cara Oculta de C#

terminan el formato de las sentencias que se ocuparán de enviar cambios al servidor SQL. Más adelante, dedicaremos más tiempo a estudiar estas opciones.

Configuración de adaptadores

Cuando termina la ejecución del asistente, Visual Studio crea y configura el adapta-dor de datos, de la clase que arrastramos sobre la superficie de diseño. Cuando estu-diamos los adaptadores por primera vez, en el capítulo 19, sólo mencionamos la existencia de una propiedad llamada SelectCommand, que era la que necesitábamos para obtener los datos que pasaríamos luego al conjunto de datos. Para poder pasar los datos actualizados de vuelta al servidor SQL, necesitamos tres propiedades más: UpdateCommand, InsertCommand y DeleteCommand. Todas ellas, hacen referencia a ob-jetos de comandos.

Los objetos de comandos pueden ser definidos de forma independiente del adapta-dor, pero cuando se utiliza el asistente de la sección anterior, los objetos de coman-dos creados por éste no aparecen en la bandeja de componentes del diseñador.

Vamos a analizar las sentencias SQL generadas para estos comandos, comenzando por el comando de inserción, InsertCommand. He abreviado un poco el texto, quitando algunas de las columnas, pero el sentido de la instrucción sigue siendo el mismo:

insert into Customers

(

CustomerID, CompanyName, ContactName,

ContactTitle, Address, City, Region /* … */

)

values (

@CustomerID, @CompanyName, @ContactName,

@ContactTitle, @Address, @City, @Region /* … */);

select CustomerID, CompanyName, ContactName,

ContactTitle, Address, City, Region /* … */

from Customers

where (CustomerID = @CustomerID)

Hay dos instrucciones, y en la primera de ellas no hay misterio alguno: es una inser-ción que toma como valores de origen parámetros con nombres idénticos a los de las columnas de la tabla base. La segunda instrucción de ese mismo grupo es más intere-sante. Se solicita el valor de todas las columnas de la fila recién insertada. Como he-mos tenido que suministrar manualmente el valor de la clave primaria, la consulta se limita a pedir el registro que tiene esa clave primaria.

Page 323: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones básicas 323

Este select se ha incluido gracias a la opción Actualizar el conjunto de datos, en el diá-logo de opciones avanzadas del asistente. El motivo para releer el registro que aca-bamos de insertar es la probable existencia de valores por omisión, o triggers que modifican valores insertados. También, aunque éste no es el caso, podrían existir columnas con el atributo de identidad o con marcas de tiempo. Todos estos casos serán analizados en el siguiente capítulo.

NO

TA

Observe que, debido a que la instrucción insert menciona explícitamente todas las co-lumnas de la tabla, no tendríamos posibilidad de comprobar el funcionamiento de los valores por omisión definidos en la base de datos. Si no proporcionásemos un valor co-rrecto para el país, digamos, estaríamos enviando de todos modos un valor nulo para dicha columna.

Esta es la instrucción asociada a la propiedad UpdateCommand:

update Customers

set CustomerID = @CustomerID, CompanyName = @CompanyName,

ContactName = @ContactName, ContactTitle = @ContactTitle

/* … */

where (CustomerID = @Original_CustomerID) and

(Address = @Original_Address or

@Original_Address is null and Address is null) and

(City = @Original_City or

@Original_City is null and City is null)

/* … */;

select CustomerID, CompanyName, ContactName,

ContactTitle, Address, City, Region /* … */

from Customers

where (CustomerID = @CustomerID)

Otra vez tenemos una instrucción de recuperación después de la modificación, y su justificación es la misma que antes. Es cierto que con un update no tendríamos que preocuparnos por valores por omisión e identidades, pero sigue existiendo la posibi-lidad de que un trigger modifique columnas a nuestras espaldas, o de que existan columnas del tipo timestamp.

La instrucción update, sin embargo, merece un análisis exhaustivo. ¿Se ha percatado de que la sentencia generada actualiza todas las columnas de la tabla, aunque sola-mente hayamos modificado una de ellas? Por ahora no tenemos un remedio a mano, pero es cierto que en la mayoría de los casos esta situación no afectará al rendi-miento... excepto cuando existan campos blobs en la tabla, porque estaríamos modi-ficando sus valores innecesariamente. Más adelante tendremos que idear soluciones para esta dificultad.

Otro detalle notable es la extravagantemente larga cláusula where de la modificación. Este es el resultado de haber activado la opción avanzada Usar concurrencia optimista. De no ser por ello, la cláusula where sería mucho más breve:

where (CustomerID = @CustomerID)

El objetivo de la cláusula generada es garantizar que la fila que vamos a actualizar no haya sido modificada por otro usuario desde el momento en que la cargamos en el

Page 324: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

324 La Cara Oculta de C#

conjunto de datos. Para ello, se compara cada columna con el valor original que leí-mos. Si hay algún cambio, la instrucción update no encontrará filas que modificar y el adaptador lo interpretará correctamente, avisándonos de que la fila ha sido modifi-cada por otro usuario. Luego veremos todas las opciones que tenemos para el con-trol de concurrencia.

Para terminar con los comandos, esta es la instrucción que ejecutará el comando al que hace referencia la propiedad DeleteCommand del adaptador:

delete from Customers

where (CustomerID = @Original_CustomerID) and

(Address = @Original_Address or

@Original_Address is null and Address is null) and

(City = @Original_City or

@Original_City is null and City is null) /* … */

Esta vez no necesitamos un select, por razones obvias. La sentencia delete, por su parte, imita el comportamiento de update, verificando que no se hayan producido modificaciones sobre esta fila desde otros procesos o estaciones de trabajo.

Aplicando los cambios

Al llegar a este punto, lo único que tenemos es una conexión, un adaptador de datos, y sus comandos. Necesitamos también un conjunto de datos y una rejilla, para poder mostrar los registros y actualizarlos. Podríamos crear un conjunto de datos con tipos ejecutando el comando Generar conjunto de datos, del menú de contexto del adaptador. Para este ejemplo, en cambio, será mejor usar un conjunto de datos genérico, sin especializar. Para ello, hay que arrastrar un DataSet de la caja de herramientas sobre el formulario. Cuando dejes caer el componente, aparecerá un diálogo en el que tendrá que decidir si prefiere un conjunto de datos con tipos o no. Ya sabe cuál debe ser la elección.

Traiga también una rejilla, o DataGrid, al formulario, e intercepte el evento Load del formulario, para cargar registros en el conjunto de datos con la ayuda del adaptador. Suponiendo que todos los componentes conservan sus nombres originales, éste debe ser el contenido del manejador del evento mencionado:

private void MainForm_Load(object sender, System.EventArgs e)

{

sqlDataAdapter1.Fill(dataSet1, "Clientes");

dataGrid1.SetDataBinding(dataSet1, "Clientes");

}

Si no llamásemos a SetDataBinding, la vista inicial de la rejilla sería un árbol con la estructura jerárquica del conjunto de datos. Para evitarlo, indicamos explícitamente un valor para la propiedad DataMember: el nombre de la primera y única tabla exis-tente en el conjunto de datos.

Añada entonces un botón, cambie su título a Guardar, y cree un manejador para su evento Click:

Page 325: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones básicas 325

private void button1_Click(object sender, System.EventArgs e)

{

sqlDataAdapter1.Update(dataSet1);

}

Y esto es todo lo que necesitamos, de momento. Al ejecutarse la aplicación, el con-junto de datos se llena, con la ayuda del adaptador, y los registros aparecen en la rejilla. Podemos modificar los registros con la rejilla, pero esas modificaciones se efectúan en la memoria dinámica, y no pasarán a la base de datos hasta que pulsemos el botón Guardar.

La metáfora del procesador de texto

En la lejana época de dBase, FoxPro y Clipper sobre MSDOS, las aplicaciones hacían creer al usuario que las modificaciones que realizaba sobre un registro tenían un efecto inmediato sobre el mismo, como sucedía en realidad. Esto era posible, y fácil de implementar, por otra “ilusión óptica” que se sugería: que la base de datos residía siempre en un disco duro local del ordenador... aunque en realidad estuviese alojada en un servidor de ficheros independiente.

Muchas de las aplicaciones modernas, basadas en la arquitectura cliente/servidor o incluso en el modelo de múltiples capas, siguen utilizando la metáfora de la modifica-ción directa de registros, por simple inercia. No obstante, es mucho más difícil hacer creer al usuario que la base de datos está ubicada en una extensión “física” de su ordenador. Y esto último hace difícil implementar la ilusión de la modificación di-recta de registros.

Es muy probable que le haya extrañado la presencia de un botón Guardar en un ejemplo de actualizaciones. Quizás haya pensado que lo vamos a cambiar en algún momento cercano. Si así lo cree, se equivoca. Las aplicaciones modernas, que deben funcionar muchas veces utilizando líneas de acceso remoto relativamente lentas, no se pueden dar el lujo de acceder constantemente al servidor que les envía los datos. El modelo de interacción más apropiado es el que llamo la metáfora del procesador de textos. Estas son las guías principales del modelo:

• Cuando el usuario “abre” una tabla completa, o una consulta que devuelve un conjunto de registros, lo que realmente hace es traer a su ordenador una copia de esos registros. En la metáfora de acción directa, en cambio, se pretendía que nuestra copia equivalía en todo a los registros reales.

• Naturalmente, cualquier modificación que hagamos una vez que hemos evaluado la consulta, se realiza sobre la copia. No se intenta ocultar este hecho al usuario.

• En particular, renunciamos al mecanismo de bloqueos pesimistas (apriorísticos) que implementaban la mayoría de las bases de datos de escritorio.

• Para que nuestros cambios se apliquen, o dicho en jerga técnica, se sincronicen con la base de datos, el usuario debe iniciar una operación de grabación, de forma más o menos explícita.

Page 326: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

326 La Cara Oculta de C#

¿Y qué tiene que ver esto con los procesadores de texto? Simplemente que los co-mandos de menú apropiados para este tipo de interfaz son muy parecidos a los que utilizaría un procesador de texto. En particular, tanto la interfaz de edición de regis-tros como los procesadores de textos necesitan un comando Guardar para que los cambios que ha realizado el usuario sobre la copia en memoria se reflejen en el origi-nal, almacenado de forma persistente en el disco duro.

No hace falta, sin embargo, tener un botón de grabación que resalte tanto. Por ejem-plo, podemos estipular que todas nuestras rejillas funcionen en el modo de sólo lec-tura, y que para modificar un registro sea necesario invocar un cuadro de diálogo modal. En este caso, la grabación se forzaría al cerrarse el diálogo, y el cierre no sería posible mientras ocurriesen errores de grabación; a no ser, claro está, que canceláse-mos la ejecución del diálogo.

En este capítulo, y en los que le siguen, iremos ahondando en la implementación del modelo de aplicaciones que acabo de presentar. Sobre todo, tendremos que aprender a manejar los conflictos que puede provocar el acceso concurrente, bajo estas premi-sas. Todo a su debido tiempo.

Parámetros acotados y versiones de filas

Como hemos visto, para grabar las modificaciones realizadas sobre un conjunto de datos en memoria, necesitamos un adaptador con las instrucciones apropiadas en las propiedades UpdateCommand, InsertCommand y DeleteCommand. Vimos, además, que estas instrucciones utilizan parámetros a diestra y siniestra. Por ejemplo, ésta era la primera mitad de la instrucción almacenada en UpdateCommand:

update Customers

set CustomerID = @CustomerID, CompanyName = @CompanyName,

ContactName = @ContactName, ContactTitle = @ContactTitle

/* … */

where (CustomerID = @Original_CustomerID) and

(Address = @Original_Address or

@Original_Address is null and Address is null) and

(City = @Original_City or

@Original_City is null and City is null)

/* … */;

Note que cada columna tiene dos parámetros asociados. Por ejemplo, a City le co-rresponden los parámetros @City y @OriginalCity. Para comprender qué está pasando, vamos a echar un vistazo al código que genera Visual Studio para inicializar los ob-jetos de parámetros. Recuerde que esta inicialización tiene lugar dentro de un método llamado InitializeComponent, que probablemente estará colapsado dentro del editor de código. Estas son las instrucciones que debe encontrar; he trucado un poco el frag-mento para que sea más legible, pero creo que podrá identificar el original sin pro-blemas.

SqlParameterCollection pc = sqlUpdateCommand1.Parameters;

pc.Add(new SqlParameter("@CustomerID", SqlDbType.NVarChar, 5,

"CustomerID"));

Page 327: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones básicas 327

pc.Add(new SqlParameter("@CompanyName", NVarChar, 40,

"CompanyName"));

pc.Add(new SqlParameter("@ContactName", SqlDbType.NVarChar, 30,

"ContactName"));

// …

pc.Add(new SqlParameter("@Original_CustomerID",

SqlDbType.NVarChar, 5, ParameterDirection.Input, false,

((byte)(0)), ((byte)(0)),

"CustomerID", DataRowVersion.Original, null));

pc.Add(new SqlParameter("@Original_Address",

SqlDbType.NVarChar, 60, ParameterDirection.Input, false,

((byte)(0)), ((byte)(0)),

"Address", DataRowVersion.Original, null));

// …

Habrá notado que hay dos grupos de parámetros. En el primero, es típico usar cons-tructores como éste:

new SqlParameter("@CustomerID", SqlDbType.NVarChar, 5, "CustomerID")

El primer argumento es el nombre del parámetro del comando, y los dos siguientes argumentos corresponden al tipo de datos y a su longitud. Hasta aquí, pisamos te-rreno conocido. Y entonces aparece la cadena "CustomerID". Está claro que se trata de un nombre de columna. La explicación está en la forma en la que se ejecutará el comando de modificación. En los ejemplos que hemos visto hasta el momento de comandos con parámetros, éramos nosotros mismos, los programadores, quienes teníamos que proporcionar valores a los parámetros. Muy diferente es lo que sucede con los comandos de actualización de un adaptador de datos: para que los paráme-tros reciban un valor, se les ofrece una fila, es decir, un objeto de la clase DataRow. El parámetro debe extraer su valor de ella, conociendo cuál columna le interesa.

Más adelante, en el segundo grupo de parámetros, aparece otro constructor, con un aspecto realmente feroz:

new SqlParameter("@Original_CustomerID",

SqlDbType.NVarChar, 5, ParameterDirection.Input, false,

((byte)(0)), ((byte)(0)),

"CustomerID", DataRowVersion.Original, null)

Estamos inicializando ahora el parámetro @Original_CustomerID, y para ello usamos el constructor de SqlParameter con mayor número de argumentos. Pasamos el tipo de datos, su longitud, la dirección (que antes utilizamos con parámetros de procedi-mientos almacenados), si admite nulos o no, la precisión y la escala, para el caso en que sea de tipo numérico... una pausa, que ahora viene la parte interesante... final-mente, viene un nombre de columna, al igual que antes. Y para variar, una constante del tipo enumerativo DataRowVersion (más un valor inicial que renunciamos a usar aquí). La constante de marras indica que, cuando el adaptador dé valores a los pará-metros, queremos que éste reciba el valor original de la columna CustomerID: el valor que existía antes de cualquier posible modificación. En el caso anterior, en el que no se especificaba cuál versión de la fila queríamos, se asumía la constante Current, para solicitar el valor de la columna incluyendo las actualizaciones.

Page 328: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

328 La Cara Oculta de C#

NO

TA

Por razones que deben ser obvias, el comando de inserción sólo utiliza parámetros vin-culados a la versión actual de la fila, mientras que el comando de borrado sólo se refiere la versión original de la fila. Cualquier semejanza con los triggers es algo más que una inocente coincidencia.

Elogio de la locura

Sólo hay una cosa que los seres humanos prefieren a comer con los dedos, y es blo-quear registros antes de editarlos. Durante algún tiempo, alimenté la vana esperanza de no tener que explicar en este libro por qué los bloqueos de edición habían sido barridos por la Historia. Pero pocos días después de dar este capítulo por terminado, recibí un apremiante mensaje de correo electrónico, de alguien angustiado por lo que consideraba “el comportamiento impropio” de su aplicación cuando se realizaban modificaciones simultáneas desde dos puestos de trabajo. Pura hipocondría. Respiré hondo, abrí este capítulo en mi procesador de textos, y heme aquí disertando sobre bloqueos de edición, una vez más...

¿Qué es un bloque de edición, o bloqueo pesimista? Esta es una técnica bastante antigua, en la que antes de comenzar la edición de un registro, el usuario solicita, a alguna autori-dad central, un permiso excluyente relacionado con dicho registro. Un registro blo-queado de esta manera no puede ser editado desde ningún otro puesto hasta que se libere el bloqueo. Es el propio usuario que inicialmente solicitó el bloqueo, el encar-gado de liberarlo al grabar el registro exitosamente, avisando nuevamente a la entidad central.

A esta técnica se le llama también bloqueo pesimista porque asume que es bastante probable que dos usuarios intenten editar el mismo registro, y toma medidas preven-tivas para evitarlo. ¿Qué hay de cierto en esta suposición? Hagamos las cuentas: to-memos como ejemplo una empresa de mediana a grande, con una tabla de clientes de 100.000 registros. Digamos que hay 100 operadores de datos ejecutando la misma aplicación. ¿Cuál es la posibilidad teórica de que dos de ellos, al menos, intenten modificar simultáneamente el mismo cliente?

No es tan sencillo de calcular como parece a simple vista. Supongamos que todos los operadores deben escoger un cliente, que todos lo harán al mismo tiempo, y que la elección será aleatoria. ¿Cuántas elecciones posibles existen? Tenga en cuenta que una de estas posibles elecciones consiste en que todos los usuarios hayan escogido a un mismo cliente; es decir, las elecciones son independientes. El número total de elecciones posibles, incluyendo las conflictivas, será igual al número de clientes ele-vado al número de usuarios. ¿Cuántas elecciones correctas son posibles? Sea c el número de clientes, y u el número de operadores. Dejemos que el primer operador escoja un cliente; tiene c posibilidades. El segundo operador sólo puede elegir uno de los restantes clientes; tiene c-1 posibilidades. Y así sucesivamente.

Sabemos entonces cuántas combinaciones existen, y conocemos cuántas de ellas son correctas. La probabilidad de que no se produzca un conflicto se obtiene al dividir el número de combinaciones correctas entre el total. En vez de echar mano del editor

Page 329: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones básicas 329

de ecuaciones para mostrarle la fórmula final, he preferido programar una función para que calcule el riesgo de colisiones:

public static double RiesgoColisiones(int usuarios, int clientes)

{

double result = 1;

for ( ; usuarios > 1; usuarios--)

result = result * (clientes - usuarios + 1) / clientes;

return (1 – result);

}

Si hay 100 usuarios y 100.000 clientes, el riesgo de que dos operadores riñan por un mismo cliente es cercano al 4,83%. Teóricamente, surgirían problemas en un caso de cada veinte...

Pero el riesgo real es mucho menor. Recuerde que asumimos, al comenzar, que todos los usuarios harían su elección a la vez, algo poco probable en la práctica. Olvidemos el álgebra combinatoria: ¿qué tiene que suceder para que un operador se ponga a trajinar con un registro de cliente? ¡Que el cliente compre algo! ¿En cuántas cajas, o líneas telefónicas, o navegadores de Internet, puede estar el mismo cliente simultá-neamente? A no ser que disfrute del don de la ubicuidad, el riesgo real de que dos operadores coincidan sobre el mismo cliente es insignificante.

Cuando expongo estos argumentos en público, lo normal es que alguien presente la siguiente objeción: ¿qué riesgo existe de que el registro de la Coca Cola, en la tabla de inventarios, se actualice simultáneamente desde dos puestos? Aparentemente, las probabilidades son altas... pero, ¿qué operación es la que lleva a actualizar las existencias en un registro de producto? Es ahí donde está la gran diferencia. Cuando hablamos de competir por los registros de clientes, es porque estábamos editando manualmente esa tabla: la operación genérica que muchos llaman “mantenimiento”.

En cambio, los retoques en las existencias de un producto no son consecuencia de un mantenimiento genérico, sino que su causa inmediata es la creación o modifica-ción de una factura. Si la creación de facturas ha sido programada correctamente, la actualización del inventario tiene lugar dentro de una transacción que debe durar el mínimo tiempo posible. En un sistema SQL, los bloqueos pesimistas no asomarán sus feas caras, y será el propio servidor quien se ocupará automáticamente de cual-quier problema de concurrencia que pueda presentarse.

Los problemas del bloqueo pesimista

Resumiendo la sección anterior: no merece la pena preocuparse demasiado por los posibles conflictos de concurrencia durante la edición, porque en circunstancias normales, y en sistemas correctamente diseñados y programados, el riesgo es mí-nimo. Más adelante veremos qué hacer para detectar las modificaciones simultáneas mediante los bloqueos optimistas.

No obstante, existen razones propias para evitar los bloqueos de edición. Aquí tiene algunas de ellas:

Page 330: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

330 La Cara Oculta de C#

1 La mayoría de los sistemas SQL no permiten usar bloqueos pesimistas, excepto si los simulamos mediante transacciones. Cuando el usuario quisiera editar un re-gistro, podríamos iniciar una transacción y forzar una actualización trivial sobre el registro, que dejase los valores iniciales intactos. Mientras no cerrásemos la transacción, ya sea confirmando o cancelando, el registro seguiría bloqueado. Pero estaríamos vinculando el tiempo de vida de la transacción al tiempo que el usuario quiera tomarse antes de confirmar los cambios. Incluso en un sistema pequeño, la carga que esto impondría sobre el servidor sería excesiva.

2 La propia operación de pedir un bloqueo implica un viaje adicional de ida y vuelta por la red. Lo mismo sucede al liberar el bloqueo. Reconozco que éste es el menos importante de los argumentos contra los bloqueos pesimistas... pero téngalo presente, de todos modos.

3 ¿Qué pasa si el usuario impone un bloqueo y su ordenador se cuelga? El bloqueo seguirá ahí, impidiendo el trabajo de otros operadores. Esta era una situación frecuente en Paradox, y para solucionarla era necesario desconectar los restantes operadores para luego eliminar un fichero compartido que contenía los bloqueos de todos los ordenadores en la red.

4 Supongamos que alguien inventa un sistema para detectar si las estaciones de trabajo que han solicitado bloqueos siguen activas o no, de forma tal que elimine automáticamente estos bloqueos conflictivos. La implementación de este meca-nismo sería increíblemente onerosa para la capacidad o ancho de banda de la red. Funcionaría con dificultad para redes pequeñas. Y para redes mayores, además del incremento del tráfico, tendríamos problemas con los cortafuegos.

El último punto me recuerda otra locura que oigo con excesiva frecuencia. Muchos programadores quisieran tener un mecanismo que avisara a todas las instancias de una aplicación sobre los cambios realizados, independientemente del sitio donde ocurran. El aumento del tráfico de red, en este caso, sería geométrico, no lineal, in-cluso existiendo una instancia central que actuase como árbitro de este servicio.

Control optimista de concurrencia

Volvamos a los comandos de actualización del adaptador, y enfoquemos dentro de esta escena los detalles que tienen que ver con el control optimista del acceso concu-rrente. Este problema no se presenta en las inserciones, y el caso de los borrados se maneja de forma similar a las modificaciones. Por lo tanto, concentraremos la aten-ción en la modificación de registros ya existentes.

Esta es la primera mitad de la instrucción SQL generada por el asistente del adapta-dor para modificar registros:

update Customers

set CustomerID = @CustomerID, CompanyName = @CompanyName,

ContactName = @ContactName, ContactTitle = @ContactTitle

/* … */

Page 331: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones básicas 331

where (CustomerID = @Original_CustomerID) and

(Address = @Original_Address or

@Original_Address is null and Address is null) and

(City = @Original_City or

@Original_City is null and City is null)

/* … */;

Hay dos detalles que llaman la atención:

• Siempre se actualizan todas las columnas del registro, aunque hayamos modifi-cado una sola de ellas.

• La cláusula where incluye una comparación de cada columna del registro res-pecto a su valor original. Puede que esto no sea evidente leyendo el fragmento anterior porque las columnas usadas en la cláusula where, excluyendo la clave primaria, han sido ordenadas alfabéticamente. Aparentemente, falta la compara-ción que correspondería a CompanyName, pero no es así, como puede comprobar en el ejemplo que acompaña al libro.

Ya mencioné el primer punto, y advertí que cuando hay campos blobs en el registro, ésta es una estrategia muy mala. Hay otro problema: si la tabla tiene un número con-siderable de columnas la longitud literal de la cadena que contiene la instrucción puede volverse importante. No es lo mismo enviar una cadena de 512 bytes al servidor SQL que enviar una cadena de 4096 bytes, especialmente si lo hacemos a través de un segmento de red lento.

¿Por qué el asistente escoge esta técnica aparentemente sin sentido? El principal motivo a tener en cuenta es que estamos usando instrucciones “estáticas”: la misma instrucción debe utilizarse cuando cambiamos una o cuarenta columnas. La única forma de superar esta dificultad sería generar y ejecutar las actualizaciones dinámi-camente, interceptando el evento RowUpdating del adaptador. Incluso si aceptásemos esta responsabilidad, tendríamos que considerar el impacto sobre la caché de planes del servidor. En SQL Server, cuando ejecutamos una consulta con parámetros varias veces, el optimizador puede detectar la semejanza en la instrucción, y mantener el plan de ejecución en memoria; esto es más evidente cuando se utilizan procedi-mientos almacenados, pero a partir de SQL Server 7, la optimización también se aplica a consultas y lotes de consultas. Si utilizamos consultas diferentes para actuali-zar cada registro, tenemos que comprender que estamos renunciando a la reutiliza-ción de los planes de ejecución en caché.

Aclarado esto, veamos el segundo detalle que hemos resaltado: ¿por qué la cláusula where tiene la forma que hemos visto? La respuesta también es sencilla: si en el momento en que vamos a grabar el registro, la instrucción update nos avisa de que no ha actualizado ningún registro, con total seguridad algún otro usuario se nos ha adelantado y ha modificado alguna de las columnas de ese mismo registro desde otro ordenador.

Page 332: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

332 La Cara Oculta de C#

El usuario A lee un registro

El usuario B lee el mismo registro, en alguno de estos puntos

El usuario B graba su registro

El usuario A intenta grabar, pero no encuentra el registro original

tiempo

NO

TA

Pospondré el tratamiento de los errores causados por el acceso concurrente durante la edición. Lo que ahora nos importa es saber que podemos detectarlos. Más adelante veremos las posibles acciones de respuesta: leer la versión más reciente del registro conflictivo, si debemos permitir o no la repetición de la grabación...

Debemos tener en cuenta algunas trampas que pueden presentarse al usar cláusulas where como la que estamos describiendo. En primer lugar, si la tabla a actualizar tiene campos blobs, estos campos no pueden aparecer en la condición de búsqueda. Por ejemplo, la tabla Employees de Northwind tiene un campo llamado Photo, que es un blob con una imagen. La siguiente condición no tendría sentido:

Photo = @Original_Photo or

Photo is null and Original_Photo is null

Puede comprobar que Visual Studio no incluye Photo en la cláusula where de los comandos de modificación y borrado.

Otro detalle: a las columnas que no admiten valores nulos se les asocia una condición de búsqueda más corta. Compare la condición asociada a CompanyName, que no ad-mite nulos, con la que corresponde a City, que sí los permite:

/* 1 */ CompanyName = @Original_CompanyName

/* 2 */ City = @Original_City or

City is null and @Original_City is null

Finalmente, debe saber que, en algunos casos, las columnas que almacenan valores de tipo flotante pueden provocar extraños errores dentro de una cláusula where. Su-ponga que tenemos una columna llamada Temperatura, que es de tipo float y que no admite valores nulos. La condición correspondiente sería:

Temperatura = @Original_Temperatura

No todos los valores de tipo flotante tienen una representación exacta como cadenas de caracteres. Puede entonces ocurrir que, aunque no haya cambiado la temperatura, falle la comparación entre el valor proveniente de la columna, y el valor original al-macenado en el parámetro. Si su aplicación empieza a fallar misteriosamente al guar-dar cambios en una tabla con columnas reales flotantes, sospeche de ellas en primer lugar, y elimínelas de las correspondientes cláusulas where.

Page 333: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones básicas 333

Control de concurrencia por marcas de tiempo

Existe otra técnica para el control de concurrencia optimista que puede reducir dra-máticamente la longitud de las instrucciones de actualización, y con ello, reducir el tráfico en red y el tiempo necesario para compilar y evaluar estas instrucciones. Tiene, eso sí, un par de desventajas: funciona únicamente con SQL Server, y requiere cambios en el diseño de la base de datos.

La técnica consiste en añadir una columna de tipo timestamp a todas las tablas. Al presentar los tipos de datos aceptados por Transact SQL, vimos que las columnas de este tipo reciben sus valores automáticamente, a partir de un contador global interno de la base de datos. Cuando insertamos un registro que contiene una marca de tiempo, SQL Server le asigna automáticamente el valor del contador global y hace que éste avance, para evitar repeticiones. Hasta aquí, timestamp se comporta como un tipo numérico con el atributo de identidad. La diferencia está en que el valor de la columna timestamp cambia también cuando se modifica el registro.

Supongamos que la tabla Customers de Northwind tiene una columna llamada TS, de-clarada como timestamp. La instrucción update podría escribirse ahora de la si-guiente manera:

update Customers

set CustomerID = @CustomerID, CompanyName = @CompanyName,

ContactName = @ContactName, ContactTitle = @ContactTitle

/* … */

where (CustomerID = @Original_CustomerID) and

(TS = @Original_TS)

La cláusula set sigue incluyendo todas las columnas, pero la cláusula where se queda en dos comparaciones: una para la clave primaria, y otra para la marca de tiempo. Si alguien modifica la fila después de que hayamos leído nuestra copia local, el valor de la marca de tiempo se modificará, ya no coincidirá con el valor que hemos copiado y la modificación no encontrará el registro. Y lo mismo puede aplicarse a la instrucción de borrado de registros.

NO

TA

Si está trabajando con un servidor que no soporta marcas de tiempo, puede lograr un efecto similar mediante un campo numérico y triggers que incrementen el valor de dicho campo al ejecutarse cualquiera de las tres operaciones de actualización. Si se adhiere a la regla de no modificar claves primarias, no necesitará un contador global para usar durante las inserciones. Puede hacer que el campo que simula la marca de tiempo se inicialice siempre a cero. En realidad, sería impensable usar estructuras globales, como una tabla de contadores, porque ésta se convertiría en un cuello de botella para todo el sistema. Y tenga cuidado también con la capacidad del campo.

Otras variantes de bloqueos optimistas

Hay otras dos variantes populares de bloqueos optimistas. Una de ellas es tan permi-siva que dudo que se pueda calificar como bloqueo. Consiste en incluir sólo las co-lumnas de la clave primaria en las cláusulas where de update y delete.

Page 334: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

334 La Cara Oculta de C#

update Customers

set CustomerID = @CustomerID, CompanyName = @CompanyName,

ContactName = @ContactName, ContactTitle = @ContactTitle

/* … */

where CustomerID = @Original_CustomerID

Suponiendo que nadie cambie las claves primarias, el único caso en que esta instruc-ción no modificaría registro alguno sería aquel en que otro usuario borrase el regis-tro. En cambio, dos modificaciones realizadas desde procesos diferentes nunca pro-vocarían un error: si se modificasen las mismas columnas, la última actualización ejecutada se limitaría a machacar calladamente los cambios efectuados por la primera. Ni una explosión, ni un simple quejido... aunque no por ello se acabaría el mundo.

La otra técnica consiste en incluir en la cláusula where la clave primaria junto con las columnas que han sido modificadas. Suponga que modificamos el nombre de la compañía y los datos de contacto de un cliente. La instrucción update generada sería entonces:

update Customers

set CompanyName = @CompanyName,

ContactName = @ContactName

where CustomerID = @Original_CustomerID and

CompanyName = @Original_CompanyName and

(ContactName = @Original_ContactName or

ContactName is null and @Original_ContactName is null)

Si otra persona modificase en paralelo el número de fax, se ejecutaría esta otra ins-trucción:

update Customers

set Fax = @Fax

where CustomerID = @Original_CustomerID and

(Fax = @Original_Fax or

Fax is null and @Original_Fax is null)

Pues bien, ambas instrucciones podrían ejecutarse sin problemas, porque los proce-sos que las han lanzado han modificado columnas diferentes. Observe que, para que el truco funcione, es esencial que la cláusula set sólo incluya las columnas que real-mente han cambiado su valor.

¿Es seguro, desde el punto de vista semántico, que dos usuarios modifiquen a la vez columnas independientes de un mismo registro? Resulta que sí, no hay problemas... siempre que las columnas sean realmente independientes. Por ejemplo, si hay algún programador tan idiota como para almacenar en una tabla la edad del cliente y su fecha de nacimiento, tendría problemas cuando un usuario modificase una fecha de nacimiento a la vez que otro modificase la edad. Claro, esto es rematadamente estú-pido, así que probemos con un ejemplo más sutil. Cierta empresa impone a sus em-pleados una regla que liga salario y antigüedad: el empleado debe llevar más de un año en la empresa para poder ganar más de un millón de euros anuales. Supongamos que hoy es el cuatro de julio (¡mi cumpleaños!) del 2003, y que hay un empleado con estos datos:

Page 335: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones básicas 335

<empleado codigo="IANM" salario="50000" contrato="2002-07-01" />

Juguemos ahora al divertido juego del poli bueno y el poli malo. Entra en escena un alma caritativa, se da cuenta de que ese pobre hombre ya ha cumplido un año en ese antro, y que se merece un aumento de salario. ¡No en vano ha descubierto la fusión fría usando horchata como catalizador! Modifica entonces el salario de ianm:

<empleado codigo="IANM" salario="1000000" contrato="2002-07-01" />

En su oscuro despacho acecha el poli malo, que quería ser abogado pero ni siquiera para eso servía, y ha terminado como analista. El poli malo ha leído el registro de ianm antes del aumento de salario, que por supuesto ignora. Se da cuenta de que el empleado ha cumplido un año en la empresa. Hombre, es cierto que ha inventado esa chorrada de la horchata y los neutrones, pero no soporta el carácter de este indi-viduo. ¿Qué tal si atrasamos tres meses la fecha de contratación? ¡Esos tres meses ini-ciales no cuentan: era el período de prueba! Actualiza la fecha de contrato, y el regis-tro resultante es:

<empleado codigo="IANM" salario="1000000" contrato="2002-10-01" />

Hemos visto como dos actualizaciones que por separado son aceptables, violan una regla de negocio cuando se mezclan. ¿Quiere decir esto que hay peligro en modificar columnas independientes a la vez? ¡De ningún modo! Si la empresa quiere que se cumpla una regla tan tonta, debería haberla verificado dentro de un trigger. Gracias a esa verificación, la actualización del abogado-fracasado-reciclado-en-analista fallaría estrepitosamente, agravando el complejo de inútil del buen señor.

NO

TA

No haga el experimento en casa, a no ser que pueda contar con la presencia de un adulto. Quiero decir, el experimento de la horchata. Además, no estoy seguro de que ésta sea el catalizador necesario, pero he depositado grandes esperanzas en la fabada astu-riana...

Aparentemente, todo es bueno en esta técnica. Pero, como en todo, hay también un lado oscuro: la instrucción necesaria para la actualización cambia considerablemente su estructura para cada modificación. Esto significa, en primer lugar, que no podría-mos utilizar directamente un adaptador con un comando estático, sino que tendría-mos que interceptar eventos del adaptador para generar dinámicamente las senten-cias update. Más adelante, aprenderemos a usar el evento RowUpdating del adaptador para esta tarea. Y en segundo lugar, SQL Server no podría reutilizar los planes de ejecución compilados en la caché de procedimientos.

El constructor de comandos

No obstante lo dicho en la sección anterior, existe una forma muy sencilla de generar las instrucciones de actualización en tiempo de ejecución. Cada proveedor de acceso a datos ofrece una clase con este propósito. Las clases no tienen un ancestro común, ni implementan tampoco una interfaz común, pero los nombres son iguales, excepto

Page 336: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

336 La Cara Oculta de C#

por el prefijo que identifica al proveedor: SqlCommandBuilder, OleDbCommandBuilder, OracleCommandBuilder... Aquí me ocuparé sólo de SqlCommandBuilder.

Todos los constructores de comandos tienen una propiedad DataAdapter, que les permite hacer referencia a un adaptador del tipo apropiado. La asociación puede configurarse por medio de la propiedad, una vez creada la instancia del constructor de comandos, o pasando el adaptador como parámetro al crear el objeto:

SqlDataAdapter da = new SqlDataAdapter();

SqlCommandBuilder cmdBuilder = new SqlCommandBuilder(da);

El constructor de comandos añade uno de sus métodos privados a la cadena de dele-gados del evento RowUpdating del adaptador de datos. Cuando el evento se dispara por primera vez, el constructor de comandos solicita información al adaptador sobre el esquema relacional de la consulta de petición de datos, y la utiliza para construir los tres comandos de actualización ya familiares.

Para que sea posible la generación dinámica de instrucciones, deben cumplirse algu-nos requisitos. Por ejemplo, la consulta asociada al adaptador no debe ser un en-cuentro natural; si lo fuera, ¿cómo sabría el constructor de comandos cuál es la tabla que debe actualizar? Además, la consulta debe devolver las columnas que forman parte de la clave primaria de la tabla que se actualizará.

¿Merece la pena utilizar un command builder? No, excepto si tenemos mucha prisa y no nos preocupa mucho la eficiencia de la aplicación:

• Las instrucciones generadas incluyen, en la cláusula where, todas las columnas de la tabla. No hay forma de controlar el tipo de bloqueo optimista.

• No se generan instrucciones para refrescar registros después de una inserción o actualización. Esto significa que la técnica es prácticamente inútil cuando hay columnas con el atributo de identidad en SQL Server.

• El constructor de comandos solicita información de esquemas relacionales a la base de datos, a través del adaptador. Eso consume tiempo de ejecución.

Sólo he mencionado la existencia de este componente porque es muy probable que lo vea en algún ejemplo por estos mundos... y porque la idea del componente, en sí, no es mala. Pero se ha llevado mal a la práctica, sobre todo por la falta de opciones de configuración.

Page 337: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

22

Actualizaciones complejas

A LLEGADO EL MOMENTO DE LA verdad. Ya tenemos una idea básica de cómo funcionan las actualizaciones en ADO.NET. Sabemos generar los comandos de

actualización, hemos analizado los distintos tipos de bloqueos optimistas y, sobre todo, conocemos las clases que actúan durante una actualización. Pero todos nues-tros ejemplos han sido muy simples: una consulta basada en una sola tabla, con-fiando en que los asistentes de Visual Studio generen las instrucciones necesarias.

En este capítulo, las cosas se complican para aproximarse a lo que sucede en la vida real. Aprenderemos a manejar identidades, actualizaremos jerarquías relacionales, detectaremos errores de concurrencia, interceptaremos eventos y mezclaremos con-juntos de datos. Póngase cómodo, que tenemos bastante trabajo.

El algoritmo de actualización

Vamos a recapitular el algoritmo de actualización que comenzamos a esbozar en el capítulo anterior. Para su mejor comprensión, dividiremos las partes de la aplicación en tres capas lógicas:

• Capa de presentación: consideraremos dentro de esta capa a todos los con-troles visuales, especialmente a los controles enlazados a datos, y los conjuntos de datos en memoria, junto a sus tablas, relaciones y restricciones.

• Capa o servidor de capa intermedia: en esta capa entrarán los adaptadores de datos y los componentes “conectados”, como las propias conexiones y los ob-jetos de comandos.

• Capa de almacenamiento: esta capa no se escribe en C#. Por el contrario, incluye el servidor SQL que estemos utilizando, y el código de procedimientos, triggers y cualquier otra regla que hayamos programado dentro de la base de datos.

No hace falta suponer, de momento, que las dos primeras capas estén separadas físicamente, pero veremos que la división lógica nos ayudará a establecer con claridad las responsabilidades que debe asumir cada tipo de componente.

Cumplidas las formalidades, he aquí un resumen del algoritmo de actualización de conjuntos de datos en ADO.NET:

H

Page 338: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

338 La Cara Oculta de C#

1 La capa cliente envía a la capa intermedia las modificaciones realizadas en un conjunto de datos en memoria.

2 Los adaptadores de la capa intermedia generan instrucciones SQL, o asignan parámetros reales a instrucciones ya generadas, a partir de la información conte-nida en las filas recibidas.

3 La capa intermedia ejecuta las instrucciones generadas sobre la base de datos. 4 Opcionalmente, la capa intermedia puede solicitar información actualizada sobre

las filas modificadas, para quedarse con la versión más reciente. 5 La capa intermedia envía los resultados de vuelta a la capa de presentación. 6 En la capa de presentación, se mezclan los cambios recuperados en el paso 4, y

se marcan todas las modificaciones como ya efectuadas.

Veamos ahora más detalles sobre cada uno de los pasos mencionados:

1 Enviar las modificaciones

Para actualizar la base de datos, los adaptadores de la capa intermedia deben re-cibir un conjunto de datos. Si las capa intermedia y la de presentación comparten una misma aplicación, estaremos ante el caso más sencillo: al adaptador se le su-ministra todo el conjunto de datos. Sin embargo, si estas dos capas residen en or-denadores o procesos separados, sería un suicidio enviar todo el conjunto de datos a través de un segmento de red relativamente lento. La solución consiste en enviar un subconjunto de las filas, que contenga solamente las filas con modi-ficaciones, más las filas relacionadas necesarias para que se cumplan las restric-ciones de integridad, en el caso de relaciones maestro/detalles.

2 Generación de instrucciones

En el caso más sencillo, los adaptadores han sido configurados en tiempo de di-seño para que apunten a objetos de comandos parametrizados. Un adaptador que recibe una tabla, recorre cada fila modificada, elige el comando de actualiza-ción pertinente, asigna sus parámetros y lanza la instrucción al servidor SQL. También es posible generar estas instrucciones en tiempo de ejecución, inter-ceptando eventos del adaptador.

Cuando las filas provienen de una sola tabla, no hay mayor problema en el orden en que se actualizan las filas. Pero cuando se envía un conjunto de datos con dos tablas en relación maestro/detalles, hay que proceder con cuidado. En el caso de una inserción, debemos insertar primero las filas maestras, y luego los detalles. En el caso de un borrado, primero se eliminan los detalles, y sólo después, las fi-las maestras. Para poder tratar estas relaciones, es necesario dividir las filas del conjunto de datos de acuerdo a su estado de actualización, para generar y ejecu-tar las correspondientes instrucciones por separado.

3 Ejecución de las instrucciones

No hay mucho que explicar sobre este paso... excepto que nos interesará ence-rrar las instrucciones que se van a ejecutar dentro de una transacción.

Page 339: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones complejas 339

4 Recuperar el estado más reciente

Suponga que acabamos de insertar una fila en una tabla que usa un campo de identidad como clave primaria. Tenemos entonces que averiguar cuál ha sido el valor concreto asignado por el servidor. Si hemos definido una columna de fecha y hora de modo que reciba por omisión la hora actual, tendremos que leer tam-bién el valor asignado. Lo mismo ocurriría si existiesen triggers preparados para modificar los valores suministrados por el usuario.

Para resolver todos estos problemas, sólo existe una técnica fiable: releer la fila recién insertada o modificada.

5 Devolución de registros y resultados

Si la capa intermedia y la capa de presentación están fundidas en una misma capa física, aquí terminaría el algoritmo, porque las filas que se han refrescado en el paso anterior son las mismas filas que el usuario está viendo en su monitor. Cuando las capas están separadas, hay que enviar el conjunto de datos modifi-cado de vuelta a la capa de presentación, junto con información sobre cualquier posible error que hayamos podido detectar.

6 Mezcla o conciliación

Al llegar a este paso, nos enfrentaremos a una situación delicada: tenemos el conjunto de datos original, con los cambios que enviamos al servidor, y tenemos un conjunto de datos de vuelta, con el estado actual de las filas enviadas. Tene-mos que mezclar, o conciliar, estos dos conjuntos de datos. Además, si hemos detectado errores, debemos presentárselos al usuario, para que éste decida qué hacer con ellos.

Identidades

El primer caso de actualización “compleja” que analizaremos será el de una tabla con una clave primaria con el atributo de identidad. Elegiremos una tabla lo suficiente-mente simple de la base de datos Northwind, la tabla de proveedores de productos:

create table SUPPLIERS (

SupplierID integer not null identity,

CompanyName nvarchar(40) not null,

ContactName nvarchar(30) null,

ContactTitle nvarchar(30) null,

Address nvarchar(60) null,

City nvarchar(15) null,

Region nvarchar(15) null,

PostalCode nvarchar(10) null,

Country nvarchar(15) null,

Phone nvarchar(24) null,

Fax nvarchar(24) null,

HomePage ntext null,

primary key (SupplierID)

)

Page 340: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

340 La Cara Oculta de C#

No hay referencias a otras tablas, no hay valores por omisión complicados, no hay triggers. Sólo una clave primaria con el atributo de identidad.

Abra un proyecto nuevo en su entorno de desarrollo favorito, active la ventana del Explorador de Servidores, seleccione la tabla Suppliers de la base de datos Northwind y arrástrela sobre el formulario principal. Visual Studio creará dos componentes, en respuesta: una conexión de tipo SqlConnection, y un adaptador, de tipo SqlDataAdapter. En mi ejemplo, he acortado el nombre de la conexión a sqlConn, y he cambiado el del adaptador a daSuppliers. El adaptador ha sido configurado automáticamente con cua-tro objetos de comandos. El más importante es el comando de selección, porque los otros comandos se configuran de acuerdo al contenido de éste. La consulta generada por Visual Studio es:

select SupplierID, CompanyName, ContactName, ContactTitle,

Address, City, Region, PostalCode, Country,

Phone, Fax, HomePage

from SUPPLIERS

NO

TA

Aquí estamos viendo la consecuencia de una teoría, según la cual es más eficiente escri-bir explícitamente todos los nombres de columnas que utilizar un asterisco en la cláusula de selección. Resulta que es falso: un select con un asterisco en sustitución de la lista de columnas, se ejecuta ligera aunque consistentemente más rápido que la misma consulta con la lista explícita de columnas, incluso en modo local. En red, el mayor tamaño de la cadena de caracteres con todas las columnas, probablemente aumente la diferencia en tiempo de ejecución. En cualquier caso, no es tan importante como para reñir por ello.

Seleccione ahora la propiedad InsertCommand del adaptador, y examine la instrucción generada para las inserciones:

insert into SUPPLIERS(CompanyName, ContactName, ContactTitle,

Address, City, Region, PostalCode, Country,

Phone, Fax, HomePage)

values (@CompanyName, @ContactName, @ContactTitle,

@Address, @City, @Region, @PostalCode, @Country,

@Phone, @Fax, @HomePage);

select *

from SUPPLIERS

where (SupplierID = @@identity)

Aparte de que he simplificado la lista de columnas en la segunda instrucción, que es una consulta, necesitaremos cambiar también su cláusula where:

insert into SUPPLIERS(CompanyName, ContactName, ContactTitle,

Address, City, Region, PostalCode, Country,

Phone, Fax, HomePage)

values (@CompanyName, @ContactName, @ContactTitle,

@Address, @City, @Region, @PostalCode, @Country,

@Phone, @Fax, @HomePage);

select *

from SUPPLIERS

where SupplierID = SCOPE_IDENTITY()

Recuerde que @@identity siempre devuelve la última identidad asignada, sin importar que haya sido asignada a la propia tabla en la que hemos insertado el registro, o si es

Page 341: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones complejas 341

consecuencia de una inserción adicional realizada durante la ejecución de un trigger. En cambio, la función scope_identity sí tiene en cuenta estos detalles, y funcionará correctamente, hagamos lo que hagamos dentro de un posible trigger. Visual Studio no genera directamente la comparación con la función correcta porque ésta fue in-troducida en SQL Server 2000, y no existe en versiones anteriores.

Volviendo al ejemplo, la consulta posterior a la inserción tiene el propósito de de-volvernos la versión más reciente del registro insertado. ¿Por qué habría de ser dife-rente? En este ejemplo concreto, cambia el valor de la clave primaria, SupplierID, porque contiene el atributo de identidad. En otros ejemplos, podríamos tener modi-ficaciones realizadas dentro de triggers, o valores por omisión. Con estos últimos hay que tener cuidado, sin embargo: si la sentencia insert generada incluye todas las co-lumnas de la tabla de forma explícita, los valores por omisión no llegarán a aplicarse.

En nuestro caso, si sólo puede cambiar la clave primaria, ¿qué sentido tiene escribir una consulta que devuelva todas las filas? Ninguno, evidentemente. Como primera aproximación, podríamos modificar así la consulta:

select SupplierID

from SUPPLIERS

where SupplierID = SCOPE_IDENTITY()

Y podemos seguir ahorrando. ¿Para qué mencionar una tabla en la cláusula from, si ya tenemos el valor que estamos buscando en la mano? La versión final del comando de inserción será la siguiente:

insert into SUPPLIERS(CompanyName, ContactName, ContactTitle,

Address, City, Region, PostalCode, Country,

Phone, Fax, HomePage)

values (@CompanyName, @ContactName, @ContactTitle,

@Address, @City, @Region, @PostalCode, @Country,

@Phone, @Fax, @HomePage);

select SCOPE_IDENTITY() as SupplierID

Observe que hemos asignado un nombre de columna a la única expresión de la cláu-sula select de la consulta, de modo que coincida con el nombre de la columna. En la siguiente sección comprenderemos la importancia del detalle.

El siguiente comando es el que modifica registros existentes:

update SUPPLIERS

set CompanyName = @CompanyName, ContactName = @ContactName,

/* etcétera */

where (SupplierID = @Original_SupplierID) and

(Address = @Original_Address or

@Original_Address is null and Address is null) and

/* etcétera */;

select *

from SUPPLIERS

where (SupplierID = @SupplierID)

En la cláusula set de la primera instrucción no se incluye la columna con la clave primaria: recuerde que una columna de identidad es de sólo lectura para SQL Server,

Page 342: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

342 La Cara Oculta de C#

y el asistente de Visual Studio se ha dado cuenta de ello. Después de la modificación, se relee el registro modificado. En nuestro ejemplo, la lectura posterior es inútil, al menos mientras no añadamos triggers o columnas de tipo timestamp a la tabla. En consecuencia, podemos simplificar el comando para que se quede con una sola ins-trucción:

update SUPPLIERS

set CompanyName = @CompanyName, ContactName = @ContactName,

/* etcétera */

where (SupplierID = @Original_SupplierID) and

(Address = @Original_Address or

@Original_Address is null and Address is null) and

/* etcétera */;

Nos queda el comando de borrado, pero éste siempre es más simple, principalmente porque después de matar un registro no tiene sentido alguno preguntar por su salud.

Relectura de filas actualizadas

¿Cómo sabe ADO.NET que el comando que inserta registros también devuelve la versión más reciente del registro insertado? ¿Cómo adivina, además, que el registro actualizado se devuelve en el resultado de una consulta select? La respuesta está en la propiedad UpdatedRowSource del propio comando, que almacena un valor pertene-ciente al tipo enumerativo UpdateRowSource (sin la d final en update):

Valor Significado None No se refresca el registro OutputParameters Los nuevos valores vienen en los parámetros de salida FirstReturnedRecord Los nuevos valores vienen en el resultado de un select Both Los nuevos valores se detectan automáticamente

Se trata de una propiedad del objeto de comando, por lo que podemos tener tres opciones diferentes para cada uno de los comandos de actualización.

NO

TA

...y si nos pusiéramos quisquillosos, tendríamos que decir que esto es un mal diseño relacional. Hasta donde llegan mis cortas luces, un comando que se ejecuta indepen-dientemente de un adaptador, no tiene nada que hacer con UpdateRowSource. Esta propiedad tampoco podría pertenecer al adaptador, a no ser que la multiplicásemos por tres. La respuesta purista sería crear una clase auxiliar que apuntase a un comando y tuviese una propiedad UpdateRowSource... ¡incluso sería pecado plantear que la clase auxiliar heredase de la clase de comandos! Por supuesto, en la vida real nos olvidamos de estas tonterías que no aportan las suficientes ventajas para justificar la complejidad adicional que provocarían.

Cuando la propiedad mencionada vale None, el adaptador entiende que no debe ac-tualizar los datos de la fila que acaba de crear o modificar. Si vale OutputParameters, el adaptador asume que la instrucción ejecutada era un procedimiento almacenado, y que debe recuperar los valores más recientes mediante parámetros de salida. Por ejemplo, podíamos haber creado un procedimiento almacenado, por adelantado, para la inserción de registros de proveedores. El procedimiento podría parecerse a éste:

Page 343: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones complejas 343

create procedure InsertarProveedor

@SupplierID int output,

@CompanyName nvarchar(40), @ContactName nvarchar(30),

@ContactTitle nvarchar(30), @Address nvarchar(60),

@City nvarchar(15), @Region nvarchar(15),

@PostalCode nvarchar(10), @Country nvarchar(15),

@Phone nvarchar(24), @Fax nvarchar(24),

@HomePage ntext as

begin

insert into SUPPLIERS(CompanyName, ContactName, ContactTitle,

Address, City, Region, PostalCode, Country,

Phone, Fax, HomePage)

values (@CompanyName, @ContactName, @ContactTitle,

@Address, @City, @Region, @PostalCode, @Country,

@Phone, @Fax, @HomePage);

set @SupplierID = scope_identity()

end

Las instrucciones son las mismas, pero ahora están envueltas dentro de un procedi-miento, y los valores de las columnas de la fila a insertar se asocian ahora a los pará-metros del procedimiento. Como sólo hay una columna que necesita ser releída des-pués de la inserción, hay un único parámetro de entrada y salida, pero podrían ser más si fuese necesario. En estos parámetros de salida, el adaptador buscaría los valo-res más recientes para refrescar la fila original.

Cuando el valor de UpdatedRowSource es FirstReturnedRecord, el adaptador espera que el procedimiento, o el lote de instrucciones que se ejecuta, devuelva al menos un con-junto de registros mediante una instrucción select libre. El adaptador sólo leería la primera fila del primer conjunto de registros devuelto, y de ahí sacaría los valores más recientes.

Sin embargo, la propiedad UpdatedRowSource vale Both en nuestro ejemplo, el valor por omisión de la propiedad. Esto obliga al adaptador a estar preparado para recibir un conjunto de registros; si no lo encontrase, buscaría entonces los parámetros de salida, si existiesen. Esto implica obligar a ADO.NET a que sude la camiseta por gusto, y podemos ganar un poco en eficiencia si asignamos a UpdatedRowSource en cada comando el valor más apropiado:

• Para los comandos DeleteCommand y UpdateCommand, asigne None, porque no necesitan releer registros, al menos en nuestro ejemplo.

• Para InsertCommand, asigne FirstReturnedRecord, porque los valores más recientes se reciben dentro de un conjunto de registros.

Enumeremos ahora todos los pasos que realiza el adaptador cuando actualiza una fila, en su sentido más general, y no se producen excepciones:

1 Los valores de las columnas de la fila se copian en los parámetros del comando correspondiente.

2 Se dispara el evento RowUpdating del adaptador, que estudiaremos más adelante. 3 Se ejecuta el comando.

Page 344: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

344 La Cara Oculta de C#

4 Si UpdatedRowSource lo permite, se comprueban los conjuntos de registros de-vueltos por el comando.

5 Si UpdatedRowSource lo permite, se examinan los posibles parámetros de salida. 6 Se dispara otro evento: RowUpdated, también del adaptador. 7 Se ejecuta el método AcceptChanges de la fila.

Lo que nos importa ahora es que, si no hay errores que nos amarguen la operación, todas las filas de la tabla que hemos actualizado pasan al estado Unchanged. Además, las filas nuevas tienen en su columna SupplierID el valor correcto, tal y como fue ge-nerado por el servidor SQL. Todo esto significa que, si no estamos trabajando en tres capas, la actualización del conjunto de datos ha terminado.

NO

TA

Por el contrario, si la capa intermedia y la de presentación estuviesen separadas, posi-blemente habríamos enviado a la capa intermedia una copia del conjunto de datos sólo con las filas pendientes de cambio. En ese caso, tendríamos que devolver a la capa de presentación el conjunto de datos actualizado, y mezclar el conjunto de datos original con los nuevos registros. Todo esto, lo veremos en breve.

Nos quedan un par de pasos para que nuestro ejemplo pueda aplicar los cambios. Abra la ventana del Explorador de Soluciones, y haga doble clic sobre el nodo titu-lado SuppliersDS, que es el que corresponde al conjunto de datos con tipos creado:

Es aconsejable que cambiemos la configuración de la columna SupplierID, asignando en la propiedad AutoIncrementStep el valor -1. Así garantizamos que no tendremos conflictos entre los valores generados en memoria para SupplierID, que serán ignora-dos por el adaptador, y los valores reales positivos que puede tener esta columna.

Finalmente, añada un botón en algún lugar del formulario y programe su respuesta al evento Click:

daSuppliers.Update(suppliersDS.Suppliers);

Cada adaptador puede actualizar sólo una tabla. Ya veremos qué sucede cuando ten-gamos que trabajar con relaciones entre tablas.

Page 345: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones complejas 345

Propagación y mezcla de datos

Compliquemos un poco las cosas. Supongamos que el conjunto de datos se encuen-tra en la capa de presentación, junto con la rejilla o los controles de edición, y que el adaptador de datos se encuentra en la capa intermedia, en otro ordenador. No vamos a mostrar ahora los detalles de la comunicación remota; tendremos que esperar a la parte final del libro. Pero nos la arreglaremos para simular las condiciones de la lla-mada remota, para poder estudiar las técnicas que ahora nos interesan.

Cuando pedimos información al adaptador, inicialmente, recibimos una cantidad relativamente grande de registros: digamos que cien, aunque Northwind sólo tiene 29 proveedores. Sin embargo, sólo modificamos un par de registros y pulsamos el botón de actualización. Cuando veamos las técnicas de comunicación remota, veremos que podemos pasar una copia exacta del conjunto de datos de un proceso a otro, sin importar sus ubicaciones. En este caso, ¿tendría sentido pasar todo el conjunto de datos, con sus cien filas, si sólo hay cambios en un par de ellas?

Por fortuna, podemos crear fácilmente un clon del conjunto de datos original que contenga solamente las filas con modificaciones. Para ello, nos basta con ejecutar el método GetChanges de la clase DataSet:

DataSet copia = suppliersDS.GetChanges();

daSuppliers.Update(copia.Tables[0]);

// … ¿y ahora qué?

Podemos enviar la copia al servidor de capa intermedia (como todavía no sabemos implementarlo, imagíneselo así: un conjunto de datos volando de una máquina a otra, esquivando a los bebedores de café mañaneros y a las secretarias ociosas). Allí, en la capa intermedia, el adaptador se las arregla para enviar las actualizaciones a la base de datos y para refrescar la copia. Luego, en otra operación imaginaria, la copia regresa a la capa de presentación (la misma imagen visual, pero ahora el objeto lleva la lengua afuera y jadea). Ahora tenemos que mezclar los registros modificados con los regis-tros que se quedaron en el lado cliente.

En el lado cliente tenemos filas que no se han tocado, filas modificadas, filas nuevas y filas marcadas para ser borradas. Las borradas desaparecerán si llamamos al método AcceptChanges sobre el conjunto de datos original. Como en el conjunto modificado que nos devuelve la capa intermedia no vienen filas borradas, hemos resuelto un tercio del problema.

También existe una solución sencilla para las filas modificadas, gracias a la existencia del método Merge de la clase DataSet:

suppliersDS.Merge(copia);

Este método tiene múltiples aplicaciones, pero en este caso, en el que hay tablas con idénticos esquemas en ambos conjuntos de datos, y en el que cada tabla tiene su clave primaria, lo que hace Merge es recorrer las filas de la copia, buscar la correspon-

Page 346: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

346 La Cara Oculta de C#

diente fila en la tabla original, y sustituir sus valores con los de la copia. ¡Justo lo que necesitamos!

La receta para las filas insertadas es más complicada. En el conjunto de datos origi-nal, las filas tendrán valores negativos en la clave primaria, que no coincidirán con los valores reales de las claves en la copia enviada desde el servidor. Merge no puede ha-cer nada aquí... a no ser que ayudemos un poco. Podemos eliminar las filas del servi-dor que tengan Added como estado en su propiedad RowState. Las filas insertadas que vienen en la copia no encontrarán una contrapartida en el conjunto de datos original, y se añadirán sin más. Esta es la solución completa:

if (suppliersDS.HasChanges())

{

// Seleccionamos las filas con cambios

DataSet copia = suppliersDS.GetChanges();

// Actualizamos la copia

daSuppliers.Update(copia.Tables[0]);

// Eliminamos las filas nuevas del original

foreach (DataRow dr in suppliersDS.Suppliers.Select(

"", "", DataViewRowState.Added))

suppliersDS.Suppliers.Rows.Remove(dr);

// Mezclamos las filas modificadas y añadimos las nuevas

suppliersDS.Merge(copia);

// Eliminamos las filas borradas

suppliersDS.AcceptChanges();

}

Para seleccionar las filas nuevas hemos utilizado el método Select, que ya estudiamos con los restantes métodos de búsqueda y filtrado. También es importante que com-prenda que la llamada final a AcceptChanges devuelve todas las filas a su estado origi-nal, Unchanged. Si volvemos a pulsar el botón de actualización, el conjunto de datos estará “limpio”, y no se ejecutarán actualizaciones en la base de datos.

Grabación de relaciones maestro/detalles

Siguiente ejemplo complicado: una grabación maestro/detalles. Esta vez editaremos la tabla de categorías y los productos asociados a las mismas. Estos son los pasos necesarios para echar a andar el ejemplo:

1 Vamos a crear un conjunto de datos con tipos. Arrastre, desde el Explorador de Servidores, la tabla de categorías, Categories, y la tabla de productos, Products, de la base de datos Northwind a un formulario vacío.

2 En el paso anterior deben haberse creado tres componentes. El primero de ellos es una conexión, de la clase SqlConnection. Cambie su nombre a sqlConn, para abreviar.

3 Además, deben haber dos SqlDataAdapter en la bandeja de componentes del formulario. Llámelos daCategories y daProducts.

Page 347: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones complejas 347

4 Para ser justos, este paso no es necesario, pero le vendrá bien practicar un poco. En ambos adaptadores, modifique la propiedad UpdatedRowSource de sus coman-dos de actualización. Para los comandos DeleteCommand, asigne None, para indicar que no hace falta refrescar registro alguno. Para los restantes comandos, asigne FirstReturnedRecord en dichas propiedades.

5 Tampoco es estrictamente necesario, pero nos vendrá bien simplificar las ins-trucciones de inserción en ambos adaptadores. La única columna que debemos refrescar en ambas tablas es la de la clave primaria. Es cierto que la tabla de pro-ductos tiene columnas con valores por omisión. Pero hay que recordar que estos valores se activan únicamente si enviamos una instrucción insert que no men-cione todas las filas. Sólo mostraré aquí el resultado de modificar la instrucción de inserción en la tabla de categorías:

insert into Categories (CategoryName, Description, Picture)

values (@CategoryName, @Description, @Picture);

select @@identity CategoryID

6 Añada un conjunto de datos desde el cuadro de herramientas. Pida la creación de un nuevo conjunto de datos con tipos, y llame ProductsDS a la clase. Exija al asistente, usando los modales que estime necesarios, que añada una instancia de la nueva clase al formulario activo.

7 Active la ventana del Explorador de Soluciones, localice el nodo ProductsDS.xsd, y haga doble clic sobre él para editar la definición del conjunto de datos en XML Schema. Para empezar, añada una relación entre la tabla de categorías, como pa-dre, y la de productos, como hijo.

8 Aproveche y modifique la propiedad AutoIncrementStep de las dos claves primarias para que valga -1, con el fin de evitar conflictos con registros existentes. Busque la columna Discontinued, de tipo lógico, en la tabla de productos, e indique false como valor por omisión para la misma. Esto le evitará unos cuantos disgustos durante el alta de productos. Cuando guarde el fichero xsd, Visual Studio regene-rará la definición de la clase del conjunto de datos, ProductsDS.

9 Regrese al formulario inicial, para ocuparnos de la interfaz visual, y añada un par de rejillas. En la primera de ellas, asigne como DataSource la instancia del con-junto de datos con tipos creada con malos modales en el paso 6, productsDS; luego, asigne Categories en su DataMember. Utilice el mismo DataSource para la se-gunda rejilla, pero asigne en su DataMember el nombre cualificado de la relación creada en el paso 7: Categories.CategoriesProducts. No hace falta esmerarse dema-siado con las rejillas.

Page 348: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

348 La Cara Oculta de C#

10 Ponga un botón en cualquier parte del formulario; nos servirá para disparar el proceso de actualización. Finalmente intercepte el evento Load del formulario, para cargar los registros de la base de datos dentro del conjunto de datos con ti-pos. Ejecute, en la respuesta a Load, las siguientes instrucciones:

private void MdForm_Load(object sender, System.EventArgs e)

{

daCategories.Fill(productsDS.Categories);

daProducts.Fill(productsDS.Products);

}

Con esto, ya hemos terminado los preparativos. Verifique, no obstante, que la carga de registros y la navegación funcionan correctamente.

El orden de actualización

Veamos qué problemas debemos resolver con esta configuración de los datos. Para empezar, tenemos el mismo problema de antes con las claves primarias... con un agravante con el que enseguida tropezaremos. Por una parte, es cierto que si conside-ramos independientemente las dos tablas, la identidad es un asunto trivial: basta con añadir una instrucción para la relectura al final de la inserción, y configurar adecuada-mente la propiedad UpdatedRowSource del comando. Del resto se encarga el adaptador. Incluso, si no enviásemos una copia al adaptador, sino que estuviéramos trabajando con el conjunto de datos original, aquí terminaría la historia. En caso contrario, un simple truco y la operación de mezcla, nativa de ADO.NET, resolverían la concilia-ción entre el original y su copia actualizada.

El problema añadido es la relación existente entre las tablas. Suponga que queremos crear un nuevo producto. ¿Dentro de cuál categoría? Digamos que se trata de una categoría ya existente. Entonces la clave primaria de la categoría maestra tendrá un valor real, positivo, y cuando la clase DataSet añada un registro de producto, estando seleccionada la categoría maestra en la rejilla superior, automáticamente asignará la

Page 349: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones complejas 349

clave de la categoría en la columna CategoryID del nuevo producto, como puede comprobar en la imagen:

Con este producto nuevo, que pertenece a una categoría “vieja” no habrá que preo-cuparse. Cuando se grabe, el adaptador recuperará su clave primaria real, y punto. El problema se presentará con los productos nuevos dentro de categorías “nuevas”. El registro de producto tendrá valores falsos en dos columnas: en ProductID, como era de esperar, pero también en CategoryID, en donde almacenará un valor cero o nega-tivo, el que corresponda a la clave artificial recibida por la nueva categoría.

Esta dificultad se puede convertir en una tragedia griega en otros sistemas multica-pas, pero ADO.NET lo resuelve en un santiamén. El truco está en la definición de la relación, dentro del conjunto de datos con tipos. Seleccione nuevamente el fichero ProductsDS.xsd para su edición, y haga doble clic sobre la relación que une las dos tablas del diagrama. En la parte inferior del diálogo de propiedades de la relación encontrará los siguientes controles:

¡Me importa un cuerno lo que diga la regla de eliminación! Lo importante es que el valor de la regla de actualización debe ser Cascade. Además, debemos garantizar que los registros de categorías siempre se inserten antes que los registros de productos. De esta manera, cuando la categoría recién creada reciba su clave primaria definitiva, este valor se propagará en cascada a la columna CategoryID de la tabla de productos. Y asunto concluido. Le Roi est mort, vive le Roi – como solía exclamar D’Artagnan cuando Porthos se emborrachaba y se caía del caballo.

Si lo pensamos un poco, esto de insertar las categorías antes de los productos tiene mucho sentido, ¿no es cierto? La mayoría de las relaciones maestro/detalles se crean cuando hay una relación de integridad referencial equivalente dentro de la base de datos, como sucede en nuestro ejemplo. No podríamos, aunque nos empeñásemos,

Page 350: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

350 La Cara Oculta de C#

crear un producto antes que su categoría... a no ser que hagamos chapuzas como asignarlo a una categoría temporal y moverlo posteriormente a la categoría correcta.

¿Y qué pasaría con los registros borrados? Esto se va complicando, porque con las eliminaciones ocurre todo lo contrario: primero, debemos borrar los productos y sólo después, atrevernos con las categorías. ¿Y las actualizaciones? Siempre que no toquemos las claves primarias (¡no se atreva!), da lo mismo. Es como la clásica pre-gunta sobre qué existió primero: el huevo, o las gallinas de Jericó.

NO

TA

Si le parece una exageración la prohibición de modificar claves primarias, recuerde dos detalles: para empezar, las columnas con el atributo de identidad son de sólo lectura. Pero si no lo fueran, estaría todavía el hecho de que SQL Server, y la mayoría de las bases de datos decentes, utiliza casi siempre un índice agrupado (clustered) para alma-cenar físicamente los registros, y casi siempre este índice se define sobre la clave prima-ria. Un cambio en el valor de esta provocaría el movimiento físico del registro de una página a otra, con una penalización importante sobre el rendimiento del servidor.

La solución está en dividir el conjunto de datos que se envía para grabar en varios grupos, de acuerdo al estado de cada fila. Entonces enviaríamos cada grupo por separado al adaptador correspondiente, cuidando mucho el orden de envío.

Uno de los posibles algoritmos para nuestra sencilla relación entre categorías y pro-ductos, sería como el siguiente:

Dividir las filas de categorías según su estado

Dividir las filas de productos según su estado

Aplicar las inserciones y modificaciones de categorías

Aplicar las inserciones y modificaciones de productos

Aplicar los borrados de productos

Aplicar los borrados de categorías

Claro está, podríamos escribir trabajosamente un algoritmo de grabación concreto para cada relación existente en nuestra aplicación... pero los programadores serios, como usted y como yo, reconocemos inmediatamente la oportunidad de sacar factor común.

Vamos a crear una estructura auxiliar para que nos eche una mano con la grabación de relaciones maestro/detalles. Este será el esqueleto de la estructura:

public struct UpdateMD

{

private System.Collections.IDictionary adapters;

private System.Data.DataSet dataSet;

public UpdateMD(DataSet ds)

{

dataSet = ds;

adapters = new System.Collections.Hashtable();

}

public UpdateMD Add(DataTable dt, SqlDataAdapter da)

{

if (dt.DataSet != dataSet)

throw new Exception("Invalid table");

Page 351: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones complejas 351

adapters[dt] = da;

return this;

}

public UpdateMD Add(string tableName, SqlDataAdapter da)

{

adapters[dataSet.Tables[tableName]] = da;

return this;

}

// … más …

}

Cuando creamos la estructura, indicamos con qué conjunto de datos va a trabajar, y lo recordamos dentro de una variable privada. Creamos también un diccionario, me-diante una tabla de hash, para que la estructura sepa con cuál adaptador debemos grabar cada tabla. El diccionario se llenará con las dos versiones sobrecargadas del método público Add. Para que se haga una idea, en nuestro ejemplo, crearemos una estructura UpdateMD con las siguientes instrucciones:

UpdateMD updateMD = new UpdateMD(copia);

updateMD.Add("Categories", daCategories);

updateMD.Add("Products", daProducts);

Estas instrucciones pueden conectarse entre sí, de esta otra manera:

UpdateMD updateMD = new UpdateMD(copia).Add(

"Categories", daCategories).Add("Products", daProducts);

No ganamos nada con eso... pero es divertido. Una vez configurada la estructura, para grabar el conjunto de datos debemos llamar a un método público, que a su vez llama a otro método recursivo, todos de la clase UpdateMD:

public void Update()

{

foreach (DataTable table in dataSet.Tables)

if (table.ParentRelations.Count == 0)

Update(table);

}

private void Update(DataTable dt)

{

SqlDataAdapter da = adapters[dt] as SqlDataAdapter;

if (da != null)

{

Update(da, dt, DataViewRowState.ModifiedCurrent);

Update(da, dt, DataViewRowState.Added);

foreach (DataRelation rel in dt.ChildRelations)

Update(rel.ChildTable);

Update(da, dt, DataViewRowState.Deleted);

}

}

private void Update(SqlDataAdapter da, DataTable dt,

DataViewRowState state)

{

da.Update(dt.Select("", "", state));

}

Page 352: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

352 La Cara Oculta de C#

El primero de los métodos Update, que es el método público mencionado, recorre las tablas del conjunto de datos, para detectar aquellas que no tienen una tabla maestra. Para ello, comprobamos que no existan objetos en la colección ParentRelations de la tabla. Las tablas que cumplen con esta condición se pasan a la siguiente versión pri-vada de Update, que es un método recursivo.

NO

TA

Para simplificar, supondremos que las tablas están organizadas como un bosque, o co-lección de árboles. Por supuesto, existen ejemplos completamente correctos en los que una misma tabla es hija en dos relaciones: éste sería el caso de la tabla de detalles de pedidos, que tendría como padres a la tabla de pedidos y a la de productos. También puede ocurrir que la tabla por la que debemos empezar a grabar, sea hija de una tabla de sólo lectura. Esto podría ocurrir si tuviésemos empleados, pedidos y detalles en un mismo conjunto de datos, y la tabla de empleados fuese de sólo lectura, porque se estu-viese utilizando sólo para traducir los códigos de empleados dentro de los pedidos. Estos casos más generales tienen también solución, aunque sea ayudando un poco más a nuestra estructura auxiliar.

En la versión recursiva de Update, nos pasan una tabla, y lo primero que hacemos es buscar dentro del diccionario cuál es el adaptador que le corresponde. Con la ayuda de otro método auxiliar, aplicamos los cambios (ahora veremos cómo) a las filas que estén en los estados Added y ModifiedCurrent. Luego viene un paréntesis: nos zambu-llimos recursivamente en las tablas hijas de la tabla actual. Cuando regresamos, apli-camos los cambios a las filas borradas.

¿Recuerda cómo se podían visitar los nodos de un árbol arbitrario? Teníamos dos algoritmos famosos: el recorrido en pre-orden y en post-orden. Nuestro Update mez-cla ambos tipos de recorridos. Para grabar las inserciones y modificaciones, utiliza el recorrido en pre-orden; para los borrados, utiliza el post-orden.

Finalmente, llega el momento de la verdad. Nos dan un adaptador, una tabla y nos dicen que sólo podemos grabar las filas que estén en cierto estado:

private void Update(SqlDataAdapter da, DataTable dt,

DataViewRowState state)

{

da.Update(dt.Select("", "", state));

}

Como puede ver, para aislar las filas requeridas podemos utilizar el método Select de la tabla, que devuelve un vector con las filas que satisfagan el filtro indicado. En rea-lidad, no utilizamos filtro alguno, ni condición de ordenación; sólo filtramos de acuerdo al estado de las filas, y pasamos el resultado al método Update, esta vez del adaptador, para que sincronice la base de datos de acuerdo a nuestras modificaciones.

Nos queda por resolver el problema de la mezcla: recibimos una copia de los regis-tros que originalmente habíamos actualizado. La única diferencia es que las claves primarias de productos y categorías nuevas tendrán sus valores definitivos, y los re-gistros que hayamos podido marcar para ser borrados, ya no estarán presentes en la copia. Es fácil extender nuestro algoritmo de mezcla para este caso:

Page 353: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones complejas 353

private void Mezclar(DataSet copia)

{

// Primero borramos las filas añadidas

foreach (DataRow dr in productsDS.Categories.Select(

"", "", DataViewRowState.Added))

dr.Delete();

foreach (DataRow dr in productsDS.Products.Select(

"", "", DataViewRowState.Added))

dr.Delete();

// Mezclamos la copia con el original y eliminamos las huellas

productsDS.Merge(copia);

productsDS.AcceptChanges();

}

Evidentemente, el método Select ha tenido un día ajetreado. Para terminar, mezcla-mos todos estos métodos en la respuesta al evento Click del botón de actualización:

private void bnUpdate_Click(object sender, System.EventArgs e)

{

if (productsDS.HasChanges())

{

DataSet copia = productsDS.GetChanges();

// Configurar la grabación… y grabar, todo en un paso

new UpdateMD(copia).Add(

"Categories", daCategories).Add(

"Products", daProducts).Update();

// Mezclar la copia con el original

Mezclar(copia);

}

}

Recuerde que, de no haber hecho la copia para enviar a un hipotético servidor re-moto de capa intermedia, el algoritmo hubiera sido más sencillo. Nos habríamos evitado, al menos, la operación final de mezcla de conjuntos de datos.

Los eventos del adaptador

Si quisiéramos aún más control sobre el proceso de sincronización con la base de da-tos, podríamos interceptar los eventos de los adaptadores relacionados con la graba-ción de filas. Estos son:

public event SqlRowUpdatingEventHandler RowUpdating;

public event SqlRowUpdatedEventHandler RowUpdated;

Como en ADO “clásico”, el evento en gerundio, RowUpdating, se dispara antes de que el adaptador ejecute el comando que corresponda. Muy importante: en ese mo-mento, ya se han asignado valores a los parámetros del comando. RowUpdated, como su nombre indica, se dispara después de la ejecución del comando, sin importar si se han detectado, o no, errores durante la ejecución.

Estas son las propiedades comunes a las clases de los argumentos de los eventos RowUpdating y RowUpdated:

Page 354: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

354 La Cara Oculta de C#

Propiedad Contiene... Command Apunta al objeto de comando que se va a utilizar a continuación Errors De tipo Exception, debe tener una referencia nula en este punto Row La fila que se va a grabar StatementType Enumerativo con los valores Select, Insert, Delete, Update Status El estado de la grabación, que inicialmente es Continue TableMapping Asocia las tablas y columnas en memoria con sus originales

La clase de argumentos correspondiente al evento RowUpdated añade otra propiedad:

Propiedad Contiene... RecordsAffected Número de registros actualizados por el comando ejecutado

El orden de ejecución determina el uso típico de los eventos. RowUpdating puede utilizarse para verificar el cumplimiento de reglas de negocio adicionales, en la capa intermedia, o incluso para sustituir el mecanismo de grabación utilizado por el adap-tador por omisión. ¿Recuerda la clase SqlCommandBuilder del capítulo anterior? Los objetos de esta clase se registran como receptores del evento RowUpdating del adapta-dor al que se asocian. Cuando se dispara el evento por primera vez, el constructor de comandos genera la instrucción SQL apropiada, de acuerdo al estado de la fila, y a partir de ese momento, esa es la instrucción que se ejecutará.

RowUpdated, en cambio, es un evento más apropiado para ejecutar instrucciones adi-cionales a las contenidas en los comandos del adaptador. Suponga que estamos tra-bajando con Access, y que queremos insertar registros en una tabla cuya clave prima-ria es una columna autoincremental. Al igual que ocurre en SQL Server, a partir de Access 2000 podríamos preguntar por el valor de la variable @@identity, pero tropeza-ríamos con dos obstáculos: Access no soporta procedimientos almacenados, ha-blando con propiedad, y tampoco permite ejecutar lotes de consultas. La solución es recuperar el valor de la identidad asignada después de la inserción, precisamente en la respuesta al evento RowUpdated:

private System.Data.SqlClient.SqlCommand cmdIdentidad = null;

private void daSuppliers_RowUpdated(object sender,

System.Data.SqlClient.SqlRowUpdatedEventArgs e)

{

if (e.Status == UpdateStatusContinue &&

e.StatementType == StatementType.Insert)

{

if (cmdIdentidad == null)

cmdIdentidad = new System.Data.SqlCommand(

"select @@identity"

e.Command.Connection);

e.row["SupplierID"] = (int) cmdIdentidad.ExecuteScalar();

e.row.AcceptChanges();

}

}

He creado el comando al vuelo, en el momento en que se necesita por primera vez, y he copiado la referencia al objeto de conexión del comando que viene en los argu-mentos del evento.

Page 355: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones complejas 355

Control de errores y transacciones

El uso más frecuente de RowUpdated es el tratamiento de errores durante la actualiza-ción. Para empezar, los adaptadores ofrecen una propiedad de tipo lógico para con-trolar la cantidad de errores admisibles durante la grabación:

public bool ContinueUpdateOnError { get; set; }

Si esta propiedad vale false, el primer error que se produce interrumpe la grabación, aunque queden más filas por actualizar; a pesar de esto, también se dispara el evento RowUpdated correspondiente a la fila que provocó el error. Si la propiedad vale true, el adaptador asigna el mensaje de error en la propiedad RowError de la fila con pro-blemas, y sigue intentando la grabación de las filas restantes.

NO

TA

El adaptador no elimina los posibles errores ya presentes en las filas. Si utilizamos la propiedad RowError durante la validación del conjunto de datos en memoria, es aconse-jable ser precavidos y eliminar cualquier error antes de proceder a la grabación mediante el adaptador.

Está claro que la técnica más sencilla consiste en terminar la actualización cuando se produzca el primer error. Así se facilitaría la identificación del error en la capa de presentación, y su corrección por parte del usuario. Pero también tiene sentido seguir con las grabaciones incluso después de producirse el primer error. Si no hay mucha carga sobre el servidor SQL, esto permitiría detectar más fallos en una sola opera-ción: se trata de un compromiso entre la comodidad del usuario y el tráfico de red, por una parte, y la carga de trabajo del servidor SQL, por la otra.

En cualquiera de estos dos casos, es casi obligatorio proteger la grabación dentro de una transacción. Aunque permitamos un único error, puede que el adaptador ya haya logrado grabar otros registros antes de que se produjese el fallo. La única forma de deshacernos de las actualizaciones parciales sería anular entonces la transacción. Pongamos por caso que vamos a grabar las modificaciones realizadas sobre la tabla tbSuppliers mediante el adaptador daSuppliers. Para proteger las grabaciones dentro de una transacción, podríamos escribir algo parecido a este fragmento:

SqlTransaction trans;

trans = daSuppliers.SelectCommand.Connection.BeginTransaction();

try {

daSuppliers.DeleteCommand.Transaction = trans;

daSuppliers.InsertCommand.Transaction = trans;

daSuppliers.UpdateCommand.Transaction = trans;

daSuppliers.Update(tbSuppliers);

if (tbSuppliers.HasErrors)

trans.Rollback();

else

trans.Commit();

}

catch (Exception) {

trans.Rollback();

throw;

}

Page 356: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

356 La Cara Oculta de C#

No obstante, sigue habiendo un problema con las filas que logran grabarse antes de producirse el error: el adaptador habrá ejecutado AcceptChanges sobre ellas, y se habrá perdido cualquier información sobre el estado inicial y los valores originales. La única forma de evitarlo es trabajar siempre con una copia del conjunto de datos, que es precisamente lo que haríamos para pasar el conjunto de datos entre capas físicas.

NO

TA

Esto es una demostración de la potencia de ADO.NET: podemos encontrar respuesta a casi todos los problemas típicos de las aplicaciones en múltiples capas, aunque implique escribir más código. Este problema de la pérdida de información de estado cuando se produce una grabación parcial, por ejemplo, se presenta también en DataSnap, la técnica de Borland Delphi para el desarrollo en tres capas. Sin embargo, DataSnap implementa la conciliación como un proceso cerrado, y no hay forma de superar la dificultad.

Los dos tipos de errores más frecuentes son los fallos de bloqueos optimistas y los errores generados en el servidor por violación de restricciones. La sección siguiente se encargará de los bloqueos optimistas, pero ahora vamos a ocuparnos de las viola-ciones de restricciones. Cuando ocurre un error de este tipo, es poco probable que podamos tomar medidas automáticas, y tenemos que confiar en que el usuario com-prenda el error y lo corrija... o abandone su intento de actualización.

Pero los mensajes de error asociados a la violación de restricciones suelen dejar la autoestima del usuario al nivel del betún de sus zapatos. Navegando sobre la tabla de proveedores, he intentado eliminar un registro, y la aplicación me ha ladrado:

DELETE statement conflicted with COLUMN REFERENCE

constraint 'FK_Products_Suppliers'. The conflict occurred in

database 'Northwind', table 'Suppliers', column 'SupplierID'

El problema no es el idioma; mi SQL está configurado en inglés, pero podría haberlo configurado en castellano. Si se tratase del idioma, la solución sería traducir la aplica-ción. Por el contrario, de lo que se trata es de la jerga. ¿No sería preferible decirle al usuario que no puede borrar ese proveedor porque tiene productos asociados?

La respuesta está en el mismo mensaje de error. Este se refiere a la restricción vio-lada por su nombre: FK_Products_Suppliers. Recuerde que estos nombres se pueden indicar al crear la restricción:

constraint FK_Products_Suppliers

foreign key (SupplierID) references dbo.Suppliers(SupplierID)

La idea consiste en identificar la restricción que hemos intentado saltarnos buscando el nombre dentro del mensaje de error. Podemos escribir un método de respuesta para el evento RowUpdated del adaptador de proveedores:

if (e.Status == UpdateStatus.ErrorsOccurred &&

e.Errors.Message.IndexOf("FK_Products_Suppliers") >= 0)

e.Errors = new Exception(

"No se puede eliminar un proveedor con productos asociados");

Page 357: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones complejas 357

Si se detecta un error, y el mensaje asociado incluye el nombre de la restricción, sus-tituimos el mensaje original por uno nuestro, asociando una nueva excepción a la propiedad Errors de los argumentos del evento.

La técnica explicada puede extenderse fácilmente. Podemos crear un fichero de texto, o si prefiere seguir la moda, un fichero XML, que asocie nombres de restricciones con los mensajes de error que queremos mostrar. De esta manera, podríamos modi-ficar los mensajes sin necesidad de tocar la aplicación.

Fallos en bloqueos optimistas

Sabemos que las instrucciones SQL generadas por los asistentes de Visual Studio y por el componente CommandBuilder exigen, para poder modificar o borrar un registro, que éste no haya sido cambiado desde el momento en que el usuario lo leyó por primera vez. ¿Qué sucede si, por el contrario, alguien ha modificado o borrado el registro desde otro puesto? Simplemente, que la operación se ejecutará sin proble-mas, pero ningún registro se verá afectado. Preste atención: si alguien ha tocado el registro, no se produce una excepción, ni un error, al ejecutar el comando corres-pondiente. La excepción la generará el adaptador más adelante, al interpretar este resultado.

Entonces, ¿cómo podemos distinguir los errores relacionados con el bloqueo opti-mista de todos los restantes errores posibles? Si estamos dentro de la respuesta al evento RowUpdated del adaptador, hay un indicio seguro:

• La propiedad Errors del segundo parámetro del evento apunta a una instancia de la clase DBConcurrencyException.

Además, la propiedad RecordsAffected, del mismo objeto, debe valer cero, indicando que no se han encontrado registros para actualizar.

Ahora bien, ¿qué debemos hacer cuando se detecta el fallo de un bloqueo optimista? Podemos mejorar un poco el mensaje, porque el original hace referencia a un objeto interno de la aplicación, del que el usuario no tiene la menor idea:

Concurrency violation: the UpdateCommand affected 0 records.

Pero esto es lo de menos: en definitiva, la traducción podría mejorar el mensaje. Lo preocupante son las opciones que tiene el usuario a partir de ese momento: lo único que puede hacer es cancelar la operación y volver a comenzar desde el principio. Recuerde que el bloqueo falló porque los valores de la versión Original de la fila con-flictiva no coincidían con los valores más recientes en la base de datos. Y el usuario no tiene la posibilidad directa de refrescar los valores originales de la fila. Cada vez que intente repetir la operación, tropezará con el mismo mensaje de error.

Para entender mejor lo que sucede, eche un vistazo al siguiente diagrama, que mues-tra las distintas versiones en juego en estos casos:

Page 358: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

358 La Cara Oculta de C#

Hay tres versiones, aunque la clase DataRow solamente soporta dos de ellas: la ver-sión original, que es la que leímos inicialmente, y la versión actual (current) que con-tiene nuestros cambios, y que es la que intentamos imponer a la base de datos. Aparte de estas dos versiones, tenemos los datos del registro real en la base de datos, que contiene los cambios realizados desde otros puestos.

Si queremos darle al usuario la posibilidad de reintentar la grabación, deberíamos leer esa versión más reciente para que sustituya eventualmente a nuestra versión original. Respecto a la mezcla, tenemos dos posibilidades:

1 Realizar la mezcla automáticamente, en la capa donde se encuentra el adaptador, sin pedir la opinión del usuario.

2 Enviar los datos más recientes de la fila a la capa de presentación, y dejar que el usuario decida qué quiere hacer... si es que lo sabe.

Para no complicar excesivamente el ejemplo, vamos a suponer que nuestra aplicación no está dividida en capas, y que queremos sustituir siempre la versión original obso-leta por la más reciente. Primero necesitaremos un método auxiliar que devuelva un conjunto de datos con la versión más reciente de una fila, dada su clave primaria:

private DataSet FilaReciente(int clave)

{

string stmt;

stmt = "select * from Suppliers where SupplierID=" + clave;

using (SqlDataAdapter da = new SqlDataAdapter(stmt, sqlConn))

{

da.MissingSchemaAction = MissingSchemaAction.AddWithKey;

DataSet ds = new DataSet();

da.Fill(ds, "Suppliers");

return ds;

}

}

Parece ser un desperdicio, porque creamos un conjunto de datos para almacenar una sola fila, pero tenga en cuenta que esto es así porque sólo permitiremos un error. Si dejásemos que se acumularan más errores, podríamos modificar el código para alma-cenar todas las versiones recientes de filas dentro del mismo conjunto de datos. Además, sería fácil evitar la creación y abandono repetido del conjunto de datos y del adaptador. Con este método auxiliar preparado, es fácil detectar el fallo del bloqueo optimista, para refrescar la versión original de la fila conflictiva:

Page 359: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Actualizaciones complejas 359

private void daSuppliers_RowUpdated(object sender,

System.Data.SqlClient.SqlRowUpdatedEventArgs e)

{

if (e.Status == UpdateStatus.ErrorsOccurred &&

e.Errors is DBConcurrencyException)

{

DataSet ds = FilaReciente((int) e.Row["SupplierID"]);

if (ds.Tables[0].Rows.Count == 0)

e.Errors = new Exception(

"Proveedor borrado por otro usuario");

else

{

e.Errors = new Exception(

"Proveedor modificado por otro usuario");

e.Row.Table.DataSet.Merge(ds, true);

}

}

}

Si detectamos que hay un error, y que la excepción que le corresponde pertenece a la clase DBConcurrencyException, pedimos la versión real de esa fila. Puede que ya no exista, y eso indicaría que la fila ha sido borrada por otro usuario: cambiamos el mensaje de error y abandonamos el método. En caso contrario, si encontramos la versión deseada, también modificamos el mensaje, aunque lo más importante es que llamamos a una versión de Merge que todavía no habíamos utilizado:

public void Merge(DataSet dataset, bool respetarCambios);

El valor true en el segundo parámetro indica que queremos mantener intacta la ver-sión Current de la fila; solamente se cambiarán los valores de la versión Original. Gra-cias a este cambio, el usuario podría reintentar la grabación, después de recibir el mensaje de error con la explicación de lo sucedido. Si nadie vuelve a interferir, esta vez sí que tendría éxito. Repasemos, de todos modos, las limitaciones que nos hemos impuesto para simplificar el algoritmo:

1 Hemos asumido la responsabilidad de la mezcla, sin dejar que el usuario inter-venga.

2 Como consecuencia de lo anterior, la aplicación no puede estar dividida en capas. De estarlo, no podríamos llevar a cabo la mezcla en el servidor de capa interme-dia. Estamos aprovechando, por lo tanto, el hecho de que pasamos el conjunto de datos original al adaptador.

3 Solamente permitimos un error durante la grabación. En caso contrario, ten-dríamos que ir acumulando filas en un conjunto de datos, para realizar la mezcla una vez terminada la actualización de todas las filas.

Debemos comprender que nuestras instrucciones de actualización, si no tomamos medidas, modifican todas las columnas del registro en la base de datos, aunque real-mente hayamos alterado un par de columnas en el conjunto de datos en memoria. Esto trae como consecuencia que, al repetir la grabación, se pierdan los cambios realizados en otras columnas por el primer usuario que actualizó la fila.

Page 360: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

360 La Cara Oculta de C#

Para solucionar esto, podríamos reemplazar el algoritmo de mezcla que hemos utili-zado, sustituyéndolo por actualizaciones directas sobre la fila conflictiva. En el algo-ritmo mejorado:

• Detectaríamos las columnas en las que su versión Original fuese la misma que su versión Current. Para estas columnas, modificaríamos la versión Current, en el caso de que la versión más reciente fuese diferente.

• Luego sustituiríamos completamente la versión Original por la versión más re-ciente, como en el ejemplo mostrado.

¿Mucho trabajo? Efectivamente, lo es. Pero es de agradecer que ADO.NET nos permita llegar a un nivel tan alto de personalización de las grabaciones. Con otros sistemas, tendríamos las manos atadas.

Page 361: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Programación remota

Parte

Programación

remota

Far away across the field The tolling of the iron bell

Calls the faithful to their knees To hear the softly spoken magic spells

Page 362: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada
Page 363: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

23

.NET Remoting

A PROGRAMACIÓN REMOTA ES UN ÁREA MUY especializada de la Informática. A la misma vez, es un área muy extensa, sobre todo por la gran variedad de siste-

mas existentes. Un buen programador, en estos tiempos que corren, debe ser capaz de dominar al menos una de los técnicas que permiten la programación distribuida en los sistemas operativos modernos. En mi opinión, ya no merece la pena diseñar aplicaciones de bases de datos que se limiten a operar dentro de los estrechos límites de la tradicional red local. Puede que este tipo de aplicaciones siga existiendo durante algún tiempo, pero programarlas ha dejado de ser rentable.

Un ejemplo muy sencillo

Este no es un libro sobre .NET Remoting; mi intención es enseñarle la parte mínima de esta disciplina que necesitará para diseñar y programar aplicaciones potentes y eficientes divididas en capas físicas, sobre sistemas distribuidos. Por fortuna, la curva de aprendizaje de .NET Remoting es mucho más fácil de superar que la de los siste-mas a los que puede sustituir... o para ser más diplomáticos, complementar. Por este motivo, podemos darnos el lujo de comenzar con un ejemplo razonablemente com-pleto, pero sencillo, en vez de presentar una indigesta ensalada de conceptos antes de escribir la primera línea de código.

En nuestro primer ejemplo programaremos una clase en el servidor con una sola misión: informar de la hora; la hora del propio servidor, se entiende. El modelo le parecerá extraño a primera vista: en todo momento, en el servidor existirá, como máximo, una sola instancia de la clase que definiremos. El primer cliente remoto que pregunte por la hora, disparará la creación de la instancia única, que los restantes clientes utilizarán a partir de ese momento. Para obtener la hora, no será necesario que el objeto en el servidor “recuerde” nada entre llamada y llamada, por lo que este sencillo modelo nos será más que suficiente.

Incluso en un ejemplo tan simple, nos encontraremos con situaciones típicas de la programación distribuida. Para empezar, no podemos limitarnos a crear una aplica-ción servidora y otra cliente, sin nada en común entre ellas. Debemos proveer algu-nas definiciones que sean comunes a ambas aplicaciones, para legislar qué tipo de conversación pueden entablar nuestros clientes con el servidor. En este caso, bastará

L

Page 364: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

364 La Cara Oculta de C#

con definir un tipo de interfaz, que alojaremos dentro de un ensamblado que será compartido por ambas aplicaciones.

Para entrar en materia, active Visual Studio, cree un proyecto de tipo Biblioteca de Clases, llame ComunRemoto al nuevo proyecto, y cambie a ComunRemoto.cs el nombre del fichero de código que creará Visual Studio:

El cambio de nombre servirá también para cambiar automáticamente el nombre del espacio de nombres creado por omisión. Con el editor de código, modifique el con-tenido de ese fichero de código para que se parezca a esto:

using System;

namespace ComunRemoto {

public interface IRemoteClock {

DateTime CurrentDate {

get;

}

}

}

Como ve, el contenido del fichero es muy simple. Incluye un tipo de interfaz público, al que he bautizado IRemoteClock, que tiene una propiedad de sólo lectura llamada CurrentDate y que suponemos que devolverá la fecha y hora actual. Cuando compile el proyecto, obtendrá una DLL llamada ComunRemoto.dll. Este fichero tendrá que ser compartido tanto por el cliente como por el servidor. En nuestro ejemplo, cliente y servidor residirán en el mismo ordenador, pero tenga presente que en un caso real, el servidor y sus clientes se ubicarán en máquinas independientes.

El primer servidor remoto

Vamos ahora a crear el servidor, y para ello utilizaremos una aplicación de consola. Puede que no le haga mucha gracia, ¿verdad? Ese tipo de aplicaciones no es el más adecuado para un servidor remoto: antes de poder ejecutar una aplicación de con-sola, es necesario iniciar sesión en Windows. Además, esa ventana negra, aún si la rebautizamos como consola o línea de comandos, sigue pareciéndose sospechosa-mente a MS-DOS. Si le preocupa este problema, tranquilícese: más adelante veremos cómo un programador “de verdad” encapsularía sus servidores remotos.

De momento, aquí tiene el código fuente que debe teclear:

using System;

using System.Runtime.Remoting;

using System.Runtime.Remoting.Channels;

using System.Runtime.Remoting.Channels.Http;

using ComunRemoto;

Page 365: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 365

namespace ServidorRemoto

{

class RemoteClockClass: MarshalByRefObject, IRemoteClock

{

public DateTime CurrentDate

{

get { return DateTime.Now; }

}

}

class ServidorApp {

static void Main(string[] args)

{

HttpChannel chn = new HttpChannel(1234);

ChannelServices.RegisterChannel(chn);

RemotingConfiguration.RegisterWellKnownServiceType(

typeof(RemoteClockClass),

"RemoteClock.soap",

WellKnownObjectMode.Singleton);

Console.ReadLine();

}

}

}

Echemos un vistazo a las referencias a espacios de nombres en las cláusulas using iniciales. ¿Ve la referencia al espacio de nombre ComunRemoto? Se trata del ensam-blado compartido, la DLL que compilamos en el paso anterior. Como la DLL no ha sido instalada formalmente, necesitaremos añadir ese fichero como referencia, en la ventana del Explorador de Soluciones de Visual C#, porque de lo contrario, la cláu-sula using que hace mención a ComunRemoto no compilará correctamente.

A partir de este punto, iremos más despacio. He extraído del listado anterior la de-claración de la clase RemoteClockClass:

class RemoteClockClass: MarshalByRefObject, IRemoteClock

{

public DateTime CurrentDate

{

get { return DateTime.Now; }

}

}

Page 366: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

366 La Cara Oculta de C#

Al definir esta clase, estamos cumpliendo dos requisitos de nuestro servidor:

1 Debemos declarar una clase que implemente el tipo de interfaz con el que que-remos que nuestros clientes remotos trabajen: IRemoteClock. Si vamos a vender churros, necesitaremos vendedores de churros, ¿o no?

2 La clase que definamos debe descender, directa o indirectamente, de la clase MarshalByRefObject. Esta es la condición que permite que las instancias de una clase dada puedan ser “manejadas” como por medio de un control remoto o mando a distancia. Más adelante, explicaré en qué consiste y cómo se logra este mando a distancia.

Ya tenemos una clase, confieso que muy sencilla, que implementa la interfaz que hemos anunciado, y que puede ser controlada a distancia. ¿Cómo haremos para que las instancias de esta clase estén a disposición de los clientes? Esto será responsabili-dad del método Main, de la clase ServidorApp, que como sabemos, será el método que se ejecutará cuando se active la aplicación de consola. El método comienza por crear una instancia de canal:

HttpChannel chn = new HttpChannel(1234);

ChannelServices.RegisterChannel(chn);

Como sugiere su nombre, un canal es el conducto por el que un cliente y su servidor remoto intercambian mensajes. En concreto, .NET Remoting pone a nuestra dispo-sición dos tipos de canales predefinidos: un tipo de canal que utiliza el protocolo HTTP, y otro que trabaja a más bajo nivel, directamente a través de zócalos de TCP/IP. Para simplificar, este ejemplo utiliza un canal HTTP, que escucha peticiones por un puerto poco usual: el puerto 1234. En realidad, nuestro pequeño servidor, al registrar este canal, comenzará a actuar como un servidor HTTP, con la única salve-dad de que utilizará el puerto 1234 en vez del habitual puerto 80. Es importante que comprenda que, para que este ejemplo funcione, no hace falta tener un servidor HTTP instalado en el ordenador que actúa como servidor. Ese papel lo desempeñará la aplicación servidora. Más adelante veremos las muchas variantes que existen para la configuración de canales.

NO

TA

Como podrá imaginar, un canal TCP es más eficiente que un canal HTTP. Entre los moti-vos que podrían llevarnos a utilizar el canal HTTP en una aplicación real está, en primer lugar, la necesidad de atravesar cortafuegos que verifiquen que el tráfico permitido cum-pla con las reglas del protocolo HTTP, pero principalmente, como veremos luego, por la posibilidad de alojar el servidor dentro de Internet Information Services.

La instrucción que sigue al registro del canal es la que finalmente se encarga de publi-car la clase que hemos implementado:

RemotingConfiguration.RegisterWellKnownServiceType(

typeof(RemoteClockClass),

"RemoteClock.soap",

WellKnownObjectMode.Singleton);

El método RegisterWellKnownServiceType anuncia la disponibilidad de una instancia de la clase RemoteClockClass en una URL relativa al servidor, que se construye mediante

Page 367: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 367

el nombre que pasamos en el segundo parámetro del método. Un cliente, por ejem-plo, podría hacer mención al servicio anterior mediante una URL como ésta:

http://www.classiqueCentral.com:1234/RemoteClock.soap

La URL mencionada se descompone en las siguientes partes:

1 El protocolo: http://

2 El servidor: www.classiqueCentral.com, hipotéticamente, claro. Ese nombre debe representar una dirección IP pública. Podríamos también mencionar una IP nu-mérica, directamente.

3 El número del puerto: 1234. Si no indicásemos el puerto, la petición se dirigiría al puerto por omisión, que en este caso sería el puerto 80.

4 La URL relativa que hemos asignado al servicio: RemoteClock.soap. La extensión es indiferente, excepto cuando el servicio se aloja dentro de Internet Informa-tion Services, pero se recomienda usar .soap y .rem.

Nos queda el parámetro más interesante: el modo de activación. Este es un enume-rativo con dos valores en su declaración:

• Singleton: El servidor creará, como máximo, una instancia de la clase remota, independientemente del número de clientes que accedan a la misma. Si guarda-mos información de estado dentro de la instancia, esta información persistirá de una llamada remota a la siguiente, y será compartida por todos los clientes.

• SingleCall: Cada vez que un cliente realice una llamada remota, el servidor creará una instancia, que será destruida en cuanto termine la llamada. No se preserva información de estado de una llamada a la siguiente.

En este caso hemos preferido Singleton. En un ejemplo real, tendríamos que decidir-nos entre los posibles problemas de concurrencia que provocaría Singleton con la pérdida del estado entre llamadas que tendríamos con SingleCall.

Finalmente, el servidor ejecuta la siguiente instrucción, para esperar a que alguien teclee un cambio de línea:

Console.ReadLine();

Recuerde que todo este código de registro y configuración se ha ejecutado durante la activación de una aplicación de consola. De no tomar medidas, la aplicación termina-ría inmediatamente y... ¡adiós servidor! El servidor debe estar en ejecución para que los clientes puedan acceder a sus servicios. No ocurre como en COM, en el que una petición remota puede provocar la ejecución o la carga del módulo que contiene la clase COM remota.

Page 368: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

368 La Cara Oculta de C#

El primer cliente remoto

Para no complicarnos, la aplicación cliente será también una sencilla aplicación de consola; como ejercicio, puede intentar convertirla en una aplicación basada en for-mularios. El código fuente es más sencillo aún, si cabe:

using System;

using System.Runtime.Remoting;

using System.Runtime.Remoting.Channels;

using System.Runtime.Remoting.Channels.Http;

using ComunRemoto;

namespace ClienteRemoto

{

class ClienteApp

{

static void Main(string[] args) {

HttpChannel canal = new HttpChannel();

ChannelServices.RegisterChannel(canal);

IRemoteClock rc = (IRemoteClock) Activator.GetObject(

typeof(IRemoteClock),

"http://localhost:1234/RemoteClock.soap");

Console.WriteLine(rc.CurrentDate.ToString());

Console.ReadLine();

}

}

}

Como puede ver, volvemos a incluir una referencia al ensamblado común, Comun-Remoto, porque vamos a utilizar otra vez el tipo de interfaz declarado en su interior. Comenzamos creando un canal, aunque esta vez se trata de un canal cliente:

HttpChannel canal = new HttpChannel();

ChannelServices.RegisterChannel(canal);

La diferencia consiste en que no hemos indicado un puerto para el canal HTTP. Luego, con una sola instrucción obtenemos un puntero a una instancia remota; al menos, eso es lo que .NET pretende que creamos:

IRemoteClock rc = (IRemoteClock) Activator.GetObject(

typeof(IRemoteClock),

"http://localhost:1234/RemoteClock.soap");

El método estático GetObject, de la clase Activator, crea un proxy en el lado cliente y lo devuelve mediante una referencia genérica del tipo object. Para poder hacer algo con esta referencia, debemos realizar una comprobación y conversión de tipos, al tipo de interfaz que hemos implementado en la clase remota.

¿Qué es esa palabreja, proxy? Podríamos traducir proxy como “delegado”... pero ten-dríamos con conflicto con delegate, que como sabe, es un tipo de datos sin relación alguna con el tema que nos ocupa. Un proxy es una imitación de un objeto remoto, en el mismo sentido en el que un mando a distancia es una réplica superficial de los botones de la consola de un televisor. Si el televisor tiene un botón de ajuste de vo-

Page 369: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 369

lumen, lo mismo deberá suceder en el mando a distancia. Al pulsar la réplica del botón en el mando, el “canal infrarrojo” envía un mensaje al televisor, para que éste actúa como si alguien hubiese manipulado el botón “local”.

Como he dicho antes, GetObject crea un proxy y se lo entrega al cliente. El proxy tiene la misma estructura del tipo de interfaz pasado en el primer parámetro del método, esto es, la estructura de IRemoteClock. Además, el proxy se configura para que envíe sus mensajes al servicio ubicado en la siguiente URL:

http://localhost:1234/RemoteClock.soap

Ya he explicado cómo interpretar este tipo de referencias. En este caso, la diferencia respecto al ejemplo anterior es que estamos accediendo al mismo ordenador, por lo que hemos utilizado localhost como nombre del servidor. Otra forma de apuntar al servicio sería la siguiente:

http://127.0.0.1:1234/RemoteClock.soap

En realidad, la creación del proxy no es el suceso que obliga a la creación del objeto remoto, sino la ejecución de uno de los métodos sobre el proxy. En este ejemplo, el objeto remoto se crea cuando el primer cliente pide la hora en el servidor:

Console.WriteLine(rc.CurrentDate.ToString());

Console.ReadLine();

Y esto es todo... de momento. Nos quedan por analizar unos cuantos modelos alter-nativos de activación y publicación de clases y, muy importante, cómo se controla el tiempo de vida de las instancias remotas. A pesar de esto, hay que reconocer que .NET Remoting es mucho más fácil de usar que COM, CORBA o Java RMI, las alternativas que hasta ahora se han encargado de agotar las neuronas de muchos programadores.

Serialización por valor y por referencia

Veamos ahora cuáles otras posibilidades nos ofrece el mecanismo de llamadas re-motas de la plataforma .NET. Para empezar, hay dos formas de pasar un objeto de un ordenador a otro: o se teletransporta una versión completa del objeto, o se trans-porta un molde de su alma inmortal. En el ejemplo que hemos presentado, sola-mente se ha utilizado el teletransporte del alma... o en términos un poco más técni-cos, se ha serializado una referencia al objeto, en vez de serializar todo el objeto.

¿En qué consiste esto de la serialización? Muy fácil: .NET nos ofrece la posibilidad de convertir un objeto en una cadena XML o en algún otro tipo de representación lineal. A partir de esta representación, deberíamos ser capaces de recomponer el objeto. Suponga, por ejemplo, que tenemos una clase Pedido, que almacena una colec-ción de líneas de detalles. El resultado de serializar una instancia de esta clase podría parecerse al siguiente fragmento de XML:

Page 370: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

370 La Cara Oculta de C#

<Pedido>

<FechaVenta>01/08/2003</FechaVenta>

<Detalles>

<Detalle Producto="Visual C#" Cantidad=1 />

<Detalle Producto="WinZip" Cantidad=2 />

</Detalles>

</Pedido>

La serialización es la base del acceso remoto a objetos, porque una vez que hemos serializado un objeto, podemos enviar su representación de un proceso a otro. El caso más simple, paradójicamente, consiste en serializar y enviar todo el contenido o estado del objeto. Esta técnica recibe el nombre, en inglés, de marshaling by value; po-demos traducir la frase como serialización por valor, aunque hay algunas diferencias de matiz entre marshal y serialize.

¿Por qué digo que esta técnica es la más simple? El motivo es que lo que transmiti-mos es una copia del objeto original. Cuando alguien, al otro lado de la línea de transporte, ejecuta un método sobre la copia recibida, el código que se ejecutará reside en el mismo proceso que ha recibido la copia. Esto implica, entre otras cosas, que un cliente que reciba un objeto por valor debe disponer del código ejecutable asociado al objeto. Observe que, en nuestro ejemplo inicial, no hemos utilizado para nada esta técnica de traspaso por valor.

¿Qué condiciones debe cumplir una clase para que sus objetos puedan transmitirse por valor? La condición indispensable es que la clase haya sido decorada con el atri-buto Serializable:

[Serializable]

class Pedido {

public DateTime FechaVenta;

public Detalle[] Detalles;

// …

}

Basta con este atributo para que .NET sea capaz de serializar los objetos de una clase... a su manera. Si quisiéramos controlar los detalles del proceso de serialización, podríamos implementar también la interfaz ISerializable. Dicho sea de paso, esto es lo que hace la clase DataSet, cuya declaración es parecida a la siguiente:

[Serializable]

public class DataSet : MarshalByValueComponent, IListSource,

ISupportInitialize, ISerializable {

// …

}

Gracias a esta característica, podemos pasar fácilmente una copia de un conjunto de datos en memoria de un proceso a otro.

Si nos limitásemos a este tipo de serialización y transporte, sin embargo, no habría-mos logrado mucho. La posibilidad más interesante es la de serializar una referencia a un objeto. El objeto permanece en el mismo proceso que lo ha creado, y en el lado cliente, en vez de tener una copia, obtenemos un proxy que hace referencia al objeto

Page 371: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 371

original. Como la estructura del proxy es similar a la del objeto remoto, el cliente pen-sará que está tratando con el original. Cada método que ejecute sobre el proxy, provo-cará la transmisión de un mensaje al objeto original, que finalmente hará que se eje-cute el método remoto. Para que una clase soporte este tipo de transporte, debe he-redar, directa o indirectamente, de la clase MarshalByRefObject, que ya hemos presen-tado en el ejemplo inicial:

class RemoteClockClass: MarshalByRefObject, IRemoteClock {

// …

}

Modelos de activación y ejecución

La explicación anterior sólo trata sobre la forma en que se puede transportar un objeto, o su referencia, de un proceso a otro. Ahora debo explicar las distintas mane-ras en que se puede desencadenar esta operación. El siguiente esquema muestra las variantes de acceso remoto soportadas por .NET Remoting:

La primera división distingue entre la activación el servidor y la activación en el cliente. Hay que aclarar que, en ambos casos, se trata de objetos remotos, manejados desde el lado cliente mediante un proxy, a pesar de que el nombre “activación en el cliente” sugiera lo contrario. Veamos entonces las diferencias:

La activación en el servidor, que es la que hemos presentado antes como ejemplo, consiste en que el servidor publica un objeto de una clase remota por medio de una URL. El servidor controla el tiempo de vida del objeto, y el proceso de creación del mismo. Ya hemos visto dos de las opciones admitidas para este tipo de aplicación. En la primera opción, Singleton, el servidor mantiene un único objeto como máximo, con independencia del número de clientes. El estado del objeto remoto es compar-tido, por lo tanto, por todos los clientes del mismo... al menos durante el tiempo de vida de la instancia. Con la configuración por omisión del tiempo de vida, la instancia solitaria puede reciclarse si transcurre cierto intervalo de tiempo sin actividad prove-niente de clientes. Por lo tanto, si el servidor no toma medidas especiales, puede que haya que recrear la instancia, al reanudarse las peticiones de los clientes.

NO

TA

Recuerde que la instancia en el servidor sólo se crea la primera vez que un cliente llama a uno de los métodos del objeto remoto, no cuando el primer cliente crea y obtiene su proxy mediante una llamada a GetObject.

Page 372: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

372 La Cara Oculta de C#

La segunda modalidad de activación en el servidor es SingleCall. En este caso, cada llamada a un método remoto crea una instancia fresca, que pasa a mejor vida al ter-minar la ejecución del método. Un programador que haya desarrollado para COM+ podría pensar que esta técnica malgasta recursos. ¿No habíamos quedado, según la teoría de COM+, en que la creación de instancias remotas es un proceso costoso? Bueno, es un proceso costoso cuando la clase remota debe controlar directamente recursos costosos, como una conexión a una base de datos. Pero la solución, en .NET Remoting, consiste en mantener una caché para estos recursos caros, como de hecho ya hace ADO.NET con las conexiones.

Y ahora debo presentar la tercera opción: la publicación directa de objetos. En nues-tro primer ejemplo, el servidor registraba una clase, y luego esperaba las peticiones de los clientes. No había una creación explícita de las instancias. Y eso quiere decir que para la creación de estas instancias, el servidor debe utilizar un constructor prede-terminado; en concreto, el constructor sin parámetros. ¿Cómo haríamos si nuestra clase remota requiriese un constructor diferente, quizás a causa de una inicialización especial? En ese caso, podríamos crear explícitamente una instancia de la clase re-mota, y publicarla mediante el método Marshal, de la clase RemotingServices:

static void Main(string[] args)

{

// Creamos el canal servidor y lo registramos

HttpChannel chn = new HttpChannel(1234);

ChannelServices.RegisterChannel(chn);

/* Creamos la instancia a publicar… */

IMiInstancia instancia = new MiClase( /* … parámetros … */ );

/* … y la publicamos */

RemotingServices.Marshal(instancia, "MiInstancia.soap");

Console.ReadLine();

}

Activación en el cliente

La alternativa a la activación en el servidor es, como sospechará, la activación en el cliente. En este modelo, se establece una relación uno a uno entre el cliente y el ob-jeto remoto que éste crea. Esto implica que el cliente puede confiar en que la instan-cia remota que está controlando mantendrá su estado interno de llamada en llamada, y que no lo compartirá con otros clientes, a menos que esa sea la voluntad de su creador (¡uf, por poco uso mayúsculas!). Otra consecuencia de este tipo de activación es que el tiempo de vida de las instancias remotas puede ser controlado eficazmente por el propio cliente.

El código necesario para publicar, en el lado del servidor, una clase con activación en el cliente debe parecerse al siguiente:

using System;

using System.Runtime.Remoting;

using System.Runtime.Remoting.Channels;

using System.Runtime.Remoting.Channels.Http;

Page 373: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 373

namespace ServidorRemoto

{

public class RemoteClockClass: System.MarshalByRefObject

{

public RemoteClockClass()

{

Console.WriteLine("Remote object created");

}

public DateTime CurrentDate

{

get { return DateTime.Now; }

}

}

class ServidorApp

{

static void Main(string[] args)

{

// Creamos y registramos un canal

ChannelServices.RegisterChannel(new HttpChannel(1234));

// Asignamos un nombre global a la aplicación

RemotingConfiguration.ApplicationName = "RelojRemoto";

// Publicamos el tipo

RemotingConfiguration.RegisterActivatedServiceType(

typeof(RemoteClockClass));

Console.WriteLine("Listening...");

Console.ReadLine();

}

}

}

Hay un detalle revelador: me he visto obligado a indicar que RemoteClockClass es una clase pública; en breve veremos por qué. Observe también la instrucción que sigue al registro del canal HTTP. En vez de asociar una URL con una clase remota, esta vez asociamos un nombre de aplicación con todo el proceso. Así, la misma aplicación pueda servir distintos tipos de clases remotas. En este ejemplo, suponiendo que el servidor se encontrase en el mismo ordenador que el cliente, la referencia genérica a la aplicación se haría mediante la siguiente URL:

http://localhost:1234/RelojRemoto

A continuación, registramos el tipo de datos en la configuración remota. Sabemos que el tipo registrado será activado en el cliente gracias a un convenio: el nombre del método contiene Activated, en vez de WellKnown. Pero lo realmente importante es que hemos registrado la clase remota, en vez de un tipo de interfaz, o una clase base abs-tracta. Para entender el porqué del cambio, tenemos examinar el código correspon-diente en el lado cliente:

using System;

using System.Runtime.Remoting;

using System.Runtime.Remoting.Channels;

using System.Runtime.Remoting.Channels.Http;

using ServidorRemoto;

namespace ClienteRemoto

{

Page 374: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

374 La Cara Oculta de C#

class ServidorApp

{

static void Main(string[] args) {

// Creamos y registramos un canal cliente

ChannelServices.RegisterChannel(new HttpChannel());

// ATENCION: registramos la clase remota

RemotingConfiguration.RegisterActivatedClientType(

typeof(ServidorRemoto.RemoteClockClass),

"http://localhost:1234/RelojRemoto");

// ¡Podemos crear instancias remotas con new!

ServidorRemoto.RemoteClockClass obj =

new ServidorRemoto.RemoteClockClass();

Console.WriteLine(obj.CurrentDate.ToString());

Console.ReadLine();

}

}

}

Como puede ver, en este ejemplo usamos el operador new y la presunta clase remota RemoteClockClass. Lo que sucede es que la semántica de new ha cambiado, y ahora, en vez de crear un objeto local y devolvernos su referencia, el operador new crea un objeto remoto y devuelve un puntero a un proxy. La consecuencia principal es que la aplicación cliente debe tener acceso a la clase, no a un simple tipo de interfaz; se necesita la estructura física de la instancia que se crea, y no basta el protocolo de interacción, que es lo que informalmente define un tipo de interfaz. Para que la apli-cación cliente pueda compilarse, hay que incluir en el proyecto, o en la línea de co-mandos del compilador, una referencia al ensamblado que contiene el servidor:

csc clienteRemoto.cs /r:servidorRemoto.exe

Nos hemos visto obligado a este extremo al haber declarado e implementado la clase RemoteClockClass directamente dentro de la aplicación servidor. Como consecuencia, la aplicación servidora debe estar presente, no sólo durante la compilación de la apli-cación cliente, ¡también durante su ejecución!

Por supuesto, otra forma de lograr el mismo efecto sería utilizar, como hemos hecho antes, un ensamblado común, compartido por el cliente y el servidor. Pero esta vez, el ensamblado común debería contener una clase con su implementación verdadera, en vez de una interfaz o una clase abstracta. ¿Es esto bueno o malo? Veamos:

1 La dependencia respecto a una clase conlleva más responsabilidades, y nos ata más, que la dependencia respecto a un tipo de interfaz. Cualquier mínimo cam-bio, incluso en campos o propiedades privadas o protegidas, nos obligaría a re-compilar el cliente. Tenga en cuenta, además, que lo que tenemos que compartir no es una clase abstracta, que no soportaría el operador new, sino la propia clase final.

2 Si incluyésemos el código de la clase remota en la aplicación cliente, estaríamos arriesgándonos a que un usuario demasiado curioso, con pocos recursos técni-cos, desensamblara el código fuente de la implementación de la clase. Si se tra-tase de una aplicación “nativa”, el riesgo sería mínimo, pero tratándose de una

Page 375: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 375

aplicación .NET cualquiera podría intentar la aventura. La importancia de este detalle dependerá de que tengamos, o no, algún secreto escondido en el código.

Existe otra posibilidad: crear un ensamblado para el lado cliente que contenga una simulación de los metadatos de la clase remota. Hay varias formas de hacerlo, y la más conocida es utilizar una aplicación llamada soapsuds, incluida en el SDK de la plataforma. Más adelante, en este mismo capítulo, veremos un ejemplo de su uso.

El modelo más adecuado

Si usted no tiene experiencia previa con otro sistema de programación remota, pro-bablemente estará confuso ante tantos modelos distintos de activación. Y si ya ha trabajado con COM+, en concreto, supongo que estará intentando averiguar las correspondencias entre los modelos de .NET y la forma de trabajo en este otro sis-tema. Es aconsejable que nos detengamos un momento, para explicar cuál es el mé-todo recomendable para cada tipo de aplicación.

La mención de COM+ es pertinente: el modelo de activación en esa plataforma se diseñó para dar soporte a aplicaciones con un número elevado de usuarios. Incluye, entre otros servicios, características relacionadas directamente con llamadas remotas. En particular, la técnica conocida como object pooling, o caché de objetos, crea la ilu-sión en las aplicaciones clientes de que tienen un objeto remoto activado para cada una de ellas, cuando en realidad están compartiendo objetos almacenados en una zona común. Este modelo parece estar a mitad de camino entre la activación en el servidor de un singleton, y la activación en el cliente, que realmente proporciona una instancia remota independiente por usuario.

¿Cuál es, entonces, el modelo de .NET Remoting que imita a COM+? Formulemos mejor la pregunta: ¿cuál es el mejor modelo para favorecer la escalabilidad? Para ser sinceros, ¡ese modelo no existe en .NET Remoting! La explicación es sencilla. Los planes originales de Microsoft eran recrear una arquitectura similar a la de COM+, de forma nativa, dentro de la plataforma .NET. Pero pasó algo: o no alcanzó el tiempo, o Microsoft decidió que no era buena idea. En su estado actual, .NET Remoting se queda corto frente a muchos de los servicios de COM+, y para producir aplicaciones escalables tenemos dos opciones principales:

1 Recrear nosotros mismos algunos servicios, como la caché de recursos. 2 Alternativamente, podemos seguir utilizando los servicios de COM+. Más ade-

lante, en el capítulo 25, Servicios de componentes, explicaré cómo.

Volvamos a los modelos nativos de .NET Remoting, suponiendo que vamos a re-crear los servicios que nos faltan. El modelo más eficiente en consumo de recursos, es la activación en el servidor de un singleton. Estas son sus ventajas:

La activación en el servidor consume menos recursos en el servidor que la acti-vación en el cliente. Esta última requiere un objeto distinto para cada cliente, y cada objeto debe mantener su estado de una llamada a la siguiente.

Page 376: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

376 La Cara Oculta de C#

El uso de un singleton, en comparación con el modelo single call, evita tener que crear un objeto para cada llamada en remota. Normalmente, la creación de estos objetos remotos no es demasiado costosa, pero al final, el tiempo acumulado puede ser significativo.

A primera vista, puede parecer que un singleton crearía un cuello de botella: si todos los clientes tienen que tratar con él, ¿no estaríamos poniendo en cola todas las peticiones, para responderlas una a una? De ningún modo: .NET Remoting crea en estos casos un depósito o caché de hilos (thread pool). Si tres o cuatro clientes ejecutan simultáneamente métodos del singleton, el servidor automática-mente pedirá tres o cuatro hilos para que el mismo objeto sea manipulado desde esos hilos. Por supuesto, el número de hilos disponible tiene un tope, y a partir de ese punto, las peticiones pendientes se pondrán en cola. Pero la elección de ese tope será responsabilidad del servidor, y normalmente, éste elegirá el umbral más adecuado.

Las desventajas de un singleton son pocas:

Los métodos remotos deben utilizar lo menos posible el estado interno del sin-gleton. En caso contrario, estaríamos obligados a sincronizar el acceso mediante mutexes, y perderíamos velocidad de respuesta.

La ventaja de la opción single call sería ésa, precisamente: nos evitaría errores por falta de sincronización. Claro, no tendría mucho sentido utilizar un estado interno que se destruiría al terminar cada llamada. Pero tendríamos el coste adicional de tener que crear instancias constantemente, para cada llamada.

Está muy claro cuál es el mejor modelo de activación para un servidor de datos de capa intermedia. Ahora bien, ¿qué significa tener un objeto singleton cuyo estado de-bemos tocar lo menos posible? Honestamente, ¡es todo lo contrario de lo que siem-pre ha sostenido la Programación Orientada a Objetos! Es como tener a nuestra disposición una colección de llamadas a procedimientos remotos, como si se tratase de una biblioteca de funciones de la época anterior a la P.O.O. Además, ¿cómo va-mos a lograr que ese objeto “sin estado” pueda implementar un servicio que merezca la pena?

Page 377: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 377

La solución es muy simple: el objeto singleton debe limitarse a actuar como fachada de una caché de objetos “verdaderos”, escondidos dentro del servidor, que serían los encargados de realizar el trabajo de verdad. Por ahora, seguiremos con los detalles de la técnica de llamadas remotas, pero en el capítulo 25 veremos cómo implementar una caché de objetos en el servidor, tal y como acabo de describir.

Canales y puertos

Veamos qué posibilidades tiene una aplicación remota para elegir y configurar el protocolo mediante el cual intercambiará mensajes con sus clientes. Hemos visto que los canales son los objetos encargados de intercambiar mensajes entre el cliente y el servidor. La versión 1.1 de la plataforma incluye dos clases de canales:

• Canales HTTP, implementados por la clase HttpChannel, declarada dentro del espacio de nombres System.Runtime.Remoting.Channels.Http.

• Canales TCP, implementados por la clase TcpChannel, declarada dentro del espa-cio de nombres System.Runtime.Remoting.Channels.Tcp.

En ambos casos, podemos indicar el puerto por el que se comunicarán los dos ex-tremos. Es cierto que HTTP se asocia normalmente con el puerto 80, pero un servi-dor de .NET Remoting puede usar este protocolo por cualquier otro puerto que indiquemos.

Hay otro aspecto a tener en cuenta: el formato de serialización, que es la forma en que un objeto o una referencia se convierten en un vector de bytes para enviar a través de un canal. .NET soporta dos formatos de serialización predefinidos: el for-mato SOAP, basado en XML, y un formato binario propietario. El formato binario es el más eficiente y expresivo. SOAP y XML, por su parte, son más conocidos y ofrecen más posibilidades de compatibilidad con otros sistemas, pero a costa de mucha redundancia. Además, es más fácil descifrar un mensaje en formato XML que un mensaje binario.

Cada canal de comunicación puede elegir el formato de serialización, pero cada uno de ellos define su formato preferido. El canal HTTP utilizará SOAP, mientras no indiquemos lo contrario, y el canal TCP recurrirá al formato binario, por omisión. Si unimos la mayor eficiencia de la serialización binaria con el hecho de que un canal TCP ya es más rápido que un canal HTTP, queda bien claro cuál canal debemos elegir cuando la compatibilidad no es el problema principal.

NO

TA

HTTP es un protocolo que se superpone sobre el mecanismo de comunicación a través de zócalos TCP/IP. Cuando usamos HTTP, tenemos que enviar cabeceras adicionales, que aumentan el tráfico entre los extremos de la conversación.

¿Recuerda nuestro primer servidor remoto? Para forzarlo a usar un canal TCP, sólo tendríamos que modificar el código de inicialización de la aplicación:

Page 378: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

378 La Cara Oculta de C#

static void Main(string[] args)

{

ChannelServices.RegisterChannel(

new System.Runtime.Remoting.Channels.Tcp.TcpChannel(1234));

RemotingConfiguration.RegisterWellKnownServiceType(

typeof(RemoteClockClass),

"RemoteClock.rem",

WellKnownObjectMode.Singleton);

Console.ReadLine();

}

Estamos indicando que usaremos un canal TCP, y que la escucha se hará a través del puerto 1234. Un cliente que quisiera conectarse a este servicio tendría que usar la siguiente URL, suponiendo que el servidor residiese en el nodo local:

tcp://localhost:1234/RemoteClock.rem

Ha cambiado el identificador de protocolo, al inicio de la URL. He modificado tam-bién la extensión del servicio, para que en vez de ser soap sea rem. Esto último no es obligatorio, a no ser que publiquemos el servidor mediante Internet Information Services, pero en el presente contexto sirve como indicación sobre el formato de serialización empleado.

Los cambios en la configuración inicial del cliente son parecidos:

static void Main(string[] args)

{

ChannelServices.RegisterChannel(

new System.Runtime.Remoting.Channels.Tcp.TcpChannel());

IRemoteClock rc = (IRemoteClock) Activator.GetObject(

typeof(IRemoteClock),

"tcp://localhost:1234/RemoteClock.rem");

Console.WriteLine(rc.CurrentDate.ToString());

Console.ReadLine();

}

Al igual que hacíamos con el canal HTTP, el canal TCP en el lado cliente se cons-truye sin indicar el puerto de escucha en el servidor. El puerto se indica más ade-lante, en la URL que pasamos al activador del objeto remoto. Observe que, tanto en el cliente como en el servidor, los cambios necesarios para cambiar de protocolo han sido mínimos.

Ficheros de configuración remota

Si es tan sencillo cambiar de protocolo, o de formato de serialización, ¿por qué no intentamos algo para que podamos realizar estos cambios sin necesidad de modificar y luego compilar las aplicaciones correspondientes? Esa es la función del método estático Configure, de la clase RemotingConfiguration:

public static void Configure(string nombreFichero);

Page 379: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 379

Configure puede ejecutarse tanto en el cliente como en el servidor. Por ejemplo, el método Main del servidor de la sección anterior podría reducirse a esto:

static void Main(string[] args)

{

RemotingConfiguration.Configure("servidor.config");

Console.ReadLine();

}

El fichero servidor.config tendría el siguiente contenido:

<configuration>

<system.runtime.remoting>

<application>

<service>

<wellknown

type="ServidorRemoto.RemoteClockClass,

ServidorRemoto"

objectUri="RemoteClock.rem"

mode="Singleton"

/>

</service>

<channels>

<channel

ref="tcp"

port="1234"

/>

</channels>

</application>

</system.runtime.remoting>

</configuration>

El método Configure sabe que debe configurar un servidor gracias al elemento service, dentro del elemento application. A su vez, el elemento wellknown indica que tenemos un servicio activado en el servidor, en vez de la activación por el cliente:

<wellknown

type="ServidorRemoto.RemoteClockClass, ServidorRemoto"

objectUri="RemoteClock.rem"

mode="Singleton"

/>

En el atributo type indicamos el nombre de la clase, completamente cualificado, y a continuación, separado por una coma, el nombre del ensamblado. El atributo object-Uri indica el nombre con el que publicamos el servicio. Y mode, finalmente, nos per-mite elegir entre Singleton y SingleCall.

Paralelo al nodo del servicio tenemos el elemento channels, que indica la lista de ca-nales que vamos a configurar; es cierto que hasta el momento, hemos configurado un único canal por aplicación. El elemento channel del ejemplo hace referencia a uno de los canales predefinidos: lo sabemos por el atributo ref, que tiene asociado tcp como valor. El otro valor predefinido es http, por supuesto. En ambos casos, port indicaría el número del puerto elegido.

Page 380: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

380 La Cara Oculta de C#

Una modificación bastante frecuente a la configuración por omisión del canal HTTP consiste en especificar el proveedor de formato binario:

<channels>

<channel ref="http" port="1234">

<serverProviders>

<formater ref="binary" />

</serverProviders>

</channel>

</channels>

Por otra parte, el cliente también se simplificaría mediante un fichero de configura-ción, aunque todo depende de los metadatos que comparta el cliente con el servidor. Por ejemplo, si lo que compartimos es una definición de interfaz, el cliente sólo po-dría reducirse hasta el siguiente estado:

static void Main(string[] args)

{

RemoteConfiguration.Configure("cliente.config");

IRemoteClock rc = (IRemoteClock) Activator.GetObject(

typeof(IRemoteClock),

"tcp://localhost:1234/RemoteClock.rem");

Console.WriteLine(rc.CurrentDate.ToString());

Console.ReadLine();

}

Y el contenido del fichero de configuración sería:

<configuration>

<system.runtime.remoting>

<application>

<channels>

<channel ref="tcp" port="0" />

</channels>

</application>

</system.runtime.remoting>

</configuration>

Pero si compartiésemos directamente la clase, podríamos utilizar la siguiente configu-ración en el cliente:

<configuration>

<system.runtime.remoting>

<application>

<client>

<wellknown

type="ComunRemoto.RemoteClockClass, comun"

url="tcp://localhost:1234/RemoteClock.rem"

/>

</client>

<channels>

<channel ref="tcp" port="0" />

</channels>

</application>

</system.runtime.remoting>

</configuration>

Page 381: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 381

Y el código se simplificaría aún más:

static void Main(string[] args)

{

RemoteConfiguration.Configure("cliente.config");

RemoteClockClass rc = new RemoteClockClass();

Console.WriteLine(rc.CurrentDate.ToString());

Console.ReadLine();

}

Aparentemente, la llamada al operador new debería crear una instancia local. Pero la configuración de llamadas remotas hace que la instancia creada sea realmente un proxy al objeto activado en el lado servidor.

NO

TA

Aunque estos ejemplos se refieren a objetos activados en el servidor, también podemos utilizar ficheros de configuración para objetos activados por el cliente. El elemento XML que se emplea para este tipo de servicios se llama activated.

Tiempo de vida

Piense un momento en un objeto activado por el servidor como singleton. El objeto se crea cuando el primer cliente realiza una llamada a uno de sus métodos. A partir de ese momento, otros clientes pueden conectarse al servidor y pedir el mismo objeto. ¿Cuándo podría ser destruido ese objeto? Podría parecer que la mejor respuesta sería: cuando se quede sin clientes, y pase un tiempo prudencial predeterminado.

Si omitimos el período de gracia final, eso era lo que pasaba con los objetos en DCOM y COM+, que mantenían un contador para la cantidad de conexiones. Pero esta política puede producir problemas graves: si el cliente se desconecta del servidor con normalidad, no pasará nada malo. Pero si un cliente se cuelga, o se interrumpe la comunicación, el contador de referencias nunca retornará a cero, y tendremos un objeto eterno, consumiendo recursos en el servidor. Para evitarlo, DCOM enviaba un comando ping a sus clientes cada cierto tiempo: “¿estás ahí todavía?”. Si el cliente no respondía, DCOM lo daba por muerto, destruía las estructuras asociadas al cliente en el servidor, y sustraía uno al contador de referencia. El problema está en esos comandos ping, que además de aumentar el tráfico en la red, podrían entrar en conflicto con los posibles cortafuegos.

Cuando Microsoft comenzó el diseño de .NET Remoting, se planteó la necesidad de implementar el tiempo de vida de los objetos remoto con un mecanismo configura-ble, que fuese al mismo tiempo más potente y flexible. Y la solución fue una técnica basada en el arrendamiento o leasing del tiempo de vida:

1 A cada objeto remoto se le asigna un tiempo de vida al ser creado. 2 Dentro del mismo dominio de aplicación donde vive el objeto original, un com-

ponente interno, conocido como lease manager, revisa periódicamente la lista de objetos remotos, para descubrir a cuáles se les ha agotado la cuerda.

3 Cada vez que se ejecuta un método de un objeto remoto, se le da cuerda, es de-cir, se aumenta su tiempo de vida restante.

Page 382: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

382 La Cara Oculta de C#

4 Los pasos anteriores son parecidos a la técnica utilizada por Java RMI, pero ahora viene la diferencia: un objeto remoto puede registrar uno o más patrocinado-res o sponsors. Estos patrocinadores pueden residir en el mismo proceso que el objeto, pero también pueden ser objetos remotos, creados en otros procesos o estaciones de trabajo.

5 Cuando al objeto le toca enfrentarse a la guillotina, el lease manager intenta comu-nicarse con sus patrocinadores, y éstos pueden indultarlo temporalmente, au-mentando nuevamente su tiempo de vida.

6 Si el pobre objeto no tiene padrinos influyentes, se le rebana la cabeza sin miseri-cordia alguna.

Esta técnica es lo suficientemente general como para simular la empleada en Java y la implementada por DCOM. En el primer caso, no se registrarían patrocinadores; en el segundo, el patrocinador sería el cliente remoto. Lo que hace que esta técnica sea atractiva es la posibilidad de configurar sistemas mucho más sofisticados que los mencionados.

Por ejemplo, podemos modificar los intervalos de tiempo utilizados por el lease man-ager de una aplicación introduciendo un elemento lifetime en un fichero de configura-ción remota:

<aplication>

<lifetime leaseTime="10M" />

</aplication>

Los atributos soportados por el elemento XML lifetime son los siguientes:

Atributo Propósito leaseTime Tiempo de vida inicial de los objetos remotos sponsorshipTimeout Tiempo de espera para contactar con patrocinadores renewOnCallTime Tiempo añadido al ejecutarse un método leaseManagerPollTime Intervalo entre activaciones del lease manager

Por omisión, el mecanismo de tiempo de vida se comporta como si lo hubiésemos configurado con estos valores:

<lifetime

leaseTime="5M"

sponsorshipTimeout="2M"

renewOnCallTime="2M"

leaseManagerPollTime="10S"

/>

El sufijo M indica minutos y S quiere decir segundos. Si no utilizamos el sufijo de uni-dad, se asume que estamos indicando un intervalo en segundos.

Claro está, con el fichero de configuración sólo podemos configurar estos paráme-tros a nivel global, para todos los objetos remotos de una aplicación. Pero podemos afinar la puntería, al menos a nivel de clase, si redefinimos el método InitializeLife-timeService, de la clase MarshalByRefObject, que es el ancestro común de todas las clases de objetos remotos:

Page 383: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 383

public override object InitializeLifetimeService

{

return null;

}

Este es un caso extremo: si devolvemos una referencia nula, estamos indicando que los objetos de esta clase tienen vida eterna. Lo normal es devolver un objeto que implemente la interfaz ILease. De hecho, la implementación original del método de-vuelve un objeto que satisface este requisito:

public override object InitializeLifetimeService

{

ILease lease = (ILease) base.InitializeLifetimeService();

lease.InitialLeaseTime = TimeSpan.FromMinutes(30);

return null;

}

Este mismo puntero de tipo ILease puede aprovecharse para añadir patrocinadores, lo mismo para toda la clase que para objetos elegidos. Para ello, debemos ejecutar el método Register de la interfaz, y pasar como parámetro un objeto que implemente la interfaz ISponsor. Recuerde que el patrocinador puede ubicarse en el mismo cliente, como sería lo más adecuado para un objeto activado por el cliente, o en el servidor, que es lo habitual para objetos activados por el servidor.

Aplicaciones de servicios

Nuestros servidores de objetos remotos, hasta el momento, han residido dentro de aplicaciones de consola. Eso está bien para un ejemplo, pero es inaceptable para un sistema real. El proceso que aloja las clases remotas debe ser capaz de iniciarse sin la ayuda de un usuario interactivo, y eso implica que debe haber una aplicación de ser-vicio involucrada de forma más o menos directa. Más adelante veremos cómo alojar una clase remota dentro de Internet Information Services. Pero I.I.S. se ejecuta como un servicio de Windows, por lo que también estaríamos ejecutando el código remoto desde un servicio.

Las aplicaciones de servicios de Windows tienen características especiales:

• Se ejecutan automáticamente, cuando se enciende el ordenador que las aloja.

• No necesitan que un usuario humano inicie una sesión interactiva.

• Del mismo modo, es difícil que un estúpido usuario humano las detenga acci-dentalmente (como sucede con nuestros ejemplos basados en consolas).

• Podemos indicar una cuenta para la ejecución del servicio, y así se puede con-trolar con precisión a cuáles recursos tendrá acceso el servicio, y a cuáles no.

Es muy fácil crear una aplicación de servicio en la plataforma .NET. Comenzaríamos con un proyecto vacío, y luego añadiríamos un elemento de tipo Servicio de Windows. Pero en consideración con el lector que no dispone de Visual Studio y está utilizando

Page 384: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

384 La Cara Oculta de C#

el Bloc de Notas y el compilador de línea de comandos que viene con el SDK, voy a prescindir de Visual Studio para explicar cómo crear la aplicación de servicio.

Cree un fichero vacío, llámelo ServiceApp.cs, y ábralo en el Bloc de Notas. Como mí-nimo, necesitaremos los siguientes espacios de nombres:

using System;

using System.Collections;

using System.ServiceProcess;

using System.ComponentModel;

using System.Configuration.Install;

El código en sí del servicio es sencillo: tenemos que crear una clase que herede de la clase predefinida ServiceBase. Debemos redefinir al menos el método virtual OnStart, y también conviene añadir un constructor sin parámetros para configurar algunas ca-racterísticas comunes a todos los servicios mediante las propiedades de ServiceBase. Además, debemos proporcionar un punto de entrada, es decir, un método Main es-tático, en el que registraremos el nuevo servicio mediante un método de la propia clase base:

public class CSharpService: System.ServiceProcess.ServiceBase {

// IMPORTANTE: Definimos un nombre para el servicio

public const string CSharpServiceName = "CSharpTestService";

public CSharpService() {

// El nombre del servicio

ServiceName = CSharpServiceName;

// Podremos detenerlo

CanStop = true;

// Podremos pausarlo y continuar su ejecución

CanPauseAndContinue = true;

// Tendremos acceso al registro de sucesos de aplicaciones

AutoLog = true;

}

// … mostraré el nuevo OnStart más adelante …

public static void Main()

{

// Registramos una instancia de la clase del servicio

System.ServiceProcess.ServiceBase.Run(new CSharpService());

}

}

He declarado una constante pública para el nombre simbólico del registro. Esto es importante, como veremos en breve, porque el servicio necesita una clase “instala-dora”, que hará referencia al servicio mediante ese nombre. Aparte de asignar un nombre, que no debe coincidir con el de ninguno de los servicios que pueden existir en el mismo sistema, pedimos acceso al registro de sucesos de Windows asignando en la propiedad AutoLog el valor true. Al hacerlo así, el servicio realizará anotaciones automáticas en el registro de sucesos de aplicaciones cada vez que se le aplique ex-ternamente una de las operaciones de inicio, detención, pausa o reanudación. Po-dremos escribir también anotaciones arbitrarias utilizando el método WriteEntry de la propiedad EventLog de ServiceBase.

Page 385: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 385

Hasta este punto, no hay de particular en nuestro servicio: es un esqueleto vacío. Las particularidades comienzan cuando redefinimos el método virtual OnStart:

protected override void OnStart(string[] args)

{

string filename =

System.Windows.Forms.Application.StartupPath +

@"\remote.config";

RemotingConfiguration.Configure(filename);

}

Por omisión, una aplicación de servicios comienza la búsqueda de los ficheros sin nombre en el directorio de sistema de Windows. Por este motivo, utilizamos explíci-tamente el nombre del directorio desde el que se ha ejecutado la aplicación para componer un nombre de fichero completamente cualificado; ese directorio se ob-tiene mediante una propiedad estática de la clase Application. Asumimos que ese fi-chero contiene la configuración de un servidor de .NET Remoting, y lo pasamos al ya conocido método estático Configure de la clase RemotingConfiguration. Y ya tenemos una aplicación de servicios para que aloje nuestras clases remotas. ¿Cuáles clases remotas? Todas aquellas que mencionemos en el fichero de configuración, siempre que la aplicación tenga acceso al ensamblado que las contiene. Así de sencillo.

Bueno, en realidad nos falta un paso. Debemos añadir una clase de instalación dentro del mismo fichero de aplicación. La clase debe descender de Installer, una clase auxi-liar definida en System.Configuration.Install, y su implementación puede ser la siguiente:

[RunInstallerAttribute(true)]

public class CSharpServiceInstaller:

System.Configuration.Install.Installer

{

private ServiceProcessInstaller processInstaller;

private ServiceInstaller serviceInstaller;

public CSharpServiceInstaller()

{

processInstaller = new ServiceProcessInstaller();

processInstaller.Account = ServiceAccount.LocalSystem;

serviceInstaller = new ServiceInstaller();

serviceInstaller.StartType = ServiceStartMode.Manual;

serviceInstaller.ServiceName =

CSharpService.CSharpServiceName;

Installers.Add(serviceInstaller);

Installers.Add(processInstaller);

}

}

Hay un par de detalles a tener en cuenta. Observe, en primer lugar, que en el instala-dor del proceso hay una propiedad Account, en la que debemos indicar cuál es la cuenta en la que por omisión se ejecutará el servicio. En este ejemplo utilizamos el valor que designa a la cuenta especial “Sistema local”. Esta cuenta tiene una extensa lista de privilegios locales, aunque no se le permite acceder al entorno de red. Casi siempre, esta cuenta es la adecuada... siempre que nuestro servicio no tenga agujeros de seguridad peligrosos. Si queremos una cuenta con menos privilegios, podemos

Page 386: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

386 La Cara Oculta de C#

usar el valor LocalService, que recibe privilegios similares a los de un invitado en el grupo local. Si necesitamos actuar en la red, debemos usar NetworkService. Y para una configuración personalizada, tenemos el valor User, que exige que asignemos también las propiedades Username y Password del instalador del proceso, o que seleccionemos una cuenta de usuario durante la instalación, si dejamos vacías las propiedades men-cionadas.

El otro detalle es el tipo de inicio del servicio, que indicamos en StartType, de la ins-tancia de instalación del propio servicio. Podemos asignar Manual, Automatic o Dis-abled, y por supuesto, podemos modificar esta opción, como la cuenta de usuario, en cualquier momento posterior a la instalación del servicio. Tome también nota de cómo asociamos el instalador de servicios a nuestro servicio: mediante el nombre simbólico que hemos declarado en una constante.

Para instalar el servicio en un ordenador debemos ejecutar la siguiente aplicación de línea de comandos:

installutil serviceapp.exe

Como el modo de inicio indicado ha sido Manual, el servicio se instalará pero no arrancará automáticamente. Para las operaciones de mantenimiento del servicio, debemos ejecutar el comando Servicios, del menú de Herramientas Administrativas de Windows. Y cuando nos cansemos del servicio, podremos eliminarlo del registro del sistema mediante la misma utilidad de instalación:

installutil /u serviceapp.exe

Hospedaje en I.I.S.

La publicación de clases remotas mediante aplicaciones de servicios tiene un punto flaco: en principio, cualquier persona con acceso al ordenador en red puede acceder al servicio y utilizar las clases. No se trata de algo grave, pero obliga a programar las características de seguridad como parte de la propia interfaz remota.

Si queremos aprovechar una infraestructura de seguridad ya existente, podemos hos-pedar nuestro servidor dentro de Internet Information Services. Esta opción, ade-

Page 387: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 387

más, nos ahorra montones de problemas con la instalación: veremos que basta con copiar los ensamblados necesarios en un subdirectorio de un directorio virtual, y si-tuar un fichero de configuración remota en ese mismo directorio virtual. Todo esto tiene su precio, sin embargo: nada de utilizar canales TCP. Estaremos obligados a usar el canal HTTP, aunque conservaremos la libertad de elegir entre el formato SOAP y la serialización binaria. Algo es algo, ¿no?

El siguiente diagrama muestra la configuración de ficheros dentro de un directorio virtual de Internet Information Services para alojar clases remotas:

Hay dos elementos que tenemos que ubicar dentro del directorio:

1 El ensamblado que contiene las clases que vamos a publicar. Se aconseja crear un subdirectorio de nombre bin dentro del directorio virtual, y copiar directamente el fichero del ensamblado. No hace falta registrar nada. Alternativamente, po-dríamos situar el ensamblado en el GAC, o caché global de ensamblados. Pero esto complicaría la instalación del servidor, y sólo se recomienda cuando varias aplicaciones en el mismo servidor comparten el ensamblado publicado.

2 En el propio directorio virtual debemos situar un fichero llamado web.config, con la configuración remota que ya hemos visto en ejemplos anteriores. Hay algunos cambios mínimos de formato: por ejemplo, no podemos dar un nombre a la aplicación dentro del fichero.

Lo mejor es que mostremos un ejemplo: vamos a crear un servidor que permita que sus clientes remotos soliciten un conjunto de datos. De paso, introduciremos una novedad: en vez de crear una DLL para compartir entre servidor y clientes, nuestro servidor será monolítico, y en su interior se definirá y también implementará la clase que ofrecerá el servicio remoto. Luego veremos como los clientes del servicio po-drían superar este obstáculo.

Comencemos por el servidor: cree un proyecto de tipo Biblioteca de clases, en Visual Studio. De esta forma, el ensamblado que se generará al compilar el proyecto será una DLL, que es lo apropiado para su alojamiento dentro de I.I.S. Dentro del pro-

Page 388: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

388 La Cara Oculta de C#

yecto, sólo tendremos que declarar una clase pública, descendiente de la clase MarshalByRefObject, como la siguiente:

namespace ServidorRemoto

{

public class ServidorRemoto : System.MarshalByRefObject

{

public DataSet Clientes(string predicado)

{

string cmd = "select * from dbo.Customers";

if (predicado != "")

cmd += " where " + predicado;

SqlConnection conn = new SqlConnection(

"server=(local);database=Northwind;" +

"trusted_connection=true");

SqlDataAdapter da = new SqlDataAdapter(cmd, conn);

DataSet result = new DataSet();

da.Fill(result, "Clientes");

return result;

}

}

}

En este caso, la clase ServidorRemoto publica una función que recibe un “predicado” y devuelve un conjunto de datos. Esto es posible gracias a que la clase DataSet es here-dera de MarshalByValueComponent: en el lado cliente recibiremos una copia del con-junto de datos original. Es cierto que también podríamos devolver una clase seriali-zada por referencia, pero en ese caso, en el lado cliente recibiríamos un proxy, y cada llamada a métodos o propiedades provocaría una llamada remota, con su consi-guiente coste.

Es muy importante comprender que el ensamblado generado por el proyecto sólo contiene el código propio de la clase: nada de registrar canales, o publicar objetos. Toda esa infraestructura se delega sobre Internet Information Services, y se confi-gura muy fácilmente mediante el fichero de configuración, web.config, que debemos ubicar directamente en el directorio virtual seleccionado. Para este ejemplo, el fichero de configuración debe contener lo siguiente:

<configuration>

<system.runtime.remoting>

<application>

<service>

<wellknown

mode="Singleton"

type="ServidorRemoto.ServidorRemoto, ServRem"

objectUri="ServidorRemoto.soap"

/>

</service>

</application>

</system.runtime.remoting>

</configuration>

No hay configuración de canales esta vez: estamos obligados a utilizar HTTP. Pero sí podemos forzar el uso de la serialización binaria, que es más eficiente. De momento, nos contentaremos con la serialización SOAP que se asume por omisión.

Page 389: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 389

La balada del cliente solitario

A diferencia de lo que sucedió en los primeros ejemplos, no disponemos de un en-samblado para compartir la información común entre el servidor y sus clientes. En estos casos, la jerga de .NET Remoting habla de clientes solitarios. Para aliviar la sole-dad de estos clientes, Microsoft ofrece la aplicación soapsuds, de la que he hablado en una sección anterior, al presentar la activación en el cliente. Esta aplicación puede generar el código fuente de un ensamblado, o incluso directamente la DLL corres-pondiente, que imita la estructura de las clases exportadas por un servidor remoto. Para ello, exige un requisito al servidor: éste debe comunicarse en formato SOAP a través de un canal HTTP.

Para nuestro ejemplo, necesitaremos teclear la siguiente cadena en la línea de coman-dos del sistema operativo:

soapsuds -url:http://localhost/iishost/ServidorRemoto.soap?wsdl

-nowp -gc

El primer parámetro es la URL en la que está disponible la clase en la que estamos interesados. Observe que he añadido un sufijo a la URL:

http://localhost/iishost/ServidorRemoto.soap?wsdl

En el capítulo siguiente volveremos a encontrarnos con las siglas WSDL, que signifi-can Web Services Description Language. Este es uno de los protocolos, o formatos, fun-damentales de los servicios Web, y sirve para describir qué es lo que nos ofrece un servicio Web del que sólo conocemos su URL.

Hemos incluido otros dos parámetros en la llamada a soapsuds. El primero de ellos, nowp, indica que no queremos un wrapped proxy, un “delegado o agente metido dentro de un envoltorio”. Un proxy “envuelto” es una clase que en su constructor ya hace referencia a una URL fija. Eso no nos interesa: es preferible tener la libertad de cam-biar la URL en cualquier momento. Con el último parámetro de soapsuds, por su parte, hemos pedido que se genere el código fuente en el directorio actual, en vez de crear directamente el ensamblado como una DLL cerrada.

NO

TA

Si tenemos acceso físico al ensamblado que contiene el servidor, también podemos indi-carle a soapsuds que utilice el fichero del ensamblado como entrada, en vez de tener que conectarse a una URL. El resultado sería idéntico, sobre todo porque no vamos a utilizar un proxy cerrado, pero no siempre tendremos esta posibilidad.

El resultado de la ejecución de soapsuds es un fichero de código, de extensión cs, que contiene una definición de clase dentro del espacio de nombres original del servidor. Podríamos crear un ensamblado a partir de este fichero, pero he optado por una vía más directa para este ejemplo. Vamos a crear una aplicación “normal” basada en formularios, y vamos a añadir el fichero al nuevo proyecto.

Modificaremos ahora el código de inicio del proyecto, para configurar esta aplicación cliente de modo que pueda conectarse a servidores remotos. Añadiremos una lla-

Page 390: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

390 La Cara Oculta de C#

mada al método Configure de la clase RemotingConfiguration, para que registre los canales y servidores que configuraremos en un fichero externo:

[STAThread]

public static void Main() {

RemotingConfiguration.Configure("rem.config");

Application.Run(new MainForm());

}

El nombre concreto del fichero no es importante. Su contenido debe ser éste:

<configuration>

<system.runtime.remoting>

<application>

<client>

<wellknown

type="ServidorRemoto.ServidorRemoto, ClienteRem"

url="http://localhost/iishost/ServidorRemoto.soap"

/>

</client>

<channels>

<channel ref="http" />

</channels>

</application>

</system.runtime.remoting>

</configuration>

En primer lugar, el elemento client sirve para señalar al servidor remoto. Para el tipo de clase publicada remotamente, respetamos el espacio de nombres del servidor y el nombre concreto de la clase. Observe, sin embargo, que el nombre que he indicado para el ensamblado donde se define la clase es ClienteRem. Esto se debe a que, efecti-vamente, la clase proxy se ha añadido al ensamblado cliente, que en este ejemplo se llama así. El fichero de configuración se encarga además de registrar un canal HTTP, sin especificar el puerto... porque ya viene incluido implícitamente en la URL que hace referencia al servidor.

Para probar el funcionamiento de la conexión, añada un DataGrid al formulario y declare un campo privado dentro de la clase del formulario principal:

private System.Data.DataSet dataSet = null;

Debe teclear la declaración, en vez de soltar un componente DataSet sobre el formu-lario, porque el conjunto de datos vamos a recibirlo a través de la conexión. Para terminar, intercepte el evento Load del formulario y asóciele la siguiente respuesta:

private void MainForm_Load(object sender, System.EventArgs e)

{

ServidorRemoto.ServidorRemoto sr =

new ServidorRemoto.ServidorRemoto();

dataSet = sr.Clientes("");

dataGrid1.SetDataBinding(dataSet, "Clientes");

}

Gracias a la configuración indicada en el fichero, podemos utilizar el operador new para crear directamente... bueno, nuestro cliente pensará que es una instancia de la

Page 391: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

.NET Remoting 391

clase ServidorRemoto, quiero decir, de la “verdadera”. Pero usted y yo tenemos muy claro que lo que el cliente tiene en sus manos sólo es un proxy. Al tratarse de un ob-jeto activado en el servidor, la creación del proxy no exige que nos comuniquemos todavía con el servidor. Esto ocurrirá cuando ejecutemos nuestro primer método sobre la instancia remota.

dataSet = sr.Clientes("");

¡Esto es muy importante! El objeto sr hace referencia a un proxy, y representa una instancia remota. Pero la llamada a su método Clientes trae desde el servidor los datos serializados, o la quintaesencia, o el alma inmortal... lo que prefiera, de un objeto de la clase DataSet. El mecanismo de llamadas remota se encarga, de forma comple-tamente invisible para nosotros, de echarle un poco de agua al alma deshidratada del conjunto de datos para entregarnos el objeto reconstituido; en realidad, una copia idéntica del mismo. Cualquier acción sobre el objeto al que hace referencia dataSet, será una acción sobre un objeto local. Y en particular, nos interesa mostrar su conte-nido en la rejilla:

dataGrid1.SetDataBinding(dataSet, "Clientes");

El método SetDataBinding es un viejo conocido, que muestra directamente la única tabla que sabemos que viene dentro del conjunto de datos clonado y transportado.

Page 392: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

24

Servicios Web

A INFORMÁTICA, COMO MUCHAS OTRAS profesiones, está expuesta a los emba-tes de la moda. Uno de los vientos que más fuerte soplan en estos tiempos son

los servicios Web. De hecho, para mucha gente poco enterada, la iniciativa .NET es, simplemente, una plataforma más de desarrollo de servicios Web. En este capítulo veremos en qué consisten estos famosos servicios, y cómo podemos aprovecharlos para dividir nuestra aplicación en módulos físicos más pequeños y manejables.

¿Qué son los servicios Web?

Los servicios Web son una técnica de programación remota, cuya principal caracte-rística es la portabilidad entre plataformas y la facilidad para atravesar cortafuegos. En el capítulo anterior presentamos el funcionamiento de otra de estas técnicas, .NET Remoting, pero advertimos que uno de sus problemas era la compatibilidad con otros sistemas: tanto el cliente como el servidor debían estar desarrollados espe-cíficamente para la plataforma .NET. Sí, es muy probable que en un futuro cercano .NET esté funcionando sobre otros sistemas operativos... pero todavía no ha llegado ese momento. Algo parecido sucede con DCOM/COM+, que por ser un invento de Microsoft, los restantes fabricantes de software se niegan a soportarlo; o con Java RMI, que por ser un invento de Sun y obligar a trabajar en Java, está prohibido men-cionarlo en mi presencia. Todos estos sistemas, además, se llevan mal con los corta-fuegos que las empresas utilizan para bloquear el acceso a hackers.

Los servicios Web cuentan con dos cartas para cumplir sus promesas:

1 La portabilidad se basa en una minuciosa descripción del protocolo de intercam-bio de mensajes. Además, ese protocolo ha sido diseñado para trabajar con el mínimo común denominador de las posibilidades ofrecidas por los sistemas ope-rativos y lenguajes de programación actuales.

2 La facilidad para traspasar barreras impuestas por cortafuegos se basa en el uso de HTTP como medio principal de transporte.

Como suele suceder, las principales virtudes de los servicios Web son, a la misma vez, el origen de sus defectos y limitaciones:

L

Page 393: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios Web 393

1 El modelo de datos de los servicios Web es más pobre que el de .NET Remot-ing. En gran medida, es culpa del protocolo de intercambio, aunque el compro-miso es aceptable: se sacrifica potencia por compatibilidad. Pero el uso de HTTP para el transporte tiene parte de la culpa: al ser HTTP un protocolo eminente-mente unidireccional, no es posible disparar eventos desde el servidor para que alcancen a los clientes.

2 Bajo circunstancias similares, un servicio Web suele ser más lento que su equiva-lente en .NET Remoting. En parte, se debe al uso de XML como formato de intercambio. El uso de HTTP tampoco ayuda a su rendimiento.

NO

TA

En realidad, los servicios Web no están atados a HTTP como protocolo de transporte. En teoría, podríamos usar TCP, UDP, o incluso SMTP. Pero en la práctica, la necesidad de usar HTTP como transporte más probable condiciona el uso de protocolos más potentes, como sería el caso de TCP.

El otro argumento en contra de los servicios Web es su juventud: hay partes de la especificación para las cuáles todavía no existe un estándar. Este es el caso de los Web Services Enhancements, un conjunto de especificaciones diseñadas para la futura estan-darización de la autenticación de usuarios, el cifrado de mensajes, el control de tran-sacciones, el enrutamiento de mensajes, etc. Lamentablemente, si utilizamos la im-plementación actual que ofrece .NET para algunas de estas adiciones, obtendremos un servicio Web que, de momento, sólo podrá ser utilizado por clientes también desarrollados en .NET.

SOAP, WSDL, UDDI...

Y ahora comienza el baile de siglas. Estas son las más importantes:

• SOAP: Quiere decir Simple Object Access Protocol, y es el protocolo basado en XML que utilizan los servidores de servicios Web y sus clientes para intercam-biar mensajes. La palabra que acabo de utilizar, mensaje, no es tan inocente como parece a simple vista.

• WSDL: Significa Web Services Description Language. Todo servicio Web que se res-pete debe proporcionar una exacta descripción de sus capacidades: el nombre de sus métodos, sus parámetros de entrada y salida, junto con los tipos de datos asociados, el formato exacto de representación de valores, las URLs implicadas... Como detalle curioso, WSDL utiliza XSD, el viejo y querido XML Schema Defini-tion, para describir los tipos de datos involucrados en el servicio.

• UDDI: Son las siglas de Universal Description, Discovery and Integration. UDDI des-cribe el acceso a una base de datos global que almacena información sobre servi-cios Web.

El guión ideal es el siguiente: usted necesita cierto tipo de servicio, puede que se trate de conversión entre monedas, o de obtener la lista de bancos y oficinas, con sus res-pectivos códigos, de un determinado país. Utiliza entonces alguna herramienta ba-sada en UDDI para localizar una compañía que ofrezca dicho servicio, interrogando alguno de los puntos de acceso a la base de datos UDDI. Como resultado de la bús-

Page 394: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

394 La Cara Oculta de C#

queda, obtendrá el listado de compañías que implementan un servicio que se ajuste a sus necesidades, con las condiciones de uso: algunos servicios son gratuitos, pero otros requieren algún tipo de pago.

Una vez elegido un servicio concreto, utilizará una de las URL devueltas por UDDI para pedir la descripción de la interfaz de programación del servicio al propio servi-cio: estoy hablando, por supuesto, de un documento WSDL. Lo que pase a continua-ción dependerá en gran medida del lenguaje de programación que vaya a utilizar para acceder al servicio, pero a grandes rasgos los pasos serán muy parecidos: a partir de WSDL, con una herramienta que dependerá del entorno de desarrollo, generará clases, o tipos de interfaz, o procedimientos... o lo que sea, que servirán para realizar llamadas al servicio. A partir de este momento, ya podrá olvidarse de UDDI y de WSDL: con el código cliente generado automáticamente, su aplicación podrá inter-cambiar mensajes en formato SOAP con el servicio, hasta la consumación de los siglos. Amén.

Por supuesto, este modelo tan general se aplica sobre todo a servicios Web de carác-ter público. Nuestros servicios Web serán menos ambiciosos, porque sólo serán aprovechados por aplicaciones clientes escritas por nosotros mismos, y en conse-cuencia, nos sobrará toda la parte que tiene que ver con UDDI. Iba a escribir “¡qué pena!”, pero me ha parecido demasiado cinismo.

NO

TA

Al igual que HTTP no es el transporte obligatorio, SOAP tampoco es el único protocolo de intercambio de mensajes permitido, aunque sí el más frecuente. .NET soporta de manera directa dos protocolos más sencillos, llamados HTTP-GET y HTTP-POST, que como sus nombres indican, están basados también en HTTP. Sin embargo, estos dos protocolos alternativos tienen muchas limitaciones.

Mensajes, llamadas y una gran mentira

El propio acrónimo SOAP encierra una gran mentira: aunque nos venden un simple object access protocol, los servicios Web no exponen objetos, sino simples procedimien-tos remotos. Es cierto que utilizaremos métodos de clases .NET para implementar esos procedimientos, pero se trata solamente de un detalle de la implementación. Hasta cierto punto, un servicio Web se parece por su comportamiento a un servidor de .NET Remoting con activación en el servidor bajo el modelo single call. Más ade-lante veremos cómo almacenar información de modo que persista entre llamada y

Page 395: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios Web 395

llamada al servicio, usando cookies o mediante una extensión conocida como cabeceras SOAP (SOAP headers). Sin embargo, esta técnica requiere que el cliente coopere con el servidor, y no está contemplada en el estándar de facto, al menos de momento.

Incluso es problemático afirmar que un servicio Web ofrece llamadas a procedi-mientos remotos. En la Edad Media, había gente en Europa que estudiaba teología y se ganaba el sustento discutiendo sobre el sexo de los ángeles. A principios del siglo XXI, los descendientes de esas mismas personas, en Europa y en la costa Este de los Estados Unidos, aprenden Java y se ganan las lentejas perorando sobre las diferencias entre llamadas y mensajes, o exaltando las virtudes del modelo document/literal sobre el pecaminoso rpc/encoded. La Humanidad no ha mejorado tanto como algunos preten-den.

¿Cuál es el problema con las llamadas, entonces? Si examinamos una llamada con un microscopio, veremos que está constituida por dos mensajes: en el primero, pasamos los parámetros de entrada y los valores iniciales de los parámetros pasados por refe-rencia, y el segundo nos comunica los valores de los parámetros de salida, los valores finales de los parámetros por referencia y el posible valor de retorno. Una vez reco-nocido este hecho trivial, se comprende que un modelo basado en mensajes puede ser más general que uno basado en llamadas. Un cliente podría enviar mensajes al servidor, sin tener que esperar una respuesta. El servidor podría también enviar men-sajes a sus clientes, por iniciativa propia... aunque luego HTTP se encargue de echar por tierra esta fantasía. Pero queda lo importante: SOAP es un protocolo basado más bien en mensajes.

Por otra parte, si un mensaje limita su carrera profesional a actuar como correveidile de una llamada, sólo deberá aprender a pasar parámetros de un sitio a otro. Pero cuando el mensaje se emancipa, el formato de parámetros comienza a quedarle estre-cho. Esto da origen a una de las clasificaciones de los servicios Web: servicios cuyos mensajes imitan llamadas remotas, en inglés Remote Procedure Calls, o RPC; y servicios cuyos mensajes permiten pasar estructuras arbitrarias, codificadas siempre como documentos XML. En estos momentos, los servicios basados en documentos son los más populares.

Por último, la más reciente batalla de los sexadores de ángeles se libra alrededor de la codificación de valores dentro de mensajes. Las posibilidades son encoded y literal, y parece ser que van ganando los segundos. Mientras escribía el párrafo anterior, oí un alarido y me asomé a la ventana, para ver caer desde la azotea al vecino del octavo, con un hacha enterrada en el cerebelo. Lo siento por él y por su mujer, porque for-maban una pareja encantadora; ambos programadores, los dos dedicados a Java en cuerpo y alma. Tenían sus más y sus menos, por supuesto. El era fanático confeso de rpc/encoded, mientras que ella era devota de los documentos literales. Pepe, vecino: si el hacha no te hubiese podido, lo habría hecho el asfalto. Que la tierra te sea leve.

Page 396: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

396 La Cara Oculta de C#

Un ejemplo sencillo de cliente

Por fortuna, la programación de clientes y servidores de servicios Web es mucho más sencilla que lo que sugieren todas estas mamarrachadas ontológicas. Comencemos por lo más fácil: cómo “consumir”, es decir, cómo actuar como cliente de un servicio Web que ya existe. Partiremos de un proyecto vacío de Windows Forms. Después de crear el proyecto, active el Explorador de Soluciones, seleccione el nodo Referencias, active el menú de contexto y ejecute el comando Agregar referencia Web:

A continuación aparecerá un diálogo modal que, entre otras cosas, le mostrará la siguiente página HTML dentro de un panel:

Estos enlaces nos intentan conectar con algunos servidores UDDI: el posible servi-dor UDDI de la red local, el registro global UDDI, o un servidor para pruebas mantenido por Microsoft, que hace referencia a ejemplos programados y registrados por programadores del mundo entero. También puede detectar directamente servi-cios Web alojados en el ordenador local. En el caso en que nos conectemos a un servidor UDDI, tendremos dos posibilidades: realizar una búsqueda dado el nombre del servicio o el proveedor, o navegar por una estructura jerárquica de servicios. Un servidor puede implementar varias de estas jerarquías. Pero, en mi opinión, localizar un servicio determinado no es tarea fácil, incluso con estas ayudas. Además, es difícil encontrar un servicio de prueba que funcione correctamente; es posible que se deba

Page 397: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios Web 397

a la versión de la plataforma .NET con la que fueron programados la mayoría de los ejemplos. En cualquier caso, es un mal signo que surjan tan pronto estos problemas de compatibilidad.

Por este motivo, vamos a localizar un servicio Web de ejemplo usando el mejor de los ordenadores: nuestros cerebritos. Active su navegador preferido de Internet, y teclee la siguiente URL:

http://www.xmethods.net

XMethods es una compañía que ofrece algunos servicios de prueba, pero que tam-bién ofrece servicios de directorio. Al final de la página aparecen listados varios ser-vicios de prueba. Vamos a elegir el Currency Exchange Rate, que nos informa sobre la tasa de cambio entre dos tipos de monedas. Si pulsamos sobre el enlace asociado al servicio, obtendremos una página con la descripción informal del servicio: qué hace, cuáles son sus condiciones de uso, recomendaciones de uso, etcétera. Lo que más nos interesa es la dirección donde podemos obtener su descripción formal, en for-mato WSDL:

http://www.xmethods.net/sd/2001/CurrencyExchangeService.wsdl

Con esta URL, podemos volver al asistente Agregar referencia Web de Visual Studio, y teclearla en la barra de direcciones. El asistente analizará el documento WSDL obte-nido, y mostrará la siguiente información en el panel de la derecha del diálogo:

Page 398: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

398 La Cara Oculta de C#

Al pulsar el botón Agregar referencia, se cerrará el asistente, y aparecerá un nuevo nodo en el Explorador de Soluciones. El nodo inicialmente ocultará los detalles de sus hijos, pero si pulsamos el botón Mostrar todos los archivos, de la barra de botones del Explorador, veremos todos los nodos dependientes de la nueva referencia:

Como puede ver, Visual Studio ha creado un fichero con código C#, que contiene una clase auxiliar, un proxy, para acceder al servicio Web.

Acceso al servicio mediante un proxy

Abra el fichero Reference.cs en el editor de código. El siguiente listado muestra sola-mente las declaraciones generadas, sin incluir el código de implementación, atributos o comentarios:

namespace SimpleClient.net.xmethods.www {

public class CurrencyExchangeService:

System.Web.Services.Protocols.SoapHttpClientProtocol {

public CurrencyExchangeService()

{ … }

public System.Single getRate(string country1, string country2)

{ … }

public System.IAsyncResult BegingetRate(

string country1, string country2,

System.AsyncCallback callback, object asyncState)

{ … }

public System.Single EndgetRate(

System.IAsyncResult asyncResult)

{ … }

}

}

Visual Studio ha generado una clase que tiene el mismo nombre que el servicio Web. La clase tiene un constructor sin parámetros, que nos conecta al servidor remoto, y tres métodos por cada método realmente ofrecido por el servicio. En este ejemplo, el servicio sólo exportaba un método llamado getRate, pero Visual Studio ha generado tres métodos en el proxy: getRate, BegingetRate y EndgetRate. Los dos últimos métodos sirven para realizar llamadas asíncronas al servicio, pero ignoraremos esta posibilidad de momento; más adelante, veremos cómo aprovecharla.

Para poder utilizar el proxy con comodidad desde el formulario principal, activaremos el editor de código para añadir la siguiente cláusula using:

using SimpleClient.net.xmethods.www;

Page 399: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios Web 399

Luego añadiremos un par de combos, un botón y un control de etiqueta sobre el formulario; la etiqueta mostrará la tasa de conversión:

El servicio que estamos usando tiene un defecto: exige que le pasemos dos identifi-cadores de moneda, pero las cadenas aceptadas no pertenecen a ningún estándar reconocible, y el propio servicio no nos ofrece la lista de posibilidades. Por este mo-tivo, tendrá que configurar a mano las listas de elementos de los combos; encontrará la lista de valores aceptados en la descripción del servicio, en la misma página de donde sacamos la URL del documento WSDL.

La llamada al servicio, cuando pulsamos el botón, es trivial:

private void bnRate_Click(object sender, System.EventArgs e)

{

using (CurrencyExchangeService ces =

new CurrencyExchangeService())

label1.Text = ces.getRate(

comboBox1.Text, comboBox2.Text).ToString();

}

Primero creamos una instancia del proxy, y a continuación ejecutamos el método, o los métodos, que nos interesan. Al terminar la ejecución del método, la instrucción using ejecuta automáticamente el método Dispose del proxy. Y no olvide que, para que estas instrucciones funcionen, debe estar conectado a Internet.

NO

TA

Hay que contar con la posibilidad de que el servicio nos engañe. En este caso particular, por ejemplo, la tasa de cambio del día en que se realizó la prueba era de 1,1781, en vez de 1,1756. No deberíamos quejarnos, porque se trata de un servicio de prueba gratuito. Pero, ¿quién le garantiza que el servicio que usted escoja para su aplicación real funcio-nará adecuadamente?

Servidores basados en ASP.NET

Nuestro objetivo inmediato, sin embargo, es crear servicios Web que actúen como servidores de capa intermedia para nuestras aplicaciones de bases de datos. La forma más sencilla de crear un servicio Web en la plataforma .NET es utilizar Internet In-formation Services para su alojamiento, y ASP.NET como sistema de desarrollo. Si es usted de esos personajes que insisten en matar la ternera para cortar sus propios filetes, puede usar directamente las clases que ofrece .NET para el trabajo con HTTP, o incluso bajar al nivel de los zócalos TCP/IP, montar artesanalmente los mensajes

Page 400: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

400 La Cara Oculta de C#

SOAP de respuesta y analizar el contenido XML de las peticiones. Debo advertirle que estas personas tan autosuficientes y mañosas suelen morir de una cornada.

El servicio Web más sencillo solamente requiere un fichero... y ni siquiera hace falta compilarlo. Eso sí, debe haber instalado Internet Information Services antes, tenerlo funcionando, y haber configurado un directorio virtual con el permiso de ejecución de secuencias de comandos. Este permiso se concede en el diálogo de propiedades del directorio, dentro de la consola de I.I.S.

¿Tiene a mano uno de estos directorios? Cree un fichero en su interior, y llámelo, por ejemplo, prueba.asmx. Abralo con el Bloc de Notas y teclee lo siguiente:

<%@ WebService Class="TestClass" Language="C#" %>

using System;

using System.Web.Services;

public class TestClass {

[WebMethod]

public int Add(int a, int b) {

return a + b;

}

[WebMethod]

public DateTime CurrentDate() {

return DateTime.Now;

}

}

Cierre el fichero, y abra su navegador de Internet. Suponiendo que el directorio vir-tual se llama webservice, teclee la siguiente URL en la barra de direcciones:

http://localhost/webservice/prueba.asmx?wsdl

Si no ha cometido un error, verá dentro del navegador el documento WSDL que describe el servicio que acabamos de crear. Puede probarlo también con el asistente para añadir referencias Web de Visual Studio. Observe que la ruta de obtención de la descripción WSDL de un servicio creado con ASP.NET consiste en la URL del fi-chero asmx con el sufijo ?wsdl. Más adelante volveremos a ocuparnos de estos deta-lles. Veremos que ASP.NET genera incluso una página de prueba para cada método del servicio. ¡Y todo esto lo hemos logrado declarando una simple clase, y marcando algunos de sus métodos con un atributo llamado WebMethod!

Page 401: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios Web 401

¿Por qué no hemos necesitado compilar el fichero? Esta es una de las características más interesantes de ASP.NET. Al producirse la primera petición, el entorno de eje-cución de ASP.NET identifica el lenguaje en que hemos escrito la página mediante la directiva de la primera línea. En ese momento, realiza un par de transformaciones sobre el contenido del fichero y llama al compilador correspondiente, para generar una clase en el lenguaje intermedio de .NET. A partir de ese momento, las peticiones que se reciban serán respondidas directamente por la clase compilada, para lograr una mayor velocidad de respuesta. Pero lo mejor de todo está por llegar: si modifi-camos el fichero asmx, ASP.NET lo compilará nuevamente, sin que nuestra interven-ción sea necesaria.

La estructura de ficheros de un servicio Web puede ser mucho más compleja, como sugiere el siguiente diagrama:

Para empezar, podemos ajustar la configuración de los servicios alojados en un di-rectorio específico añadiendo un fichero de configuración al mismo, con el nombre predefinido web.config. Otra posibilidad es crear un fichero global.asax dentro del di-rectorio. Normalmente, un servicio Web se implementa sin necesidad alguna de mantenimiento del estado entre llamadas. Pero ASP.NET permite mantener el es-tado, siempre que haya un poco de colaboración por parte de los clientes. En ese caso, podemos manejar un objeto global de aplicación, al que pueden acceder todas las llamadas, y objetos de sesión, que relacionan todas las llamadas provenientes de un mismo cliente remoto. Con la ayuda de global.asax se interceptan los eventos globales disparados por estos objetos.

Lo más común, sin embargo, es que el servicio aproveche la técnica conocida como code behind, que si me permite el chiste, traduciría como código trasero13. En la variante

13 Behind significa atrás, detrás, pero como puede imaginar, ha pasado también a significar, in-formalmente, la parte carnosa de nuestra anatomía que comienza donde termina la espalda. Se pronuncia “bijain”, pero en el sur de los Estados Unidos la pronunciación es “baijain”. Se trata, probablemente, de un caso de desplazamiento del tabú: para evitar pronunciar una palabra, la gente inventa un sustituto. Pero con el tiempo, el propio sustituto va pareciéndose cada vez más al tabú original... y debe ser nuevamente reemplazado.

Page 402: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

402 La Cara Oculta de C#

de esta técnica que se utiliza con los servicios Web, el fichero asmx contiene única-mente la directiva inicial, WebService, como en el siguiente ejemplo:

<%@ WebService Language="C#"

Class="WSTest.EmpInfo, EmpInfo.Asmx" %>

El atributo Class indica un nombre de clase y de ensamblado, en ese orden. El en-samblado mencionado se busca dentro de un subdirectorio bin que debemos crear físicamente dentro del directorio virtual de I.I.S. Gracias a este convenio, nos aho-rramos el registro del ensamblado en la caché de ensamblados de .NET. Por su-puesto, dentro del ensamblado debemos poner el código compilado de la clase que será utilizada por el servicio.

Cuando el servicio se crea con la ayuda de Visual Studio, se utiliza una variante dife-rente de la directiva WebService, que puede confundir al programador no avisado:

<%@ WebService Language="C#"

CodeBehind="EmpInfo.asmx.cs"

Class="WSTest.EmpInfo" %>

Esta vez, Class no hace mención explícita del ensamblado, pero el atributo CodeBehind contiene el nombre del fichero de código fuente que debe compilarse para crear el ensamblado binario. Sin embargo, en contra de la creencia popular, este atributo no sirve para que ASP.NET compile el ensamblado en tiempo de ejecución. Sólo Visual Studio puede aprovechar esta información con un doble propósito:

1 Establecer la correspondencia entre los ficheros asmx y asmx.cs. 2 Por supuesto, Visual Studio sí puede compilar el ensamblado, cuando se genera

el proyecto.

En este sentido, hay diferencias entre el funcionamiento de la técnica code behind para páginas y para servicios Web. En el caso de una página de ASP.NET, en vez de usar una directiva WebService, se utiliza la directiva Page. Uno de los atributos de Page, lla-mado Src, sirve para indicar dónde se encuentra el código fuente del ensamblado para compilarlo en tiempo de ejecución, si fuese necesario. WebService, en cambio, no soporta el atributo Src.

NO

TA

De todos modos, no es aconsejable usar Src incluso cuando está disponible. Por una parte, la primera llamada a la página tendría bastante trabajo adicional, pero lo más im-portante es que los errores de compilación sólo se detectarían al probar el servicio. Es preferible distribuir siempre el ensamblado compilado como una DLL.

Si nuestro servicio debe acceder a SQL Server, tendremos que tomar medidas adi-cionales. Lo más aconsejable es que utilicemos seguridad integrada para la conexión con el servidor. De esta manera, evitamos el dejar una contraseña grabada dentro de la aplicación, o aún peor, en un fichero externo, de fácil acceso.

El problema consiste, entonces, en conocer la cuenta en la que se ejecutará el servi-cio por omisión. En la configuración inicial, la cuenta se llama ASPNET, precisa-mente, y pertenece al grupo de usuarios local del servidor, en vez de pertenecer al

Page 403: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios Web 403

posible dominio en el que el servidor reside. Para evitar problemas, debe añadir un nuevo login en SQL Server:

Observe que, aunque debe indicar cuáles bases de datos puede utilizar esta cuenta, no es necesario, ni recomendable, concederle permisos de administración sobre ellas. Otra posibilidad es modificar la sección <identity>, en el fichero web.config, para indi-car la cuenta de usuario asociada al servicio. En este caso, para evitar que alguien pueda acceder al fichero de configuración y obtener esa información, podemos indi-car una clave del registro donde almacenaremos el usuario y su contraseña en for-mato cifrado.

Servicios Web en Visual Studio

Vamos a mostrar ahora un ejemplo más real de servicio Web con acceso a datos. Para crear un servicio Web en Visual Studio, debemos crear un proyecto del tipo Servicio Web ASP.NET, como muestra la siguiente imagen:

Page 404: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

404 La Cara Oculta de C#

Aquí se pueden complicar las cosas, si no somos cuidadosos. Visual Studio asume que usted quiere ubicar el proyecto dentro de una URL, esto es, en un directorio virtual que puede estar alojado en el mismo ordenador de desarrollo o en un servidor remoto. Para poder crear y administrar los ficheros del proyecto, tendrá que instalar las extensiones de servidor de FrontPage, unas extensiones que pierden su configu-ración con extrema facilidad y que plantean un problema de seguridad añadido. La experiencia me dice que es mejor crear el directorio virtual antes de crear el proyecto, para ahorrarle este trabajo a Visual Studio.

Para este ejemplo, llamaré WSTest al directorio virtual. El servicio que Visual Studio crea se llamará inicialmente Service1: este nombre se utilizará para el fichero asmx y su correspondiente asmx.cs, que es el que contendrá el código C#, y también se utilizará para nombrar la clase que implementará el servicio. Para facilitar la identificación posterior del servicio, cambie estos nombres a EmpInfo. Una vez hecho el cambio, abra el fichero EmpInfo.asm.cs; es preferible que utilice el bloc de notas, porque Visual Studio se empecinará en abrir el fichero con el código “trasero”. El contenido del fichero asmx debe ser el siguiente:

<%@ WebService

Language="C#"

CodeBehind="EmpInfo.asmx.cs"

Class="WSTest.EmpInfo" %>

Regrese a la superficie de diseño del servicio, y deje caer un SqlDataAdapter sobre la misma. Llámelo daVentas, conéctelo a la base de datos Northwind, y configúrelo en modo de sólo lectura con la siguiente instrucción:

select emp.EmployeeID, emp.LastName, emp.FirstName,

sum(round(odt.UnitPrice * odt.Quantity *

(100 - odt.Discount) / 100, 2)) TotalAmount

from Employees emp inner join

Orders od

on emp.EmployeeID = od.EmployeeID inner join

[Order Details] odt

on od.OrderID = odt.OrderID

group by emp.EmployeeID, emp.LastName, emp.FirstName

A continuación, traiga un DataSet al diseñador, y configúrelo como un conjunto de datos genérico, para simplificar. Cambie su nombre a dsVentas. Finalmente, pase a la ventana de código y añada el siguiente método a la clase EmpInfo:

[WebMethod]

public System.Data.DataSet VentasEmpleados()

{

dsVentas.Clear();

daVentas.Fill(dsVentas);

return dsVentas;

}

Compile entonces el proyecto, y ejecútelo. El proyecto genera un ensamblado DLL, pero al tratarse de un proyecto Web, Visual Studio entiende que queremos probar el proyecto, y activa una instancia de Internet Explorer como ésta:

Page 405: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios Web 405

Ignoremos de momento la advertencia sobre el espacio de nombres. La URL utilizada para activar esta ventana es:

http://localhost/WSTest/EmpInfo.asmx

Si pulsa el enlace VentasEmpleados, que es el nombre del único método disponible en este servicio, navegará a la siguiente página:

El método en cuestión no necesita parámetros de entrada, por lo que puede pulsar directamente el botón Invocar. Internet Explorer mostrará el resultado de la ejecución de VentasEmpleados: una cadena XML, en formato DiffGram, con los registros de-vueltos por la consulta que configuramos para el proyecto.

El cliente se configura con igual facilidad. Inicie un proyecto vacío del tipo Windows Forms. Active el Explorador de Soluciones, y ejecute el comando Agregar Referencia Web del menú local del nodo Referencias. En respuesta, aparecerá el ya conocido asis-tente de generación de proxies para servicios Web. Esta vez, puede intentar localizar el servicio en la página inicial, siguiendo el enlace Servicios Web del equipo local:

Page 406: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

406 La Cara Oculta de C#

Puede también teclear directamente la URL del servicio en la barra de direcciones. Incluso si olvida agregar el prefijo ?wsdl a la URL, el asistente será capaz de encontrar la descripción del servicio.

Una vez añadida la referencia, regrese al formulario principal, añada una rejilla de datos y escriba el siguiente código en respuesta al evento Load del formulario:

private void MainForm_Load(object sender, System.EventArgs e) {

EmpInfo empInfo = new EmpInfo();

dataGrid1.DataSource = empInfo.VentasEmpleados();

dataGrid1.DataMember = empInfo.Tables[0].TableName;

}

La llamada al método remoto VentasEmpleados crea un nuevo DataSet local al cliente, que se enlaza inmediatamente a la rejilla. Esta técnica tiene un inconveniente: no podemos manejar el conjunto de datos en tiempo de diseño. Si quiere evitarlo, puede crear un conjunto de datos vacío en el formulario cliente. Al llamar al método re-

Page 407: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios Web 407

moto, sólo tiene que “copiar” todo el contenido de un conjunto de datos al otro, con la ayuda del método Copy de la clase DataSet. Esto se lo dejo como ejercicio.

Atributos para servicios Web

Como es costumbre en .NET, una buena parte de las opciones de un servicio Web se especifican mediante atributos. Ya hemos visto que podemos marcar un método con el atributo WebMethod para que esté disponible remotamente. Ahora veremos cuáles otras posibilidades nos ofrece WebMethod y estudiaremos más atributos relacionados con el desarrollo de servicios Web.

Al aplicar un atributo a una clase, método, propiedad, o lo que sea, podemos especi-ficar argumentos mediante dos técnicas: incluyendo el nombre del argumento, o confiando en la posición para identificar de qué argumento se trata. Los argumentos que se pasan por posición corresponden a parámetros del constructor del atributo; los argumentos pasados por nombre corresponden a propiedades de la clase del atributo. Por ejemplo, hay cinco constructores para la clase WebMethodAttribute, que es la que corresponde al atributo WebMethod:

public WebMethodAttribute();

public WebMethodAttribute(bool enableSession);

public WebMethodAttribute(bool enableSession,

TransactionOption transactionOption);

public WebMethodAttribute(bool enableSession,

TransactionOption transactionOption, int cacheDuration);

public WebMethodAttribute(bool enableSession,

TransactionOption transactionOption, int cacheDuration,

bool bufferResponse);

Esto significa que todas estas formas de aplicar el atributo WebMethod son correctas:

// Todos los valores por omisión

[WebMethod]

// No queremos sesiones, pero sí una transacción para el servicio

[WebMethod(false, TransactionOption.Required)]

// Todas las opciones indicadas explícitamente

[WebMethod(false, TransactionOption.Required, 60, true)]

Claro, si usted no tiene la ayuda a mano, ¿cómo va a recordar el significado del úl-timo true en el ejemplo anterior? Por este motivo, casi todos los parámetros de los constructores de una clase de atributos se almacenan en propiedades, para que po-damos también pasar estos valores en argumentos con nombres. Por ejemplo:

[WebMethod(BufferResponse=true, CacheDuration=60)]

Veamos ahora las propiedades asociadas al atributo WebMethod. La propiedad de uso más frecuente es Description, una cadena de caracteres que vincula una descripción en lenguaje humano al método:

[WebMethod(Description="Resumen de ventas por empleado")]

public System.Data.DataSet VentasEmpleados() { … }

Page 408: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

408 La Cara Oculta de C#

Si indicamos una descripción para un método, ésta aparecerá en la página de ayuda asociada al servicio Web:

EnableSession, de tipo lógico y con valor false por omisión, permite que el servidor identifique todas las llamadas provenientes de un mismo cliente durante un plazo de tiempo determinado; la clase que implementa el servicio debe heredar de la clase WebService. Para este propósito se utilizan las conocidas cookies, las mismas que utili-zan las aplicaciones en Internet. La primera llamada realizada por un cliente dado no incluye cookies, por lo general. El servicio reacciona creando una y pasándola con la respuesta. En las siguientes llamadas, el cliente debe transportar la cookie de vuelta al servidor, para que éste reconozca a su viejo cliente. ¿Para qué puede interesarnos mantener el estado entre llamadas? Hay una razón importante:

• Cuando se habilitan sesiones para un servicio Web, el método remoto puede almacenar variables asociadas a la sesión, que persistan entre llamada y llamada. Recuerde que esto no puede lograrse mediante campos de la clase, porque las instancias de la clase del servicio se reciclan entre llamadas.

• Con estas variables de sesión podríamos, por ejemplo, devolver el contenido de una tabla por páginas: la primera llamada devolvería los primeros veinte registros, la siguiente traería otro grupo de veinte, etc. Más adelante mostraré un ejemplo de esta técnica.

También existen razones poderosas para evitar las sesiones:

• El cliente debe recibir la cookie, almacenarla en lugar seguro y devolverla al servi-cio en la siguiente llamada. No todas las herramientas de desarrollo lo permiten, y hay que recordar que uno de los objetivos de los servicios Web es la compati-bilidad entre plataformas.

• Como comprenderá, el servicio de sesiones consume memoria en el servidor, y puede ralentizar la operación del servicio.

Técnicas de caché

Otra de las propiedades de WebMethod es CacheDuration, que permite almacenar la respuesta de un método en caché durante cierto tiempo. Suponga que tenemos un método remoto que debe devolver una lista de registros de países. ¿Con qué frecuen-cia modificaremos la tabla de países? Tiene sentido almacenar el resultado de la pri-mera ejecución de este método, para evitar que en las siguientes llamadas el método tenga que perder tiempo interrogando inútilmente la base de datos:

[WebMethod(Description="Lista de países", CacheDuration=3600)]

public System.Data.DataSet CountryList()

{

dsCountries.Clear();

daCountries.Fill(dsCountries);

Page 409: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios Web 409

return dsCountries;

}

El tiempo de validez de la caché se indica en segundos. Hay que tener en cuenta que esta caché es global: no debemos abusar de ella almacenando estructuras demasiado grandes, pero tampoco hay que preocuparse porque crezca el consumo de memoria al aumentar el número de clientes conectados.

No debemos confundir CacheDuration con BufferResponse, otra propiedad de tipo ló-gico del atributo WebMethod. Esta propiedad vale true por omisión, e indica la forma en que se devuelve la respuesta al cliente: si la propiedad está activa, los resultados se generan sobre un buffer local, y sólo se envían al completarse. En caso contrario, la respuesta se va entregando a la conexión con el cliente según se va construyendo. Si la respuesta ocupa mucho espacio, es preferible desactivar el buffer, para ahorrar me-moria en el servidor.

Si queremos más control sobre la caché, o debemos almacenar resultados parciales, en vez de todo el resultado, podemos utilizar directamente la caché de ASP.NET, a través de la propiedad Context.Cache de la clase WebService; por supuesto, esto exige que la clase que implementa el servicio sea heredera de WebService. De este modo, podríamos establecer diferentes prioridades para los objetos almacenados, o estable-cer dependencias entre ellos:

[WebMethod(Description="Lista de regiones")]

public System.Data.DataSet ListRegiones()

{

DataSet cachedDS = Context.Cache["regiones"] as DataSet;

if (cachedDS == null)

{

dsRegiones.Clear();

daRegiones.Fill(dsRegiones);

cachedDS = dsRegiones.Copy();

Context.Cache.Add(

"regiones", cachedDS, null,

DateTime.Now.AddMinutes(15), TimeSpan.Zero,

System.Web.Caching.CacheItemPriority.Normal, null);

}

return cachedDS;

}

El método anterior devuelve la lista de regiones dentro de un conjunto de datos. Observe que, para almacenar el conjunto de datos en caché, hemos utilizado el mé-todo Add en vez de asignar directamente sobre el indizador de la clase:

Context.Cache["regiones"] = cachedDS;

Esta claro el significado de los dos primeros parámetros de Cache.Add: son el nombre que vamos a asociar al objeto, y la referencia al propio objeto. En el tercer parámetro pasaríamos las dependencias respecto a otros objetos, aunque en el ejemplo hemos recurrido a un puntero vacío. Luego viene la hora en que se eliminará el objeto de la caché: 15 minutos de haber sido añadido, en nuestro ejemplo. Podemos indicar tam-bién un tiempo de gracia que se sumará cada vez que hagamos referencia al objeto,

Page 410: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

410 La Cara Oculta de C#

aunque por simplificar, hemos pasado un cero. El penúltimo parámetro es la priori-dad o importancia del objeto: aquellos con menor prioridad serán sacrificados antes. Y, finalmente, podríamos haber pasado un delegado para recibir un aviso cuando el objeto fuese eliminado de la caché, sin importar el motivo.

Cabeceras SOAP

Una de las técnicas más llamativas relacionadas con los servicios Web es el uso de las cabeceras SOAP, o SOAP headers. Estas cabeceras consisten en información en for-mato XML que se puede añadir a un mensaje SOAP, sin importar si el mensaje va del cliente al servidor o viceversa; lo más habitual es lo primero. Aunque es posible hacer obligatorio el uso de cabeceras con determinados métodos, es más frecuente que éstas sean opcionales. Si obligásemos a pasar una cabecera cada vez que un cliente realizara una llamada remota al servicio, mejor sería convertir esa cabecera en un parámetro adicional del método, ¿no?

NO

TA

Si lo prefiere, puede pensar en las cabeceras SOAP como algo equivalente al lenguaje corporal durante una conversación. El contenido principal suele ser lo que se está di-ciendo, pero los gestos de los interlocutores también contienen información útil.

La aplicación que nos viene enseguida a la mente para estas cabeceras es la autentica-ción de llamadas: la información sobre el usuario podría pasarse al servidor en una cabecera SOAP, para que éste decida si responde correctamente, lanza una excep-ción... o si devuelve al cliente basura sin sentido. En el capítulo sobre servidores de capa intermedia veremos una variante de esta técnica, conocida como seguridad me-diante tokens. Y veremos cómo se puede paginar el resultado de una consulta me-diante cabeceras SOAP.

Aquí vamos a mostrar un ejemplo menos trillado. Tenemos un servicio Web que publica un método remoto llamado ListaClientes, que devuelve... pues eso, los regis-tros de la tabla de clientes. Normalmente, debe retornar todos los registros, pero que-remos dar la posibilidad de que algunos usuarios del servicio limiten el resultado a los clientes de determinado país, y queremos utilizar cabeceras SOAP. Si pasamos la cabecera correspondiente, la consulta busca los clientes del país mencionado; en caso contrario, se buscan todos los clientes.

Aparentemente, estamos bebiendo agua sin masticar, porque podemos lograr el mismo efecto añadiendo un parámetro de entrada al método. Piense, en cambio, la situación que tendríamos si tuviésemos una larga lista de condiciones posibles, todas ellas opcionales, pero aplicables a toda una lista de métodos. En este caso, las cabece-ras SOAP no aportan mayor potencia, pero sí mayor comodidad.

Comencemos por el servicio Web, en el lado del servidor. Primero debemos definir una clase para la cabecera. La clase debe heredar de SoapHeader, que a su vez está definida en el espacio de nombres System.Web.Services.Protocols:

Page 411: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios Web 411

using System.Web.Services.Protocols;

public class Predicado: SoapHeader {

public string Pais;

}

Observe que en la nueva clase definimos la información que se va a pasar mediante simples campos con acceso público. El siguiente paso es declarar un campo público con el tipo de clase que acabamos de crear, dentro de la clase que implementa el servicio:

public Predicado predicado = null;

La asignación explícita del puntero vacío evitará molestas advertencias por parte del compilador. Por último, podemos crear el método remoto:

[WebMethod(Description="Lista de clientes")]

[SoapHeader("predicado")]

public DataSet ListaClientes()

{

string stmt = "select * from dbo.Customers";

if (predicado != null)

stmt += String.Format(

" where Country = '{0}'", predicado.Pais);

SqlDataAdapter da = new SqlDataAdapter(stmt, sqlConn);

DataSet ds = new DataSet();

da.Fill(ds, "Clientes");

return ds;

}

Hemos marcado el método con el atributo SoapHeader:

[SoapHeader("predicado")]

El argumento del atributo es la variable donde queremos leer y escribir la informa-ción de la cabecera. Lo que pasamos como parámetro al atributo es una cadena con el nombre de la variable, para que el servicio la localice posteriormente con la ayuda de reflexión. Si quisiéramos indicar la dirección en la que se puede mover la cabecera, tendríamos que agregar un argumento por su nombre:

[SoapHeader("predicado", Direction=SoapHeaderDirection.In)]

Los valores posibles son In, Out, InOut y Fault. Los tres primeros significan, respecti-vamente, que la cabecera se envía del cliente al servidor, del servidor al cliente y en los dos sentidos; el valor por omisión es In. Fault indica que la cabecera se envía al cliente sólo cuando se produce una excepción en el servidor.

Para saber si el cliente nos ha pasado la cabecera, basta comprobar si la variable men-cionada en el atributo SoapHeader apunta a una instancia de la clase o no.

string stmt = "select * from dbo.Customers";

if (predicado != null)

stmt += String.Format(

" where Country = '{0}'", predicado.Pais);

Page 412: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

412 La Cara Oculta de C#

En este ejemplo no pasamos la cabecera de vuelta al cliente. En ese caso, después de asegurarnos de que el campo predicado apuntase a una instancia, modificaríamos el contenido de esa instancia, directamente.

Nos queda mostrar cómo se pasa la cabecera desde el cliente. Para esta tarea, el proxy nos echa una mano:

1 El importador WSDL crea una clase auxiliar Predicado para replicar la estructura de la cabecera.

2 Dentro de la propia clase proxy, se declara un campo público, PredicadoValue, cuyo tipo es la clase auxiliar asociada a la cabecera.

Con esta ayuda, pasar una cabecera al servidor es juego de niños:

private void bnLoad_Click(object sender, System.EventArgs e)

{

EmpInfo servidor = new EmpInfo();

servidor.PredicadoValue = new Predicado();

servidor.PredicadoValue.Pais = "Mexico";

dsClientes.Clear();

dsClientes.Merge(servidor.ListaClientes());

}

Igualmente, para recibir una cabecera sólo tenemos que comprobar, después de eje-cutar el método remoto, la referencia pasada en PredicadoValue.

Espacios de nombres para servicios

Cuando tecleamos la URL de cualquiera de los servicios que hemos creado hasta el momento en la barra de direcciones de un navegador, en la página informativa que se nos muestra aparece una advertencia como la siguiente:

Page 413: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios Web 413

No debe confundir el espacio de nombres de un documento o servicio XML, con el concepto de espacio de nombres de C#. En el caso de los documentos XML, el es-pacio de nombres es una cadena que se utiliza como prefijo para los nombres de elementos y atributos que contiene. El objetivo de esta técnica es evitar conflictos de nombres que podrían presentarse al mezclar o transformar documentos XML pro-venientes de fuentes distintas. Un documento XML generado en IntSight podría utilizar elementos llamados <customer>, y lo mismo podría suceder con documentos creados por Microsoft. ¿Es lo mismo un customer de IntSight que uno de la empresa de B.G.? Puede que sí, puede que no, y para evitar esta ambigüedad, cada documento puede definirse dentro de un espacio de nombres. Si no lo hacemos, se asume que estamos usando un espacio de nombres por omisión.

¿Qué formato debe tener un espacio de nombres de XML? En principio, valdría cualquier cadena de caracteres, siempre que garantizáramos su unicidad dentro de lo razonable. Por este motivo, el convenio más común es utilizar URLs: dos programa-dores que trabajen para distintas empresas pueden usar las URLs de dominio de sus respectivas compañías como base de sus espacios de nombres. A la URL de dominio podríamos añadir un sufijo arbitrario. Por ejemplo:

http://www.marteens.com/csharpbook

Lo que sigue al nombre de dominio no tiene por qué corresponder a una entidad real, por supuesto. La URL que acabo de mostrar no existe, al menos de momento.

Para asignar un espacio de nombres al servicio Web debemos utilizar un nuevo atri-buto llamado WebService, que se aplica a la clase, en vez de usarse con los métodos. Este atributo define el espacio de nombres mediante el argumento Namespace, y nos permite asociar una descripción en lenguaje natural a todo el servicio:

[WebService(Namespace="http://www.marteens.com/csharpbook",

Description="Ejemplos de servicios Web sencillos")]

public class EmpInfo : System.Web.Services.WebService {

}

Usted, por supuesto, deberá utilizar su propia URL.

Ejecución asíncrona

Nadie oculta que los servicios Web son bastante lentos en comparación con otras alternativas de programación remota. En la mayoría de los casos, sin embargo, esta lentitud relativa es aceptable: el usuario que tiene conciencia de estar consultando datos almacenados en el quinto pino, entiende que es normal que la respuesta no sea fulgurante. Pero en otros casos hay que disimular el problema dentro de lo posible y razonable.

Una de las técnicas útiles para este propósito es la ejecución asíncrona de las llama-das. La técnica, en sí, tiene su origen en los tipos delegados de la plataforma .NET, y

Page 414: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

414 La Cara Oculta de C#

puede ser aprovechada también por .NET Remoting. Eche un vistazo a la siguiente declaración de un tipo delegado:

public delegate DataSet RecuperarDatos(

string entidad, string predicado);

Informalmente, las variables de este tipo pueden “apuntar” a métodos que reciban dos parámetros de tipo cadena de caracteres, y que devuelvan conjuntos de datos como resultado. Formalmente, RecuperarDatos es una clase, aunque sus métodos y propiedades no los establecemos nosotros directamente; el compilador los deduce a partir del prototipo de método con el que declaramos el delegado14. La clase gene-rada tendría más o menos el siguiente aspecto, si pudiéramos declararla en C#:

// Esto es pseudocódigo. Entre otras cosas, no podemos …

// … heredar de MulticastDelegate

public class RecuperarDatos: MulticastDelegate

{

// Un constructor público con características "especiales"

public RecuperarDatos(object target, int methodPtr);

// Ejecución síncrona del método asociado

public DataSet Invoke(string entidad, string predicado);

// Ejecución asíncrona del método asociado

public IAsyncResult BeginInvoke(

string entidad, string predicado,

AsyncCallback callback, object asyncState);

public DataSet EndInvoke(IAsyncResult asyncResult);

}

Insisto en el carácter aproximado de esta declaración. Por ejemplo, el constructor tiene dos parámetros, que es lo que sucede realmente en el lenguaje intermedio, pero sabemos que al construir un delegado, C# exige como parámetro el nombre de un método compatible:

RecuperarDatos rd = new RecuperarDatos(MetodoCompatible);

Algo parecido ocurre con el Invoke. C# no nos permite utilizar este método directa-mente por su nombre, pero genera automáticamente una llamada al mismo cuando escribimos una instrucción como la siguiente:

// Ejecutamos el método asociado al delegado rd

DataSet ds = rd("clientes", "Country = 'UK'")

Nos quedan los dos últimos métodos de la clase: BeginInvoke y EndInvoke. Estos mé-todos se generan automáticamente a partir del prototipo del método usado en la declaración del delegado. Estas son las reglas:

1 El tipo de retorno de BeginInvoke siempre es IAsyncResult.

14 Para ser exactos, es la CLR, en tiempo de ejecución, quien implementa la clase asociada al delegado. El compilador simplemente “adelanta” la acción del entorno de ejecución.

Page 415: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios Web 415

2 Los parámetros de entrada y de referencia del método original se copian en el prototipo de BeginInvoke. Los parámetros por referencia se convierten en pará-metros de entrada.

3 Además, a BeginInvoke siempre se le añaden dos parámetros adicionales, el pri-mero del tipo AsyncCallback, y el segundo de tipo object.

4 El tipo de retorno de EndInvoke debe coincidir con el del método original. 5 Los parámetros de EndInvoke se generan a partir de los parámetros de salida y de

referencia del método original. Los parámetros por referencia se convierten en parámetros de salida.

Si estamos hablando ahora de estos métodos es porque permiten realizar llamadas asíncronas al método original. La técnica comienza con la ejecución de BeginInvoke:

IAsyncResult ar = rd.BeginInvoke("clientes", "Country = 'UK'",

null, null);

Esta es sólo una de las formas de llamar a BeginInvoke: observe que el tercer paráme-tro es un puntero nulo; luego aclararemos para qué sirve este parámetro y el si-guiente.

Internamente, BeginInvoke se apropia de uno de los hilos mantenidos en un depósito interno por el sistema operativo, y “mueve” a ese hilo el código que espera a que la llamada retorne. La llamada a BeginInvoke suele durar muy poco tiempo, y el hilo desde donde se ejecuta queda libre inmediatamente, para seguir ejecutando las ins-trucciones que vienen a continuación.

¿Cómo nos enteramos de que el método disparado con la llamada a BeginInvoke ha terminado? La respuesta la tiene el objeto de tipo IAsyncResult:

• Podemos comprobar el valor de su propiedad IsCompleted. No es lo más eficiente, porque el hilo debe ejecutar un bucle de espera que consume ciclos de CPU in-necesarios.

• La propiedad AsyncWaitHandle devuelve un objeto de la clase WaitHandle. Si lla-mamos al método WaitOne de este objeto, el hilo se bloquea hasta que estén lis-tos los resultados. Esta es la técnica aconsejable.

Traduzcámoslo en código:

RecuperarDatos rd = new RecuperarDatos(MetodoCompatible);

IAsyncResult ar = rd.BeginInvoke(

"clientes", "Country = 'UK'", null, null);

// … ejecutamos otras instrucciones …

// Ahora, esperamos por el resultado…

ar.AsyncWaitHandle.WaitOne();

// … y lo recuperamos con EndInvoke

DataSet resultado = rd.EndInvoke(ar);

Page 416: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

416 La Cara Oculta de C#

La otra posibilidad consiste en pasar un delegado en el penúltimo parámetro de BeginInvoke. El delegado debe apuntar a un método que se ejecutará cuando haya terminado la ejecución activada por BeginInvoke. Por ejemplo:

protected void ResultadoDisponible(IAsyncResult ar) {

// Aquí podemos recuperar el resultado de la operación

RecuperarDatos rd = (RecuperarDatos) ar.AsyncState;

DataSet ds = rd.EndInvoke(ar);

}

public void LlamadaAsincrona() {

RecuperarDatos rd = new RecuperarDatos(MetodoCompatible);

IAsyncResult ar = rd.BeginInvoke("clientes", "Country = 'UK'",

new AsyncCallback(ResultadoDisponible), rd);

// Nos vamos tranquilamente: el método ResultadoDisponible

// se ejecutará cuando el resultado esté disponible

}

Observe que hemos pasado el puntero al delegado original, rd, en el último paráme-tro de BeginInvoke. Cualquier valor que pasemos en este parámetro se asignará en la propiedad AsyncState del objeto IAsyncResult que se devuelve. Gracias a este detalle, el método que se dispara al terminar la llamada, ResultadoDisponible, puede obtener el delegado asíncrono para terminar la llamada con EndInvoke.

Llamadas asíncronas a servicios Web

Una técnica similar, aunque basada en un delegado más genérico, es la que se utiliza para ejecutar las llamadas a servicios Web de forma asíncrona. El propio Visual Stu-dio nos lo pone fácil al encapsular los detalles sucios dentro de métodos del proxy generado. Si abrimos el fichero cs correspondiente a una referencia Web, encontrare-mos dos métodos públicos parecidos a los siguientes:

public System.IAsyncResult BeginVentasEmpleados(

System.AsyncCallback callback, object asyncState) {

return this.BeginInvoke(

"VentasEmpleados", new object[0], callback, asyncState);

}

public System.Data.DataSet EndVentasEmpleados(

System.IAsyncResult asyncResult) {

object[] results = this.EndInvoke(asyncResult);

return ((System.Data.DataSet)(results[0]));

}

La técnica de llamada es la misma que se permite al usar delegados. Por ejemplo, esta variante bloquea el hilo principal hasta que la llamada asíncrona haya terminado:

// Crear un proxy para el servicio Web

EmpInfo empInfo = new EmpInfo();

// Iniciar la operación asíncrona

IAsyncResult ar = empInfo.BeginVentasEmpleados(null, null);

// Ejecutar otras tareas de inicialización

// …

Page 417: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios Web 417

// Esperar por el resultado de la operación asíncrona

ar.AsyncWaitHandle.WaitOne();

// Terminar la operación, y recuperar resultados

dataGrid1.DataSource = empInfo.EndVentasEmpleados(ar);

dataGrid1.DataMember = "Employees";

Pero también podemos pedir que se active un segundo método cuando esté disponi-ble el valor de retorno.

Dispara y olvida

¿Y si el método remoto no debe retornar valor alguno? ¿Para qué esperar a que ter-mine su ejecución? Llevando al extremo la distinción entre mensaje y llamada, puede interesarnos crear métodos que no nos envíen en absoluto un mensaje de respuesta. Estos métodos se conocen, en la terminología de los servicios Web, como métodos unidireccionales, o one way methods.

Para marcar un método de un servicio Web como unidireccional, hay que utilizar una propiedad común a dos clases de atributos alternativas:

• SoapDocumentMethodAttribute

• SoapRpcMethodAttribute

Estas dos clases de atributos corresponden a la gran clasificación de los servicios Web que mencionamos al principio del capítulo. Los servicios Web creados con Vi-sual Studio utilizan, por omisión, el modelo de documentos. Por lo tanto, lo más natural es que si queremos marcar un método como unidireccional, utilicemos la clase de atributos SoapDocumentMethodAttribute:

[WebMethod]

[SoapDocumentMethod(OneWay=true)]

public void OperacionLarga(Parametros parametros)

{

// …

}

El cliente no tendría que hacer nada especial: la ejecución del método sería inme-diata, y el cliente podría seguir su trabajo... aunque el servidor estuviese luchando todavía para terminar la operación.

NO

TA

En .NET Remoting se utiliza una clase diferente, OneWayAttribute, para marcar métodos de una sola dirección. Esta clase, sin embargo, no debe utilizar con métodos pertene-cientes a servicios Web.

Page 418: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

25

Servicios de componentes

AY IDEAS QUE, A PESAR DE NO SER CONOCIDAS POR el gran público, llegan a marcar toda una época. En la Informática del cambio de siglo, uno de los con-

ceptos más interesantes y fructíferos ha sido la Programación Orientada a Aspectos, o Aspect Oriented Programming (AOP), que ha influido notablemente en el diseño de COM+, de la plataforma .NET y del propio lenguaje C#. En este capítulo explicaré en qué consiste la AOP y cómo nos puede ayudar, qué tiene que ver COM+ con estas ideas, y cómo podemos usar los servicios de COM+ en aplicaciones .NET.

Los límites de la encapsulación

Esto que le voy a describir podrá parecerle una ficción literaria, pero le aseguro que es la pura verdad. Se trata de una imagen mental que asocio al proceso de desarrollo de aplicaciones. Me veo a mí mismo escogiendo piezas modulares: figuras geométri-cas simples en tres dimensiones, y colocándolas en el aire, frente a mí. Luego dejo que la construcción se “asiente”: aquí sustituyo la imagen inicial por otra que más bien recuerda al conocido juego del Tetris: las piezas van eligiendo independiente-mente el nivel o altura donde encajan mejor, hasta que la construcción entera se hace estable. “Vi” esta película mental por primera vez cuando estaba terminando el se-gundo año en la universidad, y no fue una imagen forzada o inducida por libros o por terceras personas. Simplemente, un día al despertarme, después de quedarme dormido frente a un ordenador, se produjo...

El Santo Grial de la programación se resume en una palabra: modularización. Da lo mismo si hablamos de los años setenta, cuando estaba de moda la Programación Estructurada, o si se trata de la Programación Orientada a Objetos. Aunque estas dos metodologías usan distintas herramientas y conceptos, el objetivo final de ambas es crear “piezas” de software que sean fáciles de conectar. Y la principal técnica de mo-dularización es la encapsulación. Usted reúne dentro de un módulo, ya sea una clase o una función, instrucciones o estructuras de datos que de otra forma estarían dis-persas a lo largo de una aplicación. Uno de los beneficios más importantes, pero menos reconocidos, de agrupar fragmentos de código relacionados por algún crite-rio, es que el tamaño total de la aplicación disminuye. No nos engañemos: menos líneas de código generan menos problemas de mantenimiento.

H

Page 419: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios de componentes 419

Pero algunas cosas no pueden ser encapsuladas. ¿He dicho “cosas”? Bueno, podemos ponernos serios y llamarlas “aspectos”. ¿Un ejemplo? El más evidente es el control de errores. El código fuente de una aplicación correctamente escrita, antes de que tuviésemos lenguajes con manejo de excepciones, se parecía a lo siguiente:

HacemosAlgo;

if ErrorCode <> 0 then goto PROBLEMAS;

OtraTonteria;

if ErrorCode <> 0 then goto PROBLEMAS;

Exit;

PROBLEMAS:

TomarMedidas;

Sí, de acuerdo: podíamos crear funciones o clases que nos ayudasen en el diagnóstico y en la notificación de los errores, pero la detección tenía que efectuarse dolorosa-mente, línea por línea. Un fallo en una línea y ¡adiós robustez! También es cierto que se trata de un problema superado, pero ¿en qué consistió la solución? Fue necesario añadir nuevas instrucciones a los lenguajes, y cambiar los compiladores para que generasen código especial para la detección y propagación de excepciones. Fue una solución brillante... pero reconozcamos que fue sólo una solución a la medida de un problema más general.

¿Otro ejemplo? Esta vez se trata de un problema que no tiene solución sencilla en la mayoría de las herramientas de desarrollo actuales: el problema de la seguridad. En los sistemas operativos tradicionales, y en esta categoría se incluyen esos unixes y linuxes que nos intentar vender como novedades, el código de una aplicación se eje-cuta normalmente dentro de una sola cuenta de usuario; descontando, claro está, las llamadas al núcleo del sistema operativo. Nos basta con comprobar si un usuario de-terminado tiene permiso para ejecutar la aplicación una sola vez. Pero la programa-ción moderna nos ofrece un cuadro muy diferente: aplicaciones descompuestas en módulos físicamente independientes, que incluso pueden encontrarse en ordenado-res separados. Si trazamos una línea sobre las instrucciones que ejecuta una aplica-ción, encontraremos que los cambios de cuenta o de usuario son bastante frecuentes, y no basta con verificar los permisos al comenzar la aplicación. Una aplicación segura debería incluir, al principio de cada rutina, una comprobación de los permisos del usuario activo en ese momento. Nuevamente tenemos un “aspecto” de la aplicación que genera código repartido a lo largo de toda la aplicación, aunque es cierto que en este ejemplo el código adicional no se añade a nivel de instrucciones, sino a nivel de procedimientos.

Programación Orientada a Aspectos

Puede que estas ideas hayan estado en la cabeza de muchos desde hace tiempo, pero los primeros en idear una solución y bautizarla como Programación Orientada a Aspectos fueron los miembros de un equipo de investigación en Xerox, bajo la di-rección de Gregor Kiczales. Estos señores estudiaban las transformaciones de imá-genes, y tropezaron con un problema conocido: hay transformaciones compuestas,

Page 420: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

420 La Cara Oculta de C#

que se pueden crear a partir de operaciones algebraicas que actúan sobre transforma-ciones más simples. Al implementar las operaciones algebraicas, sin embargo, la efi-ciencia caía en picado, por lo que tenían que transformar el código obtenido inicial-mente para obtener una alternativa rápida, pero difícil de mantener. Si surgía la nece-sidad de modificar la transformación, aplicando tal vez algún operador adicional, revertían la “optimización”, modificaban la expresión algebraica, y volvían a optimi-zar el algoritmo.

La proverbial bombilla se les iluminó en ese punto: dado el espacio de configuración de su problema inicial, la optimización podía realizarse mecánicamente. Sin embargo, no se trataba de la optimización de código habitual de un compilador. La solución final consistió en programar las transformaciones en la forma elegante, algebraica. Los algoritmos resultantes debían “decorarse” con atributos adicionales, como si se tratase de un lenguaje paralelo, que indicaba a un tercer módulo la forma en que tenía lugar el movimiento de información gráfica. El tercer componente transformaba en tiempo de ejecución el código inicial, para convertirlo en un algoritmo eficiente. La tarea era relativamente sencilla, porque este equipo utilizaba una variante de LISP como lenguaje de programación.

La generalización de la técnica, que pasó a llamarse Programación Orientada a Aspectos, puede resumirse en tres puntos:

1 Tenemos una serie de componentes de software programados en un lenguaje de programación convencional, del tipo que sea: orientado a objetos, funcional, etc.

2 Paralelamente, esos componentes van acompañados de una serie de atributos especificados por medio de un lenguaje declarativo. A este lenguaje se le llama lenguaje de aspectos.

3 Ya sea en tiempo de ejecución, compilación o de enlace, entra en juego un tercer elemento que se conoce como aspect weaver, traducible como tejedor de aspectos. Este componente modifica el código que realmente se ejecutará, “aplicando” los atributos declarativos al código inicial.

Como puede ver, la técnica es bastante complicada. Con sólo esta rápida descripción, es difícil de comprender cómo podríamos aplicarla a la solución de los retos plantea-dos por el control de errores y la seguridad de usuarios que he descrito al principio del capítulo. Pero alguien en Microsoft tropezó con un artículo sobre AOP, y se dio cuenta de que tenía en sus manos una metodología fundamental para el éxito de uno de los productos más importantes de la compañía...

Los servicios de COM+

... estoy hablando de COM+, claro está: una de las tecnologías que marcan la gran diferencia entre Windows y sus competidores. Sé que las preferencias por un sistema operativo casi tienen un carácter religioso, pero antes de que alguien se sienta vejado, debo aclarar: es posible añadir a un Linux capacidades similares hasta cierto punto a

Page 421: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios de componentes 421

las que ofrece COM+. El problema: el coste total del software duplica el precio de las licencias de Windows, porque la gratuidad del software para Linux termina ahí.

COM+ padece una grave enfermedad: poca gente sabe qué hace, para qué sirve. Podemos resumir su funcionalidad, al menos la parte de ella que más nos interesa, en unos pocos puntos:

• Remoting: La tarea fundamental de COM+ es ofrecer la infraestructura necesa-ria para realizar llamadas a objetos remotos. En este sentido, COM+ puede verse como el antecesor de .NET Remoting, o de los servicios Web, aunque su riqueza expresiva lo asemeja más bien al primero.

Si COM+ se limitase a esta función, que ya realizaba DCOM, no tendría mucho sentido escribir sobre él a estas alturas. El protocolo de transmisión de mensajes de COM+ es bastante problemático: aparte de exigir un formato infernal para los datos, obliga a utilizar varios puertos TCP/IP, por lo que no se lleva bien con los cortafue-gos y los administradores de red. Pero COM+ brilla gracias a una serie de servicios adicionales que ofrece:

• Object pooling: COM+ permite mantener en el servidor una lista acotada de instancias de una clase. Estas instancias pueden dar servicio a una muchedumbre de clientes, compartiendo instancias entre ellos.

• Just-in-time Activation: es decir, activación por demanda. Permite que un objeto libere temporalmente sus recursos entre llamada y llamada. Es un com-plemento importante de la caché de objetos, pero debe usarse con discerni-miento.

• Transacciones declarativas: las estrellas de esta fiesta. Lo siento, pero voy a retrasar su explicación hasta la sección siguiente.

Page 422: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

422 La Cara Oculta de C#

¿Y qué tiene que ver COM+ con la Programación Orientada a Aspectos? Pues que, aunque los servicios mencionados pueden solicitarse mediante código, es más común que se activen o desactiven declarativamente, utilizando una herramienta de admi-nistración llamada Servicios de Componentes.

La implementación de los servicios de COM+ se basa en el análisis, en tiempo de ejecución, de los atributos asociados a la clase del objeto. En dependencia de cuáles atributos estén activos, COM+ genera un interceptor, un objeto que se sitúa entre el componente y sus clientes, y que tiene la posibilidad de “actuar” antes y después de cada llamada remota. COM+, por lo tanto, puede considerarse como un sistema que soporta aspectos declarativos, y que lleva a cabo el entramado de aspectos en tiempo de ejecución, cada vez que se crea un objeto.

NO

TA

La lista de servicios ofrecidos por COM+ es mucho más larga: podemos activar la seguri-dad declarativa basada en perfiles, hacer que los objetos se comuniquen entre sí me-diante eventos débilmente acoplados (loosely coupled events), o implementar compo-nentes asíncronos mediante colas de mensajes. Una clase registrada en COM+ puede, además, publicarse como servicio Web con simplemente marcar una casilla.

Los problemas de la agregación

Suponga que tenemos una clase remota que representa una cuenta bancaria. En breve descubriremos que ésta es una mala idea, aunque ahora parezca sorprendente, pero aceptemos la suposición durante un par de páginas. Tampoco nos preocupare-mos de momento por cómo asociamos un objeto de esta clase con una cuenta ban-caria concreta: lo natural sería indicar el número o identificador de la cuenta en el constructor, pero sabemos que .NET Remoting, por ejemplo, exige que los compo-nentes activados en el servidor tengan constructores sin parámetros, y en COM+ existe un problema parecido. Para complicar las cosas más, está el problema del mantenimiento del estado entre llamadas... Mejor es que nos concentremos en lo que verdaderamente importa. Ahora sólo nos interesa un método de la clase Cuenta:

Page 423: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios de componentes 423

class Cuenta {

///<sumary>

/// Implementa ingresos y extracciones en la cuenta</summary>

///<returns>

/// Devuelve el saldo tras la operación</returns>

public Decimal Mover(Decimal importe) { … }

}

En este ejemplo, el método Mover es muy sencillo, y podría implementarse con una sencilla instrucción update. Pero vamos a generalizar, suponiendo que el método es algo más complicado, que implica cambios en varias tablas, y que debemos proteger toda la operación mediante una transacción.

Transacción

update CUENTAS

set … etc …

Mover(+100)

Tenemos que implementar una operación llamada Transferir, que saque dinero de una cuenta y lo ingrese en otra. En el más puro estilo orientado a objetos, supondremos que la operación se implementa como un método de la misma clase Cuenta; el co-mentario sobre la pureza es sarcástico, porque veremos que la pureza, en estos casos, es contraproducente.

public Decimal Transferir(Cuenta destino, Decimal importe) { … }

Como somos programadores que hemos sido educados en la noble doctrina del reciclaje de código, queremos reutilizar el método Mover para implementar Transferir:

// …

this.Mover(-importe);

destino.Mover(importe);

// …

Debemos proteger también estas dos operaciones atómicas con una transacción, pero Mover ya inicia una transacción por su cuenta:

Transacción

update CUENTAS

set … etc …

Mover(+100)

Transacción

update CUENTAS

set … etc …

Mover(-100)

Transacción

Vamos a desviarnos un poco de nuestro asunto, para mirar lo que sucede en el servi-dor SQL cuando se ejecuta cada uno de estos métodos. Suponemos que Mover ha sido programado originalmente en forma óptima: obviando la transacción, llama a un procedimiento almacenado, o en el peor de los casos, ejecuta un lote de sentencias equivalente. Sin embargo, la ejecución de Transferir dista mucho de ser óptima: o

Page 424: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

424 La Cara Oculta de C#

realiza dos llamadas a procedimientos, en dos viajes de ida y vuelta al servidor SQL, o peor aún, ejecuta dos lotes de consultas en dos pasos independientes.

Estos dos ejemplos nos dan la pista sobre un problema importante que se produce en los sistemas con un gran número de usuarios:

• La agregación, en sistemas grandes, es un camino seguro hacia el desastre.

Esta es una conclusión muy triste, porque echa por tierra todas las promesas de la modularización. Estoy seguro de que usted no lo aceptará sin lucha. De hecho, ima-gino que en este momento, todo su sistema inmunológico mental está combatiendo esta perniciosa idea parásita, así que veamos los argumentos en contra.

Cualquier perro viejo en esto de la programación orientada a objetos dirá, para em-pezar, que el error está en permitir que las clases como Cuenta sean las responsables del manejo de transacciones... o incluso del envío de instrucciones al servidor, te-niendo en cuenta el segundo problema presentado en esta sección. Podríamos inter-poner, ¡recuerde esta palabra!, un gestor de transacciones entre Cuenta y la base de datos. El código de Mover seguiría iniciando y confirmando una transacción, aunque esta vez lo haría por medio del gestor interpuesto. Esta pieza de software llevaría la cuenta del número de veces que se ha intentado iniciar una transacción. Si la opera-ción raíz fuese Mover, el intento de inicio de transacción realizado desde este método tendría éxito. Si por el contrario, la raíz fuese Transferir, la transacción solicitada por Mover sería ignorada. Algo parecido, pero a la inversa, sucedería con las solicitudes de fin de transacción.

NO

TA

De hecho, esto ya lo hace el propio Transact SQL, al llevar un contador del nivel de ani-damiento en las transacciones. Piense, sin embargo, que otros sistemas de bases de datos no cuentan con esta posibilidad. Además, ¿qué sucedería si las cuentas que parti-cipan en la transferencia se almacenan en distintas bases de datos o servidores?

La idea no es mala, pero ¿se da cuenta que tendremos que intercalar llamadas al ges-tor de transacciones, y luego también al gestor de lotes de instrucciones, a diestra y siniestra? Nuestra situación es muy parecida a la de los señores de la Xerox con sus combinaciones ineficientes de transformaciones de imágenes. ¿Por qué no poner una pizca de Programación Orientada a Aspectos en nuestras vidas?

Transacciones declarativas

Efectivamente, la solución está en el uso de atributos declarativos, que indiquen al entorno de ejecución el comportamiento que esperamos de nuestras clases con res-pecto a las transacciones. El entorno de ejecución, como debe imaginar, es COM+. Para explicar cómo funcionan estos atributos, es mejor que desarrollemos un ejem-plo sencillo.

Vamos a crear un nuevo proyecto que compilaremos como una DLL. Si quiere utili-zar Visual Studio, seleccione un proyecto de tipo Biblioteca de clases. A continuación

Page 425: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios de componentes 425

tendremos que añadir una referencia a un ensamblado externo, en la ventana del Ex-plorador de Soluciones. Para crear componentes que aprovechen los servicios de COM+, debemos crear clases derivadas de ServicedComponent, definida en el espacio de nombres System.EnterpriseServices. Este espacio está implementado dentro de un ensamblado que no se enlaza por omisión con los proyectos de Visual Studio. Tene-mos que pulsar el botón derecho del ratón en el nodo Referencias del árbol del proyecto, en el Explorador de Soluciones, y ejecutar el comando Agregar referencia:

Voy a ahorrarle, de momento, la implementación real de los métodos de la clase. Este es el esqueleto de la clase que deberá crear:

using System;

using System.Data;

using System.EnterpriseServices;

[Transaction(TransactionOption.Required)]

public class AccountManager: ServicedComponent

{

// …

public struct Cuenta {

// …

}

[AutoComplete]

public Decimal Mover(Cuenta cuenta, Decimal importe) {

return 0;

}

[AutoComplete]

public Decimal Transferir(

Cuenta destino, Cuenta origen, Decimal importe)

{

Decimal saldo = Mover(origen, -importe);

Mover(origen, +importe);

return saldo;

}

}

Page 426: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

426 La Cara Oculta de C#

La parte que nos interesa es la declaración de la clase:

[Transaction(TransactionOption.Required)]

public class AccountManager: ServicedComponent { … }

Hemos cumplido la promesa de hacer de nuestra clase un descendiente de Serviced-Component, pero lo más llamativo es el atributo Transaction con el que hemos decorado la clase. Estos son los valores del tipo enumerativo TransactionOption:

Valor Significado RequiresNew Cada vez que se llama un método remoto de esta clase, COM+ crea

una nueva transacción Required Igual que RequiresNew, pero si ya existe una transacción, la reutiliza Supported Si hay una transacción, se aprovecha; si no, da lo mismo NotSupported El componente se ejecuta en un contexto sin transacciones Disabled Ignora las transacciones declarativas

Si es la primera vez que tropieza con las transacciones declarativas, es muy probable que se sienta desconcertado. ¿Cómo es eso de que “COM+ inicia una transacción”? Hasta el momento, las transacciones han sido iniciadas por usted, explícitamente, ya sea ejecutando la instrucción begin transaction en Transact SQL, o llamando al método correspondiente de la clase de conexión. Vamos a precisar un poco más la implementación del método Mover para comprender qué es lo que está sucediendo:

[AutoComplete]

public Decimal Mover(Cuenta cuenta, Decimal importe)

{

SqlCommand cmd = CrearComando();

cmd.CommandText = InstruccionMovimiento(cuenta, importe);

return (Decimal) cmd.ExecuteScalar();

}

El método utiliza otro método auxiliar para crear un objeto de comando y su corres-pondiente conexión. No se puede deducir del código anterior, pero le aseguro que CrearComando no llama, en ningún momento, al método BeginTransaction de la cone-xión; no he incluido el código fuente de este método para simplificar los detalles relacionados con la cadena de conexión y la implementación del tipo Cuenta. Una vez que tenemos el objeto de comando, le asociamos una instrucción generada al vuelo, y la ejecutamos. ¿Cuándo y cómo se inicia la transacción, y cuándo termina?

La respuesta es sorprendente: COM+, como el Gran Hermano de Orwell, vigila las llamadas al proveedor de datos de SQL Server... con la inestimable colaboración del propio proveedor. Si usted se ha encaprichado en utilizar el servidor de MyDirtySql, un hipotético servidor de bases de datos que ha decidido ignorar la existencia de COM+, el truco de las transacciones declarativas no funcionará.

Más importante que la vigilancia sobre el proveedor de datos, en la que el sujeto espiado colabora gustosamente, es la técnica que utiliza COM+ para espiar las accio-nes de las instancias de AccountManager. Cuando alguien pide una instancia de una clase registrada dentro de COM+, éste crea también una serie de objetos auxiliares que interceptan las llamadas a los métodos de la instancia verdadera, con el objetivo

Page 427: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios de componentes 427

de ejecutar operaciones especiales antes y después de cada llamada. Gracias a estos interceptores, que no son visibles para el usuario de la clase, es que COM+ puede llevar a cabo el entramado de aspectos.

Volvamos a Mover, Transferir y al control de transacciones mediante atributos. Hemos visto que cuando el usuario ejecuta Mover directamente, COM+ debe iniciar una transacción antes, y si todo sale bien, debe confirmarla después. Sin embargo, si el usuario o cliente remoto ejecuta Transferir, la transacción debe estar ligada a este mé-todo, y la ejecución de Mover no debe crear o confirmar transacciones. ¿Cuál es la diferencia entre estas dos formas de ejecutar Mover? La diferencia la marca la frontera entre la instancia y el cliente de la instancia:

Cliente AccountManager

Mover

frontera

Transferir

Mover

Mover

Recuerde que la clase AccountManager ha especificado Required en su atributo de tran-sacción. Cuando el cliente llama directamente a Mover, la llamada atraviesa la frontera. El interceptor creado por COM+ detecta que no hay una transacción activa, y en ese momento la inicia; luego explicaré cuándo termina. En el caso de la llamada a Transfe-rir, nuevamente se cruza la frontera, COM+ detecta que no hay transacción e inicia una. Cuando Transferir llama internamente a Mover, COM+ detecta que ya hay una transacción activa. La opción Required permite reutilizar una transacción existente, por lo que COM+ permite que Mover se ejecute dentro del contexto de la transacción iniciada por Transferir. Si por el contrario, la opción transaccional fuese RequiresNew, COM+ crearía una transacción independiente para ejecutar cualquier instrucción SQL lanzada por la llamada anidada a Mover.

Votaciones y confirmación automática

Todavía tenemos que ver cuándo se confirman o anulan estas transacciones automá-ticas. En nuestro ejemplo hemos seguido el camino más fácil: asumir que, si no se producen excepciones durante la ejecución de un método, debemos confirmar. Ese es el significado del atributo AutoComplete, que se aplica al método.

[AutoComplete]

public Decimal Mover(Cuenta cuenta, Decimal importe) { … }

Page 428: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

428 La Cara Oculta de C#

Un método marcado con este atributo se comportará como si su implementación fuese la siguiente:

public Decimal Mover(Cuenta cuenta, Decimal importe)

{

try {

// … el cuerpo original …

ContextUtil.SetComplete();

}

catch {

ContextUtil.SetAbort();

throw;

}

}

El identificador ContextUtil no se refiere a una propiedad heredada de Serviced-Component, sino que es el nombre de una clase, y tanto SetComplete como SetAbort son métodos estáticos de la misma. A pesar de lo que puede parecer, estos métodos no confirman o abortan directamente la transacción: deben interpretarse como votos a favor o en contra de la confirmación final. El sistema de votos es necesario para tener en cuenta los fallos que se pueden producir dentro de llamadas anidadas a mé-todos transaccionales, como las llamadas internas a Mover dentro de Transferir.

¿Y qué sucede si dejamos que termine un método transaccional, llamado directa-mente por un cliente externo, sin votar a favor o en contra? En ese caso, la transac-ción se abortará automáticamente después de un tiempo de espera. El tiempo de es-pera por omisión se puede configurar desde la consola de los Servicios de Compo-nentes, pero se puede establecer mediante código utilizando una de las propiedades del atributo Transaction de la clase:

[Transaction(TransactionOption.Required, Timeout=30,

Isolation=TransactionIsolationLevel.Serializable)]

El tiempo de espera se expresa en segundos. Este ejemplo muestra también el uso de la propiedad Isolation, para establecer el nivel de aislamiento de una transacción decla-rativa. El nivel de aislamiento por omisión es el nivel serializable: el superior y reco-mendable para casi todos los escenarios.

Registro de clases dentro de COM+

Para poder utilizar una clase que, como AccountManager, desciende de ServicedCompo-nent, debemos registrarla en el catálogo de COM+, y para ello tenemos dos posibili-dades:

1 Registro dinámico: en determinadas circunstancias, la aplicación cliente puede provocar el registro de una clase al crear una instancia de ella por primera vez. Esta técnica sólo debe emplearse, por comodidad, durante el desarrollo.

2 Registro manual: el ensamblado que contiene la clase a registrar debe firmarse y registrarse dentro de la caché global de componentes de la máquina (GAC). Luego, la clase deberá registrarse en el catálogo de COM+.

Page 429: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios de componentes 429

Comencemos por lo más sencillo, por el registro dinámico. Identifiquemos en primer lugar cuál es el tipo de cliente más común para una clase COM+ creada en la plata-forma .NET. Sabemos que COM+ ofrece, entre otros servicios, el acceso remoto a sus clases. Sin embargo, cuando se mezclan COM+ y .NET, lo más habitual es que .NET ofrezca la parte de acceso remoto, ya sea mediante servicios Web o mediante .NET Remoting, y que la clase COM+ sea utilizada por un cliente .NET local, para aprovechar los servicios de COM+.

La otra posibilidad es muy parecida: una aplicación escrita para ASP.NET que utiliza como objeto de negocio un componente local de servicio. En estos casos, para que se produzca el registro dinámico, la DLL que contiene la clase de componente COM+ debe ubicarse en el mismo directorio que la aplicación cliente, o en caso contrario, en un subdirectorio de nombre bin. La primera referencia a la clase tardará un poco en ejecutarse, porque en ese momento se añadirá la clase al catálogo.

De todos modos, la técnica aconsejable es registrar la clase manualmente. El primer paso necesario es registrar el ensamblado en la caché global de .NET, y para ello es obligatorio firmarlo. Por lo tanto, primero debemos generar una clave, con la ayuda de la herramienta sn.exe (strong name), que debemos ejecutar con parámetros similares a los siguientes:

sn -k miclave.snk

Al terminar este comando, tendrá un fichero llamado miclave.snk, que contendrá la clave necesaria para firmar su biblioteca de clases. Copie esta clave en el directorio principal del proyecto en que reside AccountManager. Suponiendo que ha creado este proyecto con la ayuda de Visual Studio, abra el fichero AssemblyInfo.cs, y localice y modifique los siguientes atributos:

[assembly: AssemblyDelaySign(false)]

[assembly: AssemblyKeyFile("..\\..\\miclave.snk")]

[assembly: AssemblyKeyName("")]

Observe que la ruta hace referencia dos veces al directorio padre. Esto se debe a que los proyectos compilados con Visual Studio dejan el fichero compilado en el subdi-rectorio bin\debug, dentro de la carpeta del proyecto. Si ha creado y compilado el proyecto a mano, es probable que no tenga que usar una ruta relativa como la que he mostrado.

Luego de firmar la DLL, utilice el asistente de configuración de .NET, que se instala en el menú de Herramientas Administrativas, o cualquier otro método equivalente, para

Page 430: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

430 La Cara Oculta de C#

registrar la DLL en la caché global. Sólo tiene que indicar al asistente dónde reside actualmente la DLL, y éste se encargará de todo lo necesario. Finalmente, para regis-trar la clase dentro del catálogo de COM+, debe usar regsvcs.exe, otra herramienta de línea de comandos:

regsvcs /appname:EjemploNET ESClass.dll

El parámetro appname indica el nombre de la aplicación COM+ donde se instalarán los nuevos componentes. Si no indicamos este nombre, regsvcs generará uno propio. También podemos fijar el nombre de la aplicación COM+ desde el propio ensam-blado, mediante el atributo ApplicationName:

[assembly:ApplicationName("EjemploNET")]

NO

TA

Si intenta registrar nuestro ensamblado de ejemplo, recibirá una serie de advertencias. La más interesante de ellas nos avisa que AccountManager no implementa ningún tipo de interfaz. La consecuencia de esta omisión es que la clase no puede ser utilizada por clientes que no estén escritos en .NET. Nada grave en este caso, por cierto.

Activación por demanda

Activación por demanda es mi traducción para Just-in-time activation (JITA), otro de los servicios prestados por COM+. La traducción oficial por el sistema operativo, sin embargo, es activación puntual:

Para pedir esta opción a nivel de clase, se utiliza el atributo JustInTimeActivation:

[JustInTimeActivation]

class AccountManager: ServicedComponent { … }

Page 431: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios de componentes 431

La opción también se activa automáticamente si la clase admite transacciones de-clarativas, es decir, si el atributo Transaction ha sido configurado con una de las opcio-nes Required, RequiresNew o Supported.

La activación por demanda, por sí sola, no es muy interesante cuando los clientes de la clase de servicios son clientes .NET. El objetivo de esta opción es forzar el uso de objetos sin estado en el servidor. Como comprenderá, .NET Remoting ya soporta esta forma de trabajo con su modelo single call, y los servicios Web funcionan por omisión como objetos sin estado interno.

Cuando un objeto ha sido configurado para su activación por demanda, el intercep-tor de llamadas creado por COM+ vigila el valor de una propiedad de su contexto llamada DeactivateOnReturn, y que está disponible como propiedad estática en la clase ContextUtil. Si el programador ha asignado true a esta propiedad, el objeto se “des-activará” al finalizar el método. La desactivación consiste en que el objeto es des-truido, pero el proxy que el cliente maneja sigue siendo válido, y piensa que su objeto sigue existiendo. Cuando el cliente realice la siguiente llamada mediante su proxy, COM+ volverá a crear el objeto remoto. Cualquier variable de estado que contenga el objeto se pierde durante la desactivación, por supuesto.

Para marcar un objeto de forma que sea desactivado al terminar la ejecución del método activo, podemos asignar directamente true en la propiedad estática antes mencionada:

ContextUtil.DeactivateOnReturn = true;

Sin embargo, es más frecuente que utilicemos el método SetComplete de la misma clase ContextUtil para este propósito. Y también podemos usar el atributo Auto-Complete, como mostramos en el ejemplo de transacciones declarativas.

Para que el programador tenga algún control sobre el ciclo de vida de un objeto con activación por demanda, COM+ llama indirectamente a dos métodos virtuales pro-tegidos de la clase COM: Activate y Deactivate. El programador puede redefinir estos métodos:

[JustInTimeActivation]

class AccountManager: ServicedComponent {

protected internal override void Activate() { … }

protected internal override void Deactivate() { … }

}

De los dos métodos mencionados, el más útil es Deactivate, porque puede usarse como si se tratase de un destructor determinista, como en los tiempos anteriores a la recolección de basura. Si la clase establece conexiones con una base de datos, pode-mos cerrar esas conexiones durante la desactivación. En cambio, no es buena idea establecer la conexión en la versión redefinida de Activate, a no ser que todos los métodos de la clase utilizaran la conexión.

Page 432: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

432 La Cara Oculta de C#

La caché de objetos

Además de que .NET ofrece técnicas mejores para implementar objetos sin estado en un servidor remoto, la activación por demanda, por sí sola, puede provocar más problemas de eficiencia que los que resuelve. No obstante, cuando se combina con otra opción que veremos en esta sección, la desactivación cobra su verdadero sen-tido. Esta otra opción o servicio se conoce en inglés como object pooling. El sistema operativo la traduce como agrupación de objetos, pero prefiero llamarla caché de objetos. El nombre del atributo asociado es ObjectPooling.

La caché de objetos añade un detalle importante al ciclo de vida de un objeto con activación por demanda: al desactivarse el objeto, en vez de ser destruido estúpida-mente, puede pasar a residir en la caché o depósito de COM+. La decisión sobre reciclar o destruir la toma COM+ llamando al método virtual protegido CanBePooled, de la clase ServicedComponent. Lo más sencillo es redefinirlo para que siempre devuelva true:

protected internal override bool CanBePooled()

{

return true;

}

Las restantes características de la caché de objetos se establecen mediante el propio atributo que la activa:

[ObjectPooling(

MinPoolSize=0, MaxPoolSize=16, CreationTimeout=30000)]

[JustInTimeActivation]

La propiedad MinPoolSize indica el número mínimo de objetos que debe contener la caché asociada a la clase; puede que nos interese tener siempre algunos objetos a mano antes de que el primer cliente intente conectarse al servidor. MaxPoolSize, como sugiere su nombre, es el mayor número de objetos admitidos por la caché. Esto no quiere decir que la caché deba crecer inicialmente hasta este valor. Si las peticiones de clientes llegan suficientemente espaciadas entre sí, puede incluso que nos baste con una o dos instancias de la clase para servir todas esas llamadas. Sin embargo, una vez alcanzada la capacidad máxima, si todos los objetos están en uso y llega una nueva petición, el cliente correspondiente deberá esperar a que se libere alguno de los objetos ya creados. Si el tiempo de espera supera el valor en milisegun-dos establecido por CreationTimeout, el cliente recibirá una excepción.

Page 433: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servicios de componentes 433

Hay un par de motivos importantes para utilizar una caché de objetos:

1 Cuando los objetos utilizan recursos que cuesta trabajo obtener. Respecto a la técnica de desactivación pura, la caché evita que tengamos que pedir esos recur-sos para cada método que se ejecute. Respecto a no usar ningún tipo de desacti-vación, la ventaja es que podemos hacer creer a los clientes que disponen per-manentemente de un objeto remoto dedicado, cuando en realidad, están com-partiéndolo con otros clientes.

2 El otro motivo que nos puede impulsar a configurar una caché de objetos es la posibilidad de establecer límites al número de instancias creadas en el servidor. Suponga que su servidor de base de datos sólo admite diez conexiones. Puede implementar la conexión al servidor dentro de una clase COM+ configurada para usar una caché con un máximo de diez instancias. La alternativa sería utili-zar semáforos o algún otro recurso similar, y la cantidad de código a escribir se-ría muy superior.

NO

TA

Recuerde, en cualquier caso, que todos los proveedores de datos de ADO.NET imple-mentan algún tipo de caché de conexiones. Si sólo necesita controlar el número de cone-xiones a la base de datos, es preferible que se limite a configurar adecuadamente la caché de conexiones, con los parámetros de la cadena de conexión.

Page 434: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

26

Servidores de capa intermedia

A CONOCEMOS ALGUNAS TÉCNICAS BÁSICAS del acceso remoto y de la pro-gramación para bases de datos en la plataforma .NET. Pero, como siempre

sucede, el mérito está en la forma en que usted las combine. En este capítulo estudia-remos técnicas y trucos para la creación de servidores de capa intermedia con Visual C#, combinando los conocimientos adquiridos en los capítulos anteriores.

Un servidor en tiempo de diseño

¿Repasamos los requisitos de un servidor de capa intermedia para ADO.NET? Su-ponga que necesitamos trabajar con una tabla de productos. El servidor debe ofrecer al menos dos métodos remotos relacionados con esta tabla:

1 Un método para obtener un conjunto de datos con registros de productos. 2 Un método que reciba un conjunto de datos con registros de productos, para

aplicar los cambios, y que devuelva el estado actualizado del conjunto de datos, junto con información sobre los errores que se hayan detectado.

La reacción intuitiva del programador es implementar estos dos métodos haciendo uso del mismo adaptador de datos... bueno, eso es pedir demasiado: puede que sea técnicamente imposible. Veamos qué sucede en algunos ejemplos típicos de servido-res de capa intermedia:

• Si el servidor está implementado con .NET Remoting, con un objeto singleton, tendremos realmente un mismo proveedor para leer productos y para actuali-zarlos. Aunque también es cierto que todos los clientes utilizarán ese mismo adaptador.

• Si el servidor utiliza el modelo single call, cada llamada utilizará un componente adaptador nuevo, creado expresamente para esa llamada.

• En el caso de un servicio Web, o de un servidor que delegue las peticiones a componentes registrados en COM+, el cuadro es aún más complicado, porque en ocasiones se reciclarán componentes, pero también se podrían crear nuevas instancias, si fuese necesario.

En cualquier caso, es muy probable que terminemos añadiendo el componente de conexión y los adaptadores necesarios sobre la superficie de diseño del servicio:

Y

Page 435: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores de capa intermedia 435

¿Qué pasará cuando tengamos que añadir un adaptador para recuperar registros de pedidos? Si suelta un adaptador sobre esta misma superficie de diseño, estará for-zando la creación e inicialización de este componente cada vez que se instancie la clase del servicio; no olvide que, además del adaptador, la clase tendrá que configurar los tres objetos de comandos, cada uno con decenas de parámetros. Sin embargo, cada llamada sólo utilizará uno o dos de estos adaptadores.

Si las instancias de la clase se reciclan, podemos asumir el coste adicional de crear componente innecesarios, porque confiamos que en algún momento recibamos una llamada que utilice los adaptadores ociosos. Pero, incluso cuando se reciclan instan-cias, un objeto tiene un tiempo máximo de vida ociosa en la caché de objetos. Trans-currido ese tiempo, el objeto se saca de la caché y se destruye. Si hay componentes no utilizados al llegar a ese momento, habremos pagado su coste de creación sin sa-carles partido. Es cierto que, en los casos de poca actividad, el sistema no tendrá más carga por crear unos pocos objetos innecesarios. El problema está en el tiempo de latencia: el intervalo mínimo necesario para responder a una petición remota au-menta de todos modos.

Una posible solución sería crear los adaptadores, comandos y parámetros en tiempo de ejecución, en el momento en que fuesen necesarios. Después podrían permanecer vivos, o podríamos dejarlos morir a manos del recolector de basura. Pero necesita-ríamos demasiadas líneas de código, y tendríamos que consultar repetidamente el esquema relacional para averiguar los nombres de columnas, sus longitudes en el caso de las columnas de tipo varchar, si admiten nulos o no, etc. He dicho que sería una posible solución, no una buena solución.

Encapsulamiento del acceso a SQL

La solución que prefiero viene de la mano de la Programación Orientada a Objetos: vamos a encapsular la creación e inicialización de grupos de adaptadores relacio-nados dentro de clases de componentes. Para crear uno de estos componentes de acceso a SQL, debemos ejecutar el comando de menú Archivo|Agregar nuevo elemento, y debemos elegir el icono Clase de componentes en el diálogo que aparece como respuesta:

Page 436: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

436 La Cara Oculta de C#

Hemos creado una clase de componentes, en vez de una clase a secas, para aprove-char la superficie de diseño que ofrece Visual Studio en estos casos. Sobre esta super-ficie podemos añadir conexiones, comandos, adaptadores, y cualquier otro tipo de componentes que estimemos oportuno. En nuestro ejemplo, he movido los compo-nentes que antes estaban sobre la superficie de diseño del servicio Web al diseñador de la nueva clase de componentes:

Ahora podemos aprovechar la existencia de la clase Productos para encapsular las lec-turas y grabaciones en su interior:

// Este método pertenece a la clase Productos

public System.Data.DataSet Leer()

{

sqlConn.Open();

try {

System.Data.DataSet ds = new System.Data.DataSet("Productos");

daCategories.Fill(ds, "Categories");

daProducts.Fill(ds, "Products");

return ds;

}

finally {

sqlConn.Close();

}

}

Observe que estoy devolviendo un conjunto de datos genérico, en vez de un con-junto de datos con tipos. El único objetivo ha sido simplificar el ejemplo, evitando tener que manejar otra clase adicional. Pero podríamos crear la clase del conjunto de datos con tipos igual que hemos hecho hasta el momento, y aprovechar entonces para añadir relaciones y configurar columnas.

Ahora regresamos a la clase del servicio Web, y añadimos una propiedad Productos:

private Productos productos = null;

protected Productos Productos {

get {

if (productos == null)

productos = new Productos();

return productos;

}

}

La instancia se crea cuando accedemos por primera vez a esta propiedad, la primera vez que se ejecute el siguiente método:

[WebMethod]

public System.Data.DataSet GetProductos()

{

Page 437: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores de capa intermedia 437

return Productos.Leer();

}

Si por el contrario, quisiéramos liberar el componente de productos después de cada llamada, podríamos utilizar esta otra variante de código:

[WebMethod]

public System.Data.DataSet GetProductos()

{

using (Productos p = new Productos())

return p.Leer();

}

En ese caso, no necesitaríamos la propiedad antes mostrada.

El Servidor Universal de Datos

La técnica que acabamos de explicar mejora el consumo de recursos durante la crea-ción de instancias de la clase remota, además de ayudarnos a organizar el código del servidor en módulos manejables. Si todo el código ejecutado por el servicio o por el servidor remoto residiera en un mismo fichero de código, sólo podría haber un pro-gramador modificando este fichero en cada momento.

La técnica que voy a esbozar ahora va en otra dirección: puede disminuir la cantidad de código que tenemos que escribir, y sirve para eliminar muchos problemas de mantenimiento. Volvamos al ejemplo del conjunto de datos de productos: si ha prestado atención, habrá notado que además de la tabla de productos, este conjunto de datos incluye la tabla de categorías. Para crear el conjunto de datos hemos utili-zado dos adaptadores, uno para cada tabla.

¿Es esto obligatorio? Ni mucho menos: ha sido un acto reflejo. Es cierto que nece-sitaremos dos adaptadores para grabar los cambios, pero no para leer registros. Para la lectura, es más eficiente utilizar un único adaptador que contenga un comando de selección basado en dos consultas:

select * from dbo.Categories

select * from dbo.Products

En el capítulo 19 ya vimos cómo manejar estos lotes de consultas con un adaptador, configurando la propiedad TableMappings de este componente. Eso sí, seguiremos necesitando dos adaptadores para la grabación.

¿Se da cuenta de que nada en .NET nos obliga a grabar con los mismos adaptadores usados para la lectura? Desarrollemos la idea hasta su últimas consecuencias: en rea-lidad, necesitamos un solo adaptador para todas las lecturas. Mejor aún, en vez de tener un método remoto de lectura para cada entidad o grupo de entidades relacionadas, podemos crear un único método remoto “universal” que permita devolver dentro de un conjunto de datos cualquier lista de entidades que le pidamos. Sólo tendríamos que renunciar a enviar conjuntos de datos con tipos como valor de retorno, pero eso es lo de menos: podríamos seguir usando conjuntos con tipos en el lado cliente.

Page 438: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

438 La Cara Oculta de C#

El método podría tener un prototipo similar al siguiente:

[WebMethod(

Description="Servicio Universal de Datos",

TransactionOption=TransactionOption.Required)]

public System.Data.DataSet Retrieve(string entities) { … }

NO

TA

El atributo TransactionOption instruye a ASP.NET para que cree una transacción auto-mática cada vez que llamemos a este método. Así garantizamos la coherencia de los datos recuperados, especialmente si hay más de una tabla en el resultado.

El método debe recibir una cadena de caracteres con una descripción de las entida-des deseadas. Debe analizar esta cadena y generar, a partir de ella, el lote de instruc-ciones que evaluará el adaptador de datos, además de los TableMappings necesarios. Voy a proponerle un formato para las cadenas de descripción, pero usted es libre para definir el que más apropiado le parezca. Le advierto que, para mi propuesta, he elegido el formato más sencillo; como consecuencia, el formato no servirá para todas las posibilidades de configuración de tablas.

La cadena de descripción más sencilla permitirá leer todos los registros de una tabla:

employees

En realidad, employees debe representar un conjunto de entidades y podría ser, indis-tintamente, el nombre de una tabla física o el de una vista. Incluso podría ser un nombre simbólico que deberá traducirse a un objeto físico mediante un diccionario situado en el lado servidor.

También será frecuente que pidamos un subconjunto de los registros de una de nues-tras “entidades”:

employees{EmployeeID=1}

He elegido las llaves como delimitadores para que el servidor de ejemplo pueda identificar las condiciones de filtro sin necesidad de un análisis sintáctico completo. La condición deberá generarse en el lado cliente, pero se evaluará en el servidor.

Podremos pedir también dos o más tablas:

employees orders

En este caso, devolveríamos todos los registros de empleados y de pedidos en dos tablas separadas, sin relación entre ellas. Pero podríamos también pedir que el servi-dor crease la relación, basándose en las restricciones de integridad referencial defini-das en la base de datos:

employees.orders

Mejor aún, podríamos limitar los registros de la tabla maestra y pedir que esa limita-ción se propagase a la tabla dependiente:

employees{EmployeeID=1}.orders

Page 439: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores de capa intermedia 439

A estas alturas usted se estará preguntando por qué no me limito a pasar las instruc-ciones SQL. Es una buena pregunta: lo que pretendo con estas cadenas de descrip-ción es simplificar su generación. En el caso de entidades aisladas, no simplificamos tanto. Una cadena simple que haga mención a una sola entidad como employees, gene-raría una consulta igual de sencilla:

select * from dbo.Employees

En cambio, la traducción de entidades relacionadas es más compleja, sobre todo cuando hay condiciones de filtro. La última cadena de descripción mostrada debería generar las siguientes instrucciones:

select *

from dbo.Employees

where EmployeeID = 1

select *

from dbo.Orders

where OrderID in (

select OrderID

from dbo.Employees

where EmployeeID = 1)

Y hay otra razón mucho más convincente. En todos estos ejemplos asumimos que enviamos al lado cliente el resultado completo de una consulta. En la vida real, en cambio, deberíamos dividir el resultado en grupos de registros, o páginas. En breve veremos que, para implementar un mecanismo eficiente de paginación, tendremos que generar consultas con una estructura complicada. Si añadimos la complejidad provocada por la paginación a la complejidad asociada a las relaciones entre tablas y a la presencia de filtros, comprenderá que es necesario simplificar la generación de consultas en el lado cliente. El uso de entidades simbólicas, combinado con informa-ción semántica sobre sus relaciones mutuas, elimina buena parte de estas complica-ciones.

NO

TA

¿Y cómo obtenemos las restricciones de integridad referencial sin que disminuya el rendi-miento del servidor? Una posibilidad consiste en almacenar esta información en un fi-chero de configuración, quizás en formato XML. O podríamos leer las restricciones di-rectamente desde la base de datos, pero con el propósito de almacenarlas en una caché asociada al proceso.

Por último, debo advertir que la sintaxis de descripción de entidades que he mos-trado es demasiado sencilla para representar algunos tipos de relaciones. Si tuviéra-mos que leer registros de clientes, sus direcciones asociadas y sus pedidos, no basta-rían los recursos sintácticos que hemos presentado. Podríamos utilizar paréntesis para configurar el árbol de relaciones entre tablas:

employees:{EmployeeID = 1}(addresses, orders(details))

Pero seguiríamos teniendo problemas con grafos más generales, que no pueden re-presentarse como árboles. Por ejemplo, ¿cómo traeríamos, en el ejemplo anterior, los registros de productos asociados a los detalles de pedidos recuperados?

Page 440: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

440 La Cara Oculta de C#

Paginación mediante cookies

Uno de los problemas prácticos de los servidores de capa intermedia es la paginación de grandes consultas. Una parte del asunto se resuelve en el lado cliente, evitando que el usuario pida todos los registros de una tabla grande, sin filtro o condición de búsqueda. Incluso con un filtro adecuado, el conjunto de resultados podría ser seguir siendo inmanejable. Para salir del paso, podríamos protegernos con una cláusula top:

select top 100 *

from dbo.Clientes

where Nombre like "María%"

Pero tarde o temprano tendremos que implementar la recuperación de registros por grupos o páginas. Y ese será el momento en que aparecerán los vendedores de aceite de lagarto de la Capadocia. Me explico: todas las clases de adaptadores de datos he-redan una variante del método Fill que permite limitar el número de registros que se copian en un conjunto de datos.

public int Fill(DataSet dataset, int inicio, int max, string tabla);

Con esta versión podemos copiar max registros a partir de la posición inicio. La trampa está en que seguimos evaluando la consulta original, aunque solamente en-viemos un subconjunto del resultado al lado cliente. Esto es útil para disminuir el tráfico en el segmento que une al cliente con la capa intermedia, pero no alivia la carga de trabajo del servidor SQL.

La mejor solución que conozco consiste en ordenar los registros por su clave prima-ria, o por un criterio alternativo que también incluya la clave primaria. Cada vez que el cliente pida un grupo de registros, deberemos recordar cuál fue el último registro recuperado, para continuar la consulta a partir de esa posición. Pongamos por caso que queremos registros de pedidos, en grupos de cuarenta. La consulta que nece-sitamos se parecerá a la siguiente:

select top 40 od.*, cu.CompanyName,

em.FirstName + ' ' + em.LastName as Employee

from Orders od inner join

Customers cu on od.CustomerID = cu.CustomerID inner join

Employees em on od.EmployeeID = em.EmployeeID

where od.OrderID > @orderID

order by od.OrderID

El parámetro @orderID debe contener la clave primaria del último registro del último grupo. Para obtener el primer grupo, basta con asignar cero o cualquier valor nega-tivo en el parámetro. ¿Fácil, verdad? El problema está en cómo obligar al cliente a que recuerde el identificador del último pedido, y a que nos pase ese valor cada vez que pida otro grupo de registros al servidor remoto.

Si el servidor remoto está implementado como un servicio Web alojado dentro de Internet Information Services, podemos utilizar cookies, como en las aplicaciones escritas con ASP.NET, para que sea el servidor quien recuerde el último pedido re-

Page 441: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores de capa intermedia 441

cuperado por cada cliente. Una cookie es un identificador generado aleatoriamente que el servidor asocia a cada cliente, en la respuesta a la primera llamada que éste realiza. El identificador tiene que pasarse de vuelta al servidor en cada llamada poste-rior. En el caso de una página Web, esto se logra gracias a la colaboración del pro-grama explorador; en el caso de un servicio Web, el cliente tendrá que colaborar mediante código explícito, que presentaré más adelante.

Uno de los beneficios de las cookies consiste en que el servidor puede implementar un diccionario de valores para cada cliente. En cada llamada, el servidor utiliza el valor de la cookie para buscar el diccionario de valores asociado a ese cliente, y gracias a ello puede asignar valores que persisten entre llamada y llamada. Si la clase que imple-menta el servicio Web desciende de la clase WebService, el acceso a los valores persis-tentes de cada cliente se logra mediante la propiedad heredada Session:

Session["OrderID"] = 0;

Vamos a crear un servicio Web para demostrar cómo podemos aprovechar las cookies para paginar los resultados de una consulta. La clase base del servicio creado por Visual Studio es precisamente WebService, por lo que podemos crear un método como el siguiente:

[WebMethod(

Description="Lista de pedidos en grupos", EnableSession=true)]

public System.Data.DataSet ListaPedidos(bool reiniciar)

{

int lastOrderID = 0;

if (!reiniciar && Session["OrderID"] != null)

lastOrderID = (int) Session["OrderID"];

dsPedidos.Clear();

daPedidos.SelectCommand.Parameters[0].Value = lastOrderID;

daPedidos.Fill(dsPedidos);

int rowCount = dsPedidos.Tables[0].Rows.Count;

if (rowCount > 0)

lastOrderID = (int) dsPedidos.Tables[0].Rows

[rowCount - 1]["OrderID"];

Session["OrderID"] = lastOrderID;

return dsPedidos;

}

Observe que hemos activado la propiedad EnableSession del atributo WebMethod. Al hacerlo, obligamos al servidor a mantener la estructura de datos con la que imple-menta las variables de sesión. Está claro que esta estructura consume recursos, prin-cipalmente memoria. Por este motivo, el valor por omisión de EnableSession es false, para mejorar el rendimiento del servicio.

La implementación del método es muy simple. Si exigimos explícitamente que co-mencemos por el primer registro, o si la variable de sesión asociada a la cadena Order-ID es un puntero nulo, quiere decir que vamos a partir del primer registro; en caso contrario, extraemos el identificador del último pedido leído mediante la propiedad Session. Asignamos ese valor en el parámetro de la consulta y llenamos el conjunto de datos. Antes de terminar, buscamos el último registro de la tabla de pedidos, extrae-

Page 442: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

442 La Cara Oculta de C#

mos su clave primaria y actualizamos la variable de sesión, para cuando sea recupe-rada durante la siguiente llamada.

Vamos ahora al lado cliente. Primero, declaramos e inicializamos un campo privado dentro del formulario, para que actúe como recipiente de las cookies que envíe el ser-vidor:

private System.Net.CookieContainer cookies =

new System.Net.CookieContainer();

El siguiente método auxiliar es el encargado de pedir el próximo grupo de registros al servidor remoto:

private void NextRecords(bool reiniciar)

{

InfoServer iServer = new InfoServer();

iServer.CookieContainer = cookies;

dsPedidos.Merge(iServer.ListaPedidos(reiniciar));

}

Observe que, cada vez que creamos una instancia del proxy del servicio, debemos asignar nuestra colección de cookies en la propiedad CookieContainer del proxy. Eso es todo lo que hace falta: en el mensaje que envía el cliente al servidor se incluyen las cookies contenidas en nuestro contenedor. Al llegar la respuesta del mensaje, las cookies de la respuesta actualizan el contenido de la colección. Aunque el proxy se convierte en basura reciclable al terminar el método, la colección de cookies sigue estando dis-ponible gracias a la variable privada del formulario que apunta a ella.

El conjunto de datos recibido como respuesta se mezcla con la información que he-mos recuperado en peticiones anteriores:

dsPedidos.Merge(iServer.ListaPedidos(reiniciar));

Si queremos que la aplicación sea a prueba de bombas, deberíamos añadir una ins-trucción para vaciar el conjunto de datos cuando se quiera recuperar el primer con-junto de registros:

if (reiniciar) dsPedidos.Clear();

En nuestro ejemplo, esta precaución no será necesaria, porque el parámetro reiniciar sólo valdrá true cuando creemos el formulario principal:

private void MainForm_Load(object sender, System.EventArgs e)

{

NextRecords(true);

}

private void bnMas_Click(object sender, System.EventArgs e)

{

NextRecords(false);

}

Page 443: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores de capa intermedia 443

Cuando el formulario se inicializa, llamamos a NextRecords pidiendo el primer grupo de registros. Para pedir más registros, ejecutamos NextRecords desde la respuesta al evento Click de un botón.

Si quiere experimentar con este ejemplo, diseñe un mecanismo para saber si quedan más grupos de registros por recuperar desde el servidor. En caso negativo, podrá desactivar el botón correspondiente.

Paginación mediante cabeceras SOAP

A pesar de lo sencillo que resulta utilizar cookies, hay que tener en cuenta los inconve-nientes: aumenta el consumo de memoria en el servidor, no todos los clientes estarán en condiciones de devolver las cookies, y solamente podremos usarlas cuando el pro-tocolo de transporte de SOAP sea HTTP. Además, si está preocupado por la migra-ción a Indigo, en futuras versiones de Windows, sepa que el uso de cookies puede complicar un poco las cosas.

Podemos lograr un efecto parecido al de las cookies utilizando cabeceras SOAP, aun-que a costa de un poco más de trabajo por parte del cliente. Comenzamos las modi-ficaciones en el servidor, añadiendo una clase derivada de SoapHeader y una variable pública de este tipo:

using System.Web.Services.Protocols;

public class InfoPagina: SoapHeader {

public int OrderID = 0;

}

public InfoPagina infoPagina;

El método ListaPedidos debe ser modificado de la siguiente manera:

[WebMethod(Description="Lista de pedidos en grupos")]

[SoapHeader("infoPagina", Direction=SoapHeaderDirection.InOut)]

public System.Data.DataSet ListaPedidos(bool reiniciar)

{

int lastOrderID = 0;

if (infoPagina == null)

infoPagina = new InfoPagina();

if (!reiniciar)

lastOrderID = infoPagina.OrderID;

dsPedidos.Clear();

daPedidos.SelectCommand.Parameters[0].Value = lastOrderID;

daPedidos.Fill(dsPedidos);

int rowCount = dsPedidos.Tables[0].Rows.Count;

if (rowCount > 0)

lastOrderID = (int) dsPedidos.Tables[0].Rows

[rowCount - 1]["OrderID"];

infoPagina.OrderID = lastOrderID;

return dsPedidos;

}

Hemos quitado la propiedad EnableSession del atributo WebMethod, y hemos añadido el atributo SoapHeader. Es importante indicar la dirección de la cabecera, que en este

Page 444: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

444 La Cara Oculta de C#

caso debe viajar en las dos direcciones. Al principio del método comprobamos si nos han pasado la cabecera desde el cliente. En caso negativo, creamos una instancia y la asignamos a la variable correspondiente. El algoritmo no cambia, excepto que al final actualizamos el campo OrderID en la variable que actúa como cabecera SOAP.

En el lado cliente sustituimos el campo cookies por una instancia de InfoPagina:

private InfoPagina infoPagina = null;

Y sólo nos queda modificar el método NextRecords:

private void NextRecords(bool reiniciar)

{

InfoServer iServer = new InfoServer();

iServer.InfoPaginaValue = infoPagina;

dsPedidos.Merge(iServer.ListaPedidos(reiniciar));

infoPagina = iServer.InfoPaginaValue;

}

A diferencia de lo que ocurría con las cookies, debemos copiar la referencia a la cabe-cera después de terminar la llamada remota, porque la instancia devuelta por la lla-mada no es la misma que la instancia pasada al método. Tenga en cuenta que esta copia es necesaria porque creamos un proxy para cada llamada. Otra solución sería trabajar con un mismo proxy para todas las llamadas remotas. En ese caso, ni siquiera necesitaríamos un campo de tipo InfoPagina en el formulario.

Seguridad basada en tokens

La semejanza entre cabeceras SOAP y cookies es más que una coincidencia. En ambas técnicas, hay información que viaja de un lado a otro de la red, con cada llamada. La diferencia está en que con las cookies, el contenido que importa se almacena en el servidor, consumiendo espacio, mientras que la propia cabecera SOAP, que viaja por la red, contiene los valores importantes.

Hay una técnica interesante basada en esta similitud, que se utiliza para aumentar la seguridad en un entorno distribuido. ¿Cómo se puede garantizar que los usuarios que acceden a un servidor de capa intermedia tengan los permisos apropiados? Si esta-mos utilizando Internet Information Services, ya sea con un servicio Web o con .NET Remoting, lo más sensato es utilizar cualquiera de las variantes de autentica-ción soportadas por I.I.S. Por lo tanto, vamos a plantearnos un caso extremo, en el que no podamos utilizar la seguridad de Internet Information Services por un mo-tivo u otro. La técnica que voy a describir utilizará cabeceras SOAP, pero puede adaptarse para ser usada por un servidor .NET Remoting.

La primera idea que nos viene a la mente es obligar al usuario a teclear un identifica-dor y una contraseña antes de permitirle usar cualquier otra funcionalidad del servi-dor remoto, como si se tratase de las viejas aplicaciones cliente/servidor sobre red local. Sin embargo, esta técnica no funciona bien con servidores remotos, por dos razones:

Page 445: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores de capa intermedia 445

1 La interfaz de un servidor remoto está a disposición de cualquiera que tenga las herramientas adecuadas para generar un proxy. Si confiamos en que nadie ejecute el método B sin pasar antes por el método A, simplemente porque nuestra apli-cación cliente así lo hace, podemos llevarnos una desagradable sorpresa si al-guien desarrolla una aplicación pirata que ejecute B directamente.

2 Muy importante: las instancias de una clase remota se reciclan, por lo general. No vale almacenar un bit en la instancia del servidor para indicar si ya se ha rea-lizado la autenticación o no.

La consecuencia de estos hechos es que el cliente debe demostrar quién es en cada llamada. ¿Cómo hacerlo eficientemente? La solución más sencilla consiste en que la función de validación de usuarios devuelva un valor generado aleatoriamente en caso de éxito. El valor debe pertenecer a un dominio lo suficientemente grande como para hacer imposible el uso de la fuerza bruta por parte de un pirata. Por ejemplo:

public long ValidarUsuario(string username, string password) {

// …

}

El tipo long de C# corresponde al tipo System.Int64 del CLR, y representa un entero de 64 bits. ValidarUsuario debe comprobar primero si el usuario puede acceder al sistema, probablemente buscando un registro en una tabla y comprobando la contra-seña asociada. Si falla esta operación, podemos devolver un valor reservado, digamos que un cero. En caso contrario, generamos un valor aleatorio de 64 bits; una alterna-tiva es generar un valor con menos bits, y completar los bits restantes con algún tipo de control de redundancia. Además de devolver este número al cliente como resul-tado de la operación, debemos almacenarlo en alguna estructura que sea global a todas las instancias de la clase remota, quizás en una lista estática de la clase:

private static ArrayList usuarios = new ArrayList();

public long ValidarUsuario(string username, string password)

{

if (TienePermiso(username, password))

{

long token = GenerarValorAleatorio();

usuarios.Add(token);

return token;

}

else

return 0L;

}

Un token es simplemente un comprobante que se le extiende al usuario para que lo presente en las restantes llamadas que realice. Esto es, todos los demás métodos remotos deben tener un parámetro adicional, de tipo long, en el que los clientes deben pasar el token recibido al superar la validación.

Si tenemos muchos métodos remotos, es un poco aburrido tener que incluir el pará-metro adicional en todos ellos. El mantenimiento se complicaría también: piense en lo que ocurriría si decide utilizar cadenas de caracteres en vez de enteros de 64 bits.

Page 446: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

446 La Cara Oculta de C#

Si el servidor está implementado como un servicio Web, la solución más elegante es declarar un tipo de cabecera SOAP que incluya el token del usuario, y marcar todos los métodos remotos con esta cabecera, en la dirección del cliente al servidor. Para .NET Remoting, podríamos enviar automáticamente el token mediante el método estático SetData de la clase CallContext, que es la técnica equivalente a las cabeceras SOAP de los servicios Web.

NO

TA

Aunque existe una clase Random para generar valores aleatorios de propósito general, si quiere generar tokens aleatorios, del tipo que sean, es preferible que utilice un generador diseñado específicamente para criptografía, como el que implementa la clase RNG-CryptoServiceProvider.

Protección de las comunicaciones

Y ya que hemos mencionado el envío de nombres de usuarios y contraseñas, ¿cómo podemos evitar que alguien intercepte nuestros mensajes y consiga así información supuestamente protegida? Tenga en cuenta, para empezar, que un ataque de este tipo es más difícil de organizar. No obstante, si existe la posibilidad de intercepción, está claro que la solución está en cifrar los intercambios de datos. Nuevamente, si el ser-vidor se aloja dentro de Internet Information Services, podemos establecer las cone-xiones remotas a través de SSL (Secure Sockets Layer), un protocolo que intercambia paquetes de datos cifrados.

No obstante, debe saber que SSL es bastante lento, debido a la complejidad de las operaciones que exige un algoritmo de seguridad. La lentitud se debe al uso de un tipo de cifrado conocido como cifrado asimétrico, o de clave pública. Este es uno de los dos tipos principales de algoritmos de cifrado:

1 Cifrado simétrico: Recibe este nombre porque se utiliza una misma clave para cifrar y descifrar. También se conoce como cifrado de clave secreta: como el emisor y el receptor deben usar la misma clave, se asume que ambos se han puesto de acuerdo antes de comenzar a usar el algoritmo. Los algoritmos simé-tricos suelen ser razonablemente eficientes.

2 Cifrado asimétrico: Se utilizan dos claves diferentes, una privada y una pública. La clave pública depende funcionalmente de la clave privada, pero no puede de-ducirse la clave privada a partir de la clave pública. La clave privada puede usarse tanto para cifrar como para descifrar. La clave pública, por el contrario, sólo puede cifrar, pero no descifrar. Los algoritmos de este tipo son más lentos que los de cifrado simétrico.

El cifrado asimétrico se utiliza cuando las partes no pueden acordar una clave de antemano para el cifrado simétrico. El truco está en que, si una clave pública cae en manos equivocadas, no se producirá desastre alguno, porque el pirata sólo podrá cifrar con ella, no descifrar. Supongamos que yo le pido que me envíe su número de tarjeta de crédito. Debo generar un par de claves privada y pública, guardar bien la privada y enviarle la pública. Usted utilizará la clave pública para cifrar el número de su tarjeta y enviarme el resultado. Si alguien ha logrado interceptar la clave pública no

Page 447: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores de capa intermedia 447

pasará nada, porque con ella no podrá descifrar el número de tarjeta protegido. Cuando el mensaje cifrado llega a mí, puedo usar mi clave privada para recuperar el número de tarjeta. Gracias a esta útil peculiaridad, SSL utiliza un algoritmo de cifrado asimétrico para intercambiar información.

NO

TA

El cifrado asimétrico se utiliza frecuentemente para intercambiar una clave secreta apta para el cifrado simétrico. En cuanto ambas partes tienen la misma clave, los restantes intercambios utilizan este otro cifrado, buscando velocidad.

Cifrado asimétrico

Comprobemos cómo funciona el cifrado asimétrico. Cree un nuevo proyecto de aplicación, y añada estas dos cláusulas al principio del fichero de código:

using System.Security.Cryptography;

using System.Text;

El primer espacio de nombres contiene las clases que vamos a utilizar para cifrar y descifrar cadenas. Necesitaremos el segundo espacio de nombres para tener acceso a la clase UnicodeEncoding, que nos permitirá convertir cadenas de caracteres en vectores de bytes. Dentro de la clase del formulario, debemos declarar tres variables privadas:

private UnicodeEncoding encoding = null;

private RSACryptoServiceProvider clavePrivada = null;

private RSACryptoServiceProvider clavePublica = null;

Las tres variables se instancian en el constructor del formulario:

public MainForm()

{

InitializeComponent();

CspParameters parametros = new CspParameters();

parametros.Flags = CspProviderFlags.UseMachineKeyStore;

clavePrivada = new RSACryptoServiceProvider(parametros);

clavePublica = new RSACryptoServiceProvider();

clavePublica.FromXmlString(clavePrivada.ToXmlString(false));

encoding = new UnicodeEncoding();

}

Hemos creado una clave privada a partir de la información del ordenador donde se ejecuta este código. La clave es simplemente una instancia de una de las clases pro-veedoras de servicios de cifrado. En nuestro ejemplo, vamos a utilizar el algoritmo RSA, por lo que la clase necesaria es RSACryptoServiceProvider.

CspParameters parametros = new CspParameters();

parametros.Flags = CspProviderFlags.UseMachineKeyStore;

clavePrivada = new RSACryptoServiceProvider(parametros);

El siguiente paso ha sido extraer una clave pública de esta clave privada. Vamos a aprovechar la existencia de un método, FromXMLString, que permite restaurar una clave a partir de su descripción XML, y de otro método, ToXmlString, que devuelve la representación XML de una clave existente. Este segundo método recibe un pará-metro para que indiquemos si queremos incluir la parte privada de la clave o no.

Page 448: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

448 La Cara Oculta de C#

clavePublica = new RSACryptoServiceProvider();

clavePublica.FromXmlString(clavePrivada.ToXmlString(false));

En el caso de un servidor de capa intermedia remoto, la clave privada sería generada una sola vez, durante la inicialización del servidor remoto. Cuando un cliente se co-necta por primera vez al servidor, debe pedirle la parte pública de la clave, en for-mato XML. Con esta representación XML, el cliente reconstruiría una clave pública que correspondería a la clave privada almacenada en el servidor.

NO

TA

Es importante comprender que, al transmitir la clave pública como un texto XML, corre-mos el riesgo de que alguien intercepte el mensaje y averigüe el valor de la clave. Pero lo que cuenta es que la clave pública sólo sirve para cifrar, no para descifrar.

Añada ahora un botón al formulario y teclee las siguientes instrucciones:

byte[] mensaje;

string s = "Este es un documento muuuuy secreto";

mensaje = clavePublica.Encrypt(encoding.GetBytes(s), false);

s = encoding.GetString(mensaje);

MessageBox.Show(s);

s = encoding.GetString(clavePrivada.Decrypt(mensaje, false));

MessageBox.Show(s);

Los algoritmos de cifrado asimétrico deben trabajar sobre vectores de bytes. En con-secuencia, para cifrar una cadena debemos convertirla antes en un vector de bytes, con la ayuda de la instancia de la clase UnicodeEncoding. En este primer ejemplo, ci-framos la cadena con la clave pública, y la desciframos con la clave privada. Está claro que al final recuperaremos la cadena original. Sin embargo, un pequeño cambio en el algoritmo provocará una excepción:

// ¡Operación condenada al fracaso!

s = encoding.GetString(clavePublica.Decrypt(mensaje, false));

No podemos descifrar el vector de bytes con la clave pública. El propio algoritmo detectará el problema y lanzará una excepción:

Como mencioné al final de la sección anterior, podemos mezclar esta técnica de cifrado con la técnica ya conocida de validación por tokens. Un servidor de esta clase tendría al menos estos dos métodos en su interfaz remota:

public string ClavePublica();

public long ValidarUsuario(string username, string password);

Page 449: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores de capa intermedia 449

El cliente que desee utilizar el servidor remoto deberá llamar, en primer lugar, al método remoto ClavePublica, que le devolverá una clave pública serializada en for-mato XML. La clave se utilizaría para cifrar el nombre de usuario y la contraseña que se pasan a ValidarUsuario, para evitar que algún oyente inesperado se hiciese con estos datos. La implementación de ValidarUsuario en el servidor tendría que descifrar los dos parámetros con la clave privada, antes de proceder a la validación en sí.

Cifrado simétrico

Nos queda por ver cómo funciona un algoritmo típico de cifrado simétrico, o de clave secreta. Estos algoritmos necesitan, aparte de una clave, un vector de inicializa-ción o IV, según sus siglas en inglés. El vector de inicialización contiene bytes aleato-rios que se mezclan con el mensaje para hacer más difícil los ataques por fuerza bruta.

La plataforma .NET ofrece de serie algunos algoritmos simétricos, de los cuales vamos a escoger el de Rijndael como ejemplo. Como el cifrado simétrico, al menos en .NET, nos obliga a trabajar con flujos de datos o streams, vamos a dividir el algo-ritmo en métodos auxiliares más simples. Por ejemplo, necesitaremos funciones para convertir cadenas en vectores de bytes, utilizando Unicode, y a la inversa:

public byte[] String2Bytes(string s)

{

return new UnicodeEncoding().GetBytes(s);

}

public string Bytes2String(byte[] b)

{

return new UnicodeEncoding().GetString(b);

}

Vamos a encapsular también la creación de la clase que implementa el algoritmo dentro de una propiedad:

private RijndaelManaged _rijn = null;

public RijndaelManaged Rijn {

get {

if (_rijn == null) {

_rijn = new RijndaelManaged();

_rijn.GenerateKey();

_rijn.GenerateIV();

}

return _rijn;

}

}

Observe que estoy creando una clave y un vector de inicialización al crear la instancia de RijndaelManaged. En el caso de un intercambio cifrado con un servidor de capa intermedia, la clave se generará posiblemente en el lado cliente; recuerde que, si he-mos utilizado antes el cifrado asimétrico, el cliente tendrá una clave pública que sólo le sirve para cifrar. Por lo tanto, será el cliente el encargado de generar la clave secreta “simétrica” para luego enviarla cifrada al servidor.

Page 450: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

450 La Cara Oculta de C#

Ya podemos implementar la función EncryptString, para devolver la versión cifrada de una cadena de caracteres:

public string EncryptString(string input)

{

byte[] bytes = String2Bytes(input);

MemoryStream ms = new MemoryStream();

CryptoStream cs = new CryptoStream(ms,

Rijn.CreateEncryptor(Rijn.Key, Rijn.IV),

CryptoStreamMode.Write);

cs.Write(bytes, 0, bytes.Length);

cs.FlushFinalBlock();

return Bytes2String(ms.ToArray());

}

Primero convertimos la cadena en un vector de bytes. Construimos un flujo de datos en memoria y un flujo de datos especial, de la clase CryptoStream, a partir de un ob-jeto de cifrado generado por la instancia de RijndaelManaged y de las claves obtenidas durante la inicialización. Para realizar el cifrado, debemos escribir el vector de bytes sobre el flujo de cifrado... que en realidad estará actuando como intermediario y pa-sando el contenido final al flujo en memoria. Finalmente, utilizamos el método ToArray del flujo en memoria y la función auxiliar Bytes2String para obtener la cadena de caracteres cifrada.

La función que descifra cadenas es muy parecida:

public string DecrypString(string input)

{

MemoryStream ms = new MemoryStream(String2Bytes(input));

CryptoStream cs = new CryptoStream(ms,

Rijn.CreateDecryptor(Rijn.Key, Rijn.IV),

CryptoStreamMode.Read);

byte[] bytes = new byte[ms.Length];

cs.Read(bytes, 0, bytes.Length);

return Bytes2String(bytes);

}

Esta vez la CryptoStream debe leer del flujo en memoria, por lo que al crear éste lo inicializamos con la versión en Unicode de la cadena de entrada.

Para probar los métodos anteriores debe tener en cuenta que, cada vez que ejecute la aplicación estará creando una clave diferente. No intente, por lo tanto, cifrar un fi-chero, reiniciar la aplicación y descifrar el fichero, porque está claro que no va a fun-cionar. Puede poner, en cambio, tres cuadros de edición en un formulario e inter-ceptar el evento Click de un botón para ejecutar estas instrucciones:

textBox2.Text = EncryptString(textBox1.Text);

textBox3.Text = DecrypString(textBox2.Text);

Y ahora me va a permitir un sermón (salte a la próxima sección si no está de humor para moralinas). No hay nada más dañino que un aficionado intentando proteger una aplicación mediante criptografía. Yo mismo no sé nada de criptografía, perdonando la

Page 451: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores de capa intermedia 451

doble negación. Lo único que conozco son algunos peligros que hay que evitar a toda costa:

1 Un buen algoritmo de cifrado no es necesariamente un algoritmo complicado. Hay quien multiplica por un número primo el primer carácter de una cadena, luego calcula el seno hiperbólico del segundo carácter y lo resta del resultado anterior, luego... Lo malo es que este algoritmo queda almacenado en el código compilado de su aplicación, y está al alcance de cualquier pirata. Peor aún, casi siempre lo que queda en su aplicación ¡es el algoritmo inverso! Un pirata sola-mente tiene que copiar el código nativo a un contexto manipulable y usarlo di-rectamente para invertir el cifrado. Incluso si el algoritmo inventado es bueno a primera vista, puede tener defectos ocultos: quizás respeta la frecuencia de ocu-rrencia de caracteres, o transforma dos entradas diferentes en una misma salida, o el conjunto imagen de la función está excesivamente reducido.

2 Respecto al problema anterior: la tendencia moderna es utilizar algoritmos muy bien conocidos, con propiedades bien estudiadas, al alcance de cualquiera. El truco está en utilizar claves difíciles de adivinar, mientras más grandes mejor, para evitar ataques de fuerza bruta.

3 ¡No guarde claves dentro del código fuente! Esto lo veo con frecuencia: alguien utiliza un potente algoritmo de cifrado, pero utiliza una clave que guarda en la propia aplicación. Una variante de este disparate: alguien se preocupa por crear usuarios en un servidor SQL. Asigna permisos y crea perfiles de usuario. Pero un contratista que no tiene idea de Informática, le exige que almacene en una tabla los intentos de conexión fallidos. Esto exige que la aplicación abra una conexión adicional con la base de datos... ¡y obliga a almacenar una clave dentro de la apli-cación, que cualquier pirata medianamente serio puede encontrar! Se trata de una historia real, por cierto.

4 No olvide que los algoritmos de cifrado son sólo un eslabón de la cadena de se-guridad. Si usted cifra un mensaje con una clave almacenada por el sistema ope-rativo, y sin embargo, para evitar molestas llamadas de soporte a su departa-mento, concede privilegios de administración a personas que no deberían tener-los, sepa que la seguridad de su sistema está seriamente amenazada.

5 Por último, evite consejos de aficionados como yo.

Colas de mensajes

Imagine mentalmente un gráfico con la carga de trabajo de un servidor, no importa si es del servidor SQL o del de la capa intermedia. Habrá picos abruptos y aburridos valles de inactividad. Una cola de mensajes es un recurso que ayuda a “planchar” este gráfico, repartiendo la carga a lo largo del eje temporal. Luego veremos que hay más aplicaciones para las colas, aunque en mi opinión, son menos importantes.

La frase “cola de mensajes” se utiliza en más de un contexto, tanto en inglés como en español. Aquí estamos hablando de un componente del sistema operativo cono-cido como Message Queuing, o por las siglas MSMQ. El componente debe instalarse tanto en el servidor que almacenará las colas de mensajes como en los clientes que

Page 452: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

452 La Cara Oculta de C#

las enviarán. Una vez instalado un servidor, podemos administrar sus colas de men-sajes mediante una extensión de la Consola de Administración del sistema operativo:

Un mensaje es cualquier objeto serializable que tengamos a bien enviar, con su debido envoltorio para almacenar el remitente, la hora de envío, su prioridad y otros datos por el estilo. Hablamos de cola, además, porque por omisión, los mensajes se recupe-ran en el mismo orden en que se reciben, aunque al haber una prioridad asociada a los mensajes, deberíamos hablar de una cola con prioridades más que de la cola FIFO clásica.

¿Qué hay de interesante en una cola de mensajes? Piense en una aplicación de ventas por Internet. Cada vez que se acepta un pedido, el servidor debe enviar un mensaje de correo electrónico a la persona que ha realizado la compra. Enviar correo electró-nico es muy sencillo y rápido, pero como quiere que sea, exige algo de tiempo, así que ¿por qué no dejarlo para más adelante? Tradicionalmente, estos problemas se han resuelto añadiendo un registro a una tabla de la base de datos (consumiendo recursos del servidor SQL) y programando un proceso paralelo (más esfuerzo de programación) para que examine la tabla, recupere los registros (más caña sobre el servidor SQL) y envíe finalmente los mensajes.

La alternativa es depositar el aviso de compra en una cola, que puede ser local o pertenecer a otro servidor, dentro de la misma red; como no utilizamos la base de datos, ya estaríamos aliviando al servidor SQL. Nuevamente necesitaríamos un pro-ceso a la medida que leyese la cola y enviase los mensajes de correo, pero esta vez se trataría de un proceso más sencillo de programar. Y hay una ventaja adicional: la cola de mensajes acepta cualquier tipo de objeto serializable. ¿Cuánto nos costaría lograr que una tabla SQL aceptase mensajes polimórficos?

Por último, hay otro escenario en el que las colas de mensajes pueden ser muy útiles. Imagine nuevamente una aplicación de facturación, en concreto, la capa de presenta-ción instalada en un portátil o PDA que estará desconectado de la red la mayor parte del tiempo. Por este motivo, la capa de presentación no intenta grabar los pedidos nuevos en un servidor remoto, ni a través de un servicio Web que estará disponible muy poco tiempo cada día. Por el contrario, cada vez que crea un pedido, lo envía a una cola de mensajes remota... que tampoco estará conectada en ese momento. Aquí,

Page 453: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores de capa intermedia 453

sin embargo, entra en juego una característica de los clientes de colas: si la cola re-mota no está disponible, el sistema almacenará los mensajes en una cola local. Cuando nos conectemos a la “nave nodriza”, los mensajes serán reenviados a su destino original, y el servidor podrá crear los registros de pedidos a partir de ellos.

Esta solución presenta algunas dificultades. Aunque parezca algo sin mayor trascen-dencia, hace falta instalar los componentes de mensajería del sistema operativo en estos clientes. Si se trata de equipos portátiles, no tendremos mayor dificultad, pero puede ser un obstáculo importante para un PDA. Lo más importante es que al enviar los pedidos a una cola, perderemos la retroalimentación inmediata que nos puede ofrecer una conexión estable con el servidor. Por ejemplo, no sabremos cuál será el identificador de pedido definitivo. Nada insalvable, por cierto, pero deberá convencer usted a su contratista de la conveniencia de esta forma de trabajo.

Clases de mensajería en .NET

Las clases para trabajar con colas de mensajes se encuentran en el espacio de nom-bres System.Messaging. Tenemos, en primer lugar, una serie de métodos estáticos en la clase MessageQueue para administrar colas de mensajes:

public static bool string Exists(string ruta);

public static MessageQueue Create(string ruta);

public static void Delete(string ruta);

public static MessageQueue[] GetPublicQueuesByMachine(

string maquina);

public static MessageQueue[] GetPrivateQueuesByMachine(

string maquina);

He mostrado sólo dos de los métodos de enumeración de colas disponibles, pero hay unos cuantos más. Los métodos restantes son fáciles de explicar. Por ejemplo, para crear una cola privada en el ordenador local comprobando primero si no existe, de-bemos ejecutar estas instrucciones:

MessageQueue mq;

if (MessageQueue.Exists(@".\private$\compras"))

mq = new MessageQueue(@".\private$\compras");

else

mq = MessageQueue.Create(@".\private$\compras");

Si tiene usted experiencia con sistemas concurrentes, se sentirá tan incómodo como yo con el fragmento anterior: puede ocurrir que alguien cree la cola después de fallar Exists, pero antes de que Create se pudiese ejecutar. Además, las operaciones como Exists y Create requieren bastante tiempo de ejecución. Mi consejo: utilice estas ins-

Page 454: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

454 La Cara Oculta de C#

trucciones durante la instalación o configuración del sistema, si la cola va a utilizarse por varios usuarios, o si se trata de una cola privada local, a la que accederá un solo proceso por vez.

Veamos ahora la cadena que hemos utilizado para referirnos a la cola:

@".\private$\compras"

El carácter @ inicial nos avisa que no habrá caracteres de escape dentro de la cons-tante de cadena, y que no necesitamos repetir la barra inclinada inversa. El punto al inicio de la ruta indica que se trata de una cola local, mientras que private$ indica que es una cola privada. Finalmente, compras es el nombre de la cola. Para referirnos a una cola pública remota podríamos utilizar una cadena como ésta:

@"NombreServidor\NombreCola"

Voy a mostrar un ejemplo sobre cómo enviar mensajes basados en clases definidas por nosotros mismos. Nuestra clase será la siguiente:

[Serializable]

public class Compra

{

public string Cliente = null;

public DateTime Fecha = DateTime.MinValue;

public Decimal Importe = 0;

public Compra() {}

public Compra(string cliente, DateTime fecha, Decimal importe)

{

Cliente = cliente;

Fecha = fecha;

Importe = importe;

}

}

En primer lugar, la clase debe ser serializable, ya sea por estar directamente decorada con el correspondiente atributo, o por descender de una clase serializable. Otro re-quisito es que todas las propiedades y campos públicos deben ser de lectura y es-critura; además, la clase debe ofrecer un constructor sin parámetros. Estos requisitos se deben a que, cuando se lee un objeto desde una cola, el sistema operativo debe crear una instancia de la clase sin usar parámetros, y luego debe asignar cada una de las variables o propiedades públicas a partir del contenido del mensaje.

Enviar un mensaje a una cola es muy sencillo:

protected void EnviarCompra(string cola,

Compra compra, string etiqueta)

{

using (MessageQueue mq = new MessageQueue(cola))

mq.Send(compra, etiqueta);

}

Hemos creado una instancia local de MessageQueue para enviar el mensaje con su método Send. Hemos elegido la versión de Send que recibe un objeto para el cuerpo del mensaje y un nombre descriptivo. Este nombre descriptivo aparecerá en la con-

Page 455: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Servidores de capa intermedia 455

sola de administración de Windows. Si busca las propiedades de un mensaje enviado con el método mostrado, verá una ventana como la siguiente:

El objeto de la clase Compras ha sido serializado en formato XML, que no es preci-samente un ejemplo de ahorro de espacio. Si quisiéramos serializar en formato bina-rio, podríamos asignar explícitamente la propiedad Formatter del objeto de cola:

mq.Formatter = new BinaryMessageFormatter();

Incluso en este sencillo mensaje, el tamaño ha pasado de 254 bytes a 198. No es que me preocupe demasiado el espacio ocupado por el mensaje en el almacenamiento de la cola, sino el ancho de banda que consumirá si debe ser transmitido por la red.

Para recibir mensajes necesitaremos crear obligatoriamente un objeto formateador, e indicar las clases admitidas por éste.

protected Compra RecibirCompra(string cola)

{

System.Messaging.Message m;

using (MessageQueue mq = new MessageQueue(cola)) {

mq.Formatter =

new XmlMessageFormatter(new Type[]{ typeof(Compra) });

m = mq.Receive();

}

return m.Body as Compra;

}

La llamada a Receive es síncrona: el proceso se bloquea hasta que haya un mensaje para recuperar en la cola. Pero hay una variante que nos permite indicar un tiempo de espera máximo:

public Message Receive(TimeSpan tiempo);

Si no se recupera un mensaje en el tiempo indicado, se genera una excepción de la clase MessageQueueException. Receive siempre saca el mensaje encontrado de la cola, pero existe también un método para recuperar el mensaje sin eliminarlo, con las de-bidas versiones para espera finita e infinita:

Page 456: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

456 La Cara Oculta de C#

public Message Peek();

public Message Peek(TimeSpan tiempo);

NO

TA

En realidad, el método Receive tiene muchas más versiones, debido a la posibilidad de trabajar con colas de mensajes transaccionales. Hay muchas otras opciones para el manejo de colas: solicitudes de reconocimiento, colas de diario, enrutamiento...

Correo electrónico

En uno de los ejemplos relacionados con las colas de mensajes, mencioné la posibili-dad de enviar correo electrónico como respuesta a la creación de un pedido. Es muy sencillo enviar mensajes de correo en .NET, por lo que no nos llevará demasiado explicar cómo. Para empezar, tenemos que utilizar el siguiente espacio de nombres:

using System.Web.Mail;

Si el compilador no reconoce este espacio, pruebe a añadir System.Web.dll en el nodo de referencias del proyecto.

Los mensajes de correo electrónico se representan mediante la clase MailMessage, que contiene todas las propiedades que puede imaginar para un mensaje de correo, inclu-yendo la posibilidad de adjuntar ficheros. Sin embargo, la clase encargada de enviar los mensajes es SmtpMail... que curiosamente, sólo publica un método estático, Send, y una propiedad estática, SmtpServer: ¡un solo servidor SMTP, para todas las instancias de esta clase!

NO

TA

Esto se explica por el escenario para el que se diseñaron estas clases. Si no asignamos nada en la propiedad SmtpMail.SmtpServer, el método Send asumirá que vamos a enviar el mensaje por medio del servicio SMTP local que puede instalarse en Windows 2000 y Windows 2003. Si no es éste el caso, debe asignar en SmtpServer el nombre de un ser-vidor SMTP remoto para el que tenga permiso de uso.

Finalmente, estas son las instrucciones necesarias para enviar un mensaje con un fichero adjunto:

MailMessage msg = new MailMessage();

msg.To = "[email protected]";

msg.From = "[email protected]";

msg.Subject = "Prueba System.Web.Mail";

msg.Body = "Hola, Fulano";

msg.Attachments.Add(new MailAttachment(nombreFichero));

SmtpMail.SmtpServer = "smtp.servidor.com";

SmtpMail.Send(msg);

Page 457: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

¿Y...?

E HA ACABADO EL LIBRO, Y TOCA despedirse, pero prometo no ponerme dema-siado solemne. Aunque estoy bastante cansado, siento ganas de seguir escri-

biendo. Es un buen síntoma: ¡porque ésta es “sólo” la versión 1.1 de la plataforma .NET! Y queda mucho por hacer: artículos, cursos, aplicaciones, componentes... El contrato de Fausto otorgaba a Mefistófeles el derecho a llevarse su alma inmortal cuando el doctor exclamase: ¡detente instante! Este instante no es malo, pero siento impaciencia por ver qué hay más adelante.

Si quiere seguir leyendo sobre .NET, esté atento a mi página:

www.marteens.com

Entre mis planes está terminar un curso a distancia sobre los temas de este libro. El curso debe incluir, además, técnicas para acelerar el desarrollo de aplicaciones para bases de datos con Windows Forms: herencia de formularios, componentes a la me-dida, diccionarios de datos, internacionalización... Y en paralelo, deben aparecer las primeras entregas de capítulos de Intuitive C#, un libro gratuito en formato electró-nico, sobre programación orientada a objetos, desarrollo de componentes y aplica-ciones interactivas.

... son ahora las 2:28 de la madrugada. Hora de irse a la cama. Espero soñar con los ojos de Michelle, pero me temo que hoy toca La Biblioteca de Todas las Respuestas. No importa; tampoco es mal sueño. ¡Ah!, y si mañana al encender el motor del coche ve una inquieta gallina de Jericó correteando por el aparcamiento, no se frote los ojos ni se ponga nervioso. Salúdela de mi parte y sonría, porque es un hombre afortu-nado. No todos los días se puede echar un vistazo tras el espeso velo de esa ilusión que llamamos Realidad.

S

Page 458: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada
Page 459: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

INDICE ALFABÉTICO

A

Abrazo mortal · 121

AcceptChanges · 191, 207, 217, 227

DataRow · 344

DataSet · 345

AcceptRejectRule · 207

Acciones referenciales · 69, 207

Activator · 368, 371

Add

DataRowCollection · 193

AddNew

DataView · 219

AllowDBNull · 176

AllowNavigation · 205

AllowNew · 219

Anotaciones · 314

Application

StartupPath · 385

ApplicationNameAttribute · 430

AppSettingsReader · 262

Asincronía

clientes servicios Web · 416

delegados · 413

Aspectos

aspect weaver · 420

interceptores · 426

Programación Orientada a · 418

Atributos

ApplicationName · 430

AutoComplete · 427

Bindable · 239

Flags · 253

JustInTimeActivation · 430

ObjectPooling · 432

SoapHeader · 443

Transaction · 426, 428, 431

TransactionOption · 438

WebMethod · 400

WebService · 413

AutoComplete · 427

AutoIncrementStep · 344

AutoLog · 384

B

Bases de datos

grupos de ficheros · 40

master · 41

particiones · 39

SQLDMO · 270

BeginEdit

DataRow · 194

DataRowView · 219

BeginInvoke · 414

BeginTransaction · 267, 289

Bindable · 239

BindingContext · 228

BindingManagerBase · 228

Count · 230

Position · 230

PositionChanged · 232

Blobs · 287

Bloqueos

abrazo mortal · 121

fallos · 357

marcas de tiempo · 333

optimistas · 330, 357

pesimistas · 328

boxing · 213, 276, 277, 282

C

Cache

clase · 409

Cadenas de conexión

connection pooling · 263

OLE DB · 255

Oracle · 256

CallContext · 446

CancelEdit

DataRow · 194

DataRowView · 220

catch · 274

check · 62

Cifrado · 446

asimétrico · 447

simétrico · 449

Clausura transitiva · 101, 112, 131

Page 460: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

460 La Cara Oculta de C#

Claves

alternativas · 64

externas · 67

primarias · 63, 213, 304

Close

IDbConnection · 253

Columnas calculadas

ADO.NET · 176

Transact SQL · 72

ColumnChanged · 196, 200, 318

ColumnChanging · 196, 318

ColumnMapping · 186

ComboBox · 238, 247

DataSource · 239

DisplayMember · 239

SelectedValue · 239

ValueMember · 239

CommandBehavior · 280

SequentialAccess · 288

SingleResult · 282

SingleRow · 281

CommandText

SqlCommand · 273

Component · 199, 435

Configure

RemotingConfiguration · 378, 385

Conjuntos de datos · 172

con tipo · 306

Connection

SqlCommand · 273

ConnectionState · 253

ConnectionString

IDbConnection · 258

OleDbConnection · 258

SqlConnection · 261

constraint · 65

Constraint · 173

Constraints

DataTable · 181

Contains

DataRowCollection · 214

ContextUtil · 428

DeactivateOnReturn · 431

ContinueUpdateOnError · 355

Controles

de usuario · 231

cookies · 408, 440

create

database · 39

default · 53

function · 127, 129

procedure · 103, 273

rule · 52

table · 57

trigger · 150

CTE · 132

CurrencyManager · 228

PositionChanged · 232

Cursor · 117

@@fetch_status · 120

actualizable · 123

apertura y cierre · 118

bidireccional · 125

current of · 124

global o local · 122

variables de · 125

D

Data binding · Véase Enlace a datos

DataColumn · 173, 181

AllowDBNull · 176

AutoIncrementStep · 344

columnas calculadas · 176, 209

ColumnMapping · 186

DefaultValue · 193

Expression · 176

MaxLength · 176

DataGrid · 177

AllowNavigation · 205

DataMember · 295

SetDataBinding · 180, 205, 240, 324

DataGridColumnStyle · 243

DataMember · 295

DataRelation · 173, 202

DataRow · 173, 180, 192

AcceptChanges · 191, 344

BeginEdit · 194

CancelEdit · 194

Delete · 193

EndEdit · 194

GetChildRows · 208

GetColumnError · 198

HasErrors · 198

indizador · 180

RejectChanges · 191

RowError · 198, 355

RowState · 190, 194, 195, 218, 346

SetColumnError · 198

DataRowCollection

Add · 193

Contains · 214

Find · 213

Remove · 194

DataRowVersion · 192, 208

DataRowView · 218

BeginEdit · 219

Page 461: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Indice alfabético 461

CancelEdit · 220

Delete · 220

EndEdit · 219

DataSet · 172, 370

AcceptChanges · 345

DataSetName · 174, 185

EnforceConstraints · 304

GetChanges · 345

GetXml · 183

GetXmlSchema · 183

HasChanges · 346

HasErrors · 198

Merge · 359

ReadXml · 184

serialización · 370

WriteXml · 183

DataSetName · 174, 185

DataSource

ComboBox · 239

DataTable · 172

Constraints · 181

eventos · 196, 199

NewRow · 192

PrimaryKey · 181

Rows · 180, 213

Select · 214, 216, 352

DataView · 216

AddNew · 219

AllowNew · 219

Find · 218

ListChanged · 220

RowStateFilter · 220

DataViewRowState · 216

DBConcurrencyException · 357

DeactivateOnReturn · 431

Deadlock · Véase Abrazo mortal

DefaultSourceTableName · 295, 299

DefaultValue · 193

Delegados · 413, 414

Delete

DataRow · 193

DataRowView · 220

DeleteCommand · 322, 324

deleted · 150

DeleteRule · 207

DiffGram · 184, 190, 405

DisplayMember

ComboBox · 239

distinct · 77, 82, 87

E

EnableSession

WebMethodAttribute · 441

Encuentro

externo · 94

interno · 87

natural · 83

EndEdit

DataRow · 194

DataRowView · 219

EndInvoke · 414

EnforceConstraints · 304

Enlace a datos · 180

combos · 238

contexto de enlace · 228

formato de columnas · 236

fuentes de datos · 222

navegación · 229

sincronización · 249

tiempo de diseño · 224

Errores · 145

@@error · 145

Excepciones

try/finally · 279

ExecuteNonQuery · 273, 287

ExecuteReader · 18, 274, 275, 280

ExecuteScalar · 282

exists · 90

Expression

DataColumn · 176

F

FieldCount

SqlDataReader · 277

filegroup · 40

Fill · 295, 296, 302, 440

FillSchema · 297

Filtros · 214

Find

DataRowCollection · 213

DataView · 218

Flags · 253

ForeignKeyConstraint · 173, 207

Form

BindingContext · 228

Format

DataGridTextBoxColumn · 244

Funciones

coalesce · 51

de tablas en línea · 129

escalares · 127

filtros · 215

isnull · 51, 177, 215

no deterministas · 128

Page 462: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

462 La Cara Oculta de C#

nullif · 50

scope_identity · 60, 341

G

GetBytes

SqlDataReader · 288

GetChanges

DataSet · 345

GetChildRows · 208

GetColumnError · 198

GetObject · 368, 371

GetOleDbSchemaTable · 268

GetSchemaTable · 277

GetXml

DataSet · 183

group by · 81, 83

H

HasChanges · 346

HasErrors · 198

having · 83

HttpChannel · 366, 368

I

IAsyncResult · 414

IDataReader · 274

IDbCommand · 253, 272

ExecuteScalar · 282

Transaction · 290

UpdatedRowSource · 342

IDbConnection · 252

BeginTransaction · 267

Close · 253

Open · 253

State · 253

IDbDataAdapter · 292

IDbTransaction · 253, 267

Identidades · Véase identity

Identificadores

delimitados · 46

Transact SQL · 45

identity · 58, 113

Access · 354

scope_identity · 60

IDisposable · 280

ILease · 383

InfoMessage · 266

InitializeComponent · 178

InitializeLifetimeService

MarshalByRefObject · 382

inner join · 87

Inserción · 192

InsertCommand · 322

inserted · 150

Installer · 385

InstallUtil.exe · 386

Integridad referencial

acciones referenciales · 69

declarativa · 67

encuentros · 85

índices · 71

simulación · 152

valores nulos · 70

Internet Information Services · 366

IsDBNull

SqlDataReader · 277

Isolation

TransactionAttribute · 428

ISponsor · 383

ISupportInitialize · 232

Iteración · 180

J

Jericó

gallina marsupial · 71

gallinas de · 175, 202, 231, 350

JustInTimeActivation · 430

L

lifetime

atributo · 382

like · 116, 215

ListChanged · 220

M

MailMessage · 456

Marshal · 372

MarshalByRefObject · 366

InitializeLifetimeService · 382

MaxLength

DataColumn · 176

Merge · 359

MessageQueue · 453

MissingSchemaAction · 295

MSMQ · 451

Page 463: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Indice alfabético 463

N

Nepotismo · 221

NewRow · 192

NextResult · 281

Niveles de aislamiento · 137

nocount · 157

Null · 49

O

ObjectPoolingAttribute · 432

OdbcConnection · 253

OleDbCommand · 284

OleDbConnection · 253, 258

ConnectionString · 258

constructor · 258

GetOleDbSchemaTable · 268

OleDbTransaction · 267

Open

IDbConnection · 253

Oracle

parámetros de consultas · 284

OracleConnection · 254

order by · 78

outer join · Véase Encuentro externo

P

ParameterDirection · 286

Particiones · 39

Persistencia · 182

PositionChanged · 232

primary key · 63

PrimaryKey · 181

Procedimientos almacenados · 100

del sistema · 104

parámetros · 102

valor de retorno · 106

with encryption · 103

Propiedades

dinámicas · 261

R

ReadXml

DataSet · 184

ReadXmlSchema · 185

Recursividad · 112, 132

RegisterActivatedServiceType · 373

RegisterWellKnownServiceType · 366

Reglas · 52

RejectChanges · 191, 207

Relaciones · 201

Remoting · 363

activación cliente · 372

activación servidor · 365, 371

CallContext · 446

canales · 366, 377

ficheros de configuración · 378

Lease Manager · 381

leasing · 381

proxies · 368

sponsors · 382

tiempo de vida · 381

RemotingConfiguration

Configure · 378, 385

RemotingServices

Marshal · 372

Remove

DataRowCollection · 194

Restricciones

a nivel de filas · 62

claves alternativas · 64

claves primarias · 63

con nombres · 65

integridad referencial · 67

RijndaelManaged · 449

RNGCryptoServiceProvider · 446

RowChanged · 196, 199

RowChanging · 196, 318

RowDeleted · 196, 199

RowDeleting · 196

RowError · 198, 355

RowFilter · 218

Rows · 180, 213

RowState · 190, 194, 195, 218, 346

RowStateFilter · 220

RowUpdated · 344

RowUpdating · 331, 335, 336, 343

RSACryptoServiceProvider · 447

S

scope_identity · 60, 341

Seguridad integrada

caché de conexiones · 264

cadenas de conexión · 256

select · 75

agrupación · 81

cláusula top · 79

encuentro externo · 94

encuentro natural · 85

hints · 74

Page 464: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

464 La Cara Oculta de C#

ordenación · 78

recursividad · 132

selección singular · 89

subconsultas correlacionadas · 91

Select

DataTable · 214, 216, 352

SelectCommand · 298

SelectedValue

ComboBox · 239

ServiceAccount

enumerativo · 385

ServiceBase · 384

AutoLog · 384

EventLog · 384

ServicedComponent

Activate · 431

Deactivate · 431

ServiceProcessInstaller · 385

Servicios

aplicaciones de · 383

instalación · 385

Servicios Web

ASP.NET · 399

cabeceras SOAP · 410

caché · 408

descripción · 393

descubrimiento · 393

ejecución asíncrona · 416

métodos unidireccionales · 417

proxies · 398

sesiones · 408

SOAP · 393

Session

WebService · 441

SetAbort · 428

SetColumnError · 198

SetComplete · 428, 431

SetDataBinding · 180, 205, 240, 324

SmtpMail · 456

SmtpServer · 456

SOAP · 393

cabeceras · 395, 410

SoapDocumentMethod · 417

SoapHeader

atributo · 411, 443

clase · 410, 443

Sort

DataView · 218

sp_addtype · 51

sp_bindefault · 53

sp_bindrule · 52

sp_droptype · 52

sp_reset_connection · 265

sql_variant · 48

SqlCommand · 273

CommandText · 273, 285

CommandType · 285

Connection · 273

ExecuteNonQuery · 273, 287

ExecuteReader · 18, 274, 275, 280

ExecuteScalar · 282

procedimientos almacenados · 284

Transaction · 290

UpdatedRowSource · 342

SqlCommandBuilder · 336

SqlConnection · 253

ConnectionString · 261

SqlDataAdapter · 320

ContinueUpdateOnError · 355

DeleteCommand · 324

Fill · 295, 296, 302

MissingSchemaAction · 295

RowUpdated · 344

RowUpdating · 331, 335, 336, 343

SelectCommand · 298

TableMappings · 297, 299

Update · 344

SqlDataReader · 274, 275

FieldCount · 277

GetBytes · 288

GetSchemaTable · 277

IsDBNull · 277

NextResult · 281

Read · 18, 275

SQLDMO · 269

SqlHelper · 291

SqlTransaction · 267, 273, 290

SSL · 446

StartupPath · 385

State

IDbConnection · 253

StringBuilder · 296

T

Tablas

organización física · 54

SQLDMO · 271

temporales · 61, 112

TableMappings · 297, 299

TcpChannel · 377

textimage_on · 57

throw · 274

Tiempo de vida

remoting · 381

timestamp · 48, 333

Transacciones

Page 465: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

Indice alfabético 465

@@trancount · 144

aislamiento · 137

anidadas · 143

atomicidad · 134

declarativas · 424

explícitas · 266, 289

serializables · 142

Transact SQL

begin transaction · 135

comentarios · 47

commit · 135

create database · 39

create function · 127, 129

create procedure · 103, 273

create table · 57

create trigger · 150

cursores · 117

estructura lexical · 45

flujo de control · 107

guiones · 44

nocount · 157

print · 103, 144, 266

reglas · 52

rollback · 136, 147

tipos de datos · 47

TransactionAttribute · 426, 428, 431

TransactionOption

atributo · 438

Triggers

anidados · 154

instead of · 155

recursivos · 154

U

UDDI · 393, 396

UnicodeEncoding · 449

unique · 64, 214

UniqueConstraint · 173, 182

UpdateCommand · 322

UpdatedRowSource · 342

UpdateRowSource · 342

UpdateRule · 207

UserControl · 231

using · 279

Usuarios · 35

identificación · 35

perfiles · 16, 17, 36

seguridad integrada · 36

V

Valores nulos · 49

ValueMember

ComboBox · 239

Variables

@@connections · 128

@@error · 145

@@fetch_status · 120

@@identity · 59, 340, 354

@@rowcount · 111, 151, 156

@@trancount · 144

de cursor · 125

Vistas · 155

W

WaitHandle · 415

WebMethod · 400

BufferResponse · 409

CacheDuration · 408

constructor de la clase · 407

Description · 407

EnableSession · 408, 441

WebService

atributo · 413

Context · 409

Session · 441

where · 76

with encryption · 103

WriteEntry

EventLog · 384

WriteLine · 114

WriteXml

DataSet · 183

WriteXmlSchema · 185

WSDL · 393

X

XML · 309

Schema · 183, 309, 347

Page 466: Para Maite, por lograr que oiga Música cuando sólo hay Silencio. Ian · 2019-07-14 · nes de las que soy responsable en una conocida revista, leo y evalúo bastantes libros cada

La Cara Oculta de C# Copyright © 2003, Ian Marteens

Prohibida la reproducción total o parcial de esta obra, por cualquier medio, sin autorización escrita del autor.

Madrid, España, 2003