Add comprehensive documentation for PostgreSQL schema conversion

- Introduced DATABASE_SCHEMA_MISMATCH_REPORT.md to detail critical mismatches between SQL schema and C# code configurations, highlighting issues with naming conventions and potential runtime failures.
- Created SCHEMA_COLUMN_CONVERSION_REFERENCE.md to provide a reference for column name conversions from PascalCase to snake_case across all tables.
- Developed SCHEMA_CONVERSION_GUIDE.md outlining the conversion strategy for the SQL schema, including authoritative table mappings, column naming rules, and a validation checklist to ensure accuracy in the conversion process.
This commit is contained in:
2026-05-04 10:16:17 -04:00
parent 3e5d29225a
commit e67c191843
6 changed files with 83 additions and 79 deletions
@@ -127,65 +127,69 @@ public sealed class BaseItemRepository
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false)) await using (context.ConfigureAwait(false))
{ {
var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); var executionStrategy = context.Database.CreateExecutionStrategy();
await using (transaction.ConfigureAwait(false)) await executionStrategy.ExecuteAsync(async () =>
{ {
var date = (DateTime?)DateTime.UtcNow; var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
var date = (DateTime?)DateTime.UtcNow;
await EnsurePlaceholderItemAsync(context, cancellationToken).ConfigureAwait(false); await EnsurePlaceholderItemAsync(context, cancellationToken).ConfigureAwait(false);
var relatedItems = ids.SelectMany(f => TraverseHirachyDown(f, context)).ToArray(); var relatedItems = ids.SelectMany(f => TraverseHirachyDown(f, context)).ToArray();
// Remove any UserData entries for the placeholder item that would conflict with the UserData // Remove any UserData entries for the placeholder item that would conflict with the UserData
// being detached from the item being deleted. This is necessary because, during an update, // being detached from the item being deleted. This is necessary because, during an update,
// UserData may be reattached to a new entry, but some entries can be left behind. // UserData may be reattached to a new entry, but some entries can be left behind.
// Ensures there are no duplicate UserId/CustomDataKey combinations for the placeholder. // Ensures there are no duplicate UserId/CustomDataKey combinations for the placeholder.
await context.UserData await context.UserData
.Join( .Join(
context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId), context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId),
placeholder => new { placeholder.UserId, placeholder.CustomDataKey }, placeholder => new { placeholder.UserId, placeholder.CustomDataKey },
userData => new { userData.UserId, userData.CustomDataKey }, userData => new { userData.UserId, userData.CustomDataKey },
(placeholder, userData) => placeholder) (placeholder, userData) => placeholder)
.Where(e => e.ItemId == PlaceholderId) .Where(e => e.ItemId == PlaceholderId)
.ExecuteDeleteAsync(cancellationToken) .ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
// Detach all user watch data // Detach all user watch data
await context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId) await context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId)
.ExecuteUpdateAsync( .ExecuteUpdateAsync(
e => e e => e
.SetProperty(f => f.RetentionDate, date) .SetProperty(f => f.RetentionDate, date)
.SetProperty(f => f.ItemId, PlaceholderId), .SetProperty(f => f.ItemId, PlaceholderId),
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ParentItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ParentItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.AttachmentStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.AttachmentStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.BaseItemImageInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.BaseItemImageInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
var query = await context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId) var query = await context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId)
.Select(f => f.PeopleId) .Select(f => f.PeopleId)
.Distinct() .Distinct()
.ToArrayAsync(cancellationToken) .ToArrayAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
} }
}).ConfigureAwait(false);
} }
} }
catch (Npgsql.PostgresException ex) when (ex.SqlState == "40P01") // Deadlock detected catch (Npgsql.PostgresException ex) when (ex.SqlState == "40P01") // Deadlock detected
@@ -1158,10 +1162,10 @@ public sealed class BaseItemRepository
foreach (var provider in providersToUpsert) foreach (var provider in providersToUpsert)
{ {
await context.Database.ExecuteSqlAsync( await context.Database.ExecuteSqlAsync(
$@"INSERT INTO library.""BaseItemProviders"" (""ItemId"", ""ProviderId"", ""ProviderValue"") $@"INSERT INTO library.base_item_providers (item_id, provider_id, provider_value)
VALUES ({entity.Id}, {provider.ProviderId}, {provider.ProviderValue}) VALUES ({entity.Id}, {provider.ProviderId}, {provider.ProviderValue})
ON CONFLICT (""ItemId"", ""ProviderId"") ON CONFLICT (item_id, provider_id)
DO UPDATE SET ""ProviderValue"" = EXCLUDED.""ProviderValue""", DO UPDATE SET provider_value = EXCLUDED.provider_value",
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@@ -1202,14 +1206,14 @@ public sealed class BaseItemRepository
if (tvExtras is not null) if (tvExtras is not null)
{ {
await context.Database.ExecuteSqlAsync( await context.Database.ExecuteSqlAsync(
$@"INSERT INTO library.""BaseItemTvExtras"" (""ItemId"", ""SeriesId"", ""SeasonId"", ""SeriesName"", ""SeasonName"", ""SeriesPresentationUniqueKey"") $@"INSERT INTO library.base_item_tv_extras (item_id, series_id, season_id, series_name, season_name, series_presentation_unique_key)
VALUES ({extItemId}, {tvExtras.SeriesId}, {tvExtras.SeasonId}, {tvExtras.SeriesName}, {tvExtras.SeasonName}, {tvExtras.SeriesPresentationUniqueKey}) VALUES ({extItemId}, {tvExtras.SeriesId}, {tvExtras.SeasonId}, {tvExtras.SeriesName}, {tvExtras.SeasonName}, {tvExtras.SeriesPresentationUniqueKey})
ON CONFLICT (""ItemId"") DO UPDATE SET ON CONFLICT (item_id) DO UPDATE SET
""SeriesId"" = EXCLUDED.""SeriesId"", series_id = EXCLUDED.series_id,
""SeasonId"" = EXCLUDED.""SeasonId"", season_id = EXCLUDED.season_id,
""SeriesName"" = EXCLUDED.""SeriesName"", series_name = EXCLUDED.series_name,
""SeasonName"" = EXCLUDED.""SeasonName"", season_name = EXCLUDED.season_name,
""SeriesPresentationUniqueKey"" = EXCLUDED.""SeriesPresentationUniqueKey""", series_presentation_unique_key = EXCLUDED.series_presentation_unique_key",
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
} }
else else
@@ -1222,16 +1226,16 @@ public sealed class BaseItemRepository
if (liveTvExtras is not null) if (liveTvExtras is not null)
{ {
await context.Database.ExecuteSqlAsync( await context.Database.ExecuteSqlAsync(
$@"INSERT INTO library.""BaseItemLiveTvExtras"" (""ItemId"", ""StartDate"", ""EndDate"", ""EpisodeTitle"", ""ShowId"", ""ExternalSeriesId"", ""ExternalServiceId"", ""Audio"") $@"INSERT INTO library.base_item_live_tv_extras (item_id, start_date, end_date, episode_title, show_id, external_series_id, external_service_id, audio)
VALUES ({extItemId}, {liveTvExtras.StartDate}, {liveTvExtras.EndDate}, {liveTvExtras.EpisodeTitle}, {liveTvExtras.ShowId}, {liveTvExtras.ExternalSeriesId}, {liveTvExtras.ExternalServiceId}, {(int?)liveTvExtras.Audio}) VALUES ({extItemId}, {liveTvExtras.StartDate}, {liveTvExtras.EndDate}, {liveTvExtras.EpisodeTitle}, {liveTvExtras.ShowId}, {liveTvExtras.ExternalSeriesId}, {liveTvExtras.ExternalServiceId}, {(int?)liveTvExtras.Audio})
ON CONFLICT (""ItemId"") DO UPDATE SET ON CONFLICT (item_id) DO UPDATE SET
""StartDate"" = EXCLUDED.""StartDate"", start_date = EXCLUDED.start_date,
""EndDate"" = EXCLUDED.""EndDate"", end_date = EXCLUDED.end_date,
""EpisodeTitle"" = EXCLUDED.""EpisodeTitle"", episode_title = EXCLUDED.episode_title,
""ShowId"" = EXCLUDED.""ShowId"", show_id = EXCLUDED.show_id,
""ExternalSeriesId"" = EXCLUDED.""ExternalSeriesId"", external_series_id = EXCLUDED.external_series_id,
""ExternalServiceId"" = EXCLUDED.""ExternalServiceId"", external_service_id = EXCLUDED.external_service_id,
""Audio"" = EXCLUDED.""Audio""", audio = EXCLUDED.audio",
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
} }
else else
@@ -1244,14 +1248,14 @@ public sealed class BaseItemRepository
if (audioExtras is not null) if (audioExtras is not null)
{ {
await context.Database.ExecuteSqlAsync( await context.Database.ExecuteSqlAsync(
$@"INSERT INTO library.""BaseItemAudioExtras"" (""ItemId"", ""Album"", ""Artists"", ""AlbumArtists"", ""LUFS"", ""NormalizationGain"") $@"INSERT INTO library.base_item_audio_extras (item_id, album, artists, album_artists, lufs, normalization_gain)
VALUES ({extItemId}, {audioExtras.Album}, {audioExtras.Artists}, {audioExtras.AlbumArtists}, {audioExtras.LUFS}, {audioExtras.NormalizationGain}) VALUES ({extItemId}, {audioExtras.Album}, {audioExtras.Artists}, {audioExtras.AlbumArtists}, {audioExtras.LUFS}, {audioExtras.NormalizationGain})
ON CONFLICT (""ItemId"") DO UPDATE SET ON CONFLICT (item_id) DO UPDATE SET
""Album"" = EXCLUDED.""Album"", album = EXCLUDED.album,
""Artists"" = EXCLUDED.""Artists"", artists = EXCLUDED.artists,
""AlbumArtists"" = EXCLUDED.""AlbumArtists"", album_artists = EXCLUDED.album_artists,
""LUFS"" = EXCLUDED.""LUFS"", lufs = EXCLUDED.lufs,
""NormalizationGain"" = EXCLUDED.""NormalizationGain""", normalization_gain = EXCLUDED.normalization_gain",
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
} }
else else