lunes, 30 de diciembre de 2019

Sitio estático multi-lenguaje con webpack

Recientemente, he tenido que configurar una plantilla de sitio web estático (digo plantilla porque supuestamente servirá como scaffolding a futuros proyectos, pero seguro quedará obsoleta en 2 días y habrá que modificarla, veremos a posteriori si sirvió a más de un proyecto). El sitio web no es una SPA, es una MPA (Multiple-Page Application) y, además, era un requisito indispensable la traducción a múltiples lenguajes y que fuera en tiempo de compilación, por SEO. Siendo así, y después de no encontrar nada en google ya hecho y que me gustara, pensé que sería fácil y rápido hacerlo con webpack, pero craso error, me ha llevado más tiempo de lo que suponía y he acabado (no sé si con razón o sin razón), buceando en exceso en la documentación, y probando con el método ensayo-error el resultado de la compilación. La conclusión que saco de todo esto es que en el front me siento como un conductor novel que está configurando un vehículo con un excesivo y apabullante número de extras disponibles. De hecho, y por casualidad, hace unas semanas contesté a esta encuesta https://stateofjs.com/ y viendo ahora los resultados más del 50% contestamos que sí, que "La creación de aplicaciones JavaScript es demasiado compleja en este momento" https://2019.stateofjs.com/opinions/#building_js_apps_overly_complex, aunque claro, más del 50% eramos full-stack https://2019.stateofjs.com/demographics/#jobTitle así que lo mismo el problema no es javascript, si no gente que le da a todo y así no se puede (yo me incluyo por si no ha quedado claro).

En este post, me quiero centrar en las decisiones que he tomado en relación con la MPA y a la traducción. Para el resto es más fácil y seguro leer la documentación de webpack.

El repositorio con la plantilla está en https://github.com/panicoenlaxbox/webpack-static-site-template

Si tenemos una MPA, tendremos varios entries y por cada uno de ellos podremos decidir que bundles queremos incluir, la idea está sacada de https://webpack.js.org/guides/entry-advanced/

