diff --git a/BookLibrary.Tests/IntegrationTests/AuthorsControllerTests.cs b/BookLibrary.Tests/IntegrationTests/AuthorsControllerTests.cs index 291e62b..93b32f3 100644 --- a/BookLibrary.Tests/IntegrationTests/AuthorsControllerTests.cs +++ b/BookLibrary.Tests/IntegrationTests/AuthorsControllerTests.cs @@ -1,6 +1,93 @@ -namespace BookLibrary.Tests.IntegrationTests; +using System.Net.Http.Json; +using BookLibrary.Data.Responses; +using BookLibrary.Models; -public class AuthorsControllerTests +namespace BookLibrary.Tests.IntegrationTests; + +[Collection("TestContainers")] +[Trait("Category", "Integration")] +public class AuthorsControllerTests(BookLibraryTestContainersFactory factory) { + private readonly HttpClient _client = factory.CreateClient(); + + // ======================================================================== + // Тесты ListAuthorsController + // ======================================================================== + + [Fact] + public async Task GetAuthors_ReturnsListOfAuthors() + { + // Act + // Выполняем GET-запрос к эндпоинту с параметрами сортировки и пагинации + var response = await _client.GetAsync("/api/authors?sortBy=Name&PageSize=5"); + + // Assert + // Проверяем, что ответ успешный + response.EnsureSuccessStatusCode(); + + // Десериализуем JSON-ответ в общий DTO-обертку + var apiResponse = await response.Content.ReadFromJsonAsync>>(); + + Assert.NotNull(apiResponse); + Assert.True(apiResponse.Success); + Assert.NotNull(apiResponse.Data); + Assert.NotEmpty(apiResponse.Data); + + // Проверяем сообщение об успехе + Assert.Equal("Operation completed successfully", apiResponse.Message); + + // Проверяем, что данные отсортированы по имени по возрастанию + for (int i = 0; i < apiResponse.Data.Count - 1; i++) + { + Assert.True(string.Compare(apiResponse.Data[i].Name, apiResponse.Data[i + 1].Name, StringComparison.Ordinal) <= 0); + } + + // Проверяем, что размер страницы не превышает запрошенный + Assert.True(apiResponse.Data.Count <= 5); + } + + // ======================================================================== + // Тесты AuthorBooksController + // ======================================================================== + + [Fact] + public async Task GetAuthorBooks_ReturnsBooks_WithRelatedData() + { + // Arrange + // Сначала получаем список всех авторов, чтобы выбрать существующий ID + var listResponse = await _client.GetAsync("/api/authors"); + listResponse.EnsureSuccessStatusCode(); + var listApiResponse = await listResponse.Content.ReadFromJsonAsync>>(); + Assert.NotNull(listApiResponse?.Data); + Assert.NotEmpty(listApiResponse.Data); -} \ No newline at end of file + var authorId = listApiResponse.Data.First().Id; // Берем ID первого автора + + // Act + // Выполняем GET-запрос с флагами для подгрузки связанных данных + var response = await _client.GetAsync($"/api/authors/{authorId}/books?withAuthors=true&withCategories=true"); + + // Assert + response.EnsureSuccessStatusCode(); + var apiResponse = await response.Content.ReadFromJsonAsync>>(); + + Assert.NotNull(apiResponse); + Assert.True(apiResponse.Success); + Assert.NotNull(apiResponse.Data); + + // Проверяем, что хотя бы одна книга была найдена + Assert.True(apiResponse.Data.Count > 0); + + // Проверяем, что для каждой книги загружены связанные Author и Category + foreach (var book in apiResponse.Data) + { + Assert.NotNull(book.Author); + Assert.NotNull(book.Category); + + // Проверяем, что идентификаторы совпадают + Assert.Equal(authorId, book.Author.Id); + Assert.True(!string.IsNullOrEmpty(book.Title)); + Assert.True(book.PublicationYear > 1000); + } + } +} diff --git a/BookLibrary.Tests/IntegrationTests/BookLibraryTestContainersFactory.cs b/BookLibrary.Tests/IntegrationTests/BookLibraryTestContainersFactory.cs new file mode 100644 index 0000000..1a8eaa7 --- /dev/null +++ b/BookLibrary.Tests/IntegrationTests/BookLibraryTestContainersFactory.cs @@ -0,0 +1,112 @@ +using System.Text; +using BookLibrary.Database.Context; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Testcontainers.PostgreSql; // Работает ТОЛЬКО после установки Testcontainers.PostgreSql + +namespace BookLibrary.Tests.IntegrationTests; + +public class BookLibraryTestContainersFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder("postgres:15-alpine") + .WithDatabase("booklibrary_integration_test") + .WithUsername("testuser") + .WithPassword("SecureTestPass123!") + .WithCleanUp(true) + .Build(); + + // Для отладки: публичное свойство строки подключения + public string TestConnectionString => _dbContainer.GetConnectionString(); + + public async Task InitializeAsync() + { + // 🔤 Исправляем кодировку КОНСОЛИ + Console.OutputEncoding = Encoding.UTF8; + Console.InputEncoding = Encoding.UTF8; + + // 🔇 Отключаем логи Testcontainers + Environment.SetEnvironmentVariable("TESTCONTAINERS_LOGGING_DISABLED", "true"); + + // 🔒 КРИТИЧЕСКАЯ ПРОВЕРКА: Защита от случайного подключения к боевой БД + if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")?.ToLower() == "production") + { + throw new InvalidOperationException( + "❌ ЗАПРЕЩЕНО запускать интеграционные тесты в окружении Production! " + + "Установите переменную окружения ASPNETCORE_ENVIRONMENT=Test"); + } + + await _dbContainer.StartAsync(); + + Console.WriteLine($"✅ Тестовый контейнер запущен. ConnectionString: {TestConnectionString}"); + + // Применяем миграции ТОЛЬКО к тестовой БД в контейнере + using var scope = Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Дополнительная проверка: убеждаемся, что подключены к контейнеру + var actualConnection = dbContext.Database.GetDbConnection().ConnectionString; + if (!actualConnection.Contains("booklibrary_integration_test")) + { + throw new InvalidOperationException( + $"❌ КРИТИЧЕСКАЯ ОШИБКА: Приложение подключено к НЕПРАВИЛЬНОЙ БД!\n" + + $"Подключено к: {actualConnection}\n" + + $"Ожидалось подключение к: {TestConnectionString}"); + } + + await dbContext.Database.MigrateAsync(); + Console.WriteLine("✅ Миграции применены к тестовой БД"); + } + + public new async Task DisposeAsync() + { + Console.WriteLine("🧹 Очистка: остановка и удаление контейнера..."); + await _dbContainer.DisposeAsync(); + Console.WriteLine("✅ Контейнер удален. БД полностью уничтожена."); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // 🔒 ШАГ 1: Явно устанавливаем окружение ТЕСТОВОЕ + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Test"); + + // 🔒 ШАГ 2: Переопределяем строку подключения ЧЕРЕЗ ПЕРЕМЕННУЮ ОКРУЖЕНИЯ (высший приоритет!) + Environment.SetEnvironmentVariable("ConnectionStrings__DefaultConnection", _dbContainer.GetConnectionString()); + + // 🔒 ШАГ 3: Дополнительно переопределяем через конфигурацию + builder.ConfigureAppConfiguration((context, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = _dbContainer.GetConnectionString() + }!); + }); + + // 🔒 ШАГ 4: Заменяем контекст БД на тот, что использует строку из контейнера + builder.ConfigureServices(services => + { + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + + if (descriptor != null) + services.Remove(descriptor); + + services.AddDbContext(options => + options.UseNpgsql(_dbContainer.GetConnectionString())); + }); + + // 🔒 ШАГ 5: 🔇 Отключаем логи приложения (только Critical) + builder.ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddFilter("Microsoft", LogLevel.Critical); + logging.AddFilter("System", LogLevel.Critical); + logging.AddFilter("Testcontainers", LogLevel.None); + logging.AddConsole(); + logging.SetMinimumLevel(LogLevel.Critical); + }); + } +} diff --git a/BookLibrary.Tests/IntegrationTests/BooksControllerTests.cs b/BookLibrary.Tests/IntegrationTests/BooksControllerTests.cs index 6f3f777..8b14496 100644 --- a/BookLibrary.Tests/IntegrationTests/BooksControllerTests.cs +++ b/BookLibrary.Tests/IntegrationTests/BooksControllerTests.cs @@ -1,217 +1,259 @@ using System.Net; using System.Net.Http.Json; +using BookLibrary.Data.Requests.Book; using BookLibrary.Data.Responses; using BookLibrary.Models; -using Xunit; namespace BookLibrary.Tests.IntegrationTests; +[Collection("TestContainers")] [Trait("Category", "Integration")] -public class BooksControllerTests(BookLibraryWebApplicationFactory factory) : IClassFixture +public class BooksControllerTests(BookLibraryTestContainersFactory factory) { private readonly HttpClient _client = factory.CreateClient(); + + // ======================================================================== + // Тесты ListBooksController + // ======================================================================== [Fact] public async Task GetBooks_ReturnsListOfBooks() { - // Act var response = await _client.GetAsync("/api/books"); - - // Assert response.EnsureSuccessStatusCode(); var apiResponse = await response.Content.ReadFromJsonAsync>>(); + var ids = apiResponse?.Data?.Select(b => b.Id).ToList(); + Assert.NotNull(apiResponse); Assert.True(apiResponse.Success); Assert.NotNull(apiResponse.Data); - Assert.Equal("Operation completed successfully", apiResponse.Message); + Assert.NotEmpty(apiResponse.Data); + + // Проверяем, что данные по умолчанию отсортированы по id по возрастанию + var sortedIds = ids?.OrderBy(id => id).ToList(); + Assert.Equal(sortedIds, ids); } [Fact] - public async Task GetBookById_ExistingId_ReturnsBook() + public async Task GetBooks_WithWithAuthorsAndWithCategories_ReturnsBooksIncludingRelatedData() { - // Arrange - var listResponse = await _client.GetAsync("/api/books"); - listResponse.EnsureSuccessStatusCode(); - - var listApiResponse = await listResponse.Content.ReadFromJsonAsync>>(); - Assert.NotNull(listApiResponse?.Data); - Assert.NotEmpty(listApiResponse.Data); - - var existingBookId = listApiResponse.Data.First().Id; - - // Act - var response = await _client.GetAsync($"/api/books/{existingBookId}"); - - // Assert + var response = await _client.GetAsync("/api/books?withAuthors=true&withCategories=true"); response.EnsureSuccessStatusCode(); - var apiResponse = await response.Content.ReadFromJsonAsync>(); + + var apiResponse = await response.Content.ReadFromJsonAsync>>(); Assert.NotNull(apiResponse); Assert.True(apiResponse.Success); Assert.NotNull(apiResponse.Data); - Assert.Equal(existingBookId, apiResponse.Data.Id); + Assert.NotEmpty(apiResponse.Data); + + // Проверка наличия связанных данных + foreach (var book in apiResponse.Data) + { + Assert.NotNull(book.Author); + Assert.NotNull(book.Category); + Assert.True(book.Author.Id > 0); + Assert.True(book.Category.Id > 0); + Assert.False(string.IsNullOrWhiteSpace(book.Author.Name)); + Assert.False(string.IsNullOrWhiteSpace(book.Category.Name)); + } } [Fact] - public async Task GetBookById_NonExistingId_ReturnsNotFound() + public async Task GetBooks_WithSortByTitleAsc_ReturnsSortedBooks() { - // Arrange - var nonExistingBookId = 99999; - - // Act - var response = await _client.GetAsync($"/api/books/{nonExistingBookId}"); + var response = await _client.GetAsync("/api/books?sortBy=title"); + response.EnsureSuccessStatusCode(); - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var apiResponse = await response.Content.ReadFromJsonAsync>>(); + Assert.NotNull(apiResponse); + Assert.True(apiResponse.Success); + Assert.NotNull(apiResponse.Data); + Assert.NotEmpty(apiResponse.Data); + + // Проверка сортировки + var titles = apiResponse.Data.Select(b => b.Title).ToList(); + var sortedTitles = titles.OrderBy(t => t).ToList(); + Assert.Equal(sortedTitles, titles); } [Fact] - public async Task CreateBook_ValidBook_ReturnsCreatedBook() + public async Task GetBooks_WithSortByPublicationYearDesc_ReturnsSortedBooks() { - // Arrange - обязательно добавляем обязательные поля для прохождения валидации - var now = DateTime.UtcNow; - var newBook = new Book - { - Title = "Test Book Integration", - Author = "Test Author", - ISBN = "123-45-6789-012-3", - PublicationYear = 2024, - Genre = "Fiction", - IsAvailable = true, - CreatedAt = now, - UpdatedAt = now - }; - - // Act - var response = await _client.PostAsJsonAsync("/api/books", newBook); - - // Assert - Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var response = await _client.GetAsync("/api/books?sortByDesc=publicationYear"); + response.EnsureSuccessStatusCode(); - var apiResponse = await response.Content.ReadFromJsonAsync>(); + var apiResponse = await response.Content.ReadFromJsonAsync>>(); Assert.NotNull(apiResponse); Assert.True(apiResponse.Success); Assert.NotNull(apiResponse.Data); - Assert.Equal("Resource created successfully", apiResponse.Message); - Assert.Equal("Test Book Integration", apiResponse.Data.Title); - } + Assert.NotEmpty(apiResponse.Data); + var years = apiResponse.Data.Select(b => b.PublicationYear).ToList(); + var sortedYears = years.OrderByDescending(y => y).ToList(); + Assert.Equal(sortedYears, years); + } + [Fact] - public async Task CreateBook_InvalidData_ReturnsUnprocessableEntity() + public async Task GetBooks_ReturnsListOfBooks_WithPagination() { - // Arrange - пустое название вызовет ошибку валидации - var invalidBook = new Book - { - Title = "", - Author = "Author", - ISBN = "123", - PublicationYear = 100, - Genre = "Fiction", - IsAvailable = true, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - // Act - var response = await _client.PostAsJsonAsync("/api/books", invalidBook); + // Выполняем GET-запрос к эндпоинту с параметрами пагинации + var response = await _client.GetAsync("/api/books?page=1&PageSize=5"); // Assert - Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + // Проверяем, что ответ успешный + response.EnsureSuccessStatusCode(); + + // Десериализуем JSON-ответ в общий DTO-обертку + var apiResponse = await response.Content.ReadFromJsonAsync>>(); + + Assert.NotNull(apiResponse); + Assert.True(apiResponse.Success); + Assert.NotNull(apiResponse.Data); + Assert.NotEmpty(apiResponse.Data); + + // Проверяем сообщение об успехе + Assert.Equal("Operation completed successfully", apiResponse.Message); + + // Проверяем, что размер страницы не превышает запрошенный + Assert.True(apiResponse.Data.Count <= 5); } + // ======================================================================== + // Тесты ShowBooksController + // ======================================================================== + [Fact] - public async Task UpdateBook_ExistingId_ReturnsUpdatedBook() + public async Task GetBookById_ExistingId_ReturnsBook() { - // Arrange var listResponse = await _client.GetAsync("/api/books"); listResponse.EnsureSuccessStatusCode(); - var listApiResponse = await listResponse.Content.ReadFromJsonAsync>>(); - Assert.NotNull(listApiResponse?.Data); - Assert.NotEmpty(listApiResponse.Data); + var list = await listResponse.Content.ReadFromJsonAsync>>(); + Assert.NotNull(list); + Assert.NotNull(list.Data); - var existingBook = listApiResponse.Data.First(); + var books = list.Data; + Assert.NotEmpty(books); - // Обязательно добавляем временные метки - var now = DateTime.UtcNow; - var updatedBook = new Book + var bookId = books.First().Id; + var response = await _client.GetAsync($"/api/books/{bookId}"); + response.EnsureSuccessStatusCode(); + + var apiResponse = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(apiResponse); + Assert.NotNull(apiResponse.Data); + Assert.Equal(bookId, apiResponse.Data.Id); + Assert.False(string.IsNullOrWhiteSpace(apiResponse.Data.Title)); + } + + // ======================================================================== + // Тесты StoreBooksController + // ======================================================================== + + + [Fact] + public async Task CreateBook_ValidRequest_ReturnsCreatedBook() + { + var request = new CreateBookRequest { - Id = existingBook.Id, - Title = "Updated Title", - Author = "Updated Author", - ISBN = "098-76-5432-109-8", - PublicationYear = 2025, - Genre = "Non-Fiction", - IsAvailable = false, - CreatedAt = existingBook.CreatedAt, - UpdatedAt = now + Title = "Integration Test Book", + AuthorId = 1, // Из сидера + CategoryId = 1, // Из сидера + ISBN = "978-51-2345-678-9", + PublicationYear = 2026, + Genre = "Тест", + IsAvailable = true }; + + var response = await _client.PostAsJsonAsync("/api/books", request); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); - // Act - var response = await _client.PutAsJsonAsync($"/api/books/{existingBook.Id}", updatedBook); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); var apiResponse = await response.Content.ReadFromJsonAsync>(); Assert.NotNull(apiResponse); - Assert.True(apiResponse.Success); Assert.NotNull(apiResponse.Data); - Assert.Equal("Updated Title", apiResponse.Data.Title); + Assert.True(apiResponse.Success); + Assert.Equal("Integration Test Book", apiResponse.Data.Title); + Assert.Equal(2026, apiResponse.Data.PublicationYear); } + // ======================================================================== + // Тесты UpdateBooksController + // ======================================================================== + + [Fact] - public async Task DeleteBook_ExistingId_ReturnsSuccess() + public async Task UpdateBook_ExistingId_ReturnsUpdatedBook() { - // Arrange - создаем книгу с обязательными полями - var now = DateTime.UtcNow; - var newBook = new Book + // Создаём книгу для обновления + var createReq = new CreateBookRequest { - Title = "Book to Delete", - Author = "Author", - ISBN = "123-45-6789-012-3", - PublicationYear = 2024, - Genre = "Fiction", - IsAvailable = true, - CreatedAt = now, - UpdatedAt = now + Title = "Book to Update", + AuthorId = 1, + CategoryId = 1, + ISBN = "978-59-8765-432-1", + PublicationYear = 2020, + Genre = "Old Genre", + IsAvailable = true }; + var createResp = await _client.PostAsJsonAsync("/api/books", createReq); + var created = await createResp.Content.ReadFromJsonAsync>(); + var bookId = created?.Data?.Id; - var createResponse = await _client.PostAsJsonAsync("/api/books", newBook); - Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); - - var createApiResponse = await createResponse.Content.ReadFromJsonAsync>(); - Assert.NotNull(createApiResponse?.Data); - var bookId = createApiResponse.Data.Id; - - // Act - var response = await _client.DeleteAsync($"/api/books/{bookId}"); - - // Assert - response.EnsureSuccessStatusCode(); - - var deleteApiResponse = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(deleteApiResponse); - Assert.True(deleteApiResponse.Success); + // Обновляем + var updateReq = new UpdateBookRequest + { + Title = "Updated Title", + AuthorId = 1, + CategoryId = 1, + ISBN = "978-59-8765-432-1", // Сохраняем уникальный ISBN + PublicationYear = 2025, + Genre = "New Genre", + IsAvailable = false + }; + var updateResp = await _client.PutAsJsonAsync($"/api/books/{bookId}", updateReq); + Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode); - // Проверяем, что книга удалена - var getResponse = await _client.GetAsync($"/api/books/{bookId}"); - Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + var updated = await updateResp.Content.ReadFromJsonAsync>(); + Assert.Equal("Updated Title", updated?.Data?.Title); + Assert.Equal("New Genre", updated?.Data?.Genre); + Assert.Equal(2025, updated?.Data?.PublicationYear); + Assert.False(updated?.Data?.IsAvailable); } + + // ======================================================================== + // Тесты DeleteBooksController + // ======================================================================== + [Fact] - public async Task DeleteBook_NonExistingId_ReturnsBadRequestWithFalseResult() + public async Task DeleteBook_ExistingId_ReturnsSuccessAndRemovesBook() { - // Arrange - var nonExistingBookId = 99999; - - // Act - var response = await _client.DeleteAsync($"/api/books/{nonExistingBookId}"); - - // Assert - текущее поведение системы: возвращает BadRequest с { result: false } - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + // Создаём книгу для удаления + var createReq = new CreateBookRequest + { + Title = "Book to Delete", + AuthorId = 1, + CategoryId = 1, + ISBN = "978-51-1112-222-3", + PublicationYear = 2024, + Genre = "ToDelete", + IsAvailable = true + }; + var createResp = await _client.PostAsJsonAsync("/api/books", createReq); + var created = await createResp.Content.ReadFromJsonAsync>(); + var bookId = created?.Data?.Id; + + // Удаляем + var deleteResp = await _client.DeleteAsync($"/api/books/{bookId}"); + deleteResp.EnsureSuccessStatusCode(); - var apiResponse = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(apiResponse); - Assert.False(apiResponse.Success); + var deleteResult = await deleteResp.Content.ReadFromJsonAsync>(); + Assert.True(deleteResult!.Success); + + // Проверяем отсутствие + var getResp = await _client.GetAsync($"/api/books/{bookId}"); + Assert.Equal(HttpStatusCode.NotFound, getResp.StatusCode); } } diff --git a/BookLibrary.Tests/IntegrationTests/CategoriesControllerTests.cs b/BookLibrary.Tests/IntegrationTests/CategoriesControllerTests.cs new file mode 100644 index 0000000..8b2b3be --- /dev/null +++ b/BookLibrary.Tests/IntegrationTests/CategoriesControllerTests.cs @@ -0,0 +1,68 @@ +using System.Net.Http.Json; +using BookLibrary.Data.Responses; +using BookLibrary.Data.Responses.Categories; + +namespace BookLibrary.Tests.IntegrationTests; + +[Collection("TestContainers")] +[Trait("Category", "Integration")] +public class CategoriesControllerTests(BookLibraryTestContainersFactory factory) +{ + private readonly HttpClient _client = factory.CreateClient(); + + [Fact] + public async Task GetCategoryStatistics_ReturnsStats_WithoutBooks() + { + // Act + var response = await _client.GetAsync("/api/categories/statistics"); // Без флага + response.EnsureSuccessStatusCode(); + + var apiResponse = await response.Content.ReadFromJsonAsync>>(); + + // Assert + Assert.NotNull(apiResponse); + Assert.True(apiResponse.Success); + Assert.NotNull(apiResponse.Data); + Assert.NotEmpty(apiResponse.Data); + + // Проверяем, что поле Books в каждом элементе статистики равно null + foreach (var stat in apiResponse.Data) + { + Assert.NotNull(stat.CategoryName); + Assert.True(stat.BookCount >= 0); + Assert.True(stat.AveragePublicationYear > 0); + Assert.Null(stat.Books); // Основная проверка: связи не должны быть загружены + } + } + + [Fact] + public async Task GetCategoryStatistics_ReturnsStats_WithBooks() + { + // Act + var response = await _client.GetAsync("/api/categories/statistics?withBooks=true"); // С флагом + response.EnsureSuccessStatusCode(); + + var apiResponse = await response.Content.ReadFromJsonAsync>>(); + + // Assert + Assert.NotNull(apiResponse); + Assert.True(apiResponse.Success); + Assert.NotNull(apiResponse.Data); + Assert.NotEmpty(apiResponse.Data); + + // Проверяем, что поле Books теперь заполнено + foreach (var stat in apiResponse.Data) + { + Assert.NotNull(stat.Books); + Assert.NotEmpty(stat.Books); + + // Проверяем структуру данных внутри списка книг + foreach (var book in stat.Books) + { + Assert.True(book.Id > 0); + Assert.True(!string.IsNullOrEmpty(book.Title)); + Assert.True(book.PublicationYear > 0); + } + } + } +} diff --git a/BookLibrary.Tests/IntegrationTests/TestContainersCollection.cs b/BookLibrary.Tests/IntegrationTests/TestContainersCollection.cs new file mode 100644 index 0000000..63f8c3f --- /dev/null +++ b/BookLibrary.Tests/IntegrationTests/TestContainersCollection.cs @@ -0,0 +1,6 @@ +namespace BookLibrary.Tests.IntegrationTests; + +[CollectionDefinition("TestContainers", DisableParallelization = true)] +public class TestContainersCollection : ICollectionFixture { } + + diff --git a/BookLibrary.Tests/UnitTests/Services/AuthorServiceTests.cs b/BookLibrary.Tests/UnitTests/Services/AuthorServiceTests.cs new file mode 100644 index 0000000..e33d782 --- /dev/null +++ b/BookLibrary.Tests/UnitTests/Services/AuthorServiceTests.cs @@ -0,0 +1,52 @@ +using BookLibrary.Contracts.Repositories; +using BookLibrary.Contracts.Services; +using BookLibrary.Models; +using BookLibrary.Services; +using Moq; +using Xunit; + +namespace BookLibrary.Tests.UnitTests.Services; + +[Trait("Category", "Unit")] +public class AuthorServiceTests +{ + private readonly Mock _authorRepositoryMock; + private readonly IAuthorService _authorService; + + public AuthorServiceTests() + { + _authorRepositoryMock = new Mock(); + _authorService = new AuthorService(_authorRepositoryMock.Object); + } + + [Fact] + public async Task GetAuthorByIdAsync_ExistingId_ReturnsAuthor() + { + // Arrange + var author = new Author + { + Id = 1, + Name = "Агата Кристи", + Country = "Великобритания", + BirthYear = 1890, + Biography = "Британская писательница", + IsActive = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + _authorRepositoryMock + .Setup(r => r.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(author); + + // Act + var result = await _authorService.GetAuthorByIdAsync(1); + + // Assert + Assert.NotNull(result); + Assert.Equal(1, result.Id); + Assert.Equal("Агата Кристи", result.Name); + Assert.Equal("Великобритания", result.Country); + Assert.True(result.IsActive); + } +} diff --git a/BookLibrary.Tests/UnitTests/Services/BookServiceTests.cs b/BookLibrary.Tests/UnitTests/Services/BookServiceTests.cs index 276b119..fe2d9bf 100644 --- a/BookLibrary.Tests/UnitTests/Services/BookServiceTests.cs +++ b/BookLibrary.Tests/UnitTests/Services/BookServiceTests.cs @@ -1,10 +1,11 @@ using BookLibrary.Contracts.Repositories; using BookLibrary.Contracts.Services; +using BookLibrary.Data.Requests.Book; using BookLibrary.Models; using BookLibrary.Services; using BookLibrary.Specifications.Book; using Moq; -using Xunit.Abstractions; +using Xunit; namespace BookLibrary.Tests.UnitTests.Services; @@ -14,34 +15,45 @@ public class BookServiceTests private readonly Mock _bookRepositoryMock; private readonly IBookService _bookService; - public BookServiceTests(ITestOutputHelper output) + public BookServiceTests() { _bookRepositoryMock = new Mock(); _bookService = new BookService(_bookRepositoryMock.Object); } [Fact] - public async Task GetAllBooksAsync_ReturnsBooks() + public async Task GetAllBooksAsync_WithFilterParams_CallsRepositoryCorrectly() { // Arrange - var books = new List { new() { Id = 1, Title = "Test Book" } }; + var filterParams = new BookFilterParams + { + SortBy = "title", + + WithAuthors = true, + WithCategories = true + }; + var books = new List { new() { Id = 1, Title = "Test Book", AuthorId = 1, CategoryId = 1 } }; + _bookRepositoryMock - .Setup(r => r.GetAllAsync(It.IsAny(), It.IsAny())) + .Setup(r => r.GetAllAsync(It.Is(fp => + fp.SortBy == "title" && + fp.WithAuthors == true && + fp.WithCategories == true), It.IsAny())) .ReturnsAsync(books); // Act - var result = await _bookService.GetAllBooksAsync(new BookFilterParams()); + var result = await _bookService.GetAllBooksAsync(filterParams); // Assert Assert.Single(result); - Assert.Equal("Test Book", result.First().Title); + _bookRepositoryMock.Verify(r => r.GetAllAsync(It.IsAny(), It.IsAny()), Times.Once); } [Fact] - public async Task GetBookByIdAsync_ReturnsBook() + public async Task GetBookByIdAsync_ExistingId_ReturnsBook() { // Arrange - var book = new Book { Id = 1, Title = "Test Book" }; + var book = new Book { Id = 1, Title = "Test Book", AuthorId = 1, CategoryId = 1 }; _bookRepositoryMock .Setup(r => r.GetByIdAsync(1, It.IsAny())) .ReturnsAsync(book); @@ -55,18 +67,39 @@ public async Task GetBookByIdAsync_ReturnsBook() } [Fact] - public async Task CreateBookAsync_ReturnsCreatedBook() + public async Task CreateBookAsync_ValidRequest_ReturnsCreatedBook() { // Arrange - var newBook = new Book { Title = "New Book", Author = "Author" }; - var createdBook = new Book { Id = 100, Title = "New Book", Author = "Author" }; + var request = new Book + { + Title = "New Book", + AuthorId = 1, + CategoryId = 1, + ISBN = "978-3-16-148410-0", + PublicationYear = 2024, + Genre = "Fiction", + IsAvailable = true + }; + var createdBook = new Book + { + Id = 100, + Title = "New Book", + AuthorId = 1, + CategoryId = 1, + ISBN = "978-3-16-148410-0", + PublicationYear = 2024, + Genre = "Fiction", + IsAvailable = true + }; _bookRepositoryMock - .Setup(r => r.CreateAsync(newBook, It.IsAny())) + .Setup(r => r.CreateAsync(It.Is(b => + b.Title == "New Book" && + b.AuthorId == 1), It.IsAny())) .ReturnsAsync(createdBook); // Act - var result = await _bookService.CreateBookAsync(newBook); + var result = await _bookService.CreateBookAsync(request); // Assert Assert.Equal(100, result.Id); @@ -74,23 +107,45 @@ public async Task CreateBookAsync_ReturnsCreatedBook() } [Fact] - public async Task UpdateBookAsync_ReturnsUpdatedBook() + public async Task UpdateBookAsync_ValidRequest_UpdatesBook() { // Arrange - var book = new Book { Id = 1, Title = "Updated Title" }; + var request = new UpdateBookRequest + { + Title = "Updated Title", + AuthorId = 1, + CategoryId = 1, + ISBN = "978-3-16-148410-0", + PublicationYear = 2025, + Genre = "Non-Fiction", + IsAvailable = false + }; + var updatedBook = new Book + { + Id = 1, + Title = "Updated Title", + AuthorId = 1, + CategoryId = 1, + ISBN = "978-3-16-148410-0", + PublicationYear = 2025, + Genre = "Non-Fiction", + IsAvailable = false + }; + _bookRepositoryMock - .Setup(r => r.UpdateAsync(1, book, It.IsAny())) - .ReturnsAsync(book); + .Setup(r => r.UpdateAsync(1, request, It.IsAny())) + .ReturnsAsync(updatedBook); // Act - var result = await _bookService.UpdateBookAsync(1, book); + var result = await _bookService.UpdateBookAsync(1, request); // Assert Assert.Equal("Updated Title", result.Title); + Assert.Equal("Non-Fiction", result.Genre); } [Fact] - public async Task DeleteBookAsync_ReturnsTrue() + public async Task DeleteBookAsync_ExistingId_ReturnsTrue() { // Arrange _bookRepositoryMock diff --git a/BookLibrary.Tests/UnitTests/Services/CategoryServiceTests.cs b/BookLibrary.Tests/UnitTests/Services/CategoryServiceTests.cs new file mode 100644 index 0000000..a582957 --- /dev/null +++ b/BookLibrary.Tests/UnitTests/Services/CategoryServiceTests.cs @@ -0,0 +1,73 @@ +using BookLibrary.Contracts.Repositories; +using BookLibrary.Contracts.Services; +using BookLibrary.Data.Responses.Categories; +using BookLibrary.Services; +using Moq; + +namespace BookLibrary.Tests.UnitTests.Services; + +[Trait("Category", "Unit")] +public class CategoryServiceTests +{ + private readonly Mock _bookRepositoryMock; + private readonly ICategoryService _categoryService; + + public CategoryServiceTests() + { + _bookRepositoryMock = new Mock(); + _categoryService = new CategoryService(_bookRepositoryMock.Object); + } + + [Fact] + public async Task GetStatisticsAsync_WithoutBooks_ReturnsStatisticsWithoutBooks() + { + // Arrange + var stats = new List + { + new("Детектив", 3, 1950, null), + new("Фэнтези", 1, 1997, null) + }; + + _bookRepositoryMock + .Setup(r => r.GetCategoryStatisticsAsync(false, It.IsAny())) + .ReturnsAsync(stats); + + // Act + var result = (await _categoryService.GetStatisticsAsync(withBooks: false)).ToList(); + + // Assert + Assert.Equal(2, result.Count()); + Assert.All(result, s => Assert.Null(s.Books)); + Assert.Equal("Детектив", result.First().CategoryName); + Assert.Equal(3, result.First().BookCount); + } + + [Fact] + public async Task GetStatisticsAsync_WithBooks_ReturnsStatisticsWithBooks() + { + // Arrange + var stats = new List + { + new("Детектив", 3, 1950, new List + { + new(1, "Десять негритят", 1939), + new(2, "Убийство в Восточном экспрессе", 1934) + }) + }; + + _bookRepositoryMock + .Setup(r => r.GetCategoryStatisticsAsync(true, It.IsAny())) + .ReturnsAsync(stats); + + // Act + var result = (await _categoryService.GetStatisticsAsync(withBooks: true)).ToList(); + var first = result.First(); + + // Assert + Assert.Single(result); + Assert.NotNull(first.Books); + Assert.Equal(2, first.Books.Count); + Assert.Equal("Десять негритят", first.Books[0].Title); + Assert.Equal(1939, first.Books[0].PublicationYear); + } +} diff --git a/BookLibrary/BookLibrary.csproj b/BookLibrary/BookLibrary.csproj index ba9ac63..7d1667d 100644 --- a/BookLibrary/BookLibrary.csproj +++ b/BookLibrary/BookLibrary.csproj @@ -16,7 +16,22 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/BookLibrary/Bootstrap/AppSteps/MiddlewareSteps.cs b/BookLibrary/Bootstrap/AppSteps/MiddlewareSteps.cs index 7b4f942..c4ceb2d 100644 --- a/BookLibrary/Bootstrap/AppSteps/MiddlewareSteps.cs +++ b/BookLibrary/Bootstrap/AppSteps/MiddlewareSteps.cs @@ -15,9 +15,6 @@ public static void ConfigureMiddleware(WebApplication app) // Обработка несуществующих маршрутов app.UseMiddleware(); - // Обработка валидации - // app.UseMiddleware(); - // Обработка исключений app.UseMiddleware(); } diff --git a/BookLibrary/Bootstrap/BuilderSteps/DatabaseSteps.cs b/BookLibrary/Bootstrap/BuilderSteps/DatabaseSteps.cs index 7cd6d1e..22bdf7f 100644 --- a/BookLibrary/Bootstrap/BuilderSteps/DatabaseSteps.cs +++ b/BookLibrary/Bootstrap/BuilderSteps/DatabaseSteps.cs @@ -1,4 +1,12 @@ -using BookLibrary.Storage; +using BookLibrary.Config; +using BookLibrary.Contracts.Repositories; +using BookLibrary.Database.Context; +using BookLibrary.Repositories.AuthorRepositories.EFCore; +using BookLibrary.Repositories.AuthorRepositories.Memory; +using BookLibrary.Repositories.BookRepositories.EFCore; +using BookLibrary.Repositories.BookRepositories.Memory; +using BookLibrary.Storage; +using Microsoft.EntityFrameworkCore; namespace BookLibrary.Bootstrap.BuilderSteps; @@ -7,16 +15,101 @@ public class DatabaseSteps [BootstrapStep(30, "Configure database context")] public static void ConfigureDatabase(WebApplicationBuilder builder) { - // builder.Services.AddDbContext(options => - // { - // options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")); - // }); + var storageSettings = builder.Configuration + .GetSection("ApplicationSettings:Storage") + .Get() ?? new StorageSettings(); + + Console.WriteLine($"🗄️ Storage Provider: {storageSettings.Provider}"); + + if (storageSettings.UseInMemory) + { + ConfigureInMemoryStorage(builder); + } + else if (storageSettings.UsePostgreSQL) + { + ConfigurePostgresStorage(builder); + } + else + { + throw new InvalidOperationException( + $"Unsupported storage provider: {storageSettings.Provider}. " + + "Supported providers: 'InMemory', 'PostgreSQL'"); + } + } + + [BootstrapStep(31, "Apply database migrations (PostgreSQL only)")] + public static void ApplyMigrations(WebApplicationBuilder builder) + { + var storageSettings = builder.Configuration + .GetSection("ApplicationSettings:Storage") + .Get() ?? new StorageSettings(); + + Console.WriteLine(storageSettings.UsePostgreSQL + ? " → Database migrations will be applied on startup" + : " → Skipping database migrations (In-Memory storage)"); + } + + private static string MaskConnectionString(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + return string.Empty; + + var parts = connectionString.Split(';'); + var maskedParts = parts.Select(part => + { + if (part.StartsWith("Password=", StringComparison.OrdinalIgnoreCase) || + part.StartsWith("Pwd=", StringComparison.OrdinalIgnoreCase)) + { + return "Password=***"; + } + return part; + }); + + return string.Join("; ", maskedParts); } - [BootstrapStep(31, "Configure InMemory storage")] - public static void ConfigureInMemoryStorage(WebApplicationBuilder builder) + private static void ConfigureInMemoryStorage(WebApplicationBuilder builder) { // ЕДИНСТВЕННЫЙ экземпляр хранилища на всё приложение builder.Services.AddSingleton(); + + // РЕГИСТРИРУЕМ РЕПОЗИТОРИИ ДЛЯ IN-MEMORY + builder.Services.AddScoped(); + builder.Services.AddScoped(); + } + + private static void ConfigurePostgresStorage(WebApplicationBuilder builder) + { + Console.WriteLine(" → Using PostgreSQL storage"); + + var connectionString = builder.Configuration + .GetConnectionString("DefaultConnection"); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + "Connection string 'DefaultConnection' not found in configuration."); + } + + Console.WriteLine($" → Connection string: {MaskConnectionString(connectionString)}"); + + // Регистрируем контекст БД + builder.Services.AddDbContext(options => + { + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "public"); + npgsqlOptions.CommandTimeout(60); + }); + + #if DEBUG + options.EnableSensitiveDataLogging(); + options.LogTo(Console.WriteLine, LogLevel.Information); + #endif + }); + + // Регистрируем репозитории для PostgreSQL + builder.Services.AddScoped(); + builder.Services.AddScoped(); } } diff --git a/BookLibrary/Bootstrap/BuilderSteps/ServiceRegistrationSteps.cs b/BookLibrary/Bootstrap/BuilderSteps/ServiceRegistrationSteps.cs index fec823c..abc6dee 100644 --- a/BookLibrary/Bootstrap/BuilderSteps/ServiceRegistrationSteps.cs +++ b/BookLibrary/Bootstrap/BuilderSteps/ServiceRegistrationSteps.cs @@ -1,13 +1,10 @@ -using BookLibrary.Contracts.Repositories; -using BookLibrary.Contracts.Services; +using BookLibrary.Contracts.Services; using BookLibrary.Exceptions.Base; using BookLibrary.Exceptions.Contracts; using BookLibrary.Filters.ValidationFilters; -using BookLibrary.Models; -using BookLibrary.Repositories.AuthorRepositories; -using BookLibrary.Repositories.BookRepositories; using BookLibrary.Services; using BookLibrary.Validators; +using BookLibrary.Validators.Rules; using FluentValidation; using Microsoft.AspNetCore.Mvc; @@ -26,6 +23,9 @@ public static void RegisterApiServices(WebApplicationBuilder builder) { options.SerializerSettings.ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(); options.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore; + options.SerializerSettings.ReferenceLoopHandling = + Newtonsoft.Json.ReferenceLoopHandling.Ignore; + // Продолжаем десериализацию после ошибок options.SerializerSettings.Error = (sender, args) => { @@ -40,7 +40,8 @@ public static void RegisterApiServices(WebApplicationBuilder builder) public static void ConfigureValidation(WebApplicationBuilder builder) { // Регистрация валидатора для модели Book - builder.Services.AddScoped, BookValidator>(); + builder.Services.AddScoped(); + builder.Services.AddValidatorsFromAssemblyContaining(); // Кастомизация ответа валидации для ВСЕХ контроллеров // может понадобиться в будущем, если: @@ -85,10 +86,8 @@ public static void RegisterApplicationServices(WebApplicationBuilder builder) { // Services builder.Services.AddScoped(); - - // Repositories - builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); // Регистрация обработчика исключений builder.Services.AddSingleton(); diff --git a/BookLibrary/Bootstrap/BuilderSteps/SwaggerStep.cs b/BookLibrary/Bootstrap/BuilderSteps/SwaggerStep.cs index 6800b91..02ddcb7 100644 --- a/BookLibrary/Bootstrap/BuilderSteps/SwaggerStep.cs +++ b/BookLibrary/Bootstrap/BuilderSteps/SwaggerStep.cs @@ -25,6 +25,7 @@ public static void RegisterApiServices(WebApplicationBuilder builder) { { } name when name.Contains("Controllers.Books") => "Книги", { } name when name.Contains("Controllers.Authors") => "Авторы", + { } name when name.Contains("Controllers.Categories") => "Категории", _ => "Общее" } ); diff --git a/BookLibrary/Config/StorageSettings.cs b/BookLibrary/Config/StorageSettings.cs new file mode 100644 index 0000000..b22109d --- /dev/null +++ b/BookLibrary/Config/StorageSettings.cs @@ -0,0 +1,19 @@ +namespace BookLibrary.Config; + +public class StorageSettings +{ + /// + /// Провайдер хранилища: "InMemory" или "PostgreSQL" + /// + public string Provider { get; set; } = "PostgreSQL"; + + /// + /// Определяет, используется ли in-memory хранилище + /// + public bool UseInMemory => Provider.Equals("InMemory", StringComparison.OrdinalIgnoreCase); + + /// + /// Определяет, используется ли PostgreSQL + /// + public bool UsePostgreSQL => Provider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase); +} diff --git a/BookLibrary/Contracts/Repositories/IAuthorReadRepository.cs b/BookLibrary/Contracts/Repositories/IAuthorReadRepository.cs index db72970..cfcdc68 100644 --- a/BookLibrary/Contracts/Repositories/IAuthorReadRepository.cs +++ b/BookLibrary/Contracts/Repositories/IAuthorReadRepository.cs @@ -5,10 +5,8 @@ namespace BookLibrary.Contracts.Repositories; public interface IAuthorReadRepository { - /// - /// Получить всех авторов с фильтрацией, сортировкой и пагинацией - /// Task> GetAllAsync( AuthorFilterParams? filterParams = null, CancellationToken cancellationToken = default); + Task GetByIdAsync(int id, CancellationToken cancellationToken = default); } diff --git a/BookLibrary/Contracts/Repositories/IBookRepository.cs b/BookLibrary/Contracts/Repositories/IBookRepository.cs index d83fa36..fc6976a 100644 --- a/BookLibrary/Contracts/Repositories/IBookRepository.cs +++ b/BookLibrary/Contracts/Repositories/IBookRepository.cs @@ -1,4 +1,6 @@ -using BookLibrary.Models; +using BookLibrary.Data.Requests.Book; +using BookLibrary.Data.Responses.Categories; +using BookLibrary.Models; using BookLibrary.Specifications.Book; namespace BookLibrary.Contracts.Repositories; @@ -8,6 +10,10 @@ public interface IBookRepository Task> GetAllAsync(BookFilterParams? filterParams = null, CancellationToken cancellationToken = default); Task GetByIdAsync(int id, CancellationToken cancellationToken = default); Task CreateAsync(Book book, CancellationToken cancellationToken = default); - Task UpdateAsync(int id, Book book, CancellationToken cancellationToken = default); + Task UpdateAsync(int id, UpdateBookRequest data, CancellationToken cancellationToken = default); public Task DeleteAsync(int id, CancellationToken cancellationToken = default); + + public Task> GetCategoryStatisticsAsync( + bool withBooks = false, + CancellationToken cancellationToken = default); } diff --git a/BookLibrary/Contracts/Repositories/ICategoryRepository.cs b/BookLibrary/Contracts/Repositories/ICategoryRepository.cs new file mode 100644 index 0000000..4756800 --- /dev/null +++ b/BookLibrary/Contracts/Repositories/ICategoryRepository.cs @@ -0,0 +1,13 @@ +using BookLibrary.Data.Responses.Categories; +using BookLibrary.Models; + +namespace BookLibrary.Contracts.Repositories; + +public interface ICategoryRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task GetByIdAsync(int id, CancellationToken cancellationToken = default); + Task CreateAsync(Category category, CancellationToken cancellationToken = default); + Task UpdateAsync(int id, Category category, CancellationToken cancellationToken = default); + Task DeleteAsync(int id, CancellationToken cancellationToken = default); +} diff --git a/BookLibrary/Contracts/Services/IAuthorService.cs b/BookLibrary/Contracts/Services/IAuthorService.cs new file mode 100644 index 0000000..8826e43 --- /dev/null +++ b/BookLibrary/Contracts/Services/IAuthorService.cs @@ -0,0 +1,8 @@ +using BookLibrary.Models; + +namespace BookLibrary.Contracts.Services; + +public interface IAuthorService +{ + Task GetAuthorByIdAsync(int id, CancellationToken cancellationToken = default); +} diff --git a/BookLibrary/Contracts/Services/IBookService.cs b/BookLibrary/Contracts/Services/IBookService.cs index fa71308..00fc902 100644 --- a/BookLibrary/Contracts/Services/IBookService.cs +++ b/BookLibrary/Contracts/Services/IBookService.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using BookLibrary.Data.Requests.Book; using BookLibrary.Models; using BookLibrary.Specifications.Book; @@ -25,7 +25,7 @@ public interface IBookService // /// // /// Обновить книгу // /// - Task UpdateBookAsync(int id, Book book, CancellationToken cancellationToken = default); + Task UpdateBookAsync(int id, UpdateBookRequest data, CancellationToken cancellationToken = default); // /// // /// Удалить книгу diff --git a/BookLibrary/Contracts/Services/ICategoryService.cs b/BookLibrary/Contracts/Services/ICategoryService.cs new file mode 100644 index 0000000..4a4e97e --- /dev/null +++ b/BookLibrary/Contracts/Services/ICategoryService.cs @@ -0,0 +1,10 @@ +using BookLibrary.Data.Responses.Categories; + +namespace BookLibrary.Contracts.Services; + +public interface ICategoryService +{ + Task> GetStatisticsAsync( + bool withBooks = false, + CancellationToken cancellationToken = default); +} diff --git a/BookLibrary/Contracts/Specifications/IMySpecification.cs b/BookLibrary/Contracts/Specifications/IMySpecification.cs index 5c9db77..ab41b5b 100644 --- a/BookLibrary/Contracts/Specifications/IMySpecification.cs +++ b/BookLibrary/Contracts/Specifications/IMySpecification.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using Ardalis.Specification; namespace BookLibrary.Contracts.Specifications; diff --git a/BookLibrary/Controllers/Authors/AuthorBooksController.cs b/BookLibrary/Controllers/Authors/AuthorBooksController.cs new file mode 100644 index 0000000..b6814da --- /dev/null +++ b/BookLibrary/Controllers/Authors/AuthorBooksController.cs @@ -0,0 +1,35 @@ +using BookLibrary.Contracts.Services; +using BookLibrary.Specifications.Book; +using Microsoft.AspNetCore.Mvc; + +namespace BookLibrary.Controllers.Authors; + +[ApiController] +[Route("api/authors/{id:int}/books")] +[Produces("application/json")] +[Tags("Authors")] +public class AuthorBooksController( + IBookService bookService, + IAuthorService authorService + ) : ApiControllerBase +{ + /// + /// Получить все книги автора с возможностью фильтрации и включения связей + /// + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task ExecuteAsync( + [FromRoute] int id, + [FromQuery] BookFilterParams filterParams, + CancellationToken cancellationToken = default) + { + var author = await authorService.GetAuthorByIdAsync(id, cancellationToken); + filterParams.AuthorId = author.Id; + + var books = await bookService.GetAllBooksAsync(filterParams, cancellationToken); + + return Success(books); + } +} diff --git a/BookLibrary/Controllers/Authors/ShowAuthorController.cs b/BookLibrary/Controllers/Authors/ShowAuthorController.cs new file mode 100644 index 0000000..d5615db --- /dev/null +++ b/BookLibrary/Controllers/Authors/ShowAuthorController.cs @@ -0,0 +1,21 @@ +using BookLibrary.Contracts.Services; +using Microsoft.AspNetCore.Mvc; + +namespace BookLibrary.Controllers.Authors; + +[ApiController] +[Route("api/authors")] +[Produces("application/json")] +[Tags("Authors")] +public class ShowAuthorController(IAuthorService authorService) : ApiControllerBase +{ + [HttpGet("{id:int}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ExecuteAsync(int id, CancellationToken cancellationToken = default) + { + var author = await authorService.GetAuthorByIdAsync(id, cancellationToken); + + return Success(author); + } +} diff --git a/BookLibrary/Controllers/Books/StoreBookController.cs b/BookLibrary/Controllers/Books/StoreBookController.cs index 68d4f86..c53b420 100644 --- a/BookLibrary/Controllers/Books/StoreBookController.cs +++ b/BookLibrary/Controllers/Books/StoreBookController.cs @@ -1,4 +1,5 @@ using BookLibrary.Contracts.Services; +using BookLibrary.Data.Requests.Book; using BookLibrary.Models; using Microsoft.AspNetCore.Mvc; @@ -14,7 +15,7 @@ public class StoreBookController(IBookService bookService) /// /// Добавить новую книгу /// - /// Данные книги + /// Данные книги /// Токен отмены /// Созданная книга [HttpPost] @@ -23,10 +24,10 @@ public class StoreBookController(IBookService bookService) [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task ExecuteAsync( - [FromBody] Book book, + [FromBody] CreateBookRequest request, CancellationToken cancellationToken = default) { - var createdBook = await bookService.CreateBookAsync(book, cancellationToken); + var createdBook = await bookService.CreateBookAsync(request.ToBook(), cancellationToken); return Created(createdBook); } diff --git a/BookLibrary/Controllers/Books/UpdateController.cs b/BookLibrary/Controllers/Books/UpdateController.cs index 6a02d85..d16d20a 100644 --- a/BookLibrary/Controllers/Books/UpdateController.cs +++ b/BookLibrary/Controllers/Books/UpdateController.cs @@ -1,4 +1,5 @@ using BookLibrary.Contracts.Services; +using BookLibrary.Data.Requests.Book; using BookLibrary.Models; using Microsoft.AspNetCore.Mvc; @@ -13,7 +14,7 @@ public class UpdateController(IBookService bookService) : ApiControllerBase /// /// Обновить книгу /// - /// Данные книги + /// Данные книги /// /// Токен отмены /// Созданная книга @@ -26,10 +27,10 @@ public class UpdateController(IBookService bookService) : ApiControllerBase [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task ExecuteAsync( int id, - Book book, + UpdateBookRequest request, CancellationToken cancellationToken = default) { - var updatedBook = await bookService.UpdateBookAsync(id, book, cancellationToken); + var updatedBook = await bookService.UpdateBookAsync(id, request, cancellationToken); return Success(updatedBook); } diff --git a/BookLibrary/Controllers/Categories/GetCategoryStatisticsController.cs b/BookLibrary/Controllers/Categories/GetCategoryStatisticsController.cs new file mode 100644 index 0000000..992a225 --- /dev/null +++ b/BookLibrary/Controllers/Categories/GetCategoryStatisticsController.cs @@ -0,0 +1,29 @@ +using BookLibrary.Contracts.Services; +using Microsoft.AspNetCore.Mvc; + +namespace BookLibrary.Controllers.Categories; + +[ApiController] +[Route("api/categories/statistics")] +[Produces("application/json")] +[Tags("Categories")] +public class GetCategoryStatisticsController(ICategoryService categoryService) : ApiControllerBase +{ + /// + /// Получить статистику по категориям: + /// - Название категории + /// - Количество книг + /// - Средний год публикации + /// + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task ExecuteAsync( + [FromQuery] bool withBooks = false, + CancellationToken cancellationToken = default) + { + var statistics = await categoryService.GetStatisticsAsync(withBooks, cancellationToken); + + return Success(statistics); + } +} diff --git a/BookLibrary/Data/Requests/Book/CreateBookRequest.cs b/BookLibrary/Data/Requests/Book/CreateBookRequest.cs new file mode 100644 index 0000000..62e8b0a --- /dev/null +++ b/BookLibrary/Data/Requests/Book/CreateBookRequest.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using BookLibrary.Models; + +namespace BookLibrary.Data.Requests.Book; + +public class CreateBookRequest +{ + public string Title { get; set; } = string.Empty; + public int AuthorId { get; set; } + public int CategoryId { get; set; } + public string ISBN { get; set; } = string.Empty; + public int PublicationYear { get; set; } + public string? Genre { get; set; } + public bool IsAvailable { get; set; } = true; + + /// + /// Преобразует запрос в модель книги + /// + public Models.Book ToBook() => new() + { + Title = Title, + AuthorId = AuthorId, + CategoryId = CategoryId, + ISBN = ISBN, + PublicationYear = PublicationYear, + Genre = Genre, + IsAvailable = IsAvailable + }; +} diff --git a/BookLibrary/Data/Requests/Book/UpdateBookRequest.cs b/BookLibrary/Data/Requests/Book/UpdateBookRequest.cs new file mode 100644 index 0000000..a1a469c --- /dev/null +++ b/BookLibrary/Data/Requests/Book/UpdateBookRequest.cs @@ -0,0 +1,76 @@ +using System.ComponentModel.DataAnnotations; + +namespace BookLibrary.Data.Requests.Book; + +public class UpdateBookRequest +{ + /// + /// Идентификатор книги для обновления + /// + [Required] + public int Id { get; set; } + + /// + /// Новое название книги (опционально) + /// + public string? Title { get; set; } + + /// + /// Новый автор (опционально) + /// + public int? AuthorId { get; set; } + + /// + /// Новая категория (опционально) + /// + public int? CategoryId { get; set; } + + /// + /// Новый ISBN (опционально) + /// + public string? ISBN { get; set; } + + /// + /// Новый год публикации (опционально) + /// + public int? PublicationYear { get; set; } + + /// + /// Новый жанр (опционально) + /// + public string? Genre { get; set; } + + /// + /// Новый статус доступности (опционально) + /// + public bool? IsAvailable { get; set; } + + /// + /// Применяет изменения к существующей модели книги + /// + public void ApplyTo(Models.Book book) + { + ArgumentNullException.ThrowIfNull(book); + + if (Title != null) + book.Title = Title; + + if (AuthorId.HasValue) + book.AuthorId = AuthorId.Value; + + if (CategoryId.HasValue) + book.CategoryId = CategoryId.Value; + + if (ISBN != null) + book.ISBN = ISBN; + + if (PublicationYear.HasValue) + book.PublicationYear = PublicationYear.Value; + + if (Genre != null) + book.Genre = Genre; + + if (IsAvailable.HasValue) + book.IsAvailable = IsAvailable.Value; + } +} diff --git a/BookLibrary/Data/Responses/Categories/BookSummaryDto.cs b/BookLibrary/Data/Responses/Categories/BookSummaryDto.cs new file mode 100644 index 0000000..ca51872 --- /dev/null +++ b/BookLibrary/Data/Responses/Categories/BookSummaryDto.cs @@ -0,0 +1,8 @@ +namespace BookLibrary.Data.Responses.Categories; + +public record BookSummaryDto( + int Id, + string Title, + int PublicationYear +); + diff --git a/BookLibrary/Data/Responses/Categories/CategoryStatisticsDto.cs b/BookLibrary/Data/Responses/Categories/CategoryStatisticsDto.cs new file mode 100644 index 0000000..724cffa --- /dev/null +++ b/BookLibrary/Data/Responses/Categories/CategoryStatisticsDto.cs @@ -0,0 +1,10 @@ +using BookLibrary.Models; + +namespace BookLibrary.Data.Responses.Categories; + +public record CategoryStatisticsDto( + string CategoryName, + int BookCount, + int AveragePublicationYear, + List? Books + ); diff --git a/BookLibrary/Database/Context/AppDbContext.cs b/BookLibrary/Database/Context/AppDbContext.cs new file mode 100644 index 0000000..275e84f --- /dev/null +++ b/BookLibrary/Database/Context/AppDbContext.cs @@ -0,0 +1,117 @@ +using BookLibrary.Database.Seeders; +using BookLibrary.Models; +using Microsoft.EntityFrameworkCore; + +namespace BookLibrary.Database.Context; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + // DbSet для моделей + public DbSet Books { get; set; } = null!; + public DbSet Authors { get; set; } = null!; + public DbSet Categories { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Настройка сущности Book + modelBuilder.Entity(entity => + { + entity.HasKey(b => b.Id); + + entity.Property(b => b.Title) + .IsRequired() + .HasMaxLength(255); + + entity.Property(b => b.ISBN) + .IsRequired() + .HasMaxLength(20) + .HasColumnName("Isbn"); + + entity.Property(b => b.PublicationYear) + .IsRequired(); + + entity.Property(b => b.Genre) + .HasMaxLength(50); + + entity.Property(b => b.CreatedAt) + .IsRequired() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + entity.Property(b => b.UpdatedAt) + .IsRequired() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + // Внешние ключи + entity.HasOne(b => b.Author) + .WithMany(a => a.Books) + .HasForeignKey(b => b.AuthorId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(b => b.Category) + .WithMany(c => c.Books) + .HasForeignKey(b => b.CategoryId) + .OnDelete(DeleteBehavior.Restrict); + + // Индексы + entity.HasIndex(b => b.ISBN).IsUnique(); + entity.HasIndex(b => b.Title); + entity.HasIndex(b => b.AuthorId); + entity.HasIndex(b => b.CategoryId); + }); + + // Настройка сущности Author + modelBuilder.Entity(entity => + { + entity.HasKey(a => a.Id); + + entity.Property(a => a.Name) + .IsRequired() + .HasMaxLength(100); + + entity.Property(a => a.Country) + .HasMaxLength(50); + + entity.Property(a => a.Biography) + .HasMaxLength(1000); + + entity.Property(a => a.CreatedAt) + .IsRequired() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + entity.Property(a => a.UpdatedAt) + .IsRequired() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + entity.HasIndex(a => a.Name); + }); + + // Настройка сущности Category + modelBuilder.Entity(entity => + { + entity.HasKey(c => c.Id); + + entity.Property(c => c.Name) + .IsRequired() + .HasMaxLength(100); + + entity.Property(c => c.Description) + .HasMaxLength(500); + + entity.Property(c => c.CreatedAt) + .IsRequired() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + entity.Property(c => c.UpdatedAt) + .IsRequired() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + // Уникальный индекс для имени категории + entity.HasIndex(c => c.Name).IsUnique(); + }); + + // Применяем все сидеры + SeederFactory.ApplySeeders(modelBuilder); + } +} diff --git a/BookLibrary/Database/Seeders/AuthorSeeder.cs b/BookLibrary/Database/Seeders/AuthorSeeder.cs new file mode 100644 index 0000000..4e69c9d --- /dev/null +++ b/BookLibrary/Database/Seeders/AuthorSeeder.cs @@ -0,0 +1,18 @@ +using BookLibrary.Models; +using Microsoft.EntityFrameworkCore; + +namespace BookLibrary.Database.Seeders; + +public class AuthorSeeder : IDataSeeder +{ + public void Seed(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasData( + new Author { Id = 1, Name = "Агата Кристи"}, + new Author { Id = 2, Name = "Артур Конан Дойл"}, + new Author { Id = 3, Name = "Джоан Роулинг"}, + new Author { Id = 4, Name = "Фёдор Достоевский"}, + new Author { Id = 5, Name = "Лев Толстой"} + ); + } +} diff --git a/BookLibrary/Database/Seeders/BookSeeder.cs b/BookLibrary/Database/Seeders/BookSeeder.cs new file mode 100644 index 0000000..13f6613 --- /dev/null +++ b/BookLibrary/Database/Seeders/BookSeeder.cs @@ -0,0 +1,106 @@ +using BookLibrary.Models; +using Microsoft.EntityFrameworkCore; + +namespace BookLibrary.Database.Seeders; + +public class BookSeeder : IDataSeeder +{ + public void Seed(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasData( + new Book + { + Id = 1, + Title = "Десять негритят", + AuthorId = 1, + CategoryId = 1, + ISBN = "978-5-17-101234-5", + PublicationYear = 1939, + Genre = "Детектив", + IsAvailable = true + }, + new Book + { + Id = 2, + Title = "Убийство в Восточном экспрессе", + AuthorId = 1, + CategoryId = 1, + ISBN = "978-5-17-101235-2", + PublicationYear = 1934, + Genre = "Детектив", + IsAvailable = true + }, + new Book + { + Id = 3, + Title = "Гарри Поттер и философский камень", + AuthorId = 3, + CategoryId = 2, + ISBN = "978-5-17-112345-6", + PublicationYear = 1997, + Genre = "Фэнтези", + IsAvailable = true + }, + new Book + { + Id = 4, + Title = "Преступление и наказание", + AuthorId = 4, + CategoryId = 3, + ISBN = "978-5-699-12345-6", + PublicationYear = 1866, + Genre = "Роман", + IsAvailable = true + }, + new Book + { + Id = 5, + Title = "Война и мир", + AuthorId = 5, + CategoryId = 5, + ISBN = "978-5-699-23456-7", + PublicationYear = 1869, + Genre = "Роман", + IsAvailable = true + }, + // 6. Исправленная книга (меняем данные, но Id оставляем 6) + new Book + { + Id = 6, + Title = "Шерлок Холмс: Сокращенное издание", // Исправлено название + AuthorId = 2, + CategoryId = 1, + ISBN = "978-5-699-34567-8", + PublicationYear = 1902, + Genre = "Детектив", + IsAvailable = true + }, + + // 7. Новая книга (Новый Id!) + new Book + { + Id = 7, + Title = "Идиот", + AuthorId = 4, + CategoryId = 5, + ISBN = "978-5-17-999910-9", + PublicationYear = 1949, + Genre = "Роман", + IsAvailable = true + }, + + // 8. Новая книга (Новый Id!) + new Book + { + Id = 8, + Title = "Бесы", + AuthorId = 4, + CategoryId = 5, + ISBN = "978-5-17-888810-8", + PublicationYear = 1965, + Genre = "Роман", + IsAvailable = true + } + ); + } +} diff --git a/BookLibrary/Database/Seeders/CategorySeeder.cs b/BookLibrary/Database/Seeders/CategorySeeder.cs new file mode 100644 index 0000000..2d47282 --- /dev/null +++ b/BookLibrary/Database/Seeders/CategorySeeder.cs @@ -0,0 +1,18 @@ +using BookLibrary.Models; +using Microsoft.EntityFrameworkCore; + +namespace BookLibrary.Database.Seeders; + +public class CategorySeeder : IDataSeeder +{ + public void Seed(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasData( + new Category { Id = 1, Name = "Детектив"}, + new Category { Id = 2, Name = "Фэнтези"}, + new Category { Id = 3, Name = "Классика"}, + new Category { Id = 4, Name = "Научная фантастика"}, + new Category { Id = 5, Name = "Роман"} + ); + } +} diff --git a/BookLibrary/Database/Seeders/IDataSeeder.cs b/BookLibrary/Database/Seeders/IDataSeeder.cs new file mode 100644 index 0000000..38c1b4c --- /dev/null +++ b/BookLibrary/Database/Seeders/IDataSeeder.cs @@ -0,0 +1,8 @@ +using Microsoft.EntityFrameworkCore; + +namespace BookLibrary.Database.Seeders; + +public interface IDataSeeder +{ + void Seed(ModelBuilder modelBuilder); +} diff --git a/BookLibrary/Database/Seeders/SeederFactory.cs b/BookLibrary/Database/Seeders/SeederFactory.cs new file mode 100644 index 0000000..6c602e0 --- /dev/null +++ b/BookLibrary/Database/Seeders/SeederFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; + +namespace BookLibrary.Database.Seeders; + +public static class SeederFactory +{ + public static void ApplySeeders(ModelBuilder modelBuilder) + { + var seeders = new IDataSeeder[] + { + new AuthorSeeder(), + new CategorySeeder(), + new BookSeeder() + }; + + foreach (var seeder in seeders) + { + seeder.Seed(modelBuilder); + } + } +} diff --git a/BookLibrary/Filters/ValidationFilters/ValidationResponseFilter.cs b/BookLibrary/Filters/ValidationFilters/ValidationResponseFilter.cs index 036117c..21eb7ec 100644 --- a/BookLibrary/Filters/ValidationFilters/ValidationResponseFilter.cs +++ b/BookLibrary/Filters/ValidationFilters/ValidationResponseFilter.cs @@ -5,9 +5,9 @@ namespace BookLibrary.Filters.ValidationFilters; -public class ValidationResponseFilter(IServiceProvider serviceProvider) : IActionFilter +public class ValidationResponseFilter(IServiceProvider serviceProvider) : IAsyncActionFilter { - public void OnActionExecuting(ActionExecutingContext context) + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var controllerActionDescriptor = context.ActionDescriptor as ControllerActionDescriptor; @@ -30,7 +30,7 @@ public void OnActionExecuting(ActionExecutingContext context) if (validator != null && parameterValue != null) { - // 🔥 ПРОВЕРЯЕМ, ЕСТЬ ЛИ ОШИБКИ ПРИВЯЗКИ ДЛЯ СВОЙСТВ ЭТОГО ТИПА + // ПРОВЕРЯЕМ, ЕСТЬ ЛИ ОШИБКИ ПРИВЯЗКИ ДЛЯ СВОЙСТВ ЭТОГО ТИПА var propertyNames = parameterType.GetProperties() .Select(p => p.Name.ToLowerInvariant()) .ToHashSet(); @@ -51,22 +51,22 @@ public void OnActionExecuting(ActionExecutingContext context) }) .Any(key => propertyNames.Contains(key) || key.Contains(".")); - // 🔥 ВЫЗЫВАЕМ ВАЛИДАТОР ТОЛЬКО ЕСЛИ НЕТ ОШИБОК ПРИВЯЗКИ - if (!hasBindingErrors) - { - var validationResult = validator.Validate(new ValidationContext(parameterValue)); + // ВЫЗЫВАЕМ ВАЛИДАТОР ТОЛЬКО ЕСЛИ НЕТ ОШИБОК ПРИВЯЗКИ + if (hasBindingErrors) continue; + + // АСИНХРОННЫЙ ВЫЗОВ! + var validationResult = await validator.ValidateAsync(new ValidationContext(parameterValue)); - foreach (var failure in validationResult.Errors) - { - var fieldName = ToCamelCase(failure.PropertyName); + foreach (var failure in validationResult.Errors) + { + var fieldName = ToCamelCase(failure.PropertyName); - var modelStateEntry = context.ModelState.GetValueOrDefault(fieldName); - var errorExists = modelStateEntry?.Errors.Any(e => e.ErrorMessage == failure.ErrorMessage) ?? false; + var modelStateEntry = context.ModelState.GetValueOrDefault(fieldName); + var errorExists = modelStateEntry?.Errors.Any(e => e.ErrorMessage == failure.ErrorMessage) ?? false; - if (!errorExists) - { - context.ModelState.AddModelError(fieldName, failure.ErrorMessage); - } + if (!errorExists) + { + context.ModelState.AddModelError(fieldName, failure.ErrorMessage); } } } @@ -98,7 +98,11 @@ public void OnActionExecuting(ActionExecutingContext context) var response = ValidationResponseFormat.FromModelStateErrors(errors); context.Result = response; + return; // Не продолжаем выполнение } + + // Продолжаем выполнение экшена + await next(); } private string ToCamelCase(string? input) diff --git a/BookLibrary/Migrations/20260215143022_InitialCreate.Designer.cs b/BookLibrary/Migrations/20260215143022_InitialCreate.Designer.cs new file mode 100644 index 0000000..b499266 --- /dev/null +++ b/BookLibrary/Migrations/20260215143022_InitialCreate.Designer.cs @@ -0,0 +1,200 @@ +// +using System; +using BookLibrary.Database.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BookLibrary.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260215143022_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BookLibrary.Models.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Biography") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("BirthYear") + .HasColumnType("integer"); + + b.Property("Country") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeathYear") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("BookLibrary.Models.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("Isbn"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("PublicationYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.HasIndex("Title"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("BookLibrary.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("BookLibrary.Models.Book", b => + { + b.HasOne("BookLibrary.Models.Author", "Author") + .WithMany("Books") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BookLibrary.Models.Category", "Category") + .WithMany("Books") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("BookLibrary.Models.Author", b => + { + b.Navigation("Books"); + }); + + modelBuilder.Entity("BookLibrary.Models.Category", b => + { + b.Navigation("Books"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BookLibrary/Migrations/20260215143022_InitialCreate.cs b/BookLibrary/Migrations/20260215143022_InitialCreate.cs new file mode 100644 index 0000000..4836363 --- /dev/null +++ b/BookLibrary/Migrations/20260215143022_InitialCreate.cs @@ -0,0 +1,130 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BookLibrary.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Authors", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Country = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + BirthYear = table.Column(type: "integer", nullable: true), + DeathYear = table.Column(type: "integer", nullable: true), + Biography = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_Authors", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Categories", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_Categories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Books", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Title = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + AuthorId = table.Column(type: "integer", nullable: false), + CategoryId = table.Column(type: "integer", nullable: false), + Isbn = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + PublicationYear = table.Column(type: "integer", nullable: false), + Genre = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + IsAvailable = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_Books", x => x.Id); + table.ForeignKey( + name: "FK_Books_Authors_AuthorId", + column: x => x.AuthorId, + principalTable: "Authors", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Books_Categories_CategoryId", + column: x => x.CategoryId, + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_Authors_Name", + table: "Authors", + column: "Name"); + + migrationBuilder.CreateIndex( + name: "IX_Books_AuthorId", + table: "Books", + column: "AuthorId"); + + migrationBuilder.CreateIndex( + name: "IX_Books_CategoryId", + table: "Books", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Books_Isbn", + table: "Books", + column: "Isbn", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Books_Title", + table: "Books", + column: "Title"); + + migrationBuilder.CreateIndex( + name: "IX_Categories_Name", + table: "Categories", + column: "Name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Books"); + + migrationBuilder.DropTable( + name: "Authors"); + + migrationBuilder.DropTable( + name: "Categories"); + } + } +} diff --git a/BookLibrary/Migrations/20260215180422_SeedInitialData.Designer.cs b/BookLibrary/Migrations/20260215180422_SeedInitialData.Designer.cs new file mode 100644 index 0000000..5e3c225 --- /dev/null +++ b/BookLibrary/Migrations/20260215180422_SeedInitialData.Designer.cs @@ -0,0 +1,359 @@ +// +using System; +using BookLibrary.Database.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BookLibrary.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260215180422_SeedInitialData")] + partial class SeedInitialData + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BookLibrary.Models.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Biography") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("BirthYear") + .HasColumnType("integer"); + + b.Property("Country") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeathYear") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Authors"); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), + IsActive = true, + Name = "Агата Кристи", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) + }, + new + { + Id = 2, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), + IsActive = true, + Name = "Артур Конан Дойл", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) + }, + new + { + Id = 3, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), + IsActive = true, + Name = "Джоан Роулинг", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) + }, + new + { + Id = 4, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), + IsActive = true, + Name = "Фёдор Достоевский", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) + }, + new + { + Id = 5, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), + IsActive = true, + Name = "Лев Толстой", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) + }); + }); + + modelBuilder.Entity("BookLibrary.Models.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("Isbn"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("PublicationYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.HasIndex("Title"); + + b.ToTable("Books"); + + b.HasData( + new + { + Id = 1, + AuthorId = 1, + CategoryId = 1, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), + Genre = "Детектив", + ISBN = "978-5-17-101234-5", + IsAvailable = true, + PublicationYear = 1939, + Title = "Десять негритят", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) + }, + new + { + Id = 2, + AuthorId = 1, + CategoryId = 1, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), + Genre = "Детектив", + ISBN = "978-5-17-101235-2", + IsAvailable = true, + PublicationYear = 1934, + Title = "Убийство в Восточном экспрессе", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) + }, + new + { + Id = 3, + AuthorId = 3, + CategoryId = 2, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), + Genre = "Фэнтези", + ISBN = "978-5-17-112345-6", + IsAvailable = true, + PublicationYear = 1997, + Title = "Гарри Поттер и философский камень", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) + }, + new + { + Id = 4, + AuthorId = 4, + CategoryId = 3, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), + Genre = "Роман", + ISBN = "978-5-699-12345-6", + IsAvailable = true, + PublicationYear = 1866, + Title = "Преступление и наказание", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) + }, + new + { + Id = 5, + AuthorId = 5, + CategoryId = 5, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), + Genre = "Роман", + ISBN = "978-5-699-23456-7", + IsAvailable = true, + PublicationYear = 1869, + Title = "Война и мир", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) + }, + new + { + Id = 6, + AuthorId = 2, + CategoryId = 1, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), + Genre = "Детектив", + ISBN = "978-5-699-34567-8", + IsAvailable = true, + PublicationYear = 1902, + Title = "Шерлок Холмс: Собака Баскервилей", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) + }); + }); + + modelBuilder.Entity("BookLibrary.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), + Name = "Детектив", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) + }, + new + { + Id = 2, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), + Name = "Фэнтези", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) + }, + new + { + Id = 3, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), + Name = "Классика", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) + }, + new + { + Id = 4, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), + Name = "Научная фантастика", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) + }, + new + { + Id = 5, + CreatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), + Name = "Роман", + UpdatedAt = new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) + }); + }); + + modelBuilder.Entity("BookLibrary.Models.Book", b => + { + b.HasOne("BookLibrary.Models.Author", "Author") + .WithMany("Books") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BookLibrary.Models.Category", "Category") + .WithMany("Books") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("BookLibrary.Models.Author", b => + { + b.Navigation("Books"); + }); + + modelBuilder.Entity("BookLibrary.Models.Category", b => + { + b.Navigation("Books"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BookLibrary/Migrations/20260215180422_SeedInitialData.cs b/BookLibrary/Migrations/20260215180422_SeedInitialData.cs new file mode 100644 index 0000000..1c50c49 --- /dev/null +++ b/BookLibrary/Migrations/20260215180422_SeedInitialData.cs @@ -0,0 +1,138 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace BookLibrary.Migrations +{ + /// + public partial class SeedInitialData : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + table: "Authors", + columns: new[] { "Id", "Biography", "BirthYear", "Country", "CreatedAt", "DeathYear", "IsActive", "Name", "UpdatedAt" }, + values: new object[,] + { + { 1, null, null, null, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), null, true, "Агата Кристи", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) }, + { 2, null, null, null, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), null, true, "Артур Конан Дойл", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) }, + { 3, null, null, null, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), null, true, "Джоан Роулинг", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) }, + { 4, null, null, null, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), null, true, "Фёдор Достоевский", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) }, + { 5, null, null, null, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), null, true, "Лев Толстой", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) } + }); + + migrationBuilder.InsertData( + table: "Categories", + columns: new[] { "Id", "CreatedAt", "Description", "Name", "UpdatedAt" }, + values: new object[,] + { + { 1, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), null, "Детектив", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) }, + { 2, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), null, "Фэнтези", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) }, + { 3, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), null, "Классика", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) }, + { 4, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), null, "Научная фантастика", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) }, + { 5, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), null, "Роман", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) } + }); + + migrationBuilder.InsertData( + table: "Books", + columns: new[] { "Id", "AuthorId", "CategoryId", "CreatedAt", "Genre", "Isbn", "IsAvailable", "PublicationYear", "Title", "UpdatedAt" }, + values: new object[,] + { + { 1, 1, 1, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), "Детектив", "978-5-17-101234-5", true, 1939, "Десять негритят", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) }, + { 2, 1, 1, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), "Детектив", "978-5-17-101235-2", true, 1934, "Убийство в Восточном экспрессе", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) }, + { 3, 3, 2, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), "Фэнтези", "978-5-17-112345-6", true, 1997, "Гарри Поттер и философский камень", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) }, + { 4, 4, 3, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), "Роман", "978-5-699-12345-6", true, 1866, "Преступление и наказание", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) }, + { 5, 5, 5, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), "Роман", "978-5-699-23456-7", true, 1869, "Война и мир", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) }, + { 6, 2, 1, new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), "Детектив", "978-5-699-34567-8", true, 1902, "Шерлок Холмс: Собака Баскервилей", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) } + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Books", + keyColumn: "Id", + keyValue: 1); + + migrationBuilder.DeleteData( + table: "Books", + keyColumn: "Id", + keyValue: 2); + + migrationBuilder.DeleteData( + table: "Books", + keyColumn: "Id", + keyValue: 3); + + migrationBuilder.DeleteData( + table: "Books", + keyColumn: "Id", + keyValue: 4); + + migrationBuilder.DeleteData( + table: "Books", + keyColumn: "Id", + keyValue: 5); + + migrationBuilder.DeleteData( + table: "Books", + keyColumn: "Id", + keyValue: 6); + + migrationBuilder.DeleteData( + table: "Categories", + keyColumn: "Id", + keyValue: 4); + + migrationBuilder.DeleteData( + table: "Authors", + keyColumn: "Id", + keyValue: 1); + + migrationBuilder.DeleteData( + table: "Authors", + keyColumn: "Id", + keyValue: 2); + + migrationBuilder.DeleteData( + table: "Authors", + keyColumn: "Id", + keyValue: 3); + + migrationBuilder.DeleteData( + table: "Authors", + keyColumn: "Id", + keyValue: 4); + + migrationBuilder.DeleteData( + table: "Authors", + keyColumn: "Id", + keyValue: 5); + + migrationBuilder.DeleteData( + table: "Categories", + keyColumn: "Id", + keyValue: 1); + + migrationBuilder.DeleteData( + table: "Categories", + keyColumn: "Id", + keyValue: 2); + + migrationBuilder.DeleteData( + table: "Categories", + keyColumn: "Id", + keyValue: 3); + + migrationBuilder.DeleteData( + table: "Categories", + keyColumn: "Id", + keyValue: 5); + } + } +} diff --git a/BookLibrary/Migrations/20260217074602_UpdateBookSeedData.Designer.cs b/BookLibrary/Migrations/20260217074602_UpdateBookSeedData.Designer.cs new file mode 100644 index 0000000..410dd2c --- /dev/null +++ b/BookLibrary/Migrations/20260217074602_UpdateBookSeedData.Designer.cs @@ -0,0 +1,385 @@ +// +using System; +using BookLibrary.Database.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BookLibrary.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260217074602_UpdateBookSeedData")] + partial class UpdateBookSeedData + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BookLibrary.Models.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Biography") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("BirthYear") + .HasColumnType("integer"); + + b.Property("Country") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeathYear") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Authors"); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8850), + IsActive = true, + Name = "Агата Кристи", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8851) + }, + new + { + Id = 2, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8854), + IsActive = true, + Name = "Артур Конан Дойл", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8854) + }, + new + { + Id = 3, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8856), + IsActive = true, + Name = "Джоан Роулинг", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8857) + }, + new + { + Id = 4, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8858), + IsActive = true, + Name = "Фёдор Достоевский", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8858) + }, + new + { + Id = 5, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8860), + IsActive = true, + Name = "Лев Толстой", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8860) + }); + }); + + modelBuilder.Entity("BookLibrary.Models.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("Isbn"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("PublicationYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.HasIndex("Title"); + + b.ToTable("Books"); + + b.HasData( + new + { + Id = 1, + AuthorId = 1, + CategoryId = 1, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9251), + Genre = "Детектив", + ISBN = "978-5-17-101234-5", + IsAvailable = true, + PublicationYear = 1939, + Title = "Десять негритят", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9252) + }, + new + { + Id = 2, + AuthorId = 1, + CategoryId = 1, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9257), + Genre = "Детектив", + ISBN = "978-5-17-101235-2", + IsAvailable = true, + PublicationYear = 1934, + Title = "Убийство в Восточном экспрессе", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9258) + }, + new + { + Id = 3, + AuthorId = 3, + CategoryId = 2, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9261), + Genre = "Фэнтези", + ISBN = "978-5-17-112345-6", + IsAvailable = true, + PublicationYear = 1997, + Title = "Гарри Поттер и философский камень", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9261) + }, + new + { + Id = 4, + AuthorId = 4, + CategoryId = 3, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9264), + Genre = "Роман", + ISBN = "978-5-699-12345-6", + IsAvailable = true, + PublicationYear = 1866, + Title = "Преступление и наказание", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9264) + }, + new + { + Id = 5, + AuthorId = 5, + CategoryId = 5, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9266), + Genre = "Роман", + ISBN = "978-5-699-23456-7", + IsAvailable = true, + PublicationYear = 1869, + Title = "Война и мир", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9267) + }, + new + { + Id = 6, + AuthorId = 2, + CategoryId = 1, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9269), + Genre = "Детектив", + ISBN = "978-5-699-34567-8", + IsAvailable = true, + PublicationYear = 1902, + Title = "Шерлок Холмс: Сокращенное издание", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9269) + }, + new + { + Id = 7, + AuthorId = 4, + CategoryId = 4, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9272), + Genre = "Антиутопия", + ISBN = "978-5-17-999999-9", + IsAvailable = true, + PublicationYear = 1949, + Title = "Идиот", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9272) + }, + new + { + Id = 8, + AuthorId = 4, + CategoryId = 4, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9274), + Genre = "Фантастика", + ISBN = "978-5-17-888888-8", + IsAvailable = true, + PublicationYear = 1965, + Title = "Бесы", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9274) + }); + }); + + modelBuilder.Entity("BookLibrary.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9131), + Name = "Детектив", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9131) + }, + new + { + Id = 2, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9137), + Name = "Фэнтези", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9137) + }, + new + { + Id = 3, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9139), + Name = "Классика", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9140) + }, + new + { + Id = 4, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9141), + Name = "Научная фантастика", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9142) + }, + new + { + Id = 5, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9143), + Name = "Роман", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9143) + }); + }); + + modelBuilder.Entity("BookLibrary.Models.Book", b => + { + b.HasOne("BookLibrary.Models.Author", "Author") + .WithMany("Books") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BookLibrary.Models.Category", "Category") + .WithMany("Books") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("BookLibrary.Models.Author", b => + { + b.Navigation("Books"); + }); + + modelBuilder.Entity("BookLibrary.Models.Category", b => + { + b.Navigation("Books"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BookLibrary/Migrations/20260217074602_UpdateBookSeedData.cs b/BookLibrary/Migrations/20260217074602_UpdateBookSeedData.cs new file mode 100644 index 0000000..70081ba --- /dev/null +++ b/BookLibrary/Migrations/20260217074602_UpdateBookSeedData.cs @@ -0,0 +1,264 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace BookLibrary.Migrations +{ + /// + public partial class UpdateBookSeedData : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + table: "Authors", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8850), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8851) }); + + migrationBuilder.UpdateData( + table: "Authors", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8854), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8854) }); + + migrationBuilder.UpdateData( + table: "Authors", + keyColumn: "Id", + keyValue: 3, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8856), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8857) }); + + migrationBuilder.UpdateData( + table: "Authors", + keyColumn: "Id", + keyValue: 4, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8858), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8858) }); + + migrationBuilder.UpdateData( + table: "Authors", + keyColumn: "Id", + keyValue: 5, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8860), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8860) }); + + migrationBuilder.UpdateData( + table: "Books", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9251), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9252) }); + + migrationBuilder.UpdateData( + table: "Books", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9257), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9258) }); + + migrationBuilder.UpdateData( + table: "Books", + keyColumn: "Id", + keyValue: 3, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9261), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9261) }); + + migrationBuilder.UpdateData( + table: "Books", + keyColumn: "Id", + keyValue: 4, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9264), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9264) }); + + migrationBuilder.UpdateData( + table: "Books", + keyColumn: "Id", + keyValue: 5, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9266), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9267) }); + + migrationBuilder.UpdateData( + table: "Books", + keyColumn: "Id", + keyValue: 6, + columns: new[] { "CreatedAt", "Title", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9269), "Шерлок Холмс: Сокращенное издание", new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9269) }); + + migrationBuilder.InsertData( + table: "Books", + columns: new[] { "Id", "AuthorId", "CategoryId", "CreatedAt", "Genre", "Isbn", "IsAvailable", "PublicationYear", "Title", "UpdatedAt" }, + values: new object[,] + { + { 7, 4, 4, new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9272), "Антиутопия", "978-5-17-999999-9", true, 1949, "Идиот", new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9272) }, + { 8, 4, 4, new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9274), "Фантастика", "978-5-17-888888-8", true, 1965, "Бесы", new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9274) } + }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9131), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9131) }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9137), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9137) }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 3, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9139), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9140) }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 4, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9141), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9142) }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 5, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9143), new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9143) }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Books", + keyColumn: "Id", + keyValue: 7); + + migrationBuilder.DeleteData( + table: "Books", + keyColumn: "Id", + keyValue: 8); + + migrationBuilder.UpdateData( + table: "Authors", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) }); + + migrationBuilder.UpdateData( + table: "Authors", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) }); + + migrationBuilder.UpdateData( + table: "Authors", + keyColumn: "Id", + keyValue: 3, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) }); + + migrationBuilder.UpdateData( + table: "Authors", + keyColumn: "Id", + keyValue: 4, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) }); + + migrationBuilder.UpdateData( + table: "Authors", + keyColumn: "Id", + keyValue: 5, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1638) }); + + migrationBuilder.UpdateData( + table: "Books", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) }); + + migrationBuilder.UpdateData( + table: "Books", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) }); + + migrationBuilder.UpdateData( + table: "Books", + keyColumn: "Id", + keyValue: 3, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) }); + + migrationBuilder.UpdateData( + table: "Books", + keyColumn: "Id", + keyValue: 4, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) }); + + migrationBuilder.UpdateData( + table: "Books", + keyColumn: "Id", + keyValue: 5, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) }); + + migrationBuilder.UpdateData( + table: "Books", + keyColumn: "Id", + keyValue: 6, + columns: new[] { "CreatedAt", "Title", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853), "Шерлок Холмс: Собака Баскервилей", new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1853) }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 3, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 4, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 5, + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783), new DateTime(2026, 2, 15, 18, 4, 22, 24, DateTimeKind.Utc).AddTicks(1783) }); + } + } +} diff --git a/BookLibrary/Migrations/AppDbContextModelSnapshot.cs b/BookLibrary/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..281ada5 --- /dev/null +++ b/BookLibrary/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,382 @@ +// +using System; +using BookLibrary.Database.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BookLibrary.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BookLibrary.Models.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Biography") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("BirthYear") + .HasColumnType("integer"); + + b.Property("Country") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeathYear") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Authors"); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8850), + IsActive = true, + Name = "Агата Кристи", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8851) + }, + new + { + Id = 2, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8854), + IsActive = true, + Name = "Артур Конан Дойл", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8854) + }, + new + { + Id = 3, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8856), + IsActive = true, + Name = "Джоан Роулинг", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8857) + }, + new + { + Id = 4, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8858), + IsActive = true, + Name = "Фёдор Достоевский", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8858) + }, + new + { + Id = 5, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8860), + IsActive = true, + Name = "Лев Толстой", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(8860) + }); + }); + + modelBuilder.Entity("BookLibrary.Models.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("Isbn"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("PublicationYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.HasIndex("Title"); + + b.ToTable("Books"); + + b.HasData( + new + { + Id = 1, + AuthorId = 1, + CategoryId = 1, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9251), + Genre = "Детектив", + ISBN = "978-5-17-101234-5", + IsAvailable = true, + PublicationYear = 1939, + Title = "Десять негритят", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9252) + }, + new + { + Id = 2, + AuthorId = 1, + CategoryId = 1, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9257), + Genre = "Детектив", + ISBN = "978-5-17-101235-2", + IsAvailable = true, + PublicationYear = 1934, + Title = "Убийство в Восточном экспрессе", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9258) + }, + new + { + Id = 3, + AuthorId = 3, + CategoryId = 2, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9261), + Genre = "Фэнтези", + ISBN = "978-5-17-112345-6", + IsAvailable = true, + PublicationYear = 1997, + Title = "Гарри Поттер и философский камень", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9261) + }, + new + { + Id = 4, + AuthorId = 4, + CategoryId = 3, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9264), + Genre = "Роман", + ISBN = "978-5-699-12345-6", + IsAvailable = true, + PublicationYear = 1866, + Title = "Преступление и наказание", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9264) + }, + new + { + Id = 5, + AuthorId = 5, + CategoryId = 5, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9266), + Genre = "Роман", + ISBN = "978-5-699-23456-7", + IsAvailable = true, + PublicationYear = 1869, + Title = "Война и мир", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9267) + }, + new + { + Id = 6, + AuthorId = 2, + CategoryId = 1, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9269), + Genre = "Детектив", + ISBN = "978-5-699-34567-8", + IsAvailable = true, + PublicationYear = 1902, + Title = "Шерлок Холмс: Сокращенное издание", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9269) + }, + new + { + Id = 7, + AuthorId = 4, + CategoryId = 4, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9272), + Genre = "Антиутопия", + ISBN = "978-5-17-999999-9", + IsAvailable = true, + PublicationYear = 1949, + Title = "Идиот", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9272) + }, + new + { + Id = 8, + AuthorId = 4, + CategoryId = 4, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9274), + Genre = "Фантастика", + ISBN = "978-5-17-888888-8", + IsAvailable = true, + PublicationYear = 1965, + Title = "Бесы", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9274) + }); + }); + + modelBuilder.Entity("BookLibrary.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9131), + Name = "Детектив", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9131) + }, + new + { + Id = 2, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9137), + Name = "Фэнтези", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9137) + }, + new + { + Id = 3, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9139), + Name = "Классика", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9140) + }, + new + { + Id = 4, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9141), + Name = "Научная фантастика", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9142) + }, + new + { + Id = 5, + CreatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9143), + Name = "Роман", + UpdatedAt = new DateTime(2026, 2, 17, 7, 46, 1, 719, DateTimeKind.Utc).AddTicks(9143) + }); + }); + + modelBuilder.Entity("BookLibrary.Models.Book", b => + { + b.HasOne("BookLibrary.Models.Author", "Author") + .WithMany("Books") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BookLibrary.Models.Category", "Category") + .WithMany("Books") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("BookLibrary.Models.Author", b => + { + b.Navigation("Books"); + }); + + modelBuilder.Entity("BookLibrary.Models.Category", b => + { + b.Navigation("Books"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BookLibrary/Models/Author.cs b/BookLibrary/Models/Author.cs index 31ef7e2..cb0c710 100644 --- a/BookLibrary/Models/Author.cs +++ b/BookLibrary/Models/Author.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using BookLibrary.Validators; +using Newtonsoft.Json; namespace BookLibrary.Models; @@ -53,10 +54,15 @@ public class Author /// /// Дата создания записи /// + [JsonIgnore] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; /// /// Дата последнего обновления /// + [JsonIgnore] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + [JsonIgnore] + public IEnumerable? Books { get; set; } } diff --git a/BookLibrary/Models/Book.cs b/BookLibrary/Models/Book.cs index 34d1943..9a91264 100644 --- a/BookLibrary/Models/Book.cs +++ b/BookLibrary/Models/Book.cs @@ -1,42 +1,46 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; namespace BookLibrary.Models; public class Book { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } - [Required(ErrorMessage = "Название книги обязательно для заполнения")] - [StringLength(255, MinimumLength = 1, ErrorMessage = "Название должно быть от 1 до 255 символов")] - [Display(Name = "Название")] public string Title { get; set; } = string.Empty; - [Required(ErrorMessage = "Автор книги обязателен для заполнения")] - [StringLength(100, MinimumLength = 2, ErrorMessage = "Автор должен быть от 2 до 100 символов")] - [Display(Name = "Автор")] - public string Author { get; set; } = string.Empty; + [ForeignKey(nameof(Author))] + public int AuthorId { get; set; } + + [ForeignKey(nameof(Category))] + public int CategoryId { get; set; } - [Required(ErrorMessage = "ISBN обязателен для заполнения")] - [StringLength(17, MinimumLength = 10, ErrorMessage = "'{0}' должно быть длиной {2} символа(ов). Количество введенных символов: {1}.")] - [RegularExpression(@"^\d{3}-\d{2}-\d{4}-\d{3}-\d$", ErrorMessage = "ISBN должен быть в формате XXX-XX-XXXX-XXX-X (например: 978-56-9912-345-6)")] - [Display(Name = "ISBN")] public string ISBN { get; set; } = string.Empty; - [Required(ErrorMessage = "Год публикации обязателен для заполнения")] - [Range(1000, 2026, ErrorMessage = "Год публикации должен быть в диапазоне от 1000 до 2026")] - [Display(Name = "Год публикации")] public int PublicationYear { get; set; } - [StringLength(50, MinimumLength = 1, ErrorMessage = "Жанр должен быть от 1 до 50 символов")] - [Display(Name = "Жанр")] public string? Genre { get; set; } - [Display(Name = "Доступна")] public bool IsAvailable { get; set; } = true; - [Display(Name = "Дата создания")] + [JsonIgnore] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Display(Name = "Дата обновления")] + [JsonIgnore] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Навигационное свойство: автор книги + /// + [ForeignKey(nameof(AuthorId))] + public virtual Author? Author { get; set; } + + /// + /// Навигационное свойство: категория книги + /// + [ForeignKey(nameof(CategoryId))] + public virtual Category? Category { get; set; } } diff --git a/BookLibrary/Models/Category.cs b/BookLibrary/Models/Category.cs new file mode 100644 index 0000000..bf10da7 --- /dev/null +++ b/BookLibrary/Models/Category.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace BookLibrary.Models; + +public class Category +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + + /// + /// Название категории + /// + [Required(ErrorMessage = "Название категории обязательно для заполнения")] + [StringLength(100, MinimumLength = 2, ErrorMessage = "Название должно быть от 2 до 100 символов")] + public string Name { get; set; } = string.Empty; + + /// + /// Описание категории + /// + [StringLength(1500, ErrorMessage = "Описание не должно превышать 1500 символов")] + public string? Description { get; set; } + + /// + /// Дата создания записи + /// + [JsonIgnore] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Дата последнего обновления + /// + [JsonIgnore] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Навигационное свойство: коллекция книг в этой категории + /// + [JsonIgnore] + public virtual ICollection Books { get; set; } = new List(); +} diff --git a/BookLibrary/Repositories/AuthorRepositories/EFCore/EfCoreAuthorReadRepository.cs b/BookLibrary/Repositories/AuthorRepositories/EFCore/EfCoreAuthorReadRepository.cs new file mode 100644 index 0000000..fdffd55 --- /dev/null +++ b/BookLibrary/Repositories/AuthorRepositories/EFCore/EfCoreAuthorReadRepository.cs @@ -0,0 +1,74 @@ +using BookLibrary.Contracts.Repositories; +using BookLibrary.Contracts.Specifications; +using BookLibrary.Database.Context; +using BookLibrary.Exceptions.Base; +using BookLibrary.Models; +using BookLibrary.Specifications.Author; +using Microsoft.EntityFrameworkCore; + +namespace BookLibrary.Repositories.AuthorRepositories.EFCore; + +public class EfCoreAuthorReadRepository(AppDbContext context) : IAuthorReadRepository +{ + public async Task> GetAllAsync(AuthorFilterParams? filterParams = null, CancellationToken cancellationToken = default) + { + // Создаем спецификацию + var specification = new ArdalisAuthorSpecification(filterParams ?? new AuthorFilterParams()); + + // Применяем спецификацию + var query = ApplySpecification(specification); + + return await query.ToListAsync(cancellationToken); + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + var author = await context.Authors + .Include(b => b.Books) + .FirstOrDefaultAsync(b => b.Id == id, cancellationToken); + + return author ?? throw new ModelNotFoundException("Author"); + } + + private IQueryable ApplySpecification(IMySpecification spec) + { + var query = context.Authors.AsQueryable(); + + // Применяем Include (выражения) + query = spec.Includes.Aggregate(query, (current, include) => current.Include(include)); + + // Применяем Include (строки) + query = spec.IncludeStrings.Aggregate(query, (current, includeString) => current.Include(includeString)); + + // Применяем критерии фильтрации + if (spec.Criteria != null) + { + query = query.Where(spec.Criteria); + } + + // Применяем сортировку + if (spec.OrderBy.Count != 0) + { + query = spec.OrderBy.Aggregate(query, (current, orderBy) => current.OrderBy(orderBy)); + } + + if (spec.OrderByDescending.Count != 0) + { + query = spec.OrderByDescending.Aggregate(query, (current, orderByDesc) => current.OrderByDescending(orderByDesc)); + } + + // Если нет сортировки, применяем сортировку по умолчанию + if (spec.OrderBy.Count == 0 && spec.OrderByDescending.Count == 0) + { + query = query.OrderBy(b => b.Id); + } + + // Применяем пагинацию + if (spec is { IsPagingEnabled: true, Skip: not null, Take: not null }) + { + query = query.Skip(spec.Skip.Value).Take(spec.Take.Value); + } + + return query; + } +} diff --git a/BookLibrary/Repositories/AuthorRepositories/MemoryAuthorReadRepository.cs b/BookLibrary/Repositories/AuthorRepositories/Memory/MemoryAuthorReadRepository.cs similarity index 62% rename from BookLibrary/Repositories/AuthorRepositories/MemoryAuthorReadRepository.cs rename to BookLibrary/Repositories/AuthorRepositories/Memory/MemoryAuthorReadRepository.cs index 65b7ade..8a808d9 100644 --- a/BookLibrary/Repositories/AuthorRepositories/MemoryAuthorReadRepository.cs +++ b/BookLibrary/Repositories/AuthorRepositories/Memory/MemoryAuthorReadRepository.cs @@ -1,10 +1,11 @@ -using Ardalis.Specification.EntityFrameworkCore; -using BookLibrary.Contracts.Repositories; +using BookLibrary.Contracts.Repositories; +using BookLibrary.Exceptions.Base; using BookLibrary.Models; +using BookLibrary.Specifications; using BookLibrary.Specifications.Author; using BookLibrary.Storage; -namespace BookLibrary.Repositories.AuthorRepositories; +namespace BookLibrary.Repositories.AuthorRepositories.Memory; /// /// In-Memory репозиторий для авторов. @@ -22,9 +23,18 @@ public Task> GetAllAsync(AuthorFilterParams? filterParams = if (filterParams != null) { var specification = new ArdalisAuthorSpecification(filterParams); - query = query.WithSpecification(specification); + query = query.ApplyMySpecification(specification); } return Task.FromResult>(query.ToList()); } + + public Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + var author = _authors.FirstOrDefault(a => a.Id == id); + + return author == null + ? throw new ModelNotFoundException("Author") + : Task.FromResult(author); + } } diff --git a/BookLibrary/Repositories/BookRepositories/EFCore/EfCoreBookRepository.cs b/BookLibrary/Repositories/BookRepositories/EFCore/EfCoreBookRepository.cs new file mode 100644 index 0000000..5552a39 --- /dev/null +++ b/BookLibrary/Repositories/BookRepositories/EFCore/EfCoreBookRepository.cs @@ -0,0 +1,151 @@ +using BookLibrary.Contracts.Repositories; +using BookLibrary.Contracts.Specifications; +using BookLibrary.Data.Requests.Book; +using BookLibrary.Data.Responses.Categories; +using BookLibrary.Database.Context; +using BookLibrary.Exceptions; +using BookLibrary.Models; +using BookLibrary.Specifications.Book; +using Microsoft.EntityFrameworkCore; + +namespace BookLibrary.Repositories.BookRepositories.EFCore; + +public class EfCoreBookRepository(AppDbContext context) : IBookRepository +{ + public async Task> GetAllAsync( + BookFilterParams? filterParams = null, + CancellationToken cancellationToken = default) + { + // Создаем спецификацию + var specification = new MyBookSpecification(filterParams ?? new BookFilterParams()); + + // Применяем спецификацию + var query = ApplySpecification(specification); + + return await query.ToListAsync(cancellationToken); + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + var book = await context.Books + .Include(b => b.Author) + .Include(b => b.Category) + .FirstOrDefaultAsync(b => b.Id == id, cancellationToken); + + return book ?? throw BookNotFoundException.ById(id); + } + + public async Task CreateAsync(Book book, CancellationToken cancellationToken = default) + { + book.CreatedAt = DateTime.UtcNow; + book.UpdatedAt = DateTime.UtcNow; + + context.Books.Add(book); + await context.SaveChangesAsync(cancellationToken); + + // Загружаем навигационные свойства + await context.Entry(book) + .Reference(b => b.Author) + .LoadAsync(cancellationToken); + + await context.Entry(book) + .Reference(b => b.Category) + .LoadAsync(cancellationToken); + + return book; + } + + public async Task UpdateAsync(int id, UpdateBookRequest data, CancellationToken cancellationToken = default) + { + var existingBook = await GetByIdAsync(id, cancellationToken); + + data.ApplyTo(existingBook); + existingBook.UpdatedAt = DateTime.UtcNow; + + context.Books.Update(existingBook); + await context.SaveChangesAsync(cancellationToken); + + return existingBook; + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + { + var book = await context.Books + .FirstOrDefaultAsync(b => b.Id == id, cancellationToken); + + if (book == null) + return false; + + context.Books.Remove(book); + await context.SaveChangesAsync(cancellationToken); + + return true; + } + + public async Task> GetCategoryStatisticsAsync( + bool withBooks = false, + CancellationToken cancellationToken = default) + { + var result = await context.Books + .Where(b => b.Category != null) // Защита от нарушения FK + .GroupBy(b => b.Category!.Name) + .ToListAsync(cancellationToken); + return await context.Books + .Where(b => b.Category != null) // Защита от нарушения FK + .GroupBy(b => b.Category!.Name) + .Select(g => new CategoryStatisticsDto( + g.Key, + g.Count(), + (int)g.Average(b => (double)b.PublicationYear), + // в зависимости от параметра вставляем книги в ответ или нет + withBooks ? g.Select(b => new BookSummaryDto(b.Id, b.Title, b.PublicationYear)).ToList() : null + )) + .ToListAsync(cancellationToken); + } + + + /// + /// Применяет спецификацию к запросу + /// + private IQueryable ApplySpecification(IMySpecification spec) + { + var query = context.Books.AsQueryable(); + + // Применяем Include (выражения) + query = spec.Includes.Aggregate(query, (current, include) => current.Include(include)); + + // Применяем Include (строки) + query = spec.IncludeStrings.Aggregate(query, (current, includeString) => current.Include(includeString)); + + // Применяем критерии фильтрации + if (spec.Criteria != null) + { + query = query.Where(spec.Criteria); + } + + // Применяем сортировку + if (spec.OrderBy.Count != 0) + { + query = spec.OrderBy.Aggregate(query, (current, orderBy) => current.OrderBy(orderBy)); + } + + if (spec.OrderByDescending.Count != 0) + { + query = spec.OrderByDescending.Aggregate(query, (current, orderByDesc) => current.OrderByDescending(orderByDesc)); + } + + // Если нет сортировки, применяем сортировку по умолчанию + if (spec.OrderBy.Count == 0 && spec.OrderByDescending.Count == 0) + { + query = query.OrderBy(b => b.Id); + } + + // Применяем пагинацию + if (spec is { IsPagingEnabled: true, Skip: not null, Take: not null }) + { + query = query.Skip(spec.Skip.Value).Take(spec.Take.Value); + } + + return query; + } +} diff --git a/BookLibrary/Repositories/BookRepositories/Memory/MemoryBookRepository.cs b/BookLibrary/Repositories/BookRepositories/Memory/MemoryBookRepository.cs new file mode 100644 index 0000000..4e5882a --- /dev/null +++ b/BookLibrary/Repositories/BookRepositories/Memory/MemoryBookRepository.cs @@ -0,0 +1,153 @@ +using BookLibrary.Contracts.Repositories; +using BookLibrary.Data.Requests.Book; +using BookLibrary.Data.Responses.Categories; +using BookLibrary.Storage; +using BookLibrary.Exceptions; +using BookLibrary.Models; +using BookLibrary.Specifications; +using BookLibrary.Specifications.Book; + +namespace BookLibrary.Repositories.BookRepositories.Memory; + +public class MemoryBookRepository(InMemoryStore store) : IBookRepository +{ + private readonly List _books = store.GetCollection(); + private readonly List _authors = store.GetCollection(); + private readonly List _categories = store.GetCollection(); + + public Task> GetAllAsync(BookFilterParams? filterParams = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var query = _books.AsQueryable(); + + if (filterParams != null) + { + var specification = new MyBookSpecification(filterParams); + query = query.ApplyMySpecification(specification); + var result = query.ToList(); + + ClearRelatedData(result); + + if (specification.IncludeStrings.Any()) + { + LoadRelatedData(result, specification.IncludeStrings); + } + } + + return Task.FromResult>(query.ToList()); + } + + public Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + var book = _books.FirstOrDefault(b => b.Id == id); + + return book == null + ? throw BookNotFoundException.ById(id) + : Task.FromResult(book); + } + + public Task CreateAsync(Book book, CancellationToken cancellationToken = default) + { + + book.Id = _books.Count == 0 ? 1 : _books.Max(b => b.Id) + 1; + _books.Add(book); + + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(book); + } + + public Task UpdateAsync(int id, UpdateBookRequest data, CancellationToken cancellationToken = default) + { + var index = _books.FindIndex(b => b.Id == id); + + if (index == -1) + throw BookNotFoundException.ById(id); + + var book = _books[index]; + data.ApplyTo(book); + + // Сохраняем оригинальный Id из параметра (на случай если во входящем объекте он другой) + book.Id = id; + book.UpdatedAt = DateTime.UtcNow; + _books[index] = book; + + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(book); + } + + public Task DeleteAsync(int id, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var index = _books.FindIndex(b => b.Id == id); + + if (index == -1) + return Task.FromResult(false); + + _books.RemoveAt(index); + return Task.FromResult(true); + } + + public Task> GetCategoryStatisticsAsync( + bool withBooks = false, + CancellationToken cancellationToken = default) + { + var statistics = _books + .GroupBy(b => b.CategoryId) + .Select(g => { + var category = _categories.FirstOrDefault(c => c.Id == g.Key); + return new CategoryStatisticsDto( + category?.Name ?? "Без категории", + g.Count(), + (int)g.Average(b => (double)b.PublicationYear), + withBooks ? g.Select(b => new BookSummaryDto(b.Id, b.Title, b.PublicationYear)).ToList() : null + ); + }) + .ToList(); + + return Task.FromResult>(statistics); + } + + private void LoadRelatedData(List books, IEnumerable includes) + { + foreach (var include in includes) + { + switch (include) + { + case nameof(Book.Author): + foreach (var book in books) + { + book.Author = _authors.FirstOrDefault(a => a.Id == book.AuthorId); + } + break; + + case nameof(Book.Category): + foreach (var book in books) + { + book.Category = _categories.FirstOrDefault(c => c.Id == book.CategoryId); + } + break; + + // При добавлении новой связи нужно добавить: + // case nameof(Book.Publisher): + // foreach (var book in books) + // { + // book.Publisher = _publishers.FirstOrDefault(p => p.Id == book.PublisherId); + // } + // break; + } + } + } + + private void ClearRelatedData(List books) + { + foreach (var book in books) + { + book.Author = null; + book.Category = null; + // При добавлении новых связей нужно добавить сюда: + // book.Publisher = null; + } + } +} diff --git a/BookLibrary/Repositories/BookRepositories/MemoryBookRepository.cs b/BookLibrary/Repositories/BookRepositories/MemoryBookRepository.cs deleted file mode 100644 index e18fec1..0000000 --- a/BookLibrary/Repositories/BookRepositories/MemoryBookRepository.cs +++ /dev/null @@ -1,77 +0,0 @@ -using BookLibrary.Contracts.Repositories; -using BookLibrary.Storage; -using BookLibrary.Exceptions; -using BookLibrary.Models; -using BookLibrary.Specifications; -using BookLibrary.Specifications.Book; - -namespace BookLibrary.Repositories.BookRepositories; - -public class MemoryBookRepository(InMemoryStore store) : IBookRepository -{ - private readonly List _books = store.GetCollection(); - - public Task> GetAllAsync(BookFilterParams? filterParams = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - var query = _books.AsQueryable(); - - if (filterParams != null) - { - var specification = new MyBookSpecification(filterParams); - query = query.ApplyMySpecification(specification); - } - - return Task.FromResult>(query.ToList()); - } - - public Task GetByIdAsync(int id, CancellationToken cancellationToken = default) - { - var book = _books.FirstOrDefault(b => b.Id == id); - - if (book == null) - { - throw BookNotFoundException.ById(id); - } - - return Task.FromResult(book); - } - - public Task CreateAsync(Book book, CancellationToken cancellationToken = default) - { - book.Id = _books.Max(b => b.Id) + 1; - _books.Add(book); - - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(book); - } - - public Task UpdateAsync(int id, Book book, CancellationToken cancellationToken = default) - { - var index = _books.FindIndex(b => b.Id == id); - - if (index == -1) - throw BookNotFoundException.ById(id); - - // Сохраняем оригинальный Id из параметра (на случай если во входящем объекте он другой) - book.Id = id; - _books[index] = book; - - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(book); - } - - public Task DeleteAsync(int id, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - var index = _books.FindIndex(b => b.Id == id); - - if (index == -1) - return Task.FromResult(false); - - _books.RemoveAt(index); - return Task.FromResult(true); - } -} diff --git a/BookLibrary/Repositories/CategoryRepositories/EFCore/EfCoreCategoryRepository.cs b/BookLibrary/Repositories/CategoryRepositories/EFCore/EfCoreCategoryRepository.cs new file mode 100644 index 0000000..cfacb5d --- /dev/null +++ b/BookLibrary/Repositories/CategoryRepositories/EFCore/EfCoreCategoryRepository.cs @@ -0,0 +1,79 @@ +using BookLibrary.Contracts.Repositories; +using BookLibrary.Data.Responses.Categories; +using BookLibrary.Database.Context; +using BookLibrary.Exceptions.Base; +using BookLibrary.Models; +using Microsoft.EntityFrameworkCore; + +namespace BookLibrary.Repositories.CategoryRepositories.EFCore; + +public class EfCoreCategoryRepository(AppDbContext context) : ICategoryRepository +{ + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await context.Categories + .Include(c => c.Books) + .ToListAsync(cancellationToken); + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + var category = await context.Categories + .Include(c => c.Books) + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + if (category == null) + throw new ModelNotFoundException($"Category with ID {id} not found"); + + return category; + } + + public async Task CreateAsync(Category category, CancellationToken cancellationToken = default) + { + category.CreatedAt = DateTime.UtcNow; + category.UpdatedAt = DateTime.UtcNow; + + context.Categories.Add(category); + await context.SaveChangesAsync(cancellationToken); + + return category; + } + + public async Task UpdateAsync(int id, Category category, CancellationToken cancellationToken = default) + { + var existingCategory = await GetByIdAsync(id, cancellationToken); + + existingCategory.Name = category.Name; + existingCategory.Description = category.Description; + existingCategory.UpdatedAt = DateTime.UtcNow; + + context.Categories.Update(existingCategory); + await context.SaveChangesAsync(cancellationToken); + + return existingCategory; + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + { + var category = await context.Categories + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + if (category == null) + return false; + + // Проверяем, есть ли книги в этой категории + var hasBooks = await context.Books + .AnyAsync(b => b.CategoryId == id, cancellationToken); + + if (hasBooks) + { + throw new InvalidOperationException( + $"Cannot delete category with ID {id} because it has associated books"); + } + + context.Categories.Remove(category); + await context.SaveChangesAsync(cancellationToken); + + return true; + } +} diff --git a/BookLibrary/Services/AuthorService.cs b/BookLibrary/Services/AuthorService.cs new file mode 100644 index 0000000..3576172 --- /dev/null +++ b/BookLibrary/Services/AuthorService.cs @@ -0,0 +1,15 @@ +using BookLibrary.Contracts.Repositories; +using BookLibrary.Contracts.Services; +using BookLibrary.Models; + +namespace BookLibrary.Services; + +public class AuthorService(IAuthorReadRepository readRepository) : IAuthorService +{ + public async Task GetAuthorByIdAsync(int id, CancellationToken cancellationToken = default) + { + var result = readRepository.GetByIdAsync(id, cancellationToken); + + return await result; + } +} diff --git a/BookLibrary/Services/BookService.cs b/BookLibrary/Services/BookService.cs index b984b4f..d3f4390 100644 --- a/BookLibrary/Services/BookService.cs +++ b/BookLibrary/Services/BookService.cs @@ -1,6 +1,6 @@ -using System.Collections.ObjectModel; -using BookLibrary.Contracts.Repositories; +using BookLibrary.Contracts.Repositories; using BookLibrary.Contracts.Services; +using BookLibrary.Data.Requests.Book; using BookLibrary.Models; using BookLibrary.Specifications.Book; @@ -25,9 +25,12 @@ public async Task CreateBookAsync(Book book, CancellationToken cancellatio return await _repository.CreateAsync(book, cancellationToken); } - public async Task UpdateBookAsync(int id, Book book, CancellationToken cancellationToken = default) + public async Task UpdateBookAsync(int id, UpdateBookRequest data, CancellationToken cancellationToken = default) { - return await _repository.UpdateAsync(id, book, cancellationToken); + var book = new Book(); + data.ApplyTo(book); + + return await _repository.UpdateAsync(id, data, cancellationToken); } public async Task DeleteBookAsync(int id, CancellationToken cancellationToken = default) diff --git a/BookLibrary/Services/CategoryService.cs b/BookLibrary/Services/CategoryService.cs new file mode 100644 index 0000000..a3b4d35 --- /dev/null +++ b/BookLibrary/Services/CategoryService.cs @@ -0,0 +1,15 @@ +using BookLibrary.Contracts.Repositories; +using BookLibrary.Contracts.Services; +using BookLibrary.Data.Responses.Categories; + +namespace BookLibrary.Services; + +public class CategoryService(IBookRepository bookRepository) : ICategoryService +{ + public async Task> GetStatisticsAsync( + bool withBooks = false, + CancellationToken cancellationToken = default) + { + return await bookRepository.GetCategoryStatisticsAsync(withBooks, cancellationToken); + } +} diff --git a/BookLibrary/Specifications/Author/ArdalisAuthorSpecification.cs b/BookLibrary/Specifications/Author/ArdalisAuthorSpecification.cs index f06e304..688e589 100644 --- a/BookLibrary/Specifications/Author/ArdalisAuthorSpecification.cs +++ b/BookLibrary/Specifications/Author/ArdalisAuthorSpecification.cs @@ -1,93 +1,50 @@ -using Ardalis.Specification; -using System.Linq.Expressions; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; namespace BookLibrary.Specifications.Author; -public class ArdalisAuthorSpecification : Specification +public class ArdalisAuthorSpecification : MySpecification { // Поля разрешенные для сортировки - private static readonly Dictionary>> SortSelectors = - new(StringComparer.OrdinalIgnoreCase) - { - ["name"] = a => a.Name, - ["country"] = a => a.Country ?? string.Empty, - ["birthyear"] = a => a.BirthYear ?? 0, - ["createdat"] = a => a.CreatedAt, - ["id"] = a => a.Id - }; + private readonly AuthorFilterParams _filterParams; + private readonly string? _nameFilterPattern; // Шаблон для ILike: %значение% public ArdalisAuthorSpecification(AuthorFilterParams filterParams) { - // Фильтрация - if (!string.IsNullOrWhiteSpace(filterParams.Name)) - { - Query.Where(a => a.Name.Contains(filterParams.Name!, StringComparison.OrdinalIgnoreCase)); - } - - if (!string.IsNullOrWhiteSpace(filterParams.Country)) - { - Query.Where(a => a.Country == filterParams.Country); - } - - if (filterParams.BirthYear.HasValue) - { - Query.Where(a => a.BirthYear == filterParams.BirthYear.Value); - } - - if (filterParams.IsActive.HasValue) - { - Query.Where(a => a.IsActive == filterParams.IsActive.Value); - } - - // Множественная сортировка - ApplyMultipleSorting(filterParams.SortBy, filterParams.SortByDesc); - - // Пагинация - if (filterParams.PageSize.HasValue && filterParams.PageNumber.HasValue) - { - Query.Skip((filterParams.PageNumber.Value - 1) * filterParams.PageSize.Value) - .Take(filterParams.PageSize.Value); - } + _filterParams = filterParams; + _nameFilterPattern = !string.IsNullOrEmpty(filterParams.Name) + ? $"%{filterParams.Name}%" + : null; + Initialize(); } - - private void ApplyMultipleSorting(string? sortBy, string? sortByDesc) + + private void Initialize() { - var hasPrimarySort = false; - - // Сортировка по возрастанию (первое поле = основная, остальные = дополнительные) - if (!string.IsNullOrWhiteSpace(sortBy)) - { - var fields = sortBy.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - foreach (var field in fields) - { - if (SortSelectors.TryGetValue(field, out var selector)) - { - Query.OrderBy(selector!); - hasPrimarySort = true; - } - } - } - - // Сортировка по убыванию - if (!string.IsNullOrWhiteSpace(sortByDesc)) - { - var fields = sortByDesc.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - foreach (var field in fields) - { - if (SortSelectors.TryGetValue(field, out var selector)) - { - Query.OrderByDescending(selector!); - hasPrimarySort = true; - } - } - } - - // Если сортировка не указана, сортируем по Id по умолчанию - if (!hasPrimarySort) + if (_filterParams.WithBooks) { - Query.OrderBy(a => a.Id); + AddInclude(nameof(Models.Author.Books)); // Строковое имя для совместимости } + + ApplySorting( + _filterParams.SortBy, + _filterParams.SortByDesc, + defaultSort: a => a.Id // Сортировка по умолчанию + ); } + + public override Expression>? Criteria => + author => string.IsNullOrEmpty(_nameFilterPattern) || + EF.Functions.ILike(author.Name, _nameFilterPattern); + + protected override Expression>? GetSortExpression(string field) => + field switch + { + "name" => a => a.Name, + "country" => a => a.Country ?? string.Empty, + "birthyear" => a => a.BirthYear ?? int.MaxValue, + "createdat" => a => a.CreatedAt, + "updatedat" => a => a.UpdatedAt, + "id" => a => a.Id, + _ => null + }; } diff --git a/BookLibrary/Specifications/Author/AuthorFilterParams.cs b/BookLibrary/Specifications/Author/AuthorFilterParams.cs index cb7ba6d..03326c8 100644 --- a/BookLibrary/Specifications/Author/AuthorFilterParams.cs +++ b/BookLibrary/Specifications/Author/AuthorFilterParams.cs @@ -33,6 +33,12 @@ public class AuthorFilterParams public string? SortBy { get; set; } // Поля по возрастанию public string? SortByDesc { get; set; } // Поля по убыванию + /// + /// Включать ли информацию об авторах в ответ + /// + public bool WithBooks { get; set; } = false; + + /// /// Количество записей на страницу /// diff --git a/BookLibrary/Specifications/Book/BookFilterParams.cs b/BookLibrary/Specifications/Book/BookFilterParams.cs index 54cd641..244c8be 100644 --- a/BookLibrary/Specifications/Book/BookFilterParams.cs +++ b/BookLibrary/Specifications/Book/BookFilterParams.cs @@ -8,10 +8,20 @@ public class BookFilterParams public string? Title { get; set; } /// - /// Фильтр по автору (частичное совпадение) + /// Фильтр по автору + /// + public int? AuthorId { get; set; } + + /// + /// Фильтр по имени автора /// public string? Author { get; set; } + /// + /// Фильтр по категории + /// + public int? CategoryId { get; set; } + /// /// Фильтр по жанру (точное совпадение) /// @@ -28,14 +38,20 @@ public class BookFilterParams public bool? IsAvailable { get; set; } /// - /// Сортировка: title, author, year, createdAt + /// Сортировка: title, author, year, createdAt и т.д. /// - public string? SortBy { get; set; } + public string? SortBy { get; set; } // Поля по возрастанию + public string? SortByDesc { get; set; } // Поля по убыванию /// - /// Направление сортировки: asc, desc + /// Включать ли информацию об авторах в ответ + /// + public bool WithAuthors { get; set; } = false; + + /// + /// Включать ли информацию о категориях в ответ /// - public string? SortDirection { get; set; } = "asc"; + public bool WithCategories { get; set; } = false; /// /// Количество записей на страницу diff --git a/BookLibrary/Specifications/Book/MyBookSpecification.cs b/BookLibrary/Specifications/Book/MyBookSpecification.cs index 9dc0a93..3bea4a0 100644 --- a/BookLibrary/Specifications/Book/MyBookSpecification.cs +++ b/BookLibrary/Specifications/Book/MyBookSpecification.cs @@ -9,17 +9,39 @@ public class MyBookSpecification : MySpecification public MyBookSpecification(BookFilterParams filterParams) { _filterParams = filterParams; + Initialize(); + } + + private void Initialize() + { + // Добавляем Include только если запрошено + if (_filterParams.WithAuthors) + { + AddInclude(nameof(Models.Book.Author)); // ← Строковое имя для in-memory + // AddInclude(b => b.Author!); // ← Expression для EF Core (опционально) + } - ApplySorting(); + if (_filterParams.WithCategories) + { + AddInclude(nameof(Models.Book.Category)); + // AddInclude(b => b.Category!); + } + ApplySorting( + _filterParams.SortBy, + _filterParams.SortByDesc, + defaultSort: b => b.Id // Сортировка по умолчанию + ); } - public override Expression>? Criteria + public override Expression> Criteria { get { return book => - (_filterParams.Title == null || book.Title.ToLowerInvariant().Contains(_filterParams.Title.ToLowerInvariant())) && - (_filterParams.Author == null || book.Author.ToLowerInvariant().Contains(_filterParams.Author.ToLowerInvariant())) && + (_filterParams.Title == null || book.Title.ToLower().Contains(_filterParams.Title.ToLower())) && + (_filterParams.Author == null || book.Author != null && book.Author.Name.ToLower().Contains(_filterParams.Author.ToLower())) && + (_filterParams.AuthorId == null || book.AuthorId == _filterParams.AuthorId.Value) && + (_filterParams.CategoryId == null || book.CategoryId == _filterParams.CategoryId.Value) && (_filterParams.Genre == null || book.Genre == _filterParams.Genre) && (_filterParams.Year == null || book.PublicationYear == _filterParams.Year) && (_filterParams.IsAvailable == null || book.IsAvailable == _filterParams.IsAvailable); @@ -29,43 +51,23 @@ public MyBookSpecification(BookFilterParams filterParams) public override int? Take => _filterParams.PageSize; public override int? Skip => _filterParams.PageSize * (_filterParams.PageNumber - 1); public override bool IsPagingEnabled => _filterParams.PageSize.HasValue; - - private void ApplySorting() + + protected override Expression>? GetSortExpression(string field) { - if (string.IsNullOrEmpty(_filterParams.SortBy)) - return; - - var sortDirection = (_filterParams.SortDirection ?? "asc").ToLowerInvariant(); - - switch (_filterParams.SortBy.ToLowerInvariant()) + return field switch { - case "title": - if (sortDirection == "asc") - AddOrderBy(b => b.Title); - else - AddOrderByDescending(b => b.Title); - break; - - case "author": - if (sortDirection == "asc") - AddOrderBy(b => b.Author); - else - AddOrderByDescending(b => b.Author); - break; - - case "year": - if (sortDirection == "asc") - AddOrderBy(b => b.PublicationYear); - else - AddOrderByDescending(b => b.PublicationYear); - break; - - case "createdat": - if (sortDirection == "asc") - AddOrderBy(b => b.CreatedAt); - else - AddOrderByDescending(b => b.CreatedAt); - break; - } + "title" => b => b.Title, + "author" => b => b.Author != null ? b.Author.Name : string.Empty, + "authorid" => b => b.AuthorId, + "category" => b => b.Category != null ? b.Category.Name : string.Empty, + "categoryid" => b => b.CategoryId, + "publicationyear" => b => b.PublicationYear, + "genre" => b => b.Genre ?? string.Empty, + "isavailable" => b => b.IsAvailable, + "createdat" => b => b.CreatedAt, + "updatedat" => b => b.UpdatedAt, + "id" => b => b.Id, + _ => null + }; } } diff --git a/BookLibrary/Specifications/MySpecification.cs b/BookLibrary/Specifications/MySpecification.cs index 3557213..fda8533 100644 --- a/BookLibrary/Specifications/MySpecification.cs +++ b/BookLibrary/Specifications/MySpecification.cs @@ -38,4 +38,56 @@ protected virtual void AddOrderByDescending(Expression> orderByD { _orderByDescending.Add(orderByDescendingExpression); } + + // ===== СОРТИРОВКА ПО СТРОКОВЫМ ПАРАМЕТРАМ ===== + /// + /// Применяет сортировку на основе строковых параметров sortBy (ASC) и sortByDesc (DESC). + /// Поддерживает множественные поля через запятую. Добавляет сортировку по умолчанию, если не задана. + /// + /// Поля для сортировки по возрастанию (через запятую) + /// Поля для сортировки по убыванию (через запятую) + /// Выражение для сортировки по умолчанию (если не заданы параметры) + protected void ApplySorting( + string? sortBy, + string? sortByDesc, + Expression>? defaultSort = null) + { + // Применяем сортировку по возрастанию + if (!string.IsNullOrWhiteSpace(sortBy)) + { + foreach (var field in ParseSortFields(sortBy)) + { + if (GetSortExpression(field) is { } expr) + AddOrderBy(expr); + } + } + + // Применяем сортировку по убыванию + if (!string.IsNullOrWhiteSpace(sortByDesc)) + { + foreach (var field in ParseSortFields(sortByDesc)) + { + if (GetSortExpression(field) is { } expr) + AddOrderByDescending(expr); + } + } + + // Добавляем сортировку по умолчанию, если ничего не применено + if (OrderBy.Count == 0 && OrderByDescending.Count == 0 && defaultSort != null) + { + AddOrderBy(defaultSort); + } + } + + /// + /// Возвращает выражение сортировки по имени поля. Должен быть переопределён в дочерних классах. + /// + /// Имя поля в нижнем регистре (без учёта регистра) + /// Выражение для сортировки или null, если поле не поддерживается + protected virtual Expression>? GetSortExpression(string field) => null; + + // ===== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ===== + private static IEnumerable ParseSortFields(string input) => + input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(f => f.Trim().ToLowerInvariant()); } diff --git a/BookLibrary/Storage/InMemoryStore.cs b/BookLibrary/Storage/InMemoryStore.cs index f9997af..fa203f6 100644 --- a/BookLibrary/Storage/InMemoryStore.cs +++ b/BookLibrary/Storage/InMemoryStore.cs @@ -22,12 +22,68 @@ public List GetCollection() where T : class { return typeof(T) switch { + _ when typeof(T) == typeof(Category) => InitializeCategories() as List, _ when typeof(T) == typeof(Author) => InitializeAuthors() as List, _ when typeof(T) == typeof(Book) => InitializeBooks() as List, _ => [] }; } + private List InitializeCategories() + { + return + [ + new Category + { + Id = 1, + Name = "Классическая литература", + Description = "Великие произведения мировой литературы", + CreatedAt = DateTime.UtcNow.AddYears(-2), + UpdatedAt = DateTime.UtcNow.AddYears(-1) + }, + new Category + { + Id = 2, + Name = "Фантастика", + Description = "Научная фантастика и фэнтези", + CreatedAt = DateTime.UtcNow.AddYears(-2), + UpdatedAt = DateTime.UtcNow.AddYears(-1) + }, + new Category + { + Id = 3, + Name = "Детектив", + Description = "Детективные романы и триллеры", + CreatedAt = DateTime.UtcNow.AddYears(-2), + UpdatedAt = DateTime.UtcNow.AddYears(-1) + }, + new Category + { + Id = 4, + Name = "Приключения", + Description = "Приключенческие романы", + CreatedAt = DateTime.UtcNow.AddYears(-2), + UpdatedAt = DateTime.UtcNow.AddYears(-1) + }, + new Category + { + Id = 5, + Name = "Антиутопия", + Description = "Антиутопические произведения", + CreatedAt = DateTime.UtcNow.AddYears(-2), + UpdatedAt = DateTime.UtcNow.AddYears(-1) + }, + new Category + { + Id = 6, + Name = "Сатира", + Description = "Сатирические произведения", + CreatedAt = DateTime.UtcNow.AddYears(-2), + UpdatedAt = DateTime.UtcNow.AddYears(-1) + } + ]; + } + private List InitializeAuthors() { return [ @@ -173,7 +229,8 @@ private List InitializeBooks() { Id = 1, Title = "Преступление и наказание", - Author = "Фёдор Достоевский", + AuthorId = 1, + CategoryId = 1, ISBN = "978-5-699-12345-6", PublicationYear = 1866, Genre = "Роман", @@ -185,7 +242,8 @@ private List InitializeBooks() { Id = 2, Title = "Война и мир", - Author = "Лев Толстой", + AuthorId = 2, + CategoryId = 1, ISBN = "978-5-17-012345-7", PublicationYear = 1869, Genre = "Роман", @@ -197,7 +255,8 @@ private List InitializeBooks() { Id = 3, Title = "Мастер и Маргарита", - Author = "Михаил Булгаков", + AuthorId = 3, + CategoryId = 2, ISBN = "978-5-699-54321-0", PublicationYear = 1967, Genre = "Фантастика", @@ -209,7 +268,8 @@ private List InitializeBooks() { Id = 4, Title = "Анна Каренина", - Author = "Лев Толстой", + AuthorId = 2, + CategoryId = 1, ISBN = "978-5-17-012346-4", PublicationYear = 1877, Genre = "Роман", @@ -221,7 +281,8 @@ private List InitializeBooks() { Id = 5, Title = "1984", - Author = "Джордж Оруэлл", + AuthorId = 5, + CategoryId = 5, ISBN = "978-0-452-28423-4", PublicationYear = 1949, Genre = "Антиутопия", @@ -233,7 +294,8 @@ private List InitializeBooks() { Id = 6, Title = "Старик и море", - Author = "Эрнест Хемингуэй", + AuthorId = 6, + CategoryId = 1, ISBN = "978-0-684-80122-3", PublicationYear = 1952, Genre = "Повесть", @@ -245,7 +307,8 @@ private List InitializeBooks() { Id = 7, Title = "Тихий Дон", - Author = "Михаил Шолохов", + AuthorId = 4, + CategoryId = 1, ISBN = "978-5-17-012347-1", PublicationYear = 1940, Genre = "Роман", @@ -257,13 +320,79 @@ private List InitializeBooks() { Id = 8, Title = "Собачье сердце", - Author = "Михаил Булгаков", + AuthorId = 3, + CategoryId = 6, ISBN = "978-5-699-54322-7", PublicationYear = 1925, Genre = "Сатира", IsAvailable = true, CreatedAt = DateTime.UtcNow.AddYears(-1), UpdatedAt = DateTime.UtcNow.AddMonths(-2) + }, + new Book + { + Id = 9, + Title = "Маленький принц", + AuthorId = 7, + CategoryId = 1, + ISBN = "978-0-15-601219-5", + PublicationYear = 1943, + Genre = "Повесть", + IsAvailable = true, + CreatedAt = DateTime.UtcNow.AddMonths(-10), + UpdatedAt = DateTime.UtcNow.AddMonths(-8) + }, + new Book + { + Id = 10, + Title = "Убийство в Восточном экспрессе", + AuthorId = 8, + CategoryId = 3, + ISBN = "978-0-00-711930-3", + PublicationYear = 1934, + Genre = "Детектив", + IsAvailable = true, + CreatedAt = DateTime.UtcNow.AddMonths(-15), + UpdatedAt = DateTime.UtcNow.AddMonths(-10) + }, + new Book + { + Id = 11, + Title = "Приключения Тома Сойера", + AuthorId = 9, + CategoryId = 4, + ISBN = "978-0-14-243707-5", + PublicationYear = 1876, + Genre = "Приключенческий роман", + IsAvailable = true, + CreatedAt = DateTime.UtcNow.AddMonths(-20), + UpdatedAt = DateTime.UtcNow.AddMonths(-15) + }, + new Book + { + Id = 12, + Title = "Евгений Онегин", + AuthorId = 10, + CategoryId = 1, + ISBN = "978-5-08-004567-8", + PublicationYear = 1833, + Genre = "Роман в стихах", + IsAvailable = true, + CreatedAt = DateTime.UtcNow.AddYears(-5), + UpdatedAt = DateTime.UtcNow.AddYears(-4) + }, + new Book + { + Id = 13, + Title = "Превращение", + AuthorId = 11, + CategoryId = 2, + ISBN = "978-0-525-56470-2", + PublicationYear = 1915, + Genre = "Новелла", + IsAvailable = true, + CreatedAt = DateTime.UtcNow.AddMonths(-16), + UpdatedAt = DateTime.UtcNow.AddMonths(-8) } ]; } diff --git a/BookLibrary/Validators/BookValidator.cs b/BookLibrary/Validators/BookValidator.cs index 4da1059..4f88d7b 100644 --- a/BookLibrary/Validators/BookValidator.cs +++ b/BookLibrary/Validators/BookValidator.cs @@ -1,12 +1,15 @@ -using BookLibrary.Models; +using BookLibrary.Data.Requests.Book; using BookLibrary.Validators.Rules; using FluentValidation; namespace BookLibrary.Validators; -public class BookValidator : AbstractValidator +public class BookValidator : AbstractValidator { - public BookValidator() + public BookValidator( + AuthorExistsRule authorExistsRule, + CategoryExistsRule categoryExistsRule + ) { RuleFor(x => x.Title) .NotEmpty().WithMessage("Название книги обязательно для заполнения") @@ -14,11 +17,15 @@ public BookValidator() .MaximumLength(255).WithMessage("Название должно быть от 1 до 255 символов") .WithName("title"); - RuleFor(x => x.Author) - .NotEmpty().WithMessage("Автор книги обязателен для заполнения") - .MinimumLength(2).WithMessage("Автор должен быть от 2 до 100 символов") - .MaximumLength(100).WithMessage("Автор должен быть от 2 до 100 символов") - .WithName("author"); + RuleFor(x => x.AuthorId) + .GreaterThan(0).WithMessage("Автор книги обязателен для заполнения") + .SetValidator(authorExistsRule) + .WithName("authorId"); + + RuleFor(x => x.CategoryId) + .GreaterThan(0).WithMessage("Категория книги обязательна для заполнения") + .SetValidator(categoryExistsRule) + .WithName("categoryId"); RuleFor(x => x.ISBN) .ValidIsbn() diff --git a/BookLibrary/Validators/CategoryValidator.cs b/BookLibrary/Validators/CategoryValidator.cs new file mode 100644 index 0000000..8a92b85 --- /dev/null +++ b/BookLibrary/Validators/CategoryValidator.cs @@ -0,0 +1,20 @@ +using BookLibrary.Models; +using FluentValidation; + +namespace BookLibrary.Validators; + +public class CategoryValidator : AbstractValidator +{ + public CategoryValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Название категории обязательно для заполнения") + .MinimumLength(2).WithMessage("Название должно быть от 2 до 100 символов") + .MaximumLength(100).WithMessage("Название должно быть от 2 до 100 символов") + .WithName("name"); + + RuleFor(x => x.Description) + .MaximumLength(500).WithMessage("Описание не должно превышать 500 символов") + .WithName("description"); + } +} diff --git a/BookLibrary/Validators/Rules/AuthorExistsRule.cs b/BookLibrary/Validators/Rules/AuthorExistsRule.cs new file mode 100644 index 0000000..5f5b93c --- /dev/null +++ b/BookLibrary/Validators/Rules/AuthorExistsRule.cs @@ -0,0 +1,30 @@ +using BookLibrary.Database.Context; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace BookLibrary.Validators.Rules; + +public class AuthorExistsRule : AbstractValidator +{ + public AuthorExistsRule(IServiceProvider serviceProvider) + { + RuleFor(id => id) + .MustAsync(async (authorId, cancellation) => + { + // Получаем контекст только если он зарегистрирован + var context = serviceProvider.GetService(typeof(AppDbContext)); + + if (context == null) + { + // InMemory режим — пропускаем проверку существования + return true; + } + + var dbContext = (AppDbContext)context; + + return await dbContext.Authors + .AnyAsync(a => a.Id == authorId, cancellation); + }) + .WithMessage("Автор с указанным ID не найден"); + } +} diff --git a/BookLibrary/Validators/Rules/CategoryExistsRule.cs b/BookLibrary/Validators/Rules/CategoryExistsRule.cs new file mode 100644 index 0000000..77c3c9b --- /dev/null +++ b/BookLibrary/Validators/Rules/CategoryExistsRule.cs @@ -0,0 +1,30 @@ +using BookLibrary.Database.Context; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace BookLibrary.Validators.Rules; + +public class CategoryExistsRule : AbstractValidator +{ + public CategoryExistsRule(IServiceProvider serviceProvider) + { + RuleFor(id => id) + .MustAsync(async (categoryId, cancellation) => + { + // Получаем контекст только если он зарегистрирован + var context = serviceProvider.GetService(typeof(AppDbContext)); + + if (context == null) + { + // InMemory режим — пропускаем проверку существования + return true; + } + + var dbContext = (AppDbContext)context; + + return await dbContext.Categories + .AnyAsync(c => c.Id == categoryId, cancellation); + }) + .WithMessage("Категория с указанным ID не найдена"); + } +} diff --git a/BookLibrary/appsettings.json b/BookLibrary/appsettings.json index ae8666b..50a7f13 100644 --- a/BookLibrary/appsettings.json +++ b/BookLibrary/appsettings.json @@ -1,7 +1,10 @@ { "ApplicationSettings": { + "Storage": { + "Provider": "PostgreSQL" + }, "Logging": { - "IncludeStackTraceInLogs": false + "IncludeStackTraceInLogs": true }, "Exceptions": { "MessageCategory": "Ошибка сервера.", @@ -13,6 +16,9 @@ "EmptyResponseBody": false } }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=CSharpDB;Username=postgres;Password=password;Include Error Detail=true" + }, "Serilog": { "Using": [ "Serilog.Sinks.Console", diff --git a/DESCRIBE-5.md b/DESCRIBE-5.md new file mode 100644 index 0000000..b3cb944 --- /dev/null +++ b/DESCRIBE-5.md @@ -0,0 +1,597 @@ +# BookLibrary - Реализация подключения к базе данных и расширение функциональности + +## 📊 1. Настройка подключения к базе данных + +### Конфигурация подключения +Реализована поддержка PostgreSQL с гибкой настройкой через конфигурационные файлы: + +#### Конфигурация в `appsettings.json`: +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=CSharpDB;Username=postgres;Password=password;Include Error Detail=true" + }, + "Storage": { + "Provider": "Postgres" // или "InMemory" + } +} +``` + +#### Ключевые параметры подключения: +- **Host** - адрес сервера базы данных +- **Port** - порт подключения (5432 для PostgreSQL) +- **Database** - имя базы данных +- **Username/Password** - учетные данные +- **Include Error Detail** - детализация ошибок для отладки + +## 🔄 2. Гибкая система выбора хранилища + +### Архитектура переключения хранилищ +Реализован механизм выбора типа хранилища на основе конфигурации: + +#### Методы регистрации: +```csharp +// Регистрация InMemory хранилища +services.ConfigureInMemoryStorage(); + +// Регистрация PostgreSQL хранилища +services.ConfigurePostgresStorage(configuration); +``` + +#### Логика выбора: +```csharp +var storageProvider = configuration["Storage:Provider"]; +if (storageProvider == "Postgres") +{ + services.ConfigurePostgresStorage(configuration); +} +else +{ + services.ConfigureInMemoryStorage(); +} +``` + +### Преимущества подхода: +- **Гибкость** - быстрая смена хранилища через конфигурацию +- **Изоляция** - независимость бизнес-логики от типа хранилища +- **Тестируемость** - можно использовать InMemory для юнит-тестов + +## 🗄️ 3. Управление миграциями и сидерами + +### Работа с Entity Framework Core + +#### Создание и применение миграций: +```bash +# Создание начальной схемы базы данных +dotnet ef migrations add InitialCreate + +# Добавление миграции с начальными данными +dotnet ef migrations add SeedInitialData + +# Обновление существующих данных +dotnet ef migrations add UpdateBookSeedData + +# Применение миграций к базе данных +dotnet ef database update + +# Откат к конкретной миграции +dotnet ef database update 20260215180422_SeedInitialData +``` + +### Система сидеров (Data Seeders) + +#### Архитектура сидеров: +```csharp +// Базовый интерфейс сидера +public interface IDataSeeder +{ + Task SeedAsync(AppDbContext context); +} + +// Фабрика для автоматического выполнения сидеров +public class SeederFactory +{ + public static void ApplySeeders(ModelBuilder modelBuilder) + { + // Автоматическое обнаружение и выполнение всех IDataSeeder + } +} +``` + +#### Пример сидера для книг: +```csharp +public class BookSeeder : IDataSeeder +{ + public void Seed(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasData( + new Category { Id = 1, Name = "Детектив"}, + new Category { Id = 2, Name = "Фэнтези"}, + new Category { Id = 3, Name = "Классика"}, + new Category { Id = 4, Name = "Научная фантастика"}, + new Category { Id = 5, Name = "Роман"} + ); + } +} +``` + +### Пример структуры папки Migrations: + +``` +BookLibrary.Infrastructure/Data/Migrations/ +├── 20250215120000_InitialCreate.cs +├── 20250215120000_InitialCreate.Designer.cs +├── 20250215180422_SeedInitialData.cs +├── 20250215180422_SeedInitialData.Designer.cs +├── 20250216103045_UpdateBookSeedData.cs +├── 20250216103045_UpdateBookSeedData.Designer.cs +└── AppDbContextModelSnapshot.cs +``` + +### Установка инструментов разработки .NET CLI Tools: +```bash +dotnet tool install --global dotnet-ef +``` + +## 📋 4. Разделение данных через DTO + +### Проблема и решение +Для разделения данных запроса от модели предметной области реализована система DTO: + +#### Проблема: +- Сложность разделения атрибутов для полей модели +- Разные наборы свойств для ввода (POST) и вывода (GET) +- Навигационные свойства не должны отображаться в Swagger при создании + +#### Реализация: +```csharp +// DTO для создания книги +using System.ComponentModel.DataAnnotations; +using BookLibrary.Models; + +namespace BookLibrary.Data.Requests.Book; + +public class CreateBookRequest +{ + public string Title { get; set; } = string.Empty; + public int AuthorId { get; set; } + public int CategoryId { get; set; } + public string ISBN { get; set; } = string.Empty; + public int PublicationYear { get; set; } + public string? Genre { get; set; } + public bool IsAvailable { get; set; } = true; + + /// + /// Преобразует запрос в модель книги + /// + public Models.Book ToBook() => new() + { + Title = Title, + AuthorId = AuthorId, + CategoryId = CategoryId, + ISBN = ISBN, + PublicationYear = PublicationYear, + Genre = Genre, + IsAvailable = IsAvailable + }; +} + + +// Контроллер с использованием DTO +using BookLibrary.Contracts.Services; +using BookLibrary.Data.Requests.Book; +using BookLibrary.Models; +using Microsoft.AspNetCore.Mvc; + +namespace BookLibrary.Controllers.Books; + +[ApiController] +[Route("api/books")] +[Produces("application/json")] +[Tags("Books")] +public class StoreBookController(IBookService bookService) + : ApiControllerBase +{ + /// + /// Добавить новую книгу + /// + /// Данные книги + /// Токен отмены + /// Созданная книга + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task ExecuteAsync( + [FromBody] CreateBookRequest request, + CancellationToken cancellationToken = default) + { + var createdBook = await bookService.CreateBookAsync(request.ToBook(), cancellationToken); + + return Created(createdBook); + } +} + +``` +#### Ключевые моменты: +- изменен атрибут [FromBody] в методе public async Task Create([FromBody] CreateBookRequest request) +- преобразование DTO в модель request.ToBook() + +### Управление навигационными свойствами: +```csharp +public class Book +{ + // Свойства модели + public int Id { get; set; } + public string Title { get; set; } + public int Year { get; set; } + public int AuthorId { get; set; } + public int CategoryId { get; set; } + + // Навигационные свойства + [JsonIgnore] // Для исключения из Swagger и запросов создания + [ForeignKey(nameof(AuthorId))] + public virtual Author? Author { get; set; } + + [JsonIgnore] + [ForeignKey(nameof(CategoryId))] + public virtual Category? Category { get; set; } +} +``` + +## ✅ 5. Расширенная система валидации + +### Кастомные правила валидации +Реализованы специализированные правила для проверки существования связанных сущностей: + +#### Добавлены новые Правила валидации: +```csharp +// Проверка существования автора +public class AuthorExistsRule : AbstractValidator +{ + public AuthorExistsRule(IServiceProvider serviceProvider) + { + RuleFor(id => id) + .MustAsync(async (authorId, cancellation) => + { + // Получаем контекст только если он зарегистрирован + var context = serviceProvider.GetService(typeof(AppDbContext)); + + if (context == null) + { + // InMemory режим — пропускаем проверку существования + return true; + } + + var dbContext = (AppDbContext)context; + + return await dbContext.Authors + .AnyAsync(a => a.Id == authorId, cancellation); + }) + .WithMessage("Автор с указанным ID не найден"); + } +} +// Проверка существования категории +public class CategoryExistsRule : AbstractValidator +{ + // Аналогичная реализация +} +``` + +#### Использование в валидаторах: +```csharp +public class BookValidator : AbstractValidator +{ + public BookValidator(IAuthorRepository authorRepository, ICategoryRepository categoryRepository) + { + RuleFor(x => x.AuthorId) + .GreaterThan(0).WithMessage("Автор книги обязателен для заполнения") + .SetValidator(authorExistsRule) + .WithName("authorId"); + + RuleFor(x => x.CategoryId) + .GreaterThan(0).WithMessage("Категория книги обязательна для заполнения") + .SetValidator(categoryExistsRule) + .WithName("categoryId"); + } +} +``` + +### Асинхронная валидация: +```csharp +// Изменение метода фильтра для поддержки асинхронной валидации в классе +public class ValidationResponseFilter(IServiceProvider serviceProvider) : IAsyncActionFilter + +protected override async Task OnActionExecutionAsync( + ActionExecutingContext context, + ActionExecutionDelegate next) +{ + ... + + // ВЫЗЫВАЕМ ВАЛИДАТОР ТОЛЬКО ЕСЛИ НЕТ ОШИБОК ПРИВЯЗКИ + if (hasBindingErrors) continue; + + // АСИНХРОННЫЙ ВЫЗОВ! + var validationResult = await validator.ValidateAsync(new ValidationContext(parameterValue)); + + ... +} +``` + +## 🔍 6. Усовершенствованная система спецификаций + +### Базовый класс для сортировки +Вынесена общая логика обработки сортировки в базовый класс: + +```csharp +public abstract class MySpecification : ISpecification +{ + protected void ApplySorting( + string? sortBy, + string? sortByDesc, + Expression>? defaultSort = null) + { + // Логика применения сортировки по полям + if (!string.IsNullOrEmpty(sortBy)) + { + OrderBy = GetSortExpression(sortBy); + IsDescending = false; + } + else if (!string.IsNullOrEmpty(sortByDesc)) + { + OrderBy = GetSortExpression(sortByDesc); + IsDescending = true; + } + else if (defaultSort != null) + { + OrderBy = defaultSort; + } + } + + private Expression> GetSortExpression(string propertyName) + { + // Динамическое создание выражения сортировки + } +} +``` + +### Параметры включения связанных данных +Реализована поддержка параметров для загрузки связанных сущностей: + +```csharp +// Пример использования в спецификации книг +public class BookSpecification : MySpecification +{ + public BookSpecification( + string? title = null, + int? minYear = null, + int? maxYear = null, + bool withAuthors = false, + bool withCategories = false) + { + // Критерии фильтрации + + if (withAuthors) + { + Includes.Add(x => x.Author); + IncludeStrings.Add("Author"); + } + + if (withCategories) + { + Includes.Add(x => x.Category); + IncludeStrings.Add("Category"); + } + } +} +``` + +## ⚡ 7. Решение проблем с InMemory хранилищем + +### Проблема с методом AddInclude +Метод `AddInclude` предназначен для EF Core и не работает с InMemory хранилищем: + +#### Проблема: +- `AddInclude` работает только с `IQueryable`, поддерживающим отложенную загрузку +- В InMemory случае нужно вручную загружать связанные данные + +#### Решение: +```csharp +public class InMemoryRepository : IRepository where T : class +{ + public async Task> ListAsync(ISpecification specification) + { + var result = ApplySpecification(_collection.AsQueryable(), specification).ToList(); + + // Ручная загрузка связанных данных ТОЛЬКО если запрошено + if (specification.IncludeStrings.Any()) + { + LoadRelatedData(result, specification.IncludeStrings); + } + + // Очистка навигационных свойств перед возвратом + ClearRelatedData(result); + + return result; + } + + private void LoadRelatedData(List entities, List includeStrings) + { + // Ручная загрузка связанных данных из соответствующих коллекций + } + + private void ClearRelatedData(List entities) + { + // Очистка навигационных свойств для предотвращения утечки данных + foreach (var entity in entities.OfType()) + { + entity.Author = null; + entity.Category = null; + } + } +} +``` + +## 🌐 8. Новые маршруты API + +### Расширенная функциональность конечных точек + +#### 1. Фильтрация и сортировка авторов: +``` +GET /api/authors?Name=михаил&sortByDesc=birthYear +``` +- **Фильтрация** по имени автора +- **Сортировка** по году рождения в порядке убывания + +#### 2. Получение книг конкретного автора: +``` +GET /api/authors/{id}/books +``` +- Возвращает все книги указанного автора +- Поддерживает параметры фильтрации и сортировки + +#### 3. Статистика по категориям: +``` +GET /api/categories/statistics?withBooks=true +``` +- Группировка книг по категориям +- Опциональное включение списка книг в каждой категории +- Статистика по количеству книг + +## 🏗️ 9. Репозитории для работы с базой данных + +### Реализация EfCore репозиториев +Созданы специализированные репозитории для работы с Entity Framework Core: + +```csharp +// Репозиторий для книг +public class EfCoreBookRepository : EfCoreRepository, IBookRepository +{ + public EfCoreBookRepository(AppDbContext context) : base(context) + { + } + + public async Task> GetByAuthorIdAsync(int authorId) + { + return await _context.Books + .Where(b => b.AuthorId == authorId) + .ToListAsync(); + } +} + +// Репозиторий для авторов +public class EfCoreAuthorRepository : EfCoreRepository, IAuthorRepository +{ + // Специализированные методы для работы с авторами +} + +// Репозиторий для категорий +public class EfCoreCategoryRepository : EfCoreRepository, ICategoryRepository +{ + // Специализированные методы для работы с категориями +} +``` + +## 🧪 10. Тестирование с использованием TestContainers + +### Инфраструктура для интеграционных тестов +Реализована система тестирования с изолированными контейнерами базы данных: + +#### Фабрика тестового окружения: +```csharp +public class BookLibraryTestContainersFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder("postgres:15-alpine") + .WithDatabase("test") + .WithUsername("testuser") + .WithPassword("password") + .WithCleanUp(true) + .Build(); + + // Для отладки: публичное свойство строки подключения + public string TestConnectionString => _dbContainer.GetConnectionString(); + + public async Task InitializeAsync() + { + // 🔤 Исправляем кодировку КОНСОЛИ + // 🔇 Отключаем логи Testcontainers + // 🔒 КРИТИЧЕСКАЯ ПРОВЕРКА: Защита от случайного подключения к боевой БД + // Дополнительная проверка: убеждаемся, что подключены к контейнеру + } + + public new async Task DisposeAsync() + { + Console.WriteLine("🧹 Очистка: остановка и удаление контейнера..."); + await _dbContainer.DisposeAsync(); + Console.WriteLine("✅ Контейнер удален. БД полностью уничтожена."); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // 🔒 ШАГ 1: Явно устанавливаем окружение ТЕСТОВОЕ + // 🔒 ШАГ 2: Переопределяем строку подключения ЧЕРЕЗ ПЕРЕМЕННУЮ ОКРУЖЕНИЯ (высший приоритет!) + // 🔒 ШАГ 3: Дополнительно переопределяем через конфигурацию + // 🔒 ШАГ 4: Заменяем контекст БД на тот, что использует строку из контейнера + // 🔒 ШАГ 5: 🔇 Отключаем логи приложения (только Critical) + } + +``` + +#### Преимущества подхода: +- **Изоляция** - каждый тест работает с чистой базой данных +- **Реалистичность** - использование реальной PostgreSQL вместо InMemory +- **Автоматизация** - автоматическое создание и удаление контейнеров +- **Скорость** - быстрые тесты без настройки внешней инфраструктуры + +### Пример теста: +```csharp +[Fact] + public async Task CreateBook_ValidRequest_ReturnsCreatedBook() + { + var request = new CreateBookRequest + { + Title = "Integration Test Book", + AuthorId = 1, // Из сидера + CategoryId = 1, // Из сидера + ISBN = "978-51-2345-678-9", + PublicationYear = 2026, + Genre = "Тест", + IsAvailable = true + }; + + var response = await _client.PostAsJsonAsync("/api/books", request); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var apiResponse = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(apiResponse); + Assert.NotNull(apiResponse.Data); + Assert.True(apiResponse.Success); + Assert.Equal("Integration Test Book", apiResponse.Data.Title); + Assert.Equal(2026, apiResponse.Data.PublicationYear); + } +``` + +## 🎯 Заключение + +Реализованная система предоставляет: + +### Ключевые достижения: +1. **Гибкое хранилище** - быстрый переключатель между InMemory и PostgreSQL +2. **Управление миграциями** - полный контроль над схемой базы данных +3. **Автоматические сидеры** - удобное заполнение начальных данных +4. **Чистая архитектура** - разделение DTO и моделей предметной области +5. **Расширенная валидация** - проверка существования связанных сущностей +6. **Универсальные спецификации** - поддержка сортировки и включения связанных данных +7. **Решение проблем InMemory** - корректная работа с навигационными свойствами +8. **Расширенные API маршруты** - богатая функциональность конечных точек +9. **Специализированные репозитории** - оптимизированные запросы к базе данных +10. **Надежное тестирование** - изолированные тесты с TestContainers + +### Технологический стек: +- **.NET 8** - основная платформа +- **Entity Framework Core** - ORM для работы с базой данных +- **PostgreSQL** - основная реляционная база данных +- **TestContainers** - изолированное тестирование +- **FluentValidation** - расширенная валидация +- **Swagger** - документация API diff --git a/README-5.md b/README-5.md new file mode 100644 index 0000000..6bc9699 --- /dev/null +++ b/README-5.md @@ -0,0 +1,91 @@ +# 📚 Блок 5. Entity Framework Core с PostgreSQL + +## 🎯 Общее описание +Интегрируйте Entity Framework Core и PostgreSQL в ваше REST API для библиотеки книг. Замените статическое хранилище на базу данных, реализуйте связи между сущностями и расширьте функционал API. + +**Стек:** ASP.NET Core, Entity Framework Core, PostgreSQL, Docker, xUnit + +--- + +## Важно! +Выполняйте это задание после выполнения блока 4. Создайте от ветки main свою, слейте эту ветку EF-Core-5 в вашу ветку и выполняйте задание. + +## 📋 Задачи + +### 🐳 Задача 1: Настройка базы данных + +1. Запустите PostgreSQL контейнер в Docker с помощью предоставленной команды. +``` +docker run -d \ + --name postgres-db \ + -e POSTGRES_DB=mydatabase \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=password \ + -p 5432:5432 \ + postgres:latest +``` +2. Добавьте в проект необходимые NuGet пакеты для работы с EF Core и PostgreSQL. +3. Настройте строку подключения к базе данных в `appsettings.json`. +4. Зарегистрируйте контекст данных в DI-контейнере. + +### 📊 Задача 2: Создание сущностей + +1. Создайте сущность `Author` со свойствами: + - `Id` (int, первичный ключ) + - `FirstName` (string, обязательное) + - `LastName` (string, обязательное) + - `Bio` (string, необязательное) + +2. Создайте сущность `Category` со свойствами: + - `Id` (int, первичный ключ) + - `Name` (string, обязательное, уникальное) + - `Description` (string, необязательное) + +3. Модифицируйте существующую сущность `Book`: + - Добавьте свойство `CategoryId` (int, внешний ключ) + - Добавьте свойство `AuthorId` (int, внешний ключ) + - Добавьте навигационные свойства `Category` и `Author` + - Удалите свойство `Author` из строки, так как теперь это будет связь с сущностью + +4. Настройте связи в DbContext: + - Одна книга принадлежит одному автору (Author → Books: один ко многим) + - Одна книга принадлежит одной категории (Category → Books: один ко многим) + +### 🗄️ Задача 3: Миграции + +1. Создайте первую миграцию с созданием всех таблиц с именем `Initial`. +2. Примените миграцию к базе данных. +3. Убедитесь, что таблицы созданы в PostgreSQL. +4. Тестовые данные накатите вручную, скрипт представлен в items + +### 🔍 Задача 4: Рефакторинг API + +1. Обновите существующие CRUD endpoints для работы с базой данных (через Repository) вместо статического списка. +2. При создании книги теперь нужно указывать `AuthorId` и `CategoryId`. +3. При обновлении книги нужно обновлять связи с автором и категорией. + +### 📈 Задача 5: Новые endpoints + +1. **GET /api/books/with-details** + - Возвращает список книг с полной информацией об авторе и категории + - Используйте `Include()` для загрузки связанных данных + +2. **GET /api/authors/{id}/books** + - Возвращает все книги указанного автора + - Включите информацию о категории для каждой книги + +3. **GET /api/categories/statistics** + - Возвращает статистику по категориям: + - Название категории + - Количество книг в категории + - Средний год публикации книг в категории + - Используйте группировку и агрегацию LINQ + +### 🧪 Задача 6: Тестирование + +1. Напишите unit тесты с помощью Moq для новых классов Repository. +2. Напишите интеграционные тесты для всех трех новых endpoints (только положительные сценарии, используйте TestContainers). + +--- + +**Удачи в интеграции EF Core с PostgreSQL! 🚀** diff --git a/Web-4.sln b/Web-4.sln index 264c57b..2392b6b 100644 --- a/Web-4.sln +++ b/Web-4.sln @@ -9,6 +9,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "items", "items", "{F5ED5F77-3BCB-4AB8-9A2F-9BD9B41BE044}" ProjectSection(SolutionItems) = preProject README.md = README.md + README-5.md = README-5.md DESCRIBE.md = DESCRIBE.md EndProjectSection EndProject