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.

viernes, 3 de noviembre de 2017

Crear e inicializar un contexto en EF Core

En EF 6.x, crear e inicializar un contexto tiene magia. Magia en el sentido de que Entity Framework puede decidir automáticamente dónde y con que nombre crear la base de datos. Según el constructor elegido de DbContext, si existe o no una cadena de conexión o un inicializador en un fichero .config, incluso saber que prevalece lo escrito en un fichero .config sobre la configuración por código… en fin, magia, para lo bueno y para lo malo.

En EF Core (actualmente 2.0) se ha prescindido de esa magia, ahora tenemos que ser explícitos sobre la configuración del contexto. Esto parece una buena idea, no creo que haya mucho debate, pero por el contrario arrancar un proyecto requiere saber más sobre como inicializar y trabajar con EF.

Asumiendo que estamos trabajando con una aplicación de consola, la forma recomendada de agregar EF Core a un proyecto es instalar un proveedor.

dotnet add package Microsoft.EntityFrameworkCore.SqlServer

Después, habrá que agregar el CLI de EF Core y/o los comandos de PowerShell para el PMC (Power Management Console).

Para agregar el CLI de EF Core hay que editar el .csproj a mano.

<ItemGroup>
  <DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="2.0.0" />
</ItemGroup>

Para agregar los comandos al PMC, podemos hacerlo agregando un paquete con normalidad.

dotnet add package Microsoft.EntityFrameworkCore.Tools

Nuestro .csproj quedaría así:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="2.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="2.0.0" />
  </ItemGroup>
  
  <ItemGroup>
    <DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="2.0.0" />
  </ItemGroup>

</Project>
Si estamos en una aplicación de consola también será necesario agregar una referencia a Microsoft.EntityFrameworkCore.Design

Ahora ya estamos preparados para trabajar con EF Core, así que creamos un contexto cualquiera:

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

internal class Order
{
    public int Id { get; set; }
    public DateTime OrderDate { get; set; }
}

Y al crear nuestra primera migración, la primera en la frente:

dotnet ef migrations add Initial

No database provider has been configured for this DbContext. A provider can be configured by overriding the DbContext.OnConfiguring method or by using AddDbContext on the application service provider. If AddDbContext is used, then also ensure that your DbContext type accepts a DbContextOptions object in its constructor and passes it to the base constructor for DbContext.

El error nos está diciendo que el contexto no está “configurado” con ningún proveedor de base de datos, que no sabe a que bd atacar… que o bien usemos DbContext.OnConfiguring o bien AddDbContext sobre el “proveedor de servicios de la aplicación (el contenedor de dependencias)” y que si usamos este último método no olvidemos crear un constructor que acepte DbContextOptions y que llame a la base.

La primera solución pasa por sobrescribir OnConfiguring

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

“IsConfigured” lo que viene a decir es “si no me han configurado ya, entonces me configuro”, luego veremos que será posible configurar el contexto desde fuera, porque si no lo hiciéramos y quisiéramos cambiar de proveedor o de cadena de conexión tendríamos que meter más código aquí, pero, en cualquier caso, esté o no configurado el contexto está bien saber que tenemos una última oportunidad para hacer lo que queramos con la configuración. Además, hay que tener en cuenta que OnConfiguring se llama siempre para cada instancia creada del contexto.

Ahora ya funciona crear una migración con las herramientas cliente y además podríamos usar el contexto en nuestro código:

            
using (var context = new ShopContext())
{
}

Sin embargo, parece mejor que el contexto sea configurado desde fuera, será más versátil y no habremos hardcodeado nada, probemos la segunda opción.

using System;
using Microsoft.EntityFrameworkCore;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            var options = new DbContextOptionsBuilder()
                .UseSqlServer(@"Server=(LocalDB)\MSSQLLocalDB;Database=Example;Trusted_Connection=True;")
                .Options;

            using (var context = new ShopContext(options))
            {
                context.Database.Migrate();
            }
        }
    }


    class ShopContext : DbContext
    {
        public ShopContext(DbContextOptions options) : base(options)
        {
        }
        public DbSet<Order> Orders { get; set; }
    }

    internal class Order
    {
        public int Id { get; set; }
        public DateTime OrderDate { get; set; }
    }
}

Ahora somos capaces de inyectar la configuración al contexto (luego IsConfigured vale true en OnConfiguring).

Tanto DbContextOptions como DbContextOptionsBuilder tiene su versión genérica, que cuando trabajemos más adelante con DI nos servirá si tenemos más de un contexto en nuestra aplicación.

            
var options = new DbContextOptionsBuilder<ShopContext>()
    .UseSqlServer(@"Server=(LocalDB)\MSSQLLocalDB;Database=Example;Trusted_Connection=True;")
    .Options;

public ShopContext(DbContextOptions<ShopContext> options) : base(options)
{
}

Sin embargo, lo que deja de funcionar ahora es el CLI, porque, aunque encuentra el contexto, no tiene un constructor sin parámetros y por ende no se puede instanciar.

Unable to create an object of type 'ShopContext'. Add an implementation of 'IDesignTimeDbContextFactory ' to the project, or see https://go.microsoft.com/fwlink/?linkid=851728 for additional patterns supported at design time.

IDesignTimeDbContextFactory ayuda a las herramientas de cliente de EF a crear un contexto si el mismo no tiene un constructor público sin parámetros.

    
class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<ShopContext>
{
    public ShopContext CreateDbContext(string[] args)
    {
        var options = new DbContextOptionsBuilder<ShopContext>()
            .UseSqlServer(@"Server=(LocalDB)\MSSQLLocalDB;Database=Example;Trusted_Connection=True;")
            .Options;
        return new ShopContext(options);
    }
} 

Esta clase sólo será usada por las herramientas cliente, esto es importante, es tiempo de diseño, nada tiene que ver con tiempo de ejecución.

Ahora ya funcionan las migraciones.

Aunque con lo visto hasta aquí se podría funcionar, no estamos haciendo uso del contenedor de dependencias.

Para hacerlo en una aplicación de consola.

        
static void Main(string[] args)
{
    var options = new DbContextOptionsBuilder()
        .UseSqlServer(@"Server=(LocalDB)\MSSQLLocalDB;Database=Example;Trusted_Connection=True;")
        .Options;

    IServiceCollection services = new ServiceCollection();
    services
        .AddSingleton(options)
        .AddScoped<ShopContext>();

    ServiceProvider serviceProvider = services.BuildServiceProvider();

        using (var context = serviceProvider.GetService<ShopContext>())
    {
        context.Database.EnsureCreated();
    }
}