entry: {
index: [
    "./src/index.js",
    "./src/styles/index.scss",
    "selectric/public/selectric.css"
],
about: ["./src/about.js", "./src/styles/about.scss"]
},
plugins: [
new HtmlWebpackPlugin({
    filename: path.join(translation.dist, "index.html"),
    template: "src/index.html",
    chunks: ["index", "vendor"]
}),
new HtmlWebpackPlugin({
    filename: path.join(translation.dist, "about.html"),
    template: "src/about.html",
    chunks: ["about", "vendor"]
}),

Fijarse que cada entry especifica los estilos, .scss por lo que no usa require como dependencia en el .js. Además, cada nueva página debería ir acompañada de una nueva entry y una nueva instancia de HtmlWebpackPluginhttps://github.com/jantimon/html-webpack-plugin#generating-multiple-html-files.

Por otro lado, ya ha aparecido el objeto translation. Aunque se usa i18n-webpack-plugin para traducir las claves de los ficheros .js, también hay claves de traducción en ficheros .html y ahí el reemplazo lo he resuelto con este otro plugin html-string-replace-webpack-plugin-webpack-4.

new HtmlStringReplace({
    patterns: [
    {
        match: /__(.+?)__/g,
        replacement: (match, $1) => translation.translation[$1]
    }
    ]
}),

Como que el sitio es estático, quería obtener en la raíz de dist/ la versión del lenguaje neutro y luego una carpeta / por cada lenguaje soportado. Para ello, he usado una compilación mútiple (la idea está sacada de https://survivejs.com/webpack/techniques/i18n/) y luego unas tareas extras para ajustarlo todo.

Que webpack devuelve una función en vez de un objeto, se puede ver en https://webpack.js.org/configuration/configuration-types/. Esto da mucho juego y abre distintas posibilidades.

module.exports = (env, argv) => {
  const isProduction = argv.mode === "production";
  return translations.map(translation => {
    return {
      entry: {

translations es un objeto que lee los mismos ficheros que usa … y agrega algunas propiedaes útil para la compilación.

[ { language: 'en',
    translation:
     { language: 'en',
       title: 'My static site template',
       message: 'My message' },
    default: true,
    dist: 'C:\\Temp\\webpack-static-site-template\\dist' },
  { language: 'es',
    translation:
     { language: 'es',
       title: 'Mi plantilla de sitio estático',
       message: 'Mi mensaje' },
    default: false,
    dist: 'C:\\Temp\\webpack-static-site-template\\dist\\es' } ]

Ahora cada vez que webpack emite un bundle lo hace teniendo en cuenta el lenguaje:

output: {
  path: translation.dist,
  filename: `[name].${translation.language}${
      isProduction ? ".[contenthash]" : ""
  }.js`
}

Como estamos compilando lo mismo varias veces (con la única diferencia del lenguaje), acabaremos por tener en dist/ algo válido pero un poco feo y repetitivo, se puede mejorar haciendo algunos reemplazos (que por otro lado no me gusta hacer, es como hackear el sistema, de largo es lo que más oscuro me parece).

new HtmlStringReplace({
    patterns: [
        {
            match: /(<img src=")(?!(\/\/|https?:\/\/|data:image))/gi,
            replacement: (match, $1) => `${$1}/`
        }
    ]
}),
new HtmlStringReplace({
    enable: !translation.default,
    patterns: [
        {
            match: /(<link href=")(?!(\/\/|https?:\/\/))/gi,
            replacement: (match, $1) => `${$1}../`
        },
        {
            match: /(<script type="text\/javascript" src=".*?)(?=vendor\.)/gi,
            replacement: (match, $1) => {
            return $1.substring(0, $1.lastIndexOf('"') + 1) + "/";
            }
        }
    ]
}),

Y borrando por último, lo que no queremos en un hook del ciclo de compilación:

new EventHooksPlugin({
    done: () => {
        if (!translation.default) {
            exec(`rimraf \"dist/${translation.language}/!(*.html|*.js)\"`);
            exec(`rimraf \"dist/${translation.language}/vendor*.js"`);
        }
    }
})

Después de esto, acabaremos con una estructura como la siguiente:

│   about.css
│   about.en.js
│   about.html
│   index.css
│   index.en.js
│   index.html
│   vendor.css
│   vendor.js
└───es
        about.es.js
        about.html
        index.es.js
        index.html

Un saludo!

viernes, 6 de diciembre de 2019

High-order Observables (Angular) y ASP.NET Core

ReactiveX es una API para la programación asíncrona con Observables. Su implementación en Javascript es RxJS y en Angular (no sé en otros frameworks), se usa y se usa mucho, no puedes pasarlo por alto. Este post (al que luego volveré a hacer mención) empieza con un párrafo bastante lapidario "Like it or not, rxjs is a critical component of modern Angular development. Although it is perfectly possible to use Angular 2+ without using observables, you lose out on an enormous amount of functionality. The reactive pattern is extremely powerful, and once you get over the, admittedly rather high, learning curve the grass is definitely greener on the other side.". Pues así me siento yo, en mi opinión, Angular es un framework con una curva de aprendizaje medio-alta, si a eso le sumamos Redux, en su sabor Angular vía NgRx, la cosa se complica un poco/bastante más, pero todo tiene un denominador común... RxJS. De hecho, el lema de NgRx es "Reactive State for Angular", y Reactive es sinónimo de RxJS. Queda claro que RxJS debe ser importante.

En este sentido, hay un concepto de RxJS que me parece especialmente importante y son los high-order Observables. Normalmente, trabajamos con Observables que emiten valores de tipos básicos (strings, numbers, tipos de usuario, etc.), a estos se les llama first-order Observables, pero si un Observable emite, a su vez, Observables, estamos hablando de high-order Observables (es igual que cuando tenemos una función que recibe o devuelve otra función y hablamos de high-order functions, de ahí habrán sacado el nombre, digo yo).

Llegar a encontrarse en el código high-order Observables no es excepcional, bastaría un ejemplo sencillo como el siguiente, donde en la suscripción recibimos un Observable y no un number ¿Qué hago yo ahora? Yo quería recibir un number.

import { Observable, of } from "rxjs";
import { map } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    map<number, Observable<number>>((n: number) => of(n * n))
  )
  .subscribe((n: Observable<number>) => console.log(n));

// Observable { _isScalar: true, _subscribe: [Function], value: 1 }
// Observable { _isScalar: true, _subscribe: [Function], value: 4 }
// Observable { _isScalar: true, _subscribe: [Function], value: 9 }

Podemos resolverlo creando una suscripción anidada, pero es un anti-pattern. Lo es porque perdemos el control de cuando cancelar la suscripción anidada y además recuerda (sospechosamente) al famoso call-back hell. Si te encuentras dos susbcribe anidados, tienes un problema. Y no lo digo yo, lo dice Deborah Kurata, que sabe bastante más que yo.

import { Observable, of } from "rxjs";
import { map } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    map<number, Observable<number>>((n: number) => of(n * n))
  )
  .subscribe((n: Observable<number>) =>
    n.subscribe((i: number) => {
      console.log(i);
    })
  );

// 1
// 4
// 9

Y ¡ojo!, no vale hace trampas, aunque no tengamos dos subscribe juntos, hay otras formas más enrevesadas de tener nested suscriptions.

import { Observable, of } from "rxjs";
import { map, tap } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    map<number, Observable<number>>((n: number) => of(n * n)),
    tap<Observable<number>>((o: Observable<number>) => {
      o.subscribe((i: number) => {
        console.log(`inner ${i}`);
      });
    })
  )
  .subscribe((n: Observable<number>) => console.log(`outer ${n}`));

// inner 1
// outer [object Object]
// inner 4
// outer [object Object]
// inner 9
// outer [object Object]

He aprovechado este último ejemplo para introducir dos nuevas palabras que es importante tener en cuenta, source u outer Observable e inner Observable. El Observable al que nos suscribimos se llama outer Observable y, a los Observables que emite (por eso un high-order Observable) se les llama inner Observable.

También quiero aprovechar a hacer el disclaimer, de que en este post seré muy verbose con la firma de los métodos porque es una manía que tengo cuando estoy aprendiendo, es decir, lo anterior es equivalente a este otro código:

of(1, 2, 3)
  .pipe(
    map(n => of(n * n)),
    tap(o => {
      o.subscribe((i: number) => {
        console.log(`inner ${i}`);
      });
    })
  )
  .subscribe(n => console.log(`outer ${n}`));

Volviendo al problema original, ¿cómo trabajar entonces con high-order Observables? Pues hay que convertir un high-order Observable en un first-order Observable. Eso se hace con flattenig (aplastamiento, su traducción más o menos acertada al español). Estos operadores permitirán que consumamos los inner Observable como tipos básicos y, además, y esto es muy importante, gestionarán de forma automática la suscripción y cancelación al inner Observable.

Por ejemplo, con concatAll podemos resolver el problema inicial.

import { Observable, of } from "rxjs";
import { map, concatAll } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    map<number, Observable<number>>((n: number) => of(n * n)),
    concatAll<number>(),
  )
  .subscribe((n: number) => console.log(n));

// 1
// 4
// 9

Y podemos hacerlo un poco mejor, si usamos el operador concatMap, que es la suma de map y concatAll (al igual que sucede por ejemplo en vanilla Javascript con map y flat, que se combinan en flatMap)

import { Observable, of } from "rxjs";
import { map, concatAll, concatMap } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    concatMap<number, number>((n: number) => of<number>(n * n))
  )
  .subscribe((n: number) => console.log(n));

// 1
// 4
// 9

Amigo de concatMap, tenemos también a switchMap (un clásico), mergeMap y exhaustMap.

Lo más importante de entender es que todos estos operadores responden a la misma pregunta ¿Qué hacer si el outer Observable vuelve a emitir y el inner Observable todavía está trabajando? Es decir, ¿Qué hacer si se solapan emisiones del outer Observable con el inner Observable? Si pasa el suficiente tiempo entre emisiones del outer Observable como para que la suscripción y cancelación del inner Observable ya haya acabado, podemos poner lo que queramos, da igual, se va a comportar de la misma forma. Sin embargo, si el inner Observable todavía esta trabajando ¿qué hacer con el trabajo actual del inner? Aquí es donde el post que mencionaba al principio http://alanpryorjr.com/2019-05-15-rxjs-flattening-operators/ me parece genial porque (sin código, importante y felicito por ello al autor) explica con una analogía de jefe-empleado como se comportará para operador de los mencionados en el hipotético caso de que un jefe (outer Observable) manda tareas (emite) a un empleado (flatennig operator) que todavía no ha acabado la anterior (inner Observable).

Si lo llevamos a un escenario más concreto, como llamar a una API si el usuario hace click en un botón (y asumiendo no hemos tenido a bien, deshabilitar el botón después de un click, que sería lo suyo), tenemos lo siguiente:

  • switchMap. Cancela petición en curso y vuelve a llamar a la API.
  • concatMap. Cuando acabe la petición en curso, hará otra llamada. Es una cola. Ademas, se respetará el orden y no llamada hasta no acabar con la anterior (esto es importante porque queremos garantizar que en el back se procesen en orden, que se hagan las peticiones desde cliente en orden no garantiza que en el back se procesen en el mismo orden, por eso se espera a que termine una petición para lanzar la siguiente).
  • mergeMap. A la vez que la petición en curso, se lanzará una nueva llamada, en paralelo.
  • exhaustMap. No hará nada si hay una petición en curso.

En este ejemplo https://stackblitz.com/edit/angular-nkgfyr está recogido lo anterior, y usando https://www.mocky.io/ podemos simular un delay para forzar a que el inner Observable esté trabajando cuando volvamos a hacer click en el botón. Es muy importante tener abierta la pestaña network de las developer tools para ver como se cancelan las peticiones en curso en función del operador elegido. En el ejemplo, se usan switchMapTo, concatMapTo y mergeMapTo, que son iguales a sus versiones sin "To", sólo que no necesitan un parámetro de entrada.

Llegando al final y, puesto que hemos cancelado peticiones, ¿Qué podemos hacer en el back para aprovecharnos de este comportamiento? Pues usar CancellationToken y propagarlo en todos los métodos asíncronos que lo permitan (por ejemplo, EF, Dapper, MediatR, etc.). Porque es muy bonito que el cliente cancele una petición (le honra), pero si en back seguimos procesando las peticiones, sólo uno estará haciendo lo correcto, el otro seguirá sin percatarse de que está trabajando para nada y que ya a nadie le importa el resultado (triste pero cierto).

En ASP.NET Core (y haciendo una API identica a la que hemos consumido anteriormente), simplemente añadiendo el parámetro CancellationToken al método de acción y pasándoselo a Task.Delay hará la magia de lanzar una excepción del tipo System.Threading.Tasks.TaskCanceledException si el cliente cancela la petición.

[HttpGet]
public async Task<ActionResult<IEnumerable<User>>> Get(CancellationToken cancellationToken, int delay = 0)
{
    await Task.Delay(delay, cancellationToken);
    return new[]
    {
            new User() {Id = 1, Name = "Sergio", Email = "panicoenlaxbox@gmail.com"},
            new User() {Id = 2, Name = "Carmen", Email = "panicoenel20@gmail.com"}
        };
}

Un saludo!

martes, 14 de mayo de 2019

Tooling en SQL

No es ningún secreto que la programación con SQL no dispone de un tooling a su altura. Si bien es cierto que tenemos una excelente herramienta como SSMS, por el contrario, si estás acostumbrado al desarrollo en C#, javascript o cualquier otro lenguaje, tarde o temprano querrás imitar el escenario al que estás acostumbrado, esto es, linting, guías de estilo de código, integración continua, etc.

Lógicamente, si usamos SQL sólo de forma esporádica o siempre embebido en C#, el asunto sería distinto, pero si te toca lidiar con un montón de procedimientos almacenados, funciones y demás objetos de SQL Server, en mi opinión se le empiezan a ver las costuras al flujo de desarrollo.

Con sinceridad, tampoco es que la solución propuesta aquí sea la panacea, pero es un intento de poner algo de orden y evitar que lo inevitable suceda. Además, personalmente descarto (por ahora) el uso de herramientas profesionales como redgate, ApexSQL o devart, pero por otro lado, son una incuestionable fuente de inspiración para intentar copiar o imitar (que suena mejor) algunas funcionalidades que pueden ser muy útiles.

El propósito del post será llegar a tener un linter, un formateador y un git hook, todo ello en el contexto de código T-SQL.

Para el linter (y habiendo descartado SonarQube, porque el analizador de T-SQL es de pago) nos quedan pocas opciones. Lo mejor que hemos encontrado es una extensión de VSCode llamada tsqlint. Dentro de VSCode funciona muy bien, aunque la única pega que le pongo es que el fichero de configuración donde activamos o desactivamos reglas (.tsqllintrc) tiene que estar en %USERPROFILE%, eso hace un poco más difícil el “clonar el repo y listo”, pero bueno, no habiendo más oferta, doy las gracias por esta herramienta.

Aunque tengamos el linter en VSCode, parece mejor opción instalar globalmente el paquete vía npm con npm install tsqllint -g. Ahora podemos crear el fichero .tsqllintrc con tsqllint --init. Y lo más importante, ahora podemos usar el linter desde línea de comandos (lo que abre la puerta a integración continua).

Para los ejemplos voy a usar pubs (que aunque me dicen es antigua, es sencilla y hasta un niño la entiende).

Probemos con una instrucción sencilla:

SELECT j.job_id,
    j.job_desc,
    e.emp_id,
    e.fname,
    e.lname,
    e.job_id,
    e.hire_date
FROM jobs j
INNER JOIN employee e ON j.job_id = e.job_id
ORDER BY j.job_desc,
    e.fname,
    e.lname

Y al pasar el linter

C:\Temp\test>tsqllint example.sql
example.sql(8,7): error schema-qualify : Object name not schema qualified.
example.sql(9,13): error schema-qualify : Object name not schema qualified.
example.sql(12,16): warning semicolon-termination : Statement not terminated with semicolon.

Linted 1 files in 0,2461205 seconds

2 Errors.
1 Warnings

tssqlint permite crear plugins de una forma sencilla y además usa C#, así que no hay excusa.

Nuestro plugin lo que hará es buscar la palabra clave UNION y luego ya darlo como warning, error o no reportarlo, según queramos, eso ya es configuración de cada uno.

El código del plugin (lo relevante) es este:

    public class MyTSqlLintUnionPlugin : IPlugin
    {
        public void PerformAction(IPluginContext context, IReporter reporter)
        {
            string line;
            var lineNumber = 0;

            var reader = new StreamReader(File.OpenRead(context.FilePath));

            while ((line = reader.ReadLine()) != null)
            {
                lineNumber++;
                var regex = new Regex(@"\s*UNION\s*", RegexOptions.IgnoreCase);
                var match = regex.Match(line);
                if (match.Success)
                {
                    var column = match.Index;
                    reporter.ReportViolation(new RuleViolation(
                        context.FilePath,
                        "union",
                        "UNION is forbidden",
                        lineNumber,
                        column,
                        RuleViolationSeverity.Warning));
                }
            }
        }
    }

Si ahora pasamos el linter a este SQL, ¡tenemos un UNION no permitido!

SELECT 1
UNION
SELECT 2;
C:\Temp\test>tsqllint sergio.sql
Loaded plugin: 'MyTSqlLintPlugin.MyTSqlLintUnionPlugin', Version: '1.0.0.0'
sergio.sql(2,0): warning union : UNION is forbidden.

Linted 1 files in 0,2557418 seconds

0 Errors.
1 Warnings

Visto esto, el único pero que le saco a la extensibilidad (o a mi implementación, mejor dicho) es el uso de expresiones regulares, lo mismo hoy son una solución, pero ya se sabe que mañana…

En cualquier caso, aceptamos barco, y además integrar tsqllint en SSMS también es posible como una herramienta externa.

Llegados aquí, ¡tenemos linter!, pero si queremos ir un paso más allá y en vez de trabajar con expresiones regulares, usar el parser que usa el propio SQL Server (para por ejemplo, dar un warning en un DELETE sin WHERE, que es una de las reglas de SonarQube, por cierto) podemos hacerlo con Microsoft.SqlServer.DacFx.x64.

var parser = new TSql140Parser(true);
            using (var reader = new StringReader(@"
SELECT * FROM authors;
DELETE jobs --WHERE min_lvl > 10
;"))
            {
                var result = parser.Parse(reader, out var errors) as TSqlScript;
                foreach (TSqlBatch batch in result.Batches)
                {
                    foreach (var statement in batch.Statements.OfType<DeleteStatement>())
                    {
                        if (statement.DeleteSpecification.WhereClause == null)
                        {
                            Console.WriteLine("¡DELETE sin WHERE, insensato!");
                        }
                        break;
                    }
                }
            }

Si probamos a habilitar o deshabilitar el comentario con la condición del DELETE vemos que funciona. Lógicamente, habría que hacer una herramienta de línea de comandos con un código de salida para poder usarla en la build, pero por ahora con saber que se puede hacer (y como un señor con un parser potente) parece suficiente y posibilita un futuro lleno de oportunidades.

En cuanto al formateo de SQL, es decir, una guía de estilo, tampoco hay mucho donde elegir. Si ves lo que tiene por ejemplo devart, https://sql-format.com/, es flipante, sin más, por eso viven de ello. Pero alternativas open-source no hay muchas, o al menos no hay muchas que admitan cierto grado de configuración. En nuestro caso, no hemos decantado por http://poorsql.com/, tiene muy buena pinta, pero la verdad es que el proyecto parece un poco abandonado. No obstante, es lo mejor que hemos encontrado y no parece que formatear SQL sea algo que esté cambiando todos los días.

Cabe mencionar que hemos descartado el formateo que hace el plugin oficial de SQL Server en VSCode, por eso, porque sólo lo hace en VSCode. Tiene competencia con este otro, pero ninguno es invocable por línea de comandos (o al menos yo no sé).

Con el tema del formateo tengo la sensación de que vamos a tener que contentarnos con lo que haya y no pedir peras al olmo.

Usando poorsql pasaríamos de esto (escrito por una persona con muy mala baba, la verdad sea dicha).

IF (1 =1    ) BEGIN
 SELECT j.job_id, j.job_desc,
  e.emp_id,  e.fname,
  e.lname,e.job_id,
  e.hire_date
 FROM jobs j  INNER 
 JOIN employee e ON 
 j.job_id = e.job_id
 ORDER BY j.job_desc,
    e.fname, e.lname END

a esto otro

IF (1 = 1)
BEGIN
 SELECT j.job_id,
  j.job_desc,
  e.emp_id,
  e.fname,
  e.lname,
  e.job_id,
  e.hire_date
 FROM jobs j
 INNER JOIN employee e ON j.job_id = e.job_id
 ORDER BY j.job_desc,
  e.fname,
  e.lname
END

Aunque podríamos integrarlo como comando en VSCode y en SSMS como herramienta externa, para finalmente poner todo en orden, usaremos node y los scripts de npm (que además sería equivalente a lo que sucedería en el servidor de integración continua).

Para el git hook de pre-commit vamos a usar husky.

"husky": {
    "hooks": {
      "pre-commit": "node index.js ./**/*.sql"
    }
  }

Lo que pasará ahora es que cada vez que hagamos un commit, se va a ejecutar el fichero index.js con un glob pattern para todos los ficheros .sql.

index.js es el encargado de ver en que ficheros .sql ha habido cambios y están en zona de staging, para entonces pasar el linter y el formateador a cada uno de ellos y si algo falla, abortar el commit.

const { execSync } = require("child_process");
const glob = require("glob");

// 0: C:\Program Files\nodejs\node.exe
// 1: C:\Temp\test\index.js
var args = process.argv.slice(2);
args.forEach(function(arg) {
  const files = getFiles(arg);
  files.forEach(function(file) {
    if (!formatFile(file)) {
      process.exit(1);
    }
    if (!lintFile(file)) {
      process.exit(1);
    }
    stageFile(file);
  });
});

function getFiles(pattern) {
  var files = glob.sync(pattern);
  var stagedFiles = getStagedFiles();
  return files.filter(file => stagedFiles.includes(file));
}

function formatFile(file) {
  console.log(`formatting ${file}`);
  return executeCommand(`npm run sqlformat -- -f ${file} -g ${file}`);
}

function lintFile(file) {
  console.log(`linting ${file}`);
  return executeCommand(`npm run tsqllint -- ${file}`);
}

function stageFile(file) {
  console.log(`adding ${file} to index`);
  return executeCommand(`git add ${file}`);
}

function executeCommand(command) {
  try {
    execSync(command);
    return true;
  } catch (error) {
    console.log(`stderr ${error.stdout.toString()}`);
    return false;
  }
}

function getStagedFiles() {
  var output = execSync(
    `git diff --cached --name-only --diff-filter=ADMR`
  ).toString();
  return output;
}

La verdad es que explicado por escrito parecen muchas operaciones a seguir, pero bien montado puede suponer una pequeña mejora en el flujo de trabajo con SQL (aquí tienes el código subido en github).En cualquier caso, me frustra que algo tan transversal como SQL no tenga (al menos yo no he encontrado nada) otros mecanismos para poder mirar de igual a igual a otras tecnologías en cuanto a tooling se refiere.

martes, 6 de marzo de 2018

Servicios comunes en aplicación de consola .NET Core

Si usamos ASP.NET Core, es fácil que estemos acostumbrados a que los servicios comunes de la plataforma estén allí o al menos, que sea muy fácil incluirlos y consumirlos. Sin embargo, con una aplicación de consola monda y lironda, tenemos un triste Main y puede ser que incluir DI, logging o configuración se haga un poco cuesta arriba (al menos a mí se me hizo).

En cualquier caso, se tiene que poder hacer, porque ya sabemos que una aplicación ASP.NET Core es al fin y al cabo una aplicación de consola (aunque ASP.NET Core es también un framework y hace uso de IoC para facilitar nuestro trabajo). Sin embargo, con File > New > Project… > Console App estamos solos contra el mundo.

Por servicios comunes se entienden DI (bueno, estrictamente hablando un contenedor de IoC), logging y configuración, servicios a los que no debemos renunciar en una aplicación de consola, o al menos no renunciar si no queremos.

Después de escribir el post, me chivaron por twitter que en .NET Core 2.1 habrá importantes cambios en cuanto a crear un host en una aplicación de consola. Puedes leer más información en Having Fun with the .NET Core Generic Host y desde el propio github del equipo de producto.

Yo por mi parte he actualizado el repositorio de github con un ejemplo idéntico al aquí expuesto pero funcionando con el nuevo host genérico, así se pueden comparar ambos. En cualquier caso, el post seguía aquí...

Lo primero que vamos a ver es la configuración.

var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");

var builder = new ConfigurationBuilder()
    .SetBasePath(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location))
    .AddJsonFile("appsettings.json", optional: true)
    .AddJsonFile($"appsettings.{environment}.json", optional: true)
    .AddEnvironmentVariables()
    .AddCommandLine(args);

Inicialmente, para establecer la ruta base de la configuración usaba Directory.GetCurrentDirectory(), pero de nuevo en twitter me dijeron que mejor no asumir que el cwd (current working directory) es el mismo directorio que donde esté el ejecutable.

Para que no explote nada, tenemos que agregar en la raíz del proyecto un fichero con el nombre appsettings.json, por ejemplo:

{
  "Logging": {
    "IncludeScopes": false,
    "Debug": {
      "LogLevel": {
        "Default": "Warning"
      }
    },
    "Console": {
      "LogLevel": {
        "Default": "Debug"
      }
    }
  },
  "Foo": {
    "Bar": "Baz"
  }
}

Es importante poner “Copy if newer” en “Copy to Output Directory” para que se copie el fichero en el directorio \bin.

Además, toca agregar paquetes porque en una aplicación de consola empezamos con un .csproj más limpio que una patena.

dotnet add package Microsoft.Extensions.Configuration.FileExtensions
dotnet add package Microsoft.Extensions.Configuration.Json
dotnet add package Microsoft.Extensions.Configuration.EnvironmentVariables
dotnet add package Microsoft.Extensions.Configuration.CommandLine

Ahora vamos con el logging.

var loggerFactory = new LoggerFactory()
    .AddConsole(configuration.GetSection("Logging:Console"))
    .AddDebug();

Y de nuevo toca agregar los paquetes oportunos.

dotnet add package Microsoft.Extensions.Logging.Console
dotnet add package Microsoft.Extensions.Logging.Debug

La verdad es que en este punto ya podríamos usar tanto la configuración como el logging.

var logger = loggerFactory.CreateLogger<Program>();
logger.LogWarning("Hello World!");

Sin embargo, nos falta hacer DI para poder sacar todo el jugo a nuestra aplicación de consola.

Forzando un poco el ejemplo y para ver después cómo es posible usar patrón de opciones creamos la clase Foo que recogerá la configuración que agregamos antes en el fichero appsettings.json

class Foo
{
    public string Bar { get; set; }
}

Ahora ya sí, creamos el contenedor.

IServiceCollection services = new ServiceCollection();
            
services
    .AddSingleton(loggerFactory)
    .AddLogging();
            
services
    .AddSingleton(configuration)
    .AddOptions()
    .Configure<Foo>(configuration.GetSection("Foo"));

var serviceProvider = services.BuildServiceProvider();

De nuevo, estos son los paquetes que hay que instalar.

dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Options.ConfigurationExtensions

En este punto, toda la infraestructura está lista, pero ¿Cómo arrancamos nuestra aplicación de consola? Pues creando una clase App e invocando su método Run (tanto App como Run parecen nombres comúnmente usados pero eres libre de elegir cualquier otro). Lógicamente, nos toca registrar la clase App en el contenedor y resolverla antes de poder usarla.

Todo el código junto y disponible en un repositorio de github

using System;
using System.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");

            var builder = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile("appsettings.json", optional: false)
                .AddJsonFile($"appsettings.{environment}.json", optional: true)
                .AddEnvironmentVariables()
                .AddCommandLine(args);

            var configuration = builder.Build();

            var loggerFactory = new LoggerFactory()
                .AddConsole(configuration.GetSection("Logging:Console"))
                .AddDebug();

            IServiceCollection services = new ServiceCollection();

            services
                .AddSingleton(loggerFactory)
                .AddLogging();

            services
                .AddSingleton(configuration)
                .AddOptions()
                .Configure<Foo>(configuration.GetSection("Foo"));

            services.AddTransient<App>();

            var serviceProvider = services.BuildServiceProvider();

            var app = (App)serviceProvider.GetService(typeof(App));
            app.Run();
        }
    }

    class Foo
    {
        public string Bar { get; set; }
    }

    class App
    {
        private readonly ILogger<App> _logger;
        private readonly Foo _foo;

        public App(ILogger<App> logger, IOptions<Foo> foo)
        {
            _logger = logger;
            _foo = foo.Value;
        }
        public void Run()
        {
            _logger.LogDebug(_foo.Bar);
        }
    }
}

Un saludo!

lunes, 26 de febrero de 2018

Find vs First vs Single

Hay varios métodos de LINQ que, a priori, parecen sirven al mismo propósito, pero que al fijarnos en detalle vemos son muy distintos, más si cabe si estamos usando LINQ to Entities, me estoy refiriendo a First, FirstOrDefault, Single, SingleOrDefault y Find (método que de hecho sólo está disponible en DbSet<>).

Find es el método que deberíamos casi siempre intentar usar porque es el único que podría evitar un round-trip a la base de datos. Find primero busca en el contexto y si no encuentra nada, es entonces cuando ejecuta una consulta a la base de datos. En caso de ir a base de datos y volverse con las manos vacías, devuelve null. Por contra, Find recibe un params object[] keyValues, con lo que si la clave es compuesta podría no ser muy intuitivo y propenso a errores.

Sólo se me ocurre no usar Find si usamos por ejemplo Include, ya que Include es un método de extensión de IQueryable<> y Find devuelve directamente una entidad.

Por otro lado, First, FirstOrDefault, Single y SingleOrDefault siempre ejecutarán una consulta a la base de datos, aunque la entidad que fueran a materializar estuviera ya disponible en el contexto. Que siempre vaya a la base de datos no significa que devuelva los valores que estén en la base de datos ya que, de estar disponible en el contexto, nos devolverá la instancia previamente materializada, esto es lo que se denomina en terminología de ORMs, IdentityMap.

Otra gran diferencia es que Single lanzará una excepción si encuentra 2 o más registros, así que no hay se debería usar First cuando en realidad se quería usar Single o, mejor aún, Find.

Con el siguiente código se puede ver todo lo expuesto.

using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            using (var context = new ShopContext())
            {
                context.Database.EnsureDeleted();
                context.Database.EnsureCreated();
                context.Orders.Add(new Order() { Id = 1, CustomerPurchaseOrder = "foo" });
                context.Orders.Add(new Order() { Id = 2, CustomerPurchaseOrder = "foo" });
                context.SaveChanges();
            }

            using (var context = new ShopContext())
            {
                var order = context.Orders.Find(1);
                //exec sp_executesql N'SELECT TOP(1) [e].[Id], [e].[CustomerPurchaseOrder]
                //FROM [Orders] AS [e]
                //WHERE [e].[Id] = @__get_Item_0',N'@__get_Item_0 int',@__get_Item_0=1

                order.CustomerPurchaseOrder = "panicoenlaxbox"; // Identity Map

                order = context.Orders.Find(1); // no db query

                Console.WriteLine(order.CustomerPurchaseOrder); //panicoenlaxbox

                order = context.Orders.Find(3);                
                //exec sp_executesql N'SELECT TOP(1) [e].[Id], [e].[CustomerPurchaseOrder]
                //FROM [Orders] AS [e]
                //WHERE [e].[Id] = @__get_Item_0',N'@__get_Item_0 int',@__get_Item_0=3

                Console.WriteLine(order == null); // True

                order = context.Orders.First(o => o.Id == 1);
                //SELECT TOP(1) [o].[Id], [o].[CustomerPurchaseOrder]
                //FROM [Orders] AS [o]
                //WHERE [o].[Id] = 1

                Console.WriteLine(order.CustomerPurchaseOrder); //panicoenlaxbox

                try
                {
                    order = context.Orders.First(o => o.Id == 3);
                }
                catch (Exception e)
                {
                    Console.WriteLine(e.Message); // Sequence contains no elements
                }

                order = context.Orders.FirstOrDefault(o => o.Id == 3);
                //SELECT TOP(1) [o].[Id], [o].[CustomerPurchaseOrder]
                //FROM [Orders] AS [o]
                //WHERE [o].[Id] = 2

                Console.WriteLine(order == null); // True

                order = context.Orders.Single(o => o.Id == 1);
                //SELECT TOP(2) [o].[Id], [o].[CustomerPurchaseOrder]
                //FROM [Orders] AS [o]
                //WHERE [o].[Id] = 1

                Console.WriteLine(order.CustomerPurchaseOrder); //panicoenlaxbox

                try
                {
                    order = context.Orders.Single(o => o.Id == 3);
                }
                catch (Exception e)
                {
                    Console.WriteLine(e.Message); // Sequence contains no elements
                }

                try
                {
                    order = context.Orders.Single(o => o.CustomerPurchaseOrder == "foo");
                }
                catch (Exception e)
                {
                    Console.WriteLine(e.Message); // Sequence contains more than one element
                }

                order = context.Orders.SingleOrDefault(o => o.Id == 1);
                //SELECT TOP(2) [o].[Id], [o].[CustomerPurchaseOrder]
                //FROM [Orders] AS [o]
                //WHERE [o].[Id] = 1

                Console.WriteLine(order.CustomerPurchaseOrder); //panicoenlaxbox                
            }
        }
    }

    class ShopContext : DbContext
    {
        public DbSet<Order> Orders { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            if (!optionsBuilder.IsConfigured)
            {
                optionsBuilder.UseSqlServer(@"Server=(LocalDB)\MSSQLLocalDB;Database=Shop;Trusted_Connection=True");
            }
            base.OnConfiguring(optionsBuilder);
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Order>().Property(o => o.Id).ValueGeneratedNever();
            base.OnModelCreating(modelBuilder);
        }
    }

    internal class Order
    {
        public int Id { get; set; }
        public string CustomerPurchaseOrder { get; set; }
    }
}

