Nesse post, utilizaremos o Entity Framework Core em conjunto com o Azure Cosmos DB para criar um pequena aplicação em ASP.NET Core MVC . No meu setup, vou utilizar o Visual Studio For Mac para criar o aplicativo web e o Visual Studio Code para trabalhar com a infraestrutura como código.

Antes de começar, recomendo a leitura de dois posts que já escrevi sobre Azure Cosmos e Azure bicep:

Vamos inicialmente criar um projeto web MVC em .NET 7:

Com o projeto criado, podemos adicionar o pacote NuGet Microsoft.EntityFrameworkCore.Cosmos:

Vamos voltar no diretório raiz do projeto, criar um novo diretório chamado infra e um arquivo chamado main.bicep com o seguinte conteúdo:

@description('Cosmos DB account name')
param accountName string = 'cosmos-${uniqueString(resourceGroup().id)}'

@description('Location for the Cosmos DB account.')
param location string = resourceGroup().location

@description('The name for the SQL API database')
param databaseName string

@description('The name for the SQL API container')
param containerName string

resource account 'Microsoft.DocumentDB/databaseAccounts@2022-05-15' = {
  name: toLower(accountName)
  location: location
  properties: {
    enableFreeTier: true
    databaseAccountOfferType: 'Standard'
    consistencyPolicy: {
      defaultConsistencyLevel: 'Session'
    }
    locations: [
      {
        locationName: location
      }
    ]
  }
}

resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2022-05-15' = {
  parent: account
  name: databaseName
  properties: {
    resource: {
      id: databaseName
    }
    options: {
      throughput: 1000
    }
  }
}

resource container 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2022-05-15' = {
  parent: database
  name: containerName
  properties: {
    resource: {
      id: containerName
      partitionKey: {
        paths: [
          '/Id'
        ]
        kind: 'Hash'
      }
      indexingPolicy: {
        indexingMode: 'consistent'
        includedPaths: [
          {
            path: '/*'
          }
        ]
        excludedPaths: [
          {
            path: '/_etag/?'
          }
        ]
      }
    }
  }
}

Esse arquivo bicep basicamente irá criar um Azure Cosmos DB na camada gratuita. Como estou utilizando a extensão bicep, iremos fazer o deploy desse recurso de nuvem por meio do VS Code.

Antes do deploy, devemos criar um arquivo de parâmetros:

Vamos utilizar os seguintes valores para o nosso Azure Cosmos DB

Com o arquivo de parâmetros criado, podemos fazer o deploy.

Como não temos nenhum grupo de recursos criado, iremos criar um durante o deploy do arquivo bicep.

Vamos chamá-lo de: 

rg-app-eastus

Após a finalização, teremos a seguinte estrutura de arquivos e diretórios em nossa solução:

Voltando ao VS for Mac, iremos criar a entidade que será salva no Azure cosmos DB. Vamos chamá-la de Folder:

using System;
namespace App.Web.Models
{
	public class Folder
	{
		public Guid Id { get; set; }
        public Guid? ParentId { get; set; }
		public string Name { get; set; }

        public Folder(Guid? parentId, string name)
        {
            Id = Guid.NewGuid();
            ParentId = parentId;
            Name = name;
        }
    }
}

Durante o fluxo de exibição de dados na View (recomendo a leitura sobre o padrão de projeto MVC), iremos reordenar essa estrutura para uma árvore de dados.

Vamos criar a classe principal para o Entity Framework Core, o CosmosContext que herda de DbContext.

using App.Web.Models;
using Microsoft.EntityFrameworkCore;

namespace App.Web.Data
{
	public class CosmosContext : DbContext
	{
        public CosmosContext(DbContextOptions<CosmosContext> options)
       : base(options)
        {
        }

        public DbSet<Folder> Folders { get; set; }

        protected override void
            OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Folder>(x =>
            {
                x.ToContainer("Folders");
                x.HasKey(x => x.Id);
                x.HasPartitionKey(x => x.Id);
            });
        }
    }
}

Observe que definimos como a entidade Folder é modelada no banco de dados Azure Cosmos DB. Definimos sua Key, Partition key e o container. Eu já escrevi alguns posts sobre esse recursos de nuvem, vale a leitura!

Iremos criar um repositório (interface + classe), que será responsável por buscar entidades de Folders do banco de dados, além de criar novos registros.