Y si queremos simplificar el registro de servicios, tenemos el método de extensión AddDbContext, que registra el contexto como Scoped y nos da una lamba que se llamará la primera vez que se resuelva el contexto y que devolverá un DbContextOptions, que se registrará como Singleton.

        
static void Main(string[] args)
{
    IServiceCollection services = new ServiceCollection();
    services.AddDbContext<ShopContext>(builder =>
    {
        builder.UseSqlServer(@"Server=(LocalDB)\MSSQLLocalDB;Database=Example;Trusted_Connection=True;");
    });

    ServiceProvider serviceProvider = services.BuildServiceProvider();

    using (var context = serviceProvider.GetService<ShopContext>())
    {
        context.Database.EnsureCreated();
    }
}

En realidad, AddDbContext registra muchos más servicios con su llamada a AddCoreServices

Si por el contrario nuestra aplicación es web la cosa cambia.

De serie (y asumiendo que estamos usando Visual Studio) viene con el paquete Microsoft.AspNetCore.All Microsoft.AspNetCore.App que ya incluye los paquetes Microsoft.EntityFrameworkCore.SqlServer, Microsoft.EntityFrameworkCore.Tools y Microsoft.EntityFrameworkCore.Design, es decir, para tener lo mismo que en el ejemplo anterior sólo tendríamos que agregar el CLI de EF (el de PMC ya viene de serie) y también el CLI de EF (en ASP.NET Core 2.1, dotnet ef es un comando de serie)

Además, ahora estas herramientas encontrarán el método BuildWebHost

    
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .Build();
    }

Es importante saber que se busca el método BuildWebHost con ese nombre exacto, cualquier otro nombre no valdrá.

Si las herramientas cliente encuentran BuildWebHost lo ejecutarán y también ejecutarán Startup.Configure y Startup.ConfigureServices (porque aquí es donde habremos llamado a AddDbContext y registrado los servicios).

La consecuencia directa de esto es que el código de inicialización (migración, seed, etc) que tuviéramos en Startup.Configure ya no debería estar allí. Se recomienda moverlo al método Main.

public static void Main(string[] args)
{
    // Construir IWebHost
    var host = BuildWebHost(args);

    // Inicialización
    using (var scope = host.Services.CreateScope())
    {
        var services = scope.ServiceProvider;
        try
        {
            var context = services.GetRequiredService<ShopContext>();
            // Hacer algo útil...
        }
        catch (Exception ex)
        {
            ILogger<Program> logger = services.GetRequiredService<ILogger<Program>>();
            logger.LogError(ex, "An error occurred while seeding the database.");
        }
    }

    // Ejecutar IWebHost
    host.Run();
}

En este gist de Unai Zorrila hay un ejemplo más molón

Como dato curioso (o no tan curioso y sí peligroso), si pasara que tenemos tanto BuildWebHost como IDesignTimeDbContextFactory, se ejecutarían ambos, aunque prevalecería IDesignTimeDbContextFactory.

Y para terminar, además del método extensor AddDbContext, en EF Core 2 han metido el nuevo método de extensión AddDbContextPool que usa un pool de contextos para mejorar el rendimiento, aunque tiene ciertas limitaciones.

¡Un saludo!

martes, 19 de septiembre de 2017

Merge vs Rebase

Cuando se trabaja en equipo todo el mundo acepta con naturalidad la necesidad de un coding standard o similar para buscar la mayor legibilidad, consistencia y mantenibilidad del código. Igualmente, y en relación al control de código fuente, debería haber un consenso sobre que flujo de trabajo y tipo de estrategia de merge seguir.

Trabajando con git, tarde o temprano hay que elegir, merge o rebase, o para ser más exactos, estrategia de merge de tipo fast-forward o tipo recursive.

Cualquier opción es válida y ambas tienen ventajas y desventajas.

En mi caso, intentaré explicar que es merge y rebase.

TL;DR

Merge con estrategia recursive hace explícita la integración de ramas y además mantiene contiguos todos los commits de una característica, por el contrario, dificulta la legibilidad del repositorio y podemos acabar fácilmente con un guitar hero http://devhumor.com/media/i-fucked-up-git-so-bad-it-turned-into-guitar-hero

Por otro lado, rebase mantiene una historia lineal del proyecto, pero está sujeto a más situaciones comprometidas, es más propenso a meter la pata. La regla de oro es no hacer rebase sobre commits públicos, o al menos no hacerlo sobre commits sobre los que cualquier miembro del equipo haya basado su trabajo. Además, con rebase perdemos la trazabilidad de cuando se integró una rama.

En mi caso apuesto por merge, forzando incluso con --no-ff para evitar una estrategia fast-forward y así ser totalmente explícito de cuando se integró una rama. Sin embargo, para descargar cambios del repositorio remoto me parece adecuado usar git pull --rebase para evitar ciertos commits extra de merge que no aportan valor al repositorio.

Por último, el rebase de tipo clean-up es gratis en local y parece una buena práctica ¿Por qué no usarlo?

Para los ejemplos, asumo que no tienes guardado nada de valor en el directorio C:\Temp y deberían poderse seguir de principio a final si no te cambias de directorio. Además, y aunque normalmente los ejemplos se ejecutan por comandos, también mencionaré de vez en cuando a SourceTree porque es el cliente de git que uso habitualmente.

Para hacer nuestro primer merge:

mkdir C:\Temp
cd C:\Temp
mkdir example
cd example
echo F1 > F1.txt
git init
git add .
git commit -m "C1"
echo F2 > F2.txt
git add .
git commit -m "C2"
git checkout -b develop
echo F3 > F3.txt
git add .
git commit -m "C3"
git checkout master
echo F4 > F4.txt
git add .
git commit -m "C4"

Aquí la historia se ha bifurcado porque se han hecho commits en distintas ramas desde un mismo commit base.

clip_image001

Para integrar los cambios de develop en master tenemos 2 opciones: merge o rebase.

Los mantras oficiales (grábatelos a fuego) son:

  • merge “me voy a y cojo de”
  • rebase “estoy en y rebaso a”

Por ahora veremos merge y más adelante rebase

git checkout master
git merge develop                
clip_image002

Ahora el commit de merge tiene 2 padres. El primero es el commit de la rama donde se estaba (master) y el segundo es el commit de la rama que se integró (C3 de develop). El orden es importante porque después (y si fuera necesario) podríamos ver sólo los commits de una rama (excluyendo los que vinieron por integración de otras ramas) con git log --oneline --first-parent. Otra opción muy socorrida es git log --oneline --no-merge que muestra el log sin ningún commit de merge.