Un saludo!

miércoles, 31 de enero de 2018

SQL dinámico con ExpandoObject y Dapper

Crear una consulta SQL con una condición de filtrado dinámico es algo que tarde o temprano sale a la palestra

En la solución que se muestra a continuación, se usa un objeto de tipo ExpandoObject que permite agregar propiedades en tiempo de ejecución. Lo explica muy bien @eiximenis en el post Var, object y dynamic

Si a ExpandoObject le sumamos Dapper, la solución es segura contra SQL injection y flexible en cuanto a su confección

En el ejemplo he procurado que sea vea que podemos tanto agregar propierdades de forma dinámica por el mero hecho de asignar un valor, así como castear el ExpandoObject a un dicccionario para agregar propiedades de forma dinámica

Primero el código SQL para crear una tabla y que el ejemplo funcione

CREATE TABLE [dbo].[Table_1](
	[Id] [int] NOT NULL,
	[TenantId] [int] NOT NULL,
	[C1] [int] NULL,
	[C2] [nvarchar](50) NULL,
	[C3] [datetime2](7) NULL,
 CONSTRAINT [PK_Table_1] PRIMARY KEY CLUSTERED 
(
	[Id] ASC
)

Ahora el código

using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Dynamic;
using System.Linq;
using Dapper;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            QueryExample(new Dictionary<string, object>()
            {
                { "C1", 1 },
                { "C2", "Foo" },
                { "C3", DateTime.Now }
            });
        }

        private static void QueryExample(IDictionary<string, object> criterias)
        {
            const string connectionString = @"Server=(LocalDB)\MSSQLLocalDB;Database=Example;Trusted_Connection=True;";

            var sql = "SELECT * FROM Table_1 WHERE TenantId = @TenantId";

            var columnsWhitelist = new[] { "C1", "C2", "C3" };

            foreach (var criteria in criterias)
            {
                if (!columnsWhitelist.Contains(criteria.Key))
                {
                    throw new ArgumentException($"{criteria.Key} is not allowed as column name", nameof(criteria));
                }
                sql += $" AND {criteria.Key} = @{criteria.Key}";
            }

            dynamic parameters = new ExpandoObject();

            parameters.TenantId = 1;

            var dictionary = (IDictionary<string, object>)parameters;
            foreach (var criteria in criterias)
            {
                dictionary.Add(criteria.Key, criteria.Value);
            }

            using (var connection = new SqlConnection(connectionString))
            {
                connection.Query(sql, (ExpandoObject)parameters);
            }

            Console.ReadKey();
        }
    }
}

