# EF Core Removal Analysis — pgsql-jellyfin Fork

**Date**: 2025  
**Scope**: Full codebase analysis of whether EF Core can and should be removed from this PostgreSQL-only fork.  
**Verdict**: **Do not remove EF Core.** Address specific friction points instead.

---

## 1. Usage Inventory

EF Core (`IDbContextFactory<JellyfinDbContext>`) is injected into **82 call sites across ~35 source files**, permeating every layer:

| Layer | Files |
|---|---|
| API | `DatabaseViewsController` |
| Repository (Item) | `BaseItemRepository`, `PeopleRepository`, `MediaStreamRepository`, `MediaAttachmentRepository`, `KeyframeRepository`, `ChapterRepository` |
| Repository (Other) | `ActivityManager`, `DeviceManager`, `DisplayPreferencesManager`, `UserManager`, `TrickplayManager`, `MediaSegmentManager`, `AuthenticationManager`, `AuthorizationContext`, `LibraryOptionsRepository`, `BackupService` |
| Scheduled Tasks | `PeopleValidationTask`, `CleanupUserDataTask`, `CleanDatabaseScheduledTask`, `UserDataManager` |
| Migration Routines | 13 routines: `MigrateActivityLogDb`, `MigrateAuthenticationDb`, `MigrateDisplayPreferencesDb`, `MigrateKeyframeData`, `MigrateLibraryDb`, `MigrateLibraryUserData`, `MigrateRatingLevels`, `MoveExtractedFiles`, `RefreshCleanNames`, `RefreshInternalDateModified`, `FixDates`, `CleanMusicArtist`, (+ more) |
| Infrastructure | `JellyfinMigrationService`, `PostgresDatabaseProvider`, `DbContextFactoryHealthCheck`, `Program.cs`, `ServiceCollectionExtensions` |

The `JellyfinDbContext` itself defines **~35 DbSets** spanning 5 schemas (`activitylog`, `authentication`, `displaypreferences`, `library`, `users`).

---

## 2. Query Complexity — What EF Core Actually Does

### 2.1 BaseItemRepository.cs — The Core Case

`BaseItemRepository.cs` is **3,583 lines / 152 KB**, the largest file in the repository. It is the performance-critical code path for all library browsing, search, and playback-related queries. It is built entirely on EF Core LINQ.

**Key EF-dependent sub-systems inside it:**

| Method | EF Feature Used | Purpose |
|---|---|---|
| `TranslateQuery()` (lines 2548+, ~500 lines) | LINQ predicate composition over `IQueryable<BaseItemEntity>` | Translates `InternalItemsQuery` (60+ filter parameters) to SQL WHERE clauses |
| `ApplyOrder()` (~80 lines) | `OrderBy` / `ThenBy` over EF expression trees, `OrderMapper.MapOrderByField()` | Translates `ItemSortBy` enum to typed SQL ORDER BY |
| `ApplyNavigations()` | Conditional `Include()` / `ThenInclude()` | Eagerly loads TvExtras, AudioExtras, LiveTvExtras, Provider, Images, UserData per DTO field set |
| `ApplyGroupingFilter()` | `DistinctBy()` → PostgreSQL `DISTINCT ON` | Deduplication for presentation unique keys |
| `GetNextUpSeriesKeys()` | `Join()` + `GroupBy()` + `Max()` | Complex TV "next up" episode query |
| `GetItemValues()` | Nested `Join()` + `SelectMany()` + correlated subquery pattern | Artist/Genre/Studio item-value lookups |
| `UpdateOrInsertItemsAsync()` | `CreateExecutionStrategy().ExecuteAsync()`, `SaveChangesAsync()`, `Attach().State = EntityState.Modified`, `AddRange()` | Transactional batch upsert |
| Entity state management | `context.Entry(placeholder).State = EntityState.Detached` | Upsert conflict resolution |
| `EF.Constant()` | PostgreSQL-specific plan hint | Prevents parameterization of the placeholder UUID to allow index scans |