Para probar otro tipo de merge, tenemos que volver a la situación anterior, como si no hubiéramos hecho el merge. Para ello bastaría con retroceder la rama actual un commit con git reset --hard master^, el problema es que en Windows hay que poner dos acentos circunflejos porque hay que escapar ciertos caracteres https://stackoverflow.com/questions/9600549/using-a-caret-when-using-git-for-windows-in-powershell, así que en vez de poner git reset --hard master^ será mejor poner git reset --hard master~ que para el caso es lo mismo.

git reset --hard master~
git checkout develop
git merge master
clip_image004

Como vemos, el cambio más reseñable es que el mensaje por defecto del commit de merge es distinto.

  • Merge branch ‘<rama-mergeada>’ si se está integrando en master
  • Merge branch ‘<rama-mergeada>’ into <rama> si se está integrando en cualquier otra rama

Si estamos trabajando solos, esos serían los dos tipos de commits de merge que deberíamos encontrarnos normalmente.

Podría haber un tercero, si un merge que podría haber sido resuelto con la estrategia “fast-forward” (esto es, simplemente avanzar el puntero sin crear ningún commit de merge) lo forzamos para que sí haya un commit de merge con --no-ff

cd c:\Temp
rm -rf *
mkdir example
cd example
echo F1 > F1.txt
git init
git add .
git commit -m "C1"
echo F2 > F2.txt
git add .
git commit -m "C2"
git checkout -b develop
echo F3 > F3.txt
git add .
git commit -m "C3"
git checkout master                     
clip_image005
git merge develop

clip_image006

clip_image007

Si ahora hacemos esto otro

git reset --hard master~
git merge develop --no-ff                    

clip_image008

clip_image009

Está claro, si la estrategia de merge es recursive será cuando veamos un commit de merge, si es fast-forward no.

--no-ff desde Source Tree equivale a marcar la casilla “Create a new commit even if fast-forward is possible”

clip_image010

Sin embargo, si trabajando en equipo, pueden aparecer otros commits de merge cuando nos traigamos los cambios del remoto con git pull

Para trabajar con un remoto y no tener que crearlo en github, bitbucket o similar, podemos usar un repositorio bare y así todo queda en casa.

cd C:\Temp
rm -rf *
mkdir central
cd central
git init --bare
cd ..
mkdir example1
cd example1
echo F1 > F1.txt
git init
git add .
git commit -m "C1"
echo F2 > F2.txt
git add .
git commit -m "C2"
git remote add origin C:\Temp\central
git push -u origin master
cd ..
git clone C:\Temp\central example2                    

En este momento ya tenemos un repositorio remoto y 2 repositorios locales, ¡todo listo!

cd example1
echo F3 > F3.txt
git add .
git commit -m "C3"
git push                    
clip_image011
cd ..
cd example2
echo F4 > F4.txt
git add .
git commit -m "C4"                      
clip_image012

Si hacemos un git push desde example2 fallará porque no estamos al día, hay cambios en el remoto que no nos hemos bajado.

git fetch                     
clip_image013

Para ver git pull me parece interesante comentar la ventana de SourceTree

clip_image015

Si no está marcado “Commit merged changes immediately”, sería un git pull --no-commit, luego tendríamos los cambios en el working copy pero no se hará el commit.

clip_image016

“Create a new commit even if fast-forward is possible” indicará si agregar o no el modificador --no-ff

“Rebase instead of merge (WARNING: make sure you haven’t pushed your changes)” ejecutará git pull --rebase

En caso de no estar marcada ninguna opción, simplemente será un git pull

clip_image017

La diferencia más notable en el commit de merge es que ahora el mensaje predeterminado es “Merge branch ‘<rama>’ of ‘dirección_remoto’

git pull es lo mismo que hacer git fetch + git merge origin/<current-branch>, la única diferencia sería el mensaje predeterminado del commit de merge, que ahora sería “Merge remote-tracking branch 'origin/<rama>'”

clip_image018

Y si hubiéramos lanzado git pull --rebase, nuestro commit C4 se aplica en lo alto de la rama evitando un commit extra de merge

clip_image019

Y llegados a este punto ya tenemos claro de dónde vienen los distintos commits de merge, cuando se pueden producir y como reconocerlos atendiendo a sus mensajes predeterminados.

Antes de meternos con rebase es importante entender el concepto de reescribir la historia y porqué tiene mucho peligro.

Seguramente haya muchos más comandos que reescriban la historia, pero los más habituales son git commit --amend y git rebase

cd C:\Temp
rm -rf *
mkdir example
cd example
echo F1 > F1.txt
git init
git add .
git commit -m "C1"
echo F2 > F2.txt
git add .
git commit -m "C2"                    
clip_image020

Ahora nos damos cuenta de que en el commit C2 el texto del fichero F2.txt no es correcto o no nos gusta el mensaje del commit o nos hemos dejado algún fichero en el index o sin guardar https://marketplace.visualstudio.com/items?itemName=PaulCBetts.SaveAllTheTime y queremos que sea parte del commit y, sea como sea, no queremos hacer un nuevo commit sino reemplazar el último. Esto se consigue con --amend que combina el staging area/index con el contenido del último commit y reemplaza el último commit. Con --no-edit simplemente decimos que nos vale el mensaje del último commit.

echo F2_upated > F2.txt
git add . 
git commit --amend --no-edit                    

El identificador del commit ha cambiado, hemos reescrito la historia.

clip_image021

En SourceTree también podemos hacer --amend

clip_image022

Como decía, el tema está en que hemos reescrito la historia, antes el commit era a59987c y ahora es 3d41599 (de nuevo, será otro en tu equipo), es decir, son 2 commits completamente diferentes a ojos del repositorio, luego si ya eran públicos (estaban subidos al remoto), cualquier otro commit que los estuviera referenciado ya no los va a encontrar y habrá lío garantizado…

Vamos a reproducir un lío gordo y así lo vemos.

cd C:\Temp
rm -rf *
mkdir central
cd central
git init --bare
cd ..
mkdir example1
cd example1
echo F1 > F1.txt
git init
git add .
git commit -m "C1"
echo F2 > F2.txt
git add .
git commit -m "C2"
git remote add origin C:\Temp\central
git push -u origin master
cd ..
git clone C:\Temp\central example2
cd example2
echo F3 > F3.txt
git add .
git commit -m "C3"                     

Aquí estamos a nivel, todo bien.

En example1

clip_image023

En example2, C3 tiene como padre el commit C2