El SQL que se ejecuta finalmente es el siguiente

exec sp_executesql N'SELECT * FROM Table_1 WHERE TenantId = @TenantId AND C1 = @C1 AND C2 = @C2 AND C3 = @C3',N'@TenantId int,@C1 int,@C2 nvarchar(4000),@C3 datetime',@TenantId=1,@C1=1,@C2=N'Foo',@C3='2018-01-31 18:58:56.823'

Un saludo!

miércoles, 13 de diciembre de 2017

Gestión de errores en SQL Server

Si te toca escribir “algo” de lógica de negocio en T-SQL, y te agobia la gestión de errores (como a mi), espero que después de este post tengamos los 2 las cosas un poco más claras.

La primera opción que para tratar errores es con el estilo old-school, es decir, con @@ERROR, que devuelve un número de error si la última sentencia T-SQL ejecutada dio algún error, devolverá 0 si no hubo ningún error.

Para todos los ejemplos vamos a usar una tabla con una sola columna.

    CREATE TABLE Table1 (Id INT PRIMARY KEY)
    

Usando @@ERROR

    DELETE FROM Table1;
    GO
    INSERT INTO Table1 VALUES (1);
    INSERT INTO Table1 VALUES (1);
    IF @@ERROR <> 0
        PRINT 'There was an error';
    INSERT INTO Table1 VALUES (2);
    GO
    SELECT COUNT(*) FROM Table1;
    

