# Web Application Development Tutorial - Part 10: Book to Author Relation ````json //[doc-params] { "UI": ["MVC","NG"], "DB": ["EF","Mongo"] } ```` {{ if UI == "MVC" UI_Text="mvc" else if UI == "NG" UI_Text="angular" else UI_Text="?" end if DB == "EF" DB_Text="Entity Framework Core" else if DB == "Mongo" DB_Text="MongoDB" else DB_Text="?" end }} ## About This Tutorial In this tutorial series, you will build an ABP based web application named `Acme.BookStore`. This application is used to manage a list of books and their authors. It is developed using the following technologies: * **{{DB_Text}}** as the ORM provider. * **{{UI_Value}}** as the UI Framework. This tutorial is organized as the following parts; - [Part 1: Creating the server side](Part-1.md) - [Part 2: The book list page](Part-2.md) - [Part 3: Creating, updating and deleting books](Part-3.md) - [Part 4: Integration tests](Part-4.md) - [Part 5: Authorization](Part-5.md) - [Part 6: Authors: Domain layer](Part-6.md) - [Part 7: Authors: Database Integration](Part-7.md) - [Part 8: Authors: Application Layer](Part-8.md) - [Part 9: Authors: User Interface](Part-9.md) - **Part 10: Book to Author Relation (this part)** ### Download the Source Code This tutorials has multiple versions based on your **UI** and **Database** preferences. We've prepared two combinations of the source code to be downloaded: * [MVC (Razor Pages) UI with EF Core](https://github.com/abpframework/abp-samples/tree/master/BookStore-Mvc-EfCore) * [Angular UI with MongoDB](https://github.com/abpframework/abp-samples/tree/master/BookStore-Angular-MongoDb) ## Introduction We have created `Book` and `Author` functionalities for the book store application. However, currently there is no relation between these entities. In this tutorial, we will establish a **1 to N** relation between the `Book` and the `Author`. ## Add Relation to The Book Entity Open the `Books/Book.cs` in the `Acme.BookStore.Domain` project and add the following property to the `Book` entity: ````csharp public Guid AuthorId { get; set; } ```` ## Database & Data Migration Added a new, required `AuthorId` property to the `Book` entity. But, what about the existing books on the database? They currently don't have `AuthorId`s and this will be a problem when we try to run the application. This is a typical migration problem and the decision depends on your case; * If you haven't published your application to the production yet, you can just delete existing books in the database, or you can even delete the entire database in your development environment. * You can do it programmatically on data migration or seed phase. * You can manually handle it on the database. We prefer to drop the database (run the `Drop-Database` in the *Package Manager Console*) since this is just an example project and data loss is not important. Since this topic is not related to the ABP Framework, we don't go deeper for all scenarios. {{if DB=="EF"}} ### Update the EF Core Mapping Open the `BookStoreDbContextModelCreatingExtensions` class under the `EntityFrameworkCore` folder of the `Acme.BookStore.EntityFrameworkCore` project and change the `builder.Entity` part as shown below: ````csharp builder.Entity(b => { b.ToTable(BookStoreConsts.DbTablePrefix + "Books", BookStoreConsts.DbSchema); b.ConfigureByConvention(); //auto configure for the base class props b.Property(x => x.Name).IsRequired().HasMaxLength(128); // ADD THE MAPPING FOR THE RELATION b.HasOne().WithMany().HasForeignKey(x => x.AuthorId).IsRequired(); }); ```` ### Add New EF Core Migration Run the following command in the Package Manager Console (of the Visual Studio) to add a new database migration: ````bash Add-Migration "Added_AuthorId_To_Book" ```` This should create a new migration class with the following code in its `Up` method: ````csharp migrationBuilder.AddColumn( name: "AuthorId", table: "AppBooks", nullable: false, defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.CreateIndex( name: "IX_AppBooks_AuthorId", table: "AppBooks", column: "AuthorId"); migrationBuilder.AddForeignKey( name: "FK_AppBooks_AppAuthors_AuthorId", table: "AppBooks", column: "AuthorId", principalTable: "AppAuthors", principalColumn: "Id", onDelete: ReferentialAction.Cascade); ```` * Adds an `AuthorId` field to the `AppBooks` table. * Creates an index on the `AuthorId` field. * Declares the foreign key to the `AppAuthors` table. {{end}} ## Change the Data Seeder Since the `AuthorId` is a required property of the `Book` entity, current data seeder code can not work. Open the `BookStoreDataSeederContributor` in the `Acme.BookStore.Domain` project and change as the following: ````csharp using System; using System.Threading.Tasks; using Acme.BookStore.Authors; using Acme.BookStore.Books; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; namespace Acme.BookStore { public class BookStoreDataSeederContributor : IDataSeedContributor, ITransientDependency { private readonly IRepository _bookRepository; private readonly IAuthorRepository _authorRepository; private readonly AuthorManager _authorManager; public BookStoreDataSeederContributor( IRepository bookRepository, IAuthorRepository authorRepository, AuthorManager authorManager) { _bookRepository = bookRepository; _authorRepository = authorRepository; _authorManager = authorManager; } public async Task SeedAsync(DataSeedContext context) { if (await _bookRepository.GetCountAsync() > 0) { return; } var orwell = await _authorRepository.InsertAsync( await _authorManager.CreateAsync( "George Orwell", new DateTime(1903, 06, 25), "Orwell produced literary criticism and poetry, fiction and polemical journalism; and is best known for the allegorical novella Animal Farm (1945) and the dystopian novel Nineteen Eighty-Four (1949)." ) ); var douglas = await _authorRepository.InsertAsync( await _authorManager.CreateAsync( "Douglas Adams", new DateTime(1952, 03, 11), "Douglas Adams was an English author, screenwriter, essayist, humorist, satirist and dramatist. Adams was an advocate for environmentalism and conservation, a lover of fast cars, technological innovation and the Apple Macintosh, and a self-proclaimed 'radical atheist'." ) ); await _bookRepository.InsertAsync( new Book { AuthorId = orwell.Id, // SET THE AUTHOR Name = "1984", Type = BookType.Dystopia, PublishDate = new DateTime(1949, 6, 8), Price = 19.84f }, autoSave: true ); await _bookRepository.InsertAsync( new Book { AuthorId = douglas.Id, // SET THE AUTHOR Name = "The Hitchhiker's Guide to the Galaxy", Type = BookType.ScienceFiction, PublishDate = new DateTime(1995, 9, 27), Price = 42.0f }, autoSave: true ); } } } ```` The only change is that we set the `AuthorId` properties of the `Book` entities. {{if DB=="EF"}} You can now run the `.DbMigrator` console application to **migrate** the **database schema** and **seed** the initial data. {{else if DB="Mongo"}} You can now run the `.DbMigrator` console application to **seed** the initial data. {{end}} ## Application Layer We will change the `BookAppService` to support the Author relation. ### Data Transfer Objects Let's begin from the DTOs. #### BookDto Open the `BookDto` class in the `Books` folder of the `Acme.BookStore.Application.Contracts` project and add the following properties: ```csharp public Guid AuthorId { get; set; } public string AuthorName { get; set; } ``` The final `BookDto` class should be following: ```csharp using System; using Volo.Abp.Application.Dtos; namespace Acme.BookStore.Books { public class BookDto : AuditedEntityDto { public Guid AuthorId { get; set; } public string AuthorName { get; set; } public string Name { get; set; } public BookType Type { get; set; } public DateTime PublishDate { get; set; } public float Price { get; set; } } } ``` #### CreateUpdateBookDto Open the `CreateUpdateBookDto` class in the `Books` folder of the `Acme.BookStore.Application.Contracts` project and add an `AuthorId` property as shown: ````csharp public Guid AuthorId { get; set; } ```` #### AuthorLookupDto Create a new class, `AuthorLookupDto`, inside the `Books` folder of the `Acme.BookStore.Application.Contracts` project: ````csharp using System; using Volo.Abp.Application.Dtos; namespace Acme.BookStore.Books { public class AuthorLookupDto : EntityDto { public string Name { get; set; } } } ```` This will be used in a new method will be added to the `IBookAppService`. ### IBookAppService Open the `IBookAppService` interface in the `Books` folder of the `Acme.BookStore.Application.Contracts` project and add a new method, named `GetAuthorLookupAsync`, as shown below: ````csharp using System; using System.Threading.Tasks; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; namespace Acme.BookStore.Books { public interface IBookAppService : ICrudAppService< //Defines CRUD methods BookDto, //Used to show books Guid, //Primary key of the book entity PagedAndSortedResultRequestDto, //Used for paging/sorting CreateUpdateBookDto> //Used to create/update a book { // ADD the NEW METHOD Task> GetAuthorLookupAsync(); } } ```` This new method will be used from the UI to get a list of authors and fill a dropdown list to select the author of a book. ### BookAppService Open the `BookAppService` interface in the `Books` folder of the `Acme.BookStore.Application` project and replace the file content with the following code: ```csharp using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Acme.BookStore.Authors; using Acme.BookStore.Permissions; using Microsoft.AspNetCore.Authorization; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; namespace Acme.BookStore.Books { [Authorize(BookStorePermissions.Books.Default)] public class BookAppService : CrudAppService< Book, //The Book entity BookDto, //Used to show books Guid, //Primary key of the book entity PagedAndSortedResultRequestDto, //Used for paging/sorting CreateUpdateBookDto>, //Used to create/update a book IBookAppService //implement the IBookAppService { private readonly IAuthorRepository _authorRepository; public BookAppService( IRepository repository, IAuthorRepository authorRepository) : base(repository) { _authorRepository = authorRepository; GetPolicyName = BookStorePermissions.Books.Default; GetListPolicyName = BookStorePermissions.Books.Default; CreatePolicyName = BookStorePermissions.Books.Create; UpdatePolicyName = BookStorePermissions.Books.Edit; DeletePolicyName = BookStorePermissions.Books.Create; } public override async Task GetAsync(Guid id) { //Prepare a query to join books and authors var query = from book in Repository join author in _authorRepository on book.AuthorId equals author.Id where book.Id == id select new { book, author }; //Execute the query and get the book with author var queryResult = await AsyncExecuter.FirstOrDefaultAsync(query); if (queryResult == null) { throw new EntityNotFoundException(typeof(Book), id); } var bookDto = ObjectMapper.Map(queryResult.book); bookDto.AuthorName = queryResult.author.Name; return bookDto; } public override async Task> GetListAsync(PagedAndSortedResultRequestDto input) { //Prepare a query to join books and authors var query = from book in Repository join author in _authorRepository on book.AuthorId equals author.Id orderby input.Sorting select new {book, author}; query = query .Skip(input.SkipCount) .Take(input.MaxResultCount); //Execute the query and get a list var queryResult = await AsyncExecuter.ToListAsync(query); //Convert the query result to a list of BookDto objects var bookDtos = queryResult.Select(x => { var bookDto = ObjectMapper.Map(x.book); bookDto.AuthorName = x.author.Name; return bookDto; }).ToList(); //Get the total count with another query var totalCount = await Repository.GetCountAsync(); return new PagedResultDto( totalCount, bookDtos ); } public async Task> GetAuthorLookupAsync() { var authors = await _authorRepository.GetListAsync(); return new ListResultDto( ObjectMapper.Map, List>(authors) ); } } } ``` Let's see the changes we've done: * Added `[Authorize(BookStorePermissions.Books.Default)]` to authorize the methods we've newly added/overrode (remember, authorize attribute is valid for all the methods of the class when it is declared for a class). * Injected `IAuthorRepository` to query from the authors. * Overrode the `GetAsync` method of the base `CrudAppService`, which returns a single `BookDto` object with the given `id`. * Used a simple LINQ expression to join books and authors and query them together for the given book id. * Used `AsyncExecuter.FirstOrDefaultAsync(...)` to execute the query and get a result. `AsyncExecuter` was previously used in the `AuthorAppService`. Check the [repository documentation](../Repositories.md) to understand why we've used it. * Throws an `EntityNotFoundException` which results an `HTTP 404` (not found) result if requested book was not present in the database. * Finally, created a `BookDto` object using the `ObjectMapper`, then assigning the `AuthorName` manually. * Overrode the `GetListAsync` method of the base `CrudAppService`, which returns a list of books. The logic is similar to the previous method, so you can easily understand the code. * Created a new method: `GetAuthorLookupAsync`. This simple gets all the authors. The UI uses this method to fill a dropdown list and select and author while creating/editing books. ### Object to Object Mapping Configuration Introduced the `AuthorLookupDto` class and used object mapping inside the `GetAuthorLookupAsync` method. So, we need to add a new mapping definition inside the `BookStoreApplicationAutoMapperProfile.cs` file of the `Acme.BookStore.Application` project: ````csharp CreateMap(); ```` ## Unit Tests Some of the unit tests will fail since we made some changed on the `AuthorAppService`. Open the `BookAppService_Tests` in the `Books` folder of the `Acme.BookStore.Application.Tests` project and change the content as the following: ```csharp using System; using System.Linq; using System.Threading.Tasks; using Acme.BookStore.Authors; using Shouldly; using Volo.Abp.Application.Dtos; using Volo.Abp.Validation; using Xunit; namespace Acme.BookStore.Books { public class BookAppService_Tests : BookStoreApplicationTestBase { private readonly IBookAppService _bookAppService; private readonly IAuthorAppService _authorAppService; public BookAppService_Tests() { _bookAppService = GetRequiredService(); _authorAppService = GetRequiredService(); } [Fact] public async Task Should_Get_List_Of_Books() { //Act var result = await _bookAppService.GetListAsync( new PagedAndSortedResultRequestDto() ); //Assert result.TotalCount.ShouldBeGreaterThan(0); result.Items.ShouldContain(b => b.Name == "1984" && b.AuthorName == "George Orwell"); } [Fact] public async Task Should_Create_A_Valid_Book() { var authors = await _authorAppService.GetListAsync(new GetAuthorListDto()); var firstAuthor = authors.Items.First(); //Act var result = await _bookAppService.CreateAsync( new CreateUpdateBookDto { AuthorId = firstAuthor.Id, Name = "New test book 42", Price = 10, PublishDate = System.DateTime.Now, Type = BookType.ScienceFiction } ); //Assert result.Id.ShouldNotBe(Guid.Empty); result.Name.ShouldBe("New test book 42"); } [Fact] public async Task Should_Not_Create_A_Book_Without_Name() { var exception = await Assert.ThrowsAsync(async () => { await _bookAppService.CreateAsync( new CreateUpdateBookDto { Name = "", Price = 10, PublishDate = DateTime.Now, Type = BookType.ScienceFiction } ); }); exception.ValidationErrors .ShouldContain(err => err.MemberNames.Any(m => m == "Name")); } } } ``` * Changed the assertion condition in the `Should_Get_List_Of_Books` from `b => b.Name == "1984"` to `b => b.Name == "1984" && b.AuthorName == "George Orwell"` to check if the author name was filled. * Changed the `Should_Create_A_Valid_Book` method to set the `AuthorId` while creating a new book, since it is required anymore. ## The User Interface {{if UI=="MVC"}} ### The Book List Book list page change is trivial. Open the `Pages/Books/Index.js` in the `Acme.BookStore.Web` project and add the following column definition between the `name` and `type` columns: ````js ... { title: l('Name'), data: "name" }, // ADDED the NEW AUTHOR NAME COLUMN { title: l('Author'), data: "authorName" }, { title: l('Type'), data: "type", render: function (data) { return l('Enum:BookType:' + data); } }, ... ```` When you run the application, you can see the *Author* column on the table: ![bookstore-added-author-to-book-list](images/bookstore-added-author-to-book-list.png) ### Create Modal Open the `Pages/Books/CreateModal.cshtml.cs` in the `Acme.BookStore.Web` project and change the file content as shown below: ```csharp using System; using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; using Acme.BookStore.Books; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form; namespace Acme.BookStore.Web.Pages.Books { public class CreateModalModel : BookStorePageModel { [BindProperty] public CreateBookViewModel Book { get; set; } public List Authors { get; set; } private readonly IBookAppService _bookAppService; public CreateModalModel( IBookAppService bookAppService) { _bookAppService = bookAppService; } public async Task OnGetAsync() { Book = new CreateBookViewModel(); var authorLookup = await _bookAppService.GetAuthorLookupAsync(); Authors = authorLookup.Items .Select(x => new SelectListItem(x.Name, x.Id.ToString())) .ToList(); } public async Task OnPostAsync() { await _bookAppService.CreateAsync( ObjectMapper.Map(Book) ); return NoContent(); } public class CreateBookViewModel { [SelectItems(nameof(Authors))] [DisplayName("Author")] public Guid AuthorId { get; set; } [Required] [StringLength(128)] public string Name { get; set; } [Required] public BookType Type { get; set; } = BookType.Undefined; [Required] [DataType(DataType.Date)] public DateTime PublishDate { get; set; } = DateTime.Now; [Required] public float Price { get; set; } } } } ``` * Changed type of the `Book` property from `CreateUpdateBookDto` to the new `CreateBookViewModel` class defined in this file. The main motivation of this change to customize the model class based on the User Interface (UI) requirements. We didn't want to use UI-related `[SelectItems(nameof(Authors))]` and `[DisplayName("Author")]` attributes inside the `CreateUpdateBookDto` class. * Added `Authors` property that is filled inside the `OnGetAsync` method using the `IBookAppService.GetAuthorLookupAsync` method defined before. * Changed the `OnPostAsync` method to map `CreateBookViewModel` object to a `CreateUpdateBookDto` object since `IBookAppService.CreateAsync` expects a parameter of this type. ### Edit Modal Open the `Pages/Books/EditModal.cshtml.cs` in the `Acme.BookStore.Web` project and change the file content as shown below: ```csharp using System; using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; using Acme.BookStore.Books; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form; namespace Acme.BookStore.Web.Pages.Books { public class EditModalModel : BookStorePageModel { [BindProperty] public EditBookViewModel Book { get; set; } public List Authors { get; set; } private readonly IBookAppService _bookAppService; public EditModalModel(IBookAppService bookAppService) { _bookAppService = bookAppService; } public async Task OnGetAsync(Guid id) { var bookDto = await _bookAppService.GetAsync(id); Book = ObjectMapper.Map(bookDto); var authorLookup = await _bookAppService.GetAuthorLookupAsync(); Authors = authorLookup.Items .Select(x => new SelectListItem(x.Name, x.Id.ToString())) .ToList(); } public async Task OnPostAsync() { await _bookAppService.UpdateAsync( Book.Id, ObjectMapper.Map(Book) ); return NoContent(); } public class EditBookViewModel { [HiddenInput] public Guid Id { get; set; } [SelectItems(nameof(Authors))] [DisplayName("Author")] public Guid AuthorId { get; set; } [Required] [StringLength(128)] public string Name { get; set; } [Required] public BookType Type { get; set; } = BookType.Undefined; [Required] [DataType(DataType.Date)] public DateTime PublishDate { get; set; } = DateTime.Now; [Required] public float Price { get; set; } } } } ``` * Changed type of the `Book` property from `CreateUpdateBookDto` to the new `EditBookViewModel` class defined in this file, just like done before for the create modal above. * Moved the `Id` property inside the new `EditBookViewModel` class. * Added `Authors` property that is filled inside the `OnGetAsync` method using the `IBookAppService.GetAuthorLookupAsync` method. * Changed the `OnPostAsync` method to map `EditBookViewModel` object to a `CreateUpdateBookDto` object since `IBookAppService.UpdateAsync` expects a parameter of this type. These changes require a small change in the `EditModal.cshtml`. Remove the `` tag since we no longer need to it (since moved it to the `EditBookViewModel`). The final content of the `EditModal.cshtml` should be following: ````html @page @using Acme.BookStore.Localization @using Acme.BookStore.Web.Pages.Books @using Microsoft.Extensions.Localization @using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal @model EditModalModel @inject IStringLocalizer L @{ Layout = null; } ```` ### Object to Object Mapping Configuration The changes above requires to define some object to object mappings. Open the `BookStoreWebAutoMapperProfile.cs` in the `Acme.BookStore.Web` project and add the following mapping definitions inside the constructor: ```csharp CreateMap(); CreateMap(); CreateMap(); ``` You can run the application and try to create a new book or update an existing book. You will see a drop down list on the create/update form to select the author of the book: ![bookstore-added-authors-to-modals](images/bookstore-added-authors-to-modals.png) {{else if UI=="NG"}} *TODO: Preparing for the Angular UI...* {{end}}