clip_image024

Pero ahora el señor de example1 va a reescribir la historia haciendo un commit --amend y entonces C2 ya no será el mismo commit sino algún otro con un identificador distinto

cd ..
cd example1
echo F2_1 > F2_1.txt
git add .
git commit --amend -m "C2_updated”                    

Y en example1 tenemos lo siguiente:

clip_image025

¡Uy, que feo!, aparece C2_updated (a quien apunta master) pero sigue estando C2 (a quien apunta origin/master). De hecho, C2 tiene el mismo identificador, sigue estando ahí porque está en el remoto (es público).

Lógicamente tampoco el señor de example1 puede hacer un git push, no está al día, origin/master ya no está apuntado a master.

¿Qué opciones tiene example1? Pues hacer merge de origin/master en master

git merge origin/master master

Que podría o no darle un conflicto en función de que como haya sido su --amend, en nuestro ejemplo no dará conflicto, hemos añadido un fichero.

clip_image026

Estoy seguro de que el señor de example1 no quería este commit de merge… pero bueno, igualmente hace un push

                    git push
            

¿Y cómo queda el señor de example2?

cd ..
cd example2
git fetch                

Pues directamente le han hecho el lío, lo que él pensaba era un valor seguro, un commit público que estaba en el remoto grabado a piedra, pues ya no lo es tanto, sigue estando ahí pero ya no es lo mismo… se ha roto la regla de oro, no reescribir la historia en commits públicos

clip_image027
git pull
clip_image028

Y ahora ya puede hacer

git push

Gracias, señor de example1, acabas de afear la historia del repositorio y además has agregado una complejidad que me acordaré de ti por siempre… por lo menos no has hecho un git push –force, en fin…

La idea es que reescribir commits que son públicos es una pésima idea y fuente de problemas, no se hace y punto, es otro mantra.

Y por fin llegamos al rebase, que mola mucho… pero reescribe la historia, así que cuidado.

Rebasar es “mover” commits a un nuevo commit base.

Para integrar cambios, se puede hacer con merge o con rebase + merge, que siempre será fast-forward… y es aquí donde está el debate ¿merge o rebase? Según a quién preguntes te dirá una cosa u otra

Se hace rebase por alguno de estos motivos:

  • Mantener una historia “lineal” del proyecto para mejorar la legibilidad
    • Aunque se pierda trazabilidad en la integración de ramas porque no hay commits de merge
  • Hacer un clean-up de mi historia local con un rebase interactivo

Hacer un clean-up de mi historia local con un rebase interactivo.

Hacer un clean-up no parece que suscite debate (siempre y cuando se haga en local, en remoto no se hace, recuerda que reescribe la historia, el mantra…). Lo hacemos porque en local podríamos haber hecho n commits (porque nos apeteció, es tu repo, ahí no manda nadie) pero cuando queremos compartir los cambios con el resto (subir al remoto) queremos pasarle un poco la mopa para que quede todo bonito y apañado.

Por ejemplo, estoy trabajando en mi local con aparente desgana:

cd C:\Temp
rm -rf *
mkdir example
cd example
git init
echo example > example.txt
git add .
git commit -m "Initial commit"
echo F2 > F2.txt
git add .
git commit -m "C2"
echo F3 > F3.txt
git add .
git commit -m "C3"
echo F4 > F4.txt
git add .
git commit -m "C4"
echo F_fake > F_fake.txt
git add .
git commit -m "Fake"
echo F5 > F5.txt
git add .
git commit -m "C5"
echo F1 > F1.txt
git add .
git commit -m "C1"                    
clip_image029

Sinceramente, no voy a subir eso al remoto para que lo vean mis compañeros, los commits están desordenados, tengo un commit fake y además no me he preocupado por los comentarios, ¡ni yo sé que he incluido en cada commit1

Se podría hacer el rebase interactivo por comandos, pero con franqueza, una herramienta como SourceTree nos facilitará la vida.

clip_image030

Y pasamos de

clip_image031

A esto otro (haciendo squash, ordenando, eliminado y cambiando mensajes de commits)

clip_image032

clip_image033

Esto es otra cosa, ¡ya puedo subir mis cambios!

Si quieres hacerlo interactivo por consola, puedes hacerlo con git rebase [-i] <commit_base>, pero se va a abrir casi seguro VIM y ahí te apañes tú… Si por ejemplo “Initial commit” tuviera el identificador c7f0016

git rebase -i c7f0016
            
clip_image035

El otro sabor de rebase (el que entra en debate con merge) es el “no interactivo”, el que se rige por el mantra “estoy en y rebaso a”. Lo veremos con un ejemplo típico de feature branch.

cd C:\Temp
rm -rf *
mkdir example
cd example
git init
echo example > example.txt
git add .
git commit -m "Initial commit"
echo A1 > A1.txt
git add .
git commit -m "A1"
echo A2 > A2.txt
git add .
git commit -m "A2"
git checkout -b features/B
echo B1 > B1.txt
git add .
git commit -m "B1"
echo B2 > B2.txt
git add .
git commit -m "B2"
echo B3 > B3.txt
git add .
git commit -m "B3"
git checkout master
echo A3 > A3.txt
git add .
git commit -m "A3"
echo A4 > A4.txt
git add .
git commit -m "A4"
git checkout features/B                                  
clip_image036
git rebase master 

clip_image037

clip_image038

El rebase ha cogido todos los commits de features/B y los ha puesto a continuación del último commit de master, todo lineal, muy bonito… pero ha reescrito la historia de los commits de features/B

Si estás trabajando sólo tú en esa rama en este momento (y no hay ningún compañero pendiente de subir cambios al repositorio en esa rama, y tampoco nadie creó una rama a partir de un commit de esa rama), no hay problema, si no lío…

Desde Source Tree, hubieramos hecho

clip_image039

clip_image040

Por cierto, si el rebase da conflictos, los resolvemos y después git rebase --continue o git rebase --abort

Y si queremos deshacer el rebase podemos usar git reset --hard ORIG_HEAD https://stackoverflow.com/a/692763

Y después de este tipo de rebase tenemos que hacer un merge, que será siempre fast-forward

git checkout master
git merge features/B
clip_image041

Y nada más, espero que te sirva este post porque, lo que es seguro, es que yo sí volveré a él cada vez que tenga que hablar sobre este tema, seguramente para refrescar el porqué de las cosas o bien para actualizarlo porque he descubierto que algo no funcionaba como pensaba.

Un saludo!

martes, 30 de mayo de 2017

JSON en SQL Server 2016

