// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Runtime.Serialization; using AutoMapper; using AutoMapper.Internal; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Input.Bindings; using osu.Game.Models; using osu.Game.Rulesets; using osu.Game.Scoring; using Realms; namespace osu.Game.Database { public static class RealmObjectExtensions { private static readonly IMapper write_mapper = new MapperConfiguration(c => { c.ShouldMapField = _ => false; c.ShouldMapProperty = pi => pi.SetMethod?.IsPublic == true; c.CreateMap() .ForMember(s => s.Author, cc => cc.Ignore()) .AfterMap((s, d) => { copyChangesToRealm(s.Author, d.Author); }); c.CreateMap(); c.CreateMap(); c.CreateMap(); c.CreateMap(); c.CreateMap() .ForMember(s => s.Ruleset, cc => cc.Ignore()) .ForMember(s => s.Metadata, cc => cc.Ignore()) .ForMember(s => s.UserSettings, cc => cc.Ignore()) .ForMember(s => s.Difficulty, cc => cc.Ignore()) .ForMember(s => s.BeatmapSet, cc => cc.Ignore()) .AfterMap((s, d) => { d.Ruleset = d.Realm!.Find(s.Ruleset.ShortName)!; copyChangesToRealm(s.Difficulty, d.Difficulty); copyChangesToRealm(s.Metadata, d.Metadata); }); c.CreateMap() .ConstructUsing(_ => new BeatmapSetInfo(null)) .ForMember(s => s.Beatmaps, cc => cc.Ignore()) .AfterMap((s, d) => { foreach (var beatmap in s.Beatmaps) { // Importantly, search all of realm for the beatmap (not just the set's beatmaps). // It may have gotten detached, and if that's the case let's use this opportunity to fix // things up. var existingBeatmap = d.Realm!.Find(beatmap.ID); if (existingBeatmap != null) { // As above, reattach if it happens to not be in the set's beatmaps. if (!d.Beatmaps.Contains(existingBeatmap)) { Debug.Fail("Beatmaps should never become detached under normal circumstances. If this ever triggers, it should be investigated further."); Logger.Log("WARNING: One of the difficulties in a beatmap was detached from its set. Please save a copy of logs and report this to devs.", LoggingTarget.Database, LogLevel.Important); d.Beatmaps.Add(existingBeatmap); } copyChangesToRealm(beatmap, existingBeatmap); } else { var newBeatmap = new BeatmapInfo { ID = beatmap.ID, BeatmapSet = d, Ruleset = d.Realm.Find(beatmap.Ruleset.ShortName)! }; d.Beatmaps.Add(newBeatmap); copyChangesToRealm(beatmap, newBeatmap); } } }); c.Internal().ForAllMaps((_, expression) => { expression.ForAllMembers(m => { if (m.DestinationMember.Has() || m.DestinationMember.Has() || m.DestinationMember.Has()) m.Ignore(); }); }); }).CreateMapper(); private static readonly IMapper mapper = new MapperConfiguration(c => { applyCommonConfiguration(c); c.CreateMap() .ConstructUsing(_ => new BeatmapSetInfo(null)) .MaxDepth(2) .AfterMap((_, d) => { foreach (var beatmap in d.Beatmaps) beatmap.BeatmapSet = d; }); // This can be further optimised to reduce cyclic retrievals, similar to the optimised set mapper below. // Only hasn't been done yet as we detach at the point of BeatmapInfo less often. c.CreateMap() .MaxDepth(2) .AfterMap((_, d) => { for (int i = 0; i < d.BeatmapSet?.Beatmaps.Count; i++) { if (d.BeatmapSet.Beatmaps[i].Equals(d)) { d.BeatmapSet.Beatmaps[i] = d; break; } } }); }).CreateMapper(); /// /// A slightly optimised mapper that avoids double-fetches in cyclic reference. /// private static readonly IMapper beatmap_set_mapper = new MapperConfiguration(c => { applyCommonConfiguration(c); c.CreateMap() .ConstructUsing(_ => new BeatmapSetInfo(null)) .MaxDepth(2) .ForMember(b => b.Files, cc => cc.Ignore()) .AfterMap((_, d) => { foreach (var beatmap in d.Beatmaps) beatmap.BeatmapSet = d; }); c.CreateMap() .MaxDepth(1) // This is not required as it will be populated in the `AfterMap` call from the `BeatmapInfo`'s parent. .ForMember(b => b.BeatmapSet, cc => cc.Ignore()); }).CreateMapper(); private static void applyCommonConfiguration(IMapperConfigurationExpression c) { c.ShouldMapField = _ => false; // This is specifically to avoid mapping explicit interface implementations. // If we want to limit this further, we can avoid mapping properties with no setter that are not IList<>. // Takes a bit of effort to determine whether this is the case though, see https://stackoverflow.com/questions/951536/how-do-i-tell-whether-a-type-implements-ilist c.ShouldMapProperty = pi => pi.GetMethod?.IsPublic == true; c.Internal().ForAllMaps((_, expression) => { expression.ForAllMembers(m => { if (m.DestinationMember.Has() || m.DestinationMember.Has() || m.DestinationMember.Has()) m.Ignore(); }); }); c.CreateMap(); c.CreateMap(); c.CreateMap(); c.CreateMap(); c.CreateMap(); c.CreateMap(); c.CreateMap(); c.CreateMap(); c.CreateMap(); } /// /// Create a detached copy of the each item in the collection. /// /// /// Items which are already detached (ie. not managed by realm) will not be modified. /// /// A list of managed s to detach. /// The type of object. /// A list containing non-managed copies of provided items. public static List Detach(this IEnumerable items) where T : RealmObjectBase { var list = new List(); foreach (var obj in items) list.Add(obj.Detach()); return list; } /// /// Create a detached copy of the item. /// /// /// If the item if already detached (ie. not managed by realm) it will not be detached again and the original instance will be returned. This allows this method to be potentially called at multiple levels while only incurring the clone overhead once. /// /// The managed to detach. /// The type of object. /// A non-managed copy of provided item. Will return the provided item if already detached. public static T Detach(this T item) where T : RealmObjectBase { if (!item.IsManaged) return item; if (item is BeatmapSetInfo) return beatmap_set_mapper.Map(item); return mapper.Map(item); } /// /// Copy changes in a detached beatmap back to realm. /// This is a temporary method to handle existing flows only. It should not be used going forward if we can avoid it. /// /// The detached beatmap to copy from. /// The live beatmap to copy to. public static void CopyChangesToRealm(this BeatmapSetInfo source, BeatmapSetInfo destination) => copyChangesToRealm(source, destination); private static void copyChangesToRealm(T source, T destination) where T : RealmObjectBase => write_mapper.Map(source, destination); public static List> ToLiveUnmanaged(this IEnumerable realmList) where T : RealmObject, IHasGuidPrimaryKey { return realmList.Select(l => new RealmLiveUnmanaged(l)).Cast>().ToList(); } public static Live ToLiveUnmanaged(this T realmObject) where T : RealmObject, IHasGuidPrimaryKey { return new RealmLiveUnmanaged(realmObject); } public static Live ToLive(this T realmObject, RealmAccess realm) where T : RealmObject, IHasGuidPrimaryKey { return new RealmLive(realmObject, realm); } /// /// Register a callback to be invoked each time this changes. /// /// /// /// This adds osu! specific thread and managed state safety checks on top of . /// /// /// The first callback will be invoked with the initial after the asynchronous query completes, /// and then called again after each write transaction which changes either any of the objects in the collection, or /// which objects are in the collection. The changes parameter will /// be null the first time the callback is invoked with the initial results. For each call after that, /// it will contain information about which rows in the results were added, removed or modified. /// /// /// If a write transaction did not modify any objects in this , the callback is not invoked at all. /// If an error occurs the callback will be invoked with null for the sender parameter and a non-null error. /// Currently the only errors that can occur are when opening the on the background worker thread. /// /// /// At the time when the block is called, the object will be fully evaluated /// and up-to-date, and as long as you do not perform a write transaction on the same thread /// or explicitly call , accessing it will never perform blocking work. /// /// /// Notifications are delivered via the standard event loop, and so can't be delivered while the event loop is blocked by other activity. /// When notifications can't be delivered instantly, multiple notifications may be coalesced into a single notification. /// This can include the notification with the initial collection. /// /// /// The to observe for changes. /// The callback to be invoked with the updated . /// /// A subscription token. It must be kept alive for as long as you want to receive change notifications. /// To stop receiving notifications, call . /// /// /// public static IDisposable QueryAsyncWithNotifications(this IRealmCollection collection, NotificationCallbackDelegate callback) where T : RealmObjectBase { if (!RealmAccess.CurrentThreadSubscriptionsAllowed) throw new InvalidOperationException($"Make sure to call {nameof(RealmAccess)}.{nameof(RealmAccess.RegisterForNotifications)}"); return collection.SubscribeForNotifications(callback); } /// /// A convenience method that casts to and subscribes for change notifications. /// /// /// This adds osu! specific thread and managed state safety checks on top of . /// /// The to observe for changes. /// Type of the elements in the list. /// /// The callback to be invoked with the updated . /// /// A subscription token. It must be kept alive for as long as you want to receive change notifications. /// To stop receiving notifications, call . /// /// May be null in the case the provided collection is not managed. /// public static IDisposable? QueryAsyncWithNotifications(this IQueryable list, NotificationCallbackDelegate callback) where T : RealmObjectBase { // Subscribing to non-managed instances doesn't work. // In this usage, the instance may be non-managed in tests. if (!(list is IRealmCollection realmCollection)) return null; return QueryAsyncWithNotifications(realmCollection, callback); } /// /// A convenience method that casts to and subscribes for change notifications. /// /// /// This adds osu! specific thread and managed state safety checks on top of . /// /// The to observe for changes. /// Type of the elements in the list. /// /// The callback to be invoked with the updated . /// /// A subscription token. It must be kept alive for as long as you want to receive change notifications. /// To stop receiving notifications, call . /// /// May be null in the case the provided collection is not managed. /// public static IDisposable? QueryAsyncWithNotifications(this IList list, NotificationCallbackDelegate callback) where T : RealmObjectBase { // Subscribing to non-managed instances doesn't work. // In this usage, the instance may be non-managed in tests. if (!(list is IRealmCollection realmCollection)) return null; return QueryAsyncWithNotifications(realmCollection, callback); } } }