Dziś będzie trochę o bazach danych. To nie tylko tabelki i operację CRUD, potrafią zrobić zdecydowanie więcej. Chciałbym dzisiaj przybliżyć możliwości biblioteki Entity Framework Core związaną z obsługą widoków.

Czym jest widok?

Zacznijmy od początku, czyli zdefiniowania czym jest widok. Zaglądając do wikipedi otrzymamy bardzo ładną definicję:

Widok (perspektywa) to logiczny byt (obiekt), osadzony na serwerze baz danych. Umożliwia dostęp do podzbioru kolumn i wierszy tabel lub tabeli na podstawie zapytania w języku SQL, które stanowi część definicji tego obiektu.

Oznacza to, że widok jest jednym z obiektów dostępnych do utworzenia na bazie danych. Powstaje na podstawie zapytania SQL. Co ciekawe, taki obiekt może być logiczny, ale także fizyczny (widoki zmaterializowane / indeksowane). W dzisiejszym artykule zajmiemy się tymi fizycznymi, jako, że są one nieco bardziej skomplikowane.

To tyle w ramach krótkiego wstępu. Jeżeli chcesz dowiedzieć się czegoś więcej o widokach zajrzyj w linki pod artykułem. Przejdźmy do implementacji:

Przygotowanie aplikacji

Zacznijmy od przygotowania aplikacji tak, abyś miał pełną świadomość klas na jakich opierać się będzie ten artykuł. Na pierwszy ogień pójdzie DBContext. Nie mam dzisiaj  weny, aby osadzić te przykłady w wymyślonej domenie, a nie mogę użyć kodu z prawdziwej aplikacji, więc nazwałem go MyDbContext. Znaczącymi zmianami (w porównaniu do takiego standardowego dbContextu) jest dodanie statycznego propertis-a DefaultSchema, który użyjemy w konfiguracji encji oraz dołączenie logger-a wypisującego zapytania sql-a na konsole. Ostatnim dodanym elementem jest  DBSet, który będzie odpowiadał za odpytywanie się widoku.

public class MyDbContext : DbContext
{
    public static string DefaultSchema { get; } = "mySchema";
    public DbSet<ReadModel> ReadModels { get; set; }
    
    public MyDbContext(DbContextOptions options) : base(options)
    {
    }
    
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    { 
        optionsBuilder.UseLoggerFactory(MyLoggerFactory);
    }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(MyDbContext).Assembly);
        base.OnModelCreating(modelBuilder);
    }

    private static readonly ILoggerFactory MyLoggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); });
}

Jako, że omówiliśmy już DBContext, możemy przejść do naszej klasy ReadModel. Tutaj także nie ma nic skomplikowanego: ot dwa propertisy, gdzie jeden jest naszym kluczem głównym, drugi to losowe dane.

public class ReadModel
{
    public int Id { get; set; }
    public string Name { get; set; }
}

Konfiguracja modelu

Jak pewnie mogłeś zauważyć na blogu, jestem wielkim fanem wyciągania konfiguracji encji do oddzielnego pliku za pomocą EntityTypeConfiguration . Ten przypadek nie będzie odmienny.

public class ReadModelEntityTypeConfiguration : IEntityTypeConfiguration<ReadModel>
{
    public void Configure(EntityTypeBuilder<ReadModel> builder)
    {
        builder.Property(x => x.Id);
        builder.ToView(ViewName, DbContext.DefaultSchema);
    }
    
    public static string ViewName { get; } = "v_myView";
}

Konfiguracja jest prosta i bardzo podobna do tej, którą opisywałem w moim poprzednim artykule (możesz o tym poczytać tutaj). Istnieje jednak poważna różnica. Zamiast funkcji ToTable, za pomocą której byś utworzył tabele w bazie danych, trzeba wykorzystać metodę ToView. Niestety, w takim przypadku EF Core nie będzie w stanie samemu utworzyć migracji bazy danych i będziesz musiał sam utworzyć widok. Bez stresu, w dalszej części artykułu pokażę Ci jak podchodzę do rozwiązania takiego problemu.

Ostatnim elementem konfiguracji, na który chciałbym abyś zwrócił uwagę, jest statyczny propertis ViewName. Jako, że będziesz wykorzystywał nazwę widoku wielokrotnie,  lepiej jest mieć to w jednym miejscu niż posiadać magic string-i latające po aplikacji.

Tylko tyle wystarczy, aby zintegrować EF Core z widokiem. Dzięki temu będziesz mógł przeszukiwać widoki z wykorzystaniem wszystkich możliwości EF Core.

Migracje

Jak już pisałem, EF Core nie jest w stanie wygenerować automatycznej migracji z utworzeniem widoku na bazie danych. Jednak zgodnie z dobrymi praktykami warto być mieć utworzenie jak i usuwanie widoku w mechanizmie. Na szczęście EF Core pozwala nam na dopisanie własnego kodu SQL za pomocą metody .Sql na obiekcie MigrationBuilder.