Seguro que ya estabas enterado, pero en mi caso ha sido recientemente cuando he descubierto que a partir de SQL Server 2016 se puede trabajar con JSON.

Aunque hay una excelente documentación al respecto en JSON Data (SQL Server), de una forma resumida y con ejemplos que pueda recordar fácilmente, me gustaría contarte que posibilidades tenemos para trabajar con JSON en SQL Server.

Lo primero es que no hay un tipo json, en realidad trabajaremos con nvarchar y todo la magia ocurrirá a través de nuevas clausulas y funciones.

Para organizar el post, voy a plantear un escenario donde, primero importaremos datos desde un fichero .json, a continuación formatearemos en JSON la salida de una consulta SQL, para después hacer consultas SQL sobre una columna que guarda JSON y acabar, finalmente, con un consejo sobre índices para mejorar el rendimiento.

El script SQL necesario para todas las pruebas es el siguiente:

CREATE TABLE [dbo].[OrderLines](
	[Id] [int] NOT NULL,
	[Units] [int] NOT NULL,
	[Price] [decimal](18, 2) NOT NULL,
	[ProductId] [int] NOT NULL,
	[OrderId] [int] NOT NULL
 CONSTRAINT [PK_dbo.OrderLines] PRIMARY KEY CLUSTERED 
(
	[Id] ASC
))
GO
CREATE TABLE [dbo].[Orders](
	[Id] [int] NOT NULL,
	[CreatedDate] [datetime] NOT NULL,
	[Comment] [nvarchar](250) NULL
 CONSTRAINT [PK_dbo.Orders] PRIMARY KEY CLUSTERED 
(
	[Id] ASC
))
GO
CREATE TABLE [dbo].[Products](
	[Id] [int] NOT NULL,
	[Name] [nvarchar](250) NULL
 CONSTRAINT [PK_dbo.Products] PRIMARY KEY CLUSTERED 
(
	[Id] ASC
))
GO
ALTER TABLE [dbo].[OrderLines]  WITH CHECK ADD  CONSTRAINT [FK_dbo.OrderLines_dbo.Orders_OrderId] FOREIGN KEY([OrderId])
REFERENCES [dbo].[Orders] ([Id])
ON DELETE CASCADE
GO
ALTER TABLE [dbo].[OrderLines] CHECK CONSTRAINT [FK_dbo.OrderLines_dbo.Orders_OrderId]
GO
ALTER TABLE [dbo].[OrderLines]  WITH CHECK ADD  CONSTRAINT [FK_dbo.OrderLines_dbo.Products_ProductId] FOREIGN KEY([ProductId])
REFERENCES [dbo].[Products] ([Id])
ON DELETE CASCADE
GO
ALTER TABLE [dbo].[OrderLines] CHECK CONSTRAINT [FK_dbo.OrderLines_dbo.Products_ProductId]

Para importar datos desde un fichero .json podemos usar la función OPENROWSET con el parámetro SINGLE_CLOB, que lee y devuelve su contenido como una única fila y columna BulkColumn de tipo nvarchar(max). Además, tendremos que usar la función OPENJSON que convierte JSON en filas y columnas.

Nuestro fichero .json será como sigue

[
  {
    "Id": 1,
    "CreatedDate": "2017-05-29T00:00:00.000",
    "OrderLines": [
      {
        "Id": 1,
        "Units": 1,
        "Price": 1.25,
        "Product": {
          "Id": 1,
          "Name": "Product 1"
        }
      },
      {
        "Id": 2,
        "Units": 2,
        "Price": 2.5,
        "Product": {
          "Id": 2,
          "Name": "Product 2"
        }
      }
    ]
  },
  {
    "Id": 2,
    "CreatedDate": "2017-05-29T00:00:00.000",
    "Comment": "A brief but useful comment",
    "OrderLines": [
      {
        "Id": 3,
        "Units": 3,
        "Price": 3.75,
        "Product": {
          "Id": 1,
          "Name": "Product 1"
        }
      }
    ]
  }
]

Ahora podemos usar OPENROWSET y OPENJSON

SELECT BulkColumn, [key], [value], [type]
FROM OPENROWSET (BULK 'C:\panicoenlaxbox\data.json', SINGLE_CLOB) AS T
CROSS APPLY OPENJSON(BulkColumn)

OPENROWSET devuelve la columna BulkColumn, OPENJSON devuelve las columnas key, value y type.

image

Si en fichero .json tuviera un sólo objeto en vez de un array, la salida nos ayudaría a entender mejor como funciona OPENJSON

{
  "Id": 1,
  "CreatedDate": "2017-05-29T00:00:00.000",
  "OrderLines": [
    {
      "Id": 1,
      "Units": 1,
      "Price": 1.25,
      "Product": {
        "Id": 1,
        "Name": "Product 1"
      }
    },
    {
      "Id": 2,
      "Units": 2,
      "Price": 2.5,
      "Product": {
        "Id": 2,
        "Name": "Product 2"
      }
    }
  ]
}

image

OPENJSON tiene la clausula WITH con la que podemos, de forma explícita, establecer la estructura del resultado devuelto. Por ejemplo, para conseguir un conjunto de filas y columnas sobre la que poder trabajar directamente, ejecutaríamos la siguiente consulta

SELECT
	Orders.Id AS OrderId
   ,Orders.CreatedDate
   ,Orders.Comment
   ,OrderLines.Id AS OrderLineId
   ,OrderLines.Units
   ,OrderLines.Price
   ,Product.Id AS ProductId
   ,Product.[Name] AS ProductName
INTO #Table1
FROM OPENROWSET(BULK 'C:\panicoenlaxbox\data.json', SINGLE_CLOB) AS j
CROSS APPLY OPENJSON(BulkColumn)
WITH (
	Id INT,
	CreatedDate DATETIME '$.CreatedDate',
	Comment NVARCHAR(MAX),
	OrderLines NVARCHAR(MAX) AS JSON
) AS Orders
CROSS APPLY OPENJSON(Orders.OrderLines)
WITH (
	Id INT,
	Units INT,
	Price DECIMAL(18, 2),
	Product NVARCHAR(MAX) AS JSON
) AS OrderLines
CROSS APPLY OPENJSON(OrderLines.Product)
WITH (
	Id INT,
	[Name] NVARCHAR(MAX)
) AS Product    

image

Ahora ya sí podemos insertar estos datos leídos del fichero .json en nuestras tablas

