diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/ar.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/ar.json
index 44d5d79aac..751b33613e 100644
--- a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/ar.json
+++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/ar.json
@@ -142,6 +142,7 @@
"MinimumSearchContent": "يجب عليك إدخال 3 أحرف على الأقل!",
"Volo.AbpIo.Domain:060001": "عنوان URL المصدر (\"{ArticleUrl}\") ليس عنوان URL لـ Github",
"Volo.AbpIo.Domain:060002": "محتوى المقالة غير متوفر من مورد Github (\"{ArticleUrl}\").",
- "Volo.AbpIo.Domain:060003": "لم يتم العثور على محتوى مقال!"
+ "Volo.AbpIo.Domain:060003": "لم يتم العثور على محتوى مقال!",
+ "SeeMore": "شاهد المزيد"
}
}
\ No newline at end of file
diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/de-DE.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/de-DE.json
index 9dc7da3144..f3da536e7e 100644
--- a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/de-DE.json
+++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/de-DE.json
@@ -142,6 +142,7 @@
"MinimumSearchContent": "Sie müssen mindestens 3 Zeichen eingeben!",
"Volo.AbpIo.Domain:060001": "Quell-URL(\"{ArticleUrl}\") ist keine Github-URL",
"Volo.AbpIo.Domain:060002": "Artikelinhalt ist über die Github(\"{ArticleUrl}\")-Ressource nicht verfügbar.",
- "Volo.AbpIo.Domain:060003": "Kein Artikelinhalt gefunden!"
+ "Volo.AbpIo.Domain:060003": "Kein Artikelinhalt gefunden!",
+ "SeeMore": "Mehr Sehen"
}
}
\ No newline at end of file
diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/en-GB.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/en-GB.json
index 652090d864..5925ffb172 100644
--- a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/en-GB.json
+++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/en-GB.json
@@ -101,6 +101,7 @@
"ArticleRequestMessageTitle": "Open an issue on the GitHub to request an article/tutorial you want to see on this web site.",
"ArticleRequestMessageBody": "Here is the list of the requested articles by the Community. Do you want to write a requested article? Please click on the request and join the discussion.",
"Language": "Language",
- "CreateArticleLanguageInfo": "The language in which the article is written"
+ "CreateArticleLanguageInfo": "Language of the article",
+ "SeeMore": "See More"
}
-}
\ No newline at end of file
+}
diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/en.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/en.json
index 2da4a62481..16a86cac37 100644
--- a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/en.json
+++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/en.json
@@ -142,6 +142,7 @@
"MinimumSearchContent": "You must enter at least 3 characters!",
"Volo.AbpIo.Domain:060001": "Source URL(\"{ArticleUrl}\") is not Github URL",
"Volo.AbpIo.Domain:060002": "Article Content is not available from Github(\"{ArticleUrl}\") resource.",
- "Volo.AbpIo.Domain:060003": "No article content found!"
+ "Volo.AbpIo.Domain:060003": "No article content found!",
+ "SeeMore": "See More"
}
}
diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/es.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/es.json
index 7b34e043a7..3066263e8b 100644
--- a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/es.json
+++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/es.json
@@ -142,6 +142,7 @@
"MinimumSearchContent": "¡Debes ingresar al menos 3 caracteres!",
"Volo.AbpIo.Domain:060001": "La URL de origen (\"{ArticleUrl}\") no es la URL de Github",
"Volo.AbpIo.Domain:060002": "El contenido del artículo no está disponible en el recurso de Github (\"{ArticleUrl}\").",
- "Volo.AbpIo.Domain:060003": "¡No se encontró contenido del artículo!"
+ "Volo.AbpIo.Domain:060003": "¡No se encontró contenido del artículo!",
+ "SeeMore": "Ver Más"
}
}
\ No newline at end of file
diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/fi.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/fi.json
index 2c09984774..435ac1ef92 100644
--- a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/fi.json
+++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/fi.json
@@ -142,6 +142,7 @@
"MinimumSearchContent": "Sinun on annettava vähintään 3 merkkiä!",
"Volo.AbpIo.Domain:060001": "Lähteen URL-osoite (\"{ArticleUrl}\") ei ole Githubin URL-osoite",
"Volo.AbpIo.Domain:060002": "Artikkelin sisältö ei ole saatavilla Githubin (\"{ArticleUrl}\") -resurssista.",
- "Volo.AbpIo.Domain:060003": "Artikkelin sisältöä ei löytynyt!"
+ "Volo.AbpIo.Domain:060003": "Artikkelin sisältöä ei löytynyt!",
+ "SeeMore": "Katso Lisää"
}
}
\ No newline at end of file
diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/fr.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/fr.json
index 42f239f987..cbeb8ae084 100644
--- a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/fr.json
+++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/fr.json
@@ -142,6 +142,7 @@
"MinimumSearchContent": "Vous devez saisir au moins 3 caractères!",
"Volo.AbpIo.Domain:060001": "L'URL source (\"{ArticleUrl}\") n'est pas une URL Github",
"Volo.AbpIo.Domain:060002": "Le contenu de l'article n'est pas disponible à partir de la ressource Github(\"{ArticleUrl}\").",
- "Volo.AbpIo.Domain:060003": "Aucun contenu d'article trouvé !"
+ "Volo.AbpIo.Domain:060003": "Aucun contenu d'article trouvé !",
+ "SeeMore": "Voir Plus"
}
}
\ No newline at end of file
diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/hi.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/hi.json
index aa351262ca..1e8d5a9e9f 100644
--- a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/hi.json
+++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/hi.json
@@ -142,6 +142,7 @@
"MinimumSearchContent": "आपको कम से कम 3 वर्ण दर्ज करने होंगे!",
"Volo.AbpIo.Domain:060001": "स्रोत URL (\"{ArticleUrl}\") जीथब URL नहीं है",
"Volo.AbpIo.Domain:060002": "लेख सामग्री Github (\"{ArticleUrl}\") संसाधन से उपलब्ध नहीं है।",
- "Volo.AbpIo.Domain:060003": "कोई लेख सामग्री नहीं मिली!"
+ "Volo.AbpIo.Domain:060003": "कोई लेख सामग्री नहीं मिली!",
+ "SeeMore": "और देखें"
}
}
\ No newline at end of file
diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/is.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/is.json
index fd128eb596..7c8f3f2fba 100644
--- a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/is.json
+++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/is.json
@@ -142,6 +142,7 @@
"MinimumSearchContent": "Þú verður að slá inn að minnsta kosti 3 stafi!",
"Volo.AbpIo.Domain:060001": "Upprunaslóð (\"{ArticleUrl} \") er ekki Github slóð",
"Volo.AbpIo.Domain:060002": "Innihald greinar er ekki fáanlegt frá Github (\"{ArticleUrl} \") resoursum.",
- "Volo.AbpIo.Domain:060003": "Innihald greinar fannst ekki!"
+ "Volo.AbpIo.Domain:060003": "Innihald greinar fannst ekki!",
+ "SeeMore": "Sjá Meira"
}
}
\ No newline at end of file
diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/it.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/it.json
index f104045682..1b305d9b4a 100644
--- a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/it.json
+++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/it.json
@@ -142,6 +142,7 @@
"MinimumSearchContent": "Devi inserire almeno 3 caratteri!",
"Volo.AbpIo.Domain:060001": "Source URL(\"{ArticleUrl}\") non è un URL di GitHub",
"Volo.AbpIo.Domain:060002": "Il contenuto dell'articolo non è disponibile dalla risorsa Github(\"{ArticleUrl}\").",
- "Volo.AbpIo.Domain:060003": "Nessun contenuto dell'articolo trovato!"
+ "Volo.AbpIo.Domain:060003": "Nessun contenuto dell'articolo trovato!",
+ "SeeMore": "Vedi Altro"
}
}
\ No newline at end of file
diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/ro-RO.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/ro-RO.json
index 4c78f15d99..a15a1bc102 100644
--- a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/ro-RO.json
+++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/ro-RO.json
@@ -142,6 +142,7 @@
"MinimumSearchContent": "Trebuie să introduceţi cel putin 3 caractere!",
"Volo.AbpIo.Domain:060001": "Sursa URL(\"{ArticleUrl}\") nu este URL GitHub",
"Volo.AbpIo.Domain:060002": "Conţinutul articolului nu este disponibil din resursa de pe GitHub(\"{ArticleUrl}\").",
- "Volo.AbpIo.Domain:060003": "Nu a fost găsit conţinutul articolului!"
+ "Volo.AbpIo.Domain:060003": "Nu a fost găsit conţinutul articolului!",
+ "SeeMore": "Vezi mai mult"
}
}
diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/sk.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/sk.json
index e01e201969..3a2b2e5f1d 100644
--- a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/sk.json
+++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/sk.json
@@ -142,6 +142,7 @@
"MinimumSearchContent": "Musíte zadať aspoň 3 znaky!",
"Volo.AbpIo.Domain:060001": "Zdrojová URL(\"{ArticleUrl}\") nie je URL Githubu",
"Volo.AbpIo.Domain:060002": "Obsah článku nie je dostupný v Github zdroji(\"{ArticleUrl}\").",
- "Volo.AbpIo.Domain:060003": "Nenašiel sa žiadny obsah článku!"
+ "Volo.AbpIo.Domain:060003": "Nenašiel sa žiadny obsah článku!",
+ "SeeMore": "Vidět Víc"
}
}
\ No newline at end of file
diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/zh-Hans.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/zh-Hans.json
index f504842e70..15dd748d6e 100644
--- a/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/zh-Hans.json
+++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Community/Localization/Resources/zh-Hans.json
@@ -142,6 +142,7 @@
"MinimumSearchContent": "您必须输入至少 3 个字符!",
"Volo.AbpIo.Domain:060001": "源 URL(\"{ArticleUrl}\") 不是 Github URL",
"Volo.AbpIo.Domain:060002": "文章内容无法从 Github(\"{ArticleUrl}\") 资源中获得。",
- "Volo.AbpIo.Domain:060003": "没有找到文章内容!"
+ "Volo.AbpIo.Domain:060003": "没有找到文章内容!",
+ "SeeMore": "查看更多"
}
}
\ No newline at end of file
diff --git a/docs/en/Blog-Posts/2021-11-18 v5_0_Preview/POST.md b/docs/en/Blog-Posts/2021-11-18 v5_0_Preview/POST.md
index e1bc93e23a..44bcf38d06 100644
--- a/docs/en/Blog-Posts/2021-11-18 v5_0_Preview/POST.md
+++ b/docs/en/Blog-Posts/2021-11-18 v5_0_Preview/POST.md
@@ -285,6 +285,16 @@ This can be a breaking change in rare cases (for example, if you create host sid
## Community News
+### ABP Community Talks 2021.12
+
+
+
+As the core ABP development team, we've decided to organize monthly live meetings with the ABP community. The first live meeting will be at **December 16, 2021, 17:00 (UTC)** on YouTube. ABP core team members will present some of the new features coming with ABP 5.0.
+
+**The YouTube event link is: https://www.youtube.com/watch?v=uLu2t5E8T-w**
+
+You can also [subscribe to the Volosoft channel](https://www.youtube.com/channel/UCO3XKlpvq8CA5MQNVS6b3dQ) for reminders for further ABP events and videos.
+
### ABP was on ASP.NET Community Startup!
It was great for us to be invited to Microsoft's [ASP.NET Community Weekly Standup](https://dotnet.microsoft.com/live/community-standup) show, at September 28. There was a very high attention and that made us very happy. Thanks to the ABP Community and all the watchers :) If you've missed the talk, [you can watch it here](https://www.youtube.com/watch?v=vMWM-_ihjwM).
diff --git a/docs/en/Blog-Posts/2021-11-18 v5_0_Preview/community-talks.png b/docs/en/Blog-Posts/2021-11-18 v5_0_Preview/community-talks.png
new file mode 100644
index 0000000000..4056d1afb8
Binary files /dev/null and b/docs/en/Blog-Posts/2021-11-18 v5_0_Preview/community-talks.png differ
diff --git a/docs/en/Blog-Posts/2021-11-18 v5_0_Preview/cover-50.png b/docs/en/Blog-Posts/2021-11-18 v5_0_Preview/cover-50.png
new file mode 100644
index 0000000000..bd343e70b0
Binary files /dev/null and b/docs/en/Blog-Posts/2021-11-18 v5_0_Preview/cover-50.png differ
diff --git a/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/POST.md b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/POST.md
new file mode 100644
index 0000000000..6abf984298
--- /dev/null
+++ b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/POST.md
@@ -0,0 +1,1671 @@
+# Many to Many Relationship with ABP and EF Core
+
+## Introduction
+
+In this article, we'll create a **BookStore** application like in [the ABP tutorial](https://docs.abp.io/en/abp/latest/Tutorials/Part-1?UI=MVC&DB=EF) and add an extra `Category` feature to demonstrate how we can manage the many-to-many relationship with ABP-based applications (by following DDD rules).
+
+You can see the ER Diagram of our application below. This diagram will be helpful for us to demonstrate the relations between our entities.
+
+
+
+When we've examined the ER Diagram, we can see the one-to-many relationship between the **Author** and the **Book** tables. Also, the many-to-many relationship (**BookCategory** table) between the **Book** and the **Category** tables. (There can be more than one category on each book and vice-versa in our scenario).
+
+### Source Code
+
+You can find the source code of the application at https://github.com/EngincanV/ABP-Many-to-Many-Relationship-Demo .
+
+### Demo of the Final Application
+
+At the end of this article, we will have created an application same as in the gif below.
+
+
+
+## Creating the Solution
+
+In this article, we will create a new startup template with EF Core as a database provider and MVC for UI framework.
+
+* We can create a new startup template by using the [ABP CLI](https://docs.abp.io/en/abp/latest/CLI):
+
+```bash
+abp new BookStore -t app --version 5.0.0-beta.2
+```
+
+* Our project boilerplate will be ready after the download is finished. Then, we can open the solution and start developing.
+
+## Starting the Development
+
+Let's start with creating our Domain Entities.
+
+### Step 1 - (Creating the Domain Entities)
+
+We can create a folder-structure under the `BookStore.Domain` project like in the image below.
+
+
+
+Open the entity classes and add the following codes to each of these classes.
+
+* **Author.cs**
+
+```csharp
+using System;
+using JetBrains.Annotations;
+using Volo.Abp;
+using Volo.Abp.Domain.Entities.Auditing;
+
+namespace BookStore.Authors
+{
+ public class Author : FullAuditedAggregateRoot
+ {
+ public string Name { get; private set; }
+
+ public DateTime BirthDate { get; set; }
+
+ public string ShortBio { get; set; }
+
+ /* This constructor is for deserialization / ORM purpose */
+ private Author()
+ {
+ }
+
+ public Author(Guid id, [NotNull] string name, DateTime birthDate, [CanBeNull] string shortBio = null)
+ : base(id)
+ {
+ SetName(name);
+ BirthDate = birthDate;
+ ShortBio = shortBio;
+ }
+
+ public void SetName([NotNull] string name)
+ {
+ Name = Check.NotNullOrWhiteSpace(
+ name,
+ nameof(name),
+ maxLength: AuthorConsts.MaxNameLength
+ );
+ }
+ }
+}
+```
+
+> We'll create the `AuthorConsts` class later in this step.
+
+* **Book.cs**
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using Volo.Abp;
+using Volo.Abp.Domain.Entities.Auditing;
+
+namespace BookStore.Books
+{
+ public class Book : FullAuditedAggregateRoot
+ {
+ public Guid AuthorId { get; set; }
+
+ public string Name { get; private set; }
+
+ public DateTime PublishDate { get; set; }
+
+ public float Price { get; set; }
+
+ public ICollection Categories { get; private set; }
+
+ private Book()
+ {
+ }
+
+ public Book(Guid id, Guid authorId, string name, DateTime publishDate, float price)
+ : base(id)
+ {
+ AuthorId = authorId;
+ SetName(name);
+ PublishDate = publishDate;
+ Price = price;
+
+ Categories = new Collection();
+ }
+
+ public void SetName(string name)
+ {
+ Name = Check.NotNullOrWhiteSpace(name, nameof(name), BookConsts.MaxNameLength);
+ }
+
+ public void AddCategory(Guid categoryId)
+ {
+ Check.NotNull(categoryId, nameof(categoryId));
+
+ if (IsInCategory(categoryId))
+ {
+ return;
+ }
+
+ Categories.Add(new BookCategory(bookId: Id, categoryId));
+ }
+
+ public void RemoveCategory(Guid categoryId)
+ {
+ Check.NotNull(categoryId, nameof(categoryId));
+
+ if (!IsInCategory(categoryId))
+ {
+ return;
+ }
+
+ Categories.RemoveAll(x => x.CategoryId == categoryId);
+ }
+
+ public void RemoveAllCategoriesExceptGivenIds(List categoryIds)
+ {
+ Check.NotNullOrEmpty(categoryIds, nameof(categoryIds));
+
+ Categories.RemoveAll(x => !categoryIds.Contains(x.CategoryId));
+ }
+
+ public void RemoveAllCategories()
+ {
+ Categories.RemoveAll(x => x.BookId == Id);
+ }
+
+ private bool IsInCategory(Guid categoryId)
+ {
+ return Categories.Any(x => x.CategoryId == categoryId);
+ }
+ }
+}
+```
+
+* In our scenario, a book can have more than one category and a category can have more than one book so we need to create a many-to-many relationship between them.
+
+* For achieving this, we will create a **join entity** named `BookCategory`, and this class will simply have variables named `BookId` and `CategoryId`.
+
+* To manage this **join entity**, we can add it as a sub-collection to the **Book** entity, as we do above. We add this sub-collection
+to **Book** class instead of **Category** class, because a book can have tens (or mostly hundreds) of categories but on the other perspective a category can have more than a hundred (or even way much) books inside of it.
+
+* It is a significant performance problem to load thousands of items whenever you query a category. Therefore it makes much more sense to add that sub-collection to the `Book` entity.
+
+> Don't forget: **Aggregate is a pattern in Domain-Driven Design. A DDD aggregate is a cluster of domain objects that can be treated as a single unit.** (See the full [description](https://martinfowler.com/bliki/DDD_Aggregate.html))
+
+* Notice that, `BookCategory` is not an **Aggregate Root** so we are not violating one of the base rules about Aggregate Root (Rule: **"Reference Other Aggregates Only by ID"**).
+
+* If we examine the methods in the `Book` class (such as **RemoveAllCategories**, **RemoveAllCategoriesExceptGivenIds** and **AddCategory**) we will manage our sub-collection `Categories` (**BookCategory** - join table/entity) through them. (Adds or removes categories for books)
+
+> We'll create the `BookCategory` and `BookConsts` classes later in this step.
+
+* **BookCategory.cs**
+
+```csharp
+using System;
+using Volo.Abp.Domain.Entities;
+
+namespace BookStore.Books
+{
+ public class BookCategory : Entity
+ {
+ public Guid BookId { get; protected set; }
+
+ public Guid CategoryId { get; protected set; }
+
+ /* This constructor is for deserialization / ORM purpose */
+ private BookCategory()
+ {
+ }
+
+ public BookCategory(Guid bookId, Guid categoryId)
+ {
+ BookId = bookId;
+ CategoryId = categoryId;
+ }
+
+ public override object[] GetKeys()
+ {
+ return new object[] {BookId, CategoryId};
+ }
+ }
+}
+```
+
+* Here, as you can notice, we've defined the `BookCategory` as the **Join Table/Entity** for our many-to-many relationship and ensured the required properties (BookId and CategoryId) were set in the constructor method of this class to create this object.
+
+* And also we've derived this class from the `Entity` class and therefore we've had to override the **GetKeys** method of this class to define the **Composite Key**.
+
+> The composite key is composed of `BookId` and `CategoryId` in our case. And they are unique together.
+
+> For more information about **Entities with Composite Keys**, you can read the relevant section from [Entities documentation](https://docs.abp.io/en/abp/latest/Entities#entities-with-composite-keys).
+
+* **BookManager.cs**
+
+```csharp
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using BookStore.Categories;
+using JetBrains.Annotations;
+using Volo.Abp.Domain.Repositories;
+using Volo.Abp.Domain.Services;
+
+namespace BookStore.Books
+{
+ public class BookManager : DomainService
+ {
+ private readonly IBookRepository _bookRepository;
+ private readonly IRepository _categoryRepository;
+
+ public BookManager(IBookRepository bookRepository, IRepository categoryRepository)
+ {
+ _bookRepository = bookRepository;
+ _categoryRepository = categoryRepository;
+ }
+
+ public async Task CreateAsync(Guid authorId, string name, DateTime publishDate, float price, [CanBeNull]string[] categoryNames)
+ {
+ var book = new Book(GuidGenerator.Create(), authorId, name, publishDate, price);
+
+ await SetCategoriesAsync(book, categoryNames);
+
+ await _bookRepository.InsertAsync(book);
+ }
+
+ public async Task UpdateAsync(
+ Book book,
+ Guid authorId,
+ string name,
+ DateTime publishDate,
+ float price,
+ [CanBeNull] string[] categoryNames
+ )
+ {
+ book.AuthorId = authorId;
+ book.SetName(name);
+ book.PublishDate = publishDate;
+ book.Price = price;
+
+ await SetCategoriesAsync(book, categoryNames);
+
+ await _bookRepository.UpdateAsync(book);
+ }
+
+ private async Task SetCategoriesAsync(Book book, [CanBeNull] string[] categoryNames)
+ {
+ if (categoryNames == null || !categoryNames.Any())
+ {
+ book.RemoveAllCategories();
+ return;
+ }
+
+ var query = (await _categoryRepository.GetQueryableAsync())
+ .Where(x => categoryNames.Contains(x.Name))
+ .Select(x => x.Id)
+ .Distinct();
+
+ var categoryIds = await AsyncExecuter.ToListAsync(query);
+ if (!categoryIds.Any())
+ {
+ return;
+ }
+
+ book.RemoveAllCategoriesExceptGivenIds(categoryIds);
+
+ foreach (var categoryId in categoryIds)
+ {
+ book.AddCategory(categoryId);
+ }
+ }
+ }
+}
+```
+
+* If we examine the codes in the `BookManager` class, we can see that we've managed the `BookCategory` class (our join table/entity) by using some methods that we've defined in the `Book` class such as **RemoveAllCategories**, **RemoveAllCategoriesExceptGivenIds** and **AddCategory**.
+
+* These methods basically add or remove categories related to the book by conditions.
+
+* In the `CreateAsync` method, if the category names are specified, we'll retrieve their ids from the database and by using the **AddCategory** method that we've defined in the `Book` class, we'll add them.
+
+* In the `UpdateAsync` method, the same logic is also valid. But in this case, the user might want to remove some categories from books, so if the user sends us an empty **categoryNames** array, we remove all categories from the book he wants to update. If the user sends us some category names, we remove the excluded ones and add the new ones according to the **categoryNames** array.
+
+* **BookWithDetails.cs**
+
+```csharp
+using System;
+using Volo.Abp.Auditing;
+
+namespace BookStore.Books
+{
+ public class BookWithDetails : IHasCreationTime
+ {
+ public Guid Id { get; set; }
+
+ public string Name { get; set; }
+
+ public DateTime PublishDate { get; set; }
+
+ public float Price { get; set; }
+
+ public string AuthorName { get; set; }
+
+ public string[] CategoryNames { get; set; }
+
+ public DateTime CreationTime { get; set; }
+ }
+}
+```
+
+We will use this class to retrieve books with their sub-categories and author names.
+
+* **IBookRepository.cs**
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Volo.Abp.Domain.Repositories;
+
+namespace BookStore.Books
+{
+ public interface IBookRepository : IRepository
+ {
+ Task> GetListAsync(
+ string sorting,
+ int skipCount,
+ int maxResultCount,
+ CancellationToken cancellationToken = default
+ );
+
+ Task GetAsync(Guid id, CancellationToken cancellationToken = default);
+ }
+}
+```
+
+We need to create two methods named **GetListAsync** and **GetAsync** and specify their return type as `BookWithDetails`. So by implementing these methods, we will return the book/books by their details (author name and categories).
+
+* **Category.cs**
+
+```csharp
+using System;
+using Volo.Abp;
+using Volo.Abp.Domain.Entities.Auditing;
+
+namespace BookStore.Categories
+{
+ public class Category : AuditedAggregateRoot
+ {
+ public string Name { get; private set; }
+
+ /* This constructor is for deserialization / ORM purpose */
+ private Category()
+ {
+ }
+
+ public Category(Guid id, string name) : base(id)
+ {
+ SetName(name);
+ }
+
+ public Category SetName(string name)
+ {
+ Name = Check.NotNullOrWhiteSpace(name, nameof(name), CategoryConsts.MaxNameLength);
+ return this;
+ }
+ }
+}
+```
+
+After defining our entities we can seed initial data to our database by using the [Data Seeding](https://docs.abp.io/en/abp/5.0/Data-Seeding#data-seeding) system of the ABP framework. We will create initial data for both the `Category` and `Author` entities because we will not create CRUD pages for these entities.
+
+> We will create only CRUD pages for the Book entity therefore we don't need to add initial data for the Book entity. We can create a new book by using the create modal of the Book page. (We will create it in the sixth step.)
+
+Create a class named `BookStoreDataSeederContributor` in your `*.Domain` project and update with the following code:
+
+* **BookStoreDataSeederContributor.cs**
+
+```csharp
+using System;
+using System.Threading.Tasks;
+using BookStore.Authors;
+using BookStore.Categories;
+using Volo.Abp.Data;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.Domain.Repositories;
+using Volo.Abp.Guids;
+
+namespace BookStore
+{
+ public class BookStoreDataSeederContributor : IDataSeedContributor, ITransientDependency
+ {
+ private readonly IGuidGenerator _guidGenerator;
+ private readonly IRepository _categoryRepository;
+ private readonly IRepository _authorRepository;
+
+ public BookStoreDataSeederContributor(
+ IGuidGenerator guidGenerator,
+ IRepository categoryRepository,
+ IRepository authorRepository
+ )
+ {
+ _guidGenerator = guidGenerator;
+ _categoryRepository = categoryRepository;
+ _authorRepository = authorRepository;
+ }
+
+ public async Task SeedAsync(DataSeedContext context)
+ {
+ await SeedCategoriesAsync();
+ await SeedAuthorsAsync();
+ }
+
+ private async Task SeedCategoriesAsync()
+ {
+ if (await _categoryRepository.GetCountAsync() <= 0)
+ {
+ await _categoryRepository.InsertAsync(
+ new Category(_guidGenerator.Create(), "History")
+ );
+
+ await _categoryRepository.InsertAsync(
+ new Category(_guidGenerator.Create(), "Unknown")
+ );
+
+ await _categoryRepository.InsertAsync(
+ new Category(_guidGenerator.Create(), "Adventure")
+ );
+
+ await _categoryRepository.InsertAsync(
+ new Category(_guidGenerator.Create(), "Action")
+ );
+
+ await _categoryRepository.InsertAsync(
+ new Category(_guidGenerator.Create(), "Crime")
+ );
+
+ await _categoryRepository.InsertAsync(
+ new Category(_guidGenerator.Create(), "Dystopia")
+ );
+ }
+ }
+
+ private async Task SeedAuthorsAsync()
+ {
+ if (await _authorRepository.GetCountAsync() <= 0)
+ {
+ await _authorRepository.InsertAsync(
+ new Author(
+ _guidGenerator.Create(),
+ "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)."
+ )
+ );
+
+ await _authorRepository.InsertAsync(
+ new Author(
+ _guidGenerator.Create(),
+ "Dan Brown",
+ new DateTime(1964, 06, 22),
+ "Daniel Gerhard Brown (born June 22, 1964) is an American author best known for his thriller novels"
+ )
+ );
+ }
+ }
+ }
+}
+```
+
+### Step 2 - (Define Consts)
+
+We can create a folder-structure under the `BookStore.Domain.Shared` project like in the image below.
+
+
+
+* **AuthorConsts.cs**
+
+```csharp
+namespace BookStore.Authors
+{
+ public class AuthorConsts
+ {
+ public const int MaxNameLength = 128;
+
+ public const int MaxShortBioLength = 256;
+ }
+}
+```
+
+* **BookConsts.cs**
+
+```csharp
+namespace BookStore.Books
+{
+ public class BookConsts
+ {
+ public const int MaxNameLength = 128;
+ }
+}
+```
+
+* **CategoryConsts.cs**
+
+```csharp
+namespace BookStore.Categories
+{
+ public class CategoryConsts
+ {
+ public const int MaxNameLength = 64;
+ }
+}
+```
+
+In these classes, we've defined max text length for our entity properties that we will use in the **Database Integration** section to specify limits for our properties. (E.g. varchar(128) for BookName)
+
+### Step 3 - (Database Integration)
+
+After defining our entities, we can configure them for the database integration.
+Open the `BookStoreDbContext` class in the `BookStore.EntityFrameworkCore` project and update the following code blocks.
+
+```csharp
+namespace BookStore.EntityFrameworkCore
+{
+ [ReplaceDbContext(typeof(IIdentityDbContext))]
+ [ReplaceDbContext(typeof(ITenantManagementDbContext))]
+ [ConnectionStringName("Default")]
+ public class BookStoreDbContext :
+ AbpDbContext,
+ IIdentityDbContext,
+ ITenantManagementDbContext
+ {
+ //...
+
+ //DbSet properties for our Aggregate Roots
+ public DbSet Authors { get; set; }
+ public DbSet Books { get; set; }
+ public DbSet Categories { get; set; }
+
+ //NOTE: We don't need to add DbSet, because we will be query it via using the Book entity
+ // public DbSet BookCategories { get; set; }
+
+ //...
+
+ protected override void OnModelCreating(ModelBuilder builder)
+ {
+ //...
+
+ /* Configure your own tables/entities inside here */
+ builder.Entity(b =>
+ {
+ b.ToTable(BookStoreConsts.DbTablePrefix + "Authors" + BookStoreConsts.DbSchema);
+ b.ConfigureByConvention();
+
+ b.Property(x => x.Name)
+ .HasMaxLength(AuthorConsts.MaxNameLength)
+ .IsRequired();
+
+ b.Property(x => x.ShortBio)
+ .HasMaxLength(AuthorConsts.MaxShortBioLength)
+ .IsRequired();
+ });
+
+ builder.Entity(b =>
+ {
+ b.ToTable(BookStoreConsts.DbTablePrefix + "Books" + BookStoreConsts.DbSchema);
+ b.ConfigureByConvention();
+
+ b.Property(x => x.Name)
+ .HasMaxLength(BookConsts.MaxNameLength)
+ .IsRequired();
+
+ //one-to-many relationship with Author table
+ b.HasOne().WithMany().HasForeignKey(x => x.AuthorId).IsRequired();
+
+ //many-to-many relationship with Category table => BookCategories
+ b.HasMany(x => x.Categories).WithOne().HasForeignKey(x => x.BookId).IsRequired();
+ });
+
+ builder.Entity(b =>
+ {
+ b.ToTable(BookStoreConsts.DbTablePrefix + "Categories" + BookStoreConsts.DbSchema);
+ b.ConfigureByConvention();
+
+ b.Property(x => x.Name)
+ .HasMaxLength(CategoryConsts.MaxNameLength)
+ .IsRequired();
+ });
+
+ builder.Entity(b =>
+ {
+ b.ToTable(BookStoreConsts.DbTablePrefix + "BookCategories" + BookStoreConsts.DbSchema);
+ b.ConfigureByConvention();
+
+ //define composite key
+ b.HasKey(x => new { x.BookId, x.CategoryId });
+
+ //many-to-many configuration
+ b.HasOne().WithMany(x => x.Categories).HasForeignKey(x => x.BookId).IsRequired();
+ b.HasOne().WithMany().HasForeignKey(x => x.CategoryId).IsRequired();
+
+ b.HasIndex(x => new { x.BookId, x.CategoryId });
+ });
+ }
+ }
+}
+```
+
+* In this class, we've defined the **DbSet** properties for our **Aggregate Roots** (**Book**, **Author** and **Category**). Notice, we didn't define the **DbSet** for the `BookCategory` class (our join table/entity). Because, the `Book` aggregate is responsible for managing it via sub-collection.
+
+* After that, we can use the **FluentAPI** to configure our tables in the `OnModelCreating` method of this class.
+
+```csharp
+builder.Entity(b =>
+{
+ //...
+
+ //one-to-many relationship with Author table
+ b.HasOne().WithMany().HasForeignKey(x => x.AuthorId).IsRequired();
+
+ //many-to-many relationship with Category table => BookCategories
+ b.HasMany(x => x.Categories).WithOne().HasForeignKey(x => x.BookId).IsRequired();
+});
+```
+
+Here, we have provided the one-to-many relationship between the **Book** and the **Author** in the above code-block.
+
+```csharp
+builder.Entity(b =>
+{
+ //...
+
+ //define composite key
+ b.HasKey(x => new { x.BookId, x.CategoryId });
+
+ //many-to-many configuration
+ b.HasOne().WithMany(x => x.Categories).HasForeignKey(x => x.BookId).IsRequired();
+ b.HasOne().WithMany().HasForeignKey(x => x.CategoryId).IsRequired();
+
+ b.HasIndex(x => new { x.BookId, x.CategoryId });
+});
+```
+
+Here, firstly we've defined the composite key for our `BookCategory` entity. `BookId` and `CategoryId` are together as composite keys for the `BookCategory` table. Then we've configured the many-to-many relationship between the `Book` and the `Category` tables like in the above code-block.
+
+#### Implementing the `IBookRepository` Interface
+
+After making the relevant configurations for the database integration, we can now implement the `IBookRepository` interface. To do this, create a folder named `Books` in the `BookStore.EntityFrameworkCore` project and inside of this folder, create a class named `EfCoreBookRepository` and update this class with the following code:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Dynamic.Core;
+using System.Threading;
+using System.Threading.Tasks;
+using BookStore.Authors;
+using BookStore.Categories;
+using BookStore.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+using Volo.Abp.Domain.Repositories.EntityFrameworkCore;
+using Volo.Abp.EntityFrameworkCore;
+
+namespace BookStore.Books
+{
+ public class EfCoreBookRepository : EfCoreRepository, IBookRepository
+ {
+ public EfCoreBookRepository(IDbContextProvider dbContextProvider) : base(dbContextProvider)
+ {
+ }
+
+ public async Task> GetListAsync(
+ string sorting,
+ int skipCount,
+ int maxResultCount,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var query = await ApplyFilterAsync();
+
+ return await query
+ .OrderBy(!string.IsNullOrWhiteSpace(sorting) ? sorting : nameof(Book.Name))
+ .PageBy(skipCount, maxResultCount)
+ .ToListAsync(GetCancellationToken(cancellationToken));
+ }
+
+ public async Task GetAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ var query = await ApplyFilterAsync();
+
+ return await query
+ .Where(x => x.Id == id)
+ .FirstOrDefaultAsync(GetCancellationToken(cancellationToken));
+ }
+
+ private async Task> ApplyFilterAsync()
+ {
+ var dbContext = await GetDbContextAsync();
+
+ return (await GetDbSetAsync())
+ .Include(x => x.Categories)
+ .Join(dbContext.Set(), book => book.AuthorId, author => author.Id,
+ (book, author) => new {book, author})
+ .Select(x => new BookWithDetails
+ {
+ Id = x.book.Id,
+ Name = x.book.Name,
+ Price = x.book.Price,
+ PublishDate = x.book.PublishDate,
+ CreationTime = x.book.CreationTime,
+ AuthorName = x.author.Name,
+ CategoryNames = (from bookCategories in x.book.Categories
+ join category in dbContext.Set() on bookCategories.CategoryId equals category.Id
+ select category.Name).ToArray()
+ });
+ }
+
+ public override Task> WithDetailsAsync()
+ {
+ return base.WithDetailsAsync(x => x.Categories);
+ }
+ }
+}
+```
+
+* Here, we've implemented our custom repository methods and returned the book with details (author name and categories).
+
+### Step 4 - (Database Migration)
+
+* We've integrated our entities with the database in the previous step, now we can create a new database migration and apply it to the database. So let's do that.
+
+* Open the `BookStore.EntityFrameworkCore` project in the terminal. And create a new database migration by using the following command:
+
+```bash
+dotnet ef migrations add
+```
+
+* Then, run the `BookStore.DbMigrator` application to create the database.
+
+### Step 5 - (Create Application Services)
+
+* Let's start with defining our DTOs and application service interfaces in the `BookStore.Application.Contracts` layer. We can create a folder-structure like in the image below:
+
+
+
+* We can use the [`CrudAppService`](https://docs.abp.io/en/abp/latest/Application-Services#crud-application-services) base class of the ABP Framework to create application services to **Get**, **Create**, **Update** and **Delete** authors and categories.
+
+* **AuthorDto.cs**
+
+```csharp
+using System;
+using Volo.Abp.Application.Dtos;
+
+namespace BookStore.Authors
+{
+ public class AuthorDto : EntityDto
+ {
+ public string Name { get; set; }
+
+ public DateTime BirthDate { get; set; }
+
+ public string ShortBio { get; set; }
+ }
+}
+```
+
+* **AuthorLookupDto.cs**
+
+```csharp
+using System;
+using Volo.Abp.Application.Dtos;
+
+namespace BookStore.Authors
+{
+ public class AuthorLookupDto : EntityDto
+ {
+ public string Name { get; set; }
+ }
+}
+```
+
+We will use this DTO class as output DTO to get all the authors and list them in a select box in the book creation model. (Like in the image below.)
+
+
+
+* **CreateUpdateAuthorDto.cs**
+
+```csharp
+using System;
+
+namespace BookStore.Authors
+{
+ public class CreateUpdateAuthorDto
+ {
+ public string Name { get; set; }
+
+ public DateTime BirthDate { get; set; }
+
+ public string ShortBio { get; set; }
+ }
+}
+```
+
+* **IAuthorAppService.cs**
+
+```csharp
+using System;
+using Volo.Abp.Application.Dtos;
+using Volo.Abp.Application.Services;
+
+namespace BookStore.Authors
+{
+ public interface IAuthorAppService :
+ ICrudAppService
+ {
+ }
+}
+```
+
+* **BookDto.cs**
+
+```csharp
+using System;
+using Volo.Abp.Application.Dtos;
+
+namespace BookStore.Books
+{
+ public class BookDto : EntityDto
+ {
+ public string AuthorName { get; set; }
+
+ public string Name { get; set; }
+
+ public DateTime PublishDate { get; set; }
+
+ public float Price { get; set; }
+
+ public string[] CategoryNames { get; set; }
+ }
+}
+```
+
+When listing the Book/Books we will retrieve them with all their details (author name and category names).
+
+* **BookGetListInput.cs**
+
+```csharp
+using Volo.Abp.Application.Dtos;
+
+namespace BookStore.Books
+{
+ public class BookGetListInput : PagedAndSortedResultRequestDto
+ {
+ }
+}
+```
+
+* **CreateUpdateBookDto.cs**
+
+```csharp
+using System;
+
+namespace BookStore.Books
+{
+ public class CreateUpdateBookDto
+ {
+ public Guid AuthorId { get; set; }
+
+ public string Name { get; set; }
+
+ public DateTime PublishDate { get; set; }
+
+ public float Price { get; set; }
+
+ public string[] CategoryNames { get; set; }
+ }
+}
+```
+
+To create or update a book we will use this input DTO.
+
+* **IBookAppService.cs**
+
+```csharp
+using System;
+using System.Threading.Tasks;
+using BookStore.Authors;
+using BookStore.Categories;
+using Volo.Abp.Application.Dtos;
+using Volo.Abp.Application.Services;
+
+namespace BookStore.Books
+{
+ public interface IBookAppService : IApplicationService
+ {
+ Task> GetListAsync(BookGetListInput input);
+
+ Task GetAsync(Guid id);
+
+ Task CreateAsync(CreateUpdateBookDto input);
+
+ Task UpdateAsync(Guid id, CreateUpdateBookDto input);
+
+ Task DeleteAsync(Guid id);
+
+ Task> GetAuthorLookupAsync();
+
+ Task> GetCategoryLookupAsync();
+ }
+}
+```
+
+* We will create custom application service method for managing Books instead of using the `CrudAppService`'s methods.
+
+* Also we will create two additional methods and they are `GetAuthorLookupAsync` and `GetCategoryLookupAsync`. We will use these two methods to retrieve all the authors and categories without pagination and list them as a select box item in create/update modals for the Book page.
+(You can see the usage of these two methods in the gif below.)
+
+
+
+* **CategoryDto.cs**
+
+```csharp
+using System;
+using Volo.Abp.Application.Dtos;
+
+namespace BookStore.Categories
+{
+ public class CategoryDto : EntityDto
+ {
+ public string Name { get; set; }
+ }
+}
+```
+
+* **CategoryLookupDto.cs**
+
+```csharp
+using System;
+using Volo.Abp.Application.Dtos;
+
+namespace BookStore.Categories
+{
+ public class CategoryLookupDto : EntityDto
+ {
+ public string Name { get; set; }
+ }
+}
+```
+
+We will use this DTO class as an output DTO to get all categories without pagination and list them in a select box in the book create/update modals.
+
+* **CreateUpdateCategoryDto.cs**
+
+```csharp
+namespace BookStore.Categories
+{
+ public class CreateUpdateCategoryDto
+ {
+ public string Name { get; set; }
+ }
+}
+```
+
+* **ICategoryAppService.cs**
+
+```csharp
+using System;
+using Volo.Abp.Application.Dtos;
+using Volo.Abp.Application.Services;
+
+namespace BookStore.Categories
+{
+ public interface ICategoryAppService :
+ ICrudAppService
+ {
+ }
+}
+```
+
+After creating the DTOs and application service interfaces, now we can define the implementation of those interfaces. So, we can create a folder-structure like in the image below for the `BookStore.Application` layer. Open the application service classes and add the following codes to each of these classes.
+
+
+
+* **AuthorAppService.cs**
+
+```csharp
+using System;
+using Volo.Abp.Application.Dtos;
+using Volo.Abp.Application.Services;
+using Volo.Abp.Domain.Repositories;
+
+namespace BookStore.Authors
+{
+ public class AuthorAppService :
+ CrudAppService,
+ IAuthorAppService
+ {
+ public AuthorAppService(IRepository repository) : base(repository)
+ {
+ }
+ }
+}
+```
+
+* **CategoryAppService.cs**
+
+```csharp
+using System;
+using Volo.Abp.Application.Dtos;
+using Volo.Abp.Application.Services;
+using Volo.Abp.Domain.Repositories;
+
+namespace BookStore.Categories
+{
+ public class CategoryAppService :
+ CrudAppService,
+ ICategoryAppService
+ {
+ public CategoryAppService(IRepository repository) : base(repository)
+ {
+ }
+ }
+}
+```
+
+Thanks to the `CrudAppService`, we don't need to manually implement the crud methods for **AuthorAppService** and **CategoryAppService**.
+
+* **BookAppService.cs**
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using BookStore.Authors;
+using BookStore.Categories;
+using Volo.Abp.Application.Dtos;
+using Volo.Abp.Domain.Repositories;
+
+namespace BookStore.Books
+{
+ public class BookAppService : BookStoreAppService, IBookAppService
+ {
+ private readonly IBookRepository _bookRepository;
+ private readonly BookManager _bookManager;
+ private readonly IRepository _authorRepository;
+ private readonly IRepository _categoryRepository;
+
+ public BookAppService(
+ IBookRepository bookRepository,
+ BookManager bookManager,
+ IRepository authorRepository,
+ IRepository categoryRepository
+ )
+ {
+ _bookRepository = bookRepository;
+ _bookManager = bookManager;
+ _authorRepository = authorRepository;
+ _categoryRepository = categoryRepository;
+ }
+
+ public async Task> GetListAsync(BookGetListInput input)
+ {
+ var books = await _bookRepository.GetListAsync(input.Sorting, input.SkipCount, input.MaxResultCount);
+ var totalCount = await _bookRepository.CountAsync();
+
+ return new PagedResultDto(totalCount, ObjectMapper.Map, List>(books));
+ }
+
+ public async Task GetAsync(Guid id)
+ {
+ var book = await _bookRepository.GetAsync(id);
+
+ return ObjectMapper.Map(book);
+ }
+
+ public async Task CreateAsync(CreateUpdateBookDto input)
+ {
+ await _bookManager.CreateAsync(
+ input.AuthorId,
+ input.Name,
+ input.PublishDate,
+ input.Price,
+ input.CategoryNames
+ );
+ }
+
+ public async Task UpdateAsync(Guid id, CreateUpdateBookDto input)
+ {
+ var book = await _bookRepository.GetAsync(id, includeDetails: true); //return type is: Book (not BookWithDetails) Because, we don't need author name
+
+ await _bookManager.UpdateAsync(
+ book,
+ input.AuthorId,
+ input.Name,
+ input.PublishDate,
+ input.Price,
+ input.CategoryNames
+ );
+ }
+
+ public async Task DeleteAsync(Guid id)
+ {
+ await _bookRepository.DeleteAsync(id);
+ }
+
+ public async Task> GetAuthorLookupAsync()
+ {
+ var authors = await _authorRepository.GetListAsync();
+
+ return new ListResultDto(
+ ObjectMapper.Map, List>(authors)
+ );
+ }
+
+ public async Task> GetCategoryLookupAsync()
+ {
+ var categories = await _categoryRepository.GetListAsync();
+
+ return new ListResultDto(
+ ObjectMapper.Map, List>(categories)
+ );
+ }
+ }
+}
+```
+
+* As you can notice here, we've used our **Domain Service** class named `BookManager` in the **CreateAsync** and **UpdateAsync** methods. (Defined them in step 1)
+
+* As you may remember, in these methods, new categories are added to the book or removed from the sub-collection (**Categories** (`BookCategory`)) according to the relevant category names.
+
+* After implementing the application services, we need to define the mappings for our services to work. So open the `BookStoreApplicationAutoMapperProfile` class and update it with the following code:
+
+```csharp
+using AutoMapper;
+using BookStore.Authors;
+using BookStore.Books;
+using BookStore.Categories;
+
+namespace BookStore
+{
+ public class BookStoreApplicationAutoMapperProfile : Profile
+ {
+ public BookStoreApplicationAutoMapperProfile()
+ {
+ CreateMap();
+ CreateMap();
+ CreateMap();
+
+ CreateMap();
+ CreateMap();
+ CreateMap();
+
+ CreateMap();
+ }
+ }
+}
+
+```
+
+### Step 6 - (UI)
+
+The only thing we need to do is, by using the application service methods that we've defined in the previous step to create the UI.
+
+
+
+> To keep the article shorter, I'll just show you how to create the Book page (with Create/Edit modals). If you want to implement it to other pages, you can access the source code of the application at https://github.com/EngincanV/ABP-Many-to-Many-Relationship-Demo and copy-paste the relevant code-blocks to your application.
+
+#### Book Page
+
+* Create a razor page named **Index.cshtml** under the **Pages/Books** folder of the `BookStore.Web` project and paste the following code to that page.
+
+* **Index.cshtml**
+
+```html
+@page
+@model BookStore.Web.Pages.Books.Index
+
+@section scripts
+{
+
+}
+
+
+
+
+
+ Books
+
+
+
+
+
+
+
+
+
+
+```
+
+In here we've added a **New Book** button and a table with an id named "BooksTable". We'll create an `Index.js` file and by using [datatable.js](https://datatables.net) we will fill the table with our records.
+
+* **Index.js**
+
+```js
+$(function () {
+ var createModal = new abp.ModalManager(abp.appPath + 'Books/CreateModal');
+ var editModal = new abp.ModalManager(abp.appPath + 'Books/EditModal');
+
+ var bookService = bookStore.books.book;
+
+ var dataTable = $('#BooksTable').DataTable(
+ abp.libs.datatables.normalizeConfiguration({
+ serverSide: true,
+ paging: true,
+ order: [[1, "asc"]],
+ searching: false,
+ scrollX: true,
+ ajax: abp.libs.datatables.createAjax(bookService.getList),
+ columnDefs: [
+ {
+ title: 'Actions',
+ rowAction: {
+ items:
+ [
+ {
+ text: 'Edit',
+ action: function (data) {
+ editModal.open({ id: data.record.id });
+ }
+ },
+ {
+ text: 'Delete',
+ confirmMessage: function (data) {
+ return "Are you sure to delete the book '" + data.record.name +"'?";
+ },
+ action: function (data) {
+ bookService
+ .delete(data.record.id)
+ .then(function() {
+ abp.notify.info("Successfully deleted!");
+ dataTable.ajax.reload();
+ });
+ }
+ }
+ ]
+ }
+ },
+ {
+ title: 'Name',
+ data: "name"
+ },
+ {
+ title: 'Publish Date',
+ data: "publishDate",
+ render: function (data) {
+ return luxon
+ .DateTime
+ .fromISO(data, {
+ locale: abp.localization.currentCulture.name
+ }).toLocaleString();
+ }
+ },
+ {
+ title: 'Author Name',
+ data: "authorName"
+ },
+ {
+ title: 'Price',
+ data: "price"
+ },
+ {
+ title: 'Categories',
+ data: "categoryNames",
+ render: function (data) {
+ return data.join(", ");
+ }
+ }
+ ]
+ })
+ );
+
+ createModal.onResult(function () {
+ dataTable.ajax.reload();
+ });
+
+ editModal.onResult(function () {
+ dataTable.ajax.reload();
+ });
+
+ $('#NewBookButton').click(function (e) {
+ e.preventDefault();
+ createModal.open();
+ });
+});
+
+```
+
+> `abp.libs.datatables.normalizeConfiguration` is a helper function defined by the ABP Framework. It simplifies the Datatables configuration by providing conventional default values for missing options.
+
+* Let's examine what we've done in the `Index.js` file.
+
+* Firstly, we've defined our `createModal` and `editModal` modals by using the [ABP Modals](https://docs.abp.io/en/abp/latest/UI/AspNetCore/Modals). Then, we've created the DataTable and fetched our books by using the dynamic JavaScript proxy function (`bookStore.books.book.getList`) (It sends a request to the **GetListAsync** method that we've defined in the `BookAppService` under the hook) and we've shown them in the table with an id named "BooksTable".
+
+* Now let's run the application and navigate to the **/Books** route to see how our Book page looks.
+
+
+
+We need to see a page similar to the image above. Our app is working properly, we can continue developing.
+
+> If you are stuck in any point, you can examine the [source codes](https://github.com/EngincanV/ABP-Many-to-Many-Relationship-Demo).
+
+#### Model Classes and Mapping Configurations
+
+Create a folder named **Models** and add a class named `CategoryViewModel` inside of it. We will use this view modal class to determine which categories are selected or not in our Create/Edit modals.
+
+* **CategoryViewModel.cs**
+
+```csharp
+using System;
+using System.ComponentModel.DataAnnotations;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BookStore.Web.Models
+{
+ public class CategoryViewModel
+ {
+ [HiddenInput]
+ public Guid Id { get; set; }
+
+ public bool IsSelected { get; set; }
+
+ [Required]
+ [HiddenInput]
+ public string Name { get; set; }
+ }
+}
+```
+
+Then, we can open the `BookStoreWebAutoMapperProfile` class and define the required mappings as follows:
+
+```csharp
+using AutoMapper;
+using BookStore.Authors;
+using BookStore.Books;
+using BookStore.Categories;
+using BookStore.Web.Models;
+using BookStore.Web.Pages.Books;
+using Volo.Abp.AutoMapper;
+
+namespace BookStore.Web
+{
+ public class BookStoreWebAutoMapperProfile : Profile
+ {
+ public BookStoreWebAutoMapperProfile()
+ {
+ CreateMap()
+ .Ignore(x => x.IsSelected);
+
+ CreateMap();
+
+ CreateMap();
+
+ CreateMap();
+ }
+ }
+}
+```
+
+#### Create/Edit Modals
+
+After creating our index page for Books and configuring mappings, let's continue with creating the Create/Edit modals for Books.
+
+Create a razor page named **CreateModal.cshtml** (and **CreateModal.cshtml.cs**).
+
+* **CreateModal.cshtml**
+
+```html
+@page
+@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
+@model BookStore.Web.Pages.Books.CreateModal
+
+@{
+ Layout = null;
+}
+
+
+```
+
+* **CreateModal.cshtml.cs**
+
+```csharp
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using BookStore.Books;
+using BookStore.Categories;
+using BookStore.Web.Models;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace BookStore.Web.Pages.Books
+{
+ public class CreateModal : BookStorePageModel
+ {
+ [BindProperty]
+ public CreateUpdateBookDto Book { get; set; }
+
+ [BindProperty]
+ public List Categories { get; set; }
+
+ public List AuthorList { get; set; }
+
+ private readonly IBookAppService _bookAppService;
+
+ public CreateModal(IBookAppService bookAppService)
+ {
+ _bookAppService = bookAppService;
+ }
+
+ public async Task OnGetAsync()
+ {
+ Book = new CreateUpdateBookDto();
+
+ //Get all authors and fill the select list
+ var authorLookup = await _bookAppService.GetAuthorLookupAsync();
+ AuthorList = authorLookup.Items
+ .Select(x => new SelectListItem(x.Name, x.Id.ToString()))
+ .ToList();
+
+ //Get all categories
+ var categoryLookupDto = await _bookAppService.GetCategoryLookupAsync();
+ Categories = ObjectMapper.Map, List>(categoryLookupDto.Items.ToList());
+ }
+
+ public async Task OnPostAsync()
+ {
+ ValidateModel();
+
+ var selectedCategories = Categories.Where(x => x.IsSelected).ToList();
+ if (selectedCategories.Any())
+ {
+ var categoryNames = selectedCategories.Select(x => x.Name).ToArray();
+ Book.CategoryNames = categoryNames;
+ }
+
+ await _bookAppService.CreateAsync(Book);
+ return NoContent();
+ }
+ }
+}
+```
+
+Here, we've got all categories and authors inside of the `OnGetAsync` method. And use them inside of the create modal to list them so the user can choose when creating a new book.
+
+
+
+* When the user submits the form, the `OnPostAsync` method runs. Inside of this method, we get the selected categories and pass them into the **CategoryNames** array of the Book object and call the `IBookAppService.CreateAsync` method to create a new book.
+
+Create a razor page named **EditModal.cshtml** (and **EditModal.cshtml.cs**).
+
+* **EditModal.cshtml**
+
+```html
+@page
+@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
+@model BookStore.Web.Pages.Books.EditModal
+
+@{
+ Layout = null;
+}
+
+
+```
+
+* **EditModal.cshtml.cs**
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using BookStore.Books;
+using BookStore.Categories;
+using BookStore.Web.Models;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace BookStore.Web.Pages.Books
+{
+ public class EditModal : BookStorePageModel
+ {
+ [HiddenInput]
+ [BindProperty(SupportsGet = true)]
+ public Guid Id { get; set; }
+
+ [BindProperty]
+ public CreateUpdateBookDto EditingBook { get; set; }
+
+ [BindProperty]
+ public List Categories { get; set; }
+
+ public List AuthorList { get; set; }
+
+ private readonly IBookAppService _bookAppService;
+
+ public EditModal(IBookAppService bookAppService)
+ {
+ _bookAppService = bookAppService;
+ }
+
+ public async Task OnGetAsync()
+ {
+ var bookDto = await _bookAppService.GetAsync(Id);
+ EditingBook = ObjectMapper.Map(bookDto);
+
+ //get all authors
+ var authorLookup = await _bookAppService.GetAuthorLookupAsync();
+ AuthorList = authorLookup.Items
+ .Select(x => new SelectListItem(x.Name, x.Id.ToString()))
+ .ToList();
+
+ //get all categories
+ var categoryLookupDto = await _bookAppService.GetCategoryLookupAsync();
+ Categories = ObjectMapper.Map, List>(categoryLookupDto.Items.ToList());
+
+ //mark as Selected for Categories in the book
+ if (EditingBook.CategoryNames != null && EditingBook.CategoryNames.Any())
+ {
+ Categories
+ .Where(x => EditingBook.CategoryNames.Contains(x.Name))
+ .ToList()
+ .ForEach(x => x.IsSelected = true);
+ }
+ }
+
+ public async Task OnPostAsync()
+ {
+ ValidateModel();
+
+ var selectedCategories = Categories.Where(x => x.IsSelected).ToList();
+ if (selectedCategories.Any())
+ {
+ var categoryNames = selectedCategories.Select(x => x.Name).ToArray();
+ EditingBook.CategoryNames = categoryNames;
+ }
+
+ await _bookAppService.UpdateAsync(Id, EditingBook);
+ return NoContent();
+ }
+ }
+}
+```
+
+* As in the `CreateModal.cshtml.cs`, we've got all categories and authors inside of the `OnGetAsync` method. And also we get the book by id and mark the selected categories properties' as `IsSelected = true`.
+
+* When the user updates the inputs and submits the form, the `OnPostAsync` method runs. Inside of this method, we get the selected categories and pass them into the **CategoryNames** array of the Book object and call the `IBookAppService.UpdateAsync` method to update the book.
+
+
+
+### Conclusion
+
+In this article, I've tried to explain how to create a many-to-many relationship by using the ABP framework. (by following DDD principles)
+
+Thanks for reading this article, I hope it was helpful.
diff --git a/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/application-contracts-folder-structure.png b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/application-contracts-folder-structure.png
new file mode 100644
index 0000000000..c0ed574489
Binary files /dev/null and b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/application-contracts-folder-structure.png differ
diff --git a/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/application-final-demo.gif b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/application-final-demo.gif
new file mode 100644
index 0000000000..61289a8ffb
Binary files /dev/null and b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/application-final-demo.gif differ
diff --git a/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/application-folder-structure.png b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/application-folder-structure.png
new file mode 100644
index 0000000000..0d37a552d4
Binary files /dev/null and b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/application-folder-structure.png differ
diff --git a/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/book-create.gif b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/book-create.gif
new file mode 100644
index 0000000000..0e534802f2
Binary files /dev/null and b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/book-create.gif differ
diff --git a/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/book-creation-modal.png b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/book-creation-modal.png
new file mode 100644
index 0000000000..8661e016cd
Binary files /dev/null and b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/book-creation-modal.png differ
diff --git a/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/book-update-modal.png b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/book-update-modal.png
new file mode 100644
index 0000000000..d9d312399a
Binary files /dev/null and b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/book-update-modal.png differ
diff --git a/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/demo.png b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/demo.png
new file mode 100644
index 0000000000..9b954bd7d6
Binary files /dev/null and b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/demo.png differ
diff --git a/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/domain-file-structure.png b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/domain-file-structure.png
new file mode 100644
index 0000000000..6a5405dafe
Binary files /dev/null and b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/domain-file-structure.png differ
diff --git a/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/domain-shared-file-structure.png b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/domain-shared-file-structure.png
new file mode 100644
index 0000000000..22a8de6599
Binary files /dev/null and b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/domain-shared-file-structure.png differ
diff --git a/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/er-diagram.png b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/er-diagram.png
new file mode 100644
index 0000000000..5948366e0e
Binary files /dev/null and b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/er-diagram.png differ
diff --git a/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/web-folder-structure.png b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/web-folder-structure.png
new file mode 100644
index 0000000000..ed4a63a06c
Binary files /dev/null and b/docs/en/Community-Articles/2021-10-31-Many-to-Many-Relationship-with-ABP-and-EF-Core/web-folder-structure.png differ
diff --git a/docs/en/Customizing-Application-Modules-Extending-Entities.md b/docs/en/Customizing-Application-Modules-Extending-Entities.md
index c2bcd314d4..e1cb02e849 100644
--- a/docs/en/Customizing-Application-Modules-Extending-Entities.md
+++ b/docs/en/Customizing-Application-Modules-Extending-Entities.md
@@ -63,8 +63,6 @@ You can then use the same extra properties system defined in the previous sectio
Another approach can be **creating your own entity** mapped to **the same database table** (or collection for a MongoDB database).
-`AppUser` entity in the [application startup template](Startup-Templates/Application.md) already implements this approach. [EF Core Migrations document](Entity-Framework-Core-Migrations.md) describes how to implement it and manage **EF Core database migrations** in such a case. It is also possible for MongoDB, while this time you won't deal with the database migration problems.
-
## Creating a New Entity with Its Own Database Table/Collection
Mapping your entity to an **existing table** of a depended module has a few disadvantages;
diff --git a/framework/src/Volo.Abp.AspNetCore.Components.Server/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerModule.cs b/framework/src/Volo.Abp.AspNetCore.Components.Server/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerModule.cs
index 91b672b073..8077de5c8a 100644
--- a/framework/src/Volo.Abp.AspNetCore.Components.Server/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerModule.cs
+++ b/framework/src/Volo.Abp.AspNetCore.Components.Server/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerModule.cs
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Hosting;
using Volo.Abp.AspNetCore.Auditing;
using Volo.Abp.AspNetCore.Components.Web;
using Volo.Abp.AspNetCore.Mvc;
@@ -25,7 +26,13 @@ namespace Volo.Abp.AspNetCore.Components.Server
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
- var serverSideBlazorBuilder = context.Services.AddServerSideBlazor();
+ var serverSideBlazorBuilder = context.Services.AddServerSideBlazor(options =>
+ {
+ if (context.Services.GetHostingEnvironment().IsDevelopment())
+ {
+ options.DetailedErrors = true;
+ }
+ });
context.Services.ExecutePreConfiguredActions(serverSideBlazorBuilder);
Configure(options =>
diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/NuGet/CommercialPackages.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/NuGet/CommercialPackages.cs
index 3e343b7002..a74ac4e408 100644
--- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/NuGet/CommercialPackages.cs
+++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/NuGet/CommercialPackages.cs
@@ -6,7 +6,7 @@ namespace Volo.Abp.Cli.NuGet
{
internal static class CommercialPackages
{
- private static readonly HashSet _packages = new()
+ private static readonly HashSet Packages = new()
{
"volo.abp.suite"
//other PRO packages can be added to this list...
@@ -14,7 +14,7 @@ namespace Volo.Abp.Cli.NuGet
public static bool IsCommercial(string packageId)
{
- return _packages.Contains(packageId.ToLowerInvariant());
+ return Packages.Contains(packageId.ToLowerInvariant());
}
}
}