Lo más relevante de este código es que, finalmente, la tabla tiene 2 registros, es decir, a pesar del error de la línea 4, el resto del script se ha seguido ejecutando. Este comportamiento de seguir ejecutando el script es el predeterminado, pero ¿qué pasa si no quiero que sea así? Pues podemos usar XACT_ABORT, además @@ERROR no parece una técnica muy segura.

Si XACT_ABORT es ON, en caso de haber un error, se acaba la ejecución inmediatamente del lote y se revierte, si la hubiera, la transacción explícita. Si es OFF, el valor predeterminado, pues funciona como el anterior script, la ejecución sigue y no se revierte automáticamente ninguna transacción explícita (sólo la implícita que es la propia sentencia).

        SET XACT_ABORT ON;
        DELETE FROM Table1;
        GO
        INSERT INTO Table1 VALUES (1);
        INSERT INTO Table1 VALUES (1);
        INSERT INTO Table1 VALUES (2);
        GO
        SELECT COUNT(*) FROM Table1;    
        

Es decir, la instrucción 6 no se ejecuta porque se aborta la ejecución del lote, por eso finalmente, sólo hay 1 registro en la tabla destino.

Antes de ver como XACT_ABORT ON revierte automáticamente una transacción explícita, es importante conocer la función XACT_STATE. Esta función nos devuelve un valor que indica si hay o no una transacción explícita y en que estado está.

  • 1. Hay transacción.
  • 0. No hay transacción.
  • -1. Hay transacción, pero un error hizo que la transacción no se pueda confirmar. La única operación válida es deshacer toda la transacción.