INSERT INTO Products SELECT DISTINCT ProductId, ProductName FROM #table1;
INSERT INTO Orders SELECT DISTINCT OrderId, CreatedDate, Comment FROM #table1;
INSERT INTO OrderLines SELECT DISTINCT OrderLineId, Units, Price, ProductId, OrderId FROM #table1;    

Si hablamos ahora de formatear consultas, tendremos que usar la clausula FOR JSON

SELECT * FROM Orders O
INNER JOIN OrderLines OL ON OL.OrderId = O.Id
INNER JOIN Products P ON OL.ProductId = P.Id
FOR JSON AUTO

Que devuelve

    [{
    "Id": 1,
    "CreatedDate": "2017-05-29T00:00:00",
    "OL": [{
        "Id": 1,
        "Units": 1,
        "Price": 1.25,
        "ProductId": 1,
        "OrderId": 1,
        "P": [{
            "Id": 1,
            "Name": "Product 1"
        }]
    }, {
        "Id": 2,
        "Units": 2,
        "Price": 2.50,
        "ProductId": 2,
        "OrderId": 1,
        "P": [{
            "Id": 2,
            "Name": "Product 2"
        }]
    }]
}, {
    "Id": 2,
    "CreatedDate": "2017-05-29T00:00:00",
    "Comment": "A brief but useful comment",
    "OL": [{
        "Id": 3,
        "Units": 3,
        "Price": 3.75,
        "ProductId": 1,
        "OrderId": 2,
        "P": [{
            "Id": 1,
            "Name": "Product 1"
        }]
    }]
}]

Como probablemente este resultado no nos satisfaga, tendremos que tomar el control con FOR JSON PATH. Por ejemplo, la siguiente consulta devuelve exactamente lo mismo que tiene el fichero .json que usamos al comienzo para importar los datos

SELECT
	O.Id
   ,O.CreatedDate
   ,O.Comment
   ,(SELECT
			OL.Id
		   ,OL.Units
		   ,OL.Price
		   ,OL.ProductId AS 'Product.Id'
		   ,P.[Name] AS 'Product.Name'
		FROM OrderLines OL
		INNER JOIN Products P
			ON OL.ProductId = P.Id
		WHERE OL.OrderId = O.Id
		FOR JSON PATH)
	AS OrderLines
FROM Orders O
FOR JSON PATH

En cuanto a que podemos hacer para consultar datos JSON almacenados en una columna, encontramos varias funciones:

  • ISJSON
  • JSON_VALUE
  • JSON_QUERY
  • JSON_MODIFY

ISJSON valida que el texto es JSON válido

JSON_VALUE extrae un valor desde JSON

JSON_QUERY extrae como texto, un objeto u array desde JSON

JSON_MODIFY permite modificar JSON y devuelve el resultado

Lo más sencillo será agregar una nueva columna para poder jugar con ella

ALTER TABLE Orders
ADD SecurityContext NVARCHAR(MAX);
GO
UPDATE Orders SET SecurityContext = '{"Enabled":true,"Roles":["Salesman","Customer"],"Worflows":[{"Name":"Approval","Priority":1},{"Name":"Rejection","Priority":2}]}'
WHERE Id = 1;
UPDATE Orders SET SecurityContext = '{"Enabled":false}'
WHERE Id = 2;    

Ahora podemos ejecutar las siguientes consultas

--ISJSON valida si es JSON
SELECT ISJSON(SecurityContext) FROM Orders;

--JSON_VALUE extrae un valor desde JSON
--'$.Roles[1]' y 'lax $.Roles[1]' son lo mismo, por defecto es lax
SELECT JSON_VALUE(SecurityContext, '$.Roles[1]') FROM Orders;

--Con strict tendremos una excepción porque el segundo registro no tiene valor
--Property cannot be found on the specified JSON path.
--SELECT JSON_VALUE(SecurityContext, 'strict $.Roles[1]') FROM Orders;

--JSON_QUERY devuelve un objeto o un array
SELECT JSON_QUERY(SecurityContext, '$.Worflows') FROM Orders;
--[{"Name":"Approval","Priority":1},{"Name":"Rejection","Priority":2}]

DECLARE @json NVARCHAR(MAX)
SELECT @json = SecurityContext FROM Orders WHERE Id = 1;
--Modificar una propiedad
SELECT JSON_VALUE(JSON_MODIFY(@json, '$.Enabled', 'false'), '$.Enabled');
--Modificar un elemento de un array
SELECT JSON_QUERY(JSON_MODIFY(@json, '$.Roles[1]', 'Administrador'), '$.Roles');
--["Salesman","Administrador"]

--Agregar un elemento a un array
SELECT JSON_QUERY(JSON_MODIFY(@json, 'append $.Roles', 'Agent'), '$.Roles');
--["Salesman","Customer","Agent"]

Por último, sólo mencionar como podemos crear un índice para que JSON_VALUE lo use. Primero veremos el plan de ejecución de una consulta sin el índice y después como cambia cuando lo incluimos

SELECT JSON_VALUE(SecurityContext, '$.Enabled') FROM Orders 
WHERE JSON_VALUE(SecurityContext, '$.Enabled') = 'true'

image

Ahora agregamos el índice (bueno, en realidad agregamos una columna virtual y después el índice) y ejecutamos de nuevo la consulta para confirmar que lo está usando

ALTER TABLE Orders
ADD SecurityContextEnabled AS JSON_VALUE(SecurityContext,'$.Enabled')
GO
CREATE INDEX IX_Orders_Enabled
ON Orders(SecurityContextEnabled)  
GO
SELECT JSON_VALUE(SecurityContext, '$.Enabled') FROM Orders 
WHERE JSON_VALUE(SecurityContext, '$.Enabled') = 'true'

image

Un saludo!

martes, 21 de marzo de 2017

Equipos, áreas e iteraciones en VSO

Organizar el trabajo es un mal necesario y, tarde o temprano, cualquier equipo de desarrollo tendrá que intentar abordar esta tarea con cierta rigurosidad. Ahora mismo, me ha tocado a mí y a otros pocos en mi empresa la “excitante” tarea de intentar poner algo de orden en el caos. Ya aviso que no tengo ninguna skill reseñable en cuanto a gestión de proyectos, de hecho, el lema de mi blog incluye la palabra “Desordenada”, fíjate tú, me he ahorrado el disclaimer.

La única herramienta que he usado hasta la fecha para llevar a cabo tan magna tarea ha sido Visual Studio Team Services (VSTS) https://www.visualstudio.com/es/vso/ (o Visual Studio Online o Team Foundation Service como se le conocía anteriormente… ya me han corregido en Twitter https://twitter.com/jc_quijano/status/844173841949691905).

