diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..679eefe --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Database provider: sqlite (default, no Docker dependency) | postgres (opt-in, requires Docker) +DATABASE_PROVIDER=sqlite + +# Required only when DATABASE_PROVIDER=postgres +# For Docker Compose, "postgres" resolves to the postgres service container +DATABASE_URL=Host=postgres;Database=players;Username=postgres;Password=P0579r35_p455W0rd! + +# Required only when DATABASE_PROVIDER=postgres (used by the postgres service in compose.yaml) +POSTGRES_PASSWORD=P0579r35_p455W0rd! diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f7a4310..afffdd1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,7 @@ ## Overview -REST API for managing football players built with ASP.NET Core 10. Implements CRUD operations with a layered architecture, EF Core + SQLite persistence, FluentValidation, AutoMapper, and in-memory caching. Part of a cross-language comparison study (Go, Java, Python, Rust, TypeScript). Primarily a learning and reference project — clarity and educational value take precedence over brevity. +REST API for managing football players built with ASP.NET Core 10. Implements CRUD operations with a layered architecture, EF Core persistence (SQLite by default, PostgreSQL opt-in via `DATABASE_PROVIDER`), FluentValidation, AutoMapper, and in-memory caching. Part of a cross-language comparison study (Go, Java, Python, Rust, TypeScript). Primarily a learning and reference project — clarity and educational value take precedence over brevity. ## Tech Stack @@ -11,7 +11,7 @@ REST API for managing football players built with ASP.NET Core 10. Implements CR | Language | C# (.NET 10 LTS) | | Framework | ASP.NET Core (MVC controllers) | | ORM | Entity Framework Core 10 | -| Database | SQLite | +| Database | SQLite (default) · PostgreSQL 17 (opt-in) | | Mapping | AutoMapper | | Validation | FluentValidation | | Caching | `IMemoryCache` (1-hour TTL) | @@ -181,7 +181,7 @@ Example: `feat(api): add player search endpoint (#123)` - Production configurations or deployment secrets - `.runsettings` coverage thresholds - Port configuration (9000) -- Database type (SQLite — demo/dev only) +- Migration namespace constants in `ProviderSpecificMigrationsAssembly` — renaming breaks runtime provider filtering for one or both providers - CD pipeline tag format (`vX.Y.Z-stadium`) or the stadium name sequence — names are assigned sequentially A→Z from the list in `CHANGELOG.md`; the next name is always the next unused letter ### Creating Issues @@ -207,7 +207,9 @@ This project uses Spec-Driven Development (SDD): discuss in Plan mode first, cre **Add an endpoint**: Add DTO in `Models/` → update `PlayerMappingProfile` in `Mappings/` → add repository method(s) in `Repositories/` → add service method in `Services/` → add controller action in `Controllers/` → add/update validator rule set in `Validators/` → add tests in `test/.../Unit/` → run pre-commit checks. -**Modify schema**: Update `Player` entity → update DTOs → update AutoMapper profile → update `HasData()` seed data in `OnModelCreating` if needed → run `dotnet ef migrations add ` → update tests → run `dotnet test`. +**Modify schema**: Update `Player` entity → update DTOs → update AutoMapper profile → update `HasData()` seed data in `OnModelCreating` if needed → run `dotnet ef migrations add ` twice (once for each provider: output goes to `Migrations/` for SQLite, `Migrations/Npgsql/` for PostgreSQL) → update tests → run `dotnet test`. + +**Switch database provider**: Set `DATABASE_PROVIDER=postgres` (plus `DATABASE_URL`) to use PostgreSQL, or leave unset for SQLite (default). `ProviderSpecificMigrationsAssembly` filters migration discovery to the active provider's namespace at runtime — no code changes needed to switch. ## Architecture Decision Records (ADRs) @@ -216,7 +218,7 @@ Load `#file:adr/README.md` when: - Proposing changes to core architecture or dependencies - Historical context for past decisions is needed -ADRs are in `adr/` (0001–0012). Each file is self-contained. +ADRs are in `adr/` (0001–0014). Each file is self-contained. **After completing work**: Suggest a branch name (e.g. `feat/add-player-search`) and a commit message following Conventional Commits including co-author line: diff --git a/.gitignore b/.gitignore index 771f3ed..3b0ac57 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ bin/ obj/ TestResults/ storage/*.db +.env .claude/settings.local.json diff --git a/.sonarcloud.properties b/.sonarcloud.properties index 334004f..a17e587 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -23,7 +23,13 @@ sonar.exclusions=\ src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141707_SeedStarting11.Designer.cs,\ src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.cs,\ src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.Designer.cs,\ - src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs # ============================================================================= # Coverage exclusions @@ -47,6 +53,12 @@ sonar.coverage.exclusions=\ src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.cs,\ src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.Designer.cs,\ src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs,\ src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerDbContext.cs,\ src/Dotnet.Samples.AspNetCore.WebApi/Program.cs @@ -77,6 +89,12 @@ sonar.cpd.exclusions=\ src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.cs,\ src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.Designer.cs,\ src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs,\ + src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs,\ src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerDbContext.cs,\ src/Dotnet.Samples.AspNetCore.WebApi/Utilities/PlayerData.cs,\ src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs,\ diff --git a/CHANGELOG.md b/CHANGELOG.md index f77c338..8d82f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,10 +44,30 @@ This project uses famous football stadiums (A-Z) that hosted FIFA World Cup matc ### Added +- `DATABASE_PROVIDER` environment variable (`sqlite` default, `postgres` opt-in) to select the database engine at startup (issue #249). +- PostgreSQL 17 support via `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.1; migrations in `Migrations/Npgsql/` use proper PostgreSQL column types (`uuid`, `boolean`, `timestamp with time zone`). +- `ProviderSpecificMigrationsAssembly` that filters the EF Core migration set to the active provider's namespace, ensuring `MigrateAsync()` applies the correct migrations for both SQLite and PostgreSQL. +- `postgres` Docker Compose profile and service (`postgres:17-alpine`), started only when `DATABASE_PROVIDER=postgres` is set; the API service uses `depends_on` with `required: false` so SQLite mode incurs no dependency on the postgres service. +- `.env.example` documenting `DATABASE_PROVIDER`, `DATABASE_URL`, and `POSTGRES_PASSWORD`. +- `.env` added to `.gitignore`. +- ADR-0014 (`adr/0014-configurable-database-provider.md`) documenting the decision; supersedes ADR-0003. + ### Changed +- `AddDbContextPoolWithSqlite` renamed to `AddDbContextPool` in `ServiceCollectionExtensions`; now reads `DATABASE_PROVIDER` and wires either `UseSqlite` or `UseNpgsql` accordingly. +- `compose.yaml`: `api` service receives `DATABASE_PROVIDER` and `DATABASE_URL` environment variables; `postgres-data` named volume added. +- `scripts/entrypoint.sh`: SQLite file-presence check is skipped when `DATABASE_PROVIDER=postgres`. +- ADR-0003 status updated to "Superseded by ADR-0014". +- `README.md`: added Database section documenting SQLite and PostgreSQL modes. + ### Fixed +- Populate `BuildTargetModel` in Npgsql seed migration designer files so Npgsql's SQL generator can resolve column types when applying `InsertData` operations. +- Suppress `PendingModelChangesWarning` for the postgres provider path — hand-crafted designer files cannot replicate Npgsql-injected runtime annotations (`Relational:MaxIdentifierLength`, `UseIdentityByDefaultColumn`), causing a false-positive that aborted `MigrateAsync()` at startup. +- Normalize `DATABASE_PROVIDER` to lowercase in `entrypoint.sh` via `tr` so `POSTGRES`, `Postgres`, etc. are handled consistently with `AddDbContextPool`. +- Trim and normalize `DATABASE_PROVIDER` in `AddDbContextPool` before the provider switch; add explicit `sqlite`/empty case; throw `InvalidOperationException` for unrecognized values so typos no longer silently fall through to SQLite. +- Move `Npgsql.EntityFrameworkCore.PostgreSQL` package from the "Development dependencies" `ItemGroup` to "Runtime dependencies". + ### Removed --- diff --git a/README.md b/README.md index de7f0a4..3160d01 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Proof of Concept for a RESTful API built with .NET 10 (LTS) and ASP.NET Core. Ma | **API Documentation** | [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) (OpenAPI 3.0) | | **Validation** | [FluentValidation 12](https://github.com/FluentValidation/FluentValidation) | | **Mapping** | [AutoMapper 14](https://github.com/AutoMapper/AutoMapper) | -| **Database** | [SQLite 3](https://github.com/sqlite/sqlite) | +| **Database** | [SQLite 3](https://github.com/sqlite/sqlite) (default) · [PostgreSQL 17](https://github.com/postgres/postgres) (opt-in) | | **ORM** | [Entity Framework Core 10.0](https://github.com/dotnet/efcore) | | **Logging** | [Serilog 9](https://github.com/serilog/serilog) | | **Testing** | [xUnit](https://github.com/xunit/xunit), [Moq](https://github.com/devlooped/moq), [FluentAssertions](https://github.com/fluentassertions/fluentassertions) | @@ -155,6 +155,41 @@ Error codes: `400 Bad Request` (validation failed) · `404 Not Found` (player no For complete endpoint documentation with request/response schemas, explore the [interactive Swagger UI](https://localhost:9000/swagger/index.html). +## Database + +The database engine is selected via the `DATABASE_PROVIDER` environment variable. + +| Value | Engine | Requires | +| ----- | ------ | -------- | +| `sqlite` (default) | SQLite — file-based, zero infrastructure | Nothing — clone and run | +| `postgres` | PostgreSQL 17 — server-based, production-parity | Docker | + +### SQLite mode (default) + +```bash +# Local run — no setup required +dotnet run --project src/Dotnet.Samples.AspNetCore.WebApi + +# Docker Compose +docker compose up +``` + +### PostgreSQL mode (opt-in) + +```bash +# Docker Compose with the postgres profile +DATABASE_PROVIDER=postgres docker compose --profile postgres up +``` + +When using PostgreSQL you can override the connection string and password: + +```bash +DATABASE_URL=Host=localhost;Database=players;Username=postgres;Password=P0579r35_p455W0rd! +POSTGRES_PASSWORD=P0579r35_p455W0rd! +``` + +Copy `.env.example` to `.env` and edit as needed — `.env` is git-ignored. + ## Prerequisites Before you begin, ensure you have the following installed: diff --git a/adr/0003-use-sqlite-for-data-storage.md b/adr/0003-use-sqlite-for-data-storage.md index 8409b47..23cd661 100644 --- a/adr/0003-use-sqlite-for-data-storage.md +++ b/adr/0003-use-sqlite-for-data-storage.md @@ -4,7 +4,7 @@ Date: 2026-04-02 ## Status -Accepted +Superseded by [ADR-0014](0014-configurable-database-provider.md) ## Context diff --git a/adr/0014-configurable-database-provider.md b/adr/0014-configurable-database-provider.md new file mode 100644 index 0000000..8f0c8a5 --- /dev/null +++ b/adr/0014-configurable-database-provider.md @@ -0,0 +1,87 @@ +# 0014. Configurable Database Provider + +Date: 2026-05-02 + +## Status + +Accepted — supersedes [ADR-0003](0003-use-sqlite-for-data-storage.md) + +## Context + +The project used SQLite as its only database engine (ADR-0003). This meant all environments — local development, Docker Compose, and any deployment — ran the same SQLite file-based database. For the majority of contributors this is ideal: zero infrastructure required. + +However, a fixed SQLite-only setup has limits: + +- SQLite does not support concurrent writes, so multi-instance deployments are not possible. +- Developers who want to validate production-parity behaviour (e.g. PostgreSQL-specific query plans, type semantics, or constraint handling) have no path to do so without modifying the project. +- A fixed SQLite-in-dev / PostgreSQL-in-prod split (a common alternative) introduces a different problem: subtle behavioral differences between environments that are hard to catch locally. + +The goal is to make the database engine fully configurable with a single environment variable, so the same stack is used consistently across every environment a developer chooses to run. + +## Decision + +We will introduce a `DATABASE_PROVIDER` environment variable that selects the database engine at startup: + +- **`DATABASE_PROVIDER=sqlite`** (default): SQLite everywhere. Zero infrastructure required. Works on any machine without Docker. Clone and run. +- **`DATABASE_PROVIDER=postgres`**: PostgreSQL everywhere. Requires Docker. Opt-in for developers who want a server-based engine or full production parity. + +The default is `sqlite` to keep the barrier to entry as low as possible. + +### Provider selection at startup + +`ServiceCollectionExtensions.AddDbContextPool` reads `DATABASE_PROVIDER` and wires the appropriate EF Core provider: + +```csharp +switch (provider.ToLowerInvariant()) +{ + case "postgres": + options.UseNpgsql(Environment.GetEnvironmentVariable("DATABASE_URL")); + break; + default: + options.UseSqlite($"Data Source={dataSource}"); + break; +} +``` + +### Provider-specific EF Core migrations + +SQLite and PostgreSQL require different column type mappings (`TEXT`/`INTEGER` vs `uuid`/`boolean`/`timestamp with time zone`). A single migration set cannot satisfy both providers. To resolve this: + +- SQLite migrations remain in `Migrations/` (namespace `...Migrations`). +- PostgreSQL migrations are placed in `Migrations/Npgsql/` (namespace `...Migrations.Npgsql`). +- A custom `ProviderSpecificMigrationsAssembly` (implementing EF Core's `IMigrationsAssembly`) filters the discovered migrations to only those in the active provider's namespace. It is registered via `options.ReplaceService()`. + +`MigrateAsync()` at startup continues to work for both providers; each sees only its own migration set. + +### Docker Compose profiles + +The `postgres` service is declared under the `postgres` Compose profile so it only starts when explicitly requested: + +```bash +# SQLite (default — no extra Docker service) +docker compose up + +# PostgreSQL (opt-in) +DATABASE_PROVIDER=postgres docker compose --profile postgres up +``` + +## Consequences + +### Positive + +- Developers choose their engine once and use it consistently across all environments. +- SQLite remains the zero-friction default — clone, run, done. +- PostgreSQL is a first-class option for production-parity testing without requiring project-wide changes. +- The Compose profile approach prevents accidental PostgreSQL containers from starting in SQLite mode. +- `dotnet run` continues to work unchanged with SQLite. + +### Negative + +- Two parallel migration sets must be maintained. Adding a schema change requires migrations in both `Migrations/` and `Migrations/Npgsql/`. +- `ProviderSpecificMigrationsAssembly` uses an EF Core internal API (`MigrationsAssembly` from `Microsoft.EntityFrameworkCore.Migrations.Internal`), annotated with `#pragma warning disable EF1001`. This may require updates on major EF Core version upgrades. +- PostgreSQL support is not tested in CI (no Testcontainers integration yet — tracked in issue #353). + +### Neutral + +- The `DATABASE_URL` connection string format follows the Npgsql convention (`Host=...;Database=...;Username=...;Password=...`). +- `.env.example` documents all three new environment variables (`DATABASE_PROVIDER`, `DATABASE_URL`, `POSTGRES_PASSWORD`). `.env` is git-ignored. diff --git a/adr/README.md b/adr/README.md index 49418e2..9a66db7 100644 --- a/adr/README.md +++ b/adr/README.md @@ -8,7 +8,7 @@ This directory contains Architecture Decision Records (ADRs) for this project. A |-----|-------|--------| | [0001](0001-adopt-traditional-layered-architecture.md) | Adopt Traditional Layered Architecture | Accepted (Under Reconsideration) | | [0002](0002-use-mvc-controllers-over-minimal-api.md) | Use MVC Controllers over Minimal API | Accepted | -| [0003](0003-use-sqlite-for-data-storage.md) | Use SQLite for Data Storage | Accepted | +| [0003](0003-use-sqlite-for-data-storage.md) | Use SQLite for Data Storage | Superseded by ADR-0014 | | [0004](0004-use-uuid-as-database-primary-key.md) | Use UUID as Database Primary Key | Accepted | | [0005](0005-use-squad-number-as-api-mutation-key.md) | Use Squad Number as API Mutation Key | Accepted | | [0006](0006-use-rfc-7807-problem-details-for-errors.md) | Use RFC 7807 Problem Details for Errors | Accepted | @@ -19,6 +19,7 @@ This directory contains Architecture Decision Records (ADRs) for this project. A | [0011](0011-use-docker-for-containerization.md) | Use Docker for Containerization | Accepted | | [0012](0012-use-stadium-themed-semantic-versioning.md) | Use Stadium-Themed Semantic Versioning | Accepted | | [0013](0013-testing-strategy.md) | Testing Strategy | Accepted | +| [0014](0014-configurable-database-provider.md) | Configurable Database Provider | Accepted | ## When to Create an ADR diff --git a/compose.yaml b/compose.yaml index 771da07..f79155e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,9 +12,34 @@ services: volumes: - storage:/storage/ environment: + - DATABASE_PROVIDER=${DATABASE_PROVIDER:-sqlite} + - DATABASE_URL=${DATABASE_URL:-Host=postgres;Database=players;Username=postgres;Password=postgres} - STORAGE_PATH=/storage/players-sqlite3.db + depends_on: + postgres: + condition: service_healthy + required: false + restart: unless-stopped + + postgres: + image: postgres:17-alpine + container_name: aspnetcore-postgres + profiles: [postgres] + environment: + POSTGRES_DB: players + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 restart: unless-stopped volumes: storage: name: dotnet-samples-aspnetcore-webapi_storage + postgres-data: + name: dotnet-samples-aspnetcore-webapi_postgres-data diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index edcd60c..4a625fb 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -10,13 +10,19 @@ log() { log "✔ Starting container..." -VOLUME_STORAGE_PATH="${STORAGE_PATH:-/storage/players-sqlite3.db}" +DATABASE_PROVIDER=$(printf '%s' "${DATABASE_PROVIDER:-sqlite}" | tr '[:upper:]' '[:lower:]') -if [ ! -f "$VOLUME_STORAGE_PATH" ]; then - log "⚠️ No existing database file found in volume." - log "🗄️ EF Core migrations will initialize the database on first start." +if [ "$DATABASE_PROVIDER" = "postgres" ]; then + log "✔ Using PostgreSQL database." else - log "✔ Existing database file found at $VOLUME_STORAGE_PATH." + VOLUME_STORAGE_PATH="${STORAGE_PATH:-/storage/players-sqlite3.db}" + + if [ ! -f "$VOLUME_STORAGE_PATH" ]; then + log "⚠️ No existing database file found in volume." + log "🗄️ EF Core migrations will initialize the database on first start." + else + log "✔ Existing database file found at $VOLUME_STORAGE_PATH." + fi fi log "✔ Ready!" diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj b/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj index 322e3d5..d37b837 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs index 75c13d5..52fc241 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs @@ -2,12 +2,15 @@ using Dotnet.Samples.AspNetCore.WebApi.Configurations; using Dotnet.Samples.AspNetCore.WebApi.Data; using Dotnet.Samples.AspNetCore.WebApi.Mappings; +using Dotnet.Samples.AspNetCore.WebApi.Migrations; using Dotnet.Samples.AspNetCore.WebApi.Repositories; using Dotnet.Samples.AspNetCore.WebApi.Services; using Dotnet.Samples.AspNetCore.WebApi.Utilities; using Dotnet.Samples.AspNetCore.WebApi.Validators; using FluentValidation; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.OpenApi; using Serilog; @@ -19,35 +22,71 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Extensions; public static partial class ServiceCollectionExtensions { /// - /// Adds DbContextPool with SQLite configuration for PlayerDbContext. + /// Adds PlayerDbContext, selecting the database provider based on the + /// DATABASE_PROVIDER environment variable (sqlite by default, postgres + /// to opt in to PostgreSQL). /// /// The IServiceCollection instance. /// The web host environment. /// The IServiceCollection for method chaining. - public static IServiceCollection AddDbContextPoolWithSqlite( + public static IServiceCollection AddPlayerDbContext( this IServiceCollection services, IWebHostEnvironment environment ) { services.AddDbContextPool(options => { - var storagePath = Environment.GetEnvironmentVariable("STORAGE_PATH"); - var dataSource = !string.IsNullOrWhiteSpace(storagePath) - ? storagePath - : Path.Combine(AppContext.BaseDirectory, "storage", "players-sqlite3.db"); + var provider = (Environment.GetEnvironmentVariable("DATABASE_PROVIDER") ?? "") + .Trim() + .ToLowerInvariant(); - var storageDir = Path.GetDirectoryName(dataSource); - if (!string.IsNullOrWhiteSpace(storageDir)) + switch (provider) { - Directory.CreateDirectory(storageDir); + case "postgres": + var connectionString = Environment.GetEnvironmentVariable("DATABASE_URL"); + if (string.IsNullOrWhiteSpace(connectionString)) + throw new InvalidOperationException( + "DATABASE_URL is required when DATABASE_PROVIDER=postgres." + ); + options.UseNpgsql(connectionString, npgsql => npgsql.EnableRetryOnFailure()); + // Hand-crafted designer files cannot replicate Npgsql-injected runtime + // annotations (Relational:MaxIdentifierLength, UseIdentityByDefaultColumn), + // causing a false-positive PendingModelChangesWarning. Suppressed here; + // BuildTargetModel is still populated so InsertData SQL generation works. + options.ConfigureWarnings(w => + w.Ignore(RelationalEventId.PendingModelChangesWarning) + ); + break; + + case "sqlite": + case "": + var storagePath = Environment.GetEnvironmentVariable("STORAGE_PATH"); + var dataSource = !string.IsNullOrWhiteSpace(storagePath) + ? storagePath + : Path.Combine(AppContext.BaseDirectory, "storage", "players-sqlite3.db"); + + var storageDir = Path.GetDirectoryName(dataSource); + if (!string.IsNullOrWhiteSpace(storageDir)) + { + Directory.CreateDirectory(storageDir); + } + options.UseSqlite($"Data Source={dataSource}"); + break; + + default: + throw new InvalidOperationException( + $"Unsupported DATABASE_PROVIDER value: '{provider}'. " + + "Valid values are 'sqlite' (default) and 'postgres'." + ); } - options.UseSqlite($"Data Source={dataSource}"); if (environment.IsDevelopment()) { options.EnableSensitiveDataLogging(); options.LogTo(Log.Logger.Information, LogLevel.Information); } + + options.ReplaceService(); }); return services; diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs new file mode 100644 index 0000000..436267e --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs @@ -0,0 +1,68 @@ +// +using System; +using Dotnet.Samples.AspNetCore.WebApi.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql +{ + [DbContext(typeof(PlayerDbContext))] + [Migration("20260409151000_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("Dotnet.Samples.AspNetCore.WebApi.Models.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AbbrPosition") + .HasColumnType("text"); + + b.Property("DateOfBirth") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("League") + .HasColumnType("text"); + + b.Property("MiddleName") + .HasColumnType("text"); + + b.Property("Position") + .HasColumnType("text"); + + b.Property("SquadNumber") + .HasColumnType("integer"); + + b.Property("Starting11") + .HasColumnType("boolean"); + + b.Property("Team") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SquadNumber") + .IsUnique(); + + b.ToTable("Players"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs new file mode 100644 index 0000000..5bc486b --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Players", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FirstName = table.Column(type: "text", nullable: true), + MiddleName = table.Column(type: "text", nullable: true), + LastName = table.Column(type: "text", nullable: true), + DateOfBirth = table.Column( + type: "timestamp with time zone", + nullable: true + ), + SquadNumber = table.Column(type: "integer", nullable: false), + Position = table.Column(type: "text", nullable: true), + AbbrPosition = table.Column(type: "text", nullable: true), + Team = table.Column(type: "text", nullable: true), + League = table.Column(type: "text", nullable: true), + Starting11 = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Players", x => x.Id); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_Players_SquadNumber", + table: "Players", + column: "SquadNumber", + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "Players"); + } + } +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs new file mode 100644 index 0000000..cb4748a --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs @@ -0,0 +1,68 @@ +// +using System; +using Dotnet.Samples.AspNetCore.WebApi.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql +{ + [DbContext(typeof(PlayerDbContext))] + [Migration("20260409151100_SeedStarting11")] + partial class SeedStarting11 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("Dotnet.Samples.AspNetCore.WebApi.Models.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AbbrPosition") + .HasColumnType("text"); + + b.Property("DateOfBirth") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("League") + .HasColumnType("text"); + + b.Property("MiddleName") + .HasColumnType("text"); + + b.Property("Position") + .HasColumnType("text"); + + b.Property("SquadNumber") + .HasColumnType("integer"); + + b.Property("Starting11") + .HasColumnType("boolean"); + + b.Property("Team") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SquadNumber") + .IsUnique(); + + b.ToTable("Players"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs new file mode 100644 index 0000000..b833b27 --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs @@ -0,0 +1,251 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql +{ + /// + public partial class SeedStarting11 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + table: "Players", + columns: new[] + { + "Id", + "AbbrPosition", + "DateOfBirth", + "FirstName", + "LastName", + "League", + "MiddleName", + "Position", + "SquadNumber", + "Starting11", + "Team" + }, + values: new object[,] + { + { + new Guid("01772c59-43f0-5d85-b913-c78e4e281452"), + "GK", + new DateTime(1992, 9, 1, 0, 0, 0, 0, DateTimeKind.Utc), + "Damián", + "Martínez", + "Premier League", + "Emiliano", + "Goalkeeper", + 23, + true, + "Aston Villa FC" + }, + { + new Guid("0293b282-1da8-562e-998e-83849b417a42"), + "CM", + new DateTime(1994, 5, 23, 0, 0, 0, 0, DateTimeKind.Utc), + "Rodrigo", + "de Paul", + "La Liga", + "Javier", + "Central Midfield", + 7, + true, + "Altético Madrid" + }, + { + new Guid("2f6f90a0-9b9d-5023-96d2-a2aaf03143a6"), + "LB", + new DateTime(1992, 8, 30, 0, 0, 0, 0, DateTimeKind.Utc), + "Nicolás", + "Tagliafico", + "Ligue 1", + "Alejandro", + "Left-Back", + 3, + true, + "Olympique Lyon" + }, + { + new Guid("38bae91d-8519-55a2-b30a-b9fe38849bfb"), + "CF", + new DateTime(2000, 1, 30, 0, 0, 0, 0, DateTimeKind.Utc), + "Julián", + "Álvarez", + "Premier League", + null, + "Centre-Forward", + 9, + true, + "Manchester City" + }, + { + new Guid("9613cae9-16ab-5b54-937e-3135123b9e0d"), + "CM", + new DateTime(1998, 12, 23, 0, 0, 0, 0, DateTimeKind.Utc), + "Alexis", + "Mac Allister", + "Premier League", + null, + "Central Midfield", + 20, + true, + "Brighton & Hove Albion" + }, + { + new Guid("acc433bf-d505-51fe-831e-45eb44c4d43c"), + "RW", + new DateTime(1987, 6, 23, 0, 0, 0, 0, DateTimeKind.Utc), + "Lionel", + "Messi", + "Ligue 1", + "Andrés", + "Right Winger", + 10, + true, + "Paris Saint-Germain" + }, + { + new Guid("b5b46e79-929e-5ed2-949d-0d167109c022"), + "RW", + new DateTime(1988, 2, 13, 0, 0, 0, 0, DateTimeKind.Utc), + "Ángel", + "Di María", + "Liga Portugal", + "Fabián", + "Right Winger", + 11, + true, + "SL Benfica" + }, + { + new Guid("c096c69e-762b-5281-9290-bb9c167a24a0"), + "CB", + new DateTime(1998, 4, 26, 0, 0, 0, 0, DateTimeKind.Utc), + "Cristian", + "Romero", + "Premier League", + "Gabriel", + "Centre-Back", + 13, + true, + "Tottenham Hotspur" + }, + { + new Guid("d3ba552a-dac3-588a-b961-1ea7224017fd"), + "CM", + new DateTime(2001, 1, 16, 0, 0, 0, 0, DateTimeKind.Utc), + "Enzo", + "Fernández", + "Liga Portugal", + "Jeremías", + "Central Midfield", + 24, + true, + "SL Benfica" + }, + { + new Guid("d5f7dd7a-1dcb-5960-ba27-e34865b63358"), + "CB", + new DateTime(1988, 2, 11, 0, 0, 0, 0, DateTimeKind.Utc), + "Nicolás", + "Otamendi", + "Liga Portugal", + "Hernán Gonzalo", + "Centre-Back", + 19, + true, + "SL Benfica" + }, + { + new Guid("da31293b-4c7e-5e0f-a168-469ee29ecbc4"), + "RB", + new DateTime(1998, 4, 5, 0, 0, 0, 0, DateTimeKind.Utc), + "Nahuel", + "Molina", + "La Liga", + null, + "Right-Back", + 26, + true, + "Altético Madrid" + } + } + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("01772c59-43f0-5d85-b913-c78e4e281452") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("0293b282-1da8-562e-998e-83849b417a42") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("2f6f90a0-9b9d-5023-96d2-a2aaf03143a6") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("38bae91d-8519-55a2-b30a-b9fe38849bfb") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("9613cae9-16ab-5b54-937e-3135123b9e0d") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("acc433bf-d505-51fe-831e-45eb44c4d43c") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("b5b46e79-929e-5ed2-949d-0d167109c022") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("c096c69e-762b-5281-9290-bb9c167a24a0") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("d3ba552a-dac3-588a-b961-1ea7224017fd") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("d5f7dd7a-1dcb-5960-ba27-e34865b63358") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("da31293b-4c7e-5e0f-a168-469ee29ecbc4") + ); + } + } +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs new file mode 100644 index 0000000..2cddcbe --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs @@ -0,0 +1,68 @@ +// +using System; +using Dotnet.Samples.AspNetCore.WebApi.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql +{ + [DbContext(typeof(PlayerDbContext))] + [Migration("20260409151200_SeedSubstitutes")] + partial class SeedSubstitutes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("Dotnet.Samples.AspNetCore.WebApi.Models.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AbbrPosition") + .HasColumnType("text"); + + b.Property("DateOfBirth") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("League") + .HasColumnType("text"); + + b.Property("MiddleName") + .HasColumnType("text"); + + b.Property("Position") + .HasColumnType("text"); + + b.Property("SquadNumber") + .HasColumnType("integer"); + + b.Property("Starting11") + .HasColumnType("boolean"); + + b.Property("Team") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SquadNumber") + .IsUnique(); + + b.ToTable("Players"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs new file mode 100644 index 0000000..739344f --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs @@ -0,0 +1,327 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql +{ + /// + public partial class SeedSubstitutes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + table: "Players", + columns: new[] + { + "Id", + "AbbrPosition", + "DateOfBirth", + "FirstName", + "LastName", + "League", + "MiddleName", + "Position", + "SquadNumber", + "Starting11", + "Team" + }, + values: new object[,] + { + { + new Guid("191c82af-0c51-526a-b903-c3600b61b506"), + "DM", + new DateTime(1994, 4, 12, 0, 0, 0, 0, DateTimeKind.Utc), + "Guido", + "Rodríguez", + "La Liga", + null, + "Defensive Midfield", + 18, + false, + "Real Betis Balompié" + }, + { + new Guid("5a9cd988-95e6-54c1-bc34-9aa08acca8d0"), + "GK", + new DateTime(1986, 10, 16, 0, 0, 0, 0, DateTimeKind.Utc), + "Franco", + "Armani", + "Copa de la Liga", + "Daniel", + "Goalkeeper", + 1, + false, + "River Plate" + }, + { + new Guid("5fdb10e8-38c0-5084-9a3f-b369a960b9c2"), + "RB", + new DateTime(1998, 1, 12, 0, 0, 0, 0, DateTimeKind.Utc), + "Juan", + "Foyth", + "La Liga", + "Marcos", + "Right-Back", + 2, + false, + "Villarreal" + }, + { + new Guid("7941cd7c-4df1-5952-97e8-1e7f5d08e8aa"), + "SS", + new DateTime(1993, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc), + "Paulo", + "Dybala", + "Serie A", + "Exequiel", + "Second Striker", + 21, + false, + "AS Roma" + }, + { + new Guid("79c96f29-c59f-5f98-96b8-3a5946246624"), + "CF", + new DateTime(1997, 8, 22, 0, 0, 0, 0, DateTimeKind.Utc), + "Lautaro", + "Martínez", + "Serie A", + "Javier", + "Centre-Forward", + 22, + false, + "Inter Milan" + }, + { + new Guid("7cc8d527-56a2-58bd-9528-2618fc139d30"), + "LW", + new DateTime(1988, 2, 15, 0, 0, 0, 0, DateTimeKind.Utc), + "Alejandro", + "Gómez", + "Serie A", + "Darío", + "Left Winger", + 17, + false, + "AC Monza" + }, + { + new Guid("98306555-a466-5d18-804e-dc82175e697b"), + "CB", + new DateTime(1998, 1, 18, 0, 0, 0, 0, DateTimeKind.Utc), + "Lisandro", + "Martínez", + "Premier League", + null, + "Centre-Back", + 25, + false, + "Manchester United" + }, + { + new Guid("9d140400-196f-55d8-86e1-e0b96a375c83"), + "DM", + new DateTime(1994, 6, 29, 0, 0, 0, 0, DateTimeKind.Utc), + "Leandro", + "Paredes", + "Serie A", + "Daniel", + "Defensive Midfield", + 5, + false, + "AS Roma" + }, + { + new Guid("b1306b7b-a3a4-5f7c-90fd-dd5bdbed57ba"), + "RW", + new DateTime(1995, 3, 9, 0, 0, 0, 0, DateTimeKind.Utc), + "Ángel", + "Correa", + "La Liga", + "Martín", + "Right Winger", + 15, + false, + "Atlético Madrid" + }, + { + new Guid("bbd441f7-fcfb-5834-8468-2a9004b64c8c"), + "RB", + new DateTime(1997, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + "Gonzalo", + "Montiel", + "Premier League", + "Ariel", + "Right-Back", + 4, + false, + "Nottingham Forest" + }, + { + new Guid("c62f2ac1-41e8-5d34-b073-2ba0913d0e31"), + "GK", + new DateTime(1992, 5, 20, 0, 0, 0, 0, DateTimeKind.Utc), + "Gerónimo", + "Rulli", + "Eredivisie", + null, + "Goalkeeper", + 12, + false, + "Ajax Amsterdam" + }, + { + new Guid("d3b0e8e8-2c34-531a-b608-b24fed0ef986"), + "CM", + new DateTime(1998, 10, 5, 0, 0, 0, 0, DateTimeKind.Utc), + "Exequiel", + "Palacios", + "Bundesliga", + "Alejandro", + "Central Midfield", + 14, + false, + "Bayer 04 Leverkusen" + }, + { + new Guid("d8bfea25-f189-5d5e-b3a5-ed89329b9f7c"), + "CB", + new DateTime(1991, 6, 27, 0, 0, 0, 0, DateTimeKind.Utc), + "Germán", + "Pezzella", + "La Liga", + "Alejo", + "Centre-Back", + 6, + false, + "Real Betis Balompié" + }, + { + new Guid("dca343a8-12e5-53d6-89a8-916b120a5ee4"), + "LB", + new DateTime(1991, 10, 28, 0, 0, 0, 0, DateTimeKind.Utc), + "Marcos", + "Acuña", + "La Liga", + "Javier", + "Left-Back", + 8, + false, + "Sevilla FC" + }, + { + new Guid("ecec27e8-487b-5622-b116-0855020477ed"), + "AM", + new DateTime(2001, 4, 26, 0, 0, 0, 0, DateTimeKind.Utc), + "Thiago", + "Almada", + "Major League Soccer", + "Ezequiel", + "Attacking Midfield", + 16, + false, + "Atlanta United FC" + } + } + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("191c82af-0c51-526a-b903-c3600b61b506") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("5a9cd988-95e6-54c1-bc34-9aa08acca8d0") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("5fdb10e8-38c0-5084-9a3f-b369a960b9c2") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("7941cd7c-4df1-5952-97e8-1e7f5d08e8aa") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("79c96f29-c59f-5f98-96b8-3a5946246624") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("7cc8d527-56a2-58bd-9528-2618fc139d30") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("98306555-a466-5d18-804e-dc82175e697b") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("9d140400-196f-55d8-86e1-e0b96a375c83") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("b1306b7b-a3a4-5f7c-90fd-dd5bdbed57ba") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("bbd441f7-fcfb-5834-8468-2a9004b64c8c") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("c62f2ac1-41e8-5d34-b073-2ba0913d0e31") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("d3b0e8e8-2c34-531a-b608-b24fed0ef986") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("d8bfea25-f189-5d5e-b3a5-ed89329b9f7c") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("dca343a8-12e5-53d6-89a8-916b120a5ee4") + ); + + migrationBuilder.DeleteData( + table: "Players", + keyColumn: "Id", + keyValue: new Guid("ecec27e8-487b-5622-b116-0855020477ed") + ); + } + } +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/ProviderSpecificMigrationsAssembly.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/ProviderSpecificMigrationsAssembly.cs new file mode 100644 index 0000000..aca86ad --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/ProviderSpecificMigrationsAssembly.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +#pragma warning disable EF1001 // Internal EF Core API usage. +using Microsoft.EntityFrameworkCore.Migrations.Internal; + +namespace Dotnet.Samples.AspNetCore.WebApi.Migrations; + +/// +/// Filters the available migrations to only those belonging to the active database provider's +/// namespace, ensuring SQLite and PostgreSQL each apply their own migration set. +/// +#pragma warning disable CS9107 // currentContext is intentionally captured by both the subclass and the base class +public class ProviderSpecificMigrationsAssembly( + ICurrentDbContext currentContext, + IDbContextOptions options, + IMigrationsIdGenerator idGenerator, + IDiagnosticsLogger logger +) : MigrationsAssembly(currentContext, options, idGenerator, logger) +#pragma warning restore CS9107 +{ + private const string SqliteNamespace = "Dotnet.Samples.AspNetCore.WebApi.Migrations"; + private const string NpgsqlNamespace = "Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql"; + + public override IReadOnlyDictionary Migrations + { + get + { + var providerName = currentContext.Context.Database.ProviderName ?? string.Empty; + var targetNamespace = providerName.Contains( + "Sqlite", + StringComparison.OrdinalIgnoreCase + ) + ? SqliteNamespace + : NpgsqlNamespace; + + return new SortedDictionary( + base.Migrations.Where(m => m.Value.Namespace == targetNamespace) + .ToDictionary(m => m.Key, m => m.Value) + ); + } + } +} +#pragma warning restore EF1001 diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs index 59446fd..7e92d8c 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs @@ -54,7 +54,7 @@ /* Data --------------------------------------------------------------------- */ -builder.Services.AddDbContextPoolWithSqlite(builder.Environment); +builder.Services.AddPlayerDbContext(builder.Environment); var app = builder.Build(); diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/packages.lock.json b/src/Dotnet.Samples.AspNetCore.WebApi/packages.lock.json index 85513c8..c219271 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/packages.lock.json +++ b/src/Dotnet.Samples.AspNetCore.WebApi/packages.lock.json @@ -93,6 +93,17 @@ "System.Security.Cryptography.ProtectedData": "9.0.6" } }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "P6EwH0Q4xkaA264iNZDqCPhWt8pscfUGxXazDQg4noBfqjoOlk4hKWfvBjF9ZX3R/9JybRmmJfmxr2iBMj0EpA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.2" + } + }, "Serilog.AspNetCore": { "type": "Direct", "requested": "[10.0.0, )", @@ -1028,6 +1039,11 @@ "resolved": "13.0.3", "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==" + }, "NuGet.Common": { "type": "Transitive", "resolved": "6.12.1", diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Integration/PlayerWebApplicationTests.cs b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Integration/PlayerWebApplicationTests.cs index f809764..57558e4 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Integration/PlayerWebApplicationTests.cs +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Integration/PlayerWebApplicationTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Json; using Dotnet.Samples.AspNetCore.WebApi.Data; +using Dotnet.Samples.AspNetCore.WebApi.Migrations; using Dotnet.Samples.AspNetCore.WebApi.Models; using Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities; using FluentAssertions; @@ -11,6 +12,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Integration; @@ -57,7 +59,11 @@ public Task InitializeAsync() foreach (var descriptor in descriptors) services.Remove(descriptor); - services.AddDbContext(options => options.UseSqlite(_connection)); + services.AddDbContext(options => + options + .UseSqlite(_connection) + .ReplaceService() + ); }); }); diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/DatabaseFakes.cs b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/DatabaseFakes.cs index ec0fc99..fc8bc05 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/DatabaseFakes.cs +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/DatabaseFakes.cs @@ -1,7 +1,9 @@ using System.Data.Common; using Dotnet.Samples.AspNetCore.WebApi.Data; +using Dotnet.Samples.AspNetCore.WebApi.Migrations; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities { @@ -26,6 +28,7 @@ public static (DbConnection, DbContextOptions) CreateSqliteConn var dbContextOptions = new DbContextOptionsBuilder() .UseSqlite(dbConnection) + .ReplaceService() .Options; return (dbConnection, dbContextOptions); diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/packages.lock.json b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/packages.lock.json index a19ff2b..af722ff 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/packages.lock.json +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/packages.lock.json @@ -542,6 +542,24 @@ "resolved": "13.0.3", "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "P6EwH0Q4xkaA264iNZDqCPhWt8pscfUGxXazDQg4noBfqjoOlk4hKWfvBjF9ZX3R/9JybRmmJfmxr2iBMj0EpA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.2" + } + }, "Serilog": { "type": "Transitive", "resolved": "4.3.0", @@ -736,6 +754,7 @@ "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Microsoft.AspNetCore.OpenApi": "[10.0.7, )", "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.7, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Settings.Configuration": "[10.0.0, )", "Serilog.Sinks.Console": "[6.1.1, )",