Add PostgreSQL integration with EF Core + Notes demo page
This commit is contained in:
@@ -11,7 +11,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.App.Internal.Assets" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.DotNet.HotReload.WebAssembly.Browser" Version="10.0.103" />
|
||||
<PackageReference Include="Microsoft.NET.ILLink.Tasks" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.NET.Sdk.WebAssembly.Pack" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -25,5 +25,11 @@
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="notes">
|
||||
<span class="bi bi-card-text" aria-hidden="true"></span> Notes (Postgres)
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
173
Freman.Sample.Web/Freman.Sample.Web/Components/Pages/Notes.razor
Normal file
173
Freman.Sample.Web/Freman.Sample.Web/Components/Pages/Notes.razor
Normal file
@@ -0,0 +1,173 @@
|
||||
@page "/notes"
|
||||
@rendermode InteractiveServer
|
||||
@using Freman.Sample.Web.Data
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@inject AppDbContext Db
|
||||
|
||||
<PageTitle>Notes</PageTitle>
|
||||
|
||||
<h1>Notes</h1>
|
||||
|
||||
<p class="text-muted">
|
||||
A tiny EF Core + PostgreSQL demo: type a note, save it, refresh, it’s still there.
|
||||
</p>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<div class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></div>
|
||||
<span>Loading notes…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">
|
||||
@_error
|
||||
</div>
|
||||
}
|
||||
|
||||
<EditForm Model="_model" OnValidSubmit="SaveAsync">
|
||||
<DataAnnotationsValidator/>
|
||||
<ValidationSummary/>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="noteText">New note</label>
|
||||
<input id="noteText"
|
||||
class="form-control"
|
||||
@bind-value="_model.Text"
|
||||
@bind-value:event="oninput"
|
||||
maxlength="500"
|
||||
disabled="@(_isSaving || _isLoading)"/>
|
||||
<div class="form-text">Max 500 characters.</div>
|
||||
<ValidationMessage For="@(() => _model.Text)"/>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary"
|
||||
type="submit"
|
||||
disabled="@(_isSaving || _isLoading || string.IsNullOrWhiteSpace(_model.Text))">
|
||||
@if (_isSaving)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
<span>Saving…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Save</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
<button class="btn btn-outline-secondary ms-2"
|
||||
type="button"
|
||||
@onclick="LoadAsync"
|
||||
disabled="@(_isSaving || _isLoading)">
|
||||
Refresh
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_status))
|
||||
{
|
||||
<div class="mt-3 alert alert-info">@_status</div>
|
||||
}
|
||||
|
||||
<hr/>
|
||||
|
||||
<h2>Saved notes</h2>
|
||||
|
||||
@if (_notesList.Count == 0)
|
||||
{
|
||||
<p><em>No notes yet.</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="list-group">
|
||||
@foreach (var n in _notesList)
|
||||
{
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>@n.Text</span>
|
||||
<small class="text-muted">@n.CreatedUtc.ToLocalTime()</small>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
private sealed class NewNoteModel
|
||||
{
|
||||
[Required(ErrorMessage = "Please enter some text.")]
|
||||
[StringLength(500, ErrorMessage = "Notes are limited to 500 characters.")]
|
||||
public string Text { get; set; } = "";
|
||||
}
|
||||
|
||||
private readonly NewNoteModel _model = new();
|
||||
private bool _isSaving;
|
||||
private bool _isLoading;
|
||||
private string? _status;
|
||||
private string? _error;
|
||||
private List<Note> _notesList = [];
|
||||
|
||||
async protected override Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
_status = null;
|
||||
|
||||
try
|
||||
{
|
||||
_notesList = await Db.Notes
|
||||
.OrderByDescending(n => n.CreatedUtc)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Load failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_isSaving = true;
|
||||
_error = null;
|
||||
_status = null;
|
||||
|
||||
var note = new Note
|
||||
{
|
||||
Text = _model.Text.Trim(),
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Optimistic UI: show it immediately
|
||||
_notesList.Insert(0, note);
|
||||
|
||||
try
|
||||
{
|
||||
Db.Notes.Add(note);
|
||||
await Db.SaveChangesAsync();
|
||||
|
||||
_model.Text = "";
|
||||
_status = "Saved!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Roll back optimistic insert
|
||||
_notesList.Remove(note);
|
||||
_error = $"Save failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSaving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Freman.Sample.Web/Freman.Sample.Web/Data/AppDbContext.cs
Normal file
22
Freman.Sample.Web/Freman.Sample.Web/Data/AppDbContext.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Freman.Sample.Web.Data;
|
||||
|
||||
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<Note> Notes => Set<Note>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Note>(b =>
|
||||
{
|
||||
b.ToTable("notes");
|
||||
b.HasKey(x => x.Id);
|
||||
b.Property(x => x.Text).HasMaxLength(500).IsRequired();
|
||||
b.Property(x => x.CreatedUtc).IsRequired();
|
||||
b.HasIndex(x => x.CreatedUtc);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Freman.Sample.Web.Data;
|
||||
|
||||
public static class DbMigrationHelper
|
||||
{
|
||||
public static async Task ApplyMigrationsAsync(IServiceProvider services, CancellationToken ct = default)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.MigrateAsync(ct);
|
||||
}
|
||||
}
|
||||
53
Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.Designer.cs
generated
Normal file
53
Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.Designer.cs
generated
Normal file
@@ -0,0 +1,53 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Freman.Sample.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Freman.Sample.Web.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260222193339_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Freman.Sample.Web.Data.Note", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedUtc");
|
||||
|
||||
b.ToTable("notes", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Freman.Sample.Web.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "notes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Text = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
CreatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_notes", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_notes_CreatedUtc",
|
||||
table: "notes",
|
||||
column: "CreatedUtc");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "notes");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Freman.Sample.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Freman.Sample.Web.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Freman.Sample.Web.Data.Note", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedUtc");
|
||||
|
||||
b.ToTable("notes", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Freman.Sample.Web/Freman.Sample.Web/Data/Note.cs
Normal file
8
Freman.Sample.Web/Freman.Sample.Web/Data/Note.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Freman.Sample.Web.Data;
|
||||
|
||||
public sealed class Note
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -10,7 +10,15 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Freman.Sample.Web.Client\Freman.Sample.Web.Client.csproj"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.1"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.App.Internal.Assets" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.3"/>
|
||||
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Freman.Sample.Web.Client.Pages;
|
||||
using Freman.Sample.Web.Components;
|
||||
using Freman.Sample.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -8,11 +10,20 @@ builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents()
|
||||
.AddInteractiveWebAssemblyComponents();
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
{
|
||||
var cs = builder.Configuration.GetConnectionString("AppDb");
|
||||
options.UseNpgsql(cs);
|
||||
});
|
||||
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
// Auto-apply migrations for dev/demo convenience.
|
||||
await DbMigrationHelper.ApplyMigrationsAsync(app.Services);
|
||||
app.UseWebAssemblyDebugging();
|
||||
}
|
||||
else
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"AppDb": "Host=localhost;Port=5432;Database=freman_sample;Username=appuser;Password=CHANGE_ME"
|
||||
}
|
||||
}
|
||||
|
||||
47
README.md
Normal file
47
README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Freman.Sample.Web – Blazor + PostgreSQL (Docker) sample
|
||||
|
||||
This repo is a small demo for C# developers (e.g., Unity folks) who are new to web dev.
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Docker Desktop
|
||||
- .NET SDK (matching the repo)
|
||||
- (Optional) EF tooling: `dotnet-ef`
|
||||
|
||||
## Quickstart (recommended)
|
||||
|
||||
1) Create a `.env` file next to `compose.yaml`:
|
||||
```text POSTGRES_PASSWORD=your_local_dev_password```
|
||||
|
||||
2) Start everything:
|
||||
```powershell docker compose up --build```
|
||||
|
||||
3) Open the app, then navigate to **Notes (Postgres)**.
|
||||
Type a note, click **Save**, refresh the page — it should persist.
|
||||
|
||||
## Running from Rider (server on host, DB in Docker)
|
||||
|
||||
Start just Postgres:
|
||||
|
||||
```powershell docker compose up postgres```
|
||||
|
||||
Then run the `Freman.Sample.Web` project from Rider.
|
||||
|
||||
> In this mode, the app uses `appsettings.Development.json` which points to `localhost:5432`.
|
||||
|
||||
## Migrations (EF Core)
|
||||
|
||||
Install EF CLI once:
|
||||
```powershell dotnet tool install --global dotnet-ef```
|
||||
|
||||
Add a migration:
|
||||
|
||||
```powershell dotnet ef migrations add InitialCreate --project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj --startup-project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj ` --output-dir Data\Migrations```
|
||||
|
||||
Apply it:
|
||||
```powershell dotnet ef database update --project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj --startup-project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **DB auth failed**: check `.env` password matches the connection string in `compose.yaml`.
|
||||
- **Port 5432 already in use**: stop the other Postgres instance or change the port mapping in `compose.yaml`.
|
||||
26
compose.yaml
26
compose.yaml
@@ -1,9 +1,33 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: freman.sample.postgres
|
||||
environment:
|
||||
POSTGRES_DB: freman_sample
|
||||
POSTGRES_USER: appuser
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-CHANGE_ME}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- freman_sample_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U appuser -d freman_sample" ]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
freman.sample.web:
|
||||
image: freman.sample.web
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Freman.Sample.Web/Freman.Sample.Web/Dockerfile
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
ConnectionStrings__AppDb: Host=postgres;Port=5432;Database=freman_sample;Username=appuser;Password=${POSTGRES_PASSWORD:-CHANGE_ME}
|
||||
|
||||
|
||||
freman.sample.web.client:
|
||||
image: freman.sample.web.client
|
||||
@@ -11,3 +35,5 @@
|
||||
context: .
|
||||
dockerfile: Freman.Sample.Web/Freman.Sample.Web.Client/Dockerfile
|
||||
|
||||
volumes:
|
||||
freman_sample_pgdata:
|
||||
13
dotnet-tools.json
Normal file
13
dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.3",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user