From e6181c24124d97f2fbc932b8a68311e625463156 Mon Sep 17 00:00:00 2001 From: uzvkl Date: Tue, 11 Jun 2019 23:05:52 +0200 Subject: Move dsa related stuff to subfolder --- dsa/FireBase/ExceptionEventArgs.cs | 28 ++ dsa/FireBase/Extensions/ObservableExtensions.cs | 41 ++ dsa/FireBase/Extensions/TaskExtensions.cs | 23 + dsa/FireBase/FireBase.csproj | 13 + dsa/FireBase/FirebaseClient.cs | 49 +++ dsa/FireBase/FirebaseException.cs | 53 +++ dsa/FireBase/FirebaseKeyGenerator.cs | 79 ++++ dsa/FireBase/FirebaseObject.cs | 27 ++ dsa/FireBase/FirebaseOptions.cs | 52 +++ dsa/FireBase/Http/HttpClientExtensions.cs | 123 ++++++ dsa/FireBase/Http/PostResult.cs | 13 + dsa/FireBase/ObservableExtensions.cs | 40 ++ dsa/FireBase/Offline/ConcurrentOfflineDatabase.cs | 233 ++++++++++ dsa/FireBase/Offline/DatabaseExtensions.cs | 257 +++++++++++ dsa/FireBase/Offline/ISetHandler.cs | 10 + dsa/FireBase/Offline/InitialPullStrategy.cs | 23 + .../Offline/Internals/MemberAccessVisitor.cs | 46 ++ dsa/FireBase/Offline/OfflineCacheAdapter.cs | 152 +++++++ dsa/FireBase/Offline/OfflineDatabase.cs | 228 ++++++++++ dsa/FireBase/Offline/OfflineEntry.cs | 99 +++++ dsa/FireBase/Offline/RealtimeDatabase.cs | 479 +++++++++++++++++++++ dsa/FireBase/Offline/SetHandler.cs | 19 + dsa/FireBase/Offline/StreamingOptions.cs | 23 + dsa/FireBase/Offline/SyncOptions.cs | 28 ++ dsa/FireBase/Query/AuthQuery.cs | 34 ++ dsa/FireBase/Query/ChildQuery.cs | 50 +++ dsa/FireBase/Query/FilterQuery.cs | 77 ++++ dsa/FireBase/Query/FirebaseQuery.cs | 314 ++++++++++++++ dsa/FireBase/Query/IFirebaseQuery.cs | 40 ++ dsa/FireBase/Query/OrderQuery.cs | 34 ++ dsa/FireBase/Query/ParameterQuery.cs | 43 ++ dsa/FireBase/Query/QueryExtensions.cs | 210 +++++++++ dsa/FireBase/Query/QueryFactoryExtensions.cs | 187 ++++++++ dsa/FireBase/Query/SilentQuery.cs | 18 + dsa/FireBase/Settings.StyleCop | 77 ++++ dsa/FireBase/Streaming/FirebaseCache.cs | 181 ++++++++ dsa/FireBase/Streaming/FirebaseEvent.cs | 37 ++ dsa/FireBase/Streaming/FirebaseEventSource.cs | 38 ++ dsa/FireBase/Streaming/FirebaseEventType.cs | 18 + dsa/FireBase/Streaming/FirebaseServerEventType.cs | 15 + dsa/FireBase/Streaming/FirebaseSubscription.cs | 217 ++++++++++ dsa/FireBase/Streaming/NonBlockingStreamReader.cs | 63 +++ 42 files changed, 3791 insertions(+) create mode 100644 dsa/FireBase/ExceptionEventArgs.cs create mode 100644 dsa/FireBase/Extensions/ObservableExtensions.cs create mode 100644 dsa/FireBase/Extensions/TaskExtensions.cs create mode 100644 dsa/FireBase/FireBase.csproj create mode 100644 dsa/FireBase/FirebaseClient.cs create mode 100644 dsa/FireBase/FirebaseException.cs create mode 100644 dsa/FireBase/FirebaseKeyGenerator.cs create mode 100644 dsa/FireBase/FirebaseObject.cs create mode 100644 dsa/FireBase/FirebaseOptions.cs create mode 100644 dsa/FireBase/Http/HttpClientExtensions.cs create mode 100644 dsa/FireBase/Http/PostResult.cs create mode 100644 dsa/FireBase/ObservableExtensions.cs create mode 100644 dsa/FireBase/Offline/ConcurrentOfflineDatabase.cs create mode 100644 dsa/FireBase/Offline/DatabaseExtensions.cs create mode 100644 dsa/FireBase/Offline/ISetHandler.cs create mode 100644 dsa/FireBase/Offline/InitialPullStrategy.cs create mode 100644 dsa/FireBase/Offline/Internals/MemberAccessVisitor.cs create mode 100644 dsa/FireBase/Offline/OfflineCacheAdapter.cs create mode 100644 dsa/FireBase/Offline/OfflineDatabase.cs create mode 100644 dsa/FireBase/Offline/OfflineEntry.cs create mode 100644 dsa/FireBase/Offline/RealtimeDatabase.cs create mode 100644 dsa/FireBase/Offline/SetHandler.cs create mode 100644 dsa/FireBase/Offline/StreamingOptions.cs create mode 100644 dsa/FireBase/Offline/SyncOptions.cs create mode 100644 dsa/FireBase/Query/AuthQuery.cs create mode 100644 dsa/FireBase/Query/ChildQuery.cs create mode 100644 dsa/FireBase/Query/FilterQuery.cs create mode 100644 dsa/FireBase/Query/FirebaseQuery.cs create mode 100644 dsa/FireBase/Query/IFirebaseQuery.cs create mode 100644 dsa/FireBase/Query/OrderQuery.cs create mode 100644 dsa/FireBase/Query/ParameterQuery.cs create mode 100644 dsa/FireBase/Query/QueryExtensions.cs create mode 100644 dsa/FireBase/Query/QueryFactoryExtensions.cs create mode 100644 dsa/FireBase/Query/SilentQuery.cs create mode 100644 dsa/FireBase/Settings.StyleCop create mode 100644 dsa/FireBase/Streaming/FirebaseCache.cs create mode 100644 dsa/FireBase/Streaming/FirebaseEvent.cs create mode 100644 dsa/FireBase/Streaming/FirebaseEventSource.cs create mode 100644 dsa/FireBase/Streaming/FirebaseEventType.cs create mode 100644 dsa/FireBase/Streaming/FirebaseServerEventType.cs create mode 100644 dsa/FireBase/Streaming/FirebaseSubscription.cs create mode 100644 dsa/FireBase/Streaming/NonBlockingStreamReader.cs (limited to 'dsa/FireBase') diff --git a/dsa/FireBase/ExceptionEventArgs.cs b/dsa/FireBase/ExceptionEventArgs.cs new file mode 100644 index 0000000..09c205a --- /dev/null +++ b/dsa/FireBase/ExceptionEventArgs.cs @@ -0,0 +1,28 @@ +using System; + +namespace Firebase.Database +{ + /// + /// Event args holding the object. + /// + public class ExceptionEventArgs : EventArgs where T : Exception + { + public readonly T Exception; + + /// + /// Initializes a new instance of the class. + /// + /// The exception. + public ExceptionEventArgs(T exception) + { + Exception = exception; + } + } + + public class ExceptionEventArgs : ExceptionEventArgs + { + public ExceptionEventArgs(Exception exception) : base(exception) + { + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Extensions/ObservableExtensions.cs b/dsa/FireBase/Extensions/ObservableExtensions.cs new file mode 100644 index 0000000..0a672d7 --- /dev/null +++ b/dsa/FireBase/Extensions/ObservableExtensions.cs @@ -0,0 +1,41 @@ +using System; +using System.Reactive.Linq; + +namespace Firebase.Database.Extensions +{ + public static class ObservableExtensions + { + /// + /// Returns a cold observable which retries (re-subscribes to) the source observable on error until it successfully + /// terminates. + /// + /// The source observable. + /// How long to wait between attempts. + /// A predicate determining for which exceptions to retry. Defaults to all + /// + /// A cold observable which retries (re-subscribes to) the source observable on error up to the + /// specified number of times or until it successfully terminates. + /// + public static IObservable RetryAfterDelay( + this IObservable source, + TimeSpan dueTime, + Func retryOnError) + where TException : Exception + { + var attempt = 0; + + return Observable.Defer(() => + { + return (++attempt == 1 ? source : source.DelaySubscription(dueTime)) + .Select(item => new Tuple(true, item, null)) + .Catch, TException>(e => retryOnError(e) + ? Observable.Throw>(e) + : Observable.Return(new Tuple(false, default(T), e))); + }) + .Retry() + .SelectMany(t => t.Item1 + ? Observable.Return(t.Item2) + : Observable.Throw(t.Item3)); + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Extensions/TaskExtensions.cs b/dsa/FireBase/Extensions/TaskExtensions.cs new file mode 100644 index 0000000..c955b3a --- /dev/null +++ b/dsa/FireBase/Extensions/TaskExtensions.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; + +namespace Firebase.Database.Extensions +{ + public static class TaskExtensions + { + /// + /// Instead of unwrapping it throws it as it is. + /// + public static async Task WithAggregateException(this Task source) + { + try + { + await source.ConfigureAwait(false); + } + catch (Exception ex) + { + throw source.Exception ?? ex; + } + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/FireBase.csproj b/dsa/FireBase/FireBase.csproj new file mode 100644 index 0000000..2a47b27 --- /dev/null +++ b/dsa/FireBase/FireBase.csproj @@ -0,0 +1,13 @@ + + + + netcoreapp2.2 + + + + + + + + + diff --git a/dsa/FireBase/FirebaseClient.cs b/dsa/FireBase/FirebaseClient.cs new file mode 100644 index 0000000..3079f3b --- /dev/null +++ b/dsa/FireBase/FirebaseClient.cs @@ -0,0 +1,49 @@ +using System; +using System.Net.Http; +using System.Runtime.CompilerServices; +using Firebase.Database.Query; + +[assembly: InternalsVisibleTo("Firebase.Database.Tests")] + +namespace Firebase.Database +{ + /// + /// Firebase client which acts as an entry point to the online database. + /// + public class FirebaseClient : IDisposable + { + private readonly string baseUrl; + internal readonly HttpClient HttpClient; + internal readonly FirebaseOptions Options; + + /// + /// Initializes a new instance of the class. + /// + /// The base url. + /// Offline database. + public FirebaseClient(string baseUrl, FirebaseOptions options = null) + { + HttpClient = new HttpClient(); + Options = options ?? new FirebaseOptions(); + + this.baseUrl = baseUrl; + + if (!this.baseUrl.EndsWith("/")) this.baseUrl += "/"; + } + + public void Dispose() + { + HttpClient?.Dispose(); + } + + /// + /// Queries for a child of the data root. + /// + /// Name of the child. + /// . + public ChildQuery Child(string resourceName) + { + return new ChildQuery(this, () => baseUrl + resourceName); + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/FirebaseException.cs b/dsa/FireBase/FirebaseException.cs new file mode 100644 index 0000000..cfc09d3 --- /dev/null +++ b/dsa/FireBase/FirebaseException.cs @@ -0,0 +1,53 @@ +using System; +using System.Net; + +namespace Firebase.Database +{ + public class FirebaseException : Exception + { + public FirebaseException(string requestUrl, string requestData, string responseData, HttpStatusCode statusCode) + : base(GenerateExceptionMessage(requestUrl, requestData, responseData)) + { + RequestUrl = requestUrl; + RequestData = requestData; + ResponseData = responseData; + StatusCode = statusCode; + } + + public FirebaseException(string requestUrl, string requestData, string responseData, HttpStatusCode statusCode, + Exception innerException) + : base(GenerateExceptionMessage(requestUrl, requestData, responseData), innerException) + { + RequestUrl = requestUrl; + RequestData = requestData; + ResponseData = responseData; + StatusCode = statusCode; + } + + /// + /// Post data passed to the authentication service. + /// + public string RequestData { get; } + + /// + /// Original url of the request. + /// + public string RequestUrl { get; } + + /// + /// Response from the authentication service. + /// + public string ResponseData { get; } + + /// + /// Status code of the response. + /// + public HttpStatusCode StatusCode { get; } + + private static string GenerateExceptionMessage(string requestUrl, string requestData, string responseData) + { + return + $"Exception occured while processing the request.\nUrl: {requestUrl}\nRequest Data: {requestData}\nResponse: {responseData}"; + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/FirebaseKeyGenerator.cs b/dsa/FireBase/FirebaseKeyGenerator.cs new file mode 100644 index 0000000..37beed5 --- /dev/null +++ b/dsa/FireBase/FirebaseKeyGenerator.cs @@ -0,0 +1,79 @@ +using System; +using System.Text; + +namespace Firebase.Database +{ + /// + /// Offline key generator which mimics the official Firebase generators. + /// Credit: https://github.com/bubbafat/FirebaseSharp/blob/master/src/FirebaseSharp.Portable/FireBasePushIdGenerator.cs + /// + public class FirebaseKeyGenerator + { + // Modeled after base64 web-safe chars, but ordered by ASCII. + private const string PushCharsString = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz"; + private static readonly char[] PushChars; + private static readonly DateTimeOffset Epoch = new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero); + + private static readonly Random random = new Random(); + private static readonly byte[] lastRandChars = new byte[12]; + + // Timestamp of last push, used to prevent local collisions if you push twice in one ms. + private static long lastPushTime; + + static FirebaseKeyGenerator() + { + PushChars = Encoding.UTF8.GetChars(Encoding.UTF8.GetBytes(PushCharsString)); + } + + /// + /// Returns next firebase key based on current time. + /// + /// + /// The . + /// + public static string Next() + { + // We generate 72-bits of randomness which get turned into 12 characters and + // appended to the timestamp to prevent collisions with other clients. We store the last + // characters we generated because in the event of a collision, we'll use those same + // characters except "incremented" by one. + var id = new StringBuilder(20); + var now = (long) (DateTimeOffset.Now - Epoch).TotalMilliseconds; + var duplicateTime = now == lastPushTime; + lastPushTime = now; + + var timeStampChars = new char[8]; + for (var i = 7; i >= 0; i--) + { + var index = (int) (now % PushChars.Length); + timeStampChars[i] = PushChars[index]; + now = (long) Math.Floor((double) now / PushChars.Length); + } + + if (now != 0) throw new Exception("We should have converted the entire timestamp."); + + id.Append(timeStampChars); + + if (!duplicateTime) + { + for (var i = 0; i < 12; i++) lastRandChars[i] = (byte) random.Next(0, PushChars.Length); + } + else + { + // If the timestamp hasn't changed since last push, use the same random number, + // except incremented by 1. + var lastIndex = 11; + for (; lastIndex >= 0 && lastRandChars[lastIndex] == PushChars.Length - 1; lastIndex--) + lastRandChars[lastIndex] = 0; + + lastRandChars[lastIndex]++; + } + + for (var i = 0; i < 12; i++) id.Append(PushChars[lastRandChars[i]]); + + if (id.Length != 20) throw new Exception("Length should be 20."); + + return id.ToString(); + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/FirebaseObject.cs b/dsa/FireBase/FirebaseObject.cs new file mode 100644 index 0000000..2e0fd20 --- /dev/null +++ b/dsa/FireBase/FirebaseObject.cs @@ -0,0 +1,27 @@ +namespace Firebase.Database +{ + /// + /// Holds the object of type + /// + /// along with its key. + /// + /// Type of the underlying object. + public class FirebaseObject + { + internal FirebaseObject(string key, T obj) + { + Key = key; + Object = obj; + } + + /// + /// Gets the key of . + /// + public string Key { get; } + + /// + /// Gets the underlying object. + /// + public T Object { get; } + } +} \ No newline at end of file diff --git a/dsa/FireBase/FirebaseOptions.cs b/dsa/FireBase/FirebaseOptions.cs new file mode 100644 index 0000000..b4a5e51 --- /dev/null +++ b/dsa/FireBase/FirebaseOptions.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Firebase.Database.Offline; +using Newtonsoft.Json; + +namespace Firebase.Database +{ + public class FirebaseOptions + { + public FirebaseOptions() + { + OfflineDatabaseFactory = (t, s) => new Dictionary(); + SubscriptionStreamReaderFactory = s => new StreamReader(s); + JsonSerializerSettings = new JsonSerializerSettings(); + SyncPeriod = TimeSpan.FromSeconds(10); + } + + /// + /// Gets or sets the factory for Firebase offline database. Default is in-memory dictionary. + /// + public Func> OfflineDatabaseFactory { get; set; } + + /// + /// Gets or sets the method for retrieving auth tokens. Default is null. + /// + public Func> AuthTokenAsyncFactory { get; set; } + + /// + /// Gets or sets the factory for used for reading online streams. Default is + /// . + /// + public Func SubscriptionStreamReaderFactory { get; set; } + + /// + /// Gets or sets the json serializer settings. + /// + public JsonSerializerSettings JsonSerializerSettings { get; set; } + + /// + /// Gets or sets the time between synchronization attempts for pulling and pushing offline entities. Default is 10 + /// seconds. + /// + public TimeSpan SyncPeriod { get; set; } + + /// + /// Specify if token returned by factory will be used as "auth" url parameter or "access_token". + /// + public bool AsAccessToken { get; set; } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Http/HttpClientExtensions.cs b/dsa/FireBase/Http/HttpClientExtensions.cs new file mode 100644 index 0000000..6582769 --- /dev/null +++ b/dsa/FireBase/Http/HttpClientExtensions.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace Firebase.Database.Http +{ + /// + /// The http client extensions for object deserializations. + /// + internal static class HttpClientExtensions + { + /// + /// The get object collection async. + /// + /// The client. + /// The request uri. + /// The specific JSON Serializer Settings. + /// The type of entities the collection should contain. + /// The . + public static async Task>> GetObjectCollectionAsync( + this HttpClient client, string requestUri, + JsonSerializerSettings jsonSerializerSettings) + { + var responseData = string.Empty; + var statusCode = HttpStatusCode.OK; + + try + { + var response = await client.GetAsync(requestUri).ConfigureAwait(false); + statusCode = response.StatusCode; + responseData = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + var dictionary = + JsonConvert.DeserializeObject>(responseData, jsonSerializerSettings); + + if (dictionary == null) return new FirebaseObject[0]; + + return dictionary.Select(item => new FirebaseObject(item.Key, item.Value)).ToList(); + } + catch (Exception ex) + { + throw new FirebaseException(requestUri, string.Empty, responseData, statusCode, ex); + } + } + + /*/// + /// The get object collection async. + /// + /// The client. + /// The request uri. + /// /// The Data Type. + /// The specific JSON Serializer Settings. + /// The type of entities the collection should contain. + /// The . + public static async Task>> GetObjectCollectionAsync(this HttpClient client, string requestUri, + JsonSerializerSettings jsonSerializerSettings, Type dataType) + { + var responseData = string.Empty; + var statusCode = HttpStatusCode.OK; + + try + { + var response = await client.GetAsync(requestUri).ConfigureAwait(false); + statusCode = response.StatusCode; + responseData = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + Type dicType = typeof(Dictionary<,>).MakeGenericType(typeof(string), dataType); + + var dictionary = JsonConvert.DeserializeObject(responseData,dicType, jsonSerializerSettings) as Dictionary; + + if (dictionary == null) + { + return new FirebaseObject[0]; + } + + return dictionary.Select(item => new FirebaseObject(item.Key, item.Value)).ToList(); + } + catch (Exception ex) + { + throw new FirebaseException(requestUri, string.Empty, responseData, statusCode, ex); + } + }*/ + + /// + /// The get object collection async. + /// + /// The json data. + /// The type of entities the collection should contain. + /// The . + public static IEnumerable> GetObjectCollection(this string data, Type elementType) + { + var dictionaryType = typeof(Dictionary<,>).MakeGenericType(typeof(string), elementType); + IDictionary dictionary = null; + + if (data.StartsWith("[")) + { + var listType = typeof(List<>).MakeGenericType(elementType); + var list = JsonConvert.DeserializeObject(data, listType) as IList; + dictionary = Activator.CreateInstance(dictionaryType) as IDictionary; + var index = 0; + foreach (var item in list) dictionary.Add(index++.ToString(), item); + } + else + { + dictionary = JsonConvert.DeserializeObject(data, dictionaryType) as IDictionary; + } + + if (dictionary == null) yield break; + + foreach (DictionaryEntry item in dictionary) + yield return new FirebaseObject((string) item.Key, item.Value); + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Http/PostResult.cs b/dsa/FireBase/Http/PostResult.cs new file mode 100644 index 0000000..15a4894 --- /dev/null +++ b/dsa/FireBase/Http/PostResult.cs @@ -0,0 +1,13 @@ +namespace Firebase.Database.Http +{ + /// + /// Represents data returned after a successful POST to firebase server. + /// + public class PostResult + { + /// + /// Gets or sets the generated key after a successful post. + /// + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/dsa/FireBase/ObservableExtensions.cs b/dsa/FireBase/ObservableExtensions.cs new file mode 100644 index 0000000..bc46261 --- /dev/null +++ b/dsa/FireBase/ObservableExtensions.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.ObjectModel; +using Firebase.Database.Streaming; + +namespace Firebase.Database +{ + /// + /// Extensions for . + /// + public static class ObservableExtensions + { + /// + /// Starts observing on given firebase observable and propagates event into an . + /// + /// The observable. + /// Type of entity. + /// The . + public static ObservableCollection AsObservableCollection(this IObservable> observable) + { + var collection = new ObservableCollection(); + + observable.Subscribe(f => + { + if (f.EventType == FirebaseEventType.InsertOrUpdate) + { + var i = collection.IndexOf(f.Object); + if (i >= 0) collection.RemoveAt(i); + + collection.Add(f.Object); + } + else + { + collection.Remove(f.Object); + } + }); + + return collection; + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Offline/ConcurrentOfflineDatabase.cs b/dsa/FireBase/Offline/ConcurrentOfflineDatabase.cs new file mode 100644 index 0000000..1a9e607 --- /dev/null +++ b/dsa/FireBase/Offline/ConcurrentOfflineDatabase.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using LiteDB; + +namespace Firebase.Database.Offline +{ + /// + /// The offline database. + /// + public class ConcurrentOfflineDatabase : IDictionary + { + private readonly ConcurrentDictionary ccache; + private readonly LiteRepository db; + + /// + /// Initializes a new instance of the class. + /// + /// The item type which is used to determine the database file name. + /// Custom string which will get appended to the file name. + public ConcurrentOfflineDatabase(Type itemType, string filenameModifier) + { + var fullName = GetFileName(itemType.ToString()); + if (fullName.Length > 100) fullName = fullName.Substring(0, 100); + + var mapper = BsonMapper.Global; + mapper.Entity().Id(o => o.Key); + + var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var filename = fullName + filenameModifier + ".db"; + var path = Path.Combine(root, filename); + db = new LiteRepository(new LiteDatabase(path, mapper)); + + var cache = db.Database + .GetCollection() + .FindAll() + .ToDictionary(o => o.Key, o => o); + + ccache = new ConcurrentDictionary(cache); + } + + /// + /// Gets the number of elements contained in the . + /// + /// The number of elements contained in the . + public int Count => ccache.Count; + + /// + /// Gets a value indicating whether this is a read-only collection. + /// + public bool IsReadOnly => false; + + /// + /// Gets an containing the keys of the + /// . + /// + /// + /// An containing the keys of the object that + /// implements . + /// + public ICollection Keys => ccache.Keys; + + /// + /// Gets an containing the values in the + /// . + /// + /// + /// An containing the values in the object that + /// implements . + /// + public ICollection Values => ccache.Values; + + /// + /// Gets or sets the element with the specified key. + /// + /// The key of the element to get or set. + /// The element with the specified key. + public OfflineEntry this[string key] + { + get => ccache[key]; + + set + { + ccache.AddOrUpdate(key, value, (k, existing) => value); + db.Upsert(value); + } + } + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// An enumerator that can be used to iterate through the collection. + public IEnumerator> GetEnumerator() + { + return ccache.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Adds an item to the . + /// + /// The object to add to the . + public void Add(KeyValuePair item) + { + Add(item.Key, item.Value); + } + + /// + /// Removes all items from the . + /// + public void Clear() + { + ccache.Clear(); + db.Delete(LiteDB.Query.All()); + } + + /// + /// Determines whether the contains a specific value. + /// + /// The object to locate in the . + /// + /// True if is found in the ; + /// otherwise, false. + /// + public bool Contains(KeyValuePair item) + { + return ContainsKey(item.Key); + } + + /// + /// Copies the elements of the to an + /// , starting at a particular index. + /// + /// + /// The one-dimensional that is the destination of the elements copied + /// from . The must have + /// zero-based indexing. + /// + /// The zero-based index in at which copying begins. + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + ccache.ToList().CopyTo(array, arrayIndex); + } + + /// + /// Removes the first occurrence of a specific object from the + /// . + /// + /// The object to remove from the . + /// + /// True if was successfully removed from the + /// ; otherwise, false. This method also returns false if + /// is not found in the original . + /// + public bool Remove(KeyValuePair item) + { + return Remove(item.Key); + } + + /// + /// Determines whether the contains an element with the + /// specified key. + /// + /// The key to locate in the . + /// + /// True if the contains an element with the key; + /// otherwise, false. + /// + public bool ContainsKey(string key) + { + return ccache.ContainsKey(key); + } + + /// + /// Adds an element with the provided key and value to the . + /// + /// The object to use as the key of the element to add. + /// The object to use as the value of the element to add. + public void Add(string key, OfflineEntry value) + { + ccache.AddOrUpdate(key, value, (k, existing) => value); + db.Upsert(value); + } + + /// + /// Removes the element with the specified key from the . + /// + /// The key of the element to remove. + /// + /// True if the element is successfully removed; otherwise, false. This method also returns false if + /// was not found in the original . + /// + public bool Remove(string key) + { + ccache.TryRemove(key, out _); + return db.Delete(key); + } + + /// + /// Gets the value associated with the specified key. + /// + /// The key whose value to get. + /// + /// When this method returns, the value associated with the specified key, if the key is found; + /// otherwise, the default value for the type of the parameter. This parameter is passed + /// uninitialized. + /// + /// + /// True if the object that implements contains an + /// element with the specified key; otherwise, false. + /// + public bool TryGetValue(string key, out OfflineEntry value) + { + return ccache.TryGetValue(key, out value); + } + + private string GetFileName(string fileName) + { + var invalidChars = new[] {'`', '[', ',', '='}; + foreach (var c in invalidChars.Concat(Path.GetInvalidFileNameChars()).Distinct()) + fileName = fileName.Replace(c, '_'); + + return fileName; + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Offline/DatabaseExtensions.cs b/dsa/FireBase/Offline/DatabaseExtensions.cs new file mode 100644 index 0000000..e7c4074 --- /dev/null +++ b/dsa/FireBase/Offline/DatabaseExtensions.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using Firebase.Database.Query; + +namespace Firebase.Database.Offline +{ + public static class DatabaseExtensions + { + /// + /// Create new instances of the . + /// + /// Type of elements. + /// Custom string which will get appended to the file name. + /// Optional custom root element of received json items. + /// Realtime streaming options. + /// Specifies what strategy should be used for initial pulling of server data. + /// + /// Specifies whether changed items should actually be pushed to the server. It this is false, + /// then Put / Post / Delete will not affect server data. + /// + /// The . + public static RealtimeDatabase AsRealtimeDatabase(this ChildQuery query, string filenameModifier = "", + string elementRoot = "", StreamingOptions streamingOptions = StreamingOptions.LatestOnly, + InitialPullStrategy initialPullStrategy = InitialPullStrategy.MissingOnly, bool pushChanges = true) + where T : class + { + return new RealtimeDatabase(query, elementRoot, query.Client.Options.OfflineDatabaseFactory, + filenameModifier, streamingOptions, initialPullStrategy, pushChanges); + } + + /// + /// Create new instances of the . + /// + /// Type of elements. + /// Type of the custom to use. + /// Custom string which will get appended to the file name. + /// Optional custom root element of received json items. + /// Realtime streaming options. + /// Specifies what strategy should be used for initial pulling of server data. + /// + /// Specifies whether changed items should actually be pushed to the server. It this is false, + /// then Put / Post / Delete will not affect server data. + /// + /// The . + public static RealtimeDatabase AsRealtimeDatabase(this ChildQuery query, + string filenameModifier = "", string elementRoot = "", + StreamingOptions streamingOptions = StreamingOptions.LatestOnly, + InitialPullStrategy initialPullStrategy = InitialPullStrategy.MissingOnly, bool pushChanges = true) + where T : class + where TSetHandler : ISetHandler, new() + { + return new RealtimeDatabase(query, elementRoot, query.Client.Options.OfflineDatabaseFactory, + filenameModifier, streamingOptions, initialPullStrategy, pushChanges, + Activator.CreateInstance()); + } + + /// + /// Overwrites existing object with given key leaving any missing properties intact in firebase. + /// + /// The key. + /// The object to set. + /// Indicates whether the item should be synced online. + /// + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + public static void Patch(this RealtimeDatabase db, string key, T obj, bool syncOnline = true, + int priority = 1) + where T : class + { + db.Set(key, obj, syncOnline ? SyncOptions.Patch : SyncOptions.None, priority); + } + + /// + /// Overwrites existing object with given key. + /// + /// The key. + /// The object to set. + /// Indicates whether the item should be synced online. + /// + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + public static void Put(this RealtimeDatabase db, string key, T obj, bool syncOnline = true, + int priority = 1) + where T : class + { + db.Set(key, obj, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// + /// Adds a new entity to the Database. + /// + /// The object to add. + /// Indicates whether the item should be synced online. + /// + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + /// The generated key for this object. + public static string Post(this RealtimeDatabase db, T obj, bool syncOnline = true, int priority = 1) + where T : class + { + var key = FirebaseKeyGenerator.Next(); + + db.Set(key, obj, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + + return key; + } + + /// + /// Deletes the entity with the given key. + /// + /// The key. + /// Indicates whether the item should be synced online. + /// + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + public static void Delete(this RealtimeDatabase db, string key, bool syncOnline = true, int priority = 1) + where T : class + { + db.Set(key, null, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// + /// Do a Put for a nested property specified by of an object with key + /// . + /// + /// Type of the root elements. + /// Type of the property being modified + /// Database instance. + /// Key of the root element to modify. + /// Expression on the root element leading to target value to modify. + /// Value to put. + /// Indicates whether the item should be synced online. + /// + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + public static void Put(this RealtimeDatabase db, string key, + Expression> propertyExpression, TProperty value, bool syncOnline = true, + int priority = 1) + where T : class + { + db.Set(key, propertyExpression, value, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// + /// Do a Patch for a nested property specified by of an object with key + /// . + /// + /// Type of the root elements. + /// Type of the property being modified + /// Database instance. + /// Key of the root element to modify. + /// Expression on the root element leading to target value to modify. + /// Value to patch. + /// Indicates whether the item should be synced online. + /// + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + public static void Patch(this RealtimeDatabase db, string key, + Expression> propertyExpression, TProperty value, bool syncOnline = true, + int priority = 1) + where T : class + { + db.Set(key, propertyExpression, value, syncOnline ? SyncOptions.Patch : SyncOptions.None, priority); + } + + /// + /// Delete a nested property specified by of an object with key + /// . This basically does a Put with null value. + /// + /// Type of the root elements. + /// Type of the property being modified + /// Database instance. + /// Key of the root element to modify. + /// Expression on the root element leading to target value to modify. + /// Value to put. + /// Indicates whether the item should be synced online. + /// + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + public static void Delete(this RealtimeDatabase db, string key, + Expression> propertyExpression, bool syncOnline = true, int priority = 1) + where T : class + where TProperty : class + { + db.Set(key, propertyExpression, null, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// + /// Post a new entity into the nested dictionary specified by of an object with + /// key . + /// The key of the new entity is automatically generated. + /// + /// Type of the root elements. + /// Type of the dictionary being modified + /// Type of the value within the dictionary being modified + /// Database instance. + /// Key of the root element to modify. + /// Expression on the root element leading to target value to modify. + /// Value to put. + /// Indicates whether the item should be synced online. + /// + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + public static void Post(this RealtimeDatabase db, string key, + Expression> propertyExpression, TProperty value, bool syncOnline = true, + int priority = 1) + where T : class + where TSelector : IDictionary + { + var nextKey = FirebaseKeyGenerator.Next(); + var expression = Expression.Lambda>( + Expression.Call(propertyExpression.Body, + typeof(TSelector).GetRuntimeMethod("get_Item", new[] {typeof(string)}), + Expression.Constant(nextKey)), propertyExpression.Parameters); + db.Set(key, expression, value, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// + /// Delete an entity with key in the nested dictionary specified by + /// of an object with key . + /// The key of the new entity is automatically generated. + /// + /// Type of the root elements. + /// Type of the dictionary being modified + /// Type of the value within the dictionary being modified + /// Database instance. + /// Key of the root element to modify. + /// Expression on the root element leading to target value to modify. + /// Key within the nested dictionary to delete. + /// Indicates whether the item should be synced online. + /// + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + public static void Delete(this RealtimeDatabase db, string key, + Expression>> propertyExpression, string dictionaryKey, + bool syncOnline = true, int priority = 1) + where T : class + { + var expression = Expression.Lambda>( + Expression.Call(propertyExpression.Body, + typeof(IDictionary).GetRuntimeMethod("get_Item", new[] {typeof(string)}), + Expression.Constant(dictionaryKey)), propertyExpression.Parameters); + db.Set(key, expression, null, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Offline/ISetHandler.cs b/dsa/FireBase/Offline/ISetHandler.cs new file mode 100644 index 0000000..c04bd41 --- /dev/null +++ b/dsa/FireBase/Offline/ISetHandler.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Firebase.Database.Query; + +namespace Firebase.Database.Offline +{ + public interface ISetHandler + { + Task SetAsync(ChildQuery query, string key, OfflineEntry entry); + } +} \ No newline at end of file diff --git a/dsa/FireBase/Offline/InitialPullStrategy.cs b/dsa/FireBase/Offline/InitialPullStrategy.cs new file mode 100644 index 0000000..ca2bebf --- /dev/null +++ b/dsa/FireBase/Offline/InitialPullStrategy.cs @@ -0,0 +1,23 @@ +namespace Firebase.Database.Offline +{ + /// + /// Specifies the strategy for initial pull of server data. + /// + public enum InitialPullStrategy + { + /// + /// Don't pull anything. + /// + None, + + /// + /// Pull only what isn't already stored offline. + /// + MissingOnly, + + /// + /// Pull everything that exists on the server. + /// + Everything + } +} \ No newline at end of file diff --git a/dsa/FireBase/Offline/Internals/MemberAccessVisitor.cs b/dsa/FireBase/Offline/Internals/MemberAccessVisitor.cs new file mode 100644 index 0000000..89a77da --- /dev/null +++ b/dsa/FireBase/Offline/Internals/MemberAccessVisitor.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using Newtonsoft.Json; + +namespace Firebase.Database.Offline.Internals +{ + public class MemberAccessVisitor : ExpressionVisitor + { + private readonly IList propertyNames = new List(); + + private bool wasDictionaryAccess; + + public IEnumerable PropertyNames => propertyNames; + + public override Expression Visit(Expression expr) + { + if (expr?.NodeType == ExpressionType.MemberAccess) + { + if (wasDictionaryAccess) + { + wasDictionaryAccess = false; + } + else + { + var memberExpr = (MemberExpression) expr; + var jsonAttr = memberExpr.Member.GetCustomAttribute(); + + propertyNames.Add(jsonAttr?.PropertyName ?? memberExpr.Member.Name); + } + } + else if (expr?.NodeType == ExpressionType.Call) + { + var callExpr = (MethodCallExpression) expr; + if (callExpr.Method.Name == "get_Item" && callExpr.Arguments.Count == 1) + { + var e = Expression.Lambda(callExpr.Arguments[0]).Compile(); + propertyNames.Add(e.DynamicInvoke().ToString()); + wasDictionaryAccess = callExpr.Arguments[0].NodeType == ExpressionType.MemberAccess; + } + } + + return base.Visit(expr); + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Offline/OfflineCacheAdapter.cs b/dsa/FireBase/Offline/OfflineCacheAdapter.cs new file mode 100644 index 0000000..3153d1b --- /dev/null +++ b/dsa/FireBase/Offline/OfflineCacheAdapter.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace Firebase.Database.Offline +{ + internal class OfflineCacheAdapter : IDictionary, IDictionary + { + private readonly IDictionary database; + + public OfflineCacheAdapter(IDictionary database) + { + this.database = database; + } + + public void CopyTo(Array array, int index) + { + throw new NotImplementedException(); + } + + public bool IsSynchronized { get; } + + public object SyncRoot { get; } + + object IDictionary.this[object key] + { + get => database[key.ToString()].Deserialize(); + + set + { + var keyString = key.ToString(); + if (database.ContainsKey(keyString)) + database[keyString] = new OfflineEntry(keyString, value, database[keyString].Priority, + database[keyString].SyncOptions); + else + database[keyString] = new OfflineEntry(keyString, value, 1, SyncOptions.None); + } + } + + ICollection IDictionary.Values { get; } + + ICollection IDictionary.Keys { get; } + + public bool Contains(object key) + { + return ContainsKey(key.ToString()); + } + + IDictionaryEnumerator IDictionary.GetEnumerator() + { + throw new NotImplementedException(); + } + + public void Remove(object key) + { + Remove(key.ToString()); + } + + public bool IsFixedSize => false; + + public void Add(object key, object value) + { + Add(key.ToString(), (T) value); + } + + public int Count => database.Count; + + public bool IsReadOnly => database.IsReadOnly; + + public ICollection Keys => database.Keys; + + public ICollection Values => database.Values.Select(o => o.Deserialize()).ToList(); + + public T this[string key] + { + get => database[key].Deserialize(); + + set + { + if (database.ContainsKey(key)) + database[key] = new OfflineEntry(key, value, database[key].Priority, database[key].SyncOptions); + else + database[key] = new OfflineEntry(key, value, 1, SyncOptions.None); + } + } + + public IEnumerator> GetEnumerator() + { + return database.Select(d => new KeyValuePair(d.Key, d.Value.Deserialize())).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public void Add(KeyValuePair item) + { + Add(item.Key, item.Value); + } + + public void Clear() + { + database.Clear(); + } + + public bool Contains(KeyValuePair item) + { + return ContainsKey(item.Key); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + throw new NotImplementedException(); + } + + public bool Remove(KeyValuePair item) + { + return database.Remove(item.Key); + } + + public void Add(string key, T value) + { + database.Add(key, new OfflineEntry(key, value, 1, SyncOptions.None)); + } + + public bool ContainsKey(string key) + { + return database.ContainsKey(key); + } + + public bool Remove(string key) + { + return database.Remove(key); + } + + public bool TryGetValue(string key, out T value) + { + OfflineEntry val; + + if (database.TryGetValue(key, out val)) + { + value = val.Deserialize(); + return true; + } + + value = default(T); + return false; + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Offline/OfflineDatabase.cs b/dsa/FireBase/Offline/OfflineDatabase.cs new file mode 100644 index 0000000..be0380b --- /dev/null +++ b/dsa/FireBase/Offline/OfflineDatabase.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using LiteDB; + +namespace Firebase.Database.Offline +{ + /// + /// The offline database. + /// + public class OfflineDatabase : IDictionary + { + private readonly IDictionary cache; + private readonly LiteRepository db; + + /// + /// Initializes a new instance of the class. + /// + /// The item type which is used to determine the database file name. + /// Custom string which will get appended to the file name. + public OfflineDatabase(Type itemType, string filenameModifier) + { + var fullName = GetFileName(itemType.ToString()); + if (fullName.Length > 100) fullName = fullName.Substring(0, 100); + + var mapper = BsonMapper.Global; + mapper.Entity().Id(o => o.Key); + + var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var filename = fullName + filenameModifier + ".db"; + var path = Path.Combine(root, filename); + db = new LiteRepository(new LiteDatabase(path, mapper)); + + cache = db.Database.GetCollection().FindAll() + .ToDictionary(o => o.Key, o => o); + } + + /// + /// Gets the number of elements contained in the . + /// + /// The number of elements contained in the . + public int Count => cache.Count; + + /// + /// Gets a value indicating whether this is a read-only collection. + /// + public bool IsReadOnly => cache.IsReadOnly; + + /// + /// Gets an containing the keys of the + /// . + /// + /// + /// An containing the keys of the object that + /// implements . + /// + public ICollection Keys => cache.Keys; + + /// + /// Gets an containing the values in the + /// . + /// + /// + /// An containing the values in the object that + /// implements . + /// + public ICollection Values => cache.Values; + + /// + /// Gets or sets the element with the specified key. + /// + /// The key of the element to get or set. + /// The element with the specified key. + public OfflineEntry this[string key] + { + get => cache[key]; + + set + { + cache[key] = value; + db.Upsert(value); + } + } + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// An enumerator that can be used to iterate through the collection. + public IEnumerator> GetEnumerator() + { + return cache.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Adds an item to the . + /// + /// The object to add to the . + public void Add(KeyValuePair item) + { + Add(item.Key, item.Value); + } + + /// + /// Removes all items from the . + /// + public void Clear() + { + cache.Clear(); + db.Delete(LiteDB.Query.All()); + } + + /// + /// Determines whether the contains a specific value. + /// + /// The object to locate in the . + /// + /// True if is found in the ; + /// otherwise, false. + /// + public bool Contains(KeyValuePair item) + { + return ContainsKey(item.Key); + } + + /// + /// Copies the elements of the to an + /// , starting at a particular index. + /// + /// + /// The one-dimensional that is the destination of the elements copied + /// from . The must have + /// zero-based indexing. + /// + /// The zero-based index in at which copying begins. + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + cache.CopyTo(array, arrayIndex); + } + + /// + /// Removes the first occurrence of a specific object from the + /// . + /// + /// The object to remove from the . + /// + /// True if was successfully removed from the + /// ; otherwise, false. This method also returns false if + /// is not found in the original . + /// + public bool Remove(KeyValuePair item) + { + return Remove(item.Key); + } + + /// + /// Determines whether the contains an element with the + /// specified key. + /// + /// The key to locate in the . + /// + /// True if the contains an element with the key; + /// otherwise, false. + /// + public bool ContainsKey(string key) + { + return cache.ContainsKey(key); + } + + /// + /// Adds an element with the provided key and value to the . + /// + /// The object to use as the key of the element to add. + /// The object to use as the value of the element to add. + public void Add(string key, OfflineEntry value) + { + cache.Add(key, value); + db.Insert(value); + } + + /// + /// Removes the element with the specified key from the . + /// + /// The key of the element to remove. + /// + /// True if the element is successfully removed; otherwise, false. This method also returns false if + /// was not found in the original . + /// + public bool Remove(string key) + { + cache.Remove(key); + return db.Delete(key); + } + + /// + /// Gets the value associated with the specified key. + /// + /// The key whose value to get. + /// + /// When this method returns, the value associated with the specified key, if the key is found; + /// otherwise, the default value for the type of the parameter. This parameter is passed + /// uninitialized. + /// + /// + /// True if the object that implements contains an + /// element with the specified key; otherwise, false. + /// + public bool TryGetValue(string key, out OfflineEntry value) + { + return cache.TryGetValue(key, out value); + } + + private string GetFileName(string fileName) + { + var invalidChars = new[] {'`', '[', ',', '='}; + foreach (var c in invalidChars.Concat(Path.GetInvalidFileNameChars()).Distinct()) + fileName = fileName.Replace(c, '_'); + + return fileName; + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Offline/OfflineEntry.cs b/dsa/FireBase/Offline/OfflineEntry.cs new file mode 100644 index 0000000..9feffa3 --- /dev/null +++ b/dsa/FireBase/Offline/OfflineEntry.cs @@ -0,0 +1,99 @@ +using System; +using Newtonsoft.Json; + +namespace Firebase.Database.Offline +{ + /// + /// Represents an object stored in offline storage. + /// + public class OfflineEntry + { + private object dataInstance; + + /// + /// Initializes a new instance of the class with an already serialized object. + /// + /// The key. + /// The object. + /// + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + /// The sync options. + public OfflineEntry(string key, object obj, string data, int priority, SyncOptions syncOptions, + bool isPartial = false) + { + Key = key; + Priority = priority; + Data = data; + Timestamp = DateTime.UtcNow; + SyncOptions = syncOptions; + IsPartial = isPartial; + + dataInstance = obj; + } + + /// + /// Initializes a new instance of the class. + /// + /// The key. + /// The object. + /// + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + /// The sync options. + public OfflineEntry(string key, object obj, int priority, SyncOptions syncOptions, bool isPartial = false) + : this(key, obj, JsonConvert.SerializeObject(obj), priority, syncOptions, isPartial) + { + } + + /// + /// Initializes a new instance of the class. + /// + public OfflineEntry() + { + } + + /// + /// Gets or sets the key of this entry. + /// + public string Key { get; set; } + + /// + /// Gets or sets the priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + public int Priority { get; set; } + + /// + /// Gets or sets the timestamp when this entry was last touched. + /// + public DateTime Timestamp { get; set; } + + /// + /// Gets or sets the which define what sync state this entry is in. + /// + public SyncOptions SyncOptions { get; set; } + + /// + /// Gets or sets serialized JSON data. + /// + public string Data { get; set; } + + /// + /// Specifies whether this is only a partial object. + /// + public bool IsPartial { get; set; } + + /// + /// Deserializes into . The result is cached. + /// + /// Type of object to deserialize into. + /// Instance of . + public T Deserialize() + { + return (T) (dataInstance ?? (dataInstance = JsonConvert.DeserializeObject(Data))); + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Offline/RealtimeDatabase.cs b/dsa/FireBase/Offline/RealtimeDatabase.cs new file mode 100644 index 0000000..973db46 --- /dev/null +++ b/dsa/FireBase/Offline/RealtimeDatabase.cs @@ -0,0 +1,479 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Net; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Reactive.Threading.Tasks; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Firebase.Database.Extensions; +using Firebase.Database.Offline.Internals; +using Firebase.Database.Query; +using Firebase.Database.Streaming; +using Newtonsoft.Json; + +namespace Firebase.Database.Offline +{ + /// + /// The real-time Database which synchronizes online and offline data. + /// + /// Type of entities. + public class RealtimeDatabase : IDisposable where T : class + { + private readonly ChildQuery childQuery; + private readonly string elementRoot; + private readonly FirebaseCache firebaseCache; + private readonly InitialPullStrategy initialPullStrategy; + private readonly bool pushChanges; + private readonly StreamingOptions streamingOptions; + private readonly Subject> subject; + private FirebaseSubscription firebaseSubscription; + + private bool isSyncRunning; + private IObservable> observable; + + /// + /// Initializes a new instance of the class. + /// + /// The child query. + /// The element Root. + /// The offline database factory. + /// Custom string which will get appended to the file name. + /// Specifies whether changes should be streamed from the server. + /// + /// Specifies if everything should be pull from the online storage on start. It only + /// makes sense when is set to true. + /// + /// + /// Specifies whether changed items should actually be pushed to the server. If this is false, + /// then Put / Post / Delete will not affect server data. + /// + public RealtimeDatabase(ChildQuery childQuery, string elementRoot, + Func> offlineDatabaseFactory, string filenameModifier, + StreamingOptions streamingOptions, InitialPullStrategy initialPullStrategy, bool pushChanges, + ISetHandler setHandler = null) + { + this.childQuery = childQuery; + this.elementRoot = elementRoot; + this.streamingOptions = streamingOptions; + this.initialPullStrategy = initialPullStrategy; + this.pushChanges = pushChanges; + Database = offlineDatabaseFactory(typeof(T), filenameModifier); + firebaseCache = new FirebaseCache(new OfflineCacheAdapter(Database)); + subject = new Subject>(); + + PutHandler = setHandler ?? new SetHandler(); + + isSyncRunning = true; + Task.Factory.StartNew(SynchronizeThread, CancellationToken.None, TaskCreationOptions.LongRunning, + TaskScheduler.Default); + } + + /// + /// Gets the backing Database. + /// + public IDictionary Database { get; } + + public ISetHandler PutHandler { private get; set; } + + public void Dispose() + { + subject.OnCompleted(); + firebaseSubscription?.Dispose(); + } + + /// + /// Event raised whenever an exception is thrown in the synchronization thread. Exception thrown in there are + /// swallowed, so this event is the only way to get to them. + /// + public event EventHandler SyncExceptionThrown; + + /// + /// Overwrites existing object with given key. + /// + /// The key. + /// The object to set. + /// Indicates whether the item should be synced online. + /// + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + public void Set(string key, T obj, SyncOptions syncOptions, int priority = 1) + { + SetAndRaise(key, new OfflineEntry(key, obj, priority, syncOptions)); + } + + public void Set(string key, Expression> propertyExpression, object value, + SyncOptions syncOptions, int priority = 1) + { + var fullKey = GenerateFullKey(key, propertyExpression, syncOptions); + var serializedObject = JsonConvert.SerializeObject(value).Trim('"', '\\'); + + if (fullKey.Item3) + { + if (typeof(TProperty) != typeof(string) || value == null) + // don't escape non-string primitives and null; + serializedObject = $"{{ \"{fullKey.Item2}\" : {serializedObject} }}"; + else + serializedObject = $"{{ \"{fullKey.Item2}\" : \"{serializedObject}\" }}"; + } + + var setObject = firebaseCache.PushData("/" + fullKey.Item1, serializedObject).First(); + + if (!Database.ContainsKey(key) || Database[key].SyncOptions != SyncOptions.Patch && + Database[key].SyncOptions != SyncOptions.Put) + Database[fullKey.Item1] = + new OfflineEntry(fullKey.Item1, value, serializedObject, priority, syncOptions, true); + + subject.OnNext(new FirebaseEvent(key, setObject.Object, + setObject == null ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate, + FirebaseEventSource.Offline)); + } + + /// + /// Fetches an object with the given key and adds it to the Database. + /// + /// The key. + /// + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// + public void Pull(string key, int priority = 1) + { + if (!Database.ContainsKey(key)) + Database[key] = new OfflineEntry(key, null, priority, SyncOptions.Pull); + else if (Database[key].SyncOptions == SyncOptions.None) + // pull only if push isn't pending + Database[key].SyncOptions = SyncOptions.Pull; + } + + /// + /// Fetches everything from the remote database. + /// + public async Task PullAsync() + { + var existingEntries = await childQuery + .OnceAsync() + .ToObservable() + .RetryAfterDelay>, FirebaseException>( + childQuery.Client.Options.SyncPeriod, + ex => ex.StatusCode == + HttpStatusCode + .OK) // OK implies the request couldn't complete due to network error. + .Select(e => ResetDatabaseFromInitial(e, false)) + .SelectMany(e => e) + .Do(e => + { + Database[e.Key] = new OfflineEntry(e.Key, e.Object, 1, SyncOptions.None); + subject.OnNext(new FirebaseEvent(e.Key, e.Object, FirebaseEventType.InsertOrUpdate, + FirebaseEventSource.OnlinePull)); + }) + .ToList(); + + // Remove items not stored online + foreach (var item in Database.Keys.Except(existingEntries.Select(f => f.Key)).ToList()) + { + Database.Remove(item); + subject.OnNext(new FirebaseEvent(item, null, FirebaseEventType.Delete, + FirebaseEventSource.OnlinePull)); + } + } + + /// + /// Retrieves all offline items currently stored in local database. + /// + public IEnumerable> Once() + { + return Database + .Where(kvp => !string.IsNullOrEmpty(kvp.Value.Data) && kvp.Value.Data != "null" && !kvp.Value.IsPartial) + .Select(kvp => new FirebaseObject(kvp.Key, kvp.Value.Deserialize())) + .ToList(); + } + + /// + /// Starts observing the real-time Database. Events will be fired both when change is done locally and remotely. + /// + /// Stream of . + public IObservable> AsObservable() + { + if (!isSyncRunning) + { + isSyncRunning = true; + Task.Factory.StartNew(SynchronizeThread, CancellationToken.None, TaskCreationOptions.LongRunning, + TaskScheduler.Default); + } + + if (observable == null) + { + var initialData = Observable.Return(FirebaseEvent.Empty(FirebaseEventSource.Offline)); + if (Database.TryGetValue(elementRoot, out var oe)) + initialData = Observable.Return(oe) + .Where(offlineEntry => + !string.IsNullOrEmpty(offlineEntry.Data) && offlineEntry.Data != "null" && + !offlineEntry.IsPartial) + .Select(offlineEntry => new FirebaseEvent(offlineEntry.Key, offlineEntry.Deserialize(), + FirebaseEventType.InsertOrUpdate, FirebaseEventSource.Offline)); + else if (Database.Count > 0) + initialData = Database + .Where(kvp => + !string.IsNullOrEmpty(kvp.Value.Data) && kvp.Value.Data != "null" && !kvp.Value.IsPartial) + .Select(kvp => new FirebaseEvent(kvp.Key, kvp.Value.Deserialize(), + FirebaseEventType.InsertOrUpdate, FirebaseEventSource.Offline)) + .ToList() + .ToObservable(); + + observable = initialData + .Merge(subject) + .Merge(GetInitialPullObservable() + .RetryAfterDelay>, FirebaseException>( + childQuery.Client.Options.SyncPeriod, + ex => ex.StatusCode == + HttpStatusCode + .OK) // OK implies the request couldn't complete due to network error. + .Select(e => ResetDatabaseFromInitial(e)) + .SelectMany(e => e) + .Do(SetObjectFromInitialPull) + .Select(e => new FirebaseEvent(e.Key, e.Object, + e.Object == null ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate, + FirebaseEventSource.OnlineInitial)) + .Concat(Observable.Create>(observer => + InitializeStreamingSubscription(observer)))) + .Do(next => { }, e => observable = null, () => observable = null) + .Replay() + .RefCount(); + } + + return observable; + } + + private IReadOnlyCollection> ResetDatabaseFromInitial( + IReadOnlyCollection> collection, bool onlyWhenInitialEverything = true) + { + if (onlyWhenInitialEverything && initialPullStrategy != InitialPullStrategy.Everything) return collection; + + // items which are in local db, but not in the online collection + var extra = Once() + .Select(f => f.Key) + .Except(collection.Select(c => c.Key)) + .Select(k => new FirebaseObject(k, null)); + + return collection.Concat(extra).ToList(); + } + + private void SetObjectFromInitialPull(FirebaseObject e) + { + // set object with no sync only if it doesn't exist yet + // and the InitialPullStrategy != Everything + // this attempts to deal with scenario when you are offline, have local changes and go online + // in this case having the InitialPullStrategy set to everything would basically purge all local changes + if (!Database.ContainsKey(e.Key) || Database[e.Key].SyncOptions == SyncOptions.None || + Database[e.Key].SyncOptions == SyncOptions.Pull || + initialPullStrategy != InitialPullStrategy.Everything) + Database[e.Key] = new OfflineEntry(e.Key, e.Object, 1, SyncOptions.None); + } + + private IObservable>> GetInitialPullObservable() + { + FirebaseQuery query; + switch (initialPullStrategy) + { + case InitialPullStrategy.MissingOnly: + query = childQuery.OrderByKey().StartAt(() => GetLatestKey()); + break; + case InitialPullStrategy.Everything: + query = childQuery; + break; + case InitialPullStrategy.None: + default: + return Observable.Empty>>(); + } + + if (string.IsNullOrWhiteSpace(elementRoot)) + return Observable.Defer(() => query.OnceAsync().ToObservable()); + + // there is an element root, which indicates the target location is not a collection but a single element + return Observable.Defer(async () => + Observable.Return(await query.OnceSingleAsync()) + .Select(e => new[] {new FirebaseObject(elementRoot, e)})); + } + + private IDisposable InitializeStreamingSubscription(IObserver> observer) + { + var completeDisposable = Disposable.Create(() => isSyncRunning = false); + + switch (streamingOptions) + { + case StreamingOptions.LatestOnly: + // stream since the latest key + var queryLatest = childQuery.OrderByKey().StartAt(() => GetLatestKey()); + firebaseSubscription = + new FirebaseSubscription(observer, queryLatest, elementRoot, firebaseCache); + firebaseSubscription.ExceptionThrown += StreamingExceptionThrown; + + return new CompositeDisposable(firebaseSubscription.Run(), completeDisposable); + case StreamingOptions.Everything: + // stream everything + var queryAll = childQuery; + firebaseSubscription = new FirebaseSubscription(observer, queryAll, elementRoot, firebaseCache); + firebaseSubscription.ExceptionThrown += StreamingExceptionThrown; + + return new CompositeDisposable(firebaseSubscription.Run(), completeDisposable); + } + + return completeDisposable; + } + + private void SetAndRaise(string key, OfflineEntry obj, + FirebaseEventSource eventSource = FirebaseEventSource.Offline) + { + Database[key] = obj; + subject.OnNext(new FirebaseEvent(key, obj?.Deserialize(), + string.IsNullOrEmpty(obj?.Data) || obj?.Data == "null" + ? FirebaseEventType.Delete + : FirebaseEventType.InsertOrUpdate, eventSource)); + } + + private async void SynchronizeThread() + { + while (isSyncRunning) + { + try + { + var validEntries = Database.Where(e => e.Value != null); + await PullEntriesAsync(validEntries.Where(kvp => kvp.Value.SyncOptions == SyncOptions.Pull)); + + if (pushChanges) + await PushEntriesAsync(validEntries.Where(kvp => + kvp.Value.SyncOptions == SyncOptions.Put || kvp.Value.SyncOptions == SyncOptions.Patch)); + } + catch (Exception ex) + { + SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(ex)); + } + + await Task.Delay(childQuery.Client.Options.SyncPeriod); + } + } + + private string GetLatestKey() + { + var key = Database.OrderBy(o => o.Key, StringComparer.Ordinal).LastOrDefault().Key ?? string.Empty; + + if (!string.IsNullOrWhiteSpace(key)) + key = key.Substring(0, key.Length - 1) + (char) (key[key.Length - 1] + 1); + + return key; + } + + private async Task PushEntriesAsync(IEnumerable> pushEntries) + { + var groups = pushEntries.GroupBy(pair => pair.Value.Priority).OrderByDescending(kvp => kvp.Key).ToList(); + + foreach (var group in groups) + { + var tasks = group.OrderBy(kvp => kvp.Value.IsPartial).Select(kvp => + kvp.Value.IsPartial + ? ResetSyncAfterPush(PutHandler.SetAsync(childQuery, kvp.Key, kvp.Value), kvp.Key) + : ResetSyncAfterPush(PutHandler.SetAsync(childQuery, kvp.Key, kvp.Value), kvp.Key, + kvp.Value.Deserialize())); + + try + { + await Task.WhenAll(tasks).WithAggregateException(); + } + catch (Exception ex) + { + SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(ex)); + } + } + } + + private async Task PullEntriesAsync(IEnumerable> pullEntries) + { + var taskGroups = pullEntries.GroupBy(pair => pair.Value.Priority).OrderByDescending(kvp => kvp.Key); + + foreach (var group in taskGroups) + { + var tasks = group.Select(pair => + ResetAfterPull( + childQuery.Child(pair.Key == elementRoot ? string.Empty : pair.Key).OnceSingleAsync(), + pair.Key, pair.Value)); + + try + { + await Task.WhenAll(tasks).WithAggregateException(); + } + catch (Exception ex) + { + SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(ex)); + } + } + } + + private async Task ResetAfterPull(Task task, string key, OfflineEntry entry) + { + await task; + SetAndRaise(key, new OfflineEntry(key, task.Result, entry.Priority, SyncOptions.None), + FirebaseEventSource.OnlinePull); + } + + private async Task ResetSyncAfterPush(Task task, string key, T obj) + { + await ResetSyncAfterPush(task, key); + + if (streamingOptions == StreamingOptions.None) + subject.OnNext(new FirebaseEvent(key, obj, + obj == null ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate, + FirebaseEventSource.OnlinePush)); + } + + private async Task ResetSyncAfterPush(Task task, string key) + { + await task; + ResetSyncOptions(key); + } + + private void ResetSyncOptions(string key) + { + var item = Database[key]; + + if (item.IsPartial) + { + Database.Remove(key); + } + else + { + item.SyncOptions = SyncOptions.None; + Database[key] = item; + } + } + + private void StreamingExceptionThrown(object sender, ExceptionEventArgs e) + { + SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(e.Exception)); + } + + private Tuple GenerateFullKey(string key, + Expression> propertyGetter, SyncOptions syncOptions) + { + var visitor = new MemberAccessVisitor(); + visitor.Visit(propertyGetter); + var propertyType = typeof(TProperty).GetTypeInfo(); + var prefix = key == string.Empty ? string.Empty : key + "/"; + + // primitive types + if (syncOptions == SyncOptions.Patch && (propertyType.IsPrimitive || + Nullable.GetUnderlyingType(typeof(TProperty)) != null || + typeof(TProperty) == typeof(string))) + return Tuple.Create(prefix + string.Join("/", visitor.PropertyNames.Skip(1).Reverse()), + visitor.PropertyNames.First(), true); + + return Tuple.Create(prefix + string.Join("/", visitor.PropertyNames.Reverse()), + visitor.PropertyNames.First(), false); + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Offline/SetHandler.cs b/dsa/FireBase/Offline/SetHandler.cs new file mode 100644 index 0000000..6314c3c --- /dev/null +++ b/dsa/FireBase/Offline/SetHandler.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using Firebase.Database.Query; + +namespace Firebase.Database.Offline +{ + public class SetHandler : ISetHandler + { + public virtual async Task SetAsync(ChildQuery query, string key, OfflineEntry entry) + { + using (var child = query.Child(key)) + { + if (entry.SyncOptions == SyncOptions.Put) + await child.PutAsync(entry.Data); + else + await child.PatchAsync(entry.Data); + } + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Offline/StreamingOptions.cs b/dsa/FireBase/Offline/StreamingOptions.cs new file mode 100644 index 0000000..a420cbb --- /dev/null +++ b/dsa/FireBase/Offline/StreamingOptions.cs @@ -0,0 +1,23 @@ +namespace Firebase.Database.Offline +{ + public enum StreamingOptions + { + /// + /// No realtime streaming. + /// + None, + + /// + /// Streaming of only new items - not the existing ones. + /// + LatestOnly, + + /// + /// Streaming of all items. This will also pull all existing items on start, so be mindful about the number of items in + /// your DB. + /// When used, consider not setting the to + /// because you would pointlessly pull everything twice. + /// + Everything + } +} \ No newline at end of file diff --git a/dsa/FireBase/Offline/SyncOptions.cs b/dsa/FireBase/Offline/SyncOptions.cs new file mode 100644 index 0000000..ca68d0a --- /dev/null +++ b/dsa/FireBase/Offline/SyncOptions.cs @@ -0,0 +1,28 @@ +namespace Firebase.Database.Offline +{ + /// + /// Specifies type of sync requested for given data. + /// + public enum SyncOptions + { + /// + /// No sync needed for given data. + /// + None, + + /// + /// Data should be pulled from firebase. + /// + Pull, + + /// + /// Data should be put to firebase. + /// + Put, + + /// + /// Data should be patched in firebase. + /// + Patch + } +} \ No newline at end of file diff --git a/dsa/FireBase/Query/AuthQuery.cs b/dsa/FireBase/Query/AuthQuery.cs new file mode 100644 index 0000000..2cfda3c --- /dev/null +++ b/dsa/FireBase/Query/AuthQuery.cs @@ -0,0 +1,34 @@ +using System; + +namespace Firebase.Database.Query +{ + /// + /// Represents an auth parameter in firebase query, e.g. "?auth=xyz". + /// + public class AuthQuery : ParameterQuery + { + private readonly Func tokenFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The parent. + /// The authentication token factory. + /// The owner. + public AuthQuery(FirebaseQuery parent, Func tokenFactory, FirebaseClient client) : base(parent, + () => client.Options.AsAccessToken ? "access_token" : "auth", client) + { + this.tokenFactory = tokenFactory; + } + + /// + /// Build the url parameter value of this child. + /// + /// The child of this child. + /// The . + protected override string BuildUrlParameter(FirebaseQuery child) + { + return tokenFactory(); + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Query/ChildQuery.cs b/dsa/FireBase/Query/ChildQuery.cs new file mode 100644 index 0000000..014fe09 --- /dev/null +++ b/dsa/FireBase/Query/ChildQuery.cs @@ -0,0 +1,50 @@ +using System; + +namespace Firebase.Database.Query +{ + /// + /// Firebase query which references the child of current node. + /// + public class ChildQuery : FirebaseQuery + { + private readonly Func pathFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The parent. + /// The path to the child node. + /// The owner. + public ChildQuery(FirebaseQuery parent, Func pathFactory, FirebaseClient client) + : base(parent, client) + { + this.pathFactory = pathFactory; + } + + /// + /// Initializes a new instance of the class. + /// + /// The client. + /// The path to the child node. + public ChildQuery(FirebaseClient client, Func pathFactory) + : this(null, pathFactory, client) + { + } + + /// + /// Build the url segment of this child. + /// + /// The child of this child. + /// The . + protected override string BuildUrlSegment(FirebaseQuery child) + { + var s = pathFactory(); + + if (s != string.Empty && !s.EndsWith("/")) s += '/'; + + if (!(child is ChildQuery)) return s + ".json"; + + return s; + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Query/FilterQuery.cs b/dsa/FireBase/Query/FilterQuery.cs new file mode 100644 index 0000000..3434d1d --- /dev/null +++ b/dsa/FireBase/Query/FilterQuery.cs @@ -0,0 +1,77 @@ +using System; +using System.Globalization; + +namespace Firebase.Database.Query +{ + /// + /// Represents a firebase filtering query, e.g. "?LimitToLast=10". + /// + public class FilterQuery : ParameterQuery + { + private readonly Func boolValueFactory; + private readonly Func doubleValueFactory; + private readonly Func valueFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The parent. + /// The filter. + /// The value for filter. + /// The owning client. + public FilterQuery(FirebaseQuery parent, Func filterFactory, Func valueFactory, + FirebaseClient client) + : base(parent, filterFactory, client) + { + this.valueFactory = valueFactory; + } + + /// + /// Initializes a new instance of the class. + /// + /// The parent. + /// The filter. + /// The value for filter. + /// The owning client. + public FilterQuery(FirebaseQuery parent, Func filterFactory, Func valueFactory, + FirebaseClient client) + : base(parent, filterFactory, client) + { + doubleValueFactory = valueFactory; + } + + /// + /// Initializes a new instance of the class. + /// + /// The parent. + /// The filter. + /// The value for filter. + /// The owning client. + public FilterQuery(FirebaseQuery parent, Func filterFactory, Func valueFactory, + FirebaseClient client) + : base(parent, filterFactory, client) + { + boolValueFactory = valueFactory; + } + + /// + /// The build url parameter. + /// + /// The child. + /// Url parameter part of the resulting path. + protected override string BuildUrlParameter(FirebaseQuery child) + { + if (valueFactory != null) + { + if (valueFactory() == null) return "null"; + return $"\"{valueFactory()}\""; + } + + if (doubleValueFactory != null) + return doubleValueFactory().ToString(CultureInfo.InvariantCulture); + if (boolValueFactory != null) return $"{boolValueFactory().ToString().ToLower()}"; + + return string.Empty; + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Query/FirebaseQuery.cs b/dsa/FireBase/Query/FirebaseQuery.cs new file mode 100644 index 0000000..60d0289 --- /dev/null +++ b/dsa/FireBase/Query/FirebaseQuery.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Reactive.Linq; +using System.Threading.Tasks; +using Firebase.Database.Http; +using Firebase.Database.Streaming; +using Newtonsoft.Json; + +namespace Firebase.Database.Query +{ + /// + /// Represents a firebase query. + /// + public abstract class FirebaseQuery : IFirebaseQuery, IDisposable + { + protected readonly FirebaseQuery Parent; + + private HttpClient client; + protected TimeSpan DEFAULT_HTTP_CLIENT_TIMEOUT = new TimeSpan(0, 0, 180); + + /// + /// Initializes a new instance of the class. + /// + /// The parent of this query. + /// The owning client. + protected FirebaseQuery(FirebaseQuery parent, FirebaseClient client) + { + Client = client; + Parent = parent; + } + + /// + /// Disposes this instance. + /// + public void Dispose() + { + client?.Dispose(); + } + + /// + /// Gets the client. + /// + public FirebaseClient Client { get; } + + /// + /// Queries the firebase server once returning collection of items. + /// + /// Optional timeout value. + /// Type of elements. + /// Collection of holding the entities returned by server. + public async Task>> OnceAsync(TimeSpan? timeout = null) + { + var url = string.Empty; + + try + { + url = await BuildUrlAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + throw new FirebaseException("Couldn't build the url", string.Empty, string.Empty, HttpStatusCode.OK, + ex); + } + + return await GetClient(timeout).GetObjectCollectionAsync(url, Client.Options.JsonSerializerSettings) + .ConfigureAwait(false); + } + + /// + /// Starts observing this query watching for changes real time sent by the server. + /// + /// Type of elements. + /// Optional custom root element of received json items. + /// Observable stream of . + public IObservable> AsObservable( + EventHandler> exceptionHandler = null, string elementRoot = "") + { + return Observable.Create>(observer => + { + var sub = new FirebaseSubscription(observer, this, elementRoot, new FirebaseCache()); + sub.ExceptionThrown += exceptionHandler; + return sub.Run(); + }); + } + + /// + /// Builds the actual URL of this query. + /// + /// The . + public async Task BuildUrlAsync() + { + // if token factory is present on the parent then use it to generate auth token + if (Client.Options.AuthTokenAsyncFactory != null) + { + var token = await Client.Options.AuthTokenAsyncFactory().ConfigureAwait(false); + return this.WithAuth(token).BuildUrl(null); + } + + return BuildUrl(null); + } + + /*public async Task>> OnceAsync(Type dataType, TimeSpan? timeout = null) + { + var url = string.Empty; + + try + { + url = await this.BuildUrlAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + throw new FirebaseException("Couldn't build the url", string.Empty, string.Empty, HttpStatusCode.OK, ex); + } + + return await this.GetClient(timeout).GetObjectCollectionAsync(url, Client.Options.JsonSerializerSettings, dataType) + .ConfigureAwait(false); + }*/ + + /// + /// Assumes given query is pointing to a single object of type and retrieves it. + /// + /// Optional timeout value. + /// Type of elements. + /// Single object of type . + public async Task OnceSingleAsync(TimeSpan? timeout = null) + { + var responseData = string.Empty; + var statusCode = HttpStatusCode.OK; + var url = string.Empty; + + try + { + url = await BuildUrlAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + throw new FirebaseException("Couldn't build the url", string.Empty, responseData, statusCode, ex); + } + + try + { + var response = await GetClient(timeout).GetAsync(url).ConfigureAwait(false); + statusCode = response.StatusCode; + responseData = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + response.Dispose(); + + return JsonConvert.DeserializeObject(responseData, Client.Options.JsonSerializerSettings); + } + catch (Exception ex) + { + throw new FirebaseException(url, string.Empty, responseData, statusCode, ex); + } + } + + /// + /// Posts given object to repository. + /// + /// The object. + /// Specifies whether the key should be generated offline instead of online. + /// Optional timeout value. + /// Type of + /// Resulting firebase object with populated key. + public async Task> PostAsync(string data, bool generateKeyOffline = true, + TimeSpan? timeout = null) + { + // post generates a new key server-side, while put can be used with an already generated local key + if (generateKeyOffline) + { + var key = FirebaseKeyGenerator.Next(); + await new ChildQuery(this, () => key, Client).PutAsync(data).ConfigureAwait(false); + + return new FirebaseObject(key, data); + } + + var c = GetClient(timeout); + var sendData = await SendAsync(c, data, HttpMethod.Post).ConfigureAwait(false); + var result = JsonConvert.DeserializeObject(sendData, Client.Options.JsonSerializerSettings); + + return new FirebaseObject(result.Name, data); + } + + /// + /// Patches data at given location instead of overwriting them. + /// + /// The object. + /// Optional timeout value. + /// Type of + /// The . + public async Task PatchAsync(string data, TimeSpan? timeout = null) + { + var c = GetClient(timeout); + + await this.Silent().SendAsync(c, data, new HttpMethod("PATCH")).ConfigureAwait(false); + } + + /// + /// Sets or overwrites data at given location. + /// + /// The object. + /// Optional timeout value. + /// Type of + /// The . + public async Task PutAsync(string data, TimeSpan? timeout = null) + { + var c = GetClient(timeout); + + await this.Silent().SendAsync(c, data, HttpMethod.Put).ConfigureAwait(false); + } + + /// + /// Deletes data from given location. + /// + /// Optional timeout value. + /// The . + public async Task DeleteAsync(TimeSpan? timeout = null) + { + var c = GetClient(timeout); + var url = string.Empty; + var responseData = string.Empty; + var statusCode = HttpStatusCode.OK; + + try + { + url = await BuildUrlAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + throw new FirebaseException("Couldn't build the url", string.Empty, responseData, statusCode, ex); + } + + try + { + var result = await c.DeleteAsync(url).ConfigureAwait(false); + statusCode = result.StatusCode; + responseData = await result.Content.ReadAsStringAsync().ConfigureAwait(false); + + result.EnsureSuccessStatusCode(); + } + catch (Exception ex) + { + throw new FirebaseException(url, string.Empty, responseData, statusCode, ex); + } + } + + /// + /// Build the url segment of this child. + /// + /// The child of this query. + /// The . + protected abstract string BuildUrlSegment(FirebaseQuery child); + + private string BuildUrl(FirebaseQuery child) + { + var url = BuildUrlSegment(child); + + if (Parent != null) url = Parent.BuildUrl(this) + url; + + return url; + } + + private HttpClient GetClient(TimeSpan? timeout = null) + { + if (client == null) client = new HttpClient(); + + if (!timeout.HasValue) + client.Timeout = DEFAULT_HTTP_CLIENT_TIMEOUT; + else + client.Timeout = timeout.Value; + + return client; + } + + private async Task SendAsync(HttpClient client, string data, HttpMethod method) + { + var responseData = string.Empty; + var statusCode = HttpStatusCode.OK; + var requestData = data; + var url = string.Empty; + + try + { + url = await BuildUrlAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + throw new FirebaseException("Couldn't build the url", requestData, responseData, statusCode, ex); + } + + var message = new HttpRequestMessage(method, url) + { + Content = new StringContent(requestData) + }; + + try + { + var result = await client.SendAsync(message).ConfigureAwait(false); + statusCode = result.StatusCode; + responseData = await result.Content.ReadAsStringAsync().ConfigureAwait(false); + + result.EnsureSuccessStatusCode(); + + return responseData; + } + catch (Exception ex) + { + throw new FirebaseException(url, requestData, responseData, statusCode, ex); + } + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Query/IFirebaseQuery.cs b/dsa/FireBase/Query/IFirebaseQuery.cs new file mode 100644 index 0000000..0da4b15 --- /dev/null +++ b/dsa/FireBase/Query/IFirebaseQuery.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Firebase.Database.Streaming; + +namespace Firebase.Database.Query +{ + /// + /// The FirebaseQuery interface. + /// + public interface IFirebaseQuery + { + /// + /// Gets the owning client of this query. + /// + FirebaseClient Client { get; } + + /// + /// Retrieves items which exist on the location specified by this query instance. + /// + /// Optional timeout value. + /// Type of the items. + /// Collection of . + Task>> OnceAsync(TimeSpan? timeout = null); + + /// + /// Returns current location as an observable which allows to real-time listening to events from the firebase server. + /// + /// Type of the items. + /// Cold observable of . + IObservable> AsObservable( + EventHandler> exceptionHandler, string elementRoot = ""); + + /// + /// Builds the actual url of this query. + /// + /// The . + Task BuildUrlAsync(); + } +} \ No newline at end of file diff --git a/dsa/FireBase/Query/OrderQuery.cs b/dsa/FireBase/Query/OrderQuery.cs new file mode 100644 index 0000000..302d1a3 --- /dev/null +++ b/dsa/FireBase/Query/OrderQuery.cs @@ -0,0 +1,34 @@ +using System; + +namespace Firebase.Database.Query +{ + /// + /// Represents a firebase ordering query, e.g. "?OrderBy=Foo". + /// + public class OrderQuery : ParameterQuery + { + private readonly Func propertyNameFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The query parent. + /// The property name. + /// The owning client. + public OrderQuery(ChildQuery parent, Func propertyNameFactory, FirebaseClient client) + : base(parent, () => "orderBy", client) + { + this.propertyNameFactory = propertyNameFactory; + } + + /// + /// The build url parameter. + /// + /// The child. + /// The . + protected override string BuildUrlParameter(FirebaseQuery child) + { + return $"\"{propertyNameFactory()}\""; + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Query/ParameterQuery.cs b/dsa/FireBase/Query/ParameterQuery.cs new file mode 100644 index 0000000..572224c --- /dev/null +++ b/dsa/FireBase/Query/ParameterQuery.cs @@ -0,0 +1,43 @@ +using System; + +namespace Firebase.Database.Query +{ + /// + /// Represents a parameter in firebase query, e.g. "?data=foo". + /// + public abstract class ParameterQuery : FirebaseQuery + { + private readonly Func parameterFactory; + private readonly string separator; + + /// + /// Initializes a new instance of the class. + /// + /// The parent of this query. + /// The parameter. + /// The owning client. + protected ParameterQuery(FirebaseQuery parent, Func parameterFactory, FirebaseClient client) + : base(parent, client) + { + this.parameterFactory = parameterFactory; + separator = Parent is ChildQuery ? "?" : "&"; + } + + /// + /// Build the url segment represented by this query. + /// + /// The child. + /// The . + protected override string BuildUrlSegment(FirebaseQuery child) + { + return $"{separator}{parameterFactory()}={BuildUrlParameter(child)}"; + } + + /// + /// The build url parameter. + /// + /// The child. + /// The . + protected abstract string BuildUrlParameter(FirebaseQuery child); + } +} \ No newline at end of file diff --git a/dsa/FireBase/Query/QueryExtensions.cs b/dsa/FireBase/Query/QueryExtensions.cs new file mode 100644 index 0000000..df2edfc --- /dev/null +++ b/dsa/FireBase/Query/QueryExtensions.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace Firebase.Database.Query +{ + /// + /// Query extensions providing linq like syntax for firebase server methods. + /// + public static class QueryExtensions + { + /// + /// Adds an auth parameter to the query. + /// + /// The child. + /// The auth token. + /// The . + internal static AuthQuery WithAuth(this FirebaseQuery node, string token) + { + return node.WithAuth(() => token); + } + + /// + /// Appends print=silent to save bandwidth. + /// + /// The child. + /// The . + internal static SilentQuery Silent(this FirebaseQuery node) + { + return new SilentQuery(node, node.Client); + } + + /// + /// References a sub child of the existing node. + /// + /// The child. + /// The path of sub child. + /// The . + public static ChildQuery Child(this ChildQuery node, string path) + { + return node.Child(() => path); + } + + /// + /// Order data by given . Note that this is used mainly for following filtering queries and + /// due to firebase implementation + /// the data may actually not be ordered. + /// + /// The child. + /// The property name. + /// The . + public static OrderQuery OrderBy(this ChildQuery child, string propertyName) + { + return child.OrderBy(() => propertyName); + } + + /// + /// Instructs firebase to send data greater or equal to the . This must be preceded by an OrderBy + /// query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery StartAt(this ParameterQuery child, string value) + { + return child.StartAt(() => value); + } + + /// + /// Instructs firebase to send data lower or equal to the . This must be preceded by an OrderBy + /// query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EndAt(this ParameterQuery child, string value) + { + return child.EndAt(() => value); + } + + /// + /// Instructs firebase to send data equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child, string value) + { + return child.EqualTo(() => value); + } + + /// + /// Instructs firebase to send data greater or equal to the . This must be preceded by an OrderBy + /// query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery StartAt(this ParameterQuery child, double value) + { + return child.StartAt(() => value); + } + + /// + /// Instructs firebase to send data lower or equal to the . This must be preceded by an OrderBy + /// query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EndAt(this ParameterQuery child, double value) + { + return child.EndAt(() => value); + } + + /// + /// Instructs firebase to send data equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child, double value) + { + return child.EqualTo(() => value); + } + + /// + /// Instructs firebase to send data equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child, bool value) + { + return child.EqualTo(() => value); + } + + /// + /// Instructs firebase to send data equal to null. This must be preceded by an OrderBy query. + /// + /// Current node. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child) + { + return child.EqualTo(() => null); + } + + /// + /// Limits the result to first items. + /// + /// Current node. + /// Number of elements. + /// The . + public static FilterQuery LimitToFirst(this ParameterQuery child, int count) + { + return child.LimitToFirst(() => count); + } + + /// + /// Limits the result to last items. + /// + /// Current node. + /// Number of elements. + /// The . + public static FilterQuery LimitToLast(this ParameterQuery child, int count) + { + return child.LimitToLast(() => count); + } + + public static Task PutAsync(this FirebaseQuery query, T obj) + { + return query.PutAsync(JsonConvert.SerializeObject(obj, query.Client.Options.JsonSerializerSettings)); + } + + public static Task PatchAsync(this FirebaseQuery query, T obj) + { + return query.PatchAsync(JsonConvert.SerializeObject(obj, query.Client.Options.JsonSerializerSettings)); + } + + public static async Task> PostAsync(this FirebaseQuery query, T obj, + bool generateKeyOffline = true) + { + var result = + await query.PostAsync(JsonConvert.SerializeObject(obj, query.Client.Options.JsonSerializerSettings), + generateKeyOffline); + + return new FirebaseObject(result.Key, obj); + } + + /// + /// Fan out given item to multiple locations at once. See + /// https://firebase.googleblog.com/2015/10/client-side-fan-out-for-data-consistency_73.html for details. + /// + /// Type of object to fan out. + /// Current node. + /// Object to fan out. + /// Locations where to store the item. + public static async Task FanOut(this ChildQuery child, T item, params string[] relativePaths) + { + if (relativePaths == null) throw new ArgumentNullException(nameof(relativePaths)); + + var fanoutObject = new Dictionary(relativePaths.Length); + + foreach (var path in relativePaths) fanoutObject.Add(path, item); + + await child.PatchAsync(fanoutObject); + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Query/QueryFactoryExtensions.cs b/dsa/FireBase/Query/QueryFactoryExtensions.cs new file mode 100644 index 0000000..71dae5c --- /dev/null +++ b/dsa/FireBase/Query/QueryFactoryExtensions.cs @@ -0,0 +1,187 @@ +using System; + +namespace Firebase.Database.Query +{ + /// + /// Query extensions providing linq like syntax for firebase server methods. + /// + public static class QueryFactoryExtensions + { + /// + /// Adds an auth parameter to the query. + /// + /// The child. + /// The auth token. + /// The . + internal static AuthQuery WithAuth(this FirebaseQuery node, Func tokenFactory) + { + return new AuthQuery(node, tokenFactory, node.Client); + } + + /// + /// References a sub child of the existing node. + /// + /// The child. + /// The path of sub child. + /// The . + public static ChildQuery Child(this ChildQuery node, Func pathFactory) + { + return new ChildQuery(node, pathFactory, node.Client); + } + + /// + /// Order data by given . Note that this is used mainly for following filtering + /// queries and due to firebase implementation + /// the data may actually not be ordered. + /// + /// The child. + /// The property name. + /// The . + public static OrderQuery OrderBy(this ChildQuery child, Func propertyNameFactory) + { + return new OrderQuery(child, propertyNameFactory, child.Client); + } + + /// + /// Order data by $key. Note that this is used mainly for following filtering queries and due to firebase + /// implementation + /// the data may actually not be ordered. + /// + /// The child. + /// The . + public static OrderQuery OrderByKey(this ChildQuery child) + { + return child.OrderBy("$key"); + } + + /// + /// Order data by $value. Note that this is used mainly for following filtering queries and due to firebase + /// implementation + /// the data may actually not be ordered. + /// + /// The child. + /// The . + public static OrderQuery OrderByValue(this ChildQuery child) + { + return child.OrderBy("$value"); + } + + /// + /// Order data by $priority. Note that this is used mainly for following filtering queries and due to firebase + /// implementation + /// the data may actually not be ordered. + /// + /// The child. + /// The . + public static OrderQuery OrderByPriority(this ChildQuery child) + { + return child.OrderBy("$priority"); + } + + /// + /// Instructs firebase to send data greater or equal to the . This must be preceded by an + /// OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery StartAt(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "startAt", valueFactory, child.Client); + } + + /// + /// Instructs firebase to send data lower or equal to the . This must be preceded by an + /// OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EndAt(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "endAt", valueFactory, child.Client); + } + + /// + /// Instructs firebase to send data equal to the . This must be preceded by an OrderBy + /// query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "equalTo", valueFactory, child.Client); + } + + /// + /// Instructs firebase to send data greater or equal to the . This must be preceded by an + /// OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery StartAt(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "startAt", valueFactory, child.Client); + } + + /// + /// Instructs firebase to send data lower or equal to the . This must be preceded by an + /// OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EndAt(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "endAt", valueFactory, child.Client); + } + + /// + /// Instructs firebase to send data equal to the . This must be preceded by an OrderBy + /// query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "equalTo", valueFactory, child.Client); + } + + /// + /// Instructs firebase to send data equal to the . This must be preceded by an OrderBy + /// query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "equalTo", valueFactory, child.Client); + } + + /// + /// Limits the result to first items. + /// + /// Current node. + /// Number of elements. + /// The . + public static FilterQuery LimitToFirst(this ParameterQuery child, Func countFactory) + { + return new FilterQuery(child, () => "limitToFirst", () => countFactory(), child.Client); + } + + /// + /// Limits the result to last items. + /// + /// Current node. + /// Number of elements. + /// The . + public static FilterQuery LimitToLast(this ParameterQuery child, Func countFactory) + { + return new FilterQuery(child, () => "limitToLast", () => countFactory(), child.Client); + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Query/SilentQuery.cs b/dsa/FireBase/Query/SilentQuery.cs new file mode 100644 index 0000000..d09d38b --- /dev/null +++ b/dsa/FireBase/Query/SilentQuery.cs @@ -0,0 +1,18 @@ +namespace Firebase.Database.Query +{ + /// + /// Appends print=silent to the url. + /// + public class SilentQuery : ParameterQuery + { + public SilentQuery(FirebaseQuery parent, FirebaseClient client) + : base(parent, () => "print", client) + { + } + + protected override string BuildUrlParameter(FirebaseQuery child) + { + return "silent"; + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Settings.StyleCop b/dsa/FireBase/Settings.StyleCop new file mode 100644 index 0000000..833aa39 --- /dev/null +++ b/dsa/FireBase/Settings.StyleCop @@ -0,0 +1,77 @@ + + + + auth + firebase + json + linq + oauth + + + + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + True + + + + + True + + + + + True + True + False + + + + + + + True + + + + + + + \ No newline at end of file diff --git a/dsa/FireBase/Streaming/FirebaseCache.cs b/dsa/FireBase/Streaming/FirebaseCache.cs new file mode 100644 index 0000000..66241e0 --- /dev/null +++ b/dsa/FireBase/Streaming/FirebaseCache.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Firebase.Database.Http; +using Newtonsoft.Json; + +namespace Firebase.Database.Streaming +{ + /// + /// The firebase cache. + /// + /// Type of top-level entities in the cache. + public class FirebaseCache : IEnumerable> + { + private readonly IDictionary dictionary; + private readonly bool isDictionaryType; + + private readonly JsonSerializerSettings serializerSettings = new JsonSerializerSettings + { + ObjectCreationHandling = ObjectCreationHandling.Replace + }; + + /// + /// Initializes a new instance of the class. + /// + public FirebaseCache() + : this(new Dictionary()) + { + } + + /// + /// Initializes a new instance of the class and populates it with existing data. + /// + /// The existing items. + public FirebaseCache(IDictionary existingItems) + { + dictionary = existingItems; + isDictionaryType = typeof(IDictionary).GetTypeInfo().IsAssignableFrom(typeof(T).GetTypeInfo()); + } + + /// + /// The push data. + /// + /// The path of incoming data, separated by slash. + /// The data in json format as returned by firebase. + /// Collection of top-level entities which were affected by the push. + public IEnumerable> PushData(string path, string data, bool removeEmptyEntries = true) + { + object obj = this.dictionary; + Action primitiveObjSetter = null; + Action objDeleter = null; + + var pathElements = path.Split(new[] {"/"}, + removeEmptyEntries ? StringSplitOptions.RemoveEmptyEntries : StringSplitOptions.None); + + // first find where we should insert the data to + foreach (var element in pathElements) + if (obj is IDictionary) + { + // if it's a dictionary, then it's just a matter of inserting into it / accessing existing object by key + var dictionary = obj as IDictionary; + var valueType = obj.GetType().GenericTypeArguments[1]; + + primitiveObjSetter = d => dictionary[element] = d; + objDeleter = () => dictionary.Remove(element); + + if (dictionary.Contains(element)) + { + obj = dictionary[element]; + } + else + { + dictionary[element] = CreateInstance(valueType); + obj = dictionary[element]; + } + } + else + { + // if it's not a dictionary, try to find the property of current object with the matching name + var objParent = obj; + var property = objParent + .GetType() + .GetRuntimeProperties() + .First(p => p.Name.Equals(element, StringComparison.OrdinalIgnoreCase) || + element == p.GetCustomAttribute()?.PropertyName); + + objDeleter = () => property.SetValue(objParent, null); + primitiveObjSetter = d => property.SetValue(objParent, d); + obj = property.GetValue(obj); + if (obj == null) + { + obj = CreateInstance(property.PropertyType); + property.SetValue(objParent, obj); + } + } + + // if data is null (=empty string) delete it + if (string.IsNullOrWhiteSpace(data) || data == "null") + { + var key = pathElements[0]; + var target = dictionary[key]; + + objDeleter(); + + yield return new FirebaseObject(key, target); + yield break; + } + + // now insert the data + if (obj is IDictionary && !isDictionaryType) + { + // insert data into dictionary and return it as a collection of FirebaseObject + var dictionary = obj as IDictionary; + var valueType = obj.GetType().GenericTypeArguments[1]; + var objectCollection = data.GetObjectCollection(valueType); + + foreach (var item in objectCollection) + { + dictionary[item.Key] = item.Object; + + // top level dictionary changed + if (!pathElements.Any()) yield return new FirebaseObject(item.Key, (T) item.Object); + } + + // nested dictionary changed + if (pathElements.Any()) + { + this.dictionary[pathElements[0]] = this.dictionary[pathElements[0]]; + yield return new FirebaseObject(pathElements[0], this.dictionary[pathElements[0]]); + } + } + else + { + // set the data on a property of the given object + var valueType = obj.GetType(); + + // firebase sends strings without double quotes + var targetObject = valueType == typeof(string) + ? data + : JsonConvert.DeserializeObject(data, valueType); + + if ((valueType.GetTypeInfo().IsPrimitive || valueType == typeof(string)) && primitiveObjSetter != null) + // handle primitive (value) types separately + primitiveObjSetter(targetObject); + else + JsonConvert.PopulateObject(data, obj, serializerSettings); + + dictionary[pathElements[0]] = dictionary[pathElements[0]]; + yield return new FirebaseObject(pathElements[0], dictionary[pathElements[0]]); + } + } + + public bool Contains(string key) + { + return dictionary.Keys.Contains(key); + } + + private object CreateInstance(Type type) + { + if (type == typeof(string)) + return string.Empty; + return Activator.CreateInstance(type); + } + + #region IEnumerable + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public IEnumerator> GetEnumerator() + { + return dictionary.Select(p => new FirebaseObject(p.Key, p.Value)).GetEnumerator(); + } + + #endregion + } +} \ No newline at end of file diff --git a/dsa/FireBase/Streaming/FirebaseEvent.cs b/dsa/FireBase/Streaming/FirebaseEvent.cs new file mode 100644 index 0000000..1761a72 --- /dev/null +++ b/dsa/FireBase/Streaming/FirebaseEvent.cs @@ -0,0 +1,37 @@ +namespace Firebase.Database.Streaming +{ + /// + /// Firebase event which hold and the object affected by the event. + /// + /// Type of object affected by the event. + public class FirebaseEvent : FirebaseObject + { + /// + /// Initializes a new instance of the class. + /// + /// The key of the object. + /// The object. + /// The event type. + public FirebaseEvent(string key, T obj, FirebaseEventType eventType, FirebaseEventSource eventSource) + : base(key, obj) + { + EventType = eventType; + EventSource = eventSource; + } + + /// + /// Gets the source of the event. + /// + public FirebaseEventSource EventSource { get; } + + /// + /// Gets the event type. + /// + public FirebaseEventType EventType { get; } + + public static FirebaseEvent Empty(FirebaseEventSource source) + { + return new FirebaseEvent(string.Empty, default(T), FirebaseEventType.InsertOrUpdate, source); + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Streaming/FirebaseEventSource.cs b/dsa/FireBase/Streaming/FirebaseEventSource.cs new file mode 100644 index 0000000..b1385ca --- /dev/null +++ b/dsa/FireBase/Streaming/FirebaseEventSource.cs @@ -0,0 +1,38 @@ +namespace Firebase.Database.Streaming +{ + /// + /// Specifies the origin of given + /// + public enum FirebaseEventSource + { + /// + /// Event comes from an offline source. + /// + Offline, + + /// + /// Event comes from online source fetched during initial pull (valid only for RealtimeDatabase). + /// + OnlineInitial, + + /// + /// Event comes from online source received thru active stream. + /// + OnlineStream, + + /// + /// Event comes from online source being fetched manually. + /// + OnlinePull, + + /// + /// Event raised after successful online push (valid only for RealtimeDatabase which isn't streaming). + /// + OnlinePush, + + /// + /// Event comes from an online source. + /// + Online = OnlineInitial | OnlinePull | OnlinePush | OnlineStream + } +} \ No newline at end of file diff --git a/dsa/FireBase/Streaming/FirebaseEventType.cs b/dsa/FireBase/Streaming/FirebaseEventType.cs new file mode 100644 index 0000000..7606331 --- /dev/null +++ b/dsa/FireBase/Streaming/FirebaseEventType.cs @@ -0,0 +1,18 @@ +namespace Firebase.Database.Streaming +{ + /// + /// The type of event. + /// + public enum FirebaseEventType + { + /// + /// Item was inserted or updated. + /// + InsertOrUpdate, + + /// + /// Item was deleted. + /// + Delete + } +} \ No newline at end of file diff --git a/dsa/FireBase/Streaming/FirebaseServerEventType.cs b/dsa/FireBase/Streaming/FirebaseServerEventType.cs new file mode 100644 index 0000000..79c816d --- /dev/null +++ b/dsa/FireBase/Streaming/FirebaseServerEventType.cs @@ -0,0 +1,15 @@ +namespace Firebase.Database.Streaming +{ + internal enum FirebaseServerEventType + { + Put, + + Patch, + + KeepAlive, + + Cancel, + + AuthRevoked + } +} \ No newline at end of file diff --git a/dsa/FireBase/Streaming/FirebaseSubscription.cs b/dsa/FireBase/Streaming/FirebaseSubscription.cs new file mode 100644 index 0000000..fb0f403 --- /dev/null +++ b/dsa/FireBase/Streaming/FirebaseSubscription.cs @@ -0,0 +1,217 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Firebase.Database.Query; +using Newtonsoft.Json.Linq; + +namespace Firebase.Database.Streaming +{ + /// + /// The firebase subscription. + /// + /// Type of object to be streaming back to the called. + internal class FirebaseSubscription : IDisposable + { + private static readonly HttpClient http; + private readonly FirebaseCache cache; + private readonly CancellationTokenSource cancel; + private readonly FirebaseClient client; + private readonly string elementRoot; + private readonly IObserver> observer; + private readonly IFirebaseQuery query; + + static FirebaseSubscription() + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = true, + MaxAutomaticRedirections = 10, + CookieContainer = new CookieContainer() + }; + + var httpClient = new HttpClient(handler, true); + + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + http = httpClient; + } + + /// + /// Initializes a new instance of the class. + /// + /// The observer. + /// The query. + /// The cache. + public FirebaseSubscription(IObserver> observer, IFirebaseQuery query, string elementRoot, + FirebaseCache cache) + { + this.observer = observer; + this.query = query; + this.elementRoot = elementRoot; + cancel = new CancellationTokenSource(); + this.cache = cache; + client = query.Client; + } + + public void Dispose() + { + cancel.Cancel(); + } + + public event EventHandler> ExceptionThrown; + + public IDisposable Run() + { + Task.Run(() => ReceiveThread()); + + return this; + } + + private async void ReceiveThread() + { + while (true) + { + var url = string.Empty; + var line = string.Empty; + var statusCode = HttpStatusCode.OK; + + try + { + cancel.Token.ThrowIfCancellationRequested(); + + // initialize network connection + url = await query.BuildUrlAsync().ConfigureAwait(false); + var request = new HttpRequestMessage(HttpMethod.Get, url); + var serverEvent = FirebaseServerEventType.KeepAlive; + + var client = GetHttpClient(); + var response = await client + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancel.Token) + .ConfigureAwait(false); + + statusCode = response.StatusCode; + response.EnsureSuccessStatusCode(); + + using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) + using (var reader = this.client.Options.SubscriptionStreamReaderFactory(stream)) + { + while (true) + { + cancel.Token.ThrowIfCancellationRequested(); + + line = reader.ReadLine()?.Trim(); + + if (string.IsNullOrWhiteSpace(line)) continue; + + var tuple = line.Split(new[] {':'}, 2, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()).ToArray(); + + switch (tuple[0].ToLower()) + { + case "event": + serverEvent = ParseServerEvent(serverEvent, tuple[1]); + break; + case "data": + ProcessServerData(url, serverEvent, tuple[1]); + break; + } + + if (serverEvent == FirebaseServerEventType.AuthRevoked) + // auth token no longer valid, reconnect + break; + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) when (statusCode != HttpStatusCode.OK) + { + observer.OnError(new FirebaseException(url, string.Empty, line, statusCode, ex)); + Dispose(); + break; + } + catch (Exception ex) + { + ExceptionThrown?.Invoke(this, + new ExceptionEventArgs(new FirebaseException(url, string.Empty, line, + statusCode, ex))); + + await Task.Delay(2000).ConfigureAwait(false); + } + } + } + + private FirebaseServerEventType ParseServerEvent(FirebaseServerEventType serverEvent, string eventName) + { + switch (eventName) + { + case "put": + serverEvent = FirebaseServerEventType.Put; + break; + case "patch": + serverEvent = FirebaseServerEventType.Patch; + break; + case "keep-alive": + serverEvent = FirebaseServerEventType.KeepAlive; + break; + case "cancel": + serverEvent = FirebaseServerEventType.Cancel; + break; + case "auth_revoked": + serverEvent = FirebaseServerEventType.AuthRevoked; + break; + } + + return serverEvent; + } + + private void ProcessServerData(string url, FirebaseServerEventType serverEvent, string serverData) + { + switch (serverEvent) + { + case FirebaseServerEventType.Put: + case FirebaseServerEventType.Patch: + var result = JObject.Parse(serverData); + var path = result["path"].ToString(); + var data = result["data"].ToString(); + + // If an elementRoot parameter is provided, but it's not in the cache, it was already deleted. So we can return an empty object. + if (string.IsNullOrWhiteSpace(elementRoot) || !cache.Contains(elementRoot)) + if (path == "/" && data == string.Empty) + { + observer.OnNext(FirebaseEvent.Empty(FirebaseEventSource.OnlineStream)); + return; + } + + var eventType = string.IsNullOrWhiteSpace(data) + ? FirebaseEventType.Delete + : FirebaseEventType.InsertOrUpdate; + + var items = cache.PushData(elementRoot + path, data); + + foreach (var i in items.ToList()) + observer.OnNext(new FirebaseEvent(i.Key, i.Object, eventType, + FirebaseEventSource.OnlineStream)); + + break; + case FirebaseServerEventType.KeepAlive: + break; + case FirebaseServerEventType.Cancel: + observer.OnError(new FirebaseException(url, string.Empty, serverData, HttpStatusCode.Unauthorized)); + Dispose(); + break; + } + } + + private HttpClient GetHttpClient() + { + return http; + } + } +} \ No newline at end of file diff --git a/dsa/FireBase/Streaming/NonBlockingStreamReader.cs b/dsa/FireBase/Streaming/NonBlockingStreamReader.cs new file mode 100644 index 0000000..8228e32 --- /dev/null +++ b/dsa/FireBase/Streaming/NonBlockingStreamReader.cs @@ -0,0 +1,63 @@ +using System.IO; +using System.Text; + +namespace Firebase.Database.Streaming +{ + /// + /// When a regular is used in a UWP app its method + /// tends to take a long + /// time for data larger then 2 KB. This extremly simple implementation of can be used + /// instead to boost performance + /// in your UWP app. Use to inject an instance of this class into your + /// . + /// + public class NonBlockingStreamReader : TextReader + { + private const int DefaultBufferSize = 16000; + private readonly byte[] buffer; + private readonly int bufferSize; + + private readonly Stream stream; + + private string cachedData; + + public NonBlockingStreamReader(Stream stream, int bufferSize = DefaultBufferSize) + { + this.stream = stream; + this.bufferSize = bufferSize; + buffer = new byte[bufferSize]; + + cachedData = string.Empty; + } + + public override string ReadLine() + { + var currentString = TryGetNewLine(); + + while (currentString == null) + { + var read = stream.Read(buffer, 0, bufferSize); + var str = Encoding.UTF8.GetString(buffer, 0, read); + + cachedData += str; + currentString = TryGetNewLine(); + } + + return currentString; + } + + private string TryGetNewLine() + { + var newLine = cachedData.IndexOf('\n'); + + if (newLine >= 0) + { + var r = cachedData.Substring(0, newLine + 1); + cachedData = cachedData.Remove(0, r.Length); + return r.Trim(); + } + + return null; + } + } +} \ No newline at end of file -- cgit v1.2.3-54-g00ecf