public partial class AddView : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql($"CREATE VIEW {MyDbContext.DefaultSchema}.{ReadModelEntityTypeConfiguration.ViewName} WITH SCHEMABINDING AS " +
                             $"SELECT Id, Name from {MyDbContex.DefaultSchema}.Table;");

        migrationBuilder.Sql($"CREATE UNIQUE CLUSTERED INDEX ucidx_readmodel_id ON {MyDbContext.DefaultSchema}.{ReadModelEntityTypeConfiguration.ViewName}(id);");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql($"DROP INDEX ucidx_readmodel_id ON {MyDbContext.DefaultSchema}.{ReadModelEntityTypeConfiguration.ViewName}(id);");
        migrationBuilder.Sql($"DROP VIEW {MyDbContext.DefaultSchema}.{ReadModelEntityTypeConfiguration.ViewName};");
    }
}

Aby utworzyć widok (w tym przypadku indeksowany) musimy wykonać odpowiedni kod SQL z wartościami pobranymi z pól statycznych. Wartości nie będą zahardkodowane w naszej migracji. Jeżeli chcesz utworzyć widok zindeksowany (zmaterializowany) to w bazach SqlServer musisz na tym widoku utworzyć clustered index.

Taka mała dygresja z mojej strony – widoki zmaterializowane mogą okazać się, w niektórych zastosowaniach, nieco wydajniejsze, ale nic za darmo.  Kosztem jest zmniejszona ilości operacji, które możemy na nich wykonać.  Jeżeli chcesz, abym napisał dedykowany artykuł o widokach zindeksowanych / zmaterializowanych. napisz proszę komentarz.

Wracając, teraz wystarczy uruchomić aktualizację schematu bazy danych i nowo powstały widok powinien się w niej pojawić.

Interceptor jako pomoc dla baz nie Enterprise

Wróćmy jeszcze na chwilę do widoków zmaterializowanych, ponieważ mają bardzo ciekawą przypadłość na bazach nie Enterprise. Mianowicie w momencie wykonania prostego select-a na bazie

SELECT TOP 10 id, name FROM mySchema.v_myView;

Zapytanie zachowa się jak zwykły widok, czyli zejdzie niżej i wywoła zapytanie kryjące się za widokiem. Aby uniknąć tej sytuacji należy dodać hint.

SELECT TOP 10 id, name FROM mySchema.v_myView WITH(NOEXPAND);

Nie ma w tym nic trudnego, gdy korzystamy ze zwykłego kodu SQL (np.  dapper-a). Gdy EF Core generuje zapytanie, jego modyfikacja jest nieco “uciążliwa”.

Jedną z możliwości jest po prostu pisania zapytania SQL w repozytorium:

public Task Get()
    => _context.ReadModel.FromSqlRaw($"SELECT TOP 10 FROM v_myView WITH(NOEXPAND)")
       .AsNoTracing()
       .ToListAsync();

Drugą jest zastosowanie Interceptora, mechanizmu dostępnego w EF Core do przechwytywania i modyfikowania zapytania lub jego wyników. Musimy założyć kilka rzeczy:

  • na widoku będziesz robili tylko podstawowe operacje i filtrowanie
  • zapytania generowane przez EF Core oznaczysz komentarzem -- View Query tak, aby interceptor wiedział jakie zapytanie przetworzyć a jakie zostawić w spokoju.

Cała implementacja polega na znalezieniu ciągu znaków AS [znak]za pomocą wyrażenia regularnego, a następnie wstawienie za nim WITH(NOEXPAND).

public class HintCommandInterceptor : DbCommandInterceptor
{
    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(DbCommand command, 
        CommandEventData eventData, 
        InterceptionResult result,
        CancellationToken cancellationToken = new CancellationToken())
    {
        if (command.CommandText.StartsWith("-- View Query"))
        {
            var pattern = @"(\sAS\s\[\S+\])";
            var results = Regex.Split(command.CommandText, pattern, RegexOptions.IgnoreCase,
                TimeSpan.FromMilliseconds(100));
            var builder = new StringBuilder();

            for (var i = 0; i < results.Length; i++)
            {
                builder.Append(results[i]);

                if (i == 1)
                    builder.Append(" WITH(NOEXPAND)");
            }

            var newCommand = builder.ToString();

            command.CommandText = newCommand;
        }
        
        return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
    }
}

Nie jest to ładne rozwiązanie, ale pozwala na pozbycie się kodu SQL z repozytorium. Jedyne, o czym musimy pamiętać, to dodanie wywołania metody TagWith do zapytania. Jeśli zapomnisz efektem będzie powrót do zapytania bez WITH(NOEXPAND).  Cała metoda wygląda następująco:

public Task Get()
    => _context.ReadModels
            .TagWith("View Query")
            .Take(10)
            .ToListAsync();

Taki interpector musisz jeszcze zarejestrować w klasie MyDbContext

//...
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ 
    optionsBuilder.UseLoggerFactory(MyLoggerFactory);
    optionsBuilder.AddInterceptors(new HintCommandInterceptor());
}
//...

Podsumowanie

Widoki to naprawdę bardzo fajny mechanizm, którego użycie może sporo uprościć a nawet i przyspieszyć działanie aplikacji.

Mam nadzieje, że artykuł się podobaj, jeśli tak zostaw łapkę w górę. Pamiętaj o kliknięciu przycisku subskrybuj… a nie czekaj, to nie tutaj i jeszcze nic nie powstało w takiej formie, ale może w przyszłości, kto wie.

Do Następnego!

PS. Spodobał Ci się ten artykuł?

Referencje