mirror of
https://github.com/osukey/osukey.git
synced 2025-08-04 23:24:04 +09:00
Merge remote-tracking branch 'origin/master' into netstandard
This commit is contained in:
346
osu.Game/Database/ArchiveModelManager.cs
Normal file
346
osu.Game/Database/ArchiveModelManager.cs
Normal file
@ -0,0 +1,346 @@
|
||||
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
|
||||
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Ionic.Zip;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.IO.Archives;
|
||||
using osu.Game.IPC;
|
||||
using osu.Game.Overlays.Notifications;
|
||||
using SharpCompress.Common;
|
||||
using FileInfo = osu.Game.IO.FileInfo;
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// Encapsulates a model store class to give it import functionality.
|
||||
/// Adds cross-functionality with <see cref="FileStore"/> to give access to the central file store for the provided model.
|
||||
/// </summary>
|
||||
/// <typeparam name="TModel">The model type.</typeparam>
|
||||
/// <typeparam name="TFileModel">The associated file join type.</typeparam>
|
||||
public abstract class ArchiveModelManager<TModel, TFileModel> : ICanAcceptFiles
|
||||
where TModel : class, IHasFiles<TFileModel>, IHasPrimaryKey, ISoftDelete
|
||||
where TFileModel : INamedFileInfo, new()
|
||||
{
|
||||
/// <summary>
|
||||
/// Set an endpoint for notifications to be posted to.
|
||||
/// </summary>
|
||||
public Action<Notification> PostNotification { protected get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fired when a new <see cref="TModel"/> becomes available in the database.
|
||||
/// </summary>
|
||||
public event Action<TModel> ItemAdded;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when a <see cref="TModel"/> is removed from the database.
|
||||
/// </summary>
|
||||
public event Action<TModel> ItemRemoved;
|
||||
|
||||
public virtual string[] HandledExtensions => new[] { ".zip" };
|
||||
|
||||
protected readonly FileStore Files;
|
||||
|
||||
protected readonly IDatabaseContextFactory ContextFactory;
|
||||
|
||||
protected readonly MutableDatabaseBackedStore<TModel> ModelStore;
|
||||
|
||||
// ReSharper disable once NotAccessedField.Local (we should keep a reference to this so it is not finalised)
|
||||
private ArchiveImportIPCChannel ipc;
|
||||
|
||||
protected ArchiveModelManager(Storage storage, IDatabaseContextFactory contextFactory, MutableDatabaseBackedStore<TModel> modelStore, IIpcHost importHost = null)
|
||||
{
|
||||
ContextFactory = contextFactory;
|
||||
|
||||
ModelStore = modelStore;
|
||||
ModelStore.ItemAdded += s => ItemAdded?.Invoke(s);
|
||||
ModelStore.ItemRemoved += s => ItemRemoved?.Invoke(s);
|
||||
|
||||
Files = new FileStore(contextFactory, storage);
|
||||
|
||||
if (importHost != null)
|
||||
ipc = new ArchiveImportIPCChannel(importHost, this);
|
||||
|
||||
ModelStore.Cleanup();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import one or more <see cref="TModel"/> items from filesystem <paramref name="paths"/>.
|
||||
/// This will post notifications tracking progress.
|
||||
/// </summary>
|
||||
/// <param name="paths">One or more archive locations on disk.</param>
|
||||
public void Import(params string[] paths)
|
||||
{
|
||||
var notification = new ProgressNotification
|
||||
{
|
||||
Text = "Import is initialising...",
|
||||
Progress = 0,
|
||||
State = ProgressNotificationState.Active,
|
||||
};
|
||||
|
||||
PostNotification?.Invoke(notification);
|
||||
|
||||
List<TModel> imported = new List<TModel>();
|
||||
|
||||
int current = 0;
|
||||
int errors = 0;
|
||||
foreach (string path in paths)
|
||||
{
|
||||
if (notification.State == ProgressNotificationState.Cancelled)
|
||||
// user requested abort
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
notification.Text = $"Importing ({++current} of {paths.Length})\n{Path.GetFileName(path)}";
|
||||
using (ArchiveReader reader = getReaderFrom(path))
|
||||
imported.Add(Import(reader));
|
||||
|
||||
notification.Progress = (float)current / paths.Length;
|
||||
|
||||
// We may or may not want to delete the file depending on where it is stored.
|
||||
// e.g. reconstructing/repairing database with items from default storage.
|
||||
// Also, not always a single file, i.e. for LegacyFilesystemReader
|
||||
// TODO: Add a check to prevent files from storage to be deleted.
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.Error(e, $@"Could not delete original file after import ({Path.GetFileName(path)})");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
e = e.InnerException ?? e;
|
||||
Logger.Error(e, $@"Could not import ({Path.GetFileName(path)})");
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
notification.Text = errors > 0 ? $"Import complete with {errors} errors" : "Import successful!";
|
||||
notification.State = ProgressNotificationState.Completed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import an item from an <see cref="ArchiveReader"/>.
|
||||
/// </summary>
|
||||
/// <param name="archive">The archive to be imported.</param>
|
||||
public TModel Import(ArchiveReader archive)
|
||||
{
|
||||
using (ContextFactory.GetForWrite()) // used to share a context for full import. keep in mind this will block all writes.
|
||||
{
|
||||
// create a new model (don't yet add to database)
|
||||
var item = CreateModel(archive);
|
||||
|
||||
var existing = CheckForExisting(item);
|
||||
|
||||
if (existing != null) return existing;
|
||||
|
||||
item.Files = createFileInfos(archive, Files);
|
||||
|
||||
Populate(item, archive);
|
||||
|
||||
// import to store
|
||||
ModelStore.Add(item);
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import an item from a <see cref="TModel"/>.
|
||||
/// </summary>
|
||||
/// <param name="item">The model to be imported.</param>
|
||||
public void Import(TModel item) => ModelStore.Add(item);
|
||||
|
||||
/// <summary>
|
||||
/// Perform an update of the specified item.
|
||||
/// TODO: Support file changes.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to update.</param>
|
||||
public void Update(TModel item) => ModelStore.Update(item);
|
||||
|
||||
/// <summary>
|
||||
/// Delete an item from the manager.
|
||||
/// Is a no-op for already deleted items.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to delete.</param>
|
||||
public void Delete(TModel item)
|
||||
{
|
||||
using (var usage = ContextFactory.GetForWrite())
|
||||
{
|
||||
var context = usage.Context;
|
||||
|
||||
context.ChangeTracker.AutoDetectChangesEnabled = false;
|
||||
|
||||
// re-fetch the model on the import context.
|
||||
var foundModel = queryModel().Include(s => s.Files).ThenInclude(f => f.FileInfo).First(s => s.ID == item.ID);
|
||||
|
||||
if (foundModel.DeletePending) return;
|
||||
|
||||
if (ModelStore.Delete(foundModel))
|
||||
Files.Dereference(foundModel.Files.Select(f => f.FileInfo).ToArray());
|
||||
|
||||
context.ChangeTracker.AutoDetectChangesEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete multiple items.
|
||||
/// This will post notifications tracking progress.
|
||||
/// </summary>
|
||||
public void Delete(List<TModel> items)
|
||||
{
|
||||
if (items.Count == 0) return;
|
||||
|
||||
var notification = new ProgressNotification
|
||||
{
|
||||
Progress = 0,
|
||||
CompletionText = "Deleted all beatmaps!",
|
||||
State = ProgressNotificationState.Active,
|
||||
};
|
||||
|
||||
PostNotification?.Invoke(notification);
|
||||
|
||||
int i = 0;
|
||||
|
||||
using (ContextFactory.GetForWrite())
|
||||
{
|
||||
foreach (var b in items)
|
||||
{
|
||||
if (notification.State == ProgressNotificationState.Cancelled)
|
||||
// user requested abort
|
||||
return;
|
||||
|
||||
notification.Text = $"Deleting ({++i} of {items.Count})";
|
||||
|
||||
Delete(b);
|
||||
|
||||
notification.Progress = (float)i / items.Count;
|
||||
}
|
||||
}
|
||||
|
||||
notification.State = ProgressNotificationState.Completed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore multiple items that were previously deleted.
|
||||
/// This will post notifications tracking progress.
|
||||
/// </summary>
|
||||
public void Undelete(List<TModel> items)
|
||||
{
|
||||
if (!items.Any()) return;
|
||||
|
||||
var notification = new ProgressNotification
|
||||
{
|
||||
CompletionText = "Restored all deleted items!",
|
||||
Progress = 0,
|
||||
State = ProgressNotificationState.Active,
|
||||
};
|
||||
|
||||
PostNotification?.Invoke(notification);
|
||||
|
||||
int i = 0;
|
||||
|
||||
using (ContextFactory.GetForWrite())
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (notification.State == ProgressNotificationState.Cancelled)
|
||||
// user requested abort
|
||||
return;
|
||||
|
||||
notification.Text = $"Restoring ({++i} of {items.Count})";
|
||||
|
||||
Undelete(item);
|
||||
|
||||
notification.Progress = (float)i / items.Count;
|
||||
}
|
||||
}
|
||||
|
||||
notification.State = ProgressNotificationState.Completed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore an item that was previously deleted. Is a no-op if the item is not in a deleted state, or has its protected flag set.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to restore</param>
|
||||
public void Undelete(TModel item)
|
||||
{
|
||||
using (var usage = ContextFactory.GetForWrite())
|
||||
{
|
||||
usage.Context.ChangeTracker.AutoDetectChangesEnabled = false;
|
||||
|
||||
if (!ModelStore.Undelete(item)) return;
|
||||
|
||||
Files.Reference(item.Files.Select(f => f.FileInfo).ToArray());
|
||||
|
||||
usage.Context.ChangeTracker.AutoDetectChangesEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create all required <see cref="FileInfo"/>s for the provided archive, adding them to the global file store.
|
||||
/// </summary>
|
||||
private List<TFileModel> createFileInfos(ArchiveReader reader, FileStore files)
|
||||
{
|
||||
var fileInfos = new List<TFileModel>();
|
||||
|
||||
// import files to manager
|
||||
foreach (string file in reader.Filenames)
|
||||
using (Stream s = reader.GetStream(file))
|
||||
fileInfos.Add(new TFileModel
|
||||
{
|
||||
Filename = file,
|
||||
FileInfo = files.Add(s)
|
||||
});
|
||||
|
||||
return fileInfos;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a barebones model from the provided archive.
|
||||
/// Actual expensive population should be done in <see cref="Populate"/>; this should just prepare for duplicate checking.
|
||||
/// </summary>
|
||||
/// <param name="archive">The archive to create the model for.</param>
|
||||
/// <returns>A model populated with minimal information.</returns>
|
||||
protected abstract TModel CreateModel(ArchiveReader archive);
|
||||
|
||||
/// <summary>
|
||||
/// Populate the provided model completely from the given archive.
|
||||
/// After this method, the model should be in a state ready to commit to a store.
|
||||
/// </summary>
|
||||
/// <param name="model">The model to populate.</param>
|
||||
/// <param name="archive">The archive to use as a reference for population.</param>
|
||||
protected virtual void Populate(TModel model, ArchiveReader archive)
|
||||
{
|
||||
}
|
||||
|
||||
protected virtual TModel CheckForExisting(TModel model) => null;
|
||||
|
||||
private DbSet<TModel> queryModel() => ContextFactory.Get().Set<TModel>();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="ArchiveReader"/> from a valid storage path.
|
||||
/// </summary>
|
||||
/// <param name="path">A file or folder path resolving the archive content.</param>
|
||||
/// <returns>A reader giving access to the archive's content.</returns>
|
||||
private ArchiveReader getReaderFrom(string path)
|
||||
{
|
||||
if (ZipFile.IsZipFile(path))
|
||||
return new ZipArchiveReader(Files.Storage.GetStream(path), Path.GetFileName(path));
|
||||
if (Directory.Exists(path))
|
||||
return new LegacyFilesystemReader(path);
|
||||
throw new InvalidFormatException($"{path} is not a valid archive");
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +1,7 @@
|
||||
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
|
||||
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using osu.Framework.Platform;
|
||||
|
||||
@ -17,9 +14,7 @@ namespace osu.Game.Database
|
||||
/// <summary>
|
||||
/// Create a new <see cref="OsuDbContext"/> instance (separate from the shared context via <see cref="GetContext"/> for performing isolated operations.
|
||||
/// </summary>
|
||||
protected readonly Func<OsuDbContext> CreateContext;
|
||||
|
||||
private readonly ThreadLocal<OsuDbContext> queryContext;
|
||||
protected readonly IDatabaseContextFactory ContextFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Refresh an instance potentially from a different thread with a local context-tracked instance.
|
||||
@ -27,35 +22,26 @@ namespace osu.Game.Database
|
||||
/// <param name="obj">The object to use as a reference when negotiating a local instance.</param>
|
||||
/// <param name="lookupSource">An optional lookup source which will be used to query and populate a freshly retrieved replacement. If not provided, the refreshed object will still be returned but will not have any includes.</param>
|
||||
/// <typeparam name="T">A valid EF-stored type.</typeparam>
|
||||
protected virtual void Refresh<T>(ref T obj, IEnumerable<T> lookupSource = null) where T : class, IHasPrimaryKey
|
||||
protected virtual void Refresh<T>(ref T obj, IQueryable<T> lookupSource = null) where T : class, IHasPrimaryKey
|
||||
{
|
||||
var context = GetContext();
|
||||
|
||||
if (context.Entry(obj).State != EntityState.Detached) return;
|
||||
|
||||
var id = obj.ID;
|
||||
var foundObject = lookupSource?.SingleOrDefault(t => t.ID == id) ?? context.Find<T>(id);
|
||||
if (foundObject != null)
|
||||
using (var usage = ContextFactory.GetForWrite())
|
||||
{
|
||||
obj = foundObject;
|
||||
context.Entry(obj).Reload();
|
||||
var context = usage.Context;
|
||||
|
||||
if (context.Entry(obj).State != EntityState.Detached) return;
|
||||
|
||||
var id = obj.ID;
|
||||
var foundObject = lookupSource?.SingleOrDefault(t => t.ID == id) ?? context.Find<T>(id);
|
||||
if (foundObject != null)
|
||||
obj = foundObject;
|
||||
else
|
||||
context.Add(obj);
|
||||
}
|
||||
else
|
||||
context.Add(obj);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a shared context for performing lookups (or write operations on the update thread, for now).
|
||||
/// </summary>
|
||||
protected OsuDbContext GetContext() => queryContext.Value;
|
||||
|
||||
protected DatabaseBackedStore(Func<OsuDbContext> createContext, Storage storage = null)
|
||||
protected DatabaseBackedStore(IDatabaseContextFactory contextFactory, Storage storage = null)
|
||||
{
|
||||
CreateContext = createContext;
|
||||
|
||||
// todo: while this seems to work quite well, we need to consider that contexts could enter a state where they are never cleaned up.
|
||||
queryContext = new ThreadLocal<OsuDbContext>(CreateContext);
|
||||
|
||||
ContextFactory = contextFactory;
|
||||
Storage = storage;
|
||||
}
|
||||
|
||||
|
@ -1,27 +1,94 @@
|
||||
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
|
||||
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
|
||||
|
||||
using System.Threading;
|
||||
using osu.Framework.Platform;
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
public class DatabaseContextFactory
|
||||
public class DatabaseContextFactory : IDatabaseContextFactory
|
||||
{
|
||||
private readonly GameHost host;
|
||||
|
||||
private const string database_name = @"client";
|
||||
|
||||
private ThreadLocal<OsuDbContext> threadContexts;
|
||||
|
||||
private readonly object writeLock = new object();
|
||||
|
||||
private bool currentWriteDidWrite;
|
||||
private volatile int currentWriteUsages;
|
||||
|
||||
public DatabaseContextFactory(GameHost host)
|
||||
{
|
||||
this.host = host;
|
||||
recycleThreadContexts();
|
||||
}
|
||||
|
||||
public OsuDbContext GetContext() => new OsuDbContext(host.Storage.GetDatabaseConnectionString(database_name));
|
||||
/// <summary>
|
||||
/// Get a context for the current thread for read-only usage.
|
||||
/// If a <see cref="DatabaseWriteUsage"/> is in progress, the existing write-safe context will be returned.
|
||||
/// </summary>
|
||||
public OsuDbContext Get() => threadContexts.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Request a context for write usage. Can be consumed in a nested fashion (and will return the same underlying context).
|
||||
/// This method may block if a write is already active on a different thread.
|
||||
/// </summary>
|
||||
/// <returns>A usage containing a usable context.</returns>
|
||||
public DatabaseWriteUsage GetForWrite()
|
||||
{
|
||||
Monitor.Enter(writeLock);
|
||||
|
||||
Interlocked.Increment(ref currentWriteUsages);
|
||||
|
||||
return new DatabaseWriteUsage(threadContexts.Value, usageCompleted);
|
||||
}
|
||||
|
||||
private void usageCompleted(DatabaseWriteUsage usage)
|
||||
{
|
||||
int usages = Interlocked.Decrement(ref currentWriteUsages);
|
||||
|
||||
try
|
||||
{
|
||||
currentWriteDidWrite |= usage.PerformedWrite;
|
||||
|
||||
if (usages > 0) return;
|
||||
|
||||
if (currentWriteDidWrite)
|
||||
{
|
||||
// explicitly dispose to ensure any outstanding flushes happen as soon as possible (and underlying resources are purged).
|
||||
usage.Context.Dispose();
|
||||
|
||||
currentWriteDidWrite = false;
|
||||
|
||||
// once all writes are complete, we want to refresh thread-specific contexts to make sure they don't have stale local caches.
|
||||
recycleThreadContexts();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Monitor.Exit(writeLock);
|
||||
}
|
||||
}
|
||||
|
||||
private void recycleThreadContexts() => threadContexts = new ThreadLocal<OsuDbContext>(CreateContext);
|
||||
|
||||
protected virtual OsuDbContext CreateContext()
|
||||
{
|
||||
var ctx = new OsuDbContext(host.Storage.GetDatabaseConnectionString(database_name));
|
||||
ctx.Database.AutoTransactionsEnabled = false;
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
public void ResetDatabase()
|
||||
{
|
||||
// todo: we probably want to make sure there are no active contexts before performing this operation.
|
||||
host.Storage.DeleteDatabase(database_name);
|
||||
lock (writeLock)
|
||||
{
|
||||
recycleThreadContexts();
|
||||
host.Storage.DeleteDatabase(database_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
46
osu.Game/Database/DatabaseWriteUsage.cs
Normal file
46
osu.Game/Database/DatabaseWriteUsage.cs
Normal file
@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
|
||||
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
|
||||
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
public class DatabaseWriteUsage : IDisposable
|
||||
{
|
||||
public readonly OsuDbContext Context;
|
||||
private readonly IDbContextTransaction transaction;
|
||||
private readonly Action<DatabaseWriteUsage> usageCompleted;
|
||||
|
||||
public DatabaseWriteUsage(OsuDbContext context, Action<DatabaseWriteUsage> onCompleted)
|
||||
{
|
||||
Context = context;
|
||||
transaction = Context.BeginTransaction();
|
||||
usageCompleted = onCompleted;
|
||||
}
|
||||
|
||||
public bool PerformedWrite { get; private set; }
|
||||
|
||||
private bool isDisposed;
|
||||
|
||||
protected void Dispose(bool disposing)
|
||||
{
|
||||
if (isDisposed) return;
|
||||
isDisposed = true;
|
||||
|
||||
PerformedWrite |= Context.SaveChanges(transaction) > 0;
|
||||
usageCompleted?.Invoke(this);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
~DatabaseWriteUsage()
|
||||
{
|
||||
Dispose(false);
|
||||
}
|
||||
}
|
||||
}
|
22
osu.Game/Database/ICanAcceptFiles.cs
Normal file
22
osu.Game/Database/ICanAcceptFiles.cs
Normal file
@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
|
||||
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// A class which can accept files for importing.
|
||||
/// </summary>
|
||||
public interface ICanAcceptFiles
|
||||
{
|
||||
/// <summary>
|
||||
/// Import the specified paths.
|
||||
/// </summary>
|
||||
/// <param name="paths">The files which should be imported.</param>
|
||||
void Import(params string[] paths);
|
||||
|
||||
/// <summary>
|
||||
/// An array of accepted file extensions (in the standard format of ".abc").
|
||||
/// </summary>
|
||||
string[] HandledExtensions { get; }
|
||||
}
|
||||
}
|
20
osu.Game/Database/IDatabaseContextFactory.cs
Normal file
20
osu.Game/Database/IDatabaseContextFactory.cs
Normal file
@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
|
||||
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
public interface IDatabaseContextFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a context for read-only usage.
|
||||
/// </summary>
|
||||
OsuDbContext Get();
|
||||
|
||||
/// <summary>
|
||||
/// Request a context for write usage. Can be consumed in a nested fashion (and will return the same underlying context).
|
||||
/// This method may block if a write is already active on a different thread.
|
||||
/// </summary>
|
||||
/// <returns>A usage containing a usable context.</returns>
|
||||
DatabaseWriteUsage GetForWrite();
|
||||
}
|
||||
}
|
18
osu.Game/Database/IHasFiles.cs
Normal file
18
osu.Game/Database/IHasFiles.cs
Normal file
@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
|
||||
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// A model that contains a list of files it is responsible for.
|
||||
/// </summary>
|
||||
/// <typeparam name="TFile">The model representing a file.</typeparam>
|
||||
public interface IHasFiles<TFile>
|
||||
where TFile : INamedFileInfo
|
||||
|
||||
{
|
||||
List<TFile> Files { get; set; }
|
||||
}
|
||||
}
|
16
osu.Game/Database/INamedFileInfo.cs
Normal file
16
osu.Game/Database/INamedFileInfo.cs
Normal file
@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
|
||||
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
|
||||
|
||||
using osu.Game.IO;
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// Represent a join model which gives a filename and scope to a <see cref="FileInfo"/>.
|
||||
/// </summary>
|
||||
public interface INamedFileInfo
|
||||
{
|
||||
FileInfo FileInfo { get; set; }
|
||||
string Filename { get; set; }
|
||||
}
|
||||
}
|
16
osu.Game/Database/ISoftDelete.cs
Normal file
16
osu.Game/Database/ISoftDelete.cs
Normal file
@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
|
||||
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// A model that can be deleted from user's view without being instantly lost.
|
||||
/// </summary>
|
||||
public interface ISoftDelete
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether this model is marked for future deletion.
|
||||
/// </summary>
|
||||
bool DeletePending { get; set; }
|
||||
}
|
||||
}
|
149
osu.Game/Database/MutableDatabaseBackedStore.cs
Normal file
149
osu.Game/Database/MutableDatabaseBackedStore.cs
Normal file
@ -0,0 +1,149 @@
|
||||
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
|
||||
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using osu.Framework.Platform;
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// A typed store which supports basic addition, deletion and updating for soft-deletable models.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The databased model.</typeparam>
|
||||
public abstract class MutableDatabaseBackedStore<T> : DatabaseBackedStore
|
||||
where T : class, IHasPrimaryKey, ISoftDelete
|
||||
{
|
||||
public event Action<T> ItemAdded;
|
||||
public event Action<T> ItemRemoved;
|
||||
|
||||
protected MutableDatabaseBackedStore(IDatabaseContextFactory contextFactory, Storage storage = null)
|
||||
: base(contextFactory, storage)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Access items pre-populated with includes for consumption.
|
||||
/// </summary>
|
||||
public IQueryable<T> ConsumableItems => AddIncludesForConsumption(ContextFactory.Get().Set<T>());
|
||||
|
||||
/// <summary>
|
||||
/// Add a <see cref="T"/> to the database.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to add.</param>
|
||||
public void Add(T item)
|
||||
{
|
||||
using (var usage = ContextFactory.GetForWrite())
|
||||
{
|
||||
var context = usage.Context;
|
||||
context.Attach(item);
|
||||
}
|
||||
|
||||
ItemAdded?.Invoke(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update a <see cref="T"/> in the database.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to update.</param>
|
||||
public void Update(T item)
|
||||
{
|
||||
ItemRemoved?.Invoke(item);
|
||||
|
||||
using (var usage = ContextFactory.GetForWrite())
|
||||
usage.Context.Update(item);
|
||||
|
||||
ItemAdded?.Invoke(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a <see cref="T"/> from the database.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to delete.</param>
|
||||
public bool Delete(T item)
|
||||
{
|
||||
using (ContextFactory.GetForWrite())
|
||||
{
|
||||
Refresh(ref item);
|
||||
|
||||
if (item.DeletePending) return false;
|
||||
item.DeletePending = true;
|
||||
}
|
||||
|
||||
ItemRemoved?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore a <see cref="T"/> from a deleted state.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to undelete.</param>
|
||||
public bool Undelete(T item)
|
||||
{
|
||||
using (ContextFactory.GetForWrite())
|
||||
{
|
||||
Refresh(ref item, ConsumableItems);
|
||||
|
||||
if (!item.DeletePending) return false;
|
||||
item.DeletePending = false;
|
||||
}
|
||||
|
||||
ItemAdded?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allow implementations to add database-side includes or constraints when querying for consumption of items.
|
||||
/// </summary>
|
||||
/// <param name="query">The input query.</param>
|
||||
/// <returns>A potentially modified output query.</returns>
|
||||
protected virtual IQueryable<T> AddIncludesForConsumption(IQueryable<T> query) => query;
|
||||
|
||||
/// <summary>
|
||||
/// Allow implementations to add database-side includes or constraints when deleting items.
|
||||
/// Included properties could then be subsequently deleted by overriding <see cref="Purge"/>.
|
||||
/// </summary>
|
||||
/// <param name="query">The input query.</param>
|
||||
/// <returns>A potentially modified output query.</returns>
|
||||
protected virtual IQueryable<T> AddIncludesForDeletion(IQueryable<T> query) => query;
|
||||
|
||||
/// <summary>
|
||||
/// Called when removing an item completely from the database.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to be purged.</param>
|
||||
/// <param name="context">The write context which can be used to perform subsequent deletions.</param>
|
||||
protected virtual void Purge(List<T> items, OsuDbContext context) => context.RemoveRange(items);
|
||||
|
||||
public override void Cleanup()
|
||||
{
|
||||
base.Cleanup();
|
||||
PurgeDeletable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purge items in a pending delete state.
|
||||
/// </summary>
|
||||
/// <param name="query">An optional query limiting the scope of the purge.</param>
|
||||
public void PurgeDeletable(Expression<Func<T, bool>> query = null)
|
||||
{
|
||||
using (var usage = ContextFactory.GetForWrite())
|
||||
{
|
||||
var context = usage.Context;
|
||||
|
||||
var lookup = context.Set<T>().Where(s => s.DeletePending);
|
||||
|
||||
if (query != null) lookup = lookup.Where(query);
|
||||
|
||||
lookup = AddIncludesForDeletion(lookup);
|
||||
|
||||
var purgeable = lookup.ToList();
|
||||
|
||||
if (!purgeable.Any()) return;
|
||||
|
||||
Purge(purgeable, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -13,6 +13,7 @@ using osu.Game.IO;
|
||||
using osu.Game.Rulesets;
|
||||
using DatabasedKeyBinding = osu.Game.Input.Bindings.DatabasedKeyBinding;
|
||||
using LogLevel = Microsoft.Extensions.Logging.LogLevel;
|
||||
using osu.Game.Skinning;
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
@ -26,6 +27,7 @@ namespace osu.Game.Database
|
||||
public DbSet<DatabasedSetting> DatabasedSetting { get; set; }
|
||||
public DbSet<FileInfo> FileInfo { get; set; }
|
||||
public DbSet<RulesetInfo> RulesetInfo { get; set; }
|
||||
public DbSet<SkinInfo> SkinInfo { get; set; }
|
||||
|
||||
private readonly string connectionString;
|
||||
|
||||
@ -105,7 +107,7 @@ namespace osu.Game.Database
|
||||
public int SaveChanges(IDbContextTransaction transaction = null)
|
||||
{
|
||||
var ret = base.SaveChanges();
|
||||
transaction?.Commit();
|
||||
if (ret > 0) transaction?.Commit();
|
||||
return ret;
|
||||
}
|
||||
|
||||
@ -180,8 +182,6 @@ namespace osu.Game.Database
|
||||
|
||||
public void Migrate()
|
||||
{
|
||||
migrateFromSqliteNet();
|
||||
|
||||
try
|
||||
{
|
||||
Database.Migrate();
|
||||
@ -191,86 +191,6 @@ namespace osu.Game.Database
|
||||
throw new MigrationFailedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateFromSqliteNet()
|
||||
{
|
||||
try
|
||||
{
|
||||
// will fail if the database isn't in a sane EF-migrated state.
|
||||
Database.ExecuteSqlCommand("SELECT MetadataID FROM BeatmapSetInfo LIMIT 1");
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
Database.ExecuteSqlCommand("DROP TABLE IF EXISTS __EFMigrationsHistory");
|
||||
|
||||
// will fail (intentionally) if we don't have sqlite-net data present.
|
||||
Database.ExecuteSqlCommand("SELECT OnlineBeatmapSetId FROM BeatmapMetadata LIMIT 1");
|
||||
|
||||
try
|
||||
{
|
||||
Logger.Log("Performing migration from sqlite-net to EF...", LoggingTarget.Database, Framework.Logging.LogLevel.Important);
|
||||
|
||||
// we are good to perform messy migration of data!.
|
||||
Database.ExecuteSqlCommand("ALTER TABLE BeatmapDifficulty RENAME TO BeatmapDifficulty_Old");
|
||||
Database.ExecuteSqlCommand("ALTER TABLE BeatmapMetadata RENAME TO BeatmapMetadata_Old");
|
||||
Database.ExecuteSqlCommand("ALTER TABLE FileInfo RENAME TO FileInfo_Old");
|
||||
Database.ExecuteSqlCommand("ALTER TABLE KeyBinding RENAME TO KeyBinding_Old");
|
||||
Database.ExecuteSqlCommand("ALTER TABLE BeatmapSetInfo RENAME TO BeatmapSetInfo_Old");
|
||||
Database.ExecuteSqlCommand("ALTER TABLE BeatmapInfo RENAME TO BeatmapInfo_Old");
|
||||
Database.ExecuteSqlCommand("ALTER TABLE BeatmapSetFileInfo RENAME TO BeatmapSetFileInfo_Old");
|
||||
Database.ExecuteSqlCommand("ALTER TABLE RulesetInfo RENAME TO RulesetInfo_Old");
|
||||
|
||||
Database.ExecuteSqlCommand("DROP TABLE StoreVersion");
|
||||
|
||||
// perform EF migrations to create sane table structure.
|
||||
Database.Migrate();
|
||||
|
||||
// copy data table by table to new structure, dropping old tables as we go.
|
||||
Database.ExecuteSqlCommand("INSERT INTO FileInfo SELECT * FROM FileInfo_Old");
|
||||
Database.ExecuteSqlCommand("DROP TABLE FileInfo_Old");
|
||||
|
||||
Database.ExecuteSqlCommand("INSERT INTO KeyBinding SELECT ID, [Action], Keys, RulesetID, Variant FROM KeyBinding_Old");
|
||||
Database.ExecuteSqlCommand("DROP TABLE KeyBinding_Old");
|
||||
|
||||
Database.ExecuteSqlCommand(
|
||||
"INSERT INTO BeatmapMetadata SELECT ID, Artist, ArtistUnicode, AudioFile, Author, BackgroundFile, PreviewTime, Source, Tags, Title, TitleUnicode FROM BeatmapMetadata_Old");
|
||||
Database.ExecuteSqlCommand("DROP TABLE BeatmapMetadata_Old");
|
||||
|
||||
Database.ExecuteSqlCommand(
|
||||
"INSERT INTO BeatmapDifficulty SELECT `ID`, `ApproachRate`, `CircleSize`, `DrainRate`, `OverallDifficulty`, `SliderMultiplier`, `SliderTickRate` FROM BeatmapDifficulty_Old");
|
||||
Database.ExecuteSqlCommand("DROP TABLE BeatmapDifficulty_Old");
|
||||
|
||||
Database.ExecuteSqlCommand("INSERT INTO BeatmapSetInfo SELECT ID, DeletePending, Hash, BeatmapMetadataID, OnlineBeatmapSetID, Protected FROM BeatmapSetInfo_Old");
|
||||
Database.ExecuteSqlCommand("DROP TABLE BeatmapSetInfo_Old");
|
||||
|
||||
Database.ExecuteSqlCommand("INSERT INTO BeatmapSetFileInfo SELECT ID, BeatmapSetInfoID, FileInfoID, Filename FROM BeatmapSetFileInfo_Old");
|
||||
Database.ExecuteSqlCommand("DROP TABLE BeatmapSetFileInfo_Old");
|
||||
|
||||
Database.ExecuteSqlCommand("INSERT INTO RulesetInfo SELECT ID, Available, InstantiationInfo, Name FROM RulesetInfo_Old");
|
||||
Database.ExecuteSqlCommand("DROP TABLE RulesetInfo_Old");
|
||||
|
||||
Database.ExecuteSqlCommand(
|
||||
"INSERT INTO BeatmapInfo SELECT ID, AudioLeadIn, BaseDifficultyID, BeatDivisor, BeatmapSetInfoID, Countdown, DistanceSpacing, GridSize, Hash, IFNULL(Hidden, 0), LetterboxInBreaks, MD5Hash, NULLIF(BeatmapMetadataID, 0), NULLIF(OnlineBeatmapID, 0), Path, RulesetID, SpecialStyle, StackLeniency, StarDifficulty, StoredBookmarks, TimelineZoom, Version, WidescreenStoryboard FROM BeatmapInfo_Old");
|
||||
Database.ExecuteSqlCommand("DROP TABLE BeatmapInfo_Old");
|
||||
|
||||
Logger.Log("Migration complete!", LoggingTarget.Database, Framework.Logging.LogLevel.Important);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new MigrationFailedException(e);
|
||||
}
|
||||
}
|
||||
catch (MigrationFailedException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class MigrationFailedException : Exception
|
||||
|
19
osu.Game/Database/SingletonContextFactory.cs
Normal file
19
osu.Game/Database/SingletonContextFactory.cs
Normal file
@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
|
||||
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
public class SingletonContextFactory : IDatabaseContextFactory
|
||||
{
|
||||
private readonly OsuDbContext context;
|
||||
|
||||
public SingletonContextFactory(OsuDbContext context)
|
||||
{
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public OsuDbContext Get() => context;
|
||||
|
||||
public DatabaseWriteUsage GetForWrite() => new DatabaseWriteUsage(context, null);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user