Utilizando columnas computadas

5 comentarios

En este artículo ya habíamos visto lo que son las columnas computadas y para que sirven.

https://firebird21.wordpress.com/2013/06/23/columnas-computadas/

Ahora la pregunta es: ¿vale la pena usarlas?

La respuesta es un rotundo sí y las razones son las siguientes:

  1. Las columnas computadas (casi) no ocupan espacio en la Base de Datos
  2. En tus aplicaciones escribes menos
  3. En tus aplicaciones siempre obtendrás el mismo valor
  4. Si necesitas cambiar la fórmula, lo haces en un solo lugar

1. Las columnas computadas (casi) no ocupan espacio en la Base de Datos

¿Por qué? porque solamente se guarda la definición de la columna computada, no sus valores. Por ejemplo, si tu columna computada realiza alguna operación matemática entonces podrá ser de tipo BIGINT y ocupará 8 bytes. Nada más. Si la tabla tiene 1.000.000 de filas no se usarán 8.000.000 de bytes sino solamente 8 bytes, los de la definición.

Si quieres tener una columna para guardar en ella el total de la venta de un producto (cantidad * precio) y la defines como una columna normal entonces estarás haciendo dos cosas mal: primero, tu tabla no estará normalizada porque estarás guardando un valor que puede ser calculado y segundo, estarás desperdiciando espacio en el disco duro. En cambio, si la defines como columna computada la tabla continuará normalizada y además no usarás espacio del disco duro.

 2. En tus aplicaciones escribes menos

Si a tu columna computada la llamas, por ejemplo, TOTAL_VENTAS, entonces en tus stored procedures, en tus triggers, y en tu lenguaje de programación solamente necesitarás escribir TOTAL_VENTAS, no tendrás que estar multiplicando CANTIDAD * PRECIO en cada uno de esos lugares, así que ahorrarás escritura. Cuanto más larga sea la fórmula mayor cantidad de caracteres serán los que te ahorrarás de escribir.

3. En tus aplicaciones siempre obtendrás el mismo valor

Como siempre te estarás refiriendo a una sola columna (por ejemplo, llamada TOTAL_VENTAS) entonces en todos tus stored procedures, tus triggers, y en tu lenguaje de programación siempre obtendrás el mismo valor. Si no usas una columna computada podrías equivocarte y en lugar de escribir CANTIDAD * PRECIO escribir CANTIDAD + PRECIO y por supuesto el resultado estará equivocado. Es cierto que no es probable que te equivoques con una fórmula tan sencilla pero te podría ocurrir si estás muy apurado. Y aún más si la fórmula es complicada. Pero si usas columnas computadas jamás tendrás ese problema porque hay una sola fórmula que se encuentra en un solo lugar, así que es 100% seguro de que siempre obtendrás el mismo valor. Si la fórmula es correcta en todos los lugares obtendrás el valor correcto, y si la fórmula es incorrecta en todos los lugares obtendrás un valor incorrecto, pero siempre será el mismo y eso te ayudará si necesitas cambiar la fórmula.

4. Si necesitas cambiar la fórmula, lo haces en un solo lugar

Supongamos que el resultado de CANTIDAD * VENTAS lo necesitas mostrar en 40 lugares distintos. Y después te dicen que hay que multiplicar a ese resultado por 1.1, para aumentarlo en un 10 %. Si no usas columnas computadas entonces tendrás que hacer 40 cambios (y quizás ni te acuerdes que son 40 y cambies solamente en 28, a pesar de que deberías haber cambiado en 40).

Usando columnas computadas no tendrás ese problema, ya que el cambio lo haces en un solo lugar (en la columna definida como COMPUTED BY) y automáticamente ya el nuevo valor estará disponible en los 40 lugares distintos.

Conclusión:

Usar columnas computadas es altamente recomendable porque ahorramos tiempo y ganamos en confianza y en seguridad. Escribimos menos y además si alguna vez necesitamos cambiar la fórmula lo hacemos en un solo lugar.

Artículos relacionados:

Columnas computadas

Algunos ejemplos de uso de las columnas computadas

Un truco para encontrar valores que pueden estar en varias columnas

Usando un SELECT en una columna computada

Indexando una columna computada

El índice del blog Firebird21

El foro del blog Firebird21

Anuncios

Relacionando dos tablas: la forma vieja y la forma nueva