Las diferencias entre @@TRANCOUNT y XACT_STATE es que @@TRANCOUNT permite saber si hay transacciones anidadas y XACT_STATE permite saber si la transacción es confirmable.

        SET XACT_ABORT ON;
        GO
        DELETE FROM Table1;
        GO
        BEGIN TRAN;
        INSERT INTO Table1 VALUES (1);
        INSERT INTO Table1 VALUES (1);
        INSERT INTO Table1 VALUES (2);
        COMMIT TRAN;
        GO
        PRINT XACT_STATE();
        PRINT @@TRANCOUNT;
        IF XACT_STATE() = 1
            COMMIT TRAN;
        IF XACT_STATE() = -1
            ROLLBACK TRAN;
        GO
        SELECT * FROM Table1;    
        

Como la línea 7 da un error y XACT_ABORT es ON, pasa lo siguiente:

  • Se aborta la ejecución del lote, no se ejecuta la línea 8.
  • Automáticamente se revierte la transacción explícita. Luego XACT_STATE y @@TRANCOUNT pasan a valer 0.

Si comentáramos la línea 7, XACT_STATE valdría 1 y se ejecutaría COMMIT TRAN.

La línea 16 hace ROLLBACK TRAN si por algún motivo la transacción se volvió no confirmable.

De nuevo, antes de seguir es necesario entender otro concepto, como maneja los timeouts de cliente SQL Server. Un timeout de cliente es como si pulsáramos “Cancel Executing Query” en SSMS, el botón Stop, vamos. Por ejemplo, creamos un procedimiento almacenado como el siguiente:

        CREATE PROCEDURE Foo
        AS
        PRINT 'Sergio';
        WAITFOR DELAY '00:00:10';
        PRINT 'panicoenlaxbox';
        END    
        

