El objetivo de este repositorio es mostrar cómo se puede trabajar con Git en una aplicación basada en Oracle (con lógica en PL/SQL)
Además crearemos una aplicación en Django para facilitar la creación de datos de prueba en la instancia local del desarrollador, y permitiremos la ejecución de pruebas automáticas (también en Django) sobre el código PL existente.
Las tecnologías que usaremos para conseguir este objetivo son:
- Docker. Para arrancar Oracle en la máquina local.
- Flyway. Para mantener las database migrations, los scripts SQL que realizan la evolución del modelo de datos.
- Shell scripts para compilar el código PL/SQL en la instancia.
- Django Framework, usando librerías como Factory Boy para la inserción de datos.
Estructuraremos esta documentación en cuatro fases diferenciadas:
- Instalación y configuración de Docker y Flyway.
- Desarrollo diario: uso de Git y Flyway.
- Generación de datos de prueba.
- Ejecución de pruebas automáticas.
Tenemos que instalar y configurar tanto Docker como Flyway en la máquina del desarrollador.
Para desarrollar software es necesario que cada desarrollador tenga su propia base de datos Oracle funcionando en su máquina. Para esto, hay que instalar Docker, que permite arrancar sistemas operativos virtuales, llamados contenedores, con software de todo tipo funcionando, como si de una máquina virtual se tratase.
En nuestro caso, necesitamos una instancia de Oracle funcionando en la máquina local.
Para conseguir esto he tenido que crearme una cuenta en docker hub (https://hub.docker.com), para poder descargar la imagen oficial de Oracle Enterprise.
Una vez creada la cuenta, hay que:
- Buscar la imagen oficial de Oracle Database. Entrar en el detalle y aceptar los términos del servicio (hay que registrarse)
- Hacer login desde la línea de comandos con
$ docker login
- Ya se podría descargar el contenedor con la instrucción
docker pull store/oracle/database-enterprise:12.2.0.1
Existe una variante "slim" del contenedor, con nombre store/oracle/database-enterprise:12.2.0.1-slim
, que ocupa menos espacio, y no dispone de características poco usadas como Analytics, Oracle R, Oracle Label Security, Oracle Text, Oracle Application Express y Oracle DataVault.
Para facilitar el arranque de la base de datos he creado un fichero docker-compose.yml
que arranca la base de datos mediante el comando:
$ docker-compose up -d
Una vez el contenedor ha sido descargado (después del pull
), se puede ejecutar directamente docker-compose up
para arrancar oracle sin tener que hacer login de nuevo.
La opción -d
significa que se ejecute en background, si se omite esta opción Docker muestra en pantalla todo lo que va sucediendo, incluso información sobre lo que habría que añadir a un fichero tnsnames.ora
en caso de que quisieramos crearlo.
Para saber si la instancia de Oracle está lista, se puede ejecutar docker ps
y cuando para el contenedor oracle-local
el estado mostrado sea (healthy)
, significará que Oracle ha arrancado correctamente.
La configuración del docker-compose.yml
une el puerto 1521 del contenedor al puerto local 32769.
Este puerto es el que tiene que usarse al configurar la conexión en un cliente SQL como DataGrip (o TOAD).
En todo caso, la conexión usando el SID, usuario y contraseña funciona (ver imagen).
Los datos de la base de datos se guardan en el interior del contenedor, por lo que si el contenedor se borra (esto no se tiene por qué hacer) los datos se perderían.
Para un entorno de desarrollo esto no es muy importante, porque la base de datos se reconstruye usando los scripts de Flyway, pero si se quisiera mantener los datos en la máquina local, podría hacerse añadiendo volumes
al fichero docker-compose.yml
volumes:
- /data/OracleDBData:/ORCL
Donde /data/OracleDBData
es un directorio existente en la máquina local. Esto haría que los datos se guardaran fuera del contenedor, pudiendo recuperarlos en caso de borrado del mismo.
El usuario y contraseña por defecto para conectarse a la base de datos es sys
y Oradoc_db1
. El SID es ORCLCDB
.
La instancia de Oracle viene sin ningún esquema creado, además de los estándar. Para ello, necesitamos crear un esquema donde se crearán nuestras tablas y paquetes PL/SQL.
Esto se debe hacer una única vez, después de la creación del contenedor. Podría hacerse manualmente, pero he creado un shell script que se conecta al contenedor y ejecuta el script SQL en /SQL/migrations/init.sql
.
Este script crea un esquema/usuario llamado c##local
, con password localpass
. Estas serán las credenciales que usaremos posteriormente en Flyway.
El script se ejecuta desde el shell (en Mac y Linux):
$ ./shell_scripts/init.sh
Para usuarios de Windows, la versión .bat
debe ser sencilla de hacer...
$ docker exec -it oracle-local bash
$ docker ps
Los conceptos de imagen y contenedor son análogos a los conceptos de clase y objeto. La imagen es la clase (por ejemplo, la imagen de Oracle), y el contenedor es una instancia de esa imagen ejecutándose. Podemos, por lo tanto, tener varios contenedores que ejecuten la misma imagen (con nombres distintos)
$ docker images
$ docker kill oracle-local
Si se ejecutó docker-compose up
sin la opción -d
, se puede parar pulsando la combinación de teclas ctrl+c
.
Flyway es una herramienta para el control de versiones en el modelo de datos. Simplificando, no es más que un gestor de scripts SQL que establece una serie de normas de nombrado de ficheros y permite la ejecución de scripts SQL para actualizar una base de datos.
Al trabajar con Flyway, los scripts de cambios en modelo de datos (DDL y DML) se guardan en el repositorio Git como ficheros de texto SQL con una nomenclatura definida.
Flyway crea una tabla en la base de datos (flyway_schema_history
) para almacenar qué scripts están ejecutados, y también guarda un hash de cada script.
Cuando un desarrollador actualiza o descarga una rama de Git, los scripts asociados a esos cambios están ahí, y Flyway sabe qué scripts están ejecutados y cuáles no, permitiendo actualizar la versión de la base de datos de forma que sea compatible con los cambios en el código.
Para instalar Flyway se tiene que descargar de aquí la versión de escritorio (command-line) de Flyway para tu sistema operativo.
Una vez descomprimido el fichero descargado, es conveniente mover el directorio completo a la carpeta de aplicaciones o a algún sitio más "estable" que la carpeta de descargas. Es conveniente también añadir esta carpeta al PATH para permitir el acceso rápido al ejecutable de Flyway.
Flyway necesita el driver JDBC para conectarse a la base de datos. Para otros motores como Postgres o MySQL el driver está incluido en la distribución de Flyway, pero para Oracle hay que descargar el driver, ficheros ojdbc8.jar
y orai18n.jar
, (hay que asegurarse de que sea compatible con la versión de Oracle) y copiarlo en la carpeta /drivers
de la instalación de Flyway.
El driver puede descargarse de la web de Oracle
Hay instrucciones detalladas sobre el uso de Flyway con Oracle aqui. Es importante recalcar que para versiones anteriores a Oracle 12.2 (la que usamos aquí) hay que usar una licencia de Flyway Pro.
En la carpeta de la instalación de Flyway hay un subdirectorio llamado conf
que contiene un fichero flyway.conf
que puede editarse para indicar a Flyway cosas como la URL a la que acceder, usuario, contraseña, localización de los scripts SQL...
La información de configuración que necesitamos indicar es:
Propiedad | Valor |
---|---|
url | jdbc:oracle:thin:@//localhost:32769/ORCLCDB.localdomain |
user | c##local |
password | localpass |
locations | filesystem:../SQL/migrations |
En la carpeta flyway
de este repositorio hay un fichero flyway.conf
con la configuración local que he usado a modo de ejemplo. Este fichero debe pegarse en el directorio conf
de la instalación local de Flyway, para que se use esa configuración siempre. Si no se modifica esa configuración, debe ejecutarse el comando flyway desde la carpeta flyway
del repositorio para que lea nuestro fichero .conf
.
Una propiedad interesante es outOfOrder
, que nos permitirá ejecutar todas las migraciones anteriores no aplicadas, aunque hayamos aplicado migraciones posteriores en número de versión. Esto puede indicarse en cada ejecución concreta de Flyway.
La documentación sobre las posibles opciones de configuración puede encontrarse aquí
Existe una opción de configuración llamada initSql
para indicar instrucciones SQL que se ejecutarán antes de las migraciones, como por ejemplo un ALTER SESSION
, si lo vemos conveniente o necesario.
Flyway usa una tabla (flyway_schema_history
) para controlar qué scripts han sido ejecutados y cuáles no.
El propio ejecutable de Flyway permite la creación de esta tabla ejecutando flyway baseline
$ flyway baseline
Flyway Community Edition 5.2.4 by Boxfuse
Database: jdbc:oracle:thin:@//localhost:32769/ORCLCDB.localdomain (Oracle 12.2)
Creating Schema History table: "C##LOCAL"."flyway_schema_history"
Successfully baselined schema with version: 1
Esto hay que hacerlo antes de poder ejecutar migraciones.
Flyway sigue una nomenclatura concreta para los scripts SQL. Por ejemplo:
V2__Crear_tabla.sql
En este caso,
- V: es el prefijo que indica que es un script a ejecutar, un script de nueva versión. Se puede crear scripts repetibles, que se ejecutan siempre, y comienzan por R, o scripts de deshacer que empiezan por U (sólo para versión pro de Flyway)
- 2: Es el número de versión.
- __: El doble subrayado señala la separación con el campo de descripción.
- Crear_tabla.sql: Descripción y extensión del fichero.
En mi opinión, un buen ejemplo de nombrado de script debería usar como número de versión una fecha con hora minutos y segundos. Por ejemplo:
V201903112006__Creacion_tabla.sql
La fecha hasta minutos (o segundos si se quiere) hace que el número de versión sea prácticamente único.
Una vez Flyway está configurado, para actualizar la base de datos con los scripts de migración se ejecuta flyway migrate
.
Por ejemplo, en este repositorio hay un script de prueba, que crea una tabla en la base de datos, V201903112012__Crear_tabla_ejemplo.sql
.
Si ejecutamos flyway migrate
, la salida es:
$ flyway migrate
Flyway Community Edition 5.2.4 by Boxfuse
Database: jdbc:oracle:thin:@//localhost:32769/ORCLCDB.localdomain (Oracle 12.2)
Successfully validated 2 migrations (execution time 00:00.050s)
Current version of schema "C##LOCAL": 1
Migrating schema "C##LOCAL" to version 201903112012 - Crear tabla ejemplo
Successfully applied 1 migration to schema "C##LOCAL" (execution time 00:00.049s)
Flyway ejecuta el script y crea la tabla. Además, inserta una fila en la tabla de versiones, con un hash para validar que el contenido del fichero no se cambie en el futuro (los scripts han de ser inmutables)
- Cualquier DDL exportado por Oracle debe poder ser ejecutado por Flyway sin modificaciones.
- Las instrucciones SQL deben terminar en
;
- Los bloques de PL/SQL deben comenzar por
DECLARE
oBEGIN
y terminar enEND; /
(la barra es importante) - Se permite también el uso de placeholders, que pueden definirse en el fichero de configuración y usarse en el SQL (ver documentación de FLyway para más información)
Los comandos tienen que ejecutarse desde la carpeta flyway
para que lean la configuración adecuadamente, o bien sobreescribir el fichero flyway.conf
de la instalación local de Flyway con el de este repositorio (eso es lo conveniente).
$ flyway clean
Este comando BORRA todos los objetos de los esquemas que maneja Flyway (uno solo en nuestro caso)
Normalmente no es necesario, pero a veces viene bien recrear la base de datos de cero y aplicar todas las migraciones posteriormente.
He creado un script sh para ejecutar el borrado e inicialización de la base de datos, ejecutando flyway clean
y flyway baseline
. El script se llama bd_borrar.sh
.
$ flyway info
Imprime en pantalla la situación de todas las migraciones (ejecutadas o no)
La salida es algo similar a esto:
+-----------+--------------+-----------------------+----------+---------------------+----------+
| Category | Version | Description | Type | Installed On | State |
+-----------+--------------+-----------------------+----------+---------------------+----------+
| | 1 | << Flyway Baseline >> | BASELINE | 2019-03-13 19:23:11 | Baseline |
| Versioned | 201903112012 | Crear tabla ejemplo | SQL | 2019-03-13 19:36:59 | Success |
+-----------+--------------+-----------------------+----------+---------------------+----------+
$ flyway migrate
Aplica todas las migraciones no aplicadas. Es lo habitual cuando se cambia de rama.
En ocasiones, cuando hay ramas que tardan mucho tiempo en volver a mezclarse contra master, nos encontraremos con scripts de migración no ejecutados con número de versión anterior a la última versión que ya tenemos aplicada.
En ese caso Flyway, por defecto, da un error, que se puede evitar ejecutando flyway migrate -outOfOrder=true
.
He creado un script sh que ejecuta flyway migrate
, que se llama bd_migrar.sh
.
Si la base de datos existente que tenemos que utilizar tiene muchas tablas (miles...), es difícil tener un script SQL que usar con Flyway como script de creación de la base de datos. Oracle no ofrece una forma sencilla para exportar la estructura de una base de datos en formato SQL que pueda ser aplicado por Flyway como un script de migración, y para poder hacer esto hay que recurrir a productos de terceros que no son gratuitos.
Sin embargo, sí hay una forma de exportar e importar la estructura de la base de datos usando una herramienta gratuita de Oracle, los comandos expdp
y impdp
.
El comando expdp
permite exportar la estructura de la base de datos original, especificando los esquemas que quieren exportarse (puede ser necesario exportar más de uno).
Por ejemplo:
expdp C##LOCAL/localpass schemas=C##LOCAL content=metadata_only dumpfile=export.dmp reuse_dumpfiles=YES
Este comando exporta el esquema C##LOCAL
, pero únicamente la estructura de tablas del mismo. Puede especificarse varios esquemas separados por comas. El comando crea (o sobreescribe) un fichero export.dmp
en el directorio de export de Oracle en la máquina donde esté corriendo la instancia.
Para importar un fichero dmp se puede ejecutar el comando:
impdp C##LOCAL/localpass schemas=C##LOCAL dumpfile=export.dmp
Para que el comando funcione, el fichero export.dmp
debe existir en un directorio accesible por la base de datos. Por defecto el directorio de export/import es /u01/app/oracle/admin/ORCL/dpdump/
. La base de datos destino debería estar vacía para evitar problemas (puede conseguirse eso ejecutando flyway clean
).
Este comando importará la estructura de base de datos, que puede incluir procedimientos almacenados, triggers, y varios esquemas diferentes.
Cuando estamos trabajando con bases de datos heredadas que contienen muchas tablas (miles), la mejor opción para el desarrollo local es partir de un fichero dmp
(que contenga sólo la estructura de las tablas) exportado desde la base de datos original, y usar Flyway para aplicar los scripts de migración sobre esa base de datos.
El proceso para trabajar así podría ser, para cada sprint
o ciclo, al inicio del sprint, una vez se ha hecho el deployment a producción:
- Una vez ejecutados los scripts de migración, se hace una export del entorno de producción que contenga únicamente la estructura de las tablas, para los esquemas que nos interesen.
- Esa export se guarda en un directorio en Git (en nuestro caso, en SQL/export), y se sube a Git.
- Todos los scripts ejecutados en producción usando Flyway se mueven a un directorio de scripts ejecutados, en un subdirectorio con la fecha del sprint. El directorio de de migrations queda vacío. Las nuevas migraciones estarán en ese directorio una vez se vayan mergeando las ramas correspondientes.
- Se ejecuta
flyway baseline
en producción, para resetear las migraciones. - Cada desarrollador, cuando se ha hecho un deployment a producción, refresca su repositorio de Git y recrea la base de datos importando la export y posteriormente aplicando los scripts de Flyway que sean necesarios, dependiendo de la rama en la que se encuentre.
En resumen, para recrear todo localmente se debe ejecutar una import del fichero export.dmp
que haya en el directorio SQL/export
y posteriormente ejecutar flyway baseline
y flyway migrate
localmente, para inicializar las migraciones de Flyway y aplicar los scripts existentes.
Esta solución, además de sincronizar el modelo de base de datos, permite mantener un directorio de migraciones de Flyway reducido, sin cientos de ficheros SQL que van creciendo sin control. Como contraprestación, se requiere ser muy disciplinado a la hora de desarrollar, porque no seguir los pasos llevaría a resultados erróneos (al menos localmente). Se puede crear una serie de scripts sh
or bat
que hagan la vida más sencilla a los desarrolladores.
Hemos creado un script import_db.sh
que utiliza el fichero export.dmp
en el directorio SQL/export
para realizar una import de la base de datos. Si ese fichero se actualiza después de cada deployment a producción los desarrolladores pueden tener la base de datos actualizada fácilmente.
He creado un shell script que compila todos los paquetes (cabeceras primero, bodies después) en el container. El script se ejecuta:
./shell_scripts/compilar.sh
El script compila cada cabecera y body por separado en sqlplus
.
En caso de tener triggers o clases, o cualquier otro código PL que compilar, puede añadirse en este mismo script.
Al final se ejecuta un par de veces el paquete UTL_RECOMP
para recompilar los paquetes con errores.
La salida del script cuando no hay errores de compilacion es algo como:
Davids-MacBook-Pro:shell_scripts davidespihernandez$ ./compilar.sh
Compilando cabeceras
pl1.sql
pl2.sql
Compilando bodies
pl1.sql
pl2.sql
Recompilando todo
Todo compilado sin errores!
Cuando algún objeto está descompilado después de compilar todo, el script muestra una lista de los objetos inválidos, como:
Davids-MacBook-Pro:shell_scripts davidespihernandez$ ./compilar.sh
Compilando cabeceras
pl1.sql
pl2.sql
Compilando bodies
pl1.sql
pl2.sql
Recompilando todo
Hay errores de compilación!
OBJECT_TYPE OBJECT_NAME
--------------- ----------------------------------------------------------------
PACKAGE BODY PL1
Es responsabilidad del desarrollador abrir el paquete en el IDE correspondiente para localizar el error de compilación y resolverlo.
En todo caso, hay clientes SQL como TOAD que permiten la compilación masiva de los ficheros PL/SQL, por lo que esto puede hacerse manualmente, pero un script siempre simplifica las cosas.
Git es el estándar de facto para el control de versiones de código fuente. Es un software muy versátil, que permite usarse de diferentes formas para diferentes proyectos, en base a las necesidades de cada uno.
Sea cual sea la estrategia de creación de ramas, sí que hay una única forma correcta de que los cambios sean aprobados: los pull requests (PR a partir de ahora). Trabajar con PR tiene múltiples beneficios:
- Los cambios son revisados antes de ser incorporados a la
master
branch (o a cualquier otra rama) - Herramientas como Github o Bitbucket permiten realizar comentarios y discusiones sobre el código, lo que al final acaba resultando en mejoras del mismo.
- Aunque un desarrollador no esté como revisor de un PR, siempre puede acceder al mismo, ver el código y hacer los comentarios que quiera. Añadir a un desarrollador como revisor de un PR puede significar simplemente que se quiere que esté al tanto de los cambios, no la revisión concreta de los mismos.
- Las herramientas de integración continua como Jenkins permiten comprobar que para los cambios de ese PR todos los tests automáticos funcionan, y pueden deshabilitar la aprobación del PR en caso contrario.
Los PR no son una característica que venga con Git, se manejan de forma externa por servicios como Github o Bitbucket (que hay que contratar por separado). Tanto Bitbucket como Github permiten integración de los PR con sistemas externos como Jira, de forma que cuando un PR se cierra (merge), el ticket asociado se puede cambiar de estado automáticamente.
No existe una estrategia de creación de ramas única en Git, depende habitualmente de las necesidades del proyecto en cuestión.
Si el proyecto está pensado para realizar continuous deployment (CD) o continuous integration (CI) (esto es siempre lo deseable), lo mejor es usar una estrategia como Github Flow.
Por otra parte, si partimos de una estructura antigua y queremos migrar a Git, quizá sea más operativo usar inicialmente algo como Gitflow.
En todo caso, siempre puede crearse una estategia ad hoc para cada proyecto. A continuación comentamos las principales estrategias para el uso de Git.
En esta estrategia, existe una rama master
con el código fuente que está actualmente en producción.
Esta estrategia se basa en una máxima: todo lo que está en la rama master
se puede desplegar en producción.
Cuando se quiere desarrollar algo (da igual lo que sea, un error o una nueva feature), el desarrollador:
- Crea una nueva rama, a partir de
master
. - Realiza los cambios en la rama.
- Añade sus nuevos tests automáticos, y comprueba que los existentes sigan funcionando.
- Crea un pull request
- Se revisa el código usando el pull request, y se realizan las modificaciones oportunas
- En mi opinión, antes de aprobar el pull requests, debería mezclarse
master
de nuevo contra el pull request, y ejecutar los tests de nuevo. Esto no está en el procedimiento de github flow. - Una vez está todo correcto, se aprueba y mezcla (merge) el pull request.
Este proceso está muy bien explicado aquí
No hay que confundir esto con Github flow, esta estrategia es algo totalmente distinto.
En esta estrategia se crean muchas más ramas, y el proceso de desarrollo es mucho más rígido, siguiendo unos pasos muy definidos.
Este artículo define cómo se trabajaría con Gitflow. Es un post de 2010, pero sigue igual de válido hoy en día...
En resumen, este método define ramas para:
- master (producción)
- develop (desarrollo)
- feature (ramas para cada ticket o trabajo)
- release (ramas para cada despliegue en producción)
- hotfix (ramas para solucionar problemas en producción)
Además, se define muy claramente cómo se crean y contra qué se mezclan las ramas.
Por ejemplo, una rama de Hotfix se crea desde master
, y cuando se mezcla (merge) se hace contra master
y contra develop
.
El proceso está tan definido que herramientas como Atlassian Sourcetree lo incorporan para que no haya que hacerlo manualmente.
Esta estrategia puede ser una buena opción para empresas que no usen integración continua, y se basen en releases.
Hay que notar que la máxima todo lo que está en la rama master
se puede desplegar en producción. sigue valiendo aquí, por lo que se podría crear scripts para CI a partir de master, o bien usar software como Jenkins para la integración continua.
Hay una buena comparativa entre Gitflow y Github flow aquí.
Esta podría ser una variante simplificada de Gitflow, para compañías que trabajen con Agile, mediante sprints.
- Al principio del sprint se crea una rama desde master, donde se desarrollará el sprint, y que acabará siendo una release.
- Cada desarrollador crea su rama de feature partiendo de la rama del sprint.
- Los PR se crean para ser mezclados contra la rama del sprint.
- La rama del sprint se despliega (preferiblemente de forma automática) en un entorno compartido, donde se pueden realizar pruebas y validaciones manuales más allá de los tests automáticos.
- Si hay algún bug/hotfix, se trata como en Gitflow, se parte de master, y se mezcla contra master y también contra la rama actual de sprint.
- Una vez el sprint está aceptado, se mezcla la rama del sprint contra master, y se despliega en producción.
Independientemente de qué estrategia de creación de ramas se use, es fundamental en el desarrollo de software la creación de tests automáticos.
Cuando el desarrollador hace los cambios en el código en su rama, junto a esos cambios puede haber asociados unos scripts de migración de base de datos (aplicables con Flyway, siempre que haya cambios en el modelo de datos), pero es obligatorio que haya unos tests automáticos que validen que los cambios realizados son correctos. Puede que se trate de nuevos tests, o puede que seam modificaciones de los existentes, pero se debe validar automáticamente que los cambios son correctos.
El propio desarrollador ha de comprobar en local que todos los tests pasan antes de crear el PR.
Las herramientas de integración continua, como Jenkins o Circle CI permiten impedir que se pueda aprobar PR para los que hay tests que fallan.
Si se tiene la posibilidad de usar un IDE que integre Git, como las herramientas de Jetbrains (IntelliJ, Pycharm, Webstorm...), Sublime, o Visual Studio Code, esto es siempre una buena opción.
Todo lo que se puede hacer con Git se puede hacer mediante la línea de comandos. Aquí se puede encontrar la guía oficial de comandos.
En todo caso, hay muchas herramientas específicas para trabajar visualmente con Git. Comentaremos algunas.
Lo mejor de esta aplicación es que se integra con Bitbucket, permitiendo crear PR desde la misma (aunque lo que hace realmente es abrir la página web, donde se crea el PR) Es una aplicación bastante robusta y, una vez que se controla bien los cambios de ramas, se acostumbra uno a ella muy rápidamente.
Gráficamente es la más atractiva, en mi opinión. También es bastante sencilla de usar, e integra perfectamente con Github.
Hecha por los creadores de Sublime editor. Me parece la aplicación más completa, e incluye un par de características que la hacen mejor que las anteriores:
- Búsqueda avanzada
- Posibilidad de resolver merge conflicts en la propia herramienta. Esto es MUY atractivo...
Estos serían los pasos para actualizar los cambios que haya en master
:
- Pull
master
- Ejecutar
flyway migrate
obd_migrar.sh
- Ejecutar
shell_scripts/compilar.sh
. Quizá haya que compilar antes, dependiendo de si los scripts de migración hacen llamadas a PL/SQL modificados. En ese caso, es conveniente separar los scripts de migración en 2 ficheros, uno con el DDL y DML sencillo (sólo SQL) y otro con los bloques de PL que usen paquetes.
Una vez que mantenemos el esquema de base de datos mediante Flyway, el desarrollador tiene una base de datos "en blanco", creada por los scripts DDL de las migraciones manejadas por Flyway (aunque Flyway ejecuta cualquier tipo de script, no sólo DDL)
Es conveniente que el desarrollador pueda generar de forma automática una serie de datos de prueba que le permitan usar la aplicación localmente para operar con ella.
Para esto, se podría escribir una serie de scripts SQL que se ejecutarían cada vez después de ejecutar las migraciones de Flyway, y que insertarían los datos necesarios. Esta estrategia es muy complicada de mantener, y es muy fácil cometer errores en este tipo de scripts, por lo que he optado por crear una aplicación en Django y usar su ORM y algunas librerías de terceros para hacerlo.
Django es un framework de desarrollo de aplicaciones gratuito y de código abierto basado en Python.
Django lleva muchos años en el mercado y hay muchas grandes empresas y aplicaciones que lo usan. Todo lo que necesites hacer ya está hecho o puede hacerse fácilmente con Django.
Usaremos Python 3.6 y Django 2.2.
La documentación sobre instalación de la guía oficial puede encontrarse aquí.
Es conveniente hacer algún tutorial sobre Django, o leer documentación, para entender lo que estamos haciendo.
Hay un muy buen tutorial en español en Django girls, que incluye también la instalación.
La página oficial tiene una documentación de gran calidad. En concreto, es muy conveniente leerse el ORM de Django.
La idea general es que el ORM de Django permite usar clases en Python (llamadas modelos) para acceder a la base de datos, sin tener que escribir SQL. Django, además, tiene herramientas que facilitarán mucho esta tarea.
Hay que crear un virtual environment
e instalar los requerimientos del proyecto en él. Hay un script crear_venv.sh
que realiza esto (aunque se puede hacer manualmente).
Para simplificar cosas es importante que el environment se llame oracleutils
.
Para que Django funcione adecuadamente con Oracle, hay que instalar, además los drivers de cliente de Oracle. En caso de mac OS, pueden encontrarse instrucciones aquí. Hay que bajar los drivers para la versión 12.2.0.1.0.
En resumen, una vez descargado el fichero zip, hay que hacer:
mkdir -p /opt/oracle
unzip instantclient-basic-macos.x64-12.2.0.1.0.zip
mkdir ~/lib
ln -s /opt/oracle/instantclient_12_2/libclntsh.dylib ~/lib/
Un site
Django habitualmente consta de varias aplicaciones
.
En nuesto caso hemos creado una aplicación central llamada oracleutils
, y crearemos una aplicación por módulo en la base de datos.
En el repositorio encontramos un directorio para module1
y module2
. Cada uno de estos directorios es una aplicación Django, y correspondería con un módulo de la aplicación monolítica.
Las settings
están en la aplicación central, oracleutils
, en el fichero settings.py
. En este fichero destaca la configuración de la conexión de la base de datos, que es:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.oracle',
'NAME': 'localhost:32769/ORCLCDB.localdomain',
'USER': 'C##LOCAL',
'PASSWORD': 'localpass',
},
}
Django viene con una serie de comandos de administación "de serie" que permiten hacer múltiples cosas.
Entre ellos está el comando inspectdb
. La sintaxis de este comando es:
django-admin inspectdb [table [table ...]]
El comando usa la conexión de base de datos especificada en las settings
y escribe en la salida estándar una clase Python para cada una de las tablas que se le indiquen.
Se usa este comando cuando se quiere usar Django contra una base de datos existente (como es el caso).
El comando vuelca todas las clases, que se suelen pegar en un fichero models.py
(en Python es habitual tener más de una clase en un fichero).
En nuestro caso hemos optado por crear un comando que genera un fichero por cada tabla, y además los almacena en un directorio models
dentro de cada aplicación.
Este comando se llama crear_datos_prueba
, y el código está en la aplicación oracleutils
. En esta aplicación, en el fichero __init.py
se observa:
from module1 import TABLES as MODULE1_TABLES
from module2 import TABLES as MODULE2_TABLES
ALL_TABLES = {
'module1': MODULE1_TABLES,
'module2': MODULE2_TABLES,
}
La variable ALL_TABLES
debe contener un diccionario con una entrada por aplicación. En cada entrada están la tablas de las que consta ese módulo.
Por ejemplo, en el __init__.py
de module1
se observa:
TABLES = [
'CUSTOMER',
'INVOICE',
'INVOICE_LINE',
]
Esa es la lista de tablas del module1
. Cuando se ejecute el comando crear_modelos
(bien directamente con manage.py
,
o bien con el script crear_modelos.sh
) se creará un fichero .py
en el directorio models
de la aplicación module1
por cada tabla.
Esos modelos no son gestionados por Django, lo que significa que las Django migrations no les afectan, pero sí puede usarse el ORM para acceder a las tablas, y eso es lo que haremos.
[Factory Boy)[https://factoryboy.readthedocs.io/en/latest/] es una librería Python que se integra con el ORM de Django para permitir generar datos (principalmente para tests) de forma sencilla.
Hemos creado un comando, crear_datos_prueba
, que puede ejecutarse mediante un script crear_datos_prueba.sh
que elimina opcionalmente toda la información de las tablas de cada aplicación y posteriormente inserta datos de prueba en las mismas.
Para ello se define una clase TestDataBuilder
por módulo. Esta clase extiende una case base BaseDataBuilder
. que define un método build
y otro clear
, que se ejecutan convenientemente por el comando.
class BaseDataBuilder:
def build(self):
raise NotImplemented
def clear(self):
raise NotImplemented
class Meta:
abstract = True
Cada aplicación (módulo) debe definir su TestDataBuilder
, implementando esos métodos. El método build
del TestDataBuilder
de cada aplicación se llamará por el comando de generación de datos de test.
El método build
usa un builder
, que a su vez usa Factories
creadas a partir de los modelos generados automáticamente con inspectdb
, y que se encuentran en un directorio factories
dentro de cada aplicación (módulo).
En Oracle es habitual usar secuencias para las claves primarias de las tablas. Las secuencias de Oracle habitualmente son números positivos. Para que no colisionen los datos de prueba generados automáticamente con lo que pueda generarse desde la aplicación en su funcionamiento normal, he decidido que los campos secuenciales generados por las factories serán enteros negativos. Para hacer esto, se define una factoría base en la aplicación central:
class BaseFactory(factory.django.DjangoModelFactory):
@classmethod
def _setup_next_sequence(cls):
return cls._meta.model.objects.count() + 1
El número de secuencia se calcula contando el número de filas existentes en la tabla, y sumando uno. Posteriormente, cuando se quiere asignar un número de secuencia en una factoría de factory boy, se hace lo siguiente:
class PersonFactory(BaseFactory):
class Meta:
model = Person
django_get_or_create = ('name', )
person_id = factory.Sequence(lambda n: -n)
name = factory.Faker('name', locale='es_ES')
El número de secuencia calculado (el número total más uno), se devuelve en negativo. Esto hace que la primera fila generada tenga un -1, la segunda un -2 y así sucesivamente.
Factory boy facilita la generación de datos aleatorios por defecto. Por ejemplo, para el nombre en la clase PersonFactory
usamos:
name = factory.Faker('name', locale='es_ES')
Esto nos genera (siempre que no se indique un name
en la llamada) un nombre aleatorio, usando el la localización española (los nombres y apellidos serán más o menos nombres españoles)
Este es um ejemplo de uso del builder para la generación de datos de prueba para un módulo:
class TestDataBuilder(BaseDataBuilder):
builder = Builder()
@transaction.atomic()
def build(self):
log.info("Building test data for module 2")
self.build_people()
self.build_things()
def build_people(self):
log.info("Building people")
self.person_no_things = self.builder.person(name='No things')
self.person_car = self.builder.person(name='Car')
self.person_house = self.builder.person(name='House')
self.person_both = self.builder.person(name='Both')
def build_things(self):
log.info("Building things")
self.builder.car(person=self.person_car, detail='Car Make')
log.info("Building things 1")
self.builder.house(person=self.person_house, detail='House address')
log.info("Building things 2")
self.builder.car(person=self.person_both, detail='Car Make')
log.info("Building things 3")
self.builder.house(person=self.person_both, detail='House address')
@transaction.atomic()
def clear(self):
log.info("Clearing data for module 2 ")
Car.objects.all().delete()
House.objects.all().delete()
Thing.objects.all().delete()
Person.objects.all().delete()
ThingType.objects.all().delete()
def build_test_data(clear=True):
builder = TestDataBuilder()
if clear:
builder.clear()
builder.build()
El método build_test_data
es el que se ejecuta para generar los datos de cada aplicación.
En el Builder
se definen los métodos para crear filas en todas las tablas, usando las factories de factory boy. Para cada modelo debe definirse una factory (manualmente), y el Builder
usa esas factories para generar datos más elaborados.
Hay determinadas tablas que contendrán información más o menos fija, que será usada por otras tablas. Estas tablas se llaman habitualmente tablas de códigos o tablas maestras.
En el ejemplo, en el module2
, tenemos una tabla de este tipo, llamada THING_TYPE
. Es interesante que estas tablas tengan insertados los datos por defecto para nuestro entorno de pruebas. Para esto, hemos creado una clase llamada MasterTables
donde se concentrará toda la información de las tablas maestras. Por ejemplo, en nuestra MasterTables
definimos:
class MasterTables:
THING_TYPE = {
'CAR': {'thing_type_code': 'CAR', 'name': 'Car'},
'HOU': {'thing_type_code': 'HOU', 'name': 'House'},
}
La clase tendrá un objeto Python por cada master table. En cada key
tendrá el código, y en el value
tendrá otro objeto con los campos de la tabla y sus valores por defecto.
A la hora de recuperar una fila de la tabla, haremos:
@functools.lru_cache(maxsize=128)
def get_thing_type(self, thing_type_code):
data = MasterTables.THING_TYPE[thing_type_code]
if data:
return ThingTypeFactory(**data)
return None
def thing(self, person=None, thing_type='CAR'):
log.info(f"Building thing {thing_type}")
thing_type_object = self.get_thing_type(thing_type)
...
lru_cache
hace que la primera vez que se ejecuta la función se cree la fila usando la factory, y en posteriores llamadas para el mismo código se devuelve el objeto creado, sin accesos adicionales a la base de datos.
He creado un procedimiento PL/SQL que, ejecutado en un entorno de producción, genera el código Python para una tabla maestra, de forma que sea sencillo volcar la información de una tabla a la clase MasterTables
.
CREATE OR REPLACE PROCEDURE export_data_to_python(p_table_name varchar2) IS
l_sql VARCHAR2(4000);
type t_fields is table of varchar2(255) index by binary_integer;
pk_fields t_fields;
all_fields t_fields;
BEGIN
select cc.column_name
bulk collect into pk_fields
from ALL_CONSTRAINTS c, ALL_CONS_COLUMNS cc
where c.table_name=p_table_name and constraint_type='P' and cc.constraint_name=c.constraint_name
order by cc.column_name;
select c.column_name
bulk collect into all_fields
from ALL_TAB_COLUMNS c
where c.table_name=p_table_name
order by c.column_name;
l_sql := 'DECLARE python varchar2(32000) := ''' || upper(p_table_name) || '={'' || chr(10); ' ||
'k varchar2(32000); v varchar2(32000); ' ||
'BEGIN FOR r IN (SELECT * FROM '||p_table_name|| ') LOOP ';
l_sql := l_sql || ' k := ''''; ';
l_sql := l_sql || ' v := ''''; ';
-- construir la key del dict
FOR i IN 1 .. pk_fields.COUNT loop
l_sql := l_sql || ' k := k || r.' || pk_fields(i) || '; ';
end loop;
-- construir el value del dict
FOR i IN 1 .. all_fields.COUNT loop
l_sql := l_sql || ' v := v || ''"' || lower(all_fields(i)) || '": "'' || r.' || all_fields(i) || ' || ''", '';';
end loop;
--TODO
l_sql := l_sql || 'python := python|| chr(9) || ''"'' || k || ''": { '' || v || ''}, '' || chr(10); ';
l_sql := l_sql || ' END LOOP; '
|| 'python := python || chr(10) || '' }'';'
|| ' dbms_output.put_line(python); ' ||
' END; ';
EXECUTE IMMEDIATE l_sql;
END;
Una vez se ejecuta el PL (en un entorno que tenga datos) así:
begin
export_data_to_python('THING_TYPE');
end;
Obtenemos en la salida de DBMS_OUTPUT
el código Python:
THING_TYPE = {
'CAR': {'thing_type_code': 'CAR', 'name': 'Car'},
'HOU': {'thing_type_code': 'HOU', 'name': 'House'},
}
Este código puede pegarse directamente en la clase MasterTables
, y puede usarse en la función correspondiente para obtener los datos, usando la caché que hemos comentado.
El cliente cx-Oracle permite la ejecución de procedimientos almacenados PL/SQL (procedimientos y funciones). Un buen tutorial puede encontrarse aquí
Para facilitar las llamadas a PL, he creado un wrapper en la clase BaseDataBuilder que permite realizar llamadas a procedimientos y funciones PL/SQL.
Algunos ejemplos de llamada son:
builder = TestDataBuilder()
# llamada a un procedimiento sin named arguments
builder.call_pl('pl1.procedure1', params=["parametro 1"])
# llamada a un procedimiento con named arguments
builder.call_pl('pl1.procedure1', kparams={"param1": "parametro 1"})
# llamada a una función sin named arguments
response = builder.call_pl('pl1.funcion1', params=["parametro 1"], return_type=str)
# llamada a una función con named arguments
response = builder.call_pl('pl1.funcion1', kparams={"param1": "parametro 1"}, return_type=str)
Si el método call_pl
recibe un parámetro return_type
entenderá que debe llamar a una función. En caso contrario se llamará a un procedimiento.
En las llamadas pueden mezclarse argumentos posicionales y con nombre al mismo tiempo.
Como ejemplo, se ha creado un PL que inserta filas en la tabla HOUSE, y un procedimiento que llama ese PL en la generación de datos de prueba, para generar 10 filas para la misma persona. En mi opinión siempre es más conveniente usar Django (factories y builders) para generar datos de test, pero a veces ya se dispone de PL que funcionan y realizan tareas que pueden reutilizarse tanto para pruebas como para generación de datos de prueba.
El método que llama al PL, en un bucle, es bastante fácil de leer:
def build_bunch_of_houses_using_pl(self):
log.info("Creating a bunch of houses using PL")
total = 10
for i in range(total):
thing_id = self.call_pl('pl1.create_example_house',
kparams={
'person_name_p': 'muchas casas',
'detail': f'house {i}',
},
return_type=int)
log.info(f"Created house {thing_id}")
En este caso estamos haciendo una llamada con parámetros con nombre, lo que facilita la posterior lectura del código. El PL (declaración) es:
function create_example_house(person_name_p varchar2, detail varchar2) return number;
Django está diseñado para realizar pruebas automáticas, de forma que cada vez que se ejecutan los tests, Django crea una base de datos vacía y aplica las database migrations para crear las tablas.
Cada test que Django ejecuta (basado en el Django TestCase) es una transacción, por lo que todos los datos que se crean o modifican se eliminan después del test.
En nuestro caso, como las tablas que vamos a usar se crean en la base de datos usando Flyway, Django no dispone de los scripts de migración para crear la base de datos de test, por lo que no podemos usar el proceso "normal" de ejecución de tests de Django.
Sin embargo, sí podemos aprovecharnos de la transaccionalidad de los TestCase de Django, creando un TestRunner
específico:
class NoDbTestRunner(DiscoverRunner):
""" A test runner to test without database creation/deletion """
def setup_databases(self, **kwargs):
pass
def teardown_databases(self, old_config, **kwargs):
pass
Este test runner tiene que especificarse cuando se ejecuten los tests, como se indica en el siguiente comando:
python3 manage.py test module2 --testrunner=oracleutils.NoDbTestRunner
Ese comando ejecutaría los tests para el module2, usando nuestro test runner, que no crea ni elimina ninguna base de datos, sino que usa la base de datos por defecto. Esto hay que tenerlo en cuenta a la hora de programar los tests, porque las tablas que usemos pueden tener datos, que podrían afectar a nuestros tests.
Hemos creado una clase base de tests para cada módulo, que proporciona acceso al Builder y al TestBuilder de cada módulo, como variables builder
y helper
.
La siguiente clase es un ejemplo de test de una función en un PL. La función es un simple count
de la tabla thing
para una persona.
class CountThingsTestCase(Module2TestCase):
def setUp(self):
self.person = self.builder.person()
# No es necesario un tearDown, porque cada test es una transacción de la
# que se hace rollback, pero aquí va un ejemplo
# def tearDown(self):
# self.person.thing_set.all().delete()
# self.person.delete()
def perform_call(self):
return self.helper.call_pl(
package_and_method='pl1.count_person_things',
params=[self.person.person_id],
return_type=int,
)
def test_person_with_things(self):
things_number = 3
for i in range(things_number):
self.builder.thing(person=self.person)
self.assertEqual(
self.perform_call(),
things_number,
)
def test_person_without_things(self):
self.assertEqual(
self.perform_call(),
0,
)
Lo interesante de este test es que en el método setUp
, que se ejecuta antes de cada test, se crea una persona (usando el builder del módulo), que se almacena en el objeto de test, y se elimina al hacer rollback después del test, porque la clase Module2TestCase
hereda de Django TestCase
, que realiza una transacción por cada test.
Nótese que las llamadas a PL se realizan mediante el helper
, que es el TestDataBuilder
del módulo.
Al ser un TestCase
también podemos usar los métodos assert*
para realizar las comprobaciones en los tests.