**LINQ operator counts in BaseItemRepository.cs** (approximate from code review):  
- `Where()`, `Select()`, `Join()`, `GroupBy()`, `Include()`, `ThenInclude()`, `OrderBy()`: **200+ call sites**  
- `ExecuteDelete()` / `ExecuteUpdate()` bulk ops: **~25 call sites**  
- `FromSqlRaw` / `FromSqlInterpolated`: **0** — reads are pure LINQ  
- `ExecuteSqlAsync()` raw SQL upserts: **~8** (ON CONFLICT upserts for extension tables)

The LINQ query composition is not superficial mapping sugar — `TranslateQuery` conditionally applies 30+ predicate branches (user data filters, rating filters, type filters, ancestor traversal, search term scoring, tag inheritance, etc.) based on `InternalItemsQuery` flags. This is the equivalent of a 500-line dynamic SQL builder, expressed in type-safe C#.

### 2.2 Other Repositories

The other repositories (`ActivityManager`, `UserManager`, `DeviceManager`, etc.) use EF more simply — primarily `Where()` + `Select()` + `SaveChanges()` patterns — but collectively represent hundreds more LINQ call sites.

### 2.3 Hybrid Usage Already Present

`BaseItemRepository.UpdateOrInsertItemsAsync()` already uses a hybrid approach: EF Core for reads and change-tracking, **raw `ExecuteSqlAsync()` for ON CONFLICT upserts**. This tells us the team is already reaching EF's limits on writes.

---

## 3. Migration Infrastructure

### 3.1 EF Schema Migrations Are DISABLED

Per `JellyfinMigrationService.cs` (line ~275):
```csharp
// DISABLED for .NET 11 preview: EF Core database migrations don't work with preview SDK
// Database schema is initialized from SQL scripts in PostgresDatabaseProvider instead
/*
if (stage is JellyfinMigrationStageTypes.CoreInitialisation)
{
    pendingDatabaseMigrations = migrationsAssembly.Migrations...
}
*/
```

**EF's primary justification — schema migration management — is already bypassed.** Schema is initialized via `sql/schema_init/create_database_schema.sql` + `psql`.

### 3.2 EF Is Still Used for Code Migration Tracking

`JellyfinMigrationService` still uses EF to track *which code routines have run* via `__EFMigrationsHistory`:

```csharp
var historyRepository = dbContext.GetService<IHistoryRepository>();
await historyRepository.CreateIfNotExistsAsync();
var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync();
await dbContext.Database.ExecuteSqlRawAsync(
    historyRepository.GetInsertScript(new HistoryRow(migrationId, version)));
```

This usage of `IHistoryRepository` is what required the `SnakeCaseHistoryRepository` workaround (custom class overriding `MigrationIdColumnName` and `ProductVersionColumnName`, using `#pragma warning disable EF1001` because the base class is internal EF infrastructure).

---

## 4. Known Friction Points

These are the specific problems that prompted this analysis:

### 4.1 Snake_case Naming Impedance (FIXED, but fragile)

EF Core defaults to PascalCase. This fork uses snake_case PostgreSQL conventions. Three workarounds are required:

1. **`SnakeCaseNamingConvention`** — `IPropertyAddedConvention` that converts all property names to snake_case at model build time  
2. **`SnakeCaseHistoryRepository`** — custom `NpgsqlHistoryRepository` subclass overriding column name properties; requires `#pragma warning disable EF1001` (breaking internal EF API)  
3. **Manual table names in model configurations** — 31 `IEntityTypeConfiguration` files; two (`LibraryOptionsEntityConfiguration`, `HomeSectionConfiguration`) had PascalCase table names that overrode the convention and caused `relation "library.LibraryOptions" does not exist` errors at runtime

**Risk**: Every future upstream EF Core upgrade may break `SnakeCaseHistoryRepository` since it depends on internal EF infrastructure.

### 4.2 Execution Strategy Transaction Friction (FIXED, but imposed on all writers)

`EnableRetryOnFailure` in the PostgreSQL retry strategy makes EF Core incompatible with user-initiated `BeginTransaction()`. All transactional code must wrap in `context.Database.CreateExecutionStrategy().Execute(...)`. This has already been fixed in `MigrateRatingLevels` and `MigrateKeyframeData` but is an ongoing constraint.