Si lo ejecutamos y antes de que pasen 10 segundos pulsamos Stop, sólo veremos la salida 'Sergio', es decir, se deja de ejecutar el script y no vemos 'panicoenlaxbox'.

¿Desde una aplicación cliente funcionará igual?

Lo primero es poder ver PRINT en el SQL Server Profiler, esto no es necesario para comprobar esto, pero me parece útil poder ver PRINT en el Profiler, lo he sacado de aquí. Creamos el procedimiento almacenado que hace la magia:

        CREATE PROCEDURE PrintTrace1
        @Text nvarchar(max) 
        AS
        BEGIN
        DECLARE @UserData BINARY(8000) = 0
        DECLARE @UserInfo NVARCHAR(256) = SUBSTRING(@Text,1,256)
        PRINT   @Text
        EXEC sp_trace_generateevent 82, @UserInfo, @UserData
        END    
        

Y modificamos el anterior procedimiento para lo use:

    ALTER PROCEDURE Foo
    AS
    EXEC PrintTrace1 'Sergio';
    WAITFOR DELAY '00:00:10';
    EXEC PrintTrace1 'panicoenlaxbox';    
    

Por último, cuando abramos el Profiler será necesario marcar el evento UserConfigurable:0 para ver la salida de PrintTrace1.

Ahora nuestro código cliente:

    using System;
    using System.Data;
    using System.Data.SqlClient;
    
    namespace ConsoleApp1
    {
        class Program
        {
            static void Main(string[] args)
            {
                try
                {
                    using (var connection = new SqlConnection(@"Server=(LocalDB)\MSSQLLocalDB;Database=Sergio;Trusted_Connection=True;"))
                    {
                        connection.Open();
                        using (var command = connection.CreateCommand())
                        {
                            command.CommandTimeout = 5;
                            command.CommandType = CommandType.StoredProcedure;
                            command.CommandText = "Foo";
                            command.ExecuteNonQuery();
                        }
                    }
                }
                catch (Exception e)
                {
                    Console.WriteLine(e.Message);                
                }
                Console.ReadKey();
            }
        }
    }    
    

Con CommandTimeout 5 y WAITFOR DELAY '00:00:10' el timeout está garantizado, devolviendo el error típico al cliente Timeout expired. The timeout period elapsed prior to completion of the operation or the server is not responding, y confirmando que igualmente deja de ejecutar el resto de script en el servidor:

clip_image001[4]

¿Y todo esto del timeout por qué? Pues porque ahora vamos a ver TRY…CATCH de SQL Server, manejado estructurado de errores, buena cosa, pero era necesario tener los anteriores conceptos claro para poder hablar sobre ellos.