2 comentarios

En Firebird, cuando queremos relacionar dos tablas (o conjuntos de resultados) entre sí tenemos dos posibilidades:

  1. Usando la forma vieja
  2. Usando la forma nueva

Caso 1. Usando la forma vieja

Esta sintaxis fue establecida en el año 1989. Las tablas se listan separadas por comas después de la cláusula FROM y la condición que las relaciona se pone en la cláusula WHERE. No hay una sintaxis especial que distinga cuales de las condiciones del WHERE son para filtrar filas y cuales son para relacionar tablas, se supone que mirando la sentencia el desarrollador sabrá cual es cual.

¿Problemas?

  • La cláusula WHERE puede volverse muy larga y muy complicada de leer porque se la usa para relacionar tablas y también para filtrar filas
  • Solamente se pueden relacionar tablas que tengan valores idénticos, si una de las tablas tiene NULL en una columna no se podrá hacer la relación. En otras palabras: solamente se puede hacer un INNER JOIN, no se pueden hacer OUTER JOIN.

Sintaxis:

SELECT
   MiColumna1,
   MiColumna2
FROM
   MiTabla1,
   MiTabla2
WHERE
   MiCondición

Caso 2. Usando la forma nueva

Esta sintaxis fue establecida en el año 1992. La tabla principal se coloca después de la cláusula FROM y la tabla secundaria se coloca después de la cláusula JOIN, la condición que las relaciona se coloca después de ON. Por lo tanto se puede distinguir fácilmente cual es la condición usada para relacionar a la tablas y cual es la condición usada para filtrar filas, no hay confusión posible.

¿Ventajas?

  • Es muy fácil saber cual es la condición usada para relacionar a las dos tablas
  • Es muy fácil saber cual es la condición usada para filtrar filas
  • La cláusula WHERE es más corta y por lo tanto más fácil de leer que cuando se usa la forma vieja
  • Se puede usar tanto INNER JOIN como OUTER JOIN

Sintaxis:

SELECT
   MiColumna1,
   MiColumna2
FROM
   MiTabla1
JOIN
   MiTabla2
      ON MiCondición

Cuidado:

Tú puedes elegir cualquiera de las dos formas para relacionar tablas pero NUNCA debes mezclarlas. En un SELECT o usas la forma vieja o usas la forma nueva, no las mezcles, porque si las mezclas eso solamente te ocasionará problemas y ningún beneficio.

Recomendación:

La forma nueva es mejor, por eso se la inventó, por eso existe. Si ya tienes SELECTs escritos con la forma vieja y funcionan bien entonces déjalos como están, no los toques, pero para escribir todos tus nuevos SELECTs usa la forma nueva porque es la que verás cada vez más en todos los libros y documentos sobre SQL. Y es además la que siempre se usa en este blog.

Artículos relacionados:

Entendiendo a los JOIN

JOIN implícito y JOIN explícito

El índice del blog Firebird21

 

Arithmetic overflow or division by zero has occurred

Deja un comentario

Si ves el mensaje: “Arithmetic overflow or division by zero has occurred. Arithmetic exception, numeric overflow, or string truncation. String right truncation”

¿Qué significa?

Que el Firebird encontró un error grave y por eso detuvo el procesamiento. Ese error grave pudo ser debido a un error matemático (por ejemplo, división por cero), a un sobreflujo (se quiso guardar en una columna numérica un número mayor al máximo permitido), o un error de cadena (se quiso guardar en una columna una cadena de mayor longitud que la definida).

La última frase: “String right truncation” nos da la pista de cual de esos errores fue detectado. En este caso, se quiso guardar una cadena de mayor longitud que la definida.

Esto puede ocurrir en dos ocasiones típicas:

  1. Al querer hacer un INSERT o un UPDATE a una columna
  2. Al querer hacer un SELECT a una vista cuya tabla ha cambiado su estructura

Caso 1. Al querer hacer un INSERT o un UPDATE a una columna

La columna por ejemplo está definida como VARCHAR(30) y queremos guardar en ella más de 30 caracteres

¿Solución? Aumentar el ancho que la columna tiene en la tabla o disminuir la cantidad de caracteres a guardar en la columna

Caso 2. Al querer hacer un SELECT a una vista cuya tabla ha cambiado su estructura

