Web Application Development Tutorial - Part 10: Book to Author Relation
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:
- Entity Framework Core as the ORM provider.
- Angular as the UI Framework.
This tutorial is organized as the following parts;
- Part 1: Creating the server side
- Part 2: The book list page
- Part 3: Creating, updating and deleting books
- Part 4: Integration tests
- Part 5: Authorization
- Part 6: Authors: Domain layer
- Part 7: Authors: Database Integration
- Part 8: Authors: Application Layer
- Part 9: Authors: User Interface
- Part 10: Book to Author Relation (this part)
Download the Source Code
This tutorial has multiple versions based on your UI and Database preferences. We've prepared a few combinations of the source code to be downloaded:
If you encounter the "filename too long" or "unzip error" on Windows, it's probably related to the Windows maximum file path limitation. Windows has a maximum file path limitation of 250 characters. To solve this, enable the long path option in Windows 10.
If you face long path errors related to Git, try the following command to enable long paths in Windows. See https://github.com/msysgit/msysgit/wiki/Git-cannot-create-a-file-or-directory-with-a-long-path
git config --system core.longpaths true
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 Author
and the Book
entities.
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:
public Guid AuthorId { get; set; }
In this tutorial, we preferred to not add a navigation property to the
Author
entity from theBook
class (likepublic Author Author { get; set; }
). This is due to follow the DDD best practices (rule: refer to other aggregates only by id). However, you can add such a navigation property and configure it for the EF Core. In this way, you don't need to write join queries while getting books with their authors (like we will done below) which makes your application code simpler.
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 update the existing data programmatically on data migration or seed phase.
- You can manually handle it on the database.
We prefer to delete the database (you can 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 the scenarios.
Update the EF Core Mapping
Locate to OnModelCreating
method in the BookStoreDbContext
class that under the EntityFrameworkCore
folder of the Acme.BookStore.EntityFrameworkCore
project and change the builder.Entity<Book>
part as shown below:
builder.Entity<Book>(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<Author>().WithMany().HasForeignKey(x => x.AuthorId).IsRequired();
});
Add New EF Core Migration
The startup solution is configured to use Entity Framework Core Code First Migrations. Since we've changed the database mapping configuration, we should create a new migration and apply changes to the database.
Open a command-line terminal in the directory of the Acme.BookStore.EntityFrameworkCore
project and type the following command:
dotnet ef migrations add Added_AuthorId_To_Book
This should create a new migration class with the following code in its Up
method:
migrationBuilder.AddColumn<Guid>(
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 theAppBooks
table. - Creates an index on the
AuthorId
field. - Declares the foreign key to the
AppAuthors
table.
If you are using Visual Studio, you may want to use
Add-Migration Added_AuthorId_To_Book -c BookStoreDbContext
andUpdate-Database -Context BookStoreDbContext
commands in the Package Manager Console (PMC). In this case, ensure thatAcme.BookStore.HttpApi.Host
is the startup project andAcme.BookStore.EntityFrameworkCore
is the Default Project in PMC.
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:
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<Book, Guid> _bookRepository;
private readonly IAuthorRepository _authorRepository;
private readonly AuthorManager _authorManager;
public BookStoreDataSeederContributor(
IRepository<Book, Guid> 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.
Delete existing books or delete the database before executing the
DbMigrator
. See the Database & Data Migration section above for more info.
You can now run the .DbMigrator
console application to migrate the database schema and seed the initial data.
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:
public Guid AuthorId { get; set; }
public string AuthorName { get; set; }
The final BookDto
class should be following:
using System;
using Volo.Abp.Application.Dtos;
namespace Acme.BookStore.Books
{
public class BookDto : AuditedEntityDto<Guid>
{
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:
public Guid AuthorId { get; set; }
AuthorLookupDto
Create a new class, AuthorLookupDto
, inside the Books
folder of the Acme.BookStore.Application.Contracts
project:
using System;
using Volo.Abp.Application.Dtos;
namespace Acme.BookStore.Books
{
public class AuthorLookupDto : EntityDto<Guid>
{
public string Name { get; set; }
}
}
This will be used in a new method that 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:
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<ListResultDto<AuthorLookupDto>> 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
class in the Books
folder of the Acme.BookStore.Application
project and replace the file content with the following code:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Dynamic.Core;
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<Book, Guid> 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<BookDto> GetAsync(Guid id)
{
//Get the IQueryable<Book> from the repository
var queryable = await Repository.GetQueryableAsync();
//Prepare a query to join books and authors
var query = from book in queryable
join author in await _authorRepository.GetQueryableAsync() 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<Book, BookDto>(queryResult.book);
bookDto.AuthorName = queryResult.author.Name;
return bookDto;
}
public override async Task<PagedResultDto<BookDto>> GetListAsync(PagedAndSortedResultRequestDto input)
{
//Get the IQueryable<Book> from the repository
var queryable = await Repository.GetQueryableAsync();
//Prepare a query to join books and authors
var query = from book in queryable
join author in await _authorRepository.GetQueryableAsync() on book.AuthorId equals author.Id
select new {book, author};
//Paging
query = query
.OrderBy(NormalizeSorting(input.Sorting))
.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<Book, BookDto>(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<BookDto>(
totalCount,
bookDtos
);
}
public async Task<ListResultDto<AuthorLookupDto>> GetAuthorLookupAsync()
{
var authors = await _authorRepository.GetListAsync();
return new ListResultDto<AuthorLookupDto>(
ObjectMapper.Map<List<Author>, List<AuthorLookupDto>>(authors)
);
}
private static string NormalizeSorting(string sorting)
{
if (sorting.IsNullOrEmpty())
{
return $"book.{nameof(Book.Name)}";
}
if (sorting.Contains("authorName", StringComparison.OrdinalIgnoreCase))
{
return sorting.Replace(
"authorName",
"author.Name",
StringComparison.OrdinalIgnoreCase
);
}
return $"book.{sorting}";
}
}
}
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 baseCrudAppService
, which returns a singleBookDto
object with the givenid
.- 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. It is a way to use asynchronous LINQ extensions without depending on the database provider API. Check the repository documentation to understand why we've used it. - Throws an
EntityNotFoundException
which results anHTTP 404
(not found) result if requested book was not present in the database. - Finally, created a
BookDto
object using theObjectMapper
, then assigning theAuthorName
manually.
- Overrode the
GetListAsync
method of the baseCrudAppService
, 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:
CreateMap<Author, AuthorLookupDto>();
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:
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<IBookAppService>();
_authorAppService = GetRequiredService<IAuthorAppService>();
}
[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<AbpValidationException>(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
fromb => b.Name == "1984"
tob => 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 theAuthorId
while creating a new book, since it is required anymore.
The User Interface
Service Proxy Generation
Since the HTTP APIs have been changed, you need to update Angular client side service proxies. Before running generate-proxy
command, your host must be up and running.
Run the following command in the angular
folder (you may need to stop the angular application):
abp generate-proxy -t ng
This command will update the service proxy files under the /src/app/proxy/
folder.
The Book List
Book list page change is trivial. Open the /src/app/book/book.component.html
and add the following column definition between the Name
and Type
columns:
<ngx-datatable-column
[name]="'::Author' | abpLocalization"
prop="authorName"
[sortable]="false"
></ngx-datatable-column>
When you run the application, you can see the Author column on the table:
Create/Edit Forms
The next step is to add an Author selection (dropdown) to the create/edit forms. The final UI will look like the one shown below:
Added the Author dropdown as the first element in the form.
Open the /src/app/book/book.component.ts
and and change the content as shown below:
import { ListService, PagedResultDto } from '@abp/ng.core';
import { Component, OnInit } from '@angular/core';
import { BookService, BookDto, bookTypeOptions, AuthorLookupDto } from '@proxy/books';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { NgbDateNativeAdapter, NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap';
import { ConfirmationService, Confirmation } from '@abp/ng.theme.shared';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Component({
selector: 'app-book',
templateUrl: './book.component.html',
styleUrls: ['./book.component.scss'],
providers: [ListService, { provide: NgbDateAdapter, useClass: NgbDateNativeAdapter }],
})
export class BookComponent implements OnInit {
book = { items: [], totalCount: 0 } as PagedResultDto<BookDto>;
form: FormGroup;
selectedBook = {} as BookDto;
authors$: Observable<AuthorLookupDto[]>;
bookTypes = bookTypeOptions;
isModalOpen = false;
constructor(
public readonly list: ListService,
private bookService: BookService,
private fb: FormBuilder,
private confirmation: ConfirmationService
) {
this.authors$ = bookService.getAuthorLookup().pipe(map((r) => r.items));
}
ngOnInit() {
const bookStreamCreator = (query) => this.bookService.getList(query);
this.list.hookToQuery(bookStreamCreator).subscribe((response) => {
this.book = response;
});
}
createBook() {
this.selectedBook = {} as BookDto;
this.buildForm();
this.isModalOpen = true;
}
editBook(id: string) {
this.bookService.get(id).subscribe((book) => {
this.selectedBook = book;
this.buildForm();
this.isModalOpen = true;
});
}
buildForm() {
this.form = this.fb.group({
authorId: [this.selectedBook.authorId || null, Validators.required],
name: [this.selectedBook.name || null, Validators.required],
type: [this.selectedBook.type || null, Validators.required],
publishDate: [
this.selectedBook.publishDate ? new Date(this.selectedBook.publishDate) : null,
Validators.required,
],
price: [this.selectedBook.price || null, Validators.required],
});
}
save() {
if (this.form.invalid) {
return;
}
const request = this.selectedBook.id
? this.bookService.update(this.selectedBook.id, this.form.value)
: this.bookService.create(this.form.value);
request.subscribe(() => {
this.isModalOpen = false;
this.form.reset();
this.list.get();
});
}
delete(id: string) {
this.confirmation.warn('::AreYouSureToDelete', 'AbpAccount::AreYouSure').subscribe((status) => {
if (status === Confirmation.Status.confirm) {
this.bookService.delete(id).subscribe(() => this.list.get());
}
});
}
}
- Added imports for the
AuthorLookupDto
,Observable
andmap
. - Added
authors$: Observable<AuthorLookupDto[]>;
field after theselectedBook
. - Added
this.authors$ = bookService.getAuthorLookup().pipe(map((r) => r.items));
into the constructor. - Added
authorId: [this.selectedBook.authorId || null, Validators.required],
into thebuildForm()
function.
Open the /src/app/book/book.component.html
and add the following form group just before the book name form group:
<div class="form-group">
<label for="author-id">Author</label><span> * </span>
<select class="form-control" id="author-id" formControlName="authorId">
<option [ngValue]="null">Select author</option>
<option [ngValue]="author.id" *ngFor="let author of authors$ | async">
{{ author.name }}
</option>
</select>
</div>
That's all. Just run the application and try to create or edit an author.