De TRY…CATCH el ejemplo típico es el siguiente, donde al igual que pasaba con XACT_ABORT ON, ahora cuando se sucede un error dentro del bloque TRY, la ejecución no continua, sino que salta al bloque CATCH.

        DELETE FROM Table1;
        GO
        BEGIN TRY
            BEGIN TRAN;
                INSERT INTO Table1 VALUES (1);
                -- RAISERROR with severity 11-19 will cause execution to jump to the CATCH block.  
                --RAISERROR ('Error raised in TRY block.', -- Message text.  
                --		   16, -- Severity.  
                --		   1 -- State.  
                --		   ); 
                INSERT INTO Table1 VALUES (1);
                INSERT INTO Table1 VALUES (2);
            COMMIT TRAN;
        END TRY
        BEGIN CATCH
                SELECT
                ERROR_NUMBER() AS ErrorNumber
                ,ERROR_SEVERITY() AS ErrorSeverity
                ,ERROR_STATE() AS ErrorState
                ,ERROR_PROCEDURE() AS ErrorProcedure
                ,ERROR_LINE() AS ErrorLine
                ,ERROR_MESSAGE() AS ErrorMessage;
            IF XACT_STATE() <> 0 
            BEGIN
                PRINT 'ROLLBACK TRAN';
                ROLLBACK TRAN;
            END
        END CATCH
        GO
        SELECT * FROM Table1;   
        

¿Cómo se llevará TRY…CATCH con un timeout?

Si hay un error de timeout no se ejecutará el CATCH, es decir, no creas que siempre que si hay un TRY…CATCH el CATCH siempre está asegurado.

Modificando el procedimiento anterior y cancelando la ejecución desde SSMS vemos este comportamiento.

    ALTER PROCEDURE Foo
    AS
        DELETE FROM Table1;
        BEGIN TRY
            BEGIN TRAN;
                INSERT INTO Table1 VALUES (1);
                EXEC PrintTrace1 'waitfor...';			
                WAITFOR DELAY '00:00:10';
                EXEC PrintTrace1 'continue...';
                INSERT INTO Table1 VALUES (1);
                INSERT INTO Table1 VALUES (2);
            COMMIT TRAN;
        END TRY
        BEGIN CATCH
            EXEC PrintTrace1 'catch...';
            IF XACT_STATE() <> 0 
            BEGIN
                PRINT 'ROLLBACK TRAN';
                ROLLBACK TRAN;
            END
        END CATCH 
    

Y ahora la pregunta es: Si no puedo garantizar la ejecución del bloque CATCH, ¿debería activar siempre XACT_ABORT para garantizar que la transacción explícita siempre se rechazara automáticamente? Pues parece que sí, porque quien defiende XACT_ABORT lo hace porque si no está activo, un timeout de cliente podría dejar la conexión con recursos bloqueados hasta que la transacción se cancele o la conexión se cierre, y asumiendo que hay pool de conexiones, un mal código de cliente podría arruinar el servidor, incluso en un comentario del mismo post queda clara la jugada.

Por otro lado, si activamos XACT_ABORT y además hay un TRY…CATCH, el CATCH seguirá ejecutándose, pero XACT_STATE valdrá -1 (la única operación válida es deshacer la transacción) y además @@TRANCOUNT seguirá valiendo lo que valía, es decir, un -1 en XACT_STATE no rechaza automáticamente la transacción explícita.

Y antes de llegar a nuestro snippet definitivo para la gestión de errores, hablemos de transacciones anidadas en SQL Server. Poder se puede:

        BEGIN TRAN
            BEGIN TRAN
            PRINT @@TRANCOUNT --2
            COMMIT TRAN
            PRINT @@TRANCOUNT --1
        COMMIT TRAN
        PRINT @@TRANCOUNT --0    
        

Además, aparece el concepto de salvar una transacción que lo que permite es deshacer partes concretas de una transacción. Cabe mencionar que SAVE TRAN no incrementa @@TRANCOUNT y, por ende, ROLLBACK TRAN <nombre> tampoco lo decrementa.

        BEGIN TRAN
            BEGIN TRAN
            PRINT @@TRANCOUNT --2
            SAVE TRAN st1
                --Do something that can be rolled back
                PRINT @@TRANCOUNT --2
                ROLLBACK TRAN st1
            COMMIT TRAN
            PRINT @@TRANCOUNT --1
        COMMIT TRAN
        PRINT @@TRANCOUNT --0        
        

Un ROLLBACK TRAN (sin nombre) deshace todas las transacciones (anidadas también si las hubiera) y decrementa @@TRANCOUNT a 0. ROLLBACK TRAN <nombre> sólo es válido si <nombre> es un SAVE TRAN o un BEGIN TRAN <nombre> siendo esa transacción la más externa (no siendo anidada).

Un COMMIT TRAN (sin nombre) confirma la transacción actual según su nivel de indentación, aunque es válido un COMMIT TRAN <nombre> refiriéndose tanto a una transacción anidada como a una externa.

Como resumen, con transacciones anidadas podemos o bien rechazar todas las transacciones (ROLLBACK TRAN o ROLLBACK TRAN <nombre_de_la_más_externa>) o bien rechazar partes de una transacción anidada (SAVE TRAN <nombre> y ROLLBACK <nombre>).

Y en este momento, es cuando vemos la plantilla de un procedimiento almacenado que he sacado de este post donde le agregamos XACT_ABORT ON para que un timeout de cliente no nos de guerra.

        CREATE PROCEDURE [ProcedureName]
        AS
        BEGIN
            SET NOCOUNT ON;
            SET XACT_ABORT ON;
            DECLARE @trancount INT;
            SET @trancount = @@TRANCOUNT;
            BEGIN TRY
                IF @trancount = 0
                    BEGIN TRANSACTION;
                ELSE
                    SAVE TRANSACTION ProcedureName;
                -- Do something...	
                
                IF @trancount = 0	
                    COMMIT;
            END TRY
            BEGIN CATCH
                DECLARE @errorNumber INT, @message NVARCHAR(4000), @xact_state INT;
                SELECT @errorNumber = ERROR_NUMBER(), @message = ERROR_MESSAGE(), @xact_state = XACT_STATE();
                IF @xact_state = -1
                    ROLLBACK;
                IF @xact_state = 1 AND @trancount = 0
                    ROLLBACK;
                IF @xact_state = 1 AND @trancount > 0
                    ROLLBACK TRANSACTION ProcedureName;
        
                RAISERROR('ProcedureName: %d: %s', 16, 1, @errorNumber, @message) ;
            END CATCH
        END   
        

Ahora sí, podemos escribir un “poco” de lógica de negocio en T-SQL con una estrategia clara de gestión de errores.