From 9cf0cbadd6532cf1f938bd6900cdda8d6eb9731b Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Sun, 22 Feb 2026 14:51:44 -0500 Subject: [PATCH] Add PostgreSQL integration with EF Core + Notes demo page --- .../Freman.Sample.Web.Client.csproj | 6 +- .../Components/Layout/NavMenu.razor | 6 + .../Components/Pages/Notes.razor | 173 ++++++++++++++++++ .../Freman.Sample.Web/Data/AppDbContext.cs | 22 +++ .../Data/DbMigrationHelper.cs | 13 ++ .../20260222193339_InitialCreate.Designer.cs | 53 ++++++ .../20260222193339_InitialCreate.cs | 42 +++++ .../Migrations/AppDbContextModelSnapshot.cs | 50 +++++ .../Freman.Sample.Web/Data/Note.cs | 8 + .../Freman.Sample.Web.csproj | 16 +- .../Freman.Sample.Web/Program.cs | 11 ++ .../appsettings.Development.json | 3 + README.md | 47 +++++ compose.yaml | 26 +++ dotnet-tools.json | 13 ++ 15 files changed, 484 insertions(+), 5 deletions(-) create mode 100644 Freman.Sample.Web/Freman.Sample.Web/Components/Pages/Notes.razor create mode 100644 Freman.Sample.Web/Freman.Sample.Web/Data/AppDbContext.cs create mode 100644 Freman.Sample.Web/Freman.Sample.Web/Data/DbMigrationHelper.cs create mode 100644 Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.Designer.cs create mode 100644 Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.cs create mode 100644 Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/AppDbContextModelSnapshot.cs create mode 100644 Freman.Sample.Web/Freman.Sample.Web/Data/Note.cs create mode 100644 README.md create mode 100644 dotnet-tools.json diff --git a/Freman.Sample.Web/Freman.Sample.Web.Client/Freman.Sample.Web.Client.csproj b/Freman.Sample.Web/Freman.Sample.Web.Client/Freman.Sample.Web.Client.csproj index 74ae35c..82f3fd3 100644 --- a/Freman.Sample.Web/Freman.Sample.Web.Client/Freman.Sample.Web.Client.csproj +++ b/Freman.Sample.Web/Freman.Sample.Web.Client/Freman.Sample.Web.Client.csproj @@ -11,7 +11,11 @@ - + + + + + diff --git a/Freman.Sample.Web/Freman.Sample.Web/Components/Layout/NavMenu.razor b/Freman.Sample.Web/Freman.Sample.Web/Components/Layout/NavMenu.razor index 4a7f343..2e8326e 100644 --- a/Freman.Sample.Web/Freman.Sample.Web/Components/Layout/NavMenu.razor +++ b/Freman.Sample.Web/Freman.Sample.Web/Components/Layout/NavMenu.razor @@ -25,5 +25,11 @@ Weather + + \ No newline at end of file diff --git a/Freman.Sample.Web/Freman.Sample.Web/Components/Pages/Notes.razor b/Freman.Sample.Web/Freman.Sample.Web/Components/Pages/Notes.razor new file mode 100644 index 0000000..d99a04e --- /dev/null +++ b/Freman.Sample.Web/Freman.Sample.Web/Components/Pages/Notes.razor @@ -0,0 +1,173 @@ +@page "/notes" +@rendermode InteractiveServer +@using Freman.Sample.Web.Data +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@inject AppDbContext Db + +Notes + +

Notes

+ +

+ A tiny EF Core + PostgreSQL demo: type a note, save it, refresh, it’s still there. +

+ +@if (_isLoading) +{ +
+ + Loading notes… +
+} + +@if (!string.IsNullOrWhiteSpace(_error)) +{ + +} + + + + + +
+ + +
Max 500 characters.
+ +
+ + + + +
+ +@if (!string.IsNullOrWhiteSpace(_status)) +{ +
@_status
+} + +
+ +

Saved notes

+ +@if (_notesList.Count == 0) +{ +

No notes yet.

+} +else +{ + +} + +@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 _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; + } + } +} \ No newline at end of file diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/AppDbContext.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/AppDbContext.cs new file mode 100644 index 0000000..74c8f96 --- /dev/null +++ b/Freman.Sample.Web/Freman.Sample.Web/Data/AppDbContext.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; + +namespace Freman.Sample.Web.Data; + +public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Notes => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(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); + }); + } +} \ No newline at end of file diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/DbMigrationHelper.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/DbMigrationHelper.cs new file mode 100644 index 0000000..a11c09c --- /dev/null +++ b/Freman.Sample.Web/Freman.Sample.Web/Data/DbMigrationHelper.cs @@ -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(); + await db.Database.MigrateAsync(ct); + } +} \ No newline at end of file diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.Designer.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.Designer.cs new file mode 100644 index 0000000..29f4012 --- /dev/null +++ b/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.Designer.cs @@ -0,0 +1,53 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedUtc"); + + b.ToTable("notes", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.cs new file mode 100644 index 0000000..bdaa1a4 --- /dev/null +++ b/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Freman.Sample.Web.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "notes", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Text = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + CreatedUtc = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "notes"); + } + } +} diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/AppDbContextModelSnapshot.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..8c60616 --- /dev/null +++ b/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,50 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedUtc"); + + b.ToTable("notes", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/Note.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/Note.cs new file mode 100644 index 0000000..68d1e12 --- /dev/null +++ b/Freman.Sample.Web/Freman.Sample.Web/Data/Note.cs @@ -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; +} \ No newline at end of file diff --git a/Freman.Sample.Web/Freman.Sample.Web/Freman.Sample.Web.csproj b/Freman.Sample.Web/Freman.Sample.Web/Freman.Sample.Web.csproj index 446ee68..6daab93 100644 --- a/Freman.Sample.Web/Freman.Sample.Web/Freman.Sample.Web.csproj +++ b/Freman.Sample.Web/Freman.Sample.Web/Freman.Sample.Web.csproj @@ -10,13 +10,21 @@ - + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - .dockerignore - + + .dockerignore + diff --git a/Freman.Sample.Web/Freman.Sample.Web/Program.cs b/Freman.Sample.Web/Freman.Sample.Web/Program.cs index 5fc8df5..ede5460 100644 --- a/Freman.Sample.Web/Freman.Sample.Web/Program.cs +++ b/Freman.Sample.Web/Freman.Sample.Web/Program.cs @@ -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(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 diff --git a/Freman.Sample.Web/Freman.Sample.Web/appsettings.Development.json b/Freman.Sample.Web/Freman.Sample.Web/appsettings.Development.json index 0c208ae..20cb0e2 100644 --- a/Freman.Sample.Web/Freman.Sample.Web/appsettings.Development.json +++ b/Freman.Sample.Web/Freman.Sample.Web/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "ConnectionStrings": { + "AppDb": "Host=localhost;Port=5432;Database=freman_sample;Username=appuser;Password=CHANGE_ME" } } diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f9f430 --- /dev/null +++ b/README.md @@ -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`. \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 7501eb8..6ef072b 100644 --- a/compose.yaml +++ b/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: \ No newline at end of file diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..bffb60c --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.3", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file