El flujo era sencillo: crear un nuevo proyecto y agregar, mejor o peor redactados, un buen saco de PBIs y a partir de ahí y fingiendo hacer SCRUM, organizar el trabajo en sprints y tirar pa’lante.

Lo cierto es que en este post no voy a plantear algo muy distinto, pero sí me he dado cuenta con el paso del tiempo y el notable y variopinto incremento de tareas a realizar, que los valores por defecto que propone VSO podrían no ser los más idóneos. Me ha costado bastante entender la relación entre Work Items, Iterations, Areas y Teams, y como he googleado bastante he pensado que, si lo dejo por escrito, yo mismo podré volver en un tiempo futuro para encontrar aquí el porqué de algunas decisiones. Es decir, si quieres entender cómo funciona VSO, o como creo yo que funciona en relación a la pestaña “Work”, este es tu post, para todo lo demás recuerda, yo soy programador, no Project Manager.

Para hilar la explicación del post, nuestra empresa se llamará Acme (Team Project Collection) y crearemos un proyecto (Team Project) con el mismo nombre y escogeremos el proceso SCRUM.

Recién creado, el nuevo proyecto contiene lo siguiente:

  • 7 Iterations en una estructura jerárquica.
    • Su nodo principal se llama Acme y le cuelgan 6 nodos llamadas Sprint 1-6.
  • 1 Area llamada Acme.
  • Un Team con el nombre del proyecto + Team. Es decir, Acme Team.
  • El equipo Acme Team tiene como área por defecto el área Acme y tiene asignadas las 6 iteraciones Sprint 1-6.

Como verás, es todo muy “Acme”, sobredosis de nombre.

En la pestaña Overview del proyecto https://acmecorporation.visualstudio.com/Acme/_admin tenemos los equipos (el segmento Acme/_admin es importante, nos dice que estamos a nivel de proyecto):

clip_image001

En la pestaña Work encontramos Iterations y Areas:

clip_image002

clip_image003

Si navegamos al equipo, podemos ver como el segmento de la url cambia desde Acme/_admin a Acme/Acme%20Team/_admin. Aunque la interfaz nos ayuda a saber dónde estamos, viendo la url salimos de dudas, ahora estamos en el equipo, no en el proyecto.

En la pestaña Work del equipo encontramos de nuevo Iterations y Areas:

clip_image005

clip_image006

Lo cierto es que, si no creas más equipos en el proyecto, el trabajo que veas a nivel de proyecto es el mismo que verás a nivel de equipo, es decir, da igual si navegas a Acme/_workitems o a Acme/Acme%20Team/_workitems, la query que hay por debajo es la misma.

En el caso de “Backlog items”:

image

Y en el caso del Sprint 1 (por poner un ejemplo), el cambio más reseñable es que pasamos de Iteration Path Under Acme a Iteration Path Under Acme\Sprint 1

image

Es muy importante el operador de la condición, no es lo mismo Under que =, es una diferencia clave a la que hay que prestar atención.

Insisto en que si sólo hay un equipo no hay mucho donde rascar. Cuando se agoten las iteraciones iniciales (Sprint 1-6), se crea una nueva en el proyecto y se asigna al equipo para que esté disponible. Como el operador de Iteration Path para los elementos superiores “Filters for top level work Items” es “Under”, la nueva iteración se verá automáticamente y nuestra vida será plácida.

Sin embargo, si quieres dar cobijo bajo un mismo proyecto a distintos equipos o incluso a distintos proyectos (teniendo entonces un único proyecto en tu organización), las cosas se complican. Podrías usar etiquetas para organizar tu backlog… yo lo he hecho, lo reconozco, pero entender que es una iteración y un área y que relación mantienen con los Work Items y los equipos, te dará otras posibilidades no excluyentes.

A modo de resumen:

  • Un Work Item es un elemento de trabajo cuya propiedad “Work Item Type” determina de qué tipo es.
    • Un Product Backlog Item es un Work Item… una Task o un Bug también lo son.
  • Un Work Item puede relacionarse con otro Work Item.
    • Da igual si no tiene sentido, se puede. Cierto es que la interfaz nos sugiere/conduce hacía hacer las cosas bien, Epic -> Feature -> Product Backlog Item -> Task, pero si por algún extraño motivo te apetece agregar como hijo de una Task a un PBI, se puede.
  • Un Work Item siempre tiene asignado un “Iteration Path” y un “Area Path”.
  • Una iteración es un evento relacionado con el tiempo, normalmente será un sprint, pero podría ser cualquier otra cosa.
  • Un área es una división lógica del Backlog.
  • Una iteración no tiene nada que ver con un área, son entes separados, sólo se relacionan porque ambos son propiedades de un Work Item.
  • Un equipo tiene acceso a n iteraciones.
  • Un equipo tiene acceso a n áreas.
  • Por ende, un equipo verá los Work Items que tengan asignados esas iteraciones o áreas.

Si, por ejemplo, Acme Corporation tuviera dos productos, Cohetes e Imanes ¿Cómo organizar esto en un solo proyecto?

Lo primero sería crear un área por cada proyecto. Tendríamos 2 nuevas áreas hijas bajo el área inicial raíz “Acme”: “Acme\Cohetes” y “Acme\Imanes”. De esta forma, cuando agreguemos un nuevo Work Item le asignaríamos el área adecuada. El backlog empieza a tomar forma, ya podríamos dividirlo con una sencilla query y la condición Area Path = Acme\Cohetes.

Sin embargo, para asignar un área a un Work Item tiene que estar disponible en el equipo. Lo más perverso de la situación es que, aunque no esté el área asignada al equipo, podemos asignar el área a un Work Item… lo que hará que no lo veamos, ¿recuerdas la condición del backlog Area Path = Acme? Lo más sencillo es no hacerlo hasta que el equipo no tenga asignada esa área, momento en el cual la condición del backlog del equipo pasará a ser:

image

Porque esta es la verdad sobre las áreas, se agregarán tantas condiciones de igualdad al backlog como áreas tenga asignadas el equipo. Si queremos cambiar el operador a “Under”, se consigue marcando un área como “Include sub areas”, de este modo, si mañana creáramos Acme\Cohetes\Atómicos no sería necesaria agregar esta nueva área para que fuera visible en el backlog del equipo.

Lo único que queda por comentar sobre las áreas es que el área por defecto del equipo será la que se usará automáticamente cuando creemos un nuevo Work Item.

En este momento ya tenemos áreas, ya tenemos nuestro backlog dividido de forma lógica, pero para hablar de iteraciones primero hay que hablar de equipos.

