diff options
Diffstat (limited to 'dsa/FireBase')
42 files changed, 3791 insertions, 0 deletions
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 +{ + /// <summary> + /// Event args holding the <see cref="Exception" /> object. + /// </summary> + public class ExceptionEventArgs<T> : EventArgs where T : Exception + { + public readonly T Exception; + + /// <summary> + /// Initializes a new instance of the <see cref="ExceptionEventArgs" /> class. + /// </summary> + /// <param name="exception"> The exception. </param> + public ExceptionEventArgs(T exception) + { + Exception = exception; + } + } + + public class ExceptionEventArgs : ExceptionEventArgs<Exception> + { + 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 + { + /// <summary> + /// Returns a cold observable which retries (re-subscribes to) the source observable on error until it successfully + /// terminates. + /// </summary> + /// <param name="source">The source observable.</param> + /// <param name="dueTime">How long to wait between attempts.</param> + /// <param name="retryOnError">A predicate determining for which exceptions to retry. Defaults to all</param> + /// <returns> + /// 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. + /// </returns> + public static IObservable<T> RetryAfterDelay<T, TException>( + this IObservable<T> source, + TimeSpan dueTime, + Func<TException, bool> retryOnError) + where TException : Exception + { + var attempt = 0; + + return Observable.Defer(() => + { + return (++attempt == 1 ? source : source.DelaySubscription(dueTime)) + .Select(item => new Tuple<bool, T, Exception>(true, item, null)) + .Catch<Tuple<bool, T, Exception>, TException>(e => retryOnError(e) + ? Observable.Throw<Tuple<bool, T, Exception>>(e) + : Observable.Return(new Tuple<bool, T, Exception>(false, default(T), e))); + }) + .Retry() + .SelectMany(t => t.Item1 + ? Observable.Return(t.Item2) + : Observable.Throw<T>(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 + { + /// <summary> + /// Instead of unwrapping <see cref="AggregateException" /> it throws it as it is. + /// </summary> + 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 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>netcoreapp2.2</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="LiteDB" Version="4.1.4" /> + <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> + <PackageReference Include="System.Reactive" Version="4.1.0" /> + </ItemGroup> + +</Project> 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 +{ + /// <summary> + /// Firebase client which acts as an entry point to the online database. + /// </summary> + public class FirebaseClient : IDisposable + { + private readonly string baseUrl; + internal readonly HttpClient HttpClient; + internal readonly FirebaseOptions Options; + + /// <summary> + /// Initializes a new instance of the <see cref="FirebaseClient" /> class. + /// </summary> + /// <param name="baseUrl"> The base url. </param> + /// <param name="offlineDatabaseFactory"> Offline database. </param> + 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(); + } + + /// <summary> + /// Queries for a child of the data root. + /// </summary> + /// <param name="resourceName"> Name of the child. </param> + /// <returns> <see cref="ChildQuery" />. </returns> + 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; + } + + /// <summary> + /// Post data passed to the authentication service. + /// </summary> + public string RequestData { get; } + + /// <summary> + /// Original url of the request. + /// </summary> + public string RequestUrl { get; } + + /// <summary> + /// Response from the authentication service. + /// </summary> + public string ResponseData { get; } + + /// <summary> + /// Status code of the response. + /// </summary> + 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 +{ + /// <summary> + /// Offline key generator which mimics the official Firebase generators. + /// Credit: https://github.com/bubbafat/FirebaseSharp/blob/master/src/FirebaseSharp.Portable/FireBasePushIdGenerator.cs + /// </summary> + 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)); + } + + /// <summary> + /// Returns next firebase key based on current time. + /// </summary> + /// <returns> + /// The <see cref="string" />. + /// </returns> + 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 +{ + /// <summary> + /// Holds the object of type + /// <typeparam name="T" /> + /// along with its key. + /// </summary> + /// <typeparam name="T"> Type of the underlying object. </typeparam> + public class FirebaseObject<T> + { + internal FirebaseObject(string key, T obj) + { + Key = key; + Object = obj; + } + + /// <summary> + /// Gets the key of <see cref="Object" />. + /// </summary> + public string Key { get; } + + /// <summary> + /// Gets the underlying object. + /// </summary> + 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<string, OfflineEntry>(); + SubscriptionStreamReaderFactory = s => new StreamReader(s); + JsonSerializerSettings = new JsonSerializerSettings(); + SyncPeriod = TimeSpan.FromSeconds(10); + } + + /// <summary> + /// Gets or sets the factory for Firebase offline database. Default is in-memory dictionary. + /// </summary> + public Func<Type, string, IDictionary<string, OfflineEntry>> OfflineDatabaseFactory { get; set; } + + /// <summary> + /// Gets or sets the method for retrieving auth tokens. Default is null. + /// </summary> + public Func<Task<string>> AuthTokenAsyncFactory { get; set; } + + /// <summary> + /// Gets or sets the factory for <see cref="TextReader" /> used for reading online streams. Default is + /// <see cref="StreamReader" />. + /// </summary> + public Func<Stream, TextReader> SubscriptionStreamReaderFactory { get; set; } + + /// <summary> + /// Gets or sets the json serializer settings. + /// </summary> + public JsonSerializerSettings JsonSerializerSettings { get; set; } + + /// <summary> + /// Gets or sets the time between synchronization attempts for pulling and pushing offline entities. Default is 10 + /// seconds. + /// </summary> + public TimeSpan SyncPeriod { get; set; } + + /// <summary> + /// Specify if token returned by factory will be used as "auth" url parameter or "access_token". + /// </summary> + 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 +{ + /// <summary> + /// The http client extensions for object deserializations. + /// </summary> + internal static class HttpClientExtensions + { + /// <summary> + /// The get object collection async. + /// </summary> + /// <param name="client"> The client. </param> + /// <param name="requestUri"> The request uri. </param> + /// <param name="jsonSerializerSettings"> The specific JSON Serializer Settings. </param> + /// <typeparam name="T"> The type of entities the collection should contain. </typeparam> + /// <returns> The <see cref="Task" />. </returns> + public static async Task<IReadOnlyCollection<FirebaseObject<T>>> GetObjectCollectionAsync<T>( + 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<Dictionary<string, T>>(responseData, jsonSerializerSettings); + + if (dictionary == null) return new FirebaseObject<T>[0]; + + return dictionary.Select(item => new FirebaseObject<T>(item.Key, item.Value)).ToList(); + } + catch (Exception ex) + { + throw new FirebaseException(requestUri, string.Empty, responseData, statusCode, ex); + } + } + + /*/// <summary> + /// The get object collection async. + /// </summary> + /// <param name="client"> The client. </param> + /// <param name="requestUri"> The request uri. </param> + /// /// <param name="dataType"> The Data Type. </param> + /// <param name="jsonSerializerSettings"> The specific JSON Serializer Settings. </param> + /// <typeparam name="T"> The type of entities the collection should contain. </typeparam> + /// <returns> The <see cref="Task"/>. </returns> + public static async Task<IReadOnlyCollection<FirebaseObject<object>>> 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<string, object>; + + if (dictionary == null) + { + return new FirebaseObject<object>[0]; + } + + return dictionary.Select(item => new FirebaseObject<object>(item.Key, item.Value)).ToList(); + } + catch (Exception ex) + { + throw new FirebaseException(requestUri, string.Empty, responseData, statusCode, ex); + } + }*/ + + /// <summary> + /// The get object collection async. + /// </summary> + /// <param name="data"> The json data. </param> + /// <param name="elementType"> The type of entities the collection should contain. </param> + /// <returns> The <see cref="Task" />. </returns> + public static IEnumerable<FirebaseObject<object>> 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<object>((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 +{ + /// <summary> + /// Represents data returned after a successful POST to firebase server. + /// </summary> + public class PostResult + { + /// <summary> + /// Gets or sets the generated key after a successful post. + /// </summary> + 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 +{ + /// <summary> + /// Extensions for <see cref="IObservable{T}" />. + /// </summary> + public static class ObservableExtensions + { + /// <summary> + /// Starts observing on given firebase observable and propagates event into an <see cref="ObservableCollection{T}" />. + /// </summary> + /// <param name="observable"> The observable. </param> + /// <typeparam name="T"> Type of entity. </typeparam> + /// <returns> The <see cref="ObservableCollection{T}" />. </returns> + public static ObservableCollection<T> AsObservableCollection<T>(this IObservable<FirebaseEvent<T>> observable) + { + var collection = new ObservableCollection<T>(); + + 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 +{ + /// <summary> + /// The offline database. + /// </summary> + public class ConcurrentOfflineDatabase : IDictionary<string, OfflineEntry> + { + private readonly ConcurrentDictionary<string, OfflineEntry> ccache; + private readonly LiteRepository db; + + /// <summary> + /// Initializes a new instance of the <see cref="OfflineDatabase" /> class. + /// </summary> + /// <param name="itemType"> The item type which is used to determine the database file name. </param> + /// <param name="filenameModifier"> Custom string which will get appended to the file name. </param> + 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<OfflineEntry>().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<OfflineEntry>() + .FindAll() + .ToDictionary(o => o.Key, o => o); + + ccache = new ConcurrentDictionary<string, OfflineEntry>(cache); + } + + /// <summary> + /// Gets the number of elements contained in the <see cref="T:System.Collections.Generic.ICollection`1" />. + /// </summary> + /// <returns> The number of elements contained in the <see cref="T:System.Collections.Generic.ICollection`1" />. </returns> + public int Count => ccache.Count; + + /// <summary> + /// Gets a value indicating whether this is a read-only collection. + /// </summary> + public bool IsReadOnly => false; + + /// <summary> + /// Gets an <see cref="T:System.Collections.Generic.ICollection`1" /> containing the keys of the + /// <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </summary> + /// <returns> + /// An <see cref="T:System.Collections.Generic.ICollection`1" /> containing the keys of the object that + /// implements <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </returns> + public ICollection<string> Keys => ccache.Keys; + + /// <summary> + /// Gets an <see cref="T:System.Collections.Generic.ICollection`1" /> containing the values in the + /// <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </summary> + /// <returns> + /// An <see cref="T:System.Collections.Generic.ICollection`1" /> containing the values in the object that + /// implements <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </returns> + public ICollection<OfflineEntry> Values => ccache.Values; + + /// <summary> + /// Gets or sets the element with the specified key. + /// </summary> + /// <param name="key">The key of the element to get or set.</param> + /// <returns> The element with the specified key. </returns> + public OfflineEntry this[string key] + { + get => ccache[key]; + + set + { + ccache.AddOrUpdate(key, value, (k, existing) => value); + db.Upsert(value); + } + } + + /// <summary> + /// Returns an enumerator that iterates through the collection. + /// </summary> + /// <returns> An enumerator that can be used to iterate through the collection. </returns> + public IEnumerator<KeyValuePair<string, OfflineEntry>> GetEnumerator() + { + return ccache.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// <summary> + /// Adds an item to the <see cref="T:System.Collections.Generic.ICollection`1" />. + /// </summary> + /// <param name="item">The object to add to the <see cref="T:System.Collections.Generic.ICollection`1" />.</param> + public void Add(KeyValuePair<string, OfflineEntry> item) + { + Add(item.Key, item.Value); + } + + /// <summary> + /// Removes all items from the <see cref="T:System.Collections.Generic.ICollection`1" />. + /// </summary> + public void Clear() + { + ccache.Clear(); + db.Delete<OfflineEntry>(LiteDB.Query.All()); + } + + /// <summary> + /// Determines whether the <see cref="T:System.Collections.Generic.ICollection`1" /> contains a specific value. + /// </summary> + /// <param name="item">The object to locate in the <see cref="T:System.Collections.Generic.ICollection`1" />.</param> + /// <returns> + /// True if <paramref name="item" /> is found in the <see cref="T:System.Collections.Generic.ICollection`1" />; + /// otherwise, false. + /// </returns> + public bool Contains(KeyValuePair<string, OfflineEntry> item) + { + return ContainsKey(item.Key); + } + + /// <summary> + /// Copies the elements of the <see cref="T:System.Collections.Generic.ICollection`1" /> to an + /// <see cref="T:System.Array" />, starting at a particular <see cref="T:System.Array" /> index. + /// </summary> + /// <param name="array"> + /// The one-dimensional <see cref="T:System.Array" /> that is the destination of the elements copied + /// from <see cref="T:System.Collections.Generic.ICollection`1" />. The <see cref="T:System.Array" /> must have + /// zero-based indexing. + /// </param> + /// <param name="arrayIndex">The zero-based index in <paramref name="array" /> at which copying begins.</param> + public void CopyTo(KeyValuePair<string, OfflineEntry>[] array, int arrayIndex) + { + ccache.ToList().CopyTo(array, arrayIndex); + } + + /// <summary> + /// Removes the first occurrence of a specific object from the + /// <see cref="T:System.Collections.Generic.ICollection`1" />. + /// </summary> + /// <param name="item">The object to remove from the <see cref="T:System.Collections.Generic.ICollection`1" />.</param> + /// <returns> + /// True if <paramref name="item" /> was successfully removed from the + /// <see cref="T:System.Collections.Generic.ICollection`1" />; otherwise, false. This method also returns false if + /// <paramref name="item" /> is not found in the original <see cref="T:System.Collections.Generic.ICollection`1" />. + /// </returns> + public bool Remove(KeyValuePair<string, OfflineEntry> item) + { + return Remove(item.Key); + } + + /// <summary> + /// Determines whether the <see cref="T:System.Collections.Generic.IDictionary`2" /> contains an element with the + /// specified key. + /// </summary> + /// <param name="key">The key to locate in the <see cref="T:System.Collections.Generic.IDictionary`2" />.</param> + /// <returns> + /// True if the <see cref="T:System.Collections.Generic.IDictionary`2" /> contains an element with the key; + /// otherwise, false. + /// </returns> + public bool ContainsKey(string key) + { + return ccache.ContainsKey(key); + } + + /// <summary> + /// Adds an element with the provided key and value to the <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </summary> + /// <param name="key">The object to use as the key of the element to add.</param> + /// <param name="value">The object to use as the value of the element to add.</param> + public void Add(string key, OfflineEntry value) + { + ccache.AddOrUpdate(key, value, (k, existing) => value); + db.Upsert(value); + } + + /// <summary> + /// Removes the element with the specified key from the <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </summary> + /// <param name="key">The key of the element to remove.</param> + /// <returns> + /// True if the element is successfully removed; otherwise, false. This method also returns false if + /// <paramref name="key" /> was not found in the original <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </returns> + public bool Remove(string key) + { + ccache.TryRemove(key, out _); + return db.Delete<OfflineEntry>(key); + } + + /// <summary> + /// Gets the value associated with the specified key. + /// </summary> + /// <param name="key">The key whose value to get.</param> + /// <param name="value"> + /// 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 <paramref name="value" /> parameter. This parameter is passed + /// uninitialized. + /// </param> + /// <returns> + /// True if the object that implements <see cref="T:System.Collections.Generic.IDictionary`2" /> contains an + /// element with the specified key; otherwise, false. + /// </returns> + 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 + { + /// <summary> + /// Create new instances of the <see cref="RealtimeDatabase{T}" />. + /// </summary> + /// <typeparam name="T"> Type of elements. </typeparam> + /// <param name="filenameModifier"> Custom string which will get appended to the file name. </param> + /// <param name="elementRoot"> Optional custom root element of received json items. </param> + /// <param name="streamingOptions"> Realtime streaming options. </param> + /// <param name="initialPullStrategy"> Specifies what strategy should be used for initial pulling of server data. </param> + /// <param name="pushChanges"> + /// Specifies whether changed items should actually be pushed to the server. It this is false, + /// then Put / Post / Delete will not affect server data. + /// </param> + /// <returns> The <see cref="RealtimeDatabase{T}" />. </returns> + public static RealtimeDatabase<T> AsRealtimeDatabase<T>(this ChildQuery query, string filenameModifier = "", + string elementRoot = "", StreamingOptions streamingOptions = StreamingOptions.LatestOnly, + InitialPullStrategy initialPullStrategy = InitialPullStrategy.MissingOnly, bool pushChanges = true) + where T : class + { + return new RealtimeDatabase<T>(query, elementRoot, query.Client.Options.OfflineDatabaseFactory, + filenameModifier, streamingOptions, initialPullStrategy, pushChanges); + } + + /// <summary> + /// Create new instances of the <see cref="RealtimeDatabase{T}" />. + /// </summary> + /// <typeparam name="T"> Type of elements. </typeparam> + /// <typeparam name="TSetHandler"> Type of the custom <see cref="ISetHandler{T}" /> to use. </typeparam> + /// <param name="filenameModifier"> Custom string which will get appended to the file name. </param> + /// <param name="elementRoot"> Optional custom root element of received json items. </param> + /// <param name="streamingOptions"> Realtime streaming options. </param> + /// <param name="initialPullStrategy"> Specifies what strategy should be used for initial pulling of server data. </param> + /// <param name="pushChanges"> + /// Specifies whether changed items should actually be pushed to the server. It this is false, + /// then Put / Post / Delete will not affect server data. + /// </param> + /// <returns> The <see cref="RealtimeDatabase{T}" />. </returns> + public static RealtimeDatabase<T> AsRealtimeDatabase<T, TSetHandler>(this ChildQuery query, + string filenameModifier = "", string elementRoot = "", + StreamingOptions streamingOptions = StreamingOptions.LatestOnly, + InitialPullStrategy initialPullStrategy = InitialPullStrategy.MissingOnly, bool pushChanges = true) + where T : class + where TSetHandler : ISetHandler<T>, new() + { + return new RealtimeDatabase<T>(query, elementRoot, query.Client.Options.OfflineDatabaseFactory, + filenameModifier, streamingOptions, initialPullStrategy, pushChanges, + Activator.CreateInstance<TSetHandler>()); + } + + /// <summary> + /// Overwrites existing object with given key leaving any missing properties intact in firebase. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="obj"> The object to set. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </param> + public static void Patch<T>(this RealtimeDatabase<T> db, string key, T obj, bool syncOnline = true, + int priority = 1) + where T : class + { + db.Set(key, obj, syncOnline ? SyncOptions.Patch : SyncOptions.None, priority); + } + + /// <summary> + /// Overwrites existing object with given key. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="obj"> The object to set. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </param> + public static void Put<T>(this RealtimeDatabase<T> db, string key, T obj, bool syncOnline = true, + int priority = 1) + where T : class + { + db.Set(key, obj, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// <summary> + /// Adds a new entity to the Database. + /// </summary> + /// <param name="obj"> The object to add. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </param> + /// <returns> The generated key for this object. </returns> + public static string Post<T>(this RealtimeDatabase<T> 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; + } + + /// <summary> + /// Deletes the entity with the given key. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </param> + public static void Delete<T>(this RealtimeDatabase<T> db, string key, bool syncOnline = true, int priority = 1) + where T : class + { + db.Set(key, null, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// <summary> + /// Do a Put for a nested property specified by <paramref name="propertyExpression" /> of an object with key + /// <paramref name="key" />. + /// </summary> + /// <typeparam name="T"> Type of the root elements. </typeparam> + /// <typeparam name="TProperty"> Type of the property being modified</typeparam> + /// <param name="db"> Database instance. </param> + /// <param name="key"> Key of the root element to modify. </param> + /// <param name="propertyExpression"> Expression on the root element leading to target value to modify. </param> + /// <param name="value"> Value to put. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </param> + public static void Put<T, TProperty>(this RealtimeDatabase<T> db, string key, + Expression<Func<T, TProperty>> propertyExpression, TProperty value, bool syncOnline = true, + int priority = 1) + where T : class + { + db.Set(key, propertyExpression, value, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// <summary> + /// Do a Patch for a nested property specified by <paramref name="propertyExpression" /> of an object with key + /// <paramref name="key" />. + /// </summary> + /// <typeparam name="T"> Type of the root elements. </typeparam> + /// <typeparam name="TProperty"> Type of the property being modified</typeparam> + /// <param name="db"> Database instance. </param> + /// <param name="key"> Key of the root element to modify. </param> + /// <param name="propertyExpression"> Expression on the root element leading to target value to modify. </param> + /// <param name="value"> Value to patch. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </param> + public static void Patch<T, TProperty>(this RealtimeDatabase<T> db, string key, + Expression<Func<T, TProperty>> propertyExpression, TProperty value, bool syncOnline = true, + int priority = 1) + where T : class + { + db.Set(key, propertyExpression, value, syncOnline ? SyncOptions.Patch : SyncOptions.None, priority); + } + + /// <summary> + /// Delete a nested property specified by <paramref name="propertyExpression" /> of an object with key + /// <paramref name="key" />. This basically does a Put with null value. + /// </summary> + /// <typeparam name="T"> Type of the root elements. </typeparam> + /// <typeparam name="TProperty"> Type of the property being modified</typeparam> + /// <param name="db"> Database instance. </param> + /// <param name="key"> Key of the root element to modify. </param> + /// <param name="propertyExpression"> Expression on the root element leading to target value to modify. </param> + /// <param name="value"> Value to put. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </param> + public static void Delete<T, TProperty>(this RealtimeDatabase<T> db, string key, + Expression<Func<T, TProperty>> propertyExpression, bool syncOnline = true, int priority = 1) + where T : class + where TProperty : class + { + db.Set(key, propertyExpression, null, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// <summary> + /// Post a new entity into the nested dictionary specified by <paramref name="propertyExpression" /> of an object with + /// key <paramref name="key" />. + /// The key of the new entity is automatically generated. + /// </summary> + /// <typeparam name="T"> Type of the root elements. </typeparam> + /// <typeparam name="TSelector"> Type of the dictionary being modified</typeparam> + /// <typeparam name="TProperty"> Type of the value within the dictionary being modified</typeparam> + /// <param name="db"> Database instance. </param> + /// <param name="key"> Key of the root element to modify. </param> + /// <param name="propertyExpression"> Expression on the root element leading to target value to modify. </param> + /// <param name="value"> Value to put. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </param> + public static void Post<T, TSelector, TProperty>(this RealtimeDatabase<T> db, string key, + Expression<Func<T, TSelector>> propertyExpression, TProperty value, bool syncOnline = true, + int priority = 1) + where T : class + where TSelector : IDictionary<string, TProperty> + { + var nextKey = FirebaseKeyGenerator.Next(); + var expression = Expression.Lambda<Func<T, TProperty>>( + 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); + } + + /// <summary> + /// Delete an entity with key <paramref name="dictionaryKey" /> in the nested dictionary specified by + /// <paramref name="propertyExpression" /> of an object with key <paramref name="key" />. + /// The key of the new entity is automatically generated. + /// </summary> + /// <typeparam name="T"> Type of the root elements. </typeparam> + /// <typeparam name="TSelector"> Type of the dictionary being modified</typeparam> + /// <typeparam name="TProperty"> Type of the value within the dictionary being modified</typeparam> + /// <param name="db"> Database instance. </param> + /// <param name="key"> Key of the root element to modify. </param> + /// <param name="propertyExpression"> Expression on the root element leading to target value to modify. </param> + /// <param name="dictionaryKey"> Key within the nested dictionary to delete. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </param> + public static void Delete<T, TProperty>(this RealtimeDatabase<T> db, string key, + Expression<Func<T, IDictionary<string, TProperty>>> propertyExpression, string dictionaryKey, + bool syncOnline = true, int priority = 1) + where T : class + { + var expression = Expression.Lambda<Func<T, TProperty>>( + Expression.Call(propertyExpression.Body, + typeof(IDictionary<string, TProperty>).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<in T> + { + 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 +{ + /// <summary> + /// Specifies the strategy for initial pull of server data. + /// </summary> + public enum InitialPullStrategy + { + /// <summary> + /// Don't pull anything. + /// </summary> + None, + + /// <summary> + /// Pull only what isn't already stored offline. + /// </summary> + MissingOnly, + + /// <summary> + /// Pull everything that exists on the server. + /// </summary> + 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<string> propertyNames = new List<string>(); + + private bool wasDictionaryAccess; + + public IEnumerable<string> 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<JsonPropertyAttribute>(); + + 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<TKey, T> : IDictionary<string, T>, IDictionary + { + private readonly IDictionary<string, OfflineEntry> database; + + public OfflineCacheAdapter(IDictionary<string, OfflineEntry> 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<T>(); + + 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<string> Keys => database.Keys; + + public ICollection<T> Values => database.Values.Select(o => o.Deserialize<T>()).ToList(); + + public T this[string key] + { + get => database[key].Deserialize<T>(); + + 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<KeyValuePair<string, T>> GetEnumerator() + { + return database.Select(d => new KeyValuePair<string, T>(d.Key, d.Value.Deserialize<T>())).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public void Add(KeyValuePair<string, T> item) + { + Add(item.Key, item.Value); + } + + public void Clear() + { + database.Clear(); + } + + public bool Contains(KeyValuePair<string, T> item) + { + return ContainsKey(item.Key); + } + + public void CopyTo(KeyValuePair<string, T>[] array, int arrayIndex) + { + throw new NotImplementedException(); + } + + public bool Remove(KeyValuePair<string, T> 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<T>(); + 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 +{ + /// <summary> + /// The offline database. + /// </summary> + public class OfflineDatabase : IDictionary<string, OfflineEntry> + { + private readonly IDictionary<string, OfflineEntry> cache; + private readonly LiteRepository db; + + /// <summary> + /// Initializes a new instance of the <see cref="OfflineDatabase" /> class. + /// </summary> + /// <param name="itemType"> The item type which is used to determine the database file name. </param> + /// <param name="filenameModifier"> Custom string which will get appended to the file name. </param> + 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<OfflineEntry>().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<OfflineEntry>().FindAll() + .ToDictionary(o => o.Key, o => o); + } + + /// <summary> + /// Gets the number of elements contained in the <see cref="T:System.Collections.Generic.ICollection`1" />. + /// </summary> + /// <returns> The number of elements contained in the <see cref="T:System.Collections.Generic.ICollection`1" />. </returns> + public int Count => cache.Count; + + /// <summary> + /// Gets a value indicating whether this is a read-only collection. + /// </summary> + public bool IsReadOnly => cache.IsReadOnly; + + /// <summary> + /// Gets an <see cref="T:System.Collections.Generic.ICollection`1" /> containing the keys of the + /// <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </summary> + /// <returns> + /// An <see cref="T:System.Collections.Generic.ICollection`1" /> containing the keys of the object that + /// implements <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </returns> + public ICollection<string> Keys => cache.Keys; + + /// <summary> + /// Gets an <see cref="T:System.Collections.Generic.ICollection`1" /> containing the values in the + /// <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </summary> + /// <returns> + /// An <see cref="T:System.Collections.Generic.ICollection`1" /> containing the values in the object that + /// implements <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </returns> + public ICollection<OfflineEntry> Values => cache.Values; + + /// <summary> + /// Gets or sets the element with the specified key. + /// </summary> + /// <param name="key">The key of the element to get or set.</param> + /// <returns> The element with the specified key. </returns> + public OfflineEntry this[string key] + { + get => cache[key]; + + set + { + cache[key] = value; + db.Upsert(value); + } + } + + /// <summary> + /// Returns an enumerator that iterates through the collection. + /// </summary> + /// <returns> An enumerator that can be used to iterate through the collection. </returns> + public IEnumerator<KeyValuePair<string, OfflineEntry>> GetEnumerator() + { + return cache.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// <summary> + /// Adds an item to the <see cref="T:System.Collections.Generic.ICollection`1" />. + /// </summary> + /// <param name="item">The object to add to the <see cref="T:System.Collections.Generic.ICollection`1" />.</param> + public void Add(KeyValuePair<string, OfflineEntry> item) + { + Add(item.Key, item.Value); + } + + /// <summary> + /// Removes all items from the <see cref="T:System.Collections.Generic.ICollection`1" />. + /// </summary> + public void Clear() + { + cache.Clear(); + db.Delete<OfflineEntry>(LiteDB.Query.All()); + } + + /// <summary> + /// Determines whether the <see cref="T:System.Collections.Generic.ICollection`1" /> contains a specific value. + /// </summary> + /// <param name="item">The object to locate in the <see cref="T:System.Collections.Generic.ICollection`1" />.</param> + /// <returns> + /// True if <paramref name="item" /> is found in the <see cref="T:System.Collections.Generic.ICollection`1" />; + /// otherwise, false. + /// </returns> + public bool Contains(KeyValuePair<string, OfflineEntry> item) + { + return ContainsKey(item.Key); + } + + /// <summary> + /// Copies the elements of the <see cref="T:System.Collections.Generic.ICollection`1" /> to an + /// <see cref="T:System.Array" />, starting at a particular <see cref="T:System.Array" /> index. + /// </summary> + /// <param name="array"> + /// The one-dimensional <see cref="T:System.Array" /> that is the destination of the elements copied + /// from <see cref="T:System.Collections.Generic.ICollection`1" />. The <see cref="T:System.Array" /> must have + /// zero-based indexing. + /// </param> + /// <param name="arrayIndex">The zero-based index in <paramref name="array" /> at which copying begins.</param> + public void CopyTo(KeyValuePair<string, OfflineEntry>[] array, int arrayIndex) + { + cache.CopyTo(array, arrayIndex); + } + + /// <summary> + /// Removes the first occurrence of a specific object from the + /// <see cref="T:System.Collections.Generic.ICollection`1" />. + /// </summary> + /// <param name="item">The object to remove from the <see cref="T:System.Collections.Generic.ICollection`1" />.</param> + /// <returns> + /// True if <paramref name="item" /> was successfully removed from the + /// <see cref="T:System.Collections.Generic.ICollection`1" />; otherwise, false. This method also returns false if + /// <paramref name="item" /> is not found in the original <see cref="T:System.Collections.Generic.ICollection`1" />. + /// </returns> + public bool Remove(KeyValuePair<string, OfflineEntry> item) + { + return Remove(item.Key); + } + + /// <summary> + /// Determines whether the <see cref="T:System.Collections.Generic.IDictionary`2" /> contains an element with the + /// specified key. + /// </summary> + /// <param name="key">The key to locate in the <see cref="T:System.Collections.Generic.IDictionary`2" />.</param> + /// <returns> + /// True if the <see cref="T:System.Collections.Generic.IDictionary`2" /> contains an element with the key; + /// otherwise, false. + /// </returns> + public bool ContainsKey(string key) + { + return cache.ContainsKey(key); + } + + /// <summary> + /// Adds an element with the provided key and value to the <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </summary> + /// <param name="key">The object to use as the key of the element to add.</param> + /// <param name="value">The object to use as the value of the element to add.</param> + public void Add(string key, OfflineEntry value) + { + cache.Add(key, value); + db.Insert(value); + } + + /// <summary> + /// Removes the element with the specified key from the <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </summary> + /// <param name="key">The key of the element to remove.</param> + /// <returns> + /// True if the element is successfully removed; otherwise, false. This method also returns false if + /// <paramref name="key" /> was not found in the original <see cref="T:System.Collections.Generic.IDictionary`2" />. + /// </returns> + public bool Remove(string key) + { + cache.Remove(key); + return db.Delete<OfflineEntry>(key); + } + + /// <summary> + /// Gets the value associated with the specified key. + /// </summary> + /// <param name="key">The key whose value to get.</param> + /// <param name="value"> + /// 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 <paramref name="value" /> parameter. This parameter is passed + /// uninitialized. + /// </param> + /// <returns> + /// True if the object that implements <see cref="T:System.Collections.Generic.IDictionary`2" /> contains an + /// element with the specified key; otherwise, false. + /// </returns> + 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 +{ + /// <summary> + /// Represents an object stored in offline storage. + /// </summary> + public class OfflineEntry + { + private object dataInstance; + + /// <summary> + /// Initializes a new instance of the <see cref="OfflineEntry" /> class with an already serialized object. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="obj"> The object. </param> + /// <param name="priority"> + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </param> + /// <param name="syncOptions"> The sync options. </param> + 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; + } + + /// <summary> + /// Initializes a new instance of the <see cref="OfflineEntry" /> class. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="obj"> The object. </param> + /// <param name="priority"> + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </param> + /// <param name="syncOptions"> The sync options. </param> + public OfflineEntry(string key, object obj, int priority, SyncOptions syncOptions, bool isPartial = false) + : this(key, obj, JsonConvert.SerializeObject(obj), priority, syncOptions, isPartial) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="OfflineEntry" /> class. + /// </summary> + public OfflineEntry() + { + } + + /// <summary> + /// Gets or sets the key of this entry. + /// </summary> + public string Key { get; set; } + + /// <summary> + /// Gets or sets the priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </summary> + public int Priority { get; set; } + + /// <summary> + /// Gets or sets the timestamp when this entry was last touched. + /// </summary> + public DateTime Timestamp { get; set; } + + /// <summary> + /// Gets or sets the <see cref="SyncOptions" /> which define what sync state this entry is in. + /// </summary> + public SyncOptions SyncOptions { get; set; } + + /// <summary> + /// Gets or sets serialized JSON data. + /// </summary> + public string Data { get; set; } + + /// <summary> + /// Specifies whether this is only a partial object. + /// </summary> + public bool IsPartial { get; set; } + + /// <summary> + /// Deserializes <see cref="Data" /> into <typeparamref name="T" />. The result is cached. + /// </summary> + /// <typeparam name="T"> Type of object to deserialize into. </typeparam> + /// <returns> Instance of <typeparamref name="T" />. </returns> + public T Deserialize<T>() + { + return (T) (dataInstance ?? (dataInstance = JsonConvert.DeserializeObject<T>(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 +{ + /// <summary> + /// The real-time Database which synchronizes online and offline data. + /// </summary> + /// <typeparam name="T"> Type of entities. </typeparam> + public class RealtimeDatabase<T> : IDisposable where T : class + { + private readonly ChildQuery childQuery; + private readonly string elementRoot; + private readonly FirebaseCache<T> firebaseCache; + private readonly InitialPullStrategy initialPullStrategy; + private readonly bool pushChanges; + private readonly StreamingOptions streamingOptions; + private readonly Subject<FirebaseEvent<T>> subject; + private FirebaseSubscription<T> firebaseSubscription; + + private bool isSyncRunning; + private IObservable<FirebaseEvent<T>> observable; + + /// <summary> + /// Initializes a new instance of the <see cref="RealtimeDatabase{T}" /> class. + /// </summary> + /// <param name="childQuery"> The child query. </param> + /// <param name="elementRoot"> The element Root. </param> + /// <param name="offlineDatabaseFactory"> The offline database factory. </param> + /// <param name="filenameModifier"> Custom string which will get appended to the file name. </param> + /// <param name="streamChanges"> Specifies whether changes should be streamed from the server. </param> + /// <param name="pullEverythingOnStart"> + /// Specifies if everything should be pull from the online storage on start. It only + /// makes sense when <see cref="streamChanges" /> is set to true. + /// </param> + /// <param name="pushChanges"> + /// Specifies whether changed items should actually be pushed to the server. If this is false, + /// then Put / Post / Delete will not affect server data. + /// </param> + public RealtimeDatabase(ChildQuery childQuery, string elementRoot, + Func<Type, string, IDictionary<string, OfflineEntry>> offlineDatabaseFactory, string filenameModifier, + StreamingOptions streamingOptions, InitialPullStrategy initialPullStrategy, bool pushChanges, + ISetHandler<T> 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<T>(new OfflineCacheAdapter<string, T>(Database)); + subject = new Subject<FirebaseEvent<T>>(); + + PutHandler = setHandler ?? new SetHandler<T>(); + + isSyncRunning = true; + Task.Factory.StartNew(SynchronizeThread, CancellationToken.None, TaskCreationOptions.LongRunning, + TaskScheduler.Default); + } + + /// <summary> + /// Gets the backing Database. + /// </summary> + public IDictionary<string, OfflineEntry> Database { get; } + + public ISetHandler<T> PutHandler { private get; set; } + + public void Dispose() + { + subject.OnCompleted(); + firebaseSubscription?.Dispose(); + } + + /// <summary> + /// 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. + /// </summary> + public event EventHandler<ExceptionEventArgs> SyncExceptionThrown; + + /// <summary> + /// Overwrites existing object with given key. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="obj"> The object to set. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </param> + public void Set(string key, T obj, SyncOptions syncOptions, int priority = 1) + { + SetAndRaise(key, new OfflineEntry(key, obj, priority, syncOptions)); + } + + public void Set<TProperty>(string key, Expression<Func<T, TProperty>> 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<T>(key, setObject.Object, + setObject == null ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate, + FirebaseEventSource.Offline)); + } + + /// <summary> + /// Fetches an object with the given key and adds it to the Database. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="priority"> + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher + /// priority. + /// </param> + 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; + } + + /// <summary> + /// Fetches everything from the remote database. + /// </summary> + public async Task PullAsync() + { + var existingEntries = await childQuery + .OnceAsync<T>() + .ToObservable() + .RetryAfterDelay<IReadOnlyCollection<FirebaseObject<T>>, 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<T>(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<T>(item, null, FirebaseEventType.Delete, + FirebaseEventSource.OnlinePull)); + } + } + + /// <summary> + /// Retrieves all offline items currently stored in local database. + /// </summary> + public IEnumerable<FirebaseObject<T>> Once() + { + return Database + .Where(kvp => !string.IsNullOrEmpty(kvp.Value.Data) && kvp.Value.Data != "null" && !kvp.Value.IsPartial) + .Select(kvp => new FirebaseObject<T>(kvp.Key, kvp.Value.Deserialize<T>())) + .ToList(); + } + + /// <summary> + /// Starts observing the real-time Database. Events will be fired both when change is done locally and remotely. + /// </summary> + /// <returns> Stream of <see cref="FirebaseEvent{T}" />. </returns> + public IObservable<FirebaseEvent<T>> AsObservable() + { + if (!isSyncRunning) + { + isSyncRunning = true; + Task.Factory.StartNew(SynchronizeThread, CancellationToken.None, TaskCreationOptions.LongRunning, + TaskScheduler.Default); + } + + if (observable == null) + { + var initialData = Observable.Return(FirebaseEvent<T>.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<T>(offlineEntry.Key, offlineEntry.Deserialize<T>(), + 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<T>(kvp.Key, kvp.Value.Deserialize<T>(), + FirebaseEventType.InsertOrUpdate, FirebaseEventSource.Offline)) + .ToList() + .ToObservable(); + + observable = initialData + .Merge(subject) + .Merge(GetInitialPullObservable() + .RetryAfterDelay<IReadOnlyCollection<FirebaseObject<T>>, 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<T>(e.Key, e.Object, + e.Object == null ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate, + FirebaseEventSource.OnlineInitial)) + .Concat(Observable.Create<FirebaseEvent<T>>(observer => + InitializeStreamingSubscription(observer)))) + .Do(next => { }, e => observable = null, () => observable = null) + .Replay() + .RefCount(); + } + + return observable; + } + + private IReadOnlyCollection<FirebaseObject<T>> ResetDatabaseFromInitial( + IReadOnlyCollection<FirebaseObject<T>> 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<T>(k, null)); + + return collection.Concat(extra).ToList(); + } + + private void SetObjectFromInitialPull(FirebaseObject<T> 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<IReadOnlyCollection<FirebaseObject<T>>> 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<IReadOnlyCollection<FirebaseEvent<T>>>(); + } + + if (string.IsNullOrWhiteSpace(elementRoot)) + return Observable.Defer(() => query.OnceAsync<T>().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<T>()) + .Select(e => new[] {new FirebaseObject<T>(elementRoot, e)})); + } + + private IDisposable InitializeStreamingSubscription(IObserver<FirebaseEvent<T>> 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<T>(observer, queryLatest, elementRoot, firebaseCache); + firebaseSubscription.ExceptionThrown += StreamingExceptionThrown; + + return new CompositeDisposable(firebaseSubscription.Run(), completeDisposable); + case StreamingOptions.Everything: + // stream everything + var queryAll = childQuery; + firebaseSubscription = new FirebaseSubscription<T>(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<T>(key, obj?.Deserialize<T>(), + 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<KeyValuePair<string, OfflineEntry>> 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<T>())); + + try + { + await Task.WhenAll(tasks).WithAggregateException(); + } + catch (Exception ex) + { + SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(ex)); + } + } + } + + private async Task PullEntriesAsync(IEnumerable<KeyValuePair<string, OfflineEntry>> 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<T>(), + pair.Key, pair.Value)); + + try + { + await Task.WhenAll(tasks).WithAggregateException(); + } + catch (Exception ex) + { + SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(ex)); + } + } + } + + private async Task ResetAfterPull(Task<T> 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<T>(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<FirebaseException> e) + { + SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(e.Exception)); + } + + private Tuple<string, string, bool> GenerateFullKey<TProperty>(string key, + Expression<Func<T, TProperty>> 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<T> : ISetHandler<T> + { + 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 + { + /// <summary> + /// No realtime streaming. + /// </summary> + None, + + /// <summary> + /// Streaming of only new items - not the existing ones. + /// </summary> + LatestOnly, + + /// <summary> + /// 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 <see cref="InitialPullStrategy" /> to + /// <see cref="InitialPullStrategy.Everything" /> because you would pointlessly pull everything twice. + /// </summary> + 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 +{ + /// <summary> + /// Specifies type of sync requested for given data. + /// </summary> + public enum SyncOptions + { + /// <summary> + /// No sync needed for given data. + /// </summary> + None, + + /// <summary> + /// Data should be pulled from firebase. + /// </summary> + Pull, + + /// <summary> + /// Data should be put to firebase. + /// </summary> + Put, + + /// <summary> + /// Data should be patched in firebase. + /// </summary> + 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 +{ + /// <summary> + /// Represents an auth parameter in firebase query, e.g. "?auth=xyz". + /// </summary> + public class AuthQuery : ParameterQuery + { + private readonly Func<string> tokenFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="AuthQuery" /> class. + /// </summary> + /// <param name="parent"> The parent. </param> + /// <param name="tokenFactory"> The authentication token factory. </param> + /// <param name="client"> The owner. </param> + public AuthQuery(FirebaseQuery parent, Func<string> tokenFactory, FirebaseClient client) : base(parent, + () => client.Options.AsAccessToken ? "access_token" : "auth", client) + { + this.tokenFactory = tokenFactory; + } + + /// <summary> + /// Build the url parameter value of this child. + /// </summary> + /// <param name="child"> The child of this child. </param> + /// <returns> The <see cref="string" />. </returns> + 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 +{ + /// <summary> + /// Firebase query which references the child of current node. + /// </summary> + public class ChildQuery : FirebaseQuery + { + private readonly Func<string> pathFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="ChildQuery" /> class. + /// </summary> + /// <param name="parent"> The parent. </param> + /// <param name="pathFactory"> The path to the child node. </param> + /// <param name="client"> The owner. </param> + public ChildQuery(FirebaseQuery parent, Func<string> pathFactory, FirebaseClient client) + : base(parent, client) + { + this.pathFactory = pathFactory; + } + + /// <summary> + /// Initializes a new instance of the <see cref="ChildQuery" /> class. + /// </summary> + /// <param name="client"> The client. </param> + /// <param name="pathFactory"> The path to the child node. </param> + public ChildQuery(FirebaseClient client, Func<string> pathFactory) + : this(null, pathFactory, client) + { + } + + /// <summary> + /// Build the url segment of this child. + /// </summary> + /// <param name="child"> The child of this child. </param> + /// <returns> The <see cref="string" />. </returns> + 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 +{ + /// <summary> + /// Represents a firebase filtering query, e.g. "?LimitToLast=10". + /// </summary> + public class FilterQuery : ParameterQuery + { + private readonly Func<bool> boolValueFactory; + private readonly Func<double> doubleValueFactory; + private readonly Func<string> valueFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="FilterQuery" /> class. + /// </summary> + /// <param name="parent"> The parent. </param> + /// <param name="filterFactory"> The filter. </param> + /// <param name="valueFactory"> The value for filter. </param> + /// <param name="client"> The owning client. </param> + public FilterQuery(FirebaseQuery parent, Func<string> filterFactory, Func<string> valueFactory, + FirebaseClient client) + : base(parent, filterFactory, client) + { + this.valueFactory = valueFactory; + } + + /// <summary> + /// Initializes a new instance of the <see cref="FilterQuery" /> class. + /// </summary> + /// <param name="parent"> The parent. </param> + /// <param name="filterFactory"> The filter. </param> + /// <param name="valueFactory"> The value for filter. </param> + /// <param name="client"> The owning client. </param> + public FilterQuery(FirebaseQuery parent, Func<string> filterFactory, Func<double> valueFactory, + FirebaseClient client) + : base(parent, filterFactory, client) + { + doubleValueFactory = valueFactory; + } + + /// <summary> + /// Initializes a new instance of the <see cref="FilterQuery" /> class. + /// </summary> + /// <param name="parent"> The parent. </param> + /// <param name="filterFactory"> The filter. </param> + /// <param name="valueFactory"> The value for filter. </param> + /// <param name="client"> The owning client. </param> + public FilterQuery(FirebaseQuery parent, Func<string> filterFactory, Func<bool> valueFactory, + FirebaseClient client) + : base(parent, filterFactory, client) + { + boolValueFactory = valueFactory; + } + + /// <summary> + /// The build url parameter. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> Url parameter part of the resulting path. </returns> + 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 +{ + /// <summary> + /// Represents a firebase query. + /// </summary> + public abstract class FirebaseQuery : IFirebaseQuery, IDisposable + { + protected readonly FirebaseQuery Parent; + + private HttpClient client; + protected TimeSpan DEFAULT_HTTP_CLIENT_TIMEOUT = new TimeSpan(0, 0, 180); + + /// <summary> + /// Initializes a new instance of the <see cref="FirebaseQuery" /> class. + /// </summary> + /// <param name="parent"> The parent of this query. </param> + /// <param name="client"> The owning client. </param> + protected FirebaseQuery(FirebaseQuery parent, FirebaseClient client) + { + Client = client; + Parent = parent; + } + + /// <summary> + /// Disposes this instance. + /// </summary> + public void Dispose() + { + client?.Dispose(); + } + + /// <summary> + /// Gets the client. + /// </summary> + public FirebaseClient Client { get; } + + /// <summary> + /// Queries the firebase server once returning collection of items. + /// </summary> + /// <param name="timeout"> Optional timeout value. </param> + /// <typeparam name="T"> Type of elements. </typeparam> + /// <returns> Collection of <see cref="FirebaseObject{T}" /> holding the entities returned by server. </returns> + public async Task<IReadOnlyCollection<FirebaseObject<T>>> OnceAsync<T>(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<T>(url, Client.Options.JsonSerializerSettings) + .ConfigureAwait(false); + } + + /// <summary> + /// Starts observing this query watching for changes real time sent by the server. + /// </summary> + /// <typeparam name="T"> Type of elements. </typeparam> + /// <param name="elementRoot"> Optional custom root element of received json items. </param> + /// <returns> Observable stream of <see cref="FirebaseEvent{T}" />. </returns> + public IObservable<FirebaseEvent<T>> AsObservable<T>( + EventHandler<ExceptionEventArgs<FirebaseException>> exceptionHandler = null, string elementRoot = "") + { + return Observable.Create<FirebaseEvent<T>>(observer => + { + var sub = new FirebaseSubscription<T>(observer, this, elementRoot, new FirebaseCache<T>()); + sub.ExceptionThrown += exceptionHandler; + return sub.Run(); + }); + } + + /// <summary> + /// Builds the actual URL of this query. + /// </summary> + /// <returns> The <see cref="string" />. </returns> + public async Task<string> 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<IReadOnlyCollection<FirebaseObject<Object>>> 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); + }*/ + + /// <summary> + /// Assumes given query is pointing to a single object of type <typeparamref name="T" /> and retrieves it. + /// </summary> + /// <param name="timeout"> Optional timeout value. </param> + /// <typeparam name="T"> Type of elements. </typeparam> + /// <returns> Single object of type <typeparamref name="T" />. </returns> + public async Task<T> OnceSingleAsync<T>(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<T>(responseData, Client.Options.JsonSerializerSettings); + } + catch (Exception ex) + { + throw new FirebaseException(url, string.Empty, responseData, statusCode, ex); + } + } + + /// <summary> + /// Posts given object to repository. + /// </summary> + /// <param name="obj"> The object. </param> + /// <param name="generateKeyOffline"> Specifies whether the key should be generated offline instead of online. </param> + /// <param name="timeout"> Optional timeout value. </param> + /// <typeparam name="T"> Type of <see cref="obj" /> </typeparam> + /// <returns> Resulting firebase object with populated key. </returns> + public async Task<FirebaseObject<string>> 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<string>(key, data); + } + + var c = GetClient(timeout); + var sendData = await SendAsync(c, data, HttpMethod.Post).ConfigureAwait(false); + var result = JsonConvert.DeserializeObject<PostResult>(sendData, Client.Options.JsonSerializerSettings); + + return new FirebaseObject<string>(result.Name, data); + } + + /// <summary> + /// Patches data at given location instead of overwriting them. + /// </summary> + /// <param name="obj"> The object. </param> + /// <param name="timeout"> Optional timeout value. </param> + /// <typeparam name="T"> Type of <see cref="obj" /> </typeparam> + /// <returns> The <see cref="Task" />. </returns> + public async Task PatchAsync(string data, TimeSpan? timeout = null) + { + var c = GetClient(timeout); + + await this.Silent().SendAsync(c, data, new HttpMethod("PATCH")).ConfigureAwait(false); + } + + /// <summary> + /// Sets or overwrites data at given location. + /// </summary> + /// <param name="obj"> The object. </param> + /// <param name="timeout"> Optional timeout value. </param> + /// <typeparam name="T"> Type of <see cref="obj" /> </typeparam> + /// <returns> The <see cref="Task" />. </returns> + public async Task PutAsync(string data, TimeSpan? timeout = null) + { + var c = GetClient(timeout); + + await this.Silent().SendAsync(c, data, HttpMethod.Put).ConfigureAwait(false); + } + + /// <summary> + /// Deletes data from given location. + /// </summary> + /// <param name="timeout"> Optional timeout value. </param> + /// <returns> The <see cref="Task" />. </returns> + 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); + } + } + + /// <summary> + /// Build the url segment of this child. + /// </summary> + /// <param name="child"> The child of this query. </param> + /// <returns> The <see cref="string" />. </returns> + 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<string> 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 +{ + /// <summary> + /// The FirebaseQuery interface. + /// </summary> + public interface IFirebaseQuery + { + /// <summary> + /// Gets the owning client of this query. + /// </summary> + FirebaseClient Client { get; } + + /// <summary> + /// Retrieves items which exist on the location specified by this query instance. + /// </summary> + /// <param name="timeout"> Optional timeout value. </param> + /// <typeparam name="T"> Type of the items. </typeparam> + /// <returns> Collection of <see cref="FirebaseObject{T}" />. </returns> + Task<IReadOnlyCollection<FirebaseObject<T>>> OnceAsync<T>(TimeSpan? timeout = null); + + /// <summary> + /// Returns current location as an observable which allows to real-time listening to events from the firebase server. + /// </summary> + /// <typeparam name="T"> Type of the items. </typeparam> + /// <returns> Cold observable of <see cref="FirebaseEvent{T}" />. </returns> + IObservable<FirebaseEvent<T>> AsObservable<T>( + EventHandler<ExceptionEventArgs<FirebaseException>> exceptionHandler, string elementRoot = ""); + + /// <summary> + /// Builds the actual url of this query. + /// </summary> + /// <returns> The <see cref="string" />. </returns> + Task<string> 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 +{ + /// <summary> + /// Represents a firebase ordering query, e.g. "?OrderBy=Foo". + /// </summary> + public class OrderQuery : ParameterQuery + { + private readonly Func<string> propertyNameFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="OrderQuery" /> class. + /// </summary> + /// <param name="parent"> The query parent. </param> + /// <param name="propertyNameFactory"> The property name. </param> + /// <param name="client"> The owning client. </param> + public OrderQuery(ChildQuery parent, Func<string> propertyNameFactory, FirebaseClient client) + : base(parent, () => "orderBy", client) + { + this.propertyNameFactory = propertyNameFactory; + } + + /// <summary> + /// The build url parameter. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> The <see cref="string" />. </returns> + 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 +{ + /// <summary> + /// Represents a parameter in firebase query, e.g. "?data=foo". + /// </summary> + public abstract class ParameterQuery : FirebaseQuery + { + private readonly Func<string> parameterFactory; + private readonly string separator; + + /// <summary> + /// Initializes a new instance of the <see cref="ParameterQuery" /> class. + /// </summary> + /// <param name="parent"> The parent of this query. </param> + /// <param name="parameterFactory"> The parameter. </param> + /// <param name="client"> The owning client. </param> + protected ParameterQuery(FirebaseQuery parent, Func<string> parameterFactory, FirebaseClient client) + : base(parent, client) + { + this.parameterFactory = parameterFactory; + separator = Parent is ChildQuery ? "?" : "&"; + } + + /// <summary> + /// Build the url segment represented by this query. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> The <see cref="string" />. </returns> + protected override string BuildUrlSegment(FirebaseQuery child) + { + return $"{separator}{parameterFactory()}={BuildUrlParameter(child)}"; + } + + /// <summary> + /// The build url parameter. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> The <see cref="string" />. </returns> + 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 +{ + /// <summary> + /// Query extensions providing linq like syntax for firebase server methods. + /// </summary> + public static class QueryExtensions + { + /// <summary> + /// Adds an auth parameter to the query. + /// </summary> + /// <param name="node"> The child. </param> + /// <param name="token"> The auth token. </param> + /// <returns> The <see cref="AuthQuery" />. </returns> + internal static AuthQuery WithAuth(this FirebaseQuery node, string token) + { + return node.WithAuth(() => token); + } + + /// <summary> + /// Appends print=silent to save bandwidth. + /// </summary> + /// <param name="node"> The child. </param> + /// <returns> The <see cref="SilentQuery" />. </returns> + internal static SilentQuery Silent(this FirebaseQuery node) + { + return new SilentQuery(node, node.Client); + } + + /// <summary> + /// References a sub child of the existing node. + /// </summary> + /// <param name="node"> The child. </param> + /// <param name="path"> The path of sub child. </param> + /// <returns> The <see cref="ChildQuery" />. </returns> + public static ChildQuery Child(this ChildQuery node, string path) + { + return node.Child(() => path); + } + + /// <summary> + /// Order data by given <see cref="propertyName" />. Note that this is used mainly for following filtering queries and + /// due to firebase implementation + /// the data may actually not be ordered. + /// </summary> + /// <param name="child"> The child. </param> + /// <param name="propertyName"> The property name. </param> + /// <returns> The <see cref="OrderQuery" />. </returns> + public static OrderQuery OrderBy(this ChildQuery child, string propertyName) + { + return child.OrderBy(() => propertyName); + } + + /// <summary> + /// Instructs firebase to send data greater or equal to the <see cref="value" />. This must be preceded by an OrderBy + /// query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery StartAt(this ParameterQuery child, string value) + { + return child.StartAt(() => value); + } + + /// <summary> + /// Instructs firebase to send data lower or equal to the <see cref="value" />. This must be preceded by an OrderBy + /// query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery EndAt(this ParameterQuery child, string value) + { + return child.EndAt(() => value); + } + + /// <summary> + /// Instructs firebase to send data equal to the <see cref="value" />. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery EqualTo(this ParameterQuery child, string value) + { + return child.EqualTo(() => value); + } + + /// <summary> + /// Instructs firebase to send data greater or equal to the <see cref="value" />. This must be preceded by an OrderBy + /// query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery StartAt(this ParameterQuery child, double value) + { + return child.StartAt(() => value); + } + + /// <summary> + /// Instructs firebase to send data lower or equal to the <see cref="value" />. This must be preceded by an OrderBy + /// query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery EndAt(this ParameterQuery child, double value) + { + return child.EndAt(() => value); + } + + /// <summary> + /// Instructs firebase to send data equal to the <see cref="value" />. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery EqualTo(this ParameterQuery child, double value) + { + return child.EqualTo(() => value); + } + + /// <summary> + /// Instructs firebase to send data equal to the <see cref="value" />. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery EqualTo(this ParameterQuery child, bool value) + { + return child.EqualTo(() => value); + } + + /// <summary> + /// Instructs firebase to send data equal to null. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery EqualTo(this ParameterQuery child) + { + return child.EqualTo(() => null); + } + + /// <summary> + /// Limits the result to first <see cref="count" /> items. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="count"> Number of elements. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery LimitToFirst(this ParameterQuery child, int count) + { + return child.LimitToFirst(() => count); + } + + /// <summary> + /// Limits the result to last <see cref="count" /> items. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="count"> Number of elements. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery LimitToLast(this ParameterQuery child, int count) + { + return child.LimitToLast(() => count); + } + + public static Task PutAsync<T>(this FirebaseQuery query, T obj) + { + return query.PutAsync(JsonConvert.SerializeObject(obj, query.Client.Options.JsonSerializerSettings)); + } + + public static Task PatchAsync<T>(this FirebaseQuery query, T obj) + { + return query.PatchAsync(JsonConvert.SerializeObject(obj, query.Client.Options.JsonSerializerSettings)); + } + + public static async Task<FirebaseObject<T>> PostAsync<T>(this FirebaseQuery query, T obj, + bool generateKeyOffline = true) + { + var result = + await query.PostAsync(JsonConvert.SerializeObject(obj, query.Client.Options.JsonSerializerSettings), + generateKeyOffline); + + return new FirebaseObject<T>(result.Key, obj); + } + + /// <summary> + /// 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. + /// </summary> + /// <typeparam name="T"> Type of object to fan out. </typeparam> + /// <param name="query"> Current node. </param> + /// <param name="item"> Object to fan out. </param> + /// <param name="relativePaths"> Locations where to store the item. </param> + public static async Task FanOut<T>(this ChildQuery child, T item, params string[] relativePaths) + { + if (relativePaths == null) throw new ArgumentNullException(nameof(relativePaths)); + + var fanoutObject = new Dictionary<string, T>(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 +{ + /// <summary> + /// Query extensions providing linq like syntax for firebase server methods. + /// </summary> + public static class QueryFactoryExtensions + { + /// <summary> + /// Adds an auth parameter to the query. + /// </summary> + /// <param name="node"> The child. </param> + /// <param name="tokenFactory"> The auth token. </param> + /// <returns> The <see cref="AuthQuery" />. </returns> + internal static AuthQuery WithAuth(this FirebaseQuery node, Func<string> tokenFactory) + { + return new AuthQuery(node, tokenFactory, node.Client); + } + + /// <summary> + /// References a sub child of the existing node. + /// </summary> + /// <param name="node"> The child. </param> + /// <param name="pathFactory"> The path of sub child. </param> + /// <returns> The <see cref="ChildQuery" />. </returns> + public static ChildQuery Child(this ChildQuery node, Func<string> pathFactory) + { + return new ChildQuery(node, pathFactory, node.Client); + } + + /// <summary> + /// Order data by given <see cref="propertyNameFactory" />. Note that this is used mainly for following filtering + /// queries and due to firebase implementation + /// the data may actually not be ordered. + /// </summary> + /// <param name="child"> The child. </param> + /// <param name="propertyNameFactory"> The property name. </param> + /// <returns> The <see cref="OrderQuery" />. </returns> + public static OrderQuery OrderBy(this ChildQuery child, Func<string> propertyNameFactory) + { + return new OrderQuery(child, propertyNameFactory, child.Client); + } + + /// <summary> + /// 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. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> The <see cref="OrderQuery" />. </returns> + public static OrderQuery OrderByKey(this ChildQuery child) + { + return child.OrderBy("$key"); + } + + /// <summary> + /// 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. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> The <see cref="OrderQuery" />. </returns> + public static OrderQuery OrderByValue(this ChildQuery child) + { + return child.OrderBy("$value"); + } + + /// <summary> + /// 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. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> The <see cref="OrderQuery" />. </returns> + public static OrderQuery OrderByPriority(this ChildQuery child) + { + return child.OrderBy("$priority"); + } + + /// <summary> + /// Instructs firebase to send data greater or equal to the <see cref="valueFactory" />. This must be preceded by an + /// OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery StartAt(this ParameterQuery child, Func<string> valueFactory) + { + return new FilterQuery(child, () => "startAt", valueFactory, child.Client); + } + + /// <summary> + /// Instructs firebase to send data lower or equal to the <see cref="valueFactory" />. This must be preceded by an + /// OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery EndAt(this ParameterQuery child, Func<string> valueFactory) + { + return new FilterQuery(child, () => "endAt", valueFactory, child.Client); + } + + /// <summary> + /// Instructs firebase to send data equal to the <see cref="valueFactory" />. This must be preceded by an OrderBy + /// query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery EqualTo(this ParameterQuery child, Func<string> valueFactory) + { + return new FilterQuery(child, () => "equalTo", valueFactory, child.Client); + } + + /// <summary> + /// Instructs firebase to send data greater or equal to the <see cref="valueFactory" />. This must be preceded by an + /// OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery StartAt(this ParameterQuery child, Func<double> valueFactory) + { + return new FilterQuery(child, () => "startAt", valueFactory, child.Client); + } + + /// <summary> + /// Instructs firebase to send data lower or equal to the <see cref="valueFactory" />. This must be preceded by an + /// OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery EndAt(this ParameterQuery child, Func<double> valueFactory) + { + return new FilterQuery(child, () => "endAt", valueFactory, child.Client); + } + + /// <summary> + /// Instructs firebase to send data equal to the <see cref="valueFactory" />. This must be preceded by an OrderBy + /// query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery EqualTo(this ParameterQuery child, Func<double> valueFactory) + { + return new FilterQuery(child, () => "equalTo", valueFactory, child.Client); + } + + /// <summary> + /// Instructs firebase to send data equal to the <see cref="valueFactory" />. This must be preceded by an OrderBy + /// query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery EqualTo(this ParameterQuery child, Func<bool> valueFactory) + { + return new FilterQuery(child, () => "equalTo", valueFactory, child.Client); + } + + /// <summary> + /// Limits the result to first <see cref="countFactory" /> items. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="countFactory"> Number of elements. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery LimitToFirst(this ParameterQuery child, Func<int> countFactory) + { + return new FilterQuery(child, () => "limitToFirst", () => countFactory(), child.Client); + } + + /// <summary> + /// Limits the result to last <see cref="countFactory" /> items. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="countFactory"> Number of elements. </param> + /// <returns> The <see cref="FilterQuery" />. </returns> + public static FilterQuery LimitToLast(this ParameterQuery child, Func<int> 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 +{ + /// <summary> + /// Appends print=silent to the url. + /// </summary> + 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 @@ +<StyleCopSettings Version="105"> + <GlobalSettings> + <CollectionProperty Name="RecognizedWords"> + <Value>auth</Value> + <Value>firebase</Value> + <Value>json</Value> + <Value>linq</Value> + <Value>oauth</Value> + </CollectionProperty> + </GlobalSettings> + <Analyzers> + <Analyzer AnalyzerId="StyleCop.CSharp.DocumentationRules"> + <Rules> + <Rule Name="FileMustHaveHeader"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="FileHeaderMustShowCopyright"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="FileHeaderMustHaveCopyrightText"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="FileHeaderMustContainFileName"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="FileHeaderFileNameDocumentationMustMatchFileName"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="FileHeaderMustHaveValidCompanyText"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="FileHeaderFileNameDocumentationMustMatchTypeName"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="DocumentationTextMustBeginWithACapitalLetter"> + <RuleSettings> + <BooleanProperty Name="Enabled">True</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="DocumentationTextMustEndWithAPeriod"> + <RuleSettings> + <BooleanProperty Name="Enabled">True</BooleanProperty> + </RuleSettings> + </Rule> + </Rules> + <AnalyzerSettings> + <BooleanProperty Name="IgnorePrivates">True</BooleanProperty> + <BooleanProperty Name="IgnoreInternals">True</BooleanProperty> + <BooleanProperty Name="IncludeFields">False</BooleanProperty> + </AnalyzerSettings> + </Analyzer> + <Analyzer AnalyzerId="StyleCop.CSharp.ReadabilityRules"> + <Rules> + <Rule Name="DoNotUseRegions"> + <RuleSettings> + <BooleanProperty Name="Enabled">True</BooleanProperty> + </RuleSettings> + </Rule> + </Rules> + <AnalyzerSettings /> + </Analyzer> + </Analyzers> +</StyleCopSettings>
\ 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 +{ + /// <summary> + /// The firebase cache. + /// </summary> + /// <typeparam name="T"> Type of top-level entities in the cache. </typeparam> + public class FirebaseCache<T> : IEnumerable<FirebaseObject<T>> + { + private readonly IDictionary<string, T> dictionary; + private readonly bool isDictionaryType; + + private readonly JsonSerializerSettings serializerSettings = new JsonSerializerSettings + { + ObjectCreationHandling = ObjectCreationHandling.Replace + }; + + /// <summary> + /// Initializes a new instance of the <see cref="FirebaseCache{T}" /> class. + /// </summary> + public FirebaseCache() + : this(new Dictionary<string, T>()) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="FirebaseCache{T}" /> class and populates it with existing data. + /// </summary> + /// <param name="existingItems"> The existing items. </param> + public FirebaseCache(IDictionary<string, T> existingItems) + { + dictionary = existingItems; + isDictionaryType = typeof(IDictionary).GetTypeInfo().IsAssignableFrom(typeof(T).GetTypeInfo()); + } + + /// <summary> + /// The push data. + /// </summary> + /// <param name="path"> The path of incoming data, separated by slash. </param> + /// <param name="data"> The data in json format as returned by firebase. </param> + /// <returns> Collection of top-level entities which were affected by the push. </returns> + public IEnumerable<FirebaseObject<T>> PushData(string path, string data, bool removeEmptyEntries = true) + { + object obj = this.dictionary; + Action<object> 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<JsonPropertyAttribute>()?.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<T>(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<T>(item.Key, (T) item.Object); + } + + // nested dictionary changed + if (pathElements.Any()) + { + this.dictionary[pathElements[0]] = this.dictionary[pathElements[0]]; + yield return new FirebaseObject<T>(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<T>(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<FirebaseObject<T>> GetEnumerator() + { + return dictionary.Select(p => new FirebaseObject<T>(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 +{ + /// <summary> + /// Firebase event which hold <see cref="EventType" /> and the object affected by the event. + /// </summary> + /// <typeparam name="T"> Type of object affected by the event. </typeparam> + public class FirebaseEvent<T> : FirebaseObject<T> + { + /// <summary> + /// Initializes a new instance of the <see cref="FirebaseEvent{T}" /> class. + /// </summary> + /// <param name="key"> The key of the object. </param> + /// <param name="obj"> The object. </param> + /// <param name="eventType"> The event type. </param> + public FirebaseEvent(string key, T obj, FirebaseEventType eventType, FirebaseEventSource eventSource) + : base(key, obj) + { + EventType = eventType; + EventSource = eventSource; + } + + /// <summary> + /// Gets the source of the event. + /// </summary> + public FirebaseEventSource EventSource { get; } + + /// <summary> + /// Gets the event type. + /// </summary> + public FirebaseEventType EventType { get; } + + public static FirebaseEvent<T> Empty(FirebaseEventSource source) + { + return new FirebaseEvent<T>(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 +{ + /// <summary> + /// Specifies the origin of given <see cref="FirebaseEvent{T}" /> + /// </summary> + public enum FirebaseEventSource + { + /// <summary> + /// Event comes from an offline source. + /// </summary> + Offline, + + /// <summary> + /// Event comes from online source fetched during initial pull (valid only for RealtimeDatabase). + /// </summary> + OnlineInitial, + + /// <summary> + /// Event comes from online source received thru active stream. + /// </summary> + OnlineStream, + + /// <summary> + /// Event comes from online source being fetched manually. + /// </summary> + OnlinePull, + + /// <summary> + /// Event raised after successful online push (valid only for RealtimeDatabase which isn't streaming). + /// </summary> + OnlinePush, + + /// <summary> + /// Event comes from an online source. + /// </summary> + 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 +{ + /// <summary> + /// The type of event. + /// </summary> + public enum FirebaseEventType + { + /// <summary> + /// Item was inserted or updated. + /// </summary> + InsertOrUpdate, + + /// <summary> + /// Item was deleted. + /// </summary> + 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 +{ + /// <summary> + /// The firebase subscription. + /// </summary> + /// <typeparam name="T"> Type of object to be streaming back to the called. </typeparam> + internal class FirebaseSubscription<T> : IDisposable + { + private static readonly HttpClient http; + private readonly FirebaseCache<T> cache; + private readonly CancellationTokenSource cancel; + private readonly FirebaseClient client; + private readonly string elementRoot; + private readonly IObserver<FirebaseEvent<T>> 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; + } + + /// <summary> + /// Initializes a new instance of the <see cref="FirebaseSubscription{T}" /> class. + /// </summary> + /// <param name="observer"> The observer. </param> + /// <param name="query"> The query. </param> + /// <param name="cache"> The cache. </param> + public FirebaseSubscription(IObserver<FirebaseEvent<T>> observer, IFirebaseQuery query, string elementRoot, + FirebaseCache<T> 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<ExceptionEventArgs<FirebaseException>> 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<FirebaseException>(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<T>.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<T>(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 +{ + /// <summary> + /// When a regular <see cref="StreamReader" /> is used in a UWP app its <see cref="StreamReader.ReadLine" /> method + /// tends to take a long + /// time for data larger then 2 KB. This extremly simple implementation of <see cref="TextReader" /> can be used + /// instead to boost performance + /// in your UWP app. Use <see cref="FirebaseOptions" /> to inject an instance of this class into your + /// <see cref="FirebaseClient" />. + /// </summary> + 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 |