using App.Web.Models;

namespace App.Web.Data.Repositories
{
	public interface IFolderRepository
	{
		Task<IEnumerable<Folder>> GetllAllAsync();

		Task AddAsync(Folder folder);
    }
}

using App.Web.Models;
using Microsoft.EntityFrameworkCore;

namespace App.Web.Data.Repositories
{
	public class FolderRepository : IFolderRepository
	{
        private readonly CosmosContext _cosmosContext;

        public FolderRepository(CosmosContext cosmosContext)
        {
            _cosmosContext = cosmosContext;
        }

        public async Task AddAsync(Folder folder)
        {
            await _cosmosContext.AddAsync(folder);
            await _cosmosContext.SaveChangesAsync();
        }

        public async Task<IEnumerable<Folder>> GetllAllAsync()
        {
            return await _cosmosContext.Folders.ToListAsync();
        }
    }
}

Esses itens ficarão em um diretório chamado Data.

Para trabalharmos com estruturas de árvore, devemos criar a classe TreeNode<T> e TreeNodeHelper

namespace App.Web.Utils
{
    public class TreeNode<T>
    {
        public T Id { get; set; } = default!;
        public T? ParentId { get; set; }
        public string Name { get; set; } = default!;
        public List<TreeNode<T>> Children { get; set; } = new List<TreeNode<T>>();
    }
}
namespace App.Web.Utils
{
    public static class TreeNodeHelper
    {

        public static IEnumerable<TreeNode<T>> FetchChildren<T>(this TreeNode<T> root, List<TreeNode<T>> nodes)
        {
            return nodes.Where(n =>
                n.ParentId is not null &&
                n.ParentId.Equals(root.Id));
        }

        public static void RemoveChildren<T>(this TreeNode<T> root, List<TreeNode<T>> nodes)
        {
            foreach (var node in root.Children)
            {
                nodes.Remove(node);
            }
        }

        public static TreeNode<T> BuildTree<T>(this TreeNode<T> root, List<TreeNode<T>> nodes)
        {
            if (nodes.Count == 0) { return root; }

            var children = root.FetchChildren(nodes).ToList();
            root.Children.AddRange(children);
            root.RemoveChildren(nodes);

            for (int i = 0; i < children.Count; i++)
            {
                children[i] = children[i].BuildTree(nodes);
                if (nodes.Count == 0) { break; }
            }

            return root;
        }
    }
}

Vale comentar que essa implementação de árvore de dados é uma das inúmeras implementações possíveis!

Para servir como modelo para a nossa View, devemos criar a HomeIndexViewModel

using System.ComponentModel.DataAnnotations;

namespace App.Web.ViewModels
{
	public class HomeIndexViewModel
	{
		public Guid? ParentFolderId { get; set; }

		[Required]
		[Display(Name = "Folder Name")]
		public string? FolderName { get; set; } = default!;
	}
}

Temos a nossa controladora:

using Microsoft.AspNetCore.Mvc;
using App.Web.Utils;
using App.Web.Data.Repositories;
using App.Web.ViewModels;
using App.Web.Models;

namespace App.Web.Controllers;

public class HomeController : Controller
{
    private readonly ILogger<HomeController> _logger;
    private readonly IFolderRepository _folderRepository;

    public HomeController(ILogger<HomeController> logger, IFolderRepository folderRepository)
    {
        _logger = logger;
        _folderRepository = folderRepository;
    }

    public async Task<IActionResult> IndexAsync()
    {
        ViewBag.Data = await GenerateFoldersAsync();

        var viewModel = new HomeIndexViewModel();

        return View(viewModel);   
    }

    [HttpPost]
    public async Task<IActionResult> IndexAsync(HomeIndexViewModel viewModel)
    {
        if (ModelState.IsValid)
        {
            var folder = new Folder(viewModel.ParentFolderId, viewModel.FolderName!);

            await _folderRepository.AddAsync(folder);

            return RedirectToAction(nameof(Index));
        }

        ViewBag.Data = await GenerateFoldersAsync();

        return View(viewModel);
    }