¿De qué nos sirve tener el backlog dividido si al final lo percibimos todavía como un único e indivisible backlog (no queriendo por otra parte, depender de queries)? Para solucionarlo, crearemos 2 nuevos equipos a igualdad del primer nodo de las áreas lógicas que hemos creado, es decir, nuevo equipo Cohetes y nuevo equipo Imanes. Si asignamos al equipo Cohetes el área Acme\Cohetes (incluyendo sub-áreas) y al equipo Imanes el área Acme\Imanes (incluyendo sub-áreas), ahora cada uno verá en su backlog sólo lo que le concierne. Por otro lado, para ver el backlog completo, en el equipo por defecto simplemente marcamos el área raíz para que incluya sub-áreas.

La distribución quedaría así:

  • Acme Team – Acme (include sub-areas)
  • Cohetes Team – Acme\Cohetes (include sub-areas)
  • Imanes Team – Acme\Imanes (include sub-areas)

Ni que decir tiene, que un usuario puede pertenecer a cualquier número de equipos, podría construir tanto cohetes como imanes.

Vale, ahora sí veo el backlog completo (Acme Team) y por separado el backlog de cohetes (Cohetes Team) y el backlog de Imanes (Imanes Team), pero tristemente hay que ponerse a trabajar… ahora toca el turno de las iteraciones.

Como hemos dicho antes, una iteración es un evento temporal con fechas de inicio y fin opcionales. Admite una estructura jerárquica, por lo que podríamos tener algo como lo siguiente:

  • Acme
    • Release 1
      • Sprint 1
      • Sprint 2

Los valores de Iteration Path para el anterior ejemplo serían “Acme”, “Acme\Release 1”, “Acme\Release 1\Sprint 1” y “Acme\Release 1\Sprint 2”.

Para ver las iteraciones en un equipo, el equipo las tiene que tener asignadas. Podríamos asignar a Cohetes Team cualquier iteración de las anteriores, pero lo que no podemos hacer es tener una iteración hija y a la vez una iteración padre, es decir, no podemos asignar “Acme\Release 1” y “Acme\Release 1\Sprint 1” a la vez, si agregamos el sprint, el release se eliminará (pero si no agregamos el sprint, sí podríamos haber agregado el release, espero haberme explicado…).

Finalmente, en la pestaña Work y en el menú izquierdo aparecerán las iteraciones asignadas (una Current y el resto Futures).

¿Coinciden en el mismo tiempo el Sprint 1 para los 2 equipos? Usa el mismo, sin problemas, la información relativa a la capacidad, burdown chart, etc. es relativa al equipo actual. ¿No coincide? Usa iteraciones distintas. Lo que quiero decir es un tema muy flexible y las condiciones propias de tu negocio te harán tomar una u otra decisión. De nuevo, para ver todas las iteraciones a la vez, en el equipo por defecto deberían estar todas las iteraciones asignadas. La pena aquí es que sólo una iteración será la “Current”, con lo que para ver cómo van nuestros equipos tendremos que movernos entre una Current y varias Futures. Sin embargo, para cada equipo queda muy claro cuáles son sus iteraciones y verán sólo las suyas.

Respecto a iteraciones y en el contexto de un equipo, es importante entender las opciones “Default Iteration” y “Backlog iteration”.

Backlog iteration es la ruta a la iteración raíz a partir de la cual se podrán asignar iteraciones al equipo. Es decir, si la establecemos a Acme\Cohetes (porque creamos una iteración con este nombre), sólo podremos asignar iteraciones por debajo de Acme\Cohetes. Además, esto se traduce en una condición del backlog (no hablo aquí de sprints) como Iteration Path Under “Backlog iteration”. Con esta configuración ya podemos responder de forma precisa a la pregunta ¿Qué ve un equipo en su backlog? Ve Work Items de sus áreas asignadas y en iteraciones iguales o inferiores a “Backlog iteration”.

Además, Backlog iteration también será el valor por defecto para Iteration Path cuando agreguemos un Work Item desde el backlog (en sprints o en cualquier otra iteración, el valor por defecto será la propia iteración).

El campo “Default Iteration” es un poco raro, la verdad, sólo sirve para saber cuál será el valor por defecto para Iteration Path para un nuevo Work Item cuando se agregue desde el widget del dashboard principal o desde una query. Entiendo que aquí no hay contexto, no sabe si está en el backlog o en una iteración y por eso no puede tomar una decisión automáticamente. Por defecto vale @CurrentIteration, que es la iteración “Current”, así está bien, mejor no tocarlo.

Llegados a este punto, ya hemos resuelto como se relacionan Team Projects, Work Items, Teams, Iteration Paths y Areas.

Por sacarle punta al asunto, los inconvenientes que por ahora veo sobre tener equipos por áreas proyectos serían:

  • La velocidad es por área.
  • Si alguien trabaja al mismo tiempo en esprines en distintos equipos (cosa que no debería pasar idealmente), tendrá que dividir su capacidad y estar al tanto de dos esprines, no sólo uno.
  • Con el tiempo y si el número de áreas/proyectos crece, podría haber más equipos que gente trabajando. ¡Awesome!

Otro inconveniente que merece su propio punto y aparte es cómo ordenar la prioridad del backlog si un equipo tiene varias áreas asignadas. Es decir, si vamos al backlog del equipo veremos todos los PBIs de las áreas a las que tiene acceso el equipo, y priorizar allí una sola área de muchas existentes no es tarea sencilla, mucho ruido. La primera solución que se le ocurriría a cualquiera (yo incluido) sería hacer una query para filtrar por área, pero el problema es que las queries son de sólo-lectura en cuanto a la ordenación. La única solución que hemos visto viable hasta el momento ha sido crear una query que incluya el campo “Backlog priority” (campo orden, por el que se ordena el backlog) y después editar los resultados de la query vía Excel. Digo vía excel porque desde allí podemos operar sobre el backlog de forma masiva, porque aunque personalizáramos el proceso de VSTS para incluir el campo “Backlog priority” en la edición de un PBI, no permite editar este campo de forma masiva, con selección múltiple. Sin embargo, a través de excel sí que podemos hacerlo e incluso gente de negocio que no tenga instalado Visual Studio puede hacerlo igualmente descargando el plugin oportuno (Integración de Office® para Team Foundation Server 2017) para agregar la pestaña “Team” a excel y funcionar desde allí.

Bueno, espero que le sea de utilidad a alguien este mamotreto, a mí seguro que sí.

¡Un saludo!