La columna por ejemplo está definida como VARCHAR(30), creamos una vista que usa esa columna, luego modificamos la columna a VARCHAR(40), al hacer SELECT de la vista obtenemos el error. ¿Por qué? porque la vista es un SELECT compilado y se compiló cuando la columna estaba definida como VARCHAR(30), eso es lo que conoce la Base de Datos. Si más tarde cambiamos la longitud de la columna a 40 la vista no está enterada de ese cambio, detecta una inconsistencia y muestra el error.

¿Solución?

Volver a compilar la vista. Al recompilar la vista, ésta ya usará la nueva longitud.

Para que el cambio tenga efecto deberás desconectarte de la Base de Datos y volver a conectarte.

Artículo relacionado:

El índice del blog Firebird21

 

Pensando distinto

26 comentarios

Durante los últimos días fue noticia en muchos medios de comunicación el acertijo que se les pide resolver a unos niños en una escuela china. Es una de las preguntas que deben responder correctamente para ser admitidos en esa escuela.

Lo que hizo noticia al acertijo es que casi todos los niños lo resuelven en segundos, los estudiantes secundarios lo resuelven en minutos y los estudiantes universitarios o los adultos lo resuelven en horas o inclusive en días.

¿Por qué?

Porque cuanto más edad uno tiene su forma de pensar se va limitando, se acostumbra a pensar de cierta manera y las otras maneras directamente no las utiliza.

Pero en nuestro caso, profesionales de la Informática, es muy bueno tener un amplio abanico de posibilidades para poder hallar las decisiones más adecuadas a cada caso. Eso significa que a veces debemos “pensar como niños”, porque así tendremos más perspectivas. La mejor solución a veces es muy sencilla pero no la vemos porque nuestra mente está dirigida hacia otro lado. Y el acertijo chino justamente nos demuestra eso.

Estacionamiento

Imagen 1. Si haces clic en la imagen la verás más grande.

Problema: En un estacionamiento, hay muchos lugares para estacionar, esos lugares están numerados. Y como podemos ver en la imagen un lugar numerado está ocupado por un automóvil, así que la pregunta es: ¿Cuál es el número del lugar dónde está estacionado el automóvil?

Como comentario aparte, el autor de este blog resolvió el acertijo en segundos, probablemente no porque sea demasiado inteligente sino porque está acostumbrado a pensar en todas las posibilidades, porque en Informática frecuentemente debe hacerse así para obtener buenos resultados.

Artículo relacionado:

El índice del blog Firebird21

 

Algo más sobre GRANT y ROLE

2 comentarios

Los conceptos sobre GRANT y sobre ROLE pueden ser un poco difíciles de entender para quienes nunca los habían usado, así que con este artículo trataré de aclarar algunos conceptos y dudas que se puedan tener:

  1. Cuando un usuario consigue conectarse a la Base de Datos eso significa que tanto su nombre como su contraseña están reconocidos por el Firebird.
  2. Sin embargo, el hecho de haberse conseguido conectar no implica que pueda ejecutar exitosamente algún comando. Si no tiene derechos (permisos, privilegios) en esa Base de Datos entonces todos sus intentos fallarán. Ni siquiera podrá hacer un SELECT.
  3. Para que el usuario pueda ejecutar exitosamente algún comando, alguien previamente tuvo que haberle otorgado ese derecho (permiso, privilegio).
  4. Ese “alguien” que le otorgó el derecho puede ser el usuario SYSDBA, el creador de la Base de Datos, o un usuario que tiene el rol RDB$ADMIN con la opción de otorgar derechos, nadie más.
  5. Un rol es un grupo de usuarios. Los derechos (permisos, privilegios) que se otorgan a un rol automáticamente se otorgan a todos los usuarios que se conectan usando ese rol
  6. Para que un usuario pertenezca a un rol otro usuario debió haberlo hecho miembro de ese rol. Ese “otro usuario” puede ser SYSDBA, el creador de la Base de Datos o un usuario que tiene el rol RDB$ADMIN con la opción de otorgar derechos, nadie más.
  7. Cuando un usuario se conecta a la Base de Datos puede hacerlo especificando un rol o sin especificar un rol.
  8. Si se conecta sin especificar un rol, entonces tendrá solamente los derechos que a él específicamente se le hayan otorgado
  9. Si se conecta especificando un rol entonces tendrá todos los derechos que se le otorgaron específicamente a él más todos los derechos que tiene ese rol
  10. Un usuario puede pertenecer a muchísimos roles pero cuando se conecta solamente puede elegir uno de ellos y solamente tendrá los derechos que le corresponden a ese rol.

