Archivo de la etiqueta: C#

Localizando desde la base de datos


En .Net Core, la interfaz IStringLocalizer, permite estandarizar la forma en que se presentan los textos según el idioma del usuario.

En general, todos los ejemplos se basan en la utilización de archivos de recursos.

En este caso, veremos cómo podemos, implementando la interfaz, obtener los textos desde una tabla de base de datos, de modo tal de poder ir incrementando dinámicamente los idiomas a los que damos soporte.

Se utiliza una tabla para contener las cadenas, con, al menos, el siguiente esquema.

CREATE TABLE [Masters].[AppResources](
	[Culture] [nvarchar](20) NOT NULL,
	[Key] [nvarchar](50) NOT NULL,
	[StringText] [nvarchar](max) NULL,
 CONSTRAINT [PK_AppResources] PRIMARY KEY CLUSTERED 
(
	[Culture] ASC,
	[Key] ASC
)

Definimos entonces una clase que implemente la interfaz Microsoft.Extensions.Localization.IStringLocalizer.

La interfaz expone directamente las propiedades de su contenido como indexador, con el método this[Clave]

/// <summary>
/// Represents a service that provides localized strings.
/// </summary>
public interface IStringLocalizer
{
    /// <summary>
    /// Gets the string resource with the given name.
    /// </summary>
    /// <param name="name">The name of the string resource.</param>
    /// <returns>The string resource as a <see cref="LocalizedString"/>.</returns>
    LocalizedString this[string name] { get; }

    /// <summary>
    /// Gets the string resource with the given name and formatted with the supplied arguments.
    /// </summary>
    /// <param name="name">The name of the string resource.</param>
    /// <param name="arguments">The values to format the string with.</param>
    /// <returns>The formatted string resource as a <see cref="LocalizedString"/>.</returns>
    LocalizedString this[string nameparams object[] arguments] { get; }

    /// <summary>
    /// Gets all string resources.
    /// </summary>
    /// <param name="includeParentCultures">
    /// A <see cref="System.Boolean"/> indicating whether to include strings from parent cultures.
    /// </param>
    /// <returns>The strings.</returns>
    IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures);

Existe, además, una extensión a la interfaz, que expone el método GetString, que facilita aún más la obtención de valores

public static class StringLocalizerExtensions
{
    /// <summary>
    /// Gets the string resource with the given name.
    /// </summary>
    /// <param name="stringLocalizer">The <see cref="IStringLocalizer"/>.</param>
    /// <param name="name">The name of the string resource.</param>
    /// <returns>The string resource as a <see cref="LocalizedString"/>.</returns>
    public static LocalizedString GetString(
        this IStringLocalizer stringLocalizer,
        string name)
    {
        ArgumentNullThrowHelper.ThrowIfNull(stringLocalizer);
        ArgumentNullThrowHelper.ThrowIfNull(name);

        return stringLocalizer[name];
    }

Hagamos que nuestra clase mantenga en memoria aquellos textos que ya ha obtenido, preservándolos en un diccionario

private System.Collections.Concurrent.ConcurrentDictionary<stringstring> cachedElements
   = new();

Necesitaremos también preservar la cadena de conexión a la base de datos (que pasaremos al constructor) así como 2 objetos SqlCommand, uno para leer los valores y otro, que inserte aquellas claves que no encuentre para que resulte fácil saber que textos e idiomas nos están faltando

private readonly string ConnectionString;
SqlCommand command;
SqlCommand insertCommand;

En el constructor de la clase, aprovechemos para definir las sentencias T-SQL y los parámetros que corresponden a ambos objetos Command.

public Localizer(string connectionString)
{
   ConnectionString = connectionString;
   command = new SqlCommand("""
            SELECT [StringText]   
            FROM [Masters].[AppResources]
            WHERE  [Culture]=@Culture
            and [Key]=@Key
            """);
   command.Parameters.Add("@Culture", SqlDbType.NVarChar, 20);
   command.Parameters.Add("@Key", SqlDbType.NVarChar, 50);
   insertCommand = new SqlCommand("""
            INSERT INTO [Masters].[AppResources]
            ([Culture]
            ,[Key]
            ,[StringText]
            )
            VALUES
            (@Culture
            ,@Key
            ,'['+@Key+']')
 
            """);
   insertCommand.Parameters.Add("@Culture", SqlDbType.NVarChar, 20);
   insertCommand.Parameters.Add("@Key", SqlDbType.NVarChar, 50);
}

Definamos ahora una función que obtenga los textos, a la cual llamarán todos los métodos de implementación de la interfaz para centralizar la búsqueda en el diccionario en memoria y/o en base de datos.

private LocalizedString GetResultString(string key)
{
   if(string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException(nameof(key));
   string value;
   string lookFor = $"{System.Threading.Thread.CurrentThread.CurrentCulture.Name}_{key}";
   if(cachedElements.ContainsKey(lookFor))
      value = cachedElements[lookFor];
   else
   {
      value = GetFromDb(key) ?? string.Empty;
      cachedElements.TryAdd(lookFor, value);
   }
   return new LocalizedString(key, value);
}

Las acciones realizadas con la base de datos, las implementamos en otra función, que realizará la lectura, así como la inserción de claves no encontradas.

private string? GetFromDb(string key)
{
   using SqlConnection con = new SqlConnection(ConnectionString);
   con.Open();
   command.Connection = con;
   CultureInfo culture = System.Threading.Thread.CurrentThread.CurrentCulture;
   command.Parameters["@Culture"].Value = culture.Name;
   command.Parameters["@Key"].Value = key;
   var result = command.ExecuteScalar();
   // If do not found the key for the current culture,
   // try to search the same for the parent culture,
   // which is more generic as in es-AR and es
   while(result is null)
   {
      culture = culture.Parent;
      command.Parameters["@Culture"].Value = culture.LCID == 127 ? " " : culture.Name;
      result = command.ExecuteScalar();
      if(culture.LCID == 127 && result is null//This is invariant culture. No text found
      {
         result = $"[{key}]";
         culture = System.Threading.Thread.CurrentThread.CurrentCulture;
         // if we match the Invariant culture with no results, means the key is not present in the table.
         // Must be inserted
         while(!culture.IsNeutralCulture)
            culture = culture.Parent;
         insertCommand.Connection = con;
         insertCommand.Parameters["@Culture"].Value = culture.Name;
         insertCommand.Parameters["@Key"].Value = key;
         insertCommand.ExecuteNonQuery();
         insertCommand.Parameters["@Culture"].Value = " ";
         insertCommand.ExecuteNonQuery();
      }
   }
   return $"{result}";
}

Finalmente, solo resta registrar como instancia única nuestra clase para la interfaz. Por ejemplo, en el archivo program.cs de una Aplicación ASP. Net Core

builder.Services.AddSingleton<IStringLocalizer>(
   provider => new Localizer(builder.Configuration.GetConnectionString("DefaultDatabase")));

Entonces, obtendremos la instancia de la clase por inyección de dependencias, tanto en código C#, como en páginas Blazor, etc.

Para que resulte más fácil de seguir, a continuación, la clase completa.

/// <summary>
/// Implements the <see cref="IStringLocalizer"/> interface,getting the values from a database table
/// </summary>
/// <seealso cref="Microsoft.Extensions.Localization.IStringLocalizer" />
/// <remarks>
/// Requires a table with at least the following schema
/// CREATE TABLE [Masters].[AppResources](
/// 	   [Culture] [nvarchar](20) NOT NULL,
///      [Key] [nvarchar] (50) NOT NULL,
///      [StringText] [nvarchar](max) NULL,
///  CONSTRAINT[PK_AppResources] PRIMARY KEY CLUSTERED
///   (
///      [Culture] ASC,
///      [Key] ASC
///   )
/// </remarks>
public class Localizer : IStringLocalizer
 
{
   private System.Collections.Concurrent.ConcurrentDictionary<stringstring> cachedElements
      = new();
   private readonly string ConnectionString;
   SqlCommand command;
   SqlCommand insertCommand;
   public Localizer(string connectionString)
   {
      ConnectionString = connectionString;
      command = new SqlCommand("""
               SELECT [StringText]   
               FROM [Masters].[AppResources]
               WHERE  [Culture]=@Culture
               and [Key]=@Key
               """);
      command.Parameters.Add("@Culture", SqlDbType.NVarChar, 20);
      command.Parameters.Add("@Key", SqlDbType.NVarChar, 50);
      insertCommand = new SqlCommand("""
               INSERT INTO [Masters].[AppResources]
               ([Culture]
               ,[Key]
               ,[StringText]
               )
               VALUES
               (@Culture
               ,@Key
               ,'['+@Key+']')
 
               """);
      insertCommand.Parameters.Add("@Culture", SqlDbType.NVarChar, 20);
      insertCommand.Parameters.Add("@Key", SqlDbType.NVarChar, 50);
   }
   public LocalizedString this[string name] { get => GetResultString(name); }
   public LocalizedString this[string nameparams object[] arguments] { get => GetResultString(name, arguments); }
 
   public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures=> throw new NotImplementedException();
   /// <summary>
   /// Gets the localized string for the <paramref name="key"/>
   /// </summary>
   /// <param name="key">The key to search.</param>
   /// <returns>The string for the current culture</returns>
   /// <exception cref="ArgumentNullException">nameof(key)</exception>
   private LocalizedString GetResultString(string key)
   {
      if(string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException(nameof(key));
      string value;
      string lookFor = $"{System.Threading.Thread.CurrentThread.CurrentCulture.Name}_{key}";
      if(cachedElements.ContainsKey(lookFor))
         value = cachedElements[lookFor];
      else
      {
         value = GetFromDb(key) ?? string.Empty;
         cachedElements.TryAdd(lookFor, value);
      }
      return new LocalizedString(key, value);
   }
   /// <summary>
   ///  Gets the localized string for the <paramref name="key"/>
   /// </summary>
   /// <param name="key">The key.</param>
   /// <param name="arguments">The arguments.</param>
   /// <returns>The string for the current culture, with the <paramref name="arguments"/> included as values</returns>
   private LocalizedString GetResultString(string keyparams object[] arguments)
   {
      var resp = GetResultString(key);
      return new LocalizedString(resp.Name, string.Format(resp.Value, arguments));
   }
   private string? GetFromDb(string key)
   {
      using SqlConnection con = new SqlConnection(ConnectionString);
      con.Open();
      command.Connection = con;
      CultureInfo culture = System.Threading.Thread.CurrentThread.CurrentCulture;
      command.Parameters["@Culture"].Value = culture.Name;
      command.Parameters["@Key"].Value = key;
      var result = command.ExecuteScalar();
      // If do not found the key for the current culture,
      // try to search the same for the parent culture,
      // which is more generic as in es-AR and es
      while(result is null)
      {
         culture = culture.Parent;
         command.Parameters["@Culture"].Value = culture.LCID == 127 ? " " : culture.Name;
         result = command.ExecuteScalar();
         if(culture.LCID == 127 && result is null//This is invariant culture. No text found
         {
            result = $"[{key}]";
            culture = System.Threading.Thread.CurrentThread.CurrentCulture;
            // if we match the Invariant culture with no results, means the key is not present in the table.
            // Must be inserted
            while(!culture.IsNeutralCulture)
               culture = culture.Parent;
            insertCommand.Connection = con;
            insertCommand.Parameters["@Culture"].Value = culture.Name;
            insertCommand.Parameters["@Key"].Value = key;
            insertCommand.ExecuteNonQuery();
            insertCommand.Parameters["@Culture"].Value = " ";
            insertCommand.ExecuteNonQuery();
         }
      }
      return $"{result}";
   }
}
/// <summary>
/// /
/// </summary>
/// <typeparam name="T"></typeparam>
public class Localizer<T> : IStringLocalizer<T>
{
   IStringLocalizer _localizer;
 
   public Localizer(string connectionString)
   {
      _localizer = new Localizer(connectionString);
   }
   public LocalizedString this[string name] { get => _localizer[name]; }
   public LocalizedString this[string nameparams object[] arguments] { get => _localizer[name, arguments]; }
 
   public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures=> _localizer.GetAllStrings();
}

Creando cadena de conexión para SQL Server: WinForms IU


Teniendo ya el componente que nos permite generar una cadena de conexión, procedamos a crear un componente que cree un diálogo para utilizar dicha clase, desde Windows Forms.

La interfaz de usuario será la siguiente:

Para ello, generaremos un proyecto de tipo biblioteca de clases, al cual agregaremos un formulario de Windows (aunque sea biblioteca de clases), con la interfaz requerida.

Nótese que la primera imagen presenta los literales en español, mientras que, en diseño, los textos están en inglés. Esto es así, porque estamos operando con localización del componente.

El proyecto tiene una clase, con un único método para obtener la cadena de conexión:

public static class ConnectionBuilder
{
   public static string? PromptForConnection(string connectionString = null)
   {
      var dlg = new NDSoft.SqlTools.WinForms.dlgConnection();
      if(!string.IsNullOrEmpty(connectionString))
         dlg.ConnectionString = connectionString;
      if(dlg.ShowDialog() == DialogResult.OK)
         return dlg.ConnectionString;
      else
         return null;
   }
}

Un detalle importante es que el constructor del formulario NO es público, sino internal. De esa forma, no se puede crear una instancia del formulario desde otro proyecto, sino que, necesariamente, debe utilizarse el método PromptForConnection de la clase estática.

El formulario maneja la interactividad, validación, etc. que corresponde a los distintos controles de captura de datos.

Además, reaccionará ante el evento del componente para habilitar o deshabilitar los controles de captura de usuario y contraseña, cuando corresponda.

private void EnableCredentials(Boolean enable)
{
   txtPassword.Enabled = enable;
   txtUsername.Enabled = enable;
}

Finalmente, en las propiedades del proyecto, habilitaremos el empaquetado, lo que permitirá que dispongamos de un paquete Nuget con nuestro componente, para agregar a los proyectos en los que lo necesitemos.

El paquete se generará dentro de la carpeta bin, en la carpeta que corresponda al perfil de compilación (Debug, Release).

Puedes encontrar el proyecto fuente aquí

SQL Server insert using JSON


During this post, I explained how to get information from a database in JSON format. In this one, I will show you how to store information sent to the database in JSON format.

I will use a very common scenario, storing a master-detail combination. For this, I use the Order-Order Details tables in Northwind database, which you can get here.

The goal is to store a new Order, with several Order Details in the same procedure, by using T-SQL OPENJSON.

Like a cooking recipe, I will explain this step by step.

Define the JSON schema.

We want to store the information received in the Order Details table, so its schema will be the schema received in the JSON information.

Simply get the Order Details schema and remove the NOT NULL modifiers. This will be used in the WITH modifier of the OPENJSON statement this way:

OPENJSON(@Details) WITH([OrderID] [INT], [ProductID] [INT],
[UnitPrice] [MONEY], [Quantity] [SMALLINT], [Discount]
[REAL]);

Prepare the Stored Procedure.

Parameters.

It must have parameters to receive all the data for the Orders Table’s columns, and one more containing the entire JSON information for the Order Details table. Notice the OrderID parameter is declared as OUTPUT, so the calling code could retrieve the new Order ID for the inserted row.

   @OrderID        INT OUTPUT
, @CustomerID     NCHAR(5)
, @EmployeeID     INT
, @OrderDate      DATETIME
, @RequiredDate   DATETIME      = NULL
, @ShippedDate    DATETIME      = NULL
, @ShipVia        INT
, @Freight        MONEY
, @ShipName       NVARCHAR(40)
, @ShipAddress    NVARCHAR(60)
, @ShipCity       NVARCHAR(15)
, @ShipRegion     NVARCHAR(15)  = NULL
, @ShipPostalCode NVARCHAR(10)
, @ShipCountry    NVARCHAR(15)
, @Details        NVARCHAR(MAX)

Insert the Orders new row values.

It is a simple insert – values sentence, as follows:

INSERT INTO [Orders]
      (
   [CustomerID]
 , [EmployeeID]
 , [OrderDate]
 , [RequiredDate]
 , [ShippedDate]
 , [ShipVia]
 , [Freight]
 , [ShipName]
 , [ShipAddress]
 , [ShipCity]
 , [ShipRegion]
 , [ShipPostalCode]
 , [ShipCountry]
  )
VALUES
    (
  @CustomerID
, @EmployeeID
, @OrderDate
, @RequiredDate
, @ShippedDate
, @ShipVia
, @Freight
, @ShipName
, @ShipAddress
, @ShipCity
, @ShipRegion
, @ShipPostalCode
, @ShipCountry
);

Get the new inserted OrderId.

For this, the procedure must use the IDENT_CURRENT function.

SET @OrderID = IDENT_CURRENT('[Orders]');

Insert the Order Details using OPENJSON.

In this case, using Insert – select statement, and OPENJSON from the Details parameter as source, declaring it with the previously obtained schema. Notice the utilization of the @OrderID parameter for the Order Id value in each row.

INSERT INTO [Order Details]
      (
   [OrderID]
 , [ProductID]
 , [UnitPrice]
 , [Quantity]
 , [Discount]
  )
       SELECT 
          @OrderID /* Using the new Order ID*/
 
        , [Productid]
        , [UnitPrice]
        , [Quantity]
        , [Discount]
       FROM 
          OPENJSON(@Details) WITH([OrderID] [INT], [ProductID] [INT],
          [UnitPrice] [MONEY], [Quantity] [SMALLINT], [Discount]
          [REAL]);

The C# code.

Define the Order and Order Details entities in your Application.

I created a simple C# Console Application Sample. In it, the Order and Order_Details has been defined by using the Paste Special Paste JSON as Classes feature in Visual Studio. You can see the step by step here.

The Insert routine in the main program.

The code creates a new instance of the Order class, with values,

Order order = new()
{
   CustomerID = "ALFKI",
   EmployeeID = 1,
   OrderDate = DateTime.UtcNow,
   RequiredDate = DateTime.UtcNow.AddDays(5),
   ShipAddress = "Obere Str. 57",
   ShipCity = "Berlin",
   Freight = 12.05F,
   ShipCountry = "Germany",
   ShipName = "Alfreds Futterkiste",
   ShipPostalCode = "12209",
   ShipRegion = null,
   ShipVia = 1
};

Then get information from a previously defined set, (as JSON), to have an array of Order Details.

// Create the details. To Avoid a long code, just get it from a JSON sample
var details = System.Text.Json.JsonSerializer.Deserialize<OrderDetail[]>
   (InsertUsingJSONSample.Properties.Resources.Details);

Create the Connection and Command objects.

A connection object to the database is assigned to a Command object, with the name of the stored procedure as text, which is defined as a Stored Procedure command type.

SqlConnection con = new SqlConnection(InsertUsingJSONSample.Settings1.Default.ConString);
SqlCommand com = new SqlCommand("InsertWithJSONSP", con)
{
   CommandType = System.Data.CommandType.StoredProcedure
};

Add the parameters

Then, all the Order properties are added as parameters, plus one more, Details, containing the Order_Detail array expressed as JSON.

To do so, the code use Reflection to get all the properties in the Order instance and their values.

Note the parameter @OrderID is defined as InputOutput, so the code could retrieve the new Order Id once the procedure ends execution.

foreach (PropertyInfo item in order.GetType().GetProperties())
{
   com.Parameters.AddWithValue("@" + item.Name, item.GetValue(order));
 
}
com.Parameters["@OrderId"].Direction = System.Data.ParameterDirection.InputOutput;

Finally, the command is executed to insert the new Order with the Details and retrieve the new Id.

using (con)
{
   con.Open();
   int retValue = await com.ExecuteNonQueryAsync();
   int NewOrderID = (int)com.Parameters["@OrderId"].Value;
}

As usual, you will find the code sample here.

By using this method, you reduce the calls between your app and the database server, optimizing the response, and including the entire store process in a unique implicit transaction.

HTH

Languages and countries in your Apps


In our current globalized world, at this time, any web site must be multilingual, enabling the user to select the language to use.

Moreover, a global company needs to know from which country the user connected.

A lot of sites, more of the streaming services as examples, identifies the country based on the IP address. Which could be a mistake, since a lot of people are in a different country of which belongs to.  

Anyway, your database must need a language list and a countries list.

This post shows you a sample application which, using the standardized languages and countries from .Net Framework, and adding some information from external sources, set up some tables to manage this in your databases.

System.Globalization Namespace

The tool uses information from the System.Globalization namespace, which appears with .Net Framework from it very beginning.

The CultureInfo type exposes each of the languages in use around the world thru the GetCultures member, based in the ISO 639-1: Codes for the Representation of Names of Languages  and in the ISO – ISO 3166 — Country Codes.

Each CultureInfo class instance exposes these properties among others:

Name The identifier of the culture in the culturecode2-country/regioncode2 format
EnglishName The culture name in Enlgish
DisplayName The culture name in the culture of your current configuration
NativeName The culture name in it own culture
IetfcultureTag The culture part of the name
LCID The Windows Language code
ThreeLetterISOcultureName ISO 639-2 code
TextInfo Information about text management
Calendar Calendars used by the culture
DateTimeFormat How the dates and times are managed
NumberFormat How the numbers, currencies etc. Are used
IsNeutralCulture Indicates if it is just a language or a language and country specification

Finally, I get information from two external sources. I got the GPS coordinates of each country from here meanwhile the flag’s pictures are from here. You can found the urls inside the code as well.

Storage’s schema.

Languages/Countries database schema

The tool create 3 tables as you can see in the Diagram.

It is necessary this way, because some countries use more than one language, and the relationship must be preserved.

The tables have a InUse column, to enable/disable each row for your application. So, you can query the Languages table for all the rows with the InUse value in 1, to display only those languages you desire use, or have enabled.

Note: It is important using nvarchar/nchar data types, since several Native names are in UTF-8 chars.

Using the tool.

The tool expects at least the connection string to your database. It accepts a second parameter for the schema name under the tables will be created. If this value is not provided, the tool assumes “Masters” as schema name.

In any case, the DDL scripts manage the creation of the schema if it does not exist.

The source code of the DataGen solution is in my GitHub.

If you prefer just use a T-SQL script to add the tables, here is the script.

In future posts, I will show some faqncy methods for site AND CONTENT localization.

using the API REST (JSON)


In this example, we will see how we can test the different versions from a web application.

It is a simple page that calls consecutively the different versions, repeatedly (100), and returns the number of milliseconds involved in the whole process.

To increase the impedance of the evaluation, the option is given in a check box, to perform the same query, 100 times, but for all countries.

In any case, the responses will be in approximately the same proportion.

Performance

As you can see, from the point of view of the client application, the final result is faster with EF than with simple code, exactly the opposite of the performance in the first test.

It should be noted, however, that in that first test, the process requested was not the same at all, since data from different tables were required, while, in this case, the data came from only one.

In any case, the comparison could be established with the second publication of this series, which obtains the same data as the latter.

In any case, this last test has some advantage on the part of EF, compared to the rest.

In other words, let us point out conclusions:

  • The result is not always as expected. You must try. ALWAYS.
  • There is no single way to do things. We must investigate to improve the quality of the applications we generate.
  • An unit test of the final set of the application can lead to false conclusions, since other factors, such as communication, data transformation, etc., also influence performance. That is, unit tests are highly valid for testing functionality, but they are not always valid for evaluating performance.
  • In fact, most likely, in a real-world application environment, performance results can also change.

Therefore, it is important to monitor the application deployed in production, include counters, logs etc. to have permanent information and be able to periodically evaluate it, to anticipate possible problems.

We’ll see more of this in other posts.

Puedes encontrar la solución completa en este repositorio

Usando la API REST (JSON)


En este ejemplo, veremos como podemos probar las distintas versiones desde un aplicativo web.

Se trata de una página sencilla que, llama consecutivamente a las distintas versiones, repetidas veces (100), y nos retorna la cantidad de milisegundos implicados en todo el proceso.

Para incrementar la impedancia de la evaluación, se da la opción en una casilla, de realizar la misma consulta, 100 veces, pero para todos los países.

En cualquier caso, las respuestas serán aproximadamente en la misma proporción.

Rendimiento

Como se puede ver, desde el punto de vista de la aplicación cliente, el resultado final es más rápido con EF que con código plano, exactamente lo contrario a la respuesta en la primera prueba.

Es de notar, sin embargo, que en aquella primera prueba, el proceso solicitado no era el mismo en lo absoluto, dado que se requerían datos de distintas tablas, mientras que, en este caso, los datos provienen de una sola.

En todo caso, la comparativa la podríamos establecer con la segunda publicación de esta serie, que obtiene los mismos datos que ésta última.

En cualquier caso, esta última prueba presenta alguna ventaja de parte de EF, respecto del resto.

O sea, puntualicemos conclusiones:

  • No siempre el resultado es el esperado. Hay que probar. SIEMPRE.
  • No hay una sola forma de hacer las cosas. Hay que investigar para mejorar la calidad de las aplicaciones que generamos.
  • Una prueba aislada del conjunto final de la aplicación, puede llevarnos a falsas conclusiones, ya que otros factores, como comunicación, transformación de datos, etc., también influyen en el rendimiento.
    O sea, las pruebas unitarias son muy validad para comprobar funcionalidad, pero no siempre son válidas para evaluar rendimiento.

De hecho, muy probablemente, en un entorno de una aplicación real, también los resultados de rendimiento pueden cambiar.

Por ello, es importante hacer seguimiento de la aplicación desplegada en producción, incluir contadores, bitácoras etc. para tener información permanente y poder evaluar periódicamente la misma, para adelantarnos a posibles problemas.

Ya veremos más de esto.

Puedes encontrar la solución completa en este repositorio

GENERATING A REST API (JSON) [3]


Finally, I’ll add projects to use stored procedures instead of statements constructed in code.

Moreover, and based on the recommendation of a greater entity framework expert than me, I added «AsNoTracking()» to the Entity Framework LINQ query set to  Generating a REST API (JSON)[2].

The Stored Procedure.

This is the stored procedure which receives the country ID, the date from and date to, and the page to display.

Is the stored procedure which is responsible for setting values that are appropriate to the date parameters, rather than setting them from the component in C#.

CREATE PROCEDURE [dbo].[Owid Covid Data Get By Country] 
   @fromDate   SMALLDATETIME NULL
 , @toDate     SMALLDATETIME NULL
 , @CountryId  INT
 , @Page       INT           = 1
AS
  BEGIN
    SELECT 
        @froMDate = ISNULL(@fromDate,
    (
        SELECT 
            MIN([o].[date])
        FROM 
           [Owid Covid Data] AS [o]
    ));
    SELECT 
        @toDate = ISNULL(@toDate,
    (
        SELECT 
            MAX([o].[date])
        FROM 
           [Owid Covid Data] AS [o]
    ));
    DECLARE 
           @Skip  INT = ((@Page - 1) * 100);
    BEGIN
      SELECT 
          [o].[ContinentId]
        , [o].[CountriesId]
        , [o].[date]
        , [o].[hosp_patients]
        , [o].[hosp_patients_per_million]
        , [o].[icu_patients]
        , [o].[icu_patients_per_million]
        , [o].[new_cases]
        , [o].[new_cases_per_million]
        , [o].[new_cases_smoothed]
        , [o].[new_cases_smoothed_per_million]
        , [o].[new_deaths]
        , [o].[new_deaths_per_million]
        , [o].[new_deaths_smoothed]
        , [o].[new_deaths_smoothed_per_million]
        , [o].[new_tests]
        , [o].[new_tests_per_thousand]
        , [o].[new_tests_smoothed]
        , [o].[new_tests_smoothed_per_thousand]
        , [o].[new_vaccinations]
        , [o].[new_vaccinations_smoothed]
        , [o].[new_vaccinations_smoothed_per_million]
        , [o].[people_fully_vaccinated]
        , [o].[people_fully_vaccinated_per_hundred]
        , [o].[people_vaccinated]
        , [o].[people_vaccinated_per_hundred]
        , [o].[positive_rate]
        , [o].[tests_per_case]
        , [o].[tests_units]
        , [o].[total_cases]
        , [o].[total_cases_per_million]
        , [o].[total_deaths]
        , [o].[total_deaths_per_million]
        , [o].[total_tests]
        , [o].[total_tests_per_thousand]
        , [o].[total_vaccinations]
        , [o].[total_vaccinations_per_hundred]
        , [o].[weekly_hosp_admissions]
        , [o].[weekly_hosp_admissions_per_million]
        , [o].[weekly_icu_admissions]
        , [o].[weekly_icu_admissions_per_million]
      FROM 
         [Owid Covid Data] AS [o]
      WHERE [o].[date] >= @fromDate
            AND [o].[date] <= @toDate
            AND [o].[CountriesId] = @CountryId
      ORDER BY 
          date
      OFFSET @skip ROWS FETCH NEXT 100 ROWS ONLY;
    END;
  END;

Exactly the same, but with «FOR JSON PATH» at the end, is used in the project that uses pure code.

El Cambio en Entity Framework

Based on the proposal and comment, the code is as follows:

public IEnumerable<OwidCovidDatum> GetCountryData(
   int CountryId,
   DateTime? fromDate=null, 
   DateTime? toDate=null,
   int Page=1)
{
   fromDate=fromDate??
      (from el in _context.OwidCovidData orderby el.Date select el.Date).FirstOrDefault();
   toDate=toDate??
      (from el in _context.OwidCovidData orderby el.Date descending select el.Date).FirstOrDefault();
 
   return (from OwidCovidDatum el in 
              _context.OwidCovidData
              .Where(x=> x.Date>=fromDate && x.Date<=toDate && x.CountriesId==CountryId)
              .Skip((Page-1)*100)
              .Take(100select el).AsNoTracking().ToList();
}

Dapper Using Stored Procedure

We use Dapper’s stored procedure execution capability, which is capable of assign values to parameters by name matching.

public async Task<string> GetCountryData(
   int CountryId, 
   DateTime? fromDate = null, 
   DateTime? toDate = null, 
   int Page = 1)
{
 
   var result =await _dal.GetDataAsync("[Owid Covid Data Get By Country]",new { fromDate, toDate, CountryId, Page });
   string json = JsonSerializer.Serialize(result, new JsonSerializerOptions()
   {
      WriteIndented = true,
      ReferenceHandler = ReferenceHandler.Preserve
   });
   return json;
}

Code using Stored Procedure

In the case of direct code, we assign the parameters one by one, also specifying the data type, which allows greater specificity.

public async Task<string> GetCountryData(
   int CountryId, 
   DateTime? fromDate = null, 
   DateTime? toDate = null, 
   int Page = 1)
{
   var command = _dal.CreateCommand("[Owid Covid Data Get By Country JSON]");
   command.Parameters.Add("@fromDate", System.Data.SqlDbType.SmallDateTime).Value = fromDate;
   command.Parameters.Add("@toDate", System.Data.SqlDbType.SmallDateTime).Value = toDate;
   command.Parameters.Add("@CountryId", System.Data.SqlDbType.Int).Value = CountryId;
   command.Parameters.Add("@skip", System.Data.SqlDbType.Int).Value = Page;
   var json =await _dal.GetJSONDataAsync(command);
   return json;
}

Performance

The graph shows that even when you use features that improve effectiveness, the simplicity of the code improves performance.

That is, for better response to the user, more time should be invested by developers in improving their development.

As a detail, stored procedure calls directly make calls to the SP, instead of using, as we saw in the previous post, sp_executesql.

EXEC [Owid Covid Data Get By Country] 
     @fromDate = NULL
   , @toDate = NULL
   , @CountryId = 4
   , @Page = 3;

GENERANDO UNA API REST (JSON) [3]


Aquí, finalmente, agregaré proyectos para utilizar procedimientos almacenados en lugar de sentencias construidas en el código.

De paso, y por recomendación de un mayor experto que yo en Entity Framework, agregué «AsNoTracking()» a la consulta LINQ de Entity Framework establecida en Generando una API REST (JSON) [2].

El procedimiento Almacenado.

Este es el procedimiento almacenado que recibe, el Id de país, la fecha desde y la fecha hasta, y la página a mostrar.

Es el procedimiento almacenado el responsable de establecer valores adecuados a los parámetros de fecha, en lugar de establecerlos desde el componente en C#.

CREATE PROCEDURE [dbo].[Owid Covid Data Get By Country] 
   @fromDate   SMALLDATETIME NULL
 , @toDate     SMALLDATETIME NULL
 , @CountryId  INT
 , @Page       INT           = 1
AS
  BEGIN
    SELECT 
        @froMDate = ISNULL(@fromDate,
    (
        SELECT 
            MIN([o].[date])
        FROM 
           [Owid Covid Data] AS [o]
    ));
    SELECT 
        @toDate = ISNULL(@toDate,
    (
        SELECT 
            MAX([o].[date])
        FROM 
           [Owid Covid Data] AS [o]
    ));
    DECLARE 
           @Skip  INT = ((@Page - 1) * 100);
    BEGIN
      SELECT 
          [o].[ContinentId]
        , [o].[CountriesId]
        , [o].[date]
        , [o].[hosp_patients]
        , [o].[hosp_patients_per_million]
        , [o].[icu_patients]
        , [o].[icu_patients_per_million]
        , [o].[new_cases]
        , [o].[new_cases_per_million]
        , [o].[new_cases_smoothed]
        , [o].[new_cases_smoothed_per_million]
        , [o].[new_deaths]
        , [o].[new_deaths_per_million]
        , [o].[new_deaths_smoothed]
        , [o].[new_deaths_smoothed_per_million]
        , [o].[new_tests]
        , [o].[new_tests_per_thousand]
        , [o].[new_tests_smoothed]
        , [o].[new_tests_smoothed_per_thousand]
        , [o].[new_vaccinations]
        , [o].[new_vaccinations_smoothed]
        , [o].[new_vaccinations_smoothed_per_million]
        , [o].[people_fully_vaccinated]
        , [o].[people_fully_vaccinated_per_hundred]
        , [o].[people_vaccinated]
        , [o].[people_vaccinated_per_hundred]
        , [o].[positive_rate]
        , [o].[tests_per_case]
        , [o].[tests_units]
        , [o].[total_cases]
        , [o].[total_cases_per_million]
        , [o].[total_deaths]
        , [o].[total_deaths_per_million]
        , [o].[total_tests]
        , [o].[total_tests_per_thousand]
        , [o].[total_vaccinations]
        , [o].[total_vaccinations_per_hundred]
        , [o].[weekly_hosp_admissions]
        , [o].[weekly_hosp_admissions_per_million]
        , [o].[weekly_icu_admissions]
        , [o].[weekly_icu_admissions_per_million]
      FROM 
         [Owid Covid Data] AS [o]
      WHERE [o].[date] >= @fromDate
            AND [o].[date] <= @toDate
            AND [o].[CountriesId] = @CountryId
      ORDER BY 
          date
      OFFSET @skip ROWS FETCH NEXT 100 ROWS ONLY;
    END;
  END;

Exactamente igual, pero con «FOR JSON PATH» al final, se usa en el proyecto que utiliza código puro.

El Cambio en Entity Framework

Basado en la propuesta y comentario, el código queda como sigue:

public IEnumerable<OwidCovidDatum> GetCountryData(
   int CountryId,
   DateTime? fromDate=null, 
   DateTime? toDate=null,
   int Page=1)
{
   fromDate=fromDate??
      (from el in _context.OwidCovidData orderby el.Date select el.Date).FirstOrDefault();
   toDate=toDate??
      (from el in _context.OwidCovidData orderby el.Date descending select el.Date).FirstOrDefault();
 
   return (from OwidCovidDatum el in 
              _context.OwidCovidData
              .Where(x=> x.Date>=fromDate && x.Date<=toDate && x.CountriesId==CountryId)
              .Skip((Page-1)*100)
              .Take(100select el).AsNoTracking().ToList();
}

Dapper Usando Procedimientos Almacenados

Utilizamos la capacidad de ejecución de procedimientos almacenados de Dapper, que es capaz de asignar valores a los parámetros por coincidencia de nombres.

public async Task<string> GetCountryData(
   int CountryId, 
   DateTime? fromDate = null, 
   DateTime? toDate = null, 
   int Page = 1)
{
 
   var result =await _dal.GetDataAsync("[Owid Covid Data Get By Country]",new { fromDate, toDate, CountryId, Page });
   string json = JsonSerializer.Serialize(result, new JsonSerializerOptions()
   {
      WriteIndented = true,
      ReferenceHandler = ReferenceHandler.Preserve
   });
   return json;
}

Código usando Procedimientos Almacenados

En el caso del código directo, asignamos los parámetros uno a uno, especificando además el tipo de dato, que permite una mayor especificidad.

public async Task<string> GetCountryData(
   int CountryId, 
   DateTime? fromDate = null, 
   DateTime? toDate = null, 
   int Page = 1)
{
   var command = _dal.CreateCommand("[Owid Covid Data Get By Country JSON]");
   command.Parameters.Add("@fromDate", System.Data.SqlDbType.SmallDateTime).Value = fromDate;
   command.Parameters.Add("@toDate", System.Data.SqlDbType.SmallDateTime).Value = toDate;
   command.Parameters.Add("@CountryId", System.Data.SqlDbType.Int).Value = CountryId;
   command.Parameters.Add("@skip", System.Data.SqlDbType.Int).Value = Page;
   var json =await _dal.GetJSONDataAsync(command);
   return json;
}

Rendimiento

El gráfico muestra que, aún cuando se utilizan características que mejoran la efectividad, la simpleza del código mejora el rendimiento.

O sea, para mejor respuesta al usuario, se deberá invertir más tiempo de los desarrolladores en mejorar su desarrollo.

Como detalle, las llamadas de procedimiento almacenado, realizan directamente llamadas al mismo, en lugar de utilizar, como vimos en la publicación anterior, sp_executesql.

EXEC [Owid Covid Data Get By Country] 
     @fromDate = NULL
   , @toDate = NULL
   , @CountryId = 4
   , @Page = 3;

GENERATING A REST API (JSON) [2]


Let’s consider another requirement, to evaluate how you can best take advantage of the features of the Entity Framework and emulate that functionality in cases where it cannot be used, or it is more convenient to do something else.

In this example, we are using the same database explained in Data-for-demos

The requirement

You need to get the statistical information of cases, vaccinations, etc. By country, between certain dates, with the following conditions:

  • If no start date is entered, the first available date is used.
  • If the end date is not entered, the last available date is used.
  • The information must be returned in batches of 100 entries, so the requested page number must be received.

In this case, it will be implemented in the “Country» controller

Entity Framework.

The code leverages EF’s Fluent capabilities to nest conditions. Similarly, below, the Entity Framework generates a statement according to the data engine in use, in this case, SQL Server.

public async Task<ActionResult<IEnumerable<OwidCovidDatum>>> GetCountryData(
   int CountryId,
   DateTime? fromDate=null, 
   DateTime? toDate=null,
   int Page=1)
{
   fromDate=fromDate??
      (from el in _context.OwidCovidData orderby el.Date select el.Date).FirstOrDefault();
   toDate=toDate??
      (from el in _context.OwidCovidData orderby el.Date descending select el.Date).FirstOrDefault();
 
   return (from OwidCovidDatum el in 
              _context.OwidCovidData
              .Where(x=> x.Date>=fromDate && x.Date<=toDate && x.CountriesId==CountryId)
              .Skip((Page-1)*100)
              .Take(100select el).ToList();
}

Dapper

Using the returned DapperRows, we implement the call with an SQL statement that is almost the same as the one automatically generated by EF.

[HttpGet]
public async Task<stringGetCountryData(
   int CountryId, 
   DateTime? fromDate = null, 
   DateTime? toDate = null, 
   int Page = 1)
{
   int skip = ((Page - 1* 100);
   fromDate = fromDate ??
      await _dal.GetValueAsync<DateTime>("SELECT MIN([o].[date]) FROM [Owid Covid Data] AS [o];");
   toDate = toDate ??
      await _dal.GetValueAsync<DateTime>("SELECT MAX([o].[date]) FROM [Owid Covid Data] AS [o];");
   string sql = $@"SELECT 
       [o].[ContinentId]
     , [o].[CountriesId]
     , [o].[date]
     , [o].[hosp_patients]
     , [o].[hosp_patients_per_million]
     , [o].[icu_patients]
     , [o].[icu_patients_per_million]
     , [o].[new_cases]
     , [o].[new_cases_per_million]
     , [o].[new_cases_smoothed]
     , [o].[new_cases_smoothed_per_million]
     , [o].[new_deaths]
     , [o].[new_deaths_per_million]
     , [o].[new_deaths_smoothed]
     , [o].[new_deaths_smoothed_per_million]
     , [o].[new_tests]
     , [o].[new_tests_per_thousand]
     , [o].[new_tests_smoothed]
     , [o].[new_tests_smoothed_per_thousand]
     , [o].[new_vaccinations]
     , [o].[new_vaccinations_smoothed]
     , [o].[new_vaccinations_smoothed_per_million]
     , [o].[people_fully_vaccinated]
     , [o].[people_fully_vaccinated_per_hundred]
     , [o].[people_vaccinated]
     , [o].[people_vaccinated_per_hundred]
     , [o].[positive_rate]
     , [o].[tests_per_case]
     , [o].[tests_units]
     , [o].[total_cases]
     , [o].[total_cases_per_million]
     , [o].[total_deaths]
     , [o].[total_deaths_per_million]
     , [o].[total_tests]
     , [o].[total_tests_per_thousand]
     , [o].[total_vaccinations]
     , [o].[total_vaccinations_per_hundred]
     , [o].[weekly_hosp_admissions]
     , [o].[weekly_hosp_admissions_per_million]
     , [o].[weekly_icu_admissions]
     , [o].[weekly_icu_admissions_per_million]
   FROM
      [Owid Covid Data] AS[o]
   WHERE(([o].[date] >= '{fromDate.Value.ToString("u").Substring(0,10)}')
         AND([o].[date] <= '{ toDate.Value.ToString("u").Substring(010)}'))
        AND([o].[CountriesId] = {CountryId})
   ORDER BY
       date
   OFFSET {skip} ROWS FETCH NEXT 100 ROWS ONLY; ";
   var result =await _dal.GetDataAsync(sql);
   string json = JsonSerializer.Serialize(result, new JsonSerializerOptions()
   {
      WriteIndented = true,
      ReferenceHandler = ReferenceHandler.Preserve
   });
   return json;
}

Code

As in the previous example, we create a parameterized Command object that returns a string of characters with the resulting JSON, implemented in the SQL statement.

         [HttpGet]
   public async Task<stringGetCountryData(
      int CountryId, 
      DateTime? fromDate = null, 
      DateTime? toDate = null, 
      int Page = 1)
   {
      int skip = ((Page - 1* 100);
      fromDate = fromDate ??
      await _dal.GetValueAsync<DateTime>("SELECT MIN([o].[date]) FROM [Owid Covid Data] AS [o];");
      toDate = toDate ??
      await _dal.GetValueAsync<DateTime>("SELECT MAX([o].[date]) FROM [Owid Covid Data] AS [o];");
      string sql = $@"SELECT 
       [o].[ContinentId]
     , [o].[CountriesId]
     , [o].[date]
     , [o].[hosp_patients]
     , [o].[hosp_patients_per_million]
     , [o].[icu_patients]
     , [o].[icu_patients_per_million]
     , [o].[new_cases]
     , [o].[new_cases_per_million]
     , [o].[new_cases_smoothed]
     , [o].[new_cases_smoothed_per_million]
     , [o].[new_deaths]
     , [o].[new_deaths_per_million]
     , [o].[new_deaths_smoothed]
     , [o].[new_deaths_smoothed_per_million]
     , [o].[new_tests]
     , [o].[new_tests_per_thousand]
     , [o].[new_tests_smoothed]
     , [o].[new_tests_smoothed_per_thousand]
     , [o].[new_vaccinations]
     , [o].[new_vaccinations_smoothed]
     , [o].[new_vaccinations_smoothed_per_million]
     , [o].[people_fully_vaccinated]
     , [o].[people_fully_vaccinated_per_hundred]
     , [o].[people_vaccinated]
     , [o].[people_vaccinated_per_hundred]
     , [o].[positive_rate]
     , [o].[tests_per_case]
     , [o].[tests_units]
     , [o].[total_cases]
     , [o].[total_cases_per_million]
     , [o].[total_deaths]
     , [o].[total_deaths_per_million]
     , [o].[total_tests]
     , [o].[total_tests_per_thousand]
     , [o].[total_vaccinations]
     , [o].[total_vaccinations_per_hundred]
     , [o].[weekly_hosp_admissions]
     , [o].[weekly_hosp_admissions_per_million]
     , [o].[weekly_icu_admissions]
     , [o].[weekly_icu_admissions_per_million]
   FROM
      [Owid Covid Data] AS[o]
   WHERE(([o].[date] >= @fromDate)
         AND([o].[date] <= @toDate))
        AND([o].[CountriesId] = @CountryId)
   ORDER BY
       date
   OFFSET @skip ROWS FETCH NEXT 100 ROWS ONLY FOR JSON PATH; ";
      SqlCommand com = _dal.CreateCommand(sql);
      com.Parameters.AddWithValue("@CountryId", CountryId);
      com.Parameters.AddWithValue("@fromDate", fromDate);
      com.Parameters.AddWithValue("@toDate", toDate);
      com.Parameters.AddWithValue("@skip", skip);
      return await _dal.GetJSONDataAsync(com);
   }
}

SQL sentences

For ease of comparison, here are, together, the three SQL statements used.

Entity Framework

EXEC [sp_executesql] 
     N'SELECT 
    [o].[ContinentId]
  , [o].[CountriesId]
  , [o].[date]
  , [o].[hosp_patients]
  , [o].[hosp_patients_per_million]
  , [o].[icu_patients]
  , [o].[icu_patients_per_million]
  , [o].[new_cases]
  , [o].[new_cases_per_million]
  , [o].[new_cases_smoothed]
  , [o].[new_cases_smoothed_per_million]
  , [o].[new_deaths]
  , [o].[new_deaths_per_million]
  , [o].[new_deaths_smoothed]
  , [o].[new_deaths_smoothed_per_million]
  , [o].[new_tests]
  , [o].[new_tests_per_thousand]
  , [o].[new_tests_smoothed]
  , [o].[new_tests_smoothed_per_thousand]
  , [o].[new_vaccinations]
  , [o].[new_vaccinations_smoothed]
  , [o].[new_vaccinations_smoothed_per_million]
  , [o].[people_fully_vaccinated]
  , [o].[people_fully_vaccinated_per_hundred]
  , [o].[people_vaccinated]
  , [o].[people_vaccinated_per_hundred]
  , [o].[positive_rate]
  , [o].[tests_per_case]
  , [o].[tests_units]
  , [o].[total_cases]
  , [o].[total_cases_per_million]
  , [o].[total_deaths]
  , [o].[total_deaths_per_million]
  , [o].[total_tests]
  , [o].[total_tests_per_thousand]
  , [o].[total_vaccinations]
  , [o].[total_vaccinations_per_hundred]
  , [o].[weekly_hosp_admissions]
  , [o].[weekly_hosp_admissions_per_million]
  , [o].[weekly_icu_admissions]
  , [o].[weekly_icu_admissions_per_million]
FROM 
   [Owid Covid Data] AS [o]
WHERE(([o].[date] >= @__fromDate_0)
      AND ([o].[date] <= @__toDate_1))
     AND ([o].[CountriesId] = @__CountryId_2)
ORDER BY
(
    SELECT 
        1
)
OFFSET @__p_3 ROWS FETCH NEXT @__p_4 ROWS ONLY;'
   , N'@__fromDate_0 datetime,@__toDate_1 datetime,@__CountryId_2 int,@__p_3 int,@__p_4 int'
   , @__fromDate_0 = '2020-01-01 00:00:00'
   , @__toDate_1 = '2021-06-11 00:00:00'
   , @__CountryId_2 = 4
   , @__p_3 = 300
   , @__p_4 = 100;


Dapper

SELECT 
    [o].[ContinentId]
  , [o].[CountriesId]
  , [o].[date]
  , [o].[hosp_patients]
  , [o].[hosp_patients_per_million]
  , [o].[icu_patients]
  , [o].[icu_patients_per_million]
  , [o].[new_cases]
  , [o].[new_cases_per_million]
  , [o].[new_cases_smoothed]
  , [o].[new_cases_smoothed_per_million]
  , [o].[new_deaths]
  , [o].[new_deaths_per_million]
  , [o].[new_deaths_smoothed]
  , [o].[new_deaths_smoothed_per_million]
  , [o].[new_tests]
  , [o].[new_tests_per_thousand]
  , [o].[new_tests_smoothed]
  , [o].[new_tests_smoothed_per_thousand]
  , [o].[new_vaccinations]
  , [o].[new_vaccinations_smoothed]
  , [o].[new_vaccinations_smoothed_per_million]
  , [o].[people_fully_vaccinated]
  , [o].[people_fully_vaccinated_per_hundred]
  , [o].[people_vaccinated]
  , [o].[people_vaccinated_per_hundred]
  , [o].[positive_rate]
  , [o].[tests_per_case]
  , [o].[tests_units]
  , [o].[total_cases]
  , [o].[total_cases_per_million]
  , [o].[total_deaths]
  , [o].[total_deaths_per_million]
  , [o].[total_tests]
  , [o].[total_tests_per_thousand]
  , [o].[total_vaccinations]
  , [o].[total_vaccinations_per_hundred]
  , [o].[weekly_hosp_admissions]
  , [o].[weekly_hosp_admissions_per_million]
  , [o].[weekly_icu_admissions]
  , [o].[weekly_icu_admissions_per_million]
FROM 
   [Owid Covid Data] AS [o]
WHERE(([o].[date] >= '01/01/2020 00:00:00')
      AND ([o].[date] <= '06/11/2021 00:00:00'))
     AND ([o].[CountriesId] = 4)
ORDER BY 
    date
OFFSET 300 ROWS FETCH NEXT 100 ROWS ONLY;

Code

EXEC [sp_executesql] 
     N'SELECT 
             [o].[ContinentId]
           , [o].[CountriesId]
           , [o].[date]
           , [o].[hosp_patients]
           , [o].[hosp_patients_per_million]
           , [o].[icu_patients]
           , [o].[icu_patients_per_million]
           , [o].[new_cases]
           , [o].[new_cases_per_million]
           , [o].[new_cases_smoothed]
           , [o].[new_cases_smoothed_per_million]
           , [o].[new_deaths]
           , [o].[new_deaths_per_million]
           , [o].[new_deaths_smoothed]
           , [o].[new_deaths_smoothed_per_million]
           , [o].[new_tests]
           , [o].[new_tests_per_thousand]
           , [o].[new_tests_smoothed]
           , [o].[new_tests_smoothed_per_thousand]
           , [o].[new_vaccinations]
           , [o].[new_vaccinations_smoothed]
           , [o].[new_vaccinations_smoothed_per_million]
           , [o].[people_fully_vaccinated]
           , [o].[people_fully_vaccinated_per_hundred]
           , [o].[people_vaccinated]
           , [o].[people_vaccinated_per_hundred]
           , [o].[positive_rate]
           , [o].[tests_per_case]
           , [o].[tests_units]
           , [o].[total_cases]
           , [o].[total_cases_per_million]
           , [o].[total_deaths]
           , [o].[total_deaths_per_million]
           , [o].[total_tests]
           , [o].[total_tests_per_thousand]
           , [o].[total_vaccinations]
           , [o].[total_vaccinations_per_hundred]
           , [o].[weekly_hosp_admissions]
           , [o].[weekly_hosp_admissions_per_million]
           , [o].[weekly_icu_admissions]
           , [o].[weekly_icu_admissions_per_million]
         FROM
            [Owid Covid Data] AS[o]
         WHERE(([o].[date] >= @fromDate)
               AND([o].[date] <= @toDate))
              AND([o].[CountriesId] = @CountryId)
         ORDER BY
             date
         OFFSET @skip ROWS FETCH NEXT 100 ROWS ONLY FOR JSON PATH; '
   , N'@CountryId int,@fromDate datetime,@toDate datetime,@skip int'
   , @CountryId = 4
   , @fromDate = '2020-01-01 00:00:00'
   , @toDate = '2021-06-11 00:00:00'
   , @skip = 200;

It’s striking that in the case of code, it uses sp_executesql,just like EF, which takes a bit longer to execute.

It seems that this will need to be improved in the next publication.

Performance

The same procedure was used as in the previous publication to evaluate the results.

As you can see, things have improved quite a bit for EF, although in this case, Dapper seems to be the one who works best.

The difference is precisely the sp_executesql.

GENERANDO UNA API REST (JSON) [2]


Consideremos otro requisito, para evaluar como puede aprovecharse mejor las características de Entity Framework y emular esa funcionalidad en los casos en que no se pueda utilizar, o sea más conveniente otra forma de realizar la tarea.

En este ejemplo, estamos utilizando la misma base de datos explicada en Datos-para-demos

El requisito

Se necesita obtener la información estadística de casos, vacunaciones, etc. Por país, entre determinadas fechas, con las siguientes condiciones:

  • Si no se consigna fecha de inicio, se usa la primera disponible.
  • Si no se consigna la fecha de fin, se usa la última disponible.
  • Se debe retornar la información en lotes de a 100 entradas, con lo cual, se deberá recibir el número de página solicitado.

En este caso, se implementará en el controlador «Country«

Entity Framework.

El código aprovecha las funcionalidades Fluent de EF para anidar las condiciones. Igualmente, por debajo, Entity Framework genera una sentencia acorde al motor de datos en uso, en este caso, SQL Server.

public async Task<ActionResult<IEnumerable<OwidCovidDatum>>> GetCountryData(
   int CountryId,
   DateTime? fromDate=null, 
   DateTime? toDate=null,
   int Page=1)
{
   fromDate=fromDate??
      (from el in _context.OwidCovidData orderby el.Date select el.Date).FirstOrDefault();
   toDate=toDate??
      (from el in _context.OwidCovidData orderby el.Date descending select el.Date).FirstOrDefault();
 
   return (from OwidCovidDatum el in 
              _context.OwidCovidData
              .Where(x=> x.Date>=fromDate && x.Date<=toDate && x.CountriesId==CountryId)
              .Skip((Page-1)*100)
              .Take(100select el).ToList();
}

Dapper

Utilizando los DapperRow de retorno, implementamos la llamada con una sentencia SQL que es casi igual a la generada automáticamente por EF.

[HttpGet]
public async Task<stringGetCountryData(
   int CountryId, 
   DateTime? fromDate = null, 
   DateTime? toDate = null, 
   int Page = 1)
{
   int skip = ((Page - 1* 100);
   fromDate = fromDate ??
      await _dal.GetValueAsync<DateTime>("SELECT MIN([o].[date]) FROM [Owid Covid Data] AS [o];");
   toDate = toDate ??
      await _dal.GetValueAsync<DateTime>("SELECT MAX([o].[date]) FROM [Owid Covid Data] AS [o];");
   string sql = $@"SELECT 
       [o].[ContinentId]
     , [o].[CountriesId]
     , [o].[date]
     , [o].[hosp_patients]
     , [o].[hosp_patients_per_million]
     , [o].[icu_patients]
     , [o].[icu_patients_per_million]
     , [o].[new_cases]
     , [o].[new_cases_per_million]
     , [o].[new_cases_smoothed]
     , [o].[new_cases_smoothed_per_million]
     , [o].[new_deaths]
     , [o].[new_deaths_per_million]
     , [o].[new_deaths_smoothed]
     , [o].[new_deaths_smoothed_per_million]
     , [o].[new_tests]
     , [o].[new_tests_per_thousand]
     , [o].[new_tests_smoothed]
     , [o].[new_tests_smoothed_per_thousand]
     , [o].[new_vaccinations]
     , [o].[new_vaccinations_smoothed]
     , [o].[new_vaccinations_smoothed_per_million]
     , [o].[people_fully_vaccinated]
     , [o].[people_fully_vaccinated_per_hundred]
     , [o].[people_vaccinated]
     , [o].[people_vaccinated_per_hundred]
     , [o].[positive_rate]
     , [o].[tests_per_case]
     , [o].[tests_units]
     , [o].[total_cases]
     , [o].[total_cases_per_million]
     , [o].[total_deaths]
     , [o].[total_deaths_per_million]
     , [o].[total_tests]
     , [o].[total_tests_per_thousand]
     , [o].[total_vaccinations]
     , [o].[total_vaccinations_per_hundred]
     , [o].[weekly_hosp_admissions]
     , [o].[weekly_hosp_admissions_per_million]
     , [o].[weekly_icu_admissions]
     , [o].[weekly_icu_admissions_per_million]
   FROM
      [Owid Covid Data] AS[o]
   WHERE(([o].[date] >= '{fromDate.Value.ToString("u").Substring(0,10)}')
         AND([o].[date] <= '{ toDate.Value.ToString("u").Substring(010)}'))
        AND([o].[CountriesId] = {CountryId})
   ORDER BY
       date
   OFFSET {skip} ROWS FETCH NEXT 100 ROWS ONLY; ";
   var result =await _dal.GetDataAsync(sql);
   string json = JsonSerializer.Serialize(result, new JsonSerializerOptions()
   {
      WriteIndented = true,
      ReferenceHandler = ReferenceHandler.Preserve
   });
   return json;
}

Código

Al igual que en el ejemplo previo, creamos un objeto Command con parámetros que retorne una cadena de caracteres con el JSON resultante, implementado en la sentencia SQL.

         [HttpGet]
   public async Task<stringGetCountryData(
      int CountryId, 
      DateTime? fromDate = null, 
      DateTime? toDate = null, 
      int Page = 1)
   {
      int skip = ((Page - 1* 100);
      fromDate = fromDate ??
      await _dal.GetValueAsync<DateTime>("SELECT MIN([o].[date]) FROM [Owid Covid Data] AS [o];");
      toDate = toDate ??
      await _dal.GetValueAsync<DateTime>("SELECT MAX([o].[date]) FROM [Owid Covid Data] AS [o];");
      string sql = $@"SELECT 
       [o].[ContinentId]
     , [o].[CountriesId]
     , [o].[date]
     , [o].[hosp_patients]
     , [o].[hosp_patients_per_million]
     , [o].[icu_patients]
     , [o].[icu_patients_per_million]
     , [o].[new_cases]
     , [o].[new_cases_per_million]
     , [o].[new_cases_smoothed]
     , [o].[new_cases_smoothed_per_million]
     , [o].[new_deaths]
     , [o].[new_deaths_per_million]
     , [o].[new_deaths_smoothed]
     , [o].[new_deaths_smoothed_per_million]
     , [o].[new_tests]
     , [o].[new_tests_per_thousand]
     , [o].[new_tests_smoothed]
     , [o].[new_tests_smoothed_per_thousand]
     , [o].[new_vaccinations]
     , [o].[new_vaccinations_smoothed]
     , [o].[new_vaccinations_smoothed_per_million]
     , [o].[people_fully_vaccinated]
     , [o].[people_fully_vaccinated_per_hundred]
     , [o].[people_vaccinated]
     , [o].[people_vaccinated_per_hundred]
     , [o].[positive_rate]
     , [o].[tests_per_case]
     , [o].[tests_units]
     , [o].[total_cases]
     , [o].[total_cases_per_million]
     , [o].[total_deaths]
     , [o].[total_deaths_per_million]
     , [o].[total_tests]
     , [o].[total_tests_per_thousand]
     , [o].[total_vaccinations]
     , [o].[total_vaccinations_per_hundred]
     , [o].[weekly_hosp_admissions]
     , [o].[weekly_hosp_admissions_per_million]
     , [o].[weekly_icu_admissions]
     , [o].[weekly_icu_admissions_per_million]
   FROM
      [Owid Covid Data] AS[o]
   WHERE(([o].[date] >= @fromDate)
         AND([o].[date] <= @toDate))
        AND([o].[CountriesId] = @CountryId)
   ORDER BY
       date
   OFFSET @skip ROWS FETCH NEXT 100 ROWS ONLY FOR JSON PATH; ";
      SqlCommand com = _dal.CreateCommand(sql);
      com.Parameters.AddWithValue("@CountryId", CountryId);
      com.Parameters.AddWithValue("@fromDate", fromDate);
      com.Parameters.AddWithValue("@toDate", toDate);
      com.Parameters.AddWithValue("@skip", skip);
      return await _dal.GetJSONDataAsync(com);
   }
}

Las sentencias SQL

Para facilitar la comparativa, aquí están, juntas, las tres sentencias SQL utilizadas.

Entity Framework

EXEC [sp_executesql] 
     N'SELECT 
    [o].[ContinentId]
  , [o].[CountriesId]
  , [o].[date]
  , [o].[hosp_patients]
  , [o].[hosp_patients_per_million]
  , [o].[icu_patients]
  , [o].[icu_patients_per_million]
  , [o].[new_cases]
  , [o].[new_cases_per_million]
  , [o].[new_cases_smoothed]
  , [o].[new_cases_smoothed_per_million]
  , [o].[new_deaths]
  , [o].[new_deaths_per_million]
  , [o].[new_deaths_smoothed]
  , [o].[new_deaths_smoothed_per_million]
  , [o].[new_tests]
  , [o].[new_tests_per_thousand]
  , [o].[new_tests_smoothed]
  , [o].[new_tests_smoothed_per_thousand]
  , [o].[new_vaccinations]
  , [o].[new_vaccinations_smoothed]
  , [o].[new_vaccinations_smoothed_per_million]
  , [o].[people_fully_vaccinated]
  , [o].[people_fully_vaccinated_per_hundred]
  , [o].[people_vaccinated]
  , [o].[people_vaccinated_per_hundred]
  , [o].[positive_rate]
  , [o].[tests_per_case]
  , [o].[tests_units]
  , [o].[total_cases]
  , [o].[total_cases_per_million]
  , [o].[total_deaths]
  , [o].[total_deaths_per_million]
  , [o].[total_tests]
  , [o].[total_tests_per_thousand]
  , [o].[total_vaccinations]
  , [o].[total_vaccinations_per_hundred]
  , [o].[weekly_hosp_admissions]
  , [o].[weekly_hosp_admissions_per_million]
  , [o].[weekly_icu_admissions]
  , [o].[weekly_icu_admissions_per_million]
FROM 
   [Owid Covid Data] AS [o]
WHERE(([o].[date] >= @__fromDate_0)
      AND ([o].[date] <= @__toDate_1))
     AND ([o].[CountriesId] = @__CountryId_2)
ORDER BY
(
    SELECT 
        1
)
OFFSET @__p_3 ROWS FETCH NEXT @__p_4 ROWS ONLY;'
   , N'@__fromDate_0 datetime,@__toDate_1 datetime,@__CountryId_2 int,@__p_3 int,@__p_4 int'
   , @__fromDate_0 = '2020-01-01 00:00:00'
   , @__toDate_1 = '2021-06-11 00:00:00'
   , @__CountryId_2 = 4
   , @__p_3 = 300
   , @__p_4 = 100;


Dapper

SELECT 
    [o].[ContinentId]
  , [o].[CountriesId]
  , [o].[date]
  , [o].[hosp_patients]
  , [o].[hosp_patients_per_million]
  , [o].[icu_patients]
  , [o].[icu_patients_per_million]
  , [o].[new_cases]
  , [o].[new_cases_per_million]
  , [o].[new_cases_smoothed]
  , [o].[new_cases_smoothed_per_million]
  , [o].[new_deaths]
  , [o].[new_deaths_per_million]
  , [o].[new_deaths_smoothed]
  , [o].[new_deaths_smoothed_per_million]
  , [o].[new_tests]
  , [o].[new_tests_per_thousand]
  , [o].[new_tests_smoothed]
  , [o].[new_tests_smoothed_per_thousand]
  , [o].[new_vaccinations]
  , [o].[new_vaccinations_smoothed]
  , [o].[new_vaccinations_smoothed_per_million]
  , [o].[people_fully_vaccinated]
  , [o].[people_fully_vaccinated_per_hundred]
  , [o].[people_vaccinated]
  , [o].[people_vaccinated_per_hundred]
  , [o].[positive_rate]
  , [o].[tests_per_case]
  , [o].[tests_units]
  , [o].[total_cases]
  , [o].[total_cases_per_million]
  , [o].[total_deaths]
  , [o].[total_deaths_per_million]
  , [o].[total_tests]
  , [o].[total_tests_per_thousand]
  , [o].[total_vaccinations]
  , [o].[total_vaccinations_per_hundred]
  , [o].[weekly_hosp_admissions]
  , [o].[weekly_hosp_admissions_per_million]
  , [o].[weekly_icu_admissions]
  , [o].[weekly_icu_admissions_per_million]
FROM 
   [Owid Covid Data] AS [o]
WHERE(([o].[date] >= '01/01/2020 00:00:00')
      AND ([o].[date] <= '06/11/2021 00:00:00'))
     AND ([o].[CountriesId] = 4)
ORDER BY 
    date
OFFSET 300 ROWS FETCH NEXT 100 ROWS ONLY;

Código

EXEC [sp_executesql] 
     N'SELECT 
             [o].[ContinentId]
           , [o].[CountriesId]
           , [o].[date]
           , [o].[hosp_patients]
           , [o].[hosp_patients_per_million]
           , [o].[icu_patients]
           , [o].[icu_patients_per_million]
           , [o].[new_cases]
           , [o].[new_cases_per_million]
           , [o].[new_cases_smoothed]
           , [o].[new_cases_smoothed_per_million]
           , [o].[new_deaths]
           , [o].[new_deaths_per_million]
           , [o].[new_deaths_smoothed]
           , [o].[new_deaths_smoothed_per_million]
           , [o].[new_tests]
           , [o].[new_tests_per_thousand]
           , [o].[new_tests_smoothed]
           , [o].[new_tests_smoothed_per_thousand]
           , [o].[new_vaccinations]
           , [o].[new_vaccinations_smoothed]
           , [o].[new_vaccinations_smoothed_per_million]
           , [o].[people_fully_vaccinated]
           , [o].[people_fully_vaccinated_per_hundred]
           , [o].[people_vaccinated]
           , [o].[people_vaccinated_per_hundred]
           , [o].[positive_rate]
           , [o].[tests_per_case]
           , [o].[tests_units]
           , [o].[total_cases]
           , [o].[total_cases_per_million]
           , [o].[total_deaths]
           , [o].[total_deaths_per_million]
           , [o].[total_tests]
           , [o].[total_tests_per_thousand]
           , [o].[total_vaccinations]
           , [o].[total_vaccinations_per_hundred]
           , [o].[weekly_hosp_admissions]
           , [o].[weekly_hosp_admissions_per_million]
           , [o].[weekly_icu_admissions]
           , [o].[weekly_icu_admissions_per_million]
         FROM
            [Owid Covid Data] AS[o]
         WHERE(([o].[date] >= @fromDate)
               AND([o].[date] <= @toDate))
              AND([o].[CountriesId] = @CountryId)
         ORDER BY
             date
         OFFSET @skip ROWS FETCH NEXT 100 ROWS ONLY FOR JSON PATH; '
   , N'@CountryId int,@fromDate datetime,@toDate datetime,@skip int'
   , @CountryId = 4
   , @fromDate = '2020-01-01 00:00:00'
   , @toDate = '2021-06-11 00:00:00'
   , @skip = 200;

Es llamativo que en el caso de código, utiliza sp_executesql, al igual que EF, lo cual demora un poco la ejecución.

Parece que esto habrá que mejorarlo en la siguiente publicación.

Rendimiento

Se utilizó el mismo procedimiento que en la publicación anterior, para evaluar los resultados.

Como se ve, la cosa ha mejorado bastante para EF, aunque en este caso, Dapper parece ser quien da mejor resultado.

La diferencia está, precisamente, el sp_executesql.