v1.0.0 last updated 2021-12-19 01:45 Copyright © Mateusz Gienieczko 2021
Zakładam znajomość Javy, bo wszyscy ją mieli na kursie PO.
Zakładam znajomośc podstaw programowania obiektowego.
DISCLAIMER: I’ll be using English in random places.
DISCLAIMER: Bardzo dużo “lies to children”, oznaczam je 🙊
Instalujemy Code’a: https://code.visualstudio.com/Download
Klonujemy kod z https://github.com/V0ldek/CSharp-LightningCourse/tree/master
Otwieramy Code’a w folderze z repo:
code .
Instalujemy extensions, o które poprosi nas Code w prawym dolnym rogu. Najważniejsze to rozszerzenie o tajemniczej nazwie “C#”. Poza tym polecam zainstalować NuGet Gallery.
Otwieramy plik lightning-course.dib
. Wymaga on rozszerzenia .NET Interactive Notebooks.
Ten plik to interaktywny notebook zawierający przyspieszony kurs C#. Można go sobie czytać, odpalać kod, edytować go i korzystać z wszelkich udogodnień edycji C# w Codzie.
ASP .NET (Core) to flagowy framework do tworzenie aplikacji webowych na .NET-cie (por. Django w Pythonie, Spring w Jabie).
Najpopularniejszą metodą było ASP .NET Core MVC, bazujący na metajęzyku Razor, a później oparten na nim Razor Pages. Razor pozwala generować HTML-a ale odkąd mamy .NET 5 mamy też…
Blazor pozwala na jeszcze większą integrację C# z frontendem – dzięki niemu możemy pisać cały front w C# i zapomnieć o istnieniu JS-a!
Blazor występuje w dwóch smakach – Blazor Server i Blazor Webassembly. My będziemy korzystać z Blazor Server, bo jest prostszy.
W Blazor Server serwer w ASP .NET Core generuje HTML-a i wysyła go klientowi. W tym momencie następuje stałe połączenie klienta do serwera za pomocą SignalR – protokołu RPC. Klient korzysta z frontendu i generuje zdarzenia, które przesyłane są do serwera, a ten wysyła z powrotem zmiany, które powinny zajść na stronie.
Będziemy tworzyć aplikację odpowiadającą dobrze znanemu modelowi, w którym mamy konferencję naukową, Prace, Autorów, Referaty i Sesje.
Zaczynamy od setupu: musimy zainstalować .NET 6.
https://dotnet.microsoft.com/en-us/download
Zakładam, że możecie pracować na Windowsie lub Linuxie. Jeśli używacie Windowsa, dobrze zainstalować Visual Studio 2022 Community, bo jest zwyczajnie wygodniejsze od VS Code’a. Reszta tutoriala będzie jednak z wykorzystaniem VS Code’a, bo jest multiplatformowy.
Jeśli jeszcze tego nie zrobiłeś, zainstaluj VS Code: https://code.visualstudio.com/Download
Tworzymy nowy projekt korzystając z szablonu blazorserver
:
dotnet new blazorserver -o Conference
cd Conference
Jeśli nie używaliśmy wcześniej dotneta, to żeby zadziałał nam https musimy jeszcze zrobić:
dotnet dev-certs https --trust
Dostaniemy systemowy prompt o dodaniu nowego certyfikatu.
Warto zawczasu zainicjalizować sobie gita:
git init
dotnet new gitignore
Template zawiera małą przykładową aplikację pokazującą temperaturę. Sprawdzamy czy wszystko działa:
dotnet build
dotnet run
Powinniśmy dostać mniej więcej coś takiego:
info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://localhost:7105
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5020
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: D:\Code\year5\csharp\Conference\
Wchodzimy na localhost:5020
w przeglądarce. Możemy sobie poklikać w aplikacji.
Przy developmencie lepiej użyć komendy dotnet watch
, która pozwala nam bezboleśnie wykonać hot-reload i szybko wyświetlić nowe zmiany.
Dobrze, to teraz coś sobie pozmieniamy. Odpalamy Code’a
code .
Dalej konieczne są rozszerzenia. Najważniejsze to rozszerzenie o tajemniczej nazwie “C#”. Poza tym polecam zainstalować NuGet Gallery.
Cały serwer jest zdefiniowany w głównym pliku Program.cs
.
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Conference.Data;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddSingleton<WeatherForecastService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");
app.Run();
Po kolei:
Inversion of Control jest sposobem na spełnienie SOLID-owego D. Załóżmy, że nasz kod chce wyciągnąć coś z bazy danych. Ma do tego repozytorium:
public class Duck
{
public string Name { get; set; }
public Color Color { get; set; }
}
public interface IRepository
{
Duck GetDuckByName(string name);
}
public class Repository : IRepository, IDisposable
{
/* ... */
public Repository(string connectionString)
{
/* ... */
}
/* ... */
}
Przykładowa logika wyglądałaby jakoś tak:
public class DuckService
{
private const ConnectionString = "UserID=posgres;Password=postgres;Host=localhost;Port=5432;Database=duck_db";
public static void Logic()
{
using(var repository = new Repository(ConnectionString));
var duck = repository.GetDuckByName("Jacuś");
Console.WriteLine(duck is null ? "Nie ma Jacusia :(" : "Jest Jacuś! :>");
}
}
Mamy tutaj bardzo mocny coupling między Mainem
a repozytorium. Jest on wręcz nietestowalny. “New is glue”, and glueing the code is bad.
Tutaj DuckService
ma kontrolę nad tym, jakiego repozytorium używa. Trzeba tę kontrolę odwrócić (IoC) i tę zależność od repozytorium wstrzyknąć (Dependency Injection, DI).
public class DuckService
{
private const ConnectionString = "UserID=posgres;Password=postgres;Host=localhost;Port=5432;Database=duck_db";
private readonly IRepository _repository;
public DuckService(IRepostiory repository) =>
_repository = repository;
public static void Logic()
{
var duck = _repository.GetDuckByName("Jacuś");
Console.WriteLine(duck is null ? "Nie ma Jacusia :(" : "Jest Jacuś! :>");
}
}
Teraz to korzystający z DuckService
ma kontrolę nad tym, jakie repozytorium dostanie aplikacja. W szczególności może to ustawić za pomocą statycznych metadanych np. w pliku konfiguracyjnym. Teraz tę metodę można łatwo przetestować, bo możemy podać testowe repozytorium, nad którym mamy pełną kontrolę.
W ASP .NET Core odbywa się to przez builder.Services
. Możemy dodawać tam nasze serwisy i wstrzykiwać je w inne, lub prosto w nasz kod w .razor.
Przy wstrzykiwaniu określamy scope serwisu. Są trzy:
Scoped jest z reguły najlepszym domyślnym wyborem.
Kontenery zajmują się IDisposable
za nas.
Zacznijmy od czegoś prostego. Powiedzmy, że chcemy móc wyświetlić listę autorów, których mamy dostępnych w naszym systemie i dodać tam autora.
Zaczynamy od kodu w C#. Tworzymy klasę reprezentującą autora (/Data/Author.cs
):
namespace Conference.Data;
public class Author
{
public int Id { get; init; }
public string Name { get; init; }
public string Surname { get; init; }
public string DisplayName => $"{Surname}, {Name.First()}.";
public Author(int authorId, string name, string surname) =>
(Id, Name, Surname) = (authorId, name, surname);
}
i serwis zwracający kilku przykładowych autorów (/Data/AuthorService.cs
):
namespace Conference.Data;
public class AuthorService
{
private readonly List<Author> _authors = new()
{
new Author(1, "Filip", "Murlak"),
new Author(2, "Krzysztof", "Stencel"),
new Author(3, "Krzysztof", "Ciebiera"),
new Author(4, "Edgar", "Codd"),
new Author(5, "Raymond", "Boyce")
};
public IReadOnlyList<Author> GetAuthors() => _authors;
}
Rejestrujemy nasz nowy serwis w Program.cs
:
// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddSingleton<WeatherForecastService>();
builder.Services.AddSingleton<AuthorService>();
Teraz musimy stworzyć stronę wyświetlającą te dane (/Pages/Authors.razor
):
@page "/authors"
<PageTitle>Authors</PageTitle>
@using Conference.Data
@inject AuthorService AuthorService
<h1>Authors</h1>
<p>Authors registered for the Conference.</p>
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>Surname</th>
<th>Name</th>
</tr>
</thead>
<tbody>
@foreach (var author in AuthorService.GetAuthors().OrderBy(a => a.Id))
{
<tr>
<td>@author.Id</td>
<td>@author.Surname</td>
<td>@author.Name</td>
</tr>
}
</tbody>
</table>
Żeby móc wygodnie nawigować do tej strony musimy zmienić Shared/NavMenu.razor
dodając nowy NavLink
:
<div class="nav-item px-3">
<NavLink class="nav-link" href="authors">
<span class="oi oi-list-rich" aria-hidden="true"></span> Authors
</NavLink>
</div>
Odpalamy ponownie i nawigujemy do naszej strony.
dotnet watch
Żeby dodawać autorów potrzebujemy nowego modelu, na który nałożymy pewne obostrzenia (/Data/CreateAuthor
):
using System.ComponentModel.DataAnnotations;
namespace Conference.Data;
public sealed class CreateAuthor
{
[Required]
[StringLength(255)]
public string? Name { get; set; }
[Required]
[StringLength(255)]
public string? Surname { get; set; }
}
ASP .NET Core pozwala na walidację za pomocą atrybutów nałożonych na properties modelu. W tym przypadku walidacja zawiedzie, jeśli któreś z pól Name
i Surname
będzie puste lub dłuższe niż 255
znaków.
Potrzebujemy jakiejś logiki na dodawanie nowych autorów (/Data/AuthorService
):
public void CreateAuthor(CreateAuthor model)
{
if (model.Name is null || model.Surname is null)
{
throw new ArgumentNullException(nameof(model));
}
var id = _authors.Max(x => x.Id) + 1;
var author = new Author(id, model.Name, model.Surname);
_authors.Add(author);
}
Teraz na stronie authors
musimy dodać formularz dodawania nowych autorów (/Pages/Authors.razor
):
<h2>Add a new Author:</h2>
<EditForm Model="@createAuthorModel" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<p>
<label>
Name:
<InputText id="name" @bind-Value="createAuthorModel.Name" />
</label>
</p>
<p>
<label>
Surname:
<InputText id="surname" @bind-Value="createAuthorModel.Surname" />
</label>
</p>
<button type="submit">Submit</button>
</EditForm>
@code {
private CreateAuthor createAuthorModel = new();
private void HandleValidSubmit() =>
AuthorService.CreateAuthor(createAuthorModel);
}
Instalujemy sobie Postgresa, bo nie ma po co bić się z Oraclem: https://www.postgresql.org/download/
Ja mam PostgreSQL 14, ale zadziała dowolna wspierana.
Ja dla wygody ustawiam hasło superusera jako postgres
.
Logujemy się do bazy DBeaverem:
Tworzymy tabelę na autorów i kilka pierwszych rekordów:
CREATE TABLE author (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL,
surname VARCHAR(255) NOT NULL
);
INSERT INTO author (name, surname)
VALUES
('Filip', 'Murlak'),
('Krzysztof', 'Stencel'),
('Krzysztof', 'Ciebiera'),
('Edgar', 'Codd'),
('Raymond', 'Boyce');
Klikamy prawym na nasz .csproj i wybieramy Open NuGet Gallery. Instalujemy dwa package:
Dapper to bardzo prosty ORM, a Npgsql to biblioteka do łączenia się z Postgresem z .NET-a.
Dapper będzie nam mapował rezultaty zapytań na klasy w C#, żebyśmy nie musieli dłubać się w bajtach i mogli skupić na funkcjonalności.
Zacznijmy od ciekawej części: jak użyć Dappera. Zmieniamy nasz serwis (/Data/AuthorService.cs
). Po pierwsze, potrzebujemy połączenia do bazy danych, które później wstrzykniemy naszym Dependency Injection:
using System.Data;
using Dapper;
namespace Conference.Data;
public class AuthorService
{
private readonly IDbConnection _dbConnection;
public AuthorService(IDbConnection dbConnection) =>
_dbConnection = dbConnection;
...
}
Aby wykonać zapytanie, którego rezultat powinien zostać zmapowany na klasę T
wywołujemy metodę Query<T>(string)
:
public IReadOnlyList<Author> GetAuthors() =>
_dbConnection.Query<Author>("SELECT id AS authorId, name, surname FROM author").ToList();
Uwaga, święta zasada wykonywania SQL-a:
NIE WOLNO PRZEKAZYWAĆ PARAMETRÓW INLINE
Takie zapytanie:
_dbConnection.Query<Author>(
"SELECT * FROM author WHERE name = '" + name + "'");
Jest ZŁE. Bardzo złe. Nie tylko psuje cache zapytań bazy danych, ale pozwala też na SQL injection. Poprawny sposób to przez SQL-owe parametry.
_dbConnection.Query<Author>(
"SELECT * FROM author WHERE name = @Name", new {Name = name});
Napis z @
przed oznacza parametr. Parametry wypełniamy w drugim argumencie, przekazując anonimowy obiekt, którego properties nazywają się dokładnie tak, jak użyte parametry.
Aby stworzyć Autora przepisujemy CreateAuthor
na:
public void CreateAuthor(CreateAuthor model)
{
if (model.Name is null || model.Surname is null)
{
throw new ArgumentNullException(nameof(model));
}
_dbConnection.Execute(
$"INSERT INTO author (name, surname) VALUES (@Name, @Surname);",
new { model.Name, model.Surname });
}
Aby skonfigurować bazę danych potrzebujemy connection stringa. U mnie wygląda tak:
User ID=postgres;Password=postgres;Host=localhost;Port=5432;Database=postgres;Pooling=true
Dodajemy to w appsettings.Development.json
:
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"Postgres": "User ID=postgres;Password=postgres;Host=localhost;Port=5432;Database=postgres;Pooling=true"
}
}
Musimy też zmienić Program.cs
żeby zarejestrować nasze połączenie:
using System.Data;
using Conference.Data;
using Npgsql;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("postgres");
// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddScoped<AuthorService>();
builder.Services.AddScoped<IDbConnection>(_ => new NpgsqlConnection(connectionString));
Teraz możemy przeładować i cieszyć się naszą działającą aplikacją.
Możemy teraz dodać jakiś wiersz i sprawdzić, żerzeczywiście się utrwali w bazie.
Dodajemy teraz stronę, która będzie wyświetlać prace. Póki co ignorujemy autorstwo.
Zaczynamy od modelu (/Data/Paper.cs
):
namespace Conference.Data;
public class Paper
{
public int Id { get; init; }
public string Name { get; init; }
public string Classification { get; init; }
public Paper(int paperId, string name, string classification) =>
(Id, Name, Classification) = (paperId, name, classification);
}
Model do tworzenia:
using System.ComponentModel.DataAnnotations;
namespace Conference.Data;
public sealed class CreatePaper
{
[Required]
[StringLength(1023)]
public string? Name { get; set; }
[Required]
[StringLength(1023)]
public string? Classification { get; set; }
}
Serwis (/Data/PaperService.cs
):
using System.Data;
using Dapper;
namespace Conference.Data;
public class PaperService
{
private readonly IDbConnection _dbConnection;
public PaperService(IDbConnection dbConnection) => _dbConnection = dbConnection;
public IReadOnlyList<Paper> GetPapers() =>
_dbConnection.Query<Paper>("SELECT id AS paperId, name, classification FROM paper").ToList();
public void CreatePaper(CreatePaper model)
{
if (model.Name is null || model.Classification is null)
{
throw new ArgumentNullException(nameof(model));
}
_dbConnection.Execute(
$"INSERT INTO paper (name, classification) VALUES (@Name, @Classification);",
new { model.Name, model.Classification });
}
}
Strona (/Pages/Papers.razor
):
@page "/papers"
<PageTitle>Papers</PageTitle>
@using Conference.Data
@inject PaperService PaperService
<h1>Papers</h1>
<p>Papers submitted for the Conference.</p>
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>Name</th>
<th>Classification</th>
</tr>
</thead>
<tbody>
@foreach (var paper in PaperService.GetPapers().OrderBy(a => a.Id))
{
<tr>
<td>@paper.Id</td>
<td>@paper.Name</td>
<td>@paper.Classification</td>
</tr>
}
</tbody>
</table>
<h2>Add a new Paper:</h2>
<EditForm Model="@createPaperModel" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<p>
<label>
Name:
<InputText id="name" @bind-Value="createPaperModel.Name" />
</label>
</p>
<p>
<label>
Classification:
<InputText id="classification" @bind-Value="createPaperModel.Classification" />
</label>
</p>
<button type="submit">Submit</button>
</EditForm>
@code {
private CreatePaper createPaperModel = new();
private void HandleValidSubmit() => PaperService.CreatePaper(createPaperModel);
}
Pamiętamy o dodaniu do /Shared/NavMenu.razor
:
<div class="nav-item px-3">
<NavLink class="nav-link" href="papers">
<span class="oi oi-list-rich" aria-hidden="true"></span> Papers
</NavLink>
</div>
I w końcu definicja w bazie:
CREATE TABLE paper (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(1023) NOT NULL,
classification VARCHAR(1023) NOT NULL
);
INSERT INTO paper (name, classification)
VALUES
('Stackless Processing of Streamed Trees', 'Databases'),
('A Relational Model of Data for Large Shared Data Banks', 'Databases'),
('SEQUEL: A Structured English Query Language', 'Programming Languages'),
('How to Match Jobs and Candidates - A Recruitment Support System Based on Feature Engineering and Advanced Analytics.', 'Recommender systems');
Robimy reload iii… crash!
Unhandled exception rendering component: Cannot provide a value for property 'PaperService' on type 'Conference.Pages.Papers'. There is no registered service of type 'Conference.Data.PaperService'.
System.InvalidOperationException: Cannot provide a value for property 'PaperService' on type 'Conference.Pages.Papers'. There is no registered service of type 'Conference.Data.PaperService'.
Ach tak, to się zdarza. Zapomnieliśmy zarejestrować serwis, z którego korzystamy:
builder.Services.AddScoped<PaperService>();
I teraz wszystko śmiga.
Dodamy Sesje, póki co bez referatów, ale pozwoli nam to spojrzeć na dropdowny.
Tabela sesji wygląda tak:
-- SESSION to też keyword. Żeby powiedzieć SQL-owi, że chodzi nam o nazwę
-- tabeli, stawiamy w podwójne ciapki. Tak samo dla keyworda WHEN i kolumny.
CREATE TABLE "session" (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"when" TIMESTAMP(0) NOT NULL, -- TIMESTAMP to type DATETIME w Postgresie
-- Argument to precyzja sekund.
-- 0 oznacza 0 miejsc po przecinku, czyli co do sekundy.
chair_id INTEGER REFERENCES author NOT NULL
);
Odpowiadający model (/Data/Session.cs
):
namespace Conference.Data;
public class Session
{
public int Id { get; init; }
public DateTime When { get; init; }
public Author? Chair { get; set; }
public Session(int sessionId, DateTime when) =>
(Id, When) = (sessionId, when);
}
Każda sesja zna swojego prowadzącego.
Żeby wypełnić to pole, musimy skorzystać z JOIN-a. Obsługa JOIN-ów w Dapperze jest dość łopatologiczna. Na wyjściu z bazy dostajemy strumień krotek. Możemy powiedzieć Dapperowi jak podzielić krotkę na kilka części, odpowiadających kolejnym encjom. Przykładowo, przy takim zapytaniu:
SELECT s.id AS sessionId, s.when, a.id AS authorId, a.name, a.surname
FROM session s
JOIN author a
ON s.chair_id = a.id
Dostajemy na wyjściu krotki postaci:
sessionId |
when |
authorId |
name |
surname |
---|
A więc chcemy podzielić je po authorID
, zmapować pierwsze dwie kolumny na Session
i ostatnie trzy na Author
. Potem Dapper pozwala nam podać funkcję z par Session, Author
w to co chcemy dostać w wyniku – w tym przypadku Session
.
Metodą, która na to pozwala jest Query<T1, T2, TResult>
. Kod (/Data/SessionService.cs
):
public IReadOnlyList<Session> GetSessions() =>
_dbConnection.Query<Session, Author, Session>(
@"SELECT s.id AS sessionId, s.when, a.id AS authorId, a.name, a.surname
FROM session s
JOIN author a
ON s.chair_id = a.id",
(s, a) =>
{
s.Chair = a;
return s;
},
splitOn: "authorId")
.ToList();
Wyświetlamy to na /Pages/Sessions.razor
:
@page "/sessions"
<PageTitle>Sessions</PageTitle>
@using Conference.Data
@inject SessionService SessionService
<h1>Sessions</h1>
<p>Sessions scheduled.</p>
<table class="table table-hover">
<thead>
<tr>
<th>Id</th>
<th>When</th>
<th>Chair</th>
</tr>
</thead>
<tbody>
@foreach (var session in SessionService.GetSessions().OrderBy(a => a.When))
{
<tr>
<td>@session.Id</td>
<td>@session.When</td>
<td>@session.Chair?.DisplayName</td>
</tr>
}
</tbody>
</table>
Nawigacja:
<div class="nav-item px-3">
<NavLink class="nav-link" href="sessions">
<span class="oi oi-list-rich" aria-hidden="true"></span> Sessions
</NavLink>
</div>
I Dependency Injection:
builder.Services.AddScoped<SessionService>();
Teraz zastanówmy się nad dodawaniem. Model jest dość prosty:
using System.ComponentModel.DataAnnotations;
namespace Conference.Data;
public class CreateSession
{
[Required]
public DateTime When { get; set; }
[Required]
public int? ChairId { get; set; }
}
Potrzebujemy dwóch inputów w formularzu: data i czas oraz lista możliwych prowadzących. Żeby dostać datę i czas instalujemy nowy pakiet o nazwie Radzen.Blazor
. Dostarcza on kilku przydatnych komponentów do UI, w tym date pickery. O jego konfiguracji można przeczytać sobie tutaj.
Zacznijmy od tej prostej części:
<h2>Add a new Session:</h2>
<EditForm Model="@createSessionModel" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<p>
<label>
When:
<RadzenDatePicker @bind-Value="@createSessionModel.When" ShowTime="true" />
</label>
</p>
<button type="submit">Submit</button>
</EditForm>
@code {
private CreateSession createSessionModel = new() { When = DateTime.Now };
private void HandleValidSubmit() => throw new NotImplementedException(); //SessionService.CreateSession(createSessionModel);
}
W jaki sposób stworzyć dropdown z listą możliwych prowadzących? Korzystamy z komponentu InputSelect
i tagów option
:
@inject AuthorService AuthorService
...
<p>
<label>
Chair:
<InputSelect id="chair" @bind-Value="@createSessionModel.ChairId">
@foreach (var author in authors.OrderBy(a => a.DisplayName))
{
<option value="@author.Id">@author.DisplayName</option>
}
</InputSelect>
</label>
</p>
@code {
private CreateSession createSessionModel = new() { When = DateTime.Now };
private IReadOnlyList<Author> authors = Array.Empty<Author>();
protected override void OnInitialized() =>
authors = AuthorService.GetAuthors();
private void HandleValidSubmit() => throw new NotImplementedException(); //SessionService.CreateSession(createSessionModel);
}
W atrybucie value
przekazujemy wartość, która będzie wysłana formularzem, a jako content podajemy tekst do wyświetlenia.
Metoda do insertów jest banalna:
public void CreateSession(CreateSession model)
{
if (model.ChairId is null)
{
throw new ArgumentNullException();
}
_dbConnection.Execute(
$"INSERT INTO session (\"when\", chair_id) VALUES (@When, @ChairId);",
new { model.When, model.ChairId });
}
Na koniec dodamy sobie AutorstwoPracy
. Na froncie zapewne będzie nas interesować jedynie lista autorów danej pracy na liście prac. Zmodyfikujmy więc model Paper
, żeby zawierał listę swoich autorów:
namespace Conference.Data;
public class Paper
{
public int Id { get; init; }
public string Name { get; init; }
public string Classification { get; init; }
public List<Author> Authors { get; init; } = new List<Author>();
public Paper(int paperId, string name, string classification) =>
(Id, Name, Classification) = (paperId, name, classification);
}
Żeby ją wyświetlić, zdefiniujemy sobie pomocniczą metodę:
private static string GetAuthorNameList(Paper paper)
{
var displayNames = paper.Authors.Select(a => a.DisplayName);
return string.Join(", ", displayNames);
}
Dodajemy nowy wiersz:
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>Name</th>
<th>Classification</th>
<th>Authors</th>
</tr>
</thead>
<tbody>
@foreach (var paper in PaperService.GetPapers().OrderBy(a => a.Id))
{
<tr>
<td>@paper.Id</td>
<td>@paper.Name</td>
<td>@paper.Classification</td>
<td>@GetAuthorNameList(paper)</td>
</tr>
}
</tbody>
</table>
Definicja w bazie jest taka jak w oryginalnym modelu:
CREATE TABLE paper_author (
author_id INTEGER REFERENCES author NOT NULL,
paper_id INTEGER REFERENCES paper NOT NULL,
CONSTRAINT paper_paper_id_author_id_pkey PRIMARY KEY (paper_id, author_id)
);
INSERT INTO paper_author (author_id, paper_id) VALUES
(1, 1),
(2, 4),
(3, 2),
(4, 3),
(3, 4);
Teraz musimy zmienić nasz serwis. Obsługa JOINów wiele do wiele jest trochę bardziej skomplikowana.
Musimy stworzyć sobie hashmapę (Dictionary
) wszystkich prac po ich identyfikatorze. Następnie dostając parę Paper
, Author
, znajdujemy odpowiednią pracę i dodajemy autora do jej listy. W wyniku dostajemy listę wszystkich prac, ale zduplikowaną (praca występuje tyle razy, ilu ma autorów), więc wołamy Distinct
.
public IReadOnlyList<Paper> GetPapers()
{
var paperDictionary = new Dictionary<int, Paper>();
var papers = _dbConnection.Query<Paper, Author, Paper>(
@"SELECT p.id AS paperId, p.name, p.classification, a.id AS authorId, a.name, a.surname
FROM paper p
LEFT JOIN paper_author pa
ON p.id = pa.paper_id
LEFT JOIN author a
ON pa.author_id = a.id",
(p, a) =>
{
Paper? paper;
if (!paperDictionary.TryGetValue(p.Id, out paper))
{
paper = p;
paperDictionary.Add(paper.Id, paper);
}
if (a is not null)
{
paper.Authors.Add(a);
}
return paper;
},
splitOn: "authorId");
return papers.Distinct().ToList();
}
Po tej zmianie możemy zobaczyć autorów pracy na stronie /papers
.
Zostało nam jeszcze dodawanie autorstwa. Po pierwsze, nasz CreatePaper
wymaga zmiany:
public IEnumerable<int> AuthorIds { get; set; } = Enumerable.Empty<int>();
Autorów pracy będziemy wybierać przy tworzeniu pracy. W tym celu potrzebujemy inputu, który pozwoli nam wybrać listę elementów. Paczka Radzen ma komponent RadzenListBox
:
<p>
<label>
Authors:
<RadzenListBox @bind-Value=@createPaperModel.AuthorIds
Multiple="true"
Data="@authors"
TextProperty="@nameof(Author.DisplayName)"
ValueProperty="@nameof(Author.Id)"></RadzenListBox>
</label>
</p>
Wskazujemy skąd wziąć dane, które property zmapować na value, a które na content.
Jeszcze trochę kodu:
@inject AuthorService
...
@code {
private CreatePaper createPaperModel = new();
private IEnumerable<Author> authors = Enumerable.Empty<Author>();
private void HandleValidSubmit() => PaperService.CreatePaper(createPaperModel);
protected override void OnInitialized() => authors = AuthorService.GetAuthors();
private static string GetAuthorNameList(Paper paper)
{
var displayNames = paper.Authors.Select(a => a.DisplayName);
return string.Join(", ", displayNames);
}
}
I nowa logika w serwisie. Tutaj przydadzą się dwie sztuczki.
INSERT INTO A VALUES (...)
RETURNING id
public void CreatePaper(CreatePaper model)
{
if (model.Name is null || model.Classification is null)
{
throw new ArgumentNullException(nameof(model));
}
var paperId = _dbConnection.QuerySingle<int>(
$"INSERT INTO paper (name, classification) VALUES (@Name, @Classification) RETURNING id;",
new { model.Name, model.Classification });
_dbConnection.Execute(
$"INSERT INTO paper_author (paper_id, author_id) VALUES (@PaperId, @AuthorId);",
model.AuthorIds.Select(a => new {PaperId = paperId, AuthorId = a})
);
}
Takie przetwarzanie jest niebezpieczne – co jeśli baza się zepsuje w trakcie przetwarzania jednego z autorstw? Tutaj potrzebna jest transakcja!
public void CreatePaper(CreatePaper model)
{
if (model.Name is null || model.Classification is null)
{
throw new ArgumentNullException(nameof(model));
}
_dbConnection.Open();
using var transaction = _dbConnection.BeginTransaction();
var paperId = _dbConnection.QuerySingle<int>(
$"INSERT INTO paper (name, classification) VALUES (@Name, @Classification) RETURNING id;",
new { model.Name, model.Classification });
_dbConnection.Execute(
$"INSERT INTO paper_author (paper_id, author_id) VALUES (@PaperId, @AuthorId);",
model.AuthorIds.Select(a => new {PaperId = paperId, AuthorId = a})
);
transaction.Commit();
}
UWAGA: NIE ROBICIE MIKROZADANIA “DLA WSZYSTKICH” Z PHP.
Dodaj Referaty do aplikacji. W tym celu:
Sforkuj repozytorium zawierające dotychczasowy kod z mojego GitHuba. Branch master
zawiera kod z demo, task
parę dodatków na cele mikrozadania. Przełącz się na branch task
(najlepiej zrób z niego swój nowy branch).
Skonfiguruj u siebie bazę danych, korzystając z gotowego SQL-a w /database/postgres.sql
, zawierającego dotychczasowy schemat, nową tabelę lecture
oraz trigger.
Przeczytaj definicje i trigger, upewnij się, że je rozumiesz. Więcej informacji o triggerach w Postgresie znajdziesz tutaj.
W pobranym kodzie znajdują się szkielety plików do uzupełnienia. Po pierwsze, dodaj model reprezentujący encje z tej tabeli w /Data/Lecture.cs
. Powinien on zawierać property typu Paper
wskazujące na pracę, której dotyczy.
Zmodyfikuj kod metody GetSessions
w /Data/SessionService.cs
tak, aby wypełniał property Session.Lecture
referatami danej sesji. Będziemy tu mieli ogromnego JOIN-a. Można go zrealizować na dwa sposoby:
Query
z większą liczbą generycznych argumentów a jako splitOn
przekazać kolejne punkty podziału po przecinku (czyli np "Id1,Id2,Id3"
). Potem napisac logikę łączącą kolejne wiersze w listę sesji.Można wybrać to co wydaje się nam prostsze.
Przejrzyj kod Sessions.razor
. Wyświetla on teraz referaty każdej sesji, ale jedynie ich identyfikatory. Zmodyfikuj go tak, aby wyświetlał też tytuł pracy, autorów i speakera.
Przeczytaj komponent Shared/AddNewLectureForm.razor
. Jest tam dodana walidacja, która sprawdza, czy referat zaczyna się nie wcześniej niż sesja. Dopisz walidację, która zweryfikuje warunek speaker różny od chaira sesji.
Jako swój submit na Moodle’u wyślij mi link do swojego forka. Może być prywatny, ale wtedy wcześniej musisz dać mi do niego dostęp.
Wyjątkowo termin na to mikrozadanie jest do 09.01.2021. W związku z tym sprawdzę je odpowiednio później.
Dziękuję za uwagę.
Copyright © Mateusz Gienieczko 2021