Ejemplo:

Al usuario JUAN se le otorgó el derecho de hacer SELECT a la tabla CLIENTES.

Al rol R_CONTABILIDAD se le otorgó el derecho de hacer SELECT a la tabla VENTAS.

Al usuario JUAN se lo hizo miembro del rol R_CONTABILIDAD.

Si el usuario JUAN se conecta sin especificar un rol, entonces podrá hacer SELECT a la tabla CLIENTES pero no a la tabla VENTAS.

Si el usuario JUAN se conecta especificando el rol R_CONTABILIDAD entonces podrá hacer SELECT a la tabla CLIENTES y también a la tabla VENTAS.

El usuario JUAN no podrá hacer SELECT a la tabla COMPRAS porque ni a él ni al rol R_CONTABILIDAD se le otorgó el derecho de hacerle un SELECT a esa tabla. Si más adelante a él o al rol R_CONTABILIDAD se le otorga el derecho de SELECT a la tabla COMPRAS entonces cuando vuelva a conectarse podrá hacer el SELECT, pero mientras tanto le será imposible.

Recuerda:

Un rol es un grupo de usuarios, si varios usuarios necesitan tener los mismos derechos entonces ahorrarás mucho tiempo creando un rol con esos derechos y luego otorgándole ese rol a cada usuario. Si no creas un rol entonces podrías olvidarte de asignarle un derecho a un usuario y además escribirás mucho más y en consecuencia perderás mucho tiempo.

Muy importante:

En general, por motivos de seguridad, lo recomendable es que no se otorguen derechos específicos a los usuarios sino a los roles. De esta manera si un usuario se conecta sin especificar un rol nada podrá hacer en la Base de Datos, ni siquiera un SELECT. Por lo tanto, siempre deberá conectarse usando un rol y en ese caso solamente tendrá los derechos (permisos, privilegios) que se le hayan otorgado al rol con el cual se conectó.

Artículos relacionados:

Entendiendo los derechos de acceso

Otorgando permisos con EMS SQL Manager

Delegando el otorgamiento de derechos

Un stored procedure para otorgarle TODOS los derechos a un usuario

El índice del blog Firebird21

 

¿Cuándo se actualiza la estructura de una tabla?

4 comentarios

Si con un programa cambias la estructura de una tabla y con otro programa quieres usar esos cambios notarás que a veces no puedes hacerlo.

Veamos un ejemplo:

El usuario ejecuta su programa de Contabilidad y está trabajando con ese programa. Quiere introducir el nombre de un Proveedor y la columna le queda corta, digamos que solamente puede introducir 25 caracteres y él necesita introducir 31. Te llama y tú le dices: “ok, no hay problema, ya te actualizo la estructura de la tabla”.

Entonces vas, abres el EMS SQL Manager (o cual sea tu administrador gráfico), y cambias el ancho de la columna a 40, compilas la tabla, y está todo ok. Haces una prueba modificando esa columna en una fila cualquiera para que tenga 40 caracteres, le haces el COMMIT correspondiente y todo bien, funcionó perfecto. Desde luego, para terminar tu prueba borras los caracteres que le habías agregado a esa columna para probar, para que no queden registrados.

Ya has comprobado que aumentar el tamaño de la columna funcionó perfectamente entonces le llamas al usuario, y le dices: “ya está, ya podrás introducir hasta 40 caracteres en esa columna”.

El usuario feliz intenta nuevamente grabar los 31 caracteres que necesitaba grabar y … ¡¡¡NO FUNCIONA!!!

Te llama y te dice: “sigue sin funcionar, está igual que antes”

Entonces tú como eres muy educado a él no le dices una palabra pero dentro tuyo piensas: “¡¡¡la regranp…que lo recontraparió!!! ¡¡¡CÓMO QUE NO FUNCIONA!!! ¡¡¡Acabo de probar y funcionaba perfecto!!!”

Vuelves a probar y funciona bien. El usuario vuelve a probar y no le funciona.

¿Qué está pasando, cuál es el problema?

Que en tu programa los cambios sí funcionan, pero en el programa del usuario no. ¿Por qué?

