Add PostgreSQL integration with EF Core + Notes demo page

This commit is contained in:
Chuck Smith
2026-02-22 14:51:44 -05:00
parent 16f09da460
commit 9cf0cbadd6
15 changed files with 484 additions and 5 deletions

View File

@@ -11,7 +11,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <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>
<ItemGroup> <ItemGroup>

View File

@@ -25,5 +25,11 @@
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather <span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
</NavLink> </NavLink>
</div> </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> </nav>
</div> </div>

View 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, its 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;
}
}
}

View 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);
});
}
}

View File

@@ -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);
}
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View 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;
}

View File

@@ -10,7 +10,15 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Freman.Sample.Web.Client\Freman.Sample.Web.Client.csproj"/> <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>
<ItemGroup> <ItemGroup>

View File

@@ -1,5 +1,7 @@
using Freman.Sample.Web.Client.Pages; using Freman.Sample.Web.Client.Pages;
using Freman.Sample.Web.Components; using Freman.Sample.Web.Components;
using Freman.Sample.Web.Data;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -8,11 +10,20 @@ builder.Services.AddRazorComponents()
.AddInteractiveServerComponents() .AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents(); .AddInteractiveWebAssemblyComponents();
builder.Services.AddDbContext<AppDbContext>(options =>
{
var cs = builder.Configuration.GetConnectionString("AppDb");
options.UseNpgsql(cs);
});
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
// Auto-apply migrations for dev/demo convenience.
await DbMigrationHelper.ApplyMigrationsAsync(app.Services);
app.UseWebAssemblyDebugging(); app.UseWebAssemblyDebugging();
} }
else else

View File

@@ -4,5 +4,8 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"ConnectionStrings": {
"AppDb": "Host=localhost;Port=5432;Database=freman_sample;Username=appuser;Password=CHANGE_ME"
} }
} }

47
README.md Normal file
View 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`.

View File

@@ -1,9 +1,33 @@
services: 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: freman.sample.web:
image: freman.sample.web image: freman.sample.web
build: build:
context: . context: .
dockerfile: Freman.Sample.Web/Freman.Sample.Web/Dockerfile 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: freman.sample.web.client:
image: freman.sample.web.client image: freman.sample.web.client
@@ -11,3 +35,5 @@
context: . context: .
dockerfile: Freman.Sample.Web/Freman.Sample.Web.Client/Dockerfile dockerfile: Freman.Sample.Web/Freman.Sample.Web.Client/Dockerfile
volumes:
freman_sample_pgdata:

13
dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.3",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}