    private async Task<List<TreeNode<Guid>>> GenerateFoldersAsync()
    {
        var folders = await _folderRepository.GetllAllAsync();

        var data = new List<TreeNode<Guid>>();

        foreach (var rootFolder in folders.Where(x => x.ParentId is null))
        {
            var rootData = new TreeNode<Guid>
            {
                Id = rootFolder.Id,
                ParentId = default,
                Name = rootFolder.Name,
            };

            data.Add(rootData.BuildTree<Guid>(
                folders.Select(x =>
                new TreeNode<Guid>
                {
                    Id = x.Id,
                    ParentId = x.ParentId ?? default,
                    Name = x.Name

                })
                .ToList()));
        }

        return data;
    }
}

Ela basicamente possui duas actions:

  • Index (GET): Retorna dados de Folders em uma árvore de dados;
  • Index (POST): Recebe dados via HomeIndexViewModel, faz as validações do Model, adiciona um novo registro de Folder ao banco de dados e redireciona para a action Index (GET).

Dito isso, podemos criar a partial view _Folder.cshtml e a View principal Index.cshtml

@using App.Web.Utils;
@model (List<TreeNode<Guid>> data, int index)

@{
    var data = Model.data;
    var index = Model.index;
    var paddingLeft = $"{(20 * index)}px";
}


@foreach (var item in data
       .OrderByDescending(x => x.Children.Any())
       .ThenBy(x => x.Name)
       .Select(x => new { Node = x, ComponentId = Guid.NewGuid() }))
{
    <div class="row">
        <div class="col" style="padding-left:@paddingLeft">
            <input class="justify-content-start" form-check-input folder" type="checkbox">
            <span>@(item.Node.Name)</span>
            <input class="form-check-input folderId" value="@item.Node.Id" type="hidden">
        </div>
        
    </div>
    @if (item.Node.Children.Any())
    {
        <partial name="_folder" model="(item.Node.Children, index + 1)" />

    }
}
@using App.Web.ViewModels;
@using App.Web.Utils
@model HomeIndexViewModel

@{
    ViewData["Title"] = "Home Page";
    var data = ViewBag.Data as List<TreeNode<Guid>>;
}

<div class="container">
    <form asp-action="Index">
        <div asp-validation-summary="ModelOnly" class="text-danger"></div>
        <input type="hidden" asp-for="ParentFolderId"/>
        <div class="row mb-3">
            <div class="col-4">
                <label asp-for="FolderName" class="form-label"></label>
                <input asp-for="FolderName" class="form-control">
                <span asp-validation-for="FolderName" class="text-danger"></span>
            </div>
        </div>
        <div class="row mb-3">
            <div class="col-12">
                <partial name="_Folder" model="(data, 0)" />
            </div>
        </div>
        <div class="row">
            <div class="col-2">
                <button class="btn btn-primary" type="submit">
                    Add folder
                </button>
            </div>
        </div>
    </form>
</div>

No arquivo site.js, devemos adicionar o seguinte código jQuery:

var cleanSelection = function ()
{
    $(this).prop('checked', false);
}

var onClick = function () {

    var checked = $(this).is(":checked");
    
    // Clean all checkboxes
    $("input:checkbox.folder").each(cleanSelection)

    $(this).prop('checked', checked);

    if (checked) {
        var parent = $(this).parent();
        var children = parent.children();
        var parentId = children.filter(".folderId")[0].value;

        $("#ParentFolderId").val(parentId);
    }
    else {
        $("#ParentFolderId").val(null);
    }
};

$("input[type=checkbox]").on("click", onClick);

Temos a seguinte estrutura de arquivos:

Para finalizar o desenvolvimento, devemos adicionar o seguinte trecho de código no Program.cs. Isso é necessário para configurar a injeção de dependência do repositório IFolderRepository e o Azure Cosmos DB como banco de dados para o Entity Framework Core.

builder.Services.AddScoped<IFolderRepository, FolderRepository>();

builder.Services.AddDbContext<CosmosContext>(opt =>
    opt.UseCosmos(
        connectionString: "YOUR-CONNECTION-STRING",
        databaseName: "MyDb"));

O valor da connection string pode ser obtido por meio do portal do Azure, como mostrado na imagem abaixo:

Vamos executar o projeto e adicionar algumas instâncias de Folder:

a

Tudo funcionou corretamente!

Você já pode baixar o projeto por esse link, e não esquece de me seguir no LinkedIn!

Até a próxima, abraços!

💡
Podemos te ajudar com uma revisão 100% gratuita do seu ambiente cloud.
Share this post