baseline: OpenMU upstream b5a0961 (fresh source)

This commit is contained in:
Acentech Dev
2026-07-14 19:00:35 +03:00
parent 450402e47d
commit 36fc125d5c
11968 changed files with 748705 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
// <copyright file="AccountContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// A context which is used by each account. This context does not track and save configuration data.
/// </summary>
internal class AccountContext : EntityDataContext
{
/// <inheritdoc/>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
var types = modelBuilder.Model.GetEntityTypes().ToList();
var configTypes = types.Where(t => t.ClrType.BaseType?.Namespace?.Contains("Configuration") ?? (t.ClrType.BaseType?.Namespace?.Contains("AttributeSystem") ?? false)).ToList();
foreach (var type in configTypes)
{
modelBuilder.Ignore(type.ClrType);
}
modelBuilder.Ignore(typeof(AttributeDefinition));
modelBuilder.Ignore<Guild>();
modelBuilder.Ignore<GuildMember>();
modelBuilder.Entity<Item>(b => b.Ignore(e => e.RawItemStorage));
}
}

View File

@@ -0,0 +1,174 @@
// <copyright file="AccountRepository.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Linq;
using System.Threading;
using BCrypt.Net;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Persistence.EntityFramework.Json;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Repository for accounts.
/// </summary>
internal class AccountRepository : CachingGenericRepository<Account>
{
/// <summary>
/// Initializes a new instance of the <see cref="AccountRepository" /> class.
/// </summary>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="loggerFactory">The logger factory.</param>
public AccountRepository(IContextAwareRepositoryProvider repositoryProvider, ILoggerFactory loggerFactory)
: base(repositoryProvider, loggerFactory)
{
}
/// <inheritdoc />
public override async ValueTask<Account?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
(this.RepositoryProvider as ICacheAwareRepositoryProvider)?.EnsureCachesForCurrentGameConfiguration();
using var context = this.GetContext();
await context.Context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
try
{
var accountEntry = context.Context.ChangeTracker.Entries<Account>().FirstOrDefault(a => a.Entity.Id == id);
var account = accountEntry?.Entity;
if (account is null || accountEntry?.References.Any(reference => !reference.IsLoaded) is true)
{
if (account is not null)
{
context.Detach(account);
}
var objectLoader = new AccountJsonObjectLoader();
account = await objectLoader.LoadObjectAsync<Account>(id, context.Context, cancellationToken).ConfigureAwait(false);
if (account != null && !(context.Context.Entry(account) is { } entry && entry.State != EntityState.Detached))
{
context.Context.Attach(account);
}
}
return account;
}
finally
{
await context.Context.Database.CloseConnectionAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Gets the account by character name.
/// </summary>
/// <param name="characterName">The character name.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The account; otherwise, null.
/// </returns>
internal async ValueTask<DataModel.Entities.Account?> GetAccountByCharacterNameAsync(string characterName, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
using var context = this.GetContext();
var accountInfo = await context.Context.Set<Account>()
.AsNoTracking()
.FirstOrDefaultAsync(a => a.RawCharacters.Any(c => c.Name == characterName), cancellationToken)
.ConfigureAwait(false);
if (accountInfo != null)
{
return await this.GetByIdAsync(accountInfo.Id, cancellationToken).ConfigureAwait(false);
}
return null;
}
/// <summary>
/// Gets the account by login name if the password is correct.
/// </summary>
/// <param name="loginName">The login name.</param>
/// <param name="password">The password.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The account, if the password is correct. Otherwise, null.
/// </returns>
internal async ValueTask<DataModel.Entities.Account?> GetAccountByLoginNameAsync(string loginName, string password, CancellationToken cancellationToken = default)
{
using var context = this.GetContext();
return await this.LoadAccountByLoginNameByJsonQueryAsync(loginName, password, context, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Authenticates the account by login name and password, returning minimal state data without loading the full account.
/// </summary>
/// <param name="loginName">The login name.</param>
/// <param name="password">The password.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The <see cref="DataModel.Entities.AccountState"/> if credentials are valid; otherwise, null.</returns>
internal async ValueTask<DataModel.Entities.AccountState?> AuthenticateAsync(string loginName, string password, CancellationToken cancellationToken = default)
{
using var context = this.GetContext();
cancellationToken.ThrowIfCancellationRequested();
var accountInfo = await context.Context.Set<Account>()
.Where(a => a.LoginName == loginName)
.Select(a => new { a.PasswordHash, a.State })
.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (accountInfo is not null && BCrypt.Verify(password, accountInfo.PasswordHash))
{
return accountInfo.State;
}
return null;
}
/// <summary>
/// Gets the account by login name.
/// </summary>
/// <param name="loginName">The login name.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The account, if exists. Otherwise, null.
/// </returns>
internal async ValueTask<DataModel.Entities.Account?> GetAccountByLoginNameAsync(string loginName, CancellationToken cancellationToken = default)
{
using var context = this.GetContext();
var accountInfo = await context.Context.Set<Account>()
.Select(a => new { a.Id, a.LoginName })
.AsNoTracking()
.FirstOrDefaultAsync(a => a.LoginName == loginName, cancellationToken).ConfigureAwait(false);
if (accountInfo != null)
{
return await this.GetByIdAsync(accountInfo.Id, cancellationToken).ConfigureAwait(false);
}
return null;
}
private async ValueTask<Account?> LoadAccountByLoginNameByJsonQueryAsync(string loginName, string password, EntityFrameworkContextBase context, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var accountInfo = await context.Context.Set<Account>()
.Select(a => new { a.Id, a.LoginName, a.PasswordHash })
.AsNoTracking()
.FirstOrDefaultAsync(a => a.LoginName == loginName, cancellationToken).ConfigureAwait(false);
if (accountInfo != null && BCrypt.Verify(password, accountInfo.PasswordHash))
{
return await this.GetByIdAsync(accountInfo.Id, cancellationToken).ConfigureAwait(false);
}
return null;
}
}

View File

@@ -0,0 +1,119 @@
// <copyright file="CacheAwareRepositoryProvider.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// This provider holds two other repository providers:
/// One which provides repositories which actually load the data from the database,
/// and another one which returns repositories with cached data, based on the
/// loaded GameConfiguration. The CacheAwareRepositoryProvider first tries to retrieve
/// a repository for the cached data. If none is found, it takes the other.
/// </summary>
internal class CacheAwareRepositoryProvider : ICacheAwareRepositoryProvider, IContextAwareRepositoryProvider
{
private readonly ILoggerFactory _loggerFactory;
private readonly IRepositoryProvider _nonCachingRepositoryProvider;
private CachingRepositoryProvider _cachingRepositoryProvider;
/// <summary>
/// Initializes a new instance of the <see cref="CacheAwareRepositoryProvider"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="configurationChangeListener">The configuration change listener.</param>
public CacheAwareRepositoryProvider(ILoggerFactory loggerFactory, IConfigurationChangeListener? configurationChangeListener)
{
this._loggerFactory = loggerFactory;
this._cachingRepositoryProvider = new CachingRepositoryProvider(loggerFactory, this);
this._nonCachingRepositoryProvider = new NonCachingRepositoryProvider(loggerFactory, this, configurationChangeListener, this.ContextStack);
}
/// <inheritdoc />
public IContextStack ContextStack { get; } = new ContextStack();
/// <inheritdoc />
public IRepository? GetRepository(Type objectType)
{
if (this.ContextStack.GetCurrentContext() is EntityFrameworkContextBase { Context: ITypedContext editContext }
&& editContext.IsIncluded(objectType))
{
return this._nonCachingRepositoryProvider.GetRepository(objectType);
}
return this._cachingRepositoryProvider.GetRepository(objectType)
?? this._nonCachingRepositoryProvider.GetRepository(objectType);
}
/// <inheritdoc />
public IRepository<T>? GetRepository<T>()
where T : class
{
if (this.ContextStack.GetCurrentContext() is EntityFrameworkContextBase { Context: ITypedContext editContext }
&& (editContext.IsIncluded(typeof(T)) || editContext.IsIncluded(typeof(T).BaseType!)))
{
return this._nonCachingRepositoryProvider.GetRepository<T>();
}
return this._cachingRepositoryProvider.GetRepository<T>()
?? this._nonCachingRepositoryProvider.GetRepository<T>();
}
/// <inheritdoc />
public TRepository? GetRepository<T, TRepository>()
where T : class
where TRepository : IRepository
{
if (this.ContextStack.GetCurrentContext() is EntityFrameworkContextBase { Context: ITypedContext editContext }
&& (editContext.IsIncluded(typeof(T)) || editContext.IsIncluded(typeof(T).BaseType!)))
{
return this._nonCachingRepositoryProvider.GetRepository<T, TRepository>();
}
return this._cachingRepositoryProvider.GetRepository<T, TRepository>()
?? this._nonCachingRepositoryProvider.GetRepository<T, TRepository>();
}
/// <inheritdoc />
public void EnsureCachesForCurrentGameConfiguration()
{
this._cachingRepositoryProvider.EnsureCachesForCurrentGameConfiguration();
}
/// <inheritdoc />
public void ResetCache()
{
this._cachingRepositoryProvider = new CachingRepositoryProvider(this._loggerFactory, this);
}
/// <inheritdoc />
public async ValueTask UpdateCachedInstanceAsync(object changedInstance)
{
if (this._cachingRepositoryProvider.GetRepository(changedInstance.GetType().BaseType ?? changedInstance.GetType()) is IConfigurationTypeRepository repository)
{
repository.UpdateCachedInstances(changedInstance);
}
else
{
// not all types have an own repository.
var gameConfigRepo = this._cachingRepositoryProvider.GetRepository<GameConfiguration>();
if (gameConfigRepo is null)
{
return;
}
foreach (var config in await gameConfigRepo.GetAllAsync().ConfigureAwait(false))
{
var obj = config.GetObjectOfConfig(changedInstance) as IAssignable;
obj?.AssignValuesOf(changedInstance, config);
}
}
}
}

View File

@@ -0,0 +1,149 @@
// <copyright file="CachedRepository{T}.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Collections;
using System.Threading;
/// <summary>
/// A repository which caches all of its data in memory.
/// </summary>
/// <typeparam name="T">The type of the business object.</typeparam>
public class CachedRepository<T> : IRepository<T>
where T : class, IIdentifiable
{
private readonly IDictionary<Guid, T> _cache;
private bool _allLoaded;
private bool _loading;
/// <summary>
/// Initializes a new instance of the <see cref="CachedRepository{T}"/> class.
/// </summary>
/// <param name="baseRepository">The base repository.</param>
public CachedRepository(IRepository<T> baseRepository)
{
this.BaseRepository = baseRepository;
this._cache = new Dictionary<Guid, T>();
}
/// <summary>
/// Gets the underlying base repository.
/// </summary>
protected IRepository<T> BaseRepository { get; }
/// <inheritdoc/>
async ValueTask<IEnumerable> IRepository.GetAllAsync(CancellationToken cancellationToken = default)
{
return await this.GetAllAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default)
{
if (this._allLoaded)
{
return this._cache.Values;
}
if (this._loading)
{
while (this._loading)
{
await Task.Delay(10, cancellationToken).ConfigureAwait(false);
}
return this._cache.Values;
}
this._loading = true;
try
{
IEnumerable<T> values = await this.BaseRepository.GetAllAsync(cancellationToken).ConfigureAwait(false);
foreach (var obj in values)
{
if (!this._cache.ContainsKey(obj.Id))
{
this.AddToCache(obj.Id, obj);
}
}
}
finally
{
this._loading = false;
}
this._allLoaded = true;
return this._cache.Values;
}
/// <inheritdoc/>
public async ValueTask<T?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
await this.GetAllAsync(cancellationToken).ConfigureAwait(false);
this._cache.TryGetValue(id, out var result);
return result;
}
/// <inheritdoc/>
async ValueTask<object?> IRepository.GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await this.GetByIdAsync(id, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<bool> DeleteAsync(object obj)
{
if (obj is not IIdentifiable identifiable)
{
return false;
}
return await this.DeleteAsync(identifiable.Id).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<bool> DeleteAsync(Guid id)
{
if (!await this.BaseRepository.DeleteAsync(id).ConfigureAwait(false))
{
return false;
}
this.RemoveFromCache(id);
return true;
}
/// <summary>
/// Adds the object to the cache.
/// </summary>
/// <param name="id">The identifier.</param>
/// <param name="obj">The object.</param>
protected virtual void AddToCache(Guid id, T obj)
{
if (this._cache.TryGetValue(id, out var value))
{
if (Equals(value, obj))
{
throw new ArgumentException("Other object with same id is already in cache.");
}
}
else
{
this._cache.Add(id, obj);
}
}
/// <summary>
/// Removes the object from cache.
/// </summary>
/// <param name="id">The identifier.</param>
protected virtual void RemoveFromCache(Guid id)
{
this._cache.Remove(id);
}
}

View File

@@ -0,0 +1,43 @@
// <copyright file="CachingEntityFrameworkContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Implementation of <see cref="IContext"/> for the entity framework <see cref="PersistenceContextProvider"/>.
/// </summary>
/// <remarks>
/// TODO: Check if this class can be removed. It doesn't seem to have any additional logic to <see cref="EntityFrameworkContext"/>.
/// </remarks>
internal class CachingEntityFrameworkContext : EntityFrameworkContextBase
{
/// <summary>
/// Initializes a new instance of the <see cref="CachingEntityFrameworkContext" /> class.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="changeListener">The change listener.</param>
/// <param name="logger">The logger.</param>
public CachingEntityFrameworkContext(DbContext context, IContextAwareRepositoryProvider repositoryProvider, IConfigurationChangeListener? changeListener, ILogger<CachingEntityFrameworkContext> logger)
: base(context, repositoryProvider, true, changeListener, logger)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="CachingEntityFrameworkContext" /> class.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="isOwner">if set to <c>true</c> this instance owns the <paramref name="context" />.</param>
/// <param name="changeListener">The change listener.</param>
/// <param name="logger">The logger.</param>
public CachingEntityFrameworkContext(DbContext context, IContextAwareRepositoryProvider repositoryProvider, bool isOwner, IConfigurationChangeListener? changeListener, ILogger<CachingEntityFrameworkContext> logger)
: base(context, repositoryProvider, isOwner, changeListener, logger)
{
}
}

View File

@@ -0,0 +1,89 @@
// <copyright file="CachingGameConfigurationRepository.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Threading;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Persistence.EntityFramework.Json;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// The game configuration repository, which loads the configuration by using the
/// <see cref="JsonObjectLoader"/>, to speed up loading the whole object graph.
/// </summary>
internal class CachingGameConfigurationRepository : CachingGenericRepository<GameConfiguration>
{
private readonly JsonObjectLoader _objectLoader;
/// <summary>
/// Initializes a new instance of the <see cref="CachingGameConfigurationRepository" /> class.
/// </summary>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="loggerFactory">The logger factory.</param>
public CachingGameConfigurationRepository(IContextAwareRepositoryProvider repositoryProvider, ILoggerFactory loggerFactory)
: base(repositoryProvider, loggerFactory)
{
this._objectLoader = new GameConfigurationJsonObjectLoader();
}
/// <inheritdoc />
public override async ValueTask<GameConfiguration?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (this.RepositoryProvider.ContextStack.GetCurrentContext() is not EntityFrameworkContextBase currentContext)
{
throw new InvalidOperationException("There is no current context set.");
}
var database = currentContext.Context.Database;
await database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
try
{
return await this._objectLoader.LoadObjectAsync<GameConfiguration>(id, currentContext.Context, cancellationToken).ConfigureAwait(false);
}
finally
{
await database.CloseConnectionAsync().ConfigureAwait(false);
}
}
/// <inheritdoc />
public override async ValueTask<IEnumerable<GameConfiguration>> GetAllAsync(CancellationToken cancellationToken = default)
{
if (this.RepositoryProvider.ContextStack.GetCurrentContext() is not EntityFrameworkContextBase currentContext)
{
throw new InvalidOperationException("There is no current context set.");
}
var database = currentContext.Context.Database;
await database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
try
{
var configs = (await this._objectLoader.LoadAllObjectsAsync<GameConfiguration>(currentContext.Context, cancellationToken).ConfigureAwait(false)).ToList();
var oldConfig = ((EntityDataContext)currentContext.Context).CurrentGameConfiguration;
try
{
configs.ForEach(config =>
{
((EntityDataContext)currentContext.Context).CurrentGameConfiguration = config;
(this.RepositoryProvider as ICacheAwareRepositoryProvider)?.EnsureCachesForCurrentGameConfiguration();
});
}
finally
{
((EntityDataContext)currentContext.Context).CurrentGameConfiguration = oldConfig;
}
return configs;
}
finally
{
await database.CloseConnectionAsync().ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,48 @@
// <copyright file="CachingGenericRepository.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Reflection;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Logging;
/// <summary>
/// A generic repository which wraps the access to the dbset of the <see cref="EntityDataContext"/>.
/// Entities are getting eagerly (=completely) loaded automatically.
/// </summary>
/// <typeparam name="T">The type which this repository should manage.</typeparam>
internal class CachingGenericRepository<T> : GenericRepositoryBase<T>
where T : class
{
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Initializes a new instance of the <see cref="CachingGenericRepository{T}" /> class.
/// </summary>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="loggerFactory">The logger factory.</param>
public CachingGenericRepository(IContextAwareRepositoryProvider repositoryProvider, ILoggerFactory loggerFactory)
: base(repositoryProvider, loggerFactory.CreateLogger(MethodBase.GetCurrentMethod()?.DeclaringType ?? typeof(CachingGenericRepository<T>)))
{
this._loggerFactory = loggerFactory;
}
/// <summary>
/// Gets a context to work with. If no context is currently registered at the repository provider, a new one is getting created.
/// </summary>
/// <returns>The context.</returns>
protected override EntityFrameworkContextBase GetContext()
{
var context = this.RepositoryProvider.ContextStack.GetCurrentContext() as EntityFrameworkContextBase;
return new CachingEntityFrameworkContext(context?.Context ?? new EntityDataContext(), this.RepositoryProvider, context is null, null, this._loggerFactory.CreateLogger<CachingEntityFrameworkContext>());
}
/// <inheritdoc/>
protected override IEnumerable<INavigation> GetNavigations(EntityEntry entityEntry)
{
return this.FullEntityType.GetNavigations();
}
}

View File

@@ -0,0 +1,94 @@
// <copyright file="CachingRepositoryProvider.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Persistence.EntityFramework.Json;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// A repository provider which utilizes repositories which use the entity framework core to do data access.
/// </summary>
/// <remarks>
/// We create the most repositories by the following convention:
/// - Configuration data repositories: We create repositories which retrieve data from the current GameConfiguration to save memory and less database queries.
/// - Entity data (Accounts, etc.): We create repositories which retrieves every object at every access from the database.
/// </remarks>
internal class CachingRepositoryProvider : RepositoryProvider
{
private readonly IContextAwareRepositoryProvider _parent;
/// <summary>
/// Initializes a new instance of the <see cref="CachingRepositoryProvider" /> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="parent">The parent context aware repository provider.</param>
public CachingRepositoryProvider(ILoggerFactory loggerFactory, IContextAwareRepositoryProvider parent)
: base(loggerFactory, null, parent.ContextStack)
{
this._parent = parent;
}
/// <summary>
/// Ensures the caches for current game configuration.
/// It's meant to fill the caches also in <see cref="ConfigurationIdReferenceResolver"/>.
/// </summary>
public void EnsureCachesForCurrentGameConfiguration()
{
foreach (var repository in this.Repositories.Values.OfType<IConfigurationTypeRepository>())
{
repository.EnsureCacheForCurrentConfiguration();
}
}
/// <summary>
/// Registers the repositories.
/// </summary>
protected override void Initialize()
{
this.RegisterRepository(new CachedRepository<GameConfiguration>(new CachingGameConfigurationRepository(this._parent, this.LoggerFactory)));
this.RegisterRepository(new CachingGenericRepository<GameServerConfiguration>(this._parent, this.LoggerFactory));
this.RegisterRepository(new CachingGenericRepository<GameClientDefinition>(this._parent, this.LoggerFactory));
this.RegisterRepository(new CachingGenericRepository<ConnectServerDefinition>(this._parent, this.LoggerFactory));
this.RegisterRepository(new CachingGenericRepository<ChatServerDefinition>(this._parent, this.LoggerFactory));
this.RegisterRepository(new CachingGenericRepository<ChatServerEndpoint>(this._parent, this.LoggerFactory));
this.RegisterRepository(new CachingGenericRepository<GameServerEndpoint>(this._parent, this.LoggerFactory));
this.RegisterRepository(new GameServerDefinitionRepository(this._parent, this.LoggerFactory));
this.RegisterRepository(new ConfigurationTypeRepository<ItemOptionDefinition>(this._parent, this.LoggerFactory, config => config.RawItemOptions));
this.RegisterRepository(new ConfigurationTypeRepository<IncreasableItemOption>(
this._parent,
this.LoggerFactory,
config => config.RawItemOptions.SelectMany(o => o.RawPossibleOptions).Distinct().ToList()));
this.RegisterRepository(new ConfigurationTypeRepository<AttributeDefinition>(this._parent, this.LoggerFactory, config => config.RawAttributes));
this.RegisterRepository(new ConfigurationTypeRepository<AttributeRelationship>(this._parent, this.LoggerFactory, config => config.RawGlobalAttributeCombinations));
this.RegisterRepository(new ConfigurationTypeRepository<ConstValueAttribute>(this._parent, this.LoggerFactory, config => config.RawGlobalBaseAttributeValues));
this.RegisterRepository(new ConfigurationTypeRepository<DropItemGroup>(this._parent, this.LoggerFactory, config => config.RawDropItemGroups));
this.RegisterRepository(new ConfigurationTypeRepository<CharacterClass>(this._parent, this.LoggerFactory, config => config.RawCharacterClasses));
this.RegisterRepository(new ConfigurationTypeRepository<ItemOptionType>(this._parent, this.LoggerFactory, config => config.RawItemOptionTypes));
this.RegisterRepository(new ConfigurationTypeRepository<ItemSetGroup>(this._parent, this.LoggerFactory, config => config.RawItemSetGroups));
this.RegisterRepository(new ConfigurationTypeRepository<ItemOfItemSet>(this._parent, this.LoggerFactory, config => config.RawItemSetGroups.SelectMany(g => g.RawItems).ToList()));
this.RegisterRepository(new ConfigurationTypeRepository<ItemSlotType>(this._parent, this.LoggerFactory, config => config.RawItemSlotTypes));
this.RegisterRepository(new ConfigurationTypeRepository<ItemDefinition>(this._parent, this.LoggerFactory, config => config.RawItems));
this.RegisterRepository(new ConfigurationTypeRepository<JewelMix>(this._parent, this.LoggerFactory, config => config.RawJewelMixes));
this.RegisterRepository(new ConfigurationTypeRepository<MagicEffectDefinition>(this._parent, this.LoggerFactory, config => config.RawMagicEffects));
this.RegisterRepository(new ConfigurationTypeRepository<GameMapDefinition>(this._parent, this.LoggerFactory, config => config.RawMaps));
this.RegisterRepository(new ConfigurationTypeRepository<MasterSkillRoot>(this._parent, this.LoggerFactory, config => config.RawMasterSkillRoots));
this.RegisterRepository(new ConfigurationTypeRepository<MonsterDefinition>(this._parent, this.LoggerFactory, config => config.RawMonsters));
this.RegisterRepository(new ConfigurationTypeRepository<Skill>(this._parent, this.LoggerFactory, config => config.RawSkills));
this.RegisterRepository(new ConfigurationTypeRepository<PlugInConfiguration>(this._parent, this.LoggerFactory, config => config.RawPlugInConfigurations));
this.RegisterRepository(new ConfigurationTypeRepository<QuestDefinition>(this._parent, this.LoggerFactory, config => config.RawMonsters.SelectMany(m => m.RawQuests).ToList()));
this.RegisterRepository(new ConfigurationTypeRepository<Item>(this._parent, this.LoggerFactory, config => config.RawMonsters.SelectMany(m => m.RawMerchantStore?.RawItems ?? Enumerable.Empty<Item>()).ToList()));
base.Initialize();
}
/// <inheritdoc/>
protected override IRepository CreateGenericRepository(Type entityType, IContextAwareRepositoryProvider repositoryProvider)
{
var repositoryType = typeof(CachingGenericRepository<>).MakeGenericType(entityType);
return (IRepository)Activator.CreateInstance(repositoryType, this._parent, this.LoggerFactory)!;
}
}

View File

@@ -0,0 +1,152 @@
// <copyright file="ConfigFileDatabaseConnectionStringProvider.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.IO;
using System.Threading;
using System.Xml;
using System.Xml.Serialization;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Implementation of <see cref="IDatabaseConnectionSettingProvider"/> which takes the connection strings out of
/// a configuration file, usually <c>ConnectionSettings.xml</c>.
/// The settings can be influenced by the environment variables <c>DB_HOST</c>, <c>DB_ADMIN_USER</c> and <c>DB_ADMIN_PW</c>.
/// </summary>
public class ConfigFileDatabaseConnectionStringProvider : IDatabaseConnectionSettingProvider
{
private const string DbHostVariableName = "DB_HOST";
private const string DbAdminUserVariableName = "DB_ADMIN_USER";
private const string DbAdminPasswordVariableName = "DB_ADMIN_PW";
private readonly string _fileName;
private IDictionary<Type, ConnectionSetting>? _settings;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigFileDatabaseConnectionStringProvider"/> class.
/// </summary>
public ConfigFileDatabaseConnectionStringProvider()
: this("ConnectionSettings.xml")
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ConfigFileDatabaseConnectionStringProvider"/> class.
/// </summary>
/// <param name="fileName">Name of the file.</param>
public ConfigFileDatabaseConnectionStringProvider(string fileName)
{
this._fileName = fileName;
}
/// <inheritdoc />
public Task? Initialization { get; private set; }
private IDictionary<Type, ConnectionSetting> Settings => this._settings ??= this.LoadSettings();
/// <inheritdoc />
public async Task InitializeAsync(CancellationToken cancellationToken)
{
this.Initialization = Task.Run(() => this._settings = this.LoadSettings(), cancellationToken);
await this.Initialization.ConfigureAwait(false);
ConnectionConfigurator.Initialize(this);
}
/// <inheritdoc />
public ConnectionSetting GetConnectionSetting<TContextType>()
where TContextType : DbContext
{
return this.GetConnectionSetting(typeof(TContextType));
}
/// <inheritdoc />
public ConnectionSetting GetConnectionSetting(Type contextType)
{
if (this.Settings.TryGetValue(contextType, out var result))
{
return result;
}
throw new ArgumentException("DB Configuration not found for type {0}", contextType.FullName);
}
private IDictionary<Type, ConnectionSetting> LoadSettings()
{
var settings = new XmlReaderSettings
{
IgnoreComments = true,
IgnoreProcessingInstructions = true,
IgnoreWhitespace = true,
DtdProcessing = DtdProcessing.Ignore,
CloseInput = true,
XmlResolver = null,
};
var result = new Dictionary<Type, ConnectionSetting>();
var configurationFilePath = Path.Combine(Path.GetDirectoryName(new Uri(typeof(ConnectionConfigurator).Assembly.Location!).LocalPath)!, this._fileName);
using var xmlReader = XmlReader.Create(File.OpenRead(configurationFilePath), settings);
var serializer = new XmlSerializer(typeof(ConnectionSettings));
if (serializer.CanDeserialize(xmlReader))
{
if (serializer.Deserialize(xmlReader) is ConnectionSettings xmlSettings)
{
foreach (var setting in xmlSettings.Connections)
{
if (setting.ContextTypeName is null)
{
throw new InvalidDataException("ContextTypeName is null.");
}
if (setting.ConnectionString is null)
{
throw new InvalidDataException("ConnectionString is null.");
}
if (Type.GetType(setting.ContextTypeName, false, true) is { } contextType)
{
this.ApplyEnvironmentVariables(setting);
result.Add(contextType, setting);
}
else if (setting.ContextTypeName.EndsWith($".{nameof(TypedContext)}") || setting.ContextTypeName.EndsWith($".{nameof(TypedContext)}^1"))
{
this.ApplyEnvironmentVariables(setting);
result.Add(typeof(TypedContext), setting);
}
else
{
throw new InvalidDataException($"Unknown context type: {setting.ContextTypeName}");
}
}
}
}
return result;
}
private void ApplyEnvironmentVariables(ConnectionSetting setting)
{
if (Environment.GetEnvironmentVariable(DbHostVariableName) is { } dbHost
&& !string.IsNullOrEmpty(dbHost))
{
setting.ConnectionString = setting.ConnectionString!.Replace("Server=localhost;", $"Server={dbHost};");
}
if (setting.ConnectionString!.Contains("User Id=postgres;"))
{
if (Environment.GetEnvironmentVariable(DbAdminUserVariableName) is { } dbAdminUser
&& !string.IsNullOrEmpty(dbAdminUser))
{
setting.ConnectionString = setting.ConnectionString.Replace("User Id=postgres;", $"User Id={dbAdminUser};");
}
if (Environment.GetEnvironmentVariable(DbAdminPasswordVariableName) is { } dbAdminPassword
&& !string.IsNullOrEmpty(dbAdminPassword))
{
setting.ConnectionString = setting.ConnectionString.Replace("Password=admin;", $"Password={dbAdminPassword};");
}
}
}
}

View File

@@ -0,0 +1,97 @@
// <copyright file="ConfigurationChangeListener.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.IO;
using Microsoft.EntityFrameworkCore.Metadata;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Class which listens to changes within the <see cref="GameConfiguration"/>,
/// updates the cached instances and publishes them to the <see cref="IConfigurationChangePublisher"/>.
/// </summary>
public class ConfigurationChangeListener : IConfigurationChangeListener
{
private readonly Lazy<IPersistenceContextProvider> _contextProvider;
private readonly IConfigurationChangePublisher _changePublisher;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationChangeListener"/> class.
/// </summary>
/// <param name="contextProvider">The context provider.</param>
/// <param name="changePublisher">The change publisher.</param>
public ConfigurationChangeListener(Lazy<IPersistenceContextProvider> contextProvider, IConfigurationChangePublisher changePublisher)
{
this._contextProvider = contextProvider;
this._changePublisher = changePublisher;
}
/// <inheritdoc />
public async ValueTask ConfigurationChangedAsync(Type type, Guid id, object configuration, object? parent)
{
var repositoryProvider = this._contextProvider.Value.RepositoryProvider;
if (repositoryProvider is ICacheAwareRepositoryProvider cacheAwareRepositoryProvider)
{
await cacheAwareRepositoryProvider.UpdateCachedInstanceAsync(configuration).ConfigureAwait(false);
}
await this._changePublisher.ConfigurationChangedAsync(type, id, configuration).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask ConfigurationAddedAsync(Type type, Guid id, object configuration, object? parent, INavigationBase? parentCollectionNavigation)
{
if (parentCollectionNavigation?.GetCollectionAccessor() is { } collectionAccessor
&& (parent?.GetId() ?? parent as Guid?) is { } parentId)
{
using var configContext = this._contextProvider.Value.CreateNewConfigurationContext();
var gameConfiguration = (await configContext.GetAsync<GameConfiguration>().ConfigureAwait(false)).FirstOrDefault();
if (gameConfiguration is null)
{
return;
}
using var context = this._contextProvider.Value.CreateNewContext(gameConfiguration);
var cachedParent = await context.GetByIdAsync(parentId, parentCollectionNavigation.DeclaringEntityType.ClrType).ConfigureAwait(false);
if (cachedParent is not null)
{
var cachedConfiguration = configContext.CreateNew(type);
if (cachedConfiguration is IAssignable assignable)
{
assignable.AssignValuesOf(configuration, gameConfiguration);
}
else
{
throw new InvalidOperationException($"Configuration type {type} is not assignable.");
}
collectionAccessor.Add(cachedParent, cachedConfiguration, false);
}
}
await this._changePublisher.ConfigurationAddedAsync(type, id, configuration).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask ConfigurationRemovedAsync(Type type, Guid id, object? parent, INavigationBase? parentCollectionNavigation)
{
using var configContext = this._contextProvider.Value.CreateNewConfigurationContext();
var gameConfiguration = (await configContext.GetAsync<GameConfiguration>().ConfigureAwait(false)).First();
using var context = this._contextProvider.Value.CreateNewContext(gameConfiguration);
if (parentCollectionNavigation?.GetCollectionAccessor() is { } collectionAccessor
&& (parent?.GetId() ?? parent as Guid?) is { } parentId
&& await context.GetByIdAsync(parentId, parentCollectionNavigation.DeclaringEntityType.ClrType).ConfigureAwait(false) is { } cachedParent
&& await context.GetByIdAsync(id, type).ConfigureAwait(false) is { } cachedEntity)
{
collectionAccessor.Remove(cachedParent, cachedEntity);
}
await this._changePublisher.ConfigurationRemovedAsync(type, id).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,21 @@
// <copyright file="ConfigurationContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Context to access the game configuration (read-only).
/// </summary>
public class ConfigurationContext : EntityDataContext
{
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationContext"/> class.
/// </summary>
public ConfigurationContext()
{
this.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
}

View File

@@ -0,0 +1,176 @@
// <copyright file="ConfigurationTypeRepository.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Collections;
using System.Collections.Concurrent;
using System.IO;
using System.Threading;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence.EntityFramework.Json;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// A repository which gets its data from the <see cref="EntityDataContext.CurrentGameConfiguration"/>, without additionally touching the database.
/// </summary>
/// <typeparam name="T">The data object type.</typeparam>
internal class ConfigurationTypeRepository<T> : IRepository<T>, IConfigurationTypeRepository
where T : class
{
private readonly IContextAwareRepositoryProvider _repositoryProvider;
private readonly Func<GameConfiguration, ICollection<T>> _collectionSelector;
/// <summary>
/// A cache which holds each <typeparamref name="T"/> in a dictionary to be able to access it by faster by id.
/// There is one cache for each <see cref="GameConfiguration"/>, because it could be possible that more than one
/// <see cref="GameConfiguration"/> could be hosted by one server.
/// </summary>
private readonly IDictionary<GameConfiguration, IDictionary<Guid, T>> _cache = new ConcurrentDictionary<GameConfiguration, IDictionary<Guid, T>>();
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationTypeRepository{T}" /> class.
/// </summary>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="collectionSelector">The collection selector which returns the collection of <typeparamref name="T" /> of a <see cref="GameConfiguration" />.</param>
public ConfigurationTypeRepository(IContextAwareRepositoryProvider repositoryProvider, ILoggerFactory loggerFactory, Func<GameConfiguration, ICollection<T>> collectionSelector)
{
this._repositoryProvider = repositoryProvider;
this._collectionSelector = collectionSelector;
this._logger = loggerFactory.CreateLogger(this.GetType());
}
/// <summary>
/// Gets all objects by using the <see cref="_collectionSelector"/> to the current <see cref="GameConfiguration"/>.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>All objects of the repository.</returns>
public ValueTask<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult<IEnumerable<T>>(this._collectionSelector(this.GetCurrentGameConfiguration()));
}
/// <inheritdoc/>
async ValueTask<IEnumerable> IRepository.GetAllAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return await this.GetAllAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public ValueTask<T?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
this.EnsureCacheForCurrentConfiguration();
var dictionary = this._cache[this.GetCurrentGameConfiguration()];
if (dictionary.TryGetValue(id, out var result))
{
return ValueTask.FromResult<T?>(result);
}
return ValueTask.FromResult<T?>(null);
}
/// <inheritdoc />
public async ValueTask<bool> DeleteAsync(object obj)
{
if (obj is not T item)
{
return false;
}
var gameConfiguration = this.GetCurrentGameConfiguration();
var collection = this._collectionSelector(gameConfiguration);
return collection.Remove(item);
}
/// <inheritdoc />
public async ValueTask<bool> DeleteAsync(Guid id)
{
if (await this.GetByIdAsync(id).ConfigureAwait(false) is { } obj)
{
return await this.DeleteAsync(obj).ConfigureAwait(false);
}
return false;
}
/// <inheritdoc />
async ValueTask<object?> IRepository.GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await this.GetByIdAsync(id, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Ensures the cache for the current configuration.
/// TODO: Call this at a better place and time - so that we can remove this check before every GetById.
/// </summary>
public void EnsureCacheForCurrentConfiguration()
{
var configuration = this.GetCurrentGameConfiguration();
if (this._cache.ContainsKey(configuration))
{
return;
}
lock (this._cache)
{
if (this._cache.ContainsKey(configuration))
{
return;
}
var dictionary = this._collectionSelector(configuration)
.Where(item => item is IIdentifiable)
.ToDictionary(item => ((IIdentifiable)item).Id, item => item);
this._cache.Add(configuration, dictionary);
foreach (var item in dictionary.Values)
{
ConfigurationIdReferenceResolver.Instance.AddReference((IIdentifiable)item);
}
}
}
/// <inheritdoc />
public void UpdateCachedInstances(object changedInstance)
{
foreach (var (gameConfiguration, cache) in this._cache)
{
if (!cache.TryGetValue(changedInstance.GetId(), out var cachedInstance))
{
this._logger.LogDebug("Cached instance '{cachedInstance}' couldn't be updated because it wasn't found.", cachedInstance);
return;
}
if (cachedInstance is not IAssignable<T> assignable)
{
// todo: implement this for all types
this._logger.LogWarning("Cached instance '{cachedInstance}' couldn't be updated because it doesn't implement {IAssignable}.", cachedInstance, typeof(IAssignable<T>));
return;
}
assignable.AssignValuesOf((T)changedInstance, gameConfiguration);
this._logger.LogInformation("Updated cached instance '{cachedInstance}'.", cachedInstance);
}
}
private GameConfiguration GetCurrentGameConfiguration()
{
var context = (this._repositoryProvider.ContextStack.GetCurrentContext() as CachingEntityFrameworkContext)?.Context as EntityDataContext;
if (context is null)
{
throw new InvalidOperationException("This repository can only be used within an account context.");
}
return context.CurrentGameConfiguration ?? throw new InvalidOperationException("There is no current configuration.");
}
}

View File

@@ -0,0 +1,145 @@
// <copyright file="ConnectionConfigurator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Nito.AsyncEx.Synchronous;
/// <summary>
/// The database roles.
/// </summary>
public enum DatabaseRole
{
/// <summary>
/// The admin role which can create databases and other roles.
/// </summary>
Admin,
/// <summary>
/// The account role which can load and save account data.
/// </summary>
Account,
/// <summary>
/// The role which can load configuration data.
/// </summary>
Configuration,
/// <summary>
/// The role which can load guild data.
/// </summary>
Guild,
/// <summary>
/// The role which can load friend data.
/// </summary>
Friend,
}
/// <summary>
/// The database connection configurator which loads the configuration from a file.
/// TODO: Make class non-static.
/// </summary>
public static class ConnectionConfigurator
{
private static IDatabaseConnectionSettingProvider? _provider;
/// <summary>
/// Gets a value indicating whether this instance is initialized.
/// </summary>
public static bool IsInitialized => _provider is not null;
private static IDatabaseConnectionSettingProvider Provider => _provider ?? throw new InvalidOperationException("Call Initialize before.");
/// <summary>
/// Initializes this instance.
/// </summary>
/// <param name="provider">The <see cref="IDatabaseConnectionSettingProvider"/> which provides the required connection settings.</param>
public static void Initialize(IDatabaseConnectionSettingProvider provider)
{
if (_provider is not null)
{
throw new InvalidOperationException("provider is initialized already");
}
_provider = provider;
}
/// <summary>
/// Gets the name of the role from the configured connection string.
/// </summary>
/// <param name="role">The role.</param>
/// <returns>The name of the role from the configured connection string.</returns>
public static string GetRoleName(DatabaseRole role)
{
Provider.Initialization?.WaitWithoutException();
var settings = Provider.GetConnectionSetting(GetContextTypeOfRole(role));
return Regex.Match(settings.ConnectionString!, "User Id=([^;]+?);").Groups[1].Value;
}
/// <summary>
/// Gets the password password of the role from the configured connection string.
/// </summary>
/// <param name="role">The role.</param>
/// <returns>The password password of the role from the configured connection string.</returns>
public static string GetRolePassword(DatabaseRole role)
{
Provider.Initialization?.WaitWithoutException();
var settings = Provider.GetConnectionSetting(GetContextTypeOfRole(role));
return Regex.Match(settings.ConnectionString!, "Password=([^;]+?);").Groups[1].Value;
}
/// <summary>
/// Configures the specified options builder.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="optionsBuilder">The options builder.</param>
/// <exception cref="NotImplementedException">At the moment only Npgsql engine (PostgreSQL) is implemented.</exception>
internal static void Configure(this DbContext context, DbContextOptionsBuilder optionsBuilder)
{
// see https://github.com/dotnet/efcore/issues/34431
optionsBuilder.ConfigureWarnings(a => a.Ignore(RelationalEventId.PendingModelChangesWarning));
var type = context.GetType();
if (type.IsGenericType)
{
type = type.GetGenericTypeDefinition();
}
Provider.Initialization?.WaitWithoutException();
if (Provider.GetConnectionSetting(type) is { } setting)
{
switch (setting.DatabaseEngine)
{
case DatabaseEngine.Npgsql:
optionsBuilder.UseNpgsql(setting.ConnectionString!);
optionsBuilder.ReplaceService<IMigrationsSqlGenerator, MyNpgsqlMigrationsSqlGenerator>();
optionsBuilder.ReplaceService<IModelCacheKeyFactory, MyModelCacheKeyFactory>();
return;
default:
throw new NotImplementedException("At the moment only Npgsql engine (PostgreSQL) is implemented.");
}
}
throw new ArgumentException($"No configuration found for context type {context.GetType()}", nameof(context));
}
private static Type GetContextTypeOfRole(DatabaseRole role)
{
return role switch
{
DatabaseRole.Account => typeof(AccountContext),
DatabaseRole.Admin => typeof(EntityDataContext),
DatabaseRole.Configuration => typeof(ConfigurationContext),
DatabaseRole.Guild => typeof(GuildContext),
DatabaseRole.Friend => typeof(FriendContext),
_ => throw new ArgumentException($"Role {role} unknown."),
};
}
}

View File

@@ -0,0 +1,56 @@
// <copyright file="ConnectionSetting.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.ComponentModel;
using System.Xml.Serialization;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// The configured database engine.
/// </summary>
[Serializable]
[XmlType(Namespace = "http://www.munique.net/ConnectionSettings")]
public enum DatabaseEngine
{
/// <summary>
/// The NPGSQL engine (PostgreSQL).
/// </summary>
Npgsql,
/// <summary>
/// The MSSQL server engine.
/// </summary>
SqlServer,
/// <summary>
/// The in memory engine (could be used for testing).
/// </summary>
InMemory,
}
/// <summary>
/// A database connection setting for the specified role.
/// </summary>
[Serializable]
[XmlType(Namespace = "http://www.munique.net/ConnectionSettings")]
public class ConnectionSetting
{
/// <summary>
/// Gets or sets the name of the type of the <see cref="DbContext"/>.
/// </summary>
public string? ContextTypeName { get; set; }
/// <summary>
/// Gets or sets the connection string which should be used for the DbContext.
/// </summary>
public string? ConnectionString { get; set; }
/// <summary>
/// Gets or sets the database engine.
/// </summary>
[DefaultValue(DatabaseEngine.Npgsql)]
public DatabaseEngine DatabaseEngine { get; set; }
}

View File

@@ -0,0 +1,41 @@
// <copyright file="ConnectionSettings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Runtime.Serialization;
using System.Xml.Serialization;
/// <summary>
/// The database connection settings xml serialization class.
/// </summary>
[Serializable]
[XmlType(AnonymousType = true, Namespace = "http://www.munique.net/ConnectionSettings")]
[XmlRoot(Namespace = "http://www.munique.net/ConnectionSettings", IsNullable = false)]
public class ConnectionSettings : IDeserializationCallback
{
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionSettings"/> class.
/// </summary>
public ConnectionSettings()
{
this.Connections = new ConnectionSetting[0];
}
/// <summary>
/// Gets or sets the database connections.
/// </summary>
[XmlArray(IsNullable = false)]
[XmlArrayItem("Connection", IsNullable = false)]
public ConnectionSetting[] Connections { get; set; }
/// <summary>
/// Runs when the entire object graph has been deserialized.
/// </summary>
/// <param name="sender">The object that initiated the callback. The functionality for this parameter is not currently implemented.</param>
public void OnDeserialization(object? sender)
{
this.Connections ??= new ConnectionSetting[0];
}
}

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8" ?>
<ConnectionSettings xmlns="http://www.munique.net/ConnectionSettings">
<Connections>
<Connection>
<!-- The user role of this context should have rights to create/update a database and user roles. -->
<ContextTypeName>MUnique.OpenMU.Persistence.EntityFramework.EntityDataContext</ContextTypeName>
<ConnectionString>Server=localhost;Port=5432;User Id=postgres;Password=admin;Database=openmu;Command Timeout=120;</ConnectionString>
<DatabaseEngine>Npgsql</DatabaseEngine>
</Connection>
<Connection>
<!-- The user role of this context should have rights to select/insert/update/delete data in all schemas. -->
<ContextTypeName>MUnique.OpenMU.Persistence.EntityFramework.TypedContext</ContextTypeName>
<ConnectionString>Server=localhost;Port=5432;User Id=postgres;Password=admin;Database=openmu;Command Timeout=120;</ConnectionString>
<DatabaseEngine>Npgsql</DatabaseEngine>
</Connection>
<Connection>
<!-- The user role of this context should have rights to select the config schema -->
<ContextTypeName>MUnique.OpenMU.Persistence.EntityFramework.ConfigurationContext</ContextTypeName>
<ConnectionString>Server=localhost;Port=5432;User Id=config;Password=config;Database=openmu;Command Timeout=120;</ConnectionString>
<DatabaseEngine>Npgsql</DatabaseEngine>
</Connection>
<Connection>
<!-- The account context should connect with a user with less privileges. It should not be allowed to edit configuration tables, for example. -->
<ContextTypeName>MUnique.OpenMU.Persistence.EntityFramework.AccountContext</ContextTypeName>
<ConnectionString>Server=localhost;Port=5432;User Id=account;Password=account;Database=openmu;Command Timeout=120;</ConnectionString>
<DatabaseEngine>Npgsql</DatabaseEngine>
</Connection>
<Connection>
<!-- The trade context should connect with a user with less privileges. It should just be allowed to edit the item table. -->
<ContextTypeName>MUnique.OpenMU.Persistence.EntityFramework.TradeContext</ContextTypeName>
<ConnectionString>Server=localhost;Port=5432;User Id=account;Password=account;Database=openmu;Command Timeout=120;</ConnectionString>
<DatabaseEngine>Npgsql</DatabaseEngine>
</Connection>
<Connection>
<!-- The friend context should connect with a user with less privileges. It should not be allowed to edit configuration tables, for example. -->
<ContextTypeName>MUnique.OpenMU.Persistence.EntityFramework.FriendContext</ContextTypeName>
<ConnectionString>Server=localhost;Port=5432;User Id=friend;Password=friend;Database=openmu;Command Timeout=120;</ConnectionString>
<DatabaseEngine>Npgsql</DatabaseEngine>
</Connection>
<Connection>
<!-- The guild context should connect with a user with less privileges. It should not be allowed to edit Guild and GuildMember and to read the Character table. -->
<ContextTypeName>MUnique.OpenMU.Persistence.EntityFramework.GuildContext</ContextTypeName>
<ConnectionString>Server=localhost;Port=5432;User Id=guild;Password=guild;Database=openmu;Command Timeout=120;</ConnectionString>
<DatabaseEngine>Npgsql</DatabaseEngine>
</Connection>
</Connections>
</ConnectionSettings>

View File

@@ -0,0 +1,62 @@
// <copyright file="ContextStack.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Threading;
/// <summary>
/// A stack for persistence contexts.
/// </summary>
internal sealed class ContextStack : IContextStack
{
private readonly AsyncLocal<Stack<IContext>> _localStack = new();
/// <summary>
/// Puts this context on the context stack of the current thread to be used for the upcoming repository actions.
/// If no context is on the context stack of the current thread, a new temporary context will be used for the action.
/// </summary>
/// <param name="context">The context.</param>
/// <returns>The disposable to end the usage.</returns>
public IDisposable UseContext(IContext context)
{
var contextsOfCurrentThread = this._localStack.Value ??= new Stack<IContext>();
contextsOfCurrentThread.Push(context);
return new ContextPop(contextsOfCurrentThread);
}
/// <summary>
/// Gets the current context of the current thread.
/// </summary>
/// <returns>The current context.</returns>
public IContext? GetCurrentContext()
{
var contextsOfCurrentThread = this._localStack.Value ??= new Stack<IContext>();
if (contextsOfCurrentThread is { Count: > 0 })
{
return contextsOfCurrentThread.Peek();
}
return null;
}
private sealed class ContextPop : IDisposable
{
private Stack<IContext>? _stack;
public ContextPop(Stack<IContext> stack)
{
this._stack = stack;
}
public void Dispose()
{
if (this._stack != null)
{
this._stack.Pop();
this._stack = null;
}
}
}
}

View File

@@ -0,0 +1,101 @@
// <copyright file="EntityDataContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.Persistence.EntityFramework.Extensions;
using MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Context for all types of the data model.
/// </summary>
public class EntityDataContext : ExtendedTypeContext
{
/// <summary>
/// Gets or sets the current game configuration.
/// This is used by the <see cref="ConfigurationTypeRepository{T}"/> which gets its data from the current game configuration.
/// </summary>
internal GameConfiguration? CurrentGameConfiguration { get; set; }
/// <inheritdoc/>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!ConnectionConfigurator.IsInitialized)
{
ConnectionConfigurator.Initialize(new ConfigFileDatabaseConnectionStringProvider());
}
base.OnConfiguring(optionsBuilder);
this.Configure(optionsBuilder);
}
/// <inheritdoc/>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<AppearanceData>(o => o.Ignore(p => p.CharacterStatus)); // todo
modelBuilder.Ignore<ConstantElement>();
modelBuilder.Ignore<SimpleElement>();
modelBuilder.Entity<Model.AttributeDefinition>();
modelBuilder.Entity<ConnectServerDefinition>();
modelBuilder.Entity<ChatServerDefinition>();
modelBuilder.Entity<MiniGameRankingEntry>();
modelBuilder.Entity<GameServerDefinition>(entity =>
{
entity.Property(e => e.PvpEnabled).HasDefaultValue(true);
});
modelBuilder.Entity<ConfigurationUpdate>().Apply();
modelBuilder.Entity<ConfigurationUpdateState>();
modelBuilder.Entity<SystemConfiguration>();
modelBuilder.Entity<PowerUpDefinitionValue>().Apply();
modelBuilder.Entity<Model.ConstValueAttribute>().Apply();
modelBuilder.Entity<Account>().Apply();
modelBuilder.Entity<Character>().Apply();
modelBuilder.Entity<CharacterClass>().Apply();
modelBuilder.Entity<DropItemGroup>().Apply();
modelBuilder.Entity<ExitGate>().Apply();
modelBuilder.Entity<GameConfiguration>().Apply();
modelBuilder.Entity<GameMapDefinition>().Apply();
modelBuilder.Entity<ItemCrafting>().Apply();
modelBuilder.Entity<ItemDefinition>().Apply();
modelBuilder.Entity<ItemLevelBonusTable>().Apply();
modelBuilder.Entity<ItemDropItemGroup>().Apply();
modelBuilder.Entity<ItemOptionCombinationBonus>().Apply();
modelBuilder.Entity<ItemOptionDefinition>().Apply();
modelBuilder.Entity<ItemOptionType>().Apply();
modelBuilder.Entity<ItemSetGroup>().Apply();
modelBuilder.Entity<ItemSlotType>().Apply();
modelBuilder.Entity<ItemStorage>().Apply();
modelBuilder.Entity<ItemBasePowerUpDefinition>().Apply();
modelBuilder.Entity<LevelBonus>().Apply();
modelBuilder.Entity<MagicEffectDefinition>().Apply();
modelBuilder.Entity<MasterSkillRoot>().Apply();
modelBuilder.Entity<MiniGameChangeEvent>().Apply();
modelBuilder.Entity<MiniGameDefinition>().Apply();
modelBuilder.Entity<MiniGameSpawnWave>().Apply();
modelBuilder.Entity<MonsterDefinition>().Apply();
modelBuilder.Entity<MonsterSpawnArea>().Apply();
modelBuilder.Entity<Skill>().Apply();
modelBuilder.Entity<SkillComboDefinition>().Apply();
modelBuilder.Entity<SkillEntry>().Apply();
modelBuilder.Entity<MasterSkillDefinition>().Apply();
modelBuilder.Entity<LetterBody>().Apply();
modelBuilder.Entity<LetterHeader>().Apply();
modelBuilder.Entity<QuestDefinition>().Apply();
modelBuilder.Entity<WarpInfo>().Apply();
// join entity keys:
this.AddJoinDefinitions(modelBuilder);
modelBuilder.UseGuidV7Ids();
GuildContext.ConfigureModel(modelBuilder);
FriendContext.ConfigureModel(modelBuilder);
}
}

View File

@@ -0,0 +1,24 @@
// <copyright file="EntityDataContextFactory.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore.Design;
/// <summary>
/// Design-time factory for <see cref="EntityDataContext"/>.
/// </summary>
public class EntityDataContextFactory : IDesignTimeDbContextFactory<EntityDataContext>
{
/// <inheritdoc />
public EntityDataContext CreateDbContext(string[] args)
{
if (!ConnectionConfigurator.IsInitialized)
{
ConnectionConfigurator.Initialize(new ConfigFileDatabaseConnectionStringProvider());
}
return new EntityDataContext();
}
}

View File

@@ -0,0 +1,28 @@
// <copyright file="EntityFrameworkContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// A implementation of <see cref="EntityFrameworkContextBase"/> which doesn't cache and always asks the database for objects.
/// </summary>
internal class EntityFrameworkContext : EntityFrameworkContextBase
{
/// <summary>
/// Initializes a new instance of the <see cref="EntityFrameworkContext" /> class.
/// </summary>
/// <param name="context">The db context.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="isOwner">If set to <c>true</c>, this instance owns the <see cref="EntityFrameworkContextBase.Context" />. That means it will be disposed when this instance will be disposed.</param>
/// <param name="changeListener">The change listener.</param>
public EntityFrameworkContext(DbContext context, ILoggerFactory loggerFactory, IContextAwareRepositoryProvider repositoryProvider, bool isOwner, IConfigurationChangeListener? changeListener)
: base(context, repositoryProvider, isOwner, changeListener, loggerFactory.CreateLogger<EntityFrameworkContext>())
{
}
}

View File

@@ -0,0 +1,416 @@
// <copyright file="EntityFrameworkContextBase.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Collections;
using System.Reflection;
using System.Threading;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel.Composition;
using MUnique.OpenMU.DataModel.Configuration;
using Nito.AsyncEx;
using Nito.Disposables;
/// <summary>
/// Abstract base class for an <see cref="IContext"/> which uses an <see cref="DbContext"/>.
/// </summary>
internal class EntityFrameworkContextBase : IContext
{
private readonly bool _isOwner;
private readonly IConfigurationChangeListener? _changeListener;
private readonly AsyncLock _lock = new();
private readonly ILogger _logger;
private bool _isDisposed;
private int _notificationSuspensions;
/// <summary>
/// Initializes a new instance of the <see cref="EntityFrameworkContextBase" /> class.
/// </summary>
/// <param name="context">The db context.</param>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="isOwner">If set to <c>true</c>, this instance owns the <see cref="Context" />. That means it will be disposed when this instance will be disposed.</param>
/// <param name="changeListener">The change listener.</param>
/// <param name="logger">The logger.</param>
protected EntityFrameworkContextBase(DbContext context, IContextAwareRepositoryProvider repositoryProvider, bool isOwner, IConfigurationChangeListener? changeListener, ILogger logger)
{
this.Context = context;
this.RepositoryProvider = repositoryProvider;
this._isOwner = isOwner;
this._changeListener = changeListener;
this._logger = logger;
// Ensure that the model is created.
_ = context.Model;
}
/// <summary>
/// Finalizes an instance of the <see cref="EntityFrameworkContextBase"/> class.
/// </summary>
~EntityFrameworkContextBase() => this.Dispose(false);
/// <inheritdoc />
public bool HasChanges => this.Context.ChangeTracker.HasChanges();
/// <summary>
/// Gets the entity framework context.
/// </summary>
internal DbContext Context { get; }
/// <summary>
/// Gets the repository provider.
/// </summary>
protected IContextAwareRepositoryProvider RepositoryProvider { get; }
/// <inheritdoc/>
public async ValueTask<bool> SaveChangesAsync(CancellationToken cancellationToken = default)
{
using var l = await this._lock.LockAsync();
// when we have a change publisher attached, we want to get the changed entries before accepting them.
// Otherwise, we can accept them.
var acceptChanges = true;
object? sender = null;
SavedChangesEventArgs? args = null;
if (this._changeListener is { })
{
this.Context.SavedChanges += OnSavedChanges;
acceptChanges = false;
}
try
{
await this.Context.SaveChangesAsync(acceptChanges, cancellationToken).ConfigureAwait(false);
if (args is not null)
{
await this.OnSavedChangesAsync(sender, args).ConfigureAwait(false);
}
}
finally
{
this.Context.SavedChanges -= OnSavedChanges;
}
return true;
void OnSavedChanges(object? s, SavedChangesEventArgs e)
{
sender = s;
args = e;
}
}
/// <inheritdoc />
public IDisposable SuspendChangeNotifications()
{
Interlocked.Increment(ref this._notificationSuspensions);
return new Disposable(() => Interlocked.Decrement(ref this._notificationSuspensions));
}
/// <inheritdoc />
public bool Detach(object item)
{
using var l = this._lock.Lock();
return this.DetachInternal(item);
}
/// <inheritdoc />
public void Attach(object item)
{
using var l = this._lock.Lock();
this.Context.Attach(item);
}
/// <inheritdoc />
public T CreateNew<T>(params object?[] args)
where T : class
{
using var l = this._lock.Lock();
var instance = typeof(CachingEntityFrameworkContext).Assembly.CreateNew<T>(args);
this.Context.Add(instance);
return instance;
}
/// <inheritdoc />
public object CreateNew(Type type, params object?[] args)
{
using var l = this._lock.Lock();
var instance = typeof(CachingEntityFrameworkContext).Assembly.CreateNew(type, args);
this.Context.Add(instance);
return instance;
}
/// <inheritdoc/>
public async ValueTask<bool> DeleteAsync<T>(T obj)
where T : class
{
using var l = await this._lock.LockAsync();
var result = false;
var entry = this.Context.Entry(obj);
if (entry.State == EntityState.Detached)
{
this.Context.Attach(obj);
entry = this.Context.Entry(obj);
}
switch (entry.State)
{
case EntityState.Detached:
return true;
case EntityState.Added:
this.DetachInternal(obj);
break;
default:
this.Context.Remove(obj);
this.ForEachAggregate(obj, a => this.Context.Remove(a));
break;
}
result = true;
return result;
}
/// <inheritdoc/>
public async Task<T?> GetByIdAsync<T>(Guid id, CancellationToken cancellationToken)
where T : class
{
using var l = await this._lock.LockAsync(cancellationToken);
using var context = this.RepositoryProvider.ContextStack.UseContext(this);
return await this.GetRepository<T>().GetByIdAsync(id, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<object?> GetByIdAsync(Guid id, Type type, CancellationToken cancellationToken)
{
using var l = await this._lock.LockAsync(cancellationToken).ConfigureAwait(false);
using var context = this.RepositoryProvider.ContextStack.UseContext(this);
return await this.GetRepository(type).GetByIdAsync(id, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<IEnumerable<T>> GetAsync<T>(CancellationToken cancellationToken)
where T : class
{
using var l = await this._lock.LockAsync(cancellationToken).ConfigureAwait(false);
using var context = this.RepositoryProvider.ContextStack.UseContext(this);
return await this.GetRepository<T>().GetAllAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<IEnumerable> GetAsync(Type type, CancellationToken cancellationToken)
{
using var l = await this._lock.LockAsync(cancellationToken).ConfigureAwait(false);
using var context = this.RepositoryProvider.ContextStack.UseContext(this);
return await this.GetRepository(type).GetAllAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public bool IsSupporting(Type type)
{
var currentSearchType = type;
do
{
if (currentSearchType is null)
{
break;
}
if (this.Context.Model.FindLeastDerivedEntityTypes(currentSearchType).FirstOrDefault() is not null)
{
return true;
}
if (currentSearchType.Name != currentSearchType.BaseType?.Name)
{
break;
}
currentSearchType = currentSearchType.BaseType;
}
while (currentSearchType != typeof(object));
return false;
}
/// <inheritdoc/>
public void Dispose()
{
if (!this._isDisposed)
{
this.Dispose(true);
}
this._isDisposed = true;
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases unmanaged and - optionally - managed resources.
/// </summary>
/// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
protected virtual void Dispose(bool dispose)
{
if (!dispose || !this._isOwner)
{
return;
}
this.Context.Dispose();
}
private bool DetachInternal(object item)
{
var entry = this.Context.Entry(item);
if (entry is null)
{
return false;
}
var previousState = entry.State;
entry.State = EntityState.Detached;
this.ForEachAggregate(item, obj => this.DetachInternal(obj));
return previousState != EntityState.Added;
}
private IRepository<T> GetRepository<T>()
where T : class
{
if (this.RepositoryProvider.GetRepository<T>() is { } repository)
{
return repository;
}
throw new RepositoryNotFoundException(typeof(T));
}
private IRepository GetRepository(Type type)
{
if (this.RepositoryProvider.GetRepository(type) is { } repository)
{
return repository;
}
throw new RepositoryNotFoundException(type);
}
private void ForEachAggregate(object obj, Action<object> action)
{
var aggregateProperties = obj.GetType()
.GetProperties(BindingFlags.FlattenHierarchy | BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.GetCustomAttribute<MemberOfAggregateAttribute>() is { }
|| p.Name.StartsWith("Joined"));
foreach (var propertyInfo in aggregateProperties)
{
var propertyValue = propertyInfo.GetMethod?.Invoke(obj, []);
if (propertyValue is IEnumerable enumerable)
{
foreach (var value in enumerable)
{
action(value);
}
}
else if (propertyValue is { })
{
action(propertyValue);
}
}
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async ValueTask OnSavedChangesAsync(object? sender, SavedChangesEventArgs e)
{
try
{
if (this._changeListener is null || this._notificationSuspensions > 0)
{
// should never be the case
return;
}
if (e.EntitiesSavedCount == 0)
{
// why are we getting this event then anyway?
return;
}
var changedEntries = this.Context.ChangeTracker.Entries()
.Where(entity => entity.State != EntityState.Unchanged).ToList();
foreach (var entry in changedEntries)
{
var (parent, parentCollectionNavigation) = this.GetParentInformation(entry);
switch (entry.State)
{
case EntityState.Added:
await this._changeListener.ConfigurationAddedAsync(entry.Metadata.ClrType, entry.Entity.GetId(), entry.Entity, parent, parentCollectionNavigation).ConfigureAwait(false);
break;
case EntityState.Deleted:
await this._changeListener.ConfigurationRemovedAsync(entry.Metadata.ClrType, entry.Entity.GetId(), parent, parentCollectionNavigation).ConfigureAwait(false);
break;
case EntityState.Modified:
await this._changeListener.ConfigurationChangedAsync(entry.Metadata.ClrType, entry.Entity.GetId(), entry.Entity, parent).ConfigureAwait(false);
break;
default:
// no change publishing required.
break;
}
if (parent is not null && parent is not GameConfiguration && parent is not Guid)
{
await this._changeListener.ConfigurationChangedAsync(parent.GetType(), parent.GetId(), parent, null).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error publishing changes.");
}
finally
{
try
{
this.Context.ChangeTracker.AcceptAllChanges();
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when accepting all saved changes.");
}
}
}
private (object? Parent, INavigationBase? ParentCollectionNavigation) GetParentInformation(EntityEntry entry)
{
var propertyToParent = entry.Properties
.FirstOrDefault(p => p.Metadata.IsForeignKey()
&& (p.Metadata.IsShadowProperty() || p.Metadata.PropertyInfo?.GetCustomAttribute<IsLinkToParentAttribute>() is not null));
var parentId = (Guid?)(propertyToParent?.CurrentValue ?? propertyToParent?.OriginalValue);
if (parentId is null)
{
return (null, null);
}
object? parent = null;
INavigationBase? parentCollectionNavigation = null;
var parentEntry = this.Context.ChangeTracker.Entries().FirstOrDefault(e => e.Entity.GetId() == parentId);
if (parentEntry is not null && propertyToParent is not null)
{
parent = parentEntry.Entity;
var parentCollection = parentEntry.Collections
.FirstOrDefault(c => c.Metadata.IsCollection
&& (c.Metadata as INavigation)?.ForeignKey == propertyToParent.Metadata.GetContainingForeignKeys().FirstOrDefault());
parentCollectionNavigation = parentCollection?.Metadata;
}
return (parent ?? parentId, parentCollectionNavigation);
}
}

View File

@@ -0,0 +1,27 @@
// <copyright file="LocalizedStringConverter.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// A value converter for <see cref="LocalizedString" /> which stores only the underlying string value.
/// </summary>
internal class LocalizedStringConverter : ValueConverter<LocalizedString, string>
{
/// <summary>
/// Initializes a new instance of the <see cref="LocalizedStringConverter" /> class.
/// </summary>
private LocalizedStringConverter()
: base(value => value.Value ?? string.Empty, value => new LocalizedString(value))
{
}
/// <summary>
/// Gets the singleton instance of the <see cref="LocalizedStringConverter"/>.
/// </summary>
public static LocalizedStringConverter Instance { get; } = new();
}

View File

@@ -0,0 +1,27 @@
// <copyright file="AccountExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Extensions for the <see cref="EntityTypeBuilder{Account}"/>.
/// </summary>
internal static class AccountExtensions
{
/// <summary>
/// Applies the settings for the <see cref="Account"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<Account> builder)
{
builder.Property(account => account.LoginName).HasMaxLength(10).IsRequired();
builder.HasIndex(account => account.LoginName).IsUnique();
builder.Property(account => account.LanguageIsoCode).HasMaxLength(3).IsRequired().HasDefaultValue("en");
}
}

View File

@@ -0,0 +1,32 @@
// <copyright file="AttributeExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Extensions for the <see cref="Attribute"/>-related <see cref="EntityTypeBuilder"/>s.
/// </summary>
internal static class AttributeExtensions
{
/// <summary>
/// Applies the settings for the <see cref="ConstValueAttribute"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ConstValueAttribute> builder)
{
builder.Ignore(c => c.AggregateType);
}
/// <summary>
/// Applies the settings for the <see cref="PowerUpDefinitionValue"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<PowerUpDefinitionValue> builder)
{
builder.Ignore(p => p.ConstantValue);
}
}

View File

@@ -0,0 +1,44 @@
// <copyright file="CharacterExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Extensions for the <see cref="EntityTypeBuilder{Account}"/>.
/// </summary>
internal static class CharacterExtensions
{
/// <summary>
/// Applies the settings for the <see cref="Character"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<Character> builder)
{
builder.Property(character => character.Name).HasMaxLength(10).IsRequired();
builder.HasIndex(character => character.Name).IsUnique();
if (builder.Metadata.FindNavigation(nameof(Character.RawCharacterClass)) is { } navigation)
{
navigation.ForeignKey.IsRequired = true;
}
builder.Property(character => character.CharacterSlot).IsRequired();
builder.HasMany(character => character.RawLetters).WithOne(letter => letter.Receiver!).OnDelete(DeleteBehavior.Cascade);
}
/// <summary>
/// Applies the settings for the <see cref="CharacterClass"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<CharacterClass> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
builder.HasMany(c => c.RawBaseAttributeValues)
.WithOne(c => c.CharacterClass);
}
}

View File

@@ -0,0 +1,23 @@
// <copyright file="ExitGateExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Extensions for the <see cref="EntityTypeBuilder{ExitGate}"/>.
/// </summary>
internal static class ExitGateExtensions
{
/// <summary>
/// Applies the settings for the <see cref="ExitGate"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ExitGate> builder)
{
builder.HasOne(gate => gate.RawMap);
}
}

View File

@@ -0,0 +1,38 @@
// <copyright file="GameConfigurationExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Extensions for the <see cref="EntityTypeBuilder{GameConfiguration}"/>.
/// </summary>
internal static class GameConfigurationExtensions
{
/// <summary>
/// Applies the settings for the <see cref="GameConfiguration"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<GameConfiguration> builder)
{
builder.Property(c => c.ItemDropDuration).HasDefaultValue(TimeSpan.FromSeconds(60));
builder.Property(c => c.ExcellentItemDropLevelDelta).HasDefaultValue((byte)25);
builder.Property(c => c.ExperienceFormula).HasDefaultValue("if(level == 0, 0, if(level < 256, 10 * (level + 8) * (level - 1) * (level - 1), (10 * (level + 8) * (level - 1) * (level - 1)) + (1000 * (level - 247) * (level - 256) * (level - 256))))");
builder.Property(c => c.MasterExperienceFormula).HasDefaultValue("(505 * level * level * level) + (35278500 * level) + (228045 * level * level)");
builder.HasMany(c => c.RawGlobalBaseAttributeValues).WithOne(c => c.GameConfiguration);
}
/// <summary>
/// Applies the settings for the <see cref="ConfigurationUpdate"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ConfigurationUpdate> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
builder.Property(p => p.Description).HasConversion(LocalizedStringConverter.Instance);
}
}

View File

@@ -0,0 +1,36 @@
// <copyright file="GameMapDefinitionExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Extensions for the <see cref="EntityTypeBuilder{GameMapDefinition}"/>.
/// </summary>
internal static class GameMapDefinitionExtensions
{
/// <summary>
/// Applies the settings for the <see cref="GameMapDefinition"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<GameMapDefinition> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
builder.HasMany(map => map.RawEnterGates);
builder.HasMany(map => map.RawExitGates).WithOne(g => g.RawMap);
builder.HasOne(map => map.RawSafezoneMap);
builder.HasMany(map => map.RawMonsterSpawns);
}
/// <summary>
/// Applies the settings for the <see cref="WarpInfo"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<WarpInfo> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
}
}

View File

@@ -0,0 +1,133 @@
// <copyright file="ItemExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Extensions for the item-related <see cref="EntityTypeBuilder"/>.
/// </summary>
internal static class ItemExtensions
{
/// <summary>
/// Applies the settings for the <see cref="ItemDefinition"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ItemDefinition> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="ItemStorage"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ItemStorage> builder)
{
builder.HasMany(storage => storage.RawItems).WithOne(item => item.RawItemStorage!);
}
/// <summary>
/// Applies the settings for the <see cref="ItemSetGroup"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ItemSetGroup> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
builder.HasMany(isg => isg.RawItems).WithOne(item => item.RawItemSetGroup!);
}
/// <summary>
/// Applies the settings for the <see cref="ItemBasePowerUpDefinition"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ItemBasePowerUpDefinition> builder)
{
builder.Ignore(d => d.BaseValueElement);
}
/// <summary>
/// Applies the settings for the <see cref="ItemLevelBonusTable"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ItemLevelBonusTable> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
builder.Property(p => p.Description).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="ItemDropItemGroup"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ItemDropItemGroup> builder)
{
builder.Property(p => p.Description).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="LevelBonus"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<LevelBonus> builder)
{
}
/// <summary>
/// Applies the settings for the <see cref="ItemSlotType"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ItemSlotType> builder)
{
builder.Property(p => p.Description).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="ItemOptionCombinationBonus"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ItemOptionCombinationBonus> builder)
{
builder.Property(p => p.Description).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="ItemOptionDefinition"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ItemOptionDefinition> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="ItemOptionType"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ItemOptionType> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
builder.Property(p => p.Description).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="ItemCrafting"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<ItemCrafting> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="DropItemGroup"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<DropItemGroup> builder)
{
builder.Property(p => p.Description).HasConversion(LocalizedStringConverter.Instance);
}
}

View File

@@ -0,0 +1,32 @@
// <copyright file="LetterExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Extensions for the <see cref="EntityTypeBuilder{LetterBody}"/> and <see cref="EntityTypeBuilder{LetterHeader}"/>.
/// </summary>
internal static class LetterExtensions
{
/// <summary>
/// Applies the settings for the <see cref="LetterBody"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<LetterBody> builder)
{
builder.HasOne(body => body.RawHeader);
}
/// <summary>
/// Applies the settings for the <see cref="LetterHeader"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<LetterHeader> builder)
{
builder.Ignore(header => header.ReceiverName);
}
}

View File

@@ -0,0 +1,44 @@
// <copyright file="MiniGameExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Extensions for the <see cref="MiniGameDefinition"/>-related <see cref="EntityTypeBuilder"/>.
/// </summary>
internal static class MiniGameExtensions
{
/// <summary>
/// Applies the settings for the <see cref="MiniGameChangeEvent"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<MiniGameChangeEvent> builder)
{
builder.Property(p => p.Description).HasConversion(LocalizedStringConverter.Instance);
builder.Property(p => p.Message).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="MiniGameSpawnWave"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<MiniGameSpawnWave> builder)
{
builder.Property(p => p.Description).HasConversion(LocalizedStringConverter.Instance);
builder.Property(p => p.Message).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="MiniGameDefinition"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<MiniGameDefinition> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
builder.Property(p => p.Description).HasConversion(LocalizedStringConverter.Instance);
}
}

View File

@@ -0,0 +1,34 @@
// <copyright file="MonsterExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Extensions for the <see cref="MonsterDefinition"/>-related <see cref="EntityTypeBuilder"/>s.
/// </summary>
internal static class MonsterExtensions
{
/// <summary>
/// Applies the settings for the <see cref="MonsterDefinition"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<MonsterDefinition> builder)
{
builder.HasMany<QuestDefinition>().WithOne(q => q.RawQuestGiver);
builder.Property(m => m.Designation).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="MonsterSpawnArea"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<MonsterSpawnArea> builder)
{
builder.HasOne(spawn => spawn.RawMonsterDefinition);
builder.HasOne(spawn => spawn.RawGameMap).WithMany(map => map.RawMonsterSpawns);
}
}

View File

@@ -0,0 +1,23 @@
// <copyright file="QuestExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Extensions for the item-related <see cref="EntityTypeBuilder"/>.
/// </summary>
internal static class QuestExtensions
{
/// <summary>
/// Applies the settings for the <see cref="QuestDefinition"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<QuestDefinition> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
}
}

View File

@@ -0,0 +1,74 @@
// <copyright file="SkillExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Extensions for the <see cref="Skill"/>-related <see cref="EntityTypeBuilder"/>.
/// </summary>
internal static class SkillExtensions
{
/// <summary>
/// Applies the settings for the <see cref="Skill"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<Skill> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="Skill"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<SkillComboDefinition> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="SkillEntry"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<SkillEntry> builder)
{
builder.Ignore(s => s.PowerUps);
builder.Ignore(s => s.PowerUpsPvp);
builder.Ignore(s => s.PowerUpDuration);
builder.Ignore(s => s.PowerUpDurationPvp);
builder.Ignore(s => s.PowerUpChance);
builder.Ignore(s => s.PowerUpChancePvp);
builder.Ignore(s => s.Attributes);
}
/// <summary>
/// Applies the settings for the <see cref="MasterSkillRoot"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<MasterSkillRoot> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
}
/// <summary>
/// Applies the settings for the <see cref="MasterSkillDefinition"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<MasterSkillDefinition> builder)
{
builder.HasOne(s => s.RawRoot);
}
/// <summary>
/// Applies the settings for the <see cref="MagicEffectDefinition"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<MagicEffectDefinition> builder)
{
builder.Property(p => p.Name).HasConversion(LocalizedStringConverter.Instance);
}
}

View File

@@ -0,0 +1,33 @@
// <copyright file="ModelBuilderExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions;
/// <summary>
/// Extensions for <see cref="Microsoft.EntityFrameworkCore.ModelBuilder"/>.
/// </summary>
internal static class ModelBuilderExtensions
{
/// <summary>
/// Configures the model builder to use UUID V7 as primary keys.
/// </summary>
/// <param name="modelBuilder">The model builder to configure.</param>
/// <returns>The configured model builder with UUID V7 key generation applied.</returns>
public static Microsoft.EntityFrameworkCore.ModelBuilder UseGuidV7Ids(this Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder)
{
var types = modelBuilder.Model.GetEntityTypes();
foreach (var t in types)
{
var entity = modelBuilder.Entity(t.ClrType);
var key = entity.Metadata.FindProperty("Id");
if (key != null)
{
key.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAdd;
key.SetValueGeneratorFactory((_, _) => new GuidV7ValueGenerator());
}
}
return modelBuilder;
}
}

View File

@@ -0,0 +1,43 @@
// <copyright file="FriendContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore;
using MUnique.OpenMU.Persistence.EntityFramework.Extensions;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Context to load instances of <see cref="Friend"/>s.
/// </summary>
public class FriendContext : DbContext
{
/// <summary>
/// Configures the model.
/// </summary>
/// <param name="modelBuilder">The model builder.</param>
internal static void ConfigureModel(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Friend>().ToTable("Friend", SchemaNames.Friend);
modelBuilder.Entity<Friend>(e =>
{
e.HasAlternateKey(f => new { f.CharacterId, f.FriendId });
});
}
/// <inheritdoc/>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
this.Configure(optionsBuilder);
}
/// <inheritdoc/>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
ConfigureModel(modelBuilder);
modelBuilder.Entity<CharacterName>().HasKey(f => f.Id);
modelBuilder.UseGuidV7Ids();
}
}

View File

@@ -0,0 +1,95 @@
// <copyright file="FriendServerContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// A context which is used by the <see cref="IFriendServer"/>.
/// </summary>
internal class FriendServerContext : CachingEntityFrameworkContext, IFriendServerContext
{
/// <summary>
/// Initializes a new instance of the <see cref="FriendServerContext" /> class.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="repositoryProvider">The repositoryManager.</param>
/// <param name="logger">The logger.</param>
public FriendServerContext(FriendContext context, IContextAwareRepositoryProvider repositoryProvider, ILogger<FriendServerContext> logger)
: base(context, repositoryProvider, null, logger)
{
}
/// <inheritdoc/>
public async ValueTask<Interfaces.Friend> CreateNewFriendAsync(string characterName, string friendName)
{
var item = this.CreateNew<Model.Friend>();
item.CharacterId = await this.GetCharacterIdByNameAsync(characterName).ConfigureAwait(false) ?? Guid.Empty;
item.FriendId = await this.GetCharacterIdByNameAsync(friendName).ConfigureAwait(false) ?? Guid.Empty;
return item;
}
/// <inheritdoc/>
public async ValueTask DeleteAsync(string characterName, string friendName)
{
this.Context.RemoveRange(await this.FindItems(characterName, friendName).ToListAsync().ConfigureAwait(false));
}
/// <inheritdoc/>
public async ValueTask<Interfaces.Friend?> GetFriendByNamesAsync(string characterName, string friendName)
{
return await this.FindItems(characterName, friendName).FirstOrDefaultAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<IEnumerable<FriendViewItem>> GetFriendsAsync(Guid characterId)
{
return await (from friend in this.Context.Set<Model.Friend>()
join friendCharacter in this.Context.Set<CharacterName>() on friend.FriendId equals friendCharacter.Id
join character in this.Context.Set<CharacterName>() on friend.CharacterId equals character.Id
select new FriendViewItem(character.Name, friendCharacter.Name)
{
Id = friend.Id,
CharacterId = friend.CharacterId,
FriendId = friend.FriendId,
Accepted = friend.Accepted,
RequestOpen = friend.RequestOpen,
}).ToListAsync().ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask<IEnumerable<string>> GetFriendNamesAsync(Guid characterId)
{
return await (from friend in this.Context.Set<Model.Friend>()
where friend.CharacterId == characterId
join friendCharacter in this.Context.Set<CharacterName>() on friend.FriendId equals friendCharacter.Id
select friendCharacter.Name).ToListAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<IEnumerable<string>> GetOpenFriendRequesterNamesAsync(Guid characterId)
{
return await (from friend in this.Context.Set<Model.Friend>()
where friend.RequestOpen == true && friend.FriendId == characterId
join requester in this.Context.Set<CharacterName>() on friend.CharacterId equals requester.Id
select requester.Name).ToListAsync().ConfigureAwait(false);
}
private IQueryable<Model.Friend> FindItems(string characterName, string friendName)
{
return from friend in this.Context.Set<Model.Friend>()
join friendCharacter in this.Context.Set<CharacterName>() on friend.FriendId equals friendCharacter.Id
join character in this.Context.Set<CharacterName>() on friend.CharacterId equals character.Id
where friendCharacter.Name == friendName && character.Name == characterName
select friend;
}
private async ValueTask<Guid?> GetCharacterIdByNameAsync(string name) => await this.Context.Set<CharacterName>().Where(character => character.Name == name).Select(character => character.Id).FirstOrDefaultAsync().ConfigureAwait(false);
}

View File

@@ -0,0 +1,32 @@
// <copyright file="GameConfigurationContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Threading;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Persistence context which is used to access the <see cref="GameConfiguration"/>.
/// </summary>
internal class GameConfigurationContext : CachingEntityFrameworkContext, IConfigurationContext
{
/// <summary>
/// Initializes a new instance of the <see cref="GameConfigurationContext"/> class.
/// </summary>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="logger">The logger.</param>
public GameConfigurationContext(IContextAwareRepositoryProvider repositoryProvider, ILogger<GameConfigurationContext> logger)
: base(new ConfigurationContext(), repositoryProvider, null, logger)
{
}
/// <inheritdoc />
public async ValueTask<Guid?> GetDefaultGameConfigurationIdAsync(CancellationToken cancellationToken)
{
return await this.Context.Set<GameConfiguration>().Select(g => g.Id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,82 @@
// <copyright file="GameConfigurationRepository.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Threading;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Persistence.EntityFramework.Json;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// The game configuration repository, which loads the configuration by using the
/// <see cref="JsonObjectLoader"/>, to speed up loading the whole object graph.
/// </summary>
internal class GameConfigurationRepository : GenericRepository<GameConfiguration>
{
private readonly JsonObjectLoader _objectLoader;
/// <summary>
/// Initializes a new instance of the <see cref="GameConfigurationRepository" /> class.
/// </summary>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="changeListener">The change publisher.</param>
public GameConfigurationRepository(IContextAwareRepositoryProvider repositoryProvider, ILoggerFactory loggerFactory, IConfigurationChangeListener? changeListener)
: base(repositoryProvider, loggerFactory, changeListener)
{
this._objectLoader = new GameConfigurationJsonObjectLoader();
}
/// <inheritdoc />
public override async ValueTask<GameConfiguration?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
var currentContext = this.RepositoryProvider.ContextStack.GetCurrentContext() as EntityFrameworkContextBase;
if (currentContext is null)
{
throw new InvalidOperationException("There is no current context set.");
}
var database = currentContext.Context.Database;
await database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
try
{
if (await this._objectLoader.LoadObjectAsync<GameConfiguration>(id, currentContext.Context, cancellationToken).ConfigureAwait(false) is { } config)
{
currentContext.Context.Attach(config);
return config;
}
return null;
}
finally
{
await database.CloseConnectionAsync().ConfigureAwait(false);
}
}
/// <inheritdoc />
public override async ValueTask<IEnumerable<GameConfiguration>> GetAllAsync(CancellationToken cancellationToken = default)
{
var currentContext = this.RepositoryProvider.ContextStack.GetCurrentContext() as EntityFrameworkContextBase;
if (currentContext is null)
{
throw new InvalidOperationException("There is no current context set.");
}
var database = currentContext.Context.Database;
await database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
try
{
var configs = (await this._objectLoader.LoadAllObjectsAsync<GameConfiguration>(currentContext.Context, cancellationToken).ConfigureAwait(false)).ToList();
configs.ForEach(c => currentContext.Context.Attach(c));
return configs;
}
finally
{
await database.CloseConnectionAsync().ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,62 @@
// <copyright file="GameServerDefinitionRepository.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Threading;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Repository for the <see cref="GameServerDefinition"/>.
/// It sets the <see cref="EntityDataContext.CurrentGameConfiguration"/> before loading other dependent data which tries to load the configured maps of a server.
/// </summary>
internal class GameServerDefinitionRepository : CachingGenericRepository<GameServerDefinition>
{
/// <summary>
/// Initializes a new instance of the <see cref="GameServerDefinitionRepository" /> class.
/// </summary>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="loggerFactory">The logger factory.</param>
public GameServerDefinitionRepository(IContextAwareRepositoryProvider repositoryProvider, ILoggerFactory loggerFactory)
: base(repositoryProvider, loggerFactory)
{
}
/// <inheritdoc />
protected override async ValueTask LoadDependentDataAsync(object obj, DbContext currentContext, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (obj is GameServerDefinition definition)
{
var entityEntry = currentContext.Entry(obj);
foreach (var collection in entityEntry.Collections.Where(c => !c.IsLoaded && c.Metadata is INavigation))
{
await this.LoadCollectionAsync(entityEntry, (INavigation)collection.Metadata, currentContext, cancellationToken).ConfigureAwait(false);
collection.IsLoaded = true;
}
if (definition.GameConfigurationId.HasValue)
{
definition.RawGameConfiguration =
await this.RepositoryProvider.GetRepository<GameConfiguration>()!
.GetByIdAsync(definition.GameConfigurationId.Value, cancellationToken).ConfigureAwait(false);
if (currentContext is EntityDataContext context)
{
context.CurrentGameConfiguration = definition.RawGameConfiguration;
}
}
if (definition.ServerConfigurationId.HasValue)
{
definition.ServerConfiguration = await this.RepositoryProvider.GetRepository<GameServerConfiguration>()!
.GetByIdAsync(definition.ServerConfigurationId.Value, cancellationToken).ConfigureAwait(false);
}
}
}
}

View File

@@ -0,0 +1,43 @@
// <copyright file="GenericRepository.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Reflection;
using Microsoft.Extensions.Logging;
/// <summary>
/// A generic repository which wraps the access to the DBSet of the <see cref="EntityDataContext"/>.
/// Entities are getting eagerly (=completely) loaded automatically.
/// </summary>
/// <typeparam name="T">The type which this repository should manage.</typeparam>
internal class GenericRepository<T> : GenericRepositoryBase<T>
where T : class
{
private readonly ILoggerFactory _loggerFactory;
private readonly IConfigurationChangeListener? _changeListener;
/// <summary>
/// Initializes a new instance of the <see cref="GenericRepository{T}" /> class.
/// </summary>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="changeListener">The change publisher.</param>
public GenericRepository(IContextAwareRepositoryProvider repositoryProvider, ILoggerFactory loggerFactory, IConfigurationChangeListener? changeListener)
: base(repositoryProvider, loggerFactory.CreateLogger(MethodBase.GetCurrentMethod()?.DeclaringType ?? typeof(GenericRepository<T>)))
{
this._loggerFactory = loggerFactory;
this._changeListener = changeListener;
}
/// <summary>
/// Gets a context to work with. If no context is currently registered at the repository provider, a new one is getting created.
/// </summary>
/// <returns>The context.</returns>
protected override EntityFrameworkContextBase GetContext()
{
var context = this.RepositoryProvider.ContextStack.GetCurrentContext() as EntityFrameworkContextBase;
return new EntityFrameworkContext(context?.Context ?? new TypedContext(typeof(T)), this._loggerFactory, this.RepositoryProvider, context is null, this._changeListener);
}
}

View File

@@ -0,0 +1,329 @@
// <copyright file="GenericRepositoryBase.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Collections;
using System.Threading;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Logging;
/// <summary>
/// Base class for a generic repository which wraps the access to the DBSet of the <see cref="EntityDataContext"/>.
/// Entities are getting eagerly (=completely) loaded automatically.
/// </summary>
/// <typeparam name="T">The type which this repository should manage.</typeparam>
internal abstract class GenericRepositoryBase<T> : IRepository<T>, ILoadByProperty
where T : class
{
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="GenericRepositoryBase{T}" /> class.
/// </summary>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="logger">The logger.</param>
protected GenericRepositoryBase(IContextAwareRepositoryProvider repositoryProvider, ILogger logger)
{
this._logger = logger;
this.RepositoryProvider = repositoryProvider;
using var completeContext = new EntityDataContext();
this.FullEntityType = completeContext.Model.FindEntityType(typeof(T)) ?? throw new InvalidOperationException($"{typeof(T)} is not included in the model");
}
/// <summary>
/// Gets the repository provider.
/// </summary>
protected IContextAwareRepositoryProvider RepositoryProvider { get; }
/// <summary>
/// Gets the complete meta model of the entity type in <see cref="EntityDataContext"/>.
/// </summary>
protected IEntityType FullEntityType { get; }
/// <inheritdoc/>
public async ValueTask<bool> DeleteAsync(Guid id)
{
if (await this.GetByIdAsync(id).ConfigureAwait(false) is { } item)
{
return await this.DeleteAsync(item).ConfigureAwait(false);
}
return false;
}
/// <inheritdoc/>
public async ValueTask<bool> DeleteAsync(object obj)
{
using var context = this.GetContext();
return context.Context.Remove(obj) is not null;
}
/// <inheritdoc/>
async ValueTask<IEnumerable> IRepository.GetAllAsync(CancellationToken cancellationToken = default)
{
return await this.GetAllAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public virtual async ValueTask<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
using var context = this.GetContext();
var result = await context.Context.Set<T>().ToListAsync(cancellationToken).ConfigureAwait(false);
await this.LoadDependentDataAsync(result, context.Context, cancellationToken).ConfigureAwait(false);
var newItems = context.Context.ChangeTracker.Entries<T>().Where(e => e.State == EntityState.Added).Select(e => e.Entity);
result.AddRange(newItems);
return result;
}
/// <inheritdoc/>
public virtual async ValueTask<T?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
using var context = this.GetContext();
var result = await context.Context.Set<T>().FindAsync(id, cancellationToken).ConfigureAwait(false);
if (result is null)
{
this._logger.LogDebug("Object with id {Id} could not be found.", id);
}
else
{
await this.LoadDependentDataAsync(result, context.Context, cancellationToken).ConfigureAwait(false);
}
return result;
}
/// <inheritdoc/>
async ValueTask<object?> IRepository.GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await this.GetByIdAsync(id, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<IEnumerable> LoadByPropertyAsync(IProperty property, object propertyValue, CancellationToken cancellationToken = default)
{
using var context = this.GetContext();
var result = (await this.LoadByPropertyInternalAsync(property, propertyValue, context.Context, cancellationToken).ConfigureAwait(false)).OfType<T>().ToList();
await this.LoadDependentDataAsync(result, context.Context, cancellationToken).ConfigureAwait(false);
return result;
}
/// <summary>
/// Gets the navigations which should be considered when loading the data.
/// By default, we just load what the meta model of the current context contains.
/// </summary>
/// <param name="entityEntry">The entity entry.</param>
/// <returns>The navigations which should be considered when loading the data.</returns>
protected virtual IEnumerable<INavigationBase> GetNavigations(EntityEntry entityEntry)
{
if (entityEntry.Context is ITypedContext)
{
return entityEntry.Metadata.GetNavigations();
}
return this.FullEntityType.GetNavigations();
}
/// <summary>
/// Loads the dependent data of the object from the corresponding repositories.
/// </summary>
/// <param name="obj">The object.</param>
/// <param name="currentContext">The current context with which the object got loaded. It is necessary to retrieve the foreign key ids.</param>
/// <param name="cancellationToken">The cancellation token.</param>
protected virtual async ValueTask LoadDependentDataAsync(object obj, DbContext currentContext, CancellationToken cancellationToken)
{
var entityEntry = currentContext.Entry(obj);
foreach (var navigation in this.GetNavigations(entityEntry).OfType<INavigation>())
{
cancellationToken.ThrowIfCancellationRequested();
if (!navigation.IsCollection && navigation.GetClrValue(obj) is null)
{
if (currentContext is ITypedContext editContext
&& editContext.RootType != navigation.DeclaringEntityType
&& editContext.IsBackReference(entityEntry.Metadata.ClrType))
{
// prevents endless loop.
continue;
}
if (this.FullEntityType.FindPrimaryKey()?.Properties[0] == navigation.ForeignKey.Properties[0])
{
// The entity type is a many-to-many join entity and the property of the navigation is the "owner" of it.
// Therefore, we don't need to load it. It would be nice to set the object reference somehow, though.
}
else
{
await this.LoadNavigationPropertyAsync(entityEntry, navigation, cancellationToken).ConfigureAwait(false);
navigation.SetIsLoadedWhenNoTracking(obj);
}
}
}
foreach (var collection in entityEntry.Collections.Where(c => !c.IsLoaded))
{
if (collection.Metadata is INavigation metadata)
{
if (currentContext is ITypedContext editContext
&& editContext.RootType != metadata.DeclaringEntityType
&& editContext.IsBackReference(entityEntry.Metadata.ClrType))
{
// prevents endless loop.
continue;
}
await this.LoadCollectionAsync(entityEntry, metadata, currentContext, cancellationToken).ConfigureAwait(false);
collection.IsLoaded = true;
}
}
}
/// <summary>
/// Loads the dependent data of the objects from the corresponding repositories.
/// </summary>
/// <param name="loadedObjects">The loaded objects.</param>
/// <param name="currentContext">The current context with which the objects got loaded. It is necessary to retrieve the foreign key ids.</param>
/// <param name="cancellationToken">The cancellation token.</param>
protected virtual async ValueTask LoadDependentDataAsync(IEnumerable loadedObjects, DbContext currentContext, CancellationToken cancellationToken)
{
foreach (var obj in loadedObjects)
{
await this.LoadDependentDataAsync(obj, currentContext, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Loads the navigation collection.
/// </summary>
/// <param name="entityEntry">The entity entry.</param>
/// <param name="navigation">The navigation.</param>
/// <param name="context">The context.</param>
/// <param name="cancellationToken">The cancellation token.</param>
protected virtual async ValueTask LoadCollectionAsync(EntityEntry entityEntry, INavigation navigation, DbContext context, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var foreignKeyProperty = navigation.ForeignKey.Properties[0];
var loadStatusAware = navigation.GetClrValue<ILoadingStatusAwareList>(entityEntry.Entity);
if (loadStatusAware?.LoadingStatus == LoadingStatus.Loaded || loadStatusAware?.LoadingStatus == LoadingStatus.Loading)
{
// already loaded or loading
return;
}
if (loadStatusAware is null && navigation.GetClrValue(entityEntry.Entity) != null)
{
// already loaded or loading
return;
}
if (loadStatusAware is null)
{
throw new InvalidOperationException($"The collection is not implementing {nameof(ILoadingStatusAware)}");
}
loadStatusAware.LoadingStatus = LoadingStatus.Loading;
if (this.RepositoryProvider.GetRepository(foreignKeyProperty.DeclaringType.ClrType) is ILoadByProperty repository)
{
var foreignKeyValue = entityEntry.Property(navigation.ForeignKey.PrincipalKey.Properties[0].Name).CurrentValue;
if (foreignKeyValue is { })
{
var items = await repository.LoadByPropertyAsync(foreignKeyProperty, foreignKeyValue, cancellationToken).ConfigureAwait(false);
foreach (var obj in items)
{
if (!loadStatusAware.Contains(obj))
{
loadStatusAware.Add(obj);
}
}
}
loadStatusAware.LoadingStatus = LoadingStatus.Loaded;
}
else
{
this._logger.LogWarning("No repository found which supports loading by foreign key for type {ClrType}.", foreignKeyProperty.DeclaringType.ClrType);
loadStatusAware.LoadingStatus = LoadingStatus.Failed;
}
}
/// <summary>
/// Loads the data navigation property and sets it in the entity.
/// </summary>
/// <param name="entityEntry">The entity entry from the context.</param>
/// <param name="navigation">The navigation property.</param>
/// <param name="cancellationToken">The cancellation token.</param>
protected virtual async ValueTask LoadNavigationPropertyAsync(EntityEntry entityEntry, IReadOnlyNavigation navigation, CancellationToken cancellationToken)
{
if (navigation.ForeignKey.DeclaringEntityType != navigation.DeclaringEntityType)
{
// inverse property
return;
}
var keyProperty = navigation.ForeignKey.Properties[0];
var idValue = entityEntry.Property(keyProperty.Name).CurrentValue;
Guid id = (idValue as Guid?) ?? Guid.Empty;
if (id != Guid.Empty)
{
var currentValue = navigation.GetClrValue(entityEntry.Entity);
if (currentValue is IIdentifiable identifiable && identifiable.Id == id)
{
// loaded already
return;
}
IRepository? repository = null;
try
{
repository = this.RepositoryProvider.GetRepository(navigation.TargetEntityType.ClrType);
}
catch (RepositoryNotFoundException ex)
{
this._logger.LogError(ex, "Repository not found: {Message}", ex.Message);
}
if (repository != null)
{
if (!navigation.TrySetClrValue(entityEntry.Entity, await repository.GetByIdAsync(id, cancellationToken).ConfigureAwait(false)))
{
this._logger.LogError("Could not find setter for navigation {Navigation}", navigation);
}
}
else
{
this._logger.LogError("Repository not found for navigation target type {TargetEntityType}.", navigation.TargetEntityType);
}
}
}
/// <summary>
/// Gets a context to work with. If no context is currently registered at the repository provider, a new one is getting created.
/// </summary>
/// <returns>The context.</returns>
protected abstract EntityFrameworkContextBase GetContext();
private async ValueTask<IEnumerable> LoadByPropertyInternalAsync(IProperty property, object propertyValue, DbContext context, CancellationToken cancellationToken)
{
if (property.ClrType == typeof(Guid))
{
return await context.Set<T>().Where(o => EF.Property<Guid>(o, property.Name) == (Guid)propertyValue).ToListAsync(cancellationToken).ConfigureAwait(false);
}
if (property.ClrType == typeof(Guid?))
{
return await context.Set<T>().Where(o => EF.Property<Guid?>(o, property.Name) == (Guid?)propertyValue).ToListAsync(cancellationToken).ConfigureAwait(false);
}
return Enumerable.Empty<object>();
}
}

View File

@@ -0,0 +1,23 @@
// <copyright file="GuidV7ValueGenerator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.ValueGeneration;
/// <summary>
/// A value generator for UUID V7 used as primary key.
/// </summary>
public class GuidV7ValueGenerator : ValueGenerator<Guid>
{
/// <inheritdoc/>
public override bool GeneratesTemporaryValues => false;
/// <inheritdoc/>
public override Guid Next(EntityEntry entry)
{
return GuidV7.NewGuid();
}
}

View File

@@ -0,0 +1,53 @@
// <copyright file="GuildContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore;
using MUnique.OpenMU.Persistence.EntityFramework.Extensions;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Context for the guild server which just uses <see cref="Guild"/>, <see cref="GuildMember"/> and <see cref="Character"/>.
/// </summary>
public class GuildContext : DbContext
{
/// <summary>
/// Configures the model, especially defines that <see cref="Guild"/> and <see cref="GuildMember"/> are created in a separate "guild" schema.
/// </summary>
/// <param name="modelBuilder">The model builder.</param>
internal static void ConfigureModel(ModelBuilder modelBuilder)
{
modelBuilder.Entity<GuildMember>(member =>
{
member.Property(m => m.Id).ValueGeneratedNever();
member.ToTable(nameof(GuildMember), SchemaNames.Guild);
});
modelBuilder.Entity<Guild>(e =>
{
e.Property(guild => guild.Name).HasMaxLength(8).IsRequired();
e.HasIndex(guild => guild.Name).IsUnique();
e.ToTable(nameof(Guild), SchemaNames.Guild);
});
}
/// <inheritdoc/>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
this.Configure(optionsBuilder);
}
/// <inheritdoc/>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Ignore<Character>();
ConfigureModel(modelBuilder);
modelBuilder.Entity<GuildMember>().Ignore(m => m.Character);
modelBuilder.Entity<CharacterName>().HasKey(f => f.Id);
modelBuilder.UseGuidV7Ids();
}
}

View File

@@ -0,0 +1,51 @@
// <copyright file="GuildServerContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// The EF Core implementation of a context which is used by the guild server.
/// </summary>
internal class GuildServerContext : CachingEntityFrameworkContext, IGuildServerContext
{
/// <summary>
/// Initializes a new instance of the <see cref="GuildServerContext" /> class.
/// </summary>
/// <param name="guildContext">The guild context.</param>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="logger">The logger.</param>
public GuildServerContext(GuildContext guildContext, IContextAwareRepositoryProvider repositoryProvider, ILogger<GuildServerContext> logger)
: base(guildContext, repositoryProvider, null, logger)
{
}
/// <inheritdoc/>
public async ValueTask<bool> GuildWithNameExistsAsync(string name)
{
return await this.Context.Set<Guild>().AnyAsync(guild => guild.Name == name).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<IReadOnlyDictionary<Guid, string>> GetMemberNamesAsync(Guid guildId)
{
return await (from member in this.Context.Set<GuildMember>()
join character in this.Context.Set<CharacterName>() on member.Id equals character.Id
where member.GuildId == guildId
select new { character.Id, character.Name })
.ToDictionaryAsync(member => member.Id, member => member.Name).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<IReadOnlyList<DataModel.Entities.Guild>> GetAlliancesAsync(Guid allianceMasterId)
{
return await this.Context.Set<Guild>()
.Where(g => g.AllianceGuildId == allianceMasterId)
.Include(g => g.RawMembers)
.ToListAsync().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,41 @@
// <copyright file="IConfigurationChangeListener.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore.Metadata;
/// <summary>
/// Interface for an object which listens to configuration changes.
/// </summary>
public interface IConfigurationChangeListener
{
/// <summary>
/// A configuration has changed.
/// </summary>
/// <param name="type">The type.</param>
/// <param name="id">The identifier.</param>
/// <param name="configuration">The changed configuration.</param>
/// <param name="parent">The parent object, if available.</param>
ValueTask ConfigurationChangedAsync(Type type, Guid id, object configuration, object? parent);
/// <summary>
/// A configuration has been added.
/// </summary>
/// <param name="type">The type.</param>
/// <param name="id">The identifier.</param>
/// <param name="configuration">The added configuration.</param>
/// <param name="parent">The parent object, if available.</param>
/// <param name="parentCollectionNavigation">The parent collection navigation, if available.</param>
ValueTask ConfigurationAddedAsync(Type type, Guid id, object configuration, object? parent, INavigationBase? parentCollectionNavigation);
/// <summary>
/// A configuration has been removed.
/// </summary>
/// <param name="type">The type.</param>
/// <param name="id">The identifier of the removed configuration object.</param>
/// <param name="parent">The parent, if available.</param>
/// <param name="parentCollectionNavigation">The parent collection navigation, if available.</param>
ValueTask ConfigurationRemovedAsync(Type type, Guid id, object? parent, INavigationBase? parentCollectionNavigation);
}

View File

@@ -0,0 +1,22 @@
// <copyright file="IConfigurationTypeRepository.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
/// <summary>
/// Non-generic interface for <see cref="ConfigurationTypeRepository{T}"/>.
/// </summary>
internal interface IConfigurationTypeRepository
{
/// <summary>
/// Ensures the cache for the current configuration.
/// </summary>
void EnsureCacheForCurrentConfiguration();
/// <summary>
/// Updates the cached instance.
/// </summary>
/// <param name="changedInstance">The changed instance.</param>
void UpdateCachedInstances(object changedInstance);
}

View File

@@ -0,0 +1,13 @@
// <copyright file="IContextAwareRepositoryProvider.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
/// <summary>
/// A <see cref="IRepositoryProvider"/> which is aware of a current context by
/// implementing <see cref="IContextStackProvider"/>.
/// </summary>
internal interface IContextAwareRepositoryProvider : IRepositoryProvider, IContextStackProvider
{
}

View File

@@ -0,0 +1,25 @@
// <copyright file="IContextStack.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
/// <summary>
/// Interface for a stack of persistence contexts.
/// </summary>
internal interface IContextStack
{
/// <summary>
/// Puts this context on the context stack of the current thread to be used for the upcoming repository actions.
/// If no context is on the context stack of the current thread, a new temporary context will be used for the action.
/// </summary>
/// <param name="context">The context.</param>
/// <returns>The disposable to end the usage.</returns>
IDisposable UseContext(IContext context);
/// <summary>
/// Gets the current context of the current thread.
/// </summary>
/// <returns>The current context.</returns>
IContext? GetCurrentContext();
}

View File

@@ -0,0 +1,16 @@
// <copyright file="IContextStackProvider.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
/// <summary>
/// Interface for a class which provides a <see cref="IContextStack"/>.
/// </summary>
internal interface IContextStackProvider
{
/// <summary>
/// Gets the context stack.
/// </summary>
IContextStack ContextStack { get; }
}

View File

@@ -0,0 +1,40 @@
// <copyright file="IDatabaseConnectionSettingProvider.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Threading;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Interface for a provider of <see cref="ConnectionSetting"/>s for specified context types.
/// </summary>
public interface IDatabaseConnectionSettingProvider
{
/// <summary>
/// Gets the initialization task.
/// </summary>
Task? Initialization { get; }
/// <summary>
/// Initializes the settings provider.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
Task InitializeAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets the connection setting for the specified context type.
/// </summary>
/// <typeparam name="TContextType">The type of the context type.</typeparam>
/// <returns>The connection settings.</returns>
ConnectionSetting GetConnectionSetting<TContextType>()
where TContextType : DbContext;
/// <summary>
/// Gets the connection setting for the specified context type.
/// </summary>
/// <param name="contextType">Type of the context.</param>
/// <returns>The connection settings.</returns>
ConnectionSetting GetConnectionSetting(Type contextType);
}

View File

@@ -0,0 +1,26 @@
// <copyright file="ILoadByProperty.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Collections;
using System.Threading;
using Microsoft.EntityFrameworkCore.Metadata;
/// <summary>
/// Interface to load data by property.
/// </summary>
internal interface ILoadByProperty
{
/// <summary>
/// Loads objects by property.
/// </summary>
/// <param name="property">The property of the object which should be compared.</param>
/// <param name="propertyValue">The value of the property.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The enumeration of the loaded objects.
/// </returns>
ValueTask<IEnumerable> LoadByPropertyAsync(IProperty property, object propertyValue, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
// <copyright file="ILoadingStatusAware.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
/// <summary>
/// Interface for a loading status aware class.
/// </summary>
internal interface ILoadingStatusAware
{
/// <summary>
/// Gets or sets the loading status.
/// </summary>
LoadingStatus LoadingStatus { get; set; }
}

View File

@@ -0,0 +1,16 @@
// <copyright file="ILoadingStatusAwareList.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Collections;
/// <summary>
/// Interface for a loading status aware list.
/// </summary>
/// <seealso cref="MUnique.OpenMU.Persistence.EntityFramework.ILoadingStatusAware" />
/// <seealso cref="System.Collections.IList" />
internal interface ILoadingStatusAwareList : ILoadingStatusAware, IList
{
}

View File

@@ -0,0 +1,35 @@
// <copyright file="ITypedContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore.Metadata;
using MUnique.OpenMU.DataModel.Composition;
/// <summary>
/// Interface for a context which is used to edit instances of the <see cref="RootType"/>
/// and their navigations marked with the <see cref="MemberOfAggregateAttribute"/>.
/// </summary>
internal interface ITypedContext
{
/// <summary>
/// Gets the root entity type.
/// </summary>
IEntityType RootType { get; }
/// <summary>
/// Determines whether the entity type of the specified clr type is included in the context.
/// </summary>
/// <param name="clrType">The clr type.</param>
bool IsIncluded(Type clrType);
/// <summary>
/// Determines whether the entity type contains a back reference to the <see cref="RootType"/> or one of it's included types.
/// </summary>
/// <param name="clrType">Type of the color.</param>
/// <remarks>
/// These types are included in the context, but their navigations are not resolved.
/// </remarks>
bool IsBackReference(Type clrType);
}

View File

@@ -0,0 +1,13 @@
// <copyright file="IsLinkToParentAttribute.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
/// <summary>
/// Attribute to mark properties which are a link to the parent object.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class IsLinkToParentAttribute : Attribute
{
}

View File

@@ -0,0 +1,21 @@
// <copyright file="AccountJsonObjectLoader.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Json;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// A json object loader for <see cref="Account"/>s.
/// </summary>
public class AccountJsonObjectLoader : JsonObjectLoader
{
/// <summary>
/// Initializes a new instance of the <see cref="AccountJsonObjectLoader"/> class.
/// </summary>
public AccountJsonObjectLoader()
: base(new JsonQueryBuilder(), new JsonObjectDeserializer(), new CachingReferenceHandler())
{
}
}

View File

@@ -0,0 +1,100 @@
// <copyright file="BinaryAsHexJsonConverter.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
/// <summary>
/// This converter converts a hex string to a byte array.
/// Newtonsoft.Json expects a base64 string, but postgres delivers us a hex string,
/// so this converter is needed.
/// </summary>
public class BinaryAsHexJsonConverter : JsonConverter<byte[]>
{
/// <summary>
/// The prefix of a byte array string, provided by postgres.
/// </summary>
private const string ByteArrayPrefix = @"\x";
/// <summary>
/// A character to value mapping table for faster hex string parsing. Each character (0-9a-fA-F)
/// has it's corresponding value of 0-15. All other characters are initialized with <see cref="int.MaxValue"/>.
/// </summary>
private readonly int[] _characterToValue;
/// <summary>
/// Initializes a new instance of the <see cref="BinaryAsHexJsonConverter"/> class.
/// </summary>
public BinaryAsHexJsonConverter()
{
this._characterToValue = new int[byte.MaxValue + 1];
for (int i = 0; i < this._characterToValue.Length; i++)
{
this._characterToValue[i] = int.MaxValue;
}
for (byte i = 0; i < 10; i++)
{
this._characterToValue['0' + i] = i;
}
for (byte i = 0xA; i < 16; i++)
{
this._characterToValue['a' + i - 0xA] = i;
this._characterToValue['A' + i - 0xA] = i;
}
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options)
{
var arrayString = ByteArrayPrefix + BitConverter.ToString(value)
.Replace("-", string.Empty, StringComparison.InvariantCulture)
.ToLowerInvariant();
writer.WriteStringValue(arrayString);
}
/// <inheritdoc />
public override byte[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
if (reader.TokenType == JsonTokenType.String)
{
var prefixSize = ByteArrayPrefix.Length + 1; // +1 for escaping
var hexData = reader.ValueSpan.Slice(prefixSize);
var data = new byte[(hexData.Length - prefixSize) / 2];
for (var i = 0; i < data.Length; i++)
{
var index = i * 2;
int highNibble = this.ParseCharacter(hexData[index]);
int lowNibble = this.ParseCharacter(hexData[index + 1]);
data[i] = (byte)((highNibble << 4) | lowNibble);
}
return data;
}
throw new Exception($"Unexpected token parsing binary. Expected String, got {reader.TokenType}.");
}
/// <inheritdoc />
public override bool CanConvert(Type objectType) => objectType == typeof(byte[]);
private int ParseCharacter(byte character)
{
var value = this._characterToValue[character];
if (value == int.MaxValue)
{
throw new ArgumentException($"invalid character: {character}", nameof(character));
}
return value;
}
}

View File

@@ -0,0 +1,27 @@
// <copyright file="CachingReferenceHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Json;
using System.Text.Json.Serialization;
using MUnique.OpenMU.Persistence.Json;
/// <summary>
/// A reference handler which considers cached configuration objects.
/// This is basically needed when loading account data. They can have references
/// to configuration.
/// </summary>
public class CachingReferenceHandler : ReferenceHandler, IIdReferenceHandler
{
/// <summary>
/// Gets the currently used resolver.
/// </summary>
public ReferenceResolver? Current { get; private set; }
/// <inheritdoc />
public override ReferenceResolver CreateResolver()
{
return this.Current ??= new MultipleSourceReferenceResolver(new IdReferenceResolver(), ConfigurationIdReferenceResolver.Instance);
}
}

View File

@@ -0,0 +1,86 @@
// <copyright file="ConfigurationIdReferenceResolver.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Json;
using System.Text.Json.Serialization;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// A reference resolver which resolves them by looking at the objects occurring in the <see cref="GameConfiguration" />.
/// The cache is maintained by instances of the <see cref="ConfigurationTypeRepository{T}" />.
/// </summary>
/// <remarks>TODO: I don't like it as a singleton, but I keep it until I find a cleaner solution.</remarks>
internal class ConfigurationIdReferenceResolver : ReferenceResolver
{
/// <summary>
/// The singleton instance.
/// </summary>
private static readonly ConfigurationIdReferenceResolver InstanceValue = new();
private readonly IDictionary<Guid, IIdentifiable> _cache = new Dictionary<Guid, IIdentifiable>();
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationIdReferenceResolver"/> class.
/// </summary>
protected ConfigurationIdReferenceResolver()
{
}
/// <summary>
/// Gets the singleton instance.
/// </summary>
public static ConfigurationIdReferenceResolver Instance => InstanceValue;
/// <inheritdoc />
public override object ResolveReference(string referenceId)
{
var id = new Guid(referenceId);
if (id == Guid.Empty)
{
return null!;
}
var success = this._cache.TryGetValue(id, out var obj);
if (!success)
{
return null!;
}
return obj!;
}
/// <inheritdoc/>
public override string GetReference(object value, out bool alreadyExists)
{
var p = (IIdentifiable)value;
alreadyExists = this._cache.ContainsKey(p.Id);
return p.Id.ToString();
}
/// <inheritdoc/>
public override void AddReference(string referenceId, object value)
{
var id = new Guid(referenceId);
this._cache[id] = (IIdentifiable)value;
}
/// <summary>
/// Adds the reference to the cache of the resolver.
/// </summary>
/// <param name="value">The value.</param>
public void AddReference(IIdentifiable value)
{
this._cache[value.Id] = value;
}
/// <summary>
/// Removes the reference from the cache of the resolver.
/// </summary>
/// <param name="key">The key.</param>
public void RemoveReference(Guid key)
{
this._cache.Remove(key);
}
}

View File

@@ -0,0 +1,25 @@
// <copyright file="GameConfigurationJsonObjectLoader.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Json;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
using MUnique.OpenMU.Persistence.Json;
/// <summary>
/// Object loader for <see cref="GameConfiguration"/> objects.
/// </summary>
public class GameConfigurationJsonObjectLoader : JsonObjectLoader
{
/// <summary>
/// Initializes a new instance of the <see cref="GameConfigurationJsonObjectLoader"/> class.
/// </summary>
public GameConfigurationJsonObjectLoader()
: base(
new GameConfigurationJsonQueryBuilder(),
new JsonObjectDeserializer(),
new IdReferenceHandler())
{
}
}

View File

@@ -0,0 +1,31 @@
// <copyright file="GameConfigurationJsonQueryBuilder.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Json;
using Microsoft.EntityFrameworkCore.Metadata;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// <see cref="JsonQueryBuilder"/> for <see cref="GameConfiguration"/>.
/// </summary>
public class GameConfigurationJsonQueryBuilder : JsonQueryBuilder
{
/// <inheritdoc/>
protected override IEnumerable<INavigation> GetNavigations(IEntityType entityType)
{
if (entityType.ClrType != typeof(GameConfiguration))
{
return base.GetNavigations(entityType);
}
var navigations = base.GetNavigations(entityType).ToList();
// We move the maps with their spawn points to the end because they depend on all other data
var mapsProperty = navigations.First(nav => nav.Name == "RawMaps");
navigations.Remove(mapsProperty);
navigations.Add(mapsProperty);
return navigations;
}
}

View File

@@ -0,0 +1,25 @@
// <copyright file="JsonObjectDeserializer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Json;
using System.Text.Json;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// A deserializer which parses the json retrieved from the postgres database by using a query built by the <see cref="JsonQueryBuilder"/>.
/// We need to register a special binary converter, because postgres provides binary data in a non-standard format.
/// </summary>
public class JsonObjectDeserializer : MUnique.OpenMU.Persistence.Json.JsonObjectDeserializer
{
/// <inheritdoc/>
protected override void BeforeDeserialize(JsonSerializerOptions options)
{
base.BeforeDeserialize(options);
foreach (var converter in JsonConverterRegistry.Converters)
{
options.Converters.Add(converter);
}
}
}

View File

@@ -0,0 +1,109 @@
// <copyright file="JsonObjectLoader.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Json;
using System.Data;
using System.Text.Json.Serialization;
using System.Threading;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
/// <summary>
/// A class which allows to load objects from the database by using a query provided by the <see cref="JsonQueryBuilder"/>
/// which retrieves a whole object graph by using json functions of postgres.
/// </summary>
public class JsonObjectLoader
{
private readonly JsonQueryBuilder _queryBuilder;
private readonly JsonObjectDeserializer _deserializer;
private readonly ReferenceHandler _referenceHandler;
/// <summary>
/// Initializes a new instance of the <see cref="JsonObjectLoader" /> class.
/// </summary>
/// <param name="queryBuilder">The query builder.</param>
/// <param name="deserializer">The deserializer.</param>
/// <param name="referenceHandler">The reference handler.</param>
public JsonObjectLoader(JsonQueryBuilder queryBuilder, JsonObjectDeserializer deserializer, ReferenceHandler referenceHandler)
{
this._queryBuilder = queryBuilder;
this._deserializer = deserializer;
this._referenceHandler = referenceHandler;
}
/// <summary>
/// Loads all objects of the specified type <typeparamref name="T"/>.
/// Please note, that it's expected that the database connection is already established when calling this method.
/// </summary>
/// <typeparam name="T">The type of the <see cref="IIdentifiable"/> object.</typeparam>
/// <param name="context">The context.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>All objects of <typeparamref name="T"/>.</returns>
public async ValueTask<IEnumerable<T>> LoadAllObjectsAsync<T>(DbContext context, CancellationToken cancellationToken = default)
where T : class, IIdentifiable
{
cancellationToken.ThrowIfCancellationRequested();
var result = new List<T>();
var type = context.Model.FindEntityType(typeof(T)) ?? throw new ArgumentException($"{typeof(T)} is not included in the model of the context.");
var queryString = this._queryBuilder.BuildJsonQueryForEntity(type);
await using var command = context.Database.GetDbConnection().CreateCommand();
command.CommandText = queryString;
await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false);
if (reader.HasRows)
{
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
await using var textReader = reader.GetStream(2);
cancellationToken.ThrowIfCancellationRequested();
if (this._deserializer.Deserialize<T>(textReader, this._referenceHandler) is { } item)
{
result.Add(item);
}
}
}
return result;
}
/// <summary>
/// Loads the object with the specified id from the context.
/// Please note, that it's expected that the database connection is already established when calling this method.
/// </summary>
/// <typeparam name="T">The type of the <see cref="IIdentifiable"/> object.</typeparam>
/// <param name="id">The identifier of the object which should be loaded.</param>
/// <param name="context">The context.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The loaded object, if available; Otherwise, null.</returns>
public async ValueTask<T?> LoadObjectAsync<T>(Guid id, DbContext context, CancellationToken cancellationToken = default)
where T : class, IIdentifiable
{
cancellationToken.ThrowIfCancellationRequested();
IEntityType type;
await using (var completeContext = new EntityDataContext())
{
type = completeContext.Model.FindEntityType(typeof(T)) ?? throw new ArgumentException($"{typeof(T)} is not included in the model of the context.");
}
var queryString = this._queryBuilder.BuildJsonQueryForEntity(type);
queryString += " where result.\"Id\" = @id;";
await using var command = context.Database.GetDbConnection().CreateCommand();
command.CommandText = queryString;
var idParameter = command.CreateParameter();
idParameter.ParameterName = "id";
idParameter.Value = id;
command.Parameters.Add(idParameter);
await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess | CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
if (reader.HasRows && await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
await using var textReader = reader.GetStream(2);
return this._deserializer.Deserialize<T>(textReader, this._referenceHandler);
}
return default;
}
}

View File

@@ -0,0 +1,222 @@
// <copyright file="JsonQueryBuilder.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Json;
using System.Diagnostics;
using System.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
/// <summary>
/// A query builder which creates a query to return a whole object graph as json by using postgres json functions.
/// </summary>
/// <remarks>
/// TODO: Make the resulting query more readable by adding indenting for subqueries.
/// </remarks>
public class JsonQueryBuilder
{
/// <summary>
/// Builds the json query for the given entity type.
/// </summary>
/// <remarks>
/// It creates a query like this (e.g. for GameConfiguration):
/// select result."Id" "$id", result."Id", row_to_json(result) as GameConfiguration
/// from (
/// select a."Id", a.*, (
/// -- additional subqueries which return json
/// ...)
/// from config."GameConfiguration" a
/// ) result;.
/// </remarks>
/// <param name="entityType">Type of the entity.</param>
/// <returns>The query which returns the objects of the given type as json string.</returns>
public string BuildJsonQueryForEntity(IEntityType entityType)
{
// Debug.WriteLine($"Building the json query for {entityType.Name}.");
var stringBuilder = new StringBuilder();
stringBuilder.Append("select result.\"Id\" \"$id\", result.\"Id\" id, row_to_json(result) as ").AppendLine(entityType.GetTableName())
.AppendLine("from (");
this.AddTypeToQuery(entityType, stringBuilder, "a");
stringBuilder.AppendLine(") result");
var result = stringBuilder.ToString();
// Debug.WriteLine("Finished building the json query for {0}. Result: {1}", entityType.Name, result);
return result;
}
/// <summary>
/// Gets the navigations of the entity type.
/// </summary>
/// <param name="entityType">Type of the entity.</param>
/// <returns>The navigations of the entity type.</returns>
/// <remarks>
/// Can be overwritten to apply sorting.
/// TODO: Can sorting based on dependencies be done automatically?.
/// </remarks>
protected virtual IEnumerable<INavigation> GetNavigations(IEntityType entityType)
{
return entityType.GetNavigations();
}
private void AddTypeToQuery(IEntityType entityType, StringBuilder stringBuilder, string alias)
{
stringBuilder.Append("select ").Append(alias).Append(".\"Id\" as \"$id\", ").Append(alias).Append(".*");
this.AddNavigationsToQuery(entityType, stringBuilder, alias);
stringBuilder.Append(" from ").Append(entityType.GetSchema()).Append('.').Append('"').Append(entityType.GetTableName()).Append("\" ").AppendLine(alias);
}
private void AddNavigationsToQuery(IEntityType entityType, StringBuilder stringBuilder, string parentAlias)
{
var navigationAlias = this.GetNextAlias(parentAlias);
if (navigationAlias == "i")
{
// stopping circular reference
// Debug.Fail($"Stopping circular reference at entity type {entityType.Name}");
return;
}
var navigations = this.GetNavigations(entityType);
foreach (var navigation in navigations)
{
if (navigation.IsCollection)
{
this.AddCollection(navigation, entityType, stringBuilder, parentAlias);
}
else //// it's a foreign key
{
this.AddNavigation(navigation, stringBuilder, parentAlias);
}
}
}
private string GetNextAlias(string parentAlias)
{
return ((char)(parentAlias[0] + 1)).ToString(CultureInfo.InvariantCulture);
}
private void AddNavigation(INavigation navigation, StringBuilder stringBuilder, string parentAlias)
{
if (navigation.ForeignKey.DeclaringEntityType != navigation.DeclaringEntityType)
{
// inverse property, no data required
Debug.WriteLine("Inverse property {0}", navigation.Name);
return;
}
var navigationAlias = this.GetNextAlias(parentAlias);
var targetType = navigation.TargetEntityType;
var foreignKey = navigation.ForeignKey.Properties[0];
if (foreignKey.IsShadowProperty())
{
// We assume that every important foreign key is mapped to a "real" property
Debug.WriteLine("Shadow property {0}", navigation.Name);
return;
}
var isBackReference = navigation.ForeignKey.PrincipalToDependent?.IsCollection ?? false;
if (isBackReference)
{
// It's a back reference of a collection - we just have to create a reference json object
Debug.WriteLine("Back Reference property {0}", navigation.Name);
}
stringBuilder.Append(", (");
if (!navigation.IsMemberOfAggregate() || isBackReference)
{
stringBuilder
.Append($"case when {parentAlias}.\"{foreignKey.Name}\" is null then null else ")
.Append("json_build_object('$ref', ").Append(parentAlias).Append(".\"").Append(foreignKey.Name).Append("\") end");
}
else
{
stringBuilder.Append("select row_to_json(").Append(navigationAlias).Append(") from (");
this.AddTypeToQuery(targetType, stringBuilder, navigationAlias);
stringBuilder.Append(") ").Append(navigationAlias).Append(" where ").Append(navigationAlias).Append(".\"").Append(targetType.GetPrimaryKeyColumnName()).Append("\" = ").Append(parentAlias).Append(".\"").Append(foreignKey.Name).AppendLine("\"");
}
stringBuilder.Append(") as \"").Append(navigation.Name).AppendLine("\"");
}
private void AddCollection(INavigation navigation, IEntityType entityType, StringBuilder stringBuilder, string parentAlias)
{
var keyProperty = navigation.ForeignKey.Properties[0];
var navigationType = keyProperty.DeclaringEntityType;
#pragma warning disable EF1001 // Internal EF Core API usage.
if (navigationType.FindDeclaredPrimaryKey() is not { } primaryKey)
{
return;
}
#pragma warning restore EF1001 // Internal EF Core API usage.
if (primaryKey.Properties.Count > 1)
{
this.AddManyToManyCollection(navigationType, entityType, stringBuilder, parentAlias, keyProperty);
}
else
{
this.AddOneToManyCollection(navigation, navigationType, stringBuilder, parentAlias, keyProperty);
}
stringBuilder.Append(") as \"").Append(navigation.Name.Replace("Joined", string.Empty)).AppendLine("\"");
}
private void AddOneToManyCollection(INavigation navigation, IEntityType navigationType, StringBuilder stringBuilder, string parentAlias, IProperty keyProperty)
{
var primaryKeyName = navigationType.GetDeclaredPrimaryKeyColumnName();
var navigationAlias = this.GetNextAlias(parentAlias);
stringBuilder.AppendLine(", (")
.Append("select array_to_json(array_agg(row_to_json(").Append(navigationAlias).AppendLine("))) from (");
if (navigation.IsMemberOfAggregate())
{
this.AddTypeToQuery(navigationType, stringBuilder, navigationAlias);
stringBuilder.Append(") ").AppendLine(navigationAlias)
.Append("where ").Append(navigationAlias).Append(".\"").Append(keyProperty.Name).Append("\" = ").Append(parentAlias).Append(".\"").Append(primaryKeyName).AppendLine("\"");
}
else
{
var primaryKeyColumnName = navigationType.GetPrimaryKeyColumnName(); // It's always one property, usually called "Id"
stringBuilder.Append("select \"").Append(primaryKeyColumnName).AppendLine("\" as \"$ref\"");
stringBuilder.Append("from ").Append(navigationType.GetSchema()).Append(".\"").Append(navigationType.GetTableName()).AppendLine("\" ")
.Append("where \"").Append(keyProperty.Name).Append("\" = ").Append(parentAlias).Append(".\"").Append(primaryKeyName).AppendLine("\"")
.Append(") as ").AppendLine(navigationAlias);
}
}
/// <summary>
/// Adds the many to many collection to the query.
/// We assume that every many to many join entity only consists of the both foreign keys.
/// Additionally we assume that every target entity is referencable.
/// Therefore we just select the target entities as reference.
/// When adding an object of the target type, our Ef-Core entity classes would automatically create join entities
/// with the right keys, because they use <see cref="ManyToManyCollectionAdapter{T,TJoin}"/>s.
/// </summary>
/// <param name="navigationType">Type of the navigation, the join entity type.</param>
/// <param name="entityType">Type of the entity which is currently getting processed. It holds the collection which is about to be added.</param>
/// <param name="stringBuilder">The string builder which is used to create the query string.</param>
/// <param name="parentAlias">The parent alias, required to reference the primary key of the <paramref name="entityType"/>.</param>
/// <param name="keyProperty">The key property.</param>
private void AddManyToManyCollection(IEntityType navigationType, IEntityType entityType, StringBuilder stringBuilder, string parentAlias, IProperty keyProperty)
{
var navigationAlias = this.GetNextAlias(parentAlias);
var entityTypePrimaryKeyName = entityType.GetPrimaryKeyColumnName(); // usually "Id"
var otherEntityTypeForeignKey = navigationType.GetForeignKeys().FirstOrDefault(fk => fk.PrincipalEntityType != entityType);
var otherEntityTypeKey = navigationType.GetKeys().FirstOrDefault(fk => fk.DeclaringEntityType != entityType);
var referenceColumnToOtherEntity = otherEntityTypeForeignKey?.GetColumnName() ?? otherEntityTypeKey?.GetColumnName()
?? throw new InvalidOperationException("No reference column available.");
stringBuilder.AppendLine(", (")
.Append("select array_to_json(array_agg(row_to_json(").Append(navigationAlias).AppendLine("))) from (");
stringBuilder.Append("select \"").Append(referenceColumnToOtherEntity).AppendLine("\" as \"$ref\"")
.Append("from ").Append(navigationType.GetSchema()).Append(".\"").Append(navigationType.GetTableName()).AppendLine("\" ")
.Append("where ").Append('"').Append(keyProperty.Name).Append("\" = ").Append(parentAlias).Append(".\"").Append(entityTypePrimaryKeyName).AppendLine("\"")
.Append(") as ").AppendLine(navigationAlias);
}
}

View File

@@ -0,0 +1,46 @@
// <copyright file="LetterBodyRepository.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Threading;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Repository which is able to load <see cref="LetterBody"/>s for a specific letter header.
/// </summary>
internal class LetterBodyRepository : CachingGenericRepository<LetterBody>
{
/// <summary>
/// Initializes a new instance of the <see cref="LetterBodyRepository" /> class.
/// </summary>
/// <param name="repositoryProvider">The repository provider.</param>
/// <param name="loggerFactory">The logger factory.</param>
public LetterBodyRepository(IContextAwareRepositoryProvider repositoryProvider, ILoggerFactory loggerFactory)
: base(repositoryProvider, loggerFactory)
{
}
/// <summary>
/// Gets the letter body by the id of its header.
/// </summary>
/// <param name="headerId">The id of its header.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The body of the header.
/// </returns>
public async ValueTask<LetterBody?> GetBodyByHeaderIdAsync(Guid headerId, CancellationToken cancellationToken = default)
{
using var context = this.GetContext();
var letterBody = await context.Context.Set<LetterBody>().FirstOrDefaultAsync(body => body.HeaderId == headerId, cancellationToken).ConfigureAwait(false);
if (letterBody is not null)
{
await this.LoadDependentDataAsync(letterBody, context.Context, cancellationToken).ConfigureAwait(false);
}
return letterBody;
}
}

View File

@@ -0,0 +1,51 @@
// <copyright file="List.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
/// <summary>
/// A generic list which implements additional required interfaces for the usage as list and collection in the object model.
/// </summary>
/// <typeparam name="T">The type of the items of the list.</typeparam>
/// <seealso cref="System.Collections.Generic.List{T}" />
internal class List<T> : System.Collections.Generic.List<T>, ILoadingStatusAwareList
{
/// <summary>
/// Initializes a new instance of the <see cref="List{T}"/> class that
/// is empty and has the default initial capacity.
/// </summary>
public List()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="List{T}"/> class that
/// is empty and has the specified initial capacity.
/// </summary>
/// <param name="capacity">The number of elements that the new list can initially store.</param>
public List(int capacity)
: base(capacity)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="List{T}"/> class that
/// contains elements copied from the specified collection and has sufficient capacity
/// to accommodate the number of elements copied.
/// </summary>
/// <param name="collection">The collection whose elements are copied to the new list.</param>
public List(IEnumerable<T> collection)
: base(collection)
{
this.LoadingStatus = LoadingStatus.Loaded;
}
/// <summary>
/// Gets or sets the loading status.
/// </summary>
/// <value>
/// The loading status.
/// </value>
public LoadingStatus LoadingStatus { get; set; } = LoadingStatus.Unloaded;
}

View File

@@ -0,0 +1,36 @@
// <copyright file="LoadingStatus.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
/// <summary>
/// The loading status of a <see cref="ILoadingStatusAware"/>.
/// </summary>
internal enum LoadingStatus
{
/// <summary>
/// The undefined state.
/// </summary>
Undefined,
/// <summary>
/// The data is not loaded yet.
/// </summary>
Unloaded,
/// <summary>
/// The data is loaded.
/// </summary>
Loaded,
/// <summary>
/// The data is loading at the moment.
/// </summary>
Loading,
/// <summary>
/// The loading of the data failed.
/// </summary>
Failed,
}

View File

@@ -0,0 +1,55 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<!--<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>-->
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>..\..\..\bin\Debug\</OutputPath>
<DocumentationFile>..\..\..\bin\Debug\MUnique.OpenMU.Persistence.EntityFramework.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\..\..\bin\Release\</OutputPath>
<DocumentationFile>..\..\..\bin\Release\MUnique.OpenMU.Persistence.EntityFramework.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" />
<PackageReference Include="Mapster" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\AttributeSystem\MUnique.OpenMU.AttributeSystem.csproj" />
<ProjectReference Include="..\..\DataModel\MUnique.OpenMU.DataModel.csproj" />
<ProjectReference Include="..\..\Interfaces\MUnique.OpenMU.Interfaces.csproj" />
<ProjectReference Include="..\..\PlugIns\MUnique.OpenMU.PlugIns.csproj" />
<ProjectReference Include="..\MUnique.OpenMU.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="ConnectionSettings.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" />
</ItemGroup>
<Target Name="PreBuild" BeforeTargets="PreBuildEvent" Condition="'$(ci)'!='true'">
<Exec Command="dotnet run --project ../SourceGenerator/MUnique.OpenMU.Persistence.SourceGenerator.csproj -c:$(ConfigurationName) MUnique.OpenMU.Persistence.EntityFramework &quot;$(ProjectDir)Model&quot; --no-build" />
</Target>
</Project>

View File

@@ -0,0 +1,105 @@
// <copyright file="ManyToManyCollectionAdapter.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Collections;
/// <summary>
/// A many-to-many collection adapter which adapts beween <typeparamref name="T"/> and <typeparamref name="TJoin"/>.
/// </summary>
/// <remarks>
/// Usually in our object model we don't define collections of join entities and their types.
/// This is done automatically by our T4 templates.
/// </remarks>
/// <typeparam name="T">The type which is in a many to many relationship.</typeparam>
/// <typeparam name="TJoin">The type of the join entity. It contains a property of <typeparamref name="T"/>.</typeparam>
/// <seealso cref="System.Collections.Generic.ICollection{T}" />
internal class ManyToManyCollectionAdapter<T, TJoin> : ICollection<T>
{
/// <summary>
/// The raw collection, which is usually the collection which is mapped by entity framework.
/// </summary>
private readonly ICollection<TJoin> _rawCollection;
/// <summary>
/// The function to create a new join entity.
/// </summary>
private readonly Func<T, TJoin> _createJoinEntityFunction;
/// <summary>
/// The function to extract the instance of <typeparamref name="T"/> out of <typeparamref name="TJoin"/>.
/// </summary>
private readonly Func<TJoin, T> _extractFunction;
/// <summary>
/// Initializes a new instance of the <see cref="ManyToManyCollectionAdapter{T, TJoin}"/> class.
/// </summary>
/// <param name="rawCollection">The raw collection, which is usually the collection which is mapped by entity framework.</param>
/// <param name="extractFunction">The function to extract the instance of <typeparamref name="T"/> out of <typeparamref name="TJoin"/>.</param>
/// <param name="createJoinEntityFunction">The function to create a new join entity.</param>
public ManyToManyCollectionAdapter(ICollection<TJoin> rawCollection, Func<TJoin, T> extractFunction, Func<T, TJoin> createJoinEntityFunction)
{
this._rawCollection = rawCollection;
this._createJoinEntityFunction = createJoinEntityFunction;
this._extractFunction = extractFunction;
}
/// <inheritdoc />
public int Count => this._rawCollection.Count;
/// <inheritdoc/>
public bool IsReadOnly => this._rawCollection.IsReadOnly;
/// <inheritdoc/>
public IEnumerator<T> GetEnumerator()
{
return this._rawCollection.Select(this._extractFunction).GetEnumerator();
}
/// <inheritdoc/>
IEnumerator IEnumerable.GetEnumerator()
{
return this.GetEnumerator();
}
/// <inheritdoc/>
public void Add(T item)
{
if (item != null)
{
this._rawCollection.Add(this._createJoinEntityFunction(item));
}
}
/// <inheritdoc/>
public void Clear()
{
this._rawCollection.Clear();
}
/// <inheritdoc/>
public bool Contains(T item)
{
return this._rawCollection.Any(i => object.Equals(this._extractFunction(i), item));
}
/// <inheritdoc/>
public void CopyTo(T[] array, int arrayIndex)
{
this._rawCollection.Select(this._extractFunction).ToList().CopyTo(array, arrayIndex);
}
/// <inheritdoc/>
public bool Remove(T item)
{
var joinItem = this._rawCollection.FirstOrDefault(i => object.Equals(this._extractFunction(i), item));
if (joinItem != null)
{
return this._rawCollection.Remove(joinItem);
}
return false;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
// <copyright file="20221008183306_PetLevels.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using Microsoft.EntityFrameworkCore.Migrations;
/// <summary>
/// Adds pet experience columns to Item and ItemDefinition.
/// </summary>
public partial class PetLevels : Migration
{
/// <inheritdoc/>
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PetExperienceFormula",
schema: "config",
table: "ItemDefinition",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PetExperience",
schema: "data",
table: "Item",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc/>
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PetExperienceFormula",
schema: "config",
table: "ItemDefinition");
migrationBuilder.DropColumn(
name: "PetExperience",
schema: "data",
table: "Item");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
// <copyright file="20221018194652_Combo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using System;
using Microsoft.EntityFrameworkCore.Migrations;
/// <summary>
/// Adds skill combo definitions and steps.
/// </summary>
public partial class Combo : Migration
{
/// <inheritdoc/>
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ComboDefinitionId",
schema: "config",
table: "CharacterClass",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "SkillComboDefinition",
schema: "config",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
MaximumCompletionTime = table.Column<TimeSpan>(type: "interval", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_SkillComboDefinition", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SkillComboStep",
schema: "config",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SkillId = table.Column<Guid>(type: "uuid", nullable: true),
SkillComboDefinitionId = table.Column<Guid>(type: "uuid", nullable: true),
Order = table.Column<int>(type: "integer", nullable: false),
IsFinalStep = table.Column<bool>(type: "boolean", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_SkillComboStep", x => x.Id);
table.ForeignKey(
name: "FK_SkillComboStep_Skill_SkillId",
column: x => x.SkillId,
principalSchema: "config",
principalTable: "Skill",
principalColumn: "Id");
table.ForeignKey(
name: "FK_SkillComboStep_SkillComboDefinition_SkillComboDefinitionId",
column: x => x.SkillComboDefinitionId,
principalSchema: "config",
principalTable: "SkillComboDefinition",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_CharacterClass_ComboDefinitionId",
schema: "config",
table: "CharacterClass",
column: "ComboDefinitionId");
migrationBuilder.CreateIndex(
name: "IX_SkillComboStep_SkillComboDefinitionId",
schema: "config",
table: "SkillComboStep",
column: "SkillComboDefinitionId");
migrationBuilder.CreateIndex(
name: "IX_SkillComboStep_SkillId",
schema: "config",
table: "SkillComboStep",
column: "SkillId");
migrationBuilder.AddForeignKey(
name: "FK_CharacterClass_SkillComboDefinition_ComboDefinitionId",
schema: "config",
table: "CharacterClass",
column: "ComboDefinitionId",
principalSchema: "config",
principalTable: "SkillComboDefinition",
principalColumn: "Id");
}
/// <inheritdoc/>
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_CharacterClass_SkillComboDefinition_ComboDefinitionId",
schema: "config",
table: "CharacterClass");
migrationBuilder.DropTable(
name: "SkillComboStep",
schema: "config");
migrationBuilder.DropTable(
name: "SkillComboDefinition",
schema: "config");
migrationBuilder.DropIndex(
name: "IX_CharacterClass_ComboDefinitionId",
schema: "config",
table: "CharacterClass");
migrationBuilder.DropColumn(
name: "ComboDefinitionId",
schema: "config",
table: "CharacterClass");
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
// <copyright file="20221121163220_AttributeRelationship_OperandAttribute.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using System;
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class AttributeRelationshipOperandAttribute : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "OperandAttributeId",
schema: "config",
table: "AttributeRelationship",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_AttributeRelationship_OperandAttributeId",
schema: "config",
table: "AttributeRelationship",
column: "OperandAttributeId");
migrationBuilder.AddForeignKey(
name: "FK_AttributeRelationship_AttributeDefinition_OperandAttributeId",
schema: "config",
table: "AttributeRelationship",
column: "OperandAttributeId",
principalSchema: "config",
principalTable: "AttributeDefinition",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AttributeRelationship_AttributeDefinition_OperandAttributeId",
schema: "config",
table: "AttributeRelationship");
migrationBuilder.DropIndex(
name: "IX_AttributeRelationship_OperandAttributeId",
schema: "config",
table: "AttributeRelationship");
migrationBuilder.DropColumn(
name: "OperandAttributeId",
schema: "config",
table: "AttributeRelationship");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
// <copyright file="20221128203249_RemoveConsumeHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class RemoveConsumeHandler : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ConsumeHandlerClass",
schema: "config",
table: "ItemDefinition");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ConsumeHandlerClass",
schema: "config",
table: "ItemDefinition",
type: "text",
nullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
// <copyright file="20221128204928_AddItemDropDuration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using System;
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class AddItemDropDuration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<TimeSpan>(
name: "ItemDropDuration",
schema: "config",
table: "GameConfiguration",
type: "interval",
nullable: false,
defaultValue: new TimeSpan(0, 0, 1, 0, 0));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ItemDropDuration",
schema: "config",
table: "GameConfiguration");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
// <copyright file="20221221182248_MuHelper.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class MuHelper : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte[]>(
name: "MuHelperConfiguration",
schema: "data",
table: "Character",
type: "bytea",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MuHelperConfiguration",
schema: "data",
table: "Character");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
// <copyright file="20230303185735_ConfigurationUpdate.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using System;
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class ConfigurationUpdate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ConfigurationUpdate",
schema: "config",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Version = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "text", nullable: true),
Description = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
InstalledAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_ConfigurationUpdate", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ConfigurationUpdateState",
schema: "config",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
InitializationKey = table.Column<string>(type: "text", nullable: true),
CurrentInstalledVersion = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ConfigurationUpdateState", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ConfigurationUpdate",
schema: "config");
migrationBuilder.DropTable(
name: "ConfigurationUpdateState",
schema: "config");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
// <copyright file="20230306185635_MiniGameExt.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using System;
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class MiniGameExt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsClientUpdateRequired",
schema: "config",
table: "MiniGameTerrainChange",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AlterColumn<DateTime>(
name: "Timestamp",
schema: "data",
table: "MiniGameRankingEntry",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
migrationBuilder.AddColumn<bool>(
name: "AllowParty",
schema: "config",
table: "MiniGameDefinition",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "ArePlayerKillersAllowedToEnter",
schema: "config",
table: "MiniGameDefinition",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "EntranceFee",
schema: "config",
table: "MiniGameDefinition",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsClientUpdateRequired",
schema: "config",
table: "MiniGameTerrainChange");
migrationBuilder.DropColumn(
name: "AllowParty",
schema: "config",
table: "MiniGameDefinition");
migrationBuilder.DropColumn(
name: "ArePlayerKillersAllowedToEnter",
schema: "config",
table: "MiniGameDefinition");
migrationBuilder.DropColumn(
name: "EntranceFee",
schema: "config",
table: "MiniGameDefinition");
migrationBuilder.AlterColumn<DateTime>(
name: "Timestamp",
schema: "data",
table: "MiniGameRankingEntry",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
// <copyright file="20230324205415_AddSystemConfiguration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using System;
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class AddSystemConfiguration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_QuestMonsterKillRequirementState_CharacterQuestState_Charac~",
schema: "data",
table: "QuestMonsterKillRequirementState");
migrationBuilder.CreateTable(
name: "SystemConfiguration",
schema: "config",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
IpResolver = table.Column<int>(type: "integer", nullable: false),
IpResolverParameter = table.Column<string>(type: "text", nullable: true),
AutoStart = table.Column<bool>(type: "boolean", nullable: false),
AutoUpdateSchema = table.Column<bool>(type: "boolean", nullable: false),
ReadConsoleInput = table.Column<bool>(type: "boolean", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_SystemConfiguration", x => x.Id);
});
migrationBuilder.AddForeignKey(
name: "FK_QuestMonsterKillRequirementState_CharacterQuestState_Charac~",
schema: "data",
table: "QuestMonsterKillRequirementState",
column: "CharacterQuestStateId",
principalSchema: "data",
principalTable: "CharacterQuestState",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_QuestMonsterKillRequirementState_CharacterQuestState_Charac~",
schema: "data",
table: "QuestMonsterKillRequirementState");
migrationBuilder.DropTable(
name: "SystemConfiguration",
schema: "config");
migrationBuilder.AddForeignKey(
name: "FK_QuestMonsterKillRequirementState_CharacterQuestState_Charac~",
schema: "data",
table: "QuestMonsterKillRequirementState",
column: "CharacterQuestStateId",
principalSchema: "data",
principalTable: "CharacterQuestState",
principalColumn: "Id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
// <copyright file="20230504172021_StorageLimitPerCharacter.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class StorageLimitPerCharacter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "StorageLimitPerCharacter",
schema: "config",
table: "ItemDefinition",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "StorageLimitPerCharacter",
schema: "config",
table: "ItemDefinition");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
// <copyright file="20230701010432_AddChatBanUntil.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class AddChatBanUntil : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime?>(
name: "ChatBanUntil",
schema: "data",
table: "Account",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ChatBanUntil",
schema: "data",
table: "Account");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
// <copyright file="20230724154747_AccountAndMapAttributes.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using System;
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class AccountAndMapAttributes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "AccountId",
schema: "data",
table: "StatAttribute",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "GameMapDefinitionId",
schema: "config",
table: "PowerUpDefinition",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_StatAttribute_AccountId",
schema: "data",
table: "StatAttribute",
column: "AccountId");
migrationBuilder.CreateIndex(
name: "IX_PowerUpDefinition_GameMapDefinitionId",
schema: "config",
table: "PowerUpDefinition",
column: "GameMapDefinitionId");
migrationBuilder.AddForeignKey(
name: "FK_PowerUpDefinition_GameMapDefinition_GameMapDefinitionId",
schema: "config",
table: "PowerUpDefinition",
column: "GameMapDefinitionId",
principalSchema: "config",
principalTable: "GameMapDefinition",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_StatAttribute_Account_AccountId",
schema: "data",
table: "StatAttribute",
column: "AccountId",
principalSchema: "data",
principalTable: "Account",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_PowerUpDefinition_GameMapDefinition_GameMapDefinitionId",
schema: "config",
table: "PowerUpDefinition");
migrationBuilder.DropForeignKey(
name: "FK_StatAttribute_Account_AccountId",
schema: "data",
table: "StatAttribute");
migrationBuilder.DropIndex(
name: "IX_StatAttribute_AccountId",
schema: "data",
table: "StatAttribute");
migrationBuilder.DropIndex(
name: "IX_PowerUpDefinition_GameMapDefinitionId",
schema: "config",
table: "PowerUpDefinition");
migrationBuilder.DropColumn(
name: "AccountId",
schema: "data",
table: "StatAttribute");
migrationBuilder.DropColumn(
name: "GameMapDefinitionId",
schema: "config",
table: "PowerUpDefinition");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
// <copyright file="20240602060055_FixItemOptions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class FixItemOptions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_IncreasableItemOption_ItemSetGroup_ItemSetGroupId",
schema: "config",
table: "IncreasableItemOption");
migrationBuilder.AddForeignKey(
name: "FK_IncreasableItemOption_ItemSetGroup_ItemSetGroupId",
schema: "config",
table: "IncreasableItemOption",
column: "ItemSetGroupId",
principalSchema: "config",
principalTable: "ItemSetGroup",
principalColumn: "Id");
// First, we need to create the new item option definitions for the ancient sets
migrationBuilder.Sql(
"""
INSERT INTO config."ItemOptionDefinition" ("Id", "GameConfigurationId", "Name", "AddsRandomly", "AddChance", "MaximumOptionsPerItem")
SELECT UUID(REPLACE(sets.setId, '00000092-','00000083-')), '00000001-0001-0000-0000-000000000000', sets.setName || ' (Ancient Set)', false, 0, 0
FROM
(
SELECT COUNT(o."Id") c, text(o."ItemSetGroupId") setId, g."Name" setName
FROM config."IncreasableItemOption" o, config."ItemSetGroup" g
WHERE "ItemSetGroupId" is not null
AND "ItemOptionDefinitionId" is null
AND g."Id" = o."ItemSetGroupId"
GROUP BY o."ItemSetGroupId", g."Name"
ORDER BY c
) sets
WHERE sets.c > 1
""");
// Then we need to update the item option definitions of the ancient sets,
// so that they belong to the previously created item option defintions
migrationBuilder.Sql(
"""
UPDATE config."IncreasableItemOption" o
SET "ItemOptionDefinitionId" = UUID(REPLACE(TEXT(o."ItemSetGroupId"), '00000092-','00000083-'))
WHERE o."ItemSetGroupId" is not null
AND o."ItemOptionDefinitionId" is null
AND o."OptionTypeId" is not null
""");
// Then what's left are the options for set completion of normal sets.
migrationBuilder.Sql(
"""
INSERT INTO config."ItemOptionDefinition" ("Id", "GameConfigurationId", "Name", "AddsRandomly", "AddChance", "MaximumOptionsPerItem")
SELECT UUID(REPLACE(sets.optionId, '00000088-','00000083-')),
'00000001-0001-0000-0000-000000000000',
CASE WHEN sets.setLevel=0 THEN 'Complete Set Bonus (any level)'
ELSE 'Complete Set Bonus (Level ' || sets.setLevel || ')'
END,
false, 0, 0
FROM
(
SELECT text(o."Id") optionId, text(o."ItemSetGroupId") setId, g."Name" setName, g."SetLevel" setLevel
FROM config."IncreasableItemOption" o, config."ItemSetGroup" g
WHERE "ItemSetGroupId" is not null
AND "ItemOptionDefinitionId" is null
AND g."Id" = o."ItemSetGroupId"
ORDER BY setLevel
) sets
""");
// Then we need to update the item option definitions of the normal sets
// so that they belong to the previously created item option defintions
migrationBuilder.Sql(
"""
UPDATE config."IncreasableItemOption" o
SET "ItemOptionDefinitionId" = UUID(REPLACE(TEXT(o."Id"), '00000088-','00000083-'))
WHERE o."ItemOptionDefinitionId" is null
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_IncreasableItemOption_ItemSetGroup_ItemSetGroupId",
schema: "config",
table: "IncreasableItemOption");
migrationBuilder.AddForeignKey(
name: "FK_IncreasableItemOption_ItemSetGroup_ItemSetGroupId",
schema: "config",
table: "IncreasableItemOption",
column: "ItemSetGroupId",
principalSchema: "config",
principalTable: "ItemSetGroup",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

Some files were not shown because too many files have changed in this diff Show More