### 4.3 EF-PostgreSQL Translation Gaps

Multiple code comments document EF Core translation failures:

- `DistinctBy()` on complex types sometimes cannot be translated → fallback to `ApplyGroupingInMemory()` using **reflection on anonymous types**  
- `GetItemValues()` in some paths falls back to `.AsEnumerable()` for in-memory filtering  
- `GetLatestItemList()` had to be split into two queries to avoid untranslatable nested `Min()` subqueries  
- A documented 165s query regression was caused by EF generating correlated subqueries — fixed by switching to `DistinctBy()` → `DISTINCT ON`  
- `AsSingleQuery()` required explicitly to prevent N+1 for multi-table includes

### 4.4 Inconsistent Table Naming in Raw SQL

`UpdateOrInsertItemsAsync()` uses raw `ExecuteSqlAsync()` with PascalCase quoted names:

```csharp
$@"INSERT INTO library.""BaseItemProviders"" (""ItemId"", ""ProviderId"", ""ProviderValue"")..."
$@"INSERT INTO library.""BaseItemTvExtras"" (""ItemId"", ..."
```

These PascalCase quoted names (`"BaseItemProviders"`, `"BaseItemTvExtras"`) are inconsistent with the snake_case goal of the fork. The actual table names in PostgreSQL must match these quoted strings exactly — implying the schema init SQL creates them with PascalCase quoted names, not the snake_case convention applied to EF properties.

### 4.5 Known Performance Anti-Pattern in `GetItemValues`

```csharp
// TODO: This is bad refactor!
itemCount = new ItemCounts() {
    SeriesCount = itemCountQuery!.Count(f => f.Type == seriesTypeName),
    EpisodeCount = itemCountQuery!.Count(f => f.Type == episodeTypeName),
    // ... 5 more COUNT queries
}
```

This executes 7 separate `COUNT` queries per item in a result set — a classic N+1 problem embedded in an EF projection. This is an EF translation limitation (aggregate counts in a nested `SELECT` project).

---

## 5. Can EF Core Be Removed?

**Technically: Yes. Practically: The cost is prohibitive.**

A full removal would require:

1. **Rewriting `BaseItemRepository.cs`** (3,583 lines) as raw SQL — either Dapper or direct `NpgsqlCommand`. The `TranslateQuery` method alone is a ~500-line dynamic query builder that would need to be rewritten as a SQL string builder. All `Include()` chains would become explicit JOINs. All `ExecuteUpdate`/`ExecuteDelete` calls would become parameterized SQL.

2. **Replacing 31 ModelConfiguration files** with SQL schema definitions (partially done via `sql/schema_init/`).

3. **Replacing the migration tracking system** (`IHistoryRepository`) with a simple SQL table + custom tracker.

4. **Rewriting all other repositories** (~15 files) as Dapper or raw Npgsql.

5. **Replacing all `IDbContextFactory` injection points** (~35 files, 82 sites) with new connection/command factories.

**Estimated effort: 3–6 person-months of careful rewrite, plus extensive testing coverage.**

Alternatives assessed:

| Alternative | Pros | Cons |
|---|---|---|
| **Full removal (Dapper + Npgsql)** | Full control of SQL; no EF friction; better PostgreSQL-native features | 3-6 month rewrite; no dynamic LINQ query composition; lose `ExecuteUpdate`/`ExecuteDelete`; high regression risk |
| **Hybrid (EF reads, Dapper writes)** | Eliminates write friction; keeps LINQ query composition | Two persistence paradigms; still need EF for reads; does not eliminate snake_case friction |
| **Keep EF, address friction points** | Minimal disruption; preserves LINQ query composition | Ongoing EF+PostgreSQL compatibility work; preview SDK risk |
| **Replace only migration tracking** | Eliminates `SnakeCaseHistoryRepository` hack | Small gain; does not address execution strategy or translation friction |

---

## 6. Should EF Core Be Removed?

**No — but specific friction points should be addressed deliberately.**

### 6.1 Why EF Core Should Stay

**The LINQ query composition in `TranslateQuery` / `ApplyOrder` is EF Core's core contribution to this codebase.** It provides:

- **Type safety** across 60+ filter parameters in `InternalItemsQuery`
- **Composability** — predicates are built incrementally, each branch independently testable
- **Maintainability** — 500 lines of C# expression trees is significantly more maintainable than equivalent raw SQL string building
- **`ExecuteUpdate`/`ExecuteDelete`** — these EF 7+ bulk operations map directly to `UPDATE ... WHERE` and `DELETE ... WHERE` without loading entities; replacing these with raw SQL would produce functionally identical code with more boilerplate
- **Navigation property loading** (`Include`) — the conditional navigation loading in `ApplyNavigations` maps cleanly to the DTO field selection pattern used by the API layer

EF Core's value in this codebase is almost entirely in the **read path** (LINQ-to-SQL translation) and **bulk operations** (`ExecuteUpdate`/`ExecuteDelete`). Its weaknesses are in the write path and infrastructure (migration history, naming conventions, execution strategy).

### 6.2 Recommended Targeted Actions

In priority order:

#### Priority 1 — Replace Migration History Tracking (Low effort, high safety gain)

The `SnakeCaseHistoryRepository` workaround depends on EF internal APIs (`#pragma warning disable EF1001`). Replace the code migration tracking with a simple dedicated table and Dapper/Npgsql:

```sql
CREATE TABLE IF NOT EXISTS public.jellyfin_applied_migrations (
    migration_id VARCHAR(150) PRIMARY KEY,
    applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    product_version VARCHAR(32) NOT NULL
);
```

Remove `JellyfinMigrationService`'s dependency on `IHistoryRepository` and `GetAppliedMigrationsAsync()`. This eliminates the most fragile EF workaround.

#### Priority 2 — Fix Raw SQL Table Names to Match snake_case Schema (Low effort, correctness)

`UpdateOrInsertItemsAsync` uses `library."BaseItemProviders"`, `library."BaseItemTvExtras"`, etc. If the fork's goal is snake_case naming, verify whether these tables exist as quoted PascalCase or as snake_case in PostgreSQL, and make the raw SQL consistent. Quoted PascalCase in PostgreSQL is case-sensitive and is a footgun.

#### Priority 3 — Replace Write Path with Raw SQL/Dapper (Medium effort, eliminates execution strategy friction)

The `UpdateOrInsertItemsAsync` method is already a hybrid (raw SQL for ON CONFLICT upserts + EF for the surrounding logic). Converting the remaining EF write operations (`SaveChangesAsync`, `Attach().State = EntityState.Modified`, `AddRange()`) in this method to direct Npgsql/Dapper calls would:

- Eliminate the `CreateExecutionStrategy().Execute()` wrappers
- Enable direct PostgreSQL `ON CONFLICT DO UPDATE` throughout
- Remove the impedance between EF change tracking and PostgreSQL's native upsert semantics

**Do not extend this to reads** — the LINQ query composition is worth keeping.

#### Priority 4 — Fix the N+1 in `GetItemValues` (Medium effort, performance)

The 7 separate `COUNT` queries per item should be replaced with a single `GROUP BY type, COUNT(*)` query executed once and pivoted in memory. This is a correctness/performance issue independent of whether EF is removed.

---

## 7. Summary

| Question | Answer |
|---|---|
| **Can EF Core be removed?** | Yes, technically. Estimated 3–6 person-months of work. |
| **Should EF Core be removed?** | No. The LINQ query composition in `BaseItemRepository` is EF's core value and cost-prohibitive to replace. |
| **Is EF Core being used optimally?** | No. The write path, migration tracking, and naming convention workarounds are sources of ongoing friction. |
| **What should be done instead?** | Replace migration tracking with a native SQL table; convert the write path in `BaseItemRepository` to raw SQL; keep EF for the LINQ read path. |
| **Biggest ongoing risk?** | `SnakeCaseHistoryRepository` uses internal EF APIs (`EF1001`) that may break on EF Core version bumps. Replacing migration tracking eliminates this. |
| **Second biggest risk?** | EF Core + Npgsql preview packages (`11.0.0-preview.1`) on `net11.0`. Schema migrations are already disabled because of this. Pin to stable as soon as net11.0 releases. |
