diff --git a/.agents/rules/database.md b/.agents/rules/database.md index 864b096215..59f5eba5ce 100644 --- a/.agents/rules/database.md +++ b/.agents/rules/database.md @@ -27,22 +27,61 @@ Read before touching entities, DbContexts, migrations, or query filters. A child entity reached **only** through a parent's navigation collection needs `Property(x => x.Id).ValueGeneratedNever()` in its EF config — otherwise EF treats it as `Modified` instead of `Added` and the insert silently misbehaves. +## Database providers (PostgreSQL + SQL Server) + +- The provider is **config-selected**, never hardcoded: `DatabaseOptions:Provider` (`POSTGRESQL` | `MSSQL`) plus a matching `DatabaseOptions:MigrationsAssembly`. Both migrations projects ship in every build, so switching is a config change — no rescaffold, no file surgery. +- **MSSQL requires SQL Server 2025 (17.x) or Azure SQL.** JSON columns map to the native `json` type (compatibility level 170); it does not exist on 2019/2022 and those migrations will not apply there. +- **Never write provider SQL in an entity configuration.** A literal `HasColumnType("jsonb")` or `HasFilter("\"IsDeleted\" = FALSE")` locks the model to one provider and fails model-build on the other. Declare intent instead — `HeroProviderConventions` resolves it per provider: + +| Instead of | Write | PostgreSQL | SQL Server | +|---|---|---|---| +| `HasColumnType("jsonb")` | `.HasJsonColumn()` | `jsonb` | `json` | +| `HasColumnType("text")` | `.HasUnboundedTextColumn()` | `text` | `nvarchar(max)` | +| `HasDefaultValueSql("'{}'::jsonb")` | `.HasJsonDefaultEmptyObject()` | `'{}'::jsonb` | `N'{}'` | +| `HasDefaultValueSql("CURRENT_TIMESTAMP")` | `.HasUtcNowDefault()` | `CURRENT_TIMESTAMP` | `SYSUTCDATETIME()` | +| `HasFilter("\"IsDeleted\" = FALSE")` | `.HasNotDeletedFilter()` | `"IsDeleted" = FALSE` | `[IsDeleted] = 0` | +| `HasFilter("\"Status\" = 1")` | `.HasEqualsFilter("Status", 1)` | `"Status" = 1` | `[Status] = 1` | +| `.HasMethod("gin").HasOperators("gin_trgm_ops")` | `.AsTrigramSearchIndex()` | trigram GIN | *removed from model* | +| `.HasMethod("gin").HasOperators("jsonb_path_ops")` | `.AsJsonContainmentIndex()` | `jsonb_path_ops` GIN | *removed; `CREATE JSON INDEX` in the migration* | + +- A DbContext that does **not** derive from `BaseDbContext` must register the conventions itself in a `ConfigureConventions` override (`IdentityDbContext`, `TenantDbContext`, `BillingDbContext` do). `ProviderConventionRegistrationTests` fails the build if a new one forgets — the symptom otherwise is silent: JSON columns become `text`, every partial-index filter vanishes, and `Fsh:*` annotations leak into the snapshot and break the scaffolder. +- **Queries:** use `.WhereSearch(db.Database, term, x => x.Name, …)` rather than `EF.Functions.ILike` — `ILike` is Npgsql-only. For matching a JSON property as text, build the pattern with `ProviderQueryExtensions.JsonTextPropertyPattern`: PostgreSQL renders `jsonb::text` as `{"k": "v"}` (space after the colon), SQL Server's `json` casts to `{"k":"v"}` (no space), so a hard-coded pattern silently matches nothing on the other provider. +- **Capability differences on SQL Server:** + - Audit `Source`/`UserName` substring search scans — no trigram equivalent for `%term%` on `nvarchar(max)`. + - Chat message search uses `FREETEXTTABLE` when the instance has Full-Text Search, and falls back to a `LIKE` scan when it does not (the official `mcr.microsoft.com/mssql/server` container has no FTS; Azure SQL and full installs do). SQL Server populates a full-text index **asynchronously**, unlike PostgreSQL's generated `tsvector` column, so the FTS path is UNIONed with a `LIKE` pass over the last few minutes — otherwise a just-sent message would be silently unsearchable. Those pending matches are returned first, newest-first, ahead of the ranked ones. + - Guid v7 ids do **not** sort chronologically on SQL Server: `uniqueidentifier` compares the last six bytes first. Message paging therefore sorts on a persisted `char(36)` sort key (`ChatDbContext.MessageSortKey`) rather than on `Id`. Any new cursor pagination keyed on a Guid needs the same treatment — see `MessageOrdering`. +- **Raw SQL + `Include` on SQL Server:** EF wraps a `FromSql` query in a subselect to resolve the includes, and `SELECT … FROM (WITH … SELECT …) t` is not valid T-SQL. Use derived tables, never a CTE, in a `FromSql` that gets composed with `Include`. +- `EnableRetryOnFailure` is deliberately **off** for MSSQL: its execution strategy refuses user-initiated transactions, which the outbox requires. + ## Migrations -All migrations live in **one** project, `src/Host/FSH.Starter.Migrations.PostgreSQL`, organized **per-module by folder** (`Identity/`, `Catalog/`, `Chat/`, …), each with its own `{Module}DbContextModelSnapshot`. +Migrations live in **one project per provider** — `src/Host/FSH.Starter.Migrations.PostgreSQL` and `src/Host/FSH.Starter.Migrations.MSSQL` — each organized **per-module by folder** (`Identity/`, `Catalog/`, `Chat/`, …) with its own `{Module}DbContextModelSnapshot`. **An entity change needs a migration in BOTH.** ```bash +# PostgreSQL (default provider) dotnet ef migrations add {Name} \ --project src/Host/FSH.Starter.Migrations.PostgreSQL \ --startup-project src/Host/FSH.Starter.Api \ --context {Module}DbContext + +# SQL Server — the env vars pick the provider the design-time model is built against +DatabaseOptions__Provider=MSSQL \ +DatabaseOptions__MigrationsAssembly=FSH.Starter.Migrations.MSSQL \ +DatabaseOptions__ConnectionString='Server=localhost,1433;Database=fsh;User Id=sa;Password=…;TrustServerCertificate=True' \ +dotnet ef migrations add {Name} \ + --project src/Host/FSH.Starter.Migrations.MSSQL \ + --startup-project src/Host/FSH.Starter.Api \ + --context {Module}DbContext ``` +- **Verify with the `verify-migrations` skill** (`MigrationDriftTests` in `Architecture.Tests`): it compares every context's live model against that provider's own snapshot — the in-process equivalent of `has-pending-model-changes` — with no database and no Docker, and prints the exact `migrations add` command for whatever is missing. It is what catches "generated the PostgreSQL migration, forgot the SQL Server one", and also a portable-intent helper resolving differently than expected. The CLI command remains useful for one-off diagnosis. +- **Maintaining both providers is not mandatory.** `FshMaintainedDbProviders` in `src/Directory.Build.props` (`both` | `POSTGRESQL` | `MSSQL`, override per run with `FSH_MIGRATIONS_PROVIDERS`) decides which providers *fail* the build on drift; the rest only report it. Ships as `both` because the bundled modules are kept in sync for both engines — narrow it once a project settles on one, so nobody is blocked by migrations for an engine they do not use. + - **`migrations remove` operates on the snapshot** — run a full build *before* `migrations add` so the snapshot is current, or you can lose the previous migration. -- The DB is **not** migrated at API startup. The `DbMigrator` host is a separate step: `apply` (default), `seed`, `seed-demo` (dev only), `list-pending`; flags `--tenant `, `--catalog-only`, `--seed`. It migrates the tenant catalog first, then each tenant's per-module schema, serialized by a Postgres advisory lock. +- The DB is **not** migrated at API startup. The `DbMigrator` host is a separate step: `apply` (default), `seed`, `seed-demo` (dev only), `list-pending`; flags `--tenant `, `--catalog-only`, `--seed`. It migrates the tenant catalog first, then each tenant's per-module schema, serialized by a database-held migrator lock (a Postgres advisory lock, or `sp_getapplock` on SQL Server). - `dotnet-ef` is pinned in `.config/dotnet-tools.json` — run `dotnet tool restore` first. ## Tests + EF -- Integration tests use Testcontainers (real PostgreSQL) — **Docker must be running**. +- Integration tests use Testcontainers (real PostgreSQL by default) — **Docker must be running**. Set `FSH_TEST_DB_PROVIDER=MSSQL` to run the same suite against SQL Server 2025. - In integration tests, set the Finbuckle tenant context **inline in the same method** as the `UserManager`/`DbContext` call; an awaited-helper set is lost (AsyncLocal) and the tenant query filter NREs. diff --git a/.agents/rules/integration-testing.md b/.agents/rules/integration-testing.md index 63d0b7a264..2ecfdf2eaa 100644 --- a/.agents/rules/integration-testing.md +++ b/.agents/rules/integration-testing.md @@ -4,7 +4,7 @@ ## Harness -`WebApplicationFactory` over **real** infra via Testcontainers — PostgreSQL + Redis + MinIO. **Docker must be running**; if it isn't, tests fail fast with `DockerUnavailableException` (environmental, not a regression — run the unit projects instead). +`WebApplicationFactory` over **real** infra via Testcontainers — PostgreSQL + Redis + MinIO. Set `FSH_TEST_DB_PROVIDER=MSSQL` to run the same suite against SQL Server 2025 instead of PostgreSQL; the default is unchanged. **Docker must be running**; if it isn't, tests fail fast with `DockerUnavailableException` (environmental, not a regression — run the unit projects instead). `FshWebApplicationFactory` (`Integration.Tests/Infrastructure/`) boots the containers, overlays in-memory config, swaps `IMailService` → `NoOpMailService`, and rewires storage to MinIO. diff --git a/.agents/rules/testing.md b/.agents/rules/testing.md index e63c1523e9..b24abd4518 100644 --- a/.agents/rules/testing.md +++ b/.agents/rules/testing.md @@ -19,7 +19,7 @@ xUnit · Shouldly (`result.ShouldBe(...)`) · NSubstitute (`Substitute.For **Both providers, every time.** An entity or EF-config change that produces a PostgreSQL migration +> must produce the matching SQL Server one, or the MSSQL deployment silently drifts. Step 2 runs twice. ## Step 0 — restore the pinned tool (first time) @@ -33,6 +37,7 @@ Specify **all three** of `--project` (the Migrations project), `--startup-projec folder for the context). ```bash +# 2a — PostgreSQL dotnet ef migrations add {MigrationName} \ --project src/Host/FSH.Starter.Migrations.PostgreSQL \ --startup-project src/Host/FSH.Starter.Api \ @@ -40,6 +45,29 @@ dotnet ef migrations add {MigrationName} \ --output-dir {X} ``` +```bash +# 2b — SQL Server. The env vars decide which provider the design-time model is built for; without +# them you would scaffold PostgreSQL DDL into the MSSQL project. Needs a reachable SQL Server 2025. +DatabaseOptions__Provider=MSSQL \ +DatabaseOptions__MigrationsAssembly=FSH.Starter.Migrations.MSSQL \ +DatabaseOptions__ConnectionString='Server=localhost,1433;Database=fsh;User Id=sa;Password=…;TrustServerCertificate=True' \ +dotnet ef migrations add {MigrationName} \ + --project src/Host/FSH.Starter.Migrations.MSSQL \ + --startup-project src/Host/FSH.Starter.Api \ + --context {X}DbContext \ + --output-dir {X} +``` + +Then confirm neither provider has drifted. The fastest check is the **`verify-migrations`** skill +(`MigrationDriftTests`, no database, under a second), which names the exact command for anything +missing. The CLI equivalent, for one-off diagnosis: + +```bash +dotnet ef migrations has-pending-model-changes --context {X}DbContext \ + --project src/Host/FSH.Starter.Migrations.PostgreSQL --startup-project src/Host/FSH.Starter.Api +# …and the same with the MSSQL env vars + --project …Migrations.MSSQL +``` + ## Step 3 — review the generated SQL before applying ```bash @@ -65,13 +93,17 @@ dotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending # to prev ## Notes -- A **new module** also needs a `{X}/` folder in the Migrations project and the runtime project referenced from it — see `add-module`. +- A **new module** also needs a `{X}/` folder in **both** Migrations projects, and the runtime project referenced from both — see `add-module`. - `dotnet ef` against a `BaseDbContext` works because the 4-arg ctor is satisfied by the startup host's DI. +- Write EF config with the **portable helpers** (`.HasJsonColumn()`, `.HasNotDeletedFilter()`, …), never provider SQL literals — see the provider table in `.agents/rules/database.md`. A literal fails model-build on the other provider. +- Some DDL has no EF API and is hand-written into the MSSQL migration: the audit `CREATE JSON INDEX` and the chat full-text catalog. Preserve those blocks if you ever regenerate those migrations. ## Checklist - [ ] `dotnet tool restore` done (first time) - [ ] Built **before** `migrations add` - [ ] `--context {X}DbContext` + `--output-dir {X}` (lands in the right folder) +- [ ] Migration added for **both** providers (PostgreSQL *and* MSSQL) +- [ ] `verify-migrations` skill green for every maintained provider (advisory drift on the others reviewed) - [ ] Reviewed the generated SQL for data loss - [ ] Applied via DbMigrator `apply` (or `ef database update` for one context locally) diff --git a/.agents/skills/testing-guide/SKILL.md b/.agents/skills/testing-guide/SKILL.md index 1108634094..839659234f 100644 --- a/.agents/skills/testing-guide/SKILL.md +++ b/.agents/skills/testing-guide/SKILL.md @@ -93,7 +93,7 @@ Don't weaken these to make a change pass — fix the code. ## Integration tests -`Integration.Tests` runs over real Postgres/Redis/MinIO via Testcontainers — **Docker required**. Set the +`Integration.Tests` runs over real Postgres/Redis/MinIO via Testcontainers — **Docker required**. `FSH_TEST_DB_PROVIDER=MSSQL` runs the same suite against SQL Server 2025 instead of Postgres. Set the Finbuckle tenant context inline, rewire `IStorageService` post-registration for MinIO, force long-polling for SignalR. All detailed in `.agents/rules/integration-testing.md`. diff --git a/.agents/skills/verify-migrations/SKILL.md b/.agents/skills/verify-migrations/SKILL.md new file mode 100644 index 0000000000..b11704978a --- /dev/null +++ b/.agents/skills/verify-migrations/SKILL.md @@ -0,0 +1,76 @@ +--- +name: verify-migrations +description: Check that every DbContext's migrations are up to date with the model, for each database provider this project maintains. Run before committing an entity or EF-config change. Catches the "generated the PostgreSQL migration, forgot the SQL Server one" mistake. See .agents/rules/database.md. +--- + +# Verify Migrations + +With one migrations project per provider (`FSH.Starter.Migrations.PostgreSQL`, +`FSH.Starter.Migrations.MSSQL`), forgetting the second one **fails nothing**: the build passes, the +tests pass, the API starts. The forgotten provider simply deploys against a stale schema. + +`MigrationDriftTests` (in `Architecture.Tests`) compares each context's live model against that +provider's own snapshot — the in-process equivalent of `has-pending-model-changes`. No database, no +Docker, under a second. + +## Step 1 — build + +The guard reads the compiled model, so a stale build gives a stale answer. + +```bash +dotnet build src/FSH.Starter.slnx +``` + +## Step 2 — run the guard + +```bash +dotnet test src/Tests/Architecture.Tests --no-build \ + --filter "FullyQualifiedName~MigrationDriftTests" \ + --logger "console;verbosity=detailed" +``` + +`verbosity=detailed` matters: advisory findings for an unmaintained provider are printed rather than +asserted, and the default logger hides output from passing tests. + +## Step 3 — read the result + +- **Green, no output** — nothing to do. +- **Failure** — a provider this project maintains is behind. The message names the context, the + provider, and the exact `dotnet ef migrations add` command. Run it (fill in `` and + ``), rebuild, re-run. +- **`[migration-drift]` line but green** — a provider this project does *not* maintain is behind. + Not fatal; fix it only if you still intend to deploy that engine. + +To check both providers regardless of what the project maintains: + +```bash +FSH_MIGRATIONS_PROVIDERS=both dotnet test src/Tests/Architecture.Tests --no-build \ + --filter "FullyQualifiedName~MigrationDriftTests" +``` + +## Which providers are enforced + +`FshMaintainedDbProviders` in `src/Directory.Build.props` (`both` | `POSTGRESQL` | `MSSQL`), with the +`FSH_MIGRATIONS_PROVIDERS` env var as a per-run override. Ships as `both` because the modules bundled +with the kit are kept in sync for both engines. **Once a project settles on one engine, narrow it** — +then the other becomes advisory and never blocks a build over migrations for an engine nobody uses. + +## Notes + +- **Not every difference between the two projects is a mistake.** `AddMessageChronologicalSortKey` + exists only in MSSQL, because the Guid v7 sort key is added only when + `ChatDbContext` sees SQL Server. Likewise the trigram and JSON-containment indexes are removed from + the MSSQL model on purpose. The guard already accounts for this: it compares each provider against + its own snapshot, never one snapshot against the other. +- **Hand-written DDL must survive a regeneration.** The MSSQL migrations carry SQL that EF has no API + for: `CREATE JSON INDEX` on `audit.AuditRecords`, and the chat full-text catalog. If you ever + regenerate those migrations, re-apply those blocks. +- A missing snapshot for a context in one project (a new module that only got a folder in one + migrations project) is reported by `Every_Context_Should_Have_A_Snapshot_In_Both_Providers`. + +## Checklist + +- [ ] Built before running the guard +- [ ] Guard green for every maintained provider +- [ ] Advisory drift reviewed, and either fixed or consciously accepted +- [ ] Generated migrations reviewed for data loss before applying (see `create-migration`) diff --git a/.agents/workflows/migration-helper.md b/.agents/workflows/migration-helper.md index ad71f1fb54..cac28623b0 100644 --- a/.agents/workflows/migration-helper.md +++ b/.agents/workflows/migration-helper.md @@ -1,15 +1,18 @@ --- -description: Safely manage EF Core migrations for FSH's central per-module Migrations project. Use when adding entities or changing schema. The create-migration skill holds the canonical add/apply recipe. +description: Safely manage EF Core migrations for FSH's per-provider, per-module Migrations projects (PostgreSQL and MSSQL). Use when adding entities or changing schema. The create-migration skill holds the canonical add/apply recipe; verify-migrations checks nothing was left behind. --- You help manage EF Core migrations safely. The canonical add/review/apply recipe is the **`create-migration`** skill — follow it. This playbook covers the surrounding facts and troubleshooting. ## Facts (read before running commands) -- All migrations live in **one** project, `src/Host/FSH.Starter.Migrations.PostgreSQL`, foldered **per module/context** (`Catalog/`, `Identity/`, …), each with its own `{X}DbContextModelSnapshot`. +- Migrations live in **one project per provider** — `src/Host/FSH.Starter.Migrations.PostgreSQL` and `src/Host/FSH.Starter.Migrations.MSSQL` — each foldered **per module/context** (`Catalog/`, `Identity/`, …) with its own `{X}DbContextModelSnapshot`. **A model change usually needs a migration in both.** +- **Run the `verify-migrations` skill after any entity/EF-config change.** `MigrationDriftTests` compares each context's live model against that provider's own snapshot and names the exact command for whatever is missing — it is what catches "generated the PostgreSQL migration, forgot the SQL Server one". +- Maintaining both providers is **not mandatory**: `FshMaintainedDbProviders` in `src/Directory.Build.props` decides which ones fail the build. A project that has settled on one engine narrows it, and the other becomes advisory. +- Not every difference between the two projects is a defect. `AddMessageChronologicalSortKey` is MSSQL-only by design, and the MSSQL migrations carry hand-written DDL (`CREATE JSON INDEX`, full-text catalog) that must survive any regeneration. - Startup project is `src/Host/FSH.Starter.Api`. Always pass `--context {X}DbContext` and `--output-dir {X}`. - `dotnet-ef` is pinned — `dotnet tool restore` first. -- **The DB is NOT migrated on API startup.** The `DbMigrator` host applies it: it migrates the tenant catalog (`TenantDbContext`) first, then each tenant's per-module schema, serialized by a Postgres advisory lock. (`UseHeroMultiTenantDatabases()` only registers Finbuckle's tenant resolution — it does not run migrations.) +- **The DB is NOT migrated on API startup.** The `DbMigrator` host applies it: it migrates the tenant catalog (`TenantDbContext`) first, then each tenant's per-module schema, serialized by a database-held migrator lock (Postgres advisory lock, or `sp_getapplock` on SQL Server). (`UseHeroMultiTenantDatabases()` only registers Finbuckle's tenant resolution — it does not run migrations.) - **Build before `migrations add`** — it reads the snapshot, which regenerates from a build; a stale snapshot silently loses changes. `migrations remove` rewrites the snapshot, so only ever remove the latest and rebuild after. ## Context names (real) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index dc5e395f7c..dd0772805f 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -172,6 +172,50 @@ jobs: path: '**/*.trx' retention-days: 7 + integration-test-mssql: + name: Integration Tests (SQL Server) + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + # Same suite, other provider. Testcontainers starts SQL Server 2025 from TestDatabase; no + # service container here because the harness owns the lifetime. Coverage is collected by the + # PostgreSQL job only — this leg exists to catch provider-specific breakage, not to add lines. + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: Restore + run: dotnet restore src/FSH.Starter.slnx + + - name: Run integration tests against SQL Server + env: + FSH_TEST_DB_PROVIDER: MSSQL + run: | + dotnet test src/Tests/Integration.Tests -c Release \ + --results-directory ./TestResults --logger "trx;LogFileName=Integration.Tests.MSSQL.trx" + dotnet test src/Tests/Integration.Middleware.Tests -c Release \ + --results-directory ./TestResults --logger "trx;LogFileName=Integration.Middleware.Tests.MSSQL.trx" + + - name: Upload SQL Server integration test results + uses: actions/upload-artifact@v7 + if: always() + with: + name: test-results-integration-mssql + path: '**/*.trx' + retention-days: 7 + migrator-smoke: name: DbMigrator Container Smoke needs: changes @@ -228,6 +272,63 @@ jobs: | tee migrator.log grep -q "finished successfully" migrator.log + migrator-smoke-mssql: + name: DbMigrator Container Smoke (SQL Server) + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + # Same smoke as the Postgres job, against the other provider: proves the MSSQL migrations + # assembly applies from empty and that sp_getapplock coordination works. Pinned to 2025 — + # JSON columns map to the native json type, which does not exist on 2019/2022. + services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2025-latest + env: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: Migrator_Smoke_Pwd1 + MSSQL_PID: Developer + ports: + - 1433:1433 + options: >- + --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P Migrator_Smoke_Pwd1 -C -Q 'SELECT 1'" + --health-interval 10s + --health-timeout 5s + --health-retries 20 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: Publish DbMigrator container (local daemon) + run: | + dotnet publish src/Host/FSH.Starter.DbMigrator/FSH.Starter.DbMigrator.csproj \ + -c Release -r linux-x64 \ + /t:PublishContainer \ + -p:ContainerRepository=fsh-db-migrator \ + -p:ContainerImageTags=smoke-mssql + + - name: Run DbMigrator against ephemeral SQL Server + run: | + docker run --rm --network host \ + -e DatabaseOptions__Provider=MSSQL \ + -e DatabaseOptions__ConnectionString="Server=localhost,1433;Database=fsh_migrator_smoke;User Id=sa;Password=Migrator_Smoke_Pwd1;TrustServerCertificate=True;Encrypt=False" \ + -e DatabaseOptions__MigrationsAssembly=FSH.Starter.Migrations.MSSQL \ + fsh-db-migrator:smoke-mssql apply --catalog-only \ + | tee migrator-mssql.log + grep -q "finished successfully" migrator-mssql.log + coverage: name: Coverage Gate needs: [changes, test, integration-test] @@ -416,7 +517,7 @@ jobs: # job it depends on actually failed or was cancelled — skipped is fine. backend-ci: name: Backend CI - needs: [changes, test, integration-test, migrator-smoke, coverage] + needs: [changes, test, integration-test, integration-test-mssql, migrator-smoke, migrator-smoke-mssql, coverage] if: always() runs-on: ubuntu-latest steps: diff --git a/AGENTS.md b/AGENTS.md index cbe60e9e1f..be7437e42d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,8 @@ front-ends and a CLI. Multitenancy, auth, auditing, billing, files, chat and mor | `src/Host/FSH.Starter.Api` | Composition-root Web API host. | | `src/Host/FSH.Starter.AppHost` | .NET Aspire orchestrator (Postgres, Redis, MinIO, migrator, API, **both React apps**). | | `src/Host/FSH.Starter.DbMigrator` | One-shot migrate/seed runner. DB is **not** migrated at API startup. | -| `src/Host/FSH.Starter.Migrations.PostgreSQL` | All EF migrations, organized per-module by folder. | +| `src/Host/FSH.Starter.Migrations.PostgreSQL` | PostgreSQL EF migrations, organized per-module by folder. | +| `src/Host/FSH.Starter.Migrations.MSSQL` | SQL Server EF migrations, same per-module layout. **Requires SQL Server 2025 / Azure SQL.** | | `src/Tests/` | Per-module tests, `Architecture.Tests` (NetArchTest), `Integration.Tests` (Testcontainers). | | `src/Tools/CLI` | The `fsh` CLI (Spectre.Console). | | `clients/admin`, `clients/dashboard` | The two React apps. | @@ -40,7 +41,7 @@ front-ends and a CLI. Multitenancy, auth, auditing, billing, files, chat and mor | Framework | .NET 10 / C# latest | Framework | React 19 + Vite 7 + TS 5.x | | CQRS | Mediator 3.x (source-gen) | Data | TanStack Query v5 | | Validation | FluentValidation 12.x | Routing | React Router 7 | -| ORM / DB | EF Core 10 / PostgreSQL (Npgsql) | UI | Radix + Tailwind v4 + CVA (shadcn) | +| ORM / DB | EF Core 10 / PostgreSQL (Npgsql) or SQL Server 2025 | UI | Radix + Tailwind v4 + CVA (shadcn) | | Auth | JWT Bearer + ASP.NET Identity | Forms | react-hook-form + zod (**admin only**) | | Multitenancy | Finbuckle 10.x | Realtime | `@microsoft/signalr`, SSE (dashboard) | | Cache / Jobs | Redis, Hangfire | Tests | Playwright (route-mocked) | @@ -127,7 +128,7 @@ records for DTOs/events/value objects · `default!` for required non-nullable st ## Adding things (quick pointers) - **Feature** — Contracts command/query → handler → validator → endpoint → wire in module `MapEndpoints()` → tests. Details: `api-conventions.md`. -- **Module** — new `Modules.{Name}` + `.Contracts`, implement `IModule` w/ assembly-level `[assembly: FshModule(typeof(XModule), order)]`, register in **all four places**, add migration folder + tests. Details: `architecture.md`. +- **Module** — new `Modules.{Name}` + `.Contracts`, implement `IModule` w/ assembly-level `[assembly: FshModule(typeof(XModule), order)]`, register in **all four places**, add a migration folder in **both** migrations projects (PostgreSQL + MSSQL) + tests. Details: `architecture.md`. - **React page** — API module (`src/api/`) → page → register lazy route → (admin) mirror permission + RouteGuard → Playwright test. Details: `frontend/shared.md`. ## AI tooling resources diff --git a/README-template.md b/README-template.md index d97f32625d..52c35fb839 100644 --- a/README-template.md +++ b/README-template.md @@ -21,7 +21,7 @@ the shared code lives in `src/BuildingBlocks` and is yours to change. dotnet run --project src/Host/FSH.Starter.AppHost ``` -Aspire starts Postgres, Redis, and MinIO, runs database migrations, then launches the API +Aspire starts the database (Postgres by default; `DbProvider=MSSQL` runs SQL Server 2025 instead), Redis, and MinIO, runs database migrations, then launches the API **and both React apps**. | Surface | URL | diff --git a/README.md b/README.md index c2aa14df50..1206627e1d 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ The wizard asks what to include (Aspire AppHost, the React apps). Non-interactiv ```bash fsh new MyApp --non-interactive # full stack, Postgres +fsh new MyApp --db-provider mssql # same, defaulting to SQL Server 2025 fsh new MyApp --no-frontend # backend-only fsh new MyApp --no-aspire --no-frontend # minimal API + migrator ``` @@ -127,7 +128,7 @@ dotnet run --project src/Host/FSH.Starter.AppHost | `src/BuildingBlocks/` | Shared framework libraries (Core, Persistence, Web, Caching, Eventing, Storage, Quota…) | | `src/Modules/{Name}/` | Bounded contexts — each with a runtime project + a `.Contracts` project (its public API) | | `src/Host/FSH.Starter.Api` | Composition-root Web API host | -| `src/Host/FSH.Starter.AppHost` | .NET Aspire orchestrator (Postgres, Valkey, MinIO, migrator, API, both React apps) | +| `src/Host/FSH.Starter.AppHost` | .NET Aspire orchestrator (Postgres or SQL Server, Valkey, MinIO, migrator, API, both React apps) | | `src/Host/FSH.Starter.DbMigrator` | One-shot migrate/seed runner (DB is **not** migrated at API startup) | | `src/Tools/CLI` | The `fsh` CLI (Spectre.Console) | | `clients/admin`, `clients/dashboard` | The two React apps | @@ -162,7 +163,7 @@ cd clients/admin && npm run test:e2e # Playwright (operator app) cd clients/dashboard && npm run test:e2e # Playwright (tenant app) ``` -> Integration tests require Docker (Testcontainers spins real Postgres). Architecture tests enforce module boundaries. +> Integration tests require Docker (Testcontainers spins real Postgres; `FSH_TEST_DB_PROVIDER=MSSQL` runs the suite against SQL Server 2025 instead). Architecture tests enforce module boundaries. --- diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index 3cc3c4a432..1bf7a5953c 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -43,6 +43,10 @@ HANGFIRE_PASSWORD= # ── Data plane (defaults are fine for self-hosted compose) ────────── POSTGRES_PASSWORD= + +# SQL Server SA password — only used by the "mssql" compose profile. +# Requires SQL Server 2025 or Azure SQL (the model uses the native json type). +MSSQL_SA_PASSWORD= REDIS_PASSWORD= MINIO_ROOT_USER= MINIO_ROOT_PASSWORD= diff --git a/deploy/docker/README.md b/deploy/docker/README.md index bcb1304593..194cabb7e6 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -9,6 +9,7 @@ This brings up the full stack on a single host: | `dashboard` | `fsh/dashboard:local` | `FSH_DASHBOARD_PORT` (default 8082) | Tenant dashboard (nginx + React) | | `migrator` | `fsh/dbmigrator:local` | — | One-shot: applies EF migrations + seeds the root tenant + creates the default admin user | | `postgres` | `postgres:17-alpine` | (internal) | Identity, tenant catalog, module schemas | +| `sqlserver` | `mcr.microsoft.com/mssql/server:2025-latest` | (internal) | Same, when running the `mssql` profile instead of `postgres` | | `redis` | `redis:7-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store | | `minio` | `minio/minio:latest` | (internal) | S3-compatible blob store for the Files module | @@ -92,6 +93,28 @@ docker run --rm \ ## Swapping in managed services +### Running on SQL Server instead + +SQL Server ships as an opt-in compose profile. It requires **SQL Server 2025 or Azure SQL** — the +model maps JSON columns to the native `json` type, which does not exist on 2019/2022. + +```bash +# set MSSQL_SA_PASSWORD in .env first +docker compose --profile mssql up -d sqlserver +``` + +Then point the `migrator` and `api` services at it by overriding three variables (the provider and +its migrations assembly must always change together): + +```yaml +DatabaseOptions__Provider: MSSQL +DatabaseOptions__MigrationsAssembly: FSH.Starter.Migrations.MSSQL +DatabaseOptions__ConnectionString: "Server=sqlserver,1433;Database=fsh;User Id=sa;Password=${MSSQL_SA_PASSWORD};TrustServerCertificate=True" +``` + +Note `postgres-init/` (the `pgcrypto` / `uuid-ossp` / `pg_trgm` extensions) is PostgreSQL-only and is +simply not used on this path. + Single-host compose is the default story; production deployments often point at managed Postgres / Redis / S3. To do that: 1. Comment out the `postgres` / `redis` / `minio` service blocks AND remove them from the `depends_on:` of `api` and `migrator`. diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index d43c744f5b..e72ae34d3b 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -30,6 +30,32 @@ services: # ports: # - "5432:5432" + # Opt-in SQL Server, started only with `--profile mssql`. Pair it with + # DatabaseOptions__Provider=MSSQL + # DatabaseOptions__MigrationsAssembly=FSH.Starter.Migrations.MSSQL + # DatabaseOptions__ConnectionString=Server=sqlserver,1433;Database=fsh;User Id=sa;Password=${MSSQL_SA_PASSWORD};TrustServerCertificate=True + # on the migrator and api services. The 2025 tag is required: JSON columns map to the native + # json type, which does not exist on 2019/2022. + sqlserver: + image: mcr.microsoft.com/mssql/server:2025-latest + container_name: fsh-sqlserver + profiles: ["mssql"] + restart: unless-stopped + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:?MSSQL_SA_PASSWORD is required} + MSSQL_PID: Developer + volumes: + - mssql_data:/var/opt/mssql + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -C -Q 'SELECT 1' || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + # Uncomment to expose for host sqlcmd / SSMS access: + # ports: + # - "1433:1433" + redis: image: valkey/valkey:9.1.0-alpine container_name: fsh-redis @@ -176,6 +202,7 @@ services: - "${FSH_DASHBOARD_PORT:-8082}:80" volumes: + mssql_data: pg_data: redis_data: minio_data: diff --git a/src/BuildingBlocks/Eventing/Outbox/EfCoreOutboxStore.cs b/src/BuildingBlocks/Eventing/Outbox/EfCoreOutboxStore.cs index 0a7429e680..dcca99fac6 100644 --- a/src/BuildingBlocks/Eventing/Outbox/EfCoreOutboxStore.cs +++ b/src/BuildingBlocks/Eventing/Outbox/EfCoreOutboxStore.cs @@ -73,10 +73,37 @@ public async Task> ClaimBatchAsync( var now = _timeProvider.GetUtcNow().UtcDateTime; var until = now.Add(lease); + if (_dbContext.Database.IsSqlServer()) + { + // SQL Server's equivalent of SKIP LOCKED: READPAST walks past rows another transaction + // already holds, UPDLOCK takes the update lock up front so two dispatchers cannot both + // select the same row, and OUTPUT returns the rows this statement actually claimed. + // The CTE preserves the ORDER BY that a bare UPDATE TOP(n) would not guarantee. + var sqlServerClaim = $$""" + WITH c AS ( + SELECT TOP({3}) * + FROM [{{EventingConstants.SchemaName}}].[OutboxMessages] WITH (UPDLOCK, READPAST, ROWLOCK) + WHERE [IsDead] = 0 + AND [ProcessedOnUtc] IS NULL + AND ([NextRetryAt] IS NULL OR [NextRetryAt] <= {0}) + AND ([ClaimedUntilUtc] IS NULL OR [ClaimedUntilUtc] < {0}) + ORDER BY [CreatedOnUtc] + ) + UPDATE c + SET [ClaimedUntilUtc] = {1}, [ClaimedBy] = {2} + OUTPUT INSERTED.* + """; + + return await _dbContext.Set() + .FromSqlRaw(sqlServerClaim, now, until, claimedBy, batchSize) + .ToListAsync(ct) + .ConfigureAwait(false); + } + if (!_dbContext.Database.IsNpgsql()) { - // No portable SKIP LOCKED outside Postgres. Fall back to an unclaimed read, which is - // safe only while a single dispatcher instance runs. + // No portable SKIP LOCKED outside Postgres and SQL Server. Fall back to an unclaimed + // read, which is safe only while a single dispatcher instance runs. LogClaimUnsupported(_dbContext.Database.ProviderName); return await _dbContext.Set() .Where(m => !m.IsDead diff --git a/src/BuildingBlocks/Jobs/HangfireStaleLockCleanupService.cs b/src/BuildingBlocks/Jobs/HangfireStaleLockCleanupService.cs index 07881bc519..e37a7e4da7 100644 --- a/src/BuildingBlocks/Jobs/HangfireStaleLockCleanupService.cs +++ b/src/BuildingBlocks/Jobs/HangfireStaleLockCleanupService.cs @@ -1,4 +1,6 @@ +using System.Data.Common; using FSH.Framework.Shared.Persistence; +using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -24,25 +26,31 @@ public HangfireStaleLockCleanupService( _logger = logger; } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "Both statements are compile-time constants chosen by provider in CreateCleanup; no value reaches the command text.")] protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Short delay to let Hangfire initialize its schema first await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false); var dbOptions = _configuration.GetSection(nameof(DatabaseOptions)).Get(); - if (dbOptions is null || !dbOptions.Provider.Equals(DbProviders.PostgreSQL, StringComparison.OrdinalIgnoreCase)) + if (dbOptions is null) + { + return; + } + + (DbConnection Connection, string Sql)? cleanup = CreateCleanup(dbOptions); + if (cleanup is not { } target) { return; } try { - await using var connection = new NpgsqlConnection(dbOptions.ConnectionString); + await using DbConnection connection = target.Connection; await connection.OpenAsync(stoppingToken).ConfigureAwait(false); - await using var cmd = new NpgsqlCommand( - "DELETE FROM hangfire.lock WHERE acquired < NOW() - INTERVAL '5 minutes'", - connection); + await using DbCommand cmd = connection.CreateCommand(); + cmd.CommandText = target.Sql; int deleted = await cmd.ExecuteNonQueryAsync(stoppingToken).ConfigureAwait(false); if (deleted > 0) @@ -56,4 +64,28 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _logger.LogDebug(ex, "Could not cleanup stale Hangfire locks (table may not exist yet)"); } } + + /// + /// Builds the provider-specific connection and DELETE. Hangfire's two storage providers name + /// the lock table and its timestamp column differently — lowercase hangfire.lock.acquired + /// on PostgreSQL, [HangFire].[Lock].[CreatedAt] on SQL Server. + /// + private static (DbConnection Connection, string Sql)? CreateCleanup(DatabaseOptions dbOptions) + { + if (dbOptions.Provider.Equals(DbProviders.PostgreSQL, StringComparison.OrdinalIgnoreCase)) + { + return ( + new NpgsqlConnection(dbOptions.ConnectionString), + "DELETE FROM hangfire.lock WHERE acquired < NOW() - INTERVAL '5 minutes'"); + } + + if (dbOptions.Provider.Equals(DbProviders.MSSQL, StringComparison.OrdinalIgnoreCase)) + { + return ( + new SqlConnection(dbOptions.ConnectionString), + "DELETE FROM [HangFire].[Lock] WHERE [CreatedAt] < DATEADD(minute, -5, SYSUTCDATETIME())"); + } + + return null; + } } diff --git a/src/BuildingBlocks/Jobs/Jobs.csproj b/src/BuildingBlocks/Jobs/Jobs.csproj index 67fe79f1a7..6921c14652 100644 --- a/src/BuildingBlocks/Jobs/Jobs.csproj +++ b/src/BuildingBlocks/Jobs/Jobs.csproj @@ -10,6 +10,8 @@ + + diff --git a/src/BuildingBlocks/Persistence/AmbientDbTransactionRegistry.cs b/src/BuildingBlocks/Persistence/AmbientDbTransactionRegistry.cs index 826df5e561..8511bed9d5 100644 --- a/src/BuildingBlocks/Persistence/AmbientDbTransactionRegistry.cs +++ b/src/BuildingBlocks/Persistence/AmbientDbTransactionRegistry.cs @@ -11,6 +11,21 @@ namespace FSH.Framework.Persistence; /// attached to every Hero DbContext and records transactions as they start and end, which is what /// lets the outbox write enlist in the business transaction instead of committing separately. /// +/// +/// +/// Every method here must match exactly, sync *and* +/// async. The interface supplies default no-op implementations for all of its members, so a +/// near-miss signature still compiles — it just silently never gets called, leaving this registry +/// permanently empty and the outbox never enlisted. +/// +/// +/// That failure mode is invisible on PostgreSQL: Npgsql associates a command with whatever +/// transaction is open on its connection, so the outbox row joins the business transaction anyway. +/// SQL Server does not — SqlCommand.Transaction must be set explicitly or execution throws +/// "BeginExecuteReader requires the command to have a transaction…". Guarded by +/// AmbientDbTransactionRegistryTests. +/// +/// public sealed class AmbientDbTransactionRegistry : IDbTransactionInterceptor { private readonly Dictionary _open = []; @@ -22,21 +37,80 @@ public sealed class AmbientDbTransactionRegistry : IDbTransactionInterceptor public DbTransaction? Find(DbConnection connection) => connection is not null && _open.TryGetValue(connection, out var transaction) ? transaction : null; - public void TransactionStarted(DbConnection connection, TransactionEndEventData eventData) - => Track(connection, eventData?.Transaction); + public DbTransaction TransactionStarted( + DbConnection connection, + TransactionEndEventData eventData, + DbTransaction result) + { + Track(connection, result); + return result; + } - public void TransactionUsed(DbConnection connection, TransactionEventData eventData) - => Track(connection, eventData?.Transaction); + public ValueTask TransactionStartedAsync( + DbConnection connection, + TransactionEndEventData eventData, + DbTransaction result, + CancellationToken cancellationToken = default) + { + Track(connection, result); + return ValueTask.FromResult(result); + } + + public DbTransaction TransactionUsed( + DbConnection connection, + TransactionEventData eventData, + DbTransaction result) + { + Track(connection, result); + return result; + } + + public ValueTask TransactionUsedAsync( + DbConnection connection, + TransactionEventData eventData, + DbTransaction result, + CancellationToken cancellationToken = default) + { + Track(connection, result); + return ValueTask.FromResult(result); + } public void TransactionCommitted(DbTransaction transaction, TransactionEndEventData eventData) => Forget(transaction); + public Task TransactionCommittedAsync( + DbTransaction transaction, + TransactionEndEventData eventData, + CancellationToken cancellationToken = default) + { + Forget(transaction); + return Task.CompletedTask; + } + public void TransactionRolledBack(DbTransaction transaction, TransactionEndEventData eventData) => Forget(transaction); + public Task TransactionRolledBackAsync( + DbTransaction transaction, + TransactionEndEventData eventData, + CancellationToken cancellationToken = default) + { + Forget(transaction); + return Task.CompletedTask; + } + public void TransactionFailed(DbTransaction transaction, TransactionErrorEventData eventData) => Forget(transaction); + public Task TransactionFailedAsync( + DbTransaction transaction, + TransactionErrorEventData eventData, + CancellationToken cancellationToken = default) + { + Forget(transaction); + return Task.CompletedTask; + } + private void Track(DbConnection connection, DbTransaction? transaction) { if (connection is not null && transaction is not null) @@ -50,6 +124,17 @@ private void Forget(DbTransaction? transaction) if (transaction?.Connection is not null) { _open.Remove(transaction.Connection); + return; + } + + // A disposed transaction reports a null Connection, so fall back to identity: leaving a + // completed transaction in the map would make the next write try to enlist in it. + if (transaction is not null) + { + foreach (var entry in _open.Where(e => ReferenceEquals(e.Value, transaction)).ToList()) + { + _open.Remove(entry.Key); + } } } } diff --git a/src/BuildingBlocks/Persistence/ConnectionStringValidator.cs b/src/BuildingBlocks/Persistence/ConnectionStringValidator.cs index 38daa20a2e..f7ab02ad1a 100644 --- a/src/BuildingBlocks/Persistence/ConnectionStringValidator.cs +++ b/src/BuildingBlocks/Persistence/ConnectionStringValidator.cs @@ -34,7 +34,12 @@ public bool TryValidate(string connectionString, string? dbProvider = null) _ = new SqlConnectionStringBuilder(connectionString); break; default: - break; + // An unknown provider means nothing validated the string. Reporting success here + // would let a typo'd provider sail past tenant creation and fail on first query. + _logger.LogError( + "Connection String Validation failed: '{Provider}' is not a supported database provider.", + dbProvider); + return false; } return true; diff --git a/src/BuildingBlocks/Persistence/Context/BaseDbContext.cs b/src/BuildingBlocks/Persistence/Context/BaseDbContext.cs index a6a0b412fc..d595a3e4a6 100644 --- a/src/BuildingBlocks/Persistence/Context/BaseDbContext.cs +++ b/src/BuildingBlocks/Persistence/Context/BaseDbContext.cs @@ -1,6 +1,7 @@ using Finbuckle.MultiTenant.Abstractions; using Finbuckle.MultiTenant.EntityFrameworkCore; using FSH.Framework.Core.Domain; +using FSH.Framework.Persistence.Providers; using FSH.Framework.Shared.Multitenancy; using FSH.Framework.Shared.Persistence; using Microsoft.EntityFrameworkCore; @@ -41,6 +42,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyTenantIsolationByDefault(); } + /// + /// Registers the framework's provider conventions, which resolve portable column and index + /// intent into provider-specific SQL when the model is finalized. + /// + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + base.ConfigureConventions(configurationBuilder); + configurationBuilder.AddHeroProviderConventions(DbProviderResolver.FromEfProviderName(Database.ProviderName)); + } + /// /// Configures the database connection using tenant-specific connection string if available. /// diff --git a/src/BuildingBlocks/Persistence/OptionsBuilderExtensions.cs b/src/BuildingBlocks/Persistence/OptionsBuilderExtensions.cs index ce83ac6c54..88bcc7970b 100644 --- a/src/BuildingBlocks/Persistence/OptionsBuilderExtensions.cs +++ b/src/BuildingBlocks/Persistence/OptionsBuilderExtensions.cs @@ -10,6 +10,22 @@ namespace FSH.Framework.Persistence; /// public static class OptionsBuilderExtensions { + /// + /// SQL Server compatibility level the MSSQL provider targets: 170 (SQL Server 2025 / Azure SQL). + /// + /// + /// + /// Required for the native json column type the framework's portable JSON columns map to + /// — UseSqlServer otherwise defaults to level 150 (SQL Server 2019), where EF Core emits + /// nvarchar(max) instead and the generated migrations would no longer match the model. + /// + /// + /// This is the reason MSSQL support requires SQL Server 2025 (17.x) or Azure SQL. Earlier + /// versions have no json type and the migrations will not apply to them. + /// + /// + private const int MssqlCompatibilityLevel = 170; + /// /// Configures the database provider and connection for the Hero framework. /// @@ -43,10 +59,13 @@ public static DbContextOptionsBuilder ConfigureHeroDatabase( break; case DbProviders.MSSQL: + // Deliberately no EnableRetryOnFailure: the retrying execution strategy refuses + // user-initiated transactions, and the outbox joins the business transaction via + // Database.UseTransactionAsync. Enabling it here breaks every transactional publish. builder.UseSqlServer(connectionString, e => { e.MigrationsAssembly(migrationsAssembly); - e.EnableRetryOnFailure(); + e.UseCompatibilityLevel(MssqlCompatibilityLevel); }); break; @@ -90,10 +109,13 @@ public static DbContextOptionsBuilder ConfigureHeroDatabase( break; case DbProviders.MSSQL: + // Deliberately no EnableRetryOnFailure: the retrying execution strategy refuses + // user-initiated transactions, and the outbox joins the business transaction via + // Database.UseTransactionAsync. Enabling it here breaks every transactional publish. builder.UseSqlServer(connection, contextOwnsConnection: false, e => { e.MigrationsAssembly(migrationsAssembly); - e.EnableRetryOnFailure(); + e.UseCompatibilityLevel(MssqlCompatibilityLevel); }); break; diff --git a/src/BuildingBlocks/Persistence/Providers/DbProviderResolver.cs b/src/BuildingBlocks/Persistence/Providers/DbProviderResolver.cs new file mode 100644 index 0000000000..17d2e1c863 --- /dev/null +++ b/src/BuildingBlocks/Persistence/Providers/DbProviderResolver.cs @@ -0,0 +1,39 @@ +using FSH.Framework.Shared.Persistence; + +namespace FSH.Framework.Persistence.Providers; + +/// +/// Maps EF Core's provider assembly name onto the framework's constants. +/// +/// +/// Resolving from Database.ProviderName rather than DatabaseOptions.Provider means the +/// conventions pass works identically at design time (dotnet ef), under hand-constructed test +/// contexts, and at runtime — none of which reliably have the options bound. +/// +public static class DbProviderResolver +{ + private const string NpgsqlProvider = "Npgsql.EntityFrameworkCore.PostgreSQL"; + private const string SqlServerProvider = "Microsoft.EntityFrameworkCore.SqlServer"; + + /// + /// Resolves an EF Core provider assembly name to a constant, or null + /// when the provider is not one the framework has provider-specific conventions for (SQLite and + /// the in-memory provider used by tests both land here, and are treated as a no-op). + /// + public static string? FromEfProviderName(string? efProviderName) => efProviderName switch + { + NpgsqlProvider => DbProviders.PostgreSQL, + SqlServerProvider => DbProviders.MSSQL, + _ => null + }; + + /// + /// Normalizes a configured provider string, returning null when it is not recognized. + /// + public static string? Normalize(string? provider) => provider?.ToUpperInvariant() switch + { + DbProviders.PostgreSQL => DbProviders.PostgreSQL, + DbProviders.MSSQL => DbProviders.MSSQL, + _ => null + }; +} diff --git a/src/BuildingBlocks/Persistence/Providers/HeroProviderConventions.cs b/src/BuildingBlocks/Persistence/Providers/HeroProviderConventions.cs new file mode 100644 index 0000000000..f8d85aca51 --- /dev/null +++ b/src/BuildingBlocks/Persistence/Providers/HeroProviderConventions.cs @@ -0,0 +1,228 @@ +using FSH.Framework.Shared.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; + +namespace FSH.Framework.Persistence.Providers; + +/// +/// Resolves the provider-portable intent declared by and +/// into concrete, provider-specific model configuration. +/// +/// +/// +/// This is the single place in the framework that knows how the two supported providers differ at +/// the model level. Entity configurations declare what they want; this decides how to spell it. +/// +/// +/// Implemented as a model-finalizing convention rather than a call at the end of +/// OnModelCreating on purpose: it must observe every entity configuration no matter where a +/// subclass chooses to call base.OnModelCreating. A context that calls base first would +/// otherwise silently leave its intent unresolved, and the leftover annotations then fail the +/// migrations scaffolder. +/// +/// +public sealed class HeroProviderConventions : IModelFinalizingConvention +{ + // Npgsql's public builder API (HasMethod/HasOperators) needs an IndexBuilder, which is long gone + // by the time conventions run. These are the annotation names those methods set. + private const string NpgsqlIndexMethod = "Npgsql:IndexMethod"; + private const string NpgsqlIndexOperators = "Npgsql:IndexOperators"; + + /// + /// Length EF Core's SQL Server provider gives a string key with no explicit MaxLength — the + /// widest nvarchar that still fits the 900-byte index key limit. + /// + private const int SqlServerDefaultKeyLength = 450; + + private readonly string? _provider; + + /// + /// A constant, or null for a provider with no framework-specific + /// conventions (SQLite, in-memory). A null provider still strips the intent annotations. + /// + public HeroProviderConventions(string? provider) => _provider = DbProviderResolver.Normalize(provider); + + public void ProcessModelFinalizing( + IConventionModelBuilder modelBuilder, + IConventionContext context) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + + foreach (IConventionEntityType entityType in modelBuilder.Metadata.GetEntityTypes()) + { + ApplyColumnConventions(entityType); + ApplyIndexConventions(entityType); + + if (_provider == DbProviders.MSSQL) + { + ApplyUtcDateTimeConventions(entityType); + ApplyForeignKeyLengthConventions(entityType); + } + } + } + + private void ApplyColumnConventions(IConventionEntityType entityType) + { + foreach (IConventionProperty property in entityType.GetProperties()) + { + if (property.FindAnnotation(PortableColumnExtensions.ColumnKindAnnotation)?.Value is PortableColumnKind columnKind) + { + property.RemoveAnnotation(PortableColumnExtensions.ColumnKindAnnotation); + + string? columnType = ResolveColumnType(columnKind, _provider); + if (columnType is not null) + { + property.SetColumnType(columnType); + } + } + + if (property.FindAnnotation(PortableColumnExtensions.DefaultKindAnnotation)?.Value is PortableDefaultKind defaultKind) + { + property.RemoveAnnotation(PortableColumnExtensions.DefaultKindAnnotation); + + string? defaultSql = ResolveDefaultSql(defaultKind, _provider); + if (defaultSql is not null) + { + property.SetDefaultValueSql(defaultSql); + } + } + } + } + + private static string? ResolveColumnType(PortableColumnKind kind, string? provider) => (kind, provider) switch + { + (PortableColumnKind.Json, DbProviders.PostgreSQL) => "jsonb", + + // The native SQL Server json type, not nvarchar(max). Requires SQL Server 2025 (17.x) or + // Azure SQL, and compatibility level 170 — see OptionsBuilderExtensions. + (PortableColumnKind.Json, DbProviders.MSSQL) => "json", + + (PortableColumnKind.Text, DbProviders.PostgreSQL) => "text", + (PortableColumnKind.Text, DbProviders.MSSQL) => "nvarchar(max)", + + // Unknown provider: leave EF's default mapping in place. + _ => null + }; + + private static string? ResolveDefaultSql(PortableDefaultKind kind, string? provider) => (kind, provider) switch + { + (PortableDefaultKind.EmptyJson, DbProviders.PostgreSQL) => "'{}'::jsonb", + (PortableDefaultKind.EmptyJson, DbProviders.MSSQL) => "N'{}'", + + (PortableDefaultKind.UtcNow, DbProviders.PostgreSQL) => "CURRENT_TIMESTAMP", + (PortableDefaultKind.UtcNow, DbProviders.MSSQL) => "SYSUTCDATETIME()", + + _ => null + }; + + private void ApplyIndexConventions(IConventionEntityType entityType) + { + // Snapshot first: the search-index branch removes indexes from the entity. + var indexes = entityType.GetIndexes().ToList(); + + foreach (IConventionIndex index in indexes) + { + if (index.FindAnnotation(PortableIndexExtensions.IndexFilterAnnotation)?.Value is string encodedFilter) + { + index.RemoveAnnotation(PortableIndexExtensions.IndexFilterAnnotation); + + IReadOnlyList terms = PortableFilterTerm.Decode(encodedFilter); + if (_provider is not null && terms.Count > 0) + { + index.SetFilter(ProviderFilterRenderer.Render(terms, _provider)); + } + } + + if (index.FindAnnotation(PortableIndexExtensions.IndexKindAnnotation)?.Value is PortableIndexKind indexKind) + { + index.RemoveAnnotation(PortableIndexExtensions.IndexKindAnnotation); + ApplySearchIndexKind(entityType, index, indexKind); + } + } + } + + private void ApplySearchIndexKind(IConventionEntityType entityType, IConventionIndex index, PortableIndexKind kind) + { + if (_provider == DbProviders.PostgreSQL) + { + index.SetAnnotation(NpgsqlIndexMethod, "gin"); + index.SetAnnotation( + NpgsqlIndexOperators, + new[] { kind == PortableIndexKind.JsonContainment ? "jsonb_path_ops" : "gin_trgm_ops" }); + return; + } + + if (_provider == DbProviders.MSSQL) + { + // Neither shape survives as a regular index on SQL Server: + // - JsonContainment targets a `json` column, which cannot carry a regular index at all. + // The migration issues CREATE JSON INDEX instead, deliberately outside the EF model. + // - TrigramSearch targets unbounded nvarchar(max) columns, which exceed the 1700-byte + // nonclustered key limit. A B-tree index would not serve a leading-wildcard LIKE + // anyway — that is exactly what makes PostgreSQL's trigram GIN index special — so + // dropping it costs nothing a plain index would have provided. + entityType.RemoveIndex(index); + } + } + + /// + /// Widens string foreign-key columns to match the length of the principal key they reference. + /// + /// + /// + /// SQL Server refuses to create a foreign key whose columns differ in length from the principal's + /// ("Columns participating in a foreign key relationship must be defined with the same length and + /// scale", error 1753). PostgreSQL has no such rule — text and varchar(n) reference + /// each other happily — so models written against it can carry mismatches that only surface here. + /// + /// + /// Applied on SQL Server only, so the PostgreSQL schema is unaffected. It widens the dependent + /// rather than narrowing the principal, which is the only direction that cannot lose data. + /// + /// + private static void ApplyForeignKeyLengthConventions(IConventionEntityType entityType) + { + foreach (IConventionForeignKey foreignKey in entityType.GetForeignKeys()) + { + IReadOnlyList dependents = foreignKey.Properties; + IReadOnlyList principals = foreignKey.PrincipalKey.Properties; + + for (int i = 0; i < dependents.Count && i < principals.Count; i++) + { + IConventionProperty dependent = dependents[i]; + IConventionProperty principal = principals[i]; + + if (dependent.ClrType != typeof(string) || principal.ClrType != typeof(string)) + { + continue; + } + + // A string key with no explicit length becomes nvarchar(450) on SQL Server — the + // widest value that still fits the 900-byte index key limit. + int principalLength = principal.GetMaxLength() ?? SqlServerDefaultKeyLength; + + if (dependent.GetMaxLength() != principalLength) + { + dependent.SetMaxLength(principalLength); + } + } + } + } + + private static void ApplyUtcDateTimeConventions(IConventionEntityType entityType) + { + foreach (IConventionProperty property in entityType.GetProperties()) + { + Type propertyType = Nullable.GetUnderlyingType(property.ClrType) ?? property.ClrType; + + // Never stomp an explicitly configured converter. + if (propertyType == typeof(DateTime) && property.GetValueConverter() is null) + { + property.SetValueConverter(UtcDateTimeConverter.Instance); + } + } + } +} diff --git a/src/BuildingBlocks/Persistence/Providers/ModelConfigurationBuilderExtensions.cs b/src/BuildingBlocks/Persistence/Providers/ModelConfigurationBuilderExtensions.cs new file mode 100644 index 0000000000..8a16b20b1d --- /dev/null +++ b/src/BuildingBlocks/Persistence/Providers/ModelConfigurationBuilderExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; + +namespace FSH.Framework.Persistence.Providers; + +/// +/// Registers the framework's provider conventions on a . +/// +public static class ModelConfigurationBuilderExtensions +{ + /// + /// Adds so portable column and index intent is resolved + /// against when the model is finalized. + /// + /// + /// Contexts deriving from BaseDbContext get this automatically. Contexts that do not — + /// IdentityDbContext and TenantDbContext, which have their own EF base classes — + /// must call it from their own ConfigureConventions override. + /// + public static ModelConfigurationBuilder AddHeroProviderConventions( + this ModelConfigurationBuilder configurationBuilder, + string? provider) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + configurationBuilder.Conventions.Add(_ => new HeroProviderConventions(provider)); + return configurationBuilder; + } +} diff --git a/src/BuildingBlocks/Persistence/Providers/PortableColumnExtensions.cs b/src/BuildingBlocks/Persistence/Providers/PortableColumnExtensions.cs new file mode 100644 index 0000000000..8a5cac147e --- /dev/null +++ b/src/BuildingBlocks/Persistence/Providers/PortableColumnExtensions.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Framework.Persistence.Providers; + +/// +/// The provider-specific shape a column takes, declared as intent by entity configurations and +/// resolved to a concrete column type by . +/// +public enum PortableColumnKind +{ + /// A JSON document: jsonb on PostgreSQL, the native json type on SQL Server. + Json, + + /// Unbounded text: text on PostgreSQL, nvarchar(max) on SQL Server. + Text +} + +/// +/// The provider-specific default a column takes. +/// +public enum PortableDefaultKind +{ + /// An empty JSON object. + EmptyJson, + + /// The current UTC timestamp, evaluated by the database. + UtcNow +} + +/// +/// Declares provider-portable column intent on a property. These write annotations only — the +/// concrete column type is applied by +/// once the target provider is known. +/// +/// +/// Prefer these over HasColumnType with a literal type name. A literal locks the model to one +/// provider and fails at model-build time on the other. +/// +public static class PortableColumnExtensions +{ + internal const string ColumnKindAnnotation = "Fsh:ColumnKind"; + internal const string DefaultKindAnnotation = "Fsh:DefaultKind"; + + /// + /// Maps the property to the provider's JSON document type. + /// + public static PropertyBuilder HasJsonColumn(this PropertyBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.HasAnnotation(ColumnKindAnnotation, PortableColumnKind.Json); + } + + /// + /// Maps the property to the provider's unbounded text type. + /// + public static PropertyBuilder HasUnboundedTextColumn(this PropertyBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.HasAnnotation(ColumnKindAnnotation, PortableColumnKind.Text); + } + + /// + /// Defaults the column to an empty JSON object. + /// + public static PropertyBuilder HasJsonDefaultEmptyObject(this PropertyBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.HasAnnotation(DefaultKindAnnotation, PortableDefaultKind.EmptyJson); + } + + /// + /// Defaults the column to the database's current UTC timestamp. + /// + public static PropertyBuilder HasUtcNowDefault(this PropertyBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.HasAnnotation(DefaultKindAnnotation, PortableDefaultKind.UtcNow); + } +} diff --git a/src/BuildingBlocks/Persistence/Providers/PortableFilter.cs b/src/BuildingBlocks/Persistence/Providers/PortableFilter.cs new file mode 100644 index 0000000000..3755c44b74 --- /dev/null +++ b/src/BuildingBlocks/Persistence/Providers/PortableFilter.cs @@ -0,0 +1,107 @@ +using System.Globalization; + +namespace FSH.Framework.Persistence.Providers; + +/// +/// Comparison used by a term of a portable partial-index filter. +/// +public enum PortableFilterOperator +{ + /// Column equals the term's value. + Equal, + + /// Column does not equal the term's value. + NotEqual, + + /// Column IS NULL. + IsNull, + + /// Column IS NOT NULL. + IsNotNull +} + +/// +/// How a term's literal should be rendered. The kind is carried separately from the value because +/// booleans have no portable spelling — PostgreSQL wants TRUE/FALSE, SQL Server wants +/// 1/0 against a bit column. +/// +public enum PortableFilterLiteralKind +{ + /// No literal — the operator is IS NULL / IS NOT NULL. + None, + + /// An integer literal, rendered identically on both providers. + Number, + + /// A boolean literal, rendered per provider. + Boolean, + + /// + /// The soft-delete boolean. Rendered as uppercase FALSE on PostgreSQL rather than going + /// through . See ProviderFilterRenderer for why the two spellings + /// must both be preserved. + /// + SoftDeleteBoolean +} + +/// +/// One conjunct of a partial-index filter, expressed without provider-specific SQL. +/// Terms are ANDed together in the order they were declared. +/// +/// The column name, unquoted. +/// The comparison to apply. +/// How should be rendered. +/// The literal value, or null for IS NULL / IS NOT NULL. +public sealed record PortableFilterTerm( + string Column, + PortableFilterOperator Operator, + PortableFilterLiteralKind LiteralKind, + string? Value) +{ + // Safe because every field is an identifier, an enum ordinal or an integer literal — none of + // which can contain either separator. + private const char FieldSeparator = '|'; + private const char TermSeparator = ';'; + + /// + /// Encodes terms for storage in an EF annotation. + /// + /// + /// A string rather than the record itself: EF must be able to emit any surviving annotation as a + /// C# literal when scaffolding a migration snapshot, and it can only do that for primitives. An + /// annotation carrying a POCO turns a stray leftover into a hard + /// "Cannot scaffold C# literals of type ..." failure instead of something harmless. + /// + public static string Encode(IEnumerable terms) + { + ArgumentNullException.ThrowIfNull(terms); + + return string.Join(TermSeparator, terms.Select(t => string.Join( + FieldSeparator, + t.Column, + ((int)t.Operator).ToString(CultureInfo.InvariantCulture), + ((int)t.LiteralKind).ToString(CultureInfo.InvariantCulture), + t.Value ?? string.Empty))); + } + + /// + /// Decodes terms previously produced by . + /// + public static IReadOnlyList Decode(string encoded) + { + ArgumentNullException.ThrowIfNull(encoded); + + return encoded + .Split(TermSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(raw => + { + string[] parts = raw.Split(FieldSeparator); + return new PortableFilterTerm( + parts[0], + (PortableFilterOperator)int.Parse(parts[1], CultureInfo.InvariantCulture), + (PortableFilterLiteralKind)int.Parse(parts[2], CultureInfo.InvariantCulture), + parts[3].Length == 0 ? null : parts[3]); + }) + .ToList(); + } +} diff --git a/src/BuildingBlocks/Persistence/Providers/PortableIndexExtensions.cs b/src/BuildingBlocks/Persistence/Providers/PortableIndexExtensions.cs new file mode 100644 index 0000000000..a0cf6f6567 --- /dev/null +++ b/src/BuildingBlocks/Persistence/Providers/PortableIndexExtensions.cs @@ -0,0 +1,115 @@ +using System.Globalization; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Framework.Persistence.Providers; + +/// +/// A search-oriented index shape that has no portable spelling. +/// +public enum PortableIndexKind +{ + /// + /// Substring search over a bounded text column. A trigram GIN index on PostgreSQL; a plain + /// nonclustered index on SQL Server, which has no trigram equivalent. + /// + TrigramSearch, + + /// + /// Containment search over a JSON column. A jsonb_path_ops GIN index on PostgreSQL. On + /// SQL Server the index is removed from the EF model — a regular index over a json + /// column is illegal — and the equivalent CREATE JSON INDEX is emitted as raw SQL by the + /// migration instead. + /// + JsonContainment +} + +/// +/// Declares provider-portable index intent. These write annotations only; the concrete filter SQL, +/// index method and operator class are applied by +/// . +/// +/// +/// Prefer these over HasFilter with a literal predicate. A literal bakes in one provider's +/// identifier quoting and boolean spelling, neither of which is portable. +/// +public static class PortableIndexExtensions +{ + internal const string IndexFilterAnnotation = "Fsh:IndexFilter"; + internal const string IndexKindAnnotation = "Fsh:IndexKind"; + + /// + /// Restricts the index to rows that are not soft-deleted. + /// + public static IndexBuilder HasNotDeletedFilter(this IndexBuilder builder) + => builder.AppendTerm(new PortableFilterTerm( + "IsDeleted", PortableFilterOperator.Equal, PortableFilterLiteralKind.SoftDeleteBoolean, "false")); + + /// + /// Restricts the index to rows where equals . + /// + public static IndexBuilder HasBoolFilter(this IndexBuilder builder, string column, bool value) + => builder.AppendTerm(new PortableFilterTerm( + column, PortableFilterOperator.Equal, PortableFilterLiteralKind.Boolean, + value ? "true" : "false")); + + /// + /// Restricts the index to rows where is not null. + /// + public static IndexBuilder HasNotNullFilter(this IndexBuilder builder, string column) + => builder.AppendTerm(new PortableFilterTerm( + column, PortableFilterOperator.IsNotNull, PortableFilterLiteralKind.None, null)); + + /// + /// Restricts the index to rows where is null. + /// + public static IndexBuilder HasNullFilter(this IndexBuilder builder, string column) + => builder.AppendTerm(new PortableFilterTerm( + column, PortableFilterOperator.IsNull, PortableFilterLiteralKind.None, null)); + + /// + /// Restricts the index to rows where equals . + /// Use for enum discriminators stored as integers. + /// + public static IndexBuilder HasEqualsFilter(this IndexBuilder builder, string column, int value) + => builder.AppendTerm(new PortableFilterTerm( + column, PortableFilterOperator.Equal, PortableFilterLiteralKind.Number, + value.ToString(CultureInfo.InvariantCulture))); + + /// + /// Restricts the index to rows where does not equal . + /// + public static IndexBuilder HasNotEqualsFilter(this IndexBuilder builder, string column, int value) + => builder.AppendTerm(new PortableFilterTerm( + column, PortableFilterOperator.NotEqual, PortableFilterLiteralKind.Number, + value.ToString(CultureInfo.InvariantCulture))); + + /// + /// Marks the index as serving substring search over a text column. + /// + public static IndexBuilder AsTrigramSearchIndex(this IndexBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.HasAnnotation(IndexKindAnnotation, PortableIndexKind.TrigramSearch); + } + + /// + /// Marks the index as serving containment search over a JSON column. + /// + public static IndexBuilder AsJsonContainmentIndex(this IndexBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.HasAnnotation(IndexKindAnnotation, PortableIndexKind.JsonContainment); + } + + private static IndexBuilder AppendTerm(this IndexBuilder builder, PortableFilterTerm term) + { + ArgumentNullException.ThrowIfNull(builder); + + var terms = builder.Metadata.FindAnnotation(IndexFilterAnnotation)?.Value is string existing + ? new List(PortableFilterTerm.Decode(existing)) + : new List(1); + terms.Add(term); + + return builder.HasAnnotation(IndexFilterAnnotation, PortableFilterTerm.Encode(terms)); + } +} diff --git a/src/BuildingBlocks/Persistence/Providers/ProviderFilterRenderer.cs b/src/BuildingBlocks/Persistence/Providers/ProviderFilterRenderer.cs new file mode 100644 index 0000000000..b4af26793e --- /dev/null +++ b/src/BuildingBlocks/Persistence/Providers/ProviderFilterRenderer.cs @@ -0,0 +1,85 @@ +using System.Globalization; +using System.Text; +using FSH.Framework.Shared.Persistence; + +namespace FSH.Framework.Persistence.Providers; + +/// +/// Renders conjunctions to provider-specific partial-index SQL. +/// +internal static class ProviderFilterRenderer +{ + public static string Render(IReadOnlyList terms, string provider) + { + var sql = new StringBuilder(); + + for (int i = 0; i < terms.Count; i++) + { + if (i > 0) + { + sql.Append(" AND "); + } + + PortableFilterTerm term = terms[i]; + sql.Append(QuoteIdentifier(term.Column, provider)); + + switch (term.Operator) + { + case PortableFilterOperator.IsNull: + sql.Append(" IS NULL"); + break; + case PortableFilterOperator.IsNotNull: + sql.Append(" IS NOT NULL"); + break; + case PortableFilterOperator.Equal: + sql.Append(" = ").Append(RenderLiteral(term, provider)); + break; + case PortableFilterOperator.NotEqual: + sql.Append(" <> ").Append(RenderLiteral(term, provider)); + break; + default: + throw new InvalidOperationException($"Unsupported filter operator {term.Operator}."); + } + } + + return sql.ToString(); + } + + private static string QuoteIdentifier(string column, string provider) => + provider == DbProviders.MSSQL ? $"[{column}]" : $"\"{column}\""; + + private static string RenderLiteral(PortableFilterTerm term, string provider) + { + switch (term.LiteralKind) + { + case PortableFilterLiteralKind.Number: + return int.Parse(term.Value!, CultureInfo.InvariantCulture) + .ToString(CultureInfo.InvariantCulture); + + case PortableFilterLiteralKind.Boolean: + case PortableFilterLiteralKind.SoftDeleteBoolean: + bool value = bool.Parse(term.Value!); + + if (provider == DbProviders.MSSQL) + { + // SQL Server has no boolean literal; the column is a `bit`. + return value ? "1" : "0"; + } + + // PostgreSQL accepts either casing, but EF diffs the filter as an opaque string: + // changing the spelling drops and recreates the index on every existing database. + // The two spellings below are the ones already in the shipped migrations, so they + // are preserved deliberately. Do not "normalize" them. + if (term.LiteralKind == PortableFilterLiteralKind.SoftDeleteBoolean) + { + return value ? "TRUE" : "FALSE"; + } + + return value ? "true" : "false"; + + default: + throw new InvalidOperationException( + $"Filter operator {term.Operator} requires a literal but none was supplied."); + } + } +} diff --git a/src/BuildingBlocks/Persistence/Providers/ProviderQueryExtensions.cs b/src/BuildingBlocks/Persistence/Providers/ProviderQueryExtensions.cs new file mode 100644 index 0000000000..90490ac8e5 --- /dev/null +++ b/src/BuildingBlocks/Persistence/Providers/ProviderQueryExtensions.cs @@ -0,0 +1,147 @@ +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace FSH.Framework.Persistence.Providers; + +/// +/// Query helpers that paper over translation differences between the supported providers. +/// +public static class ProviderQueryExtensions +{ + private static readonly MethodInfo LikeMethod = typeof(DbFunctionsExtensions).GetMethod( + nameof(DbFunctionsExtensions.Like), + BindingFlags.Public | BindingFlags.Static, + [typeof(DbFunctions), typeof(string), typeof(string)])!; + + private static readonly MethodInfo ILikeMethod = typeof(NpgsqlDbFunctionsExtensions).GetMethod( + nameof(NpgsqlDbFunctionsExtensions.ILike), + BindingFlags.Public | BindingFlags.Static, + [typeof(DbFunctions), typeof(string), typeof(string)])!; + + /// + /// Filters to rows where any of contains , + /// case-insensitively, using whichever construct the current provider translates. + /// + /// + /// + /// PostgreSQL gets ILIKE — the same SQL the handlers emitted before this helper existed, + /// so the trigram GIN indexes still apply. Every other provider gets LIKE, which is + /// case-insensitive on SQL Server under its default collation. + /// + /// + /// The term is not escaped, preserving the pre-existing behaviour where % and _ in + /// a search box act as wildcards. Callers that need literal matching must escape up front. + /// + /// + /// The query to filter. + /// The context's database facade, used to detect the provider. + /// The substring to search for. + /// The columns to search. Null values never match. + public static IQueryable WhereSearch( + this IQueryable source, + DatabaseFacade database, + string term, + params Expression>[] selectors) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(database); + ArgumentNullException.ThrowIfNull(selectors); + + if (selectors.Length == 0) + { + return source; + } + + return source.WhereLikeCore(database, $"%{term}%", selectors); + } + + /// + /// Filters to rows where matches a caller-supplied LIKE pattern, + /// case-insensitively, using whichever construct the current provider translates. + /// + /// + /// Unlike the pattern is used verbatim, so the caller controls the + /// wildcards. Intended for structural matches such as + /// , not for user-typed search boxes. + /// + public static IQueryable WhereLike( + this IQueryable source, + DatabaseFacade database, + string pattern, + Expression> selector) + { + ArgumentNullException.ThrowIfNull(selector); + return source.WhereLikeCore(database, pattern, [selector]); + } + + /// + /// Builds a LIKE pattern matching a "name": "value" pair inside a JSON document that has + /// been cast to text. + /// + /// + /// The two providers render JSON to text differently and the difference is load-bearing here: + /// PostgreSQL's jsonb::text emits canonical form with a space after the colon + /// ({"area": "Value"}), while SQL Server's native json type casts to compact form + /// without one ({"area":"Value"}). A pattern hard-coded for either provider + /// silently matches nothing on the other. + /// + /// The context's database facade, used to detect the provider. + /// The JSON property name. + /// The value to match. + /// + /// When true the value must terminate (closing quote included); when false the pattern matches + /// any value starting with . + /// + public static string JsonTextPropertyPattern( + DatabaseFacade database, + string propertyName, + string value, + bool exact = true) + { + ArgumentNullException.ThrowIfNull(database); + + string separator = database.IsNpgsql() ? ": " : ":"; + string suffix = exact ? "\"%" : "%"; + return $"%\"{propertyName}\"{separator}\"{value}{suffix}"; + } + + private static IQueryable WhereLikeCore( + this IQueryable source, + DatabaseFacade database, + string pattern, + Expression>[] selectors) + { + MethodInfo likeMethod = database.IsNpgsql() ? ILikeMethod : LikeMethod; + ConstantExpression functions = Expression.Constant(EF.Functions); + ConstantExpression patternExpression = Expression.Constant(pattern, typeof(string)); + + ParameterExpression entity = Expression.Parameter(typeof(TEntity), "e"); + Expression? predicate = null; + + foreach (Expression> selector in selectors) + { + Expression column = ParameterRebinder.Rebind(selector.Body, selector.Parameters[0], entity); + + // Mirrors the null guards the handlers wrote by hand. Redundant in SQL (LIKE on NULL is + // NULL, which filters the row out anyway) but kept so the emitted SQL is unchanged. + Expression clause = Expression.AndAlso( + Expression.NotEqual(column, Expression.Constant(null, typeof(string))), + Expression.Call(likeMethod, functions, column, patternExpression)); + + predicate = predicate is null ? clause : Expression.OrElse(predicate, clause); + } + + return source.Where(Expression.Lambda>(predicate!, entity)); + } + + private sealed class ParameterRebinder(ParameterExpression from, ParameterExpression to) : ExpressionVisitor + { + public static Expression Rebind(Expression body, ParameterExpression from, ParameterExpression to) + => new ParameterRebinder(from, to).Visit(body); + + protected override Expression VisitParameter(ParameterExpression node) + => node == from ? to : base.VisitParameter(node); + } +} diff --git a/src/BuildingBlocks/Persistence/Providers/UtcDateTimeConverter.cs b/src/BuildingBlocks/Persistence/Providers/UtcDateTimeConverter.cs new file mode 100644 index 0000000000..82346b60aa --- /dev/null +++ b/src/BuildingBlocks/Persistence/Providers/UtcDateTimeConverter.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace FSH.Framework.Persistence.Providers; + +/// +/// Forces on values read back from the database. +/// +/// +/// PostgreSQL's timestamp with time zone round-trips as , but +/// SQL Server's datetime2 carries no kind and comes back as +/// . Without this converter every timestamp the API serializes +/// on SQL Server would lose its trailing Z, and both React clients would render it as local +/// time. Applied to SQL Server only, so the PostgreSQL model is unchanged. +/// +internal sealed class UtcDateTimeConverter : ValueConverter +{ + /// Shared instance — the converter is stateless. + public static readonly UtcDateTimeConverter Instance = new(); + + private UtcDateTimeConverter() + : base( + v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(), + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)) + { + } +} diff --git a/src/BuildingBlocks/Shared/Persistence/DatabaseOptions.cs b/src/BuildingBlocks/Shared/Persistence/DatabaseOptions.cs index 797ca50b78..ef5cb170c3 100644 --- a/src/BuildingBlocks/Shared/Persistence/DatabaseOptions.cs +++ b/src/BuildingBlocks/Shared/Persistence/DatabaseOptions.cs @@ -29,5 +29,15 @@ public IEnumerable Validate(ValidationContext validationContex { yield return new ValidationResult("connection string cannot be empty.", new[] { nameof(ConnectionString) }); } + + // Fail at startup on an unrecognized provider rather than deep inside a provider switch on + // the first query — a typo in DatabaseOptions__Provider is otherwise invisible until traffic. + if (!string.Equals(Provider, DbProviders.PostgreSQL, StringComparison.OrdinalIgnoreCase) + && !string.Equals(Provider, DbProviders.MSSQL, StringComparison.OrdinalIgnoreCase)) + { + yield return new ValidationResult( + $"'{Provider}' is not a supported database provider. Use '{DbProviders.PostgreSQL}' or '{DbProviders.MSSQL}'.", + new[] { nameof(Provider) }); + } } } \ No newline at end of file diff --git a/src/BuildingBlocks/Web/Observability/OpenTelemetry/Extensions.cs b/src/BuildingBlocks/Web/Observability/OpenTelemetry/Extensions.cs index 6b9b95bb80..85baf8a103 100644 --- a/src/BuildingBlocks/Web/Observability/OpenTelemetry/Extensions.cs +++ b/src/BuildingBlocks/Web/Observability/OpenTelemetry/Extensions.cs @@ -86,6 +86,10 @@ private static void ConfigureMetricsAndTracing( .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddNpgsqlInstrumentation() + // Both driver instrumentations are registered unconditionally: each only emits + // for its own driver, so whichever provider DatabaseOptions selects is covered + // without the observability wiring needing to know which one it is. + .AddSqlClientInstrumentation() .AddRuntimeInstrumentation(); // Apply histogram buckets for HTTP server duration @@ -147,6 +151,7 @@ private static void ConfigureMetricsAndTracing( }) .AddHttpClientInstrumentation() .AddNpgsql() + .AddSqlClientInstrumentation() .AddEntityFrameworkCoreInstrumentation() .AddRedisInstrumentation(redis => { diff --git a/src/BuildingBlocks/Web/Web.csproj b/src/BuildingBlocks/Web/Web.csproj index c84453709a..f8e0313d14 100644 --- a/src/BuildingBlocks/Web/Web.csproj +++ b/src/BuildingBlocks/Web/Web.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index be9ac87f2f..996202358a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -23,6 +23,19 @@ $(NoWarn);CS1591;MSG0005;CA1054;CA1056 + + both + 10.0.0;latest diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0d38b28190..489debc976 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -15,6 +15,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -30,6 +31,7 @@ + @@ -54,6 +56,10 @@ + + @@ -61,6 +67,11 @@ + + @@ -122,9 +133,13 @@ - - - + + + + + diff --git a/src/FSH.Starter.slnx b/src/FSH.Starter.slnx index 998d538836..5ef130cad3 100644 --- a/src/FSH.Starter.slnx +++ b/src/FSH.Starter.slnx @@ -64,6 +64,7 @@ + diff --git a/src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj b/src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj index 15e7f31438..1ea19b27d9 100644 --- a/src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj +++ b/src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj @@ -33,6 +33,7 @@ + diff --git a/src/Host/FSH.Starter.Api/appsettings.Production.json b/src/Host/FSH.Starter.Api/appsettings.Production.json index 332724534b..0d341cd33e 100644 --- a/src/Host/FSH.Starter.Api/appsettings.Production.json +++ b/src/Host/FSH.Starter.Api/appsettings.Production.json @@ -34,7 +34,8 @@ }, "DatabaseOptions": { "Provider": "POSTGRESQL", - "ConnectionString": "" + "ConnectionString": "", + "MigrationsAssembly": "FSH.Starter.Migrations.PostgreSQL" }, "OriginOptions": { "OriginUrl": "" diff --git a/src/Host/FSH.Starter.AppHost/AppHost.cs b/src/Host/FSH.Starter.AppHost/AppHost.cs index e7a70abd05..c323c1cca2 100644 --- a/src/Host/FSH.Starter.AppHost/AppHost.cs +++ b/src/Host/FSH.Starter.AppHost/AppHost.cs @@ -10,19 +10,59 @@ .ToLowerInvariant(); #pragma warning restore CA1308 -// Postgres + pgAdmin sidecar (auto-discovers registered databases); persistent so volumes and saved state survive restarts. -var postgresServer = builder.AddPostgres("postgres") - .WithDataVolume($"{appPrefix}-postgres-data") - .WithLifetime(ContainerLifetime.Persistent) - .WithPgAdmin(pa => pa - .WithHostPort(5050) - .WithLifetime(ContainerLifetime.Persistent)); - -var postgres = postgresServer.AddDatabase("fsh-db"); - -// Warm pooled-connection floor for the long-running API — Npgsql's default Minimum Pool Size of 0 lets the pool drain to cold, so /health/ready's ~10 concurrent DbContext checks cold-open a cohort at once and intermittently stall the probe; a floor keeps connections warm for reuse. -var apiPgConnection = ReferenceExpression.Create( - $"{postgres.Resource.ConnectionStringExpression};Minimum Pool Size=5"); +// Database provider is config-selected, so the whole stack switches with no code edit: +// DbProvider=MSSQL dotnet run --project src/Host/FSH.Starter.AppHost +// MSSQL requires SQL Server 2025 (17.x) or Azure SQL — the native json type the model maps to does +// not exist on 2019/2022. See .agents/rules/database.md. +// Provider names mirror FSH.Framework.Shared.Persistence.DbProviders. Duplicated as literals +// because Aspire project references do not flow assemblies into the AppHost. +const string PostgresProvider = "POSTGRESQL"; +const string MssqlProvider = "MSSQL"; + +var dbProvider = (builder.Configuration["DbProvider"] ?? PostgresProvider).ToUpperInvariant(); +var useMssql = dbProvider == MssqlProvider; + +IResourceBuilder database; +ReferenceExpression apiDbConnection; +string migrationsAssembly; + +if (useMssql) +{ + var saPassword = builder.AddParameter("mssql-password", "Str0ng_Dev_Pwd!", secret: true); + + // 2025 image tag is required: the native json type does not exist before SQL Server 2025. + var sqlServer = builder.AddSqlServer("sqlserver", password: saPassword) + .WithImageTag("2025-latest") + .WithDataVolume($"{appPrefix}-mssql-data") + .WithLifetime(ContainerLifetime.Persistent); + + var mssqlDb = sqlServer.AddDatabase("fsh-db"); + database = mssqlDb; + migrationsAssembly = "FSH.Starter.Migrations.MSSQL"; + + // TrustServerCertificate: the container serves a self-signed cert. "Min Pool Size" is the + // SqlClient spelling — "Minimum Pool Size" is Npgsql-only and would throw here. + apiDbConnection = ReferenceExpression.Create( + $"{mssqlDb.Resource.ConnectionStringExpression};Min Pool Size=5;TrustServerCertificate=True"); +} +else +{ + // Postgres + pgAdmin sidecar (auto-discovers registered databases); persistent so volumes and saved state survive restarts. + var postgresServer = builder.AddPostgres("postgres") + .WithDataVolume($"{appPrefix}-postgres-data") + .WithLifetime(ContainerLifetime.Persistent) + .WithPgAdmin(pa => pa + .WithHostPort(5050) + .WithLifetime(ContainerLifetime.Persistent)); + + var postgresDb = postgresServer.AddDatabase("fsh-db"); + database = postgresDb; + migrationsAssembly = "FSH.Starter.Migrations.PostgreSQL"; + + // Warm pooled-connection floor for the long-running API — Npgsql's default Minimum Pool Size of 0 lets the pool drain to cold, so /health/ready's ~10 concurrent DbContext checks cold-open a cohort at once and intermittently stall the probe; a floor keeps connections warm for reuse. + apiDbConnection = ReferenceExpression.Create( + $"{postgresDb.Resource.ConnectionStringExpression};Minimum Pool Size=5"); +} // Valkey (BSD-3 Redis fork) as a plain container: Aspire 13.4.0 AddRedis() forces TLS-by-default in run mode and never materializes the container, so we drop to plain RESP over TCP. Name stays "redis" so config keys don't churn. var redis = builder.AddContainer("redis", "valkey/valkey", "9.1.0") @@ -83,38 +123,38 @@ // DB migrator: applies pending migrations + seeds the root admin (admin@root.com), then exits; the API waits for its completion so it never starts against an unmigrated DB. Seed password is a dev-only default. var migrator = builder.AddProject($"{appPrefix}-db-migrator") - .WithReference(postgres) - .WaitFor(postgres) - .WithEnvironment("DatabaseOptions__Provider", "POSTGRESQL") - .WithEnvironment("DatabaseOptions__ConnectionString", postgres.Resource.ConnectionStringExpression) - .WithEnvironment("DatabaseOptions__MigrationsAssembly", "FSH.Starter.Migrations.PostgreSQL") + .WithReference(database) + .WaitFor(database) + .WithEnvironment("DatabaseOptions__Provider", dbProvider) + .WithEnvironment("DatabaseOptions__ConnectionString", database.Resource.ConnectionStringExpression) + .WithEnvironment("DatabaseOptions__MigrationsAssembly", migrationsAssembly) .WithEnvironment("Seed__DefaultAdminPassword", "123Pa$$word!") .WithArgs("apply", "--seed"); // Demo seeder (dev-only): provisions the acme/globex tenants + demo-login users via seed-demo. DOTNET_ENVIRONMENT=Development is required (console host ignores ASPNETCORE_ENVIRONMENT) or seed-demo refuses to run. var demoSeeder = builder.AddProject($"{appPrefix}-demo-seeder") - .WithReference(postgres) - .WaitFor(postgres) + .WithReference(database) + .WaitFor(database) .WaitForCompletion(migrator) .WithEnvironment("DOTNET_ENVIRONMENT", "Development") - .WithEnvironment("DatabaseOptions__Provider", "POSTGRESQL") - .WithEnvironment("DatabaseOptions__ConnectionString", postgres.Resource.ConnectionStringExpression) - .WithEnvironment("DatabaseOptions__MigrationsAssembly", "FSH.Starter.Migrations.PostgreSQL") + .WithEnvironment("DatabaseOptions__Provider", dbProvider) + .WithEnvironment("DatabaseOptions__ConnectionString", database.Resource.ConnectionStringExpression) + .WithEnvironment("DatabaseOptions__MigrationsAssembly", migrationsAssembly) .WithEnvironment("Seed__DemoPassword", "Password123!") .WithArgs("seed-demo"); // API Service var api = builder.AddProject($"{appPrefix}-api") - .WithReference(postgres) - .WaitFor(postgres) + .WithReference(database) + .WaitFor(database) .WaitFor(redis) .WaitForCompletion(minioInit) .WaitForCompletion(migrator) .WaitForCompletion(demoSeeder) .WithExternalHttpEndpoints() - .WithEnvironment("DatabaseOptions__Provider", "POSTGRESQL") - .WithEnvironment("DatabaseOptions__ConnectionString", apiPgConnection) - .WithEnvironment("DatabaseOptions__MigrationsAssembly", "FSH.Starter.Migrations.PostgreSQL") + .WithEnvironment("DatabaseOptions__Provider", dbProvider) + .WithEnvironment("DatabaseOptions__ConnectionString", apiDbConnection) + .WithEnvironment("DatabaseOptions__MigrationsAssembly", migrationsAssembly) .WithEnvironment("CachingOptions__Redis", redisConnectionString) .WithEnvironment("CachingOptions__EnableSsl", "false") // Hangfire dashboard (/jobs) creds — [Required], Password [MinLength(12)], ValidateOnStart; API won't boot without them. Dev-only, mirrors appsettings.Development.json. diff --git a/src/Host/FSH.Starter.AppHost/FSH.Starter.AppHost.csproj b/src/Host/FSH.Starter.AppHost/FSH.Starter.AppHost.csproj index 1e4c5483d6..3cd0fe1b2e 100644 --- a/src/Host/FSH.Starter.AppHost/FSH.Starter.AppHost.csproj +++ b/src/Host/FSH.Starter.AppHost/FSH.Starter.AppHost.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Host/FSH.Starter.AppHost/appsettings.json b/src/Host/FSH.Starter.AppHost/appsettings.json index 31c092aa45..1a3e88b03c 100644 --- a/src/Host/FSH.Starter.AppHost/appsettings.json +++ b/src/Host/FSH.Starter.AppHost/appsettings.json @@ -5,5 +5,6 @@ "Microsoft.AspNetCore": "Warning", "Aspire.Hosting.Dcp": "Warning" } - } + }, + "DbProvider": "MSSQL" } diff --git a/src/Host/FSH.Starter.DbMigrator/FSH.Starter.DbMigrator.csproj b/src/Host/FSH.Starter.DbMigrator/FSH.Starter.DbMigrator.csproj index 496952c711..a230887e13 100644 --- a/src/Host/FSH.Starter.DbMigrator/FSH.Starter.DbMigrator.csproj +++ b/src/Host/FSH.Starter.DbMigrator/FSH.Starter.DbMigrator.csproj @@ -48,6 +48,7 @@ + + $(NoWarn);CA1062;CA1861 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Files/20260907205258_InitialFiles.Designer.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Files/20260907205258_InitialFiles.Designer.cs new file mode 100644 index 0000000000..f4ca7df654 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Files/20260907205258_InitialFiles.Designer.cs @@ -0,0 +1,126 @@ +// +using System; +using FSH.Modules.Files.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Files +{ + [DbContext(typeof(FilesDbContext))] + [Migration("20260907205258_InitialFiles")] + partial class InitialFiles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("files") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Files.Domain.FileAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeletedBy") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeletedOnUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("OwnerId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScanStatus") + .HasColumnType("int"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("UploadDeadline") + .HasColumnType("datetimeoffset"); + + b.Property("Visibility") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Status") + .HasDatabaseName("IX_FileAsset_Status"); + + b.HasIndex("IsDeleted", "DeletedOnUtc") + .HasDatabaseName("IX_FileAsset_Deletion"); + + b.HasIndex("OwnerType", "OwnerId") + .HasDatabaseName("IX_FileAsset_Owner"); + + b.HasIndex("StorageKey", "TenantId") + .IsUnique() + .HasDatabaseName("UX_FileAsset_StorageKey") + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("FileAssets", "files"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Files/20260907205258_InitialFiles.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Files/20260907205258_InitialFiles.cs new file mode 100644 index 0000000000..bed0344846 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Files/20260907205258_InitialFiles.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Files +{ + /// + public partial class InitialFiles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "files"); + + migrationBuilder.CreateTable( + name: "FileAssets", + schema: "files", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OwnerType = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + OwnerId = table.Column(type: "uniqueidentifier", nullable: true), + FileName = table.Column(type: "nvarchar(260)", maxLength: 260, nullable: false), + OriginalFileName = table.Column(type: "nvarchar(260)", maxLength: 260, nullable: false), + ContentType = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + SizeBytes = table.Column(type: "bigint", nullable: false), + StorageKey = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), + Visibility = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + ScanStatus = table.Column(type: "int", nullable: false), + UploadDeadline = table.Column(type: "datetimeoffset", nullable: true), + CreatedByUserId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + CreatedAtUtc = table.Column(type: "datetime2", nullable: false), + UpdatedAtUtc = table.Column(type: "datetime2", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: false), + DeletedOnUtc = table.Column(type: "datetimeoffset", nullable: true), + DeletedBy = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + TenantId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FileAssets", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_FileAsset_Deletion", + schema: "files", + table: "FileAssets", + columns: new[] { "IsDeleted", "DeletedOnUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_FileAsset_Owner", + schema: "files", + table: "FileAssets", + columns: new[] { "OwnerType", "OwnerId" }); + + migrationBuilder.CreateIndex( + name: "IX_FileAsset_Status", + schema: "files", + table: "FileAssets", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "UX_FileAsset_StorageKey", + schema: "files", + table: "FileAssets", + columns: new[] { "StorageKey", "TenantId" }, + unique: true, + filter: "[IsDeleted] = 0"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FileAssets", + schema: "files"); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Files/FilesDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Files/FilesDbContextModelSnapshot.cs new file mode 100644 index 0000000000..d9ff3198fa --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Files/FilesDbContextModelSnapshot.cs @@ -0,0 +1,123 @@ +// +using System; +using FSH.Modules.Files.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Files +{ + [DbContext(typeof(FilesDbContext))] + partial class FilesDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("files") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Files.Domain.FileAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeletedBy") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeletedOnUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("OwnerId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScanStatus") + .HasColumnType("int"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("UploadDeadline") + .HasColumnType("datetimeoffset"); + + b.Property("Visibility") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Status") + .HasDatabaseName("IX_FileAsset_Status"); + + b.HasIndex("IsDeleted", "DeletedOnUtc") + .HasDatabaseName("IX_FileAsset_Deletion"); + + b.HasIndex("OwnerType", "OwnerId") + .HasDatabaseName("IX_FileAsset_Owner"); + + b.HasIndex("StorageKey", "TenantId") + .IsUnique() + .HasDatabaseName("UX_FileAsset_StorageKey") + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("FileAssets", "files"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Identity/20260907205259_InitialIdentity.Designer.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Identity/20260907205259_InitialIdentity.Designer.cs new file mode 100644 index 0000000000..eabc47622a --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Identity/20260907205259_InitialIdentity.Designer.cs @@ -0,0 +1,778 @@ +// +using System; +using FSH.Modules.Identity.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Identity +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20260907205259_InitialIdentity")] + partial class InitialIdentity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName", "TenantId") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("RoleId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("ImageUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime2"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ObjectId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("RefreshToken") + .HasColumnType("nvarchar(max)"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("datetime2"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName", "TenantId") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedBy") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedOnUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasColumnName("CreatedAt") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("DeletedOnUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("IsDefault") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsSystemGroup") + .HasColumnType("bit"); + + b.Property("LastModifiedBy") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)") + .HasColumnName("ModifiedBy"); + + b.Property("LastModifiedOnUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("ModifiedAt"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("IsDefault"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("Name"); + + b.ToTable("Groups", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.GroupRole", b => + { + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("GroupId", "RoleId"); + + b.HasIndex("GroupId"); + + b.HasIndex("RoleId"); + + b.ToTable("GroupRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.ImpersonationGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActorTenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ActorUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ActorUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClientId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EndedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("ImpersonatedTenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ImpersonatedUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ImpersonatedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Jti") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RevokeReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetime2"); + + b.Property("RevokedByUserId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RevokedByUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("Id"); + + b.HasIndex("Jti") + .IsUnique(); + + b.HasIndex("ActorUserId", "StartedAtUtc") + .HasDatabaseName("IX_ImpersonationGrants_ActorUserId_StartedAtUtc"); + + b.HasIndex("ImpersonatedTenantId", "StartedAtUtc") + .HasDatabaseName("IX_ImpersonationGrants_ImpersonatedTenantId_StartedAtUtc"); + + b.ToTable("ImpersonationGrants", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.PasswordHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("PasswordHistory", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserGroup", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("AddedBy") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserId"); + + b.ToTable("UserGroups", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Browser") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("BrowserVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("IsRevoked") + .HasColumnType("bit"); + + b.Property("LastActivityAt") + .HasColumnType("datetime2"); + + b.Property("OperatingSystem") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OsVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedBy") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("RevokedReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RefreshTokenHash"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRoleClaim", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.GroupRole", b => + { + b.HasOne("FSH.Modules.Identity.Domain.Group", "Group") + .WithMany("GroupRoles") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.PasswordHistory", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany("PasswordHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserGroup", b => + { + b.HasOne("FSH.Modules.Identity.Domain.Group", "Group") + .WithMany("UserGroups") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserSession", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshUser", b => + { + b.Navigation("PasswordHistories"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.Group", b => + { + b.Navigation("GroupRoles"); + + b.Navigation("UserGroups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Identity/20260907205259_InitialIdentity.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Identity/20260907205259_InitialIdentity.cs new file mode 100644 index 0000000000..7fd1cfd93c --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Identity/20260907205259_InitialIdentity.cs @@ -0,0 +1,565 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Identity +{ + /// + public partial class InitialIdentity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "identity"); + + migrationBuilder.CreateTable( + name: "Groups", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Description = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + IsDefault = table.Column(type: "bit", nullable: false), + IsSystemGroup = table.Column(type: "bit", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + CreatedBy = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: true), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + ModifiedBy = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: true), + IsDeleted = table.Column(type: "bit", nullable: false), + DeletedOnUtc = table.Column(type: "datetimeoffset", nullable: true), + DeletedBy = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: true), + TenantId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Groups", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ImpersonationGrants", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Jti = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ActorUserId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ActorUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ActorTenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ImpersonatedUserId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ImpersonatedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ImpersonatedTenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Reason = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + StartedAtUtc = table.Column(type: "datetime2", nullable: false), + ExpiresAtUtc = table.Column(type: "datetime2", nullable: false), + EndedAtUtc = table.Column(type: "datetime2", nullable: true), + RevokedAtUtc = table.Column(type: "datetime2", nullable: true), + RevokedByUserId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + RevokedByUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + RevokeReason = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + ClientId = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + IpAddress = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + UserAgent = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ImpersonationGrants", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Roles", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true), + TenantId = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + FirstName = table.Column(type: "nvarchar(max)", nullable: true), + LastName = table.Column(type: "nvarchar(max)", nullable: true), + ImageUrl = table.Column(type: "nvarchar(max)", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + RefreshToken = table.Column(type: "nvarchar(max)", nullable: true), + RefreshTokenExpiryTime = table.Column(type: "datetime2", nullable: false), + ObjectId = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + LastPasswordChangeDate = table.Column(type: "datetime2", nullable: false), + TenantId = table.Column(type: "nvarchar(450)", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GroupRoles", + schema: "identity", + columns: table => new + { + GroupId = table.Column(type: "uniqueidentifier", nullable: false), + RoleId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + TenantId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupRoles", x => new { x.GroupId, x.RoleId }); + table.ForeignKey( + name: "FK_GroupRoles_Groups_GroupId", + column: x => x.GroupId, + principalSchema: "identity", + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GroupRoles_Roles_RoleId", + column: x => x.RoleId, + principalSchema: "identity", + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RoleClaims", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedOn = table.Column(type: "datetimeoffset", nullable: false), + TenantId = table.Column(type: "nvarchar(max)", nullable: false), + RoleId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_RoleClaims_Roles_RoleId", + column: x => x.RoleId, + principalSchema: "identity", + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PasswordHistory", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + TenantId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PasswordHistory", x => x.Id); + table.ForeignKey( + name: "FK_PasswordHistory_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserClaims", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true), + TenantId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserClaims", x => x.Id); + table.ForeignKey( + name: "FK_UserClaims_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserGroups", + schema: "identity", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + GroupId = table.Column(type: "uniqueidentifier", nullable: false), + AddedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + AddedBy = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: true), + TenantId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserGroups", x => new { x.UserId, x.GroupId }); + table.ForeignKey( + name: "FK_UserGroups_Groups_GroupId", + column: x => x.GroupId, + principalSchema: "identity", + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserGroups_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserLogins", + schema: "identity", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + TenantId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_UserLogins_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserRoles", + schema: "identity", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + RoleId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + TenantId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_UserRoles_Roles_RoleId", + column: x => x.RoleId, + principalSchema: "identity", + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserRoles_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserSessions", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + RefreshTokenHash = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + IpAddress = table.Column(type: "nvarchar(45)", maxLength: 45, nullable: false), + UserAgent = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: false), + DeviceType = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + Browser = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + BrowserVersion = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + OperatingSystem = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + OsVersion = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + LastActivityAt = table.Column(type: "datetime2", nullable: false), + ExpiresAt = table.Column(type: "datetime2", nullable: false), + IsRevoked = table.Column(type: "bit", nullable: false), + RevokedAt = table.Column(type: "datetime2", nullable: true), + RevokedBy = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: true), + RevokedReason = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + TenantId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSessions", x => x.Id); + table.ForeignKey( + name: "FK_UserSessions_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserTokens", + schema: "identity", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true), + TenantId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_UserTokens_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GroupRoles_GroupId", + schema: "identity", + table: "GroupRoles", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_GroupRoles_RoleId", + schema: "identity", + table: "GroupRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_IsDefault", + schema: "identity", + table: "Groups", + column: "IsDefault"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_IsDeleted", + schema: "identity", + table: "Groups", + column: "IsDeleted"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_Name", + schema: "identity", + table: "Groups", + column: "Name"); + + migrationBuilder.CreateIndex( + name: "IX_ImpersonationGrants_ActorUserId_StartedAtUtc", + schema: "identity", + table: "ImpersonationGrants", + columns: new[] { "ActorUserId", "StartedAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_ImpersonationGrants_ImpersonatedTenantId_StartedAtUtc", + schema: "identity", + table: "ImpersonationGrants", + columns: new[] { "ImpersonatedTenantId", "StartedAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_ImpersonationGrants_Jti", + schema: "identity", + table: "ImpersonationGrants", + column: "Jti", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PasswordHistory_UserId", + schema: "identity", + table: "PasswordHistory", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_PasswordHistory_UserId_CreatedAt", + schema: "identity", + table: "PasswordHistory", + columns: new[] { "UserId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_RoleClaims_RoleId", + schema: "identity", + table: "RoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + schema: "identity", + table: "Roles", + columns: new[] { "NormalizedName", "TenantId" }, + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_UserClaims_UserId", + schema: "identity", + table: "UserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserGroups_GroupId", + schema: "identity", + table: "UserGroups", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_UserGroups_UserId", + schema: "identity", + table: "UserGroups", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserLogins_UserId", + schema: "identity", + table: "UserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserRoles_RoleId", + schema: "identity", + table: "UserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + schema: "identity", + table: "Users", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + schema: "identity", + table: "Users", + columns: new[] { "NormalizedUserName", "TenantId" }, + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_ExpiresAt", + schema: "identity", + table: "UserSessions", + column: "ExpiresAt"); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_RefreshTokenHash", + schema: "identity", + table: "UserSessions", + column: "RefreshTokenHash"); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_UserId", + schema: "identity", + table: "UserSessions", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_UserId_IsRevoked", + schema: "identity", + table: "UserSessions", + columns: new[] { "UserId", "IsRevoked" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GroupRoles", + schema: "identity"); + + migrationBuilder.DropTable( + name: "ImpersonationGrants", + schema: "identity"); + + migrationBuilder.DropTable( + name: "PasswordHistory", + schema: "identity"); + + migrationBuilder.DropTable( + name: "RoleClaims", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserClaims", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserGroups", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserLogins", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserRoles", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserSessions", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserTokens", + schema: "identity"); + + migrationBuilder.DropTable( + name: "Groups", + schema: "identity"); + + migrationBuilder.DropTable( + name: "Roles", + schema: "identity"); + + migrationBuilder.DropTable( + name: "Users", + schema: "identity"); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Identity/IdentityDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Identity/IdentityDbContextModelSnapshot.cs new file mode 100644 index 0000000000..fafbde27da --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Identity/IdentityDbContextModelSnapshot.cs @@ -0,0 +1,775 @@ +// +using System; +using FSH.Modules.Identity.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Identity +{ + [DbContext(typeof(IdentityDbContext))] + partial class IdentityDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName", "TenantId") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("RoleId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("ImageUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime2"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ObjectId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("RefreshToken") + .HasColumnType("nvarchar(max)"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("datetime2"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName", "TenantId") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedBy") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedOnUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasColumnName("CreatedAt") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("DeletedOnUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("IsDefault") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsSystemGroup") + .HasColumnType("bit"); + + b.Property("LastModifiedBy") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)") + .HasColumnName("ModifiedBy"); + + b.Property("LastModifiedOnUtc") + .HasColumnType("datetimeoffset") + .HasColumnName("ModifiedAt"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("IsDefault"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("Name"); + + b.ToTable("Groups", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.GroupRole", b => + { + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("GroupId", "RoleId"); + + b.HasIndex("GroupId"); + + b.HasIndex("RoleId"); + + b.ToTable("GroupRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.ImpersonationGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActorTenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ActorUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ActorUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClientId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EndedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("ImpersonatedTenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ImpersonatedUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ImpersonatedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Jti") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RevokeReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetime2"); + + b.Property("RevokedByUserId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RevokedByUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("Id"); + + b.HasIndex("Jti") + .IsUnique(); + + b.HasIndex("ActorUserId", "StartedAtUtc") + .HasDatabaseName("IX_ImpersonationGrants_ActorUserId_StartedAtUtc"); + + b.HasIndex("ImpersonatedTenantId", "StartedAtUtc") + .HasDatabaseName("IX_ImpersonationGrants_ImpersonatedTenantId_StartedAtUtc"); + + b.ToTable("ImpersonationGrants", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.PasswordHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("PasswordHistory", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserGroup", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("AddedBy") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserId"); + + b.ToTable("UserGroups", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Browser") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("BrowserVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("IsRevoked") + .HasColumnType("bit"); + + b.Property("LastActivityAt") + .HasColumnType("datetime2"); + + b.Property("OperatingSystem") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OsVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedBy") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("RevokedReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RefreshTokenHash"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRoleClaim", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.GroupRole", b => + { + b.HasOne("FSH.Modules.Identity.Domain.Group", "Group") + .WithMany("GroupRoles") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.PasswordHistory", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany("PasswordHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserGroup", b => + { + b.HasOne("FSH.Modules.Identity.Domain.Group", "Group") + .WithMany("UserGroups") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserSession", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshUser", b => + { + b.Navigation("PasswordHistories"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.Group", b => + { + b.Navigation("GroupRoles"); + + b.Navigation("UserGroups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/MultiTenancy/20260907205301_InitialTenant.Designer.cs b/src/Host/FSH.Starter.Migrations.MSSQL/MultiTenancy/20260907205301_InitialTenant.Designer.cs new file mode 100644 index 0000000000..903e59005c --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/MultiTenancy/20260907205301_InitialTenant.Designer.cs @@ -0,0 +1,355 @@ +// +using System; +using FSH.Modules.Multitenancy.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.MultiTenancy +{ + [DbContext(typeof(TenantDbContext))] + [Migration("20260907205301_InitialTenant")] + partial class InitialTenant + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Shared.Multitenancy.AppTenantInfo", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AdminEmail") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Issuer") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Plan") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("QuotaLimits") + .IsRequired() + .HasColumnType("json"); + + b.Property("ValidUpto") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.ToTable("Tenants", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Domain.TenantExpiryNotice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("NoticeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ValidUptoUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "NoticeType", "ValidUptoUtc") + .IsUnique() + .HasDatabaseName("ux_tenant_expiry_notices_tenant_type_validupto"); + + b.ToTable("TenantExpiryNotices", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Domain.TenantTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("BorderRadius") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("CreatedOnUtc") + .HasColumnType("datetimeoffset"); + + b.Property("DarkBackgroundColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkErrorColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkInfoColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkPrimaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkSecondaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkSuccessColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkSurfaceColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkTertiaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkWarningColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DefaultElevation") + .HasColumnType("int"); + + b.Property("ErrorColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("FaviconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("FontFamily") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FontSizeBase") + .HasColumnType("float"); + + b.Property("HeadingFontFamily") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InfoColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("IsDefault") + .HasColumnType("bit"); + + b.Property("LastModifiedBy") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("LastModifiedOnUtc") + .HasColumnType("datetimeoffset"); + + b.Property("LineHeightBase") + .HasColumnType("float"); + + b.Property("LogoDarkUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("PrimaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("SecondaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("SuccessColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("SurfaceColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TertiaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("WarningColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("TenantThemes", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedUtc") + .HasColumnType("datetime2"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedUtc") + .HasColumnType("datetime2"); + + b.Property("CurrentStep") + .HasColumnType("nvarchar(max)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("JobId") + .HasColumnType("nvarchar(max)"); + + b.Property("StartedUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TenantProvisionings", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedUtc") + .HasColumnType("datetime2"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ProvisioningId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Step") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProvisioningId"); + + b.ToTable("TenantProvisioningSteps", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.HasOne("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", "Provisioning") + .WithMany("Steps") + .HasForeignKey("ProvisioningId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provisioning"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/MultiTenancy/20260907205301_InitialTenant.cs b/src/Host/FSH.Starter.Migrations.MSSQL/MultiTenancy/20260907205301_InitialTenant.cs new file mode 100644 index 0000000000..a2ae5973a4 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/MultiTenancy/20260907205301_InitialTenant.cs @@ -0,0 +1,197 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.MultiTenancy +{ + /// + public partial class InitialTenant : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "tenant"); + + migrationBuilder.CreateTable( + name: "TenantExpiryNotices", + schema: "tenant", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + NoticeType = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + ValidUptoUtc = table.Column(type: "datetime2", nullable: false), + CreatedAtUtc = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantExpiryNotices", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TenantProvisionings", + schema: "tenant", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "nvarchar(max)", nullable: false), + CorrelationId = table.Column(type: "nvarchar(max)", nullable: false), + Status = table.Column(type: "int", nullable: false), + CurrentStep = table.Column(type: "nvarchar(max)", nullable: true), + Error = table.Column(type: "nvarchar(max)", nullable: true), + JobId = table.Column(type: "nvarchar(max)", nullable: true), + CreatedUtc = table.Column(type: "datetime2", nullable: false), + StartedUtc = table.Column(type: "datetime2", nullable: true), + CompletedUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantProvisionings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Tenants", + schema: "tenant", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + ConnectionString = table.Column(type: "nvarchar(max)", nullable: false), + AdminEmail = table.Column(type: "nvarchar(max)", nullable: false), + IsActive = table.Column(type: "bit", nullable: false), + ValidUpto = table.Column(type: "datetime2", nullable: false), + Issuer = table.Column(type: "nvarchar(max)", nullable: true), + Plan = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + QuotaLimits = table.Column(type: "json", nullable: false), + Identifier = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Tenants", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TenantThemes", + schema: "tenant", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + PrimaryColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + SecondaryColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + TertiaryColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + BackgroundColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + SurfaceColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + ErrorColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + WarningColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + SuccessColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + InfoColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + DarkPrimaryColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + DarkSecondaryColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + DarkTertiaryColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + DarkBackgroundColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + DarkSurfaceColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + DarkErrorColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + DarkWarningColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + DarkSuccessColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + DarkInfoColor = table.Column(type: "nvarchar(9)", maxLength: 9, nullable: false), + LogoUrl = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: true), + LogoDarkUrl = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: true), + FaviconUrl = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: true), + FontFamily = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + HeadingFontFamily = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + FontSizeBase = table.Column(type: "float", nullable: false), + LineHeightBase = table.Column(type: "float", nullable: false), + BorderRadius = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + DefaultElevation = table.Column(type: "int", nullable: false), + IsDefault = table.Column(type: "bit", nullable: false), + CreatedOnUtc = table.Column(type: "datetimeoffset", nullable: false), + CreatedBy = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + LastModifiedOnUtc = table.Column(type: "datetimeoffset", nullable: true), + LastModifiedBy = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantThemes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TenantProvisioningSteps", + schema: "tenant", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ProvisioningId = table.Column(type: "uniqueidentifier", nullable: false), + Step = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + Error = table.Column(type: "nvarchar(max)", nullable: true), + StartedUtc = table.Column(type: "datetime2", nullable: true), + CompletedUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantProvisioningSteps", x => x.Id); + table.ForeignKey( + name: "FK_TenantProvisioningSteps_TenantProvisionings_ProvisioningId", + column: x => x.ProvisioningId, + principalSchema: "tenant", + principalTable: "TenantProvisionings", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ux_tenant_expiry_notices_tenant_type_validupto", + schema: "tenant", + table: "TenantExpiryNotices", + columns: new[] { "TenantId", "NoticeType", "ValidUptoUtc" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TenantProvisioningSteps_ProvisioningId", + schema: "tenant", + table: "TenantProvisioningSteps", + column: "ProvisioningId"); + + migrationBuilder.CreateIndex( + name: "IX_Tenants_Identifier", + schema: "tenant", + table: "Tenants", + column: "Identifier", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TenantThemes_TenantId", + schema: "tenant", + table: "TenantThemes", + column: "TenantId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TenantExpiryNotices", + schema: "tenant"); + + migrationBuilder.DropTable( + name: "TenantProvisioningSteps", + schema: "tenant"); + + migrationBuilder.DropTable( + name: "Tenants", + schema: "tenant"); + + migrationBuilder.DropTable( + name: "TenantThemes", + schema: "tenant"); + + migrationBuilder.DropTable( + name: "TenantProvisionings", + schema: "tenant"); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/MultiTenancy/TenantDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.MSSQL/MultiTenancy/TenantDbContextModelSnapshot.cs new file mode 100644 index 0000000000..6e1f0458c4 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/MultiTenancy/TenantDbContextModelSnapshot.cs @@ -0,0 +1,352 @@ +// +using System; +using FSH.Modules.Multitenancy.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.MultiTenancy +{ + [DbContext(typeof(TenantDbContext))] + partial class TenantDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Shared.Multitenancy.AppTenantInfo", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AdminEmail") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Issuer") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Plan") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("QuotaLimits") + .IsRequired() + .HasColumnType("json"); + + b.Property("ValidUpto") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.ToTable("Tenants", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Domain.TenantExpiryNotice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("NoticeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ValidUptoUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "NoticeType", "ValidUptoUtc") + .IsUnique() + .HasDatabaseName("ux_tenant_expiry_notices_tenant_type_validupto"); + + b.ToTable("TenantExpiryNotices", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Domain.TenantTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("BorderRadius") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("CreatedOnUtc") + .HasColumnType("datetimeoffset"); + + b.Property("DarkBackgroundColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkErrorColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkInfoColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkPrimaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkSecondaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkSuccessColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkSurfaceColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkTertiaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DarkWarningColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DefaultElevation") + .HasColumnType("int"); + + b.Property("ErrorColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("FaviconUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("FontFamily") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FontSizeBase") + .HasColumnType("float"); + + b.Property("HeadingFontFamily") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InfoColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("IsDefault") + .HasColumnType("bit"); + + b.Property("LastModifiedBy") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("LastModifiedOnUtc") + .HasColumnType("datetimeoffset"); + + b.Property("LineHeightBase") + .HasColumnType("float"); + + b.Property("LogoDarkUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("PrimaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("SecondaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("SuccessColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("SurfaceColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TertiaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("WarningColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("TenantThemes", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedUtc") + .HasColumnType("datetime2"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedUtc") + .HasColumnType("datetime2"); + + b.Property("CurrentStep") + .HasColumnType("nvarchar(max)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("JobId") + .HasColumnType("nvarchar(max)"); + + b.Property("StartedUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TenantProvisionings", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedUtc") + .HasColumnType("datetime2"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ProvisioningId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Step") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProvisioningId"); + + b.ToTable("TenantProvisioningSteps", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.HasOne("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", "Provisioning") + .WithMany("Steps") + .HasForeignKey("ProvisioningId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provisioning"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Notifications/20260907205302_InitialNotifications.Designer.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Notifications/20260907205302_InitialNotifications.Designer.cs new file mode 100644 index 0000000000..377f6d3f11 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Notifications/20260907205302_InitialNotifications.Designer.cs @@ -0,0 +1,88 @@ +// +using System; +using FSH.Modules.Notifications.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Notifications +{ + [DbContext(typeof(NotificationsDbContext))] + [Migration("20260907205302_InitialNotifications")] + partial class InitialNotifications + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("notifications") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Notifications.Domain.Notification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Body") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Link") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("json"); + + b.Property("ReadAtUtc") + .HasColumnType("datetime2"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ReadAtUtc", "CreatedAtUtc") + .HasDatabaseName("IX_Notifications_User_Read_Created"); + + b.ToTable("Notifications", "notifications"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Notifications/20260907205302_InitialNotifications.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Notifications/20260907205302_InitialNotifications.cs new file mode 100644 index 0000000000..b591303fe3 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Notifications/20260907205302_InitialNotifications.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Notifications +{ + /// + public partial class InitialNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "notifications"); + + migrationBuilder.CreateTable( + name: "Notifications", + schema: "notifications", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Type = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Title = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Body = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + Link = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + Source = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + MetadataJson = table.Column(type: "json", nullable: false), + ReadAtUtc = table.Column(type: "datetime2", nullable: true), + CreatedAtUtc = table.Column(type: "datetime2", nullable: false), + TenantId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Notifications", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_User_Read_Created", + schema: "notifications", + table: "Notifications", + columns: new[] { "UserId", "ReadAtUtc", "CreatedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Notifications", + schema: "notifications"); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Notifications/NotificationsDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Notifications/NotificationsDbContextModelSnapshot.cs new file mode 100644 index 0000000000..47a2e35000 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Notifications/NotificationsDbContextModelSnapshot.cs @@ -0,0 +1,85 @@ +// +using System; +using FSH.Modules.Notifications.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Notifications +{ + [DbContext(typeof(NotificationsDbContext))] + partial class NotificationsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("notifications") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Notifications.Domain.Notification", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Body") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Link") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("json"); + + b.Property("ReadAtUtc") + .HasColumnType("datetime2"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ReadAtUtc", "CreatedAtUtc") + .HasDatabaseName("IX_Notifications_User_Read_Created"); + + b.ToTable("Notifications", "notifications"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Tickets/20260907205303_InitialTickets.Designer.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Tickets/20260907205303_InitialTickets.Designer.cs new file mode 100644 index 0000000000..dc37ce2b1b --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Tickets/20260907205303_InitialTickets.Designer.cs @@ -0,0 +1,175 @@ +// +using System; +using FSH.Modules.Tickets.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Tickets +{ + [DbContext(typeof(TicketsDbContext))] + [Migration("20260907205303_InitialTickets")] + partial class InitialTickets + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("tickets") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Tickets.Domain.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignedToUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClosedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeletedOnUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("ReporterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolutionNote") + .HasMaxLength(4096) + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToUserId"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("ReporterUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Number", "TenantId") + .IsUnique() + .HasDatabaseName("IX_Tickets_Number") + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("Tickets", "tickets"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Tickets.Domain.TicketComment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("AuthorUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeletedOnUtc") + .HasColumnType("datetimeoffset"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TicketId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("TicketId"); + + b.ToTable("TicketComments", "tickets"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Tickets.Domain.TicketComment", b => + { + b.HasOne("FSH.Modules.Tickets.Domain.Ticket", null) + .WithMany("Comments") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Tickets.Domain.Ticket", b => + { + b.Navigation("Comments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Tickets/20260907205303_InitialTickets.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Tickets/20260907205303_InitialTickets.cs new file mode 100644 index 0000000000..1919f27821 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Tickets/20260907205303_InitialTickets.cs @@ -0,0 +1,129 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Tickets +{ + /// + public partial class InitialTickets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "tickets"); + + migrationBuilder.CreateTable( + name: "Tickets", + schema: "tickets", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Number = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Title = table.Column(type: "nvarchar(160)", maxLength: 160, nullable: false), + Description = table.Column(type: "nvarchar(max)", maxLength: 4096, nullable: true), + Status = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Priority = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + ReporterUserId = table.Column(type: "uniqueidentifier", nullable: false), + AssignedToUserId = table.Column(type: "uniqueidentifier", nullable: true), + ResolutionNote = table.Column(type: "nvarchar(max)", maxLength: 4096, nullable: true), + CreatedAtUtc = table.Column(type: "datetime2", nullable: false), + UpdatedAtUtc = table.Column(type: "datetime2", nullable: true), + ResolvedAtUtc = table.Column(type: "datetime2", nullable: true), + ClosedAtUtc = table.Column(type: "datetime2", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: false), + DeletedOnUtc = table.Column(type: "datetimeoffset", nullable: true), + DeletedBy = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + TenantId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tickets", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TicketComments", + schema: "tickets", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TicketId = table.Column(type: "uniqueidentifier", nullable: false), + AuthorUserId = table.Column(type: "uniqueidentifier", nullable: false), + Body = table.Column(type: "nvarchar(max)", maxLength: 8192, nullable: false), + CreatedAtUtc = table.Column(type: "datetime2", nullable: false), + IsDeleted = table.Column(type: "bit", nullable: false), + DeletedOnUtc = table.Column(type: "datetimeoffset", nullable: true), + DeletedBy = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + TenantId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TicketComments", x => x.Id); + table.ForeignKey( + name: "FK_TicketComments_Tickets_TicketId", + column: x => x.TicketId, + principalSchema: "tickets", + principalTable: "Tickets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TicketComments_IsDeleted", + schema: "tickets", + table: "TicketComments", + column: "IsDeleted"); + + migrationBuilder.CreateIndex( + name: "IX_TicketComments_TicketId", + schema: "tickets", + table: "TicketComments", + column: "TicketId"); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_AssignedToUserId", + schema: "tickets", + table: "Tickets", + column: "AssignedToUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_IsDeleted", + schema: "tickets", + table: "Tickets", + column: "IsDeleted"); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_Number", + schema: "tickets", + table: "Tickets", + columns: new[] { "Number", "TenantId" }, + unique: true, + filter: "[IsDeleted] = 0"); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_ReporterUserId", + schema: "tickets", + table: "Tickets", + column: "ReporterUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_Status", + schema: "tickets", + table: "Tickets", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TicketComments", + schema: "tickets"); + + migrationBuilder.DropTable( + name: "Tickets", + schema: "tickets"); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Tickets/TicketsDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Tickets/TicketsDbContextModelSnapshot.cs new file mode 100644 index 0000000000..31885dafd2 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Tickets/TicketsDbContextModelSnapshot.cs @@ -0,0 +1,172 @@ +// +using System; +using FSH.Modules.Tickets.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Tickets +{ + [DbContext(typeof(TicketsDbContext))] + partial class TicketsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("tickets") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Tickets.Domain.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignedToUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClosedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeletedOnUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("ReporterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolutionNote") + .HasMaxLength(4096) + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToUserId"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("ReporterUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Number", "TenantId") + .IsUnique() + .HasDatabaseName("IX_Tickets_Number") + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("Tickets", "tickets"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Tickets.Domain.TicketComment", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("AuthorUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeletedOnUtc") + .HasColumnType("datetimeoffset"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TicketId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("TicketId"); + + b.ToTable("TicketComments", "tickets"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Tickets.Domain.TicketComment", b => + { + b.HasOne("FSH.Modules.Tickets.Domain.Ticket", null) + .WithMany("Comments") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Tickets.Domain.Ticket", b => + { + b.Navigation("Comments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Webhooks/20260907205305_InitialWebhooks.Designer.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Webhooks/20260907205305_InitialWebhooks.Designer.cs new file mode 100644 index 0000000000..0018080f39 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Webhooks/20260907205305_InitialWebhooks.Designer.cs @@ -0,0 +1,119 @@ +// +using System; +using FSH.Modules.Webhooks.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Webhooks +{ + [DbContext(typeof(WebhookDbContext))] + [Migration("20260907205305_InitialWebhooks")] + partial class InitialWebhooks + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("webhooks") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Webhooks.Domain.WebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptCount") + .HasColumnType("int"); + + b.Property("AttemptedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ErrorMessage") + .HasMaxLength(4096) + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("HttpStatusCode") + .HasColumnType("int"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AttemptedAtUtc"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Deliveries", "webhooks"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Webhooks.Domain.WebhookSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EventsCsv") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ProtectedSecret") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.ToTable("Subscriptions", "webhooks"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Webhooks/20260907205305_InitialWebhooks.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Webhooks/20260907205305_InitialWebhooks.cs new file mode 100644 index 0000000000..c61c274079 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Webhooks/20260907205305_InitialWebhooks.cs @@ -0,0 +1,87 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Webhooks +{ + /// + public partial class InitialWebhooks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "webhooks"); + + migrationBuilder.CreateTable( + name: "Deliveries", + schema: "webhooks", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + SubscriptionId = table.Column(type: "uniqueidentifier", nullable: false), + EventType = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + PayloadJson = table.Column(type: "nvarchar(max)", nullable: false), + HttpStatusCode = table.Column(type: "int", nullable: false), + Success = table.Column(type: "bit", nullable: false), + AttemptCount = table.Column(type: "int", nullable: false), + AttemptedAtUtc = table.Column(type: "datetime2", nullable: false), + ErrorMessage = table.Column(type: "nvarchar(max)", maxLength: 4096, nullable: true), + TenantId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Deliveries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Subscriptions", + schema: "webhooks", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Url = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: false), + EventsCsv = table.Column(type: "nvarchar(max)", maxLength: 4096, nullable: false), + ProtectedSecret = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + CreatedAtUtc = table.Column(type: "datetime2", nullable: false), + TenantId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Subscriptions", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Deliveries_AttemptedAtUtc", + schema: "webhooks", + table: "Deliveries", + column: "AttemptedAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_Deliveries_SubscriptionId", + schema: "webhooks", + table: "Deliveries", + column: "SubscriptionId"); + + migrationBuilder.CreateIndex( + name: "IX_Subscriptions_IsActive", + schema: "webhooks", + table: "Subscriptions", + column: "IsActive"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Deliveries", + schema: "webhooks"); + + migrationBuilder.DropTable( + name: "Subscriptions", + schema: "webhooks"); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/Webhooks/WebhookDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.MSSQL/Webhooks/WebhookDbContextModelSnapshot.cs new file mode 100644 index 0000000000..ad150b859e --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/Webhooks/WebhookDbContextModelSnapshot.cs @@ -0,0 +1,116 @@ +// +using System; +using FSH.Modules.Webhooks.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.Webhooks +{ + [DbContext(typeof(WebhookDbContext))] + partial class WebhookDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("webhooks") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Webhooks.Domain.WebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptCount") + .HasColumnType("int"); + + b.Property("AttemptedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ErrorMessage") + .HasMaxLength(4096) + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("HttpStatusCode") + .HasColumnType("int"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AttemptedAtUtc"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Deliveries", "webhooks"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Webhooks.Domain.WebhookSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EventsCsv") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ProtectedSecret") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.ToTable("Subscriptions", "webhooks"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs index f14e46bad8..b878fb8f37 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Context; using FSH.Framework.Core.Exceptions; using FSH.Framework.Persistence; +using FSH.Framework.Persistence.Providers; using FSH.Framework.Shared.Persistence; using FSH.Modules.Auditing.Contracts; using FSH.Modules.Auditing.Contracts.Authorization; @@ -100,12 +101,15 @@ public async ValueTask> Handle(GetAuditsQuery que if (!string.IsNullOrWhiteSpace(query.Search)) { string term = query.Search; - // ILIKE on PayloadJson is sequential; the (TenantId, OccurredAtUtc) index - // scopes the scan — add a GIN index on PayloadJson in prod for fast search. - audits = audits.Where(a => - (a.PayloadJson != null && EF.Functions.ILike(AsText(a.PayloadJson), $"%{term}%")) || - (a.Source != null && EF.Functions.ILike(a.Source, $"%{term}%")) || - (a.UserName != null && EF.Functions.ILike(a.UserName, $"%{term}%"))); + // Substring search over the raw JSON text is sequential on both providers; the + // (TenantId, OccurredAtUtc) index scopes the scan. Source/UserName are served by the + // trigram GIN indexes on PostgreSQL, and scan on SQL Server (see AuditRecordConfiguration). + audits = audits.WhereSearch( + _dbContext.Database, + term, + a => AsText(a.PayloadJson), + a => a.Source, + a => a.UserName); } audits = audits.OrderByDescending(a => a.OccurredAtUtc); diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs index f381b626c7..c628b12a1f 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs @@ -1,9 +1,11 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Auditing.Contracts; using FSH.Modules.Auditing.Contracts.Dtos; using FSH.Modules.Auditing.Contracts.v1.GetExceptionAudits; using FSH.Modules.Auditing.Persistence; using Mediator; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; using static FSH.Modules.Auditing.Persistence.AuditJsonbFunctions; namespace FSH.Modules.Auditing.Features.v1.GetExceptionAudits; @@ -27,7 +29,7 @@ public async ValueTask> Handle(GetExceptionAudits var audits = GetBaseQuery(); audits = ApplyDateFilters(audits, query); audits = ApplySeverityFilter(audits, query); - audits = ApplyPayloadFilters(audits, query); + audits = ApplyPayloadFilters(audits, query, _dbContext.Database); // Cap server-side so an unpaged call can't materialize a tenant's whole exception history. var take = query.Take is >= 1 and <= MaxPageSize ? query.Take.Value : DefaultPageSize; @@ -68,27 +70,34 @@ private static IQueryable ApplySeverityFilter(IQueryable ApplyPayloadFilters(IQueryable audits, GetExceptionAuditsQuery query) + private static IQueryable ApplyPayloadFilters( + IQueryable audits, + GetExceptionAuditsQuery query, + DatabaseFacade database) { if (query.Area.HasValue && query.Area.Value != ExceptionArea.None) { string areaValue = query.Area.Value.ToString(); - // PostgreSQL renders jsonb::text in canonical form with a space after the - // colon ({"area": "Value"}), so the patterns must include that space. - audits = audits.Where(a => a.PayloadJson != null && - EF.Functions.ILike(AsText(a.PayloadJson), $"%\"area\": \"{areaValue}\"%")); + audits = audits.WhereLike( + database, + ProviderQueryExtensions.JsonTextPropertyPattern(database, "area", areaValue), + a => AsText(a.PayloadJson)); } if (!string.IsNullOrWhiteSpace(query.ExceptionType)) { - audits = audits.Where(a => a.PayloadJson != null && - EF.Functions.ILike(AsText(a.PayloadJson), $"%\"exceptionType\": \"{query.ExceptionType}%")); + audits = audits.WhereLike( + database, + ProviderQueryExtensions.JsonTextPropertyPattern(database, "exceptionType", query.ExceptionType, exact: false), + a => AsText(a.PayloadJson)); } if (!string.IsNullOrWhiteSpace(query.RouteOrLocation)) { - audits = audits.Where(a => a.PayloadJson != null && - EF.Functions.ILike(AsText(a.PayloadJson), $"%\"routeOrLocation\": \"{query.RouteOrLocation}%")); + audits = audits.WhereLike( + database, + ProviderQueryExtensions.JsonTextPropertyPattern(database, "routeOrLocation", query.RouteOrLocation, exact: false), + a => AsText(a.PayloadJson)); } return audits; diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryHandler.cs index a014b2d919..6968db2954 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryHandler.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Auditing.Contracts; using FSH.Modules.Auditing.Contracts.Dtos; using FSH.Modules.Auditing.Contracts.v1.GetSecurityAudits; @@ -46,10 +47,10 @@ public async ValueTask> Handle(GetSecurityAuditsQ if (query.Action.HasValue && query.Action.Value != SecurityAction.None) { string actionValue = query.Action.Value.ToString(); - // PostgreSQL renders jsonb::text in canonical form with a space after the - // colon ({"action": "Value"}), so the pattern must include that space. - audits = audits.Where(a => a.PayloadJson != null && - EF.Functions.ILike(AsText(a.PayloadJson), $"%\"action\": \"{actionValue}\"%")); + audits = audits.WhereLike( + _dbContext.Database, + ProviderQueryExtensions.JsonTextPropertyPattern(_dbContext.Database, "action", actionValue), + a => AsText(a.PayloadJson)); } // Cap server-side so an unpaged call can't materialize a tenant's whole audit history. diff --git a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbContext.cs b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbContext.cs index a085444270..4e3a34b3f3 100644 --- a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbContext.cs +++ b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbContext.cs @@ -28,13 +28,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ArgumentNullException.ThrowIfNull(modelBuilder); // Required for the trigram GIN indexes on Source/UserName. Idempotent (IF NOT EXISTS); the - // migration role needs CREATE permission on the database. - modelBuilder.HasPostgresExtension("pg_trgm"); + // migration role needs CREATE permission on the database. Postgres-only API — on SQL Server + // the trigram indexes do not exist at all, so there is nothing to enable. + if (Database.IsNpgsql()) + { + modelBuilder.HasPostgresExtension("pg_trgm"); + } modelBuilder.ApplyConfigurationsFromAssembly(typeof(AuditDbContext).Assembly); - // Map AuditJsonbFunctions.AsText to `CAST(x AS text)` so jsonb PayloadJson is ILIKE-searchable. - // Without the cast, ILIKE on jsonb throws ("like_escape(jsonb, unknown) does not exist") → HTTP 500. + // Map AuditJsonbFunctions.AsText to a cast to text so the JSON PayloadJson column is + // substring-searchable. Needed on both providers: PostgreSQL's jsonb has no LIKE operator + // ("like_escape(jsonb, unknown) does not exist" → HTTP 500) and SQL Server's native json + // type likewise has to be cast to nvarchar before LIKE will accept it. var textMapping = this.GetService().FindMapping(typeof(string))!; var asTextMethod = typeof(AuditJsonbFunctions) .GetMethod(nameof(AuditJsonbFunctions.AsText), BindingFlags.Public | BindingFlags.Static)!; diff --git a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecordConfiguration.cs b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecordConfiguration.cs index 35129c770c..f1bb959337 100644 --- a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecordConfiguration.cs +++ b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecordConfiguration.cs @@ -1,4 +1,5 @@ using Finbuckle.MultiTenant.EntityFrameworkCore.Extensions; +using FSH.Framework.Persistence.Providers; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -15,7 +16,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.EventType).HasConversion(); builder.Property(x => x.Severity).HasConversion(); builder.Property(x => x.Tags).HasConversion(); - builder.Property(x => x.PayloadJson).HasColumnType("jsonb"); + builder.Property(x => x.PayloadJson).HasJsonColumn(); // Hot-path index: default audits list filters on TenantId (Finbuckle) and orders by OccurredAtUtc DESC. // A composite over both lets PostgreSQL serve the paged top-N from an index-only walk. @@ -36,22 +37,24 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => x.TraceId) .HasDatabaseName("IX_AuditRecords_TraceId"); - // ILIKE search on Source / UserName: pg_trgm GIN indexes turn `%term%` from a seq scan into a probe. - // (pg_trgm extension is created at the context level.) + // Substring search on Source / UserName. On PostgreSQL these become pg_trgm GIN indexes, + // which turn `%term%` from a seq scan into a probe (the pg_trgm extension is created at the + // context level). SQL Server has no equivalent and both columns are nvarchar(max), so the + // provider conventions pass drops these from the MSSQL model — see PortableIndexKind. builder.HasIndex(x => x.Source) - .HasMethod("gin") - .HasOperators("gin_trgm_ops") + .AsTrigramSearchIndex() .HasDatabaseName("IX_AuditRecords_Source_trgm"); builder.HasIndex(x => x.UserName) - .HasMethod("gin") - .HasOperators("gin_trgm_ops") + .AsTrigramSearchIndex() .HasDatabaseName("IX_AuditRecords_UserName_trgm"); - // GIN over jsonb via jsonb_path_ops: supports containment (@>, ?) at far less disk than default jsonb_ops. - // ILIKE on raw JSON text still seq-scans — extract indexed columns (Source, UserName) or denormalize for that. + // Containment search over the JSON payload: a jsonb_path_ops GIN index on PostgreSQL, a + // CREATE JSON INDEX emitted by the migration on SQL Server (EF has no API for those, so the + // conventions pass removes this index from the MSSQL model). + // Substring search on raw JSON text still seq-scans on both providers — extract indexed + // columns (Source, UserName) or denormalize for that. builder.HasIndex(x => x.PayloadJson) - .HasMethod("gin") - .HasOperators("jsonb_path_ops") + .AsJsonContainmentIndex() .HasDatabaseName("IX_AuditRecords_PayloadJson_gin"); } } diff --git a/src/Modules/Billing/Modules.Billing/Data/BillingDbContext.cs b/src/Modules/Billing/Modules.Billing/Data/BillingDbContext.cs index f966ed2edf..63b22137b6 100644 --- a/src/Modules/Billing/Modules.Billing/Data/BillingDbContext.cs +++ b/src/Modules/Billing/Modules.Billing/Data/BillingDbContext.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Billing.Domain; using Microsoft.EntityFrameworkCore; @@ -31,4 +32,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.HasDefaultSchema(Schema); modelBuilder.ApplyConfigurationsFromAssembly(typeof(BillingDbContext).Assembly); } + + /// + /// This context derives from rather than BaseDbContext (billing is + /// deliberately cross-tenant), so it registers the framework's provider conventions itself — + /// without them the portable column and index intent is never resolved. + /// + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + base.ConfigureConventions(configurationBuilder); + configurationBuilder.AddHeroProviderConventions(DbProviderResolver.FromEfProviderName(Database.ProviderName)); + } } diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/BillingPlanConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/BillingPlanConfiguration.cs index 24a66b0e95..2d1894c8fa 100644 --- a/src/Modules/Billing/Modules.Billing/Data/Configurations/BillingPlanConfiguration.cs +++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/BillingPlanConfiguration.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using FSH.Framework.Persistence.Providers; using FSH.Framework.Shared.Quota; using FSH.Modules.Billing.Domain; using Microsoft.EntityFrameworkCore; @@ -41,9 +42,9 @@ public void Configure(EntityTypeBuilder builder) ? new Dictionary() : JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new Dictionary()) - .HasColumnType("jsonb") + .HasJsonColumn() .HasColumnName("OverageRates") - .HasDefaultValueSql("'{}'::jsonb") + .HasJsonDefaultEmptyObject() .Metadata.SetValueComparer(new ValueComparer>( (a, b) => ReferenceEquals(a, b) || (a != null && b != null && a.SequenceEqual(b)), v => v.Aggregate(0, (h, kv) => HashCode.Combine(h, (int)kv.Key, kv.Value.GetHashCode())), diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceConfiguration.cs index c442b391ff..ab2d547a29 100644 --- a/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceConfiguration.cs +++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Billing.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -32,7 +33,7 @@ public void Configure(EntityTypeBuilder builder) // ad-hoc and may repeat within a period, so exclude them from the uniqueness filter. builder.HasIndex(x => new { x.TenantId, x.PeriodYear, x.PeriodMonth, x.Purpose }) .IsUnique() - .HasFilter($"\"Purpose\" <> {(int)Contracts.InvoicePurpose.Topup}") + .HasNotEqualsFilter("Purpose", (int)Contracts.InvoicePurpose.Topup) .HasDatabaseName("ux_invoices_tenant_period_purpose"); builder.HasIndex(x => x.Status); builder.HasIndex(x => x.InvoiceNumber).IsUnique(); diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/SubscriptionConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/SubscriptionConfiguration.cs index 8286c65a84..5eb05f8df0 100644 --- a/src/Modules/Billing/Modules.Billing/Data/Configurations/SubscriptionConfiguration.cs +++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/SubscriptionConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Billing.Contracts; using FSH.Modules.Billing.Domain; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,7 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => new { x.TenantId, x.Status }); builder.HasIndex(x => x.TenantId) - .HasFilter($"\"Status\" = {(int)SubscriptionStatus.Active}") + .HasEqualsFilter("Status", (int)SubscriptionStatus.Active) .IsUnique() .HasDatabaseName("ux_subscriptions_tenantid_active"); diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletTransactionConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletTransactionConfiguration.cs index ab1398cb0e..034baedb36 100644 --- a/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletTransactionConfiguration.cs +++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletTransactionConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Billing.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -30,7 +31,7 @@ public void Configure(EntityTypeBuilder builder) // A concurrent second MarkInvoicePaid on the same invoice fails this constraint and rolls back. builder.HasIndex(x => x.ReferenceId) .IsUnique() - .HasFilter($"\"Kind\" = {(int)Contracts.WalletTransactionKind.Topup}") + .HasEqualsFilter("Kind", (int)Contracts.WalletTransactionKind.Topup) .HasDatabaseName("ux_wallet_transactions_topup_reference"); } } diff --git a/src/Modules/Catalog/Modules.Catalog/Data/Configurations/BrandConfiguration.cs b/src/Modules/Catalog/Modules.Catalog/Data/Configurations/BrandConfiguration.cs index 589ea7e212..fc88e182f7 100644 --- a/src/Modules/Catalog/Modules.Catalog/Data/Configurations/BrandConfiguration.cs +++ b/src/Modules/Catalog/Modules.Catalog/Data/Configurations/BrandConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Catalog.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -15,7 +16,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Slug).IsRequired().HasMaxLength(160); // Filtered unique index — only enforce uniqueness across live rows // so a soft-deleted slug doesn't block recreating the same brand. - builder.HasIndex(x => x.Slug).IsUnique().HasFilter("\"IsDeleted\" = FALSE"); + builder.HasIndex(x => x.Slug).IsUnique().HasNotDeletedFilter(); builder.Property(x => x.Description).HasMaxLength(1024); builder.Property(x => x.LogoUrl).HasMaxLength(512); builder.Property(x => x.DeletedBy).HasMaxLength(64); diff --git a/src/Modules/Catalog/Modules.Catalog/Data/Configurations/CategoryConfiguration.cs b/src/Modules/Catalog/Modules.Catalog/Data/Configurations/CategoryConfiguration.cs index 716ea26695..cbc58c1fc8 100644 --- a/src/Modules/Catalog/Modules.Catalog/Data/Configurations/CategoryConfiguration.cs +++ b/src/Modules/Catalog/Modules.Catalog/Data/Configurations/CategoryConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Catalog.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -13,7 +14,7 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(x => x.Id); builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.Slug).IsRequired().HasMaxLength(160); - builder.HasIndex(x => x.Slug).IsUnique().HasFilter("\"IsDeleted\" = FALSE"); + builder.HasIndex(x => x.Slug).IsUnique().HasNotDeletedFilter(); builder.Property(x => x.Description).HasMaxLength(1024); builder.Property(x => x.DeletedBy).HasMaxLength(64); builder.HasIndex(x => x.ParentCategoryId); diff --git a/src/Modules/Catalog/Modules.Catalog/Data/Configurations/ProductConfiguration.cs b/src/Modules/Catalog/Modules.Catalog/Data/Configurations/ProductConfiguration.cs index 919da9c1b8..e99f99b7a8 100644 --- a/src/Modules/Catalog/Modules.Catalog/Data/Configurations/ProductConfiguration.cs +++ b/src/Modules/Catalog/Modules.Catalog/Data/Configurations/ProductConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Catalog.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -15,11 +16,11 @@ public void Configure(EntityTypeBuilder builder) // unique-per-tenant, so two tenants can share "ABC-001". Opt out via IGlobalEntity. builder.Property(x => x.Sku).IsRequired().HasMaxLength(64); - builder.HasIndex(x => x.Sku).IsUnique().HasFilter("\"IsDeleted\" = FALSE"); + builder.HasIndex(x => x.Sku).IsUnique().HasNotDeletedFilter(); builder.Property(x => x.Name).IsRequired().HasMaxLength(200); builder.Property(x => x.Slug).IsRequired().HasMaxLength(220); - builder.HasIndex(x => x.Slug).IsUnique().HasFilter("\"IsDeleted\" = FALSE"); + builder.HasIndex(x => x.Slug).IsUnique().HasNotDeletedFilter(); builder.Property(x => x.Description).HasMaxLength(4000); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/SearchBrands/SearchBrandsQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/SearchBrands/SearchBrandsQueryHandler.cs index e1b9b001bb..f861356860 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/SearchBrands/SearchBrandsQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/SearchBrands/SearchBrandsQueryHandler.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Framework.Shared.Persistence; using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Brands; @@ -23,9 +24,7 @@ public async ValueTask> Handle(SearchBrandsQuery query, if (!string.IsNullOrWhiteSpace(query.Search)) { string term = query.Search.Trim(); - q = q.Where(b => - EF.Functions.ILike(b.Name, $"%{term}%") || - EF.Functions.ILike(b.Slug, $"%{term}%")); + q = q.WhereSearch(dbContext.Database, term, b => b.Name, b => b.Slug); } q = ApplySort(q, query.SortBy, query.SortDir); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/SearchCategories/SearchCategoriesQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/SearchCategories/SearchCategoriesQueryHandler.cs index 6fba2e4779..a2afc3e147 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/SearchCategories/SearchCategoriesQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/SearchCategories/SearchCategoriesQueryHandler.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Framework.Shared.Persistence; using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Categories; @@ -28,9 +29,7 @@ public async ValueTask> Handle(SearchCategoriesQuery if (!string.IsNullOrWhiteSpace(query.Search)) { string term = query.Search.Trim(); - q = q.Where(c => - EF.Functions.ILike(c.Name, $"%{term}%") || - EF.Functions.ILike(c.Slug, $"%{term}%")); + q = q.WhereSearch(dbContext.Database, term, c => c.Name, c => c.Slug); } q = ApplySort(q, query.SortBy, query.SortDir); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SearchProducts/SearchProductsQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SearchProducts/SearchProductsQueryHandler.cs index 63af43314f..04712530de 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SearchProducts/SearchProductsQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SearchProducts/SearchProductsQueryHandler.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Framework.Shared.Persistence; using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Products; @@ -38,10 +39,7 @@ public async ValueTask> Handle(SearchProductsQuery que if (!string.IsNullOrWhiteSpace(query.Search)) { string term = query.Search.Trim(); - q = q.Where(p => - EF.Functions.ILike(p.Name, $"%{term}%") || - EF.Functions.ILike(p.Sku, $"%{term}%") || - EF.Functions.ILike(p.Slug, $"%{term}%")); + q = q.WhereSearch(dbContext.Database, term, p => p.Name, p => p.Sku, p => p.Slug); } q = ApplySort(q, query.SortBy, query.SortDir); diff --git a/src/Modules/Chat/Modules.Chat/ChatModule.cs b/src/Modules/Chat/Modules.Chat/ChatModule.cs index d0769c0a7f..50ec46b993 100644 --- a/src/Modules/Chat/Modules.Chat/ChatModule.cs +++ b/src/Modules/Chat/Modules.Chat/ChatModule.cs @@ -53,6 +53,9 @@ public void ConfigureServices(IHostApplicationBuilder builder) builder.Services.AddHeroDbContext(); builder.Services.AddScoped(); + // Singleton: the full-text index probe is a one-off server capability check, cached for the + // process rather than re-queried per search. + builder.Services.AddSingleton(); builder.Services.AddValidatorsFromAssembly(typeof(ChatModule).Assembly); // Realtime adapters consumed by AppHub (BuildingBlocks/Web). These let the shared hub diff --git a/src/Modules/Chat/Modules.Chat/Data/ChatDbContext.cs b/src/Modules/Chat/Modules.Chat/Data/ChatDbContext.cs index c40ae45c99..d1a32d69fa 100644 --- a/src/Modules/Chat/Modules.Chat/Data/ChatDbContext.cs +++ b/src/Modules/Chat/Modules.Chat/Data/ChatDbContext.cs @@ -22,13 +22,65 @@ public ChatDbContext( public DbSet Channels => Set(); public DbSet Messages => Set(); + /// + /// Shadow property holding the lexicographically-comparable form of Message.Id. + /// SQL Server only. + /// + public const string MessageSortKey = "IdSort"; + protected override void OnModelCreating(ModelBuilder modelBuilder) { ArgumentNullException.ThrowIfNull(modelBuilder); modelBuilder.HasDefaultSchema(Schema); modelBuilder.ApplyConfigurationsFromAssembly(typeof(ChatDbContext).Assembly); + + if (Database.IsSqlServer()) + { + ConfigureSqlServerMessageOrdering(modelBuilder); + } + // base.OnModelCreating runs LAST so BaseDbContext's auto-apply sees // fully-configured entities (including HasMany child types). base.OnModelCreating(modelBuilder); } + + /// + /// Adds a sort key that restores chronological ordering of Guid v7 message ids on SQL Server. + /// + /// + /// + /// Message paging relies on "Guid v7 is monotonic, so Id DESC is time DESC". That holds on + /// PostgreSQL, whose uuid compares in byte order. SQL Server's uniqueidentifier + /// compares the last six bytes first, so a v7 id's leading timestamp is ignored and + /// pages come back in the wrong order — silently, with no error. Casting to + /// the canonical char(36) text form compares in display order and restores it. + /// + /// + /// Text rather than binary(16) — both sort correctly, but EF Core cannot translate an + /// ordering comparison on byte[], so a binary key would force the ordering and the + /// cursor predicate into raw SQL. The cost is a 36-byte index key instead of 16. + /// + /// + /// Persisted and indexed so ordering and the Before cursor stay index-backed rather than + /// sorting the channel's history on every page. SQL Server only — the PostgreSQL model and + /// schema are untouched. + /// + /// + private static void ConfigureSqlServerMessageOrdering(ModelBuilder modelBuilder) + { + modelBuilder.Entity(b => + { + b.Property(MessageSortKey) + .HasColumnType("char(36)") + .HasComputedColumnSql("CONVERT(char(36), [Id])", stored: true); + + b.HasIndex(nameof(Message.ChannelId), MessageSortKey) + .IsDescending(false, true) + .HasDatabaseName("IX_Messages_ChannelId_IdSort"); + + b.HasIndex(nameof(Message.ParentMessageId), MessageSortKey) + .IsDescending(false, true) + .HasDatabaseName("IX_Messages_ParentMessageId_IdSort"); + }); + } } diff --git a/src/Modules/Chat/Modules.Chat/Data/Configurations/ChatChannelConfiguration.cs b/src/Modules/Chat/Modules.Chat/Data/Configurations/ChatChannelConfiguration.cs index 113c199774..817ef0bac8 100644 --- a/src/Modules/Chat/Modules.Chat/Data/Configurations/ChatChannelConfiguration.cs +++ b/src/Modules/Chat/Modules.Chat/Data/Configurations/ChatChannelConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Chat.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -20,14 +21,14 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Slug).HasMaxLength(220); builder.HasIndex(x => x.Slug) .IsUnique() - .HasFilter("\"Slug\" IS NOT NULL AND \"IsDeleted\" = FALSE"); + .HasNotNullFilter("Slug").HasNotDeletedFilter(); builder.Property(x => x.Description).HasMaxLength(2000); builder.Property(x => x.IsPrivate).IsRequired(); builder.Property(x => x.DirectKey).HasMaxLength(80); builder.HasIndex(x => x.DirectKey) .IsUnique() - .HasFilter("\"Type\" = 0 AND \"IsDeleted\" = FALSE"); + .HasEqualsFilter("Type", 0).HasNotDeletedFilter(); builder.Property(x => x.CreatedByUserId).IsRequired().HasMaxLength(64); builder.Property(x => x.CreatedAtUtc).IsRequired(); diff --git a/src/Modules/Chat/Modules.Chat/Data/Configurations/MessageConfiguration.cs b/src/Modules/Chat/Modules.Chat/Data/Configurations/MessageConfiguration.cs index 960c0b3fac..4f65cadeaa 100644 --- a/src/Modules/Chat/Modules.Chat/Data/Configurations/MessageConfiguration.cs +++ b/src/Modules/Chat/Modules.Chat/Data/Configurations/MessageConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Chat.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -15,7 +16,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.ChannelId).IsRequired(); builder.Property(x => x.AuthorUserId).IsRequired().HasMaxLength(64); - builder.Property(x => x.Body).HasColumnType("text"); + builder.Property(x => x.Body).HasUnboundedTextColumn(); builder.Property(x => x.ParentMessageId); builder.Property(x => x.ReplyCount).IsRequired(); builder.Property(x => x.EditedAtUtc); @@ -28,12 +29,12 @@ public void Configure(EntityTypeBuilder builder) // Partial index on pinned messages — small set per channel, used by // GetPinnedMessages query (filters by ChannelId). builder.HasIndex(x => new { x.ChannelId, x.IsPinned }) - .HasFilter("\"IsPinned\" = true"); + .HasBoolFilter("IsPinned", true); // Reverse-chronological paging by (ChannelId, Id) — Guid v7 is monotonically sortable // so Id desc is the time order. Index is descending on Id only. builder.HasIndex(x => new { x.ChannelId, x.Id }).IsDescending(false, true); - builder.HasIndex(x => x.ParentMessageId).HasFilter("\"ParentMessageId\" IS NOT NULL"); + builder.HasIndex(x => x.ParentMessageId).HasNotNullFilter("ParentMessageId"); builder.HasOne() .WithMany() diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs index e29d15045c..06a3a839dc 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using FSH.Framework.Core.Context; using FSH.Framework.Core.Exceptions; +using FSH.Framework.Persistence.Providers; using FSH.Modules.Chat.Contracts.v1.DTOs; using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; @@ -34,9 +35,7 @@ public async ValueTask> Handle(DiscoverChannelsQu if (!string.IsNullOrWhiteSpace(q.Search)) { var term = q.Search.Trim(); - query = query.Where(c => - EF.Functions.ILike(c.Name!, $"%{term}%") - || EF.Functions.ILike(c.Slug!, $"%{term}%")); + query = query.WhereSearch(db.Database, term, c => c.Name, c => c.Slug); } var channels = await query diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs index e845953d78..b6a488fe4f 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs @@ -30,17 +30,19 @@ public async ValueTask> Handle( ?? throw new NotFoundException("Channel not found."); channel.RequireMember(currentUserId); - // Top-level only (no thread replies). Guid v7 monotonic → Id desc = time desc. + // Top-level only (no thread replies). Newest-first via the provider's chronological key — + // see MessageOrdering: Guid v7 is monotonic in byte order, which PostgreSQL sorts by and + // SQL Server does not. IQueryable q = db.Messages .Where(m => m.ChannelId == query.ChannelId && m.ParentMessageId == null); if (query.Before is { } beforeId) { - q = q.Where(m => m.Id.CompareTo(beforeId) < 0); + q = q.WhereOlderThan(db, beforeId); } var rows = await q - .OrderByDescending(m => m.Id) + .OrderByNewest(db) .Take(query.PageSize) .Include(m => m.Attachments) .AsNoTracking() diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs index 538e110717..e6d474c7c7 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs @@ -43,11 +43,11 @@ public async ValueTask> Handle( if (query.Before is { } beforeId) { - q = q.Where(m => m.Id.CompareTo(beforeId) < 0); + q = q.WhereOlderThan(db, beforeId); } var rows = await q - .OrderByDescending(m => m.Id) + .OrderByNewest(db) .Take(query.PageSize) .Include(m => m.Attachments) .Include(m => m.Mentions) diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/MessageOrdering.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/MessageOrdering.cs new file mode 100644 index 0000000000..762592bbaa --- /dev/null +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/MessageOrdering.cs @@ -0,0 +1,46 @@ +using System.Globalization; +using FSH.Modules.Chat.Data; +using FSH.Modules.Chat.Domain; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Chat.Features.v1.Messages; + +/// +/// Chronological ordering and cursor paging for messages, in the way each provider actually sorts +/// Guid v7 ids. +/// +/// +/// PostgreSQL's uuid compares in byte order, so a v7 id's leading timestamp makes +/// Id DESC equal to newest-first. SQL Server's uniqueidentifier compares the last six +/// bytes first, which reorders pages arbitrarily; there it sorts on the persisted +/// char(36) sort key configured by instead. +/// +internal static class MessageOrdering +{ + /// Newest-first, using whichever key sorts chronologically on this provider. + public static IQueryable OrderByNewest(this IQueryable source, DbContext db) + { + ArgumentNullException.ThrowIfNull(db); + + return db.Database.IsSqlServer() + ? source.OrderByDescending(m => EF.Property(m, ChatDbContext.MessageSortKey)) + : source.OrderByDescending(m => m.Id); + } + + /// Restricts to messages strictly older than . + public static IQueryable WhereOlderThan(this IQueryable source, DbContext db, Guid beforeId) + { + ArgumentNullException.ThrowIfNull(db); + + if (!db.Database.IsSqlServer()) + { + return source.Where(m => m.Id.CompareTo(beforeId) < 0); + } + + // Compare on the same key the ordering uses, so the cursor lands on the page boundary the + // caller actually saw. CONVERT(char(36), id) is uppercase, which Guid "D" format matches + // once upper-cased. + string cursor = beforeId.ToString("D", CultureInfo.InvariantCulture).ToUpperInvariant(); + return source.Where(m => EF.Property(m, ChatDbContext.MessageSortKey).CompareTo(cursor) < 0); + } +} diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs index 0f1ce68f26..921c1e017b 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using FSH.Framework.Core.Context; using FSH.Framework.Core.Exceptions; +using FSH.Framework.Persistence.Providers; using FSH.Modules.Chat.Contracts.v1.DTOs; using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; @@ -14,7 +15,8 @@ namespace FSH.Modules.Chat.Features.v1.Search; public sealed class SearchMessagesQueryHandler( ChatDbContext db, ICurrentUser currentUser, - IMediator mediator) + IMediator mediator, + SqlServerFullTextAvailability fullText) : IQueryHandler> { public async ValueTask> Handle( @@ -46,22 +48,12 @@ public async ValueTask> Handle( return new List().AsReadOnly(); } - // Interpolation is parameterized (sanitized literal, not raw SQL); websearch_to_tsquery lets - // callers use natural syntax (quoted phrases, OR, -exclude) with no pre-processing. int offset = (page - 1) * pageSize; - FormattableString sql = $@" -SELECT m.* -FROM chat.""Messages"" m -WHERE m.""ChannelId"" = ANY({allowedChannelIds.ToArray()}) - AND m.""DeletedAtUtc"" IS NULL - AND m.""BodyTsv"" @@ websearch_to_tsquery('english', {query.Q}) -ORDER BY ts_rank(m.""BodyTsv"", websearch_to_tsquery('english', {query.Q})) DESC, - m.""Id"" DESC -LIMIT {pageSize} OFFSET {offset} -"; - var rows = await db.Messages - .FromSqlInterpolated(sql) + IQueryable matches = await BuildSearchQueryAsync( + query, allowedChannelIds, pageSize, offset, cancellationToken).ConfigureAwait(false); + + var rows = await matches .AsNoTracking() .Include(m => m.Attachments) .Include(m => m.Mentions) @@ -73,4 +65,97 @@ ORDER BY ts_rank(m.""BodyTsv"", websearch_to_tsquery('english', {query.Q})) DESC var resolved = await ChatAttachmentUrls.ResolveAsync(dtos, mediator, cancellationToken).ConfigureAwait(false); return resolved.AsReadOnly(); } + + /// + /// How far back the LIKE pass reaches to cover rows SQL Server's full-text crawl has not + /// indexed yet. Generous enough to absorb a slow crawl under load, short enough that the pass + /// stays a small scan within the caller's channels. + /// + private const int FullTextCrawlWindowMinutes = 5; + + /// + /// Builds the ranked, paged match query for the current provider. Ranking lives inside the raw + /// SQL on both full-text paths because the rank is not a column of and so + /// cannot survive LINQ composition. + /// + private async ValueTask> BuildSearchQueryAsync( + SearchMessagesQuery query, + List allowedChannelIds, + int pageSize, + int offset, + CancellationToken cancellationToken) + { + if (db.Database.IsNpgsql()) + { + // Interpolation is parameterized (sanitized literal, not raw SQL); websearch_to_tsquery lets + // callers use natural syntax (quoted phrases, OR, -exclude) with no pre-processing. + FormattableString postgres = $@" +SELECT m.* +FROM chat.""Messages"" m +WHERE m.""ChannelId"" = ANY({allowedChannelIds.ToArray()}) + AND m.""DeletedAtUtc"" IS NULL + AND m.""BodyTsv"" @@ websearch_to_tsquery('english', {query.Q}) +ORDER BY ts_rank(m.""BodyTsv"", websearch_to_tsquery('english', {query.Q})) DESC, + m.""Id"" DESC +LIMIT {pageSize} OFFSET {offset} +"; + return db.Messages.FromSqlInterpolated(postgres); + } + + if (db.Database.IsSqlServer() + && await fullText.IsAvailableAsync(db, cancellationToken).ConfigureAwait(false)) + { + // FREETEXTTABLE gives stemming and a relevance RANK, the closest analogue to + // websearch_to_tsquery + ts_rank — but SQL Server populates a full-text index + // asynchronously, so a message sent seconds ago is not in it yet. PostgreSQL's tsvector + // is a generated column and therefore synchronous, so relying on the index alone would + // make a just-sent message silently unsearchable here and nowhere else. + // + // So: ranked hits from the index, UNIONed with a plain LIKE pass over the crawl window + // the index cannot have reached yet. Those pending matches are by definition the newest, + // and are returned first (newest-first) ahead of the ranked ones, which is what someone + // searching for what they just typed expects. NOT EXISTS keeps a message that is in both + // sets from appearing twice. + // + // STRING_SPLIT keeps the channel list a single parameter rather than an interpolated + // id list. Derived tables rather than a CTE on purpose: EF wraps a FromSql query in a + // subselect to resolve the Includes, and `SELECT ... FROM (WITH ... SELECT ...) t` is + // not valid T-SQL — a CTE here fails at runtime with a 500. + string channelIds = string.Join(',', allowedChannelIds); + FormattableString sqlServer = $@" +SELECT m.* +FROM chat.[Messages] m +INNER JOIN ( + SELECT s.[Id], ft.[RANK] AS Rnk, 0 AS Pending + FROM chat.[Messages] s + INNER JOIN FREETEXTTABLE(chat.[Messages], [Body], {query.Q}) ft ON ft.[KEY] = s.[Id] + WHERE s.[ChannelId] IN (SELECT CAST(value AS uniqueidentifier) FROM STRING_SPLIT({channelIds}, ',')) + AND s.[DeletedAtUtc] IS NULL + UNION ALL + SELECT s.[Id], 0 AS Rnk, 1 AS Pending + FROM chat.[Messages] s + WHERE s.[ChannelId] IN (SELECT CAST(value AS uniqueidentifier) FROM STRING_SPLIT({channelIds}, ',')) + AND s.[DeletedAtUtc] IS NULL + AND s.[CreatedAtUtc] > DATEADD(minute, {-FullTextCrawlWindowMinutes}, SYSUTCDATETIME()) + AND s.[Body] LIKE {'%' + query.Q + '%'} + AND NOT EXISTS ( + SELECT 1 FROM FREETEXTTABLE(chat.[Messages], [Body], {query.Q}) ft2 + WHERE ft2.[KEY] = s.[Id] + ) +) h ON h.[Id] = m.[Id] +ORDER BY h.Pending DESC, h.Rnk DESC, m.[IdSort] DESC +OFFSET {offset} ROWS FETCH NEXT {pageSize} ROWS ONLY +"; + return db.Messages.FromSqlInterpolated(sqlServer); + } + + // No full-text index available: a portable substring scan, ordered newest-first because + // there is no relevance score to rank by. Composed in LINQ so it works on any provider. + return db.Messages + .Where(m => allowedChannelIds.Contains(m.ChannelId) && m.DeletedAtUtc == null) + .WhereLike(db.Database, $"%{query.Q}%", m => m.Body) + .OrderByDescending(m => m.Id) + .Skip(offset) + .Take(pageSize); + } } diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Search/SqlServerFullTextAvailability.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Search/SqlServerFullTextAvailability.cs new file mode 100644 index 0000000000..bce06f3e04 --- /dev/null +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Search/SqlServerFullTextAvailability.cs @@ -0,0 +1,55 @@ +using FSH.Modules.Chat.Data; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Chat.Features.v1.Search; + +/// +/// Caches whether the SQL Server instance actually has a full-text index on chat.Messages. +/// +/// +/// The migration only creates the full-text catalog and index when +/// SERVERPROPERTY('IsFullTextInstalled') is 1, so an instance without the Full-Text Search +/// feature migrates cleanly but has no index to query. Probing once per process lets message search +/// fall back to a LIKE scan there instead of failing with "Cannot use a CONTAINS or FREETEXT +/// predicate on table 'Messages' because it is not full-text indexed". +/// +public sealed class SqlServerFullTextAvailability +{ + private const int Unknown = 0; + private const int Available = 1; + private const int Unavailable = 2; + + // Deliberately lock-free: the probe is idempotent and cheap, so a race that runs it twice on + // startup is harmless and cheaper than holding a disposable lock for the process lifetime. + private int _state = Unknown; + + /// + /// Returns whether chat.Messages carries a full-text index, probing the catalog views on + /// first call and caching the answer for the lifetime of the process. + /// + public async ValueTask IsAvailableAsync(ChatDbContext db, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(db); + + int state = Volatile.Read(ref _state); + if (state != Unknown) + { + return state == Available; + } + + int found = await db.Database + .SqlQueryRaw( + """ + SELECT CASE WHEN EXISTS ( + SELECT 1 FROM sys.fulltext_indexes + WHERE object_id = OBJECT_ID('chat.Messages') + ) THEN 1 ELSE 0 END AS Value + """) + .SingleAsync(cancellationToken) + .ConfigureAwait(false); + + bool available = found == 1; + Volatile.Write(ref _state, available ? Available : Unavailable); + return available; + } +} diff --git a/src/Modules/Files/Modules.Files/Data/Configurations/FileAssetConfiguration.cs b/src/Modules/Files/Modules.Files/Data/Configurations/FileAssetConfiguration.cs index f16d21715c..fabcb0bb8f 100644 --- a/src/Modules/Files/Modules.Files/Data/Configurations/FileAssetConfiguration.cs +++ b/src/Modules/Files/Modules.Files/Data/Configurations/FileAssetConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Files.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -42,7 +43,7 @@ public void Configure(EntityTypeBuilder builder) // a subsequent upload that happens to choose the same path (rare, but possible). builder.HasIndex(x => x.StorageKey) .IsUnique() - .HasFilter("\"IsDeleted\" = FALSE") + .HasNotDeletedFilter() .HasDatabaseName("UX_FileAsset_StorageKey"); builder.Ignore(x => x.DomainEvents); diff --git a/src/Modules/Identity/Modules.Identity/Data/Configurations/GroupConfiguration.cs b/src/Modules/Identity/Modules.Identity/Data/Configurations/GroupConfiguration.cs index 2ea8b36df0..e0dfb373a8 100644 --- a/src/Modules/Identity/Modules.Identity/Data/Configurations/GroupConfiguration.cs +++ b/src/Modules/Identity/Modules.Identity/Data/Configurations/GroupConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using Finbuckle.MultiTenant.EntityFrameworkCore.Extensions; using FSH.Modules.Identity.Domain; using Microsoft.EntityFrameworkCore; @@ -42,7 +43,7 @@ public void Configure(EntityTypeBuilder builder) builder .Property(g => g.CreatedOnUtc) .HasColumnName("CreatedAt") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); + .HasUtcNowDefault(); builder .Property(g => g.LastModifiedOnUtc) diff --git a/src/Modules/Identity/Modules.Identity/Data/Configurations/PasswordHistoryConfiguration.cs b/src/Modules/Identity/Modules.Identity/Data/Configurations/PasswordHistoryConfiguration.cs index b046411e9f..e62899e898 100644 --- a/src/Modules/Identity/Modules.Identity/Data/Configurations/PasswordHistoryConfiguration.cs +++ b/src/Modules/Identity/Modules.Identity/Data/Configurations/PasswordHistoryConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Identity.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -25,7 +26,7 @@ public void Configure(EntityTypeBuilder builder) builder .Property(ph => ph.CreatedAt) - .HasDefaultValueSql("CURRENT_TIMESTAMP"); + .HasUtcNowDefault(); // Configure the foreign key relationship builder diff --git a/src/Modules/Identity/Modules.Identity/Data/Configurations/UserGroupConfiguration.cs b/src/Modules/Identity/Modules.Identity/Data/Configurations/UserGroupConfiguration.cs index 5420191899..c02555a7e3 100644 --- a/src/Modules/Identity/Modules.Identity/Data/Configurations/UserGroupConfiguration.cs +++ b/src/Modules/Identity/Modules.Identity/Data/Configurations/UserGroupConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using Finbuckle.MultiTenant.EntityFrameworkCore.Extensions; using FSH.Modules.Identity.Domain; using Microsoft.EntityFrameworkCore; @@ -28,7 +29,7 @@ public void Configure(EntityTypeBuilder builder) builder .Property(ug => ug.AddedAt) - .HasDefaultValueSql("CURRENT_TIMESTAMP"); + .HasUtcNowDefault(); builder .HasOne(ug => ug.User) diff --git a/src/Modules/Identity/Modules.Identity/Data/Configurations/UserSessionConfiguration.cs b/src/Modules/Identity/Modules.Identity/Data/Configurations/UserSessionConfiguration.cs index c190abae60..231ad3bba2 100644 --- a/src/Modules/Identity/Modules.Identity/Data/Configurations/UserSessionConfiguration.cs +++ b/src/Modules/Identity/Modules.Identity/Data/Configurations/UserSessionConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Identity.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -64,7 +65,7 @@ public void Configure(EntityTypeBuilder builder) builder .Property(s => s.CreatedAt) - .HasDefaultValueSql("CURRENT_TIMESTAMP"); + .HasUtcNowDefault(); builder .HasOne(s => s.User) diff --git a/src/Modules/Identity/Modules.Identity/Data/IdentityDbContext.cs b/src/Modules/Identity/Modules.Identity/Data/IdentityDbContext.cs index 3af3cbe048..010e15af70 100644 --- a/src/Modules/Identity/Modules.Identity/Data/IdentityDbContext.cs +++ b/src/Modules/Identity/Modules.Identity/Data/IdentityDbContext.cs @@ -1,5 +1,6 @@ using Finbuckle.MultiTenant.Abstractions; using Finbuckle.MultiTenant.Identity.EntityFrameworkCore; +using FSH.Framework.Persistence.Providers; using FSH.Framework.Persistence; using FSH.Framework.Shared.Multitenancy; using FSH.Framework.Shared.Persistence; @@ -64,6 +65,17 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ApplyTenantIsolationByDefault(); } + /// + /// This context does not derive from BaseDbContext, so it registers the framework's provider + /// conventions itself — without them the portable column intent is never resolved. + /// + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + base.ConfigureConventions(configurationBuilder); + configurationBuilder.AddHeroProviderConventions(DbProviderResolver.FromEfProviderName(Database.ProviderName)); + } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (!string.IsNullOrWhiteSpace(TenantInfo?.ConnectionString)) diff --git a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs index 1f41f867a6..93483bbfe8 100644 --- a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Core.Context; using FSH.Framework.Core.Exceptions; @@ -146,10 +147,12 @@ public async Task> GetUserSessionsForAdminAsync( if (!string.IsNullOrWhiteSpace(search)) { string term = search.Trim(); - q = q.Where(s => - (s.User != null && s.User.UserName != null && EF.Functions.ILike(s.User.UserName, $"%{term}%")) - || (s.User != null && s.User.Email != null && EF.Functions.ILike(s.User.Email, $"%{term}%")) - || (s.IpAddress != null && EF.Functions.ILike(s.IpAddress, $"%{term}%"))); + q = q.WhereSearch( + _db.Database, + term, + s => s.User!.UserName, + s => s.User!.Email, + s => s.IpAddress); } long total = await q.LongCountAsync(cancellationToken); diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/AppTenantInfoConfiguration.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/AppTenantInfoConfiguration.cs index 18de72c0fe..6d13b38206 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/AppTenantInfoConfiguration.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/AppTenantInfoConfiguration.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using FSH.Framework.Persistence.Providers; using FSH.Framework.Shared.Multitenancy; using FSH.Framework.Shared.Quota; using Microsoft.EntityFrameworkCore; @@ -24,7 +25,7 @@ public void Configure(EntityTypeBuilder builder) ? new Dictionary() : JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new Dictionary()) - .HasColumnType("jsonb") + .HasJsonColumn() .Metadata.SetValueComparer(new ValueComparer>( (a, b) => ReferenceEquals(a, b) || (a != null && b != null && a.SequenceEqual(b)), v => v.Aggregate(0, (h, kv) => HashCode.Combine(h, (int)kv.Key, kv.Value.GetHashCode())), diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContext.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContext.cs index 5450881f6c..adedd7109f 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContext.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContext.cs @@ -1,4 +1,5 @@ using Finbuckle.MultiTenant.EntityFrameworkCore.Stores; +using FSH.Framework.Persistence.Providers; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Multitenancy.Domain; using FSH.Modules.Multitenancy.Provisioning; @@ -31,4 +32,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfigurationsFromAssembly(typeof(TenantDbContext).Assembly); } + + /// + /// This context does not derive from BaseDbContext, so it registers the framework's provider + /// conventions itself — without them the portable column intent is never resolved. + /// + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + base.ConfigureConventions(configurationBuilder); + configurationBuilder.AddHeroProviderConventions(DbProviderResolver.FromEfProviderName(Database.ProviderName)); + } } \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContextFactory.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContextFactory.cs index 3aff4e8f55..2361f3cb59 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContextFactory.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContextFactory.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Shared.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; @@ -6,6 +7,14 @@ namespace FSH.Modules.Multitenancy.Data; public sealed class TenantDbContextFactory : IDesignTimeDbContextFactory { + /// + /// SQL Server compatibility level the MSSQL provider targets — must match + /// OptionsBuilderExtensions, or migrations scaffolded here would use + /// nvarchar(max) for JSON columns while the running app expects the native + /// json type. + /// + private const int MssqlCompatibilityLevel = 170; + public TenantDbContext CreateDbContext(string[] args) { // Design-time factory: read configuration (appsettings + env vars) to decide provider and connection. @@ -16,24 +25,41 @@ public TenantDbContext CreateDbContext(string[] args) .AddEnvironmentVariables() .Build(); - var provider = configuration["DatabaseOptions:Provider"] ?? "POSTGRESQL"; - var connectionString = configuration["DatabaseOptions:ConnectionString"] - ?? "Host=localhost;Database=fsh-tenant;Username=postgres;Password=postgres"; + var provider = configuration["DatabaseOptions:Provider"] ?? DbProviders.PostgreSQL; var migrationsAssembly = configuration["DatabaseOptions:MigrationsAssembly"] ?? "FSH.Starter.Migrations.PostgreSQL"; var optionsBuilder = new DbContextOptionsBuilder(); + var configured = configuration["DatabaseOptions:ConnectionString"]; + switch (provider.ToUpperInvariant()) { - case "POSTGRESQL": + case DbProviders.PostgreSQL: + var postgres = configured + ?? "Host=localhost;Database=fsh-tenant;Username=postgres;Password=postgres"; optionsBuilder.UseNpgsql( - connectionString, + postgres, b => b.MigrationsAssembly(migrationsAssembly)); break; + + case DbProviders.MSSQL: + // Trusted connection by default: scaffolding never opens a connection, and this + // avoids shipping a credential literal for an instance that does not exist. + var sqlServer = configured + ?? "Server=localhost;Database=fsh-tenant;Trusted_Connection=True;TrustServerCertificate=True"; + optionsBuilder.UseSqlServer( + sqlServer, + b => + { + b.MigrationsAssembly(migrationsAssembly); + b.UseCompatibilityLevel(MssqlCompatibilityLevel); + }); + break; + default: throw new NotSupportedException($"Database provider '{provider}' is not supported for TenantDbContext migrations."); } return new TenantDbContext(optionsBuilder.Options); } -} \ No newline at end of file +} diff --git a/src/Modules/Notifications/Modules.Notifications/Data/Configurations/NotificationConfiguration.cs b/src/Modules/Notifications/Modules.Notifications/Data/Configurations/NotificationConfiguration.cs index 5690baf97f..3e6f8ded5b 100644 --- a/src/Modules/Notifications/Modules.Notifications/Data/Configurations/NotificationConfiguration.cs +++ b/src/Modules/Notifications/Modules.Notifications/Data/Configurations/NotificationConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Notifications.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -18,7 +19,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Body).HasMaxLength(1024); builder.Property(x => x.Link).HasMaxLength(512); builder.Property(x => x.Source).HasMaxLength(64).IsRequired(); - builder.Property(x => x.MetadataJson).HasColumnType("jsonb").IsRequired(); + builder.Property(x => x.MetadataJson).HasJsonColumn().IsRequired(); builder.Property(x => x.CreatedAtUtc).IsRequired(); builder.Property(x => x.ReadAtUtc); diff --git a/src/Modules/Tickets/Modules.Tickets/Data/Configurations/TicketConfiguration.cs b/src/Modules/Tickets/Modules.Tickets/Data/Configurations/TicketConfiguration.cs index fdfc0644a9..4fad38cb13 100644 --- a/src/Modules/Tickets/Modules.Tickets/Data/Configurations/TicketConfiguration.cs +++ b/src/Modules/Tickets/Modules.Tickets/Data/Configurations/TicketConfiguration.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Modules.Tickets.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -15,7 +16,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Number).IsRequired().HasMaxLength(32); // Effectively unique per (TenantId, Number) since Finbuckle adds TenantId; filtered on // IsDeleted so soft-deleted ticket numbers don't conflict with new ones. - builder.HasIndex(x => x.Number).IsUnique().HasFilter("\"IsDeleted\" = FALSE"); + builder.HasIndex(x => x.Number).IsUnique().HasNotDeletedFilter(); builder.Property(x => x.Title).IsRequired().HasMaxLength(160); builder.Property(x => x.Description).HasMaxLength(4096); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/SearchTickets/SearchTicketsQueryHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/SearchTickets/SearchTicketsQueryHandler.cs index 93429bf1ce..bb8eb77e67 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/SearchTickets/SearchTicketsQueryHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/SearchTickets/SearchTicketsQueryHandler.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Persistence.Providers; using FSH.Framework.Shared.Persistence; using FSH.Modules.Tickets.Contracts.Dtos; using FSH.Modules.Tickets.Contracts.v1.Tickets; @@ -39,10 +40,7 @@ public async ValueTask> Handle(SearchTicketsQuery query if (!string.IsNullOrWhiteSpace(query.Search)) { string term = query.Search.Trim(); - q = q.Where(t => - EF.Functions.ILike(t.Title, $"%{term}%") || - EF.Functions.ILike(t.Number, $"%{term}%") || - (t.Description != null && EF.Functions.ILike(t.Description, $"%{term}%"))); + q = q.WhereSearch(dbContext.Database, term, t => t.Title, t => t.Number, t => t.Description); } q = ApplySort(q, query.SortBy, query.SortDir); diff --git a/src/Tests/Architecture.Tests/Architecture.Tests.csproj b/src/Tests/Architecture.Tests/Architecture.Tests.csproj index 14572d8b25..08290ee869 100644 --- a/src/Tests/Architecture.Tests/Architecture.Tests.csproj +++ b/src/Tests/Architecture.Tests/Architecture.Tests.csproj @@ -8,6 +8,11 @@ $(NoWarn);CA1515;CA1861;CA1707;CA1307;S125 + + + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -40,5 +45,6 @@ + diff --git a/src/Tests/Architecture.Tests/MigrationDriftTests.cs b/src/Tests/Architecture.Tests/MigrationDriftTests.cs new file mode 100644 index 0000000000..759a7d4b2f --- /dev/null +++ b/src/Tests/Architecture.Tests/MigrationDriftTests.cs @@ -0,0 +1,303 @@ +using System.Globalization; +using System.Reflection; +using FSH.Framework.Shared.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Shouldly; +using Xunit; +using Xunit.Abstractions; + +namespace Architecture.Tests; + +/// +/// Catches an entity change that got a migration for one database provider but not the other. +/// +/// +/// +/// With one migrations project per provider, forgetting the second one fails nothing: the build +/// passes, the tests pass, the API starts. The forgotten provider just deploys against a stale +/// schema until something breaks in production. +/// +/// +/// The check is per-provider drift — the live model against that provider's own snapshot, +/// which is the in-process equivalent of dotnet ef migrations has-pending-model-changes. +/// Comparing the two snapshots against each other would not work: the models differ legitimately +/// (SQL Server adds the IdSort shadow property and drops the trigram/JSON indexes), and the +/// migration histories are not comparable at all (51 historical PostgreSQL migrations vs 13 +/// consolidated MSSQL ones). +/// +/// +/// Maintaining both providers is not mandatory. A provider listed in +/// FshMaintainedDbProviders (see src/Directory.Build.props) fails the build when it +/// drifts; any other provider only reports it. That way a project that has settled on one engine is +/// never blocked by migrations for an engine it does not use. +/// +/// +public class MigrationDriftTests(ITestOutputHelper output) +{ + private const string MaintainedProvidersMetadataKey = "FshMaintainedDbProviders"; + private const string MaintainedProvidersEnvironmentVariable = "FSH_MIGRATIONS_PROVIDERS"; + + private static readonly string[] AllProviders = [DbProviders.PostgreSQL, DbProviders.MSSQL]; + + [Fact] + public void Maintained_Providers_Should_Have_No_Pending_Model_Changes() + { + var maintained = MaintainedProviders(); + var violations = new List(); + + foreach (string provider in maintained) + { + violations.AddRange(DriftFor(provider)); + } + + violations.ShouldBeEmpty( + "A model change is missing its migration. Generate it for the provider(s) below, or narrow " + + $"FshMaintainedDbProviders in src/Directory.Build.props if this project no longer keeps that " + + $"engine up to date.\n {string.Join("\n ", violations)}"); + } + + [Fact] + public void Unmaintained_Providers_Should_Report_Drift_Without_Failing() + { + var maintained = MaintainedProviders(); + var advisory = AllProviders.Except(maintained, StringComparer.Ordinal).ToList(); + + // The invariant that makes this test meaningful: a provider is either enforced by + // Maintained_Providers_Should_Have_No_Pending_Model_Changes or reported here, never both and + // never neither — otherwise a provider could silently escape the guard entirely. + advisory.Concat(maintained).OrderBy(p => p, StringComparer.Ordinal) + .ShouldBe(AllProviders.OrderBy(p => p, StringComparer.Ordinal)); + + var reported = new List(); + + foreach (string provider in advisory) + { + foreach (string drift in DriftFor(provider)) + { + // Advisory on purpose: this provider is not maintained here, so a stale migration + // must not break the build. + output.WriteLine($"[migration-drift] {drift}"); + reported.Add(drift); + } + } + + Report(reported); + } + + /// + /// Publishes advisory drift where someone will actually see it. + /// + /// + /// alone is not enough: for a passing test it only shows under + /// --logger "console;verbosity=detailed", which CI does not use, so the warning would be + /// invisible exactly where it matters. The job summary needs no change to the workflow. + /// + private static void Report(List drift) + { + if (drift.Count == 0) + { + return; + } + + string? summaryPath = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY"); + if (string.IsNullOrWhiteSpace(summaryPath)) + { + return; + } + + var summary = new System.Text.StringBuilder() + .AppendLine("### ⚠️ Migration drift on an unmaintained provider") + .AppendLine() + .AppendLine("These migrations are behind the model. Not fatal — this provider is not listed in") + .AppendLine("`FshMaintainedDbProviders` — but the schema will be stale if you ever deploy it.") + .AppendLine(); + + foreach (string item in drift) + { + summary.Append("- ").AppendLine(item); + } + + File.AppendAllText(summaryPath, summary.ToString()); + } + + [Fact] + public void Every_Context_Should_Have_A_Snapshot_In_Both_Providers() + { + var postgres = SnapshotContextNames(DbProviders.PostgreSQL); + var mssql = SnapshotContextNames(DbProviders.MSSQL); + + var missingInMssql = postgres.Except(mssql, StringComparer.Ordinal).ToList(); + var missingInPostgres = mssql.Except(postgres, StringComparer.Ordinal).ToList(); + + var gaps = missingInMssql.Select(c => $"{c}: no snapshot in the MSSQL migrations project") + .Concat(missingInPostgres.Select(c => $"{c}: no snapshot in the PostgreSQL migrations project")) + .ToList(); + + if (gaps.Count == 0) + { + return; + } + + // Only a hard failure while both engines are maintained. With one engine, a context having + // migrations for that engine alone is the expected state, not a defect. + if (MaintainedProviders().Count == AllProviders.Length) + { + gaps.ShouldBeEmpty( + "A DbContext has migrations for only one provider. A new module needs a folder in BOTH " + + $"migrations projects.\n {string.Join("\n ", gaps)}"); + } + + foreach (string gap in gaps) + { + output.WriteLine($"[migration-drift] {gap}"); + } + } + + [Fact] + public void The_Guard_Should_Actually_See_Both_Providers_And_Every_Context() + { + // Without this the tests above pass vacuously the moment assembly discovery or the snapshot + // reflection stops finding anything — which is exactly when they are most needed. + foreach (string provider in AllProviders) + { + var contexts = SnapshotContextTypes(provider); + + contexts.Count.ShouldBeGreaterThanOrEqualTo( + 11, + $"expected at least 11 DbContext snapshots in {ProviderDbContextFactory.MigrationsAssemblyFor(provider)}, " + + $"found {contexts.Count}"); + } + + // The three contexts that do not derive from BaseDbContext are the ones most likely to fall + // out of a reflection-based sweep, so assert them by name. + var names = SnapshotContextNames(DbProviders.PostgreSQL); + names.ShouldContain("IdentityDbContext"); + names.ShouldContain("TenantDbContext"); + names.ShouldContain("BillingDbContext"); + } + + /// + /// Reports, per context, whether 's migrations are behind the model. + /// + private static List DriftFor(string provider) + { + var drift = new List(); + string migrationsAssembly = ProviderDbContextFactory.MigrationsAssemblyFor(provider); + + foreach (Type contextType in SnapshotContextTypes(provider)) + { + using DbContext context = ProviderDbContextFactory.Create(contextType, provider); + + var snapshot = context.GetService().ModelSnapshot; + if (snapshot is null) + { + drift.Add( + $"{contextType.Name} [{provider}]: no ModelSnapshot found in {migrationsAssembly}."); + continue; + } + + if (context.Database.HasPendingModelChanges()) + { + drift.Add( + $"{contextType.Name} [{provider}]: model has changes with no migration. Run: " + + MigrationCommandFor(contextType, provider)); + } + } + + return drift; + } + + private static string MigrationCommandFor(Type contextType, string provider) + { + string migrationsAssembly = ProviderDbContextFactory.MigrationsAssemblyFor(provider); + string project = provider == DbProviders.MSSQL + ? "src/Host/FSH.Starter.Migrations.MSSQL" + : "src/Host/FSH.Starter.Migrations.PostgreSQL"; + + // MSSQL scaffolding needs the env vars, or the design-time model is built for PostgreSQL and + // PostgreSQL DDL lands in the MSSQL project. + string prefix = provider == DbProviders.MSSQL + ? "DatabaseOptions__Provider=MSSQL " + + $"DatabaseOptions__MigrationsAssembly={migrationsAssembly} " + + "DatabaseOptions__ConnectionString='Server=localhost,1433;Database=fsh;User Id=sa;Password=…;TrustServerCertificate=True' " + : string.Empty; + + return $"{prefix}dotnet ef migrations add --project {project} " + + $"--startup-project src/Host/FSH.Starter.Api --context {contextType.Name} --output-dir "; + } + + /// + /// The DbContext types that have a snapshot in 's migrations assembly. + /// + /// + /// Snapshots are the authoritative answer to "which context has migrations for which provider", + /// and each carries [DbContext(typeof(X))]. Enumerating them also covers + /// EventingDbContext, which lives in a framework assembly that + /// 's FSH.Modules.* sweep does not reach. + /// + private static List SnapshotContextTypes(string provider) + { + Assembly assembly = LoadMigrationsAssembly(provider); + + return assembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false } && typeof(ModelSnapshot).IsAssignableFrom(t)) + .Select(t => t.GetCustomAttribute()?.ContextType) + .Where(t => t is not null) + .Select(t => t!) + .DistinctBy(t => t.FullName, StringComparer.Ordinal) + .OrderBy(t => t.Name, StringComparer.Ordinal) + .ToList(); + } + + private static List SnapshotContextNames(string provider) => + SnapshotContextTypes(provider).Select(t => t.Name).ToList(); + + private static Assembly LoadMigrationsAssembly(string provider) + { + string name = ProviderDbContextFactory.MigrationsAssemblyFor(provider); + string path = Path.Combine(AppContext.BaseDirectory, $"{name}.dll"); + + if (!File.Exists(path)) + { + throw new InvalidOperationException( + $"{name}.dll is not in the test output. Architecture.Tests must reference both migrations " + + "projects for the migration guard to work."); + } + + return Assembly.Load(AssemblyName.GetAssemblyName(path)); + } + + /// + /// Which providers this project keeps migrations for: env var, then the build-time knob, then both. + /// + private static List MaintainedProviders() + { + string? configured = Environment.GetEnvironmentVariable(MaintainedProvidersEnvironmentVariable); + + configured ??= typeof(MigrationDriftTests).Assembly + .GetCustomAttributes() + .FirstOrDefault(a => string.Equals(a.Key, MaintainedProvidersMetadataKey, StringComparison.Ordinal)) + ?.Value; + + if (string.IsNullOrWhiteSpace(configured) || configured.Equals("both", StringComparison.OrdinalIgnoreCase)) + { + return [.. AllProviders]; + } + + var selected = configured + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(p => p.ToUpperInvariant()) + .Where(p => AllProviders.Contains(p, StringComparer.Ordinal)) + .ToList(); + + // An unrecognised value must not silently disable the guard. + return selected.Count > 0 + ? selected + : throw new InvalidOperationException(string.Create( + CultureInfo.InvariantCulture, + $"'{configured}' is not a valid {MaintainedProvidersMetadataKey} value. Use 'both', " + + $"'{DbProviders.PostgreSQL}', '{DbProviders.MSSQL}', or a comma-separated list.")); + } +} diff --git a/src/Tests/Architecture.Tests/ProviderConventionRegistrationTests.cs b/src/Tests/Architecture.Tests/ProviderConventionRegistrationTests.cs new file mode 100644 index 0000000000..f205d8695a --- /dev/null +++ b/src/Tests/Architecture.Tests/ProviderConventionRegistrationTests.cs @@ -0,0 +1,66 @@ +using System.Reflection; +using FSH.Framework.Persistence.Context; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; + +namespace Architecture.Tests; + +/// +/// Every DbContext must have the framework's provider conventions applied, or its portable column +/// and index intent silently never resolves. +/// +/// +/// +/// Contexts deriving from inherit the registration. The three that +/// cannot — IdentityDbContext, TenantDbContext and BillingDbContext, each with +/// its own EF base class — must override ConfigureConventions themselves. +/// +/// +/// This guards a failure mode that is invisible at build time and easy to miss in review: a context +/// without the conventions produces text columns where the model asked for JSON, drops every +/// partial-index filter, and leaks Fsh:* annotations into the migrations snapshot. +/// +/// +public class ProviderConventionRegistrationTests +{ + private static List NonBaseDbContexts() => + ModuleAssemblyDiscovery.GetModuleAssemblies() + .SelectMany(a => a.GetTypes()) + .Where(t => t is { IsClass: true, IsAbstract: false } + && typeof(DbContext).IsAssignableFrom(t) + && !typeof(BaseDbContext).IsAssignableFrom(t)) + .ToList(); + + private static bool DeclaresConfigureConventions(Type context) => + context.GetMethod( + "ConfigureConventions", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly, + [typeof(ModelConfigurationBuilder)]) is not null; + + [Fact] + public void Every_DbContext_Should_Derive_From_BaseDbContext_Or_Register_Provider_Conventions() + { + var offenders = NonBaseDbContexts() + .Where(c => !DeclaresConfigureConventions(c)) + .Select(c => c.FullName!) + .ToList(); + + offenders.ShouldBeEmpty( + "these DbContexts neither derive from BaseDbContext nor override ConfigureConventions, so " + + "AddHeroProviderConventions never runs for them: " + string.Join(", ", offenders)); + } + + [Fact] + public void The_Guard_Should_Actually_See_The_Contexts_It_Polices() + { + // Without this, the test above passes vacuously if assembly discovery or the reflection + // lookup ever stops finding anything — which is exactly when it is most needed. + var names = NonBaseDbContexts().Select(c => c.Name).ToList(); + + names.ShouldContain("IdentityDbContext"); + names.ShouldContain("TenantDbContext"); + names.ShouldContain("BillingDbContext"); + NonBaseDbContexts().ShouldAllBe(c => DeclaresConfigureConventions(c)); + } +} diff --git a/src/Tests/Architecture.Tests/ProviderDbContextFactory.cs b/src/Tests/Architecture.Tests/ProviderDbContextFactory.cs new file mode 100644 index 0000000000..db3d6cf0ed --- /dev/null +++ b/src/Tests/Architecture.Tests/ProviderDbContextFactory.cs @@ -0,0 +1,118 @@ +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Persistence; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Shared.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace Architecture.Tests; + +/// +/// Builds any of the framework's DbContexts against a chosen provider, without a database. +/// +/// +/// The model builds lazily on first access, so nothing here opens a connection — the connection +/// strings only have to parse. That is what lets a migration guard run in the Docker-free unit-test +/// job. +/// +internal static class ProviderDbContextFactory +{ + /// The migrations assembly that belongs to each provider. + public static string MigrationsAssemblyFor(string provider) => + provider == DbProviders.MSSQL + ? "FSH.Starter.Migrations.MSSQL" + : "FSH.Starter.Migrations.PostgreSQL"; + + /// + /// Constructs wired to . + /// + public static DbContext Create(Type dbContextType, string provider) + { + ArgumentNullException.ThrowIfNull(dbContextType); + + var optionsType = typeof(DbContextOptions<>).MakeGenericType(dbContextType); + var builderType = typeof(DbContextOptionsBuilder<>).MakeGenericType(dbContextType); + var builder = (DbContextOptionsBuilder)Activator.CreateInstance(builderType)!; + + // ConfigureHeroDatabase rather than a bare UseNpgsql/UseSqlServer: it is the only thing that + // sets the provider, the MigrationsAssembly AND (on MSSQL) UseCompatibilityLevel(170) + // together. Without the migrations assembly EF looks for the snapshot in the context's own + // assembly, finds none, and a drift check silently becomes meaningless. Without the + // compatibility level, JSON columns map to nvarchar(max) instead of the native json type and + // every JSON column reports phantom drift. + builder.ConfigureHeroDatabase( + provider, + UnreachableConnectionStringFor(provider), + MigrationsAssemblyFor(provider), + isDevelopment: false); + + var options = builder.Options; + + // Empty on purpose: BaseDbContext.OnConfiguring returns early when the tenant connection + // string is blank, so it never re-wires the provider we just configured. + var settings = Options.Create(new DatabaseOptions + { + Provider = provider, + ConnectionString = string.Empty, + MigrationsAssembly = MigrationsAssemblyFor(provider), + }); + + // Nine contexts take the BaseDbContext-shaped four-arg constructor; BillingDbContext and + // TenantDbContext derive from other EF base classes and take only their options. + var wideCtor = dbContextType.GetConstructor([ + typeof(IMultiTenantContextAccessor), + optionsType, + typeof(IOptions), + typeof(IHostEnvironment), + ]); + + if (wideCtor is not null) + { + return (DbContext)wideCtor.Invoke([ + new StubAccessor(), + options, + settings, + new StubEnvironment(), + ]); + } + + var optionsOnlyCtor = dbContextType.GetConstructor([optionsType]); + if (optionsOnlyCtor is not null) + { + return (DbContext)optionsOnlyCtor.Invoke([options]); + } + + // Fail loudly rather than skipping the context — a silently unchecked context is exactly the + // hole this guard exists to close. + throw new InvalidOperationException( + $"{dbContextType.Name} has neither the four-argument BaseDbContext constructor nor a " + + $"({optionsType.Name}) constructor, so the migration guard cannot build it. Add the new " + + "shape to ProviderDbContextFactory."); + } + + private static string UnreachableConnectionStringFor(string provider) => + provider == DbProviders.MSSQL + ? "Server=arch;Database=arch;Trusted_Connection=True;TrustServerCertificate=True" + : "Host=arch;Database=arch;Username=arch;Password=arch"; + + private sealed class StubAccessor : IMultiTenantContextAccessor + { + // IdentityDbContext dereferences TenantInfo in its constructor, so it must be non-null. + public IMultiTenantContext MultiTenantContext { get; set; } = + new MultiTenantContext( + new AppTenantInfo("arch", "arch", string.Empty, "arch@arch", "arch")); + + IMultiTenantContext IMultiTenantContextAccessor.MultiTenantContext => MultiTenantContext; + } + + private sealed class StubEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = "Development"; + public string ApplicationName { get; set; } = "arch"; + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = + new Microsoft.Extensions.FileProviders.NullFileProvider(); + } +} diff --git a/src/Tests/Framework.Tests/Persistence/AmbientDbTransactionRegistryTests.cs b/src/Tests/Framework.Tests/Persistence/AmbientDbTransactionRegistryTests.cs new file mode 100644 index 0000000000..66d89d97ea --- /dev/null +++ b/src/Tests/Framework.Tests/Persistence/AmbientDbTransactionRegistryTests.cs @@ -0,0 +1,56 @@ +using FSH.Framework.Persistence; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Shouldly; + +namespace Framework.Tests.Persistence; + +/// +/// Guards the silent-no-op failure mode of . +/// +/// +/// gives every member a default no-op implementation, so a +/// registry method whose signature does not match the interface still compiles and is simply never +/// invoked. The registry then stays empty, the outbox never enlists in the business transaction, +/// and the transactional-outbox guarantee is silently lost — which PostgreSQL masks, because Npgsql +/// associates commands with the connection's open transaction regardless. +/// +public class AmbientDbTransactionRegistryTests +{ + private static readonly string[] MustBeImplemented = + [ + "TransactionStarted", + "TransactionStartedAsync", + "TransactionUsed", + "TransactionUsedAsync", + "TransactionCommitted", + "TransactionCommittedAsync", + "TransactionRolledBack", + "TransactionRolledBackAsync", + "TransactionFailed", + "TransactionFailedAsync" + ]; + + [Fact] + public void Registry_Should_Actually_Implement_Every_Interceptor_Hook_It_Relies_On() + { + var map = typeof(AmbientDbTransactionRegistry).GetInterfaceMap(typeof(IDbTransactionInterceptor)); + + var notWiredUp = new List(); + for (int i = 0; i < map.InterfaceMethods.Length; i++) + { + string name = map.InterfaceMethods[i].Name; + if (!MustBeImplemented.Contains(name)) continue; + + // When a signature does not match, the interface's own default implementation is the + // target — meaning the registry's method is dead code. + if (map.TargetMethods[i].DeclaringType != typeof(AmbientDbTransactionRegistry)) + { + notWiredUp.Add(name); + } + } + + notWiredUp.ShouldBeEmpty( + "these IDbTransactionInterceptor hooks fall through to the interface default, so the " + + "registry never records transactions started via them: " + string.Join(", ", notWiredUp)); + } +} diff --git a/src/Tests/Framework.Tests/Persistence/ConnectionStringValidatorTests.cs b/src/Tests/Framework.Tests/Persistence/ConnectionStringValidatorTests.cs index 3ee680000a..ccd198116c 100644 --- a/src/Tests/Framework.Tests/Persistence/ConnectionStringValidatorTests.cs +++ b/src/Tests/Framework.Tests/Persistence/ConnectionStringValidatorTests.cs @@ -60,16 +60,18 @@ public void TryValidate_Should_HonorExplicitProviderOverride_When_ProvidedArgume #region Edge Cases [Fact] - public void TryValidate_Should_ReturnTrue_When_ProviderUnknown() + public void TryValidate_Should_ReturnFalse_When_ProviderUnknown() { - // Arrange — unknown provider falls through default arm without parsing. + // Arrange — an unsupported provider means nothing validated the string. Reporting success + // would let a typo'd DatabaseOptions:Provider sail past tenant creation and only fail on + // the first query. var sut = Build("SQLITE"); // Act var result = sut.TryValidate("any-string"); // Assert - result.ShouldBeTrue(); + result.ShouldBeFalse(); } [Fact] diff --git a/src/Tests/Framework.Tests/Persistence/ProviderConventionsTests.cs b/src/Tests/Framework.Tests/Persistence/ProviderConventionsTests.cs new file mode 100644 index 0000000000..d0b33c0153 --- /dev/null +++ b/src/Tests/Framework.Tests/Persistence/ProviderConventionsTests.cs @@ -0,0 +1,195 @@ +using FSH.Framework.Persistence.Providers; +using FSH.Framework.Shared.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Shouldly; + +namespace Framework.Tests.Persistence; + +/// +/// Model-level proof that portable column and index intent resolves to the right provider SQL. +/// Pure model building — no database, no Docker. +/// +public class ProviderConventionsTests +{ + private sealed class Widget + { + public Guid Id { get; set; } + public string Payload { get; set; } = default!; + public string Body { get; set; } = default!; + public string Config { get; set; } = default!; + public DateTime CreatedAtUtc { get; set; } + public DateTime? DeletedAtUtc { get; set; } + public bool IsDeleted { get; set; } + public bool IsPinned { get; set; } + public int Status { get; set; } + public string? Slug { get; set; } + public string? Source { get; set; } + } + + private sealed class ProbeDbContext(DbContextOptions options, string provider) : DbContext(options) + { + public DbSet Widgets => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + configurationBuilder.AddHeroProviderConventions(provider); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(b => + { + b.HasKey(x => x.Id); + b.Property(x => x.Payload).HasJsonColumn(); + b.Property(x => x.Config).HasJsonColumn().HasJsonDefaultEmptyObject(); + b.Property(x => x.Body).HasUnboundedTextColumn(); + b.Property(x => x.CreatedAtUtc).HasUtcNowDefault(); + + b.HasIndex(x => x.Slug).IsUnique().HasNotDeletedFilter(); + b.HasIndex(x => x.IsPinned).HasBoolFilter("IsPinned", true); + b.HasIndex(x => x.Status).HasEqualsFilter("Status", 3).HasNotDeletedFilter(); + b.HasIndex(x => x.DeletedAtUtc).HasNotNullFilter("DeletedAtUtc"); + + b.HasIndex(x => x.Source).AsTrigramSearchIndex().HasDatabaseName("IX_Widget_Source_trgm"); + b.HasIndex(x => x.Payload).AsJsonContainmentIndex().HasDatabaseName("IX_Widget_Payload_gin"); + }); + } + } + + private static IModel BuildModel(string provider) + { + var options = new DbContextOptionsBuilder(); + + if (provider == DbProviders.MSSQL) + { + options.UseSqlServer("Server=probe;Database=probe;Trusted_Connection=True"); + } + else + { + options.UseNpgsql("Host=probe;Database=probe;Username=probe;Password=probe"); + } + + using var context = new ProbeDbContext(options.Options, provider); + return context.Model; + } + + private static IEntityType WidgetEntity(string provider) => + BuildModel(provider).FindEntityType(typeof(Widget))!; + + private static string? ColumnType(string provider, string propertyName) => + WidgetEntity(provider).FindProperty(propertyName)!.GetColumnType(); + + private static string? FilterFor(string provider, string propertyName) => + WidgetEntity(provider).GetIndexes() + .Single(i => i.Properties.Count == 1 && i.Properties[0].Name == propertyName) + .GetFilter(); + + [Theory] + [InlineData(DbProviders.PostgreSQL, "jsonb")] + [InlineData(DbProviders.MSSQL, "json")] + public void HasJsonColumn_Should_Map_To_The_Providers_Native_Json_Type(string provider, string expected) + => ColumnType(provider, nameof(Widget.Payload)).ShouldBe(expected); + + [Theory] + [InlineData(DbProviders.PostgreSQL, "text")] + [InlineData(DbProviders.MSSQL, "nvarchar(max)")] + public void HasUnboundedTextColumn_Should_Map_To_The_Providers_Text_Type(string provider, string expected) + => ColumnType(provider, nameof(Widget.Body)).ShouldBe(expected); + + [Theory] + [InlineData(DbProviders.PostgreSQL, "'{}'::jsonb")] + [InlineData(DbProviders.MSSQL, "N'{}'")] + public void HasJsonDefaultEmptyObject_Should_Use_Provider_Syntax(string provider, string expected) + => WidgetEntity(provider).FindProperty(nameof(Widget.Config))!.GetDefaultValueSql().ShouldBe(expected); + + [Theory] + [InlineData(DbProviders.PostgreSQL, "CURRENT_TIMESTAMP")] + [InlineData(DbProviders.MSSQL, "SYSUTCDATETIME()")] + public void HasUtcNowDefault_Should_Use_Provider_Function(string provider, string expected) + => WidgetEntity(provider).FindProperty(nameof(Widget.CreatedAtUtc))!.GetDefaultValueSql().ShouldBe(expected); + + [Theory] + [InlineData(DbProviders.PostgreSQL, "\"IsDeleted\" = FALSE")] + [InlineData(DbProviders.MSSQL, "[IsDeleted] = 0")] + public void HasNotDeletedFilter_Should_Quote_And_Spell_Booleans_Per_Provider(string provider, string expected) + => FilterFor(provider, nameof(Widget.Slug)).ShouldBe(expected); + + [Theory] + [InlineData(DbProviders.PostgreSQL, "\"IsPinned\" = true")] + [InlineData(DbProviders.MSSQL, "[IsPinned] = 1")] + public void HasBoolFilter_Should_Preserve_The_Shipped_Postgres_Spelling(string provider, string expected) + => FilterFor(provider, nameof(Widget.IsPinned)).ShouldBe(expected); + + [Theory] + [InlineData(DbProviders.PostgreSQL, "\"Status\" = 3 AND \"IsDeleted\" = FALSE")] + [InlineData(DbProviders.MSSQL, "[Status] = 3 AND [IsDeleted] = 0")] + public void Filter_Terms_Should_Be_ANDed_In_Declaration_Order(string provider, string expected) + => FilterFor(provider, nameof(Widget.Status)).ShouldBe(expected); + + [Theory] + [InlineData(DbProviders.PostgreSQL, "\"DeletedAtUtc\" IS NOT NULL")] + [InlineData(DbProviders.MSSQL, "[DeletedAtUtc] IS NOT NULL")] + public void HasNotNullFilter_Should_Render_On_Both_Providers(string provider, string expected) + => FilterFor(provider, nameof(Widget.DeletedAtUtc)).ShouldBe(expected); + + [Theory] + [InlineData("IX_Widget_Source_trgm")] + [InlineData("IX_Widget_Payload_gin")] + public void Search_Indexes_Should_Become_Gin_Indexes_On_Postgres(string indexName) + { + // Only the index method is observable on the finalized read-model; Npgsql does not surface + // the operator class (gin_trgm_ops / jsonb_path_ops) as a readable annotation there. That + // half is covered by the migrations snapshot instead — see the has-pending-model-changes + // gate, which fails if either annotation stops being emitted. + IIndex index = WidgetEntity(DbProviders.PostgreSQL).GetIndexes() + .Single(i => i.GetDatabaseName() == indexName); + + index.FindAnnotation("Npgsql:IndexMethod")!.Value.ShouldBe("gin"); + } + + [Fact] + public void Search_Indexes_Should_Be_Removed_On_SqlServer() + { + // Neither shape is a legal regular index on SQL Server: one targets a `json` column, the + // other an nvarchar(max). Leaving them in the model would make the migration fail. + var names = WidgetEntity(DbProviders.MSSQL).GetIndexes().Select(i => i.GetDatabaseName()).ToList(); + + names.ShouldNotContain("IX_Widget_Source_trgm"); + names.ShouldNotContain("IX_Widget_Payload_gin"); + } + + [Fact] + public void DateTime_Properties_Should_Read_Back_As_Utc_On_SqlServer_Only() + { + // datetime2 carries no kind, so without a converter every timestamp the API serializes + // would lose its trailing Z. timestamptz already round-trips as Utc. + WidgetEntity(DbProviders.MSSQL).FindProperty(nameof(Widget.CreatedAtUtc))! + .GetValueConverter().ShouldNotBeNull(); + + WidgetEntity(DbProviders.PostgreSQL).FindProperty(nameof(Widget.CreatedAtUtc))! + .GetValueConverter().ShouldBeNull(); + } + + [Fact] + public void Intent_Annotations_Should_Not_Survive_Into_The_Model() + { + // A leftover Fsh:* annotation reaches the migrations snapshot, where EF must emit it as a + // C# literal. Anything non-primitive fails the scaffolder outright. + foreach (string provider in new[] { DbProviders.PostgreSQL, DbProviders.MSSQL }) + { + IEntityType entity = WidgetEntity(provider); + + entity.GetProperties() + .SelectMany(p => p.GetAnnotations()) + .Select(a => a.Name) + .ShouldNotContain(n => n.StartsWith("Fsh:", StringComparison.Ordinal)); + + entity.GetIndexes() + .SelectMany(i => i.GetAnnotations()) + .Select(a => a.Name) + .ShouldNotContain(n => n.StartsWith("Fsh:", StringComparison.Ordinal)); + } + } +} diff --git a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs index 4c2939c454..3776615f74 100644 --- a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs +++ b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs @@ -23,7 +23,6 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Testcontainers.Minio; -using Testcontainers.PostgreSql; namespace Integration.Middleware.Tests.Infrastructure; @@ -47,13 +46,7 @@ public sealed class MiddlewareWebApplicationFactory : WebApplicationFactory { - ["DatabaseOptions:Provider"] = "POSTGRESQL", - ["DatabaseOptions:ConnectionString"] = _postgres.GetConnectionString(), - ["DatabaseOptions:MigrationsAssembly"] = "FSH.Starter.Migrations.PostgreSQL", + ["DatabaseOptions:Provider"] = _database.Provider, + ["DatabaseOptions:ConnectionString"] = _database.GetConnectionString(), + ["DatabaseOptions:MigrationsAssembly"] = _database.MigrationsAssembly, ["CachingOptions:Redis"] = "", ["JwtOptions:Issuer"] = TestConstants.JwtIssuer, ["JwtOptions:Audience"] = TestConstants.JwtAudience, diff --git a/src/Tests/Integration.Middleware.Tests/Infrastructure/TestDatabase.cs b/src/Tests/Integration.Middleware.Tests/Infrastructure/TestDatabase.cs new file mode 100644 index 0000000000..ee01ccb64f --- /dev/null +++ b/src/Tests/Integration.Middleware.Tests/Infrastructure/TestDatabase.cs @@ -0,0 +1,126 @@ +using DotNet.Testcontainers.Containers; +using Testcontainers.MsSql; +using Testcontainers.PostgreSql; + +namespace Integration.Middleware.Tests.Infrastructure; + +/// +/// Picks the database container and matching DatabaseOptions for the provider under test. +/// +/// +/// +/// Selected by the FSH_TEST_DB_PROVIDER environment variable, defaulting to PostgreSQL so an +/// unqualified dotnet test behaves exactly as it did before SQL Server support existed. Set +/// it to MSSQL to run the same suite against SQL Server. +/// +/// +/// The SQL Server image is pinned to 2025 because the model maps JSON columns to the native +/// json type, which does not exist on 2019/2022 — the migrations simply will not apply there. +/// +/// +public sealed class TestDatabase +{ + public const string ProviderEnvironmentVariable = "FSH_TEST_DB_PROVIDER"; + + /// + /// Points the suite at an already-running server instead of starting a container. + /// + /// + /// Useful for capabilities a throwaway container does not have — the official + /// mcr.microsoft.com/mssql/server image ships without Full-Text Search, so the chat + /// search tests only exercise the LIKE fallback against it. Point this at an instance + /// with FTS installed to cover the FREETEXTTABLE path. Also lets CI reuse a service + /// container rather than paying for a nested one. + /// + public const string ConnectionEnvironmentVariable = "FSH_TEST_DB_CONNECTION"; + + private const string PostgresProvider = "POSTGRESQL"; + private const string MssqlProvider = "MSSQL"; + + private readonly IDatabaseContainer? _container; + private readonly string? _externalConnectionString; + + private TestDatabase( + IDatabaseContainer? container, + string? externalConnectionString, + string provider, + string migrationsAssembly) + { + _container = container; + _externalConnectionString = externalConnectionString; + Provider = provider; + MigrationsAssembly = migrationsAssembly; + } + + /// The DatabaseOptions:Provider value for the selected provider. + public string Provider { get; } + + /// The DatabaseOptions:MigrationsAssembly value for the selected provider. + public string MigrationsAssembly { get; } + + /// True when the suite is running against PostgreSQL. + public bool IsPostgres => Provider == PostgresProvider; + + /// The provider the suite is configured to run against, without starting a container. + public static string SelectedProvider => + (Environment.GetEnvironmentVariable(ProviderEnvironmentVariable) ?? PostgresProvider).ToUpperInvariant(); + + /// True when the suite is configured to run against PostgreSQL. + public static bool SelectedProviderIsPostgres => SelectedProvider == PostgresProvider; + + /// An externally provided server, or null to start a container. + private static string? ExternalConnectionString + { + get + { + string? value = Environment.GetEnvironmentVariable(ConnectionEnvironmentVariable); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + } + + /// + /// Builds the container for the selected provider. Call to run it. + /// + /// Database to create inside the container. + public static TestDatabase Create(string databaseName) + { + string migrationsAssembly = SelectedProvider == MssqlProvider + ? "FSH.Starter.Migrations.MSSQL" + : "FSH.Starter.Migrations.PostgreSQL"; + + if (ExternalConnectionString is { } external) + { + return new TestDatabase(null, external, SelectedProvider, migrationsAssembly); + } + + if (SelectedProvider == MssqlProvider) + { + return new TestDatabase( + new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest") + .WithAutoRemove(true) + .WithCleanUp(true) + .Build(), + null, + MssqlProvider, + migrationsAssembly); + } + + return new TestDatabase( + new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase(databaseName) + .WithUsername("postgres") + .WithPassword("integration_test_pwd") + .WithAutoRemove(true) + .WithCleanUp(true) + .Build(), + null, + PostgresProvider, + migrationsAssembly); + } + + public Task StartAsync() => _container?.StartAsync() ?? Task.CompletedTask; + + public ValueTask DisposeAsync() => _container?.DisposeAsync() ?? ValueTask.CompletedTask; + + public string GetConnectionString() => _externalConnectionString ?? _container!.GetConnectionString(); +} diff --git a/src/Tests/Integration.Middleware.Tests/Integration.Middleware.Tests.csproj b/src/Tests/Integration.Middleware.Tests/Integration.Middleware.Tests.csproj index 057056d088..8017859d65 100644 --- a/src/Tests/Integration.Middleware.Tests/Integration.Middleware.Tests.csproj +++ b/src/Tests/Integration.Middleware.Tests/Integration.Middleware.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs index ab8cfe3c65..2d5bc30f56 100644 --- a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs +++ b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs @@ -22,7 +22,6 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Testcontainers.Minio; -using Testcontainers.PostgreSql; namespace Integration.Tests.Infrastructure; @@ -33,13 +32,7 @@ public sealed class FshWebApplicationFactory : WebApplicationFactory, I private const string MinioBucket = "fsh-integration-test-uploads"; private static readonly SemaphoreSlim _migrationLock = new(1, 1); - private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:17-alpine") - .WithDatabase("fsh_integration_tests") - .WithUsername("postgres") - .WithPassword("integration_test_pwd") - .WithAutoRemove(true) - .WithCleanUp(true) - .Build(); + private readonly TestDatabase _database = TestDatabase.Create("fsh_integration_tests"); private readonly MinioContainer _minio = new MinioBuilder("minio/minio:latest") .WithUsername(MinioAccessKey) @@ -50,7 +43,7 @@ public sealed class FshWebApplicationFactory : WebApplicationFactory, I public async Task InitializeAsync() { - await Task.WhenAll(_postgres.StartAsync(), _minio.StartAsync()); + await Task.WhenAll(_database.StartAsync(), _minio.StartAsync()); await CreateMinioBucketAsync(); // Force host creation via the Server property (no leaked HttpClient) @@ -72,7 +65,7 @@ public async Task InitializeAsync() public new async Task DisposeAsync() { await base.DisposeAsync(); - await _postgres.DisposeAsync(); + await _database.DisposeAsync(); await _minio.DisposeAsync(); } @@ -113,9 +106,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { config.AddInMemoryCollection(new Dictionary { - ["DatabaseOptions:Provider"] = "POSTGRESQL", - ["DatabaseOptions:ConnectionString"] = _postgres.GetConnectionString(), - ["DatabaseOptions:MigrationsAssembly"] = "FSH.Starter.Migrations.PostgreSQL", + ["DatabaseOptions:Provider"] = _database.Provider, + ["DatabaseOptions:ConnectionString"] = _database.GetConnectionString(), + ["DatabaseOptions:MigrationsAssembly"] = _database.MigrationsAssembly, ["CachingOptions:Redis"] = "", ["JwtOptions:Issuer"] = TestConstants.JwtIssuer, ["JwtOptions:Audience"] = TestConstants.JwtAudience, diff --git a/src/Tests/Integration.Tests/Infrastructure/PostgresOnlyFactAttribute.cs b/src/Tests/Integration.Tests/Infrastructure/PostgresOnlyFactAttribute.cs new file mode 100644 index 0000000000..3068e1aab1 --- /dev/null +++ b/src/Tests/Integration.Tests/Infrastructure/PostgresOnlyFactAttribute.cs @@ -0,0 +1,39 @@ +using Xunit; + +namespace Integration.Tests.Infrastructure; + +/// +/// A fact that runs only when the suite is targeting PostgreSQL. +/// +/// +/// For tests that assert PostgreSQL-specific behaviour rather than application behaviour — the +/// FOR UPDATE SKIP LOCKED outbox claim, and the canonical jsonb::text rendering the +/// audit payload filters match against. The SQL Server equivalents are covered by their own tests; +/// see . +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class PostgresOnlyFactAttribute : FactAttribute +{ + public PostgresOnlyFactAttribute() + { + if (!TestDatabase.SelectedProviderIsPostgres) + { + Skip = $"PostgreSQL-specific behaviour; suite is running against {TestDatabase.SelectedProvider}."; + } + } +} + +/// +/// A fact that runs only when the suite is targeting SQL Server. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class SqlServerOnlyFactAttribute : FactAttribute +{ + public SqlServerOnlyFactAttribute() + { + if (TestDatabase.SelectedProviderIsPostgres) + { + Skip = $"SQL Server-specific behaviour; suite is running against {TestDatabase.SelectedProvider}."; + } + } +} diff --git a/src/Tests/Integration.Tests/Infrastructure/TestDatabase.cs b/src/Tests/Integration.Tests/Infrastructure/TestDatabase.cs new file mode 100644 index 0000000000..4f56814859 --- /dev/null +++ b/src/Tests/Integration.Tests/Infrastructure/TestDatabase.cs @@ -0,0 +1,140 @@ +using DotNet.Testcontainers.Containers; +using Testcontainers.MsSql; +using Testcontainers.PostgreSql; + +namespace Integration.Tests.Infrastructure; + +/// +/// Picks the database container and matching DatabaseOptions for the provider under test. +/// +/// +/// +/// Selected by the FSH_TEST_DB_PROVIDER environment variable, defaulting to PostgreSQL so an +/// unqualified dotnet test behaves exactly as it did before SQL Server support existed. Set +/// it to MSSQL to run the same suite against SQL Server. +/// +/// +/// The SQL Server image is pinned to 2025 because the model maps JSON columns to the native +/// json type, which does not exist on 2019/2022 — the migrations simply will not apply there. +/// +/// +public sealed class TestDatabase +{ + public const string ProviderEnvironmentVariable = "FSH_TEST_DB_PROVIDER"; + + /// + /// Points the suite at an already-running server instead of starting a container. + /// + /// + /// Useful for capabilities a throwaway container does not have — the official + /// mcr.microsoft.com/mssql/server image ships without Full-Text Search, so the chat + /// search tests only exercise the LIKE fallback against it. Point this at an instance + /// with FTS installed to cover the FREETEXTTABLE path. Also lets CI reuse a service + /// container rather than paying for a nested one. + /// + public const string ConnectionEnvironmentVariable = "FSH_TEST_DB_CONNECTION"; + + private const string PostgresProvider = "POSTGRESQL"; + private const string MssqlProvider = "MSSQL"; + + private readonly IDatabaseContainer? _container; + private readonly string? _externalConnectionString; + + private TestDatabase( + IDatabaseContainer? container, + string? externalConnectionString, + string provider, + string migrationsAssembly) + { + _container = container; + _externalConnectionString = externalConnectionString; + Provider = provider; + MigrationsAssembly = migrationsAssembly; + } + + /// The DatabaseOptions:Provider value for the selected provider. + public string Provider { get; } + + /// The DatabaseOptions:MigrationsAssembly value for the selected provider. + public string MigrationsAssembly { get; } + + /// True when the suite is running against PostgreSQL. + public bool IsPostgres => Provider == PostgresProvider; + + /// The provider the suite is configured to run against, without starting a container. + public static string SelectedProvider => + (Environment.GetEnvironmentVariable(ProviderEnvironmentVariable) ?? PostgresProvider).ToUpperInvariant(); + + /// True when the suite is configured to run against PostgreSQL. + public static bool SelectedProviderIsPostgres => SelectedProvider == PostgresProvider; + + /// + /// A well-formed but unreachable connection string for the selected provider. + /// + /// + /// Provisioning-failure tests need a string that passes ConnectionStringValidator and + /// then fails to connect. The validator parses with the provider's own builder, so a + /// PostgreSQL-shaped string is rejected outright on SQL Server (`Host=` is not a keyword) and + /// the tenant is refused with 400 before provisioning is ever attempted. + /// + public static string UnreachableConnectionString => + SelectedProvider == MssqlProvider + ? "Server=127.0.0.1,1;Database=does_not_exist;User Id=sa;Password=x;Connect Timeout=3;TrustServerCertificate=True" + : "Host=127.0.0.1;Port=1;Database=does_not_exist;Username=postgres;Password=x;Timeout=3;Command Timeout=3"; + + /// An externally provided server, or null to start a container. + private static string? ExternalConnectionString + { + get + { + string? value = Environment.GetEnvironmentVariable(ConnectionEnvironmentVariable); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + } + + /// + /// Builds the container for the selected provider. Call to run it. + /// + /// Database to create inside the container. + public static TestDatabase Create(string databaseName) + { + string migrationsAssembly = SelectedProvider == MssqlProvider + ? "FSH.Starter.Migrations.MSSQL" + : "FSH.Starter.Migrations.PostgreSQL"; + + if (ExternalConnectionString is { } external) + { + return new TestDatabase(null, external, SelectedProvider, migrationsAssembly); + } + + if (SelectedProvider == MssqlProvider) + { + return new TestDatabase( + new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest") + .WithAutoRemove(true) + .WithCleanUp(true) + .Build(), + null, + MssqlProvider, + migrationsAssembly); + } + + return new TestDatabase( + new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase(databaseName) + .WithUsername("postgres") + .WithPassword("integration_test_pwd") + .WithAutoRemove(true) + .WithCleanUp(true) + .Build(), + null, + PostgresProvider, + migrationsAssembly); + } + + public Task StartAsync() => _container?.StartAsync() ?? Task.CompletedTask; + + public ValueTask DisposeAsync() => _container?.DisposeAsync() ?? ValueTask.CompletedTask; + + public string GetConnectionString() => _externalConnectionString ?? _container!.GetConnectionString(); +} diff --git a/src/Tests/Integration.Tests/Integration.Tests.csproj b/src/Tests/Integration.Tests/Integration.Tests.csproj index 1aea12e3ab..379c201a48 100644 --- a/src/Tests/Integration.Tests/Integration.Tests.csproj +++ b/src/Tests/Integration.Tests/Integration.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/RetryTenantProvisioningTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/RetryTenantProvisioningTests.cs index 9edb8c4285..46cf5211b4 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/RetryTenantProvisioningTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/RetryTenantProvisioningTests.cs @@ -24,8 +24,7 @@ public sealed class RetryTenantProvisioningTests // Syntactically valid Postgres connection string pointing at a dead endpoint with a // short timeout so the Migrations step fails fast instead of hanging the test. - private const string UnreachableConnectionString = - "Host=127.0.0.1;Port=59999;Database=fsh_unreachable;Username=nope;Password=nope;Timeout=2;Command Timeout=2"; + private static string UnreachableConnectionString => TestDatabase.UnreachableConnectionString; private readonly FshWebApplicationFactory _factory; private readonly AuthHelper _auth; diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantMigrationsTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantMigrationsTests.cs index cac01e5cb2..9f503c2df6 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantMigrationsTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantMigrationsTests.cs @@ -53,7 +53,8 @@ public async Task GetMigrations_Should_IncludeRootTenant_WithProviderAndNoPendin root.ShouldNotBeNull("root tenant must appear in the migration report"); root.Error.ShouldBeNull(); root.Provider.ShouldNotBeNullOrEmpty(); - root.Provider.ShouldContain("Npgsql"); + // The report surfaces EF's provider assembly name, which differs per provider. + root.Provider.ShouldContain(TestDatabase.SelectedProviderIsPostgres ? "Npgsql" : "SqlServer"); root.HasPendingMigrations.ShouldBeFalse(); root.LastAppliedMigration.ShouldNotBeNullOrEmpty(); root.PendingMigrations.ShouldBeEmpty(); diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantProvisioningFailureTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantProvisioningFailureTests.cs index b7e413f41a..a350e372bf 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantProvisioningFailureTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantProvisioningFailureTests.cs @@ -32,9 +32,10 @@ public sealed class TenantProvisioningFailureTests }; // Well-formed (passes ConnectionStringValidator) but unreachable: port 1 refuses - // connections immediately, and the short timeouts keep the failing job fast. - private const string UnreachableConnectionString = - "Host=127.0.0.1;Port=1;Database=does_not_exist;Username=postgres;Password=x;Timeout=3;Command Timeout=3"; + // connections immediately, and the short timeouts keep the failing job fast. Shaped for + // whichever provider the suite is running against — the validator parses it with that + // provider's own connection-string builder. + private static string UnreachableConnectionString => TestDatabase.UnreachableConnectionString; private readonly FshWebApplicationFactory _factory; private readonly AuthHelper _auth; diff --git a/src/Tests/README.md b/src/Tests/README.md index 33b5c6614f..f080c6f0bf 100644 --- a/src/Tests/README.md +++ b/src/Tests/README.md @@ -19,6 +19,13 @@ This folder contains solution-wide architecture tests for the FullStackHero .NET - Run all tests (including architecture tests): `dotnet test src/FSH.Starter.slnx`. - Architecture tests are lightweight and rely only on project and file structure; they do not require any external services or databases. +## Migration drift + +`MigrationDriftTests` checks that every DbContext's migrations are up to date with its model, per +database provider — the in-process equivalent of `dotnet ef migrations has-pending-model-changes`. It +needs no database. Which providers *fail* the build is set by `FshMaintainedDbProviders` in +`src/Directory.Build.props`; the rest only report. See the `verify-migrations` skill. + ## Extending the Rules - Add new rules as additional test classes inside `Architecture.Tests`, following the existing patterns (using NetArchTest for type-level rules and reflection or project file inspection where appropriate). diff --git a/src/Tools/CLI/Commands/NewCommand.cs b/src/Tools/CLI/Commands/NewCommand.cs index b9b4f7890c..bb830ad36b 100644 --- a/src/Tools/CLI/Commands/NewCommand.cs +++ b/src/Tools/CLI/Commands/NewCommand.cs @@ -1,5 +1,7 @@ using System.ComponentModel; using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; using FSH.CLI.Infrastructure; using Spectre.Console; using Spectre.Console.Cli; @@ -47,6 +49,26 @@ public sealed class Settings : CommandSettings [CommandOption("--dry-run")] [DefaultValue(false)] public bool DryRun { get; init; } + + [Description("Default database provider: postgresql (default) or mssql. mssql requires SQL Server 2025 or Azure SQL. Env: FSH_DB_PROVIDER.")] + [CommandOption("--db-provider ")] + public DbProviderChoice? DbProvider { get; init; } + } + + /// + /// Database provider a scaffolded project defaults to. + /// + /// + /// This only picks the default written into configuration — both migrations projects ship in + /// every scaffold, so switching later is a config change, not a rescaffold. + /// + public enum DbProviderChoice + { + /// PostgreSQL (the default). + Postgresql, + + /// Microsoft SQL Server. Requires SQL Server 2025 (17.x) or Azure SQL. + Mssql } protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) @@ -69,6 +91,8 @@ protected override async Task ExecuteAsync(CommandContext context, Settings bool frontend = await ResolveFrontendAsync(settings, cancellationToken).ConfigureAwait(false); + DbProviderChoice dbProvider = await ResolveDbProviderAsync(settings, cancellationToken).ConfigureAwait(false); + string output = settings.Output ?? Path.GetFullPath(name); // 2. Check for existing directory @@ -89,7 +113,7 @@ protected override async Task ExecuteAsync(CommandContext context, Settings } // 3. Print summary - PrintSummary(name, aspire, frontend, output, settings.DryRun); + PrintSummary(name, aspire, frontend, dbProvider, output, settings.DryRun); if (settings.DryRun) { @@ -111,6 +135,7 @@ protected override async Task ExecuteAsync(CommandContext context, Settings // 6. Generate per-project dev secrets + a ready-to-run docker-compose .env GenerateDevSecrets(name, output); + ApplyDbProvider(name, output, dbProvider); bool dockerEnvReady = GenerateDockerEnv(output); // 7. Install frontend dependencies (npm install in both React apps) @@ -179,7 +204,7 @@ private static async Task ResolveFrontendAsync(Settings settings, Cancella .ShowAsync(AnsiConsole.Console, cancellationToken).ConfigureAwait(false); } - private static void PrintSummary(string name, bool aspire, bool frontend, string output, bool dryRun) + private static void PrintSummary(string name, bool aspire, bool frontend, DbProviderChoice dbProvider, string output, bool dryRun) { AnsiConsole.WriteLine(); @@ -187,6 +212,9 @@ private static void PrintSummary(string name, bool aspire, bool frontend, string AnsiConsole.MarkupLine($"[bold]Creating project:[/] {name.EscapeMarkup()}{mode}"); AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]Aspire:[/] {(aspire ? "yes" : "no")}"); AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]Frontend:[/] {(frontend ? "yes (admin + dashboard)" : "no")}"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]Database:[/] {(dbProvider == DbProviderChoice.Mssql + ? "SQL Server [yellow](requires SQL Server 2025 or Azure SQL)[/]" + : "PostgreSQL")}"); AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]Output:[/] {output.EscapeMarkup()}"); AnsiConsole.WriteLine(); } @@ -312,6 +340,68 @@ private static Task RunNpmAsync(string args, string workingDirectory, Cance // Replace the shared dev signing-key placeholder with a unique per-project key so two // freshly scaffolded projects never mint interchangeable tokens in development. + /// + /// Resolves the default database provider: flag -> env var -> prompt -> postgresql. + /// + private static async Task ResolveDbProviderAsync(Settings settings, CancellationToken cancellationToken) + { + if (settings.DbProvider is { } explicitChoice) return explicitChoice; + + string? fromEnvironment = Environment.GetEnvironmentVariable(FshConstants.DbProviderEnvVar); + if (Enum.TryParse(fromEnvironment, ignoreCase: true, out DbProviderChoice parsed)) return parsed; + + if (settings.NonInteractive) return DbProviderChoice.Postgresql; + + string selection = await new SelectionPrompt() + .Title($"[{FshConstants.AccentColor}]Default database provider?[/]") + .AddChoices("PostgreSQL", "SQL Server (requires SQL Server 2025 or Azure SQL)") + .ShowAsync(AnsiConsole.Console, cancellationToken).ConfigureAwait(false); + + return selection.StartsWith("SQL Server", StringComparison.Ordinal) + ? DbProviderChoice.Mssql + : DbProviderChoice.Postgresql; + } + + /// + /// Writes the chosen provider into the scaffolded project's configuration. + /// + /// + /// Both migrations projects ship regardless, so this only sets the default: switching a + /// scaffolded project later is editing these same values, with no file surgery. Edited as JSON + /// rather than by string replacement because the three values must stay consistent with each + /// other — a provider without its matching MigrationsAssembly fails at startup. + /// + private static void ApplyDbProvider(string name, string output, DbProviderChoice dbProvider) + { + if (dbProvider != DbProviderChoice.Mssql) return; + + string appsettings = Path.Combine(output, "src", "Host", $"{name}.Api", "appsettings.json"); + if (File.Exists(appsettings)) + { + var root = JsonNode.Parse(File.ReadAllText(appsettings))?.AsObject(); + if (root?["DatabaseOptions"] is JsonObject db) + { + db["Provider"] = "MSSQL"; + db["MigrationsAssembly"] = $"{name}.Migrations.MSSQL"; + db["ConnectionString"] = + "Server=localhost,1433;Database=fsh;User Id=sa;Password=Str0ng_Dev_Pwd!;TrustServerCertificate=True;Min Pool Size=5"; + File.WriteAllText(appsettings, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + } + + // The Aspire host reads DbProvider to decide which container to run. + string appHostSettings = Path.Combine(output, "src", "Host", $"{name}.AppHost", "appsettings.json"); + if (File.Exists(appHostSettings)) + { + var root = JsonNode.Parse(File.ReadAllText(appHostSettings))?.AsObject(); + if (root is not null) + { + root["DbProvider"] = "mssql"; + File.WriteAllText(appHostSettings, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + } + } + private static void GenerateDevSecrets(string name, string output) { string appsettingsDev = Path.Combine(output, "src", "Host", $"{name}.Api", "appsettings.Development.json"); @@ -341,6 +431,7 @@ private static bool GenerateDockerEnv(string output) ["HANGFIRE_USERNAME"] = "admin", ["HANGFIRE_PASSWORD"] = GenerateSecret(20), ["POSTGRES_PASSWORD"] = GenerateSecret(24), + ["MSSQL_SA_PASSWORD"] = GenerateSecret(24), ["REDIS_PASSWORD"] = GenerateSecret(24), ["MINIO_ROOT_USER"] = "minioadmin", ["MINIO_ROOT_PASSWORD"] = GenerateSecret(24), diff --git a/src/Tools/CLI/Infrastructure/FshConstants.cs b/src/Tools/CLI/Infrastructure/FshConstants.cs index f00364f23e..5302678990 100644 --- a/src/Tools/CLI/Infrastructure/FshConstants.cs +++ b/src/Tools/CLI/Infrastructure/FshConstants.cs @@ -7,6 +7,9 @@ internal static class FshConstants internal const string TemplatePackageId = "FullStackHero.NET.StarterKit"; internal const string TemplateShortName = "fsh"; + /// Env var selecting the default database provider for `fsh new` (postgresql | mssql). + public const string DbProviderEnvVar = "FSH_DB_PROVIDER"; + // URLs internal const string NuGetFlatContainerUrl = "https://api.nuget.org/v3-flatcontainer"; internal const string GitHubRepoUrl = "https://github.com/fullstackhero/dotnet-starter-kit";