Porque tú mantienes abierta a la Base de Datos en tu EMS SQL Manager (o cual sea tu administrador gráfico) y por lo tanto el usuario no se enterará de los cambios que hiciste hasta que no cierres la Base de Datos o salgas de ese programa.

Y en ocasiones, puede requerirse que el usuario también salga de su programa y vuelva a entrar.

Conclusión:

Que tú hagas un cambio a la estructura de una tabla no significa que al instante todos los usuarios se enteran de ese cambio, no es así. Para que se enteren debes salir del programa que usaste para realizar los cambios (el EMS SLQ Manager, el IBEXPERT, el FlameRobin, etc.) y en ocasiones el usuario también deberá salir del programa que él estaba usando. Solamente después de eso los cambios a la estructura de la tabla serán visibles.

Así que si haces cambios a una tabla y el usuario no ve esos cambios, no te desesperes. Simplemente sal de tu administrador gráfico y pídele al usuario que también salga del programa que estaba usando; cuando vuelva a entrar el asunto estará solucionado.

Artículo relacionado:

El índice del blog Firebird21

Atacando a una Base de Datos: SQL injection

6 comentarios

Las bases de datos siempre se encuentran expuestas a ser atacadas por personas mal intencionadas. Eso le puede ocurrir tanto a una Base de Datos que se encuentra en una red local como a una Base de Datos que se encuentra en una red remota, como Internet, por ejemplo. En este último caso el peligro es mucho mayor porque el atacante puede estar en la otra mitad del mundo y aunque lo descubramos no se podrá tomar medidas punitivas contra él. En cambio, si el atacante es un empleado de nuestra propia empresa siempre de alguna forma se le podrá castigar.

Nuestra tarea, por supuesto, es evitar que los ataques tengan éxito. Para ello debemos proteger a la Base de Datos lo más que podamos. A veces nuestros esfuerzos serán insuficientes pero en general los atacantes desistirán de continuar con sus ataques si se dan cuenta que se enfrentan a una Base de Datos que está muy protegida.

Una forma común de atacarla es a través de un método llamado en inglés “SQL injection”.

¿Qué significa “SQL injection”?

Que al comando SQL se le está agregando algo, se le está inyectando algo. Un código que no debería estar. Ese código adicional que agregó el atacante es el que nos causará problemas.

Ejemplo 1. Un ataque trivial de SQL injection

Supongamos que tenemos un sitio web para vender productos en línea. Los visitantes de nuestro sitio web pueden escribir el código del producto que les interesa conocer características y precios.

Todo bien hasta ahí, ¿verdad?

Quizás no, si no tomamos las debidas precauciones puede ser muy vulnerable.

En nuestra página web tenemos un campo de texto donde el visitante escribirá el código del producto que le interesa y un botón “submit” que usará para enviar su solicitud. En nuestro programa escribimos algo así:

lcComando = '''SELECT * FROM PRODUCTOS WHERE PRD_CODIGO = ' || lcCodigo || ';'''

Un vistitante normal podría querer conocer las características y precios del producto que tiene código 127 y entonces nuestro comando quedaría como:

lcComando = 'SELECT * FROM PRODUCTOS WHERE PRD_CODIGO = 127';

Y estaría perfecto, en condiciones normales no tendríamos problemas. Pero un visitante malintencionado podría escribir algo como:

127 or 1=1

 con lo cual, nuestro comando quedaría como:

lcComando = 'SELECT * FROM PRODUCTOS WHERE PRD_CODIGO = 127 or 1=1';

¡¡¡CUIDADO!!! esa condición SIEMPRE será verdadera porque SIEMPRE tendremos que 1 es igual a 1.

En este caso el atacante podrá ver las características y precios no solamente del producto cuyo código es 127 sino de TODOS los productos que tenemos en nuestra Base de Datos.

¿No importa que los vea, están para ser vistos?

Quizás en el caso de los productos no importe, pero en otros casos sí podría importar.

Ejemplo 2. Un ataque peligroso de SQL injection

El Ejemplo 1. mostró el concepto pero no era peligroso porque el atacante solamente vio datos, no modificó ni borró algo, entonces en muchos casos sería algo trivial y no muy preocupante. Pero el atacante sí puede borrar algo, si escribe:

127;DELETE FROM PRODUCTOS

porque en ese caso nuestro comando quedaría así:

lcComando = 'SELECT * FROM PRODUCTOS WHERE PRD_CODIGO = 127;DELETE FROM PRODUCTOS';

 ¿Y qué hará el Firebird en ese caso?

Pues ignorará al primer comando (o sea, el SELECT) y ejecutará el segundo comando (o sea el DELETE).

Y ahora, lo que hizo el atacante es MUY PELIGROSO porque borró todas las filas de nuestra tabla llamada PRODUCTOS.

Detectando si el ataque tuvo éxito

¿Y cómo puede saber el atacante si su ataque tuvo éxito?

Pues simplemente escribiendo el código de un producto que debía existir, por ejemplo el 127, con lo cual nuestro comando quedaría así:

lcComando = 'SELECT * FROM PRODUCTOS WHERE PRD_CODIGO = 127';

Si el atacante escribió 127 antes de lanzar el ataque y recibió datos, y luego de lanzar el ataque vuelve a escribir 127 y no recibe datos entonces puede estar 100% seguro de que su ataque tuvo éxito.

Claro, para eso tendría que saber que la tabla se llama PRODUCTOS, si la tabla tiene otro nombre evidentemente su ataque no funcionó. ¿Y qué puede hacer en ese caso? Pues probar con otros nombres similares, tales como: PROD, PRODS, PRODUCTO, PRODUCTS, ARTICULOS, ARTICLES, ARTICS, ARTS, etc.

Si alguna vez al enviar el código 127 no recibe datos entonces sabrá (al menos) cuatro cosas:

  1. El nombre de la tabla
  2. Que su ataque tuvo éxito
  3. Que el diseñador del sitio web no se preocupó por la seguridad
  4. Que puede continuar atacando a esa Base de Datos porque no está protegida

Ahora que ya borró todas las filas de la tabla PRODUCTOS puede continuar escribiendo otros comandos similares, tales como:

DELETE FROM CLIENTES
DELETE FROM PROVEEDORES
DELETE FROM USERS
DELETE FROM USUARIOS
DELETE FROM VENTAS
DROP CLIENTES
DROP PROVEEDORES
DROP VENTAS
etc.

En estos casos, no podrá saber si su ataque tuvo éxito o no, pero si escribe muchísimos comandos DELETE es muy probable que algunos de ellos sí borren todas las filas de algunas tablas.

¿Cómo evitamos los ataques de SQL injection?

Ya sabemos como la Base de Datos puede ser atacada, ¿cómo la defendemos?

  1. Validando que el visitante no introduzca espacios en blanco
  2. Validando que el visitante no introduzca puntos y comas
  3. Validando que el visitante solamente introduzca dígitos (0 .. 9)
  4. Validando que el visitante no introduzca la palabra DELETE
  5. Validando que el visitante no introduzca la palabra MODIFY
  6. Validando que el visitante no introduzca la palabra DROP
  7. Validando que la longitud de nuestra variable (llamada lcComando en estos ejemplos) no sea mayor que la predeterminada. Si nuestros códigos tienen un máximo de 4 dígitos entonces la longitud de lcComando nunca debería ser mayor que 47 (en nuestros ejemplos, claro).

Entonces, en nuestro stored procedure (o en el código fuente de nuestro lenguaje de programación) deberíamos escribir algo como:

IF (POSITION('DROP' IN lcComando) > 0) THEN BEGIN
-- ERROR, no se debe ejecutar el comando, la Base de Datos fue atacada.
END

Conclusión:

Todas las bases de datos pueden ser atacadas y como puedes ver es demasiado fácil hacerlo, ni siquiera se necesita de un programa que ayude. Aquí hemos visto algunos de los muchos métodos que pueden usar los atacantes. Mi objetivo no es enseñarte a atacar bases de datos sino mostrarte que son muy vulnerables y que si no las proteges entonces pueden ser facilmente destruidas.

Nunca debes confiar en que tu Base de Datos no será atacada porque no tiene algo interesante o de valor, muchos las atacan por diversión, porque lo toman como un juego, para pasar un buen rato, desafiando a sus amigos quienes realizan más ataques, cosas así. A muchos ni les interesa el contenido, solamente les resulta divertido hacerlo. Pero para tí puede ser un perjuicio enorme. Así que, mucho cuidado.

Artículo relacionado:

El índice del blog Firebird21

 

Older Entries Newer Entries