// 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.Linq; using System.Runtime.Serialization; using AutoMapper; using AutoMapper.Internal; using osu.Framework.Development; using osu.Game.Beatmaps; using osu.Game.Input.Bindings; using osu.Game.Models; using osu.Game.Rulesets; using osu.Game.Scoring; using Realms; #nullable enable namespace osu.Game.Database { public static class RealmObjectExtensions { private static readonly IMapper write_mapper = new MapperConfiguration(c => { c.ShouldMapField = fi => 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.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() .ForMember(s => s.Beatmaps, cc => cc.Ignore()) .AfterMap((s, d) => { foreach (var beatmap in s.Beatmaps) { var existing = d.Beatmaps.FirstOrDefault(b => b.ID == beatmap.ID); if (existing != null) copyChangesToRealm(beatmap, existing); else d.Beatmaps.Add(beatmap); } }); c.Internal().ForAllMaps((typeMap, 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() .MaxDepth(2) .AfterMap((s, 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((s, 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() .MaxDepth(2) .ForMember(b => b.Files, cc => cc.Ignore()) .AfterMap((s, 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 = fi => 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((typeMap, 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(); } /// /// 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 ILive ToLiveUnmanaged(this T realmObject) where T : RealmObject, IHasGuidPrimaryKey { return new RealmLiveUnmanaged(realmObject); } public static List> ToLive(this IEnumerable realmList, RealmContextFactory realmContextFactory) where T : RealmObject, IHasGuidPrimaryKey { return realmList.Select(l => new RealmLive(l, realmContextFactory)).Cast>().ToList(); } public static ILive ToLive(this T realmObject, RealmContextFactory realmContextFactory) where T : RealmObject, IHasGuidPrimaryKey { return new RealmLive(realmObject, realmContextFactory); } /// /// 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 . /// /// May be null in the case the provided collection is not managed. /// /// /// public static IDisposable? QueryAsyncWithNotifications(this IRealmCollection collection, NotificationCallbackDelegate callback) where T : RealmObjectBase { // Subscriptions can only work on the main thread. if (!ThreadSafety.IsUpdateThread) throw new InvalidOperationException("Cannot subscribe for realm notifications from a non-update thread."); 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); } } }