diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..1ff0c4230 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..8908c6014 --- /dev/null +++ b/.gitignore @@ -0,0 +1,267 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# GCop files +GCopLog.txt +/GCopReport.json +/GCopReport.json +@Assemblies \ No newline at end of file diff --git a/GCop.json b/GCop.json new file mode 100644 index 000000000..debcf06fe --- /dev/null +++ b/GCop.json @@ -0,0 +1,57 @@ +{ + "ReadMe": "To learn how to configure this, see:http://gcop.co/doc/config", + "Projects": [], + "DomainProjectNames": "", + "General": [ + { + "Path": "*.g.i.cs", + "Exclude": "*" + }, + { + "Path": "*\\DAL\\*", + "Exclude": "*" + }, + { + "Path": "*\\Pages\\*", + "Exclude": "*" + }, + { + "Path": "*\\Entities\\*", + "Exclude": "*" + }, + { + "Path": "*\\@Modules\\*", + "Exclude": "*" + }, + { + "Path": "*\\Modules\\*", + "Exclude": "*" + }, + { + "Path": "*\\Controller.cs", + "Exclude": "*" + }, + { + "Path": "*\\Test\\*", + "Exclude": "*" + }, + { + "Path": "*\\TaskManager.cs", + "Exclude": "*" + } + ], + "Report": { + "Enabled": true, + "AnalyzerErrorInterval": 30, + "AnalyzerPerformanceInterval": 30, + "ProjectInfoInterval": 30 + }, + "Diagnostics": { + "Enabled": false, + "URL": null + }, + "Logging": { + "LogExceptionsToFile": false, + "LogExceptionsToOutputWindow": true + } +} \ No newline at end of file diff --git a/Mvc.RemovalCandidates/Attributes/RestrictToAttribute.cs b/Mvc.RemovalCandidates/Attributes/RestrictToAttribute.cs new file mode 100644 index 000000000..b5dca7a3f --- /dev/null +++ b/Mvc.RemovalCandidates/Attributes/RestrictToAttribute.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace Olive.Mvc +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = true)] + public class RestrictToAttribute : AuthorizeAttribute + { + protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext) + { + if (HttpContext.Current.Request.IsAjaxCall()) + { + var actions = new List + { + new { OutOfModal = true, WithAjax = false, Redirect = Config.Get("Authentication.LoginUrl") } + }; + + filterContext.Result = new JsonResult(actions); + } + else + { + base.HandleUnauthorizedRequest(filterContext); + } + } + } +} \ No newline at end of file diff --git a/Mvc.RemovalCandidates/Binding/BasicViewDataContainer.cs b/Mvc.RemovalCandidates/Binding/BasicViewDataContainer.cs new file mode 100644 index 000000000..a165b2394 --- /dev/null +++ b/Mvc.RemovalCandidates/Binding/BasicViewDataContainer.cs @@ -0,0 +1,7 @@ +namespace Olive.Mvc +{ + public class BasicViewDataContainer : IViewDataContainer + { + public ViewDataDictionary ViewData { get; set; } + } +} \ No newline at end of file diff --git a/Mvc.RemovalCandidates/Binding/OliveModelBinder.cs b/Mvc.RemovalCandidates/Binding/OliveModelBinder.cs new file mode 100644 index 000000000..ec1e85035 --- /dev/null +++ b/Mvc.RemovalCandidates/Binding/OliveModelBinder.cs @@ -0,0 +1,206 @@ +// namespace Olive.Mvc +// { +// using System; +// using System.Collections; +// using System.Collections.Concurrent; +// using System.Collections.Generic; +// using System.Collections.Specialized; +// using System.ComponentModel; +// using System.Linq; +// using System.Reflection; + +// public class OliveModelBinder : DefaultModelBinder +// { +// static ConcurrentDictionary CustomBindMethods = new ConcurrentDictionary(); +// protected override PropertyDescriptorCollection GetModelProperties(ControllerContext cContext, ModelBindingContext bContext) +// { +// var result = base.GetModelProperties(cContext, bContext).Cast().ToList(); +// foreach (PropertyDescriptor prop in GetTypeDescriptor(cContext, bContext).GetProperties()) +// { +// var fromQuery = prop.Attributes.OfType().ToList(); +// if (fromQuery.None()) continue; + +// var old = result.Single(x => x.Name == prop.Name); +// result.Remove(old); +// result.AddRange(ByFromQuery(bContext, prop, fromQuery)); +// } + +// var sortedProperties = result.OrderBy(x => x.PropertyType.Implements()).ToArray(); +// return new PropertyDescriptorCollection(sortedProperties); +// } + +// /// Sets the specified property by using the specified controller context, binding context, and property value. +// protected override void SetProperty(ControllerContext cContext, ModelBindingContext bContext, PropertyDescriptor pDescriptor, object value) +// { +// if (pDescriptor.PropertyType == typeof(string)) +// { +// var stringValue = (string)value; +// if (stringValue.HasValue()) +// { +// if (pDescriptor.Attributes.OfType().None()) +// stringValue = stringValue.Trim(); +// } + +// value = stringValue; +// } + +// base.SetProperty(cContext, bContext, pDescriptor, value); +// } + +// protected override void BindProperty(ControllerContext cContext, ModelBindingContext bContext, PropertyDescriptor propertyDescriptor) +// { +// if (propertyDescriptor.PropertyType.IsA()) +// { +// BindViewModelProperty(cContext, bContext, propertyDescriptor); +// } +// else if (propertyDescriptor.PropertyType.IsA()) +// { +// BindDocumentProperty(cContext, bContext, propertyDescriptor); +// } +// else if (propertyDescriptor.Attributes.OfType().Any()) +// { +// BindMasterDetailsProperty(cContext, bContext, propertyDescriptor); +// } +// else +// { +// base.BindProperty(cContext, bContext, propertyDescriptor); +// } +// } + +// void BindMasterDetailsProperty(ControllerContext cContext, ModelBindingContext bContext, PropertyDescriptor propertyDescriptor) +// { +// if ((cContext.Controller as Controller).Request.IsGet()) return; + +// var prefix = propertyDescriptor.Attributes.OfType().Single().Prefix + "-"; +// var listObject = Activator.CreateInstance(propertyDescriptor.PropertyType) as IList; +// var formData = cContext.RequestContext.HttpContext.Request.Form; + +// var childItemIds = formData.AllKeys.ExceptNull().Where(k => k.StartsWith(prefix) && k.EndsWith(".Item")) +// .Select(x => x.TrimStart(prefix).TrimEnd(".Item")).ToList(); + +// foreach (var id in childItemIds) +// { +// var formControlsPrefix = prefix + id + "."; +// // The request form keys that are related to this row +// var dataKeys = formData.AllKeys.ExceptNull().Where(x => x.StartsWith(formControlsPrefix)).ToList(); +// var instanceType = propertyDescriptor.PropertyType.GetGenericArguments().Single(); +// var instance = Activator.CreateInstance(instanceType); +// listObject.Add(instance); +// // Set the instance properties +// foreach (var key in dataKeys) +// { +// var propertyName = key.TrimStart(formControlsPrefix); +// var property = instanceType.GetProperty(propertyName); +// SetPropertyValue(cContext, bContext, instance, key, property, formData); +// } + +// // All properties are written to ViewModel. Now also write them on the model (Item property): +// var item = instance.GetType().GetProperty("Item").GetValue(instance); +// ViewModelServices.CopyData(instance, item); +// } + +// propertyDescriptor.SetValue(bContext.Model, listObject); +// } + +// static void SetPropertyValue(ControllerContext cContext, ModelBindingContext bContext, object model, string modelName, PropertyInfo modelProperty, NameValueCollection formData) +// { +// // find the correct binding context for this property type +// var modelBinder = OliveBinderProvider.SelectBinder(modelProperty.PropertyType); +// object typed = null; +// if (modelBinder != null) +// { +// var metadata = ModelMetadataProviders.Current.GetMetadataForType(null, modelProperty.PropertyType); +// var propertyContext = new ModelBindingContext { ValueProvider = bContext.ValueProvider, ModelMetadata = metadata, ModelName = modelName }; +// typed = modelBinder.BindModel(cContext, propertyContext); +// } +// else +// { +// // if Binder is null we've got a simple value type, throw it to view model services +// try +// { +// object data; + +// if (modelProperty.Defines()) +// data = cContext.HttpContext.Request.Unvalidated.Form[modelName]; +// else +// data = formData[modelName]; + +// typed = ViewModelServices.Convert(data, modelProperty.PropertyType); +// } +// catch +// { +// // No logging is needed +// bContext.ModelState.AddModelError("Invalid Data", new Exception($"The value '{formData[modelName]}' is not valid for {modelProperty.Name}.")); +// } +// } + +// // if we're still null at this point it's going to be a new entity +// if (typed == null && modelProperty.PropertyType.IsA() && modelName.EndsWith(".Item")) +// typed = Activator.CreateInstance(modelProperty.PropertyType); +// modelProperty.SetValue(model, typed); +// } + +// void BindDocumentProperty(ControllerContext cContext, ModelBindingContext bContext, PropertyDescriptor propertyDescriptor) +// { +// var metadata = ModelMetadataProviders.Current.GetMetadataForProperty(null, bContext.ModelType, propertyDescriptor.Name); +// var pContext = new ModelBindingContext { ValueProvider = bContext.ValueProvider, ModelMetadata = metadata, ModelName = propertyDescriptor.Name }; +// var value = new DocumentModelBinder().BindModel(cContext, pContext); +// if (value != null) +// propertyDescriptor.SetValue(bContext.Model, value); +// } + +// void BindViewModelProperty(ControllerContext cContext, ModelBindingContext bContext, PropertyDescriptor propertyDescriptor) +// { +// var metadata = ModelMetadataProviders.Current.GetMetadataForProperty(null, bContext.ModelType, propertyDescriptor.Name); +// var pContext = new ModelBindingContext +// { +// ValueProvider = bContext.ValueProvider, +// ModelMetadata = metadata, //ModelName = propertyDescriptor.Name +// }; +// propertyDescriptor.SetValue(bContext.Model, BindModel(cContext, pContext)); +// } + +// IEnumerable ByFromQuery(ModelBindingContext bindingContext, PropertyDescriptor property, List attributes) +// { +// foreach (var attr in attributes) +// { +// var metadata = bindingContext.PropertyMetadata; +// if (metadata.ContainsKey(property.Name) && !metadata.ContainsKey(attr.Alias)) +// metadata.Add(attr.Alias, metadata[property.Name]); +// yield return new FromQueryPropertyDescriptor(attr.Alias, property); +// } +// } + +// protected override void OnModelUpdated(ControllerContext cContext, ModelBindingContext bContext) +// { +// base.OnModelUpdated(cContext, bContext); +// if (bContext.Model is IViewModel) +// { +// OnPreBoundAttribute.Enqueue(cContext, bContext.Model); +// OnBoundAttribute.Enqueue(cContext, bContext.Model); +// } + +// // Imagine a root ViewModel object which has properties (i.e. nested objects) which are +// // also ViewModel types themselves. +// // The invokation lines below should in fact be called during OnModelUpdated of the root object only +// // This is to ensure that all interim PreBinds happen, before all OnBound ones. +// // The following lines are called of course for all child objects (before calling it on the root object) +// // But they will do nothing (return early) when called for non-Root objects. +// OnPreBoundAttribute.InvokeAllForRoot(bContext, cContext.HttpContext); +// OnBoundAttribute.InvokeAllForRoot(bContext, cContext.HttpContext); +// } + +// protected override bool OnModelUpdating(ControllerContext cContext, ModelBindingContext bContext) +// { +// OnPreBoundAttribute.SetRoot(bContext, cContext.HttpContext); +// OnBoundAttribute.SetRoot(bContext, cContext.HttpContext); +// var result = base.OnModelUpdating(cContext, bContext); +// if (bContext.Model is IViewModel) +// { +// OnPreBindingAttribute.Execute(cContext, bContext.Model); +// } + +// return result; +// } +// } +// } \ No newline at end of file diff --git a/Mvc.RemovalCandidates/Extentions/@NamespaceConflictFix.cs b/Mvc.RemovalCandidates/Extentions/@NamespaceConflictFix.cs new file mode 100644 index 000000000..766b9b40e --- /dev/null +++ b/Mvc.RemovalCandidates/Extentions/@NamespaceConflictFix.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Linq.Expressions; + +namespace ASP +{ + /// + /// Defined in the ASP namespace to fix the namespece conflicts for extension methods with the same name. + /// These methods are preferred by the compiler for all code written in CSHTML files. + /// + [Browsable(false)] + [EditorBrowsable(EditorBrowsableState.Never)] + public static class SystemMvcWebExtensionsFix + { + delegate IHtmlContent SelectControlHelper(HtmlHelper helper, string name, IEnumerable selectList, object htmlAttributes); + + delegate IHtmlContent SelectControlHelper(HtmlHelper helper, Expression> expression, IEnumerable selectList, object htmlAttributes); + + delegate IHtmlContent InputControlHelper(HtmlHelper helper, string name, string value, object htmlAttributes); + + delegate IHtmlContent InputControlHelper(HtmlHelper helper, Expression> expression, object htmlAttributes); + + /// + /// Will replace all line breaks with a BR tag and return the result as a raw html. + /// + public static IHtmlContent ToHtmlLines(this string text) => new HtmlString(Olive.OliveExtensions.ToHtmlLines(text)); + + /// + /// Will join all items with a BR tag and return the result as a raw html. + /// + public static IHtmlContent ToHtmlLines(this IEnumerable items) => new HtmlString(Olive.OliveExtensions.ToHtmlLines(items)); + + /// + /// Will join all items with a BR tag and return the result as a raw html. + /// + public static IHtmlContent ToHtmlLines(this IEnumerable items) => new HtmlString(Olive.OliveExtensions.ToHtmlLines(items)); + + /// + /// Determines whether this text is null or empty. + /// + public static bool IsEmpty(this string text) => string.IsNullOrEmpty(text); + + /// + /// Returns an HTML select element for each property in the object that is represented by the specified expression + /// using the specified list items and HTML attributes. + /// + public static IHtmlContent DropDownListFor(this HtmlHelper htmlHelper, Expression> expression, IEnumerable selectList, object htmlAttributes) + { + return FixForEntityType(htmlHelper, expression, selectList, htmlAttributes, SelectExtensions.DropDownList, + SelectExtensions.DropDownListFor); + } + + public static IHtmlContent HiddenFor(this HtmlHelper htmlHelper, Expression> expression, object htmlAttributes = null) + { + return FixForEntityType(htmlHelper, expression, htmlAttributes, InputExtensions.Hidden, InputExtensions.HiddenFor); + } + + static IHtmlContent FixForEntityType(this HtmlHelper htmlHelper, Expression> expression, object htmlAttributes, InputControlHelper controlMethod, InputControlHelper generic) + { + var fixedCode = FixForEntityType(htmlHelper, expression, htmlAttributes, controlMethod); + + if (fixedCode != null) return fixedCode; + + return generic(htmlHelper, expression, htmlAttributes); + } + + static IHtmlContent FixForEntityType(this HtmlHelper htmlHelper, Expression> expression, IEnumerable selectList, object htmlAttributes, SelectControlHelper controlMethod, SelectControlHelper generic) + { + var fixedCode = FixForEntityType(htmlHelper, expression, selectList, htmlAttributes, controlMethod); + + if (fixedCode != null) return fixedCode; + + return ControlValidationTranslation.Translate(generic(htmlHelper, expression, selectList, htmlAttributes)); + } + + static IHtmlContent FixForEntityType(this HtmlHelper htmlHelper, Expression> expression, IEnumerable selectList, object htmlAttributes, SelectControlHelper controlMethod) + { + var property = expression.GetProperty(); + + if (property == null) return null; + + if (!property.PropertyType.IsA()) return null; + + var value = property.GetValue(htmlHelper.ViewData.Model) as IEntity; + if (value == null) return null; + + htmlHelper.ViewContext.ViewData[property.Name] = value.GetId().ToString(); + + return ControlValidationTranslation.Translate(controlMethod(htmlHelper, property.Name, selectList, htmlAttributes)); + } + + static IHtmlContent FixForEntityType(this HtmlHelper htmlHelper, Expression> expression, object htmlAttributes, InputControlHelper controlMethod) + { + var property = expression.GetProperty(); + + if (property == null) return null; + + string value; + + var type = property.PropertyType; + + if (type.IsA()) + { + var entity = property.GetValue(htmlHelper.ViewData.Model) as IEntity; + if (entity == null) return null; + value = entity.GetId().ToString(); + } + else if (type.IsA() && type.GetGenericArguments().FirstOrDefault()?.IsA() == true) + { + var list = property.GetValue(htmlHelper.ViewData.Model) as IEnumerable; + if (list == null) return null; + value = list.Cast().Select(x => x.GetId()).ToString("|"); + } + else return null; + + return ControlValidationTranslation.Translate(controlMethod(htmlHelper, property.Name, value, htmlAttributes)); + } + } +} \ No newline at end of file diff --git a/Mvc.RemovalCandidates/Extentions/ControlValidationTranslation.cs b/Mvc.RemovalCandidates/Extentions/ControlValidationTranslation.cs new file mode 100644 index 000000000..47fe79026 --- /dev/null +++ b/Mvc.RemovalCandidates/Extentions/ControlValidationTranslation.cs @@ -0,0 +1,61 @@ +using System; +using System.ComponentModel; +using System.Linq.Expressions; + +namespace ASP +{ + [Browsable(false)] + [EditorBrowsable(EditorBrowsableState.Never)] + public static class ControlValidationTranslation + { + static bool TranslateValidators = Config.Get("Translate.Validators", defaultValue: false); + static string[] ValidationTextAttributes = new[] { "data-val-length", "data-val-required", "data-val-email" }; + + internal static IHtmlContent Translate(IHtmlContent source) + { + if (!TranslateValidators) return source; + + var doc = new HtmlAgilityPack.HtmlDocument(); + doc.LoadHtml(source.ToString()); + + foreach (var node in doc.DocumentNode.Descendants()) + foreach (var att in node.Attributes.OrEmpty()) + if (att.Name.IsAnyOf(ValidationTextAttributes)) + att.Value = Translator.Translate(att.Value); + + return new HtmlString(doc.DocumentNode.InnerHtml); + } + + public static IHtmlContent TextBoxFor(this HtmlHelper htmlHelper, + Expression> expression, object htmlAttributes = null) + { + var result = InputExtensions.TextBoxFor(htmlHelper, expression, htmlAttributes); + + return Translate(result); + } + + public static IHtmlContent PasswordFor(this HtmlHelper htmlHelper, + Expression> expression, object htmlAttributes = null) + { + var result = InputExtensions.PasswordFor(htmlHelper, expression, htmlAttributes); + + return Translate(result); + } + + public static IHtmlContent RadioButtonFor(this HtmlHelper htmlHelper, + Expression> expression, object htmlAttributes = null) + { + var result = InputExtensions.RadioButtonFor(htmlHelper, expression, htmlAttributes); + + return Translate(result); + } + + public static IHtmlContent CheckBoxFor(this HtmlHelper htmlHelper, + Expression> expression, object htmlAttributes = null) + { + var result = InputExtensions.CheckBoxFor(htmlHelper, expression, htmlAttributes); + + return Translate(result); + } + } +} \ No newline at end of file diff --git a/Mvc.RemovalCandidates/Extentions/Extensions.HtmlHelpers_Partially.cs b/Mvc.RemovalCandidates/Extentions/Extensions.HtmlHelpers_Partially.cs new file mode 100644 index 000000000..43fa22bb8 --- /dev/null +++ b/Mvc.RemovalCandidates/Extentions/Extensions.HtmlHelpers_Partially.cs @@ -0,0 +1,64 @@ +using System; + +namespace Olive.Mvc +{ + partial class MSharpMvcExtensions + { + // ***************************************************************** + // The following methods remove because in .net core the request + // context does not accessible from the htmlHelper and ... + // ***************************************************************** + public static UrlHelper GetUrlHelper(this HtmlHelper html) => new UrlHelper(html.ViewContext.RequestContext); + + // ***************************************************************** + // The Following method adds overload to existing methods in the old + // .net and in the core version these methods has been removed. + // ***************************************************************** + + /// + /// Renders the specified partial view as an HTML-encoded string. + /// + /// The HTML helper instance that this method extends. + /// The name of the partial view to render. + /// The view model for the partial view. + /// The partial view that is rendered as an HTML-encoded string. + public static HtmlString Partial(this HtmlHelper html, string partialViewName, T model, bool skipAjaxPost) where T : IViewModel + { + var request = HttpContext.Current.Request; + if (skipAjaxPost && request.IsAjaxCall() && request.IsPost()) return HtmlString.Empty; + + if (model == null) + { + model = (html.ViewContext.Controller as Controller).Bind(); + + if (model == null) + throw new Exception("The model object passed to Partial() cannot be null."); + } + + return html.Partial(partialViewName, model); + } + + public static void RenderAction(this HtmlHelper html, string action = "Index") + { + html.RenderAction(action, typeof(TController).Name.TrimEnd("Controller")); + } + + /// + /// Invokes the Index action method of the specified controller and returns the result as an HTML string. + /// An anonymous object containing query string / route values to pass. + /// + public static HtmlString Action(this HtmlHelper html, object queryParameters) + { + return Action(html, "Index", queryParameters); + } + + /// + /// Invokes the specified child action method of the specified controller and returns the result as an HTML string. + /// An anonymous object containing query string / route values to pass. + /// + public static HtmlString Action(this HtmlHelper html, string action = "Index", object queryParameters = null) + { + return html.Action(action, typeof(TController).Name.TrimEnd("Controller"), queryParameters); + } + } +} \ No newline at end of file diff --git a/Mvc.RemovalCandidates/Mvc.RemovalCandidates.csproj b/Mvc.RemovalCandidates/Mvc.RemovalCandidates.csproj new file mode 100644 index 000000000..27113fed5 --- /dev/null +++ b/Mvc.RemovalCandidates/Mvc.RemovalCandidates.csproj @@ -0,0 +1,12 @@ + + + + netcoreapp2.0 + + + + ..\@Assemblies\ + ..\@Assemblies\netcoreapp2.0\Mvc.RemovalCandidates.xml + + + diff --git a/Mvc.RemovalCandidates/Utilities/HttpApplication.cs b/Mvc.RemovalCandidates/Utilities/HttpApplication.cs new file mode 100644 index 000000000..d77a6f5b0 --- /dev/null +++ b/Mvc.RemovalCandidates/Utilities/HttpApplication.cs @@ -0,0 +1,67 @@ +//namespace MSharp.Framework.Mvc +//{ +// using System; +// using System.Security.Claims; +// using System.Security.Principal; +// using System.Web; +// using System.Web.Mvc; +// using System.Web.Routing; +// using MSharp.Framework.UI; + +// /// +// /// Provides a base http application class for MSharp MVC applications. +// /// +// public abstract class HttpApplication : BaseHttpApplication +// { +// /// +// /// Sets up the standard providers and configures the application. +// /// +// protected virtual void Application_Start() +// { +// GlobalFilters.Filters.Add(new HandleErrorAttribute()); +// GlobalFilters.Filters.Add(new JsonHandlerAttribute()); + +// MvcHandler.DisableMvcResponseHeader = true; + +// RegisterRoutes(); + +// RegisterBinders(); + +// Document.SecureVirtualRoot = "/file/download?"; +// } + +// /// +// /// Registers the standard Routes. +// /// +// protected virtual void RegisterRoutes() +// { +// RouteTable.Routes.MapMvcAttributeRoutes(); +// RouteTable.Routes.MapRoute("Default", "{controller}/{action}"); +// RegisterNotFoundRoute(); + + +// } + + +// /// +// /// Retrieves the actual user object from the principal info. +// /// +// protected IPrincipal RetrieveActualUser(IPrincipal principal) +// where TUser : IEntity, IPrincipal +// where TAnonymousUser : IEntity, IPrincipal, new() +// { +// if (principal == null) return new TAnonymousUser(); +// if (!principal.Identity.IsAuthenticated) return new TAnonymousUser(); + +// var asIdentity = principal.Identity as ClaimsIdentity; +// if (asIdentity != null) +// { +// var result = Database.GetOrDefault(asIdentity.Name) as IPrincipal; +// return result ?? new TAnonymousUser(); +// } + +// throw new IdentityNotMappedException("I do not recognise " + principal.Identity.GetType().FullName + " identity for authentication."); +// } + +// } +//} \ No newline at end of file diff --git a/Olive.Entities.Data.MySql/MySqlDataProvider.Criteria.cs b/Olive.Entities.Data.MySql/MySqlDataProvider.Criteria.cs new file mode 100644 index 000000000..097c62abb --- /dev/null +++ b/Olive.Entities.Data.MySql/MySqlDataProvider.Criteria.cs @@ -0,0 +1,162 @@ +namespace Olive.Entities.Data +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + + class MySqlCriterionGenerator + { + DatabaseQuery Query; + Type EntityType; + + public MySqlCriterionGenerator(DatabaseQuery query) + { + Query = query; + EntityType = query.EntityType; + } + + public string Generate(ICriterion criterion) + { + if (criterion == null) return "(1 = 1)"; + if (criterion is BinaryCriterion binary) return Generate(binary); + else if (criterion is DirectDatabaseCriterion direct) return Generate(direct); + else if (criterion.PropertyName.Contains(".")) return ToSubQuerySql(criterion); + else return ToSqlOn(criterion, EntityType); + } + + string Generate(BinaryCriterion criterion) + { + return $"({Generate(criterion.Left)} {criterion.Operator} {Generate(criterion.Right)})"; + } + + string Generate(DirectDatabaseCriterion criterion) + { + // Add the params: + if (criterion.Parameters != null) + foreach (var x in criterion.Parameters) Query.Parameters.Add(x.Key, x.Value); + + if (criterion.PropertyName.IsEmpty() || criterion.PropertyName == "N/A") + return criterion.SqlCriteria; + + return criterion.SqlCriteria.Replace($"{{{{{criterion.PropertyName}}}}}", + Query.Column(criterion.PropertyName)); + } + + string ToNestedSubQuerySql(ICriterion criterion, string[] parts) + { + var subQueries = new List(); + var type = EntityType; + + subQueries.Add(Query.Provider.MapSubquery(parts[0] + ".*")); + + for (var i = 0; i < parts.Length - 2; i++) + { + type = type.GetProperty(parts[i]).PropertyType; + + var dataProvider = Database.Instance.GetProvider(type); + var mapping = dataProvider.MapSubquery(parts[i + 1] + ".*"); + if (mapping.Lacks(" WHERE ")) continue; + + var subquerySplitted = mapping.Split(" WHERE ", StringSplitOptions.RemoveEmptyEntries); + var whereClauseSplitted = subquerySplitted[1].Split('='); + var tablePrefix = whereClauseSplitted.Last().Split('.')[0]; + + var aliasName = tablePrefix.Trim(']').Trim(']').Split('_').ExceptLast().ToString("_") + "_"; + + var recordIdentifier = whereClauseSplitted[1].Replace(tablePrefix, aliasName); + + mapping = subquerySplitted[0] + " WHERE " + whereClauseSplitted[0] + " = " + recordIdentifier; + + subQueries.Add(mapping); + } + + var subCriterion = new Criterion(parts[parts.Length - 1], criterion.FilterFunction, criterion.Value); + type = type.GetProperty(parts[parts.Length - 2]).PropertyType; + var criteria = new StringBuilder(); + var parenthesis = ""; + for (var i = 0; i < subQueries.Count; i++) + { + criteria.AppendLine($" EXISTS ({subQueries[i].WithSuffix(" AND ")}"); + parenthesis += ")"; + } + + criteria.Append(ToSqlOn(subCriterion, type)); + criteria.Append(parenthesis); + + return criteria.ToString(); + } + + string ToSubQuerySql(ICriterion criterion) + { + var parts = criterion.PropertyName.Split('.'); + + if (parts.Count() > 2) return ToNestedSubQuerySql(criterion, parts); + + var type = EntityType.GetProperty(parts[0])?.PropertyType; + if (type == null) throw new Exception($"{EntityType.Name} does not have a public property named {parts[0]}."); + + var subquery = Query.Provider.MapSubquery(parts[0] + ".*"); + var subCriterion = new Criterion(parts[1], criterion.FilterFunction, criterion.Value); + return "EXISTS ({0}{1})".FormatWith(subquery, ToSqlOn(subCriterion, type).WithPrefix(" AND ")); + } + + string ToSqlOn(ICriterion criterion, Type type) + { + string column; + + var key = criterion.PropertyName; + + if (key.EndsWith("Id") && key.Length > 2) + { + var association = type.GetProperty(key.TrimEnd("Id")); + + if (association != null && !association.Defines() && + association.PropertyType.IsA()) + key = key.TrimEnd("Id"); + } + + column = Query.Column(key); + + var value = criterion.Value; + var function = criterion.FilterFunction; + + if (value == null) + return "{0} IS {1} NULL".FormatWith(column, "NOT".OnlyWhen(function != FilterFunction.Is)); + + var valueData = value; + if (function == FilterFunction.Contains || function == FilterFunction.NotContains) valueData = "%{0}%".FormatWith(value); + else if (function == FilterFunction.BeginsWith) valueData = "{0}%".FormatWith(value); + else if (function == FilterFunction.EndsWith) valueData = "%{0}".FormatWith(value); + else if (function == FilterFunction.In) + { + if ((value as string) == "()") return "1 = 0 /*" + column + " IN ([empty])*/"; + else return column + " " + function.GetDatabaseOperator() + " " + value; + } + + var parameterName = GetUniqueParameterName(column); + + Query.Parameters.Add(parameterName, valueData); + + var critera = $"{column} {function.GetDatabaseOperator()} @{parameterName}"; + var includeNulls = function == FilterFunction.IsNot; + return includeNulls ? $"( {critera} OR {column} {FilterFunction.Null.GetDatabaseOperator()} )" : critera; + } + + string GetUniqueParameterName(string column) + { + var result = column.Remove("[").Remove("]").Replace(".", "_"); + + if (Query.Parameters.ContainsKey(result)) + { + for (var i = 2; ; i++) + { + var name = result + "_" + i; + if (Query.Parameters.LacksKey(name)) return name; + } + } + + return result; + } + } +} diff --git a/Olive.Entities.Data.MySql/MySqlDataProvider.cs b/Olive.Entities.Data.MySql/MySqlDataProvider.cs new file mode 100644 index 000000000..c24d56971 --- /dev/null +++ b/Olive.Entities.Data.MySql/MySqlDataProvider.cs @@ -0,0 +1,155 @@ +namespace Olive.Entities.Data +{ + using MySql.Data.MySqlClient; + using System; + using System.Collections.Generic; + using System.Data; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + + public abstract partial class MySqlDataProvider : DataProvider + { + public override IDataParameter GenerateParameter(KeyValuePair data) + { + var value = data.Value; + + var result = new MySqlParameter { Value = value, ParameterName = data.Key.Remove(" ") }; + + if (value is DateTime) + { + result.DbType = DbType.DateTime2; + result.MySqlDbType = MySqlDbType.DateTime; + } + + return result; + } + + public abstract string GetFields(); + + public abstract string GetTables(); + + public abstract IEntity Parse(IDataReader reader); + + public override async Task Get(object id) + { + var command = $"SELECT {GetFields()} FROM {GetTables()} WHERE {MapColumn("ID")} = @ID"; + + using (var reader = await ExecuteReader(command, CommandType.Text, CreateParameter("ID", id))) + { + var result = new List(); + + if (reader.Read()) return Parse(reader); + else throw new DataException($"There is no record with the the ID of '{id}'."); + } + } + + protected async Task ExecuteGetListReader(IDatabaseQuery query) + { + var command = GenerateSelectCommand(query); + return await ExecuteReader(command, CommandType.Text, GenerateParameters(query.Parameters)); + } + + public override async Task> GetList(IDatabaseQuery query) + { + using (var reader = await ExecuteGetListReader(query)) + { + var result = new List(); + while (reader.Read()) result.Add(Parse(reader)); + return result; + } + } + + public override async Task Count(IDatabaseQuery query) + { + var command = GenerateCountCommand(query); + return (int)await ExecuteScalar(command, CommandType.Text, GenerateParameters(query.Parameters)); + } + + public override Task Aggregate(IDatabaseQuery query, AggregateFunction function, string propertyName) + { + var command = GenerateAggregateQuery(query, function, propertyName); + return ExecuteScalar(command, CommandType.Text, GenerateParameters(query.Parameters)); + } + + public string GenerateAggregateQuery(IDatabaseQuery query, AggregateFunction function, string propertyName) + { + var sqlFunction = function.ToString(); + + var columnValueExpression = MapColumn(propertyName); + + if (function == AggregateFunction.Average) + { + sqlFunction = "AVG"; + + var propertyType = query.EntityType.GetProperty(propertyName).PropertyType; + + if (propertyType == typeof(int) || propertyType == typeof(int?)) + columnValueExpression = $"CAST({columnValueExpression} AS decimal)"; + } + + return $"SELECT {sqlFunction}({columnValueExpression}) FROM {GetTables()}" + + GenerateWhere((DatabaseQuery)query); + } + + public string GenerateSelectCommand(IDatabaseQuery iquery) + { + var query = (DatabaseQuery)iquery; + + if (query.PageSize.HasValue && query.OrderByParts.None()) + throw new ArgumentException("PageSize cannot be used without OrderBy."); + + var r = new StringBuilder("SELECT"); + + //r.Append(query.TakeTop.ToStringOrEmpty().WithPrefix(" TOP ")); + r.AppendLine($" {GetFields()} FROM {GetTables()}"); + r.AppendLine(GenerateWhere(query)); + r.AppendLine(GenerateSort(query).WithPrefix(" ORDER BY ")); + r.AppendLine(query.TakeTop.ToStringOrEmpty().WithPrefix(" LIMIT ")); + + return r.ToString(); + } + + public string GenerateCountCommand(IDatabaseQuery iquery) + { + var query = (DatabaseQuery)iquery; + + if (query.PageSize.HasValue) + throw new ArgumentException("PageSize cannot be used for Count()."); + + if (query.TakeTop.HasValue) + throw new ArgumentException("TakeTop cannot be used for Count()."); + + return $"SELECT Count(*) FROM {GetTables()} {GenerateWhere(query)}"; + } + + string GenerateWhere(DatabaseQuery query) + { + var r = new StringBuilder(); + + if (SoftDeleteAttribute.RequiresSoftdeleteQuery(query.EntityType)) + query.Criteria.Add(new Criterion("IsMarkedSoftDeleted", false)); + + r.Append($" WHERE { query.Column("ID")} IS NOT NULL"); + + var whereGenerator = new MySqlCriterionGenerator(query); + foreach (var c in query.Criteria) + r.Append(whereGenerator.Generate(c).WithPrefix(" AND ")); + + return r.ToString(); + } + + string GenerateSort(DatabaseQuery query) + { + var parts = new List(); + + parts.AddRange(query.OrderByParts.Select(p => query.Column(p.Property) + " DESC".OnlyWhen(p.Descending))); + + var offset = string.Empty; + if (query.PageSize > 0) + offset = $" OFFSET {query.PageStartIndex} ROWS FETCH NEXT {query.PageSize} ROWS ONLY"; + + return parts.ToString(", ") + offset; + } + } +} diff --git a/Olive.Entities.Data.MySql/Olive.Entities.Data.MySql.csproj b/Olive.Entities.Data.MySql/Olive.Entities.Data.MySql.csproj new file mode 100644 index 000000000..a61fe8d39 --- /dev/null +++ b/Olive.Entities.Data.MySql/Olive.Entities.Data.MySql.csproj @@ -0,0 +1,24 @@ + + + + netcoreapp2.0 + + + + ..\@Assemblies\ + ..\@Assemblies\netcoreapp2.0\Olive.Entities.Data.MySql.xml + + + + + + + + + + + + + + + diff --git a/Olive.Entities.Data.MySql/Package.nuspec b/Olive.Entities.Data.MySql/Package.nuspec new file mode 100644 index 000000000..939ebaaeb --- /dev/null +++ b/Olive.Entities.Data.MySql/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.Entities.Data.MySql + 1.0.3 + Olive Entities Data MySql + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Olive.Entities.Data.SqlServer/Olive.Entities.Data.SqlServer.csproj b/Olive.Entities.Data.SqlServer/Olive.Entities.Data.SqlServer.csproj new file mode 100644 index 000000000..f6074615d --- /dev/null +++ b/Olive.Entities.Data.SqlServer/Olive.Entities.Data.SqlServer.csproj @@ -0,0 +1,23 @@ + + + + netcoreapp2.0 + Olive.Entities.Data + + + + NU1605 + 1701;1702;1705;1591;1573 + ..\@Assemblies + ..\@Assemblies\netcoreapp2.0\Olive.Entities.Data.SqlServer.xml + + + + + + + + + + + diff --git a/Olive.Entities.Data.SqlServer/Package.nuspec b/Olive.Entities.Data.SqlServer/Package.nuspec new file mode 100644 index 000000000..d9e32078b --- /dev/null +++ b/Olive.Entities.Data.SqlServer/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.Entities.Data.SqlServer + 1.0.3 + Olive Entities Data SqlServer + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Olive.Entities.Data.SqlServer/SqlDataProvider.Criteria.cs b/Olive.Entities.Data.SqlServer/SqlDataProvider.Criteria.cs new file mode 100644 index 000000000..6c25682b6 --- /dev/null +++ b/Olive.Entities.Data.SqlServer/SqlDataProvider.Criteria.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Olive.Entities.Data +{ + class SqlCriterionGenerator + { + DatabaseQuery Query; + Type EntityType; + + public SqlCriterionGenerator(DatabaseQuery query) + { + Query = query; + EntityType = query.EntityType; + } + + public string Generate(ICriterion criterion) + { + if (criterion == null) return "(1 = 1)"; + if (criterion is BinaryCriterion binary) return Generate(binary); + else if (criterion is DirectDatabaseCriterion direct) return Generate(direct); + else if (criterion.PropertyName.Contains(".")) return ToSubQuerySql(criterion); + else return ToSqlOn(criterion, EntityType); + } + + string Generate(BinaryCriterion criterion) + { + return $"({Generate(criterion.Left)} {criterion.Operator} {Generate(criterion.Right)})"; + } + + string Generate(DirectDatabaseCriterion criterion) + { + // Add the params: + if (criterion.Parameters != null) + foreach (var x in criterion.Parameters) Query.Parameters[x.Key] = x.Value; + + if (criterion.PropertyName.IsEmpty() || criterion.PropertyName == "N/A") + return criterion.SqlCriteria; + + return criterion.SqlCriteria.Replace($"{{{{{criterion.PropertyName}}}}}", + Query.Column(criterion.PropertyName)); + } + + string ToNestedSubQuerySql(ICriterion criterion, string[] parts) + { + var subQueries = new List(); + var type = EntityType; + + subQueries.Add(Query.Provider.MapSubquery(parts[0] + ".*")); + + for (var i = 0; i < parts.Length - 2; i++) + { + type = type.GetProperty(parts[i]).PropertyType; + + var dataProvider = Database.Instance.GetProvider(type); + var mapping = dataProvider.MapSubquery(parts[i + 1] + ".*"); + if (mapping.Lacks(" WHERE ")) continue; + + var subquerySplitted = mapping.Split(" WHERE ", StringSplitOptions.RemoveEmptyEntries); + var whereClauseSplitted = subquerySplitted[1].Split('='); + var tablePrefix = whereClauseSplitted.Last().Split('.')[0]; + + var aliasName = tablePrefix.Trim(']').Trim(']').Split('_').ExceptLast().ToString("_") + "_"; + + var recordIdentifier = whereClauseSplitted[1].Replace(tablePrefix, aliasName); + + mapping = subquerySplitted[0] + " WHERE " + whereClauseSplitted[0] + " = " + recordIdentifier; + + subQueries.Add(mapping); + } + + var subCriterion = new Criterion(parts[parts.Length - 1], criterion.FilterFunction, criterion.Value); + type = type.GetProperty(parts[parts.Length - 2]).PropertyType; + var criteria = new StringBuilder(); + var parenthesis = ""; + for (var i = 0; i < subQueries.Count; i++) + { + criteria.AppendLine($" EXISTS ({subQueries[i].WithSuffix(" AND ")}"); + parenthesis += ")"; + } + + criteria.Append(ToSqlOn(subCriterion, type)); + criteria.Append(parenthesis); + + return criteria.ToString(); + } + + string ToSubQuerySql(ICriterion criterion) + { + var parts = criterion.PropertyName.Split('.'); + + if (parts.Count() > 2) return ToNestedSubQuerySql(criterion, parts); + + var type = EntityType.GetProperty(parts[0])?.PropertyType; + if (type == null) throw new Exception($"{EntityType.Name} does not have a public property named {parts[0]}."); + + var subquery = Query.Provider.MapSubquery(parts[0] + ".*"); + var subCriterion = new Criterion(parts[1], criterion.FilterFunction, criterion.Value); + return "EXISTS ({0}{1})".FormatWith(subquery, ToSqlOn(subCriterion, type).WithPrefix(" AND ")); + } + + string ToSqlOn(ICriterion criterion, Type type) + { + string column; + + var key = criterion.PropertyName; + + if (key.EndsWith("Id") && key.Length > 2) + { + var association = type.GetProperty(key.TrimEnd("Id")); + + if (association != null && !association.Defines() && + association.PropertyType.IsA()) + key = key.TrimEnd("Id"); + } + + column = Query.Column(key); + + var value = criterion.Value; + var function = criterion.FilterFunction; + + if (value == null) + return "{0} IS {1} NULL".FormatWith(column, "NOT".OnlyWhen(function != FilterFunction.Is)); + + var valueData = value; + if (function == FilterFunction.Contains || function == FilterFunction.NotContains) valueData = "%{0}%".FormatWith(value); + else if (function == FilterFunction.BeginsWith) valueData = "{0}%".FormatWith(value); + else if (function == FilterFunction.EndsWith) valueData = "%{0}".FormatWith(value); + else if (function == FilterFunction.In) + { + if ((value as string) == "()") return "1 = 0 /*" + column + " IN ([empty])*/"; + else return column + " " + function.GetDatabaseOperator() + " " + value; + } + + var parameterName = GetUniqueParameterName(column); + + Query.Parameters.Add(parameterName, valueData); + + var critera = $"{column} {function.GetDatabaseOperator()} @{parameterName}"; + var includeNulls = function == FilterFunction.IsNot; + return includeNulls ? $"( {critera} OR {column} {FilterFunction.Null.GetDatabaseOperator()} )" : critera; + } + + string GetUniqueParameterName(string column) + { + var result = column.Remove("[").Remove("]").Replace(".", "_"); + + if (Query.Parameters.ContainsKey(result)) + { + for (var i = 2; ; i++) + { + var name = result + "_" + i; + if (Query.Parameters.LacksKey(name)) return name; + } + } + + return result; + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data.SqlServer/SqlDataProvider.cs b/Olive.Entities.Data.SqlServer/SqlDataProvider.cs new file mode 100644 index 000000000..306f8359c --- /dev/null +++ b/Olive.Entities.Data.SqlServer/SqlDataProvider.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SqlClient; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace Olive.Entities.Data +{ + /// + /// Provides a DataProvider for accessing data from the database using ADO.NET based on the SqlClient provider. + /// + public abstract partial class SqlDataProvider : DataProvider + { + public override IDataParameter GenerateParameter(KeyValuePair data) + { + var value = data.Value; + + var result = new SqlParameter { Value = value, ParameterName = data.Key.Remove(" ") }; + + if (value is DateTime) + { + result.DbType = DbType.DateTime2; + result.SqlDbType = SqlDbType.DateTime2; + } + + return result; + } + + public abstract string GetFields(); + + public abstract string GetTables(); + + public abstract IEntity Parse(IDataReader reader); + + public override async Task Get(object id) + { + var command = $"SELECT {GetFields()} FROM {GetTables()} WHERE {MapColumn("ID")} = @ID"; + + using (var reader = await ExecuteReader(command, CommandType.Text, CreateParameter("ID", id))) + { + var result = new List(); + + if (reader.Read()) return Parse(reader); + else throw new DataException($"There is no record with the the ID of '{id}'."); + } + } + + protected async Task ExecuteGetListReader(IDatabaseQuery query) + { + var command = GenerateSelectCommand(query); + return await ExecuteReader(command, CommandType.Text, GenerateParameters(query.Parameters)); + } + + public override async Task> GetList(IDatabaseQuery query) + { + using (var reader = await ExecuteGetListReader(query)) + { + var result = new List(); + while (reader.Read()) result.Add(Parse(reader)); + return result; + } + } + + public override DirectDatabaseCriterion GetAssociationInclusionCriteria(IDatabaseQuery query, + PropertyInfo association) + { + var whereClause = GenerateAssociationLoadingCriteria(query, association); + return new DirectDatabaseCriterion(whereClause) { Parameters = query.Parameters }; + } + + public override async Task Count(IDatabaseQuery query) + { + var command = GenerateCountCommand(query); + return (int)await ExecuteScalar(command, CommandType.Text, GenerateParameters(query.Parameters)); + } + + public override Task Aggregate(IDatabaseQuery query, AggregateFunction function, string propertyName) + { + var command = GenerateAggregateQuery(query, function, propertyName); + return ExecuteScalar(command, CommandType.Text, GenerateParameters(query.Parameters)); + } + + public string GenerateAggregateQuery(IDatabaseQuery query, AggregateFunction function, string propertyName) + { + var sqlFunction = function.ToString(); + + var columnValueExpression = MapColumn(propertyName); + + if (function == AggregateFunction.Average) + { + sqlFunction = "AVG"; + + var propertyType = query.EntityType.GetProperty(propertyName).PropertyType; + + if (propertyType == typeof(int) || propertyType == typeof(int?)) + columnValueExpression = $"CAST({columnValueExpression} AS decimal)"; + } + + return $"SELECT {sqlFunction}({columnValueExpression}) FROM {GetTables()}" + + GenerateWhere((DatabaseQuery)query); + } + + string GenerateAssociationLoadingCriteria(IDatabaseQuery iquery, PropertyInfo association) + { + var query = (DatabaseQuery)iquery; + + if (query.PageSize.HasValue && query.OrderByParts.None()) + throw new ArgumentException("PageSize cannot be used without OrderBy."); + + var r = new StringBuilder(); + + r.Append($"ID IN ("); + r.Append("SELECT "); + r.Append(query.TakeTop.ToStringOrEmpty().WithPrefix(" TOP ")); + r.AppendLine($" {association.Name} FROM {GetTables()}"); + r.AppendLine(GenerateWhere(query)); + r.AppendLine((query.PageSize.HasValue ? GenerateSort(query) : string.Empty).WithPrefix(" ORDER BY ")); + r.Append(")"); + + return r.ToString(); + } + + public string GenerateSelectCommand(IDatabaseQuery iquery) + { + var query = (DatabaseQuery)iquery; + + if (query.PageSize.HasValue && query.OrderByParts.None()) + throw new ArgumentException("PageSize cannot be used without OrderBy."); + + var r = new StringBuilder("SELECT"); + + r.Append(query.TakeTop.ToStringOrEmpty().WithPrefix(" TOP ")); + r.AppendLine($" {GetFields()} FROM {GetTables()}"); + r.AppendLine(GenerateWhere(query)); + r.AppendLine(GenerateSort(query).WithPrefix(" ORDER BY ")); + + return r.ToString(); + } + + public string GenerateCountCommand(IDatabaseQuery iquery) + { + var query = (DatabaseQuery)iquery; + + if (query.PageSize.HasValue) + throw new ArgumentException("PageSize cannot be used for Count()."); + + if (query.TakeTop.HasValue) + throw new ArgumentException("TakeTop cannot be used for Count()."); + + return $"SELECT Count(*) FROM {GetTables()} {GenerateWhere(query)}"; + } + + string GenerateWhere(DatabaseQuery query) + { + var r = new StringBuilder(); + + if (SoftDeleteAttribute.RequiresSoftdeleteQuery(query.EntityType)) + query.Criteria.Add(new Criterion("IsMarkedSoftDeleted", false)); + + r.Append($" WHERE { query.Column("ID")} IS NOT NULL"); + + var whereGenerator = new SqlCriterionGenerator(query); + foreach (var c in query.Criteria) + r.Append(whereGenerator.Generate(c).WithPrefix(" AND ")); + + return r.ToString(); + } + + string GenerateSort(DatabaseQuery query) + { + var parts = new List(); + + parts.AddRange(query.OrderByParts.Select(p => query.Column(p.Property) + " DESC".OnlyWhen(p.Descending))); + + var offset = string.Empty; + if (query.PageSize > 0) + offset = $" OFFSET {query.PageStartIndex} ROWS FETCH NEXT {query.PageSize} ROWS ONLY"; + + return parts.ToString(", ") + offset; + } + } +} diff --git a/Olive.Entities.Data.SqlServer/SqlServerManager.cs b/Olive.Entities.Data.SqlServer/SqlServerManager.cs new file mode 100644 index 000000000..563f262fd --- /dev/null +++ b/Olive.Entities.Data.SqlServer/SqlServerManager.cs @@ -0,0 +1,113 @@ +using System; +using System.Data; +using System.Data.SqlClient; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Olive.Entities.Data +{ + public class SqlServerManager + { + readonly string ConnectionString; + + public SqlServerManager() => ConnectionString = Config.GetConnectionString("AppDatabase"); + + public SqlServerManager(string connectionString) => ConnectionString = connectionString; + + /// + /// Executes a specified SQL command. + /// + public async Task ExecuteSql(string sql) + { + var lines = new Regex(@"^\s*GO\s*$", RegexOptions.Multiline).Split(sql); + + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.OpenAsync(); + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandType = CommandType.Text; + + foreach (var line in lines.Trim()) + { + cmd.CommandText = line; + + try { await cmd.ExecuteNonQueryAsync(); } + catch (Exception ex) { throw EnrichError(ex, line); } + } + } + } + } + + Exception EnrichError(Exception ex, string command) => + throw new Exception($"Could not execute SQL command: \r\n-----------------------\r\n{command.Trim()}\r\n-----------------------\r\n Because:\r\n\r\n{ex.Message}"); + + public async Task DetachDatabase(string databaseName) + { + string script = @" +ALTER DATABASE [{0}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; +ALTER DATABASE [{0}] SET MULTI_USER; +exec sp_detach_db '{0}'".FormatWith(databaseName); + + try + { + await ExecuteSql(script); + } + catch (Exception ex) + { + throw new Exception( + $"Could not detach database '{databaseName}' becuase '{ex.Message}'", ex); + } + } + + public async Task DeleteDatabase(string databaseName) + { + string script = @" +IF EXISTS (SELECT name FROM master.dbo.sysdatabases WHERE name = N'{0}') +BEGIN + ALTER DATABASE [{0}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + ALTER DATABASE [{0}] SET MULTI_USER; + DROP DATABASE [{0}]; +END".FormatWith(databaseName); + + try + { + await ExecuteSql(script); + } + catch (Exception ex) + { + throw new Exception("Could not drop database '" + databaseName + "'.", ex); + } + } + + public SqlServerManager CloneFor(string databaseName) + { + var builder = new SqlConnectionStringBuilder(ConnectionString) + { + InitialCatalog = databaseName + }; + + return new SqlServerManager(builder.ToString()); + } + + public async Task DatabaseExists(string databaseName) + { + var script = $"SELECT count(name) FROM master.dbo.sysdatabases WHERE name = N'{databaseName}'"; + + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.OpenAsync(); + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandType = CommandType.Text; + cmd.CommandText = script; + + try { return (int)await cmd.ExecuteScalarAsync() > 0; } + catch (Exception ex) { throw EnrichError(ex, script); } + } + } + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/ConcurrencyDataException.cs b/Olive.Entities.Data/API/ConcurrencyDataException.cs new file mode 100644 index 000000000..b7ec059a1 --- /dev/null +++ b/Olive.Entities.Data/API/ConcurrencyDataException.cs @@ -0,0 +1,9 @@ +namespace Olive.Entities.Data +{ + public class ConcurrencyException : ValidationException + { + public ConcurrencyException() { } + + public ConcurrencyException(string message) : base(message) { } + } +} diff --git a/Olive.Entities.Data/API/Database/Database.Count.cs b/Olive.Entities.Data/API/Database/Database.Count.cs new file mode 100644 index 000000000..b56870290 --- /dev/null +++ b/Olive.Entities.Data/API/Database/Database.Count.cs @@ -0,0 +1,23 @@ +namespace Olive.Entities.Data +{ + using System; + using System.Linq.Expressions; + using System.Threading.Tasks; + + public partial class Database + { + /// + /// Gets a list of entities of the given type from the database. + /// + public Task Count() where T : IEntity +=> Of().Count(); + + /// + /// Gets a list of entities of the given type from the database. + /// + public Task Count(Expression> criteria) where T : IEntity + { + return Of().Where(criteria).Count(); + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Database.Delete.cs b/Olive.Entities.Data/API/Database/Database.Delete.cs new file mode 100644 index 000000000..273f1fcd3 --- /dev/null +++ b/Olive.Entities.Data/API/Database/Database.Delete.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using System.Transactions; + +namespace Olive.Entities.Data +{ + partial class Database + { + /// + /// Deletes the specified record from the data repository. + /// + public async Task Delete(IEntity instance) => await Delete(instance, DeleteBehaviour.Default); + + async Task DoDelete(Entity entity, DeleteBehaviour behaviour) + { + // Raise deleting event + if (!IsSet(behaviour, DeleteBehaviour.BypassDeleting)) + { + var deletingArgs = new System.ComponentModel.CancelEventArgs(); + await EntityManager.RaiseOnDeleting(entity, deletingArgs); + + if (deletingArgs.Cancel) + { + Cache.Current.Remove(entity); + return; + } + } + + if (SoftDeleteAttribute.IsEnabled(entity.GetType()) && !SoftDeleteAttribute.Context.ShouldByPassSoftDelete()) + { + // Soft delete: + EntityManager.MarkSoftDeleted(entity); + await GetProvider(entity).Save(entity); + } + else + { + await GetProvider(entity).Delete(entity); + } + } + + /// + /// Deletes the specified record from the data repository. + /// + public async Task Delete(IEntity instance, DeleteBehaviour behaviour) + { + if (instance == null) + throw new ArgumentNullException(nameof(instance)); + + var entity = instance as Entity; + + if (entity == null) + throw new ArgumentException($"The type of the specified object to delete does not inherit from {typeof(Entity).FullName} class."); + + await EnlistOrCreateTransaction(async () => await DoDelete(entity, behaviour)); + + Cache.Current.Remove(entity); + if (Transaction.Current != null) + Transaction.Current.TransactionCompleted += (s, e) => { Cache.Current.Remove(entity); }; + + if (DbTransactionScope.Root != null) + DbTransactionScope.Root.OnTransactionCompleted(() => Cache.Current.Remove(entity)); + + if (!IsSet(behaviour, DeleteBehaviour.BypassLogging)) + if (!(entity is IApplicationEvent) && Config.Get("Log.Record:Application:Events", defaultValue: true)) + await ApplicationEventManager.RecordDelete(entity); + + await OnUpdated(entity); + + if (!IsSet(behaviour, DeleteBehaviour.BypassDeleted)) + await EntityManager.RaiseOnDeleted(entity); + } + + /// + /// Deletes the specified instances from the data repository. + /// The operation will be done in a transaction. + /// + public async Task Delete(IEnumerable instances) where T : IEntity + { + if (instances == null) + throw new ArgumentNullException(nameof(instances)); + + if (instances.None()) return; + + await EnlistOrCreateTransaction(async () => + { + foreach (T obj in instances.ToArray()) await Delete(obj); + }); + } + + /// + /// Deletes all objects of the specified type. + /// + /// + public async Task DeleteAll() where T : IEntity => await Delete(await GetList()); + + /// + /// Deletes all objects of the specified type matching the given criteria. + /// + public async Task DeleteAll(Expression> criteria) where T : IEntity => await Delete(await GetList(criteria)); + + /// + /// Updates all records in the database with the specified change. + /// + public async Task UpdateAll(Action change) where T : IEntity + { + var records = await GetList(); + await Update(records, change); + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Database.Find.cs b/Olive.Entities.Data/API/Database/Database.Find.cs new file mode 100644 index 000000000..5e44d45cb --- /dev/null +++ b/Olive.Entities.Data/API/Database/Database.Find.cs @@ -0,0 +1,18 @@ +namespace Olive.Entities.Data +{ + using System; + using System.Linq.Expressions; + using System.Threading.Tasks; + + partial class Database + { + /// + /// Finds an object with the specified type matching the specified criteria. + /// If not found, it returns null. + /// + public Task FirstOrDefault(Expression> criteria) where T : IEntity + { + return Of().Where(criteria).FirstOrDefault(); + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Database.Get.cs b/Olive.Entities.Data/API/Database/Database.Get.cs new file mode 100644 index 000000000..8f78e439b --- /dev/null +++ b/Olive.Entities.Data/API/Database/Database.Get.cs @@ -0,0 +1,160 @@ +using System; +using System.Threading.Tasks; + +namespace Olive.Entities.Data +{ + partial class Database + { + internal async Task GetConcrete(object entityID, Type concreteType) + { + var result = Cache.Current.Get(concreteType, entityID.ToString()); + if (result != null) return result; + + var timestamp = Cache.GetQueryTimestamp(); + + result = await FromDatabase(entityID, concreteType); + + // Don't cache the result if it is fetched in a transaction. + if (result != null) TryCache(result, timestamp); + + return result; + } + + internal void TryCache(IEntity item, DateTime? queryTime) + { + if (AnyOpenTransaction()) return; + if (queryTime != null && Cache.Current.IsUpdatedSince(item, queryTime.Value)) return; + Cache.Current.Add(item); + } + + async Task FromDatabase(object entityID, Type concreteType) + { + var result = await GetProvider(concreteType).Get(entityID); + + if (result != null) await EntityManager.RaiseOnLoaded(result as Entity); + + return result; + } + + /// + /// Gets an Entity of the given type with the given Id from the database. + /// If it can't find the object, an exception will be thrown. + /// + /// The primary key value of the object to load in string format. + public async Task Get(string entityId) where T : IEntity + { + if (entityId.IsEmpty()) return default(T); + + return (T)await Get(entityId, typeof(T)); + } + + /// + /// Get an entity with the given type and ID from the database. + /// If it can't find the object, an exception will be thrown. + /// + /// The type of the object to get + /// The primary key value of the object to load. + public async Task Get(Guid id) where T : IEntity + { + if (id == Guid.Empty) + throw new ArgumentException($"Could not load the {typeof(T).Name} because the given objectID is empty."); + + return (T)await Get(id, typeof(T)); + } + + /// + /// Get an entity with the given type and ID from the database. + /// If it can't find the object, an exception will be thrown. + /// + /// The type of the object to get + /// The primary key value of the object to load. + public async Task Get(Guid? id) where T : IEntity + { + if (id.HasValue) return await Get(id.Value); + else return default(T); + } + + /// + /// Get an entity with the given type and ID from the database. + /// If it can't find the object, an exception will be thrown. + /// + /// The type of the object to get + /// The primary key value of the object to load. + public async Task Get(int? id) where T : IEntity + { + if (id == null) return default(T); + return (T)await Get(id.Value, typeof(T)); + } + + public async Task Get(int id) where T : IEntity => (T)await Get(id, typeof(T)); + + /// + /// Get an entity with the given type and ID from the database. + /// If it can't find the object, an exception will be thrown. + /// + /// The primary key value of the object to load. + public async Task> Get(Guid entityID, Type objectType) => await Get((object)entityID, objectType) as IEntity; + + /// + /// Get an entity with the given type and ID from the database. + /// If it can't find the object, an exception will be thrown. + /// + /// The primary key value of the object to load. + public async Task Get(object entityID, Type objectType) + { + if (objectType == null) return null; + + IEntity result = null; + + if (NeedsTypeResolution(objectType)) + { + foreach (var provider in ResolveDataProviders(objectType)) + { + try + { + result = Cache.Current.Get(entityID.ToString()); + if (result != null) return result; + + result = await provider.Get(entityID); + if (result != null) break; + } + catch + { + continue; + } + } + } + else + { + result = await GetConcrete(entityID, objectType); + } + + if (result != null) + return result; + else + throw new ArgumentException($"Could not load the {objectType.FullName} instance with the ID of {entityID}."); + } + + /// + /// Get an entity with the given type and ID from the database. + /// If the key does not exist, it will return null, rather than throwing an exception. + /// + /// The type of the object to get + /// The primary key value of the object to load. + public async Task GetOrDefault(object id) where T : IEntity => (T)await GetOrDefault(id, typeof(T)); + + /// + /// Get an entity with the given type and ID from the database. + /// If the key does not exist, it will return null, rather than throwing an exception. + /// + /// The type of the object to get + /// The primary key value of the object to load. + public async Task GetOrDefault(object id, Type type) + { + if (id.ToStringOrEmpty().IsEmpty()) return null; + + try { return await Get(id, type); } + catch { return null; } + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Database.GetList.cs b/Olive.Entities.Data/API/Database/Database.GetList.cs new file mode 100644 index 000000000..f4fcbb9db --- /dev/null +++ b/Olive.Entities.Data/API/Database/Database.GetList.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Olive.Entities.Data +{ + partial class Database + { + /// + /// Gets a list of entities of the given type from the database. + /// + public Task> GetList(Expression> criteria = null) where T : IEntity + { + return Of().Where(criteria).GetList(); + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Database.ProviderManagement.cs b/Olive.Entities.Data/API/Database/Database.ProviderManagement.cs new file mode 100644 index 000000000..f9a821ae0 --- /dev/null +++ b/Olive.Entities.Data/API/Database/Database.ProviderManagement.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using System.Transactions; + +namespace Olive.Entities.Data +{ + partial class Database + { + static readonly Database instance = new Database(); + + Database() + { + AssemblyProviderFactories = new Dictionary(); + TypeProviderFactories = new Dictionary(); + + // Load from configuration: + var configSection = Config.Bind("DataProviderModel"); + + if (configSection != null) + { + if (configSection.Providers != null) + foreach (var factoryInfo in configSection.Providers) + RegisterDataProviderFactory(factoryInfo); + } + } + + public static Database Instance => instance; + + #region Updated event + /// + /// It's raised when any record is saved or deleted in the system. + /// + public AsyncEvent Updated { get; } = new AsyncEvent(); + + Task OnUpdated(IEntity entity) => Updated.Raise(entity); + + #endregion + + object DataProviderSyncLock = new object(); + public void RegisterDataProviderFactory(DataProviderFactoryInfo factoryInfo) + { + if (factoryInfo == null) throw new ArgumentNullException(nameof(factoryInfo)); + + lock (DataProviderSyncLock) + { + var type = factoryInfo.GetMappedType(); + var assembly = factoryInfo.GetAssembly(); + + // var providerFactoryType = Type.GetType(factoryInfo.ProviderFactoryType); HAS A PROIBLEM WITH VERSIONING + var providerFactoryType = assembly.GetTypes().FirstOrDefault(t => t.AssemblyQualifiedName == factoryInfo.ProviderFactoryType); + if (providerFactoryType == null) providerFactoryType = assembly.GetType(factoryInfo.ProviderFactoryType); + if (providerFactoryType == null) providerFactoryType = Type.GetType(factoryInfo.ProviderFactoryType); + + if (providerFactoryType == null) + throw new Exception("Could not find the type " + factoryInfo.ProviderFactoryType + " as specified in configuration."); + + var providerFactory = (IDataProviderFactory)Activator.CreateInstance(providerFactoryType, factoryInfo); + + if (type != null) + { + TypeProviderFactories[type] = providerFactory; + } + else if (assembly != null && providerFactory != null) + { + AssemblyProviderFactories[assembly] = providerFactory; + } + + EntityFinder.ResetCache(); + } + } + + public Dictionary AssemblyProviderFactories { get; internal set; } + Dictionary TypeProviderFactories; + + /// + /// Gets the assemblies for which a data provider factory has been registered in the current domain. + /// + public IEnumerable GetRegisteredAssemblies() + { + return TypeProviderFactories.Keys.Select(t => t.GetTypeInfo().Assembly).Concat(AssemblyProviderFactories.Keys).Distinct().ToArray(); + } + + public IDataProvider GetProvider() where T : IEntity => GetProvider(typeof(T)); + + public IDataAccess GetAccess(Type type) => GetProvider(type).Access; + + public IDataAccess GetAccess() where TEntity : IEntity => GetProvider().Access; + + public IDataAccess GetAccess(string connectionString = null) + { + if (connectionString.IsEmpty()) connectionString = DataAccess.GetCurrentConnectionString(); + + var factory = TypeProviderFactories.Values + .Concat(AssemblyProviderFactories.Values) + .FirstOrDefault(x => x.ConnectionString == connectionString); + + if (factory == null) + throw new Exception("No data provider factory's connection string matched the specified connection string."); + + return factory.GetAccess(); + } + + public IDataProvider GetProvider(IEntity item) => GetProvider(item.GetType()); + + public IDataProvider GetProvider(Type type) + { + if (TypeProviderFactories.ContainsKey(type)) + return TypeProviderFactories[type].GetProvider(type); + + // Strange bug: + if (AssemblyProviderFactories.Any(x => x.Key == null)) + AssemblyProviderFactories = new Dictionary(); + + if (!AssemblyProviderFactories.ContainsKey(type.GetTypeInfo().Assembly)) + throw new InvalidOperationException("There is no registered 'data provider' for the assembly: " + type.GetTypeInfo().Assembly.FullName); + + return AssemblyProviderFactories[type.GetTypeInfo().Assembly].GetProvider(type); + } + + /// + /// Creates a transaction scope. + /// + public ITransactionScope CreateTransactionScope(DbTransactionScopeOption option = DbTransactionScopeOption.Required) + { + var isolationLevel = Config.Get("Default.Transaction.IsolationLevel", System.Data.IsolationLevel.Serializable); + + var typeName = Config.Get("Default.TransactionScope.Type"); + + if (typeName.HasValue()) + { + Type type = null; // this is a workaround. + var dummy = typeof(DbTransactionScope); + if (dummy.AssemblyQualifiedName.StartsWith(typeName)) + type = dummy; + else + type = Type.GetType(typeName); + + if (type == null) throw new Exception("Cannot load type: " + typeName); + + return (ITransactionScope)type.CreateInstance(isolationLevel, option); + } + + // Fall back to TransactionScope: + var oldOption = option.ToString().To(); + return new TransactionScopeWrapper(isolationLevel.ToString().To().CreateScope(oldOption)); + } + + List ResolveDataProviders(Type baseType) + { + var factories = AssemblyProviderFactories.Where(f => f.Value.SupportsPolymorphism() && f.Key.References(baseType.GetTypeInfo().Assembly)).ToList(); + + var result = new List(); + + foreach (var f in factories) + result.Add(f.Value.GetProvider(baseType)); + + foreach (var type in EntityFinder.FindPossibleTypes(baseType, mustFind: factories.None())) + result.Add(GetProvider(type)); + + return result; + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Database.Save.List.cs b/Olive.Entities.Data/API/Database/Database.Save.List.cs new file mode 100644 index 000000000..c62640efe --- /dev/null +++ b/Olive.Entities.Data/API/Database/Database.Save.List.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Olive.Entities.Data +{ + partial class Database + { + /// + /// Saves the specified records in the data repository. + /// The operation will run in a Transaction. + /// + public async Task> Save(T[] records) where T : IEntity => await Save(records as IEnumerable); + + /// + /// Saves the specified records in the data repository. + /// The operation will run in a Transaction. + /// + public async Task> Save(IEnumerable records) where T : IEntity => await Save(records, SaveBehaviour.Default); + + /// + /// Saves the specified records in the data repository. + /// The operation will run in a Transaction. + /// + public async Task> Save(IEnumerable records, SaveBehaviour behaviour) where T : IEntity + { + if (records == null) + throw new ArgumentNullException(nameof(records)); + + if (records.None()) return records; + + await EnlistOrCreateTransaction(async () => + { + foreach (var record in records) + await Save(record as Entity, behaviour); + }); + + return records; + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Database.Save.cs b/Olive.Entities.Data/API/Database/Database.Save.cs new file mode 100644 index 000000000..99ef5b6b4 --- /dev/null +++ b/Olive.Entities.Data/API/Database/Database.Save.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Transactions; + +namespace Olive.Entities.Data +{ + partial class Database + { + bool ENFORCE_SAVE_TRANSACTION = Config.Get("Database:Save.Enforce.Transaction", defaultValue: false); + + static ConcurrentDictionary StringKeySyncLocks = new ConcurrentDictionary(); + + public static AsyncLock GetSyncLock(string key) => StringKeySyncLocks.GetOrAdd(key, f => new AsyncLock()); + + /// + /// Inserts or updates an object in the database. + /// + public async Task Save(T entity) where T : IEntity + { + await Save(entity as IEntity, SaveBehaviour.Default); + return entity; + } + + /// + /// Inserts or updates an object in the database. + /// + public async Task Save(IEntity entity, SaveBehaviour behaviour) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + Func save = async () => await DoSave(entity, behaviour); + + Func doSave = async () => + { + if (entity.IsNew) await save(); + else using (await GetSyncLock(entity.GetType().FullName + entity.GetId()).Lock()) await save(); + }; + + if (ENFORCE_SAVE_TRANSACTION) await EnlistOrCreateTransaction(doSave); + else await doSave(); + } + + async Task DoSave(IEntity entity, SaveBehaviour behaviour) + { + var mode = entity.IsNew ? SaveMode.Insert : SaveMode.Update; + + var asEntity = entity as Entity; + if (mode == SaveMode.Update && (asEntity._ClonedFrom?.IsStale == true) && AnyOpenTransaction()) + { + throw new InvalidOperationException("This " + entity.GetType().Name + " instance in memory is out-of-date. " + + "A clone of it is already updated in the transaction. It is not allowed to update the same instance multiple times in a transaction, because then the earlier updates would be overwriten by the older state of the instance in memory. \r\n\r\n" + + @"BAD: +Database.Update(myObject, x=> x.P1 = ...); // Note: this could also be nested inside another method that's called here instead. +Database.Update(myObject, x=> x.P2 = ...); + +GOOD: +Database.Update(myObject, x=> x.P1 = ...); +myObject = Database.Reload(myObject); +Database.Update(myObject, x=> x.P2 = ...);"); + } + + if (EntityManager.IsImmutable(entity)) + throw new ArgumentException("An immutable record must be cloned before any modifications can be applied on it. " + + $"Type={entity.GetType().FullName}, Id={entity.GetId()}."); + + var dataProvider = GetProvider(entity); + + if (!IsSet(behaviour, SaveBehaviour.BypassValidation)) + { + await EntityManager.RaiseOnValidating(entity as Entity, EventArgs.Empty); + await entity.Validate(); + } + else if (!dataProvider.SupportValidationBypassing()) + { + throw new ArgumentException(dataProvider.GetType().Name + " does not support bypassing validation."); + } + + #region Raise saving event + + if (!IsSet(behaviour, SaveBehaviour.BypassSaving)) + { + var savingArgs = new System.ComponentModel.CancelEventArgs(); + await EntityManager.RaiseOnSaving(entity, savingArgs); + + if (savingArgs.Cancel) + { + Cache.Current.Remove(entity); + return; + } + } + + #endregion + + if (!IsSet(behaviour, SaveBehaviour.BypassLogging) && !(entity is IApplicationEvent) && + Config.Get("Log.Record:Application:Events", defaultValue: true)) + await ApplicationEventManager.RecordSave(entity, mode); + + await dataProvider.Save(entity); + Cache.Current.UpdateRowVersion(entity); + + if (mode == SaveMode.Update && asEntity?._ClonedFrom != null && AnyOpenTransaction()) + { + asEntity._ClonedFrom.IsStale = true; + asEntity.IsStale = false; + } + + if (mode == SaveMode.Insert) + EntityManager.SetSaved(entity); + + Cache.Current.Remove(entity); + + if (Transaction.Current != null) + Transaction.Current.TransactionCompleted += (s, e) => { Cache.Current.Remove(entity); }; + + if (DbTransactionScope.Root != null) + DbTransactionScope.Root.OnTransactionCompleted(() => Cache.Current.Remove(entity)); + + if (!(entity is IApplicationEvent)) + await OnUpdated(entity); + + if (!IsSet(behaviour, SaveBehaviour.BypassSaved)) + await EntityManager.RaiseOnSaved(entity, new SaveEventArgs(mode)); + + // OnSaved event handler might have read the object again and put it in the cache, which would + // create invalid CachedReference objects. + Cache.Current.Remove(entity); + } + + /// + /// Saves the specified records in the data repository. + /// The operation will run in a Transaction. + /// + public async Task> Save(List records) where T : IEntity => await Save(records as IEnumerable); + + /* ===================== Update ========================*/ + + /// + /// Runs an update command on a list of given objects and persists the updated objects in database. + /// It returns the updated instances. + /// + /// The objects to be updated in database. + /// Update action. For example: o=>o.Property = "Value" + public async Task> Update(IEnumerable items, Action action) where T : IEntity => + await Update(items, action, SaveBehaviour.Default); + + /// + /// Runs an update command on a list of given objects and persists the updated objects in database. + /// It returns the updated instances. + /// + /// The objects to be updated in database. + /// Update action. For example: o=>o.Property = "Value" + public async Task> Update(IEnumerable items, Action action, SaveBehaviour behaviour) where T : IEntity + { + var result = new List(); + + await EnlistOrCreateTransaction(async () => + { + foreach (var item in items) + result.Add(await Update(item, action, behaviour)); + }); + + return result; + } + + /// + /// Runs an update command on a given object's clone and persists the updated object in database. It returns the updated instance. + /// + /// The object to be updated in database. + /// Update action. For example: o=>o.Property = "Value" + public async Task Update(T item, Action action) where T : IEntity => await Update(item, action, SaveBehaviour.Default); + + /// + /// Runs an update command on a given object's clone and persists the updated object in database. It returns the updated instance. + /// + /// The object to be updated in database. + /// Update action. For example: o=>o.Property = "Value" + public async Task Update(T item, Action action, SaveBehaviour behaviour) where T : IEntity + { + if (item == null) + throw new ArgumentNullException(nameof(item)); + + if (action == null) + throw new ArgumentNullException(nameof(action)); + + if (item.IsNew) + throw new InvalidOperationException("New instances cannot be updated using the Update method."); + + if (!(item is Entity)) + throw new ArgumentException($"Database.Update() method accepts a type inheriting from {typeof(Entity).FullName}. So {typeof(T).FullName} is not supported."); + + if ((item as Entity)._ClonedFrom?.IsStale == true && AnyOpenTransaction()) + // No need for an error. We can just get the fresh version here. + item = await Reload(item); + + if (EntityManager.IsImmutable(item as Entity)) + { + var clone = (T)((IEntity)item).Clone(); + + action(clone); + + await Save(clone as Entity, behaviour); + + if (!AnyOpenTransaction()) + action(item); + + return clone; + } + else + { + action(item); + await Save(item, behaviour); + + return item; + } + } + + /// + /// Inserts the specified objects in bulk. None of the object events will be triggered. + /// + public async Task BulkInsert(Entity[] objects, int batchSize = 10, bool bypassValidation = false) + { + if (!bypassValidation) + await objects.ValidateAll(); + + var objectTypes = objects.GroupBy(o => o.GetType()).ToArray(); + + try + { + foreach (var group in objectTypes) + await GetProvider(group.Key).BulkInsert(group.ToArray(), batchSize); + + foreach (var type in objectTypes) + Cache.Current.Remove(type.Key); + } + catch + { + await Refresh(); + throw; + } + } + + /// + /// Updates the specified objects in bulk. None of the object events will be triggered. + /// + public async Task BulkUpdate(Entity[] objects, int batchSize = 10, bool bypassValidation = false) + { + if (!bypassValidation) + await objects.ValidateAll(); + + var objectTypes = objects.GroupBy(o => o.GetType()).ToArray(); + + try + { + foreach (var group in objectTypes) + { + var records = group.ToArray(); + await GetProvider(group.Key).BulkUpdate(records, batchSize); + } + + foreach (var type in objectTypes) + Cache.Current.Remove(type.Key); + } + catch + { + await Refresh(); + throw; + } + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Database.cs b/Olive.Entities.Data/API/Database/Database.cs new file mode 100644 index 000000000..b7eb1ea07 --- /dev/null +++ b/Olive.Entities.Data/API/Database/Database.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading.Tasks; +using System.Transactions; + +namespace Olive.Entities.Data +{ + /// + /// Data access code for Application components. + /// + public partial class Database : IDatabase + { + bool IsSet(SaveBehaviour setting, SaveBehaviour behaviour) => (setting & behaviour) == behaviour; + + bool IsSet(DeleteBehaviour setting, DeleteBehaviour behaviour) => (setting & behaviour) == behaviour; + + bool NeedsTypeResolution(Type type) => type.IsInterface || type == typeof(Entity); + + public AsyncEvent CacheRefreshed { get; } = new AsyncEvent(); + /// + /// Clears the cache of all items. + /// + public async Task Refresh() + { + Cache.Current.ClearAll(); + + await CacheRefreshed.Raise(); + } + + public bool AnyOpenTransaction() => Transaction.Current != null || DbTransactionScope.Root != null; + + /// + /// If there is an existing open transaction, it will simply run the specified action in it, Otherwise it will create a new transaction. + /// + public async Task EnlistOrCreateTransaction(Func func) + { + if (AnyOpenTransaction()) await func?.Invoke(); + else + { + using (var scope = CreateTransactionScope()) + { + await func?.Invoke(); + + scope.Complete(); + } + } + } + + /// + /// Returns the first record of the specified type of which ToString() would return the specified text . + /// + public async Task Parse(string toString, bool caseSensitive = false) where T : IEntity + { + // TODO: I have replaced StringComparison.InvariantCulture with StringComparison.Ordinal + // It is possible to switch it back in next verions of .Net Core. + var comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + foreach (var instance in await GetList()) + { + string objectString; + try { objectString = instance.ToString(); } + catch (Exception ex) + { + throw new Exception("Database.Parse() failed. Calling ToString() throw an exception on the {0} object with ID of '{1}'" + .FormatWith(typeof(T).Name, instance.GetId(), ex)); + } + + if (toString == null && objectString == null) return instance; + + if (toString == null || objectString == null) continue; + + if (objectString.Equals(toString, comparison)) return instance; + } + + return default(T); + } + + /// + /// Gets the total number of objects in cache. + /// + public int CountAllObjectsInCache() => Cache.Current.CountAllObjects(); + + public async Task> ReadManyToManyRelation(IEntity instance, string property) + { + if (instance == null) throw new ArgumentNullException(nameof(instance)); + + return await GetProvider(instance).ReadManyToManyRelation(instance, property); + } + + /// + /// Gets a reloaded instance from the database to get a synced copy. + /// + public async Task Reload(T instance) where T : IEntity + { + if (instance == null) throw new ArgumentNullException(nameof(instance)); + + Cache.Current.Remove(instance); + + return (T)await Get(instance.GetId(), instance.GetType()); + } + + /// + /// Determines if there is any object in the database of the specified type. + /// + public async Task Any() where T : IEntity => await Of().Count() > 0; + + /// + /// Determines if there is any object in the database of the specified type matching a given criteria. + /// + public async Task Any(Expression> criteria) where T : IEntity => + await Of().Where(criteria).Count() > 0; + + /// + /// Determines whether there is no object of the specified type in the database. + /// + public async Task None() where T : IEntity => !await Any(); + + /// + /// Determines whether none of the objects in the database match a given criteria. + /// + public async Task None(Expression> criteria) where T : IEntity => !await Any(criteria); + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Query/AssociationInclusion.cs b/Olive.Entities.Data/API/Database/Query/AssociationInclusion.cs new file mode 100644 index 000000000..49901ba41 --- /dev/null +++ b/Olive.Entities.Data/API/Database/Query/AssociationInclusion.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Olive.Entities.Data +{ + /// + /// It provides a tree for the association properties + /// + public class AssociationInclusion + { + public PropertyInfo Association { get; set; } + List IncludedNestedAssociations = new List(); + + public static AssociationInclusion Create(PropertyInfo association) => + new AssociationInclusion { Association = association }; + + public void IncludeNestedAssociation(string nestedAssociation) + => IncludedNestedAssociations.Add(nestedAssociation); + + public async Task LoadAssociations(DatabaseQuery query, IEnumerable mainObjects) + { + var associatedObjects = LoadTheAssociatedObjects(query); + + var groupedObjects = GroupTheMainObjects(mainObjects); + + var cachedField = query.EntityType.GetField("cached" + Association.Name, + BindingFlags.NonPublic | BindingFlags.Instance); + + if (cachedField != null) + foreach (var associatedObject in await associatedObjects) + foreach (var mainEntity in groupedObjects[associatedObject.GetId()]) + BindToCachedField(cachedField, associatedObject, mainEntity); + } + + void BindToCachedField(FieldInfo cachedField, IEntity associatedObject, IEntity mainEntity) + { + var cachedRef = cachedField.GetValue(mainEntity); + + var bindMethod = cachedRef.GetType().GetMethod("Bind", + BindingFlags.NonPublic | BindingFlags.Instance); + + bindMethod?.Invoke(cachedRef, new[] { associatedObject }); + } + + Dictionary GroupTheMainObjects(IEnumerable mainObjects) + { + var idProperty = Association.DeclaringType.GetProperty(Association.Name + "Id"); + + var groupedResult = mainObjects.GroupBy(item => idProperty.GetValue(item)) + .ToDictionary(i => i.Key, i => i.ToArray()); + return groupedResult; + } + + Task> LoadTheAssociatedObjects(DatabaseQuery query) + { + return Database.Instance.Of(Association.PropertyType) + .Where(query.Provider.GetAssociationInclusionCriteria(query, Association)) + .Include(IncludedNestedAssociations) + .GetList(); + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Query/DatabaseQuery.Execution.cs b/Olive.Entities.Data/API/Database/Query/DatabaseQuery.Execution.cs new file mode 100644 index 000000000..59bd22a3b --- /dev/null +++ b/Olive.Entities.Data/API/Database/Query/DatabaseQuery.Execution.cs @@ -0,0 +1,137 @@ +namespace Olive.Entities.Data +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using System.Text; + using System.Threading.Tasks; + + partial class DatabaseQuery + { + bool IsCacheable() + { + if (PageSize.HasValue) return false; + + if (Criteria.Except(typeof(DirectDatabaseCriterion)).Any(c => c.PropertyName.Contains("."))) + return false; // This doesn't work with cache expiration rules. + + if (Criteria.OfType().Any(x => !x.IsCacheSafe)) + return false; + + // Do not cache a polymorphic call: + if (NeedsTypeResolution()) return false; + + return true; + } + + string GetCacheKey() + { + var r = new StringBuilder(); + r.Append(EntityType.GetCachedAssemblyQualifiedName()); + + r.Append(':'); + + foreach (var c in Criteria) + { + r.Append(c.ToString()); + r.Append('|'); + } + + if (TakeTop.HasValue) r.Append("|N:" + TakeTop); + + r.Append(OrderByParts.Select(x => x.ToString()).ToString(",").WithPrefix("|S:")); + + return r.ToString(); + } + + public async Task> GetList() + { + if (!IsCacheable()) return await LoadFromDatabase(); + + var cacheKey = GetCacheKey(); + + var result = Cache.Current.GetList(EntityType, cacheKey)?.Cast(); + if (result != null) + { + await LoadIncludedAssociations(result); + return result; + } + + result = await LoadFromDatabaseAndCache(); + + // If there is no transaction open, cache it: + if (!Database.Instance.AnyOpenTransaction()) + Cache.Current.AddList(EntityType, cacheKey, result); + + return result; + } + + async Task> LoadFromDatabase() + { + List result; + if (NeedsTypeResolution()) + { + var queries = ResolveDataProviders().Select(p => p.GetList(this)); + result = (await queries.AwaitAll()).SelectMany(x => x).ToList(); + } + else + result = (await Provider.GetList(this)).ToList(); + + if (OrderByParts.None()) + { + // TODO: If the entity is sortable by a single DB column, then automatically add that to the DB call. + result.Sort(); + } + + await LoadIncludedAssociations(result); + + return result; + } + + async Task LoadIncludedAssociations(IEnumerable mainResult) + { + foreach (var associationHeirarchy in Include) + { + await associationHeirarchy.LoadAssociations(this, mainResult); + //await new AssociationEagerLoadService(mainResult, associationHeirarchy.Association, associationHeirarchy.SubAssociations, this).Run(); + } + } + + async Task> LoadFromDatabaseAndCache() + { + var timestamp = Cache.GetQueryTimestamp(); + + var result = new List(); + + foreach (var item in await LoadFromDatabase()) + { + var inCache = Cache.Current.Get(item.GetType(), item.GetId().ToString()); + if (inCache != null) result.Add(inCache); + else + { + await EntityManager.RaiseOnLoaded(item); + Database.Instance.TryCache(item, timestamp); + result.Add(item); + } + } + + return result; + } + + public Task Count() => Provider.Count(this); + + public async Task FirstOrDefault() + { + TakeTop = 1; + return (await Provider.GetList(this)).FirstOrDefault(); + } + } + + partial class DatabaseQuery + { + public new async Task> GetList() => (await base.GetList()).Cast(); + + public new async Task FirstOrDefault() => (TEntity)(await base.FirstOrDefault()); + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Query/DatabaseQuery.OrderBy.cs b/Olive.Entities.Data/API/Database/Query/DatabaseQuery.OrderBy.cs new file mode 100644 index 000000000..8d8de9007 --- /dev/null +++ b/Olive.Entities.Data/API/Database/Query/DatabaseQuery.OrderBy.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; + +namespace Olive.Entities.Data +{ + partial class DatabaseQuery + { + internal const bool DESC = true; + public List OrderByParts = new List(); + + public class OrderByPart + { + public string Property; + public bool Descending; + + public override string ToString() => Property + (DESC ? ".DESC" : null); + } + + IDatabaseQuery IDatabaseQuery.OrderBy(string property, bool descending) + { + if (OrderByParts.Any()) + throw new Exception("There is already an OrderBy part added. Use ThenBy()."); + + return this.ThenBy(property, descending); + } + + IDatabaseQuery IDatabaseQuery.ThenBy(string property, bool descending) + { + OrderByParts.Add(new OrderByPart { Property = property, Descending = descending }); + return this; + } + } + + partial class DatabaseQuery + { + public IDatabaseQuery ThenBy(Expression> property, bool descending = false) + { + var propertyExpression = (property.Body as UnaryExpression)?.Operand as MemberExpression; + if (propertyExpression == null || !(propertyExpression.Expression is ParameterExpression)) + throw new Exception($"Unsupported OrderBy expression. The only supported format is \"x => x.Property\". You provided: {property}"); + return this.ThenBy(propertyExpression.Member.Name, descending); + } + + public IDatabaseQuery OrderByDescending(Expression> property) => OrderBy(property, DESC); + + public IDatabaseQuery OrderBy(Expression> property, bool descending = false) + { + if (OrderByParts.Any()) + throw new Exception("There is already an OrderBy part added. Use ThenBy()."); + + return ThenBy(property, descending); + } + + public IDatabaseQuery ThenByDescending(Expression> property) => ThenBy(property, DESC); + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Query/DatabaseQuery.Polymorphism.cs b/Olive.Entities.Data/API/Database/Query/DatabaseQuery.Polymorphism.cs new file mode 100644 index 000000000..994a8405e --- /dev/null +++ b/Olive.Entities.Data/API/Database/Query/DatabaseQuery.Polymorphism.cs @@ -0,0 +1,28 @@ +namespace Olive.Entities.Data +{ + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + + partial class DatabaseQuery + { + List ResolveDataProviders() + { + var factories = Database.Instance.AssemblyProviderFactories + .Where(f => f.Value.SupportsPolymorphism()) + .Where(f => f.Key.References(EntityType.GetTypeInfo().Assembly)).ToList(); + + var result = new List(); + + foreach (var f in factories) + result.Add(f.Value.GetProvider(EntityType)); + + foreach (var type in EntityFinder.FindPossibleTypes(EntityType, mustFind: factories.None())) + result.Add(Database.Instance.GetProvider(type)); + + return result; + } + + bool NeedsTypeResolution() => EntityType.IsInterface || EntityType == typeof(Entity); + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Query/DatabaseQuery.TEntity.Aggregate.cs b/Olive.Entities.Data/API/Database/Query/DatabaseQuery.TEntity.Aggregate.cs new file mode 100644 index 000000000..ebce7f946 --- /dev/null +++ b/Olive.Entities.Data/API/Database/Query/DatabaseQuery.TEntity.Aggregate.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Olive.Entities.Data +{ + partial class DatabaseQuery + { + /// + /// Gets a list of entities of the given type from the database with the specified type matching the specified criteria. + /// If no criteria is specified, the count of all instances will be returned. + /// + public Task Aggregate(AggregateFunction function, string propertyName) + { + return Provider.Aggregate(this, function, propertyName); + } + } + + partial class DatabaseQuery + { + /// + /// Gets a list of entities of the given type from the database with the specified type matching the specified criteria. + /// If no criteria is specified, the count of all instances will be returned. + /// + public async Task Aggregate(AggregateFunction function, + Expression> property) where TOutput : struct + { + var result = await Aggregate(function, property.GetPropertyPath()); + return result.ToStringOrEmpty().TryParseAs(); + } + + public async Task Max(Expression> property) where TProperty : struct => + await Aggregate(AggregateFunction.Max, property); + + public async Task Min(Expression> property) where TProperty : struct => + await Aggregate(AggregateFunction.Min, property); + + public async Task Sum(Expression> property) where TProperty : struct => + await Aggregate(AggregateFunction.Sum, property); + + public async Task Average(Expression> property) + where TProperty : struct => + await Aggregate(AggregateFunction.Average, property); + + public async Task Average(Expression> property) + where TProperty : struct => + await Aggregate(AggregateFunction.Average, property); + + public async Task Average(Expression> property) + where TProperty : struct => + await Aggregate(AggregateFunction.Average, property); + + public async Task Average(Expression> property) => + await Aggregate(AggregateFunction.Average, property); + + public async Task Average(Expression> property) => + await Aggregate(AggregateFunction.Average, property); + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Query/DatabaseQuery.TEntity.cs b/Olive.Entities.Data/API/Database/Query/DatabaseQuery.TEntity.cs new file mode 100644 index 000000000..e1b8b181c --- /dev/null +++ b/Olive.Entities.Data/API/Database/Query/DatabaseQuery.TEntity.cs @@ -0,0 +1,41 @@ +namespace Olive.Entities.Data +{ + using System; + using System.Collections.Generic; + using System.Linq.Expressions; + using System.Reflection; + + public partial class DatabaseQuery : DatabaseQuery, IDatabaseQuery + where TEntity : IEntity + { + public DatabaseQuery() : base(typeof(TEntity)) { } + + IDatabaseQuery IDatabaseQuery.Where(Expression> criteria) + { + if (criteria == null) return this; + Criteria.AddRange(CriteriaExtractor.Parse(criteria)); + return this; + } + + IDatabaseQuery IDatabaseQuery.Include(Expression> property) + { + ((IDatabaseQuery)this).Include(property.GetPropertyPath()); + return this; + } + + IDatabaseQuery IDatabaseQuery.Top(int rows) + { + TakeTop = rows; + return this; + } + + IDatabaseQuery IDatabaseQuery.OrderBy(string property) + => this.OrderBy(property, descending: false); + + IDatabaseQuery IDatabaseQuery.Where(params ICriterion[] criteria) + { + Criteria.AddRange(criteria); + return this; + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Query/DatabaseQuery.cs b/Olive.Entities.Data/API/Database/Query/DatabaseQuery.cs new file mode 100644 index 000000000..66e7a8ccb --- /dev/null +++ b/Olive.Entities.Data/API/Database/Query/DatabaseQuery.cs @@ -0,0 +1,87 @@ +namespace Olive.Entities.Data +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Linq.Expressions; + using System.Reflection; + + partial class Database + { + public IDatabaseQuery Of(Type type) => new DatabaseQuery(type); + + public IDatabaseQuery Of() where TEntity : IEntity => new DatabaseQuery(); + } + + public partial class DatabaseQuery : IDatabaseQuery + { + Dictionary include = new Dictionary(); + + public IDataProvider Provider { get; } + public Type EntityType { get; private set; } + public List Criteria { get; } = new List(); + public IEnumerable Include => + include.Values.ToArray(); + public Dictionary Parameters { get; } = new Dictionary(); + + public int PageStartIndex { get; set; } + public int? PageSize { get; set; } + public int? TakeTop { get; set; } + + internal DatabaseQuery(Type entityType) + { + if (entityType == null) throw new ArgumentNullException(nameof(entityType)); + + if (!entityType.IsA()) + throw new ArgumentException(entityType.Name + " is not an IEntity."); + + EntityType = entityType; + Provider = Database.Instance.GetProvider(entityType); + } + + public string Column(string propertyName) => Provider.MapColumn(propertyName); + + IDatabaseQuery IDatabaseQuery.Where(params ICriterion[] criteria) + { + Criteria.AddRange(criteria); + return this; + } + + IDatabaseQuery IDatabaseQuery.Include(string associations) + { + var immediateAssociation = associations.Split('.').First(); + var nestedAssociations = associations.Split('.').ExceptFirst().ToString("."); + + var property = EntityType.GetProperty(immediateAssociation) + ?? throw new Exception(EntityType.Name + " does not have a property named " + immediateAssociation); + + if (!property.PropertyType.IsA()) + throw new Exception(EntityType.Name + "." + immediateAssociation + " is not an Entity type."); + + if (!include.ContainsKey(immediateAssociation)) + include.Add(immediateAssociation, AssociationInclusion.Create(property)); + + if (nestedAssociations.HasValue()) + include[immediateAssociation].IncludeNestedAssociation(nestedAssociations); + + // TODO: Support one-to-many too + return this; + } + + IDatabaseQuery IDatabaseQuery.Include(IEnumerable associations) + { + foreach (var item in associations) + ((IDatabaseQuery)this).Include(item); + + return this; + } + + IDatabaseQuery IDatabaseQuery.Top(int rows) + { + TakeTop = rows; + return this; + } + + IDatabaseQuery IDatabaseQuery.OrderBy(string property) => this.OrderBy(property, descending: false); + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/Database/Query/DatabaseQueryExtensions.cs b/Olive.Entities.Data/API/Database/Query/DatabaseQueryExtensions.cs new file mode 100644 index 000000000..73b27a716 --- /dev/null +++ b/Olive.Entities.Data/API/Database/Query/DatabaseQueryExtensions.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using Olive.Entities.Data; +using System.Linq; + +namespace Olive.Entities +{ + public static partial class DatabaseQueryExtensions + { + public static T Page(this T query, int startIndex, int pageSize) + where T : IDatabaseQuery + { + if (pageSize < 1) + throw new ArgumentException("Invalid PagingQueryOption specified. PageSize should be a positive number."); + + query.PageSize = pageSize; + query.PageStartIndex = startIndex; + return query; + } + + public static T OrderBy(this T query, string property, bool descending = false) + where T : DatabaseQuery + { + return (T)((IDatabaseQuery)query).OrderBy(property, descending); + } + + public static T OrderByDescending(this T query, string property) + where T : DatabaseQuery + { + return query.OrderBy(property, descending: true); + } + + public static T ThenBy(this T query, string property, bool descending = false) + where T : DatabaseQuery + { + return (T)((IDatabaseQuery)query).ThenBy(property, descending); + } + + public static T ThenByDescending(this T query, string property) + where T : DatabaseQuery + { + return query.ThenBy(property, descending: true); + } + + public static T Where(this T query, string sqlCriteria) + where T : DatabaseQuery + { + query.Criteria.Add(new DirectDatabaseCriterion(sqlCriteria)); + return query; + } + + public static T Where(this T query, IEnumerable criteria) + where T : IDatabaseQuery + { + query.Where(criteria.ToArray()); + return query; + } + + public static T Where(this T query, IEnumerable criteria) + where T : IDatabaseQuery + { + query.Where(criteria.Cast().ToArray()); + return query; + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/API/DatabaseContext.cs b/Olive.Entities.Data/API/DatabaseContext.cs new file mode 100644 index 000000000..c48f27614 --- /dev/null +++ b/Olive.Entities.Data/API/DatabaseContext.cs @@ -0,0 +1,36 @@ +using System; + +namespace Olive.Entities.Data +{ + public class DatabaseContext : IDisposable + { + #region ConnectionString + /// + /// Gets or sets the ConnectionString of this DatabaseContext. + /// + public string ConnectionString { get; set; } + + public int? CommandTimeout { get; set; } + + #endregion + + DatabaseContext Parent; + + public DatabaseContext(string connectionString) + { + ConnectionString = connectionString; + + if (Current != null) Parent = Current; + + Current = this; + } + + public static DatabaseContext Current + { + get => CallContext.GetData(nameof(Current)); + set => CallContext.SetData(nameof(Current), value); + } + + public void Dispose() => Current = Parent; + } +} diff --git a/Olive.Entities.Data/Ado.Net/DataAccess.cs b/Olive.Entities.Data/Ado.Net/DataAccess.cs new file mode 100644 index 000000000..7f7b54a1f --- /dev/null +++ b/Olive.Entities.Data/Ado.Net/DataAccess.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; + +namespace Olive.Entities.Data +{ + public abstract class DataAccess + { + public static string GetCurrentConnectionString() + { + string result; + + if (DatabaseContext.Current != null) result = DatabaseContext.Current.ConnectionString; + else result = Config.GetConnectionString("AppDatabase"); + + if (result.IsEmpty()) + throw new Exception("No 'AppDatabase' connection string is specified in the application config file."); + + return result; + } + } + + /// + /// ADO.NET Facade for submitting single method commands. + /// + public class DataAccess : DataAccess, IDataAccess + where TConnection : DbConnection, new() + { + /// + /// Creates a connection object. + /// + public async Task GetOrCreateConnection() + { + var result = await (DbTransactionScope.Root?.GetDbConnection() ?? Task.FromResult(null)); + if (result != null) return (TConnection)result; + else return await CreateConnection(); + } + + /// + /// Creates a new DB Connection to database with the given connection string. + /// + public async Task CreateConnection(string connectionString = null) + { + var result = new TConnection + { + ConnectionString = connectionString.Or(GetCurrentConnectionString()) + }; + + await result.OpenAsync(); + return result; + } + + void CloseConnection(IDbConnection connection) + { + if (DbTransactionScope.Root == null) + { + if (connection.State != ConnectionState.Closed) + connection.Close(); + } + } + + async Task CreateCommand(CommandType type, string commandText, params IDataParameter[] @params) => + await CreateCommand(type, commandText, default(TConnection), @params); + + async Task CreateCommand(CommandType type, string commandText, IDbConnection connection, params IDataParameter[] @params) + { + if (connection == null) connection = await GetOrCreateConnection(); + + var command = (DbCommand)connection.CreateCommand(); + command.CommandText = commandText; + command.CommandType = type; + + command.Transaction = await (DbTransactionScope.Root?.GetDbTransaction() + ?? Task.FromResult(command.Transaction)); + + command.CommandTimeout = DatabaseContext.Current?.CommandTimeout ?? (Config.TryGet("Sql.Command.TimeOut")) ?? command.CommandTimeout; + + foreach (var param in @params) + command.Parameters.Add(param); + + return command; + } + + DataAccessProfiler.Watch StartWatch(string command) + { + if (DataAccessProfiler.IsEnabled) return DataAccessProfiler.Start(command); + else return null; + } + + /// + /// Executes the specified command text as nonquery. + /// + public async Task ExecuteNonQuery(string command, CommandType commandType = CommandType.Text, params IDataParameter[] @params) + { + var dbCommand = await CreateCommand(commandType, command, @params); + + var watch = StartWatch(command); + + try + { + var result = await dbCommand.ExecuteNonQueryAsync(); + DatabaseStateChangeCommand.Raise(command, commandType, @params); + return result; + } + catch (Exception ex) + { + throw new Exception("Error in running Non-Query SQL command.", ex).AddData("Command", command) + .AddData("Parameters", @params.Get(l => l.Select(p => p.ParameterName + "=" + p.Value).ToString(" | "))) + .AddData("ConnectionString", dbCommand.Connection.ConnectionString); + } + finally + { + dbCommand.Parameters.Clear(); + + CloseConnection(dbCommand.Connection); + + if (watch != null) DataAccessProfiler.Complete(watch); + } + } + + /// + /// Executes the specified command text against the database connection of the context and builds an IDataReader. + /// Make sure you close the data reader after finishing the work. + /// + public async Task ExecuteReader(string command, CommandType commandType = CommandType.Text, params IDataParameter[] @params) + { + var watch = StartWatch(command); + + var dbCommand = await CreateCommand(commandType, command, @params); + + try + { + if (DbTransactionScope.Root != null) return await dbCommand.ExecuteReaderAsync(); + else return await dbCommand.ExecuteReaderAsync(CommandBehavior.CloseConnection); + } + catch (Exception ex) + { + throw new Exception("Error in running SQL Query.", ex).AddData("Command", command) + .AddData("Parameters", @params.Get(l => l.Select(p => p.ParameterName + "=" + p.Value).ToString(" | "))) + .AddData("ConnectionString", dbCommand.Connection.ConnectionString); + } + finally + { + dbCommand.Parameters.Clear(); + if (watch != null) DataAccessProfiler.Complete(watch); + } + } + + /// + /// Executes the specified command text against the database connection of the context and returns the single value of the type specified. + /// + public async Task ExecuteScalar(string commandText, CommandType commandType = CommandType.Text, params IDataParameter[] @params) => + (T)await ExecuteScalar(commandText, commandType, @params); + + /// + /// Executes the specified command text against the database connection of the context and returns the single value. + /// + public async Task ExecuteScalar(string command, CommandType commandType = CommandType.Text, params IDataParameter[] @params) + { + var watch = StartWatch(command); + var dbCommand = await CreateCommand(commandType, command, @params); + + try + { + var result = await dbCommand.ExecuteScalarAsync(); + + if (!command.ToLowerOrEmpty().StartsWith("select ")) + DatabaseStateChangeCommand.Raise(command, commandType, @params); + + return result; + } + catch (Exception ex) + { + throw new Exception("Error in running Scalar SQL Command.", ex).AddData("Command", command) + .AddData("Parameters", @params.Get(l => l.Select(p => p.ParameterName + "=" + p.Value).ToString(" | "))) + .AddData("ConnectionString", dbCommand.Connection.ConnectionString); + } + finally + { + dbCommand.Parameters.Clear(); + CloseConnection(dbCommand.Connection); + if (watch != null) DataAccessProfiler.Complete(watch); + } + } + + /// + /// Executes a database query and returns the result as a data set. + /// + public async Task ExecuteQuery(string databaseQuery, + CommandType commandType = CommandType.Text, + params IDataParameter[] @params) + { + using (var reader = await ExecuteReader(databaseQuery, commandType, @params)) + { + var table = new DataTable(); + table.Load(reader); + return table; + } + } + + /// + /// Executes the specified command text as nonquery. + /// + public async Task ExecuteBulkNonQueries(CommandType commandType, List> commands) + { + var connection = await GetOrCreateConnection(); + var result = 0; + + try + { + foreach (var c in commands) + { + var watch = StartWatch(c.Key); + + DbCommand dbCommand = null; + try + { + dbCommand = await CreateCommand(commandType, c.Key, connection, c.Value); + result += await dbCommand.ExecuteNonQueryAsync(); + + DatabaseStateChangeCommand.Raise(c.Key, commandType, c.Value); + } + catch (Exception ex) + { + throw new Exception("Error in executing SQL command.", ex).AddData("Command", c.Key) + .AddData("Parameters", c.Value.Get(l => l.Select(p => p.ParameterName + "=" + p.Value).ToString(" | "))); + } + finally + { + dbCommand?.Parameters.Clear(); + + if (watch != null) DataAccessProfiler.Complete(watch); + } + } + + return result; + } + catch (Exception ex) + { + throw new Exception("Error in running Non-Query SQL commands.", ex).AddData("ConnectionString", connection.ConnectionString); + } + finally + { + CloseConnection(connection); + } + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/Ado.Net/DataAccessProfiler.cs b/Olive.Entities.Data/Ado.Net/DataAccessProfiler.cs new file mode 100644 index 000000000..bafa66dc1 --- /dev/null +++ b/Olive.Entities.Data/Ado.Net/DataAccessProfiler.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; + +namespace Olive.Entities.Data +{ + /// + /// Provides SQL profiling services. + /// + public class DataAccessProfiler + { + internal static bool IsEnabled = Config.Get("Database.Profile.Enabled", defaultValue: false); + + static ConcurrentBag Watches = new ConcurrentBag(); + + static object SyncLock = new object(); + + public static void Reset() => Watches = new ConcurrentBag(); + + internal static Watch Start(string command) => new Watch(command); + + internal static void Complete(Watch watch) + { + watch.Duration = DateTime.Now.Subtract(watch.Start); + + Watches.Add(watch); + } + + internal class Watch + { + internal string Command; + internal DateTime Start; + internal TimeSpan Duration; + + public Watch(string command) + { + Command = command.ToLines().ToString(" "); + Start = DateTime.Now; + } + } + + public class ReportRow + { + public string Command { get; internal set; } + public int Calls { get; internal set; } + public double Total { get; internal set; } + public double Average { get; internal set; } + public double Median { get; internal set; } + public double Longest { get; internal set; } + } + + /// + /// To invoice this you can send a request to the application using http://...?Web.Test.Command=Sql.Profile&Mode=Snapshot + /// + /// Determines whether the current log data should be removed (false) or kept for future combined future generated (true). + public static ReportRow[] GenerateReport(bool snapshot = false) + { + var items = Watches.ToArray().GroupBy(x => x.Command); + + if (!snapshot) Reset(); + + return items.Select(item => new ReportRow + { + Command = item.Key, + Calls = item.Count(), + Total = item.Sum(x => x.Duration).TotalMilliseconds.Round(1), + Average = item.Select(x => (x.Duration.TotalMilliseconds)).Average().Round(1), + Median = item.Select(x => (int)(x.Duration.TotalMilliseconds * 100)).Median() * 0.01, + Longest = item.Max(x => x.Duration).TotalMilliseconds.Round(1) + }).ToArray(); + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/Ado.Net/DataProvider.cs b/Olive.Entities.Data/Ado.Net/DataProvider.cs new file mode 100644 index 000000000..50a04a23e --- /dev/null +++ b/Olive.Entities.Data/Ado.Net/DataProvider.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Olive.Entities.Data +{ + //#region Standard Providers + + ///// + ///// Provides a DataProvider for accessing data from the database using ADO.NET based on the OleDb provider. + ///// + //public abstract class OleDbDataProvider : DataProvider { } + + ///// + ///// Provides a DataProvider for accessing data from the database using ADO.NET based on the ODBC provider. + ///// + //public abstract class OdbcDataProvider : DataProvider { } + + //#endregion + + /// + /// Provides a DataProvider for accessing data from the database using ADO.NET. + /// + public abstract class DataProvider : IDataProvider + where TConnection : DbConnection, new() + where TDataParameter : IDbDataParameter, new() + { + public IDataAccess Access { get; } = new DataAccess(); + + protected DataProvider() => connectionStringKey = GetDefaultConnectionStringKey(); + + public abstract string MapColumn(string propertyName); + + public virtual string MapSubquery(string path) + { + throw new NotSupportedException($"{GetType().Name} does not provide a sub-query mapping for '{path}'."); + } + + static string[] ExtractIdsSeparator = new[] { "", "" }; + + string connectionStringKey, connectionString; + + static string GetDefaultConnectionStringKey() => "AppDatabase"; + + public virtual async Task BulkInsert(IEntity[] entities, int batchSize) + { + foreach (var item in entities) + await Entity.Database.Save(item, SaveBehaviour.BypassAll); + } + + public async Task BulkUpdate(IEntity[] entities, int batchSize) + { + foreach (var item in entities) + await Entity.Database.Save(item, SaveBehaviour.BypassAll); + } + + public abstract Task Count(IDatabaseQuery query); + + public static List ExtractIds(string idsXml) => + idsXml.Split(ExtractIdsSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); + + public bool SupportValidationBypassing() => true; + + /// + /// Executes the specified command text as nonquery. + /// + public async Task ExecuteNonQuery(string command) => await ExecuteNonQuery(command, CommandType.Text); + + /// + /// Executes the specified command text as nonquery. + /// + public async Task ExecuteNonQuery(string command, CommandType commandType, params IDataParameter[] @params) + { + using (new DatabaseContext(ConnectionString)) + // System.Diagnostics.Trace.WriteLine(command); + return await Access.ExecuteNonQuery(command, commandType, @params); + } + + /// + /// Executes the specified command text against the database connection of the context and builds an IDataReader. Make sure you close the data reader after finishing the work. + /// + public async Task ExecuteReader(string command, CommandType commandType, params IDataParameter[] @params) + { + using (new DatabaseContext(ConnectionString)) + // System.Diagnostics.Trace.WriteLine(command); + return await Access.ExecuteReader(command, commandType, @params); + } + + /// + /// Executes the specified command text against the database connection of the context and returns the single value. + /// + public async Task ExecuteScalar(string command) => await ExecuteScalar(command, CommandType.Text); + + /// + /// Executes the specified command text against the database connection of the context and returns the single value. + /// + public async Task ExecuteScalar(string command, CommandType commandType, params IDataParameter[] @params) + { + using (new DatabaseContext(ConnectionString)) + // System.Diagnostics.Trace.WriteLine(command); + return await Access.ExecuteScalar(command, commandType, @params); + } + + public IDictionary> GetUpdatedValues(IEntity original, IEntity updated) + { + if (original == null) throw new ArgumentNullException(nameof(original)); + + var result = new Dictionary>(); + + var type = original.GetType(); + var propertyNames = type.GetProperties().Distinct().Select(p => p.Name.Trim()).ToArray(); + + Func getProperty = name => type.GetProperties().Except(p => p.IsSpecialName || p.GetGetMethod().IsStatic).Where(p => p.GetSetMethod() != null && p.GetGetMethod().IsPublic).OrderByDescending(x => x.DeclaringType == type).FirstOrDefault(p => p.Name == name); + + var dataProperties = propertyNames.Select(getProperty).ExceptNull() + .Except(CalculatedAttribute.IsCalculated) + .Where(LogEventsAttribute.ShouldLog) + .ToArray(); + + foreach (var p in dataProperties) + { + var propertyType = p.PropertyType; + // Get the original value: + string originalValue, updatedValue = null; + if (propertyType == typeof(IList)) + { + try + { + originalValue = (p.GetValue(original) as IList).ToString(","); + if (updated != null) + updatedValue = (p.GetValue(updated) as IList).ToString(","); + } + catch + { + continue; + } + } + else if (propertyType.IsGenericType && !propertyType.IsNullable()) + { + try + { + originalValue = (p.GetValue(original) as IEnumerable).ToString(", "); + if (updated != null) + updatedValue = (p.GetValue(updated) as IEnumerable).ToString(", "); + } + catch + { + continue; + } + } + else + { + try + { + originalValue = $"{p.GetValue(original)}"; + if (updated != null) + updatedValue = $"{p.GetValue(updated)}"; + } + catch + { + continue; + } + } + + if (updated == null || originalValue != updatedValue) + if (result.LacksKey(p.Name)) + result.Add(p.Name, new Tuple(originalValue, updatedValue)); + } + + return result; + } + + /// + /// Creates a data parameter with the specified name and value. + /// + public IDataParameter CreateParameter(string parameterName, object value) + { + if (value == null) value = DBNull.Value; + + return new TDataParameter { ParameterName = parameterName.Remove(" "), Value = value }; + } + + /// + /// Creates a data parameter with the specified name and value and type. + /// + public IDataParameter CreateParameter(string parameterName, object value, DbType columnType) + { + if (value == null) value = DBNull.Value; + + return new TDataParameter { ParameterName = parameterName.Remove(" "), Value = value, DbType = columnType }; + } + + /// + /// Deletes the specified record. + /// + public abstract Task Delete(IEntity record); + + /// + /// Gets the specified record by its type and ID. + /// + public abstract Task Get(object objectID); + + /// + /// Gets the list of specified records. + /// + public abstract Task> GetList(IDatabaseQuery query); + + /// + /// Returns a direct database criterion used to eager load associated objects. + /// + public abstract DirectDatabaseCriterion GetAssociationInclusionCriteria(IDatabaseQuery query, + PropertyInfo association); + + /// + /// Reads the many to many relation. + /// + public abstract Task> ReadManyToManyRelation(IEntity instance, string property); + + /// + /// Saves the specified record. + /// + public abstract Task Save(IEntity record); + + /// + /// Generates data provider specific parameters for the specified data items. + /// + public IDataParameter[] GenerateParameters(Dictionary parametersData) => + parametersData.Select(GenerateParameter).ToArray(); + + /// + /// Generates a data provider specific parameter for the specified data. + /// + public virtual IDataParameter GenerateParameter(KeyValuePair data) => + new TDataParameter { Value = data.Value, ParameterName = data.Key.Remove(" ") }; + + public abstract Task Aggregate(IDatabaseQuery query, AggregateFunction function, string propertyName); + + #region Connection String + + /// + /// Gets or sets the connection string key used for this data provider. + /// + public string ConnectionStringKey + { + get => connectionStringKey; + set + { + if (value.HasValue()) LoadConnectionString(value); + + connectionStringKey = value; + } + } + + void LoadConnectionString(string key) => connectionString = Config.GetConnectionString(key); + + /// + /// Gets or sets the connection string key used for this data provider. + /// + public string ConnectionString + { + get + { + if (connectionString.HasValue()) return connectionString; + + if (connectionStringKey.HasValue()) + LoadConnectionString(connectionStringKey); + + return connectionString; + } + set + { + connectionString = value; + } + } + + #endregion + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/Ado.Net/DatabaseStateChangeCommand.cs b/Olive.Entities.Data/Ado.Net/DatabaseStateChangeCommand.cs new file mode 100644 index 000000000..19aada629 --- /dev/null +++ b/Olive.Entities.Data/Ado.Net/DatabaseStateChangeCommand.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Text; + +namespace Olive.Entities.Data +{ + public class DatabaseStateChangeCommand + { + public static event Action ExecutedChangeCommand; + + public string CommandText { get; private set; } + public CommandType CommandType { get; private set; } + public IDataParameter[] Params { get; private set; } + + internal static void Raise(string command, CommandType type, IDataParameter[] @params) + { + if (ExecutedChangeCommand == null) return; + + var item = new DatabaseStateChangeCommand + { + CommandText = command, + CommandType = type, + Params = @params + }; + + ExecutedChangeCommand?.Invoke(item); + } + } +} diff --git a/Olive.Entities.Data/Ado.Net/DbTransactionScope.cs b/Olive.Entities.Data/Ado.Net/DbTransactionScope.cs new file mode 100644 index 000000000..116f574f7 --- /dev/null +++ b/Olive.Entities.Data/Ado.Net/DbTransactionScope.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Threading.Tasks; + +namespace Olive.Entities.Data +{ + // TODO: If it's a Suppress, then simply in the GetDbTransaction return null. + // And test to see if the command will pass in case where other commands in a transaction in the same connection exist, + // and are rolled back. + + public class DbTransactionScope : ITransactionScope + { + IsolationLevel IsolationLevel; + DbTransactionScopeOption ScopeOption; + + public static DbTransactionScope Root + { + get => CallContext.GetData(nameof(Root)); + set => CallContext.SetData(nameof(Root), value); + } + + public static DbTransactionScope Current + { + get => CallContext.GetData(nameof(Current)); + set => CallContext.SetData(nameof(Current), value); + } + + public static DbTransactionScope Parent + { + get => CallContext.GetData(nameof(Parent)); + set => CallContext.SetData(nameof(Parent), value); + } + + // Per unique connection string, one record is added to this. + Dictionary> Connections = new Dictionary>(); + + bool IsCompleted, IsAborted; + + public DbTransactionScope() : this(GetDefaultIsolationLevel()) { } + + public DbTransactionScope(DbTransactionScopeOption scopeOption) : this(GetDefaultIsolationLevel(), scopeOption) { } + + public DbTransactionScope(IsolationLevel isolationLevel, DbTransactionScopeOption scopeOption = DbTransactionScopeOption.Required) + { + IsolationLevel = isolationLevel; + ScopeOption = scopeOption; + + Parent = Root; + Current = this; + + if (Root == null) Root = this; + } + + public Guid ID { get; } = Guid.NewGuid(); + + #region TransactionCompletedEvent + + event EventHandler TransactionCompleted; + + /// + /// Attaches an event handler to be invoked when the current (root) transaction is completed. + /// + public void OnTransactionCompleted(Action eventHandler) => Root.TransactionCompleted += (s, e) => eventHandler?.Invoke(); + + #endregion + + static IsolationLevel GetDefaultIsolationLevel() => + Config.Get("Default.Transaction.IsolationLevel", IsolationLevel.ReadUncommitted); + + internal async Task GetDbTransaction() + { + var connectionString = DataAccess.GetCurrentConnectionString(); + + await Setup(connectionString); + + return Connections[connectionString].Item2; + } + + internal async Task GetDbConnection() + { + var connectionString = DataAccess.GetCurrentConnectionString(); + + await Setup(connectionString); + + return Connections[connectionString].Item1; + } + + async Task Setup(string connectionString) + { + if (Connections.LacksKey(connectionString)) + { + var access = Database.Instance.GetAccess(connectionString); + + var connection = (DbConnection)await access.CreateConnection(); + var transaction = connection.BeginTransaction(IsolationLevel); + + Connections.Add(connectionString, Tuple.Create(connection, transaction)); + } + } + + public void Dispose() + { + if (IsAborted) return; + + if (this == Root) // Root + { + Root = null; + + if (IsCompleted) + { + // Happy scenario: + Connections.Do(x => x.Value.Item1.Close()); + } + else // Root is not completed. + { + IsAborted = true; + + Connections.Do(x => x.Value.Item2.Rollback()); + Connections.Do(x => x.Value.Item2.Dispose()); + Connections.Do(x => x.Value.Item1.Close()); + } + } + else + { + Current = Parent; + + if (IsCompleted) + { + // A Sub-transaction has been happily completed. + // Just wait for the parent. + } + else + { + // A sub transaction is not completed. + Root?.Dispose(); + } + } + } + + public void Complete() + { + if (IsAborted) + throw new Exception("This transaction is already aborted, probably due to a nested transaction not being completed."); + + IsCompleted = true; + + if (Root == this) + { + // I'm the root: + foreach (var item in Connections) + item.Value.Item2.Commit(); + + TransactionCompleted?.Invoke(this, EventArgs.Empty); + } + else + { + // Ignore, and wait for the parent Completion. + } + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/Ado.Net/ITransactionScope.cs b/Olive.Entities.Data/Ado.Net/ITransactionScope.cs new file mode 100644 index 000000000..152d45eeb --- /dev/null +++ b/Olive.Entities.Data/Ado.Net/ITransactionScope.cs @@ -0,0 +1,27 @@ +using System; +using System.ComponentModel; +using System.Transactions; + +namespace Olive.Entities.Data +{ + //public interface ITransactionScope : IDisposable + //{ + // void Complete(); + + // Guid ID { get; } + //} + + [EditorBrowsable(EditorBrowsableState.Never)] + public class TransactionScopeWrapper : ITransactionScope + { + TransactionScope Scope; + + public Guid ID { get; } = Guid.NewGuid(); + + public TransactionScopeWrapper(TransactionScope scope) { Scope = scope; } + + public void Complete() => Scope.Complete(); + + public void Dispose() => Scope.Dispose(); + } +} diff --git a/Olive.Entities.Data/Ado.Net/InterfaceDataProvider.cs b/Olive.Entities.Data/Ado.Net/InterfaceDataProvider.cs new file mode 100644 index 000000000..fd84e781c --- /dev/null +++ b/Olive.Entities.Data/Ado.Net/InterfaceDataProvider.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Olive.Entities.Data +{ + /// + /// Provides data access for Interface types. + /// + public class InterfaceDataProvider : IDataProvider + where TImplementationDataProvider : IDataProvider + { + Type InterfaceType; + static ConcurrentDictionary> ImplementationsCache = new ConcurrentDictionary>(); + + public InterfaceDataProvider(Type interfaceType) => InterfaceType = interfaceType; + + List GetImplementers() => ImplementationsCache.GetOrAdd(InterfaceType, FindImplementers); + + static List FindImplementers(Type interfaceType) + { + var result = new List(); + + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies().Where(a => a.References(interfaceType.Assembly))) + { + try + { + foreach (var type in assembly.GetTypes()) + { + if (type == interfaceType) continue; + if (type.IsInterface) continue; + + if (type.Implements(interfaceType)) + result.Add(type); + } + } + catch + { + // Can't load assembly + } + } + + // For any type, if it's parent is in the list, exclude it: + + var typesWithParentsIn = result.Where(x => result.Contains(x.BaseType)).ToArray(); + + foreach (var item in typesWithParentsIn) + result.Remove(item); + + return result; + } + + List FindProviders() + { + var implementers = GetImplementers(); + return implementers.Select(x => Database.Instance.GetProvider(x)).ToList(); + } + + public async Task Count(IDatabaseQuery query) + { + var providers = FindProviders(); + var results = await providers.Select(x => x.Count(query)).AwaitAll(); + return results.Sum(); + } + + public async Task> GetList(IDatabaseQuery query) + { + if (query.TakeTop.HasValue) + throw new Exception("Top() criteria is not allowed when querying based on Interfaces."); + + if (((DatabaseQuery)query).OrderByParts.Any()) + throw new Exception("OrderBy() is not allowed when querying based on Interfaces."); + + var providers = FindProviders(); + var results = await providers.Select(x => x.GetList(query)).AwaitAll(); + return results.SelectMany(x => x); + } + + public DirectDatabaseCriterion GetAssociationInclusionCriteria(IDatabaseQuery query, + PropertyInfo association) + { + throw new InvalidOperationException("Oops! GetAssociationInclusionCriteria() is not meant to be ever called on " + GetType().Name); + } + + public Task Aggregate(IDatabaseQuery query, AggregateFunction function, string propertyName) => + throw new NotSupportedException("Database.Aggregate doesn't work on interfaces."); + + public async Task Get(object objectID) + { + foreach (var actual in GetImplementers()) + { + try + { + if (await Entity.Database.Get(objectID, actual) is Entity result) return result; + } + catch { continue; } + } + + throw new Exception($"There is no {InterfaceType.Name} record with the ID of '{objectID}'"); + } + + public Task> ReadManyToManyRelation(IEntity instance, string property) => + throw new NotSupportedException("IDataProvider.ReadManyToManyRelation() is not supported for Interfaces"); + + public Task Save(IEntity record) => + throw new NotSupportedException("IDataProvider.Save() is irrelevant to Interfaces"); + + public Task Delete(IEntity record) => + throw new NotSupportedException("IDataProvider.Delete() is irrelevant to Interfaces"); + + public string MapColumn(string propertyName) => + throw new NotSupportedException("IDataProvider.Delete() is irrelevant to Interfaces"); + + public IDictionary> GetUpdatedValues(IEntity original, IEntity updated) => + throw new NotSupportedException("GetUpdatedValues() is irrelevant to Interfaces"); + + public Task ExecuteNonQuery(string command) => + throw new NotSupportedException("ExecuteNonQuery() is irrelevant to Interfaces"); + + public Task ExecuteScalar(string command) => + throw new NotSupportedException("ExecuteScalar() is irrelevant to Interfaces"); + + public bool SupportValidationBypassing() => + throw new NotSupportedException("SupportValidationBypassing() is irrelevant to Interfaces"); + + public Task BulkInsert(IEntity[] entities, int batchSize) => + throw new NotSupportedException("BulkInsert() is irrelevant to Interfaces"); + + public Task BulkUpdate(IEntity[] entities, int batchSize) => + throw new NotSupportedException("BulkInsert() is irrelevant to Interfaces"); + + public IDataAccess Access => + throw new NotSupportedException("Access is irrelevant to Interfaces"); + + public string MapSubquery(string path) => + throw new NotSupportedException("MapSubquery() is irrelevant to Interfaces"); + + public string ConnectionString { get; set; } + + public string ConnectionStringKey { get; set; } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/Ado.Net/SubqueryMapping.cs b/Olive.Entities.Data/Ado.Net/SubqueryMapping.cs new file mode 100644 index 000000000..5d577a52b --- /dev/null +++ b/Olive.Entities.Data/Ado.Net/SubqueryMapping.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Olive.Entities.Data +{ + public class SubqueryMapping + { + public string Path, Subquery; + public Dictionary Details; + + public SubqueryMapping(string path, string prefix, Dictionary destinationPropertyMappings) + { + Path = path; + Details = destinationPropertyMappings.ToDictionary(x => x.Key, x => + { + if (x.Value.StartsWith("[")) + return x.Value.Insert(1, prefix); + else return prefix + x.Value; + }); + } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/Engine/Cache.cs b/Olive.Entities.Data/Engine/Cache.cs new file mode 100644 index 000000000..546d96f63 --- /dev/null +++ b/Olive.Entities.Data/Engine/Cache.cs @@ -0,0 +1,302 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace Olive.Entities.Data +{ + /// + /// Provides a cache of objects retrieved from the database. + /// + public class Cache + { + object SyncLock = new object(); + public static Cache Instance = new Cache(); + Dictionary> Types = new Dictionary>(); + Dictionary> Lists = new Dictionary>(); + + // Note: This feature can prevent a rare concurrency issue in highly concurrent applications. + // But it comes at the cost of performance degradation. If your application doesn't have extremely concurrent processing + // with multiple threads reading and updating records at the same time, you can disable it in web.config to improve performance. + internal static bool IsConcurrencyAware = Config.Get("Database:Concurrency.Aware.Cache", defaultValue: true); + + internal static DateTime? GetQueryTimestamp() => IsConcurrencyAware ? DateTime.UtcNow : default(DateTime?); + + #region Row Version + + // Note: This is to solve the following concurrency issue: + // In highly concurrent systems the following scenario can happen. + // A GET call loads a record from DB. + // It then adds it to the cache. + // If that record is updated in between the two steps above, then bad data is added to the cache. + + internal ConcurrentDictionary> RowVersionCache + = new ConcurrentDictionary>(); + + public virtual bool IsUpdatedSince(IEntity instance, DateTime since) + { + var type = instance.GetType(); + if (!CanCache(type)) return false; + + var cache = RowVersionCache.GetOrDefault(type); + + return cache?.GetOrDefault(instance.GetId().ToString()) > since.Ticks; + } + + public virtual void UpdateRowVersion(IEntity entity) + { + var type = entity.GetType(); + if (!CanCache(type)) return; + + var cache = RowVersionCache.GetOrAdd(type, t => new ConcurrentDictionary()); + cache[entity.GetId().ToString()] = DateTime.UtcNow.Ticks; + } + + #endregion + + #region IsEnabled property + + static bool IsCachingEnabled = Config.Get("Database:Cache.Enabled", defaultValue: true); + + public static bool CanCache(Type type) => CacheObjectsAttribute.IsEnabled(type) ?? IsCachingEnabled; + + #endregion + + /// + /// Gets the current cache. + /// + public static Cache Current => Instance; + + Dictionary GetEntities(Type type) + { + var result = Types.TryGet(type); + + if (result == null) + { + lock (SyncLock) + { + result = Types.TryGet(type); + + if (result == null) + { + result = new Dictionary(); + Types.Add(type, result); + } + } + } + + return result; + } + + Dictionary GetLists(Type type, bool autoCreate = true) + { + var result = Lists.TryGet(type); + + if (result == null && autoCreate) + { + lock (SyncLock) + { + result = Lists.TryGet(type); + if (result == null) + { + result = new Dictionary(); + Lists.Add(type, result); + } + } + } + + return result; + } + + /// + /// Gets an entity from cache. Returns null if not found. + /// + public virtual IEntity Get(string id) + { + try + { + foreach (var type in Types.Keys.ToArray().Where(t => t.IsA())) + { + var result = Get(type, id); + if (result != null) return result; + } + } + catch { } + + return null; + } + + /// + /// Gets an entity from cache. Returns null if not found. + /// + public virtual TEntity Get(object id) where TEntity : IEntity => (TEntity)Get(typeof(TEntity), id.ToStringOrEmpty()); + + /// + /// Gets an entity from cache. Returns null if not found. + /// + public virtual IEntity Get(Type entityType, string id) + { + if (!CanCache(entityType)) return null; + + var entities = GetEntities(entityType); + + if (entities.ContainsKey(id)) + { + try + { + return entities[id]; + } + catch (KeyNotFoundException) + { + // A threading issue. + return Get(entityType, id); + } + } + else + { + foreach (var type in entityType.Assembly.GetSubTypes(entityType)) + { + var result = Get(type, id); + if (result != null) return result; + } + + return null; + } + } + + /// + /// Adds a given entity to the cache. + /// + public virtual void Add(IEntity entity) + { + if (!CanCache(entity.GetType())) return; + + var entities = GetEntities(entity.GetType()); + + lock (entities) + { + var id = entity.GetId().ToString(); + if (entities.ContainsKey(id)) + { + entities.GetOrDefault(id).Perform(x => x.InvalidateCachedReferences()); + entities.Remove(id); + } + + entities.Add(id, entity); + + ExpireLists(entity.GetType()); + } + } + + /// + /// Removes a given entity from the cache. + /// + public virtual void Remove(IEntity entity) + { + entity.InvalidateCachedReferences(); + + if (!(entity is IApplicationEvent)) + foreach (var type in CacheDependentAttribute.GetDependentTypes(entity.GetType())) + Remove(type, invalidateCachedReferences: true); + + if (!CanCache(entity.GetType())) return; + + var entities = GetEntities(entity.GetType()); + + lock (entities) + { + var id = entity.GetId().ToString(); + + if (entities.ContainsKey(id)) entities.Remove(id); + + ExpireLists(entity.GetType()); + } + + if (this != Current) Current.Remove(entity); + } + + /// + /// Removes all entities of a given types from the cache. + /// + public virtual void Remove(Type type, bool invalidateCachedReferences = false) + { + if (!CanCache(type)) return; + + lock (SyncLock) + { + foreach (var inherited in Types.Keys.Where(t => t.BaseType == type).ToList()) + Remove(inherited, invalidateCachedReferences); + } + + if (Types.ContainsKey(type)) + { + lock (SyncLock) + { + if (Types.ContainsKey(type)) + { + var entities = Types[type]; + lock (entities) + { + Types.Remove(type); + ExpireLists(type); + + if (invalidateCachedReferences) + entities.Do(e => e.Value.InvalidateCachedReferences()); + } + } + } + } + + if (this != Current) + Current.Remove(type, invalidateCachedReferences); + } + + public virtual void ExpireLists(Type type) + { + if (!CanCache(type)) return; + + for (var parentType = type; parentType != typeof(Entity); parentType = parentType.BaseType) + { + var lists = GetLists(parentType, autoCreate: false); + + if (lists != null) lock (lists) lists.Clear(); + } + + if (this != Current) Current.ExpireLists(type); + } + + public virtual IEnumerable GetList(Type type, string key) + { + if (!CanCache(type)) return null; + + var lists = GetLists(type); + lock (lists) + { + if (lists.ContainsKey(key)) return lists[key]; + else return null; + } + } + + public virtual void AddList(Type type, string key, IEnumerable list) + { + if (!CanCache(type)) return; + + var lists = GetLists(type); + + lock (lists) lists[key] = list; + } + + public virtual void ClearAll() + { + lock (SyncLock) + { + RowVersionCache = new ConcurrentDictionary>(); + Types.Clear(); + Lists.Clear(); + } + } + + internal int CountAllObjects() => Types.Sum(t => t.Value.Count); + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/Engine/DataProviderFactoryInfo.cs b/Olive.Entities.Data/Engine/DataProviderFactoryInfo.cs new file mode 100644 index 000000000..1b308774c --- /dev/null +++ b/Olive.Entities.Data/Engine/DataProviderFactoryInfo.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; + +namespace Olive.Entities.Data +{ + public class DataProviderFactoryInfo + { + string mappingDirectory; + + public string MappingResource { get; set; } + + public string AssemblyName { get; set; } + public string TypeName { get; set; } + public string ProviderFactoryType { get; set; } + + public string MappingDirectory + { + get => mappingDirectory; + set + { + if (value == null) + mappingDirectory = string.Empty; + + else if (value.StartsWith("\\\\") || value.Contains(":")) + // Absolute path: + mappingDirectory = value; + + else + { + mappingDirectory = AppDomain.CurrentDomain.BaseDirectory + "/" + value + "/"; + mappingDirectory = mappingDirectory.Replace("/", "\\"); + + mappingDirectory = mappingDirectory.KeepReplacing(@"\\", @"\"); + } + } + } + + public string ConnectionStringKey { get; set; } + + public string ConnectionString { get; set; } + + public Assembly Assembly { get; set; } + public Type Type { get; set; } + + public Assembly GetAssembly() + { + if (Assembly == null) + Assembly = AppDomain.CurrentDomain.LoadAssembly(AssemblyName); + + return Assembly; + } + + public Type GetMappedType() + { + if (Type != null) return Type; + + if (TypeName.HasValue()) Type = GetAssembly().GetType(TypeName); + + return Type; + } + + public async Task LoadMappingXml() + { + if (MappingResource.IsEmpty()) + throw new Exception("No MappingResource is specified for this data provider factory."); + + if (MappingResource.Contains("/") || MappingResource.Contains("\\")) + { + // Physical file: + var path = AppDomain.CurrentDomain.BaseDirectory + MappingResource; + path = path.Replace("/", "\\").Replace("\\\\", "\\"); + + if (path.StartsWith("\\")) path = "\\" + path; + + if (!File.Exists(path)) + throw new FileNotFoundException("Could not find the data mapping xml at : " + path); + + return await File.ReadAllTextAsync(path); + } + else + { + // Embedded resource: + foreach (var resourceName in GetAssembly().GetManifestResourceNames()) + { + if (resourceName.ToLower() == MappingResource.ToLower() || resourceName.ToLower().EndsWith("." + MappingResource.ToLower())) + return await LoadMappingText(resourceName); + + // using (var resource = GetAssembly().GetManifestResourceStream(resourceName)) + // { + // using (var reader = new StreamReader(resource)) + // { + // return reader.ReadToEnd(); + // } + // } + } + + throw new Exception($"Could not build a data provider factory for {GetAssembly().FullName} because: " + + $"Could not load the manifest resource {MappingResource} from the assembly{GetAssembly().FullName}."); + } + } + + public async Task LoadMappingText(string resourceName) + { + try + { + using (var resource = GetAssembly().GetManifestResourceStream(resourceName)) + { + using (var reader = new StreamReader(resource)) + return await reader.ReadToEndAsync(); + } + } + catch (Exception ex) + { + throw new Exception("Could not load the manifest resource text for '{0}'".FormatWith(resourceName), ex); + } + } + } +} diff --git a/Olive.Entities.Data/Engine/DataProviderModelConfigurationSection.cs b/Olive.Entities.Data/Engine/DataProviderModelConfigurationSection.cs new file mode 100644 index 000000000..6ce5e8ce0 --- /dev/null +++ b/Olive.Entities.Data/Engine/DataProviderModelConfigurationSection.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Olive.Entities.Data +{ + public class DataProviderModelConfigurationSection + { + public List Providers { get; set; } + + /// + /// Gets or sets the SyncFilePath of this DataProviderModelConfigurationSection. + /// + public string SyncFilePath { get; set; } + + /// + /// Gets or sets the SyncFilePath of this DataProviderModelConfigurationSection. + /// + public string FileDependancyPath { get; set; } + } +} \ No newline at end of file diff --git a/Olive.Entities.Data/Extensions/@Misc.cs b/Olive.Entities.Data/Extensions/@Misc.cs new file mode 100644 index 000000000..7cd6613fd --- /dev/null +++ b/Olive.Entities.Data/Extensions/@Misc.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SqlTypes; +using System.IO; +using System.Linq; + +namespace Olive.Entities.Data +{ + public static partial class OliveExtensions + { + /// + /// Returns a MS T-SQL-safe DateTime value for use in queries (i.e. prevents date values earlier than 1/1/1753). + /// + public static DateTime GetSqlSafeValue(this DateTime value) => + value < SqlDateTime.MinValue.Value ? SqlDateTime.MinValue.Value : value; + + /// + /// Gets a virtual URL to this file. If the file is not in the current website folder it throws an exception. + /// + public static string ToVirtualPath(this FileInfo file) + { + if (!file.FullName.StartsWith(AppDomain.CurrentDomain.BaseDirectory, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"The file {file.FullName} is not in the current website folder."); + + var path = "/" + file.FullName.Substring(AppDomain.CurrentDomain.BaseDirectory.Length).TrimStart("\\").TrimStart("/"); + return path.Replace("\\", "/"); + } + + /// + /// Returns a DataTable with columns based on the public properties of type T and the rows + /// populated with the values in those properties for each item in this IEnumerable. + /// + /// Optional name for the DataTable (defaults to the plural of the name of type T). + public static DataTable ToDataTable(this IEnumerable items, string tableName = null) + { + var properties = typeof(T).GetProperties(); + + var dataTable = new DataTable(tableName.Or(typeof(T).Name.ToPlural())); + + foreach (var property in properties) + dataTable.Columns.Add(property.Name); + + foreach (T item in items) + { + var row = dataTable.NewRow(); + + foreach (var property in properties) + row[property.Name] = property.GetValue(item); + + dataTable.Rows.Add(row); + } + + return dataTable; + } + + public static int? GetResultsToFetch(this IEnumerable options) => + options.OfType().FirstOrDefault()?.Number; + } +} diff --git a/Olive.Entities.Data/Extensions/DataTable.cs b/Olive.Entities.Data/Extensions/DataTable.cs new file mode 100644 index 000000000..423effd1b --- /dev/null +++ b/Olive.Entities.Data/Extensions/DataTable.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace Olive.Entities.Data +{ + partial class OliveExtensions + { + /// + /// Casts this data table's records into a list of typed objects. + /// + public static IEnumerable CastTo(this DataTable dataTable) where T : new() => + CastTo(dataTable, null); + + /// + /// Casts this data table's records into a list of typed objects. + /// An anonymouse object containing property mapping information. + /// e.g.: new {Property1 = "Property name in CSV", Property2 = "...", set_Property1 = new Func<string, object>(text => Client.Parse(value)) } + /// + public static IEnumerable CastTo(this DataTable dataTable, object propertyMappings) where T : new() => + CastAsDictionary(dataTable, propertyMappings).Select(i => i.Key).ToList(); + + /// + /// Casts this data table's records into a list of typed objects. + /// An anonymouse object containing property mapping information. + /// e.g.: new {Property1 = "Property name in CSV", Property2 = "...", set_Property1 = new Func<string, object>(text => Client.Parse(value)) } + /// + public static Dictionary CastAsDictionary(this DataTable data, object propertyMappings) where T : new() + { + if (propertyMappings != null) + foreach (var p in propertyMappings.GetType().GetProperties()) + { + if (p.PropertyType == typeof(string)) continue; + + if (p.PropertyType == typeof(Func)) + { + if (!p.Name.StartsWith("set_")) + throw new ArgumentException("Property convertors must start with 'set_{property name}'"); + + continue; + } + + throw new ArgumentException($"Unrecognized value for the property {p.PropertyType} of the specified propertyMappings"); + } + + var mappings = FindPropertyMappings(typeof(T), data.Columns, propertyMappings); + + var convertors = new Dictionary>(); + if (propertyMappings != null) + convertors = propertyMappings.GetType().GetProperties().Where(p => p.PropertyType == typeof(Func)) + .ToDictionary(p => p.Name.Substring(4), p => (Func)p.GetValue(propertyMappings)); + + var result = new Dictionary(); + + foreach (DataRow record in data.Rows) + { + var item = ParseObject(record, mappings, convertors); + result.Add(item, record); + } + + return result; + } + + /// + /// Finds the property mappings for the specified target type, CSV column names and user declared mappings. + /// + static Dictionary FindPropertyMappings(Type targetType, DataColumnCollection columns, object declaredMappings) + { + var result = new Dictionary(); + + if (declaredMappings != null) + { + foreach (var property in declaredMappings.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (property.Name.StartsWith("set_")) + { + if (!result.ContainsKey(property.Name.TrimStart("set_"))) + result.Add(property.Name.TrimStart("set_"), null); + continue; + } + + // Validate property name: + var propertyInTarget = targetType.GetProperty(property.Name); + if (propertyInTarget == null) + throw new Exception(targetType.FullName + " does not have a property named " + property.Name); + + if (!propertyInTarget.CanWrite) + throw new Exception("{0}.{1} property is read-only.".FormatWith(targetType.FullName, property.Name)); + + var mappedName = (string)property.GetValue(declaredMappings); + result[property.Name] = mappedName; + } + } + + var columnNames = columns.Cast().Select(c => c.ColumnName).ToArray(); + + foreach (var property in targetType.GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)) + { + if (!property.CanWrite) + continue; + + if (result.ContainsKey(property.Name) && result[property.Name] != null) + continue; // Already added in explicit mappings. + + // Otherwise, if a column with that name is available, then that's it: + var potential = columnNames.Where(c => c.Replace(" ", "").ToLower() == property.Name.ToLower()); + if (potential.IsSingle()) + { + result[property.Name] = potential.Single(); + } + else if (potential.Any()) + { + throw new Exception("The specified data contains multiple potential matches for the property '{0}'. The potentially matched columns found: {1}. You must use explicit mappings in this case." + .FormatWith(property.Name, potential.Select(c => $"'{c}'").ToString(", "))); + } + } + + return result; + } + + /// + /// Creates an object of the specified type with the specified data and property mappings. + /// + static T ParseObject(DataRow dataContainer, Dictionary propertyMappings, Dictionary> convertors) + { + var result = Activator.CreateInstance(); + + foreach (var mapping in propertyMappings) + { + var property = result.GetType().GetProperty(mapping.Key); + + string data; + + if (mapping.Value == null) + // The setter for this property is identified, while no property mapping is specified. + data = null; + else + data = dataContainer[mapping.Value]?.ToString().TrimOrNull(); + + try + { + object dataToSet; + + if (convertors.ContainsKey(mapping.Key)) + dataToSet = convertors[mapping.Key](data); + else + dataToSet = data.To(property.PropertyType); + + property.SetValue(result, dataToSet); + } + catch (Exception ex) + { + throw new Exception("Could not set the value of the property '{0}' from the value of '{1}'.".FormatWith(mapping.Key, data), ex); + } + } + + return result; + } + + /// + /// Gets the CSV data equivalent to this data table. + /// + public static string ToCSV(this DataTable table) + { + var result = new StringBuilder(); + for (int i = 0; i < table.Columns.Count; i++) + { + result.Append(table.Columns[i].ColumnName); + result.Append(i == table.Columns.Count - 1 ? "\n" : ","); + } + + foreach (DataRow row in table.Rows) + { + for (int i = 0; i < table.Columns.Count; i++) + { + result.Append(row[i].ToString()); + result.Append(i == table.Columns.Count - 1 ? "\n" : ","); + } + } + + return result.ToString(); + } + + /// + /// Gets the rows of this data table in a LINQ-able format.. + /// + public static IEnumerable GetRows(this DataTable dataTable) => dataTable.Rows.Cast(); + } +} diff --git a/Olive.Entities.Data/Extensions/TransactionExtensions.cs b/Olive.Entities.Data/Extensions/TransactionExtensions.cs new file mode 100644 index 000000000..77c0b2ca0 --- /dev/null +++ b/Olive.Entities.Data/Extensions/TransactionExtensions.cs @@ -0,0 +1,29 @@ +using System.Transactions; + +namespace Olive.Entities.Data +{ + /// + /// Provides extension methods for transaction classes. + /// + public static class TransactionExtensions + { + /// + /// Creates a new transaction scope with this isolation level. + /// + public static TransactionScope CreateScope(this IsolationLevel isolationLevel) => + CreateScope(isolationLevel, TransactionScopeOption.Required); + + /// + /// Creates a new transaction scope with this isolation level. + /// public static TransactionScope CreateScope(this IsolationLevel isolationLevel, TransactionScopeOption scopeOption) + public static TransactionScope CreateScope(this IsolationLevel isolationLevel, TransactionScopeOption scopeOption) + { + var options = new TransactionOptions + { + IsolationLevel = isolationLevel, + Timeout = TransactionManager.DefaultTimeout + }; + return new TransactionScope(scopeOption, options); + } + } +} diff --git a/Olive.Entities.Data/Olive.Entities.Data.csproj b/Olive.Entities.Data/Olive.Entities.Data.csproj new file mode 100644 index 000000000..8143b4e3c --- /dev/null +++ b/Olive.Entities.Data/Olive.Entities.Data.csproj @@ -0,0 +1,26 @@ + + + + netcoreapp2.0 + Olive.Entities.Data + Olive.Entities.Data + + + + ..\@Assemblies\ + ..\@Assemblies\netcoreapp2.0\Olive.Entities.Data.xml + 1701;1702;1705;1591;1573 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Olive.Entities.Data/Package.nuspec b/Olive.Entities.Data/Package.nuspec new file mode 100644 index 000000000..91a8798dc --- /dev/null +++ b/Olive.Entities.Data/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.Entities.Data + 1.0.7 + Olive Entities Data + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Olive.Entities/Attributes/AutoNumberAttribute.cs b/Olive.Entities/Attributes/AutoNumberAttribute.cs new file mode 100644 index 000000000..e534ee833 --- /dev/null +++ b/Olive.Entities/Attributes/AutoNumberAttribute.cs @@ -0,0 +1,20 @@ +using System; +using System.Reflection; + +namespace Olive.Entities +{ + /// + /// When applied to a property, indicates that such property is AutoNumber (or Identity in SQL Server). + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class AutoNumberAttribute : Attribute + { + /// + /// Determines if a given property is auto number. + /// + public static bool IsAutoNumber(PropertyInfo property) + { + return property.GetCustomAttribute(inherit: false) != null; + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Attributes/CacheDependantAttribute.cs b/Olive.Entities/Attributes/CacheDependantAttribute.cs new file mode 100644 index 000000000..35c7b93e6 --- /dev/null +++ b/Olive.Entities/Attributes/CacheDependantAttribute.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Olive.Entities +{ + [AttributeUsage(AttributeTargets.Property)] + public sealed class CacheDependentAttribute : Attribute + { + /// + /// Gets the dependent type. + /// + public Type DependentType { get; private set; } + + /// + /// Creates a new CacheDependantAttribute instance. + /// + public CacheDependentAttribute(Type dependentType) => + DependentType = dependentType ?? throw new ArgumentNullException(nameof(dependentType)); + + static ConcurrentDictionary Cache = new ConcurrentDictionary(); + + /// + /// Gets a list of types that depend on a given entity. + /// + public static IEnumerable GetDependentTypes(Type entityType) + { + if (entityType == null) + throw new ArgumentNullException(nameof(entityType)); + + return Cache.GetOrAdd(entityType, FindDependentTypes); + } + + /// + /// Finds a list of types that depend on a given entity. + /// + static Type[] FindDependentTypes(Type entityType) + { + return (from type in entityType.Assembly.GetTypes() + from p in type.GetProperties() + let att = p.GetCustomAttribute() + where att != null && att.DependentType.IsAssignableFrom(entityType) + select type).Distinct().ToArray(); + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Attributes/CacheObjectsAttribute.cs b/Olive.Entities/Attributes/CacheObjectsAttribute.cs new file mode 100644 index 000000000..17873aba5 --- /dev/null +++ b/Olive.Entities/Attributes/CacheObjectsAttribute.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Olive.Entities +{ + /// + /// Specifies if a type is cacheable. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface)] + public sealed class CacheObjectsAttribute : Attribute + { + static object DetectAndCacheShouldBeStaticMethod = new object(); + + static Dictionary Cache = new Dictionary(); + + static object SyncLock = new object(); + + bool Enabled; + + /// + /// Creates a new CacheObjectsAttribute instance. + /// + public CacheObjectsAttribute(bool enabled) => Enabled = enabled; + + /// + /// Determines if caching is enabled for a given type. + /// + public static bool? IsEnabled(Type type) + { + if (Cache.TryGetValue(type, out bool? result)) return result; + + return DetectAndCache(type); + } + + static bool? DetectAndCache(Type type) + { + lock (DetectAndCacheShouldBeStaticMethod) + { + var usage = type.GetCustomAttributes(inherit: true).FirstOrDefault(); + + var result = default(bool?); + + if (usage != null) result = usage.Enabled; + + try { return Cache[type] = result; } + catch { return result; } + } + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Attributes/CalculatedAttribute.cs b/Olive.Entities/Attributes/CalculatedAttribute.cs new file mode 100644 index 000000000..40be5ce76 --- /dev/null +++ b/Olive.Entities/Attributes/CalculatedAttribute.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Olive.Entities +{ + /// + /// When applied to a property, indicates that such property does not exist in the database. + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class CalculatedAttribute : Attribute + { + static object DetectAndCacheShouldBeStaticMethod = new object(); + + static Dictionary Cache = new Dictionary(); + + /// + /// Determines if a given property is calculated. + /// + public static bool IsCalculated(PropertyInfo property) + { + if (Cache.TryGetValue(property, out bool result)) return result; + + return DetectAndCache(property); + } + + static bool DetectAndCache(PropertyInfo property) + { + lock (DetectAndCacheShouldBeStaticMethod) + { + var result = property.IsDefined(typeof(CalculatedAttribute), inherit: true); + + try { return Cache[property] = result; } + catch { return result; } + } + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Attributes/DateOnlyAttribute.cs b/Olive.Entities/Attributes/DateOnlyAttribute.cs new file mode 100644 index 000000000..92cf62fc6 --- /dev/null +++ b/Olive.Entities/Attributes/DateOnlyAttribute.cs @@ -0,0 +1,11 @@ +namespace Olive.Entities +{ + using System; + + /// + /// When applied to a property of type DateTime or Nullable[DateTime] it specifies that values are for Date only, + /// and the time part is meant to be disregarded. + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Method | AttributeTargets.Parameter)] + public class DateOnlyAttribute : Attribute { } +} \ No newline at end of file diff --git a/Olive.Entities/Attributes/IdByDatabaseAttribute.cs b/Olive.Entities/Attributes/IdByDatabaseAttribute.cs new file mode 100644 index 000000000..fefff3f93 --- /dev/null +++ b/Olive.Entities/Attributes/IdByDatabaseAttribute.cs @@ -0,0 +1,9 @@ +using System; + +namespace Olive.Entities +{ + [AttributeUsage(AttributeTargets.Class)] + public class IdByDatabaseAttribute : Attribute + { + } +} diff --git a/Olive.Entities/Attributes/InitializerAttribute.cs b/Olive.Entities/Attributes/InitializerAttribute.cs new file mode 100644 index 000000000..37d42ce00 --- /dev/null +++ b/Olive.Entities/Attributes/InitializerAttribute.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Reflection; + +namespace Olive.Entities +{ + /// + /// + /// + [AttributeUsage(AttributeTargets.Class)] + public class InitializerAttribute : Attribute + { + public string InitializerMethodName { get; } + + public InitializerAttribute(string initializerMethodName = "Initialize") => + InitializerMethodName = initializerMethodName; + + public static void InvokeInitializeMethod(Assembly assembly) where T : InitializerAttribute + { + var initializers = assembly.SelectTypesByAttribute(inherit: false); + + if (initializers.Count() == 1) + { + var initializer = initializers.First(); + var initializerAttribute = initializer.GetCustomAttribute(inherit: false); + + (initializer.GetMethod(initializerAttribute.InitializerMethodName) ?? + throw new Exception($"The initailizer class does not have the {initializerAttribute.InitializerMethodName} method or it is not a static method.") + ).Invoke(null, null); + } + + else if (initializers.Count() == 0) + throw new Exception("The given assembly has no initializer."); + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Attributes/LogEventsAttribute.cs b/Olive.Entities/Attributes/LogEventsAttribute.cs new file mode 100644 index 000000000..66a9cb301 --- /dev/null +++ b/Olive.Entities/Attributes/LogEventsAttribute.cs @@ -0,0 +1,31 @@ +using System; +using System.Reflection; + +namespace Olive.Entities +{ + /// + /// When applied to a class, indicates whether data access events should be logged for instances of that type. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Property)] + public sealed class LogEventsAttribute : Attribute + { + const bool DEFAULT_UNCONFIGURED = true; + + public bool Log { get; private set; } + + /// + /// Creates a new LogEventsAttribute instance. + /// + public LogEventsAttribute(bool shouldLog) => Log = shouldLog; + + public static bool ShouldLog(Type type) + { + var definedAttribute = type.GetCustomAttribute(inherit: true); + + return definedAttribute?.Log ?? DEFAULT_UNCONFIGURED; + } + + public static bool ShouldLog(PropertyInfo property) => + property.GetCustomAttribute(inherit: true)?.Log ?? true; + } +} diff --git a/Olive.Entities/Attributes/ManyToManyAttribute.cs b/Olive.Entities/Attributes/ManyToManyAttribute.cs new file mode 100644 index 000000000..eec2efb29 --- /dev/null +++ b/Olive.Entities/Attributes/ManyToManyAttribute.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Olive.Entities +{ + [AttributeUsage(AttributeTargets.Property)] + public sealed class ManyToManyAttribute : Attribute + { + static ConcurrentDictionary, PropertyInfo[]> Cache = new ConcurrentDictionary, PropertyInfo[]>(); + + /// + /// Gets or sets the Lazy of this ManyToManyAttribute. + /// + public bool Lazy { get; set; } + + /// + /// Gets a list of types that depend on a given entity. + /// + public static IEnumerable GetManyToManyProperties(Type type) => GetManyToManyProperties(type, lazy: null); + + /// + /// Gets a list of types that depend on a given entity. + /// + public static IEnumerable GetManyToManyProperties(Type type, bool? lazy) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + + var key = Tuple.Create(type, lazy); + + return Cache.GetOrAdd(key, x => FindManyToManyProperties(x.Item1, x.Item2)); + } + + /// + /// Returns a list of types that depend on a given entity. + /// + static PropertyInfo[] FindManyToManyProperties(Type type, bool? lazy) + { + return (from p in type.GetProperties() + let att = p.GetCustomAttribute(inherit: true) + where att != null + where lazy == null || att.Lazy == lazy + select p).Distinct().ToArray(); + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Attributes/PersistentAttribute.cs b/Olive.Entities/Attributes/PersistentAttribute.cs new file mode 100644 index 000000000..99870a7e8 --- /dev/null +++ b/Olive.Entities/Attributes/PersistentAttribute.cs @@ -0,0 +1,34 @@ +using System; + +namespace Olive.Entities +{ + /// + /// When applied to a class, defines its Application data accessor type. + /// + public sealed class PersistentAttribute : Attribute + { + bool IsPersistent; + public PersistentAttribute(bool isPersistent) + { + IsPersistent = isPersistent; + } + + internal static bool IsTypePersistent(Type type) + { + if (type.GetInterface(typeof(IEntity).FullName) == null) + return false; + + if (type.IsDefined(typeof(PersistentAttribute), inherit: true)) + { + foreach (PersistentAttribute attribute in type.GetCustomAttributes(typeof(PersistentAttribute), inherit: true)) + { + if (attribute.IsPersistent == false) + return false; + } + } + + // Default unconfigured value is true: + return true; + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Attributes/SecureFileAttribute.cs b/Olive.Entities/Attributes/SecureFileAttribute.cs new file mode 100644 index 000000000..aa2a80b30 --- /dev/null +++ b/Olive.Entities/Attributes/SecureFileAttribute.cs @@ -0,0 +1,9 @@ +using System; + +namespace Olive.Entities +{ + [AttributeUsage(AttributeTargets.Property)] + public class SecureFileAttribute : Attribute + { + } +} diff --git a/Olive.Entities/Attributes/SoftDeleteAttribute.cs b/Olive.Entities/Attributes/SoftDeleteAttribute.cs new file mode 100644 index 000000000..1759ed4bf --- /dev/null +++ b/Olive.Entities/Attributes/SoftDeleteAttribute.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; + +namespace Olive.Entities +{ + [AttributeUsage(AttributeTargets.Class)] + public sealed class SoftDeleteAttribute : Attribute + { + static object DetectAndCacheShouldBeStaticMethod = new object(); + + static Dictionary Cache = new Dictionary(); + + /// + /// Determines if soft delete is enabled for a given type. + /// + public static bool IsEnabled(Type type) + { + if (type == null) + throw new ArgumentNullException(nameof(type)); + + if (Cache.TryGetValue(type, out bool result)) return result; + + return DetectAndCache(type); + } + + static bool DetectAndCache(Type type) + { + lock (DetectAndCacheShouldBeStaticMethod) + { + var result = type.IsDefined(typeof(SoftDeleteAttribute), inherit: true); + + try { return Cache[type] = result; } + catch { return result; } + } + } + + public static bool RequiresSoftdeleteQuery() => RequiresSoftdeleteQuery(typeof(T)); + + public static bool RequiresSoftdeleteQuery(Type type) + { + if (!IsEnabled(type)) return false; + + return !Context.ShouldByPassSoftDelete(); + } + + /// + /// Provides support for bypassing softdelete rule. + /// + public class Context : IDisposable + { + bool BypassSoftdelete; + + Context ParentContext; + + public static Context Current + { + get => CallContext.GetData(nameof(Current)); + set => CallContext.SetData(nameof(Current), value); + } + + /// + /// Creates a new Context instance. + /// + public Context(bool bypassSoftdelete) + { + BypassSoftdelete = bypassSoftdelete; + + // Get from current thread: + + if (Current != null) + ParentContext = Current; + Current = this; + } + + public void Dispose() => Current = ParentContext; + + /// + /// Determines if SoftDelete check should the bypassed in the current context. + /// + public static bool ShouldByPassSoftDelete() + { + if (Current == null) return false; + else return Current.BypassSoftdelete; + } + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Attributes/TransientEntityAttribute.cs b/Olive.Entities/Attributes/TransientEntityAttribute.cs new file mode 100644 index 000000000..08bef4966 --- /dev/null +++ b/Olive.Entities/Attributes/TransientEntityAttribute.cs @@ -0,0 +1,16 @@ +using System; +using System.Reflection; + +namespace Olive.Entities +{ + [AttributeUsage(AttributeTargets.Class)] + public sealed class TransientEntityAttribute : Attribute + { + public static bool IsTransient(Type type) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + + return type.GetCustomAttribute(inherit: true) != null; + } + } +} diff --git a/Olive.Entities/Attributes/UserInfoAccessorInitializerAttribute.cs b/Olive.Entities/Attributes/UserInfoAccessorInitializerAttribute.cs new file mode 100644 index 000000000..3aeb5fdf5 --- /dev/null +++ b/Olive.Entities/Attributes/UserInfoAccessorInitializerAttribute.cs @@ -0,0 +1,7 @@ +namespace Olive.Entities +{ + public class UserInfoAccessorInitializerAttribute : InitializerAttribute + { + public UserInfoAccessorInitializerAttribute(string initializerMethodName = "Initialize") : base(initializerMethodName) { } + } +} diff --git a/Olive.Entities/Auditing/ApplicationEventManager.cs b/Olive.Entities/Auditing/ApplicationEventManager.cs new file mode 100644 index 000000000..16eec9abf Binary files /dev/null and b/Olive.Entities/Auditing/ApplicationEventManager.cs differ diff --git a/Olive.Entities/Auditing/AuditSaveEventArgs.cs b/Olive.Entities/Auditing/AuditSaveEventArgs.cs new file mode 100644 index 000000000..358d869e1 --- /dev/null +++ b/Olive.Entities/Auditing/AuditSaveEventArgs.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace Olive.Entities +{ + public class AuditSaveEventArgs : CancelEventArgs + { + public IEntity Entity { get; set; } + public IApplicationEvent ApplicationEvent { get; set; } + public SaveMode SaveMode { get; set; } + } + + public class AuditDeleteEventArgs : CancelEventArgs + { + public IEntity Entity { get; set; } + public IApplicationEvent ApplicationEvent { get; set; } + } +} \ No newline at end of file diff --git a/Olive.Entities/Auditing/DefaultApplicationEventManager.cs b/Olive.Entities/Auditing/DefaultApplicationEventManager.cs new file mode 100644 index 000000000..88443e41d Binary files /dev/null and b/Olive.Entities/Auditing/DefaultApplicationEventManager.cs differ diff --git a/Olive.Entities/Auditing/IApplicationEvent.cs b/Olive.Entities/Auditing/IApplicationEvent.cs new file mode 100644 index 000000000..286512142 --- /dev/null +++ b/Olive.Entities/Auditing/IApplicationEvent.cs @@ -0,0 +1,17 @@ +using System; + +namespace Olive.Entities +{ + [LogEvents(false)] + [CacheObjects(false)] + public interface IApplicationEvent : IEntity + { + string Data { get; set; } + DateTime Date { get; set; } + string Event { get; set; } + string IP { get; set; } + string ItemKey { get; set; } + string ItemType { get; set; } + string UserId { get; set; } + } +} diff --git a/Olive.Entities/Auditing/UndoContext.cs b/Olive.Entities/Auditing/UndoContext.cs new file mode 100644 index 000000000..d3a602e26 --- /dev/null +++ b/Olive.Entities/Auditing/UndoContext.cs @@ -0,0 +1,124 @@ +namespace Olive.Entities +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using System.Xml.Linq; + + public class UndoContext + { + public readonly List> Operations = new List>(); + public DateTime Date { get; set; } + + /// + /// Creates a new UndoContext instance. + /// + public UndoContext() + { + Date = LocalTime.Now; + } + + public static async Task Deserialize(XElement change) + { + var result = new UndoContext + { + Date = new DateTime(change.GetValue("@Date")) + }; + + foreach (var op in change.Elements()) + { + var eventId = op.GetValue("@ID"); + var ev = await Entity.Database.Get(eventId); + result.Operations.Add(new KeyValuePair(ev, op.GetValue("@Description"))); + } + + return result; + } + + public string Serialize() + { + if (Operations.None()) return string.Empty; + + var xml = new XElement("Change", new XAttribute("Date", Date.Ticks)); + + foreach (var item in Operations) + xml.Add(new XElement("Op", new XAttribute("ID", item.Key.GetId()), new XAttribute("Description", item.Value))); + + return xml.ToString(); + } + + internal void Append(IApplicationEvent eventInfo, IEntity record) + { + string description; + + if (eventInfo.Event == "Delete") + description = "Deleted {0} '{1}'".FormatWith(record.GetType().Name, record.ToString()); + else if (eventInfo.Event == "Insert") + description = "Inserted {0} '{1}'".FormatWith(record.GetType().Name, record.ToString()); + else if (eventInfo.Event == "Update") + { + description = "Update {0} '{1}':".FormatWith(record.GetType().Name, record.ToString()); + + if (eventInfo.Data.HasValue()) + { + foreach (var child in GetOldDataNode(eventInfo).Elements()) + { + var property = child.Name.LocalName; + + if (property == "IsCodeDirty") continue; + + var oldValue = child.Value; + var newValue = EntityManager.ReadProperty(record, property).ToStringOrEmpty(); + + description += property + " changed from '{0}' to '{1}' | ".FormatWith(oldValue, newValue); + } + } + } + else throw new NotSupportedException(); + + Operations.Insert(0, new KeyValuePair(eventInfo, description.TrimEnd(" | "))); + } + + public async Task Undo() + { + await Entity.Database.EnlistOrCreateTransaction(async () => { foreach (var op in Operations) await Undo(op.Key); }); + } + + async Task Undo(IApplicationEvent operation) + { + Entity item; + + switch (operation.Event) + { + case "Insert": + await Entity.Database.Delete(await operation.LoadItem(), DeleteBehaviour.BypassAll); + break; + case "Delete": + item = await operation.LoadItem() as Entity; + await Entity.Database.Save(item, SaveBehaviour.BypassSaved | SaveBehaviour.BypassSaving); + break; + case "Update": + item = (await operation.LoadItem()).Clone() as Entity; + + foreach (var p in GetOldDataNode(operation).Elements()) + { + var old = p.Value; + var property = item.GetType().GetProperty(p.Name.LocalName); + property.SetValue(item, old.To(property.PropertyType)); + } + + await Entity.Database.Save(item, SaveBehaviour.BypassSaved | SaveBehaviour.BypassSaving); + break; + default: + // Ignore other cases + break; + } + } + + static XElement GetOldDataNode(IApplicationEvent operation) + { + var node = XElement.Parse(operation.Data); + return node.Element("old") ?? node; + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Blob/Blob.cs b/Olive.Entities/Blob/Blob.cs new file mode 100644 index 000000000..207f4f76d --- /dev/null +++ b/Olive.Entities/Blob/Blob.cs @@ -0,0 +1,575 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace Olive.Entities +{ + /// + /// Provides an utility for handling Binary property types. + /// + [JsonConverter(typeof(PessimisticJsonConverter))] + public class Blob : IComparable, IComparable + { + /// + /// In Test projects particularly, having files save themselves on the disk can waste space. + /// To prevent that, apply this setting in the config file. + /// + static bool ShouldSuppressPersistence = Config.Get("Blob.Suppress.Persistence", defaultValue: false); + static bool StoreWithFileName = Config.Get("Blob.Stored.With.File.Name", defaultValue: false); + + const string EMPTY_FILE = "NoFile.Empty"; + public const string DefaultEncryptionKey = "Default_ENC_Key:_This_Better_Be_Calculated_If_Possible"; + public static string SecureVirtualRoot = "/Download.File.aspx?"; + + static string[] UnsafeExtensions = new[] { "aspx", "ascx", "ashx", "axd", "master", "bat", "bas", "asp", "app", "bin","cla","class", "cmd", "com","sitemap","skin", "asa", "cshtml", + "cpl","crt","csc","dll","drv","exe","hta","htm","html", "ini", "ins","js","jse","lnk","mdb","mde","mht","mhtm","mhtml","msc", "msi","msp", "mdb", "ldb","resources", "resx", + "mst","obj", "config","ocx","pgm","pif","scr","sct","shb","shs", "smm", "sys","url","vb","vbe","vbs","vxd","wsc","wsf","wsh" , "php", "asmx", "cs", "jsl", "asax","mdf", + "cdx","idc", "shtm", "shtml", "stm", "browser"}; + + public static ConcurrentDictionary PhysicalFilesRoots = new ConcurrentDictionary(); + + Entity ownerEntity; + public AccessMode FileAccessMode; + bool IsEmptyBlob; + byte[] FileData; + + /// + /// Initializes a new instance of the class. + /// + public Blob() { } + + /// + /// Initializes a new Blob instance with the specified data and file name. + /// + public Blob(byte[] data, string fileName) + { + FileData = data; + this.fileName = fileName.ToSafeFileName(); + } + + /// + /// Initializes a new Blob instance, for the specified file on disk. + /// + [Obsolete("By using this constructor you will async benefit, use the other ones.")] + public Blob(FileInfo file) : this(File.ReadAllBytes(file.FullName), file.Name) { } + + /// + /// Gets the address of the property owning this blob in the format: Type/ID/Property + /// + public string GetOwnerPropertyReference() + { + if (ownerEntity == null || OwnerProperty.IsEmpty()) return null; + return $"{ownerEntity?.GetType().FullName}/{ownerEntity?.GetId()}/{OwnerProperty}"; + } + + public enum AccessMode { Open, Secure } + + public string OwnerProperty { get; private set; } + + string fileName, folderName; + + bool hasValue; // For performance, cache it + + [JsonExposed] + public string FileName + { + get { return fileName.Or(EMPTY_FILE); } + set { fileName = value; } + } + + public string FileExtension + { + get + { + if (fileName.IsEmpty()) return string.Empty; + else + { + var result = Path.GetExtension(fileName) ?? string.Empty; + if (result.Length > 0 && !result.StartsWith(".")) + result = "." + result; + return result; + } + } + } + + /// + /// Gets the data of this blob. + /// + public async Task GetFileData() + { + if (IsEmpty()) return new byte[0]; + + if (FileData != null && FileData.Length > 0) + return FileData; + + FileData = await GetStorageProvider().Load(this); + + return FileData; + } + + public void SetData(byte[] data) + { + if ((data?.Length ?? 0) == 0) + throw new InvalidOperationException("Invalid value passed."); + + FileData = data; + } + + public string FolderName + { + get + { + if (folderName == null) + { + if (ownerEntity == null) return OwnerProperty; + folderName = ownerEntity.GetType().Name + "." + OwnerProperty; + } + + return folderName; + } + set + { + folderName = value; + } + } + + IBlobStorageProvider GetStorageProvider() => BlobStorageProviderFactory.GetProvider(FolderName); + + /// + /// Gets an empty blob object. + /// + public static Blob Empty() => new Blob(null, EMPTY_FILE) { IsEmptyBlob = true }; + + /// + /// Gets all fall-back paths for this Blob + /// + public IEnumerable FallbackPaths => + (ownerEntity as IPickyBlobContainer)?.GetFallbackPaths(this) ?? Enumerable.Empty(); + + /// + /// Gets the Url of this blob. + /// + public override string ToString() => Url(); + + /// + /// Gets the content + /// + /// + public async Task GetContentText() + { + if (IsEmpty()) return string.Empty; + + try + { + using (var mem = new MemoryStream(await GetFileData())) + { + using (var reader = new StreamReader(mem)) + return await reader.ReadToEndAsync(); + } + } + catch (Exception ex) + { + throw new Exception($"The {OwnerProperty} of the {ownerEntity?.GetType().FullName} entity ({ownerEntity?.GetId()}) cannot be converted to text.", ex); + } + } + + /// + /// Gets a Url to this blob. + /// + public string Url(AccessMode mode) + { + if (ownerEntity == null) return null; + + if (ownerEntity is IPickyBlobUrlContainer) + return (ownerEntity as IPickyBlobUrlContainer).GetUrl(this); + + return GetVirtualFolderUrl(mode) + GetFileNameWithoutExtension() + FileExtension + + ("?" + FileName).OnlyWhen(mode == AccessMode.Open); + } + + /// + /// Gets a Url to this blob. + /// + public string Url() => Url(FileAccessMode); + + /// + /// Returns the Url of this blob, or the provided default Url if this is Empty. + /// + public string UrlOr(string defaultUrl) + { + if (IsEmpty()) return defaultUrl; + else return Url(); + } + + /// + /// Gets a cache safe URL to this blob. + /// + public string GetCacheSafeUrl() + { + var result = Url(); + + if (result.IsEmpty()) return result; + + return result + (result.Contains("?") ? "&" : "?") + "RANDOM=" + Guid.NewGuid(); + } + + public string GetVirtualFolderUrl(AccessMode accessMode) + { + if (ownerEntity is IPickyBlobContainer) + { + var result = (ownerEntity as IPickyBlobContainer).GetVirtualFolderPath(this); + if (result.HasValue()) return result.TrimEnd('/') + "/"; + } + + var root = Config.Get("UploadFolder.VirtualRoot").Or("/Documents/"); + + if (accessMode == AccessMode.Secure) root = Config.Get("UploadFolder.VirtualRoot.Secure").Or(SecureVirtualRoot); + + return root + FolderName + "/"; + } + + /// + /// Determines whether this is an empty blob. + /// + public bool IsEmpty() + { + if (hasValue) return false; + + if (IsEmptyBlob) return true; + + if (FileName == EMPTY_FILE) return true; + + if (GetStorageProvider().FileExists(this)) { hasValue = true; return false; } + + if (FileData == null) return true; + + return FileData.None(); + } + + /// + /// Determines whether this blob has any content. + /// + public bool HasValue() => !IsEmpty(); + + /// + /// Creates a clone of this blob. + /// + public async Task Clone() => await Clone(attach: false, @readonly: false); + + public async Task Clone(bool attach, bool @readonly) + { + if (!attach && @readonly) throw new ArgumentException("readonly can be set to true only when attaching."); + + var result = new Blob(await GetFileData(), FileName); + + if (ownerEntity != null && attach) + { + if (!@readonly) Attach(ownerEntity, OwnerProperty, FileAccessMode); + else + { + result.ownerEntity = ownerEntity; + result.OwnerProperty = OwnerProperty; + result.FileAccessMode = FileAccessMode; + } + } + + return result; + } + + /// + /// Attaches this Blob to a specific record's file property. + /// + public Blob Attach(Entity owner, string propertyName) => Attach(owner, propertyName, AccessMode.Open); + + /// + /// Attaches this Blob to a specific record's file property. + /// + public Blob Attach(Entity owner, string propertyName, AccessMode accessMode) + { + ownerEntity = owner; + OwnerProperty = propertyName; + FileAccessMode = accessMode; + + if (owner is GuidEntity) owner.Saving.Handle(Owner_Saving); + else owner.Saved.Handle(Owner_Saved); + + owner.Deleting.Handle(Delete); + return this; + } + + /// + /// Detaches this Blob. + /// + public void Detach() + { + if (ownerEntity == null) return; + + ownerEntity.Saving.RemoveHandler(Owner_Saving); + ownerEntity.Saved.RemoveHandler(Owner_Saved); + ownerEntity.Deleting.RemoveHandler(Delete); + } + + // TODO: Deleting should be async and so on. + + /// + /// Deletes this blob from the disk. + /// + Task Delete(EventArgs e) + { + if (ShouldSuppressPersistence) return Task.CompletedTask; + + if (ownerEntity.GetType().Defines()) return Task.CompletedTask; + + DeleteFromDisk(); + + return Task.CompletedTask; + } + + void DeleteFromDisk() + { + if (ownerEntity == null) throw new InvalidOperationException(); + + GetStorageProvider().Delete(this); + + FileData = null; + } + + async Task Owner_Saving(System.ComponentModel.CancelEventArgs e) + { + if (!ShouldSuppressPersistence) await SaveOnDisk(); + } + + async Task Owner_Saved(SaveEventArgs e) + { + if (!ShouldSuppressPersistence) await SaveOnDisk(); + } + + /// + /// Saves this file on the disk. + /// + public async Task SaveOnDisk() + { + if (FileData != null && FileData.Length > 0) + await GetStorageProvider().Save(this); + + else if (IsEmptyBlob) + DeleteFromDisk(); + } + + /// + /// Gets the mime type based on the file extension. + /// + public string GetMimeType() => $"c:\\{FileName}".AsFile().GetMimeType();// The blob may be in-memory. + + /// Determines if this blob's file extension is for audio or video. + public bool IsMedia() => GetMimeType().StartsWithAny("audio/", "video/"); + + string GetFilesRoot() => GetPhysicalFilesRoot(FileAccessMode).FullName; + + /// + /// Gets the physical path root. + /// + public static DirectoryInfo GetPhysicalFilesRoot(AccessMode accessMode) + { + var result = PhysicalFilesRoots.GetOrAdd(accessMode, + m => + { + var folderConfigKey = accessMode == AccessMode.Secure ? "UploadFolder.Secure" : "UploadFolder"; + var defaultFolder = accessMode == AccessMode.Secure ? "App_Data\\" : "Documents\\"; + + var folder = Config.Get(folderConfigKey).Or(defaultFolder).TrimEnd('\\') + "\\"; + + if (!folder.StartsWith("\\\\") && folder[1] != ':') // Relative address: + folder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, folder); + + return folder; + }); + + return new DirectoryInfo(result); + } + + internal string LocalFolder + { + get + { + if (ownerEntity == null) return null; + + if (ownerEntity is IPickyBlobContainer) + { + var result = (ownerEntity as IPickyBlobContainer).GetPhysicalFolderPath(this); + if (result.HasValue()) return result; + } + + var docsFolder = Path.Combine(GetFilesRoot(), FolderName + "\\"); + + return docsFolder; + } + } + + /// + /// This will return the blob object linked to the correct entity. + /// + /// Expected format: Type/Id/Property. + public static Blob FromReference(string reference) + { + var parts = reference.OrEmpty().Split('/'); + if (parts.Length != 3) throw new ArgumentException("Expected format is Type/ID/Property."); + + var type = EntityFinder.GetEntityType(parts.First()); + + if (type == null) + throw new ArgumentException($"The type '{parts.First()}' is not found in the currently loaded assemblies."); + + var id = parts[1]; + var propertyName = parts.Last(); + + var entity = Entity.Database.GetOrDefault(id, type); + if (entity == null) + throw new ArgumentException($"Could not load an instance of '{parts.First()}' with the ID of '{id} from the database."); ; + + var property = type.GetProperty(propertyName); + if (property == null) + throw new Exception($"The type {type.FullName} does not have a property named {propertyName}."); + + return property.GetValue(entity) as Blob; + } + + /// + /// Gets the local physical path of this file. + /// + public string LocalPath + { + get + { + if (ownerEntity == null) return null; + + var result = Path.Combine(LocalFolder, GetFileNameWithoutExtension() + FileExtension); + + if (!Directory.Exists(LocalFolder)) Directory.CreateDirectory(LocalFolder); + + return result; + } + } + + public string GetFileNameWithoutExtension() + { + if (ownerEntity == null) return null; + if (ownerEntity is IntEntity && ownerEntity.IsNew) return null; + + if (ownerEntity is IPickyBlobContainer) + { + var result = (ownerEntity as IPickyBlobContainer).GetFileNameWithoutExtension(this); + + if (result.HasValue()) return result; + } + + if (StoreWithFileName) + return FileName.TrimEnd(FileExtension); + + return ownerEntity?.GetId().ToStringOrEmpty(); + } + + #region Unsafe Files Handling + + /// + /// Gets a list of unsafe file extensions. + /// + public static string[] GetUnsafeExtensions() => UnsafeExtensions; + + /// + /// Determines whether the extension of this file is potentially unsafe. + /// + public bool HasUnsafeExtension() => HasUnsafeFileExtension(FileName); + + public static bool HasUnsafeFileExtension(string fileName) + { + if (fileName.IsEmpty()) return false; + + var extension = Path.GetExtension(fileName).OrEmpty().Where(x => x.IsLetter()).ToArray().ToString("").ToLower(); + + return UnsafeExtensions.Contains(extension); + } + + #endregion + + public override bool Equals(object obj) + { + var other = obj as Blob; + + if (other == null) return false; + else if (ReferenceEquals(this, other)) return true; + else if (IsEmpty() && other.IsEmpty()) return true; + + return false; + } + + public override int GetHashCode() => base.GetHashCode(); + + public static bool operator ==(Blob left, Blob right) + { + if (ReferenceEquals(left, right)) + return true; + + else if (ReferenceEquals(left, null)) + return false; + + else + return left.Equals(right); + } + + public string FileNameWithoutExtension => Path.GetFileNameWithoutExtension(FileName); + + public static bool operator !=(Blob left, Blob right) => !(left == right); + + /// + /// Gets this blob if it has a value, otherwise another specified blob. + /// + public Blob Or(Blob other) + { + if (IsEmpty()) return other; + else return this; + } + + #region IComparable Members + + /// + /// Compares this blob versus a specified other blob. + /// + public int CompareTo(Blob other) + { + if (other == null) + return 1; + + if (IsEmpty()) + { + if (other.IsEmpty()) + return 0; + else return -1; + } + else + { + if (other.IsEmpty()) + return 1; + else + { + var me = FileData?.Length; + var him = other.FileData?.Length; + if (me == him) return 0; + if (me > him) return 1; + else return -1; + } + } + } + + /// + /// Compares this blob versus a specified other blob. + /// + public int CompareTo(object obj) => CompareTo(obj as Blob); + + #endregion + } +} \ No newline at end of file diff --git a/Olive.Entities/Blob/BlobStorageProviderFactory.cs b/Olive.Entities/Blob/BlobStorageProviderFactory.cs new file mode 100644 index 000000000..9e96d2b6f --- /dev/null +++ b/Olive.Entities/Blob/BlobStorageProviderFactory.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Olive.Entities +{ + public class BlobStorageProviderFactory + { + public static IBlobStorageProvider DefaultProvider = new DiskBlobStorageProvider(); + + /// + /// This is to be configured in Global.asax if a different provider is needed for specific files. + /// Example: Olive.Entities.BlobStorageProviderFactory.Add("Customer.Logo", new MySpecialStorageProvider); + /// + public static Dictionary Providers = new Dictionary(); + + /// + /// In the format: {type}.{property} e.g. Customer.Logo. + /// + internal static IBlobStorageProvider GetProvider(string folderName) + { + if (folderName.IsEmpty()) return DefaultProvider; + + return Providers.GetOrDefault(folderName) ?? DefaultProvider; + } + } +} diff --git a/Olive.Entities/Blob/DiskBlobStorageProvider.cs b/Olive.Entities/Blob/DiskBlobStorageProvider.cs new file mode 100644 index 000000000..1705d0394 --- /dev/null +++ b/Olive.Entities/Blob/DiskBlobStorageProvider.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Olive.Entities +{ + class DiskBlobStorageProvider : IBlobStorageProvider + { + // TODO: It is a quick workaround for Intern which seems to be back in .Net Core 2 + ConcurrentDictionary StringKeyAsyncLock = new ConcurrentDictionary(); + + AsyncLock GetAsyncLock(string key) => StringKeyAsyncLock.GetOrAdd(key, x => new AsyncLock()); + + public async Task Save(Blob blob) + { + var fileDataToSave = await blob.GetFileData(); // Because file data will be lost in delete. + + if (File.Exists(blob.LocalPath)) + { + using (await GetAsyncLock(blob.LocalPath).Lock()) + { + var data = await File.ReadAllBytesAsync(blob.LocalPath); + if (data == null) await Delete(blob); + else if (data.SequenceEqual(await blob.GetFileData())) return; // Nothing changed. + else await Delete(blob); + } + } + + using (await GetAsyncLock(blob.LocalPath).Lock()) + { + await new Func(async () => await File.WriteAllBytesAsync(blob.LocalPath, fileDataToSave)).Invoke(retries: 6, waitBeforeRetries: TimeSpan.FromSeconds(0.5)); + } + } + + public async Task Delete(Blob blob) + { + if (!Directory.Exists(blob.LocalFolder)) Directory.CreateDirectory(blob.LocalFolder); + + var tasks = new List(); + + // Delete old file. TODO: Archive the files instead of deleting. + foreach (var file in Directory.GetFiles(blob.LocalFolder, blob.GetFileNameWithoutExtension() + ".*")) + { + using (await GetAsyncLock(file).Lock()) + { + tasks.Add(new Func(async () => await Task.Factory.StartNew(() => File.Delete(file))) + .Invoke(retries: 6, waitBeforeRetries: TimeSpan.FromSeconds(0.5)) + ); + } + } + + await Task.WhenAll(tasks); + } + + public async Task Load(Blob blob) + { + using (await GetAsyncLock(blob.LocalPath).Lock()) + { + if (File.Exists(blob.LocalPath)) + return await File.ReadAllBytesAsync(blob.LocalPath); + } + + // Look in fall-back paths for file + foreach (var fallbackPath in blob.FallbackPaths) + { + using (await GetAsyncLock(blob.LocalPath).Lock()) + { + if (File.Exists(fallbackPath)) + return await File.ReadAllBytesAsync(fallbackPath); + } + } + + return new byte[0]; + } + + public bool FileExists(Blob blob) + { + if (blob.LocalPath.HasValue() && File.Exists(blob.LocalPath)) + return true; + + // Check for file in fall-back paths + if (blob.FallbackPaths.Any(File.Exists)) + return true; + + return false; + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Blob/IBlobStorageProvider.cs b/Olive.Entities/Blob/IBlobStorageProvider.cs new file mode 100644 index 000000000..56b29309c --- /dev/null +++ b/Olive.Entities/Blob/IBlobStorageProvider.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; + +namespace Olive.Entities +{ + public interface IBlobStorageProvider + { + Task Save(Blob blob); + Task Delete(Blob blob); + Task Load(Blob blob); + bool FileExists(Blob blob); + } +} + diff --git a/Olive.Entities/Blob/IPickyBlobContainer.cs b/Olive.Entities/Blob/IPickyBlobContainer.cs new file mode 100644 index 000000000..a7233f04b --- /dev/null +++ b/Olive.Entities/Blob/IPickyBlobContainer.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; + +namespace Olive.Entities +{ + /// + /// This interface can be implemented on any entity which has a property of type Blob. + /// + public interface IPickyBlobContainer : IEntity + { + /// + /// Gets the path to the physical folder containing files for the specified blob property. + /// If you don't need to implement this specific method, simply return NULL. + /// + string GetPhysicalFolderPath(Blob blob); + + /// + /// Gets the URL to the virtual folder containing files for the specified blob property. + /// If you don't need to implement this specific method, simply return NULL. + /// + string GetVirtualFolderPath(Blob blob); + + /// + /// Gets the name of the file used for the specified blob property, without extension. + /// If you don't need to implement this specific method, simply return NULL. + /// + string GetFileNameWithoutExtension(Blob blob); + + /// + /// Gets the fallback paths for the specified blob. + /// + IEnumerable GetFallbackPaths(Blob blob); + } +} \ No newline at end of file diff --git a/Olive.Entities/Blob/IPickyBlobUrlContainer.cs b/Olive.Entities/Blob/IPickyBlobUrlContainer.cs new file mode 100644 index 000000000..19bea5529 --- /dev/null +++ b/Olive.Entities/Blob/IPickyBlobUrlContainer.cs @@ -0,0 +1,10 @@ +namespace Olive.Entities +{ + public interface IPickyBlobUrlContainer : IEntity + { + /// + /// Gets the url of the specified blob. + /// + string GetUrl(Blob blob); + } +} \ No newline at end of file diff --git a/Olive.Entities/CachedReference.cs b/Olive.Entities/CachedReference.cs new file mode 100644 index 000000000..50e216070 --- /dev/null +++ b/Olive.Entities/CachedReference.cs @@ -0,0 +1,58 @@ +using System; +using System.Threading.Tasks; + +namespace Olive.Entities +{ + internal interface ICachedReference { void Invalidate(); } + + /// + /// Provides immediate access to retrieved entities. It is aware of deletes and updates. + /// + public class CachedReference : CachedReference where TEntity : GuidEntity { } + + /// + /// Provides immediate access to retrieved entities. It is aware of deletes and updates. + /// + public class CachedReference : ICachedReference where TEntity : Entity where TId : struct + { + TEntity Value; + TId? Id; + + /// + /// Gets the entity record from a specified database call expression. + /// The first time it is loaded, all future calls will be immediately served. + /// + public async Task Get(TId? id) + { + if (!Id.Equals(id)) Value = null; // Different ID from the cache. + Id = id; + + if (Value == null) + { + if (id == null) return null; + + var result = await Entity.Database.Get(id.ToString()); + + if (!Entity.Database.AnyOpenTransaction()) + { + Value = result; + Value.RegisterCachedCopy(this); + } + else return result; + } + + return Value; + } + + void Bind(TEntity entity) + { + Id = entity?.ID ?? throw new ArgumentNullException(nameof(entity)); + Value = entity; + + if (!Entity.Database.AnyOpenTransaction()) + Value.RegisterCachedCopy(this); + } + + void ICachedReference.Invalidate() => Value = null; + } +} \ No newline at end of file diff --git a/Olive.Entities/Database/BinaryCriterion.cs b/Olive.Entities/Database/BinaryCriterion.cs new file mode 100644 index 000000000..5a2e238a4 --- /dev/null +++ b/Olive.Entities/Database/BinaryCriterion.cs @@ -0,0 +1,60 @@ +using System.Linq.Expressions; + +namespace Olive.Entities +{ + public enum BinaryOperator { OR, AND } + + public class BinaryCriterion : Criterion + { + BinaryCriterion() : base("N/A", "N/A") { } + + BinaryCriterion(Criterion left, BinaryOperator opt, Criterion right) + : base("N/A", "N/A") + { + Left = left; + Right = right; + Operator = opt; + } + + public Criterion Left { get; set; } + public Criterion Right { get; set; } + public BinaryOperator Operator { get; set; } + internal bool IsConvertedCompletely { get; set; } = true; + + public BinaryCriterion Or(BinaryCriterion left, BinaryCriterion right) => new BinaryCriterion(left, BinaryOperator.OR, right); + + public BinaryCriterion And(BinaryCriterion left, BinaryCriterion right) => new BinaryCriterion(left, BinaryOperator.AND, right); + + public static BinaryCriterion From(BinaryExpression expression) where T : IEntity => CreateByExpression(expression); + + static Criterion CreateByExpression(Expression expression) where T : IEntity => + CriteriaExtractor.CreateCriterion(expression); + + static BinaryCriterion CreateByExpression(BinaryExpression expression) where T : IEntity + { + Criterion left, right; + + if (expression.Left.NodeType == ExpressionType.OrElse || expression.Left.NodeType == ExpressionType.AndAlso) + left = CreateByExpression(expression.Left as BinaryExpression); + else + left = CreateByExpression(expression.Left); + + if (expression.Right.NodeType == ExpressionType.OrElse || expression.Right.NodeType == ExpressionType.AndAlso) + right = CreateByExpression(expression.Right as BinaryExpression); + else + right = CreateByExpression(expression.Right); + + if (left == null || right == null) + throw new System.Exception("Failed to convert the specified expression to Criterion: " + expression); + + var op = (expression.NodeType == ExpressionType.OrElse ? BinaryOperator.OR : BinaryOperator.AND); + return new BinaryCriterion(left, op, right); + } + + public override string ToString() + { + if (SqlCondition.HasValue()) return SqlCondition; + return $"({Left}-{Operator}-{Right})"; + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Database/Criterion.cs b/Olive.Entities/Database/Criterion.cs new file mode 100644 index 000000000..48506534d --- /dev/null +++ b/Olive.Entities/Database/Criterion.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; + +namespace Olive.Entities +{ + /// + /// A basic implementation of a database query criterion. + /// + public class Criterion : ICriterion + { + const string NULL_ESCAPE = "[#-NULL-VALUE-#]", COLON_ESCAPE = "[#-SEPERATOR-#]"; + + static readonly MethodInfo StringContainsMethod = typeof(string).GetMethod("Contains"); + static readonly MethodInfo StringContainsExtensionMethod = typeof(OliveExtensions).GetMethod("Contains", new[] { typeof(string), typeof(string), typeof(bool) }); + static readonly MethodInfo StringLacksExtensionMethod = typeof(OliveExtensions).GetMethod("Lacks", new[] { typeof(string), typeof(string), typeof(bool) }); + static readonly MethodInfo StringIsEmptyExtensionMethod = typeof(OliveExtensions).GetMethod("IsEmpty", new[] { typeof(string) }); + static readonly MethodInfo StringHasValueExtensionMethod = typeof(OliveExtensions).GetMethod("HasValue", new[] { typeof(string) }); + static readonly MethodInfo IsAnyOfExtensionMethod = typeof(OliveExtensions).GetMethod("IsAnyOf", new[] { typeof(string), typeof(string[]) }); + static readonly MethodInfo StringStartsWithMethod = typeof(string).GetMethod("StartsWith", new[] { typeof(string) }); + static readonly MethodInfo StringEndsWithMethod = typeof(string).GetMethod("EndsWith", new[] { typeof(string) }); + + /// + /// Initializes a new instance of the class. + /// + public Criterion(string propertyName, object value) : this(propertyName, FilterFunction.Is, value) { } + + /// + /// Initializes a new instance of the class. + /// + public Criterion(string propertyName, FilterFunction function, object value) + { + PropertyName = propertyName; + FilterFunction = function; + Value = value; + } + + /// + /// Initializes a new instance of the class. + /// + public Criterion(string propertyName, FilterFunction function, IEnumerable ids) + : this(propertyName, function, "(" + ids.Select(x => "'" + x.ToString() + "'").ToString(", ") + ")") + { + if (function != FilterFunction.In) + throw new ArgumentException("List of IDs is only supported with 'FilterFunction.In'."); + } + + /// + /// Initializes a new instance of the class. + /// + public Criterion(string propertyName, FilterFunction function, IEnumerable ids) + : this(propertyName, function, "(" + ids.Select(x => "'" + x.ToStringOrEmpty().Replace("'", "''") + "'").ToString(", ") + ")") + { + if (function != FilterFunction.In) + throw new ArgumentException("List of IDs is only supported with 'FilterFunction.In'."); + } + + /// + /// Initializes a new instance of the class. + /// + public Criterion(string propertyName, FilterFunction function, IEnumerable ids) + : this(propertyName, function, "(" + ids.ToString(", ") + ")") + { + if (function != FilterFunction.In) + throw new ArgumentException("List of IDs is only supported with 'FilterFunction.In'."); + } + + /// + /// Initializes a new instance of the class. + /// + public Criterion(string propertyName, string function, object value) + { + PropertyName = propertyName; + FilterFunction = (FilterFunction)Enum.Parse(typeof(FilterFunction), function); + Value = value; + } + + /// + /// Gets or sets the PropertyName of this Condition. + /// + public string PropertyName { get; private set; } + + /// + /// Gets or sets the SqlCondition of this Condition. + /// + public string SqlCondition { get; private set; } + + /// + /// Gets or sets the Filter Option of this Condition. + /// + public FilterFunction FilterFunction { get; set; } + + /// + /// Gets or sets the Value of this Condition. + /// + public object Value { get; set; } + + /// + /// Gets a text representation of the value. + /// + string GetSerializedValue() + { + if (Value is IEntity) + { + return (Value as IEntity).GetId().ToString(); + } + else + { + return Value?.ToStringOrEmpty(); + } + } + + /// + /// Returns a string that represents this instance. + /// + public override string ToString() + { + if (SqlCondition.HasValue()) return SqlCondition; + + var valueText = Value == null ? NULL_ESCAPE : GetSerializedValue().Replace(":", COLON_ESCAPE); + + return string.Join(":", PropertyName, FilterFunction, valueText); + } + + /// + /// Parses the specified condition string. + /// + public static Criterion Parse(string criterionString) + { + if (criterionString.IsEmpty()) + throw new ArgumentNullException(nameof(criterionString)); + + var parts = criterionString.Split(':'); + + var value = parts[2].Replace(COLON_ESCAPE, ":"); + if (value == NULL_ESCAPE) value = null; + + return new Criterion(parts[0], parts[1], value); + } + + object ICriterion.Value => (Value as IEntity)?.GetId() ?? Value; + + public static Criterion FromSql(string sqlCondition) + { + return new DirectDatabaseCriterion(sqlCondition); + } + + public static Criterion From(Expression> criterion) where T : IEntity + { + if (criterion == null) + throw new ArgumentNullException(nameof(criterion)); + + var methodCallExpression = criterion.Body as MethodCallExpression; + if (methodCallExpression != null) return From(methodCallExpression); + + var binaryExpression = criterion.Body as BinaryExpression; + if (binaryExpression != null) return From(binaryExpression); + + return CriteriaExtractor.CreateCriterion(criterion.Body); + } + + public static Criterion From(MethodCallExpression expression, bool throwOnError = true) + { + Expression valueExpression = null; + MemberExpression propertyExpression; + FilterFunction filter; + string sql = null; + + if (expression.Method == StringContainsMethod) + { + propertyExpression = expression.Object as MemberExpression; + valueExpression = expression.Arguments[0]; + filter = FilterFunction.Contains; + } + else if (expression.Method == StringContainsExtensionMethod) + { + propertyExpression = expression.Arguments[0] as MemberExpression; + valueExpression = expression.Arguments[1]; + filter = FilterFunction.Contains; + } + else if (expression.Method == StringIsEmptyExtensionMethod) + { + propertyExpression = expression.Arguments[0] as MemberExpression; + + sql = "(${{#PROPERTY#}} IS NULL OR ${{#PROPERTY#}} = '')"; + filter = default(FilterFunction); + } + else if (expression.Method == StringHasValueExtensionMethod) + { + propertyExpression = expression.Arguments[0] as MemberExpression; + + sql = "(${{#PROPERTY#}} IS NOT NULL AND ${{#PROPERTY#}} <> '')"; + filter = default(FilterFunction); + } + else if (expression.Method == StringLacksExtensionMethod) + { + propertyExpression = expression.Arguments[0] as MemberExpression; + valueExpression = expression.Arguments[1]; + filter = FilterFunction.NotContains; + } + else if (expression.Method == StringStartsWithMethod) + { + propertyExpression = expression.Object as MemberExpression; + valueExpression = expression.Arguments[0]; + filter = FilterFunction.BeginsWith; + } + else if (expression.Method == StringEndsWithMethod) + { + propertyExpression = expression.Object as MemberExpression; + valueExpression = expression.Arguments[0]; + filter = FilterFunction.EndsWith; + } + else if (expression.Method == IsAnyOfExtensionMethod) + { + propertyExpression = expression.Arguments.First() as MemberExpression; + valueExpression = expression.Arguments[1]; + filter = FilterFunction.In; + } + else + { + if (!throwOnError) return null; + + throw new ArgumentException("Invalid database criteria. The provided filter expression cannot be evaluated and converted into a SQL condition."); + } + + if (propertyExpression == null || !(propertyExpression.Member is PropertyInfo)) + { + if (!throwOnError) return null; + + throw new ArgumentException("Invalid database criteria. The provided filter expression cannot be evaluated and converted into a SQL condition." + expression.ToString() + + Environment.NewLine + Environment.NewLine + "Consider using application level filter using the \".Where(...)\" clause."); + } + + var property = propertyExpression.Member.Name; + + // Middle properties? + while (propertyExpression.Expression is MemberExpression) + { + propertyExpression = (propertyExpression.Expression as MemberExpression); + property = propertyExpression.Member.Name + "." + property; + } + + if (sql.HasValue()) + { + if (property.Contains(".")) return null; // Nesting is not supported. + + return new DirectDatabaseCriterion(sql.Replace("#PROPERTY#", property)) { PropertyName = property }; + } + else + { + var value = Expression.Lambda(valueExpression).Compile().DynamicInvoke(); + if (valueExpression.NodeType == ExpressionType.NewArrayInit) + value = $"({(value as string[]).Select(part => $"'{part}'").ToString(",")})"; + + return new Criterion(property, filter, value); + } + } + + static Criterion From(BinaryExpression expression) + { + var propertyExpression = expression.Left as MemberExpression; + + if (propertyExpression == null && expression.Left is UnaryExpression) + { + propertyExpression = (expression.Left as UnaryExpression).Operand as MemberExpression; + } + + if (propertyExpression == null || !(propertyExpression.Member is PropertyInfo)) + throw new ArgumentException("Invalid use of Property comparison in condition expression: " + expression.ToString()); + + var value = Expression.Lambda(expression.Right).Compile().DynamicInvoke(); + + var property = propertyExpression.Member.Name; + + // Middle properties? + while (propertyExpression.Expression is MemberExpression) + { + propertyExpression = (propertyExpression.Expression as MemberExpression); + property = propertyExpression.Member.Name + "." + property; + } + + return new Criterion(property, GetFilterFunction(expression.NodeType), value); + } + + /// + /// Gets the filter option for a specified Lambda expression node type. + /// + static FilterFunction GetFilterFunction(ExpressionType nodeType) + { + switch (nodeType) + { + case ExpressionType.Equal: return FilterFunction.Is; + case ExpressionType.NotEqual: return FilterFunction.IsNot; + case ExpressionType.GreaterThan: return FilterFunction.MoreThan; + case ExpressionType.GreaterThanOrEqual: return FilterFunction.MoreThanOrEqual; + case ExpressionType.LessThan: return FilterFunction.LessThan; + case ExpressionType.LessThanOrEqual: return FilterFunction.LessThanOrEqual; + default: + throw new NotSupportedException("GetFilterFunction() does not support expression of type " + nodeType); + } + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Database/DbTransactionScopeOption.cs b/Olive.Entities/Database/DbTransactionScopeOption.cs new file mode 100644 index 000000000..c97b22baa --- /dev/null +++ b/Olive.Entities/Database/DbTransactionScopeOption.cs @@ -0,0 +1,9 @@ +namespace Olive.Entities +{ + public enum DbTransactionScopeOption + { + Required, + RequiresNew, + Suppress + } +} \ No newline at end of file diff --git a/Olive.Entities/Database/DirectDatabaseCriterion.cs b/Olive.Entities/Database/DirectDatabaseCriterion.cs new file mode 100644 index 000000000..8276a0cd0 --- /dev/null +++ b/Olive.Entities/Database/DirectDatabaseCriterion.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace Olive.Entities +{ + /// + /// Enables adding a direct SQL WHERE criteria to the database query. + /// + public class DirectDatabaseCriterion : Criterion + { + /// + /// Gets the parameters used in the specified custom SQL criteria. + /// + public Dictionary Parameters = new Dictionary(); + + /// + /// Gets or sets the SQL criteria. + /// + public string SqlCriteria { get; set; } + + /// + /// N/A. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public new object Value + { + get { return null; } + set { throw new NotSupportedException(); } + } + + /// + /// Specifies whether this criteria is compatible with normal caching. + /// + public bool IsCacheSafe { get; set; } + + /// + /// N/A. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public new FilterFunction FilterFunction + { + get { throw new NotSupportedException(); } + set { throw new NotSupportedException(); } + } + + /// + /// Initializes a new instance of the class. + /// + public DirectDatabaseCriterion(string sqlCriteria) + : base("N/A", "N/A") => SqlCriteria = sqlCriteria; + + /// + /// Initializes a new instance of the class. + /// + /// Item1 = Parameter name (without the @ character). Item2 = parameter value. + public DirectDatabaseCriterion(string sqlCriteria, params Tuple[] parameters) + : this(sqlCriteria) + { + foreach (var p in parameters) + Parameters.Add(p.Item1, p.Item2); + } + + /// + /// Initializes a new instance of the class. + /// + public DirectDatabaseCriterion(string sqlCriteria, Dictionary parameters) + : this(sqlCriteria) => Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters)); + + /// + /// Initializes a new instance of the class. + /// + /// Example: new {Parameter1 = SomeValue(), Parameter2 = AnotherValue()} + public DirectDatabaseCriterion(string sqlCriteria, object parameters) + : this(sqlCriteria) + { + if (parameters == null) throw new ArgumentNullException(nameof(parameters)); + + Parameters = parameters.GetType().GetProperties().ToDictionary(x => x.Name, x => x.GetValue(parameters)); + } + + /// + /// Returns a string that represents this instance. + /// + public override string ToString() => + $"{SqlCriteria}|{Parameters.Select(x => "{0}={1}".FormatWith(x.Key, x.Value)).ToString("|")}"; + + public string MapSqlCriteria(Dictionary propertyMappings) + { + if (PropertyName.IsEmpty() || PropertyName == "N/A") return SqlCriteria; + + return SqlCriteria.Replace($"${{{{{PropertyName}}}}}", propertyMappings[PropertyName]); + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Database/FilterFunction.cs b/Olive.Entities/Database/FilterFunction.cs new file mode 100644 index 000000000..a7d23eeca --- /dev/null +++ b/Olive.Entities/Database/FilterFunction.cs @@ -0,0 +1,70 @@ +using System; + +namespace Olive.Entities +{ + /// + /// Provides options for filter functions. + /// + public enum FilterFunction + { + Is, + IsNot, + Null, + NotNull, + + Contains, + NotContains, + ContainsAll, + ContainsAny, + + In, + NotIn, + BeginsWith, + EndsWith, + InRange, + + LessThan, + LessThanOrEqual, + MoreThan, + MoreThanOrEqual + } + + public static class FilterFunctionServices + { + /// + /// Gets the database operator equivalent for this filter option. + /// + public static string GetDatabaseOperator(this FilterFunction option) + { + switch (option) + { + case FilterFunction.Contains: + case FilterFunction.BeginsWith: + case FilterFunction.EndsWith: + return "LIKE"; + case FilterFunction.Is: + return "="; + case FilterFunction.IsNot: + return "<>"; + case FilterFunction.LessThan: + return "<"; + case FilterFunction.LessThanOrEqual: + return "<="; + case FilterFunction.MoreThan: + return ">"; + case FilterFunction.MoreThanOrEqual: + return ">="; + case FilterFunction.Null: + return "Is NULL"; + case FilterFunction.In: + return "IN"; + case FilterFunction.NotNull: + return "Is NOT NULL"; + case FilterFunction.NotContains: + return "NOT LIKE"; + default: + throw new NotSupportedException(option + " is not supported in GetDatabaseOperator()."); + } + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Database/ICriterion.cs b/Olive.Entities/Database/ICriterion.cs new file mode 100644 index 000000000..9b51a20b0 --- /dev/null +++ b/Olive.Entities/Database/ICriterion.cs @@ -0,0 +1,15 @@ +namespace Olive.Entities +{ + /// + /// Provides an abstraction for database query criteria. + /// + public interface ICriterion + { + string PropertyName { get; } + + FilterFunction FilterFunction { get; set; } + object Value { get; } + + string SqlCondition { get; } + } +} \ No newline at end of file diff --git a/Olive.Entities/Database/IDatabase.cs b/Olive.Entities/Database/IDatabase.cs new file mode 100644 index 000000000..9c4afe3a2 --- /dev/null +++ b/Olive.Entities/Database/IDatabase.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Olive.Entities +{ + public interface IDatabase + { + AsyncEvent CacheRefreshed { get; } + + /// + /// It's raised when any record is saved or deleted in the system. + /// + AsyncEvent Updated { get; } + + Task Refresh(); + + bool AnyOpenTransaction(); + + Task EnlistOrCreateTransaction(Func action); + + Task Parse(string toString, bool caseSensitive = false) where T : IEntity; + + int CountAllObjectsInCache(); + + Task> ReadManyToManyRelation(IEntity instance, string property); + + Task Reload(T instance) where T : IEntity; + + Task Any() where T : IEntity; + + Task Any(Expression> criteria) where T : IEntity; + + Task None() where T : IEntity; + + Task None(Expression> criteria) where T : IEntity; + + Task Count(Expression> criteria) where T : IEntity; + + #region Delete + + Task Delete(IEntity instance); + + Task Delete(IEntity instance, DeleteBehaviour behaviour); + + Task Delete(IEnumerable instances) where T : IEntity; + + Task DeleteAll() where T : IEntity; + + Task DeleteAll(Expression> criteria) where T : IEntity; + + Task UpdateAll(Action change) where T : IEntity; + + #endregion + + Task FirstOrDefault(Expression> criteria) where T : IEntity; + + #region Get + + Task Get(string entityId) where T : IEntity; + + Task Get(Guid id) where T : IEntity; + + Task Get(Guid? id) where T : IEntity; + + Task Get(int? id) where T : IEntity; + + Task Get(int id) where T : IEntity; + + Task> Get(Guid entityID, Type objectType); + + Task Get(object entityID, Type objectType); + + Task GetOrDefault(object id) where T : IEntity; + + Task GetOrDefault(object id, Type type); + + #endregion + + Task> GetList(Expression> criteria = null) where T : IEntity; + + #region ProviderManagement + + Dictionary AssemblyProviderFactories { get; } + + IEnumerable GetRegisteredAssemblies(); + + IDataProvider GetProvider() where T : IEntity; + + IDataProvider GetProvider(IEntity item); + + IDataProvider GetProvider(Type type); + + ITransactionScope CreateTransactionScope(DbTransactionScopeOption option = DbTransactionScopeOption.Required); + + #endregion + + #region Save + + Task Save(T entity) where T : IEntity; + + Task Save(IEntity entity, SaveBehaviour behaviour); + + Task> Save(List records) where T : IEntity; + + Task> Update(IEnumerable items, Action action) where T : IEntity; + + Task> Update(IEnumerable items, Action action, SaveBehaviour behaviour) where T : IEntity; + + Task Update(T item, Action action) where T : IEntity; + + Task Update(T item, Action action, SaveBehaviour behaviour) where T : IEntity; + + Task BulkInsert(Entity[] objects, int batchSize = 10, bool bypassValidation = false); + + Task BulkUpdate(Entity[] objects, int batchSize = 10, bool bypassValidation = false); + + #endregion + + #region Save List + + Task> Save(T[] records) where T : IEntity; + + Task> Save(IEnumerable records) where T : IEntity; + + Task> Save(IEnumerable records, SaveBehaviour behaviour) where T : IEntity; + + #endregion + + IDatabaseQuery Of() where TEntity : IEntity; + + IDatabaseQuery Of(Type type); + } +} diff --git a/Olive.Entities/Database/IDatabaseQuery.cs b/Olive.Entities/Database/IDatabaseQuery.cs new file mode 100644 index 000000000..64eae1fbc --- /dev/null +++ b/Olive.Entities/Database/IDatabaseQuery.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Olive.Entities +{ + public interface IDatabaseQuery + { + Type EntityType { get; } + Dictionary Parameters { get; } + int PageStartIndex { get; set; } + int? PageSize { get; set; } + int? TakeTop { get; set; } + + IDatabaseQuery Include(string associationProperty); + IDatabaseQuery Include(IEnumerable associationProperties); + IDatabaseQuery Where(params ICriterion[] criteria); + IDatabaseQuery OrderBy(string property, bool descending); + IDatabaseQuery ThenBy(string property, bool descending); + IDatabaseQuery Top(int rows); + IDatabaseQuery OrderBy(string property); + + Task Count(); + Task> GetList(); + Task FirstOrDefault(); + } + + public interface IDatabaseQuery : IDatabaseQuery + where TEntity : IEntity + { + IDatabaseQuery Where(Expression> criteria); + new Task> GetList(); + new Task FirstOrDefault(); + new IDatabaseQuery OrderBy(string property); + new IDatabaseQuery Top(int rows); + new IDatabaseQuery Where(params ICriterion[] criteria); + + IDatabaseQuery ThenBy(Expression> property, bool descending = false); + IDatabaseQuery OrderByDescending(Expression> property); + IDatabaseQuery OrderBy(Expression> property, bool descending = false); + IDatabaseQuery ThenByDescending(Expression> property); + IDatabaseQuery Include(Expression> property); + + /// + /// Gets a list of entities of the given type from the database with the specified type matching the specified criteria. + /// If no criteria is specified, the count of all instances will be returned. + /// + Task Aggregate(AggregateFunction function, Expression> property) + where TOutput : struct; + + Task Max(Expression> property) where TProperty : struct; + Task Min(Expression> property) where TProperty : struct; + Task Sum(Expression> property) where TProperty : struct; + Task Average(Expression> property) where TProperty : struct; + Task Average(Expression> property) where TProperty : struct; + Task Average(Expression> property); + } +} \ No newline at end of file diff --git a/Olive.Entities/Database/ITransactionScope.cs b/Olive.Entities/Database/ITransactionScope.cs new file mode 100644 index 000000000..c3779299b --- /dev/null +++ b/Olive.Entities/Database/ITransactionScope.cs @@ -0,0 +1,11 @@ +using System; + +namespace Olive.Entities +{ + public interface ITransactionScope : IDisposable + { + void Complete(); + + Guid ID { get; } + } +} diff --git a/Olive.Entities/Database/QueryOptions/FullTextSearchQueryOption.cs b/Olive.Entities/Database/QueryOptions/FullTextSearchQueryOption.cs new file mode 100644 index 000000000..e4cf8e68b --- /dev/null +++ b/Olive.Entities/Database/QueryOptions/FullTextSearchQueryOption.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Olive.Entities +{ + public class FullTextSearchQueryOption : QueryOption + { + /// + /// Creates a new FullTextIndexQueryOption instance. + /// + internal FullTextSearchQueryOption() { } + + #region Keyword + /// + /// Gets or sets the Keywords of this FullTextIndexQueryOption. + /// + public string Keyword { get; set; } + #endregion + + #region Properties + /// + /// Gets or sets the Properties of this FullTextIndexQueryOption. + /// + public IEnumerable Properties { get; set; } + #endregion + } +} diff --git a/Olive.Entities/Database/QueryOptions/PagingQueryOption.cs b/Olive.Entities/Database/QueryOptions/PagingQueryOption.cs new file mode 100644 index 000000000..f7e2844e4 --- /dev/null +++ b/Olive.Entities/Database/QueryOptions/PagingQueryOption.cs @@ -0,0 +1,37 @@ +using System; + +namespace Olive.Entities +{ + public class PagingQueryOption : QueryOption + { + /// + /// Creates a new ResultSetSizeQueryOption instance. + /// + internal PagingQueryOption() { } + + /// + /// Creates a new ResultSetSizeQueryOption instance. + /// + public PagingQueryOption(string orderBy, int startIndex, int pageSize) + { + if (orderBy.IsEmpty()) + throw new ArgumentException("Invalid PagingQueryOption specified. OrderBy is mandatory."); + + if (pageSize < 1) + throw new ArgumentException("Invalid PagingQueryOption specified. PageSize should be a positive number."); + + OrderBy = orderBy; + PageSize = pageSize; + PageStartIndex = startIndex; + } + + public int PageStartIndex { get; internal set; } + + public int PageSize { get; internal set; } + + /// + /// The direct SQL sort expression. E.g: MyColumn DESC, Something + /// + public string OrderBy { get; set; } + } +} \ No newline at end of file diff --git a/Olive.Entities/Database/QueryOptions/QueryOption.cs b/Olive.Entities/Database/QueryOptions/QueryOption.cs new file mode 100644 index 000000000..852857365 --- /dev/null +++ b/Olive.Entities/Database/QueryOptions/QueryOption.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace Olive.Entities +{ + public abstract class QueryOption + { + /// + /// Creates a FullTextSearch option for the search query. + /// + public static FullTextSearchQueryOption FullTextSearch(string keyword, params string[] properties) + { + if (keyword.IsEmpty()) + throw new ArgumentNullException(nameof(keyword)); + if (properties == null || properties.None()) + throw new ArgumentNullException(nameof(properties)); + return new FullTextSearchQueryOption { Keyword = keyword, Properties = properties }; + } + + public static FullTextSearchQueryOption FullTextSearch(string keyword, params Expression>[] properties) + { + if (properties == null || properties.None()) + throw new ArgumentNullException(nameof(properties)); + var propertyNames = new List(); + foreach (var property in properties) + { + var propertyExpression = (property.Body as UnaryExpression)?.Operand as MemberExpression; + if (propertyExpression == null) + throw new Exception($"Unsupported FullTextSearch expression. The only supported format is \"() => x.Property\". You provided: {property}"); + propertyNames.Add(propertyExpression.Member.Name); + } + + return FullTextSearch(keyword, propertyNames.ToArray()); + } + + public static FullTextSearchQueryOption FullTextSearch(string keyword, params Expression>[] properties) + { + if (properties == null || properties.None()) + throw new ArgumentNullException(nameof(properties)); + var propertyNames = new List(); + foreach (var property in properties) + { + var propertyExpression = (property.Body as UnaryExpression)?.Operand as MemberExpression; + if (propertyExpression == null || !(propertyExpression.Expression is ParameterExpression)) + throw new Exception($"Unsupported OrderBy expression. The only supported format is \"x => x.Property\". You provided: {property}"); + propertyNames.Add(propertyExpression.Member.Name); + } + + return FullTextSearch(keyword, propertyNames.ToArray()); + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Database/QueryOptions/RangeQueryOption.cs b/Olive.Entities/Database/QueryOptions/RangeQueryOption.cs new file mode 100644 index 000000000..03c8ebb3a --- /dev/null +++ b/Olive.Entities/Database/QueryOptions/RangeQueryOption.cs @@ -0,0 +1,14 @@ +namespace Olive.Entities +{ + public class RangeQueryOption : QueryOption + { + /// + /// Creates a new ResultSetSizeQueryOption instance. + /// + internal RangeQueryOption() { } + + public int From { get; internal set; } + + public int Number { get; internal set; } + } +} diff --git a/Olive.Entities/Database/QueryOptions/SortQueryOption.cs b/Olive.Entities/Database/QueryOptions/SortQueryOption.cs new file mode 100644 index 000000000..2f8233773 --- /dev/null +++ b/Olive.Entities/Database/QueryOptions/SortQueryOption.cs @@ -0,0 +1,20 @@ +namespace Olive.Entities +{ + public class SortQueryOption : QueryOption + { + /// + /// Creates a new SortQueryOption instance. + /// + public SortQueryOption() { } + + /// + /// Gets or sets the Property of this SortQueryOption. + /// + public string Property { get; set; } + + /// + /// Gets or sets the Descending of this SortQueryOption. + /// + public bool Descending { get; set; } + } +} diff --git a/Olive.Entities/Database/QueryOptions/TakeTopQueryOption.cs b/Olive.Entities/Database/QueryOptions/TakeTopQueryOption.cs new file mode 100644 index 000000000..7ae913a98 --- /dev/null +++ b/Olive.Entities/Database/QueryOptions/TakeTopQueryOption.cs @@ -0,0 +1,15 @@ +namespace Olive.Entities +{ + public class TakeTopQueryOption : QueryOption + { + /// + /// Creates a new ResultSetSizeQueryOption instance. + /// + public TakeTopQueryOption(int number) => Number = number; + + /// + /// Gets or sets the Number of this ResultSetSizeQueryOption. + /// + public int Number { get; } + } +} \ No newline at end of file diff --git a/Olive.Entities/Database/QueryOptions/WhereQueryOption.cs b/Olive.Entities/Database/QueryOptions/WhereQueryOption.cs new file mode 100644 index 000000000..2b8d98952 --- /dev/null +++ b/Olive.Entities/Database/QueryOptions/WhereQueryOption.cs @@ -0,0 +1,11 @@ +namespace Olive.Entities +{ + public class WhereQueryOption : QueryOption + { + public string SqlCriteria { get; set; } + + public WhereQueryOption() { } + + public WhereQueryOption(string sqlCriteria) => SqlCriteria = sqlCriteria; + } +} diff --git a/Olive.Entities/Engine/AggregateFunction.cs b/Olive.Entities/Engine/AggregateFunction.cs new file mode 100644 index 000000000..d63174600 --- /dev/null +++ b/Olive.Entities/Engine/AggregateFunction.cs @@ -0,0 +1,10 @@ +namespace Olive.Entities +{ + public enum AggregateFunction + { + Max, + Min, + Sum, + Average + } +} \ No newline at end of file diff --git a/Olive.Entities/Engine/CriteriaExtractor.cs b/Olive.Entities/Engine/CriteriaExtractor.cs new file mode 100644 index 000000000..536023152 --- /dev/null +++ b/Olive.Entities/Engine/CriteriaExtractor.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; + +namespace Olive.Entities +{ + public class CriteriaExtractor where T : IEntity + { + Expression> Criteria; + + public static IEnumerable Parse(Expression> criteria) + { + return new CriteriaExtractor { Criteria = criteria }.DoParse(); + } + + List DoParse() + { + var result = new List(); + + foreach (var ex in GetUnitExpressions((LambdaExpression)Criteria)) + { + var condition = ProcessCriteria(ex); + + if (condition == null) + throw new Exception("Failed to extract a criterion from expression: " + ex); + + result.Add(condition); + } + + return result; + } + + static IEnumerable GetUnitExpressions(LambdaExpression expression) => GetUnitExpressions(expression.Body); + + static IEnumerable GetUnitExpressions(Expression expression) + { + if (expression.NodeType == ExpressionType.AndAlso) + { + var binary = expression as BinaryExpression; + + return GetUnitExpressions(binary.Left).Concat(binary.Right); + } + + else return new[] { expression }; + } + + static bool IsSimpleParameter(Expression expression) + { + if (expression is ParameterExpression) + return true; + + if (expression is UnaryExpression && (expression.NodeType == ExpressionType.Convert)) + return true; + + return false; + } + + static string GetPropertyExpression(MemberExpression memberInfo) + { + // Handle the member: + var property = memberInfo.Member as PropertyInfo; + if (property == null) return null; + + // Fix for overriden properties: + try { property = memberInfo.Expression.Type.GetProperty(property.Name) ?? property; } + catch { } + + if (CalculatedAttribute.IsCalculated(property)) return null; + if (memberInfo.Expression.Type.IsNullable()) return property.Name; + if (!property.DeclaringType.Implements()) return null; + + // Handle the "member owner" expression: + if (IsSimpleParameter(memberInfo.Expression)) + { + if (IsForeignKey(property)) + return property.Name.TrimEnd(2); + + return property.Name; + } + else if (memberInfo.Expression is MemberExpression) + { + // The expression is itself a member of something. + + var parentProperty = GetPropertyExpression(memberInfo.Expression as MemberExpression); + if (parentProperty == null) + return null; + else + return $"{parentProperty}.{property.Name}"; + } + else return null; + } + + static bool IsForeignKey(PropertyInfo property) => + property.Name.EndsWith("Id") && (property.PropertyType == typeof(Guid) || property.PropertyType == typeof(Guid?)); + + Criterion ProcessCriteria(Expression expression) + { + if (expression is BinaryExpression binary) + { + if (binary.NodeType == ExpressionType.OrElse) + return BinaryCriterion.From(binary); + } + + return CreateCriterion(expression); + } + + internal static Criterion CreateCriterion(Expression expression) + { + if (expression is BinaryExpression) + return CreateCriterion(expression as BinaryExpression); + + if (expression is UnaryExpression) + return CreateCriterion(expression as UnaryExpression); + + if (expression is MemberExpression) + return CreateCriterion(expression as MemberExpression); + + if (expression is MethodCallExpression) + return CreateCriterion(expression as MethodCallExpression); + + return null; + } + + static Criterion CreateCriterion(BinaryExpression expression) + { + if (expression.Left is MemberExpression) + return CreateCriterion(expression, expression.Left as MemberExpression); + + else if (expression.Left is ParameterExpression) + return new Criterion("ID", ToOperator(expression.NodeType), GetExpressionValue(expression.Right)); + + else if (expression.Left is UnaryExpression) + { + var unary = expression.Left as UnaryExpression; + var member = unary.Operand as MemberExpression; + + if (member == null /*|| !unary.IsLiftedToNull*/) return null; + + return CreateCriterion(expression, member); + } + else + return null; + } + + static Criterion CreateCriterion(UnaryExpression expression) + { + if (expression.NodeType != ExpressionType.Not) return null; + + var member = expression.Operand as MemberExpression; + if (member == null) return null; + + var property = GetPropertyExpression(member); + + if (property.IsEmpty()) return null; + + return new Criterion(property, FilterFunction.Is, false); + } + + static Criterion CreateCriterion(MemberExpression expression) + { + if (expression.NodeType != ExpressionType.MemberAccess) + return null; + + var property = GetPropertyExpression(expression); + + if (property.IsEmpty()) return null; + if (property == "HasValue") + { + // Changed at 27 /10/2016, following James J's refactoring of Expression Runner: + // property = expression.Member?.Name; + + // To this: + property = (expression.Expression as MemberExpression)?.Member?.Name; + + if (property.IsEmpty()) return null; + return new Criterion(property, FilterFunction.IsNot, value: null); + } + + if ((expression.Member as PropertyInfo).PropertyType != typeof(bool)) return null; + + // Only one property level is supported: + return new Criterion(property, FilterFunction.Is, true); + } + + static Criterion CreateCriterion(MethodCallExpression expression) => Criterion.From(expression, throwOnError: false); + + static Criterion CreateCriterion(BinaryExpression expression, MemberExpression member) + { + var property = GetPropertyExpression(member); + + if (property.IsEmpty()) return null; + + var value = GetExpressionValue(expression.Right); + + return new Criterion(property, ToOperator(expression.NodeType), value); + } + + static object GetExpressionValue(Expression expression) + { + object result; + + result = ExtractExpressionValue(expression); + + if (result is Entity) + { + if ((result as IntEntity)?.IsNew == true) return null; + return ((dynamic)result).ID; + } + else return result; + } + + static object ExtractExpressionValue(Expression expression) + { + if (expression == null) return null; + + if (expression is ConstantExpression) + return (expression as ConstantExpression).Value; + + if (expression is MemberExpression) + { + var memberExpression = expression as MemberExpression; + var member = memberExpression.Member; + + if (member is PropertyInfo) + return (member as PropertyInfo).GetValue(ExtractExpressionValue(memberExpression.Expression)); + + else if (member is FieldInfo) + return (member as FieldInfo).GetValue(ExtractExpressionValue(memberExpression.Expression)); + + else + throw new Exception("The specified expression cannot be converted to SQL without compilation. Use simple data variables or properties in your lambda queries."); + } + else if (expression is MethodCallExpression) + { + var methodExpression = expression as MethodCallExpression; + var method = (expression as MethodCallExpression).Method; + + var instance = ExtractExpressionValue(methodExpression.Object); + + return method.Invoke(instance, methodExpression.Arguments.Select(a => ExtractExpressionValue(a)).ToArray()); + } + else + { + throw new Exception("The specified expression cannot be converted to SQL without compilation. Use simple data variables or properties in your lambda queries."); + } + } + + static FilterFunction ToOperator(ExpressionType type) + { + switch (type) + { + case ExpressionType.Equal: + return FilterFunction.Is; + case ExpressionType.NotEqual: + return FilterFunction.IsNot; + case ExpressionType.GreaterThan: + return FilterFunction.MoreThan; + case ExpressionType.GreaterThanOrEqual: + return FilterFunction.MoreThanOrEqual; + case ExpressionType.LessThan: + return FilterFunction.LessThan; + case ExpressionType.LessThanOrEqual: + return FilterFunction.LessThanOrEqual; + default: throw new NotSupportedException(type + " is still not supported."); + } + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Engine/EntityFinder.cs b/Olive.Entities/Engine/EntityFinder.cs new file mode 100644 index 000000000..b6833b9e0 --- /dev/null +++ b/Olive.Entities/Engine/EntityFinder.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace Olive.Entities +{ + /// + /// Finds an entity with unknown type from its given id. + /// + public static class EntityFinder + { + static readonly ConcurrentDictionary> PossibleTypesCache = new ConcurrentDictionary>(); + + // static readonly ConcurrentDictionary EntityTypesCache = new ConcurrentDictionary(); + + public static IEnumerable FindPossibleTypes(Type baseType, bool mustFind) => + PossibleTypesCache.GetOrAdd(baseType, t => SearchForPossibleTypes(t, mustFind)); + + /// + /// Gets the runtime type from the currently loaded assemblies. + /// + /// The type name (including namespace, but excluding assembly). + public static Type GetEntityType(string typeFullName) => + GetDomainEntityTypes().FirstOrDefault(x => x.FullName == typeFullName); + + public static IEnumerable GetDomainEntityTypes() + { + return AppDomain.CurrentDomain.GetAssemblies() + .Where(a => a.References(typeof(Entity).Assembly)) + .SelectMany(a => a.GetExportedTypes()) + .Where(t => t.Implements()) + .Where(t => !t.Name.StartsWith("App_Code")); + } + + public static IEnumerable SearchForPossibleTypes(Type baseType, bool mustFind) + { + IEnumerable result; + + if (baseType == null || baseType == typeof(Entity)) + result = GetDomainEntityTypes(); + + else if (baseType.IsInterface) + result = + Entity.Database.AssemblyProviderFactories.Except(f => f.Value.SupportsPolymorphism()) + .Select(a => a.Key).Where(a => a.References(baseType.Assembly)).Concat(baseType.Assembly) + .SelectMany(a => a.GetExportedTypes()) + .Where(t => t.Implements(baseType)).ToList(); + + else + result = baseType.Assembly.GetExportedTypes().Where(t => t.GetParentTypes().Contains(baseType)).Union(new[] { baseType }); + + result = result + // Not transient objects: + .Where(t => !TransientEntityAttribute.IsTransient(t)) + // No abstract or interface: + .Where(t => !t.IsAbstract && !t.IsInterface) + // Unless the type is marked non-persistent: + .Where(t => PersistentAttribute.IsTypePersistent(t)) + // Leaf nodes first (most concrete): + .OrderByDescending(t => t.GetParentTypes().Count()); + + result = result.Except(new[] { typeof(IApplicationEvent) }).ToArray(); + + if (result.None()) + { + if (baseType != null && mustFind) + throw new ArgumentException($"No type in the current application domain can be the implementation of the type {baseType.FullName}."); + + else if (mustFind) + throw new ArgumentException("No type in the current application domain implements Entity."); + } + + return result; + } + + // /// + // /// Finds the actual type of an object with the specified ID which inherits from a specified base type. + // /// + // public static IEntity Find(object entityId, Type baseType) + // { + // var key = baseType + + // return EntityTypesCache.GetOrAdd( + // } + + // static IEntity DiscoverType(object entityId, Type baseType) + // { + // if (baseType != null && !baseType.Implements()) + // throw new ArgumentException(baseType.FullName + " is not acceptable as a valid entity base type."); + + // if (baseType == null && !baseType.IsA()) + // { + // throw new ArgumentException("Only Guid-based entities can be retrieved from the database by their ID only."); + // } + + // var type = FromCache(entityId); + // if (type == null) + // { + // // Find possible types: + // var possibleTypes = FindPossibleTypes(baseType, true); + + // if (possibleTypes.Count() == 1) + // { + // type = possibleTypes.Single(); + // Cache(entityId, type); + // return Database.GetConcrete(entityId, type); + // } + // else + // { + // Exception firstError = null; + + // foreach (var candidateType in possibleTypes) + // { + // Entity result; + // try { result = Database.GetConcrete(entityId, candidateType); } + // catch (Exception ex) { if (firstError == null) firstError = ex; continue; } + + // if (result != null) + // { + // Cache(entityId, result.GetType()); + // return result; + // } + // } + + // if (firstError != null) throw firstError; + // } + // } + // else return Database.GetConcrete(entityId, type); + + // var error = "Could not find the type of the entity with the ID of " + entityId; + // if (baseType != null && baseType != typeof(Entity)) + // error += ", considered to be a an implementation of " + baseType.FullName; + + // throw new ArgumentException(error + "."); + // } + + public static void ResetCache() => PossibleTypesCache.Clear(); //EntityTypesCache.Clear(); + } +} diff --git a/Olive.Entities/Engine/IDataAccess.cs b/Olive.Entities/Engine/IDataAccess.cs new file mode 100644 index 000000000..b45b19f41 --- /dev/null +++ b/Olive.Entities/Engine/IDataAccess.cs @@ -0,0 +1,28 @@ +namespace Olive.Entities +{ + using System.Collections.Generic; + using System.Data; + using System.Threading.Tasks; + + /// + /// Provides direct data access to the underlying data source. + /// + public interface IDataAccess + { + Task GetOrCreateConnection(); + + Task CreateConnection(string connectionString = null); + + Task ExecuteReader(string command, CommandType commandType = CommandType.Text, params IDataParameter[] @params); + + Task ExecuteQuery(string databaseQuery, CommandType commandType = CommandType.Text, params IDataParameter[] @params); + + Task ExecuteNonQuery(string command, CommandType commandType = CommandType.Text, params IDataParameter[] @params); + + Task ExecuteScalar(string command, CommandType commandType = CommandType.Text, params IDataParameter[] @params); + + Task ExecuteScalar(string command, CommandType commandType = CommandType.Text, params IDataParameter[] @params); + + Task ExecuteBulkNonQueries(CommandType commandType, List> commands); + } +} diff --git a/Olive.Entities/Engine/IDataProvider.cs b/Olive.Entities/Engine/IDataProvider.cs new file mode 100644 index 000000000..a18b81d3e --- /dev/null +++ b/Olive.Entities/Engine/IDataProvider.cs @@ -0,0 +1,48 @@ +namespace Olive.Entities +{ + using System; + using System.Collections.Generic; + using System.Reflection; + using System.Threading.Tasks; + + public interface IDataProvider + { + Task Get(object objectID); + Task Save(IEntity record); + Task Delete(IEntity record); + + Task> GetList(IDatabaseQuery query); + + /// + /// Returns a direct database criterion used to eager load associated objects. + /// + DirectDatabaseCriterion GetAssociationInclusionCriteria(IDatabaseQuery query, PropertyInfo association); + + IDataAccess Access { get; } + + Task Count(IDatabaseQuery query); + + Task Aggregate(IDatabaseQuery query, AggregateFunction function, string propertyName); + + string MapColumn(string propertyName); + string MapSubquery(string path); + + /// + /// Reads the many to many relation and returns the IDs of the associated objects. + /// + Task> ReadManyToManyRelation(IEntity instance, string property); + + IDictionary> GetUpdatedValues(IEntity original, IEntity updated); + + Task ExecuteNonQuery(string command); + Task ExecuteScalar(string command); + + bool SupportValidationBypassing(); + + Task BulkInsert(IEntity[] entities, int batchSize); + Task BulkUpdate(IEntity[] entities, int batchSize); + + string ConnectionString { get; set; } + string ConnectionStringKey { get; set; } + } +} \ No newline at end of file diff --git a/Olive.Entities/Engine/IDataProviderFactory.cs b/Olive.Entities/Engine/IDataProviderFactory.cs new file mode 100644 index 000000000..afbea6935 --- /dev/null +++ b/Olive.Entities/Engine/IDataProviderFactory.cs @@ -0,0 +1,18 @@ +using System; + +namespace Olive.Entities +{ + public interface IDataProviderFactory + { + IDataProvider GetProvider(Type type); + + /// + /// Determines whether this data provider factory handles interface data queries. + /// + bool SupportsPolymorphism(); + + string ConnectionString { get; } + + IDataAccess GetAccess(); + } +} diff --git a/Olive.Entities/Entity.T.cs b/Olive.Entities/Entity.T.cs new file mode 100644 index 000000000..8d981831d --- /dev/null +++ b/Olive.Entities/Entity.T.cs @@ -0,0 +1,50 @@ +namespace Olive.Entities +{ + using System.Xml.Serialization; + using Newtonsoft.Json; + + public class Entity : Entity, IEntity + { + /// + /// Gets or sets the ID of this object. + /// + [JsonExposed] + public virtual T ID { get; set; } + + /// + /// Gets the original id of this type as it was in the database. + /// + [XmlIgnore, JsonIgnore, System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public virtual T OriginalId { get; internal set; } + + /// + /// Returns a hash code for this instance. + /// + /// + /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. + /// + public override int GetHashCode() => ID.GetHashCode(); + + /// + /// Determines whether this instance is equal to another specified instance. + /// + public override bool Equals(Entity other) + { + if (ReferenceEquals(this, other)) return true; + + var typed = other as Entity; + + if (ReferenceEquals(this, null) || ReferenceEquals(typed, null)) return false; + + if (GetType() != typed.GetType()) return false; + + return ID.Equals(typed.ID); + } + + /// + /// Gets the ID of this object. + /// + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public override object GetId() => ID; + } +} \ No newline at end of file diff --git a/Olive.Entities/Entity.cs b/Olive.Entities/Entity.cs new file mode 100644 index 000000000..428e0fa8d --- /dev/null +++ b/Olive.Entities/Entity.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using System.Xml.Serialization; +using Newtonsoft.Json; + +namespace Olive.Entities +{ + /// + /// Entity, a persistent object in the application. + /// + public abstract class Entity : IEntity + { + static Dictionary PrimitiveProperties = new Dictionary(); + static object PrimitivePropertiesSyncLock = new object(); + object CachedCopiesLock = new object(); + internal List CachedCopies; + public Entity _ClonedFrom; + internal bool IsImmutable; + + /// + /// Base constructor (called implicitly in all typed entity classes) to initialize an object. + /// + protected Entity() + { + IsNew = true; + IsImmutable = true; + Initialize(); + } + + /// + /// Gets the id of this entity. + /// + public abstract object GetId(); + + public override int GetHashCode() => GetId().GetHashCode(); + + #region CachedCopies + + internal void RegisterCachedCopy(ICachedReference cachedCopy) + { + if (cachedCopy == null) return; + lock (CachedCopiesLock) + { + if (CachedCopies == null) CachedCopies = new List(); + CachedCopies.Add(cachedCopy); + } + } + + /// + /// Invalidates its cached references. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public virtual void InvalidateCachedReferences() + { + lock (CachedCopiesLock) + { + if (CachedCopies == null) CachedCopies = new List(); + else foreach (var c in CachedCopies) c.Invalidate(); + } + + _ClonedFrom?.InvalidateCachedReferences(); + } + + #endregion + + /// + /// Determines whether this is a newly created instace. This value will be True for new objects, and False for anything loaded from the database. + /// + [XmlIgnore, JsonIgnore] + public virtual bool IsNew { get; internal set; } + + /// + /// Determines whether this instance is "soft-deleted". + /// + [XmlIgnore, JsonIgnore, EditorBrowsable(EditorBrowsableState.Never)] + public virtual bool IsMarkedSoftDeleted { get; internal set; } + + /// + /// Determines whether this object is already cloned and updated in the database without this instance being updated. + /// + public bool IsStale; + + /// + /// Initializes this instance. + /// This can be overridden in the business entity types to provide "construction" logic. + /// + protected internal virtual void Initialize() { } + + #region ToString(format) + + /// + /// Gets the primitive properties of this tye. + /// + PropertyInfo[] GetPrimitiveProperties() + { + var myType = GetType(); + if (PrimitiveProperties.ContainsKey(myType)) + { + // Already cached: + return PrimitiveProperties[myType]; + } + else + { + lock (PrimitivePropertiesSyncLock) + { + if (PrimitiveProperties.ContainsKey(myType)) + return PrimitiveProperties[myType]; + var result = ExtractPrimitiveProperties(myType); + PrimitiveProperties.Add(myType, result); + return result; + } + } + } + + /// + /// Extracts the primitive properties of a specified type. + /// + static PropertyInfo[] ExtractPrimitiveProperties(Type type) + { + var result = new List(); + var primitiveTypes = new[] { typeof(string), typeof(int), typeof(int?), typeof(double), typeof(double?), typeof(DateTime), typeof(DateTime?) }; + foreach (var p in type.GetProperties(BindingFlags.Instance | BindingFlags.Public).Where(p => p.CanRead).Where(p => primitiveTypes.Contains(p.PropertyType))) + { + if (p.Name == nameof(IsNew)) continue; + if (p.PropertyType.Implements()) continue; + if (CalculatedAttribute.IsCalculated(p)) continue; + result.Add(p); + } + + return result.ToArray(); + } + + /// + /// Returns a string that contains all primitive properties of this instance. + /// This should be used normally in "full text search". + /// + public virtual string ToString(string format) + { + if (format == "F") + { + var r = new StringBuilder(); + foreach (var p in GetPrimitiveProperties()) + { + try + { + r.Append(p.GetValue(this)?.ToString() + " "); + } + catch + { + // We don't want this method to throw an exception even if some properties cannot be read. + // No logging is needed + } + } + + return r.ToString(); + } + else + return ToString(); + } + + #endregion + + /// + /// Validates the data for the properties of the current instance. + /// It throws a ValidationException if an error is detected + /// + [EditorBrowsable(EditorBrowsableState.Never)] + protected virtual Task ValidateProperties() => Task.CompletedTask; + + /// + /// Validates this instance to ensure it can be saved in a data repository. + /// If this finds an issue, it throws a ValidationException for that. + /// This calls ValidateProperties(). Override this method to provide custom validation logic in a type. + /// + public virtual Task Validate() => ValidateProperties(); + + /// + /// This even is raised just after this instance is loaded from the database. + /// + public AsyncEvent Loaded { get; } = new AsyncEvent(); + protected internal virtual Task OnLoaded() => Loaded.Raise(); + + /// + /// This event is raised just before this instance is saved in the data repository. + /// + public AsyncEvent Saving { get; } = new AsyncEvent(); + protected internal virtual Task OnSaving(CancelEventArgs e) => Saving.Raise(e); + + /// + /// This is raised just before the object is being Validated. + /// It will automatically be called in Database.Save() method before calling the Validate() method. + /// Use this to do any last-minute object modifications, such as initializing complex values. + /// + public AsyncEvent Validating { get; } = new AsyncEvent(); + protected internal virtual Task OnValidating(EventArgs e) => Validating.Raise(e); + + /// + /// This event is raised after this instance is saved in the database. + /// + public AsyncEvent Saved { get; } = new AsyncEvent(); + + /// + /// Raises the event. + /// + /// The instance containing the event data. + protected internal virtual async Task OnSaved(SaveEventArgs e) + { + InvalidateCachedReferences(); + await Saved.Raise(e); + await EntityManager.RaiseStaticOnSaved(e); + InvalidateCachedReferences(); + } + + /// + /// This event is raised just before this instance is deleted from the database. + /// + public AsyncEvent Deleting { get; } = new AsyncEvent(); + protected internal virtual Task OnDeleting(CancelEventArgs e) => Deleting.Raise(e); + + /// + /// This event is raised just after this instance is deleted from the database. + /// + public AsyncEvent Deleted { get; } = new AsyncEvent(); + + protected internal virtual async Task OnDeleted(EventArgs e) + { + InvalidateCachedReferences(); + await Deleted.Raise(); + await EntityManager.RaiseStaticOnDeleted(e); + InvalidateCachedReferences(); + } + + /// + /// Creates a shallow copy of this object. + /// If you need to update an instance loaded from the database, you must create a Clone of it before applying any changes. + /// Otherwise you will be editing the "live" instance from the cache, that is used by all other threads! + /// + public virtual IEntity Clone() + { + var result = (Entity)MemberwiseClone(); + result.IsImmutable = false; + + result._ClonedFrom = this; + return result; + } + + /// + /// Determines whether the specified object is equal to this instance. + /// + public override bool Equals(object @object) => Equals(@object as Entity); + + /// Determines whether the specified object is equal to this instance. + public abstract bool Equals(Entity @object); + + /// + /// Implements the operator ==. + /// + public static bool operator ==(Entity left, object right) + { + if (right == null) return left == null; + var rightEntity = right as Entity; + if (rightEntity == null) return false; + + return left == rightEntity; + } + + public static bool operator !=(Entity left, object right) => !(left == right); + + public static bool operator ==(Entity left, Entity right) + { + if (ReferenceEquals(left, right)) return true; + if (ReferenceEquals(left, null)) return false; + return left.Equals(right); + } + + public static bool operator !=(Entity left, Entity right) => !(left == right); + + /// + /// Compares the current instance with another object of the same type. + /// + /// An object to compare with this instance. + public virtual int CompareTo(object other) + { + if (other == null) return 1; + else return string.Compare(ToString(), other.ToString(), ignoreCase: true); + } + + #region Database instance + static IDatabase DatabaseInstance; + + public static IDatabase Database => DatabaseInstance + ?? throw new InvalidOperationException("The database instance is not initialized."); + + [EditorBrowsable(EditorBrowsableState.Never)] + public static void InitializeDatabase(IDatabase instance) + { + DatabaseInstance = instance ?? throw new ArgumentException("Database instance cannot be null."); + } + #endregion + } +} \ No newline at end of file diff --git a/Olive.Entities/EntityManager.cs b/Olive.Entities/EntityManager.cs new file mode 100644 index 000000000..eeb269fd4 --- /dev/null +++ b/Olive.Entities/EntityManager.cs @@ -0,0 +1,266 @@ +using System; +using System.ComponentModel; +using System.Reflection; +using System.Threading.Tasks; + +namespace Olive.Entities +{ + /// + /// Provides services for Entity objects. + /// + public static class EntityManager + { + /// + /// Determines whether the specified record is immutable, or closed for changes. + /// An object marked as immutable is shared in the application cache. Therefore it must not be changed. + /// + public static bool IsImmutable(IEntity entity) + { + var item = entity as Entity; + + if (item == null) + throw new ArgumentNullException("entity must be a non-null instance inheriting from Entity."); + + return item.IsImmutable && !entity.IsNew; + } + + /// + /// Marks the specified object as immutable. + /// + public static void MarkImmutable(IEntity entity) + { + if (entity == null) + throw new ArgumentNullException(nameof(entity)); + + (entity as Entity).IsImmutable = true; + } + + #region Entity static events + /// + /// This event is raised for the whole Entity type before "any" object is saved in the database. + /// You can handle this to provide global functionality/event handling scenarios. + /// + public readonly static AsyncEvent InstanceSaving = new AsyncEvent(); + + /// + /// This event is raised for the whole Entity type after "any" object is saved in the database. + /// You can handle this to provide global functionality/event handling scenarios. + /// + public readonly static AsyncEvent InstanceSaved = new AsyncEvent(); + + /// + /// This event is raised for the whole Entity type before "any" object is deleted from the database. + /// You can handle this to provide global functionality/event handling scenarios. + /// + public readonly static AsyncEvent InstanceDeleting = new AsyncEvent(); + + /// + /// This event is raised for the whole Entity type before "any" object is validated. + /// You can handle this to provide global functionality/event handling scenarios. + /// This will be called as the first line of the base Entity's OnValidating method. + /// + public readonly static AsyncEvent InstanceValidating = new AsyncEvent(); + + /// + /// This event is raised for the whole Entity type after "any" object is deleted from the database. + /// You can handle this to provide global functionality/event handling scenarios. + /// + public readonly static AsyncEvent InstanceDeleted = new AsyncEvent(); + #endregion + + #region Raise events + + internal static Task RaiseStaticOnSaved(SaveEventArgs args) => + InstanceSaved.Raise(args); + + internal static Task RaiseStaticOnDeleted(EventArgs args) => + InstanceDeleted.Raise(args); + + public static async Task RaiseOnDeleting(IEntity record, CancelEventArgs args) + { + if (record == null) throw new ArgumentNullException(nameof(record)); + + await InstanceDeleting.Raise(args); + + if (args.Cancel) return; + + await (record as Entity).OnDeleting(args); + } + + public static async Task RaiseOnValidating(IEntity record, EventArgs args) + { + if (record == null) throw new ArgumentNullException(nameof(record)); + + await InstanceValidating.Raise(args); + + await (record as Entity).OnValidating(args); + } + + public static async Task RaiseOnDeleted(IEntity record) + { + if (record == null) + throw new ArgumentNullException(nameof(record)); + + await (record as Entity).OnDeleted(EventArgs.Empty); + } + + public static async Task RaiseOnLoaded(IEntity record) + { + if (record == null) + throw new ArgumentNullException(nameof(record)); + + await (record as Entity).OnLoaded(); + } + + public static async Task RaiseOnSaving(IEntity record, CancelEventArgs e) + { + if (record == null) throw new ArgumentNullException(nameof(record)); + + await InstanceSaving.Raise(e); + if (e.Cancel) return; + + await (record as Entity).OnSaving(e); + } + + public static async Task RaiseOnSaved(IEntity record, SaveEventArgs e) + { + if (record == null) + throw new ArgumentNullException(nameof(record)); + + await (record as Entity).OnSaved(e); + } + + #endregion + + /// + /// Sets the state of an entity instance to saved. + /// + public static void SetSaved(IEntity entity, bool saved = true) + { + (entity as Entity).IsNew = !saved; + + entity.GetType().GetProperty("OriginalId").SetValue(entity, entity.GetId()); + } + + /// + /// Creates a new clone of an entity. This will work in a polymorphic way. + /// + public static T CloneAsNew(T entity) where T : Entity => CloneAsNew(entity, null); + + /// + /// Creates a new clone of an entity. This will work in a polymorphic way. + /// + public static T CloneAsNew(T entity, Action changes) where T : Entity + { + var result = (T)entity.Clone(); + result.IsNew = true; + + if (result is GuidEntity) (result as GuidEntity).ID = GuidEntity.NewGuidGenerator(result.GetType()); + // TODO: the following line need to be reviewed and fixed. + // if (result is IntEntity) (result as IntEntity).ID = IntEntity.NewIdGenerator(result.GetType()); + + // Setting the value of AutoNumber properties to zero + foreach (var propertyInfo in result.GetType().GetProperties()) + if (AutoNumberAttribute.IsAutoNumber(propertyInfo)) + propertyInfo.SetValue(result, 0); + + result.Initialize(); + + // Re attach Documents: + changes?.Invoke(result); + + return result; + } + + /// + /// Sets the ID of an object explicitly. + /// + public static void RestsetOriginalId(IEntity entity) + { + if (entity == null) + throw new ArgumentNullException(nameof(entity)); + + ((dynamic)entity).OriginalId = entity.ID; + } + + public static void SetSaved(IEntity entity, T id) + { + ((dynamic)entity).IsNew = false; + + entity.ID = id; + RestsetOriginalId(entity); + } + + /// + /// Read the value of a specified property from a specified object. + /// + public static object ReadProperty(object @object, string propertyName) + { + if (@object == null) + throw new ArgumentNullException(nameof(@object)); + + var property = FindProperty(@object.GetType(), propertyName); + + try + { + return property.GetValue(@object, null); + } + catch (Exception ex) + { + throw new Exception($"Could not read the value of the property {propertyName } from the given {@object.GetType().FullName} object.", ex); + } + } + + public static PropertyInfo FindProperty(Type type, string propertyName) + { + if (propertyName.IsEmpty()) throw new ArgumentNullException(nameof(propertyName)); + + var result = type.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly); + + if (result == null) // Try inherited properties. + result = type.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public); + + if (result == null) throw new ArgumentException($"{type} does not have a property named {propertyName}"); + + return result; + } + + public static void WriteProperty(object @object, string propertyName, object value) + { + if (@object == null) + throw new ArgumentNullException(nameof(@object)); + + var property = FindProperty(@object.GetType(), propertyName); + + try + { + property.SetValue(@object, value, null); + } + catch (Exception ex) + { + throw new ArgumentException($"Could not set the value of the property {propertyName} from the given {@object.GetType().FullName} object.", ex); + } + } + + public static bool IsSoftDeleted(Entity entity) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + return entity.IsMarkedSoftDeleted; + } + + public static void MarkSoftDeleted(Entity entity) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + entity.IsMarkedSoftDeleted = true; + } + + public static void UnMarkSoftDeleted(Entity entity) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + entity.IsMarkedSoftDeleted = false; + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Extensions/@Misc.cs b/Olive.Entities/Extensions/@Misc.cs new file mode 100644 index 000000000..367e09295 --- /dev/null +++ b/Olive.Entities/Extensions/@Misc.cs @@ -0,0 +1,25 @@ +using System; + +namespace Olive.Entities +{ + public static partial class OliveExtensions + { + /// + /// Gets the root entity type of this type. + /// If this type inherits directly from Entity<T> then it will be returned, otherwise its parent... + /// + public static Type GetRootEntityType(this Type objectType) + { + var baseType = objectType.BaseType; + if (baseType == null) + throw new NotSupportedException(objectType.FullName + " not recognised. It must be a subclass of Olive.Entities.Entity."); + + if (baseType.Name == "GuidEntity") return objectType; + if (baseType == typeof(Entity)) return objectType; + if (baseType == typeof(Entity)) return objectType; + if (baseType == typeof(Entity)) return objectType; + + return GetRootEntityType(baseType); + } + } +} diff --git a/Olive.Entities/Extensions/Entity.cs b/Olive.Entities/Extensions/Entity.cs new file mode 100644 index 000000000..6549853ce --- /dev/null +++ b/Olive.Entities/Extensions/Entity.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace Olive.Entities +{ + partial class OliveExtensions + { + /// + /// Determines if this item is in a specified list of specified items. + /// + public static bool IsAnyOf(this T item, params T[] options) where T : IEntity + { + if (item == null) return options.Contains(default(T)); + + return options.Contains(item); + } + + /// + /// Determines if this item is in a specified list of specified items. + /// + public static bool IsAnyOf(this T item, IEnumerable options) where T : IEntity + { + return options.Contains(item); + } + + /// + /// Determines if this item is none of a list of specified items. + /// + public static bool IsNoneOf(this T item, params T[] options) where T : IEntity + { + if (item == null) return !options.Contains(default(T)); + + return !options.Contains(item); + } + + /// + /// Determines if this item is none of a list of specified items. + /// + public static bool IsNoneOf(this T item, IEnumerable options) where T : IEntity + { + if (item == null) return !options.Contains(default(T)); + + return !options.Contains(item); + } + + /// + /// Clones all items of this collection. + /// + public static List CloneAll(this IEnumerable list) where T : IEntity + { + return list.Select(i => (T)i.Clone()).ToList(); + } + + /// + /// Determines whether this blob is an image. + /// + public static bool IsImage(this Blob doc) + { + throw new NotImplementedException(); + // if (doc.IsEmpty()) return false; + + // try + // { + // using (System.Drawing.Imaging.BitmapHelper.FromBuffer(doc.FileData)) + // { + // return true; + // } + // } + // catch + // { + // return false; + // } + } + + /// + /// Gets the id of this entity. + /// + public static string GetFullIdentifierString(this IEntity entity) + { + if (entity == null) return null; + + return entity.GetType().GetRootEntityType().FullName + "/" + entity.GetId(); + } + + /// + /// Validates all entities in this collection. + /// + /// Entity type + /// The entities. + public static Task ValidateAll(this IEnumerable entities) where T : Entity + { + return Task.WhenAll(entities.Select(x => x.Validate())); + } + + /// + /// Returns this Entity only if the given predicate evaluates to true and this is not null. + /// + public static T OnlyWhen(this T entity, Func criteria) where T : Entity + { + return entity != null && criteria(entity) ? entity : null; + } + + /// + /// Returns all entity Guid IDs for this collection. + /// + public static IEnumerable IDs(this IEnumerable> entities) + { + return entities.Select(entity => entity.ID); + } + + public static T On(this T @this, Func @event, Func handler, + [CallerFilePath] string callerFile = null, + [CallerLineNumber] int callerLine = 0) + where T : Entity + { + @event(@this).Handle(handler, callerFile, callerLine); + return @this; + } + + public static T On(this T @this, Func @event, Action handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int callerLine = 0) + where T : Entity + { + @event(@this).Handle(handler, callerFile, callerLine); + return @this; + } + + public static T On(this T @this, Func> @event, Func handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int callerLine = 0) + where T : Entity + { + @event(@this).Handle(handler, callerFile, callerLine); + return @this; + } + + public static T On(this T @this, Func> @event, Func handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int callerLine = 0) + where T : Entity + { + @event(@this).Handle(handler, callerFile, callerLine); + return @this; + } + + public static T On(this T @this, Func> @event, Action handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int callerLine = 0) + where T : Entity + { + @event(@this).Handle(handler, callerFile, callerLine); + return @this; + } + + public static T On(this T @this, Func> @event, Action handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int callerLine = 0) + where T : Entity + { + @event(@this).Handle(handler, callerFile, callerLine); + return @this; + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Extensions/Guid.cs b/Olive.Entities/Extensions/Guid.cs new file mode 100644 index 000000000..9f1a000ff --- /dev/null +++ b/Olive.Entities/Extensions/Guid.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; + +namespace Olive.Entities +{ + partial class OliveExtensions + { + /// + /// This will use Database.Get() to load the specified entity type with this ID. + /// + public static async Task To(this Guid? guid) where T : IEntity + { + if (guid == null) return default(T); + + return await guid.Value.To(); + } + + /// + /// This will use Database.Get() to load the specified entity type with this ID. + /// + public static async Task To(this Guid guid) where T : IEntity + { + if (guid == Guid.Empty) return default(T); + + return await Entity.Database.Get(guid); + } + } +} \ No newline at end of file diff --git a/Olive.Entities/Extensions/IHierarchy.cs b/Olive.Entities/Extensions/IHierarchy.cs new file mode 100644 index 000000000..84c1753d2 --- /dev/null +++ b/Olive.Entities/Extensions/IHierarchy.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Olive.Entities +{ + partial class OliveExtensions + { /// + /// Gets the full path of this hirarchical entity, seperated by " > ". + /// + public static string GetFullPath(this IHierarchy node) => node.GetFullPath(" > "); + + /// + /// Gets whether this node is a root hierarchy node. + /// + public static bool IsRootNode(this IHierarchy node) => node.GetParent() == null; + + /// + /// Gets the full path of this hirarchical entity, seperated by a specified seperation string. + /// + public static string GetFullPath(this IHierarchy hierarchy, string seperator) + { + if (hierarchy == null) return null; + if (hierarchy.GetParent() == null || hierarchy.GetParent() == hierarchy) + return hierarchy.Name; + else return hierarchy.GetParent().GetFullPath(seperator) + seperator + hierarchy.Name; + } + + /// + /// Gets this node as well as all its children hierarchy. + /// + public static IEnumerable WithAllChildren(this IHierarchy parent) => + parent.GetAllChildren().Concat(parent).OrderBy(i => i.GetFullPath()).ToArray(); + + /// + /// Gets all children hierarchy of this node. + /// + public static IEnumerable GetAllChildren(this IHierarchy parent) => + parent.GetChildren().Except(parent).SelectMany(c => c.WithAllChildren()).OrderBy(i => i.GetFullPath()).ToArray(); + + /// + /// Gets this node as well as all its parents hierarchy. + /// + public static IEnumerable WithAllParents(this IHierarchy child) => + child.GetAllParents().Concat(child).OrderBy(i => i.GetFullPath()).ToArray(); + + /// + /// Gets all parents hierarchy of this node. + /// + public static IEnumerable GetAllParents(this IHierarchy child) + { + var parent = child.GetParent(); + + if (parent == null || parent == child) return new IHierarchy[0]; + else return parent.WithAllParents().OrderBy(i => i.GetFullPath()).ToArray(); + } + + /// + /// Gets this node as well as all its parents hierarchy. + /// + public static IEnumerable WithAllParents(this T child) where T : IHierarchy => + (child as IHierarchy).WithAllParents().Cast().ToArray(); + + /// + /// Gets all parents hierarchy of this node. + /// + public static IEnumerable GetAllParents(this IHierarchy child) where T : IHierarchy => + (child as IHierarchy).GetAllParents().Cast().ToArray(); + } +} \ No newline at end of file diff --git a/Olive.Entities/GuidEntity.cs b/Olive.Entities/GuidEntity.cs new file mode 100644 index 000000000..7b6865c60 --- /dev/null +++ b/Olive.Entities/GuidEntity.cs @@ -0,0 +1,75 @@ +using System; +using System.ComponentModel; + +namespace Olive.Entities +{ + /// Represents a base Entity with ID of type Guid. + public abstract class GuidEntity : Entity, IEntity + { + bool IsIdLoaded; // For performance, this is used instead of Nullable + Guid id; + + [EditorBrowsable(EditorBrowsableState.Never)] + public static Func NewGuidGenerator = t => Guid.NewGuid(); + + /// + /// Gets a unique Identifier for this instance. In the database, this will be the primary key of this object. + /// + [JsonExposed] + public override Guid ID + { + get + { + if (IsIdLoaded) return id; + else + { + id = NewGuidGenerator(GetType()); + IsIdLoaded = true; + return id; + } + } + set + { + id = value; + IsIdLoaded = true; + } + } + + /// + /// Creates a shallow copy of this object. + /// If you need to update an instance loaded from the database, you must create a Clone of it before applying any changes. + /// Otherwise you will be editing the "live" instance from the cache, that is used by all other threads! + /// + public override IEntity Clone() + { + var result = base.Clone() as GuidEntity; + + // This is needed to avoid the problem caused by lazy loading of ID value. + result.ID = ID; + + return result; + } + + public static bool operator !=(GuidEntity entity, Guid? id) => entity?.ID != id; + + public static bool operator ==(GuidEntity entity, Guid? id) => entity?.ID == id; + + public static bool operator !=(GuidEntity entity, Guid id) => entity?.ID != id; + + public static bool operator ==(GuidEntity entity, Guid id) => entity?.ID == id; + + public static bool operator !=(Guid? id, GuidEntity entity) => entity?.ID != id; + + public static bool operator ==(Guid? id, GuidEntity entity) => entity?.ID == id; + + public static bool operator !=(Guid id, GuidEntity entity) => entity?.ID != id; + + public static bool operator ==(Guid id, GuidEntity entity) => entity?.ID == id; + + public override bool Equals(Entity other) => ID == (other as GuidEntity)?.ID; + + public override bool Equals(object other) => ID == (other as GuidEntity)?.ID; + + public override int GetHashCode() => ID.GetHashCode(); + } +} \ No newline at end of file diff --git a/Olive.Entities/IEntity.cs b/Olive.Entities/IEntity.cs new file mode 100644 index 000000000..299d44f77 --- /dev/null +++ b/Olive.Entities/IEntity.cs @@ -0,0 +1,50 @@ +using System; +using System.ComponentModel; +using System.Threading.Tasks; + +namespace Olive.Entities +{ + /// + /// Represents an M# Entity. + /// + public interface IEntity : IComparable + { + /// + /// Determines whether this object has just been instantiated as a new object, or represent an already persisted instance. + /// + bool IsNew { get; } + + /// + /// Validates this instance and throws ValidationException if necessary. + /// + Task Validate(); + + /// + /// Gets the id of this entity. + /// + object GetId(); + + /// + /// Creates a new object that is a copy of the current instance. + /// + /// A new object that is a copy of this instance. + IEntity Clone(); + + /// + /// Invalidates all its cached referencers. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + void InvalidateCachedReferences(); + } + + /// + /// A persistent object in the application. + /// + public interface IEntity : IEntity + { + /// + /// Gets the ID. + /// + T ID { get; set; } + } +} \ No newline at end of file diff --git a/Olive.Entities/IHierarchy.cs b/Olive.Entities/IHierarchy.cs new file mode 100644 index 000000000..fde472b83 --- /dev/null +++ b/Olive.Entities/IHierarchy.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Olive.Entities +{ + public interface IHierarchy : IEntity + { + IHierarchy GetParent(); + + IEnumerable GetChildren(); + + string Name { get; } + } +} diff --git a/Olive.Entities/IntEntity.cs b/Olive.Entities/IntEntity.cs new file mode 100644 index 000000000..228ebce39 --- /dev/null +++ b/Olive.Entities/IntEntity.cs @@ -0,0 +1,96 @@ +namespace Olive.Entities +{ + using System; + using System.Collections.Concurrent; + using System.ComponentModel; + using System.Linq; + using System.Threading.Tasks; + + public class IntEntity : Entity + { + // bool IsIdLoaded = false; + int id; + + [EditorBrowsable(EditorBrowsableState.Never)] + public static Func> NewIdGenerator = DefaultNewIdGenerator; + + // TODO: The ID property need to be reviewed and fixed. + /// + /// Gets a unique Identifier for this instance. In the database, this will be the primary key of this object. + /// + public override int ID + { + get + { + if (IsNew) + throw new InvalidOperationException($"ID is not avialable for instances of '{GetType().Name}' before being saved to the database."); + + return id; + // if (IsIdLoaded) return id; + // else + // { + // if (GetType().Defines(inherit: true)) + // throw new InvalidOperationException($"ID is not avialable for instances of '{GetType().Name}' before being saved to the database."); + + // id = NewIdGenerator(GetType()); + // IsIdLoaded = true; + // return id; + // } + } + set + { + if (IsNew) + throw new InvalidOperationException($"ID is not avialable for instances of '{GetType().Name}' before being saved to the database."); + + id = value; + // IsIdLoaded = true; + } + } + + static ConcurrentDictionary LastUsedIds = new ConcurrentDictionary(); + + static async Task DefaultNewIdGenerator(Type type) + { + // One generator per hierarchy + if (type.BaseType != typeof(IntEntity)) + return await DefaultNewIdGenerator(type.BaseType); + + var initialize = (Func>)(async (t) => + { + if (TransientEntityAttribute.IsTransient(t)) return 1; + + var biggestId = ((await Database.Of(t).OrderBy("ID", descending: true) + .Top(1).GetList()).FirstOrDefault()?.GetId()); + + if (biggestId != null) return 1 + (int)biggestId; + else return 1; + }); + + var value = await initialize(type); + + return LastUsedIds.AddOrUpdate(type, value, (t, old) => old + 1); + } + + public static bool operator !=(IntEntity entity, int? id) => entity?.ID != id; + + public static bool operator ==(IntEntity entity, int? id) => entity?.ID == id; + + public static bool operator !=(IntEntity entity, int id) => entity?.ID != id; + + public static bool operator ==(IntEntity entity, int id) => entity?.ID == id; + + public static bool operator !=(int? id, IntEntity entity) => entity?.ID != id; + + public static bool operator ==(int? id, IntEntity entity) => entity?.ID == id; + + public static bool operator !=(int id, IntEntity entity) => entity?.ID != id; + + public static bool operator ==(int id, IntEntity entity) => entity?.ID == id; + + public override bool Equals(Entity other) => GetType() == other?.GetType() && ID == (other as IntEntity)?.ID; + + public override bool Equals(object other) => Equals(other as Entity); + + public override int GetHashCode() => ID.GetHashCode(); + } +} \ No newline at end of file diff --git a/Olive.Entities/Olive.Entities.csproj b/Olive.Entities/Olive.Entities.csproj new file mode 100644 index 000000000..167230aa3 --- /dev/null +++ b/Olive.Entities/Olive.Entities.csproj @@ -0,0 +1,19 @@ + + + + netcoreapp2.0 + Olive.Entities + Olive.Entities + + + + ..\@Assemblies\ + ..\@Assemblies\netcoreapp2.0\Olive.Entities.xml + 1701;1702;1705;1591;1573 + + + + + + + \ No newline at end of file diff --git a/Olive.Entities/Package.nuspec b/Olive.Entities/Package.nuspec new file mode 100644 index 000000000..9ac092974 --- /dev/null +++ b/Olive.Entities/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.Entities + 1.0.4 + Olive Entities + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Olive.Entities/SaveBehaviour.cs b/Olive.Entities/SaveBehaviour.cs new file mode 100644 index 000000000..6643a6895 --- /dev/null +++ b/Olive.Entities/SaveBehaviour.cs @@ -0,0 +1,25 @@ +using System; + +namespace Olive.Entities +{ + [Flags] + public enum SaveBehaviour + { + Default = 1, + BypassValidation = 2, + BypassSaving = 4, + BypassSaved = 8, + BypassLogging = 16, + BypassAll = 30, + } + + [Flags] + public enum DeleteBehaviour + { + Default = 1, + BypassDeleting = 2, + BypassDeleted = 4, + BypassLogging = 8, + BypassAll = 14, + } +} diff --git a/Olive.Entities/SaveEventArgs.cs b/Olive.Entities/SaveEventArgs.cs new file mode 100644 index 000000000..e29de84c2 --- /dev/null +++ b/Olive.Entities/SaveEventArgs.cs @@ -0,0 +1,12 @@ +using System; + +namespace Olive.Entities +{ + public class SaveEventArgs : EventArgs + { + public SaveEventArgs(SaveMode mode) { Mode = mode; } + public SaveMode Mode { get; private set; } + } + + public enum SaveMode { Update, Insert } +} \ No newline at end of file diff --git a/Olive.Entities/Sorting/ISortable.cs b/Olive.Entities/Sorting/ISortable.cs new file mode 100644 index 000000000..9c0d2c7e7 --- /dev/null +++ b/Olive.Entities/Sorting/ISortable.cs @@ -0,0 +1,10 @@ +namespace Olive.Entities +{ + /// + /// Represents a sortable entity type. + /// + public interface ISortable : IEntity + { + int Order { get; set; } + } +} diff --git a/Olive.Entities/Sorting/Sorter.cs b/Olive.Entities/Sorting/Sorter.cs new file mode 100644 index 000000000..5e1a86cb3 --- /dev/null +++ b/Olive.Entities/Sorting/Sorter.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Olive.Entities +{ + /// + /// Provides Sorting services for all entities. + /// + public static class Sorter + { + public const int INCREMENT = 10; + + static AsyncLock AsyncLock = new AsyncLock(); + + public static async Task FindItemAbove(ISortable item) => + (await FindSiblings(item)).Except(item).Where(o => o.Order <= item.Order).WithMax(o => o.Order); + + public static async Task FindItemBelow(ISortable item) => + (await FindSiblings(item)).Except(item).Where(i => i.Order >= item.Order).WithMin(i => i.Order); + + public static bool CanMoveUp(ISortable item) => FindItemAbove(item) != null; + + public static bool CanMoveDown(ISortable item) => FindItemBelow(item) != null; + + /// + /// Moves this item before a specified other item. If null is specified, it will be moved to the end of its siblings. + /// + public static async Task MoveBefore(ISortable item, ISortable before, SaveBehaviour saveBehaviour = SaveBehaviour.Default) + { + var newOrder = (before == null ? int.MaxValue : before.Order) - 1; + + if (newOrder < 0) newOrder = 0; + + item = await Entity.Database.Update(item, o => o.Order = newOrder, saveBehaviour); + + await JustifyOrders(item, saveBehaviour); + } + + /// + /// Moves this item after a specified other item. If null is specified, it will be moved to the beginning of its siblings. + /// + public static async Task MoveAfter(ISortable item, ISortable after, SaveBehaviour saveBehaviour = SaveBehaviour.Default) + { + var newOrder = (after == null ? 0 : after.Order) + 1; + + item = await Entity.Database.Update(item, o => o.Order = newOrder, saveBehaviour); + + await JustifyOrders(item, saveBehaviour); + } + + /// + /// Moves an item up among its siblings. Returns False if the item is already first in the list, otherwise true. + /// + public static async Task MoveUp(ISortable item, SaveBehaviour saveBehaviour = SaveBehaviour.Default) + { + using (await AsyncLock.Lock()) + { + var above = await FindItemAbove(item); + + if (above == null) return false; + + if (above.Order == item.Order) above.Order--; + + await Swap(item, above, saveBehaviour); + + item = await Entity.Database.Reload(item); + above = await Entity.Database.Reload(above); + + await JustifyOrders(item, saveBehaviour); + + return true; + } + } + + /// + /// Moves an item up to first among its siblings. Returns False if the item is already first in the list, otherwise true. + /// + public static async Task MoveFirst(ISortable item, SaveBehaviour saveBehaviour = SaveBehaviour.Default) + { + using (await AsyncLock.Lock()) + { + var first = (await FindSiblings(item)).Min(o => o.Order); + + if (first <= 0) return false; + + await Entity.Database.Update(item, o => o.Order = first - 1, saveBehaviour); + await JustifyOrders(item, saveBehaviour); + return true; + } + } + + /// + /// Moves an item up to last among its siblings. Always returns true. + /// + public static async Task MoveLast(ISortable item, SaveBehaviour saveBehaviour = SaveBehaviour.Default) + { + using (await AsyncLock.Lock()) + { + var last = (await FindSiblings(item)).Max(o => o.Order); + + await Entity.Database.Update(item, o => o.Order = last + 1, saveBehaviour); + await JustifyOrders(item, saveBehaviour); + return true; + } + } + + /// + /// Moves an item down among its siblings. Returns False if the item is already last in the list, otherwise true. + /// + public static async Task MoveDown(ISortable item, SaveBehaviour saveBehaviour = SaveBehaviour.Default) + { + using (await AsyncLock.Lock()) + { + var below = await FindItemBelow(item); + + if (below == null) return false; + + if (below.Order == item.Order) item.Order++; + + await Swap(item, below, saveBehaviour); + + await JustifyOrders(item, saveBehaviour); + + return true; + } + } + + /// + /// Swaps the order of two specified items. + /// + static async Task Swap(ISortable one, ISortable two, SaveBehaviour saveBehaviour) + { + var somethingAboveAll = (await FindSiblings(one)).Max(i => i.Order) + 20; + + await Entity.Database.EnlistOrCreateTransaction(async () => + { + var order1 = two.Order; + var order2 = one.Order; + + await Entity.Database.Update(one, i => i.Order = order1, saveBehaviour); + await Entity.Database.Update(two, i => i.Order = order2, saveBehaviour); + }); + } + + /// + /// Justifies the order of a specified item and its siblings. + /// The value of the "Order" property in those objects will be 10, 20, 30, ... + /// + public static async Task JustifyOrders(ISortable item, SaveBehaviour saveBehaviour = SaveBehaviour.Default) + { + using (await AsyncLock.Lock()) + { + var changed = new List(); + + var order = 0; + + foreach (var sibling in (await FindSiblings(item)).OrderBy(i => i.Order).Distinct().ToArray()) + { + order += INCREMENT; + if (sibling.Order == order) continue; + + var clone = sibling.Clone() as Entity; + (clone as ISortable).Order = order; + changed.Add(clone); + } + + await Entity.Database.Save(changed, saveBehaviour); + } + } + + /// + /// Discovers the siblings of the specified sortable object. + /// + static async Task> FindSiblings(ISortable item) + { + var getSiblingsMethod = item.GetType().GetMethod("GetSiblings", BindingFlags.Public | BindingFlags.Instance); + + bool isAcceptable = true; + + if (getSiblingsMethod == null) isAcceptable = false; + else if (getSiblingsMethod.GetParameters().Any()) isAcceptable = false; + else if (!getSiblingsMethod.ReturnType.Implements(typeof(IEnumerable))) isAcceptable = false; + + IEnumerable result; + + if (!isAcceptable) + { + result = (await Entity.Database.Of(item.GetType()).GetList()).Cast(); + } + else + { + var list = new List(); + + try + { + foreach (ISortable element in getSiblingsMethod.Invoke(item, null) as IEnumerable) + list.Add(element); + } + catch (Exception ex) + { + throw new Exception($"Services.Sorter Could not process the GetSiblings method from the {item.GetType().Name} instance.", ex); + } + + result = list; + } + + return result.OrderBy(i => i.Order).ToList(); + } + + /// + /// Gets the Next order for an ISortable entity. + /// The result will be 10 plus the largest order of its siblings. + /// + public static async Task GetNewOrder(ISortable item) + { + using (await AsyncLock.Lock()) + { + if (!item.IsNew) + throw new ArgumentException("Sorter.GetNewOrder() method needs a new ISortable argument (with IsNew set to true).", nameof(item)); + + return INCREMENT + ((await FindSiblings(item)).LastOrDefault()?.Order ?? 0); + } + } + } +} \ No newline at end of file diff --git a/Olive.Entities/ValidationException.cs b/Olive.Entities/ValidationException.cs new file mode 100644 index 000000000..cf3b726aa --- /dev/null +++ b/Olive.Entities/ValidationException.cs @@ -0,0 +1,16 @@ +namespace Olive.Entities +{ + using System; + + public class ValidationException : Exception + { + public ValidationException() { } + public ValidationException(string messageFormat, params object[] arguments) : base(string.Format(messageFormat, arguments)) { } + public ValidationException(string message) : base(message) { } + public ValidationException(string message, Exception inner) : base(message, inner) { } + + public string InvalidPropertyName { get; set; } + + public bool IsMessageTranslated { get; set; } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Attributes/BaseCustomModelBindAttribute.cs b/Olive.Mvc/Attributes/BaseCustomModelBindAttribute.cs new file mode 100644 index 000000000..ccc13d048 --- /dev/null +++ b/Olive.Mvc/Attributes/BaseCustomModelBindAttribute.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Olive.Mvc +{ + /// + /// Any method attributed by this which takes one IViewModel parameter, will be automatically + /// called when its view model parameter object is being bound. + /// + [AttributeUsage(AttributeTargets.Method)] + public abstract class BaseModelBindAttribute : Attribute + { + protected static IEnumerable DiscoverBindMethods(ControllerContext cContext, object model, Type attributeType, + ConcurrentDictionary> cache) + { + var customBinders = cache.GetOrAdd(model.GetType(), + + t => t.Assembly.GetTypes().Where(x => x.IsA()).SelectMany(x => x.GetMethods()) + .Where(m => m.GetCustomAttributes(attributeType).Any()) + .Where(m => m.GetParameters().IsSingle() && m.GetParameters().First().ParameterType == t) + .ToList() + ); + + throw new NotImplementedException("The following code is commented to fix on the test time."); + // foreach (var customBinder in customBinders) + // { + // ControllerBase controller; + + // if (cContext.Controller.GetType().IsA(customBinder.DeclaringType)) + // controller = cContext.Controller; + // else + // { + // controller = (ControllerBase)customBinder.DeclaringType.CreateInstance(); + // (controller as Controller).Url = new UrlHelper(cContext.RequestContext); + // controller.ControllerContext = cContext; + // } + + // Action invokeMethod = () => + // { + // try { customBinder.Invoke(controller, new[] { model }); } + // catch (Exception ex) + // { + // throw new Exception($"Error in calling the binding method of {customBinder.DeclaringType.Name}.{customBinder.Name}({model.GetType().Name}).", ex); + // } + // }; + + // yield return invokeMethod; + // } + } + + protected static void Enqueue(ControllerContext cContext, object model, string actionsKey, Type attributeType, + ConcurrentDictionary> cache) + { + var queue = cContext.HttpContext.Items[actionsKey] as List; + if (queue == null) + { + queue = new List(); + cContext.HttpContext.Items[actionsKey] = queue; + } + + foreach (var action in DiscoverBindMethods(cContext, model, attributeType, cache)) + queue.Insert(0, action); + } + + protected static void SetRoot(ModelBindingContext model, string rootKey) + { + if (model.HttpContext.Items[rootKey] == null) model.HttpContext.Items[rootKey] = model; + } + + protected static void InvokeAllForRoot(ModelBindingContext model, string rootKey, string actionsKey) + { + var httpContext = model.HttpContext; + + // Don't invoke if it's not for the root item. + if (!ReferenceEquals(httpContext.Items[rootKey], model)) return; + + var actions = httpContext.Items[actionsKey] as List; + + httpContext.Items[rootKey] = null; + httpContext.Items[actionsKey] = null; + + if (actions == null) return; + + foreach (var action in actions.ToArray()) action(); + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Attributes/CopyDataAttribute.cs b/Olive.Mvc/Attributes/CopyDataAttribute.cs new file mode 100644 index 000000000..12bb4a189 --- /dev/null +++ b/Olive.Mvc/Attributes/CopyDataAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace Olive.Mvc +{ + [AttributeUsage(AttributeTargets.Property)] + public class CopyDataAttribute : Attribute + { + public bool CanCopy; + + public CopyDataAttribute(bool canCopy) { CanCopy = canCopy; } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Attributes/CustomBoundAttribute.cs b/Olive.Mvc/Attributes/CustomBoundAttribute.cs new file mode 100644 index 000000000..264121557 --- /dev/null +++ b/Olive.Mvc/Attributes/CustomBoundAttribute.cs @@ -0,0 +1,9 @@ +using System; + +namespace Olive.Mvc +{ + [AttributeUsage(AttributeTargets.Property)] + public sealed class CustomBoundAttribute : Attribute + { + } +} diff --git a/Olive.Mvc/Attributes/HasDefaultAttribute.cs b/Olive.Mvc/Attributes/HasDefaultAttribute.cs new file mode 100644 index 000000000..084c46ae3 --- /dev/null +++ b/Olive.Mvc/Attributes/HasDefaultAttribute.cs @@ -0,0 +1,10 @@ +using System; + +namespace Olive.Mvc +{ + /// + /// Specifies that a ViewModel field has explicit default value that should be loaded for editing a new object. + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class HasDefaultAttribute : Attribute { } +} diff --git a/Olive.Mvc/Attributes/KeepWhiteSpaceAttribute.cs b/Olive.Mvc/Attributes/KeepWhiteSpaceAttribute.cs new file mode 100644 index 000000000..b54aff6f1 --- /dev/null +++ b/Olive.Mvc/Attributes/KeepWhiteSpaceAttribute.cs @@ -0,0 +1,10 @@ +using System; + +namespace Olive.Mvc +{ + /// + /// Specifies that a string property should not be trimmed. + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class KeepWhiteSpaceAttribute : Attribute { } +} diff --git a/Olive.Mvc/Attributes/LocalizedDateAttribute.cs b/Olive.Mvc/Attributes/LocalizedDateAttribute.cs new file mode 100644 index 000000000..2ea48cc8e --- /dev/null +++ b/Olive.Mvc/Attributes/LocalizedDateAttribute.cs @@ -0,0 +1,7 @@ +using System; + +namespace Olive.Mvc +{ + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + public class LocalizedDateAttribute : Attribute { } +} \ No newline at end of file diff --git a/Olive.Mvc/Attributes/MasterDetailsAttribute.cs b/Olive.Mvc/Attributes/MasterDetailsAttribute.cs new file mode 100644 index 000000000..94d507460 --- /dev/null +++ b/Olive.Mvc/Attributes/MasterDetailsAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace Olive.Mvc +{ + [AttributeUsage(AttributeTargets.Property)] + public sealed class MasterDetailsAttribute : Attribute + { + public string Prefix { get; set; } + + public MasterDetailsAttribute(string prefix) => Prefix = prefix; + } +} \ No newline at end of file diff --git a/Olive.Mvc/Attributes/OnBoundAttribute.cs b/Olive.Mvc/Attributes/OnBoundAttribute.cs new file mode 100644 index 000000000..3677566f1 --- /dev/null +++ b/Olive.Mvc/Attributes/OnBoundAttribute.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Olive.Mvc +{ + /// + /// Any method attributed by this which takes one IViewModel parameter, will be automatically called when its view model parameter object is being bound. + /// + [AttributeUsage(AttributeTargets.Method)] + public sealed class OnBoundAttribute : BaseModelBindAttribute + { + static ConcurrentDictionary> CustomBindMethods = new ConcurrentDictionary>(); + + const string ROOT = "_MSharp.CustomBindActions.RootBinder"; + const string ACTIONS = "_MSharp.CustomBindActions"; + + internal static void Enqueue(ControllerContext cContext, object model) => + Enqueue(cContext, model, ACTIONS, typeof(OnBoundAttribute), CustomBindMethods); + + internal static void SetRoot(ModelBindingContext model) => SetRoot(model, ROOT); + + internal static void InvokeAllForRoot(ModelBindingContext model) => + InvokeAllForRoot(model, ROOT, ACTIONS); + } +} \ No newline at end of file diff --git a/Olive.Mvc/Attributes/OnPreBindingAttribute.cs b/Olive.Mvc/Attributes/OnPreBindingAttribute.cs new file mode 100644 index 000000000..7d1a9cd2a --- /dev/null +++ b/Olive.Mvc/Attributes/OnPreBindingAttribute.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.AspNetCore.Mvc; + +namespace Olive.Mvc +{ + [AttributeUsage(AttributeTargets.Method)] + public sealed class OnPreBindingAttribute : BaseModelBindAttribute + { + static ConcurrentDictionary> CustomBindMethods = new ConcurrentDictionary>(); + + internal static void Execute(ControllerContext cContext, object model) + { + var methods = DiscoverBindMethods(cContext, model, typeof(OnPreBindingAttribute), CustomBindMethods); + + foreach (var action in methods) action(); + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Attributes/OnPreBoundAttribute.cs b/Olive.Mvc/Attributes/OnPreBoundAttribute.cs new file mode 100644 index 000000000..a85b9c5e0 --- /dev/null +++ b/Olive.Mvc/Attributes/OnPreBoundAttribute.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Olive.Mvc +{ + [AttributeUsage(AttributeTargets.Method)] + public sealed class OnPreBoundAttribute : BaseModelBindAttribute + { + static ConcurrentDictionary> CustomBindMethods = new ConcurrentDictionary>(); + + const string ROOT = "_MSharp.CustomPreBindActions.RootBinder"; + const string ACTIONS = "_MSharp.CustomPreBindActions"; + + internal static void Enqueue(ControllerContext cContext, object model) => + Enqueue(cContext, model, ACTIONS, typeof(OnPreBoundAttribute), CustomBindMethods); + + internal static void SetRoot(ModelBindingContext model) => SetRoot(model, ROOT); + + internal static void InvokeAllForRoot(ModelBindingContext model) => + InvokeAllForRoot(model, ROOT, ACTIONS); + } +} \ No newline at end of file diff --git a/Olive.Mvc/Attributes/RequiredUnlessDeletingAttribute.cs b/Olive.Mvc/Attributes/RequiredUnlessDeletingAttribute.cs new file mode 100644 index 000000000..def2cead1 --- /dev/null +++ b/Olive.Mvc/Attributes/RequiredUnlessDeletingAttribute.cs @@ -0,0 +1,53 @@ +namespace Olive.Mvc +{ + using System; + using System.ComponentModel.DataAnnotations; + + public class RequiredUnlessDeletingAttribute : RequiredAttribute + { + string DeletingProperty; + + /// + /// Check if the object is going to be deleted skip the validation. + /// + /// The boolean property`s name which shows the object will be deleted. + public RequiredUnlessDeletingAttribute(string deletingProperty = "MustBeDeleted") => + DeletingProperty = deletingProperty; + + protected override ValidationResult IsValid(object value, ValidationContext validationContext) => + UnlessDeletingAttributeChecker.IsValid(base.IsValid, value, validationContext, DeletingProperty); + } + + public class StringLengthUnlessDeletingAttribute : StringLengthAttribute + { + string DeletingProperty; + + /// + /// Check if the object is going to be deleted skip the validation. + /// + /// The boolean property`s name which shows the object will be deleted. + public StringLengthUnlessDeletingAttribute(int maximumLength, string deletingProperty = "MustBeDeleted") : base(maximumLength) => + DeletingProperty = deletingProperty; + + protected override ValidationResult IsValid(object value, ValidationContext validationContext) => + UnlessDeletingAttributeChecker.IsValid(base.IsValid, value, validationContext, DeletingProperty); + } + + internal static class UnlessDeletingAttributeChecker + { + public static ValidationResult IsValid( + Func func, + object value, + ValidationContext validationContext, + string deletingProperty + ) + { + var property = validationContext.ObjectType.GetProperty(deletingProperty); + + if ((bool)property.GetValue(validationContext.ObjectInstance)) + return ValidationResult.Success; + + return func(value, validationContext); + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Attributes/TimeAttribute.cs b/Olive.Mvc/Attributes/TimeAttribute.cs new file mode 100644 index 000000000..eadc0a57b --- /dev/null +++ b/Olive.Mvc/Attributes/TimeAttribute.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; + +namespace Olive.Mvc +{ + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public sealed class TimeAttribute : ValidationAttribute, IClientModelValidator + { + public override bool IsValid(object value) + { + if (value != null) + return Regex.IsMatch(value.ToStringOrEmpty(), @"^(?:0?[0-9]|1[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$"); + + return true; + } + + public void AddValidation(ClientModelValidationContext context) => + MergeAttribute(context.Attributes, "data-val-time", FormatErrorMessage(context.ModelMetadata.GetDisplayName())); + + static bool MergeAttribute(IDictionary attributes, string key, string value) + { + if (attributes.ContainsKey(key)) return false; + + attributes.Add(key, value); + return true; + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Attributes/ViewDataAttribute.cs b/Olive.Mvc/Attributes/ViewDataAttribute.cs new file mode 100644 index 000000000..33f3f7db9 --- /dev/null +++ b/Olive.Mvc/Attributes/ViewDataAttribute.cs @@ -0,0 +1,24 @@ +using System; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Olive.Mvc +{ + [AttributeUsage(AttributeTargets.All, AllowMultiple = true)] + public sealed class ViewDataAttribute : ActionFilterAttribute + { + public string Key { get; set; } + public object Value { get; set; } + + public ViewDataAttribute(string key, object value) + { + Key = key; + Value = value; + } + + public override void OnActionExecuting(ActionExecutingContext context) + { + ((Controller)context.Controller).ViewData[Key] = Value; + base.OnActionExecuting(context); + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Authentication/ExternalLoginInfo.cs b/Olive.Mvc/Authentication/ExternalLoginInfo.cs new file mode 100644 index 000000000..447e236db --- /dev/null +++ b/Olive.Mvc/Authentication/ExternalLoginInfo.cs @@ -0,0 +1,13 @@ +namespace Olive.Mvc +{ + public class ExternalLoginInfo + { + public bool IsAuthenticated { get; set; } + + public string Issuer { get; set; } + + public string Email { get; set; } + public string NameIdentifier { get; set; } + public string UserName { get; set; } + } +} diff --git a/Olive.Mvc/Authentication/OwinAuthenticaionProvider.cs b/Olive.Mvc/Authentication/OwinAuthenticaionProvider.cs new file mode 100644 index 000000000..9c91b9995 --- /dev/null +++ b/Olive.Mvc/Authentication/OwinAuthenticaionProvider.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Security.Principal; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Olive.Web; + +namespace Olive.Mvc +{ + public class OwinAuthenticaionProvider : IAuthenticationProvider + { + public readonly AsyncEvent ExternalLoginAuthenticated = new AsyncEvent(); + // public string AuthenticationScheme { get; } + + // public OwinAuthenticaionProvider(string authenticationScheme) => AuthenticationScheme = authenticationScheme; + + public async Task LogOn(IUser user, string domain, TimeSpan timeout, bool remember) + { + var context = Context.Http; + + await context.SignOutAsync(IdentityConstants.ApplicationScheme); + + var claims = new List + { + new Claim(ClaimsIdentity.DefaultNameClaimType, user.GetId().ToString()) + }; + + claims.AddRange(user.GetRoles().Select(role => new Claim(ClaimsIdentity.DefaultRoleClaimType, role))); + + await context.SignInAsync(IdentityConstants.ApplicationScheme, + new ClaimsPrincipal(new ClaimsIdentity(claims, "AuthenticationType")), // AuthenticationType is just a text and I do not know what is its usage. + new AuthenticationProperties + { + IsPersistent = remember, + ExpiresUtc = DateTimeOffset.UtcNow.Add(timeout) + }); + } + + public Task LogOff(IUser user) => Context.Http.SignOutAsync(IdentityConstants.ApplicationScheme); + + public async Task LoginBy(string provider) + { + if (Context.HttpContextAccessor.HttpContext.Request.Query["ReturnUrl"].ToString().IsEmpty()) + { + // it's mandatory, otherwise Challenge() immediately returns to Login page + throw new InvalidOperationException("Request has no ReturnUrl."); + } + + await Context.Http.ChallengeAsync(provider, new AuthenticationProperties { RedirectUri = "/ExternalLoginCallback" }); + } + + IUser IAuthenticationProvider.LoadUser(IPrincipal principal) + { + throw new NotSupportedException("IAuthenticationProvider.LoadUser() is deprecated in M# MVC."); + } + + public void PreRequestHandler(string path) + { + if (path == "/ExternalLoginCallback") + { + // this needs to be done here (PreRequestHandler) because we need to get owin context from httpcontext + ExternalLoginCallback(); + } + } + + internal void ExternalLoginCallback() + { + throw new NotImplementedException("The following code is commented to fix on the test time."); + // var authenticationManager = AccessorsHelper.HttpContextAccessor.HttpContext.GetOwinContext().Authentication; + // var loginInfo = authenticationManager.GetExternalLoginInfo(); + + // var info = new ExternalLoginInfo(); + + // if (loginInfo != null) + // { + // var nameIdentifierClaim = loginInfo.ExternalIdentity.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier); + + // info.IsAuthenticated = loginInfo.ExternalIdentity.IsAuthenticated; + // info.Issuer = nameIdentifierClaim.Get(c => c.Issuer); + // info.NameIdentifier = nameIdentifierClaim.Get(c => c.Value); + // info.Email = loginInfo.Email; + // info.UserName = loginInfo.DefaultUserName; + // } + + // NotifyExternalLoginAuthenticated(this, info); + } + + public async Task NotifyExternalLoginAuthenticated(ExternalLoginInfo info) + { + if (!ExternalLoginAuthenticated.IsHandled()) + throw new InvalidOperationException("ExternalLogin requested but no handler found for ExternalLoginAuthenticated event"); + + await ExternalLoginAuthenticated.Raise(info); + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Binding/IViewComponent.cs b/Olive.Mvc/Binding/IViewComponent.cs new file mode 100644 index 000000000..459a6a15c --- /dev/null +++ b/Olive.Mvc/Binding/IViewComponent.cs @@ -0,0 +1,9 @@ +namespace Olive.Mvc +{ + /// + /// Marks a controller class as ViewComponent. + /// + public interface IViewComponent + { + } +} diff --git a/Olive.Mvc/Binding/IViewModel.cs b/Olive.Mvc/Binding/IViewModel.cs new file mode 100644 index 000000000..aae951ae9 --- /dev/null +++ b/Olive.Mvc/Binding/IViewModel.cs @@ -0,0 +1,4 @@ +namespace Olive.Mvc +{ + public interface IViewModel { } +} diff --git a/Olive.Mvc/Binding/ListPaginationBinder.cs b/Olive.Mvc/Binding/ListPaginationBinder.cs new file mode 100644 index 000000000..2a4775c46 --- /dev/null +++ b/Olive.Mvc/Binding/ListPaginationBinder.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Olive.Mvc +{ + public class ListPaginationBinder : IModelBinder + { + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + + if (value == null) return null; + + var prefix = bindingContext.ModelName.OrEmpty().Unless("p"); + if (prefix.EndsWith(".p")) prefix = prefix.Split('.').ExceptLast().ToString("."); + + var old = bindingContext.Model as ListPagination; + + bindingContext.Result = ModelBindingResult.Success(new ListPagination(old.Container, value.FirstValue) + { + Prefix = prefix, + UseAjaxPost = old.UseAjaxPost, + UseAjaxGet = old.UseAjaxGet + }); + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Binding/ListSortExpressionBinder.cs b/Olive.Mvc/Binding/ListSortExpressionBinder.cs new file mode 100644 index 000000000..e80d44af4 --- /dev/null +++ b/Olive.Mvc/Binding/ListSortExpressionBinder.cs @@ -0,0 +1,26 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Olive.Mvc +{ + public class ListSortExpressionBinder : IModelBinder + { + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + + if (value == null) return Task.CompletedTask; + + var old = bindingContext.Model as ListSortExpression; + + bindingContext.Result = ModelBindingResult.Success(new ListSortExpression(old.Container, value.FirstValue) + { + UseAjaxPost = old.UseAjaxPost, + Prefix = old.Prefix + } + ); + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Binding/OliveBinderProvider.cs b/Olive.Mvc/Binding/OliveBinderProvider.cs new file mode 100644 index 000000000..db7864734 --- /dev/null +++ b/Olive.Mvc/Binding/OliveBinderProvider.cs @@ -0,0 +1,419 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Olive.Entities; + +namespace Olive.Mvc +{ + public class OliveBinderProvider : IModelBinderProvider + { + static Type[] PrimitiveTypes = new[] {typeof(DateTime), typeof(DateTime?), typeof(TimeSpan), + typeof(TimeSpan?), typeof(bool), typeof(bool?)}; + + static ConcurrentDictionary PropertyBinderCache = new ConcurrentDictionary(); + + IModelBinder IModelBinderProvider.GetBinder(ModelBinderProviderContext context) => SelectBinder(context); + + static internal IModelBinder SelectBinder(ModelBinderProviderContext context) + { + var modelType = context.Metadata.ModelType; + + if (modelType.IsA()) + { + var propertyBinders = new Dictionary(); + + foreach (var property in GetProperties(context.Metadata)) + propertyBinders.Add(property, context.CreateBinder(property)); + + return new OliveModelBinder(propertyBinders); + } + + if (modelType.IsA()) return new ListSortExpressionBinder(); + if (modelType.IsA()) return new ListPaginationBinder(); + if (modelType.IsA()) return new ColumnSelectionBinder(); + if (modelType.IsA()) return new EntityModelBinder(); + if (modelType.IsA() || modelType.IsA>()) return new DocumentModelBinder(); + + if (PrimitiveTypes.Contains(modelType)) return new PrimitiveValueModelBinder(); + + if (TryGetListModelBinder(modelType, out IModelBinder binder)) + return binder; + + if (modelType == typeof(OptionalBooleanFilter)) return new OptionalBooleanFilterModelBinder(); + if (modelType == typeof(List)) return new OptionalBooleanFilterListModelBinder(); + + if (IsListOfEnum(modelType)) + return new EnumListModelBinder(modelType.GetGenericArguments().First(), modelType); + + if (IsListOfEntity(modelType)) + return new EntityListModelBinder(modelType.GetGenericArguments().First(), modelType); + + return null; + } + + [EscapeGCop("The nature of the try methods is to have out parameter.")] + static bool TryGetListModelBinder(Type modelType, out IModelBinder modelBinder) + { + modelBinder = null; + + if (modelType.IsA>()) modelBinder = new ListModelBinder(); + if (modelType.IsA>()) modelBinder = new ListModelBinder(); + if (modelType.IsA>()) modelBinder = new ListModelBinder(); + if (modelType.IsA>()) modelBinder = new ListModelBinder(); + if (modelType.IsA>()) modelBinder = new ListModelBinder(); + if (modelType.IsA>()) modelBinder = new ListModelBinder(); + if (modelType.IsA>()) modelBinder = new ListModelBinder(); + if (modelType.IsA>()) modelBinder = new ListModelBinder(); + + return modelBinder != null; + } + + static ModelMetadata[] GetProperties(ModelMetadata metadata) + { + return PropertyBinderCache.GetOrAdd(metadata, x => + { + var result = new List(metadata.Properties.Count); + + foreach (var property in metadata.Properties) + { + if (((DefaultModelMetadata)property).Attributes.Attributes.OfType().Any()) + result.AddRange(GetProperties(property.ElementMetadata)); + + result.Add(property); + } + + return result.ToArray(); + }); + } + + static bool IsListOfEnum(Type modelType) + { + // if (modelType == null) return false; + if (!modelType.IsGenericType) return false; + if (!modelType.GetGenericArguments().First().IsEnum) return false; ; + + return modelType.GetGenericTypeDefinition().FullName.StartsWith("System.Collections.Generic.List"); + } + + static bool IsListOfEntity(Type modelType) + { + // if (modelType == null) return false; + if (!modelType.IsGenericType) return false; + if (!modelType.GetGenericArguments().First().IsA()) return false; ; + + return modelType.GetGenericTypeDefinition().FullName.StartsWith("System.Collections.Generic.List"); + } + } + + public class EntityModelBinder : IModelBinder + { + static Dictionary> CustomParsers = new Dictionary>(); + + #region GuidEntityReadableTextParsers + + static Dictionary> GuidEntityReadableTextParsers = new Dictionary>(); + + /// + /// If you want to use the string format of an guid entity in URL, then you can get MVC to bind the entity directly from a textual route value. This registers your binding in addition to the normal binding from GUID. + /// + public static void RegisterReadableTextParser(Func binder) where TEntity : GuidEntity + { + GuidEntityReadableTextParsers.Add(typeof(TEntity), binder); + } + + static GuidEntity ParseGuidEntityFromReadableText(Type entityType, string data) + { + var actualType = entityType; + + while (true) + { + var binder = GuidEntityReadableTextParsers.TryGet(actualType); + + if (binder != null) return binder(data); + + if (actualType.BaseType == typeof(GuidEntity)) + { + // Not found: + throw new Exception($"Cannot parse the data '{data}' to {entityType.FullName} as no parser is registered for this type.\r\n" + + "Hint: Use EntityModelBinder.RegisterParser() to define your 'text to entity convertor' logic."); + } + else actualType = actualType.BaseType; + } + } + + #endregion + + #region Custom parsers + + /// + /// Will register a custom binder for a type instead of the default which uses a Database.Get. + /// + public static void RegisterCustomParser(Func binder) where TEntity : IEntity + { + CustomParsers.Add(typeof(TEntity), binder); + } + + static Func FindCustomParser(Type entityType) + { + Func result = null; + + foreach (var actualType in entityType.WithAllParents()) + { + if (CustomParsers.TryGetValue(actualType, out result)) + return result; + } + + return null; + } + + #endregion + + public async Task BindModelAsync(ModelBindingContext bindingContext) + { + var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + + if (value == null) return; + + var data = value.FirstValue; + + // Special cases: + if (data.IsEmpty() || data.IsAnyOf("{NULL}", "-", Guid.Empty.ToString())) return; + + if (IsReadOnly(bindingContext)) return; + + if (bindingContext.ModelType.IsA() && data.TryParseAs() == null) + { + // We have some data which is not Guid. + bindingContext.Result = ModelBindingResult.Success(ParseGuidEntityFromReadableText(bindingContext.ModelType, data)); + } + + var customBinder = FindCustomParser(bindingContext.ModelType); + if (customBinder != null) + { + try + { + bindingContext.Result = ModelBindingResult.Success(customBinder(data)); + } + catch (Exception ex) + { + throw new Exception($"Failed to bind the value of type '{bindingContext.ModelType.FullName}' from '{data}'.", ex); + } + } + else + { + try + { + bindingContext.Result = ModelBindingResult.Success((await Entity.Database.GetOrDefault(data, bindingContext.ModelType)) + // Sometimes (e.g. in master detail binding) the view model data is written to the 'Item ', so it must be cloned. + ?.Clone()); + } + catch (Exception ex) + { + throw new Exception($"Failed to bind the value of type '{bindingContext.ModelType.FullName}' from '{data}'.", ex); + } + } + } + + bool IsReadOnly(ModelBindingContext context) + { + var metaData = context.ModelMetadata; + if (metaData == null) return false; + + var type = metaData.ContainerType; + if (type == null) return false; + + var propertyName = metaData.PropertyName; + if (propertyName.IsEmpty()) return false; + + var property = type.GetProperty(propertyName); + if (property == null) return false; + + return property.GetCustomAttributes().Any(x => x.IsReadOnly); + } + } + + class ListModelBinder : IModelBinder + { + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + + if (value == null) + bindingContext.Result = ModelBindingResult.Success(null); + + else if (value.FirstValue == "{NULL}" || value.FirstValue == Guid.Empty.ToString()) + bindingContext.Result = ModelBindingResult.Success(null); + + else + { + var result = new List(); + + // It is possible that data is sent as a single value but in pipeline seperated format. + foreach (var idOrIds in value.Values) + result.AddRange(idOrIds.Split('|').Trim().Select(x => x.To())); + + bindingContext.Result = ModelBindingResult.Success(result); + } + + return Task.CompletedTask; + } + } + + class OptionalBooleanFilterModelBinder : IModelBinder + { + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + + if (value == null) + bindingContext.Result = ModelBindingResult.Success(null); + else + bindingContext.Result = ModelBindingResult.Success(OptionalBooleanFilter.Parse(value.FirstValue)); + + return Task.CompletedTask; + } + } + + class OptionalBooleanFilterListModelBinder : IModelBinder + { + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + + if (value == null) + bindingContext.Result = ModelBindingResult.Success(null); + else + bindingContext.Result = ModelBindingResult.Success(value.FirstValue.OrEmpty().Split('|').Select(OptionalBooleanFilter.Parse).ExceptNull().ToList()); + + return Task.CompletedTask; + } + } + + class DocumentModelBinder : IModelBinder + { + public async Task BindModelAsync(ModelBindingContext bindingContext) + { + var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).Get(x => x.FirstValue).OrEmpty(); + + if (bindingContext.ModelType.IsA()) + bindingContext.Result = ModelBindingResult.Success(BindDocument(value)); + else + bindingContext.Result = ModelBindingResult.Success((await BindDocuments(value)).ToList()); + } + + internal async Task BindDocument(string value) + { + var docs = (await BindDocuments(value)).ToList(); + + return docs.FirstOrDefault() ?? new Blob(new byte[0], "«UNCHANGED»"); + } + + internal async Task> BindDocuments(string value) + { + if (value.IsEmpty() || value == "KEEP") + return new Blob[0]; + + else if (value == "REMOVE") + return new[] { Blob.Empty() }; + + else + return await value.Split('|').Trim() + .Where(x => x.StartsWith("file:")) + .Select(async id => await new FileUploadService().Bind(id)) + .AwaitAll(); + } + } + + class PrimitiveValueModelBinder : IModelBinder + { + public async Task BindModelAsync(ModelBindingContext bindingContext) + { + var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).Get(x => x.FirstValue).OrEmpty(); + + bindingContext.Result = ModelBindingResult.Success(await ViewModelServices.Convert(value, bindingContext.ModelType)); + } + } + + class EnumListModelBinder : IModelBinder + { + Type EnumType, ListType; + public EnumListModelBinder(Type enumType, Type listType) + { + EnumType = enumType; + ListType = listType; + } + + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + + var result = (IList)Activator.CreateInstance(ListType); + + if (value == null) + bindingContext.Result = ModelBindingResult.Success(null); + + else if (value.FirstValue != "{NULL}" || value.FirstValue.HasValue()) + { + foreach (var ids in value.Values) + { + foreach (var item in ids.Split('|').Trim()) + { + var asInt = item.TryParseAs(); + if (asInt.HasValue) + result.Add(Enum.ToObject(EnumType, asInt)); + else + result.Add(Enum.Parse(EnumType, item)); + } + } + } + + bindingContext.Result = ModelBindingResult.Success(result); + + return Task.CompletedTask; + } + } + + class EntityListModelBinder : IModelBinder + { + Type EntityType, ListType; + public EntityListModelBinder(Type entityType, Type listType) + { + EntityType = entityType; + ListType = listType; + } + + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + + var result = (IList)Activator.CreateInstance(ListType); + + if (value == null) + bindingContext.Result = ModelBindingResult.Success(null); + + else + { + foreach (var idOrIds in value.Values) + { + foreach (var id in idOrIds.OrEmpty().Split('|').Trim().Except("{NULL}", "-", Guid.Empty.ToString())) + { + var item = Entity.Database.GetOrDefault(id, EntityType); + + if (item != null) result.Add(item); + } + } + } + + bindingContext.Result = ModelBindingResult.Success(result); + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Binding/OliveModelBinder.cs b/Olive.Mvc/Binding/OliveModelBinder.cs new file mode 100644 index 000000000..6c8867745 --- /dev/null +++ b/Olive.Mvc/Binding/OliveModelBinder.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Olive.Web; + +namespace Olive.Mvc +{ + public class OliveModelBinder : ComplexTypeModelBinder + { + readonly IDictionary PropertyBinders; + + public OliveModelBinder(IDictionary propertyBinders) : base(propertyBinders) + { + PropertyBinders = propertyBinders; + } + + // /// Sets the specified property by using the specified controller context, binding context, and property value. + // protected override void SetProperty(ModelBindingContext bindingContext, string modelName, ModelMetadata propertyMetadata, ModelBindingResult result) + // { + // if(result.IsModelSet && propertyMetadata.ModelType == typeof(string)) + // { + // var stringValue = (string)result.Model; + // if (stringValue.HasValue()) + // { + // if(propertyMetadata.ModelType.CustomAttributes.OfType().None()) + // result = ModelBindingResult.Success(stringValue); + // } + // } + + // base.SetProperty(bindingContext, modelName, propertyMetadata, result); + // } + + protected override Task BindProperty(ModelBindingContext bindingContext) + { + Task result; + + var attribute = ((DefaultModelMetadata)bindingContext.ModelMetadata).Attributes.Attributes.OfType().FirstOrDefault(); + if (attribute != null) + result = BindMasterDetailsProperty(bindingContext, attribute); + + else + result = base.BindProperty(bindingContext); + + return result; + } + + async Task BindMasterDetailsProperty(ModelBindingContext bindingContext, MasterDetailsAttribute attribute) + { + if (Context.Request.IsGet()) return; + + var prefix = attribute.Prefix + "-"; + var listObject = Activator.CreateInstance(bindingContext.ModelType) as IList; + // var formData = cContext.RequestContext.HttpContext.Request.Form; + + var childItemIds = bindingContext.ValueProvider.GetValue(prefix + ".Item").FirstValue?.Split("|")?.ToArray() ?? new string[0]; + + foreach (var id in childItemIds) + { + var formControlsPrefix = prefix + id + "."; + + var instanceType = bindingContext.ModelMetadata.ElementType; + var instance = Activator.CreateInstance(instanceType); + listObject.Add(instance); + + // Set the instance properties + foreach (var property in bindingContext.ModelMetadata.ElementMetadata.Properties) + { + var key = formControlsPrefix + property.PropertyName; + + await SetPropertyValue(bindingContext, instance, key, property); + } + + // All properties are written to ViewModel. Now also write them on the model (Item property): + var item = instance.GetType().GetProperty("Item").GetValue(instance); + await ViewModelServices.CopyData(instance, item); + } + + bindingContext.Result = ModelBindingResult.Success(listObject); + } + + async Task SetPropertyValue(ModelBindingContext bindingContext, object model, string modelName, ModelMetadata property) + { + var fieldName = property.BinderModelName ?? property.PropertyName; + + ModelBindingResult result; + using (bindingContext.EnterNestedScope( + modelMetadata: property, + fieldName: fieldName, + modelName: modelName, + model: model)) + { + await base.BindProperty(bindingContext); + + if (bindingContext.ModelState.Keys.Contains(modelName) && + bindingContext.ModelState[modelName].ValidationState == ModelValidationState.Unvalidated) + bindingContext.ModelState[modelName].ValidationState = ModelValidationState.Skipped; + + result = bindingContext.Result; + } + + if (result.IsModelSet) + { + if (property.PropertyName == "Item" && result.Model == null) + result = ModelBindingResult.Success(Activator.CreateInstance(property.ModelType)); + + property.PropertySetter(model, result.Model); + // SetProperty(bindingContext, modelName, property, result); + } + else if (property.IsBindingRequired) + { + var message = property.ModelBindingMessageProvider.MissingBindRequiredValueAccessor(fieldName); + bindingContext.ModelState.TryAddModelError(modelName, message); + } + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Binding/ViewModelServices.cs b/Olive.Mvc/Binding/ViewModelServices.cs new file mode 100644 index 000000000..62d97fc0d --- /dev/null +++ b/Olive.Mvc/Binding/ViewModelServices.cs @@ -0,0 +1,225 @@ +namespace Olive +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.ComponentModel; + using System.Linq; + using System.Reflection; + using System.Threading.Tasks; + using Entities; + using Mvc; + + public static class ViewModelServices + { + static BindingFlags PropertyFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance + | BindingFlags.FlattenHierarchy | BindingFlags.SetProperty; + + public static async Task To(this IEntity model, string sourcePrefix = null, string targetPrefix = null) where T : IViewModel + { + var result = (T)Activator.CreateInstance(typeof(T)); + + await model.CopyDataTo(result, sourcePrefix, targetPrefix); + + return result; + } + + public static Task CopyDataTo(this IViewModel from, IEntity to, string sourcePrefix = null, string targetPrefix = null) => + CopyData(from, to, sourcePrefix, targetPrefix); + + public static Task CopyDataTo(this IEntity from, IViewModel to, string sourcePrefix = null, string targetPrefix = null) => + CopyData(from, to, sourcePrefix, targetPrefix); + + static bool IsReadonly(PropertyInfo property) => property.GetCustomAttribute()?.IsReadOnly == true; + + // [TODO]: Remove the following attribute + [EscapeGCop("It takes time to fix this warning now. I will check it later.")] + public static async Task CopyData(object from, object to, string sourcePrefix = null, string targetPrefix = null) + { + if (from == null) throw new ArgumentNullException(nameof(from)); + if (to == null) throw new ArgumentNullException(nameof(to)); + + foreach (var property in from.GetType().GetProperties(PropertyFlags)) + { + if (sourcePrefix.HasValue() && !property.Name.StartsWith(sourcePrefix)) continue; + + var inTarget = to.GetType().GetProperty(targetPrefix + property.Name.TrimStart(sourcePrefix.OrEmpty()), PropertyFlags); + + if (inTarget == null) continue; + + if (inTarget.GetSetMethod() == null) continue; + + if (inTarget.GetCustomAttributes().Any()) continue; + + if (new[] { property, inTarget }.Any(p => p.GetCustomAttributes().Any(x => x.CanCopy == false))) continue; + + if (from is IViewModel) + { + if ((from as IViewModel).IsInvisible(property.Name) && + // If it's readonly then it's perhaps set by a property setter or custom code + // as it can't be injected maliciously. + !IsReadonly(property)) + continue; // Not visible + } + + var sourceValue = property.GetValue(from); + + if (from is IEntity && to is IViewModel) + { + var hasClrDefaultValue = property.PropertyType.IsValueType && + (from as IEntity).IsNew && + inTarget.GetCustomAttributes().None(); + + var isValueDefault = sourceValue.ToStringOrEmpty() == property.PropertyType.GetDefaultValue().ToStringOrEmpty(); + + if (hasClrDefaultValue && isValueDefault) continue; + } + + if (property.GetCustomAttributes().Any(x => x.IsReadOnly)) + { + if (property.GetCustomAttributes().None()) continue; + if (sourceValue == null || string.Empty.Equals(sourceValue)) continue; + // Note: Property setters this way cannot set something to null. + } + + try + { + var value = await Convert(sourceValue, inTarget.PropertyType); + + if (property.Defines() && !inTarget.Defines()) + value = ((DateTime?)value).ToUniversal(); + + if (!property.Defines() && inTarget.Defines()) + value = ((DateTime?)value).ToLocal(); + + if (inTarget.PropertyType == typeof(Blob)) + { + if (from is IEntity && to is IViewModel) + { + (value as Blob).FolderName = (sourceValue as Blob).FolderName; + } + else if (from is IViewModel && ((value as Blob).Get(x => x.FileName == "«UNCHANGED»") ?? true)) + { + // Null in view model means not changed. + continue; + } + } + + if (value == null && inTarget.PropertyType.IsA()) continue; // Don't set to null + + inTarget.SetValue(to, value); + } + catch (Exception ex) + { + throw new Exception($"Could not copy the {property.PropertyType} value of {{{sourceValue}}} from {from.GetType().Name}." + + $" {property.Name.TrimStart(sourcePrefix.OrEmpty())} to {inTarget.PropertyType} in {to.GetType().Name}", ex); + } + } + } + + // [TODO]: Remove the following attribute + [EscapeGCop("It takes time to fix this warning now. I will check it later.")] + public static async Task Convert(object source, Type target) + { + if (source == null) return null; + + if (source is Blob) return (source as Blob).Clone(attach: true, @readonly: true); + + if (source.GetType().IsA(target)) return source; + + if (source is string && target == typeof(Guid?)) return (source as string).TryParseAs(); + + if (source is GuidEntity && target == typeof(Guid?)) return (source as GuidEntity).ID; + + if (target.IsA()) + { + if (new[] { typeof(string), typeof(Guid?), typeof(Guid) }.Lacks(source.GetType())) + { + throw new Exception($"Cannot convert {source.GetType().FullName} to {target.FullName}"); + } + + if (source.ToString().IsEmpty()) return null; + + else return (await Entity.Database.GetOrDefault(source.ToString(), target)).Get(x => x.Clone()); + } + + if (target.IsA() && source is IEntity) + { + IViewModel result; + if (target.GetConstructor(new[] { source.GetType() }) != null) + result = (IViewModel)Activator.CreateInstance(target, new object[] { source }); + else result = (IViewModel)Activator.CreateInstance(target); + await (source as IEntity).CopyDataTo(result); + return result; + } + + if (source is string && target.Implements()) + return await ConvertCollection((source as string).Split('|'), target); + + if (source.GetType().Implements() && target == typeof(string)) + return (source as IEnumerable).ToString("|"); + + if (source is IEnumerable) + { + if (target.IsA>()) + return await new DocumentModelBinder().BindDocuments(source.ToStringOrEmpty()); + + var result = await ConvertCollection(source as IEnumerable, target); + if (result != null) return result; + } + + if (source is IEntity) + { + if (target.IsA()) return source; + else return Convert((source as IEntity).GetId(), target); + } + + if (target.IsA()) + return await new DocumentModelBinder().BindDocuments(source.ToStringOrEmpty()); + + try + { + return source.ToStringOrEmpty().To(target); + } + catch + { + throw; + } + } + + static async Task ConvertCollection(IEnumerable source, Type target) + { + IList result; + Type objectType = null; + + if (target.IsA()) + { + result = Activator.CreateInstance(target) as IList; + } + else if (target.IsA>()) + { + objectType = target.GetGenericArguments().Single(); + result = typeof(List<>).MakeGenericType(objectType).CreateInstance() as IList; + } + else return null; + + var targetHoldsIds = target.IsA>() || target.IsA>() || target.IsA>(); + + foreach (var item in source) + { + if (item is IEntity) + { + if (targetHoldsIds) result.Add((item as IEntity).GetId()); + else result.Add(item); + } + else + { + if (targetHoldsIds) result.Add(item); + else result.Add(await Entity.Database.Get(item, objectType)); + } + } + + return result; + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Extentions/Extensions.HtmlHelpers.cs b/Olive.Mvc/Extentions/Extensions.HtmlHelpers.cs new file mode 100644 index 000000000..ad8f14a14 --- /dev/null +++ b/Olive.Mvc/Extentions/Extensions.HtmlHelpers.cs @@ -0,0 +1,381 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Text; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Rendering; +using Newtonsoft.Json; +using Olive; +using Olive.Entities; +using Olive.Services.Testing; +using Olive.Web; + +namespace Olive.Mvc +{ + partial class OliveMvcExtensions + { + const int DEFAULT_VISIBLE_PAGES = 7; + + // public static UrlHelper GetUrlHelper(this IHtmlHelper html) => new UrlHelper(html.ViewContext.RequestContext); + + // /// + // /// Renders the specified partial view as an HTML-encoded string. + // /// + // /// The HTML helper instance that this method extends. + // /// The name of the partial view to render. + // /// The view model for the partial view. + // /// The partial view that is rendered as an HTML-encoded string. + // public static HtmlString Partial(this IHtmlHelper html, string partialViewName, T model, bool skipAjaxPost) where T : IViewModel + // { + // var request = HttpContext.Current.Request; + // if (skipAjaxPost && request.IsAjaxCall() && request.IsPost()) return HtmlString.Empty; + + // if (model == null) + // { + // model = (html.ViewContext.Controller as Controller).Bind(); + + // if (model == null) + // throw new Exception("The model object passed to Partial() cannot be null."); + // } + + // return html.Partial(partialViewName, model); + // } + + public static HtmlString ToJson(this IHtmlHelper html, object obj) + { + if (obj == null) return new HtmlString("[]"); + + return new HtmlString(JsonConvert.SerializeObject(obj)); + } + + public static HtmlString GetActionsJson(this IHtmlHelper html) + { + var data = html.ViewContext.HttpContext.Items["JavascriptActions"] as object; + + return html.ToJson(data); + } + + public static IHtmlContent RadioButtonsFor(this IHtmlHelper html, Expression> property, IEnumerable selectList, object htmlAttributes) + { + var propertyInfo = property.GetProperty(); + + var value = propertyInfo.GetValue(html.ViewData.Model); + + if (value is IEntity) value = (value as IEntity).GetId(); + + var settings = ToHtmlAttributes(htmlAttributes); + + var result = new HtmlContentBuilder(); + + result.AppendHtmlLine($"
"); + + foreach (var item in selectList) + { + result.AppendHtmlLine($""); + + var id = propertyInfo.Name + "_" + selectList.IndexOf(item); + + result.AppendHtml(html.RadioButton(propertyInfo.Name, item.Value, IsSelected(item, value), new { id = id })); + + result.AppendHtmlLine($""); + + result.AppendHtmlLine("
"); + } + + result.AppendHtmlLine(""); + + return result; + } + + static bool IsSelected(SelectListItem item, object boundValue) + { + if (boundValue.ToStringOrEmpty() == item.Value) return true; + + if (boundValue == null && item.Value == "-") return true; + + return false; + } + + public static IHtmlContent FileUploadFor(this IHtmlHelper html, Expression>> property, object htmlAttributes = null) => + new DefaultFileUploadMarkupGenerator().Generate(html, html.ViewData.Model, property, htmlAttributes); + + public static IHtmlContent FileUploadFor(this IHtmlHelper html, Expression> property, object htmlAttributes = null) => + new DefaultFileUploadMarkupGenerator().Generate(html, html.ViewData.Model, property, htmlAttributes); + + public static HtmlString CheckBoxesFor(this IHtmlHelper html, Expression> property, IEnumerable selectList, object htmlAttributes = null) => + GenerateCheckBoxesFor(html, property, selectList, htmlAttributes, setContainerId: true); + + static HtmlString GenerateCheckBoxesFor(this IHtmlHelper html, Expression> property, IEnumerable selectList, object htmlAttributes, bool setContainerId) + { + var propertyInfo = property.GetProperty(); + + var value = propertyInfo.GetValue(html.ViewData.Model); + + return html.GenerateCheckBoxes(propertyInfo.Name, value as IEnumerable, selectList, htmlAttributes, propertyInfo, setContainerId); + } + + public static HtmlString CheckBoxes(this IHtmlHelper html, string name, IEnumerable selectedItems, IEnumerable selectList, object htmlAttributes = null, PropertyInfo property = null) + { + return GenerateCheckBoxes(html, name, selectedItems, selectList, htmlAttributes, property, setContainerId: true); + } + + static HtmlString GenerateCheckBoxes(this IHtmlHelper html, string name, IEnumerable selectedItems, IEnumerable selectList, object htmlAttributes = null, PropertyInfo property = null, bool setContainerId = true) + { + var currentItems = new string[0]; + + if (selectedItems != null) + { + if (selectedItems is string) currentItems = new[] { (string)selectedItems }; + else currentItems = (selectedItems as IEnumerable).Cast().ExceptNull() + .Select(x => (x as IEntity).Get(b => b.GetId()).ToStringOrEmpty().Or(x.ToString())).ToArray(); + } + + var settings = ToHtmlAttributes(htmlAttributes); + + var r = new StringBuilder(); + + r.Append("
"); + + var isRequiredProperty = property?.IsDefined(typeof(RequiredAttribute)) == true; + + var requiredValidationMessage = isRequiredProperty ? GetRequiredValidationMessage(property) : string.Empty; + + foreach (var item in selectList) + { + r.AddFormattedLine("", settings); + + var id = name + "_" + selectList.IndexOf(item); + + r.AppendLine($""); + + r.AppendLine($""); + r.AppendLine("
"); + } + + r.AppendLine(""); + + return new HtmlString(r.ToString()); + } + + static string GetRequiredValidationMessage(PropertyInfo property) + { + var requiredAttribute = property?.GetCustomAttribute(typeof(RequiredAttribute)) as RequiredAttribute; + + if (requiredAttribute.ErrorMessage.HasValue()) return requiredAttribute.ErrorMessage; + + var propertyName = property.Name; + + if (property.IsDefined(typeof(DisplayNameAttribute))) + { + var displayAttribute = property?.GetCustomAttribute(typeof(DisplayNameAttribute)) as DisplayNameAttribute; + propertyName = displayAttribute?.DisplayName; + } + + return $"The {propertyName} field is required."; + } + + public static HtmlString CollapsibleCheckBoxesFor(this IHtmlHelper html, + Expression> property, + IEnumerable selectList, + object htmlAttributes = null) + { + var name = property.GetProperty().Name; + + var itemsHtml = html.GenerateCheckBoxesFor(property, selectList, htmlAttributes: null, setContainerId: false).ToString(); + var attributes = ToHtmlAttributes(htmlAttributes); + + var result = $@"
+
+ +
{itemsHtml}
+
+
+
+ + +
+
+ + +
+
"; + + return new HtmlString(result); + } + + internal static string ToHtmlAttributes(object htmlAttributes) + { + if (htmlAttributes == null) return string.Empty; + + var settings = htmlAttributes.GetType().GetProperties() + .Select(x => new { name = x.Name.Replace("_", "-"), value = x.GetValue(htmlAttributes) }).ToList(); + + var r = new StringBuilder(); + + return settings.Select(x => x.name + "=\"" + x.value + "\"").ToString(" ").WithPrefix(" "); + } + + public static HtmlString Pagination(this IHtmlHelper html, ListPagination paging, object htmlAttributes = null, string prefix = null) => + Pagination(html, paging, DEFAULT_VISIBLE_PAGES, htmlAttributes, prefix); + + public static HtmlString Pagination(this IHtmlHelper html, ListPagination paging, int visiblePages, object htmlAttributes = null, string prefix = null) => + new PaginationRenderer(html, paging, visiblePages, htmlAttributes, prefix).Render(); + + // public static void RenderAction(this IHtmlHelper html, string action = "Index") + // { + // html.RenderAction(action, typeof(TController).Name.TrimEnd("Controller")); + // } + + // /// + // /// Invokes the Index action method of the specified controller and returns the result as an HTML string. + // /// An anonymous object containing query string / route values to pass. + // /// + // public static HtmlString Action(this IHtmlHelper html, object queryParameters) + // { + // return Action(html, "Index", queryParameters); + // } + + // /// + // /// Invokes the specified child action method of the specified controller and returns the result as an HTML string. + // /// An anonymous object containing query string / route values to pass. + // /// + // public static HtmlString Action(this IHtmlHelper html, string action = "Index", object queryParameters = null) + // { + // return html.Action(action, typeof(TController).Name.TrimEnd("Controller"), queryParameters); + // } + + /// + /// Will join this with other Mvc Html String items; + /// + public static IHtmlContent Concat(this IHtmlContent me, IHtmlContent first, params IHtmlContent[] others) + { + var result = new HtmlContentBuilder(); + result.AppendHtml(me); + result.AppendHtml(first); + others.Do(x => result.AppendHtml(x)); + + return result; + } + + public static IHtmlContent RegisterStartupActions(this IHtmlHelper html) + { + var startupActions = html.GetActionsJson().ToString().Unless("[]"); + + var result = startupActions.HasValue() ? html.Hidden("Startup.Actions", startupActions) : HtmlString.Empty; + + var request = html.ViewContext.HttpContext.Request; + + if (request.IsAjaxGet()) + { + var title = Context.Http.Items["Page.Title"].ToStringOrEmpty().Or(html.ViewData["Title"].ToStringOrEmpty()); + result = result.Concat(html.Hidden("page.meta.title", title)); + } + + return result; + } + + public static HttpRequest Request(this IHtmlHelper html) => + html.ViewContext.HttpContext.Request; + + /// + /// Creates a hidden field to contain the json data for the start-up actions. + /// + public static IHtmlContent StartupActionsJson(this IHtmlHelper html) + { + if (!html.Request().IsAjaxPost()) return null; + + var startupActions = html.GetActionsJson().ToString().Unless("[]"); + + if (startupActions.HasValue()) + return html.Hidden("Startup.Actions", startupActions); + + return HtmlString.Empty; + } + + public static HtmlString ResetDatabaseLink(this IHtmlHelper html) + { + if (!WebTestManager.IsTddExecutionMode()) return null; + + if (html.Request().IsAjaxCall()) return null; + + if (WebTestManager.IsSanityExecutionMode()) + html.RunJavascript("page.skipNewWindows();"); + + return new HtmlString(WebTestManager.GetWebTestWidgetHtml(Context.Http.Request)); + } + + // /// + // /// Creates a new Html helper for a new ViewModel object. + // /// This enables to start from a new view context, so that normal Html helper methods (such as TextBoxFor, etc) yield the correct name attributes, use correct existing value, etc. + // /// + // public static HtmlHelper For(this IHtmlHelper html, TTarget model) + // { + // var container = new BasicViewDataContainer { ViewData = new ViewDataDictionary { Model = model } }; + + // var viewContext = new ViewContext(html.ViewContext, html.ViewContext.View, container.ViewData, html.ViewContext.Writer); + + // return new HtmlHelper(viewContext, container); + // } + + public static HtmlString RunJavascript(this IHtmlHelper html, string script, PageLifecycleStage stage = PageLifecycleStage.Init) => + RunJavascript(html, script, script, stage); + + public static HtmlString RunJavascript(this IHtmlHelper html, string key, string script, PageLifecycleStage stage = PageLifecycleStage.Init) + { + var actions = html.ViewContext.HttpContext.Items["JavascriptActions"] as List; + if (actions == null) html.ViewContext.HttpContext.Items["JavascriptActions"] = actions = new List(); + + // If already added, ignore: + var exists = actions + .Where(x => x.GetType().GetProperty("Script") != null) + .Where(x => x.GetType().GetProperty("Key") != null) + .Cast().Any(x => x.Key == key); + + if (!exists) + actions.Add(new { Script = script, Key = key, Stage = stage.ToString() }); + + return HtmlString.Empty; + } + + public static HtmlString ReferenceScriptFile(this IHtmlHelper html, string scriptUrl) + { + var items = html.ViewContext.HttpContext.Items["MVC.Registered.Script.Files"] as List; + if (items == null) html.ViewContext.HttpContext.Items["MVC.Registered.Script.Files"] = items = new List(); + + if (items.Lacks(scriptUrl)) items.Add(scriptUrl); + + return HtmlString.Empty; + } + + /// + /// Call this at the bottom of the layout file. It will register script tags to reference dynamically referenced script files. + /// + public static HtmlString RegisterDynamicScriptFiles(this IHtmlHelper html) + { + var result = new StringBuilder(); + + if (html.ViewContext.HttpContext.Items["MVC.Registered.Script.Files"] is List items) + return new HtmlString(items.Select(f => $"").ToLinesString()); + + return HtmlString.Empty; + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Extentions/Extensions.SelectListItem.cs b/Olive.Mvc/Extentions/Extensions.SelectListItem.cs new file mode 100644 index 000000000..08a3d40eb --- /dev/null +++ b/Olive.Mvc/Extentions/Extensions.SelectListItem.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Rendering; +using Olive; +using Olive.Entities; +using Olive.Services.Globalization; + +namespace Olive.Mvc +{ + partial class OliveMvcExtensions + { + public static void AddRange(this IList listItems, IEnumerable items, Func displayExpression) where T : IEntity + { + foreach (var item in items) + listItems.Add(new SelectListItem + { + Text = displayExpression(item).ToStringOrEmpty(), + Value = item.GetId().ToString() + }); + } + + public static void AddRange(this IList listItems, IEnumerable items, T selectedItem, Func displayExpression) where T : IEntity + { + foreach (var item in items) + { + if (item == null) continue; + + listItems.Add(new SelectListItem + { + Text = displayExpression(item).ToStringOrEmpty(), + Value = item.GetId().ToString(), + Selected = item.Equals(selectedItem) + }); + } + } + + public static void AddRange(this IList listItems, IEnumerable items, + Func displayExpression, + Func valueExpression) + { + foreach (var item in items) + { + var value = valueExpression(item); + + if (value is IEntity) value = (value as IEntity).GetId(); + + listItems.Add(new SelectListItem + { + Text = displayExpression(item).ToStringOrEmpty(), + Value = value.ToStringOrEmpty() + }); + } + } + + public static void AddRange(this IList listItems, IEnumerable items) => + AddRange(listItems, items, (IEnumerable)null); + + public static void AddRange(this IList listItems, IEnumerable items, IEntity selectedItem) => + AddRange(listItems, items, new[] { selectedItem }); + + public static void AddRange(this IList listItems, IEnumerable items, IEnumerable selectedItems, Func displayExpression) where T : IEntity + { + foreach (var item in items) + { + if (item == null) continue; + + listItems.Add(new SelectListItem + { + Text = displayExpression(item).ToString(), + Value = item.GetId().ToString(), + Selected = selectedItems != null && selectedItems.Contains(item) + }); + } + } + + //public static async Task Add(this IList items, IEntity entity, ILanguage language) + //{ + // items.Add(new SelectListItem + // { + // Text = await entity.ToString(language), + // Value = entity.GetId().ToString() + // }); + //} + + public static void Add(this IList items, IEntity entity) => + items.Add(new SelectListItem { Text = entity.ToString(), Value = entity.GetId().ToString() }); + + public static void Add(this IList items, object text, object value) => + items.Add(new SelectListItem { Text = text.ToStringOrEmpty(), Value = value.ToStringOrEmpty() }); + + public static void AddRange(this IList listItems, IEnumerable items, IEnumerable selectedItems) + { + var selected = selectedItems?.OfType(); + + foreach (var item in items) + { + if (item == null) continue; + else if (item is IEntity) listItems.Add((IEntity)item); + else if (item is SelectListItem) listItems.Add((SelectListItem)item); + else listItems.Add(new SelectListItem { Text = item.ToStringOrEmpty(), Value = item.ToStringOrEmpty() }); + + if (selected != null && selected.Contains(item)) + listItems[listItems.Count - 1].Selected = true; + } + } + + public static string GetSelected(this SelectListItem item) + { + if (item.Selected) return "selected=\"selected\""; + else return null; + } + + public static List Clone(this IEnumerable items) + { + return items.Select(x => new SelectListItem + { + Text = x.Text, + Selected = x.Selected, + Value = x.Value, + Group = x.Group, + Disabled = x.Disabled + }).ToList(); + } + + public static void SetSelected(this IEnumerable items, params string[] selectedValues) + { + if (selectedValues.None()) return; + + items.Where(x => selectedValues.Contains(x.Value.Or(x.Text))).Do(x => x.Selected = true); + } + + public static List ToSelectListItems(this IEnumerable items) + { + var result = new List(); + result.AddRange(items); + + return result; + } + + public static void AddRange(this List items) where TEnum : struct, IConvertible + { + var type = typeof(TEnum); + + if (!type.IsEnum) throw new ArgumentException("TEnum must be an enumerated type"); + + foreach (var item in Enum.GetValues(type)) + items.Add(item.ToString(), (int)item); + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Extentions/Extensions.UrlHelpers.cs b/Olive.Mvc/Extentions/Extensions.UrlHelpers.cs new file mode 100644 index 000000000..cfa662099 --- /dev/null +++ b/Olive.Mvc/Extentions/Extensions.UrlHelpers.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Olive.Entities; +using Olive.Web; + +namespace Olive.Mvc +{ + partial class OliveMvcExtensions + { + static ConcurrentDictionary> IndexActionRoutes = new ConcurrentDictionary>(); + + public static string Current(this IUrlHelper url) + { + var result = url.ActionContext?.HttpContext.Request.GetValue("current.request.url"); + + if (result.IsEmpty()) result = Context.Http?.Request.GetValue("current.request.url"); + + if (result.HasValue()) + { + if (!url.IsLocalUrl(result)) throw new Exception("Invalid injected current url."); + } + else + { + result = url.ActionContext?.HttpContext.Request.ToRawUrl(); + + if (result.IsEmpty()) result = Context.Http?.Request.ToRawUrl(); + } + + return result; + } + + public static string Current(this IUrlHelper url, object queryParameters) + { + if (queryParameters == null) return Current(url); + + var settings = queryParameters.GetType().GetProperties() + .ToDictionary(x => x.Name, x => x.GetValue(queryParameters).ToStringOrEmpty()); + + return Current(url, settings); + } + + public static string Current(this IUrlHelper url, IDictionary queryParameters) + { + var result = url.CurrentUri().RemoveEmptyQueryParameters(); + + if (queryParameters == null) queryParameters = new Dictionary(); + + foreach (var item in queryParameters) + result = result.RemoveQueryString(item.Key).AddQueryString(item.Key, item.Value); + + return result.PathAndQuery; + } + + public static Uri CurrentUri(this IUrlHelper urlHelper) + { + var url = urlHelper.Current(); + if (url.StartsWith("http")) return url.AsUri().RemoveEmptyQueryParameters(); + return ($"http://domain.com{url}").AsUri().RemoveEmptyQueryParameters(); + } + + public static string QueryString(this IUrlHelper url) => url.ActionContext.HttpContext.Request.QueryString.ToString(); + + public static string ReturnUrl(this IUrlHelper urlHelper) + { + var url = urlHelper.ActionContext.HttpContext.Request.GetValue("ReturnUrl"); + if (url.IsEmpty()) return string.Empty; + + if (urlHelper.IsLocalUrl(url)) return url; + + throw new Exception(url + " is not a valid ReturnUrl as it's external and so unsafe."); + } + + /// + /// Returns the specified actionUrl. But it first adds the current route and query string query parameters, all as query string. + /// + public static string ActionWithQuery(this IUrlHelper url, string actionUrl, IEntity listItem = null) => + url.ActionWithQuery(actionUrl, new { list_item = listItem }); + + public static string ActionWithQuery(this IUrlHelper url, string actionUrl, object query) + { + var data = url.ActionContext.GetRequestParameters(); + + if (query != null) + { + var queryData = query.GetType().GetProperties() + .ToDictionary(p => p.Name.ToLower().Replace("_", "."), p => + { + var value = p.GetValue(query); + if (value is IEntity) + return (value as IEntity).GetId().ToStringOrEmpty(); + return value.ToStringOrEmpty(); + }); + + foreach (var item in queryData.Where(x => x.Value.HasValue())) data[item.Key] = item.Value; + } + + var queryString = data.Where(x => x.Value.HasValue()).Select(x => x.Key + "=" + WebUtility.UrlEncode(x.Value)).ToString("&"); + + return url.Content("~/" + actionUrl + queryString.WithPrefix("?").Replace("?&", "?")); + } + + /// + /// Determines if a request parameter (route or query string) value exists for the specified key. + /// + public static bool Has(this ActionContext actionContext, string key) => actionContext.Param(key).HasValue(); + + /// + /// Determines if a request parameter (route or query string) value does not exists for the specified key. + /// + public static bool Lacks(this ActionContext actionContext, string key) => !actionContext.Has(key); + + /// + /// Will get the value for the specified key in the current request whether it comes from Route or Query String. + /// + public static string Param(this ActionContext actionContext, string key) + { + if (key.IsEmpty()) throw new ArgumentNullException(nameof(key)); + + return actionContext.GetRequestParameters() + .FirstOrDefault(x => x.Key.OrEmpty().ToLower() == key.ToLower()) + .Get(x => x.Value); + } + + public static Task> GetList(this HttpRequest request, string key, char separator = ',') where T : IEntity + { + return request.GetValue(key).OrEmpty().Split(separator).Trim().Select(x => Entity.Database.Get(x)).AwaitAll(); + } + + public static Dictionary GetRequestParameters(this ActionContext actionContext) + { + var result = actionContext.RouteData.Values.ToDictionary(x => x.Key, x => x.Value.ToStringOrEmpty()); + + result.Remove("controller"); + result.Remove("action"); + + var byQuerystring = actionContext.HttpContext.Request.Query.ToDictionary(x => x.Key, x => x.Value.ToString()); + + byQuerystring = byQuerystring.Except(x => result.ContainsKey(x.Key)).ToDictionary(x => x.Key, x => x.Value); + + result.Add(byQuerystring); + + return result; + } + + public static object RouteValue(this IUrlHelper url, string key) => url.ActionContext.RouteData.Values[key]; + + static List FindIndexRouteTemplates(string controllerName) + { + var relevantAssemblies = AppDomain.CurrentDomain.GetAssemblies().Where(a => a.References(Assembly.GetExecutingAssembly())).ToList(); + + var types = relevantAssemblies.SelectMany(a => a.GetTypes().Where(t => t.Name == controllerName)) + .ExceptNull() + .Where(x => x.InhritsFrom(typeof(ControllerBase))).ToList(); + + if (types.None()) + throw new Exception("Controller class not found: " + controllerName); + + if (types.HasMany()) + throw new Exception("Multiple Controller classes found: " + types.Select(x => x.AssemblyQualifiedName).ToLinesString()); + + var type = types.Single(); + + var indexAction = type.GetMethods().Where(x => x.Name == "Index").ToList(); + + if (indexAction.None()) throw new Exception(type.FullName + " has no Index method."); + + if (indexAction.HasMany()) throw new Exception(type.FullName + " has multiple Index methods."); + + var attributes = indexAction.First().GetCustomAttributes(); + + if (attributes.None()) throw new Exception(type.FullName + ".Index() has no Route attribute."); + + return attributes.Select(x => new RouteTemplate(x.Template)).ToList(); + } + + public static string Index(this IUrlHelper url, object routeValues = null) where TController : Microsoft.AspNetCore.Mvc.Controller => + url.Index(typeof(TController).Name.TrimEnd("Controller"), routeValues); + + public static string Index(this IUrlHelper url, string controllerName, object routeValues = null) + { + var routeParameters = new Dictionary(); + + if (routeValues != null) + routeParameters = routeValues.GetType().GetProperties() + .ToDictionary(p => p.Name.ToCamelCaseId(), p => p.GetValue(routeValues).ToStringOrEmpty()); + + return url.Index(controllerName, routeParameters); + } + + public static string Index(this IUrlHelper url, string controllerName, Dictionary routeParameters) + { + if (!controllerName.EndsWith("Controller")) controllerName += "Controller"; + + var routeTemplates = IndexActionRoutes.GetOrAdd(controllerName, FindIndexRouteTemplates); + + var bestRouteMatch = FindBestRouteMatch(routeTemplates, routeParameters); + + if (bestRouteMatch == null) + { + var message = $"Failed to evaluate: @Url.Index(\"{controllerName.TrimEnd("Controller")}\"" + + routeParameters.Select(x => x.Key + "= «" + x.Value + "»").ToString(", ").WithWrappers(", new { ", "}") + + ")\r\n\r\n"; + + message += "Destination route pattern(s) don't match the provided parameters.\r\n\r\n"; + message += "Destination route pattern(s): " + routeTemplates.ToLinesString(); + + throw new Exception(message); + } + + try + { + return bestRouteMatch.Merge(routeParameters); + } + catch (Exception ex) + { + throw new Exception("Failed to create the URL for pattern: '" + bestRouteMatch + "' with the provided data: {" + + routeParameters.Select(x => x.Key + ":" + x.Value).ToString(" , ") + "}", ex); + } + } + + static RouteTemplate FindBestRouteMatch(IEnumerable templates, Dictionary providedParameters) + { + templates = templates.Where(t => t.IsMatch(providedParameters)).ToList(); + + return templates + // Exact number of parameters takes priority. + .OrderByDescending(t => t.FindMatchingParameters(providedParameters).Count()) + + // Then the one with highest number of parameters. + .ThenBy(t => t.Parameters.Count - t.FindMatchingParameters(providedParameters).Count()) + .FirstOrDefault(); + } + + public static string MergeRoute(this IUrlHelper url, string routeTemplate, Dictionary routeData) + { + if (routeTemplate.IsEmpty()) + throw new ArgumentNullException(nameof(routeTemplate)); + + return new RouteTemplate(routeTemplate).Merge(routeData); + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Extentions/Extensions.cs b/Olive.Mvc/Extentions/Extensions.cs new file mode 100644 index 000000000..b9b57dd8e --- /dev/null +++ b/Olive.Mvc/Extentions/Extensions.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Olive.Entities.Data; +using Olive.Entities; +using Olive; + +namespace Olive.Mvc +{ + public static partial class OliveMvcExtensions + { + public static IEnumerable TakePage(this IEnumerable list, ListPagination paging) => + list.TakePage(paging.PageSize, paging.CurrentPage); + + public static HtmlString Replace(this IHtmlContent content, string oldText, string newText) => + new HtmlString(GetString(content).KeepReplacing(oldText, newText)); + + public static HtmlString PrefixName(this IHtmlContent content, string prefix) + { + var code = GetString(content) + .Replace(" name=\"", $" name=\"{prefix}.") + .Replace(" id=\"", $" id=\"{prefix}.") + .Replace(" for=\"", $" for=\"{prefix}."); + + return new HtmlString(code); + } + + internal static string GetString(this IHtmlContent content) + { + var writer = new System.IO.StringWriter(); + content.WriteTo(writer, HtmlEncoder.Default); + + return writer.ToString(); + } + + internal static void ReplaceIdentificationAttributes(this Microsoft.AspNetCore.Razor.TagHelpers.TagHelperOutput output, string newValue) + { + output.Attributes.Remove(output.Attributes.First(att => att.Name == "name")); + output.Attributes.Add("name", newValue); + + output.Attributes.Remove(output.Attributes.First(att => att.Name == "id")); + output.Attributes.Add("id", newValue); + + if (output.Attributes.Any(att => att.Name == "for")) + { + output.Attributes.Remove(output.Attributes.First(att => att.Name == "for")); + output.Attributes.Add("for", newValue); + } + } + + /// + /// Looks for a property named propertyName_Visible on this object. If it finds one and find it to be false it returns true. + /// Otherwise false. + /// + public static bool IsInvisible(this IViewModel viewModel, string propertyName) + { + var visibleProperty = viewModel.GetType().GetProperty(propertyName + "_Visible"); + + if (visibleProperty == null) return false; + + return !(bool)visibleProperty.GetValue(viewModel); + } + + public static bool IsValid(this ModelStateDictionary modelState, IViewModel viewModel) + { + foreach (var item in modelState) + if (viewModel.IsInvisible(item.Key)) + item.Value.Errors.Clear(); + + return modelState.IsValid; + } + + /// + /// Gets all errors for all values in this model state instance. + /// + public static IEnumerable GetErrors(this ModelStateDictionary modelState, bool errorStack = false) + { + foreach (var item in modelState.Values) + foreach (ModelError error in item.Errors) + yield return error.ErrorMessage.Or(error.Exception.Get(x => errorStack ? x.ToLogString() : x.Message)); + } + + /// + /// Will convert this html string into a + /// + public static HtmlString Raw(this string text) => new HtmlString(text.OrEmpty()); + + /// + /// Gets access to the current ViewBag. + /// + public static dynamic ViewBag(this HttpContext context) => (dynamic)context.Items["ViewBag"]; + + /// + /// Gets the name of the currently requested controller. + /// + public static string GetCurrentControllerName(this ActionContext context) => + context.RouteData.Values["controller"].ToString(); + + public static T OrderBy(this T query, ListSortExpression sort) + where T : IDatabaseQuery + { + query.OrderBy(sort.Expression, sort.Descending); + return query; + } + + public static T Page(this T query, ListPagination paging) + where T : IDatabaseQuery + { + query.Page(paging.CurrentPage, paging.PageSize ?? 100000); + return query; + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Extentions/Http.cs b/Olive.Mvc/Extentions/Http.cs new file mode 100644 index 000000000..badd7818b --- /dev/null +++ b/Olive.Mvc/Extentions/Http.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Routing; +using Olive; +using Olive.Entities; +using Olive.Web; + +namespace Olive.Mvc +{ + partial class OliveMvcExtensions + { + public static HttpContext GetHttpContextBase(this HttpContext context) + { + var owinInfo = context.Items["owin.Environment"] as + IDictionary; + + return owinInfo?["System.Web.HttpContextBase"] as HttpContext; + } + + /// + /// Determines if this is an Ajax GET http request. + /// + public static bool IsAjaxGet(this HttpRequest request) => request.IsAjaxCall() && request.IsGet(); + + /// + /// Determines if this is an Ajax Post http request. + /// + public static bool IsAjaxPost(this HttpRequest request) => request.IsAjaxCall() && request.IsPost(); + + /// + /// Dispatches a binary data block back to the client. + /// + public static async Task Dispatch(this HttpResponse response, byte[] responseData, string fileName, string contentType = "Application/octet-stream") + { + if (responseData == null) throw new ArgumentNullException(nameof(responseData)); + + if (fileName.IsEmpty()) throw new ArgumentNullException(nameof(fileName)); + + response.Clear(); + response.ContentType = contentType; + + response.Headers.Add("Cache-Control", "no-store"); + response.Headers.Add("Pragma", "no-cache"); + + response.Headers.Add("Content-Disposition", "attachment; filename=\"{0}\"".FormatWith(fileName.Remove("\"", ","))); + + await response.Body.WriteAsync(responseData, 0, responseData.Length); + } + + /// + /// Dispatches a file back to the client. + /// + public static async Task Dispatch(this HttpResponse response, Blob blob, string contentType = "Application/octet-stream") => + await Dispatch(response, blob.LocalPath.AsFile(), blob.FileName, contentType); + + /// + /// Dispatches a file back to the client. + /// + /// If set to null, the same file name of the file will be used. + public static async Task Dispatch(this HttpResponse response, FileInfo responseFile, string fileName = null, string contentType = "Application/octet-stream") + { + if (responseFile == null) throw new ArgumentNullException(nameof(responseFile)); + + if (fileName.IsEmpty()) fileName = responseFile.Name; + + var data = await responseFile.ReadAllBytes(); + + await response.Dispatch(data, fileName, contentType); + } + + /// + /// Dispatches a string back to the client as a file. + /// + public static async Task Dispatch(this HttpResponse response, string responseText, string fileName, string contentType = "Application/octet-stream", System.Text.Encoding encoding = null) + { + response.Clear(); + + if (encoding == null) encoding = Encoding.UTF8; + + var bytes = encoding == Encoding.UTF8 ? responseText.GetUtf8WithSignatureBytes() : encoding.GetBytes(responseText); + + await response.Dispatch(bytes, fileName, contentType); + } + + /// + /// Gets a URL helper for the current http context. + /// + public static UrlHelper GetUrlHelper(this HttpContext context) => + new UrlHelper(Context.ActionContextAccessor.ActionContext); + } +} \ No newline at end of file diff --git a/Olive.Mvc/Olive.Mvc.csproj b/Olive.Mvc/Olive.Mvc.csproj new file mode 100644 index 000000000..5c489b61f --- /dev/null +++ b/Olive.Mvc/Olive.Mvc.csproj @@ -0,0 +1,26 @@ + + + + netcoreapp2.0 + + + + ..\@Assemblies\ + ..\@Assemblies\netcoreapp2.0\Olive.Mvc.xml + 1701;1702;1705;1591;1573 + + + + + + + + + + + + + + + + diff --git a/Olive.Mvc/Package.nuspec b/Olive.Mvc/Package.nuspec new file mode 100644 index 000000000..8c49b85c2 --- /dev/null +++ b/Olive.Mvc/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.Mvc + 1.0.6 + Olive MVC + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Olive.Mvc/Services/DefaultFileUploadMarkupGenerator.cs b/Olive.Mvc/Services/DefaultFileUploadMarkupGenerator.cs new file mode 100644 index 000000000..6feff8dc4 --- /dev/null +++ b/Olive.Mvc/Services/DefaultFileUploadMarkupGenerator.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq.Expressions; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc.Rendering; +using Olive.Entities; +using Olive.Web; + +namespace Olive.Mvc +{ + class DefaultFileUploadMarkupGenerator + { + public IHtmlContent Generate(IHtmlHelper html, object model, Expression> property, object htmlAttributes) + { + var propertyInfo = property.GetProperty(); + var blob = propertyInfo.GetValue(model) as Blob ?? Blob.Empty(); + var value = html.ViewContext.HttpContext.Request.HasFormContentType ? + html.ViewContext.HttpContext.Request.Form[propertyInfo.Name] : + Microsoft.Extensions.Primitives.StringValues.Empty; + if (value == "KEEP") + { + var itemProperty = model.GetType().GetProperty("Item"); + var item = itemProperty.GetValue(model); + var originalPropertyInfo = item.GetType().GetProperty(propertyInfo.Name); + blob = originalPropertyInfo.GetValue(item) as Blob ?? Blob.Empty(); + } + + // Note: If this method is called with an IEnumerable property, + // then the existing data will never be loaded. + var result = new HtmlContentBuilder(); + + result.AppendHtmlLine("
"); + result.AppendHtmlLine($"" + + $"{blob.FileName.OrEmpty().HtmlEncode()}"); + result.AppendHtmlLine($""); + // For validation to work, this works instead of Hidden. + if (value.ToString().IsEmpty() && blob.HasValue()) value = "KEEP"; + result.AppendHtml(html.TextBox(propertyInfo.Name, value.OrEmpty(), string.Empty, + new { tabindex = "-1", style = "width:1px; height:0; border:0; padding:0; margin:0;", @class = "file-id", autocomplete = "off" })); + result.AppendHtmlLine("
"); + result.AppendHtmlLine(""); + result.AppendHtmlLine("
"); + + return result; + } + } +} diff --git a/Olive.Mvc/Services/FileUploadService.cs b/Olive.Mvc/Services/FileUploadService.cs new file mode 100644 index 000000000..9b1903b2f --- /dev/null +++ b/Olive.Mvc/Services/FileUploadService.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Olive.Entities; + +namespace Olive.Mvc +{ + // TODO: Make it flexible, to be overriden in projects. Use DI + + public class FileUploadService + { + public async Task TempSaveUploadedFile(IFormFile file) + { + var id = Guid.NewGuid().ToString(); + + var path = GetFolder(id).EnsureExists().GetFile(file.FileName.ToSafeFileName()); + + using (var stream = new MemoryStream()) + { + file.CopyTo(stream); + await File.WriteAllBytesAsync(path.FullName, stream.ToArray()); + } + + return new { ID = id, Name = file.FileName.ToSafeFileName() }; + } + + public static DirectoryInfo GetFolder(string key = null) + { + var folder = Blob.GetPhysicalFilesRoot(Blob.AccessMode.Secure) + .GetOrCreateSubDirectory("@Temp.File.Uploads").FullName; + + if (key.HasValue()) folder = Path.Combine(folder, key); + + return folder.AsDirectory(); + } + + internal async Task Bind(string fileKey) + { + if (!fileKey.StartsWith("file:")) throw new Exception("Expected file input is in the format of 'file:{GUID}'."); + + fileKey = fileKey.TrimStart("file:"); + + var folder = GetFolder(fileKey); + if (!folder.Exists()) + throw new Exception("The folder for this uploaded file does not exist: " + fileKey); + + if (folder.GetFiles().None()) + throw new Exception("There is no file in the temp folder " + fileKey); + + if (folder.GetFiles().HasMany()) + throw new Exception("There are multiple files in the temp folder " + fileKey); + + var file = folder.GetFiles().Single(); + + return new Blob(await file.ReadAllBytes(), file.Name); + } + + public static async Task DeleteTempFiles(TimeSpan olderThan) + { + foreach (var folder in GetFolder().EnsureExists().GetDirectories()) + { + // Is it Guid? + if (folder.Name.TryParseAs() == null) continue; + + // Age: + var age = LocalTime.Now.Subtract(folder.LastWriteTime); + if (age < olderThan) continue; + + await folder.Delete(recursive: true, harshly: true); + } + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Services/PaginationNavigation.cs b/Olive.Mvc/Services/PaginationNavigation.cs new file mode 100644 index 000000000..137631731 --- /dev/null +++ b/Olive.Mvc/Services/PaginationNavigation.cs @@ -0,0 +1,123 @@ +namespace Olive.Mvc +{ + using System.Collections.Generic; + using System.Text; + using Microsoft.AspNetCore.Html; + using Microsoft.AspNetCore.Mvc.Rendering; + using Microsoft.AspNetCore.Mvc.Routing; + using Olive.Web; + + class PaginationRenderer + { + IHtmlHelper Html; + ListPagination Paging; + int VisiblePages, Start, End; + object HtmlAttributes; + string Prefix; + + public PaginationRenderer(IHtmlHelper html, ListPagination paging, int visiblePages, object htmlAttributes, string prefix) + { + Html = html; + Paging = paging; + VisiblePages = visiblePages; + HtmlAttributes = htmlAttributes; + Prefix = prefix; + } + + void FindBoundaries() + { + if (Paging.CurrentPage > Paging.LastPage) Paging.CurrentPage = 1; + + Start = Paging.CurrentPage; + End = Paging.CurrentPage; + + while ((Start > 1 || End < Paging.LastPage) && End - Start < VisiblePages - 1) + { + if (Start > 1) Start--; + if (End - Start < VisiblePages - 1 && End < Paging.LastPage) End++; + } + } + + string GetPagingKey() => Paging.Prefix.WithSuffix(".") + "p"; + + string GetLinkAttributes(int number) + { + var urlHelper = new UrlHelper(Context.ActionContextAccessor.ActionContext); + + if (Paging.UseAjaxPost) + { + return "href=\"#\" formaction=\"{0}\" data-pagination=\"{1}{2}\"" + .FormatWith(urlHelper.ActionWithQuery(Paging.Container.GetType().Name + "/Reload"), Paging.Prefix.WithSuffix(".p="), Paging.GetQuery(number)); + } + else + { + var url = urlHelper.Current(new Dictionary { { GetPagingKey(), Paging.GetQuery(number) } }); + + return "href=\"{0}\"".FormatWith(url) + " data-redirect=\"ajax\"".OnlyWhen(Paging.UseAjaxGet); + } + } + + public HtmlString Render() + { + if (Paging.PageSize == null || Paging.TotalItemsCount == 0) + return null; + + if (ListPagination.DisplayForSinglePage == false && Paging.LastPage == 1) + return null; + + FindBoundaries(); + + var r = new StringBuilder(); + + if (ListPagination.WrapperCssClass.HasValue()) + r.AppendLine($"
"); + + r.AddFormattedLine("", OliveMvcExtensions.ToHtmlAttributes(HtmlAttributes)); + + var isFirst = Paging.CurrentPage == 1; + var isLast = Paging.CurrentPage == Paging.LastPage; + + // add first page control + if (Paging.ShowFirstLastLinks) + AddPaginationControl(r, Paging.FirstText, "First page", Paging.CurrentPage == 1, 1); + + // add previous page control + if (Paging.ShowPreviousNextLinks) + { + var previousPage = isFirst ? 1 : Paging.CurrentPage - 1; + AddPaginationControl(r, Paging.PreviousText, "Previous page", isFirst, previousPage); + } + + for (var i = Start; i <= End; i++) + { + r.AppendFormat("
  • ", " class=\"active\"".OnlyWhen(i == Paging.CurrentPage)); + r.AddFormattedLine("{1}
  • ", GetLinkAttributes(i), i); + } + + // add next page control + if (Paging.ShowPreviousNextLinks) + { + var nextPage = isLast ? Paging.LastPage : Paging.CurrentPage + 1; + AddPaginationControl(r, Paging.NextText, "Next page", isLast, nextPage); + } + + // add last page control + if (Paging.ShowFirstLastLinks) + AddPaginationControl(r, Paging.LastText, "Last page", isLast, Paging.LastPage); + + r.AppendLine(""); + r.AppendLineIf("
    ", ListPagination.WrapperCssClass.HasValue()); + + return new HtmlString(r.ToString()); + } + + void AddPaginationControl(StringBuilder builder, string text, string ariaText, bool isDisabled, int pageNumber) + { + builder.AppendFormat("", " class=\"disabled\"".OnlyWhen(isDisabled)); + builder.AddFormattedLine("", GetLinkAttributes(pageNumber), ariaText); + builder.AddFormattedLine("{0}", text); + builder.AppendLine(""); + builder.AppendLine(""); + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Services/TempFileService.cs b/Olive.Mvc/Services/TempFileService.cs new file mode 100644 index 000000000..5c7a62be0 --- /dev/null +++ b/Olive.Mvc/Services/TempFileService.cs @@ -0,0 +1,47 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Olive.Web; + +namespace Olive.Mvc +{ + public class TempFileService + { + public static async Task Download(string key) + { + var folder = FileUploadService.GetFolder(key); + if (!folder.Exists()) return CreateError("The folder does not exist for key: " + key); + + var files = folder.GetFiles(); + + if (files.None()) return CreateError("There is no file for key: " + key); + if (files.HasMany()) return CreateError("There are multiple files for the key: " + key); + + var file = files.Single(); + + return new FileContentResult(await file.ReadAllBytes(), "application/octet-stream") { FileDownloadName = file.Name }; + } + + static FileContentResult CreateError(string errorText) + { + var bytes = Encoding.ASCII.GetBytes(errorText); + + return new FileContentResult(bytes, "application/octet-stream") { FileDownloadName = "Error.txt" }; + } + + public static async Task CreateDownloadAction(byte[] data, string filename) + { + var key = Guid.NewGuid().ToString(); + var folder = FileUploadService.GetFolder(key).EnsureExists(); + await folder.GetFile(filename).WriteAllBytes(data); + + var url = "/temp-file/" + key; + + Context.Http.Perform(c => url = c.GetUrlHelper().Content("~" + url)); + + return new { Download = url }; + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/TagHelpers/PrefixIdentificationsTagHelper.cs b/Olive.Mvc/TagHelpers/PrefixIdentificationsTagHelper.cs new file mode 100644 index 000000000..8538bce37 --- /dev/null +++ b/Olive.Mvc/TagHelpers/PrefixIdentificationsTagHelper.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Olive.Mvc +{ + [HtmlTargetElement(Attributes = PREFIX_ATTRIBUTE_NAME)] + public class PrefixIdentificationsTagHelper : TagHelper + { + const string PREFIX_ATTRIBUTE_NAME = "asp-prefix"; + + [HtmlAttributeName(PREFIX_ATTRIBUTE_NAME)] + public string Prefix { get; set; } + + public override int Order => 0; + + public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + await base.ProcessAsync(context, output); + + var newName = $"{Prefix}.{output.Attributes.FirstOrDefault(att => att.Name == "name").Value}"; + + output.ReplaceIdentificationAttributes(newName); + } + } +} diff --git a/Olive.Mvc/TagHelpers/ReplaceIdentificationsTagHelper.cs b/Olive.Mvc/TagHelpers/ReplaceIdentificationsTagHelper.cs new file mode 100644 index 000000000..18316c57f --- /dev/null +++ b/Olive.Mvc/TagHelpers/ReplaceIdentificationsTagHelper.cs @@ -0,0 +1,30 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Olive.Mvc +{ + [HtmlTargetElement(Attributes = REPLACE_THIS_ATTRIBUTE_NAME + "," + WITH_THIS_ATTRIBUTE_NAME)] + public class ReplaceIdentificationsTagHelper : TagHelper + { + const string REPLACE_THIS_ATTRIBUTE_NAME = "asp-replace-this"; + const string WITH_THIS_ATTRIBUTE_NAME = "asp-with-this"; + + [HtmlAttributeName(REPLACE_THIS_ATTRIBUTE_NAME)] + public string ReplaceThis { get; set; } + + [HtmlAttributeName(WITH_THIS_ATTRIBUTE_NAME)] + public string WithThis { get; set; } + + public override int Order => 0; + + public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + await base.ProcessAsync(context, output); + + var newName = output.Attributes.First(att => att.Name == "name").Value.ToString().Replace(ReplaceThis, WithThis); + + output.ReplaceIdentificationAttributes(newName); + } + } +} diff --git a/Olive.Mvc/TagHelpers/SelectTagHelper.cs b/Olive.Mvc/TagHelpers/SelectTagHelper.cs new file mode 100644 index 000000000..2aeba90c3 --- /dev/null +++ b/Olive.Mvc/TagHelpers/SelectTagHelper.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Olive.Entities; + +namespace Olive.Mvc +{ + [HtmlTargetElement("select", Attributes = "asp-for")] + [HtmlTargetElement("select", Attributes = "asp-items")] + //[HtmlTargetElement("select")] + public class SelectTagHelper : Microsoft.AspNetCore.Mvc.TagHelpers.SelectTagHelper + { + public SelectTagHelper(IHtmlGenerator generator) : base(generator) + { + } + + public override int Order => 0; + + public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + var isEntity = For?.Model?.GetType()?.IsA() ?? false; + + // It should clear the original tag helper effects because it will call the base method again. + output.PreContent.Clear(); + output.Content.Clear(); + output.PostContent.Clear(); + + await base.ProcessAsync(context, output); + + if (isEntity) + { + var postContent = output.PostContent.GetContent(); + + var stringId = ((IEntity)For.Model).GetId().ToString(); + + postContent = postContent.Replace($"\"{stringId}\"", $"\"{stringId}\" selected=\"selected\""); + + output.PostContent.Clear(); + + output.PostContent.SetHtmlContent(postContent); + } + } + } +} diff --git a/Olive.Mvc/TagHelpers/ValidationTranslatorTagHelper.cs b/Olive.Mvc/TagHelpers/ValidationTranslatorTagHelper.cs new file mode 100644 index 000000000..c89cffbd3 --- /dev/null +++ b/Olive.Mvc/TagHelpers/ValidationTranslatorTagHelper.cs @@ -0,0 +1,32 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Olive.Services.Globalization; + +namespace Olive.Mvc +{ + [HtmlTargetElement("select")] + [HtmlTargetElement("input")] + public class ValidationTranslatorTagHelper : TagHelper + { + static bool TranslateValidators = Config.Get("Translate.Validators", defaultValue: false); + static string[] ValidationTextAttributes = new[] { "data-val-length", "data-val-required", "data-val-email" }; + + public override int Order => int.MaxValue; + + public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + if (TranslateValidators) + { + var temp = output.Attributes + .Where(att => att.Name.IsAnyOf(ValidationTextAttributes)) + .Select(att => new { Name = att.Name, Value = att.Value.ToString() }).ToArray(); + + foreach (var att in temp) + output.Attributes.SetAttribute(att.Name, Translator.Translate(att.Value)); + } + + await base.ProcessAsync(context, output); + } + } +} diff --git a/Olive.Mvc/Utilities/ColumnSelection.cs b/Olive.Mvc/Utilities/ColumnSelection.cs new file mode 100644 index 000000000..346a22c78 --- /dev/null +++ b/Olive.Mvc/Utilities/ColumnSelection.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Rendering; +using Olive.Web; + +namespace Olive.Mvc +{ + public class ColumnSelection + { + List Current; + string cookieKey, Prefix; + + public ColumnSelection(string prefix = null) => Prefix = prefix; + + public List Options { get; set; } + + public IEnumerable GetListOptions() + { + var result = new List(); + result.AddRange(Options); + return result; + } + + public async Task> GetCurrent() + { + if (Current != null) return Current; + + SetSelection((await CookieProperty.Get(CookieKey)).OrEmpty().Split('|')); + + return Current; + } + + public List Default { get; set; } + + [ReadOnly(true)] + public string CookieKey + { + get + { + if (cookieKey == null) + cookieKey = Context.ActionContextAccessor.ActionContext.RouteData.Values["action"] + + Prefix.WithPrefix(".") + ".Columns"; + + return cookieKey; + } + set + { + cookieKey = value; + } + } + + public bool Contains(string column) => Current.Contains(column); + + internal void SetSelection(IEnumerable selectedColumns) + { + selectedColumns = selectedColumns.Or(new string[0]).Intersect(Options); + + Current = selectedColumns.Or(Default).ToList(); + + if (Current.IsEquivalentTo(Default)) + CookieProperty.Remove(CookieKey); + else + CookieProperty.Set(CookieKey, Current.ToString("|")); + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/Controller.File.cs b/Olive.Mvc/Utilities/Controller.File.cs new file mode 100644 index 000000000..1e49d3386 --- /dev/null +++ b/Olive.Mvc/Utilities/Controller.File.cs @@ -0,0 +1,26 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Olive.Entities; + +namespace Olive.Mvc +{ + partial class Controller : Microsoft.AspNetCore.Mvc.Controller + { + /// + /// Gets a FilePathResult based on the file's path. It sets the mime type based on the file's extension. + /// + /// If specified, the browser will not try to process the file directly (such as PDF files) and instead always opens the file download dialogue. + protected async Task File(Blob file, string downloadFileName = null) => + File(await file.GetFileData(), file.GetMimeType(), downloadFileName.Or(file.FileName)); + + protected JsonResult NonobstructiveFile(byte[] data, string filename) => + AddAction(TempFileService.CreateDownloadAction(data, filename)); + + protected async Task NonobstructiveFile(Blob file, string downloadFileName = null) => + NonobstructiveFile(await file.GetFileData(), downloadFileName.Or(file.FileName)); + + protected async Task NonobstructiveFile(FileInfo file) => + NonobstructiveFile(await file.ReadAllBytes(), file.Name); + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/Controller.cs b/Olive.Mvc/Utilities/Controller.cs new file mode 100644 index 000000000..0029c3c00 --- /dev/null +++ b/Olive.Mvc/Utilities/Controller.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.CodeAnalysis; +using Olive.Entities; +using Olive.Services.Globalization; +using Olive.Web; + +namespace Olive.Mvc +{ + /// + /// Provides methods that respond to HTTP requests that are made to an ASP.NET MVC Web site. + /// + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public abstract partial class Controller : Microsoft.AspNetCore.Mvc.Controller + { + /// + /// Initializes a new instance of the System.Web.Mvc.Controller class. + /// + protected Controller() + { + ModelState.Clear(); + HttpContext.Items["ViewBag"] = ViewBag; + } + + protected static IDatabase Database => Entities.Data.Database.Instance; + + /// + /// Provides a dummy Html helper. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public HtmlHelper Html + { + get + { + throw new NotImplementedException("The following code is commented to fix on the test time."); + // return new HtmlHelper(new ViewContext(ControllerContext, + // new RazorView(ControllerContext, "unknownPath", "unknownLayout", false, null), + // new ViewDataDictionary(), + // new TempDataDictionary(), TextWriter.Null), new ViewPage()); + } + } + + /// + /// Do not use this overload. Always provide a viewmodel as a parameter. + /// + protected new internal ViewResult View() => + throw new InvalidOperationException("View() method should not be called without specifying a view model."); + + /// + /// Creates a ViewResult object by using the model that renders a view to the response. + /// + protected new internal async Task View(object model) + { + AddAction(await NotificationAction.GetScheduledNotification()); + return base.View(model); + } + + /// + /// Creates a ViewResult object by using the model that renders a view to the response. + /// + protected new internal async Task View(string viewName) + { + AddAction(await NotificationAction.GetScheduledNotification()); + return base.View(viewName); + } + + /// + /// Creates a ViewResult object by using the model that renders a view to the response. + /// + protected new internal async Task View(string viewName, object model) + { + AddAction(await NotificationAction.GetScheduledNotification()); + return base.View(viewName, model); + } + + /// + /// Gets HTTP-specific information about an individual HTTP request. + /// + public new HttpContext HttpContext => base.HttpContext ?? Context.Http; + + /// + /// Creates a new instance of the specified view model type and binds it using the standard request data. + /// + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + await BindAttributeRunner.Run(context, next); + await base.OnActionExecutionAsync(context, next); + } + + public override async Task TryUpdateModelAsync(TModel model) + { + if (await base.TryUpdateModelAsync(model)) + { + await BindAttributeRunner.BindOn(this, (IViewModel)model); + return true; + } + + return false; + } + + protected new HttpRequest Request => HttpContext.Request; + + protected List Actions => + (List)(HttpContext.Items["JavascriptActions"] ?? (HttpContext.Items["JavascriptActions"] = new List())); + + [NonAction] + protected JsonResult AddAction(object action) + { + if (action != null) Actions.Add(action); + + return JsonActions(); + } + + [NonAction] + protected JsonResult JsonActions() => Json(Actions); + + [NonAction] + protected async Task JsonActions(IViewModel info) + { + if (Request.IsAjaxCall()) return Json(Actions); + else return await View(info); + } + + [NonAction] + protected JsonResult Notify(object message, bool obstruct = true) => Notify(message, style: null, obstruct: obstruct); + + [NonAction] + protected JsonResult Notify(object message, string style, bool obstruct = true) => + AddAction(new NotificationAction { Notify = message.ToStringOrEmpty(), Style = style, Obstruct = obstruct }); + + [NonAction] + public JsonResult JavaScript(string script) => JavaScript(script, PageLifecycleStage.Init); + + [NonAction] + public JsonResult JavaScript(string script, PageLifecycleStage stage) => + AddAction(new { Script = script, Stage = stage.ToString() }); + + [NonAction] + public ActionResult AjaxRedirect(string url) + { + url = url.Or("#"); + if (!url.OrEmpty().ToLower().StartsWithAny("/", "http:", "https:")) url = "/" + url; + + if (Actions.OfType().Any()) + NotificationAction.ScheduleForNextRequest(Actions); + + Actions.Add(new { Redirect = url, WithAjax = true }); + + return JsonActions(); + } + + [NonAction] + public ActionResult Redirect(string url, string target = null) + { + url = url.Or("#"); + if (!url.OrEmpty().ToLower().StartsWithAny("/", "http:", "https:")) url = "/" + url; + + if (Actions.OfType().Any()) + NotificationAction.ScheduleForNextRequest(Actions); + + if (Request.IsAjaxCall() || target.HasValue()) + { + Actions.Add(new { Redirect = url, Target = target }); + + return JsonActions(); + } + else + { + return base.Redirect(url); + } + } + + [NonAction] + protected JsonResult Do(WindowAction action) + { + if (Actions.OfType().Any()) + if (new[] { WindowAction.Refresh, WindowAction.CloseModalRefreshParent }.Contains(action)) + NotificationAction.ScheduleForNextRequest(Actions); + + return AddAction(new { BrowserAction = action.ToString() }); + } + + [NonAction] + protected JsonResult ReplaceView(string text, bool htmlEncode = true) + { + if (htmlEncode) text = text.HtmlEncode(); + + return AddAction(new { ReplaceView = text }); + } + + [NonAction] + protected JsonResult ReplaceSource(string controlId, IEnumerable newItems) => + AddAction(new { ReplaceSource = controlId, Items = newItems.ToList() }); + + [NonAction] + protected ActionResult SubFormView(string subFormName, string subFormView) where TViewModel : new() => + SubFormView(subFormName, subFormView, new TViewModel()); + + [NonAction] + protected JsonResult SubFormView(string subFormName, string subFormView, object viewModel) + { + ModelState.Clear(); + + var view = RenderPartialView(subFormView, viewModel); + + return AddAction(new { SubForm = subFormName, NewItem = view }); + } + + protected async Task RenderPartialView(string viewName, object model) + { + var renderer = HttpContext.RequestServices.GetService(typeof(IViewRenderService)) as IViewRenderService; + + return await renderer.RenderToStringAsync(viewName, model); + } + + protected virtual ActionResult RedirectToLogin() => + Redirect("/login?ReturnUrl=" + HttpContext.GetUrlHelper().Current().UrlEncode()); + + /// + /// Will return the translation of the specified phrase in the language specified in user's cookie (or default language). + /// + public static async Task Translate(string phrase) => await Translator.Translate(phrase); + + /// + /// Will return the translation of the specified validation exception's message in the language specified in user's cookie (or default language). + /// If the IsMessageTranslated property is set, it will return message without extra translation. + /// + public static async Task Translate(ValidationException exception) + { + if (exception.IsMessageTranslated) return exception.Message; + else return await Translate(exception.Message); + } + + /// + /// Will return the translation of the specified markup in the language specified in user's cookie (or default language). + /// + public static async Task TranslateHtml(string markup) => await Translator.TranslateHtml(markup); + + /// + /// Creates a System.Web.Mvc.PartialViewResult object that renders a partial view, + /// by using the specified view name and model. + public new virtual PartialViewResult PartialView(string viewName, object model) => base.PartialView(viewName, model); + } + + public enum WindowAction + { + CloseModal, + CloseModalRefreshParent, + Refresh, + Close, + Back, + Print, + ShowPleaseWait + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/DatabaseFilters.cs b/Olive.Mvc/Utilities/DatabaseFilters.cs new file mode 100644 index 000000000..240aade0d --- /dev/null +++ b/Olive.Mvc/Utilities/DatabaseFilters.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using Olive.Entities; + +namespace Olive +{ + public class DatabaseFilters : List where T : IEntity + { + public void Add(Expression> criterion) => Add(Criterion.From(criterion)); + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/EmptyListItem.cs b/Olive.Mvc/Utilities/EmptyListItem.cs new file mode 100644 index 000000000..52a97be77 --- /dev/null +++ b/Olive.Mvc/Utilities/EmptyListItem.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Olive.Mvc +{ + public class EmptyListItem : SelectListItem + { + public EmptyListItem() : this("---Select---") { } + + public EmptyListItem(string text) + { + Text = text; + Value = string.Empty; + } + + public EmptyListItem(string text, string value) + { + Text = text; + Value = value.OrEmpty(); + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/ExecuteBindMethodsFilter.cs b/Olive.Mvc/Utilities/ExecuteBindMethodsFilter.cs new file mode 100644 index 000000000..0e0cd2e15 --- /dev/null +++ b/Olive.Mvc/Utilities/ExecuteBindMethodsFilter.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Olive.Mvc +{ + public static class BindAttributeRunner + { + static ConcurrentDictionary PreBindingCache = new ConcurrentDictionary(); + static ConcurrentDictionary PreBoundCache = new ConcurrentDictionary(); + static ConcurrentDictionary BoundCache = new ConcurrentDictionary(); + + public static Task Run(ActionExecutingContext context, ActionExecutionDelegate next) + { + var args = context.ActionArguments.Select(x => x.Value).OfType().ToArray(); + return BindOn(context.Controller, args); + } + + public static async Task BindOn(object controller, params IViewModel[] items) + { + foreach (var item in items) + await Run(item, controller); + + foreach (var item in items) + await Run(item, controller); + + foreach (var item in items) + await Run(item, controller); + } + + internal static async Task Run(IViewModel viewModel, object controller) + where TAttribute : Attribute + { + var methods = FindMethods(viewModel, controller); + await InvokeMethods(methods, controller, viewModel); + } + + static MethodInfo[] FindMethods(IViewModel viewModel, object controller) + where TAtt : Attribute + { + var key = GetKey(controller, viewModel); + return GetCache().GetOrAdd(key, + t => + { + var methods = controller.GetType().GetMethods().Where(m => m.Defines()).ToArray(); + + methods = methods.Where(m => m.GetParameters().IsSingle() + && m.GetParameters().First().ParameterType == viewModel.GetType()).ToArray(); + + return methods; + }); + } + + static Task InvokeMethods(MethodInfo[] methods, object controller, object viewModel) + { + foreach (var info in viewModel.GetType().GetProperties()) + { + var nestedValue = info.GetValue(viewModel); + if (info.PropertyType.IsA() && nestedValue != null) + InvokeMethods(methods, controller, nestedValue); + } + + var tasks = new List(); + + foreach (var method in methods) + { + var result = method.Invoke(controller, new[] { viewModel }); + if (result is Task task) tasks.Add(task); + } + + return Task.WhenAll(tasks); + } + + static ConcurrentDictionary GetCache() + { + if (typeof(TAttribute) == typeof(OnPreBindingAttribute)) return PreBindingCache; + else if (typeof(TAttribute) == typeof(OnPreBoundAttribute)) return PreBoundCache; + else if (typeof(TAttribute) == typeof(OnBoundAttribute)) return BoundCache; + else throw new NotSupportedException(typeof(TAttribute) + " is not supported!!"); + } + + static string GetKey(object controller, IViewModel viewModel) + { + return controller.GetType().FullName + "|" + viewModel.GetType().FullName; + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/ITaskManager.cs b/Olive.Mvc/Utilities/ITaskManager.cs new file mode 100644 index 000000000..74b278331 --- /dev/null +++ b/Olive.Mvc/Utilities/ITaskManager.cs @@ -0,0 +1,7 @@ +namespace Olive.Mvc +{ + public interface ITaskManager + { + void Run(); + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/JsonHandlerAttribute.cs b/Olive.Mvc/Utilities/JsonHandlerAttribute.cs new file mode 100644 index 000000000..28e4b76d7 --- /dev/null +++ b/Olive.Mvc/Utilities/JsonHandlerAttribute.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Olive.Mvc +{ + public class JsonHandlerAttribute : ActionFilterAttribute + { + public override void OnActionExecuted(ActionExecutedContext filterContext) + { + if (filterContext.Result is JsonResult jsonResult) + filterContext.Result = new JsonNetResult(jsonResult.Value, jsonResult.SerializerSettings); + + base.OnActionExecuted(filterContext); + } + } +} diff --git a/Olive.Mvc/Utilities/JsonNetResult.cs b/Olive.Mvc/Utilities/JsonNetResult.cs new file mode 100644 index 000000000..a0aef31ee --- /dev/null +++ b/Olive.Mvc/Utilities/JsonNetResult.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; + +namespace Olive.Mvc +{ + public class JsonNetResult : JsonResult + { + public JsonNetResult(object value) : base(value) + { + Settings = new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Error, + Formatting = Formatting.Indented + }; + } + + public JsonNetResult(object value, JsonSerializerSettings serializerSettings) : base(value, serializerSettings) + { + Settings = new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Error, + Formatting = Formatting.Indented + }; + } + + public JsonSerializerSettings Settings { get; } + + public override async Task ExecuteResultAsync(ActionContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + + var response = context.HttpContext.Response; + response.ContentType = string.IsNullOrEmpty(ContentType) ? "application/json" : ContentType; + + if (Value == null) return; + + using (var writer = new StringWriter()) + { + var scriptSerializer = JsonSerializer.Create(Settings); + scriptSerializer.Serialize(writer, Value); + + var data = Encoding.UTF8.GetBytes(writer.ToString()); + + await response.Body.WriteAsync(data, 0, data.Length); + } + + return; + } + } +} diff --git a/Olive.Mvc/Utilities/ListPagination.cs b/Olive.Mvc/Utilities/ListPagination.cs new file mode 100644 index 000000000..6e8c1c493 --- /dev/null +++ b/Olive.Mvc/Utilities/ListPagination.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using Olive.Entities; + +namespace Olive.Mvc +{ + public class ListPagination + { + public static string DefaultFirstText = "«"; + public static string DefaultPreviousText = "‹"; + public static string DefaultNextText = "›"; + public static string DefaultLastText = "»"; + + public static bool DisplayForSinglePage = false; + public static string WrapperCssClass; + public static bool DefaultShowFirstLastLinks = true; + public static bool DefaultShowPreviousNextLinks = false; + + public bool ShowFirstLastLinks = DefaultShowFirstLastLinks; + public bool ShowPreviousNextLinks = DefaultShowPreviousNextLinks; + + public int CurrentPage { get; set; } + public int? PageSize { get; set; } + public int TotalItemsCount { get; set; } + public string Prefix { get; set; } + public bool UseAjaxPost { get; set; } + public bool UseAjaxGet { get; set; } + + public string FirstText { get; set; } = DefaultFirstText; + public string PreviousText { get; set; } = DefaultPreviousText; + public string NextText { get; set; } = DefaultNextText; + public string LastText { get; set; } = DefaultLastText; + + public List SizeOptions = new List(); + public IViewModel Container; + + public int LastPage + { + get + { + if (PageSize == null) return 0; + return (int)Math.Ceiling(1.0 * TotalItemsCount / PageSize.Value); + } + } + + public ListPagination(IViewModel container, int? pageSize = null) + { + CurrentPage = 1; + PageSize = pageSize; + Container = container; + } + + public ListPagination(IViewModel container, string queryInfo) + : this(container) + { + if (queryInfo.IsEmpty()) return; + + var parts = queryInfo.Split('-'); + CurrentPage = parts.First().TryParseAs() ?? 1; + if (CurrentPage < 1) CurrentPage = 1; + + if (parts.HasMany()) + { + PageSize = parts.ElementAt(1).TryParseAs(); + if (PageSize < 1) PageSize = null; + } + } + + public override string ToString() => CurrentPage + PageSize.ToStringOrEmpty().WithPrefix("-"); + + public void Clear() => SizeOptions.Clear(); + + public void AddPageSizeOptions(params object[] options) + { + foreach (var option in options) + { + var text = option.ToStringOrEmpty(); + + var size = text.TryParseAs(); + + SizeOptions.Add(new SelectListItem + { + Text = text, + Value = 1 + size.ToString().WithPrefix("-"), + Selected = size == PageSize + }); + } + } + + public string GetQuery(int pageNumber) => pageNumber + PageSize.ToStringOrEmpty().WithPrefix("-"); + + public PagingQueryOption ToQueryOption(string orderBy = null) + { + orderBy = orderBy.Or("ID"); + + var start = CurrentPage * PageSize ?? 0; + var size = PageSize ?? TotalItemsCount; + + return new PagingQueryOption(orderBy, start, size); + } + + public PagingQueryOption ToQueryOption(ListSortExpression sort) + { + var orderBy = sort.Expression.Or("ID") + " DESC".OnlyWhen(sort.Descending); + + return ToQueryOption(orderBy); + } + } + + public class ColumnSelectionBinder : IModelBinder + { + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + + if (value == null) return Task.CompletedTask; ; + + var result = bindingContext.Model as ColumnSelection; + + result?.SetSelection(value.FirstValue.OrEmpty().Split('|')); + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/ListSortExpression.cs b/Olive.Mvc/Utilities/ListSortExpression.cs new file mode 100644 index 000000000..d7770282a --- /dev/null +++ b/Olive.Mvc/Utilities/ListSortExpression.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Olive.Web; + +namespace Olive.Mvc +{ + public class ListSortExpression + { + public string Expression { get; set; } + public bool Descending { get; set; } + public string Prefix { get; set; } + public bool UseAjaxPost { get; set; } + public IViewModel Container { get; set; } + + public ListSortExpression(IViewModel container) { Container = container; } + + public ListSortExpression(IViewModel container, string expression) : this(container) + { + Descending = expression?.EndsWith(".DESC") ?? false; + Expression = expression?.TrimEnd(".DESC"); + } + + public IEnumerable Apply(IEnumerable items, Func expression) + { + if (Descending) return items.OrderByDescending(expression); + else return items.OrderBy(expression); + } + + public IEnumerable Apply(IEnumerable items) + { + if (items == null) return Enumerable.Empty(); + + if (Expression.IsEmpty()) return items; + + try + { + if (Descending) return items.OrderByDescending(Expression); + else return items.OrderBy(Expression); + } + catch (Exception ex) + { + throw new Exception("Could not sort the " + typeof(T).Name.ToPlural() + " list with expression: " + Expression, ex); + } + } + + public override string ToString() => Expression + ".DESC".OnlyWhen(Descending); + + public string Url(string sortExpression) + { + sortExpression = sortExpression.OrEmpty(); + + if (UseAjaxPost) + return Context.HttpContextAccessor.HttpContext.GetUrlHelper().ActionWithQuery(Container.GetType().Name + "/Reload"); + else + return UrlForGet(sortExpression); + } + + string UrlForGet(string sortExpression) + { + var result = Context.HttpContextAccessor.HttpContext.GetUrlHelper().CurrentUri(); + + var queryKey = Prefix.WithSuffix(".").ToLower() + "s"; + + var currentSort = result.GetQueryString().TryGet(queryKey).Or(Expression); + + if (currentSort.HasValue()) + { + if (currentSort == sortExpression) sortExpression += ".DESC"; + else if (currentSort == sortExpression + ".DESC") sortExpression = sortExpression.TrimEnd(".DESC"); + } + + result = result.RemoveQueryString(queryKey).AddQueryString(queryKey, sortExpression); + + return result.PathAndQuery; + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/MenuItem.cs b/Olive.Mvc/Utilities/MenuItem.cs new file mode 100644 index 000000000..2d1e42bd2 --- /dev/null +++ b/Olive.Mvc/Utilities/MenuItem.cs @@ -0,0 +1,24 @@ +using Olive.Web; + +namespace Olive.Mvc +{ + public class MenuItem + { + public string Key { get; set; } + + public string Url { get; set; } + + public MenuItem(string key, string url) + { + Key = key; + Url = url; + } + + public bool MatchesCurrentUrl() + { + var currentUrl = Context.HttpContextAccessor.HttpContext.Request.ToPathAndQuery(); + + return currentUrl.StartsWith(Url.OrEmpty().TrimAfter("?", trimPhrase: true), caseSensitive: false); + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/NotificationAction.cs b/Olive.Mvc/Utilities/NotificationAction.cs new file mode 100644 index 000000000..d15e2e30b --- /dev/null +++ b/Olive.Mvc/Utilities/NotificationAction.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Olive.Web; + +namespace Olive.Mvc +{ + class NotificationAction + { + public string Notify { get; set; } + public bool Obstruct { get; set; } + public string Style { get; set; } + + const string COOKIE_KEY = "M#.Scheduled.Notifications"; + + /// + /// Removes the notification actions from the current set of specified actions, and schedules them in the next request through the cookie. + /// + internal static void ScheduleForNextRequest(List actions) + { + var notificationActions = actions.OfType().ToList(); + + if (notificationActions.None()) return; + + notificationActions.Do(a => actions.Remove(a)); + + var json = JsonConvert.SerializeObject(new NotificationAction + { + Notify = notificationActions.Select(x => x.Notify).ToLinesString(), + Obstruct = notificationActions.First().Obstruct, + Style = notificationActions.First().Style + }); + + CookieProperty.Set(COOKIE_KEY, json); + } + + internal static async Task GetScheduledNotification() + { + var value = await CookieProperty.Get(COOKIE_KEY); + + if (value.IsEmpty()) return null; + + CookieProperty.Remove(COOKIE_KEY); + + return JsonConvert.DeserializeObject(value); + } + } +} diff --git a/Olive.Mvc/Utilities/OptionalBooleanFilter.cs b/Olive.Mvc/Utilities/OptionalBooleanFilter.cs new file mode 100644 index 000000000..071520b7e --- /dev/null +++ b/Olive.Mvc/Utilities/OptionalBooleanFilter.cs @@ -0,0 +1,52 @@ +namespace Olive.Mvc +{ + public class OptionalBooleanFilter + { + public readonly bool? Value; + public readonly static OptionalBooleanFilter Null = new OptionalBooleanFilter(null); + public readonly static OptionalBooleanFilter True = new OptionalBooleanFilter(true); + public readonly static OptionalBooleanFilter False = new OptionalBooleanFilter(false); + + private OptionalBooleanFilter(bool? value) => Value = value; + + public override string ToString() + { + if (Value == null) return "Null"; + else return Value.Value.ToString(); + } + + public static OptionalBooleanFilter Parse(string text) + { + text = text.OrEmpty().ToLower(); + + if (text == "null") return Null; + + if (text.IsAnyOf("true", "yes")) return True; + + if (text.IsAnyOf("false", "no")) return False; + + return null; + } + + public static implicit operator OptionalBooleanFilter(bool? value) => new OptionalBooleanFilter(value); + + public static implicit operator bool? (OptionalBooleanFilter value) => value.Value; + + public override int GetHashCode() => Value.GetHashCode(); + + public override bool Equals(object obj) + { + if (obj == null) return false; + + try + { + return Value == ((OptionalBooleanFilter)obj).Value; + } + catch + { + // No logging is needed + return false; + } + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/PageLifecycleStage.cs b/Olive.Mvc/Utilities/PageLifecycleStage.cs new file mode 100644 index 000000000..49277a8fb --- /dev/null +++ b/Olive.Mvc/Utilities/PageLifecycleStage.cs @@ -0,0 +1,8 @@ +namespace Olive.Mvc +{ + public enum PageLifecycleStage + { + PreInit, + Init + } +} diff --git a/Olive.Mvc/Utilities/RangeFileContentResult.cs b/Olive.Mvc/Utilities/RangeFileContentResult.cs new file mode 100644 index 000000000..cce5e1ca3 --- /dev/null +++ b/Olive.Mvc/Utilities/RangeFileContentResult.cs @@ -0,0 +1,31 @@ +namespace Olive.Mvc +{ + using System; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; + using Olive.Entities; + + public class RangeFileContentResult : RangeFileResult + { + public byte[] FileContents { get; private set; } + + public RangeFileContentResult(byte[] fileContents, string contentType, string fileName, DateTime modificationDate) + : base(contentType, fileName, modificationDate, fileContents.Length) => + FileContents = fileContents ?? throw new ArgumentNullException(nameof(fileContents)); + + public static async Task From(Blob blob) + { + var data = await blob.GetFileData(); + var mime = blob.GetMimeType(); + return new RangeFileContentResult(data, mime, blob.FileName, LocalTime.Now); + } + + protected override void WriteEntireEntity(HttpResponse response) => + response.Body.Write(FileContents, 0, FileContents.Length); + + protected override void WriteEntityRange(HttpResponse response, long rangeStartIndex, long rangeEndIndex) + { + response.Body.Write(FileContents, Convert.ToInt32(rangeStartIndex), Convert.ToInt32(rangeEndIndex - rangeStartIndex) + 1); + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/RangeFileResult.cs b/Olive.Mvc/Utilities/RangeFileResult.cs new file mode 100644 index 000000000..08992912f --- /dev/null +++ b/Olive.Mvc/Utilities/RangeFileResult.cs @@ -0,0 +1,281 @@ +using System; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Olive.Mvc +{ + public abstract class RangeFileResult : ActionResult + { + static string[] DateFormats = new string[] { "r", "dddd, dd-MMM-yy HH':'mm':'ss 'GMT'", "ddd MMM d HH':'mm':'ss yyyy" }; + + const int HTTP_STATUS_CODE_OK = 200; + const int HTTP_STATUS_CODE_PARTIAL_CONTENT = 206; + const int HTTP_STATUS_CODE_PAYLOAD_TO_LONG = 413; + const int HTTP_STATUS_CODE_BAD_REQUEST = 400; + const int HTTP_STATUS_CODE_PRECONDITION_FAILED = 412; + const int HTTP_STATUS_CODE_NOT_MODIFIED = 304; + + const int ADDITIONAL_CONTENT_LENGTH = 49; + + DateTime HttpModificationDate; + string EntityTag; + long[] RangesStartIndexes, RangesEndIndexes; + bool RangeRequest, MultipartRequest; + + protected RangeFileResult(string contentType, string fileName, DateTime modificationDate, long fileLength) + { + if (contentType.IsEmpty()) throw new ArgumentNullException(nameof(contentType)); + + ContentType = contentType; + FileName = fileName; + FileModificationDate = modificationDate; + HttpModificationDate = modificationDate.ToUniversal(); + HttpModificationDate = new DateTime(HttpModificationDate.Year, HttpModificationDate.Month, HttpModificationDate.Day, HttpModificationDate.Hour, HttpModificationDate.Minute, HttpModificationDate.Second, DateTimeKind.Utc); + FileLength = fileLength; + } + + protected virtual string GenerateEntityTag(ActionContext context) + { + var entityTagBytes = Encoding.ASCII.GetBytes($"{FileName}|{FileModificationDate}"); + return Convert.ToBase64String(new MD5CryptoServiceProvider().ComputeHash(entityTagBytes)); + } + + public string ContentType { get; private set; } + + public string FileName { get; private set; } + + public DateTime FileModificationDate { get; private set; } + + public long FileLength { get; private set; } + + protected abstract void WriteEntireEntity(HttpResponse response); + + protected abstract void WriteEntityRange(HttpResponse response, long rangeStartIndex, long rangeEndIndex); + + public override void ExecuteResult(ActionContext context) + { + EntityTag = GenerateEntityTag(context); + FillRanges(context.HttpContext.Request); + + if (!(IsRangesValid(context.HttpContext.Response) && + IsModificationDateValid(context.HttpContext.Request, context.HttpContext.Response) && + IsEntityTagValid(context.HttpContext.Request, context.HttpContext.Response))) + return; + + context.HttpContext.Response.Headers.Add("Last-Modified", FileModificationDate.ToString("r")); + context.HttpContext.Response.Headers.Add("ETag", string.Format("\"{0}\"", EntityTag)); + context.HttpContext.Response.Headers.Add("Accept-Ranges", "bytes"); + + if (!RangeRequest) + { + context.HttpContext.Response.Headers.Add("Content-Length", FileLength.ToString()); + context.HttpContext.Response.ContentType = ContentType; + context.HttpContext.Response.StatusCode = HTTP_STATUS_CODE_OK; + if (context.HttpContext.Request.Method != "HEAD") + WriteEntireEntity(context.HttpContext.Response); + } + else + { + string boundary = "---------------------------" + LocalTime.Now.Ticks.ToString("x"); + + context.HttpContext.Response.Headers.Add("Content-Length", GetContentLength(boundary).ToString()); + if (!MultipartRequest) + { + context.HttpContext.Response.Headers.Add("Content-Range", string.Format("bytes {0}-{1}/{2}", RangesStartIndexes[0], RangesEndIndexes[0], FileLength)); + context.HttpContext.Response.ContentType = ContentType; + } + else + context.HttpContext.Response.ContentType = string.Format("multipart/byteranges; boundary={0}", boundary); + context.HttpContext.Response.StatusCode = HTTP_STATUS_CODE_PARTIAL_CONTENT; + if (context.HttpContext.Request.Method != "HEAD") + { + for (int i = 0; i < RangesStartIndexes.Length; i++) + { + if (MultipartRequest) + { + context.HttpContext.Response.WriteAsync(string.Format("--{0}\r\n", boundary)).RunSynchronously(); + context.HttpContext.Response.WriteAsync(string.Format("Content-Type: {0}\r\n", ContentType)).RunSynchronously(); + context.HttpContext.Response.WriteAsync(string.Format("Content-Range: bytes {0}-{1}/{2}\r\n\r\n", RangesStartIndexes[i], RangesEndIndexes[i], FileLength)).RunSynchronously(); + } + + if (!context.HttpContext.RequestAborted.IsCancellationRequested) + { + WriteEntityRange(context.HttpContext.Response, RangesStartIndexes[i], RangesEndIndexes[i]); + if (MultipartRequest) + context.HttpContext.Response.WriteAsync("\r\n").RunSynchronously(); + } + else + return; + } + + if (MultipartRequest) context.HttpContext.Response.WriteAsync(string.Format("--{0}--", boundary)).RunSynchronously(); + } + } + } + + string GetHeader(HttpRequest request, string header, string defaultValue = "") + { + return request.Headers[header].ToString().IsEmpty() ? defaultValue : + request.Headers[header].ToString().Replace("\"", string.Empty); + } + + void FillRanges(HttpRequest request) + { + string rangesHeader = GetHeader(request, "Range"); + string ifRangeHeader = GetHeader(request, "If-Range", EntityTag); + bool isIfRangeHeaderDate = DateTime.TryParseExact(ifRangeHeader, DateFormats, null, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime ifRangeHeaderDate); + + if (rangesHeader.IsEmpty() || (!isIfRangeHeaderDate && ifRangeHeader != EntityTag) || (isIfRangeHeaderDate && HttpModificationDate > ifRangeHeaderDate)) + { + RangesStartIndexes = new long[] { 0 }; + RangesEndIndexes = new long[] { FileLength - 1 }; + RangeRequest = false; + MultipartRequest = false; + } + else + { + var ranges = rangesHeader.Remove("bytes=").Split(','); + + RangesStartIndexes = new long[ranges.Length]; + RangesEndIndexes = new long[ranges.Length]; + RangeRequest = true; + MultipartRequest = ranges.HasMany(); + + for (int i = 0; i < ranges.Length; i++) + { + var currentRange = ranges[i].Split('-'); + + if (currentRange[1].IsEmpty()) + RangesEndIndexes[i] = FileLength - 1; + else + RangesEndIndexes[i] = currentRange[1].To(); + + if (currentRange[0].IsEmpty()) + { + RangesStartIndexes[i] = FileLength - 1 - RangesEndIndexes[i]; + RangesEndIndexes[i] = FileLength - 1; + } + else + RangesStartIndexes[i] = currentRange[0].To(); + } + } + } + + int GetContentLength(string boundary) + { + int contentLength = 0; + + for (int i = 0; i < RangesStartIndexes.Length; i++) + { + contentLength += Convert.ToInt32(RangesEndIndexes[i] - RangesStartIndexes[i]) + 1; + + if (MultipartRequest) + contentLength += boundary.Length + ContentType.Length + RangesStartIndexes[i].ToString().Length + + RangesEndIndexes[i].ToString().Length + FileLength.ToString().Length + ADDITIONAL_CONTENT_LENGTH; + } + + if (MultipartRequest) + contentLength += boundary.Length + 4; + + return contentLength; + } + + bool IsRangesValid(HttpResponse response) + { + if (FileLength > int.MaxValue) + { + response.StatusCode = HTTP_STATUS_CODE_PAYLOAD_TO_LONG; + return false; + } + + for (int i = 0; i < RangesStartIndexes.Length; i++) + { + if (RangesStartIndexes[i] > FileLength - 1 || RangesEndIndexes[i] > FileLength - 1 || RangesStartIndexes[i] < 0 || RangesEndIndexes[i] < 0 || RangesEndIndexes[i] < RangesStartIndexes[i]) + { + response.StatusCode = HTTP_STATUS_CODE_BAD_REQUEST; + return false; + } + } + + return true; + } + + bool IsModificationDateValid(HttpRequest request, HttpResponse response) + { + string modifiedSinceHeader = GetHeader(request, "If-Modified-Since"); + if (modifiedSinceHeader.HasValue()) + { + DateTime.TryParseExact(modifiedSinceHeader, DateFormats, null, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime modifiedSinceDate); + + if (HttpModificationDate <= modifiedSinceDate) + { + response.StatusCode = HTTP_STATUS_CODE_NOT_MODIFIED; + return false; + } + } + + string unmodifiedSinceHeader = GetHeader(request, "If-Unmodified-Since", GetHeader(request, "Unless-Modified-Since")); + if (unmodifiedSinceHeader.HasValue()) + { + bool unmodifiedSinceDateParsed = DateTime.TryParseExact(unmodifiedSinceHeader, DateFormats, null, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime unmodifiedSinceDate); + + if (HttpModificationDate > unmodifiedSinceDate) + { + response.StatusCode = HTTP_STATUS_CODE_PRECONDITION_FAILED; + return false; + } + } + + return true; + } + + bool IsEntityTagValid(HttpRequest request, HttpResponse response) + { + string matchHeader = GetHeader(request, "If-Match"); + if (matchHeader.HasValue() && matchHeader != "*") + { + var entitiesTags = matchHeader.Split(','); + int entitieTagIndex; + for (entitieTagIndex = 0; entitieTagIndex < entitiesTags.Length; entitieTagIndex++) + { + if (EntityTag == entitiesTags[entitieTagIndex]) + break; + } + + if (entitieTagIndex >= entitiesTags.Length) + { + response.StatusCode = HTTP_STATUS_CODE_PRECONDITION_FAILED; + return false; + } + } + + string noneMatchHeader = GetHeader(request, "If-None-Match"); + if (noneMatchHeader.HasValue()) + { + if (noneMatchHeader == "*") + { + response.StatusCode = HTTP_STATUS_CODE_PRECONDITION_FAILED; + return false; + } + + var entitiesTags = noneMatchHeader.Split(','); + foreach (string entityTag in entitiesTags) + { + if (EntityTag != entityTag) + continue; + + response.Headers.Add("ETag", $"\"{entityTag}\""); + response.StatusCode = HTTP_STATUS_CODE_NOT_MODIFIED; + return false; + } + } + + return true; + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/RazorPage.cs b/Olive.Mvc/Utilities/RazorPage.cs new file mode 100644 index 000000000..8cb57a068 --- /dev/null +++ b/Olive.Mvc/Utilities/RazorPage.cs @@ -0,0 +1,47 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Http; +using Olive.Services.Globalization; + +namespace Olive.Mvc +{ + public abstract class RazorPage : Microsoft.AspNetCore.Mvc.Razor.RazorPage + { + /// + /// Gets the View Model instance to provide a consistent API to gain access to the ViewModel object from controller and View. + /// + protected virtual TModel info => Model; + + public HttpRequest Request => Context.Request; + + /// + /// Will return the translation of the specified phrase in the language specified in user's cookie (or default language). + /// + public static Task Translate(string phrase) => Translator.Translate(phrase); + + /// + /// Will return the translation of the specified markup in the language specified in user's cookie (or default language). + /// + public static async Task TranslateHtml(string markup) + { + if (markup.IsEmpty()) return HtmlString.Empty; + + return new HtmlString(await Translator.TranslateHtml(markup)); + } + + /// + /// Gets a file from its relative path. + /// + public FileInfo MapPath(string relativePath) + { + var fileProvider = Environment.ContentRootFileProvider; + var path = fileProvider.GetFileInfo(relativePath)?.PhysicalPath; + if (path.IsEmpty()) return null; + return path.AsFile(); + } + + public virtual IHostingEnvironment Environment => Web.Context.HostingEnvironment; + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/ReferencesMetadataReferenceFeatureProvider.cs b/Olive.Mvc/Utilities/ReferencesMetadataReferenceFeatureProvider.cs new file mode 100644 index 000000000..b5def9f18 --- /dev/null +++ b/Olive.Mvc/Utilities/ReferencesMetadataReferenceFeatureProvider.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection.PortableExecutable; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Razor.Compilation; +using Microsoft.CodeAnalysis; +using Microsoft.Extensions.DependencyModel; + +namespace Olive.Mvc +{ + public class ReferencesMetadataReferenceFeatureProvider : IApplicationFeatureProvider + { + public void PopulateFeature(IEnumerable parts, MetadataReferenceFeature feature) + { + var libraryPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var assemblyPart in parts.OfType()) + { + var dependencyContext = DependencyContext.Load(assemblyPart.Assembly); + if (dependencyContext != null) + { + foreach (var library in dependencyContext.CompileLibraries) + { + if (string.Equals("reference", library.Type, StringComparison.OrdinalIgnoreCase)) + { + foreach (var libraryAssembly in library.Assemblies) + { + libraryPaths.Add(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, libraryAssembly)); + } + } + else + { + foreach (var path in library.ResolveReferencePaths()) + libraryPaths.Add(path); + } + } + } + else + { + libraryPaths.Add(assemblyPart.Assembly.Location); + } + } + + foreach (var path in libraryPaths) + feature.MetadataReferences.Add(CreateMetadataReference(path)); + } + + static MetadataReference CreateMetadataReference(string path) + { + using (var stream = File.OpenRead(path)) + { + var moduleMetadata = ModuleMetadata.CreateFromStream(stream, PEStreamOptions.PrefetchMetadata); + var assemblyMetadata = AssemblyMetadata.Create(moduleMetadata); + + return assemblyMetadata.GetReference(filePath: path); + } + } + } +} diff --git a/Olive.Mvc/Utilities/RouteTemplate.cs b/Olive.Mvc/Utilities/RouteTemplate.cs new file mode 100644 index 000000000..a87ca111f --- /dev/null +++ b/Olive.Mvc/Utilities/RouteTemplate.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Olive.Web; + +namespace Olive.Mvc +{ + public class RouteTemplate + { + public string Template; + public List Parameters = new List(); + public RouteTemplate(string pattern) + { + Template = pattern; + var remaining = pattern; + while (remaining.Contains("{")) + { + var parameter = remaining.Substring("{", "}", inclusive: true); + var key = parameter.TrimStart("{").TrimEnd("}"); + var mandatory = !key.EndsWith("?"); + key = key.TrimEnd("?"); + var type = typeof(string); + if (key.Contains(":")) + { + // TODO: Type doesn't matter in this use case. + // type = Type.GetType(key.Split(':').Last()); + key = key.Split(':').First(); + } + + Parameters.Add(new RouteTemplateParameter { Parameter = parameter, Key = key.ToCamelCaseId(), Type = type, IsMandatory = mandatory }); + remaining = remaining.Substring(remaining.IndexOf("{") + 1); + } + } + + public bool IsMatch(Dictionary routeData) + { + foreach (var p in Parameters) + { + var routeKey = routeData.Keys.FirstOrDefault(x => x.ToLower() == p.Key.ToLower()); + if (routeKey != null) + { + if (!p.MatchesType(routeData[routeKey])) + return false; + } + else if (p.IsMandatory) + { + // The value of this parameter in the route is not provided. + return false; + } + } + + return true; + } + + public IEnumerable FindMatchingParameters(Dictionary routeData) + { + foreach (var p in Parameters) + { + var routeKey = routeData.Keys.FirstOrDefault(x => x.ToLower() == p.Key.ToLower()); + if (routeKey == null) + continue; + if (!p.MatchesType(routeData[routeKey])) + continue; + yield return p; + } + } + + public override string ToString() => Template; + + /// + /// It will merge the provided route data parameters into the pattern of the template. + /// If any parameter in the template is non-optional, and yet a value has not been provided, it will throw an error. + /// If any of the provided route data parameters aren't expected in the pattern, then they will be added to the query string. + /// + internal string Merge(Dictionary routeData) + { + if (routeData == null) + throw new ArgumentNullException(nameof(routeData)); + var result = Template; + foreach (var p in Parameters) + { + var routeKey = routeData.Keys.FirstOrDefault(x => x.ToLower() == p.Key.ToLower()); + if (routeKey != null) + { + // TODO: Type check + result = result.Replace(p.Parameter, routeData[routeKey]); + routeData.Remove(routeKey); + } + else + { + if (p.IsMandatory) + { + throw new Exception("The value of " + p.Parameter + " in the route " + Template + " is not provided."); + } + + result = result.Replace(p.Parameter, null); + } + } + + result = "/" + result.KeepReplacing("//", "/").TrimEnd("/").TrimStart("/"); + result += routeData.Select(x => x.Key + "=" + x.Value.UrlEncode()).ToString("&").WithPrefix("?"); + return result; + } + + public class RouteTemplateParameter + { + public string Parameter; // Example: {key:type?} + public string Key; + public Type Type; + public bool IsMandatory; + + internal bool MatchesType(string value) + { + try + { + value.To(Type); + // No error? + return true; + } + catch + { + // No logging is needed + return false; + } + } + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/SecureFileAccessor.cs b/Olive.Mvc/Utilities/SecureFileAccessor.cs new file mode 100644 index 000000000..3da8cf58b --- /dev/null +++ b/Olive.Mvc/Utilities/SecureFileAccessor.cs @@ -0,0 +1,115 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Olive.Entities; +using Olive.Web; + +namespace Olive.Mvc +{ + public class FileAccessor + { + string[] PathParts; + + Type Type; + string Property; + PropertyInfo PropertyInfo; + IUser CurrentUser; + + public IEntity Instance { get; private set; } + + public Blob Blob { get; private set; } + + public string SecurityErrors { get; private set; } + + /// + /// Use create method to instantiate the class. + /// + private FileAccessor() { } + + /// + /// Creates a new SecureFileAccessor instance. + /// + public static async Task Create(string path, IUser currentUser) + { + var result = new FileAccessor + { + CurrentUser = currentUser, + PathParts = path.Split('/') + }; + + if (result.PathParts.Length < 2) + throw new Exception($"Invalid path specified: '{path}'"); + + result.FindRequestedProperty(); + + await result.FindRequestedObject(); + + result.SecurityErrors = result.GetSecurityErrors(); + + return result; + } + + public bool IsAllowed() => SecurityErrors.IsEmpty(); + + public FileInfo GetFile() + { + var file = Blob.LocalPath; + + // Fall-back logic + if (!File.Exists(file)) + file = Blob.FallbackPaths.FirstOrDefault(File.Exists); + + return file.AsFile(); + } + + void FindRequestedProperty() + { + var typeName = PathParts[0].Split('.')[0]; + + Type = Entity.Database.GetRegisteredAssemblies().Select(a => a.GetExportedTypes().SingleOrDefault(t => t.Name == typeName)).ExceptNull().FirstOrDefault(); + if (Type == null) throw new Exception($"Invalid type name specified: '{typeName}'"); + + Property = PathParts[0].Split('.')[1]; + + PropertyInfo = Type.GetProperty(Property); + if (PropertyInfo == null) + throw new Exception($"Could not find the property '{Property}' on the type '{Type.FullName}'."); + } + + async Task FindRequestedObject() + { + var idData = PathParts[1]; + + foreach (var key in new[] { ".", "/" }) + if (idData.Contains(key)) idData = idData.Substring(0, idData.IndexOf(key)); + + Instance = await Entity.Database.GetOrDefault(idData, Type); + + if (Instance == null) throw new Exception($"Invalid {Type.FullName} ID specified: '{idData}'"); + + Blob = EntityManager.ReadProperty(Instance, Property) as Blob; + } + + bool NeedsSecureAccess() => PropertyInfo.GetCustomAttribute() != null; + + string GetSecurityErrors() + { + if (!NeedsSecureAccess()) return null; + + var method = Type.GetMethod($"Is{Property}VisibleTo", BindingFlags.Public | BindingFlags.Instance); + + if (method == null) + return $"{Type.FullName}.Is{Property}VisibleTo() method is not defined."; + + if (method.GetParameters().Count() != 1 || !method.GetParameters().Single().ParameterType.Implements()) + return $"{Type.FullName}.{method.Name}() doesn't accept a single argument that implements IUser"; + + if (!(bool)method.Invoke(Instance, new object[] { CurrentUser })) + return "You are not authorised to view the requested file."; + + return null; + } + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/Startup.cs b/Olive.Mvc/Utilities/Startup.cs new file mode 100644 index 000000000..752a50769 --- /dev/null +++ b/Olive.Mvc/Utilities/Startup.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.Razor.Compilation; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Serialization; +using Olive.Entities; +using Olive.Services.Testing; +using Olive.Web; + +namespace Olive.Mvc +{ + public abstract class Startup + { + protected virtual IViewLocationExpander GetViewLocationExpander() => new ViewLocationExpander(); + + // This method gets called by the runtime. Use this method to add services to the container. + // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 + public virtual void ConfigureServices(IServiceCollection services) + { + services.InjectOliveDependencies(); + + var builder = services.AddMvc(o => o.ModelBinderProviders.Insert(0, new OliveBinderProvider())) + + .AddJsonOptions(o => o.SerializerSettings.ContractResolver = new DefaultContractResolver()) + + .ConfigureApplicationPartManager(manager => + { + manager.FeatureProviders.RemoveWhere(x => x is MetadataReferenceFeatureProvider); + manager.FeatureProviders.Add(new ReferencesMetadataReferenceFeatureProvider()); + }); + + services.Configure(options => + options.ViewLocationExpanders.Add(GetViewLocationExpander())); + + AddIdentityAndStores(services).AddAuthentication(IdentityConstants.ApplicationScheme); + + services.ConfigureApplicationCookie(ConfigureApplicationCookie) + .AddDistributedMemoryCache() // Adds a default in-memory implementation of IDistributedCache. + .AddSession(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public virtual void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + InitializeDatabase(app, env); + + app.ConfigureOliveDependencies(env); + + ConfigureExceptionPage(app, env); + + app.UseAuthentication() + .UseStaticFiles() + .UseRequestLocalization(RequestLocalizationOptions) + .UseSession() + .UseMvc(ConfigureRoutes); + + WebTestManager.CreateReferenceDataBy(CreateReferenceData); + WebTestManager.InitiateTempDatabase(enforceRestart: false, mustRenew: false).Wait(); + } + + protected virtual void InitializeDatabase(IApplicationBuilder app, IHostingEnvironment env) + => Entity.InitializeDatabase(Entities.Data.Database.Instance); + + protected virtual void ConfigureExceptionPage(IApplicationBuilder app, IHostingEnvironment env) + { + if (env.IsDevelopment()) app.UseDeveloperExceptionPage().UseBrowserLink(); + else app.UseExceptionHandler("/Home/Error"); + } + + protected virtual CultureInfo GetRequestCulture() => CultureInfo.CurrentCulture; + + protected virtual RequestLocalizationOptions RequestLocalizationOptions + { + get + { + var culture = GetRequestCulture(); + + return new RequestLocalizationOptions + { + DefaultRequestCulture = new Microsoft.AspNetCore.Localization.RequestCulture(culture), + SupportedCultures = new List { culture }, + SupportedUICultures = new List { culture } + }; + } + } + + protected virtual void ConfigureRoutes(IRouteBuilder routes) { } + + protected virtual void ConfigureApplicationCookie(CookieAuthenticationOptions options) + { + options.AccessDeniedPath = "/Login"; + options.LoginPath = "/Login"; + } + + protected abstract IServiceCollection AddIdentityAndStores(IServiceCollection services); + + /// Invoked by the WebTestManager right after creating a new database. + protected abstract Task CreateReferenceData(); + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/ViewComponent.cs b/Olive.Mvc/Utilities/ViewComponent.cs new file mode 100644 index 000000000..81198ba48 --- /dev/null +++ b/Olive.Mvc/Utilities/ViewComponent.cs @@ -0,0 +1,43 @@ +namespace Olive.Mvc +{ + using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Olive.Entities; + using Olive.Services.Globalization; + using Olive.Web; + + public abstract class ViewComponent : Microsoft.AspNetCore.Mvc.ViewComponent + { + protected static IDatabase Database => Entities.Data.Database.Instance; + + /// + /// Gets HTTP-specific information about an individual HTTP request. + /// + public new HttpContext HttpContext => base.HttpContext ?? Context.Http; + + protected new HttpRequest Request => HttpContext?.Request; + + public ActionResult Redirect(string url) => new RedirectResult(url); + + /// + /// Will return the translation of the specified phrase in the language specified in user's cookie (or default language). + /// + public static async Task Translate(string phrase) => await Translator.Translate(phrase); + + /// + /// Will return the translation of the specified validation exception's message in the language specified in user's cookie (or default language). + /// If the IsMessageTranslated property is set, it will return message without extra translation. + /// + public static async Task Translate(ValidationException exception) + { + if (exception.IsMessageTranslated) return exception.Message; + else return await Translate(exception.Message); + } + + /// + /// Will return the translation of the specified markup in the language specified in user's cookie (or default language). + /// + public static async Task TranslateHtml(string markup) => await Translator.TranslateHtml(markup); + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/ViewLocationExpander.cs b/Olive.Mvc/Utilities/ViewLocationExpander.cs new file mode 100644 index 000000000..74c846305 --- /dev/null +++ b/Olive.Mvc/Utilities/ViewLocationExpander.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Razor; + +namespace Olive.Mvc +{ + public class ViewLocationExpander : IViewLocationExpander + { + /// + /// Used to specify the locations that the view engine should search to + /// locate views. + /// + public virtual IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable viewLocations) + { + // {2} is area, {1} is controller,{0} is the action + var partialViewLocationFormats = new[] { + "~/Views/Modules/{0}.cshtml", + "~/Views/Layouts/{0}.cshtml", + "~/Views/Shared/{0}.cshtml" }; + + var viewLocationFormats = new[] { + "~/Views/Modules/{0}.cshtml", + "~/Views/Pages/{1}.cshtml", + "~/Views/Modules/{1}.cshtml" }; + + return context.IsMainPage ? viewLocationFormats : partialViewLocationFormats; + } + + public virtual void PopulateValues(ViewLocationExpanderContext context) => + context.Values["customviewlocation"] = nameof(ViewLocationExpander); + } +} \ No newline at end of file diff --git a/Olive.Mvc/Utilities/ViewRenderService.cs b/Olive.Mvc/Utilities/ViewRenderService.cs new file mode 100644 index 000000000..4feaa91f0 --- /dev/null +++ b/Olive.Mvc/Utilities/ViewRenderService.cs @@ -0,0 +1,66 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Routing; + +namespace Olive.Mvc +{ + public interface IViewRenderService + { + Task RenderToStringAsync(string viewName, object model); + } + + public class ViewRenderService : IViewRenderService + { + IRazorViewEngine RazorViewEngine; + ITempDataProvider TempDataProvider; + IServiceProvider ServiceProvider; + + public ViewRenderService(IRazorViewEngine razorViewEngine, + ITempDataProvider tempDataProvider, + IServiceProvider serviceProvider) + { + RazorViewEngine = razorViewEngine; + TempDataProvider = tempDataProvider; + ServiceProvider = serviceProvider; + } + + public async Task RenderToStringAsync(string viewName, object model) + { + var httpContext = new DefaultHttpContext { RequestServices = ServiceProvider }; + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + using (var sw = new StringWriter()) + { + var viewResult = RazorViewEngine.FindView(actionContext, viewName, isMainPage: false); + + if (viewResult.View == null) + throw new ArgumentException($"{viewName} does not match any available view"); + + var viewDictionary = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()) + { + Model = model + }; + + var viewContext = new ViewContext( + actionContext, + viewResult.View, + viewDictionary, + new TempDataDictionary(actionContext.HttpContext, TempDataProvider), + sw, + new HtmlHelperOptions() + ); + + await viewResult.View.RenderAsync(viewContext); + return sw.ToString(); + } + } + } +} \ No newline at end of file diff --git a/Olive.Web/Authentication/IAuthenticationProvider.cs b/Olive.Web/Authentication/IAuthenticationProvider.cs new file mode 100644 index 000000000..540773cc5 --- /dev/null +++ b/Olive.Web/Authentication/IAuthenticationProvider.cs @@ -0,0 +1,16 @@ +using System; +using System.Security.Principal; +using System.Threading.Tasks; + +namespace Olive.Web +{ + public interface IAuthenticationProvider + { + Task LogOn(IUser user, string domain, TimeSpan timeout, bool remember); + Task LogOff(IUser user); + Task LoginBy(string provider); + + IUser LoadUser(IPrincipal principal); + void PreRequestHandler(string path); + } +} diff --git a/Olive.Web/Authentication/IUser.cs b/Olive.Web/Authentication/IUser.cs new file mode 100644 index 000000000..fb34cbaa5 --- /dev/null +++ b/Olive.Web/Authentication/IUser.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Linq; +using Olive.Entities; + +namespace Olive.Web +{ + public interface IUser : IEntity + { + IEnumerable GetRoles(); + } + + public static class IUserExtensions + { + /// + /// Determines whether this user has a specified role. + /// + public static bool IsInRole(this IUser user, string role) + { + if (user == null) return false; + else return user.GetRoles().Contains(role); + } + + /// + /// Determines if this user Is Authenticated. + /// + public static bool IsAuthenticated(this IUser user) + { + if (user == null) return false; + else return !user.IsNew; + } + } +} \ No newline at end of file diff --git a/Olive.Web/Authentication/UserInfoAccessorInitializer.cs b/Olive.Web/Authentication/UserInfoAccessorInitializer.cs new file mode 100644 index 000000000..8cd7488e8 --- /dev/null +++ b/Olive.Web/Authentication/UserInfoAccessorInitializer.cs @@ -0,0 +1,18 @@ +using System.Security.Principal; +using Olive.Entities; + +namespace Olive.Web +{ + [UserInfoAccessorInitializer] + public class UserInfoAccessorInitializer + { + public static void Initialize() => + DefaultApplicationEventManager.InitializeUseAccessors(GetUserPrincipal, GetUserIP); + + static IPrincipal GetUserPrincipal() => + Context.HttpContextAccessor.HttpContext.User; + + static string GetUserIP() => + Context.HttpContextAccessor.HttpContext.Connection.RemoteIpAddress.ToString(); + } +} diff --git a/Olive.Web/Authentication/UserServices.cs b/Olive.Web/Authentication/UserServices.cs new file mode 100644 index 000000000..235a93dce --- /dev/null +++ b/Olive.Web/Authentication/UserServices.cs @@ -0,0 +1,55 @@ +using System; +using System.ComponentModel; + +namespace Olive.Web +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public static class UserServices + { + const int DEFAULT_AUTHENTICATION_TIMEOUT_IN_MINUTES = 20; + + public static IAuthenticationProvider AuthenticationProvider; + + static UserServices() + { + var provider = Config.Get("Authentication:Provider"); + if (provider.HasValue()) + AuthenticationProvider = (IAuthenticationProvider)Type.GetType(provider).CreateInstance(); + else + throw new Exception("The authentication provider is not specified."); + } + + public static void LogOn(this IUser user) => LogOn(user, domain: null, remember: false); + + static void LogOn(this IUser user, string domain) => LogOn(user, domain, remember: false); + + public static void LogOn(this IUser user, bool remember) => LogOn(user, null, remember); + + public static TimeSpan GetAuthenticationTimeout() + { + try + { + var minutes = Config.Get("Authentication:Timeout", 5); + + return TimeSpan.FromMinutes(minutes); + } + catch + { + // No Logging Neede + return TimeSpan.FromMinutes(DEFAULT_AUTHENTICATION_TIMEOUT_IN_MINUTES); + } + } + + static void LogOn(this IUser user, string domain, bool remember) => + AuthenticationProvider.LogOn(user, domain, GetAuthenticationTimeout(), remember); + + public static void LogOff(this IUser user) + { + AuthenticationProvider.LogOff(user); + + Context.HttpContextAccessor.HttpContext.Session.Perform(s => s.Clear()); + } + + public static void LoginBy(string provider) => AuthenticationProvider.LoginBy(provider); + } +} \ No newline at end of file diff --git a/Olive.Web/CookieAwareWebClient.cs b/Olive.Web/CookieAwareWebClient.cs new file mode 100644 index 000000000..8bdb5d8ca --- /dev/null +++ b/Olive.Web/CookieAwareWebClient.cs @@ -0,0 +1,24 @@ +using System; +using System.Net; + +namespace Olive.Web +{ + public class CookieAwareWebClient : WebClient + { + public CookieAwareWebClient() : this(new CookieContainer()) { } + + public CookieAwareWebClient(CookieContainer container) => CookieContainer = container; + + public CookieContainer CookieContainer { get; set; } + + protected override WebRequest GetWebRequest(Uri address) + { + var result = base.GetWebRequest(address); + + if (result is HttpWebRequest castRequest) + castRequest.CookieContainer = CookieContainer; + + return result; + } + } +} \ No newline at end of file diff --git a/Olive.Web/DI/Context.cs b/Olive.Web/DI/Context.cs new file mode 100644 index 000000000..5875025eb --- /dev/null +++ b/Olive.Web/DI/Context.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace Olive.Web +{ + public static class Context + { + static bool isInitialized; + + static IApplicationBuilder applicationBuilder; + + static IHttpContextAccessor httpContextAccessor; + + static IActionContextAccessor actionContextAccessor; + + static IHostingEnvironment hostingEnvironment; + + public static void Initialize( + IApplicationBuilder applicationBuilder, + IHostingEnvironment hostingEnvironment, + IHttpContextAccessor httpContextAccessor, + IActionContextAccessor actionContextAccessor) + { + Context.applicationBuilder = applicationBuilder; + Context.hostingEnvironment = hostingEnvironment; + Context.httpContextAccessor = httpContextAccessor; + Context.actionContextAccessor = actionContextAccessor; + + isInitialized = true; + } + + public static IApplicationBuilder ApplicationBuilder => applicationBuilder ?? throw GetNotInitializedException(); + + public static IHostingEnvironment HostingEnvironment => hostingEnvironment ?? throw GetNotInitializedException(); + + public static IHttpContextAccessor HttpContextAccessor => httpContextAccessor ?? throw GetNotInitializedException(); + + public static IActionContextAccessor ActionContextAccessor => actionContextAccessor ?? throw GetNotInitializedException(); + + public static HttpContext Http => HttpContextAccessor.HttpContext; + + public static HttpRequest Request => Http?.Request; + + public static HttpResponse Response => Http?.Response; + + static Exception GetNotInitializedException() => + new InvalidOperationException("HttpContextAccessorHelper is not initialized"); + + public static bool IsInitialized => isInitialized; + } +} diff --git a/Olive.Web/DI/OliveDependencies.cs b/Olive.Web/DI/OliveDependencies.cs new file mode 100644 index 000000000..9fccd3387 --- /dev/null +++ b/Olive.Web/DI/OliveDependencies.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace Olive.Web +{ + public static class OliveDependencies + { + /// + /// Inject the required dependencies. + /// It should be called in Startup.ConfigureServices + /// + public static void Inject(IServiceCollection services) + { + services.AddSingleton(typeof(IHttpContextAccessor), typeof(HttpContextAccessor)); + services.AddSingleton(typeof(IActionContextAccessor), typeof(ActionContextAccessor)); + } + + /// + /// Inject the required dependencies. + /// It should be called in Startup.ConfigureServices + /// + public static void InjectOliveDependencies(this IServiceCollection services) => Inject(services); + + /// + /// Configure the helper classes. + /// It should be called in Startup.Configure + /// + public static void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + Context.Initialize( + app, + env, + app.ApplicationServices.GetService(), + app.ApplicationServices.GetService() + ); + } + + /// + /// Configure the helper classes. + /// It should be called in Startup.Configure + /// + public static void ConfigureOliveDependencies(this IApplicationBuilder app, IHostingEnvironment env) => Configure(app, env); + } +} diff --git a/Olive.Web/Extensions/@Misc.cs b/Olive.Web/Extensions/@Misc.cs new file mode 100644 index 000000000..3fbb4d8a6 --- /dev/null +++ b/Olive.Web/Extensions/@Misc.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using System.Xml.Linq; +using Newtonsoft.Json; +using Olive.Entities; + +namespace Olive.Web +{ + public static partial class OliveExtensions + { + /// + /// First three bytes of GZip compressed Data + /// + readonly static byte[] GZipStarter = new byte[] { 31, 139, 8 }; + + public static async Task DownloadData(this WebClient client, string address, bool handleGzip) + { + if (!handleGzip) + return await client.DownloadDataTaskAsync(address); + + var result = await client.DownloadDataTaskAsync(address); + if (result != null && result.Length > 3 && result[0] == GZipStarter[0] && result[1] == GZipStarter[1] && result[2] == GZipStarter[2]) + { + // GZIP: + using (var stream = new System.IO.Compression.GZipStream(new MemoryStream(result), System.IO.Compression.CompressionMode.Decompress)) + { + var buffer = new byte[4096]; + using (var memory = new MemoryStream()) + { + while (true) + { + var count = await stream.ReadAsync(buffer, 0, buffer.Length); + if (count > 0) await memory.WriteAsync(buffer, 0, count); + else break; + } + + return memory.ToArray(); + } + } + } + else + { + return result; + } + } + + /// + /// Posts the specified data to a url and returns the response as string. + /// All properties of the postData object will be sent as individual FORM parameters to the destination. + /// + /// An anonymous object containing post data. + public static async Task Post(this WebClient webClient, string url, object postData) + { + if (postData == null) + throw new ArgumentNullException(nameof(postData)); + + var data = new Dictionary(); + data.AddFromProperties(postData); + + return await Post(webClient, url, data); + } + + /// + /// Gets the response data as string. + /// + public static async Task GetString(this WebResponse response) + { + using (var stream = response.GetResponseStream()) + { + using (var reader = new StreamReader(stream)) + return await reader.ReadToEndAsync(); + } + } + + /// + /// Gets the response data as string. + /// + public static async Task GetResponseString(this HttpWebRequest request) + { + using (var response = request.GetResponse()) + return await response.GetString(); + } + + /// + /// Posts the specified object as JSON data to this URL. + /// + public static async Task PostJson(this Uri url, object data) + { + var req = (HttpWebRequest)WebRequest.Create(url); + + req.Method = WebRequestMethods.Http.Post; + req.ContentType = "application/json"; + + using (var stream = new StreamWriter(await req.GetRequestStreamAsync())) + await stream.WriteAsync(JsonConvert.SerializeObject(data)); + + return await req.GetResponseString(); + } + + /// + /// Posts the specified data to this url and returns the response as string. + /// All items in the postData object will be sent as individual FORM parameters to the destination. + /// + public static async Task Post(this Uri url, object data, Action customiseClient = null) + { + using (var client = new WebClient()) + { + customiseClient?.Invoke(client); + + return await client.Post(url.ToString(), data); + } + } + + /// + /// Posts the specified data to this url and returns the response as string. + /// All items in the postData object will be sent as individual FORM parameters to the destination. + /// + public static async Task Post(this Uri url, Dictionary postData, Action customiseClient = null) + { + using (var client = new WebClient()) + { + customiseClient?.Invoke(client); + return await client.Post(url.ToString(), postData); + } + } + + /// + /// Posts the specified data to a url and returns the response as string. + /// All items in the postData object will be sent as individual FORM parameters to the destination. + /// + public static async Task Post(this WebClient webClient, string url, Dictionary postData) => + await Post(webClient, url, postData, Encoding.UTF8); + + /// + /// Posts the specified data to a url and returns the response as string. + /// + public static async Task Post(this WebClient webClient, string url, Dictionary postData, Encoding responseEncoding) + { + if (responseEncoding == null) + throw new ArgumentNullException(nameof(responseEncoding)); + + if (postData == null) + throw new ArgumentNullException(nameof(postData)); + + if (url.IsEmpty()) + throw new ArgumentNullException(nameof(url)); + + var responseBytes = await webClient.UploadValuesTaskAsync(url, postData.ToNameValueCollection()); + + try + { + return responseEncoding.GetString(responseBytes); + } + catch (WebException ex) + { + throw new Exception(await ex.GetResponseBody()); + } + } + + public static string ToAuditDataHtml(this IApplicationEvent applicationEvent, bool excludeIds = false) + { + if (applicationEvent.Event == "Insert" && applicationEvent.Data.OrEmpty().StartsWith("")) + { + // return applicationEvent.Data.To().Elements().Select(p => $"
    {p.Name}: {p.Value.HtmlEncode()}
    ").ToLinesString(); + + var insertData = applicationEvent.Data.To().Elements().ToArray(); + + if (excludeIds) + insertData = insertData.Except(x => x.Name.LocalName.EndsWith("Id") && insertData.Select(p => p.Name.LocalName).Contains(x.Name.LocalName.TrimEnd("Id"))) + .Except(x => x.Name.LocalName.EndsWith("Ids") && insertData.Select(p => p.Name.LocalName).Contains(x.Name.LocalName.TrimEnd("Ids"))).ToArray(); + + return insertData.Select(p => $"
    {p.Name.LocalName.ToLiteralFromPascalCase()}: {p.Value.HtmlEncode()}
    ").ToLinesString(); + } + + if (applicationEvent.Event == "Update" && applicationEvent.Data.OrEmpty().StartsWith("")) + { + var data = applicationEvent.Data.To(); + var old = data.Element("old"); + var newData = data.Element("new"); + var propertyNames = old.Elements().Select(x => x.Name.LocalName) + .Concat(newData.Elements().Select(x => x.Name.LocalName)).Distinct().ToArray(); + + if (excludeIds) + propertyNames = propertyNames.Except(p => p.EndsWith("Id") && propertyNames.Contains(p.TrimEnd("Id"))) + .Except(p => p.EndsWith("Ids") && propertyNames.Contains(p.TrimEnd("Ids"))).ToArray(); + + return propertyNames.Select(p => $"
    Changed {p.ToLiteralFromPascalCase()} from \"{ old.GetValue(p).HtmlEncode() }\" to \"{ newData.GetValue(p).HtmlEncode() }\"
    ").ToLinesString(); + } + + if (applicationEvent.Event == "Delete" && applicationEvent.Data.OrEmpty().StartsWith("")) + { + var data = applicationEvent.Data.To(); + var old = data.Element("old"); + + var propertyNames = old.Elements().Select(x => x.Name.LocalName).ToArray(); + + if (excludeIds) + propertyNames = propertyNames.Except(p => p.EndsWith("Id") && propertyNames.Contains(p.TrimEnd("Id"))) + .Except(p => p.EndsWith("Ids") && propertyNames.Contains(p.TrimEnd("Ids"))).ToArray(); + + return propertyNames.Select(p => $"
    {p.ToLiteralFromPascalCase()} was \"{old.GetValue(p).HtmlEncode() }\"
    ").ToLinesString(); + } + + return applicationEvent.Data.OrEmpty().HtmlEncode(); + } + } +} diff --git a/Olive.Web/Extensions/Exception.cs b/Olive.Web/Extensions/Exception.cs new file mode 100644 index 000000000..6a165336e --- /dev/null +++ b/Olive.Web/Extensions/Exception.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace Olive.Web +{ + partial class OliveExtensions + { + /// + /// Returns a more complete text dump of this exception, than just its text. + /// + public static string ToFullMessage(this Exception error, string additionalMessage, bool includeStackTrace, bool includeData) + { + if (error == null) + throw new NullReferenceException("This exception object is null"); + var resultBuilder = new StringBuilder(); + resultBuilder.AppendLineIf(additionalMessage, additionalMessage.HasValue()); + var err = error; + while (err != null) + { + resultBuilder.AppendLine(err.Message); + if (includeData && err.Data != null) + { + resultBuilder.AppendLine("\r\nException Data:\r\n{"); + foreach (var i in err.Data) + resultBuilder.AppendLine(Olive.OliveExtensions.ToLogText(i).WithPrefix(" ")); + + resultBuilder.AppendLine("}"); + } + + if (err is ReflectionTypeLoadException) + { + foreach (var loaderEx in (err as ReflectionTypeLoadException).LoaderExceptions) + resultBuilder.AppendLine("Type load exception: " + loaderEx.ToFullMessage()); + } + + // try + // { + // resultBuilder.AppendLineIf((err as HttpUnhandledException)?.GetHtmlErrorMessage().TrimBefore("Server Error")); + // } + // catch + // { + // // No logging is needed + // } + + err = err.InnerException; + if (err != null) + { + resultBuilder.AppendLine(); + if (includeStackTrace) + resultBuilder.AppendLine("###############################################"); + resultBuilder.Append("Base issue: "); + } + } + + if (includeStackTrace && error.StackTrace.HasValue()) + { + var stackLines = error.StackTrace.Or("").Trim().ToLines(); + stackLines = stackLines.Except(l => l.Trim().StartsWith("at System.Data.")).ToArray(); + resultBuilder.AppendLine(stackLines.ToString("\r\n\r\n").WithPrefix("\r\n--------------------------------------\r\nSTACK TRACE:\r\n\r\n")); + } + + return resultBuilder.ToString(); + } + + public static async Task GetResponseBody(this WebException ex) + { + if (ex.Response == null) return null; + + using (var reader = new StreamReader(ex.Response.GetResponseStream())) + return await reader.ReadToEndAsync(); + } + } +} diff --git a/Olive.Web/Olive.Web.csproj b/Olive.Web/Olive.Web.csproj new file mode 100644 index 000000000..c784347ad --- /dev/null +++ b/Olive.Web/Olive.Web.csproj @@ -0,0 +1,25 @@ + + + + netcoreapp2.0 + Olive.Web + Olive.Web + + + + ..\@Assemblies\ + ..\@Assemblies\netcoreapp2.0\Olive.Web.xml + 1701;1702;1705;1591;1573 + + + + + + + + + + + + + \ No newline at end of file diff --git a/Olive.Web/Package.nuspec b/Olive.Web/Package.nuspec new file mode 100644 index 000000000..6623780c7 --- /dev/null +++ b/Olive.Web/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.Web + 1.0.3 + Olive Web + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Olive.Web/SystemExtensions/Common.cs b/Olive.Web/SystemExtensions/Common.cs new file mode 100644 index 000000000..5c2436b65 --- /dev/null +++ b/Olive.Web/SystemExtensions/Common.cs @@ -0,0 +1,567 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using System.Web; +using Microsoft.AspNetCore.Http; +using Olive.Entities; + +namespace Olive.Web +{ + public static partial class OliveExtensions + { + const int HTTP_PORT_NUMBER = 80; + + const int HTTPS_PORT_NUMBER = 443; + + const int MOVED_PERMANENTLY_STATUS_CODE = 301; + + const int DEFAULT_DOWNLOAD_TIMEOUT = 60000; + + static readonly Range[] PrivateIpRanges = new[] { + //new Range(0u, 50331647u), // 0.0.0.0 to 2.255.255.255 + new Range(167772160u, 184549375u), // 10.0.0.0 to 10.255.255.255 + new Range(2130706432u, 2147483647u), // 127.0.0.0 to 127.255.255.255 + new Range(2851995648u, 2852061183u), // 169.254.0.0 to 169.254.255.255 + new Range(2886729728u, 2887778303u), // 172.16.0.0 to 172.31.255.255 + new Range(3221225984u, 3221226239u), // 192.0.2.0 to 192.0.2.255 + new Range(3232235520u, 3232301055u), // 192.168.0.0 to 192.168.255.255 + new Range(4294967040u, 4294967295u) // 255.255.255.0 to 255.255.255.255 + }; + + /// + /// Adds the specified query string setting to this Url. + /// + public static Uri AddQueryString(this Uri url, string key, string value) + { + var qs = url.GetQueryString(); + + qs.RemoveWhere(x => x.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); + + qs.Add(key, value); + + return url.ReplaceQueryString(qs); + } + + /// + /// Gets the query string parameters of this Url. + /// + public static Dictionary GetQueryString(this Uri url) + { + var entries = System.Web.HttpUtility.ParseQueryString(url.Query); + return entries.AllKeys.ExceptNull().ToDictionary(a => a.ToLower(), a => entries[a]); + } + + #region Request.Get + + /// + /// Returns an object whose ID is given in query string with the key of "id". + /// + public static async Task Get(this HttpRequest request) where T : class, IEntity => await Get(request, "id"); + + /// + /// Gets the cookies sent by the client. + /// + public static IEnumerable> GetCookies(this HttpRequest request) + { + if (request.Cookies == null) return Enumerable.Empty>(); + + return request.Cookies.AsEnumerable(); + } + + /// + /// Gets the data with the specified type from QueryString[key]. + /// If the specified type is an entity, then the ID of that record will be read from query string and then fetched from database. + /// + public static async Task Get(this HttpRequest request, string key) => await DoGet(request, key, throwIfNotFound: true); + + static async Task DoGet(this HttpRequest request, string key, bool throwIfNotFound) + { + if (typeof(T).Implements()) + return await GetEntity(request, key, throwIfNotFound); + + else if (typeof(T) == typeof(string)) + return (T)(object)GetValue(request, key, ignoreRouteData: false); + + else if (typeof(T).IsValueType) + return GetValue(request, key); + + else throw new Exception("Request.Get() does not recognize the type of " + typeof(T).FullName); + } + + /// + /// Returns a string value specified in the request context. + /// + public static string GetValue(this HttpRequest request, string key, bool ignoreRouteData = true) + { + if (Context.ActionContextAccessor.ActionContext.HttpContext.Request != request) + throw new Exception("The given request is not match with ActionContext`s request."); + + return + request.Cookies[key].OrNullIfEmpty() ?? + (request.HasFormContentType ? request.Form[key].ToString().OrNullIfEmpty() : null) ?? + request.Query[key].ToString().OrNullIfEmpty() ?? + request.Headers[key].ToString().OrNullIfEmpty() ?? + (ignoreRouteData ? null : Context.ActionContextAccessor.ActionContext.RouteData.Values[key].ToStringOrEmpty()); + } + + /// + /// Returns a value specified in the request context. + /// + static T GetValue(this HttpRequest request, string key) + { + var data = GetValue(request, key, ignoreRouteData: false); + + if (data.IsEmpty()) + { + if (typeof(T).IsNullable() || typeof(T) == typeof(string)) return default(T); + else throw new Exception($"Request does not contain a value for '{key}'"); + } + + return data.To(); + } + + /// + /// Gets the record with the specified type. The ID of the record will be read from QueryString[key]. + /// + static async Task GetEntity(this HttpRequest request, string key, bool throwIfNotFound = true) + { + if (request == null) + { + if (Context.Http != null) + request = Context.Request; + else + throw new InvalidOperationException("Request.Get() can only be called inside an Http context."); + } + + if (key == ".") key = "." + typeof(T).Name; + + var value = request.GetValue(key); + if (value.IsEmpty()) return default(T); + + try { return (T)await Entity.Database.Get(value, typeof(T)); } + catch (Exception ex) + { + if (throwIfNotFound) + throw new InvalidOperationException($"Loading a {typeof(T).FullName} from the page argument of '{key}' failed.", ex); + else + return default(T); + } + } + + #endregion + + #region Request.GetOrDefault + + /// + /// Gets the record with the specified type. The ID of the record will be read from QueryString["id"]. + /// + public static async Task GetOrDefault(this HttpRequest request) => await GetOrDefault(request, "id"); + + /// + /// Gets the record with the specified type. The ID of the record will be read from QueryString[key]. + /// + public static async Task GetOrDefault(this HttpRequest request, string key) + { + if (request == null) + { + if (Context.Http != null) + request = Context.Request; + else + throw new InvalidOperationException("Request.GetOrDefault() can only be called inside an Http context."); + } + + if (key == ".") key = "." + typeof(T).Name; + + if (!request.Has(key)) return default(T); + + try { return await request.DoGet(key, throwIfNotFound: false); } + catch + { + // No Loging needed + return default(T); + } + } + + #endregion + + public static IEnumerable> GetList(this HttpRequest request, string key) where T : class, IEntity => GetList(request, key, ','); + + /// + /// Gets a list of objects of which Ids come in query string. + /// + /// The key of the query string element containing ids. + /// The sepeerator of Ids in the query string value. The default will be comma (","). + public static IEnumerable> GetList(this HttpRequest request, string key, char seperator) where T : class, IEntity + { + var ids = request.GetValue(key); + if (ids.IsEmpty()) + yield break; + else + foreach (var id in ids.Split(seperator)) + yield return Entity.Database.Get(id); + } + + /// + /// Finds the search keywords used by this user on Google that led to the current request. + /// + public static string FindSearchKeyword(this HttpRequest request) + { + var urlReferrer = request.Headers["Referer"].ToString(); + if (urlReferrer.IsEmpty()) return null; + + // Note: Only Google is supported for now: + + if (!urlReferrer.ToLower().Contains(".google.co")) + return null; + + foreach (var possibleQuerystringKey in new[] { "q", "as_q" }) + { + var queryString = urlReferrer.Split('?').Skip(1).FirstOrDefault(); + var query = queryString.TrimStart("?").Split('&').Trim(). + FirstOrDefault(p => p.StartsWith(possibleQuerystringKey + "=")); + + if (query.HasValue()) + return HttpUtility.UrlDecode(query.Substring(1 + possibleQuerystringKey.Length)); + } + + return null; + } + + /// + /// Gets the actual IP address of the user considering the Proxy and other HTTP elements. + /// + public static string GetIPAddress(this HttpRequest request) => request.HttpContext.Connection.RemoteIpAddress.ToString(); + + #region Private IPs + + /// + /// Determines if the given ip address is in any of the private IP ranges + /// + public static bool IsPrivateIp(string address) + { + if (address.IsEmpty()) return false; + + var bytes = IPAddress.Parse(address).GetAddressBytes(); + if (BitConverter.IsLittleEndian) + bytes = bytes.Reverse().ToArray(); + + var ip = BitConverter.ToUInt32(bytes, 0); + + return PrivateIpRanges.Any(range => range.Contains(ip)); + } + + #endregion + + /// + /// Writes the specified content wrapped in a DIV tag. + /// + public static void WriteLine(this HttpResponse response, string content) => + response.WriteAsync($"
    {content}
    ").RunSynchronously(); + + /// + /// Redirects the client to the specified URL with a 301 status (permanent). + /// + public static void RedirectPermanent(this HttpResponse response, string permanentUrl) + { + response.StatusCode = MOVED_PERMANENTLY_STATUS_CODE; + response.Headers.Add("Location", permanentUrl); + } + + /// + /// Removes the specified query string parameter. + /// + public static Uri RemoveEmptyQueryParameters(this Uri url) + { + var toRemove = url.GetQueryString().Where(x => x.Value.IsEmpty()).ToList(); + + foreach (var item in toRemove) url = url.RemoveQueryString(item.Key); + + return url; + } + + /// + /// Removes the specified query string parameter. + /// + public static Uri RemoveQueryString(this Uri url, string key) + { + var qs = url.GetQueryString(); + key = key.ToLower(); + if (qs.ContainsKey(key)) qs.Remove(key); + + return url.ReplaceQueryString(qs); + } + + /// + /// Removes all query string parameters of this Url and instead adds the specified ones. + /// + public static Uri ReplaceQueryString(this Uri baseUrl, Dictionary queryStringDictionary) + { + var r = new StringBuilder(); + + r.Append(baseUrl.Scheme); + + r.Append("://"); + + r.Append(baseUrl.Host); + + if (baseUrl.Port != HTTP_PORT_NUMBER && baseUrl.Port != HTTPS_PORT_NUMBER) r.Append(":" + baseUrl.Port); + + r.Append(baseUrl.AbsolutePath); + + var query = queryStringDictionary.Select(a => "{0}={1}".FormatWith(a.Key, a.Value.UrlEncode())).ToString("&"); + + if (query.HasValue()) + { + r.Append("?"); + r.Append(query); + } + + return new Uri(r.ToString()); + } + + /// + /// Adds the specified list to session state and returns a unique Key for that. + /// + public static string AddList(this ISession session, IEnumerable list) where T : IEntity => + AddList(session, list, TimeSpan.FromHours(1)); + + /// + /// Adds the specified list to session state and returns a unique Key for that. + /// + public static string AddList(this ISession session, IEnumerable list, TimeSpan timeout) where T : IEntity + { + var expiryDate = DateTime.Now.Add(timeout); + + var key = "L|" + Guid.NewGuid() + "|" + expiryDate.ToOADate(); + session.SetString(key, list.Where(x => x != null).Select(a => a.GetId()).ToString("|").Or(string.Empty)); + + var expiredKeys = session.Keys.Where(k => k.StartsWith("L|") && k.Split('|').Length == 3 && DateTime.FromOADate(k.Split('|').Last().To()) < DateTime.Now).ToArray(); + expiredKeys.Do(k => session.Remove(k)); + + return key; + } + + /// + /// Retrieves a list of objects specified by the session key which is previously generated by Session.AddList() method. + /// + public static async Task> GetList(this ISession session, string key) where T : Entity + { + if (key.IsEmpty()) + throw new ArgumentNullException(nameof(key)); + + if (key.Split('|').Length != 3) + throw new ArgumentException("Invalid list key specified. Bar character is expected."); + + var date = key.Split('|').Last().TryParseAs(); + + if (date == null) + throw new ArgumentException("Invalid list key specified. Data after Bar character should be OADate."); + + var ids = session.GetString(key); + if (ids == null) + throw new TimeoutException($"The list with the key {key} is expired and removed from the session."); + + return (await ids.Split('|').Select(async i => await Entity.Database.GetOrDefault(i)).AwaitAll()).ExceptNull(); + } + + /// + /// Runs the parallel select in the current HTTP context. + /// + public static ParallelQuery SelectInHttpContext(this ParallelQuery list, Func selector) + { + var httpContext = Context.HttpContextAccessor.HttpContext; + + return list.Select(x => { Context.HttpContextAccessor.HttpContext = httpContext; return selector(x); }); + } + + /// + /// Determines if the specified argument exists in the request (query string or form). + /// + public static bool Has(this HttpRequest request, string argument) + { + return request.Query.Keys.Contains(argument) || (request.HasFormContentType && request.Form.Keys.Contains(argument)); + } + + /// + /// Determines if the specified argument not exists in the request (query string or form). + /// + public static bool Lacks(this HttpRequest request, string argument) => !request.Has(argument); + + /// + /// Gets the root of the requested website. + /// + public static string GetWebsiteRoot(this HttpRequest request) => $"{request.Scheme}://{request.Host}/"; + + /// + /// Gets the raw url of the request. + /// + public static string ToRawUrl(this HttpRequest request) => + $"{request.PathBase}{request.Path}{request.QueryString}"; + + public static string ToPathAndQuery(this HttpRequest request) => + $"{request.Path}{request.QueryString}"; + + /// + /// Gets the absolute Uri of the request. + /// + public static string ToAbsoluteUri(this HttpRequest request) => + $"{request.GetWebsiteRoot().TrimEnd('/')}{request.PathBase}{request.Path}{request.QueryString}"; + + /// + /// Gets the absolute URL for a specified relative url. + /// + public static string GetAbsoluteUrl(this HttpRequest request, string relativeUrl) => + request.GetWebsiteRoot() + relativeUrl.TrimStart("/"); + + /// + /// Downloads the text in this URL. + /// + public static async Task Download(this Uri url, string cookieValue = null, int timeOut = DEFAULT_DOWNLOAD_TIMEOUT) + { + var request = (HttpWebRequest)WebRequest.Create(url); + + request.Timeout = timeOut; + + if (cookieValue.HasValue()) + { + request.CookieContainer = new CookieContainer(); + request.CookieContainer.SetCookies(url, cookieValue.OrEmpty()); + } + + using (var response = await request.GetResponseAsync()) + { + using (var stream = response.GetResponseStream()) + return await stream.ReadAllText(); + } + } + + /// + /// Downloads the data in this URL. + /// + public static async Task DownloadData(this Uri url, string cookieValue = null, int timeOut = DEFAULT_DOWNLOAD_TIMEOUT) + { + var request = (HttpWebRequest)HttpWebRequest.Create(url); + + request.Timeout = timeOut; + + if (cookieValue.HasValue()) + { + request.CookieContainer = new CookieContainer(); + request.CookieContainer.SetCookies(url, cookieValue.OrEmpty()); + } + + using (var response = await request.GetResponseAsync()) + { + using (var stream = response.GetResponseStream()) + return await stream.ReadAllBytes(); + } + } + + /// + /// Downloads the data in this URL. + /// + public static async Task DownloadBlob(this Uri url, string cookieValue = null, int timeOut = DEFAULT_DOWNLOAD_TIMEOUT) + { + var fileName = "File.Unknown"; + + if (url.IsFile) + fileName = url.ToString().Split('/').Last(); + + return new Blob(await url.DownloadData(cookieValue, timeOut), fileName); + } + + /// + /// Reads all text in this stream as UTF8. + /// + public static async Task ReadAllText(this Stream response) + { + string result = ""; + + // Pipes the stream to a higher level stream reader with the required encoding format. + using (var readStream = new StreamReader(response, Encoding.UTF8)) + { + var read = new char[256]; + // Reads 256 characters at a time. + int count = await readStream.ReadAsync(read, 0, read.Length); + + while (count > 0) + { + // Dumps the 256 characters on a string and displays the string to the console. + result += new string(read, 0, count); + + count = await readStream.ReadAsync(read, 0, read.Length); + } + } + + return result; + } + + /// + /// Gets the Html Encoded version of this text. + /// + public static string HtmlEncode(this string text) + { + if (text.IsEmpty()) return string.Empty; + + return HttpUtility.HtmlEncode(text); + } + + /// + /// Gets the Html Decoded version of this text. + /// + public static string HtmlDecode(this string text) + { + if (text.IsEmpty()) return string.Empty; + + return HttpUtility.HtmlDecode(text); + } + + /// + /// Gets the Url Encoded version of this text. + /// + public static string UrlEncode(this string text) + { + if (text.IsEmpty()) return string.Empty; + + return HttpUtility.UrlEncode(text); + } + + /// + /// Gets the Url Decoded version of this text. + /// + public static string UrlDecode(this string text) + { + if (text.IsEmpty()) return string.Empty; + + return HttpUtility.UrlDecode(text); + } + + /// + /// Properly sets a query string key value in this Uri, returning a new Uri object. + /// + public static Uri SetQueryString(this Uri uri, string key, object value) + { + var valueString = string.Empty; + + if (value != null) + { + if (value is IEntity) + valueString = (value as IEntity).GetId().ToString(); + else + valueString = value.ToString(); + } + + var pairs = HttpUtility.ParseQueryString(uri.Query); + + pairs[key] = valueString; + + var builder = new UriBuilder(uri) { Query = pairs.ToString() }; + + return builder.Uri; + } + } +} \ No newline at end of file diff --git a/Olive.Web/SystemExtensions/Http.cs b/Olive.Web/SystemExtensions/Http.cs new file mode 100644 index 000000000..792e1f84b --- /dev/null +++ b/Olive.Web/SystemExtensions/Http.cs @@ -0,0 +1,149 @@ +using System; +using System.IO; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Olive.Entities; + +namespace Olive.Web +{ + partial class OliveExtensions + { + /// + /// Determines whether this is an Ajax call. + /// + public static bool IsAjaxRequest(this HttpRequest request) => request.IsAjaxCall(); + + /// + /// Determines whether this is an Ajax call. + /// + public static bool IsAjaxCall(this HttpRequest request) => request.Headers["X-Requested-With"] == "XMLHttpRequest"; + + /// + /// Dispatches a binary data block back to the client. + /// + public static async Task Dispatch(this HttpResponse response, byte[] responseData, string fileName, string contentType = "Application/octet-stream") + { + if (responseData == null) + throw new ArgumentNullException(nameof(responseData)); + + if (fileName.IsEmpty()) + throw new ArgumentNullException(nameof(fileName)); + + response.Clear(); + response.ContentType = contentType; + + response.Headers.Add("Content-Disposition", $"attachment; filename=\"{fileName.Remove("\"").Replace(",", "-")}\""); + + await response.Body.WriteAsync(responseData, 0, responseData.Length); + await response.Body.FlushAsync(); + } + + /// + /// Dispatches a string back to the client as a file. + /// + public static void Dispatch(this HttpResponse response, string responseText, string fileName, string contentType = "Application/octet-stream", System.Text.Encoding encoding = null) + { + response.Clear(); + + response.Headers.Add("Cache-Control", "no-store"); + response.Headers.Add("Pragma", "no-cache"); + + if (fileName.HasValue()) + response.Headers.Add("Content-Disposition", $"attachment;filename={fileName.Replace(" ", "_")}"); + + response.ContentType = contentType; + + if (encoding != null) + response.WriteAsync(responseText, encoding).RunSynchronously(); + else + response.WriteAsync(responseText).RunSynchronously(); + } + + /// + /// Dispatches a file back to the client. + /// + /// If set to null, the same file name of the file will be used. + public static async Task Dispatch(this HttpResponse response, FileInfo responseFile, string fileName = null, string contentType = "Application/octet-stream") + { + if (responseFile == null) + throw new ArgumentNullException(nameof(responseFile)); + + if (fileName.IsEmpty()) + fileName = responseFile.Name; + + await Dispatch(response, await responseFile.ReadAllBytes(), fileName, contentType); + } + + /// + /// Dispatches a file back to the client. + /// + public static async Task Dispatch(this HttpResponse response, Blob blob, string contentType = "Application/octet-stream") + { + await Dispatch(response, await File.ReadAllBytesAsync(blob.LocalPath), blob.FileName, contentType); + } + + /// + /// Determines if this is a GET http request. + /// + public static bool IsGet(this HttpRequest request) => request.Method == System.Net.WebRequestMethods.Http.Get; + + /// + /// Determines if this is a POST http request. + /// + public static bool IsPost(this HttpRequest request) => request.Method == System.Net.WebRequestMethods.Http.Post; + + /// + /// Gets the currently specified return URL. + /// + public static string GetReturnUrl(this HttpRequest request) + { + var result = request.GetValue("ReturnUrl"); + + if (result.IsEmpty()) return string.Empty; + + if (result.StartsWith("http", StringComparison.OrdinalIgnoreCase) || + result.ToCharArray().ContainsAny('\'', '\"', '>', '<') || + result.ContainsAny(new[] { "//", ":" }, caseSensitive: false)) + throw new Exception("Invalid ReturnUrl."); + + return result; + } + + /// + /// Writes the specified message in the response and then ends the response. + /// + public static void EndWith(this HttpResponse response, string message, string mimeType = "text/html") + { + response.ContentType = mimeType; + response.WriteAsync(message).RunSynchronously(); + } + + /// + /// Reads the full content of a posted text file. + /// + public static async Task ReadAllText(this IFormFile file) + { + using (var reader = new StreamReader(file.OpenReadStream())) + return await reader.ReadToEndAsync(); + } + + public static bool IsLocal(this HttpRequest req) + { + var connection = req.HttpContext.Connection; + if (connection.RemoteIpAddress != null) + { + if (connection.LocalIpAddress != null) + return connection.RemoteIpAddress.Equals(connection.LocalIpAddress); + else + return IPAddress.IsLoopback(connection.RemoteIpAddress); + } + + // for in memory TestServer or when dealing with default connection info + if (connection.RemoteIpAddress == null && connection.LocalIpAddress == null) + return true; + + return false; + } + } +} \ No newline at end of file diff --git a/Olive.Web/UrlRewriting/IWebResource.cs b/Olive.Web/UrlRewriting/IWebResource.cs new file mode 100644 index 000000000..a20eb62f3 --- /dev/null +++ b/Olive.Web/UrlRewriting/IWebResource.cs @@ -0,0 +1,9 @@ +using Olive.Entities; + +namespace Olive.Web +{ + public interface IWebResource : IEntity + { + string GetUrl(); + } +} diff --git a/Olive.Web/UrlRewriting/UrlRewriting.cs b/Olive.Web/UrlRewriting/UrlRewriting.cs new file mode 100644 index 000000000..16f52cb76 --- /dev/null +++ b/Olive.Web/UrlRewriting/UrlRewriting.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Olive.Entities; + +namespace Olive.Web +{ + public static class UrlRewriting + { + static Dictionary> RewritingMapping = new Dictionary>(); + static Dictionary> ResourceLoaders = new Dictionary>(); + + /// + /// Creates a suitable string for urls. + /// + public static string Escape(string text) + { + if (text.IsEmpty()) return string.Empty; + + var r = new StringBuilder(); + + r.Append(string.Empty); + + foreach (var c in text) + { + if (c.IsLetterOrDigit()) r.Append(c); + switch (c) + { + case '-': + case '_': r.Append(c); break; + case ' ': r.Append('-'); break; + case '&': r.Append("-and-"); break; + default: break; // Keep it + } + } + + var result = r.ToString().Trim("-_".ToCharArray()); + + while (result.Contains("__")) + result = result.Replace("__", "_"); + + while (result.Contains("--")) + result = result.Replace("--", "-"); + + return result; + } + + public static void RegisterLoader(Func loaderMethod) => ResourceLoaders.Add(typeof(T), loaderMethod); + + static Func GetMapper(Type type) + { + for (var parent = type; parent != null; parent = parent.BaseType) + if (RewritingMapping.ContainsKey(type)) + return RewritingMapping[type]; + + throw new Exception("No Url Rewrite mapping has been specified for the type " + type.FullName); + } + + public static void Register(Func mapping) where T : IWebResource + { + if (!RewritingMapping.ContainsKey(typeof(T))) + RewritingMapping.Add(typeof(T), r => mapping?.Invoke((T)r)); + } + + public static string GetExecutionPath(IWebResource resource) + { + if (resource == null) + throw new ArgumentNullException(nameof(resource)); + + var path = GetMapper(resource.GetType())(resource); + + var queryString = Context.HttpContextAccessor.HttpContext.Request.Query; + if (queryString.Keys.Any()) + { + foreach (var key in queryString.Keys) + { + if (path.Contains(key + "=")) continue; + + if (!path.Contains("?")) path += "?"; + else if (!path.EndsWith("&")) path += "&"; + path += key + "=" + queryString[key]; + } + } + + return path; + } + + /// + /// Gets the Currently requested resource. + /// + public static async Task FindRequestedResource() => await FindRequestedResource(ignoreDomain: true); + + /// + /// Gets the Currently requested resource. + /// + public static async Task FindRequestedResource(bool ignoreDomain) + { + var path = Context.HttpContextAccessor.HttpContext.Request.Path.ToString(); + + if (!ignoreDomain) + { + if (path.StartsWith("/")) path = path.Substring(1); + path = Context.HttpContextAccessor.HttpContext.Request.GetWebsiteRoot() + path; + } + + IWebResource result; + + foreach (var type in RewritingMapping.Keys) + { + if (ResourceLoaders.ContainsKey(type)) + result = ResourceLoaders[type](path); + else + result = (await Entity.Database.Of(type).GetList()).OfType().FirstOrDefault(r => r.GetUrl().Equals(path, StringComparison.OrdinalIgnoreCase)); + + if (result != null) return result; + } + + return null; + } + + /// + /// Determines if this web resource's Url matches a given path. + /// + public static bool Matches(this IWebResource resource, string path) => + resource.GetUrl().Equals(path, StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/Olive.Web/Web/CookieProperty.cs b/Olive.Web/Web/CookieProperty.cs new file mode 100644 index 000000000..78f38c94a --- /dev/null +++ b/Olive.Web/Web/CookieProperty.cs @@ -0,0 +1,194 @@ +namespace Olive.Web +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; + using Olive.Entities; + + /// + /// Provides easy access to HTTP cookie data. + /// + public class CookieProperty + { + const string BAR_SCAPE = "[#*^BAR_SCAPE^*#]"; + + /// + /// Gets the value of the property sent from the client browser as a cookie. + /// + public static async Task Get() => await Get(null, default(T)); + + /// + /// Gets the value of a string property sent from the client browser as a cookie. + /// + public static async Task Get(string key) => await Get(key, null); + + /// + /// Gets the value of the property sent from the client browser as a cookie. + /// + public static async Task Get(T defaultValue) => await Get(null, defaultValue); + + /// + /// Gets the value of the property sent from the client browser as a cookie. + /// + public static async Task Get(string propertyName) => await Get(propertyName, default(T)); + + public static async Task> GetStrings(string propertyName) => + await Get>(propertyName, null); + + /// + /// Gets the value of the property sent from the client browser as a cookie. + /// + public static async Task Get(string propertyName, T defaultValue) + { + var key = propertyName.Or("Default.Value.For." + typeof(T).FullName); + + var value = Context.HttpContextAccessor.HttpContext.Request.Cookies[key]; + + if (!Context.HttpContextAccessor.HttpContext.Request.Cookies.ContainsKey(key)) + { + return defaultValue; + } + else if (typeof(T).Implements()) + { + var id = value.Contains('/') ? value.Split('/')[1] : value; // Remove class name prefix if exists + return (T)await Entity.Database.GetOrDefault(id, typeof(T)); + } + else if (typeof(T) == typeof(string)) + { + return (T)(object)value; + } + else if (typeof(T) == typeof(IEnumerable) || typeof(T) == typeof(string[])) + { + return (T)(object)value.Or("").Split('|').Trim().Select(p => p.Replace(BAR_SCAPE, "|")).ToArray(); + } + else if (typeof(T).Namespace.StartsWith("System")) + { + return (T)value.To(typeof(T)); + } + + throw new Exception("CookieProperty.Get() does not support T type of " + typeof(T).FullName); + } + + /// + /// Sets a specified value in the response cookie as well as request cookie. + /// + /// Specifies whether the cookie should be accessible via Javascript too, or Server (http) only. + public static void Set(T value, bool isHttpOnly = true) => Set(null, value, isHttpOnly); + + /// + /// Sets a specified value in the response cookie as well as request cookie. + /// + /// Specifies whether the cookie should be accessible via Javascript too, or Server (http) only. + public static void Set(string propertyName, T value, bool isHttpOnly = true) + { + var key = propertyName.Or("Default.Value.For." + typeof(T).FullName); + + var stringValue = value?.ToString(); + + if (value is IEntity) stringValue = (value as IEntity).GetFullIdentifierString(); + Set(key, stringValue, isHttpOnly); + } + + /// + /// Sets a specified list in the response cookie as well as request cookie. + /// + /// Specifies whether the cookie should be accessible via Javascript too, or Server (http) only. + public static void SetList(string propertyName, IEnumerable list, bool isHttpOnly = true) where T : IEntity + { + var key = propertyName.Or("Default.List.For." + typeof(T).FullName); + + if (list == null) + { + Set(key, string.Empty, isHttpOnly); + } + else + { + var stringValue = list.Except(n => n == null).Select(i => i.GetFullIdentifierString()).ToString("|"); + Set(key, stringValue, isHttpOnly); + } + } + + /// + /// Sets a specified list in the response cookie as well as request cookie. + /// + public static async Task> GetList() where T : IEntity => await GetList(null); + + /// + /// Gets a specified list in the response cookie as well as request cookie. + /// + public static async Task GetList(string propertyName) where T : IEntity + { + var key = propertyName.Or("Default.List.For." + typeof(T).FullName); + + var result = await Get(key); + if (result.IsEmpty()) return new T[0]; + + return result.Split('|').Select(x => ExtractItem(x)).Except(n => n == null).ToArray(); + } + + static T ExtractItem(string valueExpression) where T : IEntity + { + var id = valueExpression.Contains('/') ? valueExpression.Split('/')[1] : valueExpression; // Remove class name prefix if exists + return (T)(object)Entity.Database.GetOrDefault(id, typeof(T)); + } + + /// + /// Removes the specified cookie property. + /// + public static void Remove() => Set(default(T)); + + /// + /// Removes the specified cookie property. + /// + public static void Remove(string propertyName) => Set(propertyName, default(T)); + + /// + /// Removes the specified cookie property. + /// + public static void Remove(string propertyName) + { + var cookies = Context.HttpContextAccessor.HttpContext?.Response?.Cookies; + + if (cookies == null) return; + + cookies.Delete(propertyName); + } + + /// + /// Sets a specified value in the response cookie as well as request cookie. + /// + /// Specifies whether the cookie should be accessible via Javascript too, or Server (http) only. + public static void Set(string propertyName, IEnumerable strings, bool isHttpOnly = true) + { + strings = strings ?? new string[0]; + Set(propertyName, strings.Trim().Select(s => s.Replace("|", BAR_SCAPE)).ToString("|"), isHttpOnly); + } + + /// + /// Sets a specified value in the response cookie as well as request cookie. + /// + /// Specifies whether the cookie should be accessible via Javascript too, or Server (http) only. + public static void Set(string key, string value, bool isHttpOnly = true) + { + if (key.IsEmpty()) + throw new ArgumentNullException(nameof(key)); + + var cookies = Context.HttpContextAccessor.HttpContext?.Response?.Cookies; + + if (cookies == null) return; + + cookies.Append( + key, + value, + new CookieOptions + { + HttpOnly = isHttpOnly, + Expires = DateTime.Now.AddYears(10), + Secure = Context.HttpContextAccessor.HttpContext.Request.IsHttps + } + ); + } + } +} \ No newline at end of file diff --git a/Olive.Web/Web/HttpContextCache.cs b/Olive.Web/Web/HttpContextCache.cs new file mode 100644 index 000000000..c944a6ae0 --- /dev/null +++ b/Olive.Web/Web/HttpContextCache.cs @@ -0,0 +1,46 @@ +namespace Olive.Web +{ + using System; + + /// + /// Provides a HttpRequest level cache of objects. + /// + public static class HttpContextCache + { + /// + /// Gets a specified cached value from the current HttpContext. + /// If it doesn't exist, it will evaluate the provider expression to produce the value, adds it to cache, and returns it. + /// + public static TValue GetOrAdd(TKey key, Func valueProducer) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + if (valueProducer == null) throw new ArgumentNullException(nameof(valueProducer)); + + var bag = Context.HttpContextAccessor.HttpContext?.Items; + if (bag == null) return valueProducer(); + + if (bag.ContainsKey(key)) return (TValue)bag[key]; + + var value = valueProducer(); + + if (!bag.ContainsKey(key)) bag[key] = value; + + return value; + } + + /// + /// Removes a specified cached object by its key from the current Http Context. + /// + public static void Remove(TKey key) + { + if (key == null) throw new ArgumentNullException("key"); + + var context = Context.HttpContextAccessor.HttpContext; + if (context == null) return; + + var bag = context.Items; + + if (bag.ContainsKey(key)) bag.Remove(key); + } + } +} \ No newline at end of file diff --git a/Olive.Web/Web/IWebRequestLog.cs b/Olive.Web/Web/IWebRequestLog.cs new file mode 100644 index 000000000..8b6daec16 --- /dev/null +++ b/Olive.Web/Web/IWebRequestLog.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; +using Olive.Entities; + +namespace Olive.Web +{ + [LogEvents(false), CacheObjects(false)] + public interface IWebRequestLog : IEntity + { + string IP { get; set; } + double ProcessingTime { get; set; } + string RequestType { get; set; } + int ResponseLength { get; set; } + string SearchKeywords { get; } + string SessionId { get; set; } + DateTime Start { get; set; } + string Url { get; set; } + string UrlReferer { get; set; } + string UserAgent { get; set; } + } + + public static class WebRequestLogExtensions + { + public static async Task CountSessionRequests(this IWebRequestLog request) + { + if (request.SessionId.IsEmpty()) return 1; + return await Entity.Database.Count(x => x.SessionId == request.SessionId); + } + + public static async Task GetLastVisitedUrl(this IWebRequestLog request) + { + if (request.SessionId.IsEmpty()) return request.Url; + + return (await Entity.Database.GetList(x => x.SessionId == request.SessionId)).WithMax(x => x.Start)?.Url; + } + + public static TimeSpan GetDuration(this IWebRequestLog request) => TimeSpan.FromMilliseconds(request.ProcessingTime); + + public static async Task IsBouncedBack(this IWebRequestLog request) => await request.CountSessionRequests() == 1; + } +} diff --git a/Olive.Web/Web/SecureFileDispatcher.cs b/Olive.Web/Web/SecureFileDispatcher.cs new file mode 100644 index 000000000..3a81a0f2d --- /dev/null +++ b/Olive.Web/Web/SecureFileDispatcher.cs @@ -0,0 +1,160 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Olive.Entities; + +namespace Olive.Web +{ + public class SecureFileDispatcher + { + public static readonly AsyncEvent UnauthorisedFileRequested = + new AsyncEvent(); + + string[] PathParts; + + Type Type; + object Instance; + string Property; + HttpResponse Response; + Blob Blob; + IUser CurrentUser; + + /// + /// Creates a new SecureFileDispatcher instance. + /// + public SecureFileDispatcher(string path, IUser currentUser) + { + CurrentUser = currentUser; + + Response = Context.HttpContextAccessor.HttpContext.Response; + + PathParts = path.Split('/'); + + if (PathParts.Length < 2) + { + throw new Exception($"Invalid path specified: '{path}'"); + } + + FindRequestedProperty(); + + FindRequestedObject(); + } + + public async Task Dispatch() => await DispatchFile(await GetFile()); + + public async Task GetFile() + { + await EnsureSecurity(); + + var file = Blob.LocalPath; + + // Fall-back logic + if (!File.Exists(file)) + file = Blob.FallbackPaths.FirstOrDefault(File.Exists); + + return file.AsFile(); + } + + void FindRequestedProperty() + { + var typeName = PathParts[0].Split('.')[0]; + + Type = Entity.Database.GetRegisteredAssemblies().Select(a => a.GetExportedTypes().SingleOrDefault(t => t.Name == typeName)).ExceptNull().FirstOrDefault(); + if (Type == null) throw new Exception($"Invalid type name specified: '{typeName}'"); + + Property = PathParts[0].Split('.')[1]; + } + + void FindRequestedObject() + { + var idData = PathParts[1]; + + foreach (var key in new[] { ".", "/" }) + if (idData.Contains(key)) idData = idData.Substring(0, idData.IndexOf(key)); + + var id = idData.TryParseAs(); + if (id == null) throw new Exception($"Invalid object ID specified: '{idData}'"); + + Instance = Entity.Database.Get(id.Value, Type); + if (Instance == null) throw new Exception($"Invalid {Type.FullName} ID specified: '{id}'"); + + Blob = EntityManager.ReadProperty(Instance, Property) as Blob; + } + + async Task EnsureSecurity() + { + try + { + var method = Type.GetMethod("Is" + Property + "VisibleTo", BindingFlags.Public | BindingFlags.Instance); + if (method == null) + { + throw new Exception(Type.FullName + ".Is" + Property + "VisibleTo() method is not defined."); + } + + if (method.GetParameters().Count() != 1 || !method.GetParameters().Single().ParameterType.Implements()) + throw new Exception(Type.FullName + "." + method.Name + "() doesn't accept a single argument that implements IUser"); + + var result = (Task)method.Invoke(Instance, new object[] { CurrentUser }); + if (!await result) + throw new Exception("You are not authorised to view the requested file."); + } + catch (Exception ex) + { + if (UnauthorisedFileRequested.IsHandled()) + { + await UnauthorisedFileRequested.Raise(new UnauthorisedRequestEventArgs + { + Exception = ex, + Instance = Instance as IEntity, + Property = Type.GetProperty(Property) + }); + } + else + { + Response.Clear(); + Response.WriteAsync("

    File access issue

    ").RunSynchronously(); + Log.Error("Invalid secure file access: " + PathParts.ToString("/"), ex); + + Response.WriteLine("Invalid file request. Please contact your I.T. support."); + Response.WriteLine(ex.Message); + } + } + } + + async Task DispatchFile(FileInfo file) + { + if (!file.Exists()) + { + Response.Clear(); + Response.WriteAsync("File does not exist: " + file).RunSynchronously(); + return; + } + + var fileName = Blob.FileName.Or(file.Name); + var contentType = file.Extension.OrEmpty().TrimStart(".").ToLower().Or("Application/octet-stream"); + + await Response.Dispatch(await file.ReadAllBytes(), fileName, contentType); + } + + public class UnauthorisedRequestEventArgs : EventArgs + { + /// + /// A property of type Blob which represents the requested file property. + /// + public PropertyInfo Property; + + /// + /// The object on which the blob property was requested. + /// + public IEntity Instance; + + /// + /// The security error raised by M# framework. + /// + public Exception Exception; + } + } +} \ No newline at end of file diff --git a/Olive.Web/Web/WebRequestLogMiddleware.cs b/Olive.Web/Web/WebRequestLogMiddleware.cs new file mode 100644 index 000000000..30e98646f --- /dev/null +++ b/Olive.Web/Web/WebRequestLogMiddleware.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using static Olive.Web.WebRequestLogService; + +namespace Olive.Web +{ + public class WebRequestLogMiddleware + { + readonly RequestDelegate Next; + readonly IHttpContextAccessor HttpContextAccessor; + + public WebRequestLogMiddleware(RequestDelegate next, IHttpContextAccessor httpContextAccessor) + { + Next = next; + HttpContextAccessor = httpContextAccessor; + } + + public async Task Invoke(HttpContext context) + { + CurrentRequestLog = WebRequestLogFactory?.Invoke(); + CurrentRequestLog.SessionId = context.Session.Id; + + await Next.Invoke(context); + + await CurrentRequestLog.Perform(async x => await x.Record(HttpContextAccessor.HttpContext)); + } + } + + public static class WebRequestLogMiddlewareExtension + { + public static IApplicationBuilder UseWebRequestLogMiddleware(this IApplicationBuilder builder) => + builder.UseMiddleware(); + } +} diff --git a/Olive.Web/Web/WebRequestLogService.cs b/Olive.Web/Web/WebRequestLogService.cs new file mode 100644 index 000000000..6893978e2 --- /dev/null +++ b/Olive.Web/Web/WebRequestLogService.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Web; +using Microsoft.AspNetCore.Http; +using Olive.Entities; + +namespace Olive.Web +{ + /// + /// Provides services to web request log objects. + /// + public static class WebRequestLogService + { + static bool IsEnabled = Config.Get("Enable.Request.Logging", defaultValue: false); + + #region Factory + + /// + /// Specifies a factory to instantiate WebRequestLog objects. + /// + public static Func WebRequestLogFactory = CreateWebRequestLog; + + static Type concreteWebRequestLogType; + static IWebRequestLog CreateWebRequestLog() + { + if (concreteWebRequestLogType != null) + { + var result = Activator.CreateInstance(concreteWebRequestLogType) as IWebRequestLog; + result.Start = LocalTime.Now; + return result; + } + + var possible = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => a.References(typeof(IWebRequestLog).Assembly)) + .SelectMany(a => a.GetExportedTypes().Where(t => t.Implements())).ToList(); + + if (possible.Count == 0) + throw new Exception("No type in the currently loaded assemblies implements IWebRequestLog."); + + if (possible.Count > 1) + throw new Exception("More than one type in the currently loaded assemblies implement IWebRequestLog:" + possible.Select(x => x.FullName).ToString(" and ")); + + concreteWebRequestLogType = possible.Single(); + return CreateWebRequestLog(); + } + + #endregion + + /// + /// Records this web request log in the provided http context. + /// + public static async Task Record(this IWebRequestLog log, HttpContext context) + { + try + { + await DoRecord(log, context); + } + catch (Exception ex) + { + Log.Error("Could not record Web Request Log", ex); + } + } + + static async Task DoRecord(IWebRequestLog log, HttpContext context) + { + log.ProcessingTime = LocalTime.Now.Subtract(log.Start).TotalMilliseconds; + + log.Url = context.Request.ToRawUrl(); + log.UserAgent = context.Request.Headers["User-Agent"].ToString(); + log.IP = context.Connection.RemoteIpAddress.ToString(); + log.RequestType = context.Request.Method; + log.UrlReferer = context.Request.Headers["Referer"].ToString(); + log.SessionId = context.Session?.Id; + + if (context.Response.ContentLength.HasValue) + log.ResponseLength = (int)(context.Response.ContentLength.Value / 1024); + + await Entity.Database.Save(log); + } + + public static string FindSearchKeywords(this IWebRequestLog log) + { + if (log.UrlReferer.IsEmpty()) return null; + + // var url = UrlReferer.ToLower(); + + if (!log.UrlReferer.ToLower().Contains(".google.co")) + return null; + + foreach (var possibleQuerystringKey in new[] { "q", "as_q" }) + { + var query = new Uri(log.UrlReferer).Query.TrimStart("?").OrEmpty().Split('&').Trim().FirstOrDefault(p => p.StartsWith(possibleQuerystringKey + "=")); + + if (query.HasValue()) + { + return HttpUtility.UrlDecode(query.Substring(1 + possibleQuerystringKey.Length)); + } + } + + return log.UrlReferer; + } + + /// + /// Gets the number of requests made in the same session. + /// + public static async Task CountRequestsInSession(this IWebRequestLog log) => + await Entity.Database.Count(r => r.SessionId == log.SessionId); + + /// + /// Gest the last url visited in this session. + /// + public static async Task GetLastVisitedUrl(this IWebRequestLog log) + { + return (await Entity.Database.GetList(r => r.SessionId == log.SessionId)).WithMax(r => r.Start).Url; + } + + /// + /// Gets the value of a query string key. + /// + public static string GetData(this IWebRequestLog log, string key) + { + var query = new Uri(log.Url).Query?.TrimStart("?").Split('&').FirstOrDefault(p => p.StartsWith(key + "=")); + + if (query.IsEmpty()) + return null; + else + return HttpUtility.UrlDecode(query.Substring(1 + key.Length)); + } + + /// + /// Gets the first request of every session that has had an activity during the last 10 minutes. + /// + public static async Task> FindRecentSessions(TimeSpan since) + { + var startDate = LocalTime.Now.Subtract(since); + + var sessions = (await Entity.Database.GetList(r => r.Start > startDate)).GroupBy(r => r.SessionId); + + return sessions.Select(session => session.WithMin(r => r.Start)).ToArray(); + } + + public static IWebRequestLog CurrentRequestLog + { + get { return Context.HttpContextAccessor.HttpContext.Items["Current.Request.Log"] as IWebRequestLog; } + set { Context.HttpContextAccessor.HttpContext.Items.Add("Current.Request.Log", value); } + } + } +} \ No newline at end of file diff --git a/Olive.sln b/Olive.sln new file mode 100644 index 000000000..affd1fc62 --- /dev/null +++ b/Olive.sln @@ -0,0 +1,178 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.27004.2005 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive", "Olive\Olive.csproj", "{EF844D85-7C45-4725-B033-DBC6111C5F7F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EB802F83-E581-4C4B-9B16-7D00D66C9329}" + ProjectSection(SolutionItems) = preProject + GCop.json = GCop.json + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Entities", "Olive.Entities\Olive.Entities.csproj", "{81D870C8-EA2A-46DA-A936-0D4D520B2C32}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Entities.Data", "Olive.Entities.Data\Olive.Entities.Data.csproj", "{35B19C59-D221-47AF-AAC6-F9CEB0953FFB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Web", "Olive.Web\Olive.Web.csproj", "{EA922ABB-FF72-4C92-B5BE-C4FEAF5AC168}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{943E7871-2EEA-4C97-857B-16443CB240E6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.Compression", "Services\Olive.Services.Compression\Olive.Services.Compression.csproj", "{AB221943-A14F-4817-BAA6-A6829841EA51}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.CSV", "Services\Olive.Services.CSV\Olive.Services.CSV.csproj", "{50ABA031-EAD6-4554-AC73-8BCA56C637B2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.Email", "Services\Olive.Services.Email\Olive.Services.Email.csproj", "{FCE33030-315F-4102-87B9-E36B21F666B0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.Excel", "Services\Olive.Services.Excel\Olive.Services.Excel.csproj", "{E6C2B0FC-8C65-4AD9-A50F-21484D0E9F06}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.GeoLocation", "Services\Olive.Services.GeoLocation\Olive.Services.GeoLocation.csproj", "{253531A7-A460-4D61-9A85-A3E29B33AA94}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.Globalization", "Services\Olive.Services.Globalization\Olive.Services.Globalization.csproj", "{5EE54E7F-07C7-4966-9EFA-5E2252206B77}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.PDF", "Services\Olive.Services.PDF\Olive.Services.PDF.csproj", "{5B7D4B8F-66B2-4A74-A42F-37298CF1878F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.SMS", "Services\Olive.Services.SMS\Olive.Services.SMS.csproj", "{2CCA47DA-038F-4C63-8E7F-9F4A9296BB44}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.Integration", "Services\Olive.Services.Integration\Olive.Services.Integration.csproj", "{0199C138-414E-46AF-8898-3724D3331F4C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.Drawing", "Services\Olive.Services.Drawing\Olive.Services.Drawing.csproj", "{55390C42-F8DE-4089-B079-74E18714236D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.IpFilter", "Services\Olive.Services.IpFilter\Olive.Services.IpFilter.csproj", "{816FFC81-38E0-442C-8236-742551AC8AFC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.ImpersonationSession", "Services\Olive.Services.ImpersonationSession\Olive.Services.ImpersonationSession.csproj", "{E66479DB-792B-4A2F-AA03-DEAAB128CE94}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.Testing", "Services\Olive.Services.Testing\Olive.Services.Testing.csproj", "{4C446033-19E3-4D88-B4A2-AE2D5190AF41}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Services.TaskAutomation", "Services\Olive.Services.TaskAutomation\Olive.Services.TaskAutomation.csproj", "{4EA5F66A-38DD-4CA8-B797-18A5CE083BFA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Mvc", "Olive.Mvc\Olive.Mvc.csproj", "{08ADF968-0FFF-4E3C-88EC-E04E2FA94D13}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mvc.RemovalCandidates", "Mvc.RemovalCandidates\Mvc.RemovalCandidates.csproj", "{D6CB3117-1CB6-4263-AE89-37483A0379B9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Entities.Data.SqlServer", "Olive.Entities.Data.SqlServer\Olive.Entities.Data.SqlServer.csproj", "{8BE6BB58-FFF0-4057-9027-94CF7B96A0E3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Olive.Entities.Data.MySql", "Olive.Entities.Data.MySql\Olive.Entities.Data.MySql.csproj", "{8614E659-EA39-4D64-B014-A4ED525EE045}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OliveVSIX", "OliveVSIX\OliveVSIX.csproj", "{83FFCB26-B836-4E43-8F16-3FC0B29A7572}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {EF844D85-7C45-4725-B033-DBC6111C5F7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EF844D85-7C45-4725-B033-DBC6111C5F7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EF844D85-7C45-4725-B033-DBC6111C5F7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EF844D85-7C45-4725-B033-DBC6111C5F7F}.Release|Any CPU.Build.0 = Release|Any CPU + {81D870C8-EA2A-46DA-A936-0D4D520B2C32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {81D870C8-EA2A-46DA-A936-0D4D520B2C32}.Debug|Any CPU.Build.0 = Debug|Any CPU + {81D870C8-EA2A-46DA-A936-0D4D520B2C32}.Release|Any CPU.ActiveCfg = Release|Any CPU + {81D870C8-EA2A-46DA-A936-0D4D520B2C32}.Release|Any CPU.Build.0 = Release|Any CPU + {35B19C59-D221-47AF-AAC6-F9CEB0953FFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {35B19C59-D221-47AF-AAC6-F9CEB0953FFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35B19C59-D221-47AF-AAC6-F9CEB0953FFB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {35B19C59-D221-47AF-AAC6-F9CEB0953FFB}.Release|Any CPU.Build.0 = Release|Any CPU + {EA922ABB-FF72-4C92-B5BE-C4FEAF5AC168}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA922ABB-FF72-4C92-B5BE-C4FEAF5AC168}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA922ABB-FF72-4C92-B5BE-C4FEAF5AC168}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA922ABB-FF72-4C92-B5BE-C4FEAF5AC168}.Release|Any CPU.Build.0 = Release|Any CPU + {AB221943-A14F-4817-BAA6-A6829841EA51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB221943-A14F-4817-BAA6-A6829841EA51}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB221943-A14F-4817-BAA6-A6829841EA51}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB221943-A14F-4817-BAA6-A6829841EA51}.Release|Any CPU.Build.0 = Release|Any CPU + {50ABA031-EAD6-4554-AC73-8BCA56C637B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50ABA031-EAD6-4554-AC73-8BCA56C637B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50ABA031-EAD6-4554-AC73-8BCA56C637B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50ABA031-EAD6-4554-AC73-8BCA56C637B2}.Release|Any CPU.Build.0 = Release|Any CPU + {FCE33030-315F-4102-87B9-E36B21F666B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FCE33030-315F-4102-87B9-E36B21F666B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FCE33030-315F-4102-87B9-E36B21F666B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FCE33030-315F-4102-87B9-E36B21F666B0}.Release|Any CPU.Build.0 = Release|Any CPU + {E6C2B0FC-8C65-4AD9-A50F-21484D0E9F06}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6C2B0FC-8C65-4AD9-A50F-21484D0E9F06}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6C2B0FC-8C65-4AD9-A50F-21484D0E9F06}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6C2B0FC-8C65-4AD9-A50F-21484D0E9F06}.Release|Any CPU.Build.0 = Release|Any CPU + {253531A7-A460-4D61-9A85-A3E29B33AA94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {253531A7-A460-4D61-9A85-A3E29B33AA94}.Debug|Any CPU.Build.0 = Debug|Any CPU + {253531A7-A460-4D61-9A85-A3E29B33AA94}.Release|Any CPU.ActiveCfg = Release|Any CPU + {253531A7-A460-4D61-9A85-A3E29B33AA94}.Release|Any CPU.Build.0 = Release|Any CPU + {5EE54E7F-07C7-4966-9EFA-5E2252206B77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5EE54E7F-07C7-4966-9EFA-5E2252206B77}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5EE54E7F-07C7-4966-9EFA-5E2252206B77}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5EE54E7F-07C7-4966-9EFA-5E2252206B77}.Release|Any CPU.Build.0 = Release|Any CPU + {5B7D4B8F-66B2-4A74-A42F-37298CF1878F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5B7D4B8F-66B2-4A74-A42F-37298CF1878F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5B7D4B8F-66B2-4A74-A42F-37298CF1878F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5B7D4B8F-66B2-4A74-A42F-37298CF1878F}.Release|Any CPU.Build.0 = Release|Any CPU + {2CCA47DA-038F-4C63-8E7F-9F4A9296BB44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CCA47DA-038F-4C63-8E7F-9F4A9296BB44}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CCA47DA-038F-4C63-8E7F-9F4A9296BB44}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CCA47DA-038F-4C63-8E7F-9F4A9296BB44}.Release|Any CPU.Build.0 = Release|Any CPU + {0199C138-414E-46AF-8898-3724D3331F4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0199C138-414E-46AF-8898-3724D3331F4C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0199C138-414E-46AF-8898-3724D3331F4C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0199C138-414E-46AF-8898-3724D3331F4C}.Release|Any CPU.Build.0 = Release|Any CPU + {55390C42-F8DE-4089-B079-74E18714236D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55390C42-F8DE-4089-B079-74E18714236D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55390C42-F8DE-4089-B079-74E18714236D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55390C42-F8DE-4089-B079-74E18714236D}.Release|Any CPU.Build.0 = Release|Any CPU + {816FFC81-38E0-442C-8236-742551AC8AFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {816FFC81-38E0-442C-8236-742551AC8AFC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {816FFC81-38E0-442C-8236-742551AC8AFC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {816FFC81-38E0-442C-8236-742551AC8AFC}.Release|Any CPU.Build.0 = Release|Any CPU + {E66479DB-792B-4A2F-AA03-DEAAB128CE94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E66479DB-792B-4A2F-AA03-DEAAB128CE94}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E66479DB-792B-4A2F-AA03-DEAAB128CE94}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E66479DB-792B-4A2F-AA03-DEAAB128CE94}.Release|Any CPU.Build.0 = Release|Any CPU + {4C446033-19E3-4D88-B4A2-AE2D5190AF41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C446033-19E3-4D88-B4A2-AE2D5190AF41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C446033-19E3-4D88-B4A2-AE2D5190AF41}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C446033-19E3-4D88-B4A2-AE2D5190AF41}.Release|Any CPU.Build.0 = Release|Any CPU + {4EA5F66A-38DD-4CA8-B797-18A5CE083BFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4EA5F66A-38DD-4CA8-B797-18A5CE083BFA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4EA5F66A-38DD-4CA8-B797-18A5CE083BFA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4EA5F66A-38DD-4CA8-B797-18A5CE083BFA}.Release|Any CPU.Build.0 = Release|Any CPU + {08ADF968-0FFF-4E3C-88EC-E04E2FA94D13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08ADF968-0FFF-4E3C-88EC-E04E2FA94D13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08ADF968-0FFF-4E3C-88EC-E04E2FA94D13}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08ADF968-0FFF-4E3C-88EC-E04E2FA94D13}.Release|Any CPU.Build.0 = Release|Any CPU + {D6CB3117-1CB6-4263-AE89-37483A0379B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6CB3117-1CB6-4263-AE89-37483A0379B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6CB3117-1CB6-4263-AE89-37483A0379B9}.Release|Any CPU.Build.0 = Release|Any CPU + {8BE6BB58-FFF0-4057-9027-94CF7B96A0E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8BE6BB58-FFF0-4057-9027-94CF7B96A0E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8BE6BB58-FFF0-4057-9027-94CF7B96A0E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8BE6BB58-FFF0-4057-9027-94CF7B96A0E3}.Release|Any CPU.Build.0 = Release|Any CPU + {8614E659-EA39-4D64-B014-A4ED525EE045}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8614E659-EA39-4D64-B014-A4ED525EE045}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8614E659-EA39-4D64-B014-A4ED525EE045}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8614E659-EA39-4D64-B014-A4ED525EE045}.Release|Any CPU.Build.0 = Release|Any CPU + {83FFCB26-B836-4E43-8F16-3FC0B29A7572}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83FFCB26-B836-4E43-8F16-3FC0B29A7572}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83FFCB26-B836-4E43-8F16-3FC0B29A7572}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83FFCB26-B836-4E43-8F16-3FC0B29A7572}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {AB221943-A14F-4817-BAA6-A6829841EA51} = {943E7871-2EEA-4C97-857B-16443CB240E6} + {50ABA031-EAD6-4554-AC73-8BCA56C637B2} = {943E7871-2EEA-4C97-857B-16443CB240E6} + {FCE33030-315F-4102-87B9-E36B21F666B0} = {943E7871-2EEA-4C97-857B-16443CB240E6} + {E6C2B0FC-8C65-4AD9-A50F-21484D0E9F06} = {943E7871-2EEA-4C97-857B-16443CB240E6} + {253531A7-A460-4D61-9A85-A3E29B33AA94} = {943E7871-2EEA-4C97-857B-16443CB240E6} + {5EE54E7F-07C7-4966-9EFA-5E2252206B77} = {943E7871-2EEA-4C97-857B-16443CB240E6} + {5B7D4B8F-66B2-4A74-A42F-37298CF1878F} = {943E7871-2EEA-4C97-857B-16443CB240E6} + {2CCA47DA-038F-4C63-8E7F-9F4A9296BB44} = {943E7871-2EEA-4C97-857B-16443CB240E6} + {0199C138-414E-46AF-8898-3724D3331F4C} = {943E7871-2EEA-4C97-857B-16443CB240E6} + {55390C42-F8DE-4089-B079-74E18714236D} = {943E7871-2EEA-4C97-857B-16443CB240E6} + {816FFC81-38E0-442C-8236-742551AC8AFC} = {943E7871-2EEA-4C97-857B-16443CB240E6} + {E66479DB-792B-4A2F-AA03-DEAAB128CE94} = {943E7871-2EEA-4C97-857B-16443CB240E6} + {4C446033-19E3-4D88-B4A2-AE2D5190AF41} = {943E7871-2EEA-4C97-857B-16443CB240E6} + {4EA5F66A-38DD-4CA8-B797-18A5CE083BFA} = {943E7871-2EEA-4C97-857B-16443CB240E6} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B8459027-6FAF-42E6-B8A8-4887D7730704} + EndGlobalSection +EndGlobal diff --git a/Olive/-Extensions/@Misc.cs b/Olive/-Extensions/@Misc.cs new file mode 100644 index 000000000..0f6035b03 --- /dev/null +++ b/Olive/-Extensions/@Misc.cs @@ -0,0 +1,120 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; + +namespace Olive +{ + /// + /// Provides extensions methods to Standard .NET types. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static partial class OliveExtensions + { + const int MAXIMUM_ATTEMPTS = 3; + const int ATTEMPT_PAUSE = 50 /*Milisseconds*/; + + /// + /// Shortens this GUID. + /// + public static ShortGuid Shorten(this Guid guid) => new ShortGuid(guid); + + static async Task TryHard(FileSystemInfo fileOrFolder, Func> func, string error) + { + var result = default(T); + await TryHard(fileOrFolder, async () => { result = await func(); }, error); + return result; + } + + static async Task TryHard(FileSystemInfo fileOrFolder, Func func, string error) + { + var attempt = 0; + + Exception problem = null; + + while (attempt <= MAXIMUM_ATTEMPTS) + { + try + { + await func?.Invoke(); + return; + } + catch (Exception ex) + { + problem = ex; + + // Remove attributes: + try { fileOrFolder.Attributes = FileAttributes.Normal; } + catch + { + // No logging needed + } + + attempt++; + + // Pause for a short amount of time (to allow a potential external process to leave the file/directory). + await Task.Delay(ATTEMPT_PAUSE); + } + } + + throw new IOException(error.FormatWith(fileOrFolder.FullName), problem); + } + + /// + /// Will set the Position to zero, and then copy all bytes to a memory stream's buffer. + /// + public static async Task ReadAllBytes(this Stream stream) + { + using (var memoryStream = new MemoryStream()) + { + stream.Position = 0; + await stream.CopyToAsync(memoryStream); + return memoryStream.ToArray(); + } + } + + /// + /// Returns a nullable value wrapper object if this value is the default for its type. + /// + public static T? NullIfDefault(this T @value, T defaultValue = default(T)) where T : struct + { + if (value.Equals(defaultValue)) return null; + + return @value; + } + + /// + /// Gets the full path of a file or directory from a specified relative path. + /// + public static string GetPath(this AppDomain applicationDomain, params string[] relativePathSections) + { + var result = applicationDomain.BaseDirectory; + + foreach (var path in relativePathSections) + if (path.HasValue()) + result = Path.Combine(result, path.Replace('/', Path.DirectorySeparatorChar)); + + return result; + } + + public static Assembly LoadAssembly(this AppDomain domain, string assemblyName) + { + var result = domain.GetAssemblies().FirstOrDefault(a => a.FullName == assemblyName); + if (result != null) return result; + + // Nothing found with exact name. Try with file name. + var fileName = assemblyName.EnsureEndsWith(".dll", caseSensitive: false); + + var file = domain.GetPath(fileName).AsFile(); + if (file.Exists()) + return Assembly.Load(AssemblyName.GetAssemblyName(file.FullName)); + + // Maybe absolute file? + if (File.Exists(fileName)) + return Assembly.Load(AssemblyName.GetAssemblyName(fileName)); + + throw new Exception($"Failed to find the requrested assembly: '{assemblyName}'"); + } + } +} \ No newline at end of file diff --git a/Olive/-Extensions/Boolean.cs b/Olive/-Extensions/Boolean.cs new file mode 100644 index 000000000..d48fdab66 --- /dev/null +++ b/Olive/-Extensions/Boolean.cs @@ -0,0 +1,26 @@ +namespace Olive +{ + partial class OliveExtensions + { + public static string ToString(this bool? value, string format) => ("{0:" + format + "}").FormatWith(value); + + /// + /// Returns Yes or No string depending on whether the result is true of false. + /// + public static string ToYesNoString(this bool value, string yes = "Yes", string no = "No") => value ? yes : no; + + /// + /// Returns Yes or No string depending on whether the result is true of false. + /// + public static string ToYesNoString(this bool? value, string yes = "Yes", string no = "No") => + value.HasValue ? ToYesNoString(value.Value) : string.Empty; + + public static int CompareTo(this bool? @this, bool? another) + { + if (@this == another) return 0; + if (another == null) return @this.Value ? 1 : -1; + if (@this == null) return another.Value ? -1 : 1; + return @this.Value.CompareTo(another.Value); + } + } +} diff --git a/Olive/-Extensions/DataTable.cs b/Olive/-Extensions/DataTable.cs new file mode 100644 index 000000000..ba5c60f40 --- /dev/null +++ b/Olive/-Extensions/DataTable.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace Olive +{ + partial class OliveExtensions + { + /// + /// Casts this data table's records into a list of typed objects. + /// + public static IEnumerable CastTo(this DataTable dataTable) where T : new() => CastTo(dataTable, null); + + /// + /// Casts this data table's records into a list of typed objects. + /// An anonymouse object containing property mapping information. + /// e.g.: new {Property1 = "Property name in CSV", Property2 = "...", set_Property1 = new Func<string, object>(text => Client.Parse(value)) } + /// + public static IEnumerable CastTo(this DataTable dataTable, object propertyMappings) where T : new() => + CastAsDictionary(dataTable, propertyMappings).Select(i => i.Key).ToList(); + + /// + /// Casts this data table's records into a list of typed objects. + /// An anonymouse object containing property mapping information. + /// e.g.: new {Property1 = "Property name in CSV", Property2 = "...", set_Property1 = new Func<string, object>(text => Client.Parse(value)) } + /// + public static Dictionary CastAsDictionary(this DataTable data, object propertyMappings) where T : new() + { + if (propertyMappings != null) + foreach (var p in propertyMappings.GetType().GetProperties()) + { + if (p.PropertyType == typeof(string)) continue; + + if (p.PropertyType == typeof(Func)) + { + if (!p.Name.StartsWith("set_")) + throw new ArgumentException("Property convertors must start with 'set_{property name}'"); + + continue; + } + + throw new ArgumentException($"Unrecognized value for the property {p.PropertyType} of the specified propertyMappings"); + } + + var mappings = FindPropertyMappings(typeof(T), data.Columns, propertyMappings); + + var convertors = new Dictionary>(); + if (propertyMappings != null) + convertors = propertyMappings.GetType().GetProperties().Where(p => p.PropertyType == typeof(Func)) + .ToDictionary(p => p.Name.Substring(4), p => (Func)p.GetValue(propertyMappings)); + + var result = new Dictionary(); + + foreach (DataRow record in data.Rows) + { + var item = ParseObject(record, mappings, convertors); + result.Add(item, record); + } + + return result; + } + + /// + /// Finds the property mappings for the specified target type, CSV column names and user declared mappings. + /// + static Dictionary FindPropertyMappings(Type targetType, DataColumnCollection columns, object declaredMappings) + { + var result = new Dictionary(); + + if (declaredMappings != null) + { + foreach (var property in declaredMappings.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (property.Name.StartsWith("set_")) + { + if (!result.ContainsKey(property.Name.TrimStart("set_"))) + result.Add(property.Name.TrimStart("set_"), null); + continue; + } + + // Validate property name: + var propertyInTarget = targetType.GetProperty(property.Name); + if (propertyInTarget == null) + throw new Exception(targetType.FullName + " does not have a property named " + property.Name); + + if (!propertyInTarget.CanWrite) + throw new Exception($"{targetType.FullName}.{property.Name} property is read-only."); + + var mappedName = (string)property.GetValue(declaredMappings); + result[property.Name] = mappedName; + } + } + + var columnNames = columns.Cast().Select(c => c.ColumnName).ToArray(); + + foreach (var property in targetType.GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)) + { + if (!property.CanWrite) continue; + + if (result.ContainsKey(property.Name) && result[property.Name] != null) + continue; // Already added in explicit mappings. + + // Otherwise, if a column with that name is available, then that's it: + var potential = columnNames.Where(c => c.Replace(" ", "").ToLower() == property.Name.ToLower()); + if (potential.IsSingle()) + result[property.Name] = potential.Single(); + + else if (potential.Any()) + { + throw new Exception("The specified data contains multiple potential matches for the property '{0}'. The potentially matched columns found: {1}. You must use explicit mappings in this case." + .FormatWith(property.Name, potential.Select(c => $"'{c}'").ToString(", "))); + } + } + + return result; + } + + /// + /// Creates an object of the specified type with the specified data and property mappings. + /// + static T ParseObject(DataRow dataContainer, Dictionary propertyMappings, Dictionary> convertors) + { + var result = Activator.CreateInstance(); + + foreach (var mapping in propertyMappings) + { + var property = result.GetType().GetProperty(mapping.Key); + + string data; + + if (mapping.Value == null) + // The setter for this property is identified, while no property mapping is specified. + data = null; + else + data = dataContainer[mapping.Value]?.ToString().TrimOrNull(); + + try + { + object dataToSet; + + if (convertors.ContainsKey(mapping.Key)) + dataToSet = convertors[mapping.Key](data); + else + dataToSet = data.To(property.PropertyType); + + property.SetValue(result, dataToSet); + } + catch (Exception ex) + { + throw new Exception($"Could not set the value of the property '{mapping.Key}' from the value of '{data}'.", ex); + } + } + + return result; + } + + /// + /// Gets the CSV data equivalent to this data table. + /// + public static string ToCSV(this DataTable table) + { + var result = new StringBuilder(); + for (int i = 0; i < table.Columns.Count; i++) + { + result.Append(table.Columns[i].ColumnName); + result.Append(i == table.Columns.Count - 1 ? "\n" : ","); + } + + foreach (DataRow row in table.Rows) + { + for (int i = 0; i < table.Columns.Count; i++) + { + result.Append(row[i].ToString()); + result.Append(i == table.Columns.Count - 1 ? "\n" : ","); + } + } + + return result.ToString(); + } + + /// + /// Gets the rows of this data table in a LINQ-able format.. + /// + public static IEnumerable GetRows(this DataTable dataTable) => dataTable.Rows.Cast(); + } +} diff --git a/Olive/-Extensions/DateTime.cs b/Olive/-Extensions/DateTime.cs new file mode 100644 index 000000000..b8c2c38a7 --- /dev/null +++ b/Olive/-Extensions/DateTime.cs @@ -0,0 +1,893 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Olive +{ + partial class OliveExtensions + { + const int WEEK_DAYS_COUNT = 7; + const int CHRISTMAS_DAY = 25; + const int MINUS_THREE = -3; + const int MINUS_SIX = -6; + const int MAC_DAY_COUNT_IN_MONTH = 31; + const int HOURS_IN_A_DAY = 24; + + #region EasterMondays + + static DateTime[] EasterMondays = new[]{ +new DateTime(1950,04,10), +new DateTime(1951,03,26), +new DateTime(1952,04,14), +new DateTime(1953,04,06), +new DateTime(1954,04,19), +new DateTime(1955,04,11), +new DateTime(1956,04,02), +new DateTime(1957,04,22), +new DateTime(1958,04,07), +new DateTime(1959,03,30), +new DateTime(1960,04,18), +new DateTime(1961,04,03), +new DateTime(1962,04,23), +new DateTime(1963,04,15), +new DateTime(1964,03,30), +new DateTime(1965,04,19), +new DateTime(1966,04,11), +new DateTime(1967,03,27), +new DateTime(1968,04,15), +new DateTime(1969,04,07), +new DateTime(1970,03,30), +new DateTime(1971,04,12), +new DateTime(1972,04,03), +new DateTime(1973,04,23), +new DateTime(1974,04,15), +new DateTime(1975,03,31), +new DateTime(1976,04,19), +new DateTime(1977,04,11), +new DateTime(1978,03,27), +new DateTime(1979,04,16), +new DateTime(1980,04,07), +new DateTime(1981,04,20), +new DateTime(1982,04,12), +new DateTime(1983,04,04), +new DateTime(1984,04,23), +new DateTime(1985,04,08), +new DateTime(1986,03,31), +new DateTime(1987,04,20), +new DateTime(1988,04,04), +new DateTime(1989,03,27), +new DateTime(1990,04,16), +new DateTime(1991,04,01), +new DateTime(1992,04,20), +new DateTime(1993,04,12), +new DateTime(1994,04,04), +new DateTime(1995,04,17), +new DateTime(1996,04,08), +new DateTime(1997,03,31), +new DateTime(1998,04,13), +new DateTime(1999,04,05), +new DateTime(2000,04,24), +new DateTime(2001,04,16), +new DateTime(2002,04,01), +new DateTime(2003,04,21), +new DateTime(2004,04,12), +new DateTime(2005,03,28), +new DateTime(2006,04,17), +new DateTime(2007,04,09), +new DateTime(2008,03,24), +new DateTime(2009,04,13), +new DateTime(2010,04,05), +new DateTime(2011,04,25), +new DateTime(2012,04,09), +new DateTime(2013,04,01), +new DateTime(2014,04,21), +new DateTime(2015,04,06), +new DateTime(2016,03,28), +new DateTime(2017,04,17), +new DateTime(2018,04,02), +new DateTime(2019,04,22), +new DateTime(2020,04,13), +new DateTime(2021,04,05), +new DateTime(2022,04,18), +new DateTime(2023,04,10), +new DateTime(2024,04,01), +new DateTime(2025,04,21), +new DateTime(2026,04,06), +new DateTime(2027,03,29), +new DateTime(2028,04,17), +new DateTime(2029,04,02), +new DateTime(2030,04,22), +new DateTime(2031,04,14), +new DateTime(2032,03,29), +new DateTime(2033,04,18), +new DateTime(2034,04,10), +new DateTime(2035,03,26), +new DateTime(2036,04,14), +new DateTime(2037,04,06), +new DateTime(2038,04,26), +new DateTime(2039,04,11), +new DateTime(2040,04,02), +new DateTime(2041,04,22), +new DateTime(2042,04,07), +new DateTime(2043,03,30), +new DateTime(2044,04,18), +new DateTime(2045,04,10), +new DateTime(2046,03,26), +new DateTime(2047,04,15), +new DateTime(2048,04,06), +new DateTime(2049,04,19), +new DateTime(2050,04,11), +new DateTime(2051,04,03), +new DateTime(2052,04,22), +new DateTime(2053,04,07), +new DateTime(2054,03,30), +new DateTime(2055,04,19), +new DateTime(2056,04,03), +new DateTime(2057,04,23), +new DateTime(2058,04,15), +new DateTime(2059,03,31), +new DateTime(2060,04,19), +new DateTime(2061,04,11), +new DateTime(2062,03,27), +new DateTime(2063,04,16), +new DateTime(2064,04,07), +new DateTime(2065,03,30), +new DateTime(2066,04,12), +new DateTime(2067,04,04), +new DateTime(2068,04,23), +new DateTime(2069,04,15), +new DateTime(2070,03,31), +new DateTime(2071,04,20), +new DateTime(2072,04,11), +new DateTime(2073,03,27), +new DateTime(2074,04,16), +new DateTime(2075,04,08), +new DateTime(2076,04,20), +new DateTime(2077,04,12), +new DateTime(2078,04,04), +new DateTime(2079,04,24), +new DateTime(2080,04,08), +new DateTime(2081,03,31), +new DateTime(2082,04,20), +new DateTime(2083,04,05), +new DateTime(2084,03,27), +new DateTime(2085,04,16), +new DateTime(2086,04,01), +new DateTime(2087,04,21), +new DateTime(2088,04,12), +new DateTime(2089,04,04), +new DateTime(2090,04,17), +new DateTime(2091,04,09), +new DateTime(2092,03,31), +new DateTime(2093,04,13), +new DateTime(2094,04,05), +new DateTime(2095,04,25), +new DateTime(2096,04,16), +new DateTime(2097,04,01), +new DateTime(2098,04,21), +new DateTime(2099,04,13)}; + + #endregion EasterMondays + + /// + /// Dictionary that contains exceptional dates for Early May Bank Holiday. + /// Key: Year, Value: Exceptional Date for that year. + /// + static Dictionary EarlyMayExceptions = new Dictionary(); + + #region SpringBankHolidayExceptions + + /// + /// Dictionary that contains exceptional dates for Spring Bank Holiday. + /// Key: Year, Value: Exceptional Date for that year. + /// + static Dictionary SpringBankHolidayExceptions = new Dictionary + { + {2012,new DateTime(2012,06,04)} + }; + + #endregion SpringBankHolidayExceptions + + #region LateSummerBankHolidayExceptions + + /// + /// Dictionary that contains exceptional dates for Last Summer Bank Holiday. + /// Key: Year, Value: Exceptional Date for that year. + /// + static Dictionary LateSummerBankHolidayExceptions = new Dictionary(); + + #endregion LateSummerBankHolidayExceptions + + #region AdditionalBankHolidays + + /// + /// Dictionary that contains exceptional dates for Last Summer Bank Holiday. + /// Key: Year, Value: Exceptional Date for that year. + /// + static Dictionary AdditionalBankHolidays = new Dictionary + { + {2012,new[]{new DateTime(2012,6,5)}} + }; + + #endregion AdditionalBankHolidays + + /// + /// Determines if a specified date is an English national holiday or weekend. + /// + public static bool IsEnglishHoliday(this DateTime date) + { + date = date.Date; // drop time. + + if (date.IsWeekend()) return true; + + // 1 January - New Year's Day + if (date == GetActualHolidayDate(new DateTime(date.Year, 1, 1))) + return true; + + // 1st Monday in May Early May Bank Holiday + if (date == GetEarlyMayBankHoliday(date.Year)) + return true; + + // Last Monday in May Spring Bank Holiday + if (date == GetSpringBankHoliday(date.Year)) + return true; + + // Last Monday in August Late Summer Bank Holiday + if (date == GetLateSummerBankHoliday(date.Year)) + return true; + + // December 25 Christmas Day + if (date == GetActualHolidayDate(new DateTime(date.Year, 12, CHRISTMAS_DAY))) + return true; + + // December 26 Boxing Day + if (date == GetBoxingDay(date.Year)) + return true; + + try + { + var easterMonday = GetEasterMonday(date.Year); + + // Easter Monday + if (date == easterMonday) + return true; + + // Good Friday + if (date == easterMonday.AddDays(MINUS_THREE)) + return true; + } + catch { /* No logging needed. out of supported range*/ } + + // Additional Holidays + if (IsAdditionalBankHoliday(date)) + return true; + + return false; + } + + /// + /// Check if Date it is Additional bank holiday in that year + /// + /// the date to check + static bool IsAdditionalBankHoliday(DateTime date) + { + if (AdditionalBankHolidays.ContainsKey(date.Year)) + return AdditionalBankHolidays[date.Year].Contains(date); + + return false; + } + + /// + /// Gets the first upcoming specified week day. + /// + public static DateTime GetUpcoming(this DateTime date, DayOfWeek dayOfWeek, bool skipToday = false) + { + if (skipToday) date = date.Date.AddDays(1); + else date = date.Date; + + while (true) + { + if (date.DayOfWeek == dayOfWeek) return date; + + date = date.AddDays(1); + } + } + + /// + /// Gets the last occurance of the specified week day. + /// + public static DateTime GetLast(this DateTime date, DayOfWeek dayOfWeek, bool skipToday = false) + { + date = date.Date; + + if (skipToday) date = date.AddDays(-1); + + while (true) + { + if (date.DayOfWeek == dayOfWeek) return date; + + date = date.AddDays(-1); + } + } + + /// + /// Get Early May Bank Holiday Date for the required year + /// + /// the year to check if in that year there is an exception to the normal bank holiday rule + static DateTime GetEarlyMayBankHoliday(int year) + { + if (EarlyMayExceptions.ContainsKey(year)) + { + return EarlyMayExceptions[year]; + } + else + { + return GetFirstWeekdayIn(year, 5 /* May */, DayOfWeek.Monday); + } + } + + /// + /// Get Spring Bank Holiday Date for the required year + /// + /// the year to check if in that year there is an exception to the normal bank holiday rule + static DateTime GetSpringBankHoliday(int year) + { + if (SpringBankHolidayExceptions.ContainsKey(year)) + return SpringBankHolidayExceptions[year]; + else + return GetLastWeekdayIn(year, 5, DayOfWeek.Monday); + } + + /// + /// Get Late Summer Bank Holiday Date for the required year + /// + /// the year to check if in that year there is an exception to the normal bank holiday rule + static DateTime GetLateSummerBankHoliday(int year) + { + if (LateSummerBankHolidayExceptions.ContainsKey(year)) + return LateSummerBankHolidayExceptions[year]; + else + return GetLastWeekdayIn(year, (int)CalendarMonth.August, DayOfWeek.Monday); + } + + static DateTime GetEasterMonday(int year) + { + var result = EasterMondays.FirstOrDefault(d => d.Year == year); + + if (result == DateTime.MinValue) + throw new ArgumentException("GetEasterMonday() is not supported for the year: " + year); + + return result; + } + + /// + /// Get Boxing Day Holiday Date for the required year + /// + /// the year to check if in that year there is an exception to the normal bank holiday rule + static DateTime GetBoxingDay(int year) + { + var christmasDay = new DateTime(year, 12, CHRISTMAS_DAY); + var result = GetActualHolidayDate(new DateTime(year, 12, CHRISTMAS_DAY + 1)); + + if (christmasDay.IsWeekend()) + result = result.AddDays(1); + + return result; + } + + static DateTime GetActualHolidayDate(DateTime originalDay) + { + var result = originalDay; + while (result.IsWeekend()) + result = result.AddDays(1); + + return result; + } + + static DateTime GetFirstWeekdayIn(int year, int month, DayOfWeek weekday) + { + for (var day = new DateTime(year, month, 1); ; day = day.AddDays(1)) + if (day.DayOfWeek == weekday) return day; + } + + static DateTime GetLastWeekdayIn(int year, int month, DayOfWeek weekday) + { + for (var day = new DateTime(year, month, 1).AddMonths(1).AddDays(-1); ; day = day.AddDays(-1)) + if (day.DayOfWeek == weekday) return day; + } + + public static bool IsOlderThan(this DateTime source, TimeSpan span) => (LocalTime.Now - source) > span; + + public static bool IsNewerThan(this DateTime source, TimeSpan span) => (LocalTime.Now - source) < span; + + public static bool IsAfterOrEqualTo(this DateTime date, DateTime otherDate) => date >= otherDate; + + public static bool IsBeforeOrEqualTo(this DateTime date, DateTime otherDate) => date <= otherDate; + + /// + /// Determines whether this day is in the same week (Monday to Sunday) as the specified other date. + /// + public static bool IsInSameWeek(this DateTime day, DateTime other) + { + day = day.Date; + + var beginningOfWeek = day.GetBeginningOfWeek(); + + if (other < beginningOfWeek) return false; + if (other >= beginningOfWeek.AddDays(WEEK_DAYS_COUNT)) return false; + + return true; + } + + /// + /// Determines whether this day is in the same month as the specified other date. + /// + public static bool IsInSameMonth(this DateTime day, DateTime other) => day.Month == other.Month && day.Year == other.Year; + + /// + /// Gets the number of days in this year. + /// + public static int DaysInYear(this DateTime date) => new DateTime(date.Year, 12, MAC_DAY_COUNT_IN_MONTH).DayOfYear; + + /// + /// Gets the number of days in this month. + /// + public static int DaysInMonth(this DateTime date) => DateTime.DaysInMonth(date.Year, date.Month); + + /// + /// Gets the mid-night of Monday of this week. + /// + public static DateTime GetBeginningOfWeek(this DateTime day) => day.Date.GetLast(DayOfWeek.Monday, skipToday: false); + + /// + /// Gets one tick before the start of next week. + /// + public static DateTime GetEndOfWeek(this DateTime date, DayOfWeek startOfWeek = DayOfWeek.Monday) => + date.GetUpcoming(startOfWeek, skipToday: true).AddTicks(-1); + + /// + /// Gets the mid-night of the first day of this month. + /// + public static DateTime GetBeginningOfMonth(this DateTime day) => new DateTime(day.Year, day.Month, 1); + + /// + /// Gets the end of this day (one tick before the next day). + /// + public static DateTime EndOfDay(this DateTime date) + { + try + { + return date.Date.AddDays(1).AddTicks(-1); + } + catch (ArgumentOutOfRangeException) + { + return DateTime.MaxValue; + } + } + + public static DateTime? EndOfDay(this DateTime? date) => date?.EndOfDay(); + + /// + /// Determines whether this date is in the future. + /// + public static bool IsInTheFuture(this DateTime date) => date > LocalTime.Now; + + /// + /// Determines whether this date is in the future. + /// + public static bool IsTodayOrFuture(this DateTime date) => date.Date >= LocalTime.Today; + + /// + /// Determines whether this date is in the future. + /// + public static bool IsToday(this DateTime date) => date.Date == LocalTime.Today; + + public static bool IsInThePast(this DateTime date) => date < LocalTime.Now; + + public static bool IsAfter(this DateTime date, DateTime otherDate) => date > otherDate; + + public static bool IsBefore(this DateTime date, DateTime otherDate) => date < otherDate; + + /// + /// E.g. 4am or 6:30pm. + /// + public static string ToSmallTime(this DateTime date) => date.ToString("h:mm").TrimEnd(":00") + date.ToString("tt").ToLower(); + + public static bool IsWeekend(this DateTime value) => value.DayOfWeek == DayOfWeek.Sunday || value.DayOfWeek == DayOfWeek.Saturday; + + public static DateTime AddWorkingDays(this DateTime date, int days, bool considerEnglishBankHolidays = true) + { + if (days == 0) return date; + + var result = date; + + if (days > 0) + for (int day = 0; day < days; day++) + result = result.NextWorkingDay(considerEnglishBankHolidays); + else + for (int day = 0; day < -days; day++) + result = result.PreviousWorkingDay(considerEnglishBankHolidays); + + return result; + } + + public static string ToTimeDifferenceString(this DateTime? date, int precisionParts = 2, bool longForm = true) + { + if (date == null) return null; + return ToTimeDifferenceString(date.Value, precisionParts, longForm); + } + + public static string ToTimeDifferenceString(this DateTime date) => ToTimeDifferenceString(date, longForm: true); + + public static string ToTimeDifferenceString(this DateTime date, bool longForm) => ToTimeDifferenceString(date, 2, longForm); + + public static string ToTimeDifferenceString(this DateTime date, int precisionParts) => ToTimeDifferenceString(date, precisionParts, longForm: true); + + public static string ToTimeDifferenceString(this DateTime date, int precisionParts, bool longForm) + { + var now = LocalTime.Now; + + if (now == date) + { + if (longForm) + return "Just now"; + else + return "Now"; + } + + if (now > date) + return now.Subtract(date).ToNaturalTime(precisionParts, longForm) + " ago"; + else + return date.Subtract(now).ToNaturalTime(precisionParts, longForm) + " later"; + } + + public static string ToString(this DateTime? value, string format) => ($"{{0:{format}}}").FormatWith(value); + + /// + /// Gets the next working day. + /// + public static DateTime NextWorkingDay(this DateTime date, bool considerEnglishHolidays = true) + { + var result = date.AddDays(1); + + if (considerEnglishHolidays) + while (result.IsEnglishHoliday()) + result = result.AddDays(1); + else + while (result.IsWeekend()) + result = result.AddDays(1); + + return result; + } + + /// + /// Gets the days between this day and the specified other day. + /// It will remove TIME information. + /// + public static IEnumerable GetDaysInBetween(this DateTime day, DateTime other, bool inclusive = false) + { + day = day.Date; + other = other.Date; + + var from = day <= other ? day : other; + var to = day > other ? day : other; + + var count = (int)to.Subtract(from).TotalDays; + + if (!inclusive) count--; + else count++; + + if (count < 1) return new DateTime[0]; + + var numbers = Enumerable.Range(inclusive ? 0 : 1, count); + + var result = numbers.Select(i => from.AddDays(i)); + + if (day > other) + return result.Reverse(); + else + return result; + } + + /// + /// Gets the previous working day. + /// + public static DateTime PreviousWorkingDay(this DateTime date, bool considerEnglishHolidays = true) + { + var result = date.AddDays(-1); + + if (considerEnglishHolidays) + while (result.IsEnglishHoliday()) + result = result.AddDays(-1); + else + while (result.IsWeekend()) + result = result.AddDays(-1); + + return result; + } + + public static string ToFriendlyDateString(this DateTime date) + { + string formattedDate; + if (date.Date == DateTime.Today) + formattedDate = "Today"; + + else if (date.Date == DateTime.Today.AddDays(-1)) + formattedDate = "Yesterday"; + + else if (date.Date > DateTime.Today.AddDays(MINUS_SIX)) + // *** Show the Day of the week + formattedDate = date.ToString("dddd"); + + else + formattedDate = date.ToString("MMMM dd, yyyy"); + + // append the time portion to the output + formattedDate += " @ " + date.ToString("t").ToLower(); + return formattedDate; + } + + /// + /// Determines whether this date is between two sepcified dates. + /// + public static bool IsBetween(this DateTime date, DateTime from, DateTime to, bool includingEdges = true) + { + if (from > to) + throw new ArgumentException("\"From\" date should be smaller than or equal to \"To\" date."); + + if (date < from || date > to) return false; + + if (!includingEdges) + if (date == from || date == to) return false; + + return true; + } + + /// + /// Calculates the total working times in the specified duration which are between the two specified day-hours. + /// This can be used to calculate working hours in a particular duration. + /// + public static TimeSpan CalculateTotalWorkingHours(this DateTime date, TimeSpan period, TimeSpan workingStartTime, TimeSpan workingEndTime, bool considerEnglishBankHolidays = true) + { + if (period < TimeSpan.Zero) + throw new ArgumentException("duration should be a positive time span."); + + if (workingStartTime < TimeSpan.Zero || workingStartTime >= TimeSpan.FromHours(HOURS_IN_A_DAY)) + throw new ArgumentException("fromTime should be greater than or equal to 0, and smaller than 24."); + + if (workingEndTime <= TimeSpan.Zero || workingEndTime > TimeSpan.FromHours(HOURS_IN_A_DAY)) + throw new ArgumentException("toTime should be greater than 0, and smaller than or equal to 24."); + + var result = TimeSpan.Zero; + + // var inclusiveTimeSpan = toTime - fromTime; + + var workingTimesInday = new List>(); + if (workingEndTime > workingStartTime) + { + workingTimesInday.Add(new KeyValuePair(workingStartTime, workingEndTime)); + } + else + { + workingTimesInday.Add(new KeyValuePair(workingEndTime, TimeSpan.FromDays(1))); + workingTimesInday.Add(new KeyValuePair(TimeSpan.Zero, workingStartTime)); + } + + // For each working day in the range, calculate relevant times + for (var day = date.Date; day < date.Add(period); day = day.AddWorkingDays(1, considerEnglishBankHolidays)) + { + foreach (var range in workingTimesInday) + { + var from = day.Add(range.Key); + + if (from < date) from = date; + + var to = day.Add(range.Value); + if (to < date) continue; + + if (to > date.Add(period)) to = date.Add(period); + + var amount = to - from; + + if (amount < TimeSpan.Zero) continue; + + result += amount; + } + } + + return result; + } + + /// + /// Returns the Date of the beginning of Quarter for this DateTime value (time will be 00:00:00). + /// + public static DateTime GetBeginningOfQuarter(this DateTime date) + { + var startMonths = new[] { 1, 4, 7, 10 }; + + for (int i = startMonths.Length - 1; i >= 0; i--) + { + var beginningOfQuarter = new DateTime(date.Year, startMonths[i], 1); + if (date >= beginningOfQuarter) + return beginningOfQuarter; + } + + return DateTime.MinValue; + } + + /// + /// Returns the Date of the end of Quarter for this DateTime value (time will be 11:59:59). + /// + public static DateTime GetEndOfQuarter(this DateTime date) => + date.GetBeginningOfQuarter().AddMonths(4).GetBeginningOfQuarter().AddTicks(-1); + + /// + /// Returns the Date of the end of Quarter for this DateTime value (time will be 11:59:59). + /// + public static DateTime GetEndOfMonth(this DateTime date) => date.GetBeginningOfMonth().AddMonths(1).AddTicks(-1); + + public static bool IsLastDayOfMonth(this DateTime date) => date.Date == date.GetEndOfMonth().Date; + + /// + /// Gets the last date with the specified month and day. + /// + public static DateTime GetLast(this DateTime date, CalendarMonth month, int day) + { + date = date.Date; // cut time + + var thisYear = new DateTime(date.Year, (int)month, day); + + if (date >= thisYear) return thisYear; + else return new DateTime(date.Year - 1, (int)month, day); + } + + /// + /// Gets the last date with the specified month and day. + /// + public static DateTime GetNext(this DateTime date, CalendarMonth month, int day) + { + date = date.Date; // cut time + + var thisYear = new DateTime(date.Year, (int)month, day); + + if (date >= thisYear) return new DateTime(date.Year + 1, (int)month, day); + else return thisYear; + } + + /// + /// Returns the Date of the end of Quarter for this DateTime value (time will be 11:59:59). + /// + public static DateTime GetEndOfYear(this DateTime date) => new DateTime(date.Year + 1, 1, 1).AddTicks(-1); + + /// + /// Gets the minimum value between this date and a specified other date. + /// + public static DateTime Min(this DateTime date, DateTime other) + { + if (other < date) + return other; + else + return date; + } + + /// + /// Gets the maximum value between this date and a specified other date. + /// + public static DateTime Max(this DateTime date, DateTime other) + { + if (other > date) + return other; + else + return date; + } + + /// + /// Adds the specified number of weeks and returns the result. + /// + public static DateTime AddWeeks(this DateTime date, int numberofWeeks) => date.AddDays(WEEK_DAYS_COUNT * numberofWeeks); + + /// + /// Gets the latest date with the specified day of week and time that is before (or same as) this date. + /// + public static DateTime GetLast(this DateTime date, DayOfWeek day, TimeSpan timeOfDay) + { + var result = date.GetLast(day).Add(timeOfDay); + + if (result > date) + { + return result.AddWeeks(-1); + } + else + { + return result; + } + } + + /// + /// Returns the local time equivalent of this UTC date value based on the TimeZone specified in Localtime.TimeZoneProvider. + /// Use this instead of ToLocalTime() so you get control over the TimeZone. + /// + public static DateTime? ToLocal(this DateTime? utcValue) => utcValue?.ToLocal(); + + /// + /// Returns the local time equivalent of this UTC date value based on the TimeZone specified in Localtime.CurrentTimeZone(). + /// Use this instead of ToLocalTime() so you get control over the TimeZone. + /// + public static DateTime ToLocal(this DateTime utcValue) => utcValue.ToLocal(LocalTime.CurrentTimeZone()); + + public static DateTime ToLocal(this DateTime utcValue, TimeZoneInfo timeZone) => + new DateTime(utcValue.Ticks, DateTimeKind.Local).Add(timeZone.GetUtcOffset(utcValue)); + + /// + /// Returns the equivalent Universal Time (UTC) of this local date value. + /// + public static DateTime? ToUniversal(this DateTime? localValue) => localValue?.ToUniversal(); + + /// + /// Returns the equivalent Universal Time (UTC) of this local date value. + /// + public static DateTime ToUniversal(this DateTime localValue) => + localValue.ToUniversal(sourceTimezone: LocalTime.CurrentTimeZone()); + + /// + /// Returns the equivalent Universal Time (UTC) of this local date value. + /// + public static DateTime ToUniversal(this DateTime localValue, TimeZoneInfo sourceTimezone) => + new DateTime(localValue.Ticks, DateTimeKind.Utc).Subtract(sourceTimezone.BaseUtcOffset); + + /// + /// Rounds this up to the nearest whole second. + /// + public static DateTime RoundToSecond(this DateTime @this) => @this.Round(1.Seconds()); + + /// + /// Rounds this up to the nearest whole minute. + /// + public static DateTime RoundToMinute(this DateTime @this) => @this.Round(1.Minutes()); + + /// + /// Rounds this up to the nearest whole hour. + /// + public static DateTime RoundToHour(this DateTime @this) => @this.Round(1.Hours()); + + /// + /// Rounds this up to the nearest interval (e.g. second, minute, hour, etc). + /// + public static DateTime Round(this DateTime dateTime, TimeSpan nearest) + { + if (nearest == TimeSpan.Zero) return dateTime; + + var remainder = dateTime.Ticks % nearest.Ticks; + + var result = dateTime.AddTicks(-remainder); + + if (remainder >= nearest.Ticks / 2) result = result.Add(nearest); + + return result; + } + + public static int CompareTo(this DateTime? @this, DateTime? another) + { + if (@this == another) return 0; + if (another == null) return 1; + if (@this == null) return -1; + return @this.Value.CompareTo(another.Value); + } + } + + public enum CalendarMonth + { + January = 1, + February = 2, + March = 3, + April = 4, + May = 5, + June = 6, + July = 7, + August = 8, + September = 9, + October = 10, + November = 11, + December = 12 + } +} \ No newline at end of file diff --git a/Olive/-Extensions/Delegates.cs b/Olive/-Extensions/Delegates.cs new file mode 100644 index 000000000..a26dfff05 --- /dev/null +++ b/Olive/-Extensions/Delegates.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Olive +{ + partial class OliveExtensions + { + /// + /// Invokes the specified action for the specified number of times. + /// + /// The action to execute. + /// The number of times to try running the action. + /// The time to wait before every two retries. + /// The action to run every time the method invokation fails. You can use this to log the error. + public static void Invoke(this Action action, int retries, TimeSpan waitBeforeRetries, Action onEveryError = null) + { + var asFunction = new Func(() => { action?.Invoke(); return null; }); + asFunction.Invoke(retries, waitBeforeRetries, onEveryError); + } + + /// + /// Invokes the specified function for the specified number of times. + /// + /// The function to evaluate. + /// The number of times to try running the action. + /// The time to wait before every two retries. + /// The action to run every time the method invokation fails. You can use this to log the error. + public static T Invoke(this Func function, int retries, TimeSpan waitBeforeRetries, Action onEveryError = null) + { + if (retries < 2) + throw new Exception("retries should be greater than 2."); + + var count = 0; + + while (true) + { + try + { + return function(); + } + catch (Exception ex) + { + count++; + + onEveryError?.Invoke(ex); + + if (count == retries) + { + // Give up: + throw; + } + + Thread.Sleep(waitBeforeRetries); + } + } + } + + public static async Task WithTimeout(this Task task, TimeSpan timeout, Action success = null, Action timeoutAction = null) + { + if (await Task.WhenAny(task, Task.Delay(timeout)) == task) success?.Invoke(); + + else + { + if (timeoutAction == null) throw new TimeoutException("The task didn't complete within " + timeout + "ms"); + else timeoutAction(); + } + } + + public static async Task WithTimeout(this Task task, TimeSpan timeout, Action success = null, Func timeoutAction = null) + { + if (await Task.WhenAny(task, Task.Delay(timeout)) == task) + { + success?.Invoke(); + await task; + return task.GetAwaiter().GetResult(); + } + + else + { + if (timeoutAction == null) throw new TimeoutException("The task didn't complete within " + timeout + "ms"); + else { return timeoutAction(); } + } + } + } +} \ No newline at end of file diff --git a/Olive/-Extensions/DirectoryInfo.cs b/Olive/-Extensions/DirectoryInfo.cs new file mode 100644 index 000000000..3a00a08a8 --- /dev/null +++ b/Olive/-Extensions/DirectoryInfo.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Olive +{ + partial class OliveExtensions + { + /// + /// If specified as recursive and harshly, then it tries multiple times to delete this directory. + /// + public static async Task Delete(this DirectoryInfo directory, bool recursive, bool harshly) + { + if (directory == null) + throw new ArgumentNullException(nameof(directory)); + + if (!directory.Exists()) return; + + if (harshly && !recursive) + throw new ArgumentException("For deleting a folder harshly, the recursive option should also be specified."); + + if (!harshly) + { + directory.Delete(recursive); + return; + } + + // Otherwise, it is harsh and recursive: + try + { + // First attempt: Simple delete: + await Task.Factory.StartNew(() => directory.Delete(recursive: true)); + } + catch + { + // No loging is needed + // Normal attempt failed. Let's try it harshly! + await HarshDelete(directory); + } + } + + /// + /// Will try to delete a specified directory by first deleting its sub-folders and files. + /// + static async Task HarshDelete(DirectoryInfo directory) + { + if (!directory.Exists()) return; + + await TryHard(directory, async () => + { + await directory.GetFiles().Do(async (f) => await f.Delete(harshly: true)); + await directory.GetDirectories().Do(async (d) => await HarshDelete(d)); + await Task.Factory.StartNew(directory.Delete); + }, "The system cannot delete the directory, even after several attempts. Directory: {0}"); + } + + /// + /// Copies the entire content of a directory to a specified destination. + /// + public static async Task CopyTo(this DirectoryInfo source, DirectoryInfo destination, bool overwrite = false) => + await CopyTo(source, destination.FullName, overwrite); + + /// + /// Determines whether the file's contents start with MZ which is the signature for EXE files. + /// + public static bool HasExeContent(this FileInfo file) + { + var twoBytes = new byte[2]; + using (var fileStream = File.Open(file.FullName, FileMode.Open)) + { + try + { + fileStream.Read(twoBytes, 0, 2); + } + catch + { + // No logging is needed + return false; // No content + } + } + + return Encoding.UTF8.GetString(twoBytes) == "MZ"; + } + + /// + /// Copies the entire content of a directory to a specified destination. + /// + public static async Task CopyTo(this DirectoryInfo source, string destination, bool overwrite = false) + { + destination.AsDirectory().EnsureExists(); + + foreach (var file in source.GetFiles()) + await file.CopyTo(Path.Combine(destination, file.Name).AsFile(), overwrite); + + foreach (var sub in source.GetDirectories()) + await sub.CopyTo(Path.Combine(destination, sub.Name), overwrite); + } + + /// + /// Copies this file to a specified destination directiry with the original file name. + /// + public static async Task CopyTo(this FileInfo file, DirectoryInfo destinationDirectory, bool overwrite = false) => + await file.CopyTo(destinationDirectory.GetFile(file.Name), overwrite); + + public static string[] GetFiles(this DirectoryInfo folder, bool includeSubDirectories) + { + var result = new List(folder.GetFiles().Select(f => f.FullName)); + + if (includeSubDirectories) + foreach (var subFolder in folder.GetDirectories()) + result.AddRange(subFolder.GetFiles(includeSubDirectories: true)); + + return result.ToArray(); + } + + /// + /// Gets a file info with the specified name under this folder. That file does not have to exist already. + /// + public static FileInfo GetFile(this DirectoryInfo folder, string fileName) => + Path.Combine(folder.FullName, fileName).AsFile(); + + /// + /// Gets a subdirectory with the specified name. It does not need to exist necessarily. + /// + public static DirectoryInfo GetSubDirectory(this DirectoryInfo parent, string subdirectoryName) + { + if (subdirectoryName.IsEmpty()) + throw new ArgumentNullException("GetSubDirectory(name) expects a non-empty sub-directory name."); + + return new DirectoryInfo(Path.Combine(parent.FullName, subdirectoryName)); + } + + /// + /// Gets or creates a subdirectory with the specified name. + /// + public static DirectoryInfo GetOrCreateSubDirectory(this DirectoryInfo parent, string subdirectoryName) + { + var result = new DirectoryInfo(Path.Combine(parent.FullName, subdirectoryName)); + + result.Create(); + + return result; + } + + /// + /// Gets the subdirectory tree of this directory. + /// + public static IEnumerable GetDirectories(this DirectoryInfo parent, bool recursive) + { + if (!recursive) return parent.GetDirectories(); + else + { + var result = parent.GetDirectories().ToList(); + + foreach (var sub in parent.GetDirectories()) + result.AddRange(sub.GetDirectories(recursive: true)); + + return result; + } + } + + /// + /// Creates the directory if it doesn't already exist. + /// + public static DirectoryInfo EnsureExists(this DirectoryInfo folder) + { + if (!folder.Exists()) + Directory.CreateDirectory(folder.FullName); + + // if (!folder.Exists) folder.Create(); This has caching bug in the core .NET code :-( + + return folder; + } + + /// + /// Clears the specified folder by deleting all its sub-directories and files. + /// + public static async Task Clear(this DirectoryInfo folder, bool harshly = true) + { + if (!folder.Exists()) throw new Exception("The specified directory does not exist: " + folder.FullName); + await folder.GetFiles().Do(async (f) => await f.Delete(harshly)); + await folder.GetDirectories().Do(async (f) => await f.Delete(recursive: true, harshly: harshly)); + } + + /// + /// Determines whether this folder is empty of any files or sub-directories. + /// + public static bool IsEmpty(this DirectoryInfo folder) + { + if (folder.GetFiles().Any()) return false; + if (folder.GetDirectories().Any()) return false; + + return true; + } + } +} diff --git a/Olive/-Extensions/Double.cs b/Olive/-Extensions/Double.cs new file mode 100644 index 000000000..d92d4aa19 --- /dev/null +++ b/Olive/-Extensions/Double.cs @@ -0,0 +1,205 @@ +using System; + +namespace Olive +{ + partial class OliveExtensions + { + const int HALF_CIRCLE_DEGREES = 180; + + /// + /// Rounds this value. + /// + public static double Round(this double value, int digits) => + (double)Math.Round((decimal)value, digits, MidpointRounding.AwayFromZero); + + /// + /// Rounds this value. + /// + public static decimal Round(this decimal value, int digits) => + Math.Round(value, digits, MidpointRounding.AwayFromZero); + + /// + /// In mathematics and computer science, truncation is the term for limiting the number of digits right of the decimal point, by discarding the least significant ones. + /// Note that in some cases, truncating would yield the same result as rounding, but truncation does not round up or round down the digits; it merely cuts off at the specified digit. + /// + public static double Truncate(this double value, int places) + { + var multiplier = Math.Pow(10, (double)places); + + if (value > 0) + return Math.Floor(value * multiplier) / multiplier; + else + return Math.Ceiling(value * multiplier) / multiplier; + } + + public static string ToString(this double? value, string format) => ($"{{0:{format}}}").FormatWith(value); + + public static string ToString(this decimal? value, string format) => ($"{{0:{format}}}").FormatWith(value); + + /// + /// Drops the floating point digits from the end of the money string. + /// For example for 1500.00 it will yield "£1,500" and for 18.56 it will yield "£18.56". + /// + public static string ToShortMoneyString(this double value) => "{0:c}".FormatWith(value).TrimEnd(".00"); + + public static string ToShortMoneyString(this double? value) + { + if (value.HasValue) return value.ToShortMoneyString(); + else return string.Empty; + } + + /// + /// Drops the floating point digits from the end of the money string. + /// For example for 1500.00 it will yield "£1,500" and for 18.56 it will yield "£18.56". + /// + public static string ToInformalMoneyString(this double value) + { + var identifiers = new[] { "k", "m", "bn" }; + + for (var i = identifiers.Length; i > 0; i--) + { + var power = 2 + 3 * (i - 1); + + var figure = Math.Pow(10, power); + + if (value % figure == 0 && (value / figure) > 10) + { + value = value / figure * 10; + + if (value > 1000) + return value.ToShortMoneyString() + identifiers[i - 1]; + else + return value.ToShortMoneyString()[0].ToString() + value + identifiers[i - 1]; + } + } + + return value.ToShortMoneyString(); + } + + /// + /// Converts degree into radians. + /// + public static double ToRadians(this double degrees) => Math.PI * degrees / HALF_CIRCLE_DEGREES; + + /// + /// Return this value as a percentages the of the given total. + /// + public static double AsPercentageOf(this double value, double total, bool multiplyBy100 = true, int? roundTo = null) + { + var pc = value / total; + + if (double.IsNaN(pc) || double.IsInfinity(pc)) return 0d; + + if (multiplyBy100) pc = pc * 100d; + + if (roundTo.HasValue) pc = pc.Round(roundTo.Value); + + return pc; + } + + /// + /// Return this value as a percentages the of the given total. + /// + public static decimal AsPercentageOf(this decimal value, decimal total, bool multiplyBy100 = true, int? roundTo = null) + { + var pc = value / total; + + if (multiplyBy100) pc = pc * 100; + + if (roundTo.HasValue) + pc = Math.Round(pc, roundTo.Value); + + return pc; + } + + /// + /// Rounds up to nearest value. + /// + public static double RoundUpToNearest(this double value, double roundIntervals) + { + var remainder = value % roundIntervals; + if (remainder == 0) return value; + + return value + (roundIntervals - remainder); + } + + /// + /// Rounds up to nearest value. + /// + public static decimal RoundUpToNearest(this decimal value, decimal roundIntervals) + { + var remainder = value % roundIntervals; + if (remainder == 0) return value; + + return value + (roundIntervals - remainder); + } + + /// + /// Rounds down to nearest value with the intervals specified. + /// + public static double RoundDownToNearest(this double value, double roundIntervals) => value - (value % roundIntervals); + + /// + /// Rounds down to nearest value with the intervals specified. + /// + public static decimal RoundDownToNearest(this decimal value, decimal roundIntervals) => value - (value % roundIntervals); + + /// + /// Determines if this double value is almost equal to the specified other value. + /// This should be used instead of == or != operators due to the nature of double processing in .NET. + /// + /// Specifies the tolerated level of difference. + public static bool AlmostEquals(this double value, double otherValue, double tolerance = 0.00001) => + Math.Abs(value - otherValue) <= tolerance; + + /// + /// Determines if this float value is almost equal to the specified other value. + /// This should be used instead of == or != operators due to the nature of float processing in .NET. + /// + /// Specifies the tolerated level of difference. + public static bool AlmostEquals(this float value, float otherValue, float tolerance = 0.001f) => + Math.Abs(value - otherValue) <= tolerance; + + public static float LimitMax(this float value, float maxValue) => value > maxValue ? maxValue : value; + + public static float LimitMin(this float value, float minValue) => value < minValue ? minValue : value; + + public static float LimitWithin(this float value, float minValue, float maxValue) => value.LimitMin(minValue).LimitMax(maxValue); + + public static double LimitMax(this double value, double maxValue) => value > maxValue ? maxValue : value; + + public static double LimitMin(this double value, double minValue) => value < minValue ? minValue : value; + + public static double LimitWithin(this double value, double minValue, double maxValue) => value.LimitMin(minValue).LimitMax(maxValue); + + public static int LimitMax(this int value, int maxValue) => value > maxValue ? maxValue : value; + + public static int LimitMin(this int value, int minValue) => value < minValue ? minValue : value; + + public static int LimitWithin(this int value, int minValue, int maxValue) => value.LimitMin(minValue).LimitMax(maxValue); + + public static int CompareTo(this double? @this, double? another) + { + if (@this == another) return 0; + if (another == null) return @this < 0 ? -1 : 1; + if (@this == null) return another < 0 ? 1 : -1; + return @this.Value.CompareTo(another.Value); + } + + public static int CompareTo(this float? @this, float? another) + { + if (@this == another) return 0; + if (another == null) return @this < 0 ? -1 : 1; + if (@this == null) return another < 0 ? 1 : -1; + return @this.Value.CompareTo(another.Value); + } + + public static int CompareTo(this decimal? @this, decimal? another) + { + if (@this == another) return 0; + if (another == null) return @this < 0 ? -1 : 1; + if (@this == null) return another < 0 ? 1 : -1; + return @this.Value.CompareTo(another.Value); + } + } +} diff --git a/Olive/-Extensions/EmbeddedResource.cs b/Olive/-Extensions/EmbeddedResource.cs new file mode 100644 index 000000000..428300f9c --- /dev/null +++ b/Olive/-Extensions/EmbeddedResource.cs @@ -0,0 +1,76 @@ +namespace Olive +{ + using System; + using System.Text; + using System.Diagnostics; + using System.Reflection; + using System.Threading.Tasks; + + partial class OliveExtensions + { + /// + /// Gets the embedded resource name for a specified relative file path in the project. + /// If the resulting resource name does not exist in this assembly it will throw. + /// + /// The default namespace of your Visual Studio project. + /// For example MyRootFolder\MySubFolder\MyFile.cs (this is case sensitive). + public static string GetEmbeddedResourceName(this Assembly assembly, string rootNamespace, string fileRelativePath) + { + var result = rootNamespace + "." + fileRelativePath.Trim('/', '\\').Replace("/", "\\").Replace("\\", "."); + + using (var resource = assembly.GetManifestResourceStream(result)) + if (result == null) + throw new Exception($"The requested embedded resource '{result}' does not exist in the assembly '{assembly.FullName}'"); + + return result; + } + + /// The default namespace of your Visual Studio project. + /// For example MyRootFolder\MySubFolder\MyFile.cs (this is case sensitive). + public static Task ReadEmbeddedResource(this Assembly assembly, string rootNamespace, string fileRelativePath) + { + var resourceName = assembly.GetEmbeddedResourceName(rootNamespace, fileRelativePath); + return assembly.ReadEmbeddedResource(resourceName); + } + + public static async Task ReadEmbeddedResource(this Assembly assembly, string resourceName) + { + try + { + using (var stream = assembly.GetManifestResourceStream(resourceName)) + { + if (stream == null) + throw new Exception("There is no embedded resource named '" + resourceName + + "' in the assembly: " + assembly.FullName); + + return await stream.ReadAllBytes(); + } + } + catch (Exception ex) + { + Debug.WriteLine("Reading embedded resource failed: " + resourceName + Environment.NewLine + ex); + Debug.WriteLine("Available resources:\r\n" + assembly.GetManifestResourceNames().ToLinesString()); + throw; + } + } + + /// The default namespace of your Visual Studio project. + /// For example MyRootFolder\MySubFolder\MyFile.cs (this is case sensitive). + public static Task ReadEmbeddedTextFile(this Assembly assembly, string rootNamespace, string fileRelativePath) + { + var resourceName = assembly.GetEmbeddedResourceName(rootNamespace, fileRelativePath); + return assembly.ReadEmbeddedTextFile(resourceName); + } + + public static Task ReadEmbeddedTextFile(this Assembly assembly, string resourceName) + { + return assembly.ReadEmbeddedTextFile(resourceName, Encoding.UTF8); + } + + public static async Task ReadEmbeddedTextFile(this Assembly assembly, string resourceName, + Encoding encoding) + { + return encoding.GetString(await assembly.ReadEmbeddedResource(resourceName)); + } + } +} \ No newline at end of file diff --git a/Olive/-Extensions/Encryption.cs b/Olive/-Extensions/Encryption.cs new file mode 100644 index 000000000..c50af3df3 --- /dev/null +++ b/Olive/-Extensions/Encryption.cs @@ -0,0 +1,84 @@ +using System; +using System.Security.Cryptography; + +namespace Olive +{ + partial class OliveExtensions + { + public static void FromXmlString(this RSA rsa, string xmlString) + { + var parameters = new RSAParameters(); + + var xmlReader = xmlString.ToXmlReader(); + + Func getBytes = () => xmlReader.ReadContentAsString().ToBytes(); + + if (xmlReader.Name.Equals("RSAKeyValue")) + { + while (xmlReader.Read()) + { + switch (xmlReader.Name) + { + case "Modulus": + parameters.Modulus = getBytes(); + break; + + case "Exponent": + parameters.Exponent = getBytes(); + break; + + case "P": + parameters.P = getBytes(); + break; + + case "Q": + parameters.Q = getBytes(); + break; + + case "DP": + parameters.DP = getBytes(); + break; + + case "DQ": + parameters.DQ = getBytes(); + break; + + case "InverseQ": + parameters.InverseQ = getBytes(); + break; + + case "D": + parameters.D = getBytes(); + break; + + default: + throw new ArgumentException("Invalid XML RSA key."); + } + } + } + else + { + throw new ArgumentException("Invalid XML RSA key."); + } + + rsa.ImportParameters(parameters); + } + + public static string ToXmlString(this RSA rsa, bool includePrivateParameters) + { + var parameters = rsa.ExportParameters(includePrivateParameters); + + return + "" + + $"{parameters.Modulus.ToBase64String()}" + + $"{parameters.Exponent.ToBase64String()}" + + $"

    {parameters.P.ToBase64String()}

    " + + $"{parameters.Q.ToBase64String()}" + + $"{parameters.DP.ToBase64String()}" + + $"{parameters.DQ.ToBase64String()}" + + $"{parameters.InverseQ.ToBase64String()}" + + $"{parameters.D.ToBase64String()}" + + "
    "; + } + } +} diff --git a/Olive/-Extensions/Exception.cs b/Olive/-Extensions/Exception.cs new file mode 100644 index 000000000..7aece914e --- /dev/null +++ b/Olive/-Extensions/Exception.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace Olive +{ + partial class OliveExtensions + { + public static string ToFullMessage(this Exception ex) + { + return ToFullMessage(ex, additionalMessage: null, includeStackTrace: false, includeSource: false, includeData: false); + } + + /// + /// Returns a more complete text dump of this exception, than just its text. + /// + public static string ToFullMessage(this Exception error, string additionalMessage, bool includeStackTrace, bool includeSource, bool includeData) + { + if (error == null) + throw new NullReferenceException("This exception object is null"); + var resultBuilder = new StringBuilder(); + resultBuilder.AppendLineIf(additionalMessage, additionalMessage.HasValue()); + var err = error; + while (err != null) + { + resultBuilder.AppendLine(err.Message); + if (includeData && err.Data != null && err.Data.Count > 0) + { + resultBuilder.AppendLine("\r\nException Data:\r\n{"); + foreach (var i in err.Data) + resultBuilder.AppendLine(ToLogText(i).WithPrefix(" ")); + + resultBuilder.AppendLine("}"); + } + + if (err is ReflectionTypeLoadException) + { + foreach (var loaderEx in (err as ReflectionTypeLoadException).LoaderExceptions) + resultBuilder.AppendLine("Type load exception: " + loaderEx.ToFullMessage()); + } + + err = err.InnerException; + if (err != null) + { + resultBuilder.AppendLine(); + if (includeStackTrace) + resultBuilder.AppendLine("###############################################"); + resultBuilder.Append("Base issue: "); + } + } + + if (includeStackTrace && error.StackTrace.HasValue()) + { + var stackLines = error.StackTrace.Or("").Trim().ToLines(); + stackLines = stackLines.Except(l => l.Trim().StartsWith("at System.Data.")).ToArray(); + resultBuilder.AppendLine(stackLines.ToString("\r\n\r\n").WithPrefix("\r\n--------------------------------------\r\nSTACK TRACE:\r\n\r\n")); + } + + return resultBuilder.ToString(); + } + + /// + /// It returns ToString for all objects except DictionaryEntries. + /// + public static string ToLogText(object item) + { + try + { + if (item is DictionaryEntry) + return ((DictionaryEntry)item).Get(x => x.Key + ": " + x.Value); + return item.ToString(); + } + catch + { + // No logging is needed + return "?"; + } + } + + /// + /// Creates a log-string from the Exception. + /// The result includes the stacktrace, innerexception et cetera, separated by . + /// + /// The exception to create the string from. + /// Additional message to place at the top of the string, maybe be empty or null. + public static string ToLogString(this Exception ex, string additionalMessage) + { + var r = new StringBuilder(); + r.AppendLine(ex.ToFullMessage(additionalMessage, includeStackTrace: true, includeSource: true, includeData: true)); + return r.ToString(); + } + + public static string ToLogString(this Exception ex) => ToLogString(ex, null); + + /// + /// Adds a piece of data to this exception. + /// + public static Exception AddData(this Exception exception, string key, object value) + { + if (value != null) + { + try + { + exception.Data.Add(key, value); + } + catch + { + // Not serializable + try + { + exception.Data.Add(key, value.ToString()); + } + catch + { + // No logging is needed + } + } + } + + return exception; + } + } +} \ No newline at end of file diff --git a/Olive/-Extensions/Expression.cs b/Olive/-Extensions/Expression.cs new file mode 100644 index 000000000..0e2b50528 --- /dev/null +++ b/Olive/-Extensions/Expression.cs @@ -0,0 +1,61 @@ +using System; +using System.Linq.Expressions; +using System.Reflection; + +namespace Olive +{ + partial class OliveExtensions + { + /// + /// Determines whether this property info is the specified property (in lambda expression). + /// + public static bool Is(this PropertyInfo property, Expression> expression) + { + if (!typeof(T).IsA(property.DeclaringType)) return false; + return expression.GetPropertyPath() == property.Name; + } + + /// + /// Gets the property name for a specified expression. + /// + public static MemberInfo GetMember(this Expression> memberExpression) + { + var asMemberExpression = memberExpression.Body as MemberExpression; + + if (asMemberExpression == null) + { + // Maybe Unary: + asMemberExpression = (memberExpression.Body as UnaryExpression)?.Operand as MemberExpression; + } + + if (asMemberExpression == null) throw new Exception("Invalid expression"); + + return asMemberExpression.Member; + } + + public static PropertyInfo GetProperty(this Expression> property) => + property.GetMember() as PropertyInfo; + + /// + /// For example if the expression is (x => x.A.B) it will return A.B. + /// + public static string GetPropertyPath(this Expression expression) + { + if (expression is MemberExpression m) + { + var result = m.Member.Name; + + if (m.Expression.ToString().Contains(".")) + result = m.Expression.GetPropertyPath() + "." + result; + + return result; + } + + if (expression is LambdaExpression l) return l.Body.GetPropertyPath(); + + if (expression is UnaryExpression u) return u.Operand.GetPropertyPath(); + + throw new Exception("Failed to get the property name from this expression: " + expression); + } + } +} \ No newline at end of file diff --git a/Olive/-Extensions/FileInfo.cs b/Olive/-Extensions/FileInfo.cs new file mode 100644 index 000000000..db8925026 --- /dev/null +++ b/Olive/-Extensions/FileInfo.cs @@ -0,0 +1,380 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Olive +{ + partial class OliveExtensions + { + static readonly Encoding DefaultEncoding = CodePagesEncodingProvider.Instance.GetEncoding(1252); + + /// + /// Gets the entire content of this file. + /// + public static Task ReadAllBytes(this FileInfo file) + { + return TryHard(file, async () => await File.ReadAllBytesAsync(file.FullName), "The system cannot read the file: {0}"); + } + + /// + /// Gets the entire content of this file. + /// + public static async Task ReadAllText(this FileInfo file) => await ReadAllText(file, DefaultEncoding); + + public static string NameWithoutExtension(this FileInfo file) => Path.GetFileNameWithoutExtension(file.FullName); + + /// + /// Gets the entire content of this file. + /// + public static async Task ReadAllText(this FileInfo file, Encoding encoding) + { + Func> readFile = async () => + { + using (var stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + using (var reader = new StreamReader(stream, encoding)) + return await reader.ReadToEndAsync(); + } + }; + + return await TryHard(file, readFile, "The system cannot read the file: {0}"); + } + + /// + /// Will try to delete a specified directory by first deleting its sub-folders and files. + /// + /// If set to true, then it will try multiple times, in case the file is temporarily locked. + public static async Task Delete(this FileInfo file, bool harshly) + { + if (file == null) + throw new ArgumentNullException(nameof(file)); + + if (!file.Exists()) return; + + if (!harshly) + { + await Task.Factory.StartNew(file.Delete); + return; + } + + await TryHard(file, async () => await Task.Factory.StartNew(file.Delete), "The system cannot delete the file, even after several attempts. Path: {0}"); + } + + /// + /// Saves the specified content on this file. + /// + public static async Task WriteAllBytes(this FileInfo file, byte[] content) + { + if (!file.Directory.Exists()) + file.Directory.Create(); + + await TryHard(file, async () => await File.WriteAllBytesAsync(file.FullName, content), "The system cannot write the specified content on the file: {0}"); + } + + /// + /// Saves the specified content on this file using the Western European Windows Encoding 1252. + /// + public static async Task WriteAllText(this FileInfo file, string content) => await WriteAllText(file, content, DefaultEncoding); + + /// + /// Saves the specified content on this file. + /// Note: For backward compatibility, for UTF-8 encoding, it will always add the BOM signature. + /// + public static async Task WriteAllText(this FileInfo file, string content, Encoding encoding) + { + if (encoding == null) encoding = DefaultEncoding; + + file.Directory.EnsureExists(); + + if (encoding is UTF8Encoding) encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); + + await File.WriteAllTextAsync(file.FullName, content, encoding); + } + + /// + /// Saves the specified content to the end of this file. + /// + public static async Task AppendAllText(this FileInfo file, string content) => await AppendAllText(file, content, DefaultEncoding); + + /// + /// Saves the specified content to the end of this file. + /// + public static async Task AppendLine(this FileInfo file, string content = null) => + await AppendAllText(file, content + Environment.NewLine, DefaultEncoding); + + /// + /// Saves the specified content to the end of this file. + /// + public static async Task AppendAllText(this FileInfo file, string content, Encoding encoding) + { + if (encoding == null) encoding = DefaultEncoding; + + file.Directory.EnsureExists(); + + await File.AppendAllTextAsync(file.FullName, content, encoding); + } + + /// + /// Copies this file onto the specified desination path. + /// + public static async Task CopyTo(this FileInfo file, FileInfo destinationPath, bool overwrite = true) + { + if (!overwrite && destinationPath.Exists()) return; + + var content = await file.ReadAllBytes(); + await destinationPath.WriteAllBytes(content); + } + + /// + /// Writes the specified content on this file, only when this file does not already have the same content. + /// + public static async Task WriteWhenDifferent(this FileInfo file, string newContent, Encoding encoding) + { + if (file.Exists()) + { + var oldContent = await file.ReadAllText(); + if (newContent == oldContent) + return false; + } + + await file.WriteAllText(newContent, encoding); + return true; + } + + /// + /// Determines whether or not this directory exists. + /// Note: The standard Exists property has a caching bug, so use this for accurate result. + /// + public static bool Exists(this DirectoryInfo folder) + { + if (folder == null) return false; + return Directory.Exists(folder.FullName); + } + + /// + /// Determines whether or not this file exists. + /// Note: The standard Exists property has a caching bug, so use this for accurate result. + /// + public static bool Exists(this FileInfo file) + { + if (file == null) return false; + return File.Exists(file.FullName); + } + + /// + /// Compresses this data into Gzip. + /// + public static async Task GZip(this byte[] data) + { + using (var outFile = new MemoryStream()) + { + using (var inFile = new MemoryStream(data)) + using (var Compress = new GZipStream(outFile, CompressionMode.Compress)) + await inFile.CopyToAsync(Compress); + + return outFile.ToArray(); + } + } + + /// + /// Compresses this string into Gzip. By default it will use UTF8 encoding. + /// + public static async Task GZip(this string data) => await GZip(data, Encoding.UTF8); + + /// + /// Compresses this string into Gzip. + /// + public static async Task GZip(this string data, Encoding encoding) => await encoding.GetBytes(data).GZip(); + + /// + /// Gets the total size of all files in this directory. + /// + public static long GetSize(this DirectoryInfo folder, bool includeSubDirectories = true) => + folder.GetFiles(includeSubDirectories).Sum(x => x.AsFile().Length); + + /// + /// Gets the size of this folder in human readable text. + /// + public static string GetSizeText(this DirectoryInfo folder, bool includeSubDirectories = true, int round = 1) => + folder.GetSize(includeSubDirectories).ToFileSizeString(round); + + /// + /// Gets the size of this file in human readable text. + /// + public static string GetSizeText(this FileInfo file, int round = 1) => file.Length.ToFileSizeString(round); + + /// + /// Detects the characters which are not acceptable in File System and replaces them with a hyphen. + /// + /// The character with which to replace invalid characters in the name. + public static string ToSafeFileName(this string name, char replacement = '-') + { + if (name.IsEmpty()) return string.Empty; + + var controlCharacters = name.Where(c => char.IsControl(c)); + + var invalidChars = new[] { '<', '>', ':', '"', '/', '\\', '|', '?', '*' }.Concat(controlCharacters); + + foreach (var c in invalidChars) + name = name.Replace(c, replacement); + + if (replacement.ToString().HasValue()) + name = name.KeepReplacing(replacement.ToString() + replacement, replacement.ToString()); + + return name.Summarize(255).TrimEnd("..."); + } + + /// + /// Executes this EXE file and returns the standard output. + /// + public static string Execute(this FileInfo exeFile, string args, bool waitForExit = true) => + Execute(exeFile, args, waitForExit, null); + + /// + /// Executes this EXE file and returns the standard output. + /// + public static string Execute(this FileInfo exeFile, string args, bool waitForExit, Action configuration) + { + var output = new StringBuilder(); + + var process = new Process + { + EnableRaisingEvents = true, + + StartInfo = new ProcessStartInfo + { + FileName = exeFile.FullName, + Arguments = args, + WorkingDirectory = exeFile.Directory.FullName, + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + } + }; + + configuration?.Invoke(process); + + process.ErrorDataReceived += (sender, e) => { if (e.Data.HasValue()) { output.AppendLine(e.Data); } }; + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + output.AppendLine(e.Data); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (waitForExit) + { + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new Exception($"Error running '{exeFile.FullName}':{output}"); + } + } + + return output.ToString(); + } + + /// + /// Gets the mime type based on the file extension. + /// + public static string GetMimeType(this FileInfo file) + { + switch (file.Extension.OrEmpty().TrimStart(".")) + { + case "doc": case "docx": return "application/msword"; + case "pdf": return "application/pdf"; + case "ppt": return "application/powerpoint"; + case "rtf": return "application/rtf"; + case "gz": return "application/x-gzip"; + case "zip": return "application/zip"; + case "mpga": case "mp2": return "audio/mpeg"; + case "ram": return "audio/x-pn-realaudio"; + case "ra": return "audio/x-realaudio"; + case "wav": return "audio/x-wav"; + case "gif": return "image/gif"; + case "jpeg": case "jpg": case "jpe": return "image/jpeg"; + case "png": return "image/png"; + case "tiff": case "tif": return "image/tiff"; + case "html": case "htm": return "text/html"; + case "txt": return "text/plain"; + case "mpeg": case "mpg": case "mpe": return "video/mpeg"; + case "mov": case "qt": return "video/quicktime"; + case "avi": return "video/avi"; + case "mid": return "audio/mid"; + case "midi": return "application/x-midi"; + case "divx": return "video/divx"; + case "webm": return "video/webm"; + case "wma": return "audio/x-ms-wma"; + case "mp3": return "audio/mp3"; + case "ogg": return "audio/ogg"; + case "rma": return "audio/rma"; + case "mp4": return "video/mp4"; + case "wmv": return "video/x-ms-wmv"; + case "f4v": return "video/x-f4v"; + case "ogv": return "video/ogg"; + case "3gp": return "video/3gpp"; + default: return "application/octet-stream"; + } + } + + /// + /// Gets the files in this folder. If this folder is null or non-existent it will return an empty array. + /// + public static IEnumerable GetFilesOrEmpty(this DirectoryInfo folder, string searchPattern) + { + if (folder == null || !folder.Exists()) + return Enumerable.Empty(); + + return folder.GetFiles(searchPattern); + } + + // public static async Task ReadAllTextAsync(this FileInfo file) + // { + // using (var reader = File.OpenText(file.FullName)) + // { + // return await reader.ReadToEndAsync(); + // } + // } + + // public static async Task ReadAllBytesAsync(this FileInfo file) + // { + // using (var reader = File.OpenRead(file.FullName)) + // { + // var result = new byte[file.Length]; + // await reader.ReadAsync(result, 0, result.Length); + // return result; + // } + // } + + // /// By default it will be UTF-8 + // public static async Task WriteAllTextAsync(this FileInfo file, string content, Encoding encoding = null) + // { + // if (encoding == null) + // encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); + + // await file.WriteAllBytesAsync(encoding.GetBytes(content)); + // } + + // public static async Task WriteAllBytesAsync(this FileInfo file, byte[] data) + // { + // using (var writer = File.Create(file.FullName)) + // { + // await writer.WriteAsync(data, 0, data.Length); + // } + // } + } +} \ No newline at end of file diff --git a/Olive/-Extensions/Integer.cs b/Olive/-Extensions/Integer.cs new file mode 100644 index 000000000..ce677bb73 --- /dev/null +++ b/Olive/-Extensions/Integer.cs @@ -0,0 +1,205 @@ +using System; +using System.Runtime.CompilerServices; + +namespace Olive +{ + partial class OliveExtensions + { + const int ONE_MILLION = 1000000, TWENTY = 20; + + /// Static mapping array, used by ToWordString for Units. + static readonly string[] NumberWordsUnits = new[] + { + "zero", "one", "two", "three", "four", "five", "six", + "seven", "eight", "nine", "ten", + "eleven", "twelve", "thirteen", "fourteen", "fifteen", + "sixteen", "seventeen", + "eighteen", "nineteen" + }; + + /// Static mapping array, used by ToWordString for Tens. + static readonly string[] NumberWordsTens = new[] + { + "zero", "ten", "twenty", "thirty", "forty", "fifty", + "sixty", "seventy", "eighty", + "ninety" + }; + + /// + /// Rounds up to nearest value with the intervals specified. + /// + public static int RoundUpToNearest(this int value, int roundIntervals) + { + var difference = roundIntervals - (value % roundIntervals); + return value + difference; + } + + /// + /// Rounds down to nearest value with the intervals specified. + /// + public static int RoundDownToNearest(this int value, int roundIntervals) => value - (value % roundIntervals); + + /// + /// Converts this number to a short textual representation. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Base32Integer ToBase32(this int value) => new Base32Integer(value); + + public static int CompareTo(this int? @this, int? another) + { + if (@this == another) return 0; + if (another == null) return @this < 0 ? -1 : 1; + if (@this == null) return another < 0 ? 1 : -1; + return @this.Value.CompareTo(another.Value); + } + + public static string ToString(this int? value, string format) => ($"{{0:{format}}}").FormatWith(value); + + /// + /// To the word string. + /// + /// + /// Some awesome code from http://stackoverflow.com/questions/2729752/converting-numbers-in-to-words-c-sharp + /// + public static string ToWordString(this int number) + { + if (number == 0) return "zero"; + + if (number < 0) + return "minus " + ToWordString(Math.Abs(number)); + + string words = ""; + + if ((number / ONE_MILLION) > 0) + { + words += ToWordString(number / ONE_MILLION) + " million "; + number %= ONE_MILLION; + } + + if ((number / 1000) > 0) + { + words += ToWordString(number / 1000) + " thousand "; + number %= 1000; + } + + if ((number / 100) > 0) + { + words += ToWordString(number / 100) + " hundred "; + number %= 100; + } + + if (number > 0) + { + if (words != "") + words += "and "; + + if (number < TWENTY) + words += NumberWordsUnits[number]; + else + { + words += NumberWordsTens[number / 10]; + if ((number % 10) > 0) + words += "-" + NumberWordsUnits[number % 10]; + } + } + + return words; + } + + /// + /// Emits a user readable file size (including units). + /// + public static string ToFileSizeString(this long fileSize, string units, int round) + { + if ("MB".Equals(units, StringComparison.OrdinalIgnoreCase)) + return string.Format("{0:0.0} MB", Math.Round((double)fileSize / 0x100000, round)); + + var suffix = new[] { "B", "KB", "MB", "GB", "TB" }; + long index = 0; + + while (fileSize > 0x400 && index < suffix.Length) + { + fileSize = fileSize / 0x400; + index++; + } + + return string.Concat(fileSize, " ", suffix[index]); + } + + /// + /// Gets the size text for the given number of bytes. E.g. 4.5MB or 11KB. + /// + public static string ToFileSizeString(this int size, int round = 1) => ToFileSizeString((long)size, round); + + /// + /// Gets the size text for the given number of bytes. + /// + public static string ToFileSizeString(this long size, int round = 1) + { + var scale = new[] { "B", "KB", "MB", "GB", "TB" }; + if (size == 0) return "0" + scale[0]; + + var sign = Math.Sign(size); + size = Math.Abs(size); + + var place = Convert.ToInt32(Math.Floor(Math.Log(size, 1024))); + var num = Math.Round(size / Math.Pow(1024, place), round); + + return (sign * num) + scale[place]; + // return (Math.Sign(size) * num).ToString() + scale[place]; + } + + /// + /// Emits a user readable file size (including units). + /// + public static string ToFileSizeString(this int fileSize, string units, int round) => + ToFileSizeString((long)fileSize, units, round); + + /// + /// Return this value as a percentages the of the given total. + /// + /// Multiply this by 100. + /// Rounding decimals to. + public static double AsPercentageOf(this int value, int total, bool multiplyBy100 = true, int? roundTo = 0) => + AsPercentageOf((double)value, total, multiplyBy100, roundTo); + + /// + /// E.g. converts 1 to 1st. Or converts 13 to 13th. + /// + public static string ToOrdinal(this int number) + { + switch (number % 100) + { + case 11: + case 12: + case 13: + return number + "th"; + default: + // Other numbers are fine. + break; + } + + switch (number % 10) + { + case 1: + return number + "st"; + case 2: + return number + "nd"; + case 3: + return number + "rd"; + default: + return number + "th"; + } + } + + /// + /// Concerts this integer value to GUID. + /// + public static Guid ToGuid(this int value) + { + var bytes = new byte[16]; + BitConverter.GetBytes(value).CopyTo(bytes, 0); + return new Guid(bytes); + } + } +} \ No newline at end of file diff --git a/Olive/-Extensions/Linq.cs b/Olive/-Extensions/Linq.cs new file mode 100644 index 000000000..665963b83 --- /dev/null +++ b/Olive/-Extensions/Linq.cs @@ -0,0 +1,1288 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Olive +{ +#pragma warning disable GCop112 // This class is too large. Break its responsibilities down into more classes. + partial class OliveExtensions +#pragma warning restore GCop112 // This class is too large. Break its responsibilities down into more classes. + { + static Random RandomProvider = new Random(LocalTime.Now.TimeOfDay.Milliseconds); + + public static string ToString(this IEnumerable list, string seperator) + { + if (list == null) return "{NULL}"; + return ToString(list.Cast(), seperator); + } + + public static string ToFormatString(this IEnumerable list, string format, string seperator, string lastSeperator) => + list.Select(i => format.FormatWith(i)).ToString(seperator, lastSeperator); + + public static bool LacksKey(this IDictionary dictionary, TKey key) => + !dictionary.ContainsKey(key); + + public static bool Lacks(this IDictionary dictionary, object key) => !dictionary.Contains(key); + + public static bool Any(this IEnumerable list, Func predicate) => list.Any(predicate); + + public static string ToFormatString(this IEnumerable list, string format, string seperator) => + list.Select(i => format.FormatWith(i)).ToString(seperator); + + public static string ToString(this IEnumerable list, string seperator) => ToString(list, seperator, seperator); + + public static string ToString(this IEnumerable list, string seperator, string lastSeperator) + { + var result = new StringBuilder(); + + var items = list.Cast().ToArray(); + + for (int i = 0; i < items.Length; i++) + { + var item = items[i]; + + if (item == null) result.Append("{NULL}"); + else result.Append(item.ToString()); + + if (i < items.Length - 2) + result.Append(seperator); + + if (i == items.Length - 2) + result.Append(lastSeperator); + } + + return result.ToString(); + } + + public static int IndexOf(this IEnumerable list, T element) + { + if (list == null) + throw new NullReferenceException("No collection is given for the extension method IndexOf()."); + + if (list.Contains(element) == false) return -1; + + int result = 0; + foreach (var el in list) + { + if (el == null) + { + if (element == null) return result; + else continue; + } + + if (el.Equals(element)) return result; + result++; + } + + return -1; + } + + /// + /// Gets the index of the first item in this list which matches the specified criteria. + /// + public static int IndexOf(this IEnumerable list, Func criteria) + { + var result = 0; + + foreach (var item in list) + { + if (criteria(item)) return result; + + result++; + } + + return -1; + } + + public static void RemoveWhere(this IList list, Func selector) + { + lock (list) + { + var itemsToRemove = list.Where(selector).ToList(); + list.Remove(itemsToRemove); + } + } + + /// + /// Adds all items from a specified dictionary to this dictionary. + /// + public static void Add(this IDictionary dictionary, IDictionary items) + { + foreach (var item in items) + dictionary.Add(item.Key, item.Value); + } + + public static void RemoveWhere( + this IDictionary dictionary, + Func, bool> selector) + { + lock (dictionary) + { + var toRemove = dictionary.Where(selector).ToList(); + + foreach (var item in toRemove) dictionary.Remove(item.Key); + } + } + + public static void RemoveWhereKey(this IDictionary dictionary, Func selector) + { + lock (dictionary) + { + var toRemove = dictionary.Where(x => selector(x.Key)).ToList(); + + foreach (var item in toRemove) dictionary.Remove(item.Key); + } + } + + /// + /// Gets all items of this list except those meeting a specified criteria. + /// + /// Exclusion criteria + [EscapeGCop("It is the Except definition and so it cannot call itself")] + public static IEnumerable Except(this IEnumerable list, Func criteria) => list.Where(i => !criteria(i)); + + public static IEnumerable Except(this IEnumerable list, T item) => list.Except(new T[] { item }); + + [EscapeGCop("It is the Except definition and so it cannot call itself")] + public static IEnumerable Except(this IEnumerable list, params T[] items) + { + if (items == null) return list; + + return list.Where(x => !items.Contains(x)); + } + + public static IEnumerable Except(this IEnumerable list, List itemsToExclude) => list.Except(itemsToExclude.ToArray()); + + public static IEnumerable Except(this IEnumerable list, IEnumerable itemsToExclude) => + list.Except(itemsToExclude.ToArray()); + + public static IEnumerable Except(this IEnumerable list, IEnumerable itemsToExclude, bool alsoDistinct = false) + { + var result = list.Except(itemsToExclude.ToArray()); + + if (alsoDistinct) result = result.Distinct(); + + return result; + } + + public static IEnumerable Except(this IEnumerable list, IEnumerable itemsToExclude) => + list.Except(itemsToExclude.ToArray()); + + /// + /// Gets all Non-NULL items of this list. + /// + public static IEnumerable ExceptNull(this IEnumerable list) where T : class => list.Where(i => i != null); + + /// + /// Gets all Non-NULL items of this list. + /// + public static IEnumerable ExceptNull(this IEnumerable list) => + list.Where(i => i.HasValue).Select(x => x.Value); + + /// + /// Gets all Non-NULL items of this list. + /// + public static IEnumerable ExceptNull(this IEnumerable list) => + list.Where(i => i.HasValue).Select(x => x.Value); + + /// + /// Gets all Non-NULL items of this list. + /// + public static IEnumerable ExceptNull(this IEnumerable list) => + list.Where(i => i.HasValue).Select(x => x.Value); + + /// + /// Gets all Non-NULL items of this list. + /// + public static IEnumerable ExceptNull(this IEnumerable list) => + list.Where(i => i.HasValue).Select(x => x.Value); + + /// + /// Gets all Non-NULL items of this list. + /// + public static IEnumerable ExceptNull(this IEnumerable list) => list.Where(i => i.HasValue).Select(x => x.Value); + + /// + /// Gets all Non-NULL items of this list. + /// + public static IEnumerable ExceptNull(this IEnumerable list) => + list.Where(i => i.HasValue).Select(x => x.Value); + + /// + /// Gets all Non-NULL items of this list. + /// + public static IEnumerable ExceptNull(this IEnumerable list) => list.Where(i => i.HasValue).Select(x => x.Value); + + public static bool IsSingle(this IEnumerable list) => IsSingle(list, x => true); + + public static bool IsSingle(this IEnumerable list, Func criteria) + { + var visitedAny = false; + + foreach (var item in list.Where(criteria)) + { + if (visitedAny) return false; + visitedAny = true; + } + + return visitedAny; + } + + [EscapeGCop("This is for performance reasons.")] + public static bool IsSingle(this IEnumerable list, out T first) where T : class + { + first = null; + + foreach (var item in list) + if (first == null) first = item; + else return false; + + return first != null; + } + + [EscapeGCop("This is impossible to instantiate an interface so, we cannot use IEnumerable.")] + public static List Clone(this List list) + { + if (list == null) return list; + return new List(list); + } + + /// + /// Adds the specified list to the beginning of this list. + /// + public static IEnumerable Prepend(this IEnumerable list, IEnumerable prefix) => prefix.Concat(list); + + /// + /// Adds the specified item(s) to the beginning of this list. + /// + public static IEnumerable Prepend(this IEnumerable list, params T[] prefix) => prefix.Concat(list); + + /// + /// Performs an action for all items within the list. + /// + public static void Do(this IEnumerable list, ItemHandler action) + { + if (list == null) return; + + foreach (var item in list) + action?.Invoke(item); + } + + /// + /// Performs an action for all items within the list. + /// + public static async Task Do(this IEnumerable list, Func func) + { + if (list == null) return; + + foreach (var item in list) + await func?.Invoke(item); + } + + /// + /// Performs an action for all items within the list. + /// It will provide the index of the item in the list to the action handler as well. + /// + public static void Do(this IEnumerable list, Action action) + { + if (list == null) return; + + var index = 0; + + foreach (var item in list) + { + action?.Invoke(item, index); + index++; + } + } + + public delegate void ItemHandler(T arg); + + public static IEnumerable Concat(this IEnumerable list, T item) => list.Concat(new T[] { item }); + + public static void AddRange(this IList list, IEnumerable items) + { + foreach (T item in items) + list.Add(item); + } + + /// + /// Gets the minimum value of a specified expression in this list. If the list is empty, then the default value of the expression will be returned. + /// + public static R MinOrDefault(this IEnumerable list, Func expression) + { + if (list.None()) return default(R); + return list.Min(expression); + } + + /// + /// Gets the maximum value of a specified expression in this list. If the list is empty, then the default value of the expression will be returned. + /// + public static R MaxOrDefault(this IEnumerable list, Func expression) + { + if (list.None()) return default(R); + return list.Max(expression); + } + + /// + /// Gets the maximum value of the specified expression in this list. + /// If no items exist in the list then null will be returned. + /// + public static R? MaxOrNull(this IEnumerable list, Func expression) where R : struct + { + if (list.None()) return default(R?); + return list.Max(expression); + } + + /// + /// Gets the maximum value of the specified expression in this list. + /// If no items exist in the list then null will be returned. + /// + public static R? MaxOrNull(this IEnumerable list, Func expression) where R : struct => + list.MaxOrNull(item => (R?)expression(item)); + + /// + /// Gets the minimum value of the specified expression in this list. + /// If no items exist in the list then null will be returned. + /// + public static R? MinOrNull(this IEnumerable list, Func expression) where R : struct + { + if (list.None()) return default(R?); + return list.Min(expression); + } + + /// + /// Gets the minimum value of the specified expression in this list. + /// If no items exist in the list then null will be returned. + /// + public static R? MinOrNull(this IEnumerable list, Func expression) where R : struct => + list.MinOrNull(item => (R?)expression(item)); + + public static bool IsSubsetOf(this IEnumerable source, IEnumerable target) => target.ContainsAll(source); + + /// + /// Determines whether this list is equivalent to another specified list. Items in the list should be distinct for accurate result. + /// + public static bool IsEquivalentTo(this IEnumerable list, IEnumerable other) + { + if (list == null) list = new T[0]; + if (other == null) other = new T[0]; + + if (list.Count() != other.Count()) return false; + + foreach (var item in list) + if (!other.Contains(item)) return false; + return true; + } + + /// + /// Counts the number of items in this list matching the specified criteria. + /// + public static int Count(this IEnumerable list, Func criteria) => list.Count((x, i) => criteria(x, i)); + + /// + /// Picks an item from the list. + /// + public static T PickRandom(this IEnumerable list) + { + if (list.Any()) + { + var index = RandomProvider.Next(list.Count()); + return list.ElementAt(index); + } + else return default(T); + } + + public static IEnumerable PickRandom(this IEnumerable list, int number) + { + if (number < 1) throw new ArgumentException("number should be greater than 0."); + + var items = list as List ?? list.ToList(); + + if (number >= items.Count) number = items.Count; + + var randomIndices = RandomProvider.PickNumbers(number, 0, items.Count - 1); + + foreach (var index in randomIndices) + yield return items[index]; + } + + /// + /// Works as opposite of Contains(). + /// + public static bool Lacks(this IEnumerable list, T item) => !list.Contains(item); + + /// + /// Determines if this list lacks any item in the specified list. + /// + public static bool LacksAny(this IEnumerable list, IEnumerable items) => !list.ContainsAll(items); + + /// + /// Determines if this list lacks all items in the specified list. + /// + public static bool LacksAll(this IEnumerable list, IEnumerable items) => !list.ContainsAny(items.ToArray()); + + public static IEnumerable Randomize(this IEnumerable list) + { + if (list.None()) return new T[0]; + + var items = list.ToList(); + + return PickRandom(items, items.Count); + } + + /// + /// Returns a subset of the items in a given collection in a range including the items at lower and upper bounds. + /// + public static IEnumerable Take(this IEnumerable list, int lowerBound, int upperBound) + { + if (upperBound == 0) return Enumerable.Empty(); + if (lowerBound < 0) throw new ArgumentOutOfRangeException(nameof(lowerBound)); + if (upperBound < 0) throw new ArgumentOutOfRangeException(nameof(upperBound)); + + if (lowerBound > upperBound) + throw new ArgumentException("lower bound should be smaller than upper bound.", nameof(upperBound)); + + var result = new List(); + + var index = -1; + foreach (var item in list) + { + index++; + + if (index < lowerBound) continue; + if (index > upperBound) break; + result.Add(item); + } + + return result; + } + + public static IEnumerable Distinct(this IEnumerable list, Func selector) + { + var keys = new List(); + + foreach (var item in list) + { + var keyValue = selector(item); + + if (keys.Contains(keyValue)) continue; + + keys.Add(keyValue); + yield return item; + } + } + + /// + /// Determines of this list contains all items of another given list. + /// + public static bool ContainsAll(this IEnumerable list, IEnumerable items) => + items.All(i => list.Contains(i)); + + /// + /// Determines if this list contains any of the specified items. + /// + public static bool ContainsAny(this IEnumerable list, params T[] items) => list.Intersects(items); + + /// + /// Determines if none of the items in this list meet a given criteria. + /// + public static bool None(this IEnumerable list, Func criteria) => !list.Any(criteria); + + /// + /// Determines if this is null or an empty list. + /// + public static bool None(this IEnumerable list) + { + if (list == null) return true; + return !list.Any(); + } + + /// + /// Determines if this list intersects with another specified list. + /// + public static bool Intersects(this IEnumerable list, IEnumerable otherList) + { + var countList = (list as ICollection)?.Count; + var countOther = (otherList as ICollection)?.Count; + + if (countList == null || countOther == null || countOther < countList) + { + foreach (var item in otherList) + if (list.Contains(item)) + return true; + } + else + { + foreach (var item in list) + if (otherList.Contains(item)) + return true; + } + + return false; + } + + /// + /// Determines if this list intersects with another specified list. + /// + public static bool Intersects(this IEnumerable list, params T[] items) => list.Intersects((IEnumerable)items); + + /// + /// Selects the item with maximum of the specified value. + /// If this list is empty, NULL (or default of T) will be returned. + /// + public static T WithMax(this IEnumerable list, Func keySelector) + { + if (list.None()) return default(T); + return list.Aggregate((a, b) => Comparer.Default.Compare(keySelector(a), keySelector(b)) > 0 ? a : b); + } + + /// + /// Selects the item with minimum of the specified value. + /// + public static T WithMin(this IEnumerable list, Func keySelector) + { + if (list.None()) return default(T); + return list.Aggregate((a, b) => Comparer.Default.Compare(keySelector(a), keySelector(b)) < 0 ? a : b); + } + + /// + /// Gets the element after a specified item in this list. + /// If the specified element does not exist in this list, an ArgumentException will be thrown. + /// If the specified element is the last in the list, NULL will be returned. + /// + public static T GetElementAfter(this IEnumerable list, T item) where T : class + { + if (item == null) + throw new ArgumentNullException(nameof(item)); + + var index = list.IndexOf(item); + if (index == -1) + throw new ArgumentException("The specified item does not exist to this list."); + + if (index == list.Count() - 1) return null; + + return list.ElementAt(index + 1); + } + + /// + /// Gets the element before a specified item in this list. + /// If the specified element does not exist in this list, an ArgumentException will be thrown. + /// If the specified element is the first in the list, NULL will be returned. + /// + public static T GetElementBefore(this IEnumerable list, T item) where T : class + { + if (item == null) + throw new ArgumentNullException(nameof(item)); + + var index = list.IndexOf(item); + if (index == -1) + throw new ArgumentException("The specified item does not exist to this list."); + + if (index == 0) return null; + + return list.ElementAt(index - 1); + } + + public static void AddFormat(this IList list, string format, params object[] arguments) => + list.Add(string.Format(format, arguments)); + + public static void AddFormattedLine(this IList list, string format, params object[] arguments) => + list.Add(string.Format(format + Environment.NewLine, arguments)); + + public static void AddLine(this IList list, string text) => list.Add(text + Environment.NewLine); + + /// + /// Removes a list of items from this list. + /// + public static void Remove(this IList list, IEnumerable itemsToRemove) + { + if (itemsToRemove != null) + { + foreach (var item in itemsToRemove) + if (list.Contains(item)) + list.Remove(item); + } + } + + /// + /// Determines if all items in this collection are unique. + /// + public static bool AreItemsUnique(this IEnumerable collection) + { + if (collection.None()) return true; + + return collection.Distinct().Count() == collection.Count(); + } + + /// + /// Returns the union of this list with the specified other lists. + /// + + public static IEnumerable Union(this IEnumerable list, params IEnumerable[] otherLists) + { + var result = list; + + foreach (var other in otherLists) + result = Enumerable.Union(result, other); + + return result; + } + + /// + /// Returns the union of this list with the specified items. + /// + public static IEnumerable Union(this IEnumerable list, params T[] otherItems) => list.Union((IEnumerable)otherItems); + + public static IEnumerable Concat(this IEnumerable list, params IEnumerable[] otherLists) + { + var result = list; + + foreach (var other in otherLists) result = Enumerable.Concat(result, other); + + return result; + } + + /// + /// Gets the average of the specified expression on all items of this list. + /// If the list is empty, null will be returned. + /// + public static double? AverageOrDefault(this IEnumerable list, Func selector) + { + if (list.None()) return null; + else return list.Average(selector); + } + + /// + /// Gets the average of the specified expression on all items of this list. + /// If the list is empty, null will be returned. + /// + public static double? AverageOrDefault(this IEnumerable list, Func selector) + { + if (list.None()) return null; + else return list.Average(selector); + } + + /// + /// Gets the average of the specified expression on all items of this list. + /// If the list is empty, null will be returned. + /// + public static double? AverageOrDefault(this IEnumerable list, Func selector) + { + if (list.None()) return null; + else return list.Average(selector); + } + + /// + /// Gets the average of the specified expression on all items of this list. + /// If the list is empty, null will be returned. + /// + public static double? AverageOrDefault(this IEnumerable list, Func selector) + { + if (list.None()) return null; + else return list.Average(selector); + } + + /// + /// Gets the average of the specified expression on all items of this list. + /// If the list is empty, null will be returned. + /// + public static decimal? AverageOrDefault(this IEnumerable list, Func selector) + { + if (list.None()) return null; + else return list.Average(selector); + } + + /// + /// Gets the average of the specified expression on all items of this list. + /// If the list is empty, null will be returned. + /// + public static decimal? AverageOrDefault(this IEnumerable list, Func selector) + { + if (list.None()) return null; + else return list.Average(selector); + } + + /// + /// Trims all elements in this list and excludes all null and "empty string" elements from the list. + /// + public static IEnumerable Trim(this IEnumerable list) + { + if (list == null) return Enumerable.Empty(); + + return list.Except(v => string.IsNullOrWhiteSpace(v)).Select(v => v.Trim()).Where(v => v.HasValue()).ToArray(); + } + + /// + /// Determines whether this list of strings contains the specified string. + /// + public static bool Contains(this IEnumerable list, string instance, bool caseSensitive) + { + if (caseSensitive || instance.IsEmpty()) + return list.Contains(instance); + else return list.Any(i => i.HasValue() && i.Equals(instance, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Determines whether this list of strings contains the specified string. + /// + public static bool Lacks(this IEnumerable list, string instance, bool caseSensitive) => + !Contains(list, instance, caseSensitive); + + /// + /// Concats all elements in this list with Environment.NewLine. + /// + public static string ToLinesString(this IEnumerable list) => list.ToString(Environment.NewLine); + + /// + /// Gets the value with the specified key, or null. + /// + public static TValue TryGet(this IDictionary list, TKey key) + { + if (list.TryGetValue(key, out TValue result)) return result; + + return default(TValue); + } + + /// + /// Chops a list into same-size smaller lists. For example: + /// new int[] { 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16 }.Chop(5) + /// will return: { {1,2,3,4,5}, {6,7,8,9,10}, {11,12,13,14,15}, {16} } + /// + public static IEnumerable> Chop(this IEnumerable list, int chopSize) + { + if (chopSize == 0 || list.None()) + { + yield return list; + yield break; + } + + yield return list.Take(chopSize); + + if (list.Count() > chopSize) + { + var rest = list.Skip(chopSize); + + foreach (var item in Chop(rest, chopSize)) + yield return item; + } + } + + /// + /// Gets the keys of this dictionary. + /// + public static IEnumerable GetKeys(this IDictionary dictionary) => dictionary.Select(i => i.Key); + + /// + /// Returns the sum of a timespan selector on this list. + /// + public static TimeSpan Sum(this IEnumerable list, Func selector) + { + var result = TimeSpan.Zero; + foreach (var item in list) result += selector(item); + return result; + } + + /// + /// Returns the indices of all items which matche a specified criteria. + /// + public static IEnumerable AllIndicesOf(IEnumerable list, Func criteria) + { + int index = 0; + + foreach (var item in list) + { + if (criteria(item)) yield return index; + + index++; + } + } + + /// + /// Replaces the specified item in this list with the specified new item. + /// + public static void Replace(this IList list, T oldItem, T newItem) + { + list.Remove(oldItem); + list.Add(newItem); + } + + /// + /// Gets all values from this dictionary. + /// + public static IEnumerable GetAllValues(this IDictionary dictionary) + { + foreach (var item in dictionary) + yield return item.Value; + } + + /// + /// Gets all values from this dictionary. + /// + public static IEnumerable GetAllValues(this IEnumerable> dictionary) + { + foreach (var item in dictionary) + yield return item.Value; + } + + /// + /// Returns all elements of this list except those at the specified indices. + /// + public static IEnumerable ExceptAt(this IEnumerable list, params int[] indices) => + list.Where((item, index) => !indices.Contains(index)); + + /// + /// Returns all elements of this list except the last X items. + /// + public static IEnumerable ExceptLast(this IEnumerable list, int count = 1) + { + var last = list.Count(); + return list.ExceptAt(Enumerable.Range(last - count, count).ToArray()); + } + + /// + /// Returns all elements of this list except the first X items. + /// + public static IEnumerable ExceptFirst(this IEnumerable list, int count = 1) => + list.ExceptAt(Enumerable.Range(0, count).ToArray()); + + /// + /// Removes the nulls from this list. + /// + public static void RemoveNulls(this IList list) => list.RemoveWhere(i => i == null); + + /// + /// Tries to the remove an item with the specified key from this dictionary. + /// + public static K TryRemove(this System.Collections.Concurrent.ConcurrentDictionary list, T key) + { + if (list.TryRemove(key, out K result)) + return result; + else return default(K); + } + + /// + /// Tries to the remove an item with the specified key from this dictionary. + /// + public static K TryRemoveAt(this System.Collections.Concurrent.ConcurrentDictionary list, int index) + { + try + { + return list.TryRemove(list.Keys.ElementAt(index)); + } + catch + { + // No logging is needed + return default(K); + } + } + + /// + /// Determines whether this least contains at least the specified number of items. + /// This can be faster than calling "x.Count() >= N" for complex iterators. + /// + public static bool ContainsAtLeast(this System.Collections.IEnumerable list, int numberOfItems) + { + // Special case for List: + if (list is ICollection asList) return asList.Count >= numberOfItems; + + var itemsCount = 0; + var result = itemsCount == numberOfItems; + var enumerator = list.GetEnumerator(); + + while (enumerator.MoveNext()) + { + itemsCount++; + + if (itemsCount >= numberOfItems) return true; + } + + return result; + } + + /// + /// Converts this to a KeyValueCollection. + /// + public static NameValueCollection ToNameValueCollection(this IDictionary dictionary) + { + var result = new NameValueCollection(); + + foreach (var item in dictionary) + result.Add(item.Key.ToStringOrEmpty(), item.Value.ToStringOrEmpty()); + + return result; + } + + /// + /// Adds the properties of a specified [anonymous] object as items to this dictionary. + /// It ignores duplicate entries and null values. + /// + public static void AddFromProperties(this Dictionary dictionary, object data) + { + if (data == null) + throw new ArgumentNullException(nameof(data)); + + foreach (var property in data.GetType().GetProperties()) + { + if (dictionary.ContainsKey(property.Name)) continue; + + var value = property.GetValue(data); + + if (value == null) continue; + + if (typeof(TValue) == typeof(string) && value.GetType() != typeof(string)) + value = value.ToStringOrEmpty(); + + if (!value.GetType().IsA(typeof(TValue))) + { + throw new Exception("The value on property '{0}' is of type '{1}' which cannot be casted as '{2}'." + .FormatWith(property.Name, value.GetType().FullName, typeof(TValue).FullName)); + } + + dictionary.Add(property.Name, (TValue)value); + } + } + + /// + /// Adds the specified key/value pair to this list. + /// + public static KeyValuePair Add(this IList> list, TKey key, TValue value) + { + var result = new KeyValuePair(key, value); + list.Add(result); + + return result; + } + + /// + /// Adds the specified items to this set. + /// + public static void AddRange(this HashSet set, IEnumerable items) + { + if (items == null) return; + + foreach (var item in items) + set.Add(item); + } + + /// + /// Dequeues all queued items in the right order. + /// + public static IEnumerable DequeueAll(this Queue @this) + { + while (@this.Count > 0) + yield return @this.Dequeue(); + } + + /// + /// Returns a HashSet of type T (use for performance in place of ToList()). + /// + public static HashSet ToHashSet(this IEnumerable collection) => new HashSet(collection); + + /// + /// Gets all indices of the specified item in this collection. + /// + public static IEnumerable AllIndicesOf(this IEnumerable all, T item) + { + var index = 0; + foreach (var i in all) + { + if (ReferenceEquals(item, null)) + { + if (ReferenceEquals(i, null)) yield return index; + } + else + { + if (item.Equals(i)) yield return index; + } + + index++; + } + } + + /// + /// Returns an empty collection if this collection is null. + /// + public static IEnumerable OrEmpty(this IEnumerable collection) => collection ?? Enumerable.Empty(); + + public static IEnumerable TakePage(this IEnumerable list, int? pageSize, int currentPage) + { + if (pageSize == null) return list; + + if (currentPage < 1) currentPage = 1; + + var startIndex = (pageSize.Value) * (currentPage - 1); + var endIndex = startIndex + pageSize.Value; + + if (currentPage > 1 && startIndex > list.Count()) + return TakePage(list, pageSize, 1); + + if (currentPage > 1) endIndex--; + + return list.Take(startIndex, endIndex); + } + + /// + /// Determines if the specified item exists in this list. + /// + public static bool Contains(this IEnumerable items, T? item) where T : struct + { + if (item == null) return false; + + return Enumerable.Contains(items, item.Value); + } + + /// + /// Determines if the specified item exists in this list. + /// + public static bool Lacks(this IEnumerable items, T? item) where T : struct => !items.Contains(item); + + /// + /// Determines if this item is in the specified list. + /// + public static bool IsAnyOf(this T? item, IEnumerable items) where T : struct + { + if (item == null) return false; + + return items.Contains(item.Value); + } + + /// + /// Specifies whether this list contains any of the specified values. + /// + public static bool ContainsAny(this IEnumerable list, params Guid?[] ids) => list.ContainsAny(ids.ExceptNull().ToArray()); + + /// + /// Finds the median of a list of integers + /// + public static int Median(this IEnumerable numbers) + { + numbers = numbers.ToList(); + + if (numbers.None()) + throw new ArgumentException("number list cannot be empty"); + + var ordered = numbers.OrderBy(i => i).ToList(); + + var middle = numbers.Count() / 2; + + if (numbers.Count() % 2 == 1) + return ordered.ElementAt(middle); + + // Return the average of the two middle numbers. + return (ordered.ElementAt(middle - 1) + ordered.ElementAt(middle)) / 2; + } + + /// + /// If this list is null or empty, then the specified alternative will be returned, otherwise this will be returned. + /// + public static IEnumerable Or(this IEnumerable list, IEnumerable valueIfEmpty) + { + if (list.None()) return valueIfEmpty; + else return list; + } + + public static Dictionary ToDictionary(this IEnumerable> items) => + items.ToDictionary(x => x.Key, x => x.Value); + + public static IEnumerable Except(this IEnumerable list, Type excludedType) + { + if (list == null) throw new NullReferenceException("No collection is given for the extension method Except()."); + + if (excludedType == null) throw new ArgumentNullException(nameof(excludedType)); + + var excludedTypeInfo = excludedType; + + if (excludedTypeInfo.IsClass) + { + foreach (var item in list) + { + if (item == null) yield return item; + + var type = item.GetType(); + + if (type == excludedType) continue; + else if (type.IsSubclassOf(excludedType)) continue; + else yield return item; + } + } + else if (excludedTypeInfo.IsInterface) + { + foreach (var item in list) + { + if (item == null) yield return item; + + var type = item.GetType(); + + if (type.Implements(excludedType)) continue; + else yield return item; + } + } + else throw new NotSupportedException("Except(System.Type) method does not recognize " + excludedType); + + // return list.Where(each => each == null || (each.GetType().IsAssignableFrom(excludedType) == false)); + } + + /// + /// Creates a list of the specified runtime type including all items of this collection. + /// + public static IEnumerable Cast(this IEnumerable list, Type type) + { + if (type == null) + throw new ArgumentNullException(nameof(type)); + + // Generic List is in mscorelib, just like System.String: + + var listType = typeof(string).Assembly.GetType("System.Collections.Generic.List`1").MakeGenericType(type); + var result = (IList)Activator.CreateInstance(listType); + + foreach (var item in list) + result.Add(item); + + return result; + } + + public static IEnumerable OrderBy(this IEnumerable list, string propertyName) + { + if (propertyName.IsEmpty()) + throw new ArgumentNullException(nameof(propertyName)); + + var property = typeof(TSource).GetProperty(propertyName); + if (property == null) + throw new ArgumentException("{0} is not a readable property of {1} type.".FormatWith(propertyName, typeof(TSource).FullName)); + + return list.OrderBy(new Func((new PropertyComparer(property)).ExtractValue)); + } + + /// + /// Sorts this list by the specified property name. + /// + public static IEnumerable OrderBy(this IEnumerable list, string propertyName) + { + if (propertyName.IsEmpty()) + throw new ArgumentNullException(nameof(propertyName)); + + if (propertyName.EndsWith(" DESC")) + return OrderByDescending(list, propertyName.TrimEnd(" DESC".Length)); + + Type itemType = null; + foreach (var item in list) + { + itemType = item.GetType(); + break; + } + + // Empty list: + if (itemType == null) return list; + + var property = itemType.GetProperty(propertyName); + if (property == null) throw new ArgumentException("Unusable property name specified:" + propertyName); + + var comparer = new PropertyComparer(property); + + var result = new ArrayList(); + + foreach (var item in list) + result.Add(item); + + result.Sort(comparer); + + return result; + } + + public static IEnumerable OrderByDescending(this IEnumerable list, string property) + { + if (string.IsNullOrEmpty(property)) throw new ArgumentNullException(nameof(property)); + + var result = new ArrayList(); + foreach (var item in list.OrderBy(property)) + result.Insert(0, item); + + return result; + } + + public static IEnumerable OrderByDescending(this IEnumerable list, string propertyName) + { + if (propertyName.IsEmpty()) throw new ArgumentNullException(nameof(propertyName)); + + var property = typeof(TSource).GetProperty(propertyName); + + if (property == null) + throw new ArgumentException($"{propertyName} is not a readable property of {typeof(TSource).FullName}."); + + return list.OrderByDescending(new Func((new PropertyComparer(property)).ExtractValue)); + } + + public static TValue GetOrDefault(this IDictionary dictionary, TKey key) + { + var keyType = typeof(TKey); + + if (keyType.IsValueType || keyType == typeof(string) || keyType == typeof(Type)) + { + if (dictionary.TryGetValue(key, out TValue result)) return result; + return default(TValue); + } + else + { + return dictionary.Keys.FirstOrDefault(k => k.Equals(key)).Get(k => dictionary[k]); + } + } + + public static T FirstOrDefault(this ICollection collection, Func selector) + { + foreach (var item in collection) + if (selector(item)) return item; + + return default(T); + } + + public static Task AwaitAll(this IEnumerable list, Func task) + { + var tasks = new List(); + + if (list != null) + foreach (var item in list) + tasks.Add(task?.Invoke(item) ?? Task.CompletedTask); + + return Task.WhenAll(tasks); + } + + public static async Task> AwaitAll(this IEnumerable list, Func> task) + { + var tasks = new List>(); + + if (list != null) + foreach (var item in list) + tasks.Add(task?.Invoke(item)); + + await Task.WhenAll(tasks); + + return tasks.Select(x => x.Result); + } + + public static async Task> AwaitAll(this IEnumerable> list) => await Task.WhenAll(list); + + public static async Task> ToList(this Task> list) => (await list).ToList(); + + public static async Task> Select( + this Task> list, + Func func) + { + var awaited = await list; + return awaited.Select(func); + } + + public static async Task> Select( + this Task> list, + Func> func) + { + var awaited = await list; + var resultTasks = awaited.Select(func); + return await Task.WhenAll(resultTasks); + } + } +} \ No newline at end of file diff --git a/Olive/-Extensions/Object.Get.cs b/Olive/-Extensions/Object.Get.cs new file mode 100644 index 000000000..fa2df4198 --- /dev/null +++ b/Olive/-Extensions/Object.Get.cs @@ -0,0 +1,261 @@ +using System; +using System.Threading.Tasks; + +namespace Olive +{ + partial class OliveExtensions + { + /// + /// Performs a specified action on this item if it is not null. If it is null, it simply ignores the action. + /// + public static void Perform(this T item, Action action) where T : class + { + if (item != null) action?.Invoke(item); + } + + /// + /// Performs a specified action on this item if it is not null. If it is null, it simply ignores the action. + /// + public static async Task Perform(this T item, Func func) where T : class + { + if (item != null) await func?.Invoke(item); + } + + /// + /// Gets a specified member of this object. If this is null, null will be returned. Otherwise the specified expression will be returned. + /// + [System.Diagnostics.DebuggerStepThrough] + public static K Get(this T item, Func selector) + { + if (object.ReferenceEquals(item, null)) + return default(K); + else + { + try + { + return selector(item); + } + catch (NullReferenceException) + { + return default(K); + } + } + } + + /// + /// Gets a specified member of this object. If this is null, null will be returned. Otherwise the specified expression will be returned. + /// + [System.Diagnostics.DebuggerStepThrough] + public static K? Get(this T item, Func selector) where K : struct + { + if (item == null) return null; + + try + { + return selector(item); + } + catch (NullReferenceException) + { + return default(K?); + } + } + + /// + /// Gets a specified member of this object. If this is null, null will be returned. Otherwise the specified expression will be returned. + /// + [System.Diagnostics.DebuggerStepThrough] + public static K Get(TimeSpan? item, Func selector) + { + if (item == null) return default(K); + + try + { + return selector(item.Value); + } + catch (NullReferenceException) + { + return default(K); + } + } + + /// + /// Gets a specified member of this object. If this is null, null will be returned. Otherwise the specified expression will be returned. + /// + [System.Diagnostics.DebuggerStepThrough] + public static Guid? Get(this T item, Func selector) where T : class + { + if (item == null) return null; + + try + { + return selector(item); + } + catch (NullReferenceException) + { + return null; + } + } + + /// + /// Gets a specified member of this object. If this is null, null will be returned. Otherwise the specified expression will be returned. + /// + [System.Diagnostics.DebuggerStepThrough] + public static int? Get(this T item, Func selector) where T : class + { + if (item == null) return null; + + try + { + return selector(item); + } + catch (NullReferenceException) + { + return null; + } + } + + /// + /// Gets a specified member of this object. If this is null, null will be returned. Otherwise the specified expression will be returned. + /// + [System.Diagnostics.DebuggerStepThrough] + public static double? Get(this T item, Func selector) where T : class + { + if (item == null) return null; + + try + { + return selector(item); + } + catch (NullReferenceException) + { + return null; + } + } + + /// + /// Gets a specified member of this object. If this is null, null will be returned. Otherwise the specified expression will be returned. + /// + [System.Diagnostics.DebuggerStepThrough] + public static decimal? Get(this T item, Func selector) where T : class + { + if (item == null) return null; + + try + { + return selector(item); + } + catch (NullReferenceException) + { + return null; + } + } + + /// + /// Gets a specified member of this object. If this is null, null will be returned. Otherwise the specified expression will be returned. + /// + [System.Diagnostics.DebuggerStepThrough] + public static bool? Get(this T item, Func selector) where T : class + { + if (item == null) return null; + + try + { + return selector(item); + } + catch (NullReferenceException) + { + return null; + } + } + + /// + /// Gets a specified member of this object. If this is null, null will be returned. Otherwise the specified expression will be returned. + /// + [System.Diagnostics.DebuggerStepThrough] + public static string Get(this DateTime? item, Func selector) + { + if (item == null) return null; + + try + { + return selector?.Invoke(item); + } + catch (NullReferenceException) + { + return null; + } + } + + /// + /// Gets a specified member of this object. If this is null, null will be returned. Otherwise the specified expression will be returned. + /// + [System.Diagnostics.DebuggerStepThrough] + public static byte? Get(this T item, Func selector) where T : class + { + if (item == null) return null; + + try + { + return selector(item); + } + catch (NullReferenceException) + { + return null; + } + } + + /// + /// Gets a specified member of this object. If this is null, null will be returned. Otherwise the specified expression will be returned. + /// + [System.Diagnostics.DebuggerStepThrough] + public static DateTime? Get(this T item, Func selector) where T : class + { + if (item == null) return null; + + try + { + return selector(item); + } + catch (NullReferenceException) + { + return null; + } + } + + /// + /// Gets a specified member of this object. If this is null, null will be returned. Otherwise the specified expression will be returned. + /// + [System.Diagnostics.DebuggerStepThrough] + public static DateTime? Get(this T item, Func selector) where T : class + { + if (item == null) return null; + + try + { + return selector(item); + } + catch (NullReferenceException) + { + return null; + } + } + + /// + /// Gets a specified member of this object. If this is null, null will be returned. Otherwise the specified expression will be returned. + /// + [System.Diagnostics.DebuggerStepThrough] + public static T Get(this DateTime? item, Func selector) where T : struct + { + if (item == null) return default(T); + + try + { + return selector(item); + } + catch (NullReferenceException) + { + return default(T); + } + } + } +} diff --git a/Olive/-Extensions/Random.cs b/Olive/-Extensions/Random.cs new file mode 100644 index 000000000..8960b7f67 --- /dev/null +++ b/Olive/-Extensions/Random.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; + +namespace Olive +{ + partial class OliveExtensions + { + const string CHAR_SET_READABLE = "ACEFGHJKLMNPQRTUVWXYZ0123456789"; + const string CHAR_SET_FULL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + /// + /// Gets a random boolean value with the specified chance (0-100). + /// + public static bool NextBoolean(this Random random, double chance) + { + if (chance < 0 || chance > 100) throw new Exception("Chance should be between 0 and 100 percent."); + + return random.NextDouble() >= 1 - (chance / 100.0); + } + + /// + /// Gets a random boolean value. + /// + public static bool NextBoolean(this Random random) => NextBoolean(random, 50); + + /// + /// Generates and returns a Random alphanumeric string. + /// + /// Random instance. + /// Length of string to return + /// Pass true to miss-out letters that can be confused with numbers (BDIOS) + /// String instance containing random alphanumeric characters. + public static string NextAlphaNumericString(this Random rng, int length, bool omitConfusableCharacters = false) + { + if (length < 1) throw new ArgumentOutOfRangeException(nameof(length), "Length should be 1 or more."); + + var charSet = omitConfusableCharacters ? CHAR_SET_READABLE : CHAR_SET_FULL; + + var buffer = new char[length]; + var chLen = charSet.Length; + for (int i = 0; i < length; i++) + buffer[i] = charSet[rng.Next(chLen)]; + + return new string(buffer); + } + + /// + /// Returns [quantity] number of unique random integers within the given range. + /// + public static List PickNumbers(this Random randomProvider, int quantity, int minValue, int maxValue) + { + if (minValue > maxValue) throw new ArgumentException("Invalid min and Max value specified."); + + var possibleMaxQuantity = (maxValue - minValue) + 1; + + if (quantity > possibleMaxQuantity) + throw new ArgumentException($"There are not {quantity} unique numbers between {minValue} and {maxValue}."); + + var result = new List(); + + var quantityPicked = 0; + + while (quantityPicked < quantity) + { + var candidate = randomProvider.Next(minValue, maxValue + 1); + + if (result.Contains(candidate)) continue; + + result.Add(candidate); + quantityPicked++; + } + + return result; + } + } +} \ No newline at end of file diff --git a/Olive/-Extensions/Range.cs b/Olive/-Extensions/Range.cs new file mode 100644 index 000000000..c61365267 --- /dev/null +++ b/Olive/-Extensions/Range.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; + +namespace Olive +{ + partial class OliveExtensions + { + /// + /// Returns a formatted string based on this Range<DateTime> object and the given string format. + /// + public static string ToString(this Range @this, string format) + { + if (@this.From == DateTime.MinValue && @this.To == DateTime.MinValue) return string.Empty; + + if ("A".Equals(format, StringComparison.OrdinalIgnoreCase)) + { + if (@this.To != DateTime.MaxValue) return "{0:d}-{1:d}".FormatWith(@this.From, @this.To); + else return "{0:d}-...".FormatWith(@this.From); + } + + if ("F".Equals(format, StringComparison.OrdinalIgnoreCase)) + { + if (@this.To != DateTime.MaxValue) return "From {0:d} to {1:d}".FormatWith(@this.From, @this.To); + else return "From {0:d}".FormatWith(@this.From); + } + + if ("T".Equals(format, StringComparison.OrdinalIgnoreCase)) + { + if (@this.To != DateTime.MaxValue) return "{0:d} to {1:d}".FormatWith(@this.From, @this.To); + else return "{0:d}".FormatWith(@this.From); + } + + return @this.ToString(); + } + + /// + /// Gets all possible items in the range based on the specified intervals. + /// + public static IEnumerable GetIntervals(this Range range, double interval) + { + if (interval <= 0) throw new Exception("Interval should be a positive number."); + + for (var item = range.From; item <= range.To; item += interval) + yield return item; + } + + /// + /// Gets all possible items in the range based on the specified intervals. + /// + public static IEnumerable GetIntervals(this Range range, decimal interval) + { + if (interval <= 0) throw new Exception("Interval should be a positive number."); + + for (var item = range.From; item <= range.To; item += interval) + yield return item; + } + + /// + /// Gets all possible items in the range based on the specified intervals. + /// + public static IEnumerable GetIntervals(this Range range, int interval = 1) + { + if (interval <= 0) throw new Exception("Interval should be a positive number."); + + for (var item = range.From; item <= range.To; item += interval) + yield return item; + } + + /// + /// Merges adjecant items in this list if their gap is within the specified tolerance. + /// The result will be another list of ranges with potentially fewer (but larger) ranges. + /// Consider sorting the items before calling this method. + /// + public static IEnumerable> MergeAdjacents(this IEnumerable> items, TimeSpan tolerance) + { + Range last = null; + + foreach (var item in items) + { + if (last == null) + { + last = item; + continue; + } + + var difference = item.From.Subtract(last.To); + if (difference < TimeSpan.Zero) difference = -difference; + + if (difference > tolerance) + { + yield return last; + last = item; + } + else + { + last = new Range(last.From, item.To); + } + } + + if (last != null) yield return last; + } + } +} diff --git a/Olive/-Extensions/Reflection.cs b/Olive/-Extensions/Reflection.cs new file mode 100644 index 000000000..fb16ff081 --- /dev/null +++ b/Olive/-Extensions/Reflection.cs @@ -0,0 +1,440 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace Olive +{ + partial class OliveExtensions + { + static ConcurrentDictionary, bool> TypeImplementsCache = new ConcurrentDictionary, bool>(); + + static SortedDictionary CacheData = new SortedDictionary(); + + static ConcurrentDictionary>> SubTypesCache = new ConcurrentDictionary>>(); + + static ConcurrentDictionary ProgrammingNameCache = new ConcurrentDictionary(); + + static ConcurrentDictionary AssemblyQualifiedNameCache = new ConcurrentDictionary(); + + public delegate T Method(); + + /// + /// Gets all parent types hierarchy for this type. + /// + public static IEnumerable GetParentTypes(this Type type) + { + var result = new List(); + + for (var @base = type.BaseType; @base != null; @base = @base.BaseType) + result.Add(@base); + + return result; + } + + /// + /// Determines whether this type inherits from a specified base type, either directly or indirectly. + /// + public static bool InhritsFrom(this Type type, Type baseType) + { + if (baseType == null) + throw new ArgumentNullException(nameof(baseType)); + + if (baseType.IsInterface) + return type.Implements(baseType); + + return type.GetParentTypes().Contains(baseType); + } + + public static bool Implements(this Type type) => Implements(type, typeof(T)); + + public static bool IsA(this Type type) => typeof(T).IsAssignableFrom(type); + + public static bool IsA(this Type thisType, Type type) => type.IsAssignableFrom(thisType); + + public static bool References(this Assembly assembly, Assembly anotherAssembly) + { + if (assembly == null) throw new NullReferenceException("assembly should not be null."); + if (anotherAssembly == null) throw new ArgumentNullException(nameof(anotherAssembly)); + + return assembly.GetReferencedAssemblies().Any(each => each.FullName.Equals(anotherAssembly.FullName)); + } + + public static string GetDisplayName(this Type input) + { + string displayName = input.Name; + + for (int i = displayName.Length - 1; i >= 0; i--) + { + if (displayName[i] == char.ToUpper(displayName[i])) + if (i > 0) + displayName = displayName.Insert(i, " "); + } + + return displayName; + } + + public static IEnumerable WithAllParents(this Type @this) + { + yield return @this; + + if (@this.BaseType != null) + foreach (var p in @this.BaseType.WithAllParents()) yield return p; + } + + /// + /// Retuns the name of this type in the same way that is used in C# programming. + /// + public static string GetCSharpName(this Type type, bool includeNamespaces = false) + { + if (type.GetGenericArguments().None()) return type.Name; + + return type.Name.TrimAfter("`", trimPhrase: true) + "<" + + type.GetGenericArguments().Select(t => t.GetCSharpName(includeNamespaces)).ToString(", ") + ">"; + } + + public static bool Implements(this Type type, Type interfaceType) + { + if (interfaceType == null) throw new ArgumentNullException(nameof(interfaceType)); + + var key = Tuple.Create(type, interfaceType); + + return TypeImplementsCache.GetOrAdd(key, t => + { + if (!interfaceType.IsInterface) + throw new ArgumentException($"The provided value for interfaceType, {interfaceType.FullName} is not an interface type."); + + if (t.Item1 == t.Item2) return true; + + var implementedInterface = t.Item1.GetInterface(t.Item2.FullName, ignoreCase: false); + + if (implementedInterface == null) return false; + else return implementedInterface.FullName == t.Item2.FullName; + }); + } + + /// + /// Gets the value of this property on the specified object. + /// + public static object GetValue(this PropertyInfo property, object @object) + { + try + { + return property.GetValue(@object, null); + } + catch (Exception ex) + { + throw new Exception($"Could not get the value of property '{property.DeclaringType.Name}.{property.Name}' " + + "on the specified instance: {ex.Message}", ex); + } + } + + /// + /// Set the value of this property on the specified object. + /// + public static void SetValue(this PropertyInfo property, object @object, object value) => property.SetValue(@object, value, null); + + /// + /// Adds the specified types pair to this type dictionary. + /// + public static void Add(this IDictionary typeDictionary) => typeDictionary.Add(typeof(T), typeof(K)); + + /// + /// Creates the instance of this type. + /// + public static object CreateInstance(this Type type, params object[] constructorParameters) => + Activator.CreateInstance(type, constructorParameters); + + /// + /// Determines whether it has a specified attribute applied to it. + /// + public static bool Defines(this MemberInfo member, bool inherit = true) where TAttribute : Attribute => + member.IsDefined(typeof(TAttribute), inherit); + + /// + /// Creates the instance of this type casted to the specified type. + /// + public static TCast CreateInstance(this Type type, params object[] constructorParameters) => + (TCast)Activator.CreateInstance(type, constructorParameters); + + public static T Cache(this MethodBase method, object[] arguments, Method methodBody) where T : class => + Cache(method, null, arguments, methodBody); + + /// + /// Determines if this type is a nullable of something. + /// + public static bool IsNullable(this Type type) + { + if (!type.IsGenericType) return false; + + if (type.GetGenericTypeDefinition() != typeof(Nullable<>)) return false; + + return true; + } + + public static T Cache(this MethodBase method, object instance, object[] arguments, Method methodBody) where T : class + { + var key = method.DeclaringType.GUID + ":" + method.Name; + if (instance != null) + key += instance.GetHashCode() + ":"; + + arguments?.Do(arg => key += arg.GetHashCode() + ":"); + + if (CacheData[key] == null) + { + var result = methodBody?.Invoke(); + CacheData[key] = result; + return result; + } + + return CacheData[key] as T; + } + + public static bool Is(this PropertyInfo property, string propertyName) + { + var type1 = property.DeclaringType; + var type2 = typeof(T); + + if (type1.IsA(type2) || type2.IsA(type1)) + return property.Name == propertyName; + else + return false; + } + + /// + /// Determines whether this type is static. + /// + public static bool IsStatic(this Type type) => type.IsAbstract && type.IsSealed; + + public static bool IsExtensionMethod(this MethodInfo method) => + method.GetCustomAttributes(inherit: false).Any(); + + // /// + // /// Gets all defined attributes of the specified type. + // /// + // public static TAttribute[] GetCustomAttributes1(this MemberInfo member, bool inherit = true) where TAttribute : Attribute + // { + // member.GetCustomAttributes() + + // var result = member.GetCustomAttributes(typeof(TAttribute), inherit); + // if (result == null) return new TAttribute[0]; + // else return result.Cast().ToArray(); + // } + + #region Sub-Types + + /// + /// Gets all types in this assembly that are directly inherited from a specified base type. + /// + public static IEnumerable GetSubTypes(this Assembly assembly, Type baseType) + { + var cache = SubTypesCache.GetOrAdd(assembly, a => new ConcurrentDictionary>()); + + return cache.GetOrAdd(baseType, bt => + { + try + { + return assembly.GetTypes().Where(t => t.BaseType == bt).ToArray(); + } + catch (ReflectionTypeLoadException ex) + { + throw new Exception("Could not load the types of the assembly '{0}'. Type-load exceptions: {1}".FormatWith(assembly.FullName, + ex.LoaderExceptions.Select(e => e.Message).Distinct().ToString(" | "))); + } + }); + } + + #endregion + + /// + /// Gets the full programming name of this type. Unlike the standard FullName property, it handles Generic types properly. + /// + public static string GetProgrammingName(this Type type) => + ProgrammingNameCache.GetOrAdd(type, x => GetProgrammingName(x, useGlobal: false)); + + /// + /// Gets the full programming name of this type. Unlike the standard FullName property, it handles Generic types properly. + /// + public static string GetProgrammingName(this Type type, bool useGlobal, bool useNamespace = true, bool useNamespaceForParams = true, bool useGlobalForParams = false) + { + if (type.GetGenericArguments().Any()) + { + return "global::".OnlyWhen(useGlobal && type.FullName != null) + + "{0}{1}<{2}>".FormatWith( + type.Namespace.OnlyWhen(useNamespace).WithSuffix("."), + type.Name.Remove(type.Name.IndexOf('`')), + type.GetGenericArguments().Select(t => t.GetProgrammingName(useGlobalForParams, useNamespaceForParams, useNamespaceForParams, useGlobalForParams)).ToString(", ")); + } + else + { + if (type.FullName == null) + { + // Generic parameter name: + return type.Name.TrimEnd("&"); + } + + return "global::".OnlyWhen(useGlobal) + type.Namespace.OnlyWhen(useNamespace).WithSuffix(".") + type.Name.Replace("+", ".").TrimEnd("&"); + } + } + + /// + /// Determines if this type is a generic class of the specified type. + /// + public static bool IsGenericOf(this Type type, Type genericType, params Type[] genericParameters) + { + if (!type.IsGenericType) return false; + + if (type.GetGenericTypeDefinition() != genericType) return false; + + var args = type.GetGenericArguments(); + + if (args.Length != genericParameters.Length) return false; + + for (var i = 0; i < args.Length; i++) + if (args[i] != genericParameters[i]) return false; + + return true; + } + + internal static bool IsAnyOf(this Type type, params Type[] types) + { + if (type == null) return types.Any(x => x == null); + + return types.Contains(type); + } + + public static string GetCachedAssemblyQualifiedName(this Type type) => + AssemblyQualifiedNameCache.GetOrAdd(type, x => x.AssemblyQualifiedName); + + public static MemberInfo GetPropertyOrField(this Type type, string name) => + type.GetProperty(name) ?? (MemberInfo)type.GetField(name); + + public static IEnumerable GetPropertiesAndFields(this Type type, BindingFlags flags) => + type.GetProperties(flags).Cast().Concat(type.GetFields(flags)); + + public static Type GetPropertyOrFieldType(this MemberInfo member) => + (member as PropertyInfo)?.PropertyType ?? (member as FieldInfo)?.FieldType; + + static IEnumerable GetReferencingAssemblies(Assembly anotherAssembly) => + AppDomain.CurrentDomain.GetAssemblies().Where(assebly => assebly.References(anotherAssembly)); + + public static IEnumerable SelectTypesByAttribute(this Assembly assembly, bool inherit) where T : Attribute => + assembly.GetExportedTypes().Where(t => t.IsDefined(typeof(T), inherit)); + + /// + /// Gets all types in the current appDomain which implement this interface. + /// + public static List FindImplementerClasses(this Type interfaceType) + { + if (!interfaceType.IsInterface) throw new InvalidOperationException(interfaceType.GetType().FullName + " is not an Interface."); + + var result = new List(); + + foreach (var assembly in GetReferencingAssemblies(interfaceType.Assembly)) + { + try + { + foreach (var type in assembly.GetTypes()) + { + if (type == interfaceType) continue; + if (!type.IsClass) continue; + + if (type.Implements(interfaceType)) + result.Add(type); + } + } + catch + { + // No logging is needed + // Can't load assembly + } + } + + return result; + } + + public static object GetObjectByPropertyPath(this Type type, object instance, string propertyPath) + { + if (propertyPath.Contains(".")) + { + var directProperty = type.GetProperty(propertyPath.TrimAfter(".")); + + if (directProperty == null) + throw new Exception(type.FullName + " does not have a property named '" + propertyPath.TrimAfter(".") + "'"); + + var associatedObject = directProperty.GetValue(instance); + if (associatedObject == null) return null; + + var remainingPath = propertyPath.TrimStart(directProperty.Name + "."); + return associatedObject.GetType().GetObjectByPropertyPath(associatedObject, remainingPath); + } + else + { + return type.GetProperty(propertyPath).GetValue(instance); + } + } + + /// + /// Creates a new thread and copies the current Culture and UI Culture. + /// + public static Thread CreateNew(this Thread thread, Action threadStart) => CreateNew(thread, threadStart, null); + + /// + /// Creates a new thread and copies the current Culture and UI Culture. + /// + public static Thread CreateNew(this Thread thread, Action threadStart, Action initializer) + { + var result = new Thread(new ThreadStart(threadStart)); + + initializer?.Invoke(result); + + return result; + } + + /// + /// Gets the default value for this type. It's equivalent to default(T). + /// + public static object GetDefaultValue(this Type type) + { + if (type.IsValueType) + return Activator.CreateInstance(type); + + return null; + } + + /// + /// If it specifies DisplayNameAttribute the value from that will be returned. + /// Otherwise it returns natural English literal text for the name of this member. + /// For example it coverts "ThisIsSomething" to "This is something". + /// + public static string GetDisplayName(this MemberInfo member) + { + var byAttribute = member.GetCustomAttribute()?.DisplayName; + return byAttribute.Or(() => member.Name.ToLiteralFromPascalCase()); + } + + /// + /// Determine whether this property is static. + /// + public static bool IsStatic(this PropertyInfo property) => (property.GetGetMethod() ?? property.GetSetMethod()).IsStatic; + + /// + /// It works similar to calling .Result property, but it forces a context switch to prevent deadlocks in UI and ASP.NET context. + /// + public static TResult AwaitResult(this Task task) => Task.Run(async () => await task).Result; + + public static TTArget GetTargetOrDefault(this WeakReference reference) + where TTArget : class + { + if (reference == null) return null; + + if (reference.TryGetTarget(out var result)) return result; + return null; + } + + internal static WeakReference GetWeakReference(this T item) where T : class => new WeakReference(item); + } +} diff --git a/Olive/-Extensions/String.Conversion.cs b/Olive/-Extensions/String.Conversion.cs new file mode 100644 index 000000000..eee8bd765 --- /dev/null +++ b/Olive/-Extensions/String.Conversion.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; + +namespace Olive +{ + partial class OliveExtensions + { + public delegate bool TryParseProvider(string text, Type type, out object result); + + public static List TryParseProviders = new List(); + + /// + /// Determines whether this string can be converted to the specified type. + /// + public static bool Is(this string text) where T : struct => text.TryParseAs().HasValue; + + /// + /// Tries to parse this text to the specified type. + /// Returns null if parsing is not possible. + /// + public static T? TryParseAs(this string text) where T : struct + { + if (text.IsEmpty()) return default(T?); + + // Check common types first, for performance: + if (TryParseToCommonTypes(text, out T? result)) + return result; + + foreach (var parser in TryParseProviders) + if (parser(text, typeof(T), out object result2)) + return (T)result2; + + try { return (T)Convert.ChangeType(text, typeof(T)); } + catch + { + // No logging is needed + return null; + } + } + + [EscapeGCop("It is ok for trying methods to have out param.")] + static bool TryParseToCommonTypes(this string text, out T? result) where T : struct + { + result = null; + + if (typeof(T) == typeof(int)) + { + if (int.TryParse(text, out int tempResult)) result = (T)(object)tempResult; + + return true; + } + + if (typeof(T) == typeof(double)) + { + if (double.TryParse(text, out double tempResult)) result = (T)(object)tempResult; + + return true; + } + + if (typeof(T) == typeof(decimal)) + { + if (decimal.TryParse(text, out decimal tempResult)) result = (T)(object)tempResult; + + return true; + } + + if (typeof(T) == typeof(bool)) + { + if (bool.TryParse(text, out bool tempResult)) result = (T)(object)tempResult; + + return true; + } + + if (typeof(T) == typeof(DateTime)) + { + if (DateTime.TryParse(text, out DateTime tempResult)) result = (T)(object)tempResult; + + return true; + } + + if (typeof(T) == typeof(TimeSpan)) + { + if (TimeSpan.TryParse(text, out TimeSpan tempResult)) result = (T)(object)tempResult; + + return true; + } + + if (typeof(T) == typeof(Guid)) + { + if (Guid.TryParse(text, out Guid tempResult)) result = (T)(object)tempResult; + + return true; + } + + if (typeof(T).IsEnum) + { + if (Enum.TryParse(text, ignoreCase: true, result: out T tempResult)) result = (T)(object)tempResult; + + return true; + } + + if (typeof(T) == typeof(ShortGuid)) + { + try { result = (T)(object)ShortGuid.Parse(text); } + catch + { + // No logging is needed + return true; + } + } + + return false; + } + + /// + /// It converts this text to the specified data type. + /// It supports all primitive types, Enums, Guid, XElement, XDocument, Color, ... + /// + public static T To(this string text) => (T)To(text, typeof(T)); + + /// + /// Converts the value of this string object into the specified target type. + /// It supports all primitive types, Enums, Guid, XElement, XDocument, Color, ... + /// + public static object To(this string text, Type targetType) + { + try + { + return ChangeType(text, targetType); + } + catch (Exception ex) + { + throw new Exception($"Could not convert \"{text}\" to type { targetType.FullName}.", ex); + } + } + + static object ChangeType(string text, Type targetType) + { + if (targetType == typeof(string)) return text; + + if (text.IsEmpty()) + return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; + + // Check common types first, for performance: + if (TryParseToCommonTypes(text, targetType, out object result)) + return result; + + if (targetType.IsEnum) return Enum.Parse(targetType, text); + + if (targetType == typeof(XElement)) return XElement.Parse(text); + + if (targetType == typeof(XDocument)) return XDocument.Parse(text); + + if (targetType == typeof(ShortGuid)) return ShortGuid.Parse(text); + + foreach (var parser in TryParseProviders) + if (parser(text, targetType, out object result2)) + return result2; + + return Convert.ChangeType(text, targetType); + } + + [EscapeGCop("It is ok for trying methods to have out param.")] + static bool TryParseToCommonTypes(string text, Type targetType, out object result) + { + var actualTargetType = targetType; + + bool isNullable = targetType.IsNullable(); + + if (isNullable) + targetType = targetType.GetGenericArguments().Single(); + + result = null; + + try + { + if (targetType == typeof(int)) result = int.Parse(text); + + if (targetType == typeof(long)) result = long.Parse(text); + + if (targetType == typeof(double)) result = double.Parse(text); + + if (targetType == typeof(decimal)) result = decimal.Parse(text); + + if (targetType == typeof(bool)) result = bool.Parse(text); + + if (targetType == typeof(DateTime)) result = DateTime.Parse(text); + + if (targetType == typeof(Guid)) result = new Guid(text); + + if (targetType == typeof(TimeSpan)) + { + if (text.Is()) result = TimeSpan.FromTicks(text.To()); + else result = TimeSpan.Parse(text); + } + + return result != null; + } + catch + { + if (targetType.IsAnyOf(typeof(int), typeof(long))) + { + if (text.Contains(".") && text.TrimBefore(".", caseSensitive: true, trimPhrase: true).All(x => x == '0')) + result = text.TrimAfter(".").To(actualTargetType); + } + + if (isNullable) + return true; + else + throw; // Although it is a try method, it is ok to raise an exception. + } + } + } +} \ No newline at end of file diff --git a/Olive/-Extensions/String.cs b/Olive/-Extensions/String.cs new file mode 100644 index 000000000..f179b6fa9 --- /dev/null +++ b/Olive/-Extensions/String.cs @@ -0,0 +1,1384 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace Olive +{ +#pragma warning disable GCop112 // This class is too large. Break its responsibilities down into more classes. + partial class OliveExtensions +#pragma warning restore GCop112 // This class is too large. Break its responsibilities down into more classes. + { + static byte UTF8SignatureFirstByte = 0xEF; + static byte UTF8SignatureSecondByte = 0xBB; + static byte UTF8SignatureThirdByte = 0xBF; + + static string[][] XMLEscapingChars = new string[][] + { + new string[]{ "&", "&" }, + new string[]{ "<", "<" }, + new string[]{ ">", ">" }, + new string[]{ "\"", """ }, + new string[]{ "'", "'" }, + }; + + /// + /// Array of unsafe characters that need to be replaced with their character code literals in a JavaScript string. + /// + static readonly char[] JsUnsafeCharacters = new[] { '\'', '\"' }; + + static ConcurrentDictionary LiteralFromPascalCaseCache = new ConcurrentDictionary(); + + /// + /// Removes the specified text from the start of this string instance. + /// + public static string TrimStart(this string text, string textToTrim) + { + if (text == null) text = string.Empty; + + if (textToTrim.IsEmpty() || text.IsEmpty()) return text; + + if (text.StartsWith(textToTrim)) return text.Substring(textToTrim.Length).TrimStart(textToTrim); + else return text; + } + + /// + /// Trims the end of this instance of string with the specified number of characters. + /// + public static string TrimEnd(this string text, int numberOfCharacters) + { + if (numberOfCharacters < 0) + throw new ArgumentException("numberOfCharacters must be greater than 0."); + + if (numberOfCharacters == 0) return text; + + if (text.IsEmpty() || text.Length <= numberOfCharacters) + return string.Empty; + + return text.Substring(0, text.Length - numberOfCharacters); + } + + /// + /// If this string object is null, it will return null. Otherwise it will trim the text and return it. + /// + public static string TrimOrNull(this string text) => text?.Trim(); + + /// + /// If this string object is null, it will return empty string. Otherwise it will trim the text and return it. + /// + public static string TrimOrEmpty(this string text) => text.TrimOrNull().OrEmpty(); + + public static bool IsNoneOf(this string text, params string[] items) => !text.IsAnyOf(items); + + /// + /// Returns a copy of this text converted to lower case. If it is null it will return empty string. + /// + public static string ToLowerOrEmpty(this string text) => text.OrEmpty().ToLower(); + + /// + /// Returns a copy of this text converted to upper case. If it is null it will return empty string. + /// + public static string ToUpperOrEmpty(this string text) => text.OrEmpty().ToUpper(); + + public static bool IsAnyOf(this string text, params string[] items) + { + if (text == null) return items.Any(x => x == null); + + return items.Contains(text); + } + + public static bool IsAnyOf(this string text, IEnumerable items) => IsAnyOf(text, items.ToArray()); + + public static string EnsureStart(this string text, string startText, bool caseSensitive = false) + { + if (startText.IsEmpty()) + throw new ArgumentNullException(nameof(startText)); + + if (text.IsEmpty()) return string.Empty; + + if (caseSensitive) + if (text.StartsWith(startText)) + return text; + + else + { + if (text.ToLower().StartsWith(startText.ToLower())) + return text; + } + + return startText + text; + } + + public static bool ContainsAll(this string text, string[] keywords, bool caseSensitive) + { + if (!caseSensitive) + { + text = (text ?? string.Empty).ToLower(); + + for (int i = 0; i < keywords.Length; i++) keywords[i] = keywords[i].ToLower(); + } + + foreach (var key in keywords) + if (!text.Contains(key)) return false; + + return true; + } + + /// + /// Determines whether this instance of string is null or empty. + /// + + public static bool IsEmpty(this string text) => string.IsNullOrEmpty(text); + + /// + /// Determines whether this instance of string is not null or empty. + /// + + public static bool HasValue(this string text) => !string.IsNullOrEmpty(text); + + /// + /// Will replace all line breaks with a BR tag and return the result as a raw html. + /// + public static string ToHtmlLines(this string text) => text.OrEmpty().ToLines().ToString("
    "); + + /// + /// Will join all items with a BR tag and return the result as a raw html. + /// + public static string ToHtmlLines(this IEnumerable items) => items.ToString("
    "); + + /// + /// Gets the same string if it is not null or empty. Otherwise it returns the specified default value. + /// + public static string Or(this string text, string defaultValue) + { + if (string.IsNullOrEmpty(text)) return defaultValue; + else return text; + } + + /// + /// Gets the same string if it is not null or empty. + /// Otherwise it invokes the specified default value provider and returns the result. + /// + public static string Or(this string text, Func defaultValueProvider) + { + if (string.IsNullOrEmpty(text)) return defaultValueProvider?.Invoke(); + else return text; + } + + /// + /// Gets the same string unless it is the same as the specified text. If they are the same, empty string will be returned. + /// + public static string Unless(this string text, string unwantedText) + { + if (text == unwantedText) return string.Empty; + else return text; + } + + /// + /// Summarizes the specified source. + /// + public static string Summarize(this string text, int maximumLength, bool enforceMaxLength) + { + var result = Summarize(text, maximumLength); + + if (enforceMaxLength && result.Length > maximumLength) + result = text.Substring(0, maximumLength - 3) + "..."; + + return result; + } + + /// + /// Summarizes the specified text. + /// + public static string Summarize(this string text, int maximumLength) + { + if (text.IsEmpty()) return text; + + if (text.Length > maximumLength) + { + text = text.Substring(0, maximumLength); + + var lastSpace = -1; + + foreach (char wordSeperator in " \r\n\t") + lastSpace = Math.Max(text.LastIndexOf(wordSeperator), lastSpace); + + if (lastSpace > maximumLength / 2) + text = text.Substring(0, lastSpace); + + text += "..."; + } + + return text; + } + + #region Count string + + public static string Count(this IEnumerable list, string objectTitle) + { + if (objectTitle.IsEmpty()) + objectTitle = SeparateAtUpperCases(typeof(T).Name); + + return objectTitle.ToCountString(list.Count()); + } + + public static string Count(this IEnumerable list, string objectTitle, string zeroQualifier) + { + if (objectTitle.IsEmpty()) + objectTitle = SeparateAtUpperCases(typeof(T).Name); + + return objectTitle.ToCountString(list.Count(), zeroQualifier); + } + + public static string ToCountString(this string name, int count) + { + var zeroQualifier = "no"; + + if (name.HasValue() && char.IsUpper(name[0])) + zeroQualifier = "No"; + + return ToCountString(name, count, zeroQualifier); + } + + public static string ToCountString(this string name, int count, string zeroQualifier) + { + name = name.Or("").Trim(); + if (name.IsEmpty()) + throw new Exception("'name' cannot be empty for ToCountString()."); + + if (count < 0) + throw new ArgumentException("count should be greater than or equal to 0."); + + if (count == 0) return zeroQualifier + " " + name; + else if (count == 1) return "1 " + name; + else return $"{count} {name.ToPlural()}"; + } + + public static string SeparateAtUpperCases(this string pascalCase) + { + var sb = new StringBuilder(); + for (int i = 0; i < pascalCase.Length; i++) + { + if (char.IsUpper(pascalCase[i]) && i > 0) + sb.Append(" "); + sb.Append(pascalCase[i]); + } + + return sb.ToString().ToLower(); + } + + public static string ToPlural(this string singular) + { + if (singular.IsEmpty()) return string.Empty; + + // Only change the last word: + var phrase = singular; + var prefix = ""; + if (phrase.Split(' ').Length > 1) + { + // Multi word, set prefix to anything but the last word: + prefix = phrase.Substring(0, phrase.LastIndexOf(" ")) + " "; + singular = phrase.Substring(phrase.LastIndexOf(" ") + 1); + } + + string plural; + var irregular = GetIrregularPlural(singular); + + if (irregular != "") + { + if (prefix == "") + irregular = char.ToUpper(irregular[0]) + irregular.Substring(1); + + plural = irregular; + } + else plural = GetRegularPlural(singular); + + return prefix + plural; + } + + static string GetRegularPlural(string singular) + { + var ending = char.ToLower(singular[singular.Length - 1]); + + char secondEnding; + if (singular.Length > 1) + secondEnding = char.ToLower(singular[singular.Length - 2]); + else + secondEnding = char.MinValue; + + if (ending == 's' || (secondEnding.ToString() + ending) == "ch" || (secondEnding.ToString() + ending) == "sh") + return singular + "es"; + + else if (ItNeedsIESForPlural(ending, secondEnding)) + return singular.Substring(0, singular.Length - 1) + "ies"; + + return singular + "s"; + } + + static bool ItNeedsIESForPlural(char ending, char secondEnding) + { + return ending == 'y' && + secondEnding != 'a' && + secondEnding != 'e' && + secondEnding != 'o' && + secondEnding != 'i' && + secondEnding != 'u'; + } + + [EscapeGCop("It is fine for this method to be long.")] + static string GetIrregularPlural(string singular) + { + singular = singular.ToLower(); + + switch (singular) + { + case "addendum": return "addenda"; + case "alga": return "algae"; + case "alumna": return "alumnae"; + case "alumnus": return "alumni"; + case "analysis": return "analyses"; + case "apparatus": return "apparatuses"; + case "appendix": return "appendices"; + case "axis": return "axes"; + case "bacillus": return "bacilli"; + case "bacterium": return "bacteria"; + case "basis": return "bases"; + case "beau": return "beaux"; + case "bison": return "bison"; + case "buffalo": return "buffaloes"; + case "bureau": return "bureaus"; + case "calf": return "calves"; + case "child": return "children"; + case "corps": return "corps"; + case "crisis": return "crises"; + case "criterion": return "criteria"; + case "curriculum": return "curricula"; + case "datum": return "data"; + case "deer": return "deer"; + case "die": return "dice"; + case "dwarf": return "dwarfs"; + case "diagnosis": return "diagnoses"; + case "echo": return "echoes"; + case "elf": return "elves"; + case "ellipsis": return "ellipses"; + case "embargo": return "embargoes"; + case "emphasis": return "emphases"; + case "erratum": return "errata"; + case "fireman": return "firemen"; + case "fish": return "fish"; + case "focus": return "focus"; + case "foot": return "feet"; + case "formula": return "formulas"; + case "fungus": return "fungi"; + case "genus": return "genera"; + case "goose": return "geese"; + case "half": return "halves"; + case "hero": return "heroes"; + case "hippopotamus": return "hippopotami"; + case "hoof": return "hoofs"; + case "hypothesis": return "hypotheses"; + case "index": return "indices"; + case "knife": return "knives"; + case "leaf": return "leaves"; + case "life": return "lives"; + case "loaf": return "loaves"; + case "louse": return "lice"; + case "man": return "men"; + case "matrix": return "matrices"; + case "means": return "means"; + case "medium": return "media"; + case "memorandum": return "memoranda"; + case "millennium": return "milennia"; + case "moose": return "moose"; + case "mosquito": return "mosquitoes"; + case "mouse": return "mice"; + case "nebula": return "nebulas"; + case "neurosis": return "neuroses"; + case "nucleus": return "nuclei"; + case "oasis": return "oases"; + case "octopus": return "octopi"; + case "ovum": return "ova"; + case "ox": return "oxen"; + case "paralysis": return "paralyses"; + case "parenthesis": return "parentheses"; + case "person": return "people"; + case "phenomenon": return "phenomena"; + case "potato": return "potatoes"; + case "scarf": return "scarfs"; + case "self": return "selves"; + case "series": return "series"; + case "sheep": return "sheep"; + case "shelf": return "shelves"; + case "scissors": return "scissors"; + case "species": return "species"; + case "stimulus": return "stimuli"; + case "stratum": return "strata"; + case "synthesis": return "syntheses"; + case "synopsis": return "synopses"; + case "tableau": return "tableaux"; + case "that": return "those"; + case "thesis": return "theses"; + case "thief": return "thieves"; + case "this": return "these"; + case "tomato": return "tomatoes"; + case "tooth": return "teeth"; + case "torpedo": return "torpedoes"; + case "vertebra": return "vertebrae"; + case "veto": return "vetoes"; + case "vita": return "vitae"; + case "watch": return "watches"; + case "wife": return "wives"; + case "wolf": return "wolves"; + case "woman": return "women"; + + default: return ""; + } + } + + #endregion + + /// + /// Trims some unnecessary text from the end of this string, if it exists. + /// + public static string TrimEnd(this string text, string unnecessaryText) => TrimEnd(text, unnecessaryText, caseSensitive: true); + + /// + /// Trims some unnecessary text from the end of this string, if it exists. + /// + /// By default it's TRUE. + public static string TrimEnd(this string text, string unnecessaryText, bool caseSensitive) + { + if (unnecessaryText.IsEmpty() || text.IsEmpty()) + return text.OrEmpty(); + + else if (text.EndsWith(unnecessaryText, caseSensitive ? StringComparison.CurrentCulture : StringComparison.CurrentCultureIgnoreCase)) + return text.TrimEnd(unnecessaryText.Length); + + else + return text; + } + + /// + /// Returns the last few characters of the string with a length + /// specified by the given parameter. If the string's length is less than the + /// given length the complete string is returned. If length is zero or + /// less an empty string is returned + /// + /// Number of characters to return + public static string Right(this string text, int length) + { + length = Math.Max(length, 0); + + if (text.Length > length) + return text.Substring(text.Length - length, length); + else + return text; + } + + /// + /// Returns the first few characters of the string with a length + /// specified by the given parameter. If the string's length is less than the + /// given length the complete string is returned. If length is zero or + /// less an empty string is returned + /// + /// Number of characters to return + public static string Left(this string text, int length) + { + length = Math.Max(length, 0); + + if (text.Length > length) + return text.Substring(0, length); + else + return text; + } + + public static string FormatWith(this string format, object arg, params object[] additionalArgs) + { + try + { + if (additionalArgs == null || additionalArgs.Length == 0) + return string.Format(format, arg); + else + return string.Format(format, new object[] { arg }.Concat(additionalArgs).ToArray()); + } + catch (Exception ex) + { + throw new FormatException("Cannot format the string '{0}' with the specified arguments.".FormatWith(format), ex); + } + } + + public static string GetLastChar(this string input) + { + if (input.HasValue()) + { + if (input.Length >= 1) + return input.Substring(input.Length - 1, 1); + else + return input; + } + else + return null; + } + + public static bool StartsWithAny(this string input, params string[] listOfBeginnings) + { + foreach (var option in listOfBeginnings) + if (input.StartsWith(option)) return true; + + return false; + } + + public static bool StartsWith(this string input, string other, bool caseSensitive) + { + if (other.IsEmpty()) return false; + + if (caseSensitive) return input.StartsWith(other); + else return input.StartsWith(other, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Gets whether this string item ends with any of the specified items. + /// + public static bool EndsWithAny(this string input, params string[] listOfEndings) + { + foreach (var option in listOfEndings) + if (input.EndsWith(option)) return true; + + return false; + } + + /// + /// Removes all Html tags from this html string. + /// + public static string RemoveHtmlTags(this string source) + { + if (source.IsEmpty()) return string.Empty; + + source = source + .Replace("
    ", Environment.NewLine) + .Replace("
    ", Environment.NewLine) + .Replace("
    ", Environment.NewLine) + .Replace("
    ", Environment.NewLine) + .Replace("

    ", Environment.NewLine); + + var from = new string[] { + """, "'", "&", "<", ">", " ", + "¡","¢","£","¤","¥","¦","§","¨", + "©","ª","«","¬","­","®","¯","°","±", + "²","³","´","µ","¶","·","¸","¹", + "º","»","¼","½","¾","¿","×", + "÷","À","Á","Â","Ã","Ä","Å", + "Æ","Ç","È","É","Ê","Ë","Ì", + "Í","Î","Ï","Ð","Ñ","Ò","Ó", + "Ô","Õ","Ö","Ø","Ù","Ú","Û", + "Ü","Ý","Þ","ß","à","á","â", + "ã","ä","å","æ","ç","è","é", + "ê","ë","ì","í","î","ï","ð","ñ", + "ò","ó","ô","õ","ö","ø","ù", + "ú","û","ü","ý","þ","ÿ"}; + + var to = new string[] { "\"", "'", "&", "<", ">", " ", + "¡","¢","£","¤","¥","¦","§","¨","©","ª","«","¬","-","®","¯","°","±","²", + "³","´","µ","¶","•","¸","¹","º","»","¼","½","¾","¿","×","÷","À","Á","Â", + "Ã","Ä","Å","Æ","Ç","È","É","Ê","Ë","Ì","Í","Î","Ï","Ð","Ñ","Ò","Ó","Ô", + "Õ","Ö","Ø","Ù","Ú","Û","Ü","Ý","Þ","ß","à","á","â","ã","ä","å","æ","ç", + "è","é","ê","ë","ì","í","î","ï","ð","ñ","ò","ó","ô","õ","ö","ø","ù","ú", + "û","ü","ý","þ","ÿ", + }; + + for (int i = 0; i < from.Length; i++) + source = source.Replace(from[i], to[i]); + + return Regex.Replace(source, @"<(.|\n)*?>", " ").Trim(); + } + + ///

    + /// Gets all indices of a specified string inside this text. + /// + public static IEnumerable AllIndicesOf(this string text, string pattern) + { + if (pattern == null) + throw new ArgumentNullException(nameof(pattern)); + + var result = new List(); + + int index = -1; + + do + { + index = text.IndexOf(pattern, index + 1); + if (index > -1) + result.Add(index); + } + while (index > -1); + + return result; + } + + /// + /// Returns this text with the specified prefix if this has a value. If this text is empty or null, it will return empty string. + /// + public static string WithPrefix(this string text, string prefix) + { + if (text.IsEmpty()) return string.Empty; + else return prefix + text; + } + + /// + /// Returns this text with the specified suffix if this has a value. If this text is empty or null, it will return empty string. + /// + public static string WithSuffix(this string text, string suffix) + { + if (text.IsEmpty()) return string.Empty; + else return text + suffix; + } + + /// + /// Wraps this text between the left and right wrappers, only if this has a value. + /// + public static string WithWrappers(this string text, string left, string right) + { + if (text.IsEmpty()) + return string.Empty; + + return left + text + right; + } + + /// + /// Repeats this text by the number of times specified. + /// + public static string Repeat(this string text, int times) => Repeat(text, times, null); + + /// + /// Repeats this text by the number of times specified, seperated with the specified seperator. + /// + public static string Repeat(this string text, int times, string seperator) + { + if (times < 0) throw new ArgumentOutOfRangeException(nameof(times), $"{nameof(times)} should be 0 or more."); + + if (times == 0) return string.Empty; + + var r = new StringBuilder(); + + for (var i = 1; i <= times; i++) + { + r.Append(text); + + if (seperator != null) r.Append(seperator); + } + + return r.ToString(); + } + + /// + /// Determines if this string value contains a specified substring. + /// + public static bool Contains(this string text, string subString, bool caseSensitive) + { + if (text == null && subString == null) + return true; + + if (text == null) return false; + + if (subString.IsEmpty()) return true; + + if (caseSensitive) + return text.Contains(subString); + else + return text.ToUpper().Contains(subString?.ToUpper()); + } + + /// + /// Removes the specified substrings from this string object. + /// + public static string Remove(this string text, string firstSubstringsToRemove, params string[] otherSubstringsToRemove) => + text.Remove(firstSubstringsToRemove).Remove(otherSubstringsToRemove); + + [EscapeGCop("It is the Except definition and so it cannot call itself")] + public static string Remove(this string text, string[] substringsToRemove) + { + if (text.IsEmpty()) return text; + + var result = text; + + foreach (var sub in substringsToRemove) + if (sub.HasValue()) + result = result.Replace(sub, string.Empty); + + return result; + } + + /// + /// Removes the specified substrings from this string object. + /// + [EscapeGCop("It is the Except definition and so it cannot call itself")] + public static string Remove(this string text, string substringToRemove) + { + if (text.IsEmpty()) return text; + + return text.Replace(substringToRemove, string.Empty); + } + + /// + /// Replaces all occurances of a specified phrase to a substitude, even if the original phrase gets produced again as the result of substitution. Note: It's an expensive call. + /// + public static string KeepReplacing(this string text, string original, string substitute) + { + if (text.IsEmpty()) return text; + + if (original == substitute) return text; // prevent loop + + while (text.Contains(original)) + text = text.Replace(original, substitute); + + return text; + } + + /// + /// Gets this same string when a specified condition is True, otherwise it returns empty string. + /// + public static string OnlyWhen(this string text, bool condition) + { + if (condition) + return text; + else + return string.Empty; + } + + /// + /// Gets this same string when a specified condition is False, otherwise it returns empty string. + /// + public static string Unless(this string text, bool condition) + { + if (condition) + return string.Empty; + else + return text; + } + + /// + /// Gets the lines of this string. + /// + public static string[] ToLines(this string text) + { + if (text == null) return new string[0]; + + return text.Split('\n').Select(l => l.Trim('\r')).ToArray(); + } + + /// + /// Indicates whether this character is categorized as an uppercase letter. + /// + public static bool IsUpper(this char character) => char.IsUpper(character); + + /// + /// Indicates whether this character is categorized as a lowercase letter. + /// + public static bool IsLower(this char character) => char.IsLower(character); + + /// + /// Indicates whether this character is categorized as a letter. + /// + public static bool IsLetter(this char character) => char.IsLetter(character); + + public static bool IsAnyOf(this char character, params char[] characters) => characters.Contains(character); + + /// + /// Indicates whether this character is categorized as digit. + /// + public static bool IsDigit(this char character) => char.IsDigit(character); + + /// + /// Indicates whether this character is categorized as White Space (space, tab, new line, etc). + /// + public static bool IsWhiteSpace(this char character) => char.IsWhiteSpace(character); + + /// + /// Indicates whether this character is categorized as a letter or digit. + /// + public static bool IsLetterOrDigit(this char character) => char.IsLetterOrDigit(character); + + /// + /// Converts the value of this character to its uppercase equivalent. + /// + public static char ToUpper(this char character) => char.ToUpper(character); + + /// + /// Converts the value of this character to its lowercase equivalent. + /// + public static char ToLower(this char character) => char.ToLower(character); + + /// + /// If this expression is null, returns an empty string. Otherwise, it returns the ToString() of this instance. + /// + public static string ToStringOrEmpty(this object @object) + { + if (@object == null) + return string.Empty; + else + return @object.ToString().Or(string.Empty); + } + + /// + /// Determines whether this string object does not contain the specified phrase. + /// + public static bool Lacks(this string text, string phrase, bool caseSensitive = false) + { + if (text.IsEmpty()) + return phrase.HasValue(); + + return !text.Contains(phrase, caseSensitive); + } + + /// + /// Determines whether this string object does not contain any of the specified phrases. + /// + public static bool LacksAll(this string text, params string[] phrases) => + LacksAll(text, caseSensitive: false, phrases: phrases); + + /// + /// Determines whether this string object does not contain any of the specified phrases. + /// + public static bool LacksAll(this string text, bool caseSensitive, params string[] phrases) + { + if (text.IsEmpty()) return true; + + return phrases.None(p => p.HasValue() && text.Contains(p, caseSensitive)); + } + + /// + /// Returns natural English literal text for a specified pascal case string value. + /// For example it coverts "ThisIsSomething" to "This is something". + /// + public static string ToLiteralFromPascalCase(this string pascalCaseText) + { + if (pascalCaseText.IsEmpty()) return string.Empty; + + return LiteralFromPascalCaseCache.GetOrAdd(pascalCaseText, source => + { + var parts = new List(); + var lastPart = ""; + + foreach (var c in source) + { + if (c.IsUpper() && lastPart.HasValue()) + { + parts.Add(lastPart); + lastPart = ""; + } + + lastPart += c; + } + + parts.Add(lastPart); + + var result = parts.First() + parts.Skip(1).Select(a => a.Length < 2 ? a : " " + a.ToLower()).ToString(""); + return result.Trim(); + }); + } + + /// + /// Returns the all-lower-case version of this list. + /// + public static IEnumerable ToLower(this IEnumerable list) => list.ExceptNull().Select(i => i.ToLower()); + + /// + /// Returns the all-upper-case version of this list. + /// + public static IEnumerable ToUpper(this IEnumerable list) => list.ExceptNull().Select(i => i.ToUpper()); + + /// + /// Gets the UTF8-with-signature bytes of this text. + /// + public static byte[] GetUtf8WithSignatureBytes(this string text) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(text); + + // Add signature: + var result = new byte[bytes.Length + 3]; + + // Utf-8 signature code: BOM + result[0] = UTF8SignatureFirstByte; + result[1] = UTF8SignatureSecondByte; + result[2] = UTF8SignatureThirdByte; + + bytes.CopyTo(result, 3); + + return result; + } + + /// + /// Converts this array of bytes to a Base64 string. + /// + public static string ToBase64String(this byte[] value) + { + if (value == null) return null; + + return Convert.ToBase64String(value); + } + + /// + /// Converts this Base64 string to an array of bytes. + /// + public static byte[] ToBytes(this string value) + { + if (value.IsEmpty()) return new byte[0]; + + return Convert.FromBase64String(value); + } + + /// + /// Converts this string to an array of bytes with the given encoding. + /// + public static byte[] ToBytes(this string value, Encoding encoding) => encoding.GetBytes(value); + + /// + /// Determines whether this text contains any of the specified keywords. + /// If the keywords list contains a null or empty string, it throws an exception. If you wish to ignore those, use .Trim() on your keywords list. + /// + public static bool ContainsAny(this string text, IEnumerable keywords, bool caseSensitive = true) + { + if (keywords == null) + throw new ArgumentNullException(nameof(keywords)); + + if (text.IsEmpty()) return false; + + foreach (var key in keywords) + { + if (key.IsEmpty()) throw new ArgumentException($"nameof(keywords) contains a null or empty string element."); + + if (text.Contains(key, caseSensitive)) + return true; + } + + return false; + } + + /// + /// Splits this list of string items by a specified separator into a number of smaller lists of string. + /// + public static IEnumerable> Split(this IEnumerable list, string separator) + { + var currentArray = new List(); + + foreach (var item in list) + { + if (item == separator) + { + if (currentArray.Count > 0) + { + yield return currentArray; + currentArray = new List(); + } + } + else + currentArray.Add(item); + } + } + + /// + /// Converts this path into a file object. + /// + public static System.IO.FileInfo AsFile(this string path) => new System.IO.FileInfo(path); + + /// + /// Converts this path into a Uri object. + /// + public static Uri AsUri(this string path) => new Uri(path); + + /// + /// Converts this path into a directory object. + /// + public static System.IO.DirectoryInfo AsDirectory(this string path) => new System.IO.DirectoryInfo(path); + + /// + /// Gets the Xml Encoded version of this text. + /// + public static string XmlEncode(this string text) + { + if (text.IsEmpty()) return string.Empty; + + foreach (var set in XMLEscapingChars) + text = text.Replace(set[0], set[1]); + + return text; + } + + /// + /// Gets the Xml Decoded version of this text. + /// + public static string XmlDecode(this string text) + { + if (text.IsEmpty()) return string.Empty; + + foreach (var set in XMLEscapingChars) + text = text.Replace(set[1], set[0]); + + return text; + } + + /// + /// Creates a hash of a specified clear text with a mix of MD5 and SHA1. + /// + public static string CreateHash(this string clearText, object salt = null) + { + var firstHash = clearText.CreateMD5Hash(); + + firstHash = $"«6\"£k&36 2{firstHash}mmñÃ5d*"; + + firstHash += salt.ToStringOrEmpty(); + + return firstHash.CreateSHA1Hash(); + } + + /// + /// Creates MD5 hash of this text + /// Specifies whether a hex-compatible string is expected. + /// + public static string CreateMD5Hash(this string clearText, bool asHex = false) + { + var value = MD5.Create().ComputeHash(UnicodeEncoding.UTF8.GetBytes(clearText)); + + if (asHex) + return BitConverter.ToString(value).Remove("-"); + else + return Convert.ToBase64String(value); + } + + /// + /// Creates MD5 hash of this text + /// + public static string CreateMD5Hash(this string clearText) => + Convert.ToBase64String(MD5.Create().ComputeHash(UnicodeEncoding.UTF8.GetBytes(clearText))); + + /// + /// Creates SHA1 hash of this text + /// + public static string CreateSHA1Hash(this string clearText) + { + return Convert.ToBase64String(SHA1.Create().ComputeHash(UnicodeEncoding.UTF8.GetBytes(clearText))).TrimEnd('='); + } + + public static IEnumerable Split(this string text, int chunkSize) + { + if (text.HasValue()) + { + if (text.Length > chunkSize) + { + yield return text.Substring(0, chunkSize); + foreach (var part in text.Substring(chunkSize).Split(chunkSize)) + yield return part; + } + else yield return text; + } + } + + public static string Substring(this string text, int fromIndex, string toText) + { + var toIndex = text.IndexOf(toText, fromIndex + 1); + + if (fromIndex == -1) return string.Empty; + + if (toIndex == -1) return string.Empty; + + if (toIndex < fromIndex) return string.Empty; + + return text.Substring(fromIndex, toIndex - fromIndex); + } + + public static string Substring(this string text, string from, string to, bool inclusive) => + Substring(text, from, to, inclusive, caseSensitive: true); + + public static string Substring(this string text, string from, string to, bool inclusive, bool caseSensitive) + { + var comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + var fromIndex = text.IndexOf(from, comparison); + var toIndex = text.IndexOf(to, fromIndex + from.Length + 1, comparison); + + if (fromIndex == -1) + return string.Empty; + + if (toIndex == -1) + return string.Empty; + + if (toIndex < fromIndex) + return string.Empty; + + if (inclusive) toIndex += to.Length; + else fromIndex += from.Length; + + return text.Substring(fromIndex, toIndex - fromIndex); + } + + public static string ToString(this byte[] data, Encoding encoding) => encoding.GetString(data); + + /// + /// Escapes all invalid characters of this string to it's usable as a valid json constant. + /// + public static string ToJsonText(this string source) + { + if (source.IsEmpty()) return string.Empty; + + return source.Replace("\\", "\\\\").Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t").Replace("\"", "\\\""); + } + + /// + /// Getsa SHA1 hash of this text where all characters are alpha numeric. + /// + public static string ToSimplifiedSHA1Hash(this string clearText) => + new string(clearText.CreateSHA1Hash().ToCharArray().Where(c => c.IsLetterOrDigit()).ToArray()); + + /// + /// Attempts to Parse this String as the given Enum type. + /// + public static T? TryParseEnum(this string text, T? @default = null) where T : struct + { + if (Enum.TryParse(text, ignoreCase: true, result: out T value)) return value; + + return @default; + } + + /// + /// If it's null, it return empty string. Otherwise it returns this. + /// + public static string OrEmpty(this string text) + { + if (text == null) return string.Empty; + return text; + } + + /// + /// Returns the only matched string in the given text using this Regex pattern. + /// Returns null if more than one match found. + /// + public static string GetSingleMatchedValueOrDefault(this Regex pattern, string text) + { + var matches = pattern.Matches(text).Cast() + .Except(m => !m.Success || string.IsNullOrWhiteSpace(m.Value)) + .ToList(); + return matches.Count == 1 ? matches[0].Value : null; + } + + /// + /// Returns true if this collection has more than one item. + /// + public static bool HasMany(this IEnumerable collection) + { + using (var en = collection.GetEnumerator()) + return en.MoveNext() && en.MoveNext(); + } + + /// + /// Returns a string value that can be saved in xml. + /// + public static string XmlEscape(this string unescaped) + { + if (unescaped.IsEmpty()) return string.Empty; + + foreach (var set in XMLEscapingChars.Take(4)) + unescaped = unescaped.Replace(set[0], set[1]); + + return unescaped; + } + + /// + /// Returns a string value without any xml-escaped characters. + /// + public static string XmlUnescape(this string escaped) + { + if (escaped.IsEmpty()) return string.Empty; + + foreach (var set in XMLEscapingChars.Take(4)) + escaped = escaped.Replace(set[1], set[0]); + + return escaped; + } + + /// + /// Returns valid JavaScript string content with reserved characters replaced by encoded literals. + /// + public static string JavascriptEncode(this string text) + { + foreach (var ch in JsUnsafeCharacters) + { + var replace = new string(ch, 1); + var encoded = string.Format("\\x{0:X}", Convert.ToInt32(ch)); + text = text.Replace(replace, encoded); + } + + text = text.Replace(Environment.NewLine, "\\n"); + + return text; + } + + /// + /// Returns valid PascalCase JavaScript or C# string content. + /// + public static string ToPascalCaseId(this string text) + { + if (text.IsEmpty()) return text; + + return new PascalCaseIdGenerator(text).Build(); + } + + /// + /// Returns valid camelCase javaScript or C# string content. + /// + public static string ToCamelCaseId(this string text) + { + var result = ToPascalCaseId(text); + + if (result.IsEmpty()) return string.Empty; + + if (result.Length == 1) return result.ToLower(); + else return char.ToLower(result[0]) + result.Substring(1); + } + + /// + /// Converts [hello world] to [Hello World]. + /// + public static string CapitaliseFirstLetters(this string name) + { + if (name.IsEmpty()) return name; + + return name.Split(' ').Trim().Select(x => x.First().ToUpper() + x.Substring(1)).ToString(" "); + } + + /// + /// Trims all text before the specified search phrase. + /// + public static string TrimBefore(this string text, string search, bool caseSensitive = false, bool trimPhrase = false) + { + if (text.IsEmpty()) return text; + + int index; + + if (caseSensitive) index = text.IndexOf(search); + else + index = text.IndexOf(search, StringComparison.OrdinalIgnoreCase); + + if (index == -1) return text; + + text = text.Substring(index); + + if (trimPhrase) text = text.TrimStart(search, caseSensitive); + + return text; + } + + public static string TrimStart(this string text, string search, bool caseSensitive) + { + if (caseSensitive) return text.TrimStart(search); + + if (text.StartsWith(search, caseSensitive: false)) + return text.Substring(search.Length); + + return text; + } + + public static string TrimAfter(this string text, string phrase, bool trimPhrase = true, bool caseSensitive = false) + { + if (text.IsEmpty()) return text; + + int index; + + if (caseSensitive) index = text.IndexOf(phrase); + else + index = text.IndexOf(phrase, StringComparison.OrdinalIgnoreCase); + + if (index == -1) return text; + + if (!trimPhrase) index += phrase.Length; + + return text.Substring(0, index); + } + + /// + /// Returns this string. But if it's String.Empty, it returns NULL. + /// + public static string OrNullIfEmpty(this string text) + { + if (string.Equals(text, string.Empty)) return null; + + return text; + } + + /// + /// Capitalises the first letter and lower-cases the rest. + /// + public static string ToProperCase(this string name) + { + if (name.IsEmpty()) return name; + + return name.First().ToUpper() + name.Substring(1).ToLower(); + } + + /// + /// It will replace all occurances of a specified WHOLE WORD and skip occurances of the word with characters or digits attached to it. + /// + public static string ReplaceWholeWord(this string text, string word, string replacement, bool caseSensitive = true) + { + var pattern = "\\b" + Regex.Escape(word) + "\\b"; + if (caseSensitive) return Regex.Replace(text, pattern, replacement); + else return Regex.Replace(text, pattern, replacement, RegexOptions.IgnoreCase); + } + + /// + /// Returns if a specified WHOLE WORD is found in this text. It skips occurances of the word with characters or digits attached to it. + /// + public static bool ContainsWholeWord(this string text, string word, bool caseSensitive = true) + { + if (text.IsEmpty()) return false; + + var pattern = "\\b" + Regex.Escape(word) + "\\b"; + + if (caseSensitive) return Regex.IsMatch(text, pattern); + else return Regex.IsMatch(text, pattern, RegexOptions.IgnoreCase); + } + + [EscapeGCop("It is an extension for boolean type")] + public static string ToString(this bool value, string trueText, string falseText) => + ToString(value, trueText, falseText, nullText: null); + + public static string ToString(this bool? value, string trueText, string falseText, string nullText = null) + { + if (value == true) return trueText; + else if (value == false) return falseText; + else return nullText; + } + + /// + /// Ensure that this string object starts with a specified other one. + /// If it does not, then it prepends that and return the combined text. + /// + public static string EnsureStartsWith(this string text, string expression, bool caseSensitive = true) + { + if (expression.IsEmpty()) return text; + + if (text.IsEmpty()) return expression; + + if (text.StartsWith(expression, caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase)) return text; + + return expression + text; + } + + /// + /// Ensure that this string object ends with a specified other one. + /// If it does not, then it appends that and return the combined text. + /// + public static string EnsureEndsWith(this string text, string expression, bool caseSensitive = true) + { + if (expression.IsEmpty()) return text; + + if (text.IsEmpty()) return expression; + + if (text.EndsWith(expression, caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase)) return text; + + return text + expression; + } + } +} \ No newline at end of file diff --git a/Olive/-Extensions/StringBuilder.cs b/Olive/-Extensions/StringBuilder.cs new file mode 100644 index 000000000..7bc3a544d --- /dev/null +++ b/Olive/-Extensions/StringBuilder.cs @@ -0,0 +1,58 @@ +using System; +using System.Text; + +namespace Olive +{ + partial class OliveExtensions + { + [System.Diagnostics.DebuggerStepThrough] + public static void AddFormattedLine(this StringBuilder builder, string format, params object[] args) + { + try + { + builder.AppendFormat(format, args); + builder.AppendLine(); + } + catch (Exception ex) + { + throw new FormatException("Could not add formatted line of \"" + + format + "\" with the following parameters: {" + + args.ToString(", ") + "}.", ex); + } + } + + /// + /// Wraps the content of this string builder with the provided text blocks. + /// + public static void WrapIn(this StringBuilder builder, string left, string right) + { + builder.Insert(0, left); + builder.Append(right); + } + + /// + /// Wraps the content of this string builder with the provided lines of text. + /// A line-break will be added to the left element, and another line break will be added before the right element. + /// + public static void WrapInLines(this StringBuilder builder, string left, string right) + { + builder.Insert(0, left + Environment.NewLine); + builder.Append(Environment.NewLine + right); + } + + public static void AppendIf(this StringBuilder builder, string text, bool condition) + { + if (condition) builder.Append(text); + } + + public static void AppendLineIf(this StringBuilder builder, string text, bool condition) + { + if (condition) builder.AppendLine(text); + } + + public static void AppendLineIf(this StringBuilder builder, string text) + { + if (text.HasValue()) builder.AppendLine(text); + } + } +} diff --git a/Olive/-Extensions/TimeSpan.cs b/Olive/-Extensions/TimeSpan.cs new file mode 100644 index 000000000..5e531760c --- /dev/null +++ b/Olive/-Extensions/TimeSpan.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Olive +{ + partial class OliveExtensions + { + const double ACTUAL_DAYS_PER_YEAR = 365.2425; + const int NINETEEN_HUNDRED = 1900; + + public static TimeSpan Days(this int number) => TimeSpan.FromDays(number); + + public static TimeSpan Hours(this int number) => TimeSpan.FromHours(number); + + public static TimeSpan Minutes(this int number) => TimeSpan.FromMinutes(number); + + public static TimeSpan Seconds(this int number) => TimeSpan.FromSeconds(number); + + public static TimeSpan Milliseconds(this int number) => TimeSpan.FromMilliseconds(number); + + public static TimeSpan Ticks(this int number) => TimeSpan.FromTicks(number); + + public static TimeSpan Multiply(this TimeSpan @this, double by) => TimeSpan.FromMilliseconds(@this.TotalMilliseconds * by); + + /// + /// Gets the approximate number of the total years equivalent to this timespan. + /// This is not accurate due to unknown leap years in the actual period to which this TimeSpan relates. + /// + public static double ApproxTotalYears(this TimeSpan @this) => @this.TotalDays / ACTUAL_DAYS_PER_YEAR; + + /// + /// Converts this time to the date time on date of 1900-01-01. + /// + public static DateTime ToDate(this TimeSpan time) => new DateTime(NINETEEN_HUNDRED, 1, 1).Add(time); + + /// + /// Converts this time to the date time on date of 1900-01-01. + /// + public static DateTime? ToDate(this TimeSpan? time) => time?.ToDate(); + + /// + /// Gets the natural text for this timespan. For example "2 days, 4 hours and 3 minutes". + /// + public static string ToNaturalTime(this TimeSpan period) => ToNaturalTime(period, longForm: true); + + public static string ToNaturalTime(this TimeSpan period, bool longForm) => ToNaturalTime(period, 2, longForm); + + public static string ToNaturalTime(this TimeSpan period, int precisionParts) => + ToNaturalTime(period, precisionParts, longForm: true); + + [EscapeGCop("It is ok for trying methods to have out param.")] + static bool TryReduceDays(ref TimeSpan period, int len, out double result) + { + if (period.TotalDays >= len) + { + result = (int)Math.Floor(period.TotalDays / len); + period -= TimeSpan.FromDays(len * result); + + return true; + } + + result = 0; + return false; + } + + /// + /// Gets the natural text for this timespan. For example "2 days, 4 hours and 3 minutes". + /// + public static string ToNaturalTime(this TimeSpan period, int precisionParts, bool longForm) + { + // TODO: Support months and years. + // Hint: Assume the timespan shows a time in the past of NOW. Count years and months from there. + // i.e. count years and go back. Then count months and go back... + + var names = new Dictionary { { "year", "y" }, { "month", "M" }, { "week", "w" }, { "day", "d" }, { "hour", "h" }, { "minute", "m" }, { "second", "s" }, { " and ", " " }, { ", ", " " } }; + + Func name = (k) => longForm ? k : names[k]; + + var parts = new Dictionary(); + + if (TryReduceDays(ref period, 365, out double years)) + parts.Add(name("year"), years); + + if (TryReduceDays(ref period, 30, out double months)) + parts.Add(name("month"), months); + + if (TryReduceDays(ref period, 7, out double weeks)) + parts.Add(name("week"), weeks); + + if (period.TotalDays >= 1) + { + parts.Add(name("day"), period.Days); + period -= TimeSpan.FromDays(period.Days); + } + + if (period.TotalHours >= 1 && period.Hours > 0) + { + parts.Add(name("hour"), period.Hours); + period = period.Subtract(TimeSpan.FromHours(period.Hours)); + } + + if (period.TotalMinutes >= 1 && period.Minutes > 0) + { + parts.Add(name("minute"), period.Minutes); + period = period.Subtract(TimeSpan.FromMinutes(period.Minutes)); + } + + if (period.TotalSeconds >= 1 && period.Seconds > 0) + { + parts.Add(name("second"), period.Seconds); + period = period.Subtract(TimeSpan.FromSeconds(period.Seconds)); + } + + else if (period.TotalSeconds > 0) + { + parts.Add(name("second"), period.TotalSeconds.Round(3)); + period = TimeSpan.Zero; + } + + var outputParts = parts.Take(precisionParts).ToList(); + var r = new StringBuilder(); + + foreach (var part in outputParts) + { + r.Append(part.Value); + + if (longForm) r.Append(" "); + + r.Append(part.Key); + + if (part.Value > 1 && longForm) r.Append("s"); + + if (outputParts.IndexOf(part) == outputParts.Count - 2) + r.Append(name(" and ")); + else if (outputParts.IndexOf(part) < outputParts.Count - 2) + r.Append(name(", ")); + } + + return r.ToString(); + } + + public static string ToString(this TimeSpan? value, string format) => ("{0:" + format + "}").FormatWith(value); + + public static int CompareTo(this TimeSpan? @this, TimeSpan? another) + { + if (@this == another) return 0; + if (another == null) return @this == TimeSpan.Zero ? 1 : @this.Value.CompareTo(TimeSpan.Zero); + if (@this == null) return another == TimeSpan.Zero ? -1 : another.Value.CompareTo(TimeSpan.Zero); + return @this.Value.CompareTo(another.Value); + } + } +} \ No newline at end of file diff --git a/Olive/-Extensions/Xml.cs b/Olive/-Extensions/Xml.cs new file mode 100644 index 000000000..9230c9cfc --- /dev/null +++ b/Olive/-Extensions/Xml.cs @@ -0,0 +1,121 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Linq; + +namespace Olive +{ + partial class OliveExtensions + { + /// + /// Gets an Element with the specified path. For example "Tree/Branch1/Branch2". + /// + public static XElement GetElement(this XContainer parent, string path) => FindNode(parent, path) as XElement; + + /// + /// Gets a node with the specified path. For example "Tree/Branch1/Branch2". + /// + [Obsolete("Use FindNode instead")] + public static XObject GetNode(this XContainer parent, string path) => parent.FindNode(path); + + /// + /// Finds a node with the specified path. For example "Tree/Branch1/Branch2". + /// + public static XObject FindNode(this XContainer parent, string path) + { + if (path.IsEmpty()) + throw new ArgumentNullException(nameof(path)); + + var node = parent; + + foreach (var part in path.Split('/')) + { + if (part.StartsWith("@")) + { + // Attribute: + var element = node as XElement; + if (element == null) return null; + else + { + var attributeName = part.Substring(1); + var withXName = element.Attribute(attributeName); + if (withXName != null) return withXName; + else + return element.Attributes().FirstOrDefault(a => a.Name != null && a.Name.LocalName == attributeName); + } + } + else + { + var withXName = node.Element(part); + if (withXName != null) node = withXName; + else + { + node = node.Elements().FirstOrDefault(e => e.Name != null && e.Name.LocalName == part); + if (node == null) return null; + } + } + } + + return node; + } + + /// + /// Gets the value of an attribute or inner text of an element with the specified path. For example "Tree/Branch1/Branch2". + /// + public static T GetValue(this XContainer parent, string path) + { + string value = null; + + var node = parent.FindNode(path); + + if (node is XElement) value = (node as XElement).Value; + else if (node is XAttribute) value = (node as XAttribute).Value; + else if (node != null) + throw new Exception("The provided path (" + path + ") points to an invalid Xml node (" + node.GetType() + ")."); + + if (value.IsEmpty()) return default(T); + + if (typeof(T) == typeof(string)) return (T)(object)value; + + return value.To(); + } + + /// + /// Adds this node to a specified container and returns it back to be used as fluent API. + /// + public static T AddTo(this T node, XContainer container) where T : XNode + { + container.Add(node); + return node; + } + + /// + /// Removes all namespaces from this element. + /// + public static XElement RemoveNamespaces(this XElement node) + { + var result = new XElement(node.Name.LocalName); + + foreach (var attribute in node.Attributes()) + result.Add(new XAttribute(attribute.Name.LocalName, attribute.Value)); + + if (node.HasElements) + foreach (var child in node.Elements()) + result.Add(child.RemoveNamespaces()); + else + result.Value = node.Value; + + return result; + } + + public static XmlReader ToXmlReader(this string xmlString) => XmlReader.Create(new StringReader(xmlString)); + + // public static XmlElement ToXmlElement(this XElement element) + // { + // var doc = new XmlDocument(); + // doc.Load(element.CreateReader()); + // return doc.DocumentElement; + // } + } +} \ No newline at end of file diff --git a/Olive/Logging/DefaultLogger.cs b/Olive/Logging/DefaultLogger.cs new file mode 100644 index 000000000..26eb032e4 --- /dev/null +++ b/Olive/Logging/DefaultLogger.cs @@ -0,0 +1,49 @@ +using System; + +namespace Olive +{ + public class DefaultLogger : ILogger + { + static DefaultLogger InstanceField; + + public bool Enable { get; set; } + + public static DefaultLogger Instance + { + get + { + if (InstanceField == null) + { + InstanceField = new DefaultLogger + { + Enable = Config.Get(key: "DebugMode", defaultValue: false) + }; + } + + return InstanceField; + } + } + + public DefaultLogger() + { + // single tone + } + + public void Log(string eventTitle, string description, object relatedObject, string userId, string userIp) + { + if (!Enable) return; + + Console.Write($"Event Start\r\nTitle: '{eventTitle}', UserId: '{userId}', UserIP: '{userIp}'"); + Console.Write($"Description: {description}\r\nEvent End"); + } + + public void RecordException(Exception ex) => RecordException(string.Empty, ex); + + public void RecordException(string description, Exception ex) + { + if (!Enable) return; + + Console.Write(ex.ToLogString(description)); + } + } +} diff --git a/Olive/Logging/ILogger.cs b/Olive/Logging/ILogger.cs new file mode 100644 index 000000000..cf2ea0af2 --- /dev/null +++ b/Olive/Logging/ILogger.cs @@ -0,0 +1,11 @@ +namespace Olive +{ + using System; + + public interface ILogger + { + void RecordException(Exception ex); + void RecordException(string description, Exception ex); + void Log(string eventTitle, string description, object relatedObject, string userId, string userIp); + } +} \ No newline at end of file diff --git a/Olive/Logging/Log.cs b/Olive/Logging/Log.cs new file mode 100644 index 000000000..bed548206 --- /dev/null +++ b/Olive/Logging/Log.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace Olive +{ + public static class Log + { + static List Loggers = new List(); + + static Log() => Loggers.Add(DefaultLogger.Instance); + + public static void RegisterLogger(ILogger logger) => Loggers.Add(logger); + + public static void ClearLogger() => Loggers.Clear(); + + public static void Error(Exception ex) => Loggers.ForEach(logger => logger.RecordException(ex)); + + public static void Error(string description, Exception ex = null) + { + if (ex == null) Record("Exception", description); + else + Loggers.ForEach(logger => logger.RecordException(description, ex)); + } + + public static void Warning(string description, object relatedObject = null, string userId = null, string userIp = null) => + Record("Warning", description, relatedObject, userId, userIp); + + public static void Debug(string description, object relatedObject = null, string userId = null, string userIp = null) => + Record("Debug", description, relatedObject, userId, userIp); + + public static void Info(string description, object relatedObject = null, string userId = null, string userIp = null) => + Record("Info", description, relatedObject, userId, userIp); + + public static void Audit(string description, object relatedObject = null, string userId = null, string userIp = null) => + Record("Audit", description, relatedObject, userId, userIp); + + public static void Record(string eventTitle, string description, object relatedObject = null, string userId = null, string userIp = null) => + Loggers.ForEach(logger => logger.Log(eventTitle, description, relatedObject, userId, userIp)); + } +} \ No newline at end of file diff --git a/Olive/Logo.png b/Olive/Logo.png new file mode 100644 index 000000000..9d2475044 Binary files /dev/null and b/Olive/Logo.png differ diff --git a/Olive/Nuget.png b/Olive/Nuget.png new file mode 100644 index 000000000..aad9cc9a3 Binary files /dev/null and b/Olive/Nuget.png differ diff --git a/Olive/Olive.csproj b/Olive/Olive.csproj new file mode 100644 index 000000000..a834b4394 --- /dev/null +++ b/Olive/Olive.csproj @@ -0,0 +1,27 @@ + + + + netcoreapp2.0 + Olive + Olive + + + + ..\@Assemblies\ + ..\@Assemblies\netcoreapp2.0\Olive.xml + 1701;1702;1705;1591;1573 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Olive/Package.nuspec b/Olive/Package.nuspec new file mode 100644 index 000000000..736f9dad8 --- /dev/null +++ b/Olive/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive + 1.0.3 + Olive + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Olive/Security/Encryption.cs b/Olive/Security/Encryption.cs new file mode 100644 index 000000000..7226eb65b --- /dev/null +++ b/Olive/Security/Encryption.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace Olive +{ + public class Encryption + { + const int MAX_ENCRYPTION_PART_LENGTH = 117, SIXTEEN = 16, THIRTY_TWO = 32; + + /// + /// Generates a public/private key for asymmetric encryption. + /// + public static KeyValuePair GenerateAsymmetricKeys() + { + var rsa = CreateRSACryptoServiceProvider(); + + return new KeyValuePair(rsa.ToXmlString(includePrivateParameters: false), rsa.ToXmlString(includePrivateParameters: true)); + } + + static RSA CreateRSACryptoServiceProvider() => RSA.Create(); + + /// + /// Encrypts the specified text with the specified public key. + /// + /// The default padding value is OaepSHA512. + public static string EncryptAsymmetric(string text, string publicKeyXml, RSAEncryptionPadding padding = null) + { + if (text.IsEmpty()) + throw new ArgumentNullException(nameof(text)); + + if (publicKeyXml.IsEmpty()) + throw new ArgumentNullException(nameof(publicKeyXml)); + + if (text.Length > MAX_ENCRYPTION_PART_LENGTH) + { + return text.Split(MAX_ENCRYPTION_PART_LENGTH).Select(p => EncryptAsymmetric(p, publicKeyXml)).ToString("|"); + } + else + { + var rsa = CreateRSACryptoServiceProvider(); + + rsa.FromXmlString(publicKeyXml); + + return rsa.Encrypt(text.ToBytes(Encoding.UTF8), padding ?? RSAEncryptionPadding.OaepSHA512).ToBase64String(); + } + } + + /// + /// Decrypts the specified text with the specified public/private key pair. + /// + /// The default padding value is OaepSHA512. + public static string DecryptAsymmetric(string encodedText, string publicPrivateKeyXml, RSAEncryptionPadding padding = null) + { + if (encodedText.IsEmpty()) + throw new ArgumentNullException(nameof(encodedText)); + + if (publicPrivateKeyXml.IsEmpty()) + throw new ArgumentNullException(nameof(publicPrivateKeyXml)); + + if (encodedText.Contains("|")) + { + return encodedText.Split('|').Select(p => DecryptAsymmetric(p, publicPrivateKeyXml)).ToString(string.Empty); + } + else + { + var rsa = CreateRSACryptoServiceProvider(); + + rsa.FromXmlString(publicPrivateKeyXml); + + return rsa.Decrypt(encodedText.ToBytes(), padding ?? RSAEncryptionPadding.OaepSHA512).ToString(Encoding.UTF8); + } + } + + /// + /// Encrypts the specified text with the specified password. + /// + public static string Encrypt(string text, string password) + { + if (text.IsEmpty()) + throw new ArgumentNullException(nameof(text)); + + if (password.IsEmpty()) + throw new ArgumentNullException(nameof(password)); + + using (var key = new Rfc2898DeriveBytes(password, Encoding.ASCII.GetBytes(password.Length.ToString()))) + { + var aes = Aes.Create(); + aes.Padding = PaddingMode.PKCS7; + var encryptor = aes.CreateEncryptor(key.GetBytes(THIRTY_TWO), key.GetBytes(SIXTEEN)); + + var textData = Encoding.Unicode.GetBytes(text); + using (var encrypted = new MemoryStream()) + { + using (var cryptoStream = new CryptoStream(encrypted, encryptor, CryptoStreamMode.Write)) + { + cryptoStream.Write(textData, 0, textData.Length); + cryptoStream.FlushFinalBlock(); + + return Convert.ToBase64String(encrypted.ToArray()); + } + } + } + } + + /// + /// Decrypts the specified encrypted text with the specified password. + /// + public static string Decrypt(string encryptedText, string password) + { + using (var key = new Rfc2898DeriveBytes(password, Encoding.ASCII.GetBytes(password.Length.ToString()))) + { + var encryptedData = encryptedText.ToBytes(); + + using (var aes = Aes.Create()) + { + aes.Padding = PaddingMode.PKCS7; + + using (var decryptor = aes.CreateDecryptor(key.GetBytes(THIRTY_TWO), key.GetBytes(SIXTEEN))) + { + using (var memoryStream = new MemoryStream(encryptedData)) + { + using (var cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read)) + { + // The size of decrypted data is unknown, so we allocate a buffer big enough to store encrypted data. + // decrypted data is always the same or smaller than encrypted data. + var plainText = new byte[encryptedData.Length]; + int decryptedSize = cryptoStream.Read(plainText, 0, plainText.Length); + + return Encoding.Unicode.GetString(plainText, 0, decryptedSize); + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/Olive/Security/JsonExposedAttribute.cs b/Olive/Security/JsonExposedAttribute.cs new file mode 100644 index 000000000..0b7ac63b8 --- /dev/null +++ b/Olive/Security/JsonExposedAttribute.cs @@ -0,0 +1,8 @@ +using System; + +namespace Olive +{ + /// Marks a property as Serializable (mainly for Json). + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + public class JsonExposedAttribute : Attribute { } +} \ No newline at end of file diff --git a/Olive/Security/PessimisticJsonConverter.cs b/Olive/Security/PessimisticJsonConverter.cs new file mode 100644 index 000000000..e1290e470 --- /dev/null +++ b/Olive/Security/PessimisticJsonConverter.cs @@ -0,0 +1,69 @@ +using System; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Olive +{ + /// + /// When serializing objects it ignores all properties unless they have [Exposed] attribute. + /// + public class PessimisticJsonConverter : JsonConverter + { + static BindingFlags Flags = BindingFlags.Instance | BindingFlags.Public; + + public override bool CanConvert(Type objectType) => true; + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteStartObject(); + + Action add = (member, val) => + { + writer.WritePropertyName(GetJsonName(member)); + serializer.Serialize(writer, val); + }; + + foreach (var property in value.GetType().GetProperties(Flags)) + if (property.Defines()) add(property, property.GetValue(value)); + + foreach (var property in value.GetType().GetFields(Flags)) + if (property.Defines()) add(property, property.GetValue(value)); + + writer.WriteEndObject(); + } + + static string GetJsonName(MemberInfo member) + { + var customName = member.GetCustomAttribute()?.PropertyName; + return customName.Or(member.Name); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = objectType.CreateInstance(); + + foreach (var jsonProperty in JObject.Load(reader).Properties().ToList()) + { + var objectProperty = objectType.GetProperties(Flags).FirstOrDefault(x => GetJsonName(x) == jsonProperty.Name); + if (objectProperty != null) + { + var castedValue = jsonProperty.Value.ToString().To(objectProperty.PropertyType); + objectProperty.SetValue(result, castedValue); + } + else + { + var objectField = objectType.GetFields(Flags).FirstOrDefault(x => GetJsonName(x) == jsonProperty.Name); + if (objectField != null) + { + var castedValue = jsonProperty.Value.ToString().To(objectField.FieldType); + objectField?.SetValue(result, castedValue); + } + } + } + + return result; + } + } +} \ No newline at end of file diff --git a/Olive/Security/SecurePassword.cs b/Olive/Security/SecurePassword.cs new file mode 100644 index 000000000..17d1ebe1f --- /dev/null +++ b/Olive/Security/SecurePassword.cs @@ -0,0 +1,71 @@ +namespace Olive +{ + using System; + using System.Security.Cryptography; + + /// + /// Provides secure password hashing service based on PBKDF2. + /// + public class SecurePassword + { + // The following constants may be changed without breaking existing hashes. + const int SALT_BYTE_SIZE = 64, HASH_BYTE_SIZE = 64; + static int PBKDF2_ITERATIONS = Config.Get("Secure.Password.Pbkdf2.Iterations", defaultValue: 10000); + + public string Password { get; set; } + public string Salt { get; set; } + + /// + /// Creates a salted PBKDF2 hash of the password. + /// + public static SecurePassword Create(string password) + { + // Generate a random salt + using (var csprng = RandomNumberGenerator.Create()) + { + var salt = new byte[SALT_BYTE_SIZE]; + csprng.GetBytes(salt); + // Hash the password and encode the parameters + var hashBytes = GetBytes(password, salt, PBKDF2_ITERATIONS, HASH_BYTE_SIZE); + + return new SecurePassword + { + Password = Convert.ToBase64String(hashBytes), + Salt = Convert.ToBase64String(salt) + }; + } + } + + /// + /// Validates a password given a hash of the correct one. + /// + public static bool Verify(string clearTextPassword, string hashedPassword, string salt) + { + if (clearTextPassword.IsEmpty()) return false; + if (hashedPassword.IsEmpty()) return false; + if (salt.IsEmpty()) return false; + + var pass = new SecurePassword { Password = hashedPassword, Salt = salt }; + + var hashedBytes = pass.GetHashBytes(); + + var testHash = GetBytes(clearTextPassword, pass.GetSaltBytes(), PBKDF2_ITERATIONS, hashedBytes.Length); + return SlowEquals(hashedBytes, testHash); + } + + static bool SlowEquals(byte[] leftBytes, byte[] rightBytes) + { + var diff = (uint)leftBytes.Length ^ (uint)rightBytes.Length; + for (var i = 0; i < leftBytes.Length && i < rightBytes.Length; i++) + diff |= (uint)(leftBytes[i] ^ rightBytes[i]); + return diff == 0; + } + + static byte[] GetBytes(string password, byte[] salt, int iterations, int outputBytes) => + new Rfc2898DeriveBytes(password, salt, iterations).GetBytes(outputBytes); + + byte[] GetHashBytes() => Convert.FromBase64String(Password); + + byte[] GetSaltBytes() => Convert.FromBase64String(Salt); + } +} \ No newline at end of file diff --git a/Olive/TPL/AsyncEvent/AsyncEvent.Handler.cs b/Olive/TPL/AsyncEvent/AsyncEvent.Handler.cs new file mode 100644 index 000000000..a204cc2f9 --- /dev/null +++ b/Olive/TPL/AsyncEvent/AsyncEvent.Handler.cs @@ -0,0 +1,132 @@ +namespace Olive +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Threading.Tasks; + + public interface IAsyncEventHandler : IDisposable { object Action { get; } } + + /// + /// Provides a mechanism to prevent event handler dependency memory leaks. + /// + /// + public class EventHandlerDisposer + { + object SyncLock = new object(); + List> Dependencies = new List>(); + + /// + /// Will dispose all registered event handlers and clear them from the list. + /// + public void DisposeAll() + { + lock (SyncLock) + { + foreach (var c in Dependencies.ToArray()) + { + c.GetTargetOrDefault()?.Dispose(); + c.SetTarget(null); + Dependencies.Remove(c); + } + } + } + + public void Register(IAsyncEventHandler handler) + { + if (handler == null) return; + + lock (SyncLock) + Dependencies.Add(handler.GetWeakReference()); + } + } + + public abstract class AsyncEventHandler : IDisposable, IEquatable + { + internal string Caller; + protected bool IsDisposed; + + [DebuggerStepThrough] + protected Task Raise(Func raiser) + { + if (IsDisposed) return Task.CompletedTask; + + if (raiser == null) return Task.CompletedTask; + return raiser.Invoke(); + } + + [DebuggerStepThrough] + protected Task Raise(Action action) + { + if (IsDisposed) return Task.CompletedTask; + + action?.Invoke(); + + return Task.CompletedTask; + } + + public abstract bool Equals(AsyncEventHandler other); + + public abstract void Dispose(); + } + + public abstract class AsyncEventHandler : AsyncEventHandler, IAsyncEventHandler + where TActionFunction : class + { + internal TActionFunction Action; + // WeakReference EventRef; + + internal AbstractAsyncEvent Event { get; set; } + + // internal AbstractAsyncEvent Event + // { + // get => EventRef?.GetTargetOrDefault(); + // set => value?.GetWeakReference(); + // } + + object IAsyncEventHandler.Action => Action; + + public override bool Equals(AsyncEventHandler other) + { + return Action == (other as AsyncEventHandler)?.Action; + } + + public override void Dispose() + { + IsDisposed = true; + + Event?.RemoveHandler(this); + // EventRef = null; + Event = null; + Action = null; + Caller = null; + } + } + + public class AsyncEventActionHandler : AsyncEventHandler + { + [DebuggerStepThrough] + internal Task Raise() => Raise(Action); + } + + public class AsyncEventTaskHandler : AsyncEventHandler> + { + [DebuggerStepThrough] + internal Task Raise() => Raise(RaiseIt); + + [DebuggerStepThrough] + Task RaiseIt() => Action?.Invoke() ?? Task.CompletedTask; + } + + public class AsyncEventActionHandler : AsyncEventHandler> + { + [DebuggerStepThrough] + internal Task Raise(T arg) => Raise(() => Action?.Invoke(arg)); + } + + public class AsyncEventTaskHandler : AsyncEventHandler> + { + [DebuggerStepThrough] + internal Task Raise(T arg) => Raise(() => Action?.Invoke(arg) ?? Task.CompletedTask); + } +} \ No newline at end of file diff --git a/Olive/TPL/AsyncEvent/AsyncEvent.Raise.cs b/Olive/TPL/AsyncEvent/AsyncEvent.Raise.cs new file mode 100644 index 000000000..32f052e31 --- /dev/null +++ b/Olive/TPL/AsyncEvent/AsyncEvent.Raise.cs @@ -0,0 +1,82 @@ +namespace Olive +{ + using System; + using System.Diagnostics; + using System.Linq; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + + partial class AbstractAsyncEvent + { + AsyncLock RaisingLock; + bool IsRaising; + + /// + /// Determines how concurrent attempts to raise an event should be handled. + /// + protected ConcurrentEventRaisePolicy ConcurrentRaisePolicy = ConcurrentEventRaisePolicy.Parallel; + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + protected async Task Raise(Func raiser, bool inParallel) + { + if (Handlers.None()) return; + + var handlers = Handlers.ToArray(); + + await Raise(handlers, raiser, inParallel); + } + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + Task Raise(AsyncEventHandler[] handlers, Func raiser, bool inParallel) + { + switch (ConcurrentRaisePolicy) + { + case ConcurrentEventRaisePolicy.Parallel: return RaiseOnce(handlers, raiser, inParallel); + case ConcurrentEventRaisePolicy.Ignore: return RaiseWithIgnorePolicy(handlers, raiser, inParallel); + case ConcurrentEventRaisePolicy.Queue: return RaiseWithQueuePolicy(handlers, raiser, inParallel); + default: throw new NotImplementedException(); + } + } + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + async Task RaiseWithIgnorePolicy(AsyncEventHandler[] handlers, Func raiser, bool inParallel) + { + if (IsRaising) return; + + IsRaising = true; + try { await RaiseOnce(handlers, raiser, inParallel); } + finally { IsRaising = false; } + } + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + async Task RaiseWithQueuePolicy(AsyncEventHandler[] handlers, Func raiser, bool inParallel) + { + if (RaisingLock == null) RaisingLock = new AsyncLock(); + + using (await RaisingLock.Lock()) await RaiseOnce(handlers, raiser, inParallel); + } + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + async Task RaiseOnce(AsyncEventHandler[] handlers, Func raiser, bool inParallel) + { + try + { + if (inParallel) + { + await Task.WhenAll(handlers.Select(x => + { + if (!IsDisposing) return raiser(x); + else return Task.CompletedTask; + })); + } + else foreach (var h in handlers) + if (!IsDisposing) await raiser(h); + } + catch (Exception ex) + { + Debug.WriteLine($"Raising the event {DeclaringType}.{EventName} failed: " + ex.ToLogString()); + throw; + } + } + } +} diff --git a/Olive/TPL/AsyncEvent/AsyncEvent.TArg.cs b/Olive/TPL/AsyncEvent/AsyncEvent.TArg.cs new file mode 100644 index 000000000..b9eecb11e --- /dev/null +++ b/Olive/TPL/AsyncEvent/AsyncEvent.TArg.cs @@ -0,0 +1,99 @@ +namespace Olive +{ + using System; + using System.Diagnostics; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + + public class AsyncEvent : AbstractAsyncEvent + { + public AsyncEvent([CallerMemberName] string eventName = "", [CallerFilePath] string declaringFile = "") + : base(eventName, declaringFile) { } + + public AsyncEvent(ConcurrentEventRaisePolicy raisePolicy, [CallerMemberName] string eventName = "", [CallerFilePath] string declaringFile = "") : base(eventName, declaringFile) + { + ConcurrentRaisePolicy = raisePolicy; + } + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + public AsyncEvent Handle(Func handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int line = 0) + { + return DoHandleOn>(handler, null, callerFile, line); + } + + /// + /// The same as Handle. It's added to get past the strange bug in C# for selecting the correct overload of Handle(). + /// + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + public AsyncEvent HandleWith(Action handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int line = 0) + { + return DoHandleOn(null, handler, callerFile, line); + } + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + public AsyncEvent Handle(Action handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int line = 0) + { + return DoHandleOn(null, handler, callerFile, line); + } + + protected AsyncEvent DoHandleOn(Func handlerTask, Action handlerAction, + string callerFile, int line) + { + return DoHandleOn>(handlerTask, handlerAction, callerFile, line); + } + + [DebuggerStepThrough] + public IAsyncEventHandler CreateHandler(Func handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int line = 0) + { + if (handler == null) throw new ArgumentNullException(nameof(handler)); + + lock (Handlers) + { + RemoveHandler(handler); + + var result = new AsyncEventTaskHandler + { + Action = handler, + Event = this, + Caller = Debugger.IsAttached ? $"{callerFile}:{line}" : string.Empty + }; + + Handlers.Add(result); + return result; + } + } + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + Task RaiseHandler(AsyncEventHandler handler, TArg arg) + { + if (handler is AsyncEventActionHandler h) return h.Raise(); + else if (handler is AsyncEventTaskHandler t) return t.Raise(); + else if (handler is AsyncEventActionHandler ha) return ha.Raise(arg); + else if (handler is AsyncEventTaskHandler aa) return aa.Raise(arg); + else return Task.CompletedTask; + } + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task Raise(TArg arg) => Raise(arg, inParallel: false); + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task Raise(TArg arg, bool inParallel) => Raise(h => RaiseHandler(h, arg), inParallel); + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + public AsyncEvent RemoveHandler(Action handler) => this.DoRemoveHandler(handler); + + /// + /// The same as RemoveHandler. + /// It's added to get past the strange bug in C# for selecting the correct overload of RemoveHandler(). + /// + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + public AsyncEvent RemoveActionHandler(Action handler) => this.DoRemoveHandler(handler); + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + public AsyncEvent RemoveHandler(Func handler) => this.DoRemoveHandler(handler); + } +} \ No newline at end of file diff --git a/Olive/TPL/AsyncEvent/AsyncEvent.cs b/Olive/TPL/AsyncEvent/AsyncEvent.cs new file mode 100644 index 000000000..c12843c2c --- /dev/null +++ b/Olive/TPL/AsyncEvent/AsyncEvent.cs @@ -0,0 +1,30 @@ +namespace Olive +{ + using System.Diagnostics; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + + public class AsyncEvent : AbstractAsyncEvent + { + public AsyncEvent([CallerMemberName] string eventName = "", [CallerFilePath] string declaringFile = "") : base(eventName, declaringFile) { } + + public AsyncEvent(ConcurrentEventRaisePolicy raisePolicy, [CallerMemberName] string eventName = "", [CallerFilePath] string declaringFile = "") : base(eventName, declaringFile) + { + ConcurrentRaisePolicy = raisePolicy; + } + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task RaiseHandler(AsyncEventHandler handler) + { + if (handler is AsyncEventActionHandler h) return h.Raise(); + else if (handler is AsyncEventTaskHandler t) return t.Raise(); + else return Task.CompletedTask; + } + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task Raise() => Raise(inParallel: false); + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task Raise(bool inParallel) => Raise(RaiseHandler, inParallel); + } +} \ No newline at end of file diff --git a/Olive/TPL/AsyncEvent/AsyncEventExtensions.cs b/Olive/TPL/AsyncEvent/AsyncEventExtensions.cs new file mode 100644 index 000000000..e79da7654 --- /dev/null +++ b/Olive/TPL/AsyncEvent/AsyncEventExtensions.cs @@ -0,0 +1,190 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace Olive +{ + public static class AsyncEventExtensions + { + /// + /// The same as RemoveHandler. + /// It's added to get past the strange bug in C# for selecting the correct overload of RemoveHandler(). + /// + public static TEvent RemoveActionHandler(this TEvent @event, Action handler) + where TEvent : AbstractAsyncEvent + { + return @event.DoRemoveHandler(handler); + } + + [DebuggerStepThrough] + public static TEvent RemoveActionHandler(this TEvent @event, Action handler) + where TEvent : AbstractAsyncEvent + { + return @event.DoRemoveHandler(handler); + } + + [DebuggerStepThrough] + public static TEvent RemoveActionHandler(this TEvent @event, Action handler) + where TEvent : AbstractAsyncEvent + { + return @event.DoRemoveHandler(handler); + } + + [DebuggerStepThrough] + public static TEvent RemoveHandler(this TEvent @event, Func handler) + where TEvent : AbstractAsyncEvent + { + return @event.DoRemoveHandler(handler); + } + + [DebuggerStepThrough] + public static TEvent RemoveHandler(this TEvent @event, Func handler) + where TEvent : AbstractAsyncEvent + { + return @event.DoRemoveHandler(handler); + } + + [DebuggerStepThrough] + public static TEvent RemoveHandler(this TEvent @event, Func handler) + where TEvent : AbstractAsyncEvent + { + return @event.DoRemoveHandler(handler); + } + + [DebuggerStepThrough] + internal static TEvent DoRemoveHandler(this TEvent @event, object handlerFunction) + where TEvent : AbstractAsyncEvent + { + lock (@event.Handlers) + { + var itemsToRemove = @event.Handlers.Where(x => ((IAsyncEventHandler)x).Action == handlerFunction).ToArray(); + itemsToRemove.Do(x => x.Dispose()); + @event.Handlers.Remove(itemsToRemove); + } + + return @event; + } + + /// + /// The same as Handle. It's added to get past the strange bug in C# for selecting the correct overload of Handle(). + /// + public static TEvent HandleWith(this TEvent @event, Action handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int callerLine = 0) + where TEvent : AbstractAsyncEvent + { + return Handle(@event, handler, callerFile, callerLine); + } + + public static TEvent Handle(this TEvent @event, Action handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int callerLine = 0) + where TEvent : AbstractAsyncEvent + { + return HandleOn(@event, handler, callerFile, callerLine); + } + + public static TEvent Handle(this TEvent @event, Func handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int callerLine = 0) + where TEvent : AbstractAsyncEvent + { + return HandleOn(@event, handler, callerFile, callerLine); + } + + /// + /// The same as HandleOn. It's added to get past the strange bug in C# for selecting the correct overload of HandleOn(). + /// + public static TEvent HandleActionOn(this TEvent @event, Action handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int line = 0) + where TEvent : AbstractAsyncEvent + { + return HandleOn(@event, handler, callerFile, line); + } + + public static TEvent HandleOn(this TEvent @event, Action handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int line = 0) + where TEvent : AbstractAsyncEvent + { + if (handler == null) return @event; + + lock (@event.Handlers) + @event.Handlers.AddUnique(new AsyncEventActionHandler + { + Action = handler, + Event = @event, + Caller = Debugger.IsAttached ? $"{callerFile}:{line}" : string.Empty + }); + + return @event; + } + + /// + /// Creates an event handler which you can dispose of explicitly if required. + /// + public static IAsyncEventHandler CreateActionHandler(this TEvent @event, Action handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int line = 0) + where TEvent : AbstractAsyncEvent + { + if (handler == null) throw new ArgumentNullException(nameof(handler)); + + lock (@event.Handlers) + { + @event.RemoveActionHandler(handler); + + var result = new AsyncEventActionHandler + { + Action = handler, + Event = @event, + Caller = Debugger.IsAttached ? $"{callerFile}:{line}" : string.Empty + }; + + @event.Handlers.Add(result); + return result; + } + } + + /// + /// Creates an event handler which you can dispose of explicitly if required. + /// + public static IAsyncEventHandler CreateHandler(this TEvent @event, Func handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int line = 0) + where TEvent : AbstractAsyncEvent + { + if (handler == null) throw new ArgumentNullException(nameof(handler)); + + lock (@event.Handlers) + { + @event.RemoveHandler(handler); + + var result = new AsyncEventTaskHandler + { + Action = handler, + Event = @event, + Caller = Debugger.IsAttached ? $"{callerFile}:{line}" : string.Empty + }; + + @event.Handlers.Add(result); + return result; + } + } + + public static TEvent HandleOn(this TEvent @event, Func handler, + [CallerFilePath] string callerFile = null, [CallerLineNumber] int line = 0) + where TEvent : AbstractAsyncEvent + { + if (handler == null) return @event; + + lock (@event.Handlers) + { + @event.Handlers.AddUnique(new AsyncEventTaskHandler + { + Action = handler, + Event = @event, + Caller = Debugger.IsAttached ? $"{callerFile}:{line}" : string.Empty + }); + } + + return @event; + } + } +} \ No newline at end of file diff --git a/Olive/TPL/AsyncEvent/AsyncEventHandlingException.cs b/Olive/TPL/AsyncEvent/AsyncEventHandlingException.cs new file mode 100644 index 000000000..576e61b13 --- /dev/null +++ b/Olive/TPL/AsyncEvent/AsyncEventHandlingException.cs @@ -0,0 +1,9 @@ +namespace Olive +{ + using System; + + public class AsyncEventHandlingException : Exception + { + public AsyncEventHandlingException(string message, Exception innerException) : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/Olive/TPL/AsyncEvent/BaseAsyncEvent.cs b/Olive/TPL/AsyncEvent/BaseAsyncEvent.cs new file mode 100644 index 000000000..35226a1d9 --- /dev/null +++ b/Olive/TPL/AsyncEvent/BaseAsyncEvent.cs @@ -0,0 +1,107 @@ +namespace Olive +{ + using System; + using System.Diagnostics; + using System.Linq; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + + public interface IAsyncEvent { bool IsHandled(); } + + public abstract partial class AbstractAsyncEvent : IAsyncEvent, IDisposable + { + public TimeSpan? Timeout { get; set; } + + protected WeakReference OwnerReference; + protected string DeclaringFile, EventName; + protected bool IsDisposing; + + internal protected ConcurrentList Handlers = new ConcurrentList(); + + protected AbstractAsyncEvent(string eventName, string declaringFile) + { + if (Debugger.IsAttached) + { + EventName = eventName; + DeclaringFile = declaringFile; + } + } + + public int HandlersCount => Handlers.Count; + + public void SetOwner(object owner) => OwnerReference = owner.GetWeakReference(); + + public object Owner => OwnerReference.GetTargetOrDefault(); + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + internal bool HasHandler(object handlerFunction) + { + return Handlers.Any(x => ReferenceEquals(((IAsyncEventHandler)x).Action, handlerFunction)); + } + + public bool IsHandled() => Handlers.Any(); + + protected string DeclaringType => DeclaringFile.OrEmpty().Split('\\').LastOrDefault().TrimEnd(".cs", caseSensitive: false).Split('.').FirstOrDefault(); + + public override string ToString() => DeclaringType + "." + EventName + " ◀ " + Owner; + + internal void RemoveHandler(AsyncEventHandler handler) + { + lock (Handlers) Handlers.Remove(handler); + } + + /// Removes all current handlers from this event. + public void ClearHandlers() => Handlers = new ConcurrentList(); + + /// + /// Returns a tasks that completes once as soon as this event is fired. + /// + public Task AwaitRaiseCompletion() + { + var completionTask = new TaskCompletionSource(); + + void waiter() + { + completionTask.TrySetResult(result: true); + this.RemoveActionHandler(waiter); + } + + this.HandleWith(waiter); + return completionTask.Task; + } + + [DebuggerStepThrough, MethodImpl(MethodImplOptions.AggressiveInlining)] + protected TReturn DoHandleOn(Func handlerTask, + Action handlerAction, string callerFile, int line) + where TReturn : AbstractAsyncEvent + { + if (handlerTask == null && handlerAction == null) return (TReturn)this; + + var caller = Debugger.IsAttached ? $"{callerFile}:{line}" : string.Empty; + + if (handlerTask != null && !HasHandler(handlerTask)) + Handlers.Add(new AsyncEventTaskHandler + { + Action = handlerTask, + Event = this, + Caller = caller + }); + + if (handlerAction != null && !HasHandler(handlerTask)) + Handlers.Add(new AsyncEventActionHandler + { + Action = handlerAction, + Event = this, + Caller = caller + }); + + return (TReturn)this; + } + + public void Dispose() + { + IsDisposing = true; + ClearHandlers(); + } + } +} \ No newline at end of file diff --git a/Olive/TPL/AsyncEvent/ConcurrentEventRaisePolicy.cs b/Olive/TPL/AsyncEvent/ConcurrentEventRaisePolicy.cs new file mode 100644 index 000000000..2c273c783 --- /dev/null +++ b/Olive/TPL/AsyncEvent/ConcurrentEventRaisePolicy.cs @@ -0,0 +1,23 @@ +namespace Olive +{ + /// + /// Determines how concurrent attempts to raise an event should be handled. + /// + public enum ConcurrentEventRaisePolicy + { + /// + /// A new concurrent attempt to raise this event should be ignored while the previous raise is still running. + /// + Ignore, + + /// + /// A new concurrent attempt to raise this event should be queued to run after the previous raise is still running. + /// + Queue, + + /// + /// A new concurrent attempt to raise this event should run immediately irrespective of any unfinished previous raise. + /// + Parallel + } +} diff --git a/Olive/TPL/AsyncLock/AsyncLock.cs b/Olive/TPL/AsyncLock/AsyncLock.cs new file mode 100644 index 000000000..3ed20854e --- /dev/null +++ b/Olive/TPL/AsyncLock/AsyncLock.cs @@ -0,0 +1,54 @@ +namespace Olive +{ + using System; + using System.Threading.Tasks; + + /// A recursive mutual exclusion lock that to use with async code. + public sealed class AsyncLock + { + bool IsTaken; + readonly AsyncWaitQueue Queue = new AsyncWaitQueue(); + readonly Task WaitTask; + readonly object Mutex = new object(); + + public AsyncLock() + { + WaitTask = Task.FromResult(new Key(this)); + } + + public DisposableAwaitable Lock() + { + lock (Mutex) + { + if (IsTaken) return new DisposableAwaitable(Queue.Enqueue()); + else + { + IsTaken = true; + return new DisposableAwaitable(WaitTask); + } + } + } + + void Release() + { + IDisposable toRelease = null; + + lock (Mutex) + { + if (Queue.Count == 0) IsTaken = false; + else toRelease = Queue.Dequeue(WaitTask.Result); + } + + toRelease?.Dispose(); + } + + sealed class Key : IDisposable + { + readonly AsyncLock Lock; + + public Key(AsyncLock asyncLock) { Lock = asyncLock; } + + public void Dispose() => Lock.Release(); + } + } +} diff --git a/Olive/TPL/AsyncLock/AsyncLockDeque.cs b/Olive/TPL/AsyncLock/AsyncLockDeque.cs new file mode 100644 index 000000000..e90910449 --- /dev/null +++ b/Olive/TPL/AsyncLock/AsyncLockDeque.cs @@ -0,0 +1,64 @@ +namespace Olive +{ + using System; + using System.Collections; + using System.Collections.Generic; + + internal sealed class AsyncLockDeque : IEnumerable + { + T[] Buffer; + int Offset; + + public AsyncLockDeque(int capacity = 5) + { + Buffer = new T[capacity]; + } + + int Capacity => Buffer.Length; + + public int Count { get; private set; } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public IEnumerator GetEnumerator() + { + int count = Count; + for (int i = 0; i != count; ++i) + yield return Buffer[(i + Offset) % Capacity]; + } + + void GrowBy(int items) + { + var newBuffer = new T[Capacity + items]; + + if (Offset > (Capacity - Count)) + { + int length = Capacity - Offset; + Array.Copy(Buffer, Offset, newBuffer, 0, length); + Array.Copy(Buffer, 0, newBuffer, length, Count - length); + } + else Array.Copy(Buffer, Offset, newBuffer, 0, Count); + + Buffer = newBuffer; + Offset = 0; + } + + public void Enqueue(T value) + { + if (Count == Capacity) GrowBy(5); + + Buffer[(Count + Offset) % Capacity] = value; + ++Count; + } + + public T Deque() + { + --Count; + + var index = Offset; + Offset = (Offset + 1) % Capacity; + + return Buffer[index]; + } + } +} \ No newline at end of file diff --git a/Olive/TPL/AsyncLock/AsyncWaitQueue.cs b/Olive/TPL/AsyncLock/AsyncWaitQueue.cs new file mode 100644 index 000000000..c93b33690 --- /dev/null +++ b/Olive/TPL/AsyncLock/AsyncWaitQueue.cs @@ -0,0 +1,45 @@ +namespace Olive +{ + using System; + using System.Threading.Tasks; + + internal sealed class AsyncWaitQueue + { + readonly AsyncLockDeque> Queue = new AsyncLockDeque>(); + + public int Count { get { lock (Queue) return Queue.Count; } } + + public Task Enqueue() + { + var source = new TaskCompletionSource(); + lock (Queue) Queue.Enqueue(source); + return source.Task; + } + + public IDisposable Dequeue(T result) + { + lock (Queue) return new CompleteDisposable(result, Queue.Deque()); + } + + sealed class CompleteDisposable : IDisposable + { + readonly TaskCompletionSource[] Sources; + readonly T Result; + + public CompleteDisposable(T result, params TaskCompletionSource[] taskCompletionSources) + { + Result = result; + Sources = taskCompletionSources; + } + + public void Dispose() + { + foreach (var s in Sources) + { + Task.Run(() => s.TrySetResult(Result)); + s.Task.Wait(); + } + } + } + } +} diff --git a/Olive/TPL/AsyncLock/DisposableAwaitable.cs b/Olive/TPL/AsyncLock/DisposableAwaitable.cs new file mode 100644 index 000000000..b0c7a8b21 --- /dev/null +++ b/Olive/TPL/AsyncLock/DisposableAwaitable.cs @@ -0,0 +1,15 @@ +namespace Olive +{ + using System; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + + public struct DisposableAwaitable where T : IDisposable + { + public readonly Task Task; + + internal DisposableAwaitable(Task task) { Task = task; } + + public TaskAwaiter GetAwaiter() => Task.GetAwaiter(); + } +} \ No newline at end of file diff --git a/Olive/TPL/CallContext.cs b/Olive/TPL/CallContext.cs new file mode 100644 index 000000000..873f68d48 --- /dev/null +++ b/Olive/TPL/CallContext.cs @@ -0,0 +1,27 @@ +namespace Olive +{ + using System.Collections.Concurrent; + using System.Threading; + + public static class CallContext + { + static ConcurrentDictionary> state = new ConcurrentDictionary>(); + + /// + /// Stores a given object and associates it with the specified name. + /// + /// The name with which to associate the new item in the call context. + /// The object to store in the call context. + public static void SetData(string name, T data) => + state.GetOrAdd(name, _ => new AsyncLocal()).Value = data; + + /// + /// Retrieves an object with the specified name from the current call context/>. + /// + /// The type of the data being retrieved. Must match the type used when the was set via . + /// The name of the item in the call context. + /// The object in the call context associated with the specified name, or a default value for if none is found. + public static T GetData(string name) => + state.TryGetValue(name, out AsyncLocal data) ? data.Value : default(T); + } +} \ No newline at end of file diff --git a/Olive/TPL/ConcurrentList.cs b/Olive/TPL/ConcurrentList.cs new file mode 100644 index 000000000..e63778057 --- /dev/null +++ b/Olive/TPL/ConcurrentList.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace Olive +{ + public class ConcurrentList : IList + { + readonly List List; + readonly object Lock = new object(); + bool IsVirgin = true; // For performance + T FirstItem; + + public ConcurrentList() { List = new List(); } + + public ConcurrentList(int capacity) + { + Lock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion); + List = new List(capacity); + } + + public ConcurrentList(IEnumerable items) + { + List = new List(items); + IsVirgin = false; + } + + public override string ToString() => "ConcurrentList<" + typeof(T).GetProgrammingName() + "> [" + Count + "]"; + + public void Add(T item) + { + lock (Lock) + { + IsVirgin = false; + if (IsVirgin) FirstItem = item; + List.Add(item); + } + } + + /// + /// Adds an object only if it doesn't already exist in the list. + /// + public void AddUnique(T item) + { + lock (Lock) + { + if (List.Contains(item)) return; + IsVirgin = false; + if (IsVirgin) FirstItem = item; + List.Add(item); + } + } + + public void Insert(int index, T item) + { + lock (Lock) + { + IsVirgin = false; + if (index == 0) FirstItem = item; + List.Insert(index, item); + } + } + + public bool Remove(T item) + { + lock (Lock) + { + var result = List.Remove(item); + FirstItem = List.FirstOrDefault(); + return result; + } + } + + public void RemoveAt(int index) + { + lock (Lock) + { + List.RemoveAt(index); + if (index == 0) FirstItem = List.FirstOrDefault(); + } + } + + public int IndexOf(T item) + { + lock (Lock) + { + if (IsVirgin) return -1; + return List.IndexOf(item); + } + } + + public void Clear() + { + lock (Lock) + { + if (IsVirgin) return; + List.Clear(); + FirstItem = default(T); + } + } + + public bool Contains(T item) + { + lock (Lock) + { + if (IsVirgin) return false; + return List.Contains(item); + } + } + + public void CopyTo(T[] array, int arrayIndex) + { + lock (Lock) + { + if (IsVirgin) return; + List.CopyTo(array, arrayIndex); + } + } + + public IEnumerator GetEnumerator() + { + T[] copy; + + lock (Lock) + { + if (IsVirgin) return Enumerable.Empty().GetEnumerator(); + copy = List.ToArray(); + } + + return ((IEnumerable)copy).GetEnumerator(); + } + + public bool Any() + { + lock (Lock) + { + if (IsVirgin) return false; + return List.Any(); + } + } + + public IEnumerable OfType() where TType : T + { + lock (Lock) + { + if (IsVirgin) return Enumerable.Empty(); + return List.OfType(); + } + } + + public bool AnyOfType() where TType : T + { + lock (Lock) + { + if (IsVirgin) return false; + return List.Any(x => x is TType); + } + } + + public bool AnyOfType(Func criteria) where TType : T + { + lock (Lock) + { + if (IsVirgin) return false; + return List.Any(x => x is TType t && criteria(t)); + } + } + + public bool Any(Func criteria) + { + lock (Lock) + { + if (IsVirgin) return false; + return List.Any(criteria); + } + } + + public bool None() + { + lock (Lock) + { + if (IsVirgin) return true; + return List.None(); + } + } + + public T FirstOrDefault() + { + lock (Lock) return FirstItem; + } + + public T FirstOrDefault(Func criteria) + { + lock (Lock) + { + if (IsVirgin) return default(T); + if (FirstItem is T && criteria(FirstItem)) return FirstItem; + return List.FirstOrDefault(criteria); + } + } + + public TType FirstOrDefaultOfType() where TType : T + { + lock (Lock) + { + if (IsVirgin) return default(TType); + if (FirstItem is TType t) return t; + return List.OfType().FirstOrDefault(); + } + } + + public TType FirstOrDefaultOfType(Func criteria) where TType : T + { + lock (Lock) + { + if (IsVirgin) return default(TType); + if (FirstItem is TType t && criteria(t)) return t; + return List.OfType().FirstOrDefault(criteria); + } + } + + public T LastOrDefault() + { + lock (Lock) + { + if (IsVirgin) return default(T); + var index = List.Count - 1; + if (index == -1) return default(T); + return List[index]; + } + } + + public T LastOrDefault(Func criteria = null) + { + lock (Lock) + { + if (IsVirgin) return default(T); + + for (var i = List.Count - 1; i >= 0; i--) + { + var item = List[i]; + if (criteria(item)) return item; + } + + return default(T); + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public T this[int index] + { + get + { + lock (Lock) return List[index]; + } + set + { + lock (Lock) List[index] = value; + } + } + + public int Count + { + get + { + lock (Lock) + { + if (IsVirgin) return 0; + return List.Count; + } + } + } + + public bool IsReadOnly => false; + } +} diff --git a/Olive/Utilities/Base32Integer.cs b/Olive/Utilities/Base32Integer.cs new file mode 100644 index 000000000..92b715976 --- /dev/null +++ b/Olive/Utilities/Base32Integer.cs @@ -0,0 +1,65 @@ +namespace Olive +{ + using System; + using System.Text; + + public sealed class Base32Integer + { + // the valid chars for the encoding + static string ValidChars = "1AZ2WSX3" + "EDC4RFV5" + "TGB6YHN7" + "UJM8K9LP"; + + public int Value { get; } + + /// + /// Creates a new Base32Integer instance. + /// + public Base32Integer(int value) => Value = value; + + /// + /// Creates a new Base32Integer instance. + /// + public Base32Integer(string base32Integer) => Value = FromBase32String(base32Integer); + + public override string ToString() => ToBase32String(Value); + + /// + /// Converts an array of bytes to a Base32-k string. + /// + public static string ToBase32String(int value) + { + var r = new StringBuilder(); + + do + { + var mod = value % 32; + r.Insert(0, ValidChars[mod]); + value = (value - mod) / 32; + } + while (value > 0); + + return r.ToString(); + } + + /// + /// Converts a Base32-k string into an array of bytes. + /// + public static int FromBase32String(string valueString) + { + var result = 0; + + for (var figureIndex = valueString.Length - 1; figureIndex >= 0; figureIndex--) + { + var figureChar = valueString[figureIndex]; + var figureValue = ValidChars.IndexOf(figureChar); + + result += (int)Math.Pow(32, valueString.Length - figureIndex - 1) * figureValue; + } + + return result; + } + + public static implicit operator int(Base32Integer value) => value.Value; + + public static implicit operator Base32Integer(int value) => new Base32Integer(value); + } +} \ No newline at end of file diff --git a/Olive/Utilities/CachedValue.cs b/Olive/Utilities/CachedValue.cs new file mode 100644 index 000000000..5d52c5f95 --- /dev/null +++ b/Olive/Utilities/CachedValue.cs @@ -0,0 +1,54 @@ +using System; + +namespace Olive +{ + public static class CachedValue + { + /// + /// Creates a CachedValue the specified value builder. + /// + public static CachedValue Create(Func valueBuilder) => new CachedValue(valueBuilder); + } + + public class CachedValue + { + /// + /// Stores the underlying value. + /// + T _Value; + + Func ValueBuilder; + + /// + /// Gets the underlying value. + /// + public T Value + { + get + { + if (ValueBuilder != null) + { + _Value = ValueBuilder(); + ValueBuilder = null; + } + + return _Value; + } + } + + /// + /// Creates a new CachedValue instance. + /// + public CachedValue(T value) => _Value = value; + + /// + /// Initializes a new CachedValue instance with lazy loading support. + /// + /// The value builder. + public CachedValue(Func valueBuilder) => ValueBuilder = valueBuilder; + + public static implicit operator T(CachedValue value) => value.Value; + + public static implicit operator CachedValue(T value) => new CachedValue(value); + } +} diff --git a/Olive/Utilities/Config.cs b/Olive/Utilities/Config.cs new file mode 100644 index 000000000..63da09025 --- /dev/null +++ b/Olive/Utilities/Config.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.Extensions.Configuration; + +namespace Olive +{ + /// + /// Provides shortcut access to the value specified in web.config (or App.config) under AppSettings or ConnectionStrings. + /// + public static class Config + { + static IConfiguration Configuration; + + const string CONNECTION_STRINGS_CONFIG_ROOT = "ConnectionStrings"; + + static Config() + { + var builder = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); + + Configuration = builder.Build(); + } + + /// + /// Gets the connection string with the specified key. + /// The connection strings should store directly under the ConnectionStrings section. + /// + public static string GetConnectionString(string key) + { + return Configuration[$"{CONNECTION_STRINGS_CONFIG_ROOT}:{key}"] ?? + throw new ArgumentException($"Thre is no connectionString defined in the appsettings.json or web.config with the key '{key}'."); + } + + /// + /// Gets all the connection strings. + /// The connection strings should store directly under the ConnectionStrings section. + /// + public static IEnumerable> GetConnectionStrings() + { + return Configuration.GetSection(CONNECTION_STRINGS_CONFIG_ROOT).GetChildren() + .Select(section => new KeyValuePair(section.Key, section.Value)); + } + + /// + /// Attempts to bind the given object instance to configuration values by matching + /// property names against configuration keys recursively. + /// + /// The key of the configuration section. + /// The object to bind. + public static void Bind(string key, object instance) => Configuration.GetSection(key).Bind(instance); + + /// + /// Attempts to bind a new instance of given type to configuration values by matching + /// property names against configuration keys recursively. + /// + /// The key of the configuration section. + /// A new instance of the given generic type. + public static T Bind(string key) where T : new() + { + var result = new T(); + + Configuration.GetSection(key).Bind(result); + + return result; + } + + /// + /// Gets the value configured in Web.Config (or App.config) under AppSettings. + /// + public static string Get(string key) => Get(key, string.Empty); + + /// + /// Gets the value configured in Web.Config (or App.config) under AppSettings. + /// If no value is found there, it will return the specified default value. + /// + public static string Get(string key, string defaultValue) => Configuration[key].Or(defaultValue); + + /// + /// Reads the value configured in Web.Config (or App.config) under AppSettings. + /// It will then convert it into the specified type. + /// + public static T Get(string key) => Get(key, default(T)); + + /// + /// Reads the value configured in Web.Config (or App.config) under AppSettings. + /// It will then convert it into the specified type. + /// If no value is found there, it will return the specified default value. + /// + public static T Get(string key, T defaultValue) + { + var value = "[???]"; + try + { + value = Get(key, defaultValue.ToStringOrEmpty()); + + if (value.IsEmpty()) return defaultValue; + + var type = typeof(T); + + return value.To(); + } + catch (Exception ex) + { + throw new Exception("Could not retrieve '{0}' config value for key '{1}' and value '{2}'.".FormatWith(typeof(T).FullName, key, value), ex); + } + } + + /// + /// Reads the value configured in Web.Config (or App.config) under AppSettings. + /// It will then try to convert it into the specified type. + /// If no vale is found in AppSettings or the conversion fails, then it will return null, or the default value of the specified type T. + /// + public static T TryGet(string key) + { + var value = Get(key); + + if (value.IsEmpty()) return default(T); + + var type = typeof(T); + + try + { + return (T)value.To(type); + } + catch + { + // No logging is needed + return default(T); + } + } + + /// + /// Determines whether the specified key is defined in configuration file. + /// + public static bool IsDefined(string key) => Get(key).HasValue(); + + /// + /// Reads the app settings from a specified configuration file. + /// + [Obsolete("The XML settings are outdated.")] + public static async Task> ReadAppSettings(FileInfo configFile) + { + if (configFile == null) throw new ArgumentNullException(nameof(configFile)); + + if (!configFile.Exists()) throw new ArgumentException("File does not exist: " + configFile.FullName); + + var result = new Dictionary(); + + var config = XDocument.Parse(await configFile.ReadAllText()); + + var appSettings = config.Root.Elements().SingleOrDefault(a => a.Name.LocalName == "appSettings"); + + foreach (var setting in appSettings?.Elements()) + { + var key = setting.GetValue("@key"); + + if (result.ContainsKey(key)) + throw new Exception($"The key '{key}' is defined more than once in the application config file '{configFile.FullName}'."); + + result.Add(key, setting.GetValue("@value")); + } + + return result; + } + } +} \ No newline at end of file diff --git a/Olive/Utilities/EscapeGCopAttribute.cs b/Olive/Utilities/EscapeGCopAttribute.cs new file mode 100644 index 000000000..e46230e41 --- /dev/null +++ b/Olive/Utilities/EscapeGCopAttribute.cs @@ -0,0 +1,15 @@ +namespace Olive +{ + using System; + + /// + /// When applied to a method it will skip all GCop warnings for that method (not to be abused). + /// It is bad to escape any cop. Always try to avoid using this attribute by fixing your code. + /// + [AttributeUsage(AttributeTargets.All, AllowMultiple = true)] + public class EscapeGCopAttribute : Attribute + { + public EscapeGCopAttribute(string reason) { Reason = reason; } + public string Reason { get; private set; } + } +} \ No newline at end of file diff --git a/Olive/Utilities/LocalTime.cs b/Olive/Utilities/LocalTime.cs new file mode 100644 index 000000000..a5b25bc5b --- /dev/null +++ b/Olive/Utilities/LocalTime.cs @@ -0,0 +1,155 @@ +using System; + +namespace Olive +{ + public class LocalTime + { + /// + /// By default provides the current server's timezone. + /// You can override this to provide user-specific time-zones or based on any other system setting. + /// + public static Func CurrentTimeZone = () => TimeZoneInfo.Local; + + /// + /// If set, it will provide the "Now" value. + /// Note: This has lower priority than thread-level overrides. + /// + static Func GlobalNowGetter; + + /// + /// Gets the local current date/time of the application. + /// By default it equals to System.DateTime.Now. + /// To override its value, you should wrap the calling code inside "using (LocalTime.SetNow(some date)) { ... }" + ///   + /// Examples: + /// ————————————————————————————————— + /// var now = LocalTime.Now // which is identical to DateTime.Now + /// ————————————————————————————————— + /// using (LocalTime.Set(DateTime.Parse("15/01/2000 06:13"))) + /// { + ///  var date = LocalTime.Now; // that sets date to 15th Jan 200 at 6:13. + /// } + /// + public static DateTime Now + { + get + { + var setting = ProcessContext.Current; + + var nowGetter = setting?.NowGetter ?? GlobalNowGetter; + + if (nowGetter != null) return nowGetter(); + + return DateTime.UtcNow.ToLocal(); + } + } + + /// + /// Gets the current Universal Time. + /// + public static DateTime UtcNow + { + get + { + var setting = ProcessContext.Current; + + var nowGetter = setting?.NowGetter ?? GlobalNowGetter; + + if (nowGetter != null) return nowGetter().ToUniversal(); + + return DateTime.UtcNow; + } + } + + public static bool IsRedefined => (ProcessContext.Current?.NowGetter ?? GlobalNowGetter) != null; + + /// + /// Gets the local current date of the application (no time). + /// By default it equals to System.DateTime.Today. + /// To override its value, you should wrap the calling code inside "using (LocalTime.SetNow(some date)) { ... }" + ///   + /// Examples: + /// ————————————————————————————————— + /// var now = LocalTime.Today // which is identical to DateTime.Today + /// ————————————————————————————————— + /// using (LocalTime.Set(DateTime.Parse("15/01/2000 06:13"))) + /// { + ///  var date = LocalTime.Today; // that sets date to 15th Jan 200. + /// } + /// + public static DateTime Today => Now.Date; + + /// + /// Gets the current Universal Time's date part (without time). + /// + public static DateTime UtcToday => UtcNow.Date; + + /// + /// Sets the current time of the application. + ///   + /// Examples: + /// ————————————————————————————————— + /// using (LocalTime.Set(DateTime.Parse("15/01/2000 06:13"))) + /// { + /// //Here any call for LocalTime.Now/Today will return 15th of Jan 2000 (at 6:30). + /// } + /// + public static IDisposable Set(DateTime overriddenNow) => Set(() => overriddenNow); + + /// + /// Sets the current time function of the application. + /// + public static IDisposable Set(Func overriddenNow) => + new ProcessContext(new OverriddenApplicationDate(overriddenNow)); + + /// + /// Sets the current time function of the application. + /// Note: This has lower priority than thread-level time setting. + /// + public static void RedefineNow(Func overriddenNow) => GlobalNowGetter = overriddenNow; + + /// + /// Freezes the time to the current system time. + ///   + /// Examples: + /// ————————————————————————————————— + /// using (LocalTime.Stop()) + /// { + ///  // Freezes the time to Datetime.Now. + /// } + /// + public static IDisposable Stop() => Set(DateTime.Now); + + /// + /// Adds the specified time to the current LocalTime. + /// + public static void Add(TimeSpan time) + { + var setting = ProcessContext.Current; + + if (setting == null) + throw new InvalidOperationException("The current thread is not running inside a LocalTime."); + + var currentGetter = setting.NowGetter; + setting.NowGetter = () => currentGetter.Invoke().Add(time); + } + + public static void AddSeconds(double seconds) => Add(TimeSpan.FromSeconds(seconds)); + + public static void AddMinutes(double minutes) => Add(TimeSpan.FromMinutes(minutes)); + + public static void AddHours(double hours) => Add(TimeSpan.FromHours(hours)); + + public static void AddDays(double days) => Add(TimeSpan.FromDays(days)); + } + + class OverriddenApplicationDate + { + public Func NowGetter { get; internal set; } + + /// + /// Creates a new OverriddenApplicationDate instance. + /// + public OverriddenApplicationDate(Func time) => NowGetter = time; + } +} \ No newline at end of file diff --git a/Olive/Utilities/PascalCaseIdGenerator.cs b/Olive/Utilities/PascalCaseIdGenerator.cs new file mode 100644 index 000000000..24cb379a7 --- /dev/null +++ b/Olive/Utilities/PascalCaseIdGenerator.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; + +namespace Olive +{ + /// + /// Generates an identifier for a given string value. + /// + internal class PascalCaseIdGenerator + { + public string Value { get; private set; } + + public PascalCaseIdGenerator(string value) + { + if (value.IsEmpty()) + throw new ArgumentNullException(nameof(value)); + + Value = value.Trim(); + } + + void Replace(string from, string to) + { + while (Value.Contains(from)) + Value = Value.Replace(from, to); + } + + void Remove(params char[] characters) + { + foreach (var c in characters) + while (Value.Contains(c)) + Value = Value.Remove(c.ToString()); + } + + void ConvertToPascalCase() + { + for (var index = 0; index < Value.Length; index++) + { + var isFirstLetter = index == 0 || Value[index - 1] == ' '; + var character = Value[index]; + + if (isFirstLetter) + { + if (char.IsLower(character)) + { + Value = Value.Remove(index, 1); + Value = Value.Insert(index, char.ToUpper(character).ToString()); + } + + if (index > 0 && Value[index - 1] == ' ') + Value = Value.Remove(index - 1, 1); + } + } + } + + public string Build() + { + Remove('\''); + Replace(" ", " "); + Replace("&", "And"); + + foreach (var c in Value) + { + if (c == '_' || c == ' ') continue; + + if (!char.IsLetterOrDigit(c)) + Replace(c.ToString(), "_"); + } + + Value = Value.Trim('_', ' ', '\r', '\n', '\t'); + + ConvertToPascalCase(); + + while (Value.ContainsAny(new[] { " _", "_ ", "__" })) + { + Replace(" _", "_"); + Replace("_ ", "_"); + Replace("__", "_"); + } + + if (Value.FirstOrDefault().IsDigit()) Value = "_" + Value; + + return Value; + } + } +} \ No newline at end of file diff --git a/Olive/Utilities/ProcessContext.cs b/Olive/Utilities/ProcessContext.cs new file mode 100644 index 000000000..f332ea3b4 --- /dev/null +++ b/Olive/Utilities/ProcessContext.cs @@ -0,0 +1,73 @@ +using System; + +namespace Olive +{ + /// + /// Provides process context data sharing mechanism to pass arguments and data around execution in a shared pipeline. + /// It supports context nesting. + /// + public class ProcessContext : IDisposable + { + string Key; + + /// Gets or sets the Data of this ProcessContext. + public T Data { get; } + + /// + /// Creates a new Process Context. + /// + public ProcessContext(T data) : this(null, data) { } + + /// + /// Gets the data of the current context with default key (null). + /// + public static T Current => CallContext.GetData(GetKey()); + + /// + /// Creates a new Process Context with the specified key and data. + /// + public ProcessContext(string key, T data) + { + Data = data; + Key = GetKey(key); + CallContext.SetData(Key, data); + } + + static string GetKey(string key = null) => $"ProcessContext:{typeof(T).FullName}|K:{key}"; + + /// + /// Gets the data of the current context with the specified key. + /// + public static T GetCurrent(string key) => CallContext.GetData(GetKey(key)); + + /// + /// Disposes the current process context and switches the actual context to the containing process context. + /// + public void Dispose() + { + try { CallContext.SetData(GetKey(Key), default(T)); } + catch + { + // No logging is needed + } + } + } + + /// + /// Provides a facade for easiper creation of a Process Context. + /// + public static class ProcessContext + { + /// + /// Create a process context for the specified object. + /// To access the context object, you can use ProcessContext<Your Type>.Current. + /// + public static ProcessContext Create(T contextObject) => new ProcessContext(contextObject); + + /// + /// Create a process context for the specified object with the specified key. + /// To access the context object, you can use ProcessContext<Your Type>.GetCurrent(key). + /// + public static ProcessContext Create(string key, T contextObject) => new ProcessContext(key, contextObject); + } +} \ No newline at end of file diff --git a/Olive/Utilities/PropertyComparer.cs b/Olive/Utilities/PropertyComparer.cs new file mode 100644 index 000000000..bfa5b1ec1 --- /dev/null +++ b/Olive/Utilities/PropertyComparer.cs @@ -0,0 +1,30 @@ +using System.Collections; +using System.Collections.Generic; +using System.Reflection; + +namespace Olive +{ + internal class PropertyComparer : IComparer + { + PropertyInfo Property; + + public PropertyComparer(PropertyInfo property) => Property = property; + + public TValue ExtractValue(TItem item) => (TValue)Property.GetValue(item, null); + + public int Compare(object first, object second) => + Comparer.Default.Compare(Property.GetValue(first, null), Property.GetValue(second, null)); + } + + internal class PropertyComparer : IComparer + { + PropertyInfo Property; + + public PropertyComparer(PropertyInfo property) => Property = property; + + public TValue ExtractValue(TItem item) => (TValue)Property.GetValue(item, null); + + public int Compare(T first, T second) => + Comparer.Default.Compare((T)Property.GetValue(first, null), (T)Property.GetValue(second, null)); + } +} diff --git a/Olive/Utilities/Range.cs b/Olive/Utilities/Range.cs new file mode 100644 index 000000000..64d4d7bfe --- /dev/null +++ b/Olive/Utilities/Range.cs @@ -0,0 +1,130 @@ +using System; + +namespace Olive +{ + /// + /// Provides a range of values. + /// + public class Range where T : IComparable, IComparable + { + const int NINETEEN_HUNDRED = 1900; + + /// + /// Gets or sets the From of this Range. + /// + public T From { get; set; } + + /// + /// Gets or sets the To of this Range. + /// + public T To { get; set; } + + /// + /// Creates a new Range instance. + /// + public Range() { } + + /// + /// Creates a new Range instance with the specified boundaries. + /// + public Range(T from, T to) + { + if (from.CompareTo(to) > 0) + throw new ArgumentException($"nameof{from} should be smaller than or equal to {nameof(To)} value in a range."); + + From = from; + To = to; + } + + /// + /// Gets the length of this range. For a date range, use the TimeOfDay property of the returned date time. + /// + public T GetLength() + { + if (From.CompareTo(To) > 0) + throw new InvalidOperationException("'from' should be smaller than or equal to 'To' value in a range."); + + var fromValue = (object)From; + var toValue = (object)To; + object result; + + if (typeof(T) == typeof(int)) + result = (int)toValue - (int)fromValue; + + else if (typeof(T) == typeof(double)) + result = (double)toValue - (double)fromValue; + + else if (typeof(T) == typeof(long)) + result = (long)toValue - (long)fromValue; + + else if (typeof(T) == typeof(decimal)) + result = (decimal)toValue - (decimal)fromValue; + + else if (typeof(T) == typeof(DateTime)) + result = new DateTime(NINETEEN_HUNDRED, 1, 1).Add((DateTime)toValue - (DateTime)fromValue); + + else + throw new NotSupportedException("GetLength() is not supported on type: " + typeof(T).FullName); + + return (T)result; + } + + /// + /// Determines whether or not this range lacks the given value. + /// + public bool Lacks(T value, bool includingEdges = true) => !Contains(value, includingEdges); + + /// + /// Determines whether or not this range cotnains the given value + /// + public bool Contains(T value, bool includeEdges = true) + { + if (includeEdges) + { + if (value.CompareTo(From) < 0) return false; + if (value.CompareTo(To) > 0) return false; + } + else + { + if (value.CompareTo(From) <= 0) return false; + if (value.CompareTo(To) >= 0) return false; + } + + return true; + } + + /// + /// Determines whether or not this range contains with the given range + /// + public bool Contains(Range range) => Contains(range.From, includeEdges: true) && Contains(range.To, includeEdges: true); + + /// + /// Determines whether or not this range intersects with the given range + /// + public bool Intersects(Range range, bool includeEdges = true) + { + return Contains(range.From, includeEdges) || + Contains(range.To, includeEdges) || + range.Contains(From, includeEdges) || + range.Contains(To, includeEdges) || + (From.Equals(range.From) && To.Equals(range.To)); + } + + /// + /// Returns: {From} - {To}. + /// + public override string ToString() => ToString(" - "); + + /// + /// Returns {From}{rangeSeparator}{To}. + /// + public string ToString(string rangeSeparator) => From + rangeSeparator + To; + + /// + /// Returns the From and To values formatted by the specified format and then joined together with the specified rangeSeparator. + /// + /// E.g. {0:dd MM yy} + public string ToString(string rangeSeparator, string perItemFormat) => + string.Format(perItemFormat, From) + rangeSeparator + string.Format(perItemFormat, To); + } +} \ No newline at end of file diff --git a/Olive/Utilities/ShortGuid.cs b/Olive/Utilities/ShortGuid.cs new file mode 100644 index 000000000..7c74fa861 --- /dev/null +++ b/Olive/Utilities/ShortGuid.cs @@ -0,0 +1,190 @@ +using System; + +namespace Olive +{ + /// + /// Represents a globally unique identifier (GUID) with a shorter string value. + /// + public struct ShortGuid + { + const int Thirty = 30; + const int TwentyTwo = 22; + + Guid _guid; + string _value; + + /// + /// Gets/sets the underlying Guid + /// + public Guid Guid + { + get => _guid; + set + { + if (value != _guid) + { + _guid = value; + _value = Encode(value); + } + } + } + + /// + /// Gets/sets the underlying base64 encoded string + /// + public string Value + { + get => _value; + set + { + if (value != _value) + { + _value = value; + _guid = Decode(value); + } + } + } + + /// + /// Equivalent to Guid.Empty. + /// + public static readonly ShortGuid Empty = new ShortGuid(Guid.Empty); + + /// + /// Parses a specified text (that is either a normal Guid or a short guid). + /// + public static ShortGuid Parse(string text) + { + if (text.IsEmpty()) return Empty; + if (text.Length >= Thirty) + return new ShortGuid(new Guid(text)); + return new ShortGuid(text); + } + + /// + /// Creates a ShortGuid from a base64 encoded string + /// + /// The encoded guid as a + /// base64 string + public ShortGuid(string value) + { + _value = value; + _guid = Decode(value); + } + + /// + /// Creates a ShortGuid from a Guid + /// + /// The Guid to encode + public ShortGuid(Guid guid) + { + _value = Encode(guid); + _guid = guid; + } + + /// + /// Returns the base64 encoded guid as a string + /// + public override string ToString() => _value; + + /// + /// Returns a value indicating whether this instance and a + /// specified Object represent the same type and value. + /// + /// The object to compare + public override bool Equals(object obj) + { + if (obj is ShortGuid) + return _guid.Equals(((ShortGuid)obj)._guid); + if (obj is Guid) + return _guid.Equals((Guid)obj); + if (obj is string) + return _guid.Equals(((ShortGuid)obj)._guid); + return false; + } + + /// + /// Returns the HashCode for underlying Guid. + /// + public override int GetHashCode() => _guid.GetHashCode(); + + /// + /// Initialises a new instance of the ShortGuid class + /// + public static ShortGuid NewGuid() => new ShortGuid(Guid.NewGuid()); + + /// + /// Creates a new instance of a Guid using the string value, + /// then returns the base64 encoded version of the Guid. + /// + /// An actual Guid string (i.e. not a ShortGuid) + public static string Encode(string value) + { + var guid = new Guid(value); + return Encode(guid); + } + + /// + /// Encodes the given Guid as a base64 string that is 22 + /// characters long. + /// + /// The Guid to encode + public static string Encode(Guid guid) + { + string encoded = Convert.ToBase64String(guid.ToByteArray()); + encoded = encoded + .Replace("/", "_") + .Replace("+", "-"); + return encoded.Substring(0, TwentyTwo); + } + + /// + /// Decodes the given base64 string + /// + /// The base64 encoded string of a Guid + /// A new Guid + public static Guid Decode(string value) + { + value = value + .Replace("_", "/") + .Replace("-", "+"); + var buffer = Convert.FromBase64String(value + "=="); + return new Guid(buffer); + } + + /// + /// Determines if both ShortGuids have the same underlying Guid value. + /// + public static bool operator ==(ShortGuid me, ShortGuid other) + { + if ((object)me == null) return (object)other == null; + return me._guid == other._guid; + } + + /// + /// Determines if both ShortGuids do not have the + /// same underlying Guid value. + /// + public static bool operator !=(ShortGuid me, ShortGuid other) => !(me == other); + + /// + /// Implicitly converts the ShortGuid to it's string equivilent + /// + public static implicit operator string(ShortGuid shortGuid) => shortGuid._value; + + /// + /// Implicitly converts the ShortGuid to it's Guid equivilent + /// + public static implicit operator Guid(ShortGuid shortGuid) => shortGuid._guid; + + /// + /// Implicitly converts the string to a ShortGuid + /// + public static implicit operator ShortGuid(string shortGuid) => new ShortGuid(shortGuid); + + /// + /// Implicitly converts the Guid to a ShortGuid + /// + public static implicit operator ShortGuid(Guid guid) => new ShortGuid(guid); + } +} \ No newline at end of file diff --git a/Olive/Utilities/TemporaryFile.cs b/Olive/Utilities/TemporaryFile.cs new file mode 100644 index 000000000..2d62c8292 --- /dev/null +++ b/Olive/Utilities/TemporaryFile.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; + +namespace Olive +{ + /// + /// This class provides a unique file path in a temporary folder (i.e. in the application temp folder + /// in the system by default and can be provided in Config of the application through a setting with key "Application.TemporaryFilesPath") + /// After this instance is disposed any possibly created file in the path will be deleted physically. + /// + /// If this class fails to dispose an application event will be added to the projects database. + /// + public class TemporaryFile : IDisposable + { + static string TemporaryFileFolder = GetTemporaryFileFolder(); + + Guid ID; + + public string Extension { get; set; } + + /// + /// Creates a new instance of temporary file. The file will have "dat" extension by default. + /// + public TemporaryFile() : this("dat") { } + + /// + /// Gets or sets the FilePath of this TemporaryFile. + /// + public string FilePath + { + get + { + var folder = TemporaryFileFolder; + if (Directory.Exists(folder) == false) + Directory.CreateDirectory(folder); + + var filename = ID + "." + Extension.Trim('.'); + return folder + "\\" + filename; + } + } + + /// + /// Creates a new instance of temporary file. + /// with the given extension. Extension can either have "." or not + /// + public TemporaryFile(string extension) + { + ID = Guid.NewGuid(); + Extension = extension; + } + + /// + /// Finds a proper folder path for temporary files + /// + static string GetTemporaryFileFolder() + { + var relativePath = Config.Get("Application.TemporaryFilesPath", ""); + + if (relativePath.HasValue()) + return AppDomain.CurrentDomain.BaseDirectory + relativePath.Replace("/", "\\").Replace("\\\\", "\\").Trim('\\'); + + return Path.GetTempPath(); + } + + /// + /// Disposes this instance of temporary file and deletes the file if provided + /// + public void Dispose() + { + try + { + if (FilePath.AsFile().Exists()) File.Delete(FilePath); + } + catch (Exception ex) + { + Log.Error("Can not dispose temporary file.", ex); + } + } + } +} \ No newline at end of file diff --git a/OliveVSIX/Key.snk b/OliveVSIX/Key.snk new file mode 100644 index 000000000..00963ed6c Binary files /dev/null and b/OliveVSIX/Key.snk differ diff --git a/OliveVSIX/NugetPacker/NugetPacker.cs b/OliveVSIX/NugetPacker/NugetPacker.cs new file mode 100644 index 000000000..f97d0acbe --- /dev/null +++ b/OliveVSIX/NugetPacker/NugetPacker.cs @@ -0,0 +1,112 @@ +using System; +using System.ComponentModel.Design; +using System.Globalization; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; + +namespace OliveVSIX.NugetPacker +{ + /// + /// Command handler + /// + internal sealed class NugetPacker + { + bool ExceptionOccurred; + + /// + /// Command ID. + /// + public const int CommandId = 0x0100; + + /// + /// Command menu group (command set GUID). + /// + public static readonly Guid CommandSet = new Guid("090581b5-cfbb-40d7-9ff4-bdc7f81edef5"); + + /// + /// VS Package that provides this command, not null. + /// + readonly Package package; + + /// + /// Initializes a new instance of the class. + /// Adds our command handlers for menu (commands must exist in the command table file) + /// + /// Owner package, not null. + NugetPacker(Package package) + { + this.package = package ?? throw new ArgumentNullException("package"); + + if (ServiceProvider.GetService(typeof(IMenuCommandService)) is OleMenuCommandService commandService) + { + var menuCommandID = new CommandID(CommandSet, CommandId); + var menuItem = new MenuCommand(MenuItemCallback, menuCommandID); + commandService.AddCommand(menuItem); + + NugetPackerLogic.OnCompleted += NugetPackerLogic_OnCompleted; + NugetPackerLogic.OnException += NugetPackerLogic_OnException; + } + } + + void NugetPackerLogic_OnException(object sender, Exception arg) + { + ExceptionOccurred = true; + + VsShellUtilities.ShowMessageBox( + ServiceProvider, + arg.Message, + arg.GetType().ToString(), + OLEMSGICON.OLEMSGICON_CRITICAL, + OLEMSGBUTTON.OLEMSGBUTTON_OK, + OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); + } + + void NugetPackerLogic_OnCompleted(object sender, EventArgs e) + { + VsShellUtilities.ShowMessageBox( + ServiceProvider, + ExceptionOccurred ? "The process completed with error(s)." : "The selected projects are updated.", + "Nuget updater", + OLEMSGICON.OLEMSGICON_INFO, + OLEMSGBUTTON.OLEMSGBUTTON_OK, + OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); + } + + /// + /// Gets the instance of the command. + /// + public static NugetPacker Instance + { + get; + private set; + } + + /// + /// Gets the service provider from the owner package. + /// + IServiceProvider ServiceProvider => package; + + /// + /// Initializes the singleton instance of the command. + /// + /// Owner package, not null. + public static void Initialize(Package package) + { + Instance = new NugetPacker(package); + } + + /// + /// This function is the callback used to execute the command when the menu item is clicked. + /// See the constructor to see how the menu item is associated with this function using + /// OleMenuCommandService service and MenuCommand class. + /// + /// Event sender. + /// Event args. + void MenuItemCallback(object sender, EventArgs e) + { + ExceptionOccurred = false; + var dte2 = Package.GetGlobalService(typeof(EnvDTE.DTE)) as EnvDTE80.DTE2; + NugetPackerLogic.Pack(dte2); + } + } +} diff --git a/OliveVSIX/NugetPacker/NugetPackerLogic.cs b/OliveVSIX/NugetPacker/NugetPackerLogic.cs new file mode 100644 index 000000000..250da7dfc --- /dev/null +++ b/OliveVSIX/NugetPacker/NugetPackerLogic.cs @@ -0,0 +1,152 @@ +using EnvDTE; +using EnvDTE80; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Xml; + +namespace OliveVSIX.NugetPacker +{ + public delegate void ExceptionHandler(object sender, Exception arg); + + static class NugetPackerLogic + { + const string NuspecFileName = "Package.nuspec"; + const string NugetFileName = "nuget.exe"; + const string OutputFolder = "NugetPackages"; +#pragma warning disable GCop412 // Never hardcode a path or drive name in code. Get the application path programmatically and use relative path. Use “AppDomain.CurrentDomain.GetPath” to get the physical path. + const string ApiKeyContainingFile = @"C:\Projects\NUGET-Publish-Key.txt"; +#pragma warning restore GCop412 // Never hardcode a path or drive name in code. Get the application path programmatically and use relative path. Use “AppDomain.CurrentDomain.GetPath” to get the physical path. + + static DTE2 Dte2; + static string NugetExe; + static string SolutionPath; + static string ApiKey; + static string NugetPackagesFolder; + + static System.Threading.Thread Thread; + + public static event EventHandler OnCompleted; + public static event ExceptionHandler OnException; + + public static void Pack(DTE2 dte2) + { + Dte2 = dte2; + + SolutionPath = Path.GetDirectoryName(Dte2.Solution.FullName); + NugetExe = Path.Combine(SolutionPath, NugetFileName); + ApiKey = File.ReadAllText(ApiKeyContainingFile); + NugetPackagesFolder = Path.Combine(SolutionPath, OutputFolder); + + var start = new System.Threading.ThreadStart(() => + { + try + { + foreach (var item in GetSelectedProjectPath()) + PackSingleProject(item); + } + catch (Exception exception) + { + InvokeException(exception); + } + + OnCompleted?.Invoke(null, EventArgs.Empty); + }); + + Thread = new System.Threading.Thread(start) { IsBackground = true }; + Thread.Start(); + } + + static void InvokeException(Exception exception) => OnException?.Invoke(null, exception); + + static void PackSingleProject(Project proj) + { + var projectPath = Path.GetDirectoryName(proj.FullName); + var nuspecAddress = Path.Combine(projectPath, NuspecFileName); + + var packageFilename = UpdateVersionThenReturnPackageName(nuspecAddress); + + if (TryPack(nuspecAddress, out string packingMessage)) + { + if (!TryPush(packageFilename, out string pushingMessage)) + InvokeException(new Exception(pushingMessage)); + } + else + InvokeException(new Exception(packingMessage)); + } + + static bool TryPush(string packageFilename, out string message) + { + return ExecuteNuget($"push \"{NugetPackagesFolder}\\{packageFilename}\" {ApiKey} -NonInteractive -Source https://www.nuget.org/api/v2/package", out message); + } + + static bool TryPack(string nuspecAddress, out string message) => + ExecuteNuget($"pack \"{nuspecAddress}\" -OutputDirectory \"{NugetPackagesFolder}\"", out message); + + static bool ExecuteNuget(string arguments, out string message) + { + var startInfo = new ProcessStartInfo(NugetExe) + { + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + UseShellExecute = false, + WindowStyle = ProcessWindowStyle.Hidden + }; + + var process = System.Diagnostics.Process.Start(startInfo); + + process.WaitForExit(); + + if (process.ExitCode == 0) + { + message = null; + return true; + } + else + { + message = $"{arguments}:\r\n{process.StandardError.ReadToEnd()}"; + return false; + } + } + + static string UpdateVersionThenReturnPackageName(string nuspecAddress) + { + var doc = new XmlDocument(); + doc.Load(nuspecAddress); + + // TODO: This is not a proper way to access to xml nodes. It is just a in rush code. + var idNode = doc.ChildNodes[1].ChildNodes[0].ChildNodes[0]; + var versionNode = doc.ChildNodes[1].ChildNodes[0].ChildNodes[1]; + + var versionParts = versionNode.InnerText.Split('.'); + var minorPart = int.Parse(versionParts.LastOrDefault()) + 1; + + var newVersion = string.Empty; + for (var index = 0; index < versionParts.Length - 1; index++) + newVersion += $"{versionParts[index]}."; + + versionNode.InnerText = $"{newVersion}{minorPart}"; + + doc.Save(nuspecAddress); + + return $"{idNode.InnerText}.{versionNode.InnerText}.nupkg"; + } + + static IEnumerable GetSelectedProjectPath() + { + var uih = Dte2.ToolWindows.SolutionExplorer; + var selectedItems = (Array)uih.SelectedItems; + + if (selectedItems != null) + foreach (UIHierarchyItem selItem in selectedItems) + if (selItem.Object is Project proj) + yield return proj; + } + } +} diff --git a/OliveVSIX/NugetPacker/NugetPackerPackage.cs b/OliveVSIX/NugetPacker/NugetPackerPackage.cs new file mode 100644 index 000000000..250cb1b0d --- /dev/null +++ b/OliveVSIX/NugetPacker/NugetPackerPackage.cs @@ -0,0 +1,69 @@ +using System; +using System.ComponentModel.Design; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.InteropServices; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.OLE.Interop; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.Win32; + +namespace OliveVSIX.NugetPacker +{ + /// + /// This is the class that implements the package exposed by this assembly. + /// + /// + /// + /// The minimum requirement for a class to be considered a valid package for Visual Studio + /// is to implement the IVsPackage interface and register itself with the shell. + /// This package uses the helper classes defined inside the Managed Package Framework (MPF) + /// to do it: it derives from the Package class that provides the implementation of the + /// IVsPackage interface and uses the registration attributes defined in the framework to + /// register itself and its components with the shell. These attributes tell the pkgdef creation + /// utility what data to put into .pkgdef file. + /// + /// + /// To get loaded into VS, the package must be referred by <Asset Type="Microsoft.VisualStudio.VsPackage" ...> in .vsixmanifest file. + /// + /// + [PackageRegistration(UseManagedResourcesOnly = true)] + [InstalledProductRegistration("#110", "#112", "1.0", IconResourceID = 400)] // Info on this package for Help/About + [ProvideMenuResource("Menus.ctmenu", 1)] + [Guid(NugetPackerPackage.PackageGuidString)] + [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1650:ElementDocumentationMustBeSpelledCorrectly", Justification = "pkgdef, VS and vsixmanifest are valid VS terms")] + public sealed class NugetPackerPackage : Package + { + /// + /// NugetPackerPackage GUID string. + /// + public const string PackageGuidString = "aebcdf85-651a-4513-8f1c-a706edd15c5c"; + + /// + /// Initializes a new instance of the class. + /// + public NugetPackerPackage() + { + // Inside this method you can place any initialization code that does not require + // any Visual Studio service because at this point the package object is created but + // not sited yet inside Visual Studio environment. The place to do all the other + // initialization is the Initialize method. + } + + #region Package Members + + /// + /// Initialization of the package; this method is called right after the package is sited, so this is the place + /// where you can put all the initialization code that rely on services provided by VisualStudio. + /// + protected override void Initialize() + { + NugetPacker.Initialize(this); + base.Initialize(); + } + + #endregion + } +} diff --git a/OliveVSIX/NugetPacker/NugetPackerPackage.vsct b/OliveVSIX/NugetPacker/NugetPackerPackage.vsct new file mode 100644 index 000000000..adf226da5 --- /dev/null +++ b/OliveVSIX/NugetPacker/NugetPackerPackage.vsct @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OliveVSIX/NugetPacker/Resources/NugetPacker.png b/OliveVSIX/NugetPacker/Resources/NugetPacker.png new file mode 100644 index 000000000..b22d975cb Binary files /dev/null and b/OliveVSIX/NugetPacker/Resources/NugetPacker.png differ diff --git a/OliveVSIX/NugetPacker/VSPackage.resx b/OliveVSIX/NugetPacker/VSPackage.resx new file mode 100644 index 000000000..9c5cd70bd --- /dev/null +++ b/OliveVSIX/NugetPacker/VSPackage.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + NugetPacker Extension + + + NugetPacker Visual Studio Extension Detailed Info + + \ No newline at end of file diff --git a/OliveVSIX/OliveVSIX.csproj b/OliveVSIX/OliveVSIX.csproj new file mode 100644 index 000000000..bf0e762ed --- /dev/null +++ b/OliveVSIX/OliveVSIX.csproj @@ -0,0 +1,204 @@ + + + + + 15.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + true + + + true + + + Key.snk + + + + Debug + AnyCPU + 2.0 + {82b43b9b-a64c-4715-b499-d71e9ca2bd60};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + {83FFCB26-B836-4E43-8F16-3FC0B29A7572} + Library + Properties + OliveVSIX + OliveVSIX + v4.6.1 + true + true + true + true + true + false + Program + $(DevEnvDir)devenv.exe + /rootsuffix Exp + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + Designer + + + + + Menus.ctmenu + Designer + + + Always + true + + + + + False + + + False + + + False + + + False + + + + False + + + ..\packages\Microsoft.VisualStudio.CoreUtility.15.0.26606\lib\net45\Microsoft.VisualStudio.CoreUtility.dll + True + + + ..\packages\Microsoft.VisualStudio.Imaging.15.0.26606\lib\net45\Microsoft.VisualStudio.Imaging.dll + True + + + ..\packages\Microsoft.VisualStudio.OLE.Interop.7.10.6070\lib\Microsoft.VisualStudio.OLE.Interop.dll + True + + + ..\packages\Microsoft.VisualStudio.Shell.15.0.15.0.26606\lib\Microsoft.VisualStudio.Shell.15.0.dll + True + + + ..\packages\Microsoft.VisualStudio.Shell.Framework.15.0.26606\lib\net45\Microsoft.VisualStudio.Shell.Framework.dll + True + + + ..\packages\Microsoft.VisualStudio.Shell.Interop.7.10.6071\lib\Microsoft.VisualStudio.Shell.Interop.dll + True + + + True + ..\packages\Microsoft.VisualStudio.Shell.Interop.10.0.10.0.30319\lib\Microsoft.VisualStudio.Shell.Interop.10.0.dll + True + + + True + ..\packages\Microsoft.VisualStudio.Shell.Interop.11.0.11.0.61030\lib\Microsoft.VisualStudio.Shell.Interop.11.0.dll + True + + + True + ..\packages\Microsoft.VisualStudio.Shell.Interop.12.0.12.0.30110\lib\Microsoft.VisualStudio.Shell.Interop.12.0.dll + True + + + True + ..\packages\Microsoft.VisualStudio.Shell.Interop.15.3.DesignTime.15.0.26606\lib\net20\Microsoft.VisualStudio.Shell.Interop.15.3.DesignTime.dll + True + + + ..\packages\Microsoft.VisualStudio.Shell.Interop.8.0.8.0.50727\lib\Microsoft.VisualStudio.Shell.Interop.8.0.dll + True + + + ..\packages\Microsoft.VisualStudio.Shell.Interop.9.0.9.0.30729\lib\Microsoft.VisualStudio.Shell.Interop.9.0.dll + True + + + ..\packages\Microsoft.VisualStudio.TextManager.Interop.7.10.6070\lib\Microsoft.VisualStudio.TextManager.Interop.dll + True + + + ..\packages\Microsoft.VisualStudio.TextManager.Interop.8.0.8.0.50727\lib\Microsoft.VisualStudio.TextManager.Interop.8.0.dll + True + + + ..\packages\Microsoft.VisualStudio.Threading.15.3.23\lib\net45\Microsoft.VisualStudio.Threading.dll + True + + + ..\packages\Microsoft.VisualStudio.Utilities.15.0.26606\lib\net45\Microsoft.VisualStudio.Utilities.dll + True + + + ..\packages\Microsoft.VisualStudio.Validation.15.3.15\lib\net45\Microsoft.VisualStudio.Validation.dll + True + + + False + + + + + + + + + + + true + VSPackage + Designer + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + + + \ No newline at end of file diff --git a/OliveVSIX/Properties/AssemblyInfo.cs b/OliveVSIX/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..b2ea00f8c --- /dev/null +++ b/OliveVSIX/Properties/AssemblyInfo.cs @@ -0,0 +1,33 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("OliveVSIX")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("OliveVSIX")] +[assembly: AssemblyCopyright("")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/OliveVSIX/Resources/Olive.png b/OliveVSIX/Resources/Olive.png new file mode 100644 index 000000000..c29070869 Binary files /dev/null and b/OliveVSIX/Resources/Olive.png differ diff --git a/OliveVSIX/packages.config b/OliveVSIX/packages.config new file mode 100644 index 000000000..76e7edd94 --- /dev/null +++ b/OliveVSIX/packages.config @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/OliveVSIX/source.extension.vsixmanifest b/OliveVSIX/source.extension.vsixmanifest new file mode 100644 index 000000000..9692c8b3d --- /dev/null +++ b/OliveVSIX/source.extension.vsixmanifest @@ -0,0 +1,26 @@ + + + + + OliveVSIX + Empty VSIX Project. + https://github.com/Geeksltd/Olive + Resources\Olive.png + Resources\Olive.png + + + + + + + + + + + + + + + + + diff --git a/Services/Olive.Services.CSV/CSVReader.cs b/Services/Olive.Services.CSV/CSVReader.cs new file mode 100644 index 000000000..8c4450a83 --- /dev/null +++ b/Services/Olive.Services.CSV/CSVReader.cs @@ -0,0 +1,73 @@ +using System; +using System.Data; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Olive.Entities; + +namespace Olive.Services.CSV +{ + /// + /// A data-reader style interface for reading Csv files. + /// + public static class CsvReader + { + /// + /// Reads a CSV blob into a data table. Note use the CastTo() method on the returned DataTable to gain fully-typed objects. + /// + public static async Task Read(Blob csv, bool isFirstRowHeaders, int minimumFieldCount = 0) => + Read(await csv.GetContentText(), isFirstRowHeaders, minimumFieldCount); + + /// + /// Reads a CSV file into a data table. Note use the CastTo() method on the returned DataTable to gain fully-typed objects. + /// + public static async Task Read(FileInfo csv, bool isFirstRowHeaders, int minimumFieldCount = 0) => + Read(await File.ReadAllTextAsync(csv.FullName), isFirstRowHeaders, minimumFieldCount); + + /// + /// Reads a CSV piece of string into a data table. Note use the CastTo() method on the returned DataTable to gain fully-typed objects. + /// + public static DataTable Read(string csvContent, bool isFirstRowHeaders, int minimumFieldCount = 0) + { + var output = new DataTable(); + + using (var csv = new LumenWorks.Framework.IO.Csv.CsvDataReader(new StringReader(csvContent), isFirstRowHeaders)) + { + csv.MissingFieldAction = LumenWorks.Framework.IO.Csv.MissingFieldAction.ReplaceByNull; + var fieldCount = Math.Max(csv.FieldCount, minimumFieldCount); + var headers = csv.GetFieldHeaders(); + + if (!isFirstRowHeaders) + headers = Enumerable.Range(0, fieldCount).Select(i => "Column" + i).ToArray(); + + for (int i = 0; i < fieldCount; i++) + output.Columns.Add(new DataColumn(headers[i], typeof(string))); + + while (csv.ReadNextRecord()) + { + var row = output.NewRow(); + + for (int i = 0; i < fieldCount; i++) row[i] = csv[i]; + + output.Rows.Add(row); + } + } + + return output; + } + + /// + /// Gets the column names on the specified CSV blob. + /// + public static async Task GetColumns(Blob blob) => GetColumns(await blob.GetContentText()); + + /// + /// Gets the column names on the specified CSV content. + /// + public static string[] GetColumns(string csvContent) + { + using (var csv = new LumenWorks.Framework.IO.Csv.CsvDataReader(new StringReader(csvContent), true)) + return csv.GetFieldHeaders(); + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.CSV/LumenWorks.Framework/CsvDataReader.Enums.cs b/Services/Olive.Services.CSV/LumenWorks.Framework/CsvDataReader.Enums.cs new file mode 100644 index 000000000..8f43cd3e5 --- /dev/null +++ b/Services/Olive.Services.CSV/LumenWorks.Framework/CsvDataReader.Enums.cs @@ -0,0 +1,101 @@ +// LumenWorks.Framework.IO.CSV.ParseErrorEventArgs +// Copyright (c) 2006 Sébastien Lorion +// +// MIT license (http://en.wikipedia.org/wiki/MIT_License) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +using System; + +namespace LumenWorks.Framework.IO.Csv +{ + [Flags] + internal enum ValueTrimmingOptions + { + None = 0, + UnquotedOnly = 1, + QuotedOnly = 2, + All = UnquotedOnly | QuotedOnly + } + + /// + /// Specifies the action to take when a parsing error has occured. + /// + internal enum ParseErrorAction + { + /// + /// Raises the event. + /// + RaiseEvent = 0, + + /// + /// Tries to advance to next line. + /// + AdvanceToNextLine = 1, + + /// + /// Throws an exception. + /// + ThrowException = 2, + } + + /// + /// Specifies the action to take when a field is missing. + /// + internal enum MissingFieldAction + { + /// + /// Treat as a parsing error. + /// + ParseError = 0, + + /// + /// Replaces by an empty value. + /// + ReplaceByEmpty = 1, + + /// + /// Replaces by a null value (). + /// + ReplaceByNull = 2, + } + + partial class CsvDataReader + { + /// + /// Defines the data reader validations. + /// + [Flags] + private enum DataReaderValidations + { + /// + /// No validation. + /// + None = 0, + + /// + /// Validate that the data reader is initialized. + /// + IsInitialized = 1, + + /// + /// Validate that the data reader is not closed. + /// + IsNotClosed = 2 + } + } +} diff --git a/Services/Olive.Services.CSV/LumenWorks.Framework/CsvDataReader.RecordEnumerator.cs b/Services/Olive.Services.CSV/LumenWorks.Framework/CsvDataReader.RecordEnumerator.cs new file mode 100644 index 000000000..6dc643c02 --- /dev/null +++ b/Services/Olive.Services.CSV/LumenWorks.Framework/CsvDataReader.RecordEnumerator.cs @@ -0,0 +1,160 @@ +// LumenWorks.Framework.IO.CSV.CsvReader.RecordEnumerator +// Copyright (c) 2005 S�bastien Lorion +// +// MIT license (http://en.wikipedia.org/wiki/MIT_License) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections; +using System.Collections.Generic; +using LumenWorks.Framework.IO.Csv.Resources; + +namespace LumenWorks.Framework.IO.Csv +{ + partial class CsvDataReader + { + /// + /// Supports a simple iteration over the records of a . + /// + public struct RecordEnumerator + : IEnumerator, IEnumerator + { + #region Fields + + /// + /// Contains the enumerated . + /// + CsvDataReader _reader; + + /// + /// Contains the current record. + /// + string[] _current; + + /// + /// Contains the current record index. + /// + long _currentRecordIndex; + + #endregion + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + /// The to iterate over. + /// + /// is a . + /// + public RecordEnumerator(CsvDataReader reader) + { + if (reader == null) + throw new ArgumentNullException("reader"); + + _reader = reader; + _current = null; + + _currentRecordIndex = reader.currentRecordIndex; + } + + #endregion + + #region IEnumerator Members + + /// + /// Gets the current record. + /// + public string[] Current => _current; + + /// + /// Advances the enumerator to the next record of the CSV. + /// + /// if the enumerator was successfully advanced to the next record, if the enumerator has passed the end of the CSV. + public bool MoveNext() + { + if (_reader.currentRecordIndex != _currentRecordIndex) + throw new InvalidOperationException(ExceptionMessage.EnumerationVersionCheckFailed); + + if (_reader.ReadNextRecord()) + { + _current = new string[_reader.fieldCount]; + + _reader.CopyCurrentRecordTo(_current); + _currentRecordIndex = _reader.currentRecordIndex; + + return true; + } + else + { + _current = null; + _currentRecordIndex = _reader.currentRecordIndex; + + return false; + } + } + + #endregion + + #region IEnumerator Members + + /// + /// Sets the enumerator to its initial position, which is before the first record in the CSV. + /// + public void Reset() + { + if (_reader.currentRecordIndex != _currentRecordIndex) + throw new InvalidOperationException(ExceptionMessage.EnumerationVersionCheckFailed); + + _reader.MoveTo(-1); + + _current = null; + _currentRecordIndex = _reader.currentRecordIndex; + } + + /// + /// Gets the current record. + /// + object IEnumerator.Current + { + get + { + if (_reader.currentRecordIndex != _currentRecordIndex) + throw new InvalidOperationException(ExceptionMessage.EnumerationVersionCheckFailed); + + return Current; + } + } + + #endregion + + #region IDisposable Members + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + _reader = null; + _current = null; + } + + #endregion + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.CSV/LumenWorks.Framework/CsvDataReader.cs b/Services/Olive.Services.CSV/LumenWorks.Framework/CsvDataReader.cs new file mode 100644 index 000000000..4428b8b6a --- /dev/null +++ b/Services/Olive.Services.CSV/LumenWorks.Framework/CsvDataReader.cs @@ -0,0 +1,2285 @@ +// LumenWorks.Framework.IO.CSV.CsvReader +// Copyright (c) 2005 S�bastien Lorion +// +// MIT license (http://en.wikipedia.org/wiki/MIT_License) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.IO; +using LumenWorks.Framework.IO.Csv.Resources; +using Debug = System.Diagnostics.Debug; + +namespace LumenWorks.Framework.IO.Csv +{ + /// + /// Represents a reader that provides fast, non-cached, forward-only access to CSV data. + /// + internal partial class CsvDataReader + : IDataReader, IEnumerable, IDisposable + { + #region Constants + + /// + /// Defines the default buffer size. + /// + public const int DefaultBufferSize = 0x1000; + + /// + /// Defines the default delimiter character separating each field. + /// + public const char DefaultDelimiter = ','; + + /// + /// Defines the default quote character wrapping every field. + /// + public const char DefaultQuote = '"'; + + /// + /// Defines the default escape character letting insert quotation characters inside a quoted field. + /// + public const char DefaultEscape = '"'; + + /// + /// Defines the default comment character indicating that a line is commented out. + /// + public const char DefaultComment = '#'; + + #endregion + + #region Fields + + /// + /// Contains the field header comparer. + /// + static readonly StringComparer fieldHeaderComparer = StringComparer.CurrentCultureIgnoreCase; + + #region Settings + + /// + /// Contains the pointing to the CSV file. + /// + TextReader reader; + + /// + /// Contains the buffer size. + /// + int bufferSize; + + /// + /// Contains the comment character indicating that a line is commented out. + /// + char comment; + + /// + /// Contains the escape character letting insert quotation characters inside a quoted field. + /// + char escape; + + /// + /// Contains the delimiter character separating each field. + /// + char delimiter; + + /// + /// Contains the quotation character wrapping every field. + /// + char quote; + + /// + /// Determines which values should be trimmed. + /// + ValueTrimmingOptions trimmingOptions; + + /// + /// Indicates if field names are located on the first non commented line. + /// + bool hasHeaders; + + #endregion + + #region State + + /// + /// Indicates if the class is initialized. + /// + bool initialized; + + /// + /// Contains the field headers. + /// + string[] fieldHeaders; + + /// + /// Contains the dictionary of field indexes by header. The key is the field name and the value is its index. + /// + Dictionary fieldHeaderIndexes; + + /// + /// Contains the current record index in the CSV file. + /// A value of means that the reader has not been initialized yet. + /// Otherwise, a negative value means that no record has been read yet. + /// + long currentRecordIndex; + + /// + /// Contains the starting position of the next unread field. + /// + int nextFieldStart; + + /// + /// Contains the index of the next unread field. + /// + int nextFieldIndex; + + /// + /// Contains the array of the field values for the current record. + /// A null value indicates that the field have not been parsed. + /// + string[] fields; + + /// + /// Contains the maximum number of fields to retrieve for each record. + /// + int fieldCount; + + /// + /// Contains the read buffer. + /// + char[] buffer; + + /// + /// Contains the current read buffer length. + /// + int bufferLength; + + /// + /// Indicates if the end of the reader has been reached. + /// + bool eof; + + /// + /// Indicates if the last read operation reached an EOL character. + /// + bool eol; + + /// + /// Indicates if the first record is in cache. + /// This can happen when initializing a reader with no headers + /// because one record must be read to get the field count automatically + /// + bool firstRecordInCache; + + /// + /// Indicates if one or more field are missing for the current record. + /// Resets after each successful record read. + /// + bool missingFieldFlag; + + /// + /// Indicates if a parse error occured for the current record. + /// Resets after each successful record read. + /// + bool parseErrorFlag; + + #endregion + + #endregion + + #region Constructors + + /// + /// Initializes a new instance of the CsvReader class. + /// + /// A pointing to the CSV file. + /// if field names are located on the first non commented line, otherwise, . + /// + /// is a . + /// + /// + /// Cannot read from . + /// + public CsvDataReader(TextReader reader, bool hasHeaders) + : this(reader, hasHeaders, DefaultDelimiter, DefaultQuote, DefaultEscape, DefaultComment, ValueTrimmingOptions.UnquotedOnly, DefaultBufferSize) + { + } + + /// + /// Initializes a new instance of the CsvReader class. + /// + /// A pointing to the CSV file. + /// if field names are located on the first non commented line, otherwise, . + /// The buffer size in bytes. + /// + /// is a . + /// + /// + /// Cannot read from . + /// + public CsvDataReader(TextReader reader, bool hasHeaders, int bufferSize) + : this(reader, hasHeaders, DefaultDelimiter, DefaultQuote, DefaultEscape, DefaultComment, ValueTrimmingOptions.UnquotedOnly, bufferSize) + { + } + + /// + /// Initializes a new instance of the CsvReader class. + /// + /// A pointing to the CSV file. + /// if field names are located on the first non commented line, otherwise, . + /// The delimiter character separating each field (default is ','). + /// + /// is a . + /// + /// + /// Cannot read from . + /// + public CsvDataReader(TextReader reader, bool hasHeaders, char delimiter) + : this(reader, hasHeaders, delimiter, DefaultQuote, DefaultEscape, DefaultComment, ValueTrimmingOptions.UnquotedOnly, DefaultBufferSize) + { + } + + /// + /// Initializes a new instance of the CsvReader class. + /// + /// A pointing to the CSV file. + /// if field names are located on the first non commented line, otherwise, . + /// The delimiter character separating each field (default is ','). + /// The buffer size in bytes. + /// + /// is a . + /// + /// + /// Cannot read from . + /// + public CsvDataReader(TextReader reader, bool hasHeaders, char delimiter, int bufferSize) + : this(reader, hasHeaders, delimiter, DefaultQuote, DefaultEscape, DefaultComment, ValueTrimmingOptions.UnquotedOnly, bufferSize) + { + } + + /// + /// Initializes a new instance of the CsvReader class. + /// + /// A pointing to the CSV file. + /// if field names are located on the first non commented line, otherwise, . + /// The delimiter character separating each field (default is ','). + /// The quotation character wrapping every field (default is '''). + /// + /// The escape character letting insert quotation characters inside a quoted field (default is '\'). + /// If no escape character, set to '\0' to gain some performance. + /// + /// The comment character indicating that a line is commented out (default is '#'). + /// Determines which values should be trimmed. + /// + /// is a . + /// + /// + /// Cannot read from . + /// + public CsvDataReader(TextReader reader, bool hasHeaders, char delimiter, char quote, char escape, char comment, ValueTrimmingOptions trimmingOptions) + : this(reader, hasHeaders, delimiter, quote, escape, comment, trimmingOptions, DefaultBufferSize) + { + } + + /// + /// Initializes a new instance of the CsvReader class. + /// + /// A pointing to the CSV file. + /// if field names are located on the first non commented line, otherwise, . + /// The delimiter character separating each field (default is ','). + /// The quotation character wrapping every field (default is '''). + /// + /// The escape character letting insert quotation characters inside a quoted field (default is '\'). + /// If no escape character, set to '\0' to gain some performance. + /// + /// The comment character indicating that a line is commented out (default is '#'). + /// Determines which values should be trimmed. + /// The buffer size in bytes. + /// + /// is a . + /// + /// + /// must be 1 or more. + /// + public CsvDataReader(TextReader reader, bool hasHeaders, char delimiter, char quote, char escape, char comment, ValueTrimmingOptions trimmingOptions, int bufferSize) + { +#if DEBUG + allocStack = new System.Diagnostics.StackTrace(); +#endif + + if (reader == null) + throw new ArgumentNullException(nameof(reader)); + + if (bufferSize <= 0) + throw new ArgumentOutOfRangeException("bufferSize", bufferSize, ExceptionMessage.BufferSizeTooSmall); + + this.bufferSize = bufferSize; + + if (reader is StreamReader) + { + var stream = ((StreamReader)reader).BaseStream; + + if (stream.CanSeek) + { + // Handle bad implementations returning 0 or less + if (stream.Length > 0) + this.bufferSize = (int)Math.Min(bufferSize, stream.Length); + } + } + + this.reader = reader; + this.delimiter = delimiter; + this.quote = quote; + this.escape = escape; + this.comment = comment; + + this.hasHeaders = hasHeaders; + this.trimmingOptions = trimmingOptions; + SupportsMultiline = true; + SkipEmptyLines = true; + DefaultHeaderName = "Column"; + + currentRecordIndex = -1; + DefaultParseErrorAction = ParseErrorAction.RaiseEvent; + } + + #endregion + + #region Events + + /// + /// Occurs when there is an error while parsing the CSV stream. + /// + public event EventHandler ParseError; + + /// + /// Raises the event. + /// + /// The that contains the event data. + protected virtual void OnParseError(ParseErrorEventArgs e) => ParseError?.Invoke(this, e); + + #endregion + + #region Properties + + #region Settings + + /// + /// Gets the comment character indicating that a line is commented out. + /// + /// The comment character indicating that a line is commented out. + public char Comment => comment; + + /// + /// Gets the escape character letting insert quotation characters inside a quoted field. + /// + /// The escape character letting insert quotation characters inside a quoted field. + public char Escape => escape; + + /// + /// Gets the delimiter character separating each field. + /// + /// The delimiter character separating each field. + public char Delimiter => delimiter; + + /// + /// Gets the quotation character wrapping every field. + /// + /// The quotation character wrapping every field. + public char Quote => quote; + + /// + /// Indicates if field names are located on the first non commented line. + /// + /// if field names are located on the first non commented line, otherwise, . + public bool HasHeaders => hasHeaders; + + /// + /// Indicates if spaces at the start and end of a field are trimmed. + /// + /// if spaces at the start and end of a field are trimmed, otherwise, . + public ValueTrimmingOptions TrimmingOption => trimmingOptions; + + /// + /// Gets the buffer size. + /// + public int BufferSize => bufferSize; + + /// + /// Gets or sets the default action to take when a parsing error has occured. + /// + /// The default action to take when a parsing error has occured. + public ParseErrorAction DefaultParseErrorAction { get; set; } + + /// + /// Gets or sets the action to take when a field is missing. + /// + /// The action to take when a field is missing. + public MissingFieldAction MissingFieldAction { get; set; } + + /// + /// Gets or sets a value indicating if the reader supports multiline fields. + /// + /// A value indicating if the reader supports multiline field. + public bool SupportsMultiline { get; set; } + + /// + /// Gets or sets a value indicating if the reader will skip empty lines. + /// + /// A value indicating if the reader will skip empty lines. + public bool SkipEmptyLines { get; set; } + + /// + /// Gets or sets the default header name when it is an empty string or only whitespaces. + /// The header index will be appended to the specified name. + /// + /// The default header name when it is an empty string or only whitespaces. + public string DefaultHeaderName { get; set; } + + #endregion + + #region State + + /// + /// Gets the maximum number of fields to retrieve for each record. + /// + /// The maximum number of fields to retrieve for each record. + /// + /// The instance has been disposed of. + /// + public int FieldCount + { + get + { + EnsureInitialize(); + return fieldCount; + } + } + + /// + /// Gets a value that indicates whether the current stream position is at the end of the stream. + /// + /// if the current stream position is at the end of the stream; otherwise . + public virtual bool EndOfStream => eof; + + /// + /// Gets the field headers. + /// + /// The field headers or an empty array if headers are not supported. + /// + /// The instance has been disposed of. + /// + public string[] GetFieldHeaders() + { + EnsureInitialize(); + Debug.Assert(this.fieldHeaders != null, "Field headers must be non null."); + + var fieldHeaders = new string[this.fieldHeaders.Length]; + + for (int i = 0; i < fieldHeaders.Length; i++) + fieldHeaders[i] = this.fieldHeaders[i]; + + return fieldHeaders; + } + + /// + /// Gets the current record index in the CSV file. + /// + /// The current record index in the CSV file. + public virtual long CurrentRecordIndex => currentRecordIndex; + + /// + /// Indicates if one or more field are missing for the current record. + /// Resets after each successful record read. + /// + public bool MissingFieldFlag => missingFieldFlag; + + /// + /// Indicates if a parse error occured for the current record. + /// Resets after each successful record read. + /// + public bool ParseErrorFlag => parseErrorFlag; + + #endregion + + #endregion + + #region Indexers + + /// + /// Gets the field with the specified name and record position. must be . + /// + /// + /// The field with the specified name and record position. + /// + /// + /// is or an empty string. + /// + /// + /// The CSV does not have headers ( property is ). + /// + /// + /// not found. + /// + /// + /// Record index must be > 0. + /// + /// + /// Cannot move to a previous record in forward-only mode. + /// + /// + /// Cannot read record at . + /// + /// + /// The CSV appears to be corrupt at the current position. + /// + /// + /// The instance has been disposed of. + /// + public string this[int record, string field] + { + get + { + if (!MoveTo(record)) + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, ExceptionMessage.CannotReadRecordAtIndex, record)); + + return this[field]; + } + } + + /// + /// Gets the field at the specified index and record position. + /// + /// + /// The field at the specified index and record position. + /// A is returned if the field cannot be found for the record. + /// + /// + /// must be included in [0, [. + /// + /// + /// Record index must be > 0. + /// + /// + /// Cannot move to a previous record in forward-only mode. + /// + /// + /// Cannot read record at . + /// + /// + /// The CSV appears to be corrupt at the current position. + /// + /// + /// The instance has been disposed of. + /// + public string this[int record, int field] + { + get + { + if (!MoveTo(record)) + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, ExceptionMessage.CannotReadRecordAtIndex, record)); + + return this[field]; + } + } + + /// + /// Gets the field with the specified name. must be . + /// + /// + /// The field with the specified name. + /// + /// + /// is or an empty string. + /// + /// + /// The CSV does not have headers ( property is ). + /// + /// + /// not found. + /// + /// + /// The CSV appears to be corrupt at the current position. + /// + /// + /// The instance has been disposed of. + /// + public string this[string field] + { + get + { + if (string.IsNullOrEmpty(field)) + throw new ArgumentNullException(nameof(field)); + + if (!hasHeaders) + throw new InvalidOperationException(ExceptionMessage.NoHeaders); + + int index = GetFieldIndex(field); + + if (index < 0) + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, ExceptionMessage.FieldHeaderNotFound, field), "field"); + + return this[index]; + } + } + + /// + /// Gets the field at the specified index. + /// + /// The field at the specified index. + /// + /// must be included in [0, [. + /// + /// + /// No record read yet. Call ReadLine() first. + /// + /// + /// The CSV appears to be corrupt at the current position. + /// + /// + /// The instance has been disposed of. + /// + public virtual string this[int field] + { + get + { + return ReadField(field, initializing: false, discardValue: false); + } + } + + #endregion + + #region Methods + + #region EnsureInitialize + + /// + /// Ensures that the reader is initialized. + /// + void EnsureInitialize() + { + if (!initialized) + ReadNextRecord(onlyReadHeaders: true, skipToNextLine: false); + + Debug.Assert(fieldHeaders != null); + Debug.Assert(fieldHeaders.Length > 0 || (fieldHeaders.Length == 0 && fieldHeaderIndexes == null)); + } + + #endregion + + #region GetFieldIndex + + /// + /// Gets the field index for the provided header. + /// + /// The header to look for. + /// The field index for the provided header. -1 if not found. + /// + /// The instance has been disposed of. + /// + public int GetFieldIndex(string header) + { + EnsureInitialize(); + + int index; + + if (fieldHeaderIndexes != null && fieldHeaderIndexes.TryGetValue(header, out index)) + return index; + else + return -1; + } + + #endregion + + #region CopyCurrentRecordTo + + /// + /// Copies the field array of the current record to a one-dimensional array, starting at the beginning of the target array. + /// + /// The one-dimensional array that is the destination of the fields of the current record. + public void CopyCurrentRecordTo(string[] array) => CopyCurrentRecordTo(array, 0); + + /// + /// Copies the field array of the current record to a one-dimensional array, starting at the beginning of the target array. + /// + /// The one-dimensional that is the destination of the fields of the current record. + /// The zero-based index in at which copying begins. + /// + /// is . + /// + /// + /// is les than zero or is equal to or greater than the length . + /// + /// + /// No current record. + /// + /// + /// The number of fields in the record is greater than the available space from to the end of . + /// + public void CopyCurrentRecordTo(string[] array, int index) + { + if (array == null) + throw new ArgumentNullException(nameof(array)); + + if (index < 0 || index >= array.Length) + throw new ArgumentOutOfRangeException("index", index, string.Empty); + + if (currentRecordIndex < 0 || !initialized) + throw new InvalidOperationException(ExceptionMessage.NoCurrentRecord); + + if (array.Length - index < fieldCount) + throw new ArgumentException(ExceptionMessage.NotEnoughSpaceInArray, "array"); + + for (int i = 0; i < fieldCount; i++) + { + if (parseErrorFlag) + array[index + i] = null; + else + array[index + i] = this[i]; + } + } + + #endregion + + #region GetCurrentRawData + + /// + /// Gets the current raw CSV data. + /// + /// Used for exception handling purpose. + /// The current raw CSV data. + public string GetCurrentRawData() + { + if (buffer != null && bufferLength > 0) + return new string(buffer, 0, bufferLength); + else + return string.Empty; + } + + #endregion + + #region IsWhiteSpace + + /// + /// Indicates whether the specified Unicode character is categorized as white space. + /// + bool IsWhiteSpace(char character) + { + // Handle cases where the delimiter is a whitespace (e.g. tab) + if (character == delimiter) + return false; + else + { + // See char.IsLatin1(char c) in Reflector + if (character <= '\x00ff') + return (character == ' ' || character == '\t'); + else + return (System.Globalization.CharUnicodeInfo.GetUnicodeCategory(character) == System.Globalization.UnicodeCategory.SpaceSeparator); + } + } + + #endregion + + #region MoveTo + + /// + /// Moves to the specified record index. + /// + /// The record index. + /// true if the operation was successful; otherwise, false. + /// + /// The instance has been disposed of. + /// + public virtual bool MoveTo(long record) + { + if (record < currentRecordIndex) return false; + + // Get number of record to read + long offset = record - currentRecordIndex; + + while (offset > 0) + { + if (!ReadNextRecord()) return false; + + offset--; + } + + return true; + } + + #endregion + + #region ParseNewLine + + /// + /// Parses a new line delimiter. + /// + /// The starting position of the parsing. Will contain the resulting end position. + /// if a new line delimiter was found; otherwise, . + /// + /// The instance has been disposed of. + /// + bool ParseNewLine(ref int pos) + { + Debug.Assert(pos <= bufferLength); + + // Check if already at the end of the buffer + if (pos == bufferLength) + { + pos = 0; + + if (!ReadBuffer()) return false; + } + + var character = buffer[pos]; + + // Treat \r as new line only if it's not the delimiter + + if (character == '\r' && delimiter != '\r') + { + pos++; + + // Skip following \n (if there is one) + + if (pos < bufferLength) + { + if (buffer[pos] == '\n') pos++; + } + else + { + if (ReadBuffer()) + { + if (buffer[0] == '\n') + pos = 1; + else + pos = 0; + } + } + + if (pos >= bufferLength) + { + ReadBuffer(); + pos = 0; + } + + return true; + } + else if (character == '\n') + { + pos++; + + if (pos >= bufferLength) + { + ReadBuffer(); + pos = 0; + } + + return true; + } + + return false; + } + + /// + /// Determines whether the character at the specified position is a new line delimiter. + /// + /// The position of the character to verify. + /// + /// if the character at the specified position is a new line delimiter; otherwise, . + /// + bool IsNewLine(int pos) + { + Debug.Assert(pos < bufferLength); + + var c = buffer[pos]; + + if (c == '\n') return true; + else if (c == '\r' && delimiter != '\r') + return true; + else + return false; + } + + #endregion + + #region ReadBuffer + + /// + /// Fills the buffer with data from the reader. + /// + /// if data was successfully read; otherwise, . + /// + /// The instance has been disposed of. + /// + bool ReadBuffer() + { + if (eof) return false; + + CheckDisposed(); + + bufferLength = reader.Read(buffer, 0, bufferSize); + + if (bufferLength > 0) return true; + else + { + eof = true; + buffer = null; + + return false; + } + } + + #endregion + + #region ReadField + + /// + /// Reads the field at the specified index. + /// Any unread fields with an inferior index will also be read as part of the required parsing. + /// + /// The field index. + /// Indicates if the reader is currently initializing. + /// Indicates if the value(s) are discarded. + /// + /// The field at the specified index. + /// A indicates that an error occured or that the last field has been reached during initialization. + /// + /// + /// is out of range. + /// + /// + /// There is no current record. + /// + /// + /// The CSV data appears to be missing a field. + /// + /// + /// The CSV data appears to be malformed. + /// + /// + /// The instance has been disposed of. + /// + string ReadField(int field, bool initializing, bool discardValue) + { + if (!initializing) + { + if (field < 0 || field >= fieldCount) + throw new ArgumentOutOfRangeException("field", field, string.Format(CultureInfo.InvariantCulture, ExceptionMessage.FieldIndexOutOfRange, field)); + + if (currentRecordIndex < 0) + throw new InvalidOperationException(ExceptionMessage.NoCurrentRecord); + + // Directly return field if cached + if (fields[field] != null) + return fields[field]; + else if (missingFieldFlag) + return HandleMissingField(null, field, ref nextFieldStart); + } + + CheckDisposed(); + + int index = nextFieldIndex; + + while (index < field + 1) + { + // Handle case where stated start of field is past buffer + // This can occur because _nextFieldStart is simply 1 + last char position of previous field + if (nextFieldStart == bufferLength) + { + nextFieldStart = 0; + + // Possible EOF will be handled later (see Handle_EOF1) + ReadBuffer(); + } + + string value = null; + + if (missingFieldFlag) + { + value = HandleMissingField(value, index, ref nextFieldStart); + } + else if (nextFieldStart == bufferLength) + { + // Handle_EOF1: Handle EOF here + + // If current field is the requested field, then the value of the field is "" as in "f1,f2,f3,(\s*)" + // otherwise, the CSV is malformed + + if (index == field) + { + if (!discardValue) + { + value = string.Empty; + fields[index] = value; + } + + missingFieldFlag = true; + } + else + { + value = HandleMissingField(value, index, ref nextFieldStart); + } + } + else + { + // Trim spaces at start + if ((trimmingOptions & ValueTrimmingOptions.UnquotedOnly) != 0) + SkipWhiteSpaces(ref nextFieldStart); + + if (eof) + { + value = string.Empty; + fields[field] = value; + } + else if (buffer[nextFieldStart] != quote) + { + // Non-quoted field + + int start = nextFieldStart; + int pos = nextFieldStart; + + for (; ; ) + { + while (pos < bufferLength) + { + var c = buffer[pos]; + + if (c == delimiter) + { + nextFieldStart = pos + 1; + + break; + } + else if (c == '\r' || c == '\n') + { + nextFieldStart = pos; + eol = true; + + break; + } + else + pos++; + } + + if (pos < bufferLength) break; + else + { + if (!discardValue) + value += new string(buffer, start, pos - start); + + start = 0; + pos = 0; + nextFieldStart = 0; + + if (!ReadBuffer()) + break; + } + } + + if (!discardValue) + { + if ((trimmingOptions & ValueTrimmingOptions.UnquotedOnly) == 0) + { + if (!eof && pos > start) + value += new string(buffer, start, pos - start); + } + else + { + if (!eof && pos > start) + { + // Do the trimming + pos--; + while (pos > -1 && IsWhiteSpace(buffer[pos])) + pos--; + pos++; + + if (pos > 0) + value += new string(buffer, start, pos - start); + } + else + pos = -1; + + // If pos <= 0, that means the trimming went past buffer start, + // and the concatenated value needs to be trimmed too. + if (pos <= 0) + { + pos = (value == null ? -1 : value.Length - 1); + + // Do the trimming + while (pos > -1 && IsWhiteSpace(value[pos])) + pos--; + + pos++; + + if (pos > 0 && pos != value.Length) + value = value.Substring(0, pos); + } + } + + if (value == null) value = string.Empty; + } + + if (eol || eof) + { + eol = ParseNewLine(ref nextFieldStart); + + // Reaching a new line is ok as long as the parser is initializing or it is the last field + if (!initializing && index != fieldCount - 1) + { + if (value != null && value.Length == 0) + value = null; + + value = HandleMissingField(value, index, ref nextFieldStart); + } + } + + if (!discardValue) fields[index] = value; + } + else + { + // Quoted field + + // Skip quote + int start = nextFieldStart + 1; + int pos = start; + + bool quoted = true; + bool escaped = false; + + if ((trimmingOptions & ValueTrimmingOptions.QuotedOnly) != 0) + { + SkipWhiteSpaces(ref start); + pos = start; + } + + for (; ; ) + { + while (pos < bufferLength) + { + var c = buffer[pos]; + + if (escaped) + { + escaped = false; + start = pos; + } + // IF current char is escape AND (escape and quote are different OR next char is a quote) + else if (c == escape && (escape != quote || (pos + 1 < bufferLength && buffer[pos + 1] == quote) || (pos + 1 == bufferLength && reader.Peek() == quote))) + { + if (!discardValue) + value += new string(buffer, start, pos - start); + + escaped = true; + } + else if (c == quote) + { + quoted = false; + break; + } + + pos++; + } + + if (!quoted) break; + else + { + if (!discardValue && !escaped) + value += new string(buffer, start, pos - start); + + start = 0; + pos = 0; + nextFieldStart = 0; + + if (!ReadBuffer()) + { + HandleParseError(new MalformedCsvException(GetCurrentRawData(), nextFieldStart, Math.Max(0, currentRecordIndex), index), ref nextFieldStart); + return null; + } + } + } + + if (!eof) + { + // Append remaining parsed buffer content + if (!discardValue && pos > start) + value += new string(buffer, start, pos - start); + + if (!discardValue && value != null && (trimmingOptions & ValueTrimmingOptions.QuotedOnly) != 0) + { + int newLength = value.Length; + while (newLength > 0 && IsWhiteSpace(value[newLength - 1])) + newLength--; + + if (newLength < value.Length) + value = value.Substring(0, newLength); + } + + // Skip quote + nextFieldStart = pos + 1; + + // Skip whitespaces between the quote and the delimiter/eol + SkipWhiteSpaces(ref nextFieldStart); + + // Skip delimiter + bool delimiterSkipped; + + if (nextFieldStart < bufferLength && buffer[nextFieldStart] == delimiter) + { + nextFieldStart++; + delimiterSkipped = true; + } + else + { + delimiterSkipped = false; + } + + // Skip new line delimiter if initializing or last field + // (if the next field is missing, it will be caught when parsed) + if (!eof && !delimiterSkipped && (initializing || index == fieldCount - 1)) + eol = ParseNewLine(ref nextFieldStart); + + // If no delimiter is present after the quoted field and it is not the last field, then it is a parsing error + if (!delimiterSkipped && !eof && !(eol || IsNewLine(nextFieldStart))) + HandleParseError(new MalformedCsvException(GetCurrentRawData(), nextFieldStart, Math.Max(0, currentRecordIndex), index), ref nextFieldStart); + } + + if (!discardValue) + { + if (value == null) + value = string.Empty; + + fields[index] = value; + } + } + } + + nextFieldIndex = Math.Max(index + 1, nextFieldIndex); + + if (index == field) + { + // If initializing, return null to signify the last field has been reached + + if (initializing) + { + if (eol || eof) + return null; + else + return string.IsNullOrEmpty(value) ? string.Empty : value; + } + else + return value; + } + + index++; + } + + // Getting here is bad ... + HandleParseError(new MalformedCsvException(GetCurrentRawData(), nextFieldStart, Math.Max(0, currentRecordIndex), index), ref nextFieldStart); + return null; + } + + #endregion + + #region ReadNextRecord + + /// + /// Reads the next record. + /// + /// if a record has been successfully reads; otherwise, . + /// + /// The instance has been disposed of. + /// + public bool ReadNextRecord() => ReadNextRecord(onlyReadHeaders: false, skipToNextLine: false); + + /// + /// Reads the next record. + /// + /// + /// Indicates if the reader will proceed to the next record after having read headers. + /// if it stops after having read headers; otherwise, . + /// + /// + /// Indicates if the reader will skip directly to the next line without parsing the current one. + /// To be used when an error occurs. + /// + /// if a record has been successfully reads; otherwise, . + /// + /// The instance has been disposed of. + /// + protected virtual bool ReadNextRecord(bool onlyReadHeaders, bool skipToNextLine) + { + if (eof) + { + if (firstRecordInCache) + { + firstRecordInCache = false; + currentRecordIndex++; + + return true; + } + else + return false; + } + + CheckDisposed(); + + if (!initialized) + { + buffer = new char[bufferSize]; + + // will be replaced if and when headers are read + fieldHeaders = new string[0]; + + if (!ReadBuffer()) return false; + + if (!SkipEmptyAndCommentedLines(ref nextFieldStart)) + return false; + + // Keep growing _fields array until the last field has been found + // and then resize it to its final correct size + + fieldCount = 0; + fields = new string[16]; + + while (ReadField(fieldCount, initializing: true, discardValue: false) != null) + { + if (parseErrorFlag) + { + fieldCount = 0; + Array.Clear(fields, 0, fields.Length); + parseErrorFlag = false; + nextFieldIndex = 0; + } + else + { + fieldCount++; + + if (fieldCount == fields.Length) + Array.Resize(ref fields, (fieldCount + 1) * 2); + } + } + + // _fieldCount contains the last field index, but it must contains the field count, + // so increment by 1 + fieldCount++; + + if (fields.Length != fieldCount) + Array.Resize(ref fields, fieldCount); + + initialized = true; + + // If headers are present, call ReadNextRecord again + if (hasHeaders) + { + // Don't count first record as it was the headers + currentRecordIndex = -1; + + firstRecordInCache = false; + + fieldHeaders = new string[fieldCount]; + fieldHeaderIndexes = new Dictionary(fieldCount, fieldHeaderComparer); + + for (int i = 0; i < fields.Length; i++) + { + string headerName = fields[i]; + if (string.IsNullOrEmpty(headerName) || headerName.Trim().Length == 0) + headerName = DefaultHeaderName + i.ToString(); + + fieldHeaders[i] = headerName; + fieldHeaderIndexes.Add(headerName, i); + } + + // Proceed to first record + if (!onlyReadHeaders) + { + // Calling again ReadNextRecord() seems to be simpler, + // but in fact would probably cause many subtle bugs because a derived class does not expect a recursive behavior + // so simply do what is needed here and no more. + + if (!SkipEmptyAndCommentedLines(ref nextFieldStart)) + return false; + + Array.Clear(fields, 0, fields.Length); + nextFieldIndex = 0; + eol = false; + + currentRecordIndex++; + return true; + } + } + else + { + if (onlyReadHeaders) + { + firstRecordInCache = true; + currentRecordIndex = -1; + } + else + { + firstRecordInCache = false; + currentRecordIndex = 0; + } + } + } + else + { + if (skipToNextLine) + SkipToNextLine(ref nextFieldStart); + else if (currentRecordIndex > -1 && !missingFieldFlag) + { + // If not already at end of record, move there + if (!eol && !eof) + { + if (!SupportsMultiline) + SkipToNextLine(ref nextFieldStart); + else + { + // a dirty trick to handle the case where extra fields are present + while (ReadField(nextFieldIndex, initializing: true, discardValue: true) != null) + { + } + } + } + } + + if (!firstRecordInCache && !SkipEmptyAndCommentedLines(ref nextFieldStart)) + return false; + + if (hasHeaders || !firstRecordInCache) + eol = false; + + // Check to see if the first record is in cache. + // This can happen when initializing a reader with no headers + // because one record must be read to get the field count automatically + if (firstRecordInCache) + firstRecordInCache = false; + else + { + Array.Clear(fields, 0, fields.Length); + nextFieldIndex = 0; + } + + missingFieldFlag = false; + parseErrorFlag = false; + currentRecordIndex++; + } + + return true; + } + + #endregion + + #region SkipEmptyAndCommentedLines + + /// + /// Skips empty and commented lines. + /// If the end of the buffer is reached, its content be discarded and filled again from the reader. + /// + /// + /// The position in the buffer where to start parsing. + /// Will contains the resulting position after the operation. + /// + /// if the end of the reader has not been reached; otherwise, . + /// + /// The instance has been disposed of. + /// + bool SkipEmptyAndCommentedLines(ref int pos) + { + if (pos < bufferLength) + DoSkipEmptyAndCommentedLines(ref pos); + + while (pos >= bufferLength && !eof) + { + if (ReadBuffer()) + { + pos = 0; + DoSkipEmptyAndCommentedLines(ref pos); + } + else + return false; + } + + return !eof; + } + + /// + /// Worker method. + /// Skips empty and commented lines. + /// + /// + /// The position in the buffer where to start parsing. + /// Will contains the resulting position after the operation. + /// + /// + /// The instance has been disposed of. + /// + void DoSkipEmptyAndCommentedLines(ref int pos) + { + while (pos < bufferLength) + { + if (buffer[pos] == comment) + { + pos++; + SkipToNextLine(ref pos); + } + else if (SkipEmptyLines && ParseNewLine(ref pos)) + continue; + else + break; + } + } + + #endregion + + #region SkipWhiteSpaces + + /// + /// Skips whitespace characters. + /// + /// The starting position of the parsing. Will contain the resulting end position. + /// if the end of the reader has not been reached; otherwise, . + /// + /// The instance has been disposed of. + /// + bool SkipWhiteSpaces(ref int pos) + { + for (; ; ) + { + while (pos < bufferLength && IsWhiteSpace(buffer[pos])) + pos++; + + if (pos < bufferLength) break; + else + { + pos = 0; + + if (!ReadBuffer()) + return false; + } + } + + return true; + } + + #endregion + + #region SkipToNextLine + + /// + /// Skips ahead to the next NewLine character. + /// If the end of the buffer is reached, its content be discarded and filled again from the reader. + /// + /// + /// The position in the buffer where to start parsing. + /// Will contains the resulting position after the operation. + /// + /// if the end of the reader has not been reached; otherwise, . + /// + /// The instance has been disposed of. + /// + bool SkipToNextLine(ref int pos) + { + // ((pos = 0) == 0) is a little trick to reset position inline + while ((pos < bufferLength || (ReadBuffer() && ((pos = 0) == 0))) && !ParseNewLine(ref pos)) + pos++; + + return !eof; + } + + #endregion + + #region HandleParseError + + /// + /// Handles a parsing error. + /// + /// The parsing error that occured. + /// The current position in the buffer. + /// + /// is . + /// + void HandleParseError(MalformedCsvException error, ref int pos) + { + if (error == null) + throw new ArgumentNullException("error"); + + parseErrorFlag = true; + + switch (DefaultParseErrorAction) + { + case ParseErrorAction.ThrowException: + throw error; + + case ParseErrorAction.RaiseEvent: + var e = new ParseErrorEventArgs(error, ParseErrorAction.ThrowException); + OnParseError(e); + + switch (e.Action) + { + case ParseErrorAction.ThrowException: + throw e.Error; + + case ParseErrorAction.RaiseEvent: + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, ExceptionMessage.ParseErrorActionInvalidInsideParseErrorEvent, e.Action), e.Error); + + case ParseErrorAction.AdvanceToNextLine: + // already at EOL when fields are missing, so don't skip to next line in that case + if (!missingFieldFlag && pos >= 0) + SkipToNextLine(ref pos); + break; + + default: + throw new NotSupportedException(string.Format(CultureInfo.InvariantCulture, ExceptionMessage.ParseErrorActionNotSupported, e.Action), e.Error); + } + + break; + + case ParseErrorAction.AdvanceToNextLine: + // already at EOL when fields are missing, so don't skip to next line in that case + if (!missingFieldFlag && pos >= 0) + SkipToNextLine(ref pos); + break; + + default: + throw new NotSupportedException(string.Format(CultureInfo.InvariantCulture, ExceptionMessage.ParseErrorActionNotSupported, DefaultParseErrorAction), error); + } + } + + #endregion + + #region HandleMissingField + + /// + /// Handles a missing field error. + /// + /// The partially parsed value, if available. + /// The missing field index. + /// The current position in the raw data. + /// + /// The resulting value according to . + /// If the action is set to , + /// then the parse error will be handled according to . + /// + string HandleMissingField(string value, int fieldIndex, ref int currentPosition) + { + if (fieldIndex < 0 || fieldIndex >= fieldCount) + throw new ArgumentOutOfRangeException("fieldIndex", fieldIndex, string.Format(CultureInfo.InvariantCulture, ExceptionMessage.FieldIndexOutOfRange, fieldIndex)); + + missingFieldFlag = true; + + for (int i = fieldIndex + 1; i < fieldCount; i++) + fields[i] = null; + + if (value != null) + return value; + else + { + switch (MissingFieldAction) + { + case MissingFieldAction.ParseError: + HandleParseError(new MissingFieldCsvException(GetCurrentRawData(), currentPosition, Math.Max(0, currentRecordIndex), fieldIndex), ref currentPosition); + return value; + + case MissingFieldAction.ReplaceByEmpty: + return string.Empty; + + case MissingFieldAction.ReplaceByNull: + return null; + + default: + throw new NotSupportedException(string.Format(CultureInfo.InvariantCulture, ExceptionMessage.MissingFieldActionNotSupported, MissingFieldAction)); + } + } + } + + #endregion + + #endregion + + #region IDataReader support methods + + /// + /// Validates the state of the data reader. + /// + /// The validations to accomplish. + /// + /// No current record. + /// + /// + /// This operation is invalid when the reader is closed. + /// + void ValidateDataReader(DataReaderValidations validations) + { + if ((validations & DataReaderValidations.IsInitialized) != 0 && !initialized) + throw new InvalidOperationException(ExceptionMessage.NoCurrentRecord); + + if ((validations & DataReaderValidations.IsNotClosed) != 0 && isDisposed) + throw new InvalidOperationException(ExceptionMessage.ReaderClosed); + } + + /// + /// Copy the value of the specified field to an array. + /// + /// The index of the field. + /// The offset in the field value. + /// The destination array where the field value will be copied. + /// The destination array offset. + /// The number of characters to copy from the field value. + /// + long CopyFieldToArray(int field, long fieldOffset, Array destinationArray, int destinationOffset, int length) + { + EnsureInitialize(); + + if (field < 0 || field >= fieldCount) + throw new ArgumentOutOfRangeException("field", field, string.Format(CultureInfo.InvariantCulture, ExceptionMessage.FieldIndexOutOfRange, field)); + + if (fieldOffset < 0 || fieldOffset >= int.MaxValue) + throw new ArgumentOutOfRangeException("fieldOffset"); + + // Array.Copy(...) will do the remaining argument checks + + if (length == 0) + return 0; + + var value = this[field] ?? string.Empty; + + Debug.Assert(fieldOffset < int.MaxValue); + + Debug.Assert(destinationArray.GetType() == typeof(char[]) || destinationArray.GetType() == typeof(byte[])); + + if (destinationArray.GetType() == typeof(char[])) + Array.Copy(value.ToCharArray((int)fieldOffset, length), 0, destinationArray, destinationOffset, length); + else + { + var chars = value.ToCharArray((int)fieldOffset, length); + var source = new byte[chars.Length]; ; + + for (int i = 0; i < chars.Length; i++) + source[i] = Convert.ToByte(chars[i]); + + Array.Copy(source, 0, destinationArray, destinationOffset, length); + } + + return length; + } + + #endregion + + #region IDataReader Members + + int IDataReader.RecordsAffected => -1; + + bool IDataReader.IsClosed => eof; + + bool IDataReader.NextResult() + { + ValidateDataReader(DataReaderValidations.IsNotClosed); + + return false; + } + + void IDataReader.Close() => Dispose(); + + bool IDataReader.Read() + { + ValidateDataReader(DataReaderValidations.IsNotClosed); + + return ReadNextRecord(); + } + + int IDataReader.Depth + { + get + { + ValidateDataReader(DataReaderValidations.IsNotClosed); + + return 0; + } + } + + DataTable IDataReader.GetSchemaTable() + { + EnsureInitialize(); + ValidateDataReader(DataReaderValidations.IsNotClosed); + + var schema = new DataTable("SchemaTable") + { + Locale = CultureInfo.InvariantCulture, + MinimumCapacity = fieldCount + }; + + schema.Columns.Add(SchemaTableColumn.AllowDBNull, typeof(bool)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.BaseColumnName, typeof(string)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.BaseSchemaName, typeof(string)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.BaseTableName, typeof(string)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.ColumnName, typeof(string)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.ColumnOrdinal, typeof(int)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.ColumnSize, typeof(int)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.DataType, typeof(object)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.IsAliased, typeof(bool)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.IsExpression, typeof(bool)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.IsKey, typeof(bool)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.IsLong, typeof(bool)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.IsUnique, typeof(bool)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.NumericPrecision, typeof(short)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.NumericScale, typeof(short)).ReadOnly = true; + schema.Columns.Add(SchemaTableColumn.ProviderType, typeof(int)).ReadOnly = true; + + schema.Columns.Add(SchemaTableOptionalColumn.BaseCatalogName, typeof(string)).ReadOnly = true; + schema.Columns.Add(SchemaTableOptionalColumn.BaseServerName, typeof(string)).ReadOnly = true; + schema.Columns.Add(SchemaTableOptionalColumn.IsAutoIncrement, typeof(bool)).ReadOnly = true; + schema.Columns.Add(SchemaTableOptionalColumn.IsHidden, typeof(bool)).ReadOnly = true; + schema.Columns.Add(SchemaTableOptionalColumn.IsReadOnly, typeof(bool)).ReadOnly = true; + schema.Columns.Add(SchemaTableOptionalColumn.IsRowVersion, typeof(bool)).ReadOnly = true; + + string[] columnNames; + + if (hasHeaders) columnNames = fieldHeaders; + else + { + columnNames = new string[fieldCount]; + + for (int i = 0; i < fieldCount; i++) + columnNames[i] = "Column" + i.ToString(CultureInfo.InvariantCulture); + } + + // null marks columns that will change for each row + object[] schemaRow = new object[] { + true, // 00- AllowDBNull + null, // 01- BaseColumnName + string.Empty, // 02- BaseSchemaName + string.Empty, // 03- BaseTableName + null, // 04- ColumnName + null, // 05- ColumnOrdinal + int.MaxValue, // 06- ColumnSize + typeof(string), // 07- DataType + false, // 08- IsAliased + false, // 09- IsExpression + false, // 10- IsKey + false, // 11- IsLong + false, // 12- IsUnique + DBNull.Value, // 13- NumericPrecision + DBNull.Value, // 14- NumericScale + (int) DbType.String, // 15- ProviderType + + string.Empty, // 16- BaseCatalogName + string.Empty, // 17- BaseServerName + false, // 18- IsAutoIncrement + false, // 19- IsHidden + true, // 20- IsReadOnly + false // 21- IsRowVersion + }; + + for (int i = 0; i < columnNames.Length; i++) + { + schemaRow[1] = columnNames[i]; // Base column name + schemaRow[4] = columnNames[i]; // Column name + schemaRow[5] = i; // Column ordinal + + schema.Rows.Add(schemaRow); + } + + return schema; + } + + #endregion + + #region IDataRecord Members + + int IDataRecord.GetInt32(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + + string value = this[i]; + + return int.Parse(value == null ? string.Empty : value, CultureInfo.CurrentCulture); + } + + object IDataRecord.this[string name] + { + get + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return this[name]; + } + } + + object IDataRecord.this[int i] + { + get + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return this[i]; + } + } + + object IDataRecord.GetValue(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + + if (((IDataRecord)this).IsDBNull(i)) + return DBNull.Value; + else + return this[i]; + } + + bool IDataRecord.IsDBNull(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return (string.IsNullOrEmpty(this[i])); + } + + long IDataRecord.GetBytes(int i, long fieldOffset, byte[] buffer, int bufferoffset, int length) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + + return CopyFieldToArray(i, fieldOffset, buffer, bufferoffset, length); + } + + byte IDataRecord.GetByte(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return byte.Parse(this[i], CultureInfo.CurrentCulture); + } + + Type IDataRecord.GetFieldType(int i) + { + EnsureInitialize(); + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + + if (i < 0 || i >= fieldCount) + throw new ArgumentOutOfRangeException("i", i, string.Format(CultureInfo.InvariantCulture, ExceptionMessage.FieldIndexOutOfRange, i)); + + return typeof(string); + } + + decimal IDataRecord.GetDecimal(int number) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return decimal.Parse(this[number], CultureInfo.CurrentCulture); + } + + int IDataRecord.GetValues(object[] values) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + + var record = (IDataRecord)this; + + for (int i = 0; i < fieldCount; i++) + values[i] = record.GetValue(i); + + return fieldCount; + } + + string IDataRecord.GetName(int i) + { + EnsureInitialize(); + ValidateDataReader(DataReaderValidations.IsNotClosed); + + if (i < 0 || i >= fieldCount) + throw new ArgumentOutOfRangeException("i", i, string.Format(CultureInfo.InvariantCulture, ExceptionMessage.FieldIndexOutOfRange, i)); + + if (hasHeaders) + return fieldHeaders[i]; + else + return "Column" + i.ToString(CultureInfo.InvariantCulture); + } + + long IDataRecord.GetInt64(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return long.Parse(this[i], CultureInfo.CurrentCulture); + } + + double IDataRecord.GetDouble(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return double.Parse(this[i], CultureInfo.CurrentCulture); + } + + bool IDataRecord.GetBoolean(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + + string value = this[i]; + + int result; + + if (int.TryParse(value, out result)) + return (result != 0); + else + return bool.Parse(value); + } + + Guid IDataRecord.GetGuid(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return new Guid(this[i]); + } + + DateTime IDataRecord.GetDateTime(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return DateTime.Parse(this[i], CultureInfo.CurrentCulture); + } + + int IDataRecord.GetOrdinal(string name) + { + EnsureInitialize(); + ValidateDataReader(DataReaderValidations.IsNotClosed); + + int index; + + if (!fieldHeaderIndexes.TryGetValue(name, out index)) + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, ExceptionMessage.FieldHeaderNotFound, name), "name"); + + return index; + } + + string IDataRecord.GetDataTypeName(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return typeof(string).FullName; + } + + float IDataRecord.GetFloat(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return float.Parse(this[i], CultureInfo.CurrentCulture); + } + + IDataReader IDataRecord.GetData(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + + if (i == 0) + return this; + else + return null; + } + + long IDataRecord.GetChars(int field, long fieldoffset, char[] buffer, int bufferoffset, int length) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + + return CopyFieldToArray(field, fieldoffset, buffer, bufferoffset, length); + } + + string IDataRecord.GetString(int field) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return this[field]; + } + + char IDataRecord.GetChar(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return char.Parse(this[i]); + } + + short IDataRecord.GetInt16(int i) + { + ValidateDataReader(DataReaderValidations.IsInitialized | DataReaderValidations.IsNotClosed); + return short.Parse(this[i], CultureInfo.CurrentCulture); + } + + #endregion + + #region IEnumerable Members + + /// + /// Returns an that can iterate through CSV records. + /// + /// An that can iterate through CSV records. + /// + /// The instance has been disposed of. + /// + public CsvDataReader.RecordEnumerator GetEnumerator() + { + return new CsvDataReader.RecordEnumerator(this); + } + + /// + /// Returns an that can iterate through CSV records. + /// + /// An that can iterate through CSV records. + /// + /// The instance has been disposed of. + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + #endregion + + #region IEnumerable Members + + /// + /// Returns an that can iterate through CSV records. + /// + /// An that can iterate through CSV records. + /// + /// The instance has been disposed of. + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + #endregion + + #region IDisposable members + +#if DEBUG + /// + /// Contains the stack when the object was allocated. + /// + System.Diagnostics.StackTrace allocStack; +#endif + + /// + /// Contains the disposed status flag. + /// + bool isDisposed = false; + + /// + /// Contains the locking object for multi-threading purpose. + /// + readonly object _lock = new object(); + + /// + /// Occurs when the instance is disposed of. + /// + public event EventHandler Disposed; + + /// + /// Gets a value indicating whether the instance has been disposed of. + /// + /// + /// if the instance has been disposed of; otherwise, . + /// + [System.ComponentModel.Browsable(false)] + public bool IsDisposed => isDisposed; + + /// + /// Raises the event. + /// + /// A that contains the event data. + protected virtual void OnDisposed(EventArgs e) => Disposed?.Invoke(this, e); + + /// + /// Checks if the instance has been disposed of, and if it has, throws an ; otherwise, does nothing. + /// + /// + /// The instance has been disposed of. + /// + /// + /// Derived classes should call this method at the start of all methods and properties that should not be accessed after a call to . + /// + protected void CheckDisposed() + { + if (isDisposed) + throw new ObjectDisposedException(GetType().FullName); + } + + /// + /// Releases all resources used by the instance. + /// + /// + /// Calls with the disposing parameter set to to free unmanaged and managed resources. + /// + public void Dispose() + { + if (!isDisposed) + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } + + /// + /// Releases the unmanaged resources used by this instance and optionally releases the managed resources. + /// + /// + /// to release both managed and unmanaged resources; to release only unmanaged resources. + /// + protected virtual void Dispose(bool disposing) + { + // Refer to http://www.bluebytesoftware.com/blog/PermaLink,guid,88e62cdf-5919-4ac7-bc33-20c06ae539ae.aspx + // Refer to http://www.gotdotnet.com/team/libraries/whitepapers/resourcemanagement/resourcemanagement.aspx + + // No exception should ever be thrown except in critical scenarios. + // Unhandled exceptions during finalization will tear down the process. + if (!isDisposed) + { + try + { + // Dispose-time code should call Dispose() on all owned objects that implement the IDisposable interface. + // "owned" means objects whose lifetime is solely controlled by the container. + // In cases where ownership is not as straightforward, techniques such as HandleCollector can be used. + // Large managed object fields should be nulled out. + + // Dispose-time code should also set references of all owned objects to null, after disposing them. This will allow the referenced objects to be garbage collected even if not all references to the "parent" are released. It may be a significant memory consumption win if the referenced objects are large, such as big arrays, collections, etc. + if (disposing) + { + // Acquire a lock on the object while disposing. + + if (reader != null) + { + lock (_lock) + { + if (reader != null) + { + reader.Dispose(); + + reader = null; + buffer = null; + eof = true; + } + } + } + } + } + finally + { + // Ensure that the flag is set + isDisposed = true; + + // Catch any issues about firing an event on an already disposed object. + try + { + OnDisposed(EventArgs.Empty); + } + catch { } + } + } + } + + /// + /// Releases unmanaged resources and performs other cleanup operations before the instance is reclaimed by garbage collection. + /// + ~CsvDataReader() + { +#if DEBUG + Debug.WriteLine("FinalizableObject was not disposed" + allocStack.ToString()); +#endif + + Dispose(disposing: false); + } + + #endregion + } +} \ No newline at end of file diff --git a/Services/Olive.Services.CSV/LumenWorks.Framework/Exceptions/ExceptionMessage.Designer.cs b/Services/Olive.Services.CSV/LumenWorks.Framework/Exceptions/ExceptionMessage.Designer.cs new file mode 100644 index 000000000..72a79628a --- /dev/null +++ b/Services/Olive.Services.CSV/LumenWorks.Framework/Exceptions/ExceptionMessage.Designer.cs @@ -0,0 +1,204 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:2.0.50727.42 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace LumenWorks.Framework.IO.Csv.Resources +{ + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "2.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class ExceptionMessage + { + + static global::System.Resources.ResourceManager resourceMan; + + static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal ExceptionMessage() + { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager + { + get + { + if (object.ReferenceEquals(resourceMan, null)) + { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("LumenWorks.Framework.IO.Csv.Resources.ExceptionMessage", typeof(ExceptionMessage).Assembly); + resourceMan = temp; + } + + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture + { + get + { + return resourceCulture; + } + set + { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Buffer size must be 1 or more.. + /// + internal static string BufferSizeTooSmall => "Buffer size must be 1 or more."; + + /// + /// Looks up a localized string similar to Cannot move to a previous record in forward-only mode.. + /// + internal static string CannotMovePreviousRecordInForwardOnly + { + get + { + return "Cannot move to a previous record in forward-only mode."; + } + } + + /// + /// Looks up a localized string similar to Cannot read record at index '{0}'.. + /// + internal static string CannotReadRecordAtIndex => "Cannot read record at index '{0}'."; + + /// + /// Looks up a localized string similar to Enumeration has either not started or has already finished.. + /// + internal static string EnumerationFinishedOrNotStarted + { + get + { + return "Enumeration has either not started or has already finished."; + } + } + + /// + /// Looks up a localized string similar to Collection was modified; enumeration operation may not execute.. + /// + internal static string EnumerationVersionCheckFailed + { + get + { + return "Collection was modified; enumeration operation may not execute."; + } + } + + /// + /// Looks up a localized string similar to '{0}' field header not found.. + /// + internal static string FieldHeaderNotFound => "'{0}' field header not found."; + + /// + /// Looks up a localized string similar to Field index must be included in [0, FieldCount[. Specified field index was : '{0}'.. + /// + internal static string FieldIndexOutOfRange + { + get + { + return "Field index must be included in [0, FieldCount[. Specified field index was : '{0}'."; + } + } + + /// + /// Looks up a localized string similar to The CSV appears to be corrupt near record '{0}' field '{1} at position '{2}'. Current raw data : '{3}'.. + /// + internal static string MalformedCsvException + { + get + { + return "The CSV appears to be corrupt near record '{0}' field '{1} at position '{2}'. Current raw data : '{3}'."; + } + } + + /// + /// Looks up a localized string similar to '{0}' is not a supported missing field action.. + /// + internal static string MissingFieldActionNotSupported + { + get + { + return "'{0}' is not a supported missing field action."; + } + } + + /// + /// Looks up a localized string similar to No current record.. + /// + internal static string NoCurrentRecord => "No current record."; + + /// + /// Looks up a localized string similar to The CSV does not have headers (CsvReader.HasHeaders property is false).. + /// + internal static string NoHeaders + { + get + { + return "The CSV does not have headers (CsvReader.HasHeaders property is false)."; + } + } + + /// + /// Looks up a localized string similar to The number of fields in the record is greater than the available space from index to the end of the destination array.. + /// + internal static string NotEnoughSpaceInArray + { + get + { + return "The number of fields in the record is greater than the available space from index to the end of the destination array."; + } + } + + /// + /// Looks up a localized string similar to '{0}' is not a valid ParseErrorAction while inside a ParseError event.. + /// + internal static string ParseErrorActionInvalidInsideParseErrorEvent + { + get + { + return "'{0}' is not a valid ParseErrorAction while inside a ParseError event."; + } + } + + /// + /// Looks up a localized string similar to '{0}' is not a supported ParseErrorAction.. + /// + internal static string ParseErrorActionNotSupported => "'{0}' is not a supported ParseErrorAction."; + + /// + /// Looks up a localized string similar to This operation is invalid when the reader is closed.. + /// + internal static string ReaderClosed => "This operation is invalid when the reader is closed."; + + /// + /// Looks up a localized string similar to Record index must be 0 or more.. + /// + internal static string RecordIndexLessThanZero => "Record index must be 0 or more."; + } +} diff --git a/Services/Olive.Services.CSV/LumenWorks.Framework/Exceptions/MalformedCsvException.cs b/Services/Olive.Services.CSV/LumenWorks.Framework/Exceptions/MalformedCsvException.cs new file mode 100644 index 000000000..0698a0ef1 --- /dev/null +++ b/Services/Olive.Services.CSV/LumenWorks.Framework/Exceptions/MalformedCsvException.cs @@ -0,0 +1,204 @@ +// LumenWorks.Framework.IO.Csv.MalformedCsvException +// Copyright (c) 2005 S�bastien Lorion +// +// MIT license (http://en.wikipedia.org/wiki/MIT_License) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +using System; +using System.Globalization; +using System.Runtime.Serialization; +using LumenWorks.Framework.IO.Csv.Resources; + +namespace LumenWorks.Framework.IO.Csv +{ + /// + /// Represents the exception that is thrown when a CSV file is malformed. + /// + [Serializable()] + internal class MalformedCsvException + : Exception + { + #region Fields + + /// + /// Contains the message that describes the error. + /// + string message; + + /// + /// Contains the raw data when the error occured. + /// + string rawData; + + /// + /// Contains the current field index. + /// + int currentFieldIndex; + + /// + /// Contains the current record index. + /// + long currentRecordIndex; + + /// + /// Contains the current position in the raw data. + /// + int currentPosition; + + #endregion + + #region Constructors + + /// + /// Initializes a new instance of the MalformedCsvException class. + /// + public MalformedCsvException() + : this(null, null) + { + } + + /// + /// Initializes a new instance of the MalformedCsvException class. + /// + /// The message that describes the error. + public MalformedCsvException(string message) + : this(message, null) + { + } + + /// + /// Initializes a new instance of the MalformedCsvException class. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public MalformedCsvException(string message, Exception innerException) + : base(string.Empty, innerException) + { + this.message = (message == null ? string.Empty : message); + + rawData = string.Empty; + currentPosition = -1; + currentRecordIndex = -1; + currentFieldIndex = -1; + } + + /// + /// Initializes a new instance of the MalformedCsvException class. + /// + /// The raw data when the error occured. + /// The current position in the raw data. + /// The current record index. + /// The current field index. + public MalformedCsvException(string rawData, int currentPosition, long currentRecordIndex, int currentFieldIndex) + : this(rawData, currentPosition, currentRecordIndex, currentFieldIndex, null) + { + } + + /// + /// Initializes a new instance of the MalformedCsvException class. + /// + /// The raw data when the error occured. + /// The current position in the raw data. + /// The current record index. + /// The current field index. + /// The exception that is the cause of the current exception. + public MalformedCsvException(string rawData, int currentPosition, long currentRecordIndex, int currentFieldIndex, Exception innerException) + : base(string.Empty, innerException) + { + this.rawData = (rawData == null ? string.Empty : rawData); + this.currentPosition = currentPosition; + this.currentRecordIndex = currentRecordIndex; + this.currentFieldIndex = currentFieldIndex; + + message = string.Format(CultureInfo.InvariantCulture, ExceptionMessage.MalformedCsvException, this.currentRecordIndex, this.currentFieldIndex, this.currentPosition, this.rawData); + } + + /// + /// Initializes a new instance of the MalformedCsvException class with serialized data. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected MalformedCsvException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + message = info.GetString("MyMessage"); + + rawData = info.GetString("RawData"); + currentPosition = info.GetInt32("CurrentPosition"); + currentRecordIndex = info.GetInt64("CurrentRecordIndex"); + currentFieldIndex = info.GetInt32("CurrentFieldIndex"); + } + + #endregion + + #region Properties + + /// + /// Gets the raw data when the error occured. + /// + /// The raw data when the error occured. + public string RawData => rawData; + + /// + /// Gets the current position in the raw data. + /// + /// The current position in the raw data. + public int CurrentPosition => currentPosition; + + /// + /// Gets the current record index. + /// + /// The current record index. + public long CurrentRecordIndex => currentRecordIndex; + + /// + /// Gets the current field index. + /// + /// The current record index. + public int CurrentFieldIndex => currentFieldIndex; + + #endregion + + #region Overrides + + /// + /// Gets a message that describes the current exception. + /// + /// A message that describes the current exception. + public override string Message => message; + + /// + /// When overridden in a derived class, sets the with information about the exception. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + public override void GetObjectData(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) + { + base.GetObjectData(info, context); + + info.AddValue("MyMessage", message); + + info.AddValue("RawData", rawData); + info.AddValue("CurrentPosition", currentPosition); + info.AddValue("CurrentRecordIndex", currentRecordIndex); + info.AddValue("CurrentFieldIndex", currentFieldIndex); + } + + #endregion + } +} \ No newline at end of file diff --git a/Services/Olive.Services.CSV/LumenWorks.Framework/Exceptions/MissingFieldCsvException.cs b/Services/Olive.Services.CSV/LumenWorks.Framework/Exceptions/MissingFieldCsvException.cs new file mode 100644 index 000000000..6c7766041 --- /dev/null +++ b/Services/Olive.Services.CSV/LumenWorks.Framework/Exceptions/MissingFieldCsvException.cs @@ -0,0 +1,102 @@ +// LumenWorks.Framework.IO.Csv.MissingFieldCsvException +// Copyright (c) 2005 Sébastien Lorion +// +// MIT license (http://en.wikipedia.org/wiki/MIT_License) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +using System; +using System.Runtime.Serialization; + +namespace LumenWorks.Framework.IO.Csv +{ + /// + /// Represents the exception that is thrown when a there is a missing field in a record of the CSV file. + /// + /// + /// MissingFieldException would have been a better name, but there is already a . + /// + [Serializable()] + internal class MissingFieldCsvException + : MalformedCsvException + { + #region Constructors + + /// + /// Initializes a new instance of the MissingFieldCsvException class. + /// + public MissingFieldCsvException() + { + } + + /// + /// Initializes a new instance of the MissingFieldCsvException class. + /// + /// The message that describes the error. + public MissingFieldCsvException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the MissingFieldCsvException class. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public MissingFieldCsvException(string message, Exception innerException) + : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the MissingFieldCsvException class. + /// + /// The raw data when the error occured. + /// The current position in the raw data. + /// The current record index. + /// The current field index. + public MissingFieldCsvException(string rawData, int currentPosition, long currentRecordIndex, int currentFieldIndex) + : base(rawData, currentPosition, currentRecordIndex, currentFieldIndex) + { + } + + /// + /// Initializes a new instance of the MissingFieldCsvException class. + /// + /// The raw data when the error occured. + /// The current position in the raw data. + /// The current record index. + /// The current field index. + /// The exception that is the cause of the current exception. + public MissingFieldCsvException(string rawData, int currentPosition, long currentRecordIndex, int currentFieldIndex, Exception innerException) + : base(rawData, currentPosition, currentRecordIndex, currentFieldIndex, innerException) + { + } + + /// + /// Initializes a new instance of the MissingFieldCsvException class with serialized data. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected MissingFieldCsvException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + + #endregion + } +} \ No newline at end of file diff --git a/Services/Olive.Services.CSV/LumenWorks.Framework/ParseErrorEventArgs.cs b/Services/Olive.Services.CSV/LumenWorks.Framework/ParseErrorEventArgs.cs new file mode 100644 index 000000000..3f322b4f6 --- /dev/null +++ b/Services/Olive.Services.CSV/LumenWorks.Framework/ParseErrorEventArgs.cs @@ -0,0 +1,51 @@ +using System; + +namespace LumenWorks.Framework.IO.Csv +{ + /// + /// Provides data for the event. + /// + internal class ParseErrorEventArgs + : EventArgs + { + #region Fields + + /// + /// Contains the error that occured. + /// + MalformedCsvException _error; + + #endregion + + #region Constructors + + /// + /// Initializes a new instance of the ParseErrorEventArgs class. + /// + /// The error that occured. + /// The default action to take. + public ParseErrorEventArgs(MalformedCsvException error, ParseErrorAction defaultAction) + { + _error = error; + Action = defaultAction; + } + + #endregion + + #region Properties + + /// + /// Gets the error that occured. + /// + /// The error that occured. + public MalformedCsvException Error => _error; + + /// + /// Gets or sets the action to take. + /// + /// The action to take. + public ParseErrorAction Action { get; set; } + + #endregion + } +} \ No newline at end of file diff --git a/Services/Olive.Services.CSV/Olive.Services.CSV.csproj b/Services/Olive.Services.CSV/Olive.Services.CSV.csproj new file mode 100644 index 000000000..2e8cfe09c --- /dev/null +++ b/Services/Olive.Services.CSV/Olive.Services.CSV.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp2.0 + Olive.Services.CSV + Olive.Services.CSV + + + + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.CSV.xml + 1701;1702;1705;1591;1573 + + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.CSV/Package.nuspec b/Services/Olive.Services.CSV/Package.nuspec new file mode 100644 index 000000000..0f5165f6c --- /dev/null +++ b/Services/Olive.Services.CSV/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.CSV + 1.0.3 + Olive CSV (Service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Compression/Olive.Services.Compression.csproj b/Services/Olive.Services.Compression/Olive.Services.Compression.csproj new file mode 100644 index 000000000..096048337 --- /dev/null +++ b/Services/Olive.Services.Compression/Olive.Services.Compression.csproj @@ -0,0 +1,19 @@ + + + + netcoreapp2.0 + Olive.Services.Compression + Olive.Services.Compression + + + + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.Compression.xml + 1701;1702;1705;1591;1573 + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Compression/Package.nuspec b/Services/Olive.Services.Compression/Package.nuspec new file mode 100644 index 000000000..0f42afd92 --- /dev/null +++ b/Services/Olive.Services.Compression/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.Compression + 1.0.3 + Olive Compression (Service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Compression/SevenZip.cs b/Services/Olive.Services.Compression/SevenZip.cs new file mode 100644 index 000000000..c807e6fb8 --- /dev/null +++ b/Services/Olive.Services.Compression/SevenZip.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace Olive.Services.Compression +{ + public class SevenZip + { + public static string SEVEN_ZIP_EXE_FILE_PATH; // C:\Program Files\7-Zip\7z.exe + + static SevenZip() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + SEVEN_ZIP_EXE_FILE_PATH = GetSevenZipFileAddress(); + else + SEVEN_ZIP_EXE_FILE_PATH = "7z"; + } + + public enum CompressionMode + { + Fastest, + Fast, + Normal, + HighCompression, + MaximumCompression + } + + [EscapeGCop("This paths will not change.")] + static string GetSevenZipFileAddress() + { + var programFilesOptions = new List + { + @"C:\Program Files\", + @"C:\Program Files(x86)\", + @"D:\Program Files\", + @"D:\Program Files(x86)\", + @"E:\Program Files\", + @"E:\Program Files(x86)\", + @"F:\Program Files\", + @"F:\Program Files(x86)\" + }; + + try { programFilesOptions.Add(Environment.GetEnvironmentVariable("ProgramFiles(x86)")); } + catch { /* No Logging Needed */ } + + try { programFilesOptions.Add(Environment.GetEnvironmentVariable("ProgramFiles")); } + catch { /* No Logging Needed */ } + + foreach (var item in programFilesOptions.Distinct()) + { + try + { + var file = Path.Combine(item, "7-Zip\\7z.exe"); + if (File.Exists(file)) + return file; + } + catch { } + } + + throw new Exception("7Zip was not found in any of: \r\n" + programFilesOptions.ToLinesString()); + } + + static string GetCompressionModeSwitch(CompressionMode mode) + { + switch (mode) + { + case CompressionMode.Fastest: return "-mx1"; + case CompressionMode.Fast: return "-mx3"; + case CompressionMode.Normal: return "-mx5"; + case CompressionMode.HighCompression: return "-mx7"; + case CompressionMode.MaximumCompression: return "-mx9"; + default: throw new NotSupportedException(mode + " is not supported in GetCompressionModeSwitch()"); + } + } + + /// + /// Compresses the specified folders into a 7 Zip archive folder. + /// + public static Process Compress(string zipFileName, params string[] foldersToCompress) => + Compress(zipFileName, null, foldersToCompress); + + /// + /// Compresses the specified folders into a 7 Zip archive folder. + /// + /// The maximum size of each splitted size in Kilo Bytes + public static Process Compress(string zipFileName, int? splitSize, params string[] foldersToCompress) => + Compress(zipFileName, splitSize, CompressionMode.Normal, foldersToCompress); + + /// + /// Compresses the specified folders into a 7 Zip archive folder. + /// + /// The maximum size of each splitted size in Kilo Bytes + public static Process Compress(string zipFileName, int? splitSize, CompressionMode compressionMode, params string[] foldersToCompress) => + Compress(zipFileName, splitSize, compressionMode, null, foldersToCompress, new string[0]); + + /// + /// Compresses the specified source files into a temp 7Zip file and returns the temp 7Zip file. + /// + public static async Task Compress(IEnumerable sourceFiles, CompressionMode compressionMode = CompressionMode.Normal, string customParameters = null) + { + var result = Path.GetTempPath().AsDirectory().GetFile("Temp7Zip-" + Guid.NewGuid() + ".7z"); + await Compress(result, sourceFiles, compressionMode, customParameters); + + return result; + } + + /// + /// Compresses the specified source files into a 7Zip file and returns the data of the 7Zip file. The temp file is deleted. + /// + public static async Task CompressToBytes(IEnumerable sourceFiles, CompressionMode compressionMode = CompressionMode.Normal, string customParameters = null) + { + var tempFolder = Path.GetTempPath().AsDirectory().GetOrCreateSubDirectory("Temp7Zip-" + Guid.NewGuid()); + var tempFile = tempFolder.GetFile("Files.7z"); + + try + { + await Compress(tempFile, sourceFiles, compressionMode, customParameters); + return await tempFile.ReadAllBytes(); + } + finally + { + await tempFolder?.Delete(recursive: true, harshly: true); + } + } + + /// + /// Creates a 7Zip file from the specified files. + /// + public static async Task Compress(FileInfo destinationPath, IEnumerable sourceFiles, CompressionMode compressionMode = CompressionMode.Normal, string customParameters = null) + { + var files = sourceFiles.ToArray(); + if (files.Distinct(x => x.Name.ToLower()).Count() != files.Count()) throw new Exception("File names must be unique."); + + var tempFolder = Path.GetTempPath().AsDirectory().GetOrCreateSubDirectory("7ZipTemp." + Guid.NewGuid()); + var filesFolder = tempFolder.GetOrCreateSubDirectory(Path.GetFileNameWithoutExtension(destinationPath.FullName)); + try + { + await Task.WhenAll(files.Select(f => f.CopyTo(filesFolder))); + + Compress(destinationPath.FullName, default(int?), compressionMode, customParameters, new[] { filesFolder.FullName }).WaitForExit(); + } + finally + { + await tempFolder.Delete(recursive: true, harshly: true); + } + } + + /// + /// Compresses the specified folders into a 7 Zip archive folder. + /// + /// Use wildcards. Example: *\Folder\Sub-folder\* + public static Process Compress(string zipFileName, int? splitSize, CompressionMode compressionMode, string customParameters, string[] foldersToCompress, string[] excludedFilePatterns = null) + { + if (zipFileName.IsEmpty()) + throw new ArgumentNullException(nameof(zipFileName)); + + if (foldersToCompress == null || foldersToCompress.Length == 0) + throw new ArgumentNullException(nameof(foldersToCompress)); + + if (splitSize < 1) + throw new ArgumentException("The minimum possible split size for 7 Zipper is 1KB."); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !File.Exists(SEVEN_ZIP_EXE_FILE_PATH)) + throw new Exception("7 Zip is not installed. Could not find the file at " + SEVEN_ZIP_EXE_FILE_PATH); + + var command = new ProcessStartInfo(SEVEN_ZIP_EXE_FILE_PATH, "a " + GetCompressionModeSwitch(compressionMode)); + + if (splitSize.HasValue) + command.Arguments += " -v\"" + splitSize + "k\" "; + + if (customParameters.HasValue()) + command.Arguments += " " + customParameters + " "; + + if (excludedFilePatterns != null && excludedFilePatterns.Any()) + foreach (var ex in excludedFilePatterns) + command.Arguments += " -xr!\"" + ex + "\" "; + + // Add destination folder: + command.Arguments += " \"" + zipFileName + "\" "; + + // Add source folders: + command.Arguments += foldersToCompress.Select(pt => " \"" + pt + "\"").ToString(" "); + + return Process.Start(command); + } + } +} diff --git a/Services/Olive.Services.Drawing/BitmapHelper.cs b/Services/Olive.Services.Drawing/BitmapHelper.cs new file mode 100644 index 000000000..60c1853e5 --- /dev/null +++ b/Services/Olive.Services.Drawing/BitmapHelper.cs @@ -0,0 +1,133 @@ +// using System; +// using System.Drawing; +// using System.Drawing.Imaging; +// using System.IO_Dummy; +// using System.Linq; + +// namespace Olive.Services.Drawing +// { +// public static class BitmapHelper +// { +// static EncoderParameters GenerateEncoderParameters(int quality) => +// new EncoderParameters(1) { Param = { [0] = new EncoderParameter(Encoder.Quality, quality) } }; + +// static ImageCodecInfo GenerateCodecInfo(ImageFormat format) +// { +// var allCodecs = ImageCodecInfo.GetImageEncoders(); +// return allCodecs.FirstOrDefault(a => a.FormatID == format.Guid) ?? allCodecs.ElementAt(1); // Defauilt JPEG +// } + +// public static byte[] ToBuffer(this Image image) +// { +// if (image.RawFormat.Guid == ImageFormat.MemoryBmp.Guid) +// throw new ArgumentException("For a MemoryBMP the actual image format must be specified. Use the other overload of the ToBuffer method."); + +// return image.ToBuffer(image.RawFormat); +// } + +// /// +// /// Gets the binary data of this image. +// /// +// public static byte[] ToBuffer(this Image image, ImageFormat format, int quality = 100) +// { +// using (var stream = new MemoryStream()) +// { +// image.Save(stream, GenerateCodecInfo(format), GenerateEncoderParameters(quality)); +// return stream.ToArray(); +// } +// } + +// /// +// /// Converts the specified binary data to a bitmap. +// /// +// public static Image FromBuffer(byte[] buffer) +// { +// using (var stream = new MemoryStream(buffer)) +// { +// return Bitmap.FromStream(stream); +// } +// } + +// /// +// /// Determines whether the specified binary data is for a valid image. +// /// +// public static bool IsValidImage(byte[] buffer) +// { +// try +// { +// return FromBuffer(buffer) != null; +// } +// catch +// { +// return false; +// } +// } + +// /// +// /// Returns a resized version of this image. +// /// +// public static Image Resize(this Image source, int newWidth, int newHeight) +// { +// var result = new Bitmap(newWidth, newHeight); + +// using (var grraphics = Graphics.FromImage(result)) +// { +// grraphics.DrawImage(source, 0, 0, newWidth, newHeight); +// return result; +// } +// } + +// /// +// /// Brightens or darkens this image to the specified level. Level should be between 0 and 255. +// /// 0 Means totally dark and 255 means totally bright. +// /// +// public static Image Brighten(this Image source, int level) +// { +// if (level < 0 || level > 255) +// throw new ArgumentException("Level must be between 0 and 255."); + +// var result = new Bitmap(source); + +// using (var gr = Graphics.FromImage(result)) +// { +// using (var brush = new SolidBrush(Color.FromArgb(level, Color.White))) +// { +// gr.FillRectangle(brush, new Rectangle(Point.Empty, source.Size)); +// return result; +// } +// } +// } + +// /// +// /// Creates a graphics object for this image. +// /// +// public static Graphics CreateGraphics(this Image image) +// { +// var result = Graphics.FromImage(image); + +// result.PageUnit = GraphicsUnit.Pixel; +// result.RenderingOrigin = Point.Empty; +// result.PageScale = 1f; +// result.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; +// result.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + +// return result; +// } + +// /// +// /// Crops this image with the specified rectangle. +// /// +// public static Image Crop(this Image image, Rectangle rectangle) +// { +// //var result = new Bitmap(image, rectangle.Size); +// var result = new Bitmap(rectangle.Width, rectangle.Height); + +// using (var gr = result.CreateGraphics()) +// { +// gr.DrawImage(image, new Rectangle(Point.Empty, result.Size), rectangle, GraphicsUnit.Pixel); +// } + +// return result; +// } +// } +// } diff --git a/Services/Olive.Services.Drawing/Extensions.cs b/Services/Olive.Services.Drawing/Extensions.cs new file mode 100644 index 000000000..bf73bc5bf --- /dev/null +++ b/Services/Olive.Services.Drawing/Extensions.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using Olive.Entities; + +namespace Olive.Services.Drawing +{ + public static class Extensions + { + const int IMAGE_DEFAULT_QUALITY = 70; + + /// + /// Optimizes the image based on the settings in the arguments. + /// + public static Task OptimizeImage(this Blob blob, int maxWidth, int maxHeight) => + blob.OptimizeImage(maxWidth, maxHeight, IMAGE_DEFAULT_QUALITY); + + /// + /// Optimizes the image based on the settings in the arguments. + /// + public static async Task OptimizeImage(this Blob blob, int maxWidth, int maxHeight, int quality, bool toJpeg = true) + { + if ((await blob.GetFileData()).Length > 10) + { + var optimizer = new ImageOptimizer(maxWidth, maxHeight, quality); + blob.SetData(optimizer.Optimize(await blob.GetFileData(), toJpeg)); + } + } + } +} diff --git a/Services/Olive.Services.Drawing/GifPalleteGenerator.cs b/Services/Olive.Services.Drawing/GifPalleteGenerator.cs new file mode 100644 index 000000000..e3268558c --- /dev/null +++ b/Services/Olive.Services.Drawing/GifPalleteGenerator.cs @@ -0,0 +1,74 @@ +// namespace Olive.Services.Drawing +// { +// using System; +// using System.Collections.Generic; +// using System.Drawing; +// using System.Drawing.Imaging; +// using System.Linq; + +// /// +// /// Generates a color pallete for a GIF image. +// /// +// public static class GifPalleteGenerator +// { +// const int STANDARD_COLOR_PALLETTE_SIZE = 256; + +// static readonly Color TRANSPARENT = Color.FromArgb(0, 0, 0, 0); + +// /// +// /// Generates a color pallete based on the colors used in a specified image. +// /// +// public static ColorPalette GeneratePallete(Bitmap image) +// { +// if (image == null) +// throw new ArgumentNullException("image"); + +// var result = CreateEmptyPallette(); + +// var usedColours = FindAllColours(image); + +// result.Entries[0] = TRANSPARENT; + +// for (int i = 1; i < STANDARD_COLOR_PALLETTE_SIZE; i++) +// if (usedColours.Count >= i) +// result.Entries[i] = usedColours[i - 1].Key; +// else result.Entries[i] = Color.White; + +// return result; +// } + +// static ColorPalette CreateEmptyPallette() +// { +// using (var temp = new Bitmap(1, 1, PixelFormat.Format8bppIndexed)) +// { +// return temp.Palette; +// } +// } + +// /// +// /// Finds all colours used in the specified image. +// /// The result will be the list of colours sorted by then umber of times that is used. +// /// +// static List> FindAllColours(Bitmap image) +// { +// var colors = new Dictionary(); + +// for (int x = 0; x < image.Width; x++) +// for (int y = 0; y < image.Height; y++) +// { +// var pixel = image.GetPixel(x, y); + +// if (colors.ContainsKey(pixel)) +// colors[pixel]++; +// else +// colors.Add(pixel, 1); +// } + +// var result = from item in colors +// orderby item.Value descending +// select new KeyValuePair(item.Key, item.Value); + +// return result.ToList(); +// } +// } +// } \ No newline at end of file diff --git a/Services/Olive.Services.Drawing/GifProcessor.cs b/Services/Olive.Services.Drawing/GifProcessor.cs new file mode 100644 index 000000000..619ede314 --- /dev/null +++ b/Services/Olive.Services.Drawing/GifProcessor.cs @@ -0,0 +1,111 @@ +// namespace Olive.Services.Drawing +// { +// using System; +// using System.Drawing; +// using System.Drawing.Imaging; +// using System.Linq; + +// class GifProcessor +// { +// static object SyncLock = new object(); + +// Bitmap Source; +// ColorPalette CorrectPallete; + +// public GifProcessor(Bitmap sourceImage) +// { +// Source = sourceImage ?? throw new ArgumentNullException("sourceImage"); +// CorrectPallete = GifPalleteGenerator.GeneratePallete(sourceImage); +// } + +// Bitmap CreateBitmapWithIndexedColors(Bitmap source) +// { +// var width = source.Width; +// var height = source.Height; + +// var result = new Bitmap(width, height, PixelFormat.Format8bppIndexed) +// { +// Palette = CorrectPallete +// }; + +// var bitmapData = result.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format8bppIndexed); + +// var pixels = bitmapData.Scan0; + +// unsafe +// { +// var pointer = (byte*)pixels.ToPointer(); +// if (bitmapData.Stride <= 0) +// pointer += bitmapData.Stride * (height - 1); + +// var stride = (uint)Math.Abs(bitmapData.Stride); + +// for (uint x = 0; x < width; ++x) +// { +// for (uint y = 0; y < height; ++y) +// { +// var pixel = source.GetPixel((int)x, (int)y); + +// byte* newPointer = x + pointer + y * stride; + +// // Set that byte to the color in the new pallet +// *newPointer = FindPalleteEntryIndex(pixel); +// } +// } +// } + +// // Unlock the relevant area of the bitmap so the changes can be committed. +// result.UnlockBits(bitmapData); + +// return result; +// } + +// static Bitmap CreateCopy(Bitmap source) => +// source.Clone(new Rectangle(0, 0, source.Width, source.Height), PixelFormat.Format32bppArgb); + +// public void Save(string filename, bool transparent) +// { +// var width = Source.Width; +// var height = Source.Height; + +// lock (SyncLock) +// { +// using (var copy = CreateCopy(Source)) +// { +// var toSave = CreateBitmapWithIndexedColors(copy); + +// toSave.Save(filename, System.Drawing.Imaging.ImageFormat.Gif); +// } +// } +// } + +// /// +// /// Finds the index of the relevant entry in the new pallete to the specified color. +// /// +// byte FindPalleteEntryIndex(Color color) +// { +// if (color.A == 0) +// // First entry in the pallete is "Transparent" +// return 0; + +// // If there is an exact match in the entries for this color, then obviously that's our entry: +// for (byte index = 0; index < byte.MaxValue; index++) +// if (CorrectPallete.Entries[index] == color) return index; + +// // Otherwise, find the nearest color: +// return CorrectPallete.Entries.Select((c, i) => new { Index = (byte)i, Color = c }).WithMin(entry => GetDifference(color, entry.Color)).Index; +// } + +// /// +// /// Gets the difference between the 2 specified colors. +// /// +// static int GetDifference(Color c1, Color c2) +// { +// return +// Math.Abs(c1.A - c2.A) + +// Math.Abs(c1.R - c2.R) + +// Math.Abs(c1.G - c2.G) + +// Math.Abs(c1.B - c2.B); +// } +// } +// } \ No newline at end of file diff --git a/Services/Olive.Services.Drawing/GraphicExtensions.cs b/Services/Olive.Services.Drawing/GraphicExtensions.cs new file mode 100644 index 000000000..328b5195d --- /dev/null +++ b/Services/Olive.Services.Drawing/GraphicExtensions.cs @@ -0,0 +1,184 @@ +// using System; +// using System.Drawing; +// using System.Drawing.Imaging; +// using System.Drawing.Text; + +// namespace Olive.Services.Drawing +// { +// public static class GraphicExtensions +// { +// const double HALF_PI = Math.PI / 2.00; +// const double RADIANS = Math.PI / 180.0; + +// /// +// /// Gets an image which is a column of this image at the specified index. +// /// +// public static Bitmap GetColumn(this Bitmap image, int columnIndex) +// { +// var height = image.Height; + +// var result = new Bitmap(1, height, PixelFormat.Format32bppArgb); + +// for (int i = 0; i < height; i++) +// result.SetPixel(0, i, image.GetPixel(columnIndex, i)); + +// return result; +// } + +// /// +// /// Gets the width of a specified text in this font. +// /// +// public static int GetWidth(this Font font, string text, bool useAntialias) +// { +// using (var tempImage = new Bitmap(1, 1)) +// { +// using (var tempGraphics = Graphics.FromImage(tempImage)) +// { +// if (useAntialias) +// tempGraphics.TextRenderingHint = TextRenderingHint.AntiAlias; + +// var result = (int)(tempGraphics.MeasureString(text, font).Width); + +// // Measure string trims the text by default. God knows why: +// var spaces = text.Length - text.TrimEnd(' ').Length; + +// if (spaces > 0) +// { +// result += spaces * (int)(tempGraphics.MeasureString(" ", font).Width); +// } + +// return result; +// } +// } +// } + +// /// +// /// Inserts the specified image at the specified column inside this host image. +// /// +// public static Bitmap Insert(this Bitmap host, int columnIndex, Bitmap image) +// { +// if (image == null) +// throw new ArgumentNullException("image"); + +// if (image.Height != host.Height) +// throw new ArgumentException("The height of the specified image is different from the host image."); + +// var height = host.Height; +// var width = host.Width + image.Width; +// var right = columnIndex + image.Width; +// var result = new Bitmap(width, height); + +// Color pixel; + +// for (int y = 0; y < height; y++) +// { +// for (int x = 0; x < width; x++) +// { +// if (x < columnIndex) +// // Left section: +// pixel = host.GetPixel(x, y); + +// else if (x < right) +// // Middle section (inserting image) +// pixel = image.GetPixel(x - columnIndex, y); + +// else +// // Right section +// pixel = host.GetPixel(x - image.Width, y); + +// if (pixel.A == 0) +// // Transparent +// result.SetPixel(x, y, Color.Transparent); +// else +// result.SetPixel(x, y, pixel); +// } +// } + +// return result; +// } + +// /// +// /// Creates a rotated version from this image. +// /// +// /// The number of degrees to rotate this image. Direction of rotation will be clock-wise. +// public static Bitmap Rotate(this Image image, double degrees) +// { +// if (image == null) +// throw new ArgumentNullException("image"); + +// // Use radians: +// degrees *= RADIANS; + +// var originalWidth = (double)image.Width; +// var originalHeight = (double)image.Height; + +// double newWidth, newHeight, topLeft, topRight, bottomLeft, bottomRight; +// int resultWidth, resultHeight; + +// if ((degrees >= 0.0 && degrees < HALF_PI) || (degrees < (Math.PI + HALF_PI) && degrees >= Math.PI)) +// { +// topLeft = originalWidth * Math.Abs(Math.Cos(degrees)); +// topRight = originalWidth * Math.Abs(Math.Sin(degrees)); + +// bottomLeft = originalHeight * Math.Abs(Math.Cos(degrees)); +// bottomRight = originalHeight * Math.Abs(Math.Sin(degrees)); +// } +// else +// { +// topLeft = originalHeight * Math.Abs(Math.Sin(degrees)); +// topRight = originalHeight * Math.Abs(Math.Cos(degrees)); + +// bottomLeft = originalWidth * Math.Abs(Math.Sin(degrees)); +// bottomRight = originalWidth * Math.Abs(Math.Cos(degrees)); +// } + +// newWidth = bottomRight + topLeft; +// newHeight = topRight + bottomLeft; + +// resultWidth = (int)Math.Ceiling(newWidth); +// resultHeight = (int)Math.Ceiling(newHeight); + +// var result = new Bitmap(resultWidth, resultHeight); + +// using (var graph = Graphics.FromImage(result)) +// { +// Point[] points; + +// if (degrees >= 0.0 && degrees < HALF_PI) +// points = new[] { new Point((int)bottomRight, 0), new Point(resultWidth, (int)topRight), new Point(0, (int)bottomLeft) }; +// else if (degrees < Math.PI && degrees >= HALF_PI) +// points = new[] { new Point(resultWidth, (int)topRight), new Point((int)topLeft, resultHeight), new Point((int)bottomRight, 0) }; +// else if (degrees < (Math.PI + HALF_PI) && degrees >= Math.PI) +// points = new[] { new Point((int)topLeft, resultHeight), new Point(0, (int)bottomLeft), new Point(resultWidth, (int)topRight) }; +// else +// points = new[] { new Point(0, (int)bottomLeft), new Point((int)bottomRight, 0), new Point((int)topLeft, resultHeight) }; + +// graph.DrawImage(image, points); +// } + +// return result; +// } + +// /// +// /// Stretches the specified image. +// /// +// public static Bitmap Stretch(this Bitmap image, int width) +// { +// if (image == null || image.Width != 1) +// throw new Exception("Bitmap.Stretch() should be called on an image with one column only."); + +// var result = new Bitmap(width, image.Height); +// for (int column = 0; column < image.Height; column++) +// for (int row = 0; row < width; row++) +// result.SetPixel(row, column, image.GetPixel(0, column)); + +// return result; +// } + +// public static void SaveAsGif(this Bitmap image, string path, bool transparent) +// { +// var processor = new GifProcessor(image); +// processor.Save(path, transparent); +// } +// } +// } \ No newline at end of file diff --git a/Services/Olive.Services.Drawing/ImageOptimizer.cs b/Services/Olive.Services.Drawing/ImageOptimizer.cs new file mode 100644 index 000000000..e84b34025 --- /dev/null +++ b/Services/Olive.Services.Drawing/ImageOptimizer.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using SkiaSharp; + +//using System.Drawing.Imaging; + +namespace Olive.Services.Drawing +{ + /// + /// A utility to resize and optimise image files. + /// + public class ImageOptimizer + { + const int DEFAULT_MAX_WIDTH = 900, DEFAULT_MAX_HEIGHT = 700, DEFAULT_QUALITY = 80; + /// + /// Creates a new instance of ImageOptimizer class with default settings. + /// + public ImageOptimizer() : this(DEFAULT_MAX_WIDTH, DEFAULT_MAX_HEIGHT, DEFAULT_QUALITY) { } + + /// + /// Creates a new instance of ImageOptimizer class. + /// + public ImageOptimizer(int maxWidth, int maxHeight, int quality) + { + MaximumWidth = maxWidth; + MaximumHeight = maxHeight; + Quality = quality; + OutputFormat = ImageFormat.Jpeg; + } + + public int MaximumWidth { get; set; } + public int MaximumHeight { get; set; } + public int Quality { get; set; } + public ImageFormat OutputFormat { get; set; } + + /// + /// Gets the available output image formats. + /// + public enum ImageFormat { Bmp = 0, Jpeg = 1, Gif = 2, Png = 4 } + + /// + /// Applies the settings of this instance on a specified source image, and provides an output optimized/resized image. + /// + public SKBitmap Optimize(SKBitmap source) + { + // Calculate the suitable width and heigth for the output image: + var width = source.Width; + var height = source.Height; + + if (width > MaximumWidth) + { + height = (int)(height * (1.0 * MaximumWidth) / width); + width = MaximumWidth; + } + + if (height > MaximumHeight) + { + width = (int)(width * (1.0 * MaximumHeight) / height); + height = MaximumHeight; + } + + if (width == source.Width && height == source.Height) + return source; + + var result = new SKBitmap(width, height); + + SKBitmap.Resize(result, source, SKBitmapResizeMethod.Lanczos3); + + return result; + } + + /// + /// Optimizes the specified source image and returns the binary data of the output image. + /// + public byte[] Optimize(byte[] sourceData, bool toJpeg = true) + { + try + { + using (var source = SKBitmap.Decode(sourceData)) + { + using (var resultBitmap = Optimize(source)) + { + using (SKImage image = SKImage.FromBitmap(resultBitmap)) + { + return image.Encode(toJpeg ? SKEncodedImageFormat.Jpeg : SKEncodedImageFormat.Png, Quality).ToArray(); + } + } + } + } + catch + { + // No Logging Needed + return sourceData; + } + } + + /// + /// Applies optimization settings on a a source image file on the disk and saves the output to another file with the specified path. + /// + public async Task Optimize(string souceImagePath, string optimizedImagePath) + { + if (!File.Exists(souceImagePath)) + throw new Exception("Could not find the file: " + souceImagePath); + + SKBitmap source; + + try + { + source = SKBitmap.Decode(souceImagePath); + } + catch (Exception ex) + { + throw new Exception("Could not obtain bitmap data from the file: {0}.".FormatWith(souceImagePath), ex); + } + + using (source) + { + using (var optimizedImage = SKImage.FromBitmap(Optimize(source))) + { + await File.WriteAllBytesAsync(optimizedImagePath, optimizedImage.Encode(SKEncodedImageFormat.Jpeg, Quality).ToArray()); + } + } + } + + /// + /// Applies optimization settings on a source image file. + /// Please note that the original file data is lost (overwritten) in this overload. + /// + public Task Optimize(string imagePath) => Optimize(imagePath, imagePath); + + // EncoderParameters GenerateEncoderParameters() + // { + // var result = new EncoderParameters(1); + // result.Param[0] = new EncoderParameter(Encoder.Quality, Quality); + // return result; + // } + + // ImageCodecInfo GenerateCodecInfo() => ImageCodecInfo.GetImageEncoders()[(int)OutputFormat]; + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Drawing/Olive.Services.Drawing.csproj b/Services/Olive.Services.Drawing/Olive.Services.Drawing.csproj new file mode 100644 index 000000000..38441db94 --- /dev/null +++ b/Services/Olive.Services.Drawing/Olive.Services.Drawing.csproj @@ -0,0 +1,25 @@ + + + + netcoreapp2.0 + Olive.Services.Drawing + Olive.Services.Drawing + + + + true + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.Drawing.xml + 1701;1702;1705;1591;1573 + + + + + + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Drawing/Package.nuspec b/Services/Olive.Services.Drawing/Package.nuspec new file mode 100644 index 000000000..7ca0bd3d7 --- /dev/null +++ b/Services/Olive.Services.Drawing/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.Drawing + 1.0.4 + Olive Drawing (Service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.DynamicExpressions/Compiler.cs b/Services/Olive.Services.DynamicExpressions/Compiler.cs new file mode 100644 index 000000000..97682b355 --- /dev/null +++ b/Services/Olive.Services.DynamicExpressions/Compiler.cs @@ -0,0 +1,165 @@ +using System; +using System.CodeDom.Compiler; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using Microsoft.CSharp; + +namespace Olive.Framework.Services +{ + internal class Compiler + { + List References = new List(); + + /// + /// Creates a new Compiler instance. + /// + public Compiler() + { + Reference(); + Reference(); + Reference(); + } + + internal Type CompileClass(string classCode) + { + #region Parameters + var parameters = new CompilerParameters + { + GenerateExecutable = false, + IncludeDebugInformation = true, + GenerateInMemory = true + //CompilerOptions = "/optimize" + /*TreatWarningsAsErrors = false, WarningLevel = 3*/ + }; + + // Add references: + foreach (var dll in References.Distinct().ToArray()) + { + parameters.ReferencedAssemblies.Add(dll.Location); + } + #endregion + + #region Compiler Options + var options = new Dictionary(); + //options.Add("CompilerVersion", "v3.5"); + options.Add("CompilerVersion", "v4.0"); + #endregion + + using (var codeProvider = new CSharpCodeProvider(options)) + { + var compileResult = codeProvider.CompileAssemblyFromSource(parameters, new[] { classCode }); + + EvaluateResult(compileResult); + + var types = compileResult.CompiledAssembly.GetTypes(); + if (types.None()) + throw new Exception("The dynamic type for the following class has no type, also no error messages were produced by the compiler:\r\n" + + classCode); + + + return types.Single(t => t.Name == "Class"); + } + } + + public void Reference(bool ignoreFailedAssemblies = false) + { + Reference(typeof(T), ignoreFailedAssemblies); + } + + public void Reference(Type type, bool ignoreFailedAssemblies = false) + { + Reference(type.Assembly, ignoreFailedAssemblies); + } + + static ConcurrentDictionary _AssemblyFullNames = new ConcurrentDictionary(); + static string GetAssemblyFullName(Assembly assembly) + { + return _AssemblyFullNames.GetOrAdd(assembly, a => a.GetName().FullName); + } + + bool IsReferenceAdded(Assembly assembly) + { + return References.Any(r => GetAssemblyFullName(r) == GetAssemblyFullName(assembly)); + } + + public void Reference(Assembly assembly, bool ignoreFailedAssemblies = false) + { + if (IsReferenceAdded(assembly)) return; + + References.Add(assembly); + + var currentlyLoaded = AppDomain.CurrentDomain.GetAssemblies(); + + foreach (var name in assembly.GetReferencedAssemblies()) + { + try + { + if (References.Any(re => GetAssemblyFullName(re) == name.FullName)) continue; + + var matched = currentlyLoaded.Where(x => GetAssemblyFullName(x) == name.FullName).ToArray(); + + var toRef = matched.LastOrDefault(); + + //Assembly.ReflectionOnlyLoad( + //var r = Assembly.Load(name); + if (toRef == null) continue; + + Reference(toRef); + } + catch + { + if (!ignoreFailedAssemblies) + throw; + // Ignore this assembly. + } + } + } + + private void EvaluateResult(CompilerResults result) + { + if (result.Errors.Count == 0) return; + + var errors = new List(); + var messages = new List(); + + foreach (var error in result.Errors.Cast().Where(e => !e.IsWarning)) + { + if (messages.Contains(error.ErrorText)) continue; + else messages.Add(error.ErrorText); + + errors.AddFormat("{0} ({1}:{2})", error.ErrorText, error.FileName, error.Line); + } + + if (messages.Any()) + { + throw new Exception("I cannot compile the dynamic assembly ", new Exception(errors.ToLinesString())); + } + } + + internal Type CompileMethods(string methods) + { + var r = new StringBuilder(); + + r.AppendLine("using System;"); + r.AppendLine("using System.Collections;"); + r.AppendLine("using System.Linq;"); + r.AppendLine("using System.Collections.Generic;"); + r.AppendLine("using System.Text;"); + r.AppendLine(""); + r.AppendLine(""); + r.AppendLine("using App;"); + r.AppendLine(); + + r.AppendLine("public static class Evaluator"); + r.AppendLine("{"); + + r.AppendLine(methods); + r.AppendLine("}"); + + return CompileClass(r.ToString()); + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.DynamicExpressions/DynamicExpressionsCompiler.cs b/Services/Olive.Services.DynamicExpressions/DynamicExpressionsCompiler.cs new file mode 100644 index 000000000..3284d1449 --- /dev/null +++ b/Services/Olive.Services.DynamicExpressions/DynamicExpressionsCompiler.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace Olive.Framework.Services +{ + class DynamicExpressionsCompiler + { + Type ListType; + IEnumerable List; + + static Dictionary WhereCache = new Dictionary(); + + static Dictionary> SelectCache = new Dictionary>(); + + /// + /// Creates a new DynamicExpressionsCompiler instance. + /// + public DynamicExpressionsCompiler(IEnumerable list) + : this(list, typeof(T)) + { + } + + /// + /// Creates a new DynamicExpressionsCompiler instance. + /// + public DynamicExpressionsCompiler(IEnumerable list, Type listType) + { + List = list; + ListType = listType; + } + + string FullTypeName + { + get + { + return ListType.FullName; + } + } + + internal IEnumerable Where(string criteria) + { + var key = ListType.FullName + "|" + criteria; + + lock (WhereCache) + { + if (!WhereCache.ContainsKey(key)) + { + var method = CompileMethod(criteria, "each"); + WhereCache.Add(key, method); + } + } + + var arg = List.Cast(ListType); + + var result = WhereCache[key].Invoke(null, new object[] { arg }); + + return (result as IEnumerable).Cast(); + } + + internal IEnumerable Select(string query) + { + Dictionary pool; + + lock (SelectCache) + { + if (SelectCache.ContainsKey(typeof(K))) + pool = SelectCache[typeof(K)]; + else + { + pool = new Dictionary(); + SelectCache.Add(typeof(K), pool); + } + } + + lock (pool) + { + if (!pool.ContainsKey(query)) + { + var method = CompileMethod(condition: null, expression: query); + pool.Add(query, method); + } + } + + if (List.None()) + { + return new List(); + } + + //var actualT = List.First().GetType(); // pool[query].GetParameters().First().ParameterType.GetGenericArguments().First(); + //var listTType = typeof(System.Collections.Generic.List<>).MakeGenericType(actualT); + //var args = listTType.CreateInstance() as IList; + ////var args = new List(); + //foreach(var item in List) + //{ + // args.Add(item); + //} + ////var actualT = pool[query].GetParameters().First().ParameterType.GetGenericArguments().First() + + + var arg = List.Cast(ListType); + + var result = pool[query].Invoke(null, new object[] { arg }); + + return (result as IEnumerable).Cast(); + } + + string CreateCode(string condition, string expression) + { + var r = new StringBuilder(); + + var namespaces = new[] { ListType, typeof(K) }.Select(t => t.Namespace) + .Concat(new[] { "System", "System.Linq", "System.Collections", "System.Collections.Generic" }).Distinct(); + + foreach (var n in namespaces) + r.AddFormattedLine("using {0};", n); + + r.AppendLine("public static class Class"); + r.AppendLine("{"); + + r.AddFormattedLine("public static IEnumerable<{1}> Run(IEnumerable<{0}> list)", FullTypeName, typeof(K).FullName); + r.AppendLine("{"); + + r.AppendLine("foreach (var each in list)"); + r.AppendLine("{"); + + if (condition.HasValue()) + r.AddFormattedLine("if ({0})", condition); + + r.AddFormattedLine("yield return {0};", expression); + + r.AppendLine("}"); + + //r.AddFormattedLine("return list.{0}(each => {1});", method, expression); + r.AppendLine("}"); + + r.AppendLine("}"); // Class + + return r.ToString(); + } + + MethodInfo CompileMethod(string condition, string expression) + { + var code = CreateCode(condition, expression); + + var compiler = new Compiler(); + compiler.Reference(ListType, ignoreFailedAssemblies: true); + + var type = compiler.CompileClass(code); + + return type.GetMethod("Run"); + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.DynamicExpressions/Olive.Services.DynamicExpressions.csproj b/Services/Olive.Services.DynamicExpressions/Olive.Services.DynamicExpressions.csproj new file mode 100644 index 000000000..0aac81745 --- /dev/null +++ b/Services/Olive.Services.DynamicExpressions/Olive.Services.DynamicExpressions.csproj @@ -0,0 +1,9 @@ + + + + netcoreapp2.0 + Olive.Services.DynamicExpressions + Olive.Services.DynamicExpressions + + + \ No newline at end of file diff --git a/Services/Olive.Services.DynamicExpressions/Olive.Services.DynamicExpressions.nuspec b/Services/Olive.Services.DynamicExpressions/Olive.Services.DynamicExpressions.nuspec new file mode 100644 index 000000000..3e63f875e --- /dev/null +++ b/Services/Olive.Services.DynamicExpressions/Olive.Services.DynamicExpressions.nuspec @@ -0,0 +1,13 @@ + + + + Olive + 0.0.1 + Olive + Geeks Ltd + https://github.com/Geeksltd/Olive + + Copyright ©2017 Geeks Ltd - All rights reserved. + + + \ No newline at end of file diff --git a/Services/Olive.Services.Email/Email.Sending/EmailExtensions.cs b/Services/Olive.Services.Email/Email.Sending/EmailExtensions.cs new file mode 100644 index 000000000..f62306fc5 --- /dev/null +++ b/Services/Olive.Services.Email/Email.Sending/EmailExtensions.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Olive.Entities; + +namespace Olive.Services.Email +{ + public static class EmailExtensions + { + /// + /// Gets the mandatory placeholder tokens for this template. + /// + public static IEnumerable GetPlaceholderTokens(this IEmailTemplate template) => + template.MandatoryPlaceholders.Or("").Split(',').Trim().Select(t => $"[#{t.ToUpper()}#]"); + + /// + /// Ensures the mandatory placeholders are all specified in this template. + /// + public static void EnsurePlaceholders(this IEmailTemplate template) + { + // Make sure that all place holders appear in the email body or subject. + var missingElements = template.GetPlaceholderTokens().Except(t => (template.Subject + template.Body).Contains(t)); + if (missingElements.Any()) + throw new ValidationException("Email template subject or body must have all place-holders for {0}. The missing ones are: {1}", template.Key, missingElements.ToString(", ")); + } + + /// + /// Merges the subjcet of this email template with the specified data. + /// + /// The email template + /// An anonymouse object. All property names should correspond to the placeholder names. + /// For example: new {FirstName = GetFirstName() , LastName = "john"} + public static string MergeSubject(this IEmailTemplate template, object mergeData) => Merge(template.Subject, mergeData); + + /// + /// Merges the body of this email template with the specified data. + /// + /// The email template + /// An anonymouse object. All property names should correspond to the placeholder names. + /// For example: new {FirstName = GetFirstName() , LastName = "john"} + public static string MergeBody(this IEmailTemplate template, object mergeData) => Merge(template.Body, mergeData); + + /// + /// Merges the specified template with the provided. + /// + static string Merge(string template, object mergeData) + { + var result = template; + + foreach (var p in mergeData.GetType().GetProperties()) + { + var key = $"[#{p.Name.ToUpper()}#]"; + var value = $"{p.GetValue(mergeData)}"; + + result = result.Replace(key, value); + } + + return result; + } + + /// + /// Attaches a file to this email. + /// + public static void Attach(this IEmailQueueItem mail, Blob file) + { + if (file == null) throw new ArgumentNullException(nameof(file)); + if (file.IsEmpty()) return; + + if (file.LocalPath.IsEmpty()) + throw new ArgumentException("In-memory blob instances cannot be used for email attachment. It should be saved on disk first."); + + var reference = file.GetOwnerPropertyReference(); + if (reference.HasValue()) + { + var json = JsonConvert.SerializeObject(new { PropertyReference = reference }); + + if (mail.Attachments.IsEmpty()) mail.Attachments = json; + else mail.Attachments += "|" + json; + } + else + { + Attach(mail, file.LocalPath); + } + } + + /// + /// Attaches a file to this email. + /// + /// The email queue item. + /// The path of the attachment file. + /// This must be the physical path of a file inside the running application. + public static void Attach(this IEmailQueueItem mail, string filePath) + { + if (filePath.IsEmpty()) throw new ArgumentNullException(nameof(filePath)); + + var basePath = AppDomain.CurrentDomain.BaseDirectory.ToLower(); + + if (filePath.ToLower().StartsWith(basePath)) // Relative: + filePath = filePath.Substring(basePath.Length).TrimStart("\\"); + + if (mail.Attachments.IsEmpty()) mail.Attachments = filePath; + else mail.Attachments += "|" + filePath; + } + + /// + /// Attaches the specified byte array content to this email as an attachment. + /// + public static void Attach(this IEmailQueueItem mail, byte[] fileData, string name, string contentId, bool isLinkedResource = false) + { + var data = new { Contents = fileData.ToBase64String(), Name = name, ContentId = contentId, IsLinkedResource = isLinkedResource }; + var json = JsonConvert.SerializeObject(data); + + if (mail.Attachments.IsEmpty()) mail.Attachments = json; + else mail.Attachments += "|" + json; + } + + /// + /// Will send an email and returns true for successful sending. + /// + public static async Task Send(this IEmailQueueItem mailItem) => await EmailService.Send(mailItem); + + /// + /// Records an unsuccessful attempt to send this email. + /// + public static async Task RecordRetry(this IEmailQueueItem emailItem) + { + if (emailItem.IsNew) throw new InvalidOperationException(); + + var retries = emailItem.Retries + 1; + + if (!emailItem.IsNew) + await Entity.Database.Update(emailItem, e => e.Retries = retries); + + // Also update this local instance: + emailItem.Retries = retries; + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Email/Email.Sending/EmailSendingEventArgs.cs b/Services/Olive.Services.Email/Email.Sending/EmailSendingEventArgs.cs new file mode 100644 index 000000000..55333a70e --- /dev/null +++ b/Services/Olive.Services.Email/Email.Sending/EmailSendingEventArgs.cs @@ -0,0 +1,19 @@ +namespace Olive.Services.Email +{ + using System; + using System.Net.Mail; + + public class EmailSendingEventArgs + { + public MailMessage MailMessage { get; } + public IEmailQueueItem Item { get; } + + public Exception Error { get; internal set; } + + public EmailSendingEventArgs(IEmailQueueItem item, MailMessage message) + { + MailMessage = message; + Item = item; + } + } +} diff --git a/Services/Olive.Services.Email/Email.Sending/EmailService.Attachments.cs b/Services/Olive.Services.Email/Email.Sending/EmailService.Attachments.cs new file mode 100644 index 000000000..3de84c744 --- /dev/null +++ b/Services/Olive.Services.Email/Email.Sending/EmailService.Attachments.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Mail; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Olive.Entities; + +namespace Olive.Services.Email +{ + partial class EmailService + { + /// + /// Gets the Attachment objects to be attached to this email. + /// + public static async Task> GetAttachments(this IEmailQueueItem mail) + { + var result = new List(); + + foreach (var attachmentInfo in mail.Attachments.OrEmpty().Split('|').Trim()) + { + var item = await ParseAttachment(attachmentInfo); + if (item != null) result.Add(item); + } + + return result; + } + + public static async Task ParseAttachment(string attachmentInfo) + { + if (attachmentInfo.StartsWith("{")) + { + return await GetAttachmentFromJSon(attachmentInfo); + } + else + { + if (attachmentInfo.StartsWith("\\\\") || Path.IsPathRooted(attachmentInfo) /*(attachment[1] == ':' && attachment[2] == '\\')*/) + // absolute path: + return new Attachment(attachmentInfo); + else + return new Attachment(AppDomain.CurrentDomain.GetPath(attachmentInfo)); + } + } + + static async Task GetAttachmentFromJSon(string attachmentInfo) + { + var data = JsonConvert.DeserializeObject>(attachmentInfo); + + if (data == null) return null; + + var contents = data.GetOrDefault("Contents") as string; + + if (contents.HasValue()) + { + if (data.GetOrDefault("IsLinkedResource").ToStringOrEmpty().TryParseAs() == true) return null; // No attachment needed? + + var stream = new MemoryStream(Convert.FromBase64String(contents)); + var name = data["Name"] as string; + var contentId = data["ContentId"] as string; + + return new Attachment(stream, name) { ContentId = contentId }; + } + + var reference = data.GetOrDefault("PropertyReference") as string; + if (reference.HasValue()) + { + var blob = Blob.FromReference(reference); + return new Attachment(new MemoryStream(await blob.GetFileData()), blob.FileName); + } + + return null; + } + + /// + /// Gets the Linked Resource objects to be attached to this email. + /// + [EscapeGCop("It would cause error if you dispose the result.")] + public static IEnumerable GetLinkedResources(this IEmailQueueItem mail) + { + if (mail.Attachments.HasValue()) + { + foreach (var resource in mail.Attachments.Trim().Split('|').Where(x => x.StartsWith("{"))) + { + var data = JsonConvert.DeserializeObject>(resource); + + if (data == null) continue; + + var contents = data.GetOrDefault("Contents") as string; + + if (contents.IsEmpty()) continue; + + var isLinkedResource = data.GetOrDefault("IsLinkedResource").ToStringOrEmpty().TryParseAs() ?? false; + + if (!isLinkedResource) continue; + + var stream = new MemoryStream(Convert.FromBase64String(contents)); + var name = data["Name"] as string; + var contentId = data["ContentId"] as string; + + yield return new LinkedResource(stream) + { + ContentId = contentId, + ContentType = new System.Net.Mime.ContentType { Name = name } + }; + } + } + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Email/Email.Sending/EmailService.cs b/Services/Olive.Services.Email/Email.Sending/EmailService.cs new file mode 100644 index 000000000..af2c5f25f --- /dev/null +++ b/Services/Olive.Services.Email/Email.Sending/EmailService.cs @@ -0,0 +1,380 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Mail; +using System.Net.Mime; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Olive.Entities; + +namespace Olive.Services.Email +{ + /// + /// Provides email sending services. + /// + public static partial class EmailService + { + const string ALL_CATEGORIES = "*"; + static Type concreteEmailQueueItemType; + static AsyncLock AsyncLock = new AsyncLock(); + static Random Random = new Random(); + + public static int MaximumRetries => Config.Get("Email:Maximum.Retries", 4); + + /// + /// Specifies a factory to instantiate EmailQueueItem objects. + /// + public static Func EmailQueueItemFactory = CreateEmailQueueItem; + + /// + /// Provides a message which can dispatch an email message. + /// Returns whether the message was sent successfully. + /// + public static Func> EmailDispatcher = SendViaSmtp; + + #region Events + + /// + /// Occurs when the smtp mail message for this email is about to be sent. + /// + public static readonly AsyncEvent Sending = new AsyncEvent(); + + /// + /// Occurs when the smtp mail message for this email is sent. Sender is the IEmailQueueItem instance that was sent. + /// + public static readonly AsyncEvent Sent = new AsyncEvent(); + + /// + /// Occurs when an exception happens when sending an email. Sender parameter will be the IEmailQueueItem instance that couldn't be sent. + /// + public static readonly AsyncEvent SendError = new AsyncEvent(); + + #endregion + + #region Factory + + static IEmailQueueItem CreateEmailQueueItem() + { + if (concreteEmailQueueItemType != null) + return Activator.CreateInstance(concreteEmailQueueItemType) as IEmailQueueItem; + + var possible = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => a.References(typeof(IEmailQueueItem).Assembly)) + .SelectMany(a => { try { return a.GetExportedTypes(); } catch { return new Type[0]; /* No logging needed */ } }) + .Where(t => t.IsClass && !t.IsAbstract && t.Implements()).ToList(); + + if (possible.Count == 0) + throw new Exception("No type in the currently loaded assemblies implements IEmailQueueItem."); + + if (possible.Count > 1) + throw new Exception("More than one type in the currently loaded assemblies implement IEmailQueueItem:" + possible.Select(x => x.FullName).ToString(" and ")); + + concreteEmailQueueItemType = possible.Single(); + return CreateEmailQueueItem(); + } + + #endregion + + static bool IsSendingPermitted(string to) + { + var permittedDomains = Config.Get("Email:Permitted.Domains").Or("geeks.ltd.uk|uat.co").ToLowerOrEmpty(); + if (permittedDomains == "*") return true; + + if (permittedDomains.Split('|').Trim().Any(d => to.TrimEnd(">").EndsWith("@" + d))) return true; + + var permittedAddresses = Config.Get("Email:Permitted.Addresses").ToLowerOrEmpty().Split('|').Trim(); + + return permittedAddresses.Any() && new MailAddress(to).Address.IsAnyOf(permittedAddresses); + } + + /// + /// Tries to sends all emails. + /// + public static async Task SendAll() => await SendAll(ALL_CATEGORIES, TimeSpan.Zero); + + /// + /// Tries to sends all emails. + /// + /// The category of the emails to send. Use "*" to indicate "all emails". + public static async Task SendAll(string category) => await SendAll(category, TimeSpan.Zero); + + /// + /// Tries to sends all emails. + /// + /// The time to wait in between sending each outstanding email. + public static async Task SendAll(TimeSpan delay) => await SendAll(ALL_CATEGORIES, delay); + + /// + /// Tries to sends all emails. + /// + /// The category of the emails to send. Use "*" to indicate "all emails". + public static async Task SendAll(string category, TimeSpan delay) + { + using (await AsyncLock.Lock()) + { + foreach (var mail in (await Entity.Database.GetList()).OrderBy(e => e.Date).ToArray()) + { + if (mail.Retries >= MaximumRetries) continue; + + if (category != ALL_CATEGORIES) + { + if (category.IsEmpty() && mail.Category.HasValue()) continue; + if (category != mail.Category) continue; + } + + if (delay > TimeSpan.Zero) + { + var multiply = 1 + (Random.NextDouble() - 0.5) / 4; // from 0.8 to 1.2 + + try + { + await Task.Delay(TimeSpan.FromMilliseconds(delay.TotalMilliseconds * multiply)); + } + catch (ThreadAbortException) + { + // Application terminated. + return; + } + } + + try + { + if (await mail.Send() && !mail.IsNew) + await Entity.Database.Delete(mail); + } + catch (Exception ex) + { + Log.Error("Could not send a queued email item " + mail.GetId(), ex); + } + } + } + } + + /// + /// Will try to send the specified email and returns true for successful sending. + /// + public static async Task Send(IEmailQueueItem mailItem) + { + if (mailItem == null) throw new ArgumentNullException(nameof(mailItem)); + + if (mailItem.Retries >= MaximumRetries) return false; + + MailMessage mail = null; + + try + { + using (mail = await CreateMailMessage(mailItem)) + { + if (mail == null) return false; + return await EmailDispatcher(mailItem, mail); + } + } + catch (Exception ex) + { + await SendError.Raise(new EmailSendingEventArgs(mailItem, mail) { Error = ex }); + await mailItem.RecordRetry(); + Log.Error($"Error in sending an email for this EmailQueueItem of '{mailItem.GetId()}'", ex); + return false; + } + } + + static async Task SendViaSmtp(IEmailQueueItem mailItem, MailMessage mail) + { + // Developer note: Web.config setting for SSL is designed to take priority over the specific setting of the email. + // If in your application you want the email item's setting to take priority, do this: + // 1. Remove the 'Email->Enable.Ssl' setting from appsettings.json totally. + // 2. If you need a default value, use your application's Global Settings object and use that value everywhere you create an EmailQueueItem. + using (var smtpClient = new SmtpClient { EnableSsl = Config.Get("Email:Enable.Ssl", mailItem.EnableSsl) }) + { + smtpClient.Configure(); + + if (mailItem.SmtpHost.HasValue()) + smtpClient.Host = mailItem.SmtpHost; + + if (mailItem.SmtpPort.HasValue) + smtpClient.Port = mailItem.SmtpPort.Value; + + if (mailItem.Username.HasValue()) + smtpClient.Credentials = new NetworkCredential(mailItem.Username, mailItem.Password.Or((smtpClient.Credentials as NetworkCredential).Get(c => c.Password))); + + if (Config.IsDefined("Email:Random.Usernames")) + { + var userName = Config.Get("Email:Random.Usernames").Split(',').Trim().PickRandom(); + smtpClient.Credentials = new NetworkCredential(userName, Config.Get("Email:Password")); + } + + await Sending.Raise(new EmailSendingEventArgs(mailItem, mail)); + + await smtpClient.SendMailAsync(mail); + + await Sent.Raise(new EmailSendingEventArgs(mailItem, mail)); + } + + return true; + } + + /// + /// Gets the email items which have been sent (marked as soft deleted). + /// + public static async Task> GetSentEmails() where T : IEmailQueueItem + { + using (new SoftDeleteAttribute.Context(bypassSoftdelete: false)) + { + return (await Entity.Database.GetList()) + .Where(x => EntityManager.IsSoftDeleted((Entity)(IEntity)x)); + } + } + + /// + /// Creates an SMTP mail message for a specified mail item. + /// + static async Task CreateMailMessage(IEmailQueueItem mailItem) + { + // Make sure it's due: + if (mailItem.Date > LocalTime.Now) return null; + + var mail = new MailMessage { Subject = mailItem.Subject.Or("[NO SUBJECT]").Remove("\r", "\n") }; + + #region Set Body + + if (mailItem.Html) + { + var htmlView = AlternateView.CreateAlternateViewFromString(mailItem.Body, new ContentType("text/html; charset=UTF-8")); + + // Add Linked Resources + htmlView.LinkedResources.AddRange(mailItem.GetLinkedResources()); + + mail.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(mailItem.Body.RemoveHtmlTags(), new ContentType("text/plain; charset=UTF-8"))); + mail.AlternateViews.Add(htmlView); + } + else + { + mail.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(mailItem.Body.RemoveHtmlTags(), new ContentType("text/plain; charset=UTF-8"))); + } + + if (mailItem.VCalendarView.HasValue()) + { + var calendarType = new ContentType("text/calendar"); + calendarType.Parameters.Add("method", "REQUEST"); + calendarType.Parameters.Add("name", "meeting.ics"); + + var calendarView = AlternateView.CreateAlternateViewFromString(mailItem.VCalendarView, calendarType); + calendarView.TransferEncoding = TransferEncoding.SevenBit; + + mail.AlternateViews.Add(calendarView); + } + + #endregion + + #region Set Sender + + mail.From = mailItem.GetSender(); + mail.ReplyToList.Add(mailItem.GetReplyTo()); + + #endregion + + #region Set Receivers + + // Add To: + foreach (var address in mailItem.To.Or("").Split(',').Trim().Where(a => IsSendingPermitted(a))) + mail.To.Add(address); + + // Add Cc: + foreach (var address in mailItem.Cc.Or("").Split(',').Trim().Where(a => IsSendingPermitted(a))) + mail.CC.Add(address); + + foreach (var address in Config.Get("Email:Auto.CC.Address").Or("").Split(',').Trim().Where(a => IsSendingPermitted(a))) + mail.CC.Add(address); + + // Add Bcc: + foreach (var address in mailItem.Bcc.Or("").Split(',').Trim().Where(a => IsSendingPermitted(a))) + mail.Bcc.Add(address); + + if (mail.To.None() && mail.CC.None() && mail.Bcc.None()) + return null; + + #endregion + + // Add attachments + mail.Attachments.AddRange(await mailItem.GetAttachments()); + + return mail; + } + + public static MailAddress GetSender(this IEmailQueueItem mailItem) + { + var addressPart = mailItem.SenderAddress.Or(Config.Get("Email:Sender:Address")); + var displayNamePart = mailItem.SenderName.Or(Config.Get("Email:Sender:Name")); + return new MailAddress(addressPart, displayNamePart); + } + + public static MailAddress GetReplyTo(this IEmailQueueItem mailItem) + { + var result = mailItem.GetSender(); + + var asCustomReplyTo = mailItem as ICustomReplyToEmailQueueItem; + if (asCustomReplyTo == null) return result; + + return new MailAddress(asCustomReplyTo.ReplyToAddress.Or(result.Address), + asCustomReplyTo.ReplyToName.Or(result.DisplayName)); + } + + #region Configuration + + /// + /// Configures this smtp client with the specified config file path. + /// + public static void Configure(this SmtpClient client) + { + var setting = Config.Bind("system.net:mailSettings"); + + client.Port = setting.Port; + + if (setting.TargetName.HasValue()) + client.TargetName = setting.TargetName; + + if (client.DeliveryMethod == SmtpDeliveryMethod.Network) + client.Host = setting.Host; + + if (setting.DefaultCredentials && setting.UserName.HasValue() && + setting.Password.HasValue()) + { + client.Credentials = new NetworkCredential(setting.UserName, setting.Password); + } + } + + #endregion + + /// + /// Creates a VCalendar text with the specified parameters. + /// + /// This uniquely identifies the meeting and is used for changes / cancellations. It is recommended to use the ID of the owner object. + public static string CreateVCalendarView(string meetingUniqueIdentifier, DateTime start, DateTime end, string subject, string description, string location) + { + var dateFormat = "yyyyMMddTHHmmssZ"; + + Func cleanUp = s => s.Or("").Remove("\r").Replace("\n", "\\n"); + + var r = new StringBuilder(); + r.AppendLine(@"BEGIN:VCALENDAR"); + r.AppendLine(@"PRODID:-//Microsoft Corporation//Outlook 12.0 MIMEDIR//EN"); + r.AppendLine(@"VERSION:1.0"); + r.AppendLine(@"BEGIN:VEVENT"); + + r.AddFormattedLine(@"DTSTART:{0}", start.ToString(dateFormat)); + r.AddFormattedLine(@"DTEND:{0}", end.ToString(dateFormat)); + r.AddFormattedLine(@"UID:{0}", meetingUniqueIdentifier); + r.AddFormattedLine(@"SUMMARY:{0}", cleanUp(subject)); + r.AppendLine("LOCATION:" + cleanUp(location)); + r.AppendLine("DESCRIPTION:" + cleanUp(description)); + + // bodyCalendar.AppendLine(@"PRIORITY:3"); + r.AppendLine(@"END:VEVENT"); + r.AppendLine(@"END:VCALENDAR"); + + return r.ToString(); + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Email/Email.Sending/IEmailQueueItem.cs b/Services/Olive.Services.Email/Email.Sending/IEmailQueueItem.cs new file mode 100644 index 000000000..e7b2af19b --- /dev/null +++ b/Services/Olive.Services.Email/Email.Sending/IEmailQueueItem.cs @@ -0,0 +1,108 @@ +using System; +using Olive.Entities; + +namespace Olive.Services.Email +{ + public interface ICustomReplyToEmailQueueItem : IEmailQueueItem + { + string ReplyToAddress { get; set; } + string ReplyToName { get; set; } + } + + /// + /// Represents an email generated by the application. + /// + [LogEvents(false), CacheObjects(false)] + public interface IEmailQueueItem : IEntity + { + /// + /// Gets or sets the body of this email. + /// + string Body { get; set; } + + /// + /// Gets or sets the Date of this email. + /// + DateTime Date { get; set; } + + /// + /// Gets or sets whether SSL is enabled. + /// + bool EnableSsl { get; set; } + + /// + /// Gets or sets whether this email is HTML. + /// + bool Html { get; set; } + + /// + /// Gets or sets the Sender Address of this email. + /// + string SenderAddress { get; set; } + + /// + /// Gets or sets the Sender Name for this email. + /// + string SenderName { get; set; } + + /// + /// Gets or sets the Subject of this email. + /// + string Subject { get; set; } + + /// + /// Gets or sets the recipient of this email. + /// + string To { get; set; } + + /// + /// Gets or sets the Attachments information for this email. + /// + string Attachments { get; set; } + + /// + /// Gets or sets the Bcc recipients of this email. + /// + string Bcc { get; set; } + + /// + /// Gets or sets the Bcc recipients of this email. + /// + string Cc { get; set; } + + /// + /// Gets or sets the number of times sending this email has been tried. + /// + int Retries { get; set; } + + /// + /// Gets or sets the VCalendar View of this email. + /// + string VCalendarView { get; set; } + + /// + /// Gets or sets the Username to use for sending this email. + /// + string Username { get; set; } + + /// + /// Gets or sets the Password to use for sending this email. + /// + string Password { get; set; } + + /// + /// Gets or sets the Smtp host address to use for sending this email. + /// + string SmtpHost { get; set; } + + /// + /// Gets or sets the Smtp port to use for sending this email. + /// + int? SmtpPort { get; set; } + + /// + /// Gets or sets the Category for sending this email. + /// + string Category { get; set; } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Email/Email.Sending/IEmailTemplate.cs b/Services/Olive.Services.Email/Email.Sending/IEmailTemplate.cs new file mode 100644 index 000000000..ce46a3221 --- /dev/null +++ b/Services/Olive.Services.Email/Email.Sending/IEmailTemplate.cs @@ -0,0 +1,32 @@ +using Olive.Entities; + +namespace Olive.Services.Email +{ + /// + /// Represents an instance of Email template entity type. + /// + public partial interface IEmailTemplate : IEntity + { + /* -------------------------- Properties -------------------------*/ + + /// + /// Gets or sets the value of Body on this Email template instance. + /// + string Body { get; set; } + + /// + /// Gets or sets the value of Key on this Email template instance. + /// + string Key { get; set; } + + /// + /// Gets or sets the value of MandatoryPlaceholders on this Email template instance. + /// + string MandatoryPlaceholders { get; set; } + + /// + /// Gets or sets the value of Subject on this Email template instance. + /// + string Subject { get; set; } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Email/Email.Sending/SmtpNetworkSetting.cs b/Services/Olive.Services.Email/Email.Sending/SmtpNetworkSetting.cs new file mode 100644 index 000000000..1829ba2db --- /dev/null +++ b/Services/Olive.Services.Email/Email.Sending/SmtpNetworkSetting.cs @@ -0,0 +1,14 @@ +namespace Olive.Services.Email +{ + internal class SmtpNetworkSetting + { + public bool DefaultCredentials { get; set; } + public string Host { get; set; } + public string TargetName { get; set; } + public string ClientDomain { get; set; } + public string Password { get; set; } + public int Port { get; set; } + public string UserName { get; set; } + public bool EnableSsl { get; set; } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Email/EmailTestService.cs b/Services/Olive.Services.Email/EmailTestService.cs new file mode 100644 index 000000000..21e69d994 --- /dev/null +++ b/Services/Olive.Services.Email/EmailTestService.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Mail; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Olive.Entities; +using Olive.Web; + +namespace Olive.Services.Email +{ + public class EmailTestService + { + static readonly Regex LinkPattern = new Regex("(https?://[^ ]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + HttpRequest Request; + HttpResponse Response; + string To, ReturnUrl; + Attachment AttachmentFile; + IEmailQueueItem Email; + bool IsInitialized; + + public EmailTestService(HttpRequest request, HttpResponse response) + { + Request = request; + Response = response; + } + + public async Task Initialize() + { + To = Request.GetValue("to").ToStringOrEmpty().ToLower(); + ReturnUrl = Request.GetReturnUrl(); + if (Request.Has("attachmentInfo")) + AttachmentFile = await EmailService.ParseAttachment(Request.GetValue("attachmentInfo")); + + using (new SoftDeleteAttribute.Context(bypassSoftdelete: true)) + Email = await Request.GetOrDefault("id"); + + IsInitialized = true; + } + + public void ThrowIfItIsNotInitialized() + { + if (!IsInitialized) throw new InvalidOperationException("Initialize the instance before using it."); + } + + void Validate() + { + if (Request.Has("id") && Email == null) throw new Exception("Invalid Email id specified."); + } + + public async Task Process() + { + ThrowIfItIsNotInitialized(); + Validate(); + + string response; + if (AttachmentFile != null) + { + if (IsTextFile(AttachmentFile.Name)) + response = "<< Back to emails
    " + (await AttachmentFile.ContentStream.ReadAllText()).HtmlEncode() + "
    "; + else + { + await Response.Dispatch(await AttachmentFile.ContentStream.ReadAllBytes(), AttachmentFile.Name); + return; + } + } + else if (Email == null) + { + response = await GenerateInbox(); + } + else + { + response = GenerateEmailView(); + } + + await Dispatch(response); + } + + async Task Dispatch(string response) + { + Response.Clear(); + Response.ContentType = "text/html"; + await Response.WriteAsync(""); + + await Response.WriteAsync(""); + await Response.WriteAsync(""); + await Response.WriteAsync(""); + await Response.WriteAsync(""); + + await Response.WriteAsync(response); + + await Response.WriteAsync("Exit Mailbox".FormatWith(ReturnUrl)); + + // TDD hack: + await Response.WriteAsync("Restart Temp Database"); + + await Response.WriteAsync(""); + } + + async Task> GetEmails() + { + using (new SoftDeleteAttribute.Context(bypassSoftdelete: true)) + { + var items = (await Entity.Database.GetList()).Where(x => To.IsEmpty() || (x.To + "," + x.Cc + ", " + x.Bcc).ToLower().Contains(To)); + + return items.OrderByDescending(x => x.Date).Take(15).ToList(); + } + } + + static string GetBodyHtml(string body, bool wasHtml) + { + if (wasHtml) return body; + + body = body.HtmlEncode().Replace("\n", "
    ").Replace("\r", ""); + body = LinkPattern.Replace(body, "$1"); + return body; + } + + async Task GenerateInbox() + { + var r = new StringBuilder(); + + var emails = await GetEmails(); + + r.AppendLine("

    Emails sent to " + To.Or("ALL") + "

    "); + r.AppendLine(""); + r.AppendLine(""); + r.AppendLine(""); + r.AppendLine(""); + r.AppendLine(""); + r.AppendLine(""); + r.AppendLine(""); + r.AppendLine(""); + r.AppendLine(""); + r.AppendLine(""); + r.AppendLine(""); + + if (emails.None()) + { + r.AppendLine(""); + r.AppendLine(""); + r.AppendLine(""); + } + else + { + foreach (var item in emails) + { + r.AppendLine(""); + r.AddFormattedLine("", item.Date.ToString("yyyy-MM-dd")); + r.AddFormattedLine("", item.Date.ToSmallTime()); + r.AddFormattedLine("", GetFrom(item)); + r.AddFormattedLine("", item.To); + r.AddFormattedLine("", item.Cc); + r.AddFormattedLine("", item.Bcc); + + r.AddFormattedLine("", + item.GetId(), To, ReturnUrl.UrlEncode(), item.Subject.Or("[NO SUBJECT]").HtmlEncode()); + + r.AddFormattedLine("", GetAttachmentLinks(item)); + + r.AppendLine(""); + } + } + + r.AppendLine("
    DateTimeFromToCcBccSubjectAttachments
    No emails in the system
    {0}{0}{0}{0}{0}{0}{3}{0}
    "); + + return r.ToString(); + } + + string GetFrom(IEmailQueueItem email) => email.GetSender().Get(s => s.DisplayName.Or("").HtmlEncode() + s.Address.WithWrappers(" <", ">")); + + async Task GetAttachmentLinks(IEmailQueueItem email) + { + return (await email.Attachments.OrEmpty().Split('|').Trim() + .Select(async f => $"
    {(await EmailService.ParseAttachment(f))?.Name.HtmlEncode()}
    ") + .AwaitAll()).ToString(""); + } + + bool IsTextFile(string fileName) => Path.GetExtension(fileName).ToLower().IsAnyOf(".txt", ".csv", ".xml"); + + string GenerateEmailView() + { + var r = new StringBuilder(); + + r.AppendLine("<< Back"); + r.AppendLine("

    Subject: " + Email.Subject.Or("[NO SUBJECT]") + "

    "); + r.AppendLine(""); + + var body = GetBodyHtml(Email.Body.Or("[EMPTY BODY]"), Email.Html); + + var toShow = new Dictionary { + { "Date", Email.Date.ToString("yyyy-MM-dd") +" at " + Email.Date.ToString("HH:mm") }, + {"From", GetFrom(Email)}, + { "To", Email.To}, + {"Bcc", Email.Bcc}, + {"Cc", Email.Cc}, + {"Subject", Email.Subject.Or("[NO SUBJECT]").HtmlEncode().WithWrappers("", "")}, + {"Body", body.WithWrappers("
    " ,"
    ") }, + {"Attachments", GetAttachmentLinks(Email) } + }; + + foreach (var item in toShow.Where(x => x.Value.ToStringOrEmpty().HasValue())) + { + r.AppendLine("
    "); + r.AddFormattedLine("", item.Key.HtmlEncode()); + r.AddFormattedLine("", item.Value); + + r.AppendLine(""); + } + + r.AppendLine("
    {0}:{0}
    "); + + return r.ToString(); + } + } +} diff --git a/Services/Olive.Services.Email/ExtensionMethods.cs b/Services/Olive.Services.Email/ExtensionMethods.cs new file mode 100644 index 000000000..4ae9dc684 --- /dev/null +++ b/Services/Olive.Services.Email/ExtensionMethods.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using Olive.Entities; +using Olive.Web; + +namespace Olive.Services.Email +{ + public static class ExtensionMethods + { + /// + /// Sends this error as a notification email to the address in web.config as Error.Notification.Receiver. + /// + public static Task SendAsNotification(this Exception error) => + SendAsNotification(error, Config.Get("Error.Notification.Receiver")); + + /// + /// Sends this error as a notification email to the address in web.config as Error.Notification.Receiver. + /// + public static async Task SendAsNotification(this Exception error, string toNotify) + { + var context = Context.HttpContextAccessor.HttpContext; + + if (toNotify.IsEmpty()) + return null; + var email = EmailService.EmailQueueItemFactory(); + email.To = toNotify; + email.Subject = "Error In Application"; + email.Body = $"URL: {context?.Request?.ToRawUrl()}{Environment.NewLine}" + + $"IP: {context?.Connection.RemoteIpAddress}{Environment.NewLine}" + + $"User: {ApplicationEventManager.GetCurrentUserId(context?.User)}{Environment.NewLine}" + + error.ToLogString(error.Message); + await Entity.Database.Save(email); + return email; + } + } +} diff --git a/Services/Olive.Services.Email/Olive.Services.Email.csproj b/Services/Olive.Services.Email/Olive.Services.Email.csproj new file mode 100644 index 000000000..861a48edc --- /dev/null +++ b/Services/Olive.Services.Email/Olive.Services.Email.csproj @@ -0,0 +1,25 @@ + + + + netcoreapp2.0 + Olive.Services.Email + Olive.Services.Email + + + + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.Email.xml + 1701;1702;1705;1591;1573 + + + + + + + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Email/Package.nuspec b/Services/Olive.Services.Email/Package.nuspec new file mode 100644 index 000000000..1b36ad032 --- /dev/null +++ b/Services/Olive.Services.Email/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.Email + 1.0.4 + Olive Email (Service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Excel/ExcelCell.cs b/Services/Olive.Services.Excel/ExcelCell.cs new file mode 100644 index 000000000..a34ddf773 --- /dev/null +++ b/Services/Olive.Services.Excel/ExcelCell.cs @@ -0,0 +1,39 @@ +using System; + +namespace Olive.Services.Excel +{ + public class ExcelCell + { + /// + /// Initializes a new instance of the class. + /// + public ExcelCell() => Style = new ExcelCellStyle(); + + public ExcelCell(string text) : this() => Text = text; + + public ExcelCell SetStyle(Action setter) + { + setter?.Invoke(Style); + return this; + } + + /// + /// Gets or sets the text of this cell. + /// + public string Text { get; set; } + + /// + /// Gets or sets the type of this cell. + /// + public string Type { get; set; } + + public ExcelCellStyle Style { get; set; } + + /// + /// Determines if this cell has the same style as the specifying one. + /// + internal bool MatchStyle(ExcelCell other) => Style == other.Style; + + public override string ToString() => Text; + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Excel/ExcelCellStyle.cs b/Services/Olive.Services.Excel/ExcelCellStyle.cs new file mode 100644 index 000000000..ce6ec9b0d --- /dev/null +++ b/Services/Olive.Services.Excel/ExcelCellStyle.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Olive.Web; + +namespace Olive.Services.Excel +{ + /// + /// Provides styles for excel cells. + /// + public class ExcelCellStyle + { + /// + /// Initializes a new instance of the class. + /// + public ExcelCellStyle() => Italic = false; + + #region Alignment + + /// + /// Gets or sets the horizontal alignment of this style. + /// + public ExcelExporter.HorizentalAlignment Alignment + { + get => GetSetting("Alignment.Horizontal", ExcelExporter.HorizentalAlignment.Left); + set => Settings["Alignment.Horizontal"] = ((int)value).ToString(); + } + + #endregion + + #region VerticalAlignment + + /// + /// Gets or sets the vertical alignment of this style. + /// + public ExcelExporter.VerticalAlignment VerticalAlignment + { + get => GetSetting("Alignment.Vertical", ExcelExporter.VerticalAlignment.Center); + set => Settings["Alignment.Vertical"] = ((int)value).ToString(); + } + + #endregion + + #region Orientation + + /// + /// Gets or sets the cell orientation of this style. + /// + public ExcelExporter.CellOrientation Orientation + { + get => GetSetting("Alignment.Orientation", ExcelExporter.CellOrientation.Horizontal); + set => Settings["Alignment.Orientation"] = ((int)value).ToString(); + } + + #endregion + + #region FontSize + + /// + /// Gets or sets the size of the font. + /// + public int FontSize + { + get => GetSetting("Font.FontSize", 10); + set => Settings["Font.FontSize"] = value.ToString(); + } + + #endregion + + #region BackgroundColor + + /// + /// Gets or sets the background color of this style. + /// + public string BackgroundColor + { + get => Settings.TryGet("Interior.Color").Or("#ffffff"); + set => Settings["Interior.Color"] = value; + } + + #endregion + + #region Border Color + + /// + /// Gets or sets the border color of this style. + /// + public string BorderColor + { + get => Settings.TryGet("Border.Color").Or("#000000"); + set => Settings["Border.Color"] = value; + } + + #endregion + + #region BorderWidth + + /// + /// Gets or sets the width of the border. + /// + public int BorderWidth + { + get => Settings.TryGet("Border.Width").TryParseAs() ?? 0; + set + { + if (value < 0 || value > 2) throw new Exception("Border width should be 0, 1 or 2"); + Settings["Border.Width"] = value.ToString(); + } + } + + #endregion + + #region FontName + + /// + /// Gets or sets the font name of this style. + /// + public string FontName + { + get => Settings.TryGet("Font.FontName").Or("Arial"); + set => Settings["Font.FontName"] = value; + } + + #endregion + + #region NumberFormat + + /// + /// Gets or sets the Number format of this style. + /// + public string NumberFormat + { + get => Settings.TryGet("NumberFormat.Format"); + set => Settings["NumberFormat.Format"] = value; + } + + #endregion + + #region Bold + + /// + /// Gets or sets if font should be bold. + /// + public bool Bold + { + get => Settings.TryGet("Font.Bold").TryParseAs() ?? false; + set => Settings["Font.Bold"] = value.ToString(); + } + + #endregion + + #region WrapText + + /// + /// Gets or sets if the text should be wrapped. + /// + public bool WrapText + { + get => Settings.TryGet("WrapText").TryParseAs() ?? true; + set => Settings["WrapText"] = value.ToString(); + } + + #endregion + + #region Italic + + /// + /// Gets or sets if font should be Italic. + /// + public bool Italic + { + get => Settings.TryGet("Font.Italic").TryParseAs() ?? false; + set => Settings["Font.Italic"] = value.ToString(); + } + + #endregion + + #region ForeColor + + /// + /// Gets or sets the background color of this style. + /// + public string ForeColor + { + get => Settings.TryGet("Font.Color").Or("#000000"); + set => Settings["Font.Color"] = value; + } + + #endregion + + #region Manage Style items + + /// + /// Gets or sets the Style of this ExcelColumn. + /// Use ExcelExporter.Style.[Item] to add styles to this. + /// + public Dictionary Settings = new Dictionary(); + + /// + /// Use ExcelExporter.Style.[Item] to add styles. + /// + public ExcelCellStyle Set(string key, string value) + { + Settings[key] = value; + return this; + } + + #endregion + + T GetSetting(string settingKey, T defaultValue) => + (T)(object)(Settings.TryGet(settingKey).TryParseAs() ?? (int)(object)defaultValue); + + public override bool Equals(object obj) + { + var style2 = obj as ExcelCellStyle; + if (style2 == null) return false; + + if (ReferenceEquals(this, style2)) return true; + + if (((object)this == null) || ((object)style2 == null)) return false; + + return new[] { + new { Value1 = BackgroundColor , Value2 = style2.BackgroundColor }, + new { Value1 = FontName , Value2 = style2.FontName }, + new { Value1 = ForeColor , Value2 = style2.ForeColor }, + } + .All(s => s.Value2?.ToString().ToLower() == s.Value1.Get(v => v.ToLower())); + } + + public static bool operator ==(ExcelCellStyle style1, ExcelCellStyle style2) + { + if (ReferenceEquals(style1, style2)) return true; + + if ((object)style1 == null) return false; + + return style1.Equals(style2); + } + + public static bool operator !=(ExcelCellStyle style1, ExcelCellStyle style2) => !(style1 == style2); + + public override int GetHashCode() => base.GetHashCode(); + + /// + /// Gets a unique ID for this style. + /// + public string GetStyleId() => "s" + Settings.Select(i => "s" + i.Key + "_" + i.Value).ToString("__").GetHashCode(); + + internal string GenerateStyle() => GenerateStyleTemplate().Replace("[#Style.ID#]", GetStyleId()); + + string GenerateStyleTemplate() + { + var r = new StringBuilder(); + + r.AppendLine(@""); + + return r.ToString(); + } + + string GetCellRotation() + { + switch (Orientation) + { + case ExcelExporter.CellOrientation.Vertical: + return "90"; + case ExcelExporter.CellOrientation.Horizontal: + return "0"; + default: + throw new NotSupportedException("This orientation is not supported."); + } + } + + internal ExcelCellStyle OverrideWith(ExcelCellStyle overrideStyle) + { + var result = new ExcelCellStyle(); + result.Settings = new Dictionary(Settings); + + foreach (var setting in overrideStyle.Settings) + result.Settings[setting.Key] = setting.Value; + + return result; + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Excel/ExcelExporter.ExcelColumn.cs b/Services/Olive.Services.Excel/ExcelExporter.ExcelColumn.cs new file mode 100644 index 000000000..76fd22193 --- /dev/null +++ b/Services/Olive.Services.Excel/ExcelExporter.ExcelColumn.cs @@ -0,0 +1,95 @@ +using System; + +namespace Olive.Services.Excel +{ + partial class ExcelExporter + { + public class ExcelColumn + { + public int? Width { get; set; } + + /// + /// Gets or sets a Workbook Unique integer ID to use for creating styles. + /// + internal int UniqueId { get; set; } + + public ExcelColumn() + { + HeaderStyle = new ExcelCellStyle { BackgroundColor = "#DDDDDD", BorderWidth = 1, BorderColor = "#aaaaaa" }; + GroupingStyle = new ExcelCellStyle { BackgroundColor = "#777777", ForeColor = "#ffffff", Bold = true, Alignment = ExcelExporter.HorizentalAlignment.Center }; + RowStyle = new ExcelCellStyle(); + } + + /// + /// Creates a new ExcelColumn instance. + /// + public ExcelColumn(string headerText, string dataType) + : this() + { + HeaderText = headerText; + DataType = dataType; + } + + /// + /// Sets the specified row style attribute. + /// + public ExcelColumn SetRowStyle(Action setter) + { + setter?.Invoke(RowStyle); + return this; + } + + /// + /// Sets the specified header style attribute. + /// + public ExcelColumn SetHeaderStyle(Action setter) + { + setter?.Invoke(HeaderStyle); + return this; + } + + /// + /// Gets or sets the style of this columns's header cell. + /// + public ExcelCellStyle HeaderStyle { get; set; } + + public ExcelCellStyle GroupingStyle { get; set; } + + /// + /// Gets or sets the style of this columns's data cells. + /// + public ExcelCellStyle RowStyle { get; set; } + + /// + /// Gets or sets the HeaderText of this ExcelColumn. + /// + public string HeaderText { get; set; } + + /// + /// Gets or sets the Type of this ExcelColumn. + /// + public string DataType { get; set; } = "String"; + + /// + /// Gets or sets the Formula of this ExcelColumn. + /// + public string Formula { get; set; } + + /// + /// Gets or sets the group name of this ExcelColumn. + /// + public string GroupName { get; set; } + + public Func Data { get; set; } + + /// + /// Customizes this column. + /// + public ExcelColumn Customize(Action> customisations) + { + customisations?.Invoke(this); + return this; + } + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Excel/ExcelExporter.ExcelDropDownColumn.cs b/Services/Olive.Services.Excel/ExcelExporter.ExcelDropDownColumn.cs new file mode 100644 index 000000000..bd4974bd9 --- /dev/null +++ b/Services/Olive.Services.Excel/ExcelExporter.ExcelDropDownColumn.cs @@ -0,0 +1,25 @@ +namespace Olive.Services.Excel +{ + partial class ExcelExporter + { + class ExcelDropDownColumn : ExcelColumn + { + public ExcelDropDownColumn(string headerText, string dataType, string enumerationName, object[] possibleValues) + : base(headerText, dataType) + { + PossibleValues = possibleValues; + EnumerationName = enumerationName; + } + + /// + /// enumeration items to select from + /// + public object[] PossibleValues { get; set; } + + /// + /// Gets or sets the Name of this ExcelDropDownColumn. + /// + public string EnumerationName { get; set; } + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Excel/ExcelExporter.Output.cs b/Services/Olive.Services.Excel/ExcelExporter.Output.cs new file mode 100644 index 000000000..82882d005 --- /dev/null +++ b/Services/Olive.Services.Excel/ExcelExporter.Output.cs @@ -0,0 +1,14 @@ +namespace Olive.Services.Excel +{ + partial class ExcelExporter + { + /// + /// Specifies the type of output file. + /// + public enum Output + { + ExcelXml, + Csv + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Excel/ExcelExporter.T.cs b/Services/Olive.Services.Excel/ExcelExporter.T.cs new file mode 100644 index 000000000..c24650d56 --- /dev/null +++ b/Services/Olive.Services.Excel/ExcelExporter.T.cs @@ -0,0 +1,616 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Olive; +using Olive.Entities; + +namespace Olive.Services.Excel +{ + public partial class ExcelExporter + { + const int LINK_SEPRATOR_CHAR_CODE = 166, MAX_LENGTH_FOR_SUMMARIZE = 31; + + public static string LinkSeperator => Convert.ToChar(LINK_SEPRATOR_CHAR_CODE).ToString(); + /// + /// Creates a new ExcelExporter instance. + /// + public ExcelExporter(string documentName) + { + DocumentName = documentName; + HeaderGroupBackgroundColor = HeaderBackGroundColor = "#CCCCCC"; + HeaderFontName = "Arial"; + } + + /// + /// Creates a new ExcelExporter instance for a data table. + /// It automatically configures the exporter for all columns and rows of the data table. + /// + public ExcelExporter(System.Data.DataTable dataTable) + { + if (dataTable == null) + throw new ArgumentNullException(nameof(dataTable)); + + DocumentName = dataTable.TableName; + HeaderBackGroundColor = "#CCCCCC"; + + foreach (System.Data.DataColumn column in dataTable.Columns) + AddColumn(column.ColumnName);// TODO: Add data type when necessary + + foreach (System.Data.DataRow row in dataTable.Rows) + AddRow(row.ItemArray); + } + + #region DocumentName + /// + /// Gets or sets the DocumentName of this ExcelExporter. + /// + public string DocumentName { get; set; } + #endregion + + #region HeaderBackGroundColor + /// + /// Gets or sets the HeaderBackGroundColor of this ExcelExporter. + /// + public string HeaderBackGroundColor { get; set; } + #endregion + + #region HeaderFontName + /// + /// Gets or sets the HeaderFontName of this ExcelExporter. + /// + public string HeaderFontName { get; set; } + #endregion + + #region HeaderGroupBackgroundColor + /// + /// Gets or sets the HeaderGroupBackgroundColor of this ExcelExporter. + /// + public string HeaderGroupBackgroundColor { get; set; } + #endregion + + public bool FreezeHeader { get; set; } + + public bool FreezeFirstColumn { get; set; } + + public double DefaultColumnWidth { get; set; } + + /// + /// Gets or sets the IncludeHeader of this ExcelExporter. + /// + public bool ExcludeHeader { get; set; } + + public List> Columns = new List>(); + public List DataRows = new List(); + + public ExcelColumn GetColumn(string headerText) => Columns.FirstOrDefault(x => x.HeaderText == headerText); + + /// + /// Adds a header cell. + /// + public ExcelColumn AddColumn(string headerText) => AddColumn(headerText, "String"); + + /// + /// Adds a header cell. + /// + public ExcelColumn AddColumn(string headerText, string type) => AddColumn(headerText, type, default(Func)); + + /// + /// Adds a header cell. + /// + public ExcelColumn AddColumn(string headerText, string type, Func data) + { + if (headerText.IsEmpty()) + throw new ArgumentNullException(nameof(headerText)); + + if (type.IsEmpty()) + throw new ArgumentNullException(nameof(type)); + + var result = new ExcelColumn(headerText, type) { Data = data }; + Columns.Add(result); + return result; + } + + /// + /// Removes the column with the specified header text. + /// + public void RemoveColumn(string headerText) + { + var columns = Columns.Where(c => c.HeaderText == headerText); + if (columns.Count() > 1) + throw new ArgumentException($"There are {columns.Count()} columns with header text of '{headerText}'. Please use RemoveColumn(index) instead."); + + if (columns.None()) + throw new ArgumentException($"There is no column with header text of '{headerText}'."); + + RemoveColumn(Columns.IndexOf(columns.Single())); + } + + public void RemoveColumn(ExcelColumn column) => RemoveColumn(Columns.IndexOf(column)); + + /// + /// Removes the column at the specified index. + /// + public void RemoveColumn(int columnIndex) + { + if (columnIndex < 0 || columnIndex > Columns.Count - 1) + throw new ArgumentException("columnIndex should be between 0 and " + (Columns.Count - 1)); + + Columns.RemoveAt(columnIndex); + + for (var i = 0; i < DataRows.Count; i++) + DataRows[i] = DataRows[i].Where((r, ind) => ind != columnIndex).ToArray(); + } + + /// + /// Adds a data row to the excel output. + /// Either ExcelCell instances or value objects. + /// + public void AddRow(params object[] dataCells) + { + if (dataCells == null) + throw new ArgumentNullException(nameof(dataCells)); + + if (Columns.All(x => x.GroupName.IsEmpty())) + { + if (dataCells.Length != Columns.Count()) + throw new ArgumentException($"The number of row cell values does not match the number of columns ({dataCells.Length} <> {Columns.Count()})"); + } + else + { + // Do we need validation for grouping mode? + } + + DataRows.Add(dataCells); + } + + public void AddRows(IEnumerable dataItems) + { + if (dataItems == null) + throw new ArgumentNullException(nameof(dataItems)); + + foreach (var column in Columns.Where(c => c.Data == null)) + throw new Exception($"ExcelColumn.Data should be specified for ExcelExporter.AddRows() method to work. For '{column.HeaderText}' it is null."); + + foreach (var item in dataItems) + { + var dataCells = new List(); + + foreach (var column in Columns) + { + try + { + dataCells.Add(column.Data(item)); + } + catch (Exception ex) + { + throw new Exception($"Invoking the Data evaluator for excel column '{column.HeaderText}' failed on {item.GetType().Name} instance: '{item}'", ex); + } + } + + AddRow(dataCells.ToArray()); + } + } + + public ExcelColumn AddDropDownColumn(string headerText, string type, string enumerationName, IEnumerable possibleValues) + { + if (headerText.IsEmpty()) + throw new ArgumentNullException(nameof(headerText)); + + if (type.IsEmpty()) + throw new ArgumentNullException(nameof(type)); + + if (possibleValues == null) + throw new ArgumentNullException(nameof(possibleValues)); + + var result = new ExcelDropDownColumn(headerText, type, enumerationName, possibleValues.ToArray()); + Columns.Add(result); + + return result; + } + + /// + /// Generates the content of the output Excel file. + /// + public string Generate(ExcelExporter.Output output) + { + switch (output) + { + case ExcelExporter.Output.Csv: + return GenerateCsv(); + case ExcelExporter.Output.ExcelXml: + return GenerateExcelXml(this); + default: + throw new NotSupportedException(); + } + } + + string GenerateCsv() + { + var r = new StringBuilder(); + + // Header row: + if (!ExcludeHeader) + r.AppendLine(Columns.Select(c => EscapeCsvValue(c.HeaderText)).ToString(",")); + + // Data rows: + + foreach (var row in DataRows) + { + var fields = new List(); + for (int i = 0; i < row.Length; i++) + { + var cell = row[i]; + var column = Columns[i]; + + var value = cell?.ToString().OrEmpty(); + + if (column.DataType == "Link") + { + if (value.IsEmpty()) + fields.Add(value); + else + { + var parts = value.Split(LinkSeperator.ToCharArray().Single()); + + if (parts.Length != 2) + throw new Exception("Invalid Link value for ExporttoExcel: " + value); + + fields.Add(parts[0] + ": " + parts[1]); + } + } + else + { + fields.Add(value); + } + } + + r.AppendLine(fields.Select(f => EscapeCsvValue(f)).ToString(",")); + } + + return r.ToString(); + } + + static string EscapeCsvValue(string value) + { + if (value.IsEmpty()) return string.Empty; + + value = value.Remove("\r").Replace("\n", "\r\n"); + + if (value.Contains(",") || value.Contains("\"") || value.Contains("\n")) + value = "\"{0}\"".FormatWith(value.Replace("\"", "\"\"")); + + return value; + } + + #region Generate Excel Xml + + string GenerateExcelWorksheet() + { + var result = new StringBuilder(); + result.AddFormattedLine(@"", + DocumentName.Remove("/", @"\", "?", "*", ":", "[", "]", "\r", "\n").KeepReplacing(" ", " ").Summarize(MAX_LENGTH_FOR_SUMMARIZE, enforceMaxLength: true).XmlEncode()); + result.AddFormattedLine(@"", DefaultColumnWidth); + + result.AppendLine(Columns.Select((h, i) => GenerateColumnTag(h, i + 1)).Trim().ToLinesString()); + + result.AppendLine(GenerateHeaderGroupings()); + + if (!ExcludeHeader) + result.AppendLine(GenerateSheetHeaderRow()); + + result.AppendLine(GenerateDataRows()); + + result.AppendLine(@"
    "); + + result.AppendLine(GenerateDropDownDataValidation()); + + result.AppendLine(GenerateWorksheetSettings()); + + result.AppendLine(@"
    "); + + return result.ToString(); + } + + internal string GenerateColumnTag(ExcelColumn column, int index) + { + if (column.Width == null) return null; + + var r = new StringBuilder(); + + r.Append($""); + + return r.ToString(); + } + + string GenerateWorksheetSettings() + { + var frozenHeaderSetting = "11"; + var frozenFirstColumnSetting = "11"; + + return @" + + + + + {0} + {1} + False + False + " + .FormatWith( + frozenHeaderSetting.OnlyWhen(FreezeHeader), + frozenFirstColumnSetting.OnlyWhen(FreezeFirstColumn)); + } + + string GenerateHeaderGroupings() + { + var groups = GetGroups(); + + if (groups.None()) return string.Empty; + + var r = new StringBuilder(); + + r.AppendLine(@""); + + foreach (var g in groups) + r.AddFormattedLine("{2}", g.Style.GetStyleId(), g.Quantity, g.GroupName); + + r.AppendLine(""); + + return r.ToString(); + } + + IEnumerable GetGroups() + { + var result = new List(); + + if (Columns.All(i => i.GroupName.IsEmpty())) return result; // No grouping has been provided. + + foreach (var column in Columns) + { + var previousGroup = result.LastOrDefault(r => r.GroupName == column.GroupName); + + if (previousGroup != null) + { + previousGroup.Quantity++; + } + else + { + previousGroup = new ColumnGroup { GroupName = column.GroupName, Quantity = 0, Style = column.GroupingStyle }; + result.Add(previousGroup); + } + } + + return result; + } + + class ColumnGroup + { + internal string GroupName; + internal int Quantity; + internal ExcelCellStyle Style; + } + + public static string GenerateExcelXml(params ExcelExporter[] sheets) + { + if (sheets == null || sheets.None()) + throw new ArgumentException("No excel sheets specified."); + + if (sheets.GroupBy(s => s.DocumentName).Any(x => x.Count() > 1)) + throw new ArgumentException("Sheet names should be unique. At least 2 sheets in the provided list have the same DocumentName."); + + var r = new StringBuilder(); + + r.AppendLine(@""); + r.AppendLine(@""); + + // Generate styles + r.AppendLine(GenerateStyles(sheets)); + + // NamedRanges: + var namedRanges = sheets.SelectMany(s => s.Columns.OfType>()).Distinct(c => c.EnumerationName); + var nameRangeNodes = namedRanges.Select(c => "".FormatWith(c.EnumerationName, c.PossibleValues.Length)); + r.AddFormattedLine("{0}", nameRangeNodes.ToLinesString()); + + foreach (var sheet in sheets) + r.AppendLine(sheet.GenerateExcelWorksheet()); + + r.AppendLine(namedRanges.Select(c => GenerateDropDownSourceSheet(c)).ToLinesString()); + + r.AppendLine(@""); + + return r.ToString(); + } + + static string GenerateStyles(params ExcelExporter[] sheets) + { + var r = new StringBuilder(); + + r.AppendLine(""); + + // Link style + r.AddFormattedLine(@"", sheets.First().HeaderBackGroundColor); + + // Merge settings: + sheets.Do(s => s.MergeStyles()); + + var uniqueStyles = sheets.SelectMany(x => x.GetAllStyles()).Distinct(x => x.GetStyleId()).ToList(); + foreach (var style in uniqueStyles) + r.AppendLine(style.GenerateStyle()); + + r.AppendLine(""); + + return r.ToString(); + } + + IEnumerable GetAllStyles() + { + var header = Columns.SelectMany(x => new[] { x.HeaderStyle, x.RowStyle }); + var rows = DataRows.SelectMany(x => x.ExceptNull().OfType()).Select(x => x.Style); + var groupings = Columns.Where(c => c.GroupName.HasValue()).Select(x => x.GroupingStyle); + + return header.Concat(rows).Concat(groupings).Distinct(x => x.GetStyleId()).ToArray(); + } + + void MergeStyles() + { + foreach (var row in DataRows) + { + for (int i = 0; i < row.Length; i++) + { + var cell = row[i] as ExcelCell; + + if (cell == null) continue; + + var column = Columns[i]; + + cell.Style = column.RowStyle.OverrideWith(cell.Style); + } + } + } + + /// + /// Generates Hidden Worksheets that contain Possible Values for each DropDown + /// + static string GenerateDropDownSourceSheet(ExcelDropDownColumn column) + { + var rows = column.PossibleValues. + Select(v => $@"{v}"); + + return $@" + + {rows.ToLinesString()} +
    + + SheetHidden + +
    "; + } + + /// + /// DataValidation assigns a DropDown for each cell and restrics possible values to that drop down + /// + string GenerateDropDownDataValidation() + { + return Columns.OfType>().Select(c => + @" + List + R1C{0}:R{1}C{0} + {2} + ".FormatWith(Columns.IndexOf(c) + 1, DataRows.Count + 1, c.EnumerationName)).ToLinesString(); + } + + string GenerateSheetHeaderRow() + { + var r = new StringBuilder(); + + r.AppendLine(@""); + + foreach (var c in Columns) + { + r.AppendFormat("", c.HeaderStyle.GetStyleId()); + r.AddFormattedLine("{0}", c.HeaderText.XmlEncode()); + r.AppendLine(""); + } + + r.AppendLine(""); + + return r.ToString(); + } + + string GenerateDataRows() + { + var r = new StringBuilder(); + + foreach (var row in DataRows) + { + r.AppendLine(@""); + + for (int i = 0; i < row.Length; i++) + { + var cell = row[i]; + var column = Columns[i]; + + var cellInfo = cell as ExcelCell; + + var value = cell?.ToString().OrEmpty(); + + if (column.DataType == "Link") + { + if (value.IsEmpty()) + { + r.AppendLine(""); + } + else + { + var parts = value.Split(LinkSeperator.ToCharArray().Single()); + + if (parts.Length != 2) + throw new Exception("Invalid Link value for ExporttoExcel: " + value); + + r.AddFormattedLine("{1}", + parts[1].XmlEncode(), + parts[0].XmlEncode()); + } + } + else + { + if (value.HasValue()) value = value.XmlEncode(); + + r.Append(""); + + if (value.HasValue()) + r.AddFormattedLine("{1}", column.DataType, value); + + r.AppendLine(""); + } + } + + r.AppendLine(""); + } + + return r.ToString(); + } + + #endregion + + /// + /// Gets the file extension for a specified output format. + /// + public string GetFileExtension(ExcelExporter.Output output) + { + switch (output) + { + case ExcelExporter.Output.ExcelXml: + return ".xls"; + case ExcelExporter.Output.Csv: + return ".csv"; + default: + throw new NotSupportedException(); + } + } + + public Blob ToDocument(ExcelExporter.Output type) => + new Blob(Generate(type).GetUtf8WithSignatureBytes(), DocumentName + GetFileExtension(type)); + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Excel/ExcelExporter.cs b/Services/Olive.Services.Excel/ExcelExporter.cs new file mode 100644 index 000000000..2117160c5 --- /dev/null +++ b/Services/Olive.Services.Excel/ExcelExporter.cs @@ -0,0 +1,35 @@ +namespace Olive.Services.Excel +{ + public partial class ExcelExporter : ExcelExporter + { + /// + /// Creates a new ExcelExporter instance. + /// + public ExcelExporter(string documentName) : base(documentName) { } + + /// + /// Creates a new ExcelExporter instance. + /// + public ExcelExporter(System.Data.DataTable dataTable) : base(dataTable) { } + + public enum CellOrientation : int { Vertical, Horizontal } + public enum VerticalAlignment : int { Top, Center, Bottom } + public enum HorizentalAlignment : int { Left, Center, Right } + + public class Styles + { + public const string NumberFormat_Format = "NumberFormat.Format"; + public const string Alignment_Vertical = "Alignment.Vertical"; + public const string Alignment_Horizontal = "Alignment.Horizontal"; + + public const string Font_FontName = "Font.FontName"; + public const string Font_Bold = "Font.Bold"; + public const string Font_Italic = "Font.Italic"; + public const string Font_Size = "Font.Size"; + public const string Font_Color = "Font.Color"; + + public const string Interior_Color = "Interior.Color"; + public const string Interior_Pattern = "Interior.Pattern"; + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Excel/ExtensionMethods.cs b/Services/Olive.Services.Excel/ExtensionMethods.cs new file mode 100644 index 000000000..7c5548995 --- /dev/null +++ b/Services/Olive.Services.Excel/ExtensionMethods.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Olive.Entities; +using Olive.Entities.Data; + +namespace Olive.Services.Excel +{ + // The following piece of code is copied here due to compile error in data project. I will decide about it later. + public static class ExtensionMethods + { + public static async Task GenerateReport(this DataAccessProfiler profiler, bool snapshot = false) + { + var lines = DataAccessProfiler.GenerateReport(snapshot); + var exporter = new ExcelExporter("Sql.Profile.Report"); + + exporter.AddColumn("Command"); + exporter.AddColumn("Calls"); + exporter.AddColumn("Total ms"); + exporter.AddColumn("Longest ms"); + exporter.AddColumn("Average ms"); + exporter.AddColumn("Median ms"); + + foreach (var line in lines.OrderByDescending(x => x.Total)) + exporter.AddRow(line.Command, line.Calls, line.Total, line.Longest, line.Average, line.Median); + + var result = exporter.Generate(ExcelExporter.Output.Csv); + + var file = Blob.GetPhysicalFilesRoot(Blob.AccessMode.Secure).EnsureExists().GetFile("Sql.Profile." + DateTime.Now.ToOADate() + ".csv"); + + await file.WriteAllText(result); + + return file; + } + } +} diff --git a/Services/Olive.Services.Excel/Olive.Services.Excel.csproj b/Services/Olive.Services.Excel/Olive.Services.Excel.csproj new file mode 100644 index 000000000..6a6364bfc --- /dev/null +++ b/Services/Olive.Services.Excel/Olive.Services.Excel.csproj @@ -0,0 +1,22 @@ + + + + netcoreapp2.0 + Olive.Services.Excel + Olive.Services.Excel + + + + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.Excel.xml + 1701;1702;1705;1591;1573 + + + + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Excel/Package.nuspec b/Services/Olive.Services.Excel/Package.nuspec new file mode 100644 index 000000000..645a844b3 --- /dev/null +++ b/Services/Olive.Services.Excel/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.Excel + 1.0.3 + Olive Excel (Service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.GeoLocation/GeoLocationExtensions.cs b/Services/Olive.Services.GeoLocation/GeoLocationExtensions.cs new file mode 100644 index 000000000..0743f17f0 --- /dev/null +++ b/Services/Olive.Services.GeoLocation/GeoLocationExtensions.cs @@ -0,0 +1,53 @@ +using System; +using System.ComponentModel; + +namespace Olive.Services.GeoLocation +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public static class GeoLocationExtensions + { + const int EARTH_RADIUS = 3963; + + /// + /// Gets the geo distance in miles between this and another specified location. + /// + public static double? GetDistance(this IGeoLocation from, IGeoLocation to) + { + if (from == null) return null; + + if (to == null) return null; + + var dLat = (to.Latitude - from.Latitude).ToRadians(); + var dLon = (to.Longitude - from.Longitude).ToRadians(); + + var a1 = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(from.Latitude.ToRadians()) * Math.Cos(to.Latitude.ToRadians()) * + Math.Sin(dLon / 2) * Math.Sin(dLon / 2); + + var c1 = 2 * Math.Atan2(Math.Sqrt(a1), Math.Sqrt(1 - a1)); + + var result = EARTH_RADIUS * c1; + + if (result > 100) + return result.Round(0); + else + return result.Round(1); + } + + /// + /// Gets the geo distance in miles between this located object and a specified location. + /// + public static double? GetDistance(this IGeoLocated from, IGeoLocation to) => GetDistance(from.Get(l => l.GetLocation()), to); + + /// + /// Gets the geo distance in miles between this location and a specified located object. + /// + public static double? GetDistance(this IGeoLocation from, IGeoLocated to) => GetDistance(from, to.Get(l => l.GetLocation())); + + /// + /// Gets the geo distance in miles between this and another specified located object. + /// + public static double? GetDistance(this IGeoLocated from, IGeoLocated to) => + GetDistance(from.Get(l => l.GetLocation()), to.Get(l => l.GetLocation())); + } +} \ No newline at end of file diff --git a/Services/Olive.Services.GeoLocation/GeoLocationService.cs b/Services/Olive.Services.GeoLocation/GeoLocationService.cs new file mode 100644 index 000000000..6a111eac2 --- /dev/null +++ b/Services/Olive.Services.GeoLocation/GeoLocationService.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using System.Xml.Linq; +using Olive.Web; + +namespace Olive.Services.GeoLocation +{ + /// + /// Provides location services. + /// + public class GeoLocationService + { + const string DIRECTION_URL = "https://" + "maps.googleapis.com/maps/api/distancematrix/xml?units=imperial"; + public static string GoogleClientKey = Config.Get("Google.Maps.Api.Client.Key"); + public static string GoogleSignatureKey = Config.Get("Google.Maps.Api.Signature"); + + static ConcurrentDictionary CachedLocations = new ConcurrentDictionary(); + + /// + /// Gets the Geo Location of a specified postcode using Google API. + /// This method has daily usage limit of 25000 calls. + /// + public static GeoLocation GetPostcodeLocation(string postcode, string countryCode = "GB") + { + var fullAddress = postcode + "," + countryCode; + + return CachedLocations.GetOrAdd(fullAddress, address => + { + var clientParameter = "key".OnlyWhen(GoogleSignatureKey.IsEmpty()).Or("client"); + + var url = "https://" + $"maps.googleapis.com/maps/api/geocode/xml?address={address}&sensor=false" + + GoogleClientKey.UrlEncode().WithPrefix($"&{clientParameter}=") + + GoogleSignatureKey.UrlEncode().WithPrefix("&signature="); + + var response = (new WebClient().DownloadString(url)).To(); + + var status = response.GetValue("status"); + + if (status == "ZERO_RESULTS") return null; + if (status != "OK") throw new Exception("Google API Error: " + status + "\r\n\r\n" + response); + + var location = response.Element("result").Get(x => x.Element("geometry")).Get(x => x.Element("location")); + + if (location == null) throw new Exception("Unexpected result from Google API: \r\n\r\n" + response); + + return new GeoLocation + { + Latitude = location.GetValue("lat").To(), + Longitude = location.GetValue("lng").To() + }; + }); + } + + /// + /// Gets the distance between 2 locations in miles. + /// + public static double? CalculateDistance(string postcode1, string postcode2, string countryCode = "GB") + { + var location1 = GetPostcodeLocation(postcode1, countryCode); + if (location1 == null) return null; + + var location2 = GetPostcodeLocation(postcode2, countryCode); + if (location2 == null) return null; + + return location1.GetDistance(location2); + } + + /// + /// Returns the traveling distance in miles using the Google Maps API. + /// + public static async Task CalculateTravelDistance(string fromPostCode, string toPostCode, string countryCode = "GB") + { + var fromLocation = GetPostcodeLocation(fromPostCode, countryCode).Get(x => x.Latitude + "," + x.Longitude); + var toLocation = GetPostcodeLocation(toPostCode, countryCode).Get(x => x.Latitude + "," + x.Longitude); + + var url = DIRECTION_URL.AsUri() + .AddQueryString("origins", fromLocation) + .AddQueryString("destinations", toLocation); + + if (GoogleClientKey.HasValue()) + url = url.AddQueryString("key".OnlyWhen(GoogleSignatureKey.IsEmpty()).Or("client"), GoogleClientKey); + + if (GoogleSignatureKey.HasValue()) + url = url.AddQueryString("signature", GoogleSignatureKey); + + var response = (await url.Download()).To(); + + var status = response.GetValue("status"); + + if (status == "ZERO_RESULTS") return null; + if (status != "OK") throw new Exception("Google API Error: " + status + "\r\n\r\n" + response); + + var miles = response.Element("row").Get(r => r.Element("element").Get(e => e.Element("distance")).Get(d => d.Element("text"))); + + if (miles == null) throw new Exception("Unexpected result from Google API: \r\n\r\n" + response); + + var result = miles.Value.Split(' ').FirstOrDefault().TryParseAs(); + if (result == null) + throw new Exception("Unexpected result format from Google API: \r\n\r\n" + response); + + return result; + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.GeoLocation/IGeoLocated.cs b/Services/Olive.Services.GeoLocation/IGeoLocated.cs new file mode 100644 index 000000000..ac9f9e25d --- /dev/null +++ b/Services/Olive.Services.GeoLocation/IGeoLocated.cs @@ -0,0 +1,7 @@ +namespace Olive.Services.GeoLocation +{ + public interface IGeoLocated + { + IGeoLocation GetLocation(); + } +} diff --git a/Services/Olive.Services.GeoLocation/IGeoLocation.cs b/Services/Olive.Services.GeoLocation/IGeoLocation.cs new file mode 100644 index 000000000..6c31e17e2 --- /dev/null +++ b/Services/Olive.Services.GeoLocation/IGeoLocation.cs @@ -0,0 +1,14 @@ +namespace Olive.Services.GeoLocation +{ + public interface IGeoLocation + { + double Longitude { get; } + double Latitude { get; } + } + + public class GeoLocation : IGeoLocation + { + public double Longitude { get; set; } + public double Latitude { get; set; } + } +} diff --git a/Services/Olive.Services.GeoLocation/Olive.Services.GeoLocation.csproj b/Services/Olive.Services.GeoLocation/Olive.Services.GeoLocation.csproj new file mode 100644 index 000000000..dfb769ca5 --- /dev/null +++ b/Services/Olive.Services.GeoLocation/Olive.Services.GeoLocation.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp2.0 + Olive.Services.GeoLocation + + + + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.GeoLocation.xml + 1701;1702;1705;1591;1573 + + + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.GeoLocation/Package.nuspec b/Services/Olive.Services.GeoLocation/Package.nuspec new file mode 100644 index 000000000..da1759f22 --- /dev/null +++ b/Services/Olive.Services.GeoLocation/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.GeoLocation + 1.0.3 + Olive GeoLocation (Service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Globalization/ExtensionMethods.cs b/Services/Olive.Services.Globalization/ExtensionMethods.cs new file mode 100644 index 000000000..76d9c0a0e --- /dev/null +++ b/Services/Olive.Services.Globalization/ExtensionMethods.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading.Tasks; +using Olive.Entities; + +namespace Olive.Services.Globalization +{ + public static class ExtensionMethods + { + /// + /// Returns the translation of this object's string representation. + /// + public static Task ToString(this IEntity instance, ILanguage language) + { + if (language == null) + throw new ArgumentNullException(nameof(language)); + + return Translator.Translate(instance.ToString(), language); + } + } +} diff --git a/Services/Olive.Services.Globalization/GoogleAutoDetect.cs b/Services/Olive.Services.Globalization/GoogleAutoDetect.cs new file mode 100644 index 000000000..db07fde37 --- /dev/null +++ b/Services/Olive.Services.Globalization/GoogleAutoDetect.cs @@ -0,0 +1,59 @@ +using System.Runtime.Serialization; +using System.Threading.Tasks; +using Olive.Entities; + +namespace Olive.Services.Globalization +{ + [DataContract] + internal class GoogleAutoDetectJsonResponseRootObject + { + [DataMember] + public GoogleAutoDetectJsonResponseData data { get; set; } + } + + [DataContract] + internal class GoogleAutoDetectJsonResponseData + { + [DataMember] + public GoogleAutoDetectJsonResponseDetection[][] detections { get; set; } + } + + [DataContract] + internal class GoogleAutoDetectJsonResponseDetection + { + [DataMember] + public string language { get; set; } + [DataMember] + public bool isReliable { get; set; } + [DataMember] + public float confidence { get; set; } + } + + /// + /// Response returned by Google API for each auto-detect language request + /// + public class GoogleAutodetectResponse + { + /// ISO Code + public string ISOCode { get; private set; } + /// Confidence [0;1] about the detection + public double? Confidence { get; private set; } + // public bool IsReliable { get; set; } // Deprecated + + /// + /// Initialize a new Google auto-detect response + /// + public GoogleAutodetectResponse(string isoCode, double? confidence) + { + ISOCode = isoCode; + Confidence = confidence; + } + + /// Language detected based on iso639-1 + public async Task GetLanguage() + { + var iso6391Code = ISOCode.Substring(0, 2).ToLowerInvariant(); // ISO639-1 are two letters code, but for Chinese Google returns 2 different codes (zh-CN for simplified and zh-TW for traditional) + return await Entity.Database.FirstOrDefault(l => l.IsoCode.ToLowerInvariant() == iso6391Code); + } + } +} diff --git a/Services/Olive.Services.Globalization/GoogleTranslate.cs b/Services/Olive.Services.Globalization/GoogleTranslate.cs new file mode 100644 index 000000000..b0871983b --- /dev/null +++ b/Services/Olive.Services.Globalization/GoogleTranslate.cs @@ -0,0 +1,25 @@ +using System.Runtime.Serialization; + +namespace Olive.Services.Globalization +{ + [DataContract] + internal class GoogleTranslateJsonResponseRootObject + { + [DataMember] + public GoogleTranslateJsonResponseData data { get; set; } + } + + [DataContract] + internal class GoogleTranslateJsonResponseData + { + [DataMember] + public GoogleTranslateJsonResponseTranslation[] translations { get; set; } + } + + [DataContract] + internal class GoogleTranslateJsonResponseTranslation + { + [DataMember] + public string translatedText { get; set; } + } +} diff --git a/Services/Olive.Services.Globalization/ILanguage.cs b/Services/Olive.Services.Globalization/ILanguage.cs new file mode 100644 index 000000000..894ede70b --- /dev/null +++ b/Services/Olive.Services.Globalization/ILanguage.cs @@ -0,0 +1,11 @@ +using Olive.Entities; + +namespace Olive.Services.Globalization +{ + public interface ILanguage : IEntity + { + string Name { get; } + string IsoCode { get; } + bool IsDefault { get; } + } +} diff --git a/Services/Olive.Services.Globalization/IPhrase.cs b/Services/Olive.Services.Globalization/IPhrase.cs new file mode 100644 index 000000000..1d7210702 --- /dev/null +++ b/Services/Olive.Services.Globalization/IPhrase.cs @@ -0,0 +1,11 @@ +using Olive.Entities; + +namespace Olive.Services.Globalization +{ + public interface IPhraseTranslation : IEntity + { + string Phrase { get; } + string Translation { get; } + ILanguage Language { get; } + } +} diff --git a/Services/Olive.Services.Globalization/LanguageExtensions.cs b/Services/Olive.Services.Globalization/LanguageExtensions.cs new file mode 100644 index 000000000..87e6de64c --- /dev/null +++ b/Services/Olive.Services.Globalization/LanguageExtensions.cs @@ -0,0 +1,9 @@ +using System.ComponentModel; + +namespace Olive.Services.Globalization +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public static class LanguageExtensions + { + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Globalization/Olive.Services.Globalization.csproj b/Services/Olive.Services.Globalization/Olive.Services.Globalization.csproj new file mode 100644 index 000000000..8a8ff3a18 --- /dev/null +++ b/Services/Olive.Services.Globalization/Olive.Services.Globalization.csproj @@ -0,0 +1,23 @@ + + + + netcoreapp2.0 + Olive.Services.Globalization + + + + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.Globalization.xml + 1701;1702;1705;1591;1573 + + + + + + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Globalization/Package.nuspec b/Services/Olive.Services.Globalization/Package.nuspec new file mode 100644 index 000000000..89faad09e --- /dev/null +++ b/Services/Olive.Services.Globalization/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.Globalization + 1.0.3 + Olive Globalization (Service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Globalization/TranslationDownloadedEventArgs.cs b/Services/Olive.Services.Globalization/TranslationDownloadedEventArgs.cs new file mode 100644 index 000000000..606a70705 --- /dev/null +++ b/Services/Olive.Services.Globalization/TranslationDownloadedEventArgs.cs @@ -0,0 +1,38 @@ +using System; + +namespace Olive.Services.Globalization +{ + public class TranslationDownloadedEventArgs : EventArgs + { + /// + /// Creates a new TranslationDownloadedEventArgs instance. + /// + public TranslationDownloadedEventArgs(string word, ILanguage language, string translation) + { + Word = word; + Language = language; + Translation = translation; + } + + #region Word + /// + /// Gets or sets the Word of this TranslationDownloadedEventArgs. + /// + public string Word { get; private set; } + #endregion + + #region Language + /// + /// Gets or sets the Language of this TranslationDownloadedEventArgs. + /// + public ILanguage Language { get; private set; } + #endregion + + #region Translation + /// + /// Gets or sets the Translation of this TranslationDownloadedEventArgs. + /// + public string Translation { get; private set; } + #endregion + } +} diff --git a/Services/Olive.Services.Globalization/TranslationRequestedEventArgs.cs b/Services/Olive.Services.Globalization/TranslationRequestedEventArgs.cs new file mode 100644 index 000000000..db28128b1 --- /dev/null +++ b/Services/Olive.Services.Globalization/TranslationRequestedEventArgs.cs @@ -0,0 +1,13 @@ +namespace Olive.Services.Globalization +{ + using System; + using System.ComponentModel; + + public class TranslationRequestedEventArgs : CancelEventArgs + { + public string PhraseInDefaultLanguage { get; internal set; } + public ILanguage Language { get; internal set; } + + public Func TranslationProvider; + } +} diff --git a/Services/Olive.Services.Globalization/Translator.cs b/Services/Olive.Services.Globalization/Translator.cs new file mode 100644 index 000000000..ffd581878 --- /dev/null +++ b/Services/Olive.Services.Globalization/Translator.cs @@ -0,0 +1,394 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Runtime.Serialization.Json; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; +using HtmlAgilityPack; +using Olive.Entities; +using Olive.Web; + +namespace Olive.Services.Globalization +{ + /// + /// Provides translation services. + /// + public static class Translator + { + /// Length of the query without the phrase + static readonly int GOOGLE_TRANSLATE_QUERY_LENGTH = 115; + + /// Maximum number of characters for each request to Google API + static readonly int GOOGLE_TRANSLATE_LIMIT = 2000; + + /// Maximum number of characters for each phrase that can be sent to Google Translate + public static readonly int GOOGLE_PHRASE_LIMIT = GOOGLE_TRANSLATE_LIMIT - GOOGLE_TRANSLATE_QUERY_LENGTH; + + /// Message returned by Google if suspected terms of service abuse. + const string GOOGLE_TERMS_OF_SERVICE_ABUSE_MESSAGE = "Suspected Terms of Service Abuse. Please see http://code.google.com/apis/errors"; + + /// HTML tag for a line break + static readonly string LINE_BREAK_HTML = "
    "; + + /// Unicode value of a HTML line break + static readonly string LINE_BREAK_UNICODE = "\u003cbr /\u003e"; + + public static bool AttemptAutomaticTranslation = true; + static bool IsGoogleTranslateMisconfigured; + + /// + /// Gets the language of the current user from cookie. + /// If no language is specified, then the default language will be used as configured in the database. + /// + public static Func> GetCurrentLanguage = async () => await CookieProperty.Get() ?? await GetDefaultLanguage(); + + static ILanguage DefaultLanguage; + static async Task GetDefaultLanguage() + { + if (DefaultLanguage == null) + { + DefaultLanguage = await Entity.Database.FirstOrDefault(l => l.IsDefault); + + if (DefaultLanguage == null) + { + throw new Exception("There is no default language specified in the system."); + } + } + + return DefaultLanguage; + } + + #region Translate Html + + public static async Task TranslateHtml(string htmlInDefaultLanguage) => await TranslateHtml(htmlInDefaultLanguage, null); + + public static async Task TranslateHtml(string htmlInDefaultLanguage, ILanguage language) + { + if (language == null) language = await GetCurrentLanguage(); + + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(htmlInDefaultLanguage); + + var docNode = htmlDoc.DocumentNode; + await TranslateNode(docNode, language); + + return docNode.OuterHtml; + } + + static async Task TranslateNode(HtmlNode node, ILanguage language) + { + if (node.InnerHtml.Length == 0 || + (node.NodeType == HtmlNodeType.Text && + !Regex.IsMatch(node.InnerHtml, @"\w+" /* whitespaces */, RegexOptions.Multiline))) + return; + + if (node.Name == "img") + { + var alt = node.Attributes["alt"]; + if (alt != null) + alt.Value = await Translate(alt.Value, language); + } + + if (!node.HasChildNodes && node.InnerHtml.Length <= GOOGLE_TRANSLATE_LIMIT) + { + node.InnerHtml = await Translate(node.InnerHtml, language); + return; + } + else if (node.ChildNodes.Count > 0) + { + foreach (var child in node.ChildNodes) + await TranslateNode(child, language); + } + else + { + var lines = Wrap(node.InnerHtml, GOOGLE_TRANSLATE_LIMIT); + var sb = new StringBuilder(); + + foreach (var line in lines) + sb.Append(await Translate(line, language)); + + node.InnerHtml = sb.ToString(); + return; + } + } + + static string[] Wrap(string text, int eachLineLength) + { + text = text.Replace("\n\r", "\n"); + var splites = new[] { '\n', ' ', '.', ',', ';', '!', '?' }; + + var resultLines = new List(); + + var currentLine = new StringBuilder(); + + for (int i = 0; i < text.Length; i++) + { + if (currentLine.Length <= eachLineLength) + { + currentLine.Append(text[i]); + } + else // currentLineLength > eachLineLength + { + while (!splites.Contains(currentLine[currentLine.Length - 1])/* last char is not splitter*/) + { + currentLine.Remove(currentLine.Length - 1, 1); // remove last char + i--; + } + + i--; + resultLines.Add(currentLine.ToString()); + currentLine = new StringBuilder(); + } + } + + return resultLines.ToArray(); + } + + #endregion + + public static async Task Translate(string phraseInDefaultLanguage) + { + var retries = 3; + while (true) + { + try + { + return await Translate(phraseInDefaultLanguage, null); + } + catch + { + if (retries == 0) throw; + + await Task.Delay(10); // Wait and try again: + retries--; + } + } + } + + /// + /// Occurs when a translation is requested. + /// + public static readonly AsyncEvent TranslationRequested = + new AsyncEvent(); + + [EscapeGCop("It is ok for try methods to have out parameters.")] + static async Task> TryTranslateUsingTheEvent(string phraseInDefaultLanguage, ILanguage language) + { + if (TranslationRequested.IsHandled()) + { + var args = new TranslationRequestedEventArgs { PhraseInDefaultLanguage = phraseInDefaultLanguage, Language = language }; + + await TranslationRequested.Raise(args); + + if (args.Cancel) + return Tuple.Create(true, phraseInDefaultLanguage); + + if (args.TranslationProvider != null) + return Tuple.Create(true, args.TranslationProvider()); + } + + return Tuple.Create(false, default(string)); + } + + public static async Task Translate(string phraseInDefaultLanguage, ILanguage language) + { + if (language == null) language = await GetCurrentLanguage(); + + var byEvent = await TryTranslateUsingTheEvent(phraseInDefaultLanguage, language); + if (byEvent.Item1) return byEvent.Item2; + + if (phraseInDefaultLanguage.IsEmpty()) + return phraseInDefaultLanguage; + + if (language.Equals(await GetDefaultLanguage())) + { + return phraseInDefaultLanguage; + } + else + { + // First try: Exact match: + var translation = await GetLocalTranslation(phraseInDefaultLanguage, language); + + if (translation.HasValue()) + { + return translation; + } + + // special characters aren't translated: + if (phraseInDefaultLanguage.ToCharArray().None(c => char.IsLetter(c))) + return phraseInDefaultLanguage; + + // Next try: Remove special characters: + var leftDecorators = FindLeftDecorators(phraseInDefaultLanguage); + var rightDecorators = FindRightDecorators(phraseInDefaultLanguage); + + if (leftDecorators.HasValue()) + phraseInDefaultLanguage = phraseInDefaultLanguage.TrimStart(leftDecorators); + + if (rightDecorators.HasValue()) + phraseInDefaultLanguage = phraseInDefaultLanguage.TrimEnd(rightDecorators); + + translation = await GetLocalTranslation(phraseInDefaultLanguage, language); + + if (translation.IsEmpty()) + { + if (phraseInDefaultLanguage.Length <= GOOGLE_TRANSLATE_LIMIT && AttemptAutomaticTranslation) + { + translation = await GoogleTranslate(phraseInDefaultLanguage, language.IsoCode); + } + else + { + translation = phraseInDefaultLanguage; + } + + if (translation.HasValue()) + { + try + { + var arg = new TranslationDownloadedEventArgs(phraseInDefaultLanguage, language, translation); + await TranslationDownloaded.Raise(arg); + } + catch { /* No Logging needed*/ } + } + } + + return leftDecorators + translation.Or(phraseInDefaultLanguage) + rightDecorators; + } + } + + static async Task GetLocalTranslation(string phraseInDefaultLanguage, ILanguage language) + { + return (await Entity.Database.FirstOrDefault(p => + p.Phrase == phraseInDefaultLanguage && p.Language.Equals(language)) + ) + .Get(p => p.Translation); + } + + /// + /// Occurs when a word's translation is downloaded off the Internet. + /// + public static readonly AsyncEvent TranslationDownloaded = + new AsyncEvent(); + + static string FindLeftDecorators(string phraseInDefaultLanguage) + { + var result = new StringBuilder(); + + for (int i = 0; i < phraseInDefaultLanguage.Length && !char.IsLetter(phraseInDefaultLanguage[i]); i++) + result.Append(phraseInDefaultLanguage[i]); + + return result.ToString(); + } + + static string FindRightDecorators(string phraseInDefaultLanguage) + { + var result = new StringBuilder(); + + for (int i = phraseInDefaultLanguage.Length - 1; i >= 0 && !char.IsLetter(phraseInDefaultLanguage[i]); i--) + result.Insert(0, phraseInDefaultLanguage[i]); + + return result.ToString(); + } + + /// Check the configuration status of Google Translate + public static bool IsGoogleMisconfigured() => IsGoogleTranslateMisconfigured; + + /// Set the status of Google Translate as well configured + public static void ReconfigureGoogleTranslate() => IsGoogleTranslateMisconfigured = false; + + /// + /// Uses Google Translate service to translate a specified phrase to the specified language. + /// + public static async Task GoogleTranslate(string phrase, string languageIsoCodeTo, string languageIsoCodeFrom = "en") + { + if (IsGoogleTranslateMisconfigured) + return null; + + if (Config.Get("Enable.Google.Translate", defaultValue: false) == false) return null; + + var key = Config.Get("Google.Translate.Key"); + if (key.IsEmpty()) + throw new InvalidOperationException("There is no key specified for Google Translate."); + + // Replace line breaks by HTML tag, otherwise the API will remove lines + phrase = phrase.Replace(Environment.NewLine, LINE_BREAK_HTML); + + var request = "https://www.googleapis.com/language/translate/v2?key={0}&q={1}&source={2}&target={3}".FormatWith(key, HttpUtility.UrlEncode(phrase), languageIsoCodeFrom.ToLower(), languageIsoCodeTo.ToLower()); + if (request.Length > GOOGLE_TRANSLATE_LIMIT) + throw new ArgumentOutOfRangeException("Cannot use google translate with queries larger than {0} characters".FormatWith(GOOGLE_TRANSLATE_LIMIT)); + + try + { + var response = (await new WebClient().DownloadDataTaskAsync(request)).ToString(Encoding.UTF8); + + if (response.Contains(GOOGLE_TERMS_OF_SERVICE_ABUSE_MESSAGE, caseSensitive: false)) + { + IsGoogleTranslateMisconfigured = true; + return null; + } + else + { + var ser = new DataContractJsonSerializer(typeof(GoogleTranslateJsonResponseRootObject)); + var stream = new MemoryStream(Encoding.UTF8.GetBytes(response)); + var rootObjectResponse = ser.ReadObject(stream) as GoogleTranslateJsonResponseRootObject; + var result = rootObjectResponse.data.translations[0].translatedText; + result = result.Replace(LINE_BREAK_UNICODE, Environment.NewLine); // Decode line breaks + return HttpUtility.HtmlDecode(result); + } + } + catch + { + // No Logging needed + return null; + } + } + + /// + /// Detect the language of a phrase. + /// The API can translate multiple piece of text in the same time, if needed create a function with parameter "params string phrase" and return a list of GoogleAutoDetectLanguage. + /// + public static async Task GoogleAutodetectLanguage(string phrase) + { + if (IsGoogleTranslateMisconfigured) + return null; + + if (!Config.Get("Enable.Google.Autodetect", defaultValue: false)) + return null; + + var key = Config.Get("Google.Translate.Key"); + if (key.IsEmpty()) + throw new InvalidOperationException("There is no key specified for Google Translate."); + + var request = "https://www.googleapis.com/language/translate/v2/detect?key={0}&q={1}".FormatWith(key, HttpUtility.UrlEncode(phrase)); + if (request.Length > GOOGLE_TRANSLATE_LIMIT) + throw new ArgumentOutOfRangeException("Cannot use google translate with queries larger than {0} characters".FormatWith(GOOGLE_TRANSLATE_LIMIT)); + + try + { + var response = Encoding.UTF8.GetString(await new WebClient().DownloadDataTaskAsync(request)); + + if (response.Contains(GOOGLE_TERMS_OF_SERVICE_ABUSE_MESSAGE, caseSensitive: false)) + { + IsGoogleTranslateMisconfigured = true; + return null; + } + else + { + var ser = new DataContractJsonSerializer(typeof(GoogleAutoDetectJsonResponseRootObject)); + var stream = new MemoryStream(Encoding.UTF8.GetBytes(response)); + var rootObjectResponse = ser.ReadObject(stream) as GoogleAutoDetectJsonResponseRootObject; + var dectection = rootObjectResponse.data.detections[0][0]; + return new GoogleAutodetectResponse(dectection.language, dectection.confidence); + } + } + catch + { + // No Logging needed + return null; + } + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.ImpersonationSession/ImpersonationSession.cs b/Services/Olive.Services.ImpersonationSession/ImpersonationSession.cs new file mode 100644 index 000000000..db16ad646 --- /dev/null +++ b/Services/Olive.Services.ImpersonationSession/ImpersonationSession.cs @@ -0,0 +1,125 @@ +using System; +using System.Security.Principal; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Olive.Entities; +using Olive.Services.Globalization; +using Olive.Web; + +namespace Olive.Services.ImpersonationSession +{ + /// + /// Defines an admin user who can impersonate other users. + /// + public interface IImpersonator : IUser, IIdentity, IPrincipal + { + /// + /// A unique single-use-only cookie-based token to specify the currently impersonated user session. + /// + string ImpersonationToken { get; set; } + + /// + /// Determines if this user can impersonate the specified other user. + /// + bool CanImpersonate(IUser user); + } + + /// + /// Provides the business logic for ImpersonationContext class. + /// + public class ImpersonationSession + { + /// + /// Provides the current user. + /// + public static Func CurrentUserProvider = GetCurrentUser; + + static HttpContext Context => Web.Context.Http; + + /// + /// Determines if the current user is impersonated. + /// + public static async Task IsImpersonated() => await GetImpersonator() != null; + + /// + /// Impersonates the specified user by the current admin user. + /// + /// If not specified, the current HTTP request's URL will be used. + public static async Task Impersonate(IUser user, bool redirectToHome = true, string originalUrl = null) + { + if (user == null) throw new ArgumentNullException(nameof(user)); + + var admin = CurrentUserProvider?.Invoke() as IImpersonator; + + if (admin == null) + throw new InvalidOperationException("The current user is not an IImpersonator."); + + if (!admin.CanImpersonate(user)) + throw new InvalidOperationException("The current user is not allowed to impersonate the specified user."); + + var token = Guid.NewGuid().ToString(); + + await Entity.Database.Update(admin, o => o.ImpersonationToken = token); + + SetImpersonationToken(token); + + SetOriginalUrl(originalUrl.Or(Context.Request.ToRawUrl())); + + user.LogOn(); + + if (redirectToHome && !Context.Request.IsAjaxCall()) + Context.Response.Redirect("~/"); + } + + /// + /// Ends the current impersonation session. + /// + public static async Task End() + { + if (!await IsImpersonated()) return; + + var admin = await GetImpersonator(); + + await Entity.Database.Update(admin, o => o.ImpersonationToken = null); + + admin.LogOn(); + + var returnUrl = await GetOriginalUrl(); + SetOriginalUrl(null); + SetImpersonationToken(null); + + if (!Context.Request.IsAjaxCall()) + Context.Response.Redirect(returnUrl); + } + + static IUser GetCurrentUser() + { + var result = Context.User as IIdentity; + if (result == null || !result.IsAuthenticated) return null; + + return result as IUser; + } + + /// + /// Gets the original user who impersonated the current user. + /// + public static async Task GetImpersonator() + { + var user = CurrentUserProvider?.Invoke(); + if (user == null || user.IsInRole("Guest") || user.IsInRole("Anonymous")) return null; + + var token = await ImpersonationToken; + if (token.IsEmpty()) return null; + + return await Entity.Database.FirstOrDefault(x => x.ImpersonationToken == token); + } + + static Task ImpersonationToken => CookieProperty.Get("Impersonation.Token"); + + static void SetImpersonationToken(string value) => CookieProperty.Set("Impersonation.Token", value); + + public static async Task GetOriginalUrl() => (await CookieProperty.Get("Impersonation.Original.Url")).Or("~/"); + + public static void SetOriginalUrl(string value) => CookieProperty.Set("Impersonation.Original.Url", value); + } +} \ No newline at end of file diff --git a/Services/Olive.Services.ImpersonationSession/Olive.Services.ImpersonationSession.csproj b/Services/Olive.Services.ImpersonationSession/Olive.Services.ImpersonationSession.csproj new file mode 100644 index 000000000..4e3ce4227 --- /dev/null +++ b/Services/Olive.Services.ImpersonationSession/Olive.Services.ImpersonationSession.csproj @@ -0,0 +1,24 @@ + + + + netcoreapp2.0 + + + + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.ImpersonationSession.xml + 1701;1702;1705;1591;1573 + + + + + + + + + + + + + + diff --git a/Services/Olive.Services.ImpersonationSession/Package.nuspec b/Services/Olive.Services.ImpersonationSession/Package.nuspec new file mode 100644 index 000000000..ad1a1ece7 --- /dev/null +++ b/Services/Olive.Services.ImpersonationSession/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.ImpersonationSession + 1.0.3 + Olive ImpersonationSession (service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Integration/IIntegrationQueueItem.cs b/Services/Olive.Services.Integration/IIntegrationQueueItem.cs new file mode 100644 index 000000000..5b1a8dce8 --- /dev/null +++ b/Services/Olive.Services.Integration/IIntegrationQueueItem.cs @@ -0,0 +1,47 @@ +using System; +using Olive.Entities; + +namespace Olive.Services.Integration +{ + /// + /// Stores one item requested for processing via an external service. + /// + public interface IIntegrationQueueItem : IEntity + { + /// + /// The name of the integration service used for processing this item. + /// + string IntegrationService { get; set; } + + /// + /// The body of the request being sent to the integration service. + /// + string Request { get; set; } + + /// + /// The response generated by the remote service. + /// + string Response { get; set; } + + /// + /// The error encountered in processing this item. + /// + string ErrorResponse { get; set; } + + /// + /// Specifies the date and time when this item was picked for processing. + /// If it's null, it means this item has not been picked yet. + /// + DateTime? DatePicked { get; set; } + + /// + /// Specifies the date and time when this item was first requested and added to the queue. + /// + DateTime RequestDate { get; set; } + + /// + /// Specifies the date when the response was retrieved from the remote service. + /// + DateTime? ResponseDate { get; set; } + } +} diff --git a/Services/Olive.Services.Integration/IServiceImplementor.cs b/Services/Olive.Services.Integration/IServiceImplementor.cs new file mode 100644 index 000000000..4a95c0944 --- /dev/null +++ b/Services/Olive.Services.Integration/IServiceImplementor.cs @@ -0,0 +1,23 @@ +namespace Olive.Services.Integration +{ + // /// + // /// All integration services should implement this interface. + // /// Each service should be registered At application start-up time by calling: + // /// IntegrationManager.Register[] + // /// + // public interface IIntegrationService : IIntegrationService + // { + // /// + // /// It will process the specified request, send it to the remote service, and return the response. + // /// + // string GetResponse(string request); + // } + + public interface IServiceImplementor + { + /// + /// It will process the specified request, send it to the remote service, and return the response. + /// + TResponse GetResponse(TRequest request); + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Integration/IntegrationExtensions.cs b/Services/Olive.Services.Integration/IntegrationExtensions.cs new file mode 100644 index 000000000..b0fd2c062 --- /dev/null +++ b/Services/Olive.Services.Integration/IntegrationExtensions.cs @@ -0,0 +1,11 @@ +namespace Olive +{ + using Services.Integration; + + public static class IntegrationExtensions + { + public static bool IsInProcess(this IIntegrationQueueItem item) => item.DatePicked != null; + + public static bool IsProcessed(this IIntegrationQueueItem item) => item.ResponseDate != null; + } +} diff --git a/Services/Olive.Services.Integration/IntegrationManager.cs b/Services/Olive.Services.Integration/IntegrationManager.cs new file mode 100644 index 000000000..6dd4704b4 --- /dev/null +++ b/Services/Olive.Services.Integration/IntegrationManager.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Olive.Entities; + +namespace Olive.Services.Integration +{ + /// + /// Provides services for integration services. + /// + public class IntegrationManager + { + internal const int WaitInterval = 50; + + static Type IntegrationQueueItemType; + + /// + /// Key = TRequest|TResponse + /// Value = TService + /// + internal static Dictionary IntegrationServices = new Dictionary(); + + static AsyncLock PickLock = new AsyncLock(); + + /// + /// Will find a Service Registered to process the item. + /// + public static async Task Process(IIntegrationQueueItem item) + { + if (item.ResponseDate.HasValue) return item; // Already processed: + + #region Pick the item + + Type serviceType; + + using (await PickLock.Lock()) + { + if (item.DatePicked.HasValue) + // Already picked, let the other thread finish its job: + return null; + + if (!item.IsNew) item = (IIntegrationQueueItem)item.Clone(); + + serviceType = IntegrationServices.GetOrDefault(item.IntegrationService); + if (serviceType == null) return null; + + item.DatePicked = LocalTime.Now; + await Entity.Database.Save(item); + item = (IIntegrationQueueItem)(await Entity.Database.Reload(item)).Clone(); + } + + // TOOD: This is not thread safe in multi-server (web farm) scenarios. + // To make it completely safe, we need a single StoredProc or SQL command that will do both at the same time, with a lock at the DB level. + #endregion + + var service = serviceType.CreateInstance(); + + var serviceInterface = serviceType.GetInterfaces().Single(x => x.Name.Contains("IServiceImplementor")); + + var typeOfRequest = serviceInterface.GetGenericArguments().First(); + + var request = JsonConvert.DeserializeObject(item.Request, typeOfRequest); + + var method = serviceType.GetMethod("GetResponse", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public); + + try + { + var response = method.Invoke(service, new[] { request }); + item.Response = JsonConvert.SerializeObject(response); + } + catch (Exception ex) + { + item.ErrorResponse = ex.ToString(); + } + + item.ResponseDate = LocalTime.Now; + await Entity.Database.Save(item); + return item; + } + + public static async Task Process(string id) => await Process(await Entity.Database.Get(id)); + + /// + /// Uses the right Integration Service to process the outstanding items in all queues. + /// This should be called as an Automated Task in the application. + /// + public static async Task> ProcessOutstandingItems() + { + var result = new List(); + + foreach (var serviceType in IntegrationServices.Keys) + result.AddRange(await ProcessOutstandingItems(serviceType)); + + return result; + } + + /// + /// Uses the right Integration Service to process the next item in that queue. + /// + public static async Task> ProcessOutstandingItems(string serviceName) + { + var serviceType = IntegrationServices.GetOrDefault(serviceName); + if (serviceType == null) throw new Exception("Integration service not registered:" + serviceName); + + var result = new List(); + + foreach (var item in await Entity.Database.GetList(x => x.IntegrationService == serviceName && x.DatePicked == null)) + { + await Entity.Database.Update(item, x => x.DatePicked = LocalTime.Now); + // TODO: Use T-SQL to fetch an item and return it too. + + await Process(item); + result.Add(item); + } + + return result; + } + + static void DiscoverQueueType() + { + if (IntegrationQueueItemType != null) return; + + try + { + IntegrationQueueItemType = typeof(IIntegrationQueueItem).FindImplementerClasses().Single(x => !x.IsAbstract); + } + catch (Exception ex) + { + throw new Exception("Cannot find the correct implementation type for IIntegrationQueueItem.", ex); + } + } + + public static string GetServiceKey(Type serviceType) + { + var serviceBaseType = serviceType.WithAllParents() + .FirstOrDefault(x => x.IsGenericType && x.Name.StartsWith("IntegrationService")); + + if (serviceBaseType == null) + throw new Exception("TService should inherit from IntegrationService"); + + var types = serviceBaseType.GetGenericArguments(); + return GetServiceKey(types.First(), types.Last()); + } + + public static string GetServiceKey() where TService : IntegrationService => GetServiceKey(typeof(TService)); + + static string GetServiceKey(Type request, Type response) => request.Name + "|" + response.Name; + + /// + /// Registers an integration service. + /// + public static void Register() where TService : IServiceImplementor + { + DiscoverQueueType(); + + var key = GetServiceKey(typeof(TRequest), typeof(TResponse)); + + IntegrationServices.Add(key, typeof(TService)); + } + + /// + /// Inserts a queu item to call this service and waits until the item is processed. + /// Then it will return the response. + /// + public static async Task Request(TRequest request) + { + var item = await RequestAsync(request); + + return await AwaitResponse(item); + } + + /// + /// Inserts a request in the queue and immediately returns without waiting for a response. + /// It will return the token string for this request, that can be queried later on for a response (using Await Response). + /// + public static async Task RequestAsync(TRequest request) + { + DiscoverQueueType(); + + var item = IntegrationQueueItemType.CreateInstance(); + item.IntegrationService = GetServiceKey(request.GetType(), typeof(TResponse)); + // item.Request = new JavaScriptSerializer().Serialize(request); + item.Request = JsonConvert.SerializeObject(request); + + item.RequestDate = LocalTime.Now; + await Entity.Database.Save(item); + + return item.ID.ToString(); + } + + /// + /// It will wait until a response is provided by another thread to the integration queue item specified by its token. + /// + public static async Task AwaitResponse(string requestToken, int waitIntervals = WaitInterval) + { + while (true) + { + // var item = Database.Get(requestToken); + + var processedItem = await Process(requestToken); + + if (processedItem != null) + { + // var result = new JavaScriptSerializer().Deserialize(processedItem.Response, typeof(TResponse)); + var result = JsonConvert.DeserializeObject(processedItem.Response, typeof(TResponse)); + return (TResponse)result; + } + + await Task.Delay(waitIntervals); + } + } + + /// + /// Injects an asyncronous waiter which will inject the provided response for one potential future request. + /// It will check every 5 milliseconds to see if a request item is inserted in the queue, and in that case respond to it. + /// + public static async Task InjectResponse(TResponse injectedResponse) + { + var service = GetServiceKey(typeof(TRequest), typeof(TResponse)); + + // Get queue item: + while (true) + { + var item = await Entity.Database.Of().OrderBy(x => x.RequestDate) + .Where(x => x.IntegrationService == service && x.ResponseDate == null) + .FirstOrDefault(); + + if (item != null) + { + item = item.Clone() as IIntegrationQueueItem; + + // item.Response = new JavaScriptSerializer().Serialize(injectedResponse); + item.Response = JsonConvert.SerializeObject(injectedResponse); + item.ResponseDate = LocalTime.Now; + + await Entity.Database.Save(item); + + return; + } + + await Task.Delay(5); + } + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Integration/IntegrationService.cs b/Services/Olive.Services.Integration/IntegrationService.cs new file mode 100644 index 000000000..b856ba3a7 --- /dev/null +++ b/Services/Olive.Services.Integration/IntegrationService.cs @@ -0,0 +1,41 @@ +using System.Threading.Tasks; + +namespace Olive.Services.Integration +{ + public abstract class IntegrationService { } + + public class IntegrationService : IntegrationService + { + /// + /// Inserts a queu item to call this service and waits until the item is processed. + /// Then it will return the response. + /// + public static Task Request(TRequest request) => IntegrationManager.Request(request); + + /// + /// Registers an integration service implementor. + /// + public static void RegisterImplementor() where TService : IServiceImplementor => + IntegrationManager.Register(); + + /// + /// Injects an asyncronous waiter which will inject the provided response for one potential future request. + /// It will check every 5 milliseconds to see if a request item is inserted in the queue, and in that case respond to it. + /// + public static Task InjectResponse(TResponse injectedResponse) => + IntegrationManager.InjectResponse(injectedResponse); + + /// + /// It will wait until a response is provided by another thread to the integration queue item specified by its token. + /// + public static Task AwaitResponse(string requestToken, int waitIntervals = IntegrationManager.WaitInterval) => + IntegrationManager.AwaitResponse(requestToken, waitIntervals); + + /// + /// Inserts a request in the queue and immediately returns without waiting for a response. + /// It will return the token string for this request, that can be queried later on for a response (using Await Response). + /// + public static Task RequestAsync(TRequest request) => + IntegrationManager.RequestAsync(request); + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Integration/IntegrationTestInjector.cs b/Services/Olive.Services.Integration/IntegrationTestInjector.cs new file mode 100644 index 000000000..e2ba79f74 --- /dev/null +++ b/Services/Olive.Services.Integration/IntegrationTestInjector.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Olive.Entities; + +namespace Olive.Services.Integration +{ + public class IntegrationTestInjector + { + const int INJECTOR_AGENT_WAIT_INTERVALS = 10;// ms + + public static async Task Inject(Type serviceType, string request, string response) + { + var serviceKey = IntegrationManager.GetServiceKey(serviceType); + + while (true) + { + var queueItem = await Entity.Database.FirstOrDefault(i => + i.IntegrationService == serviceKey && + i.ResponseDate == null && + (request.IsEmpty() || i.Request == request)); + + if (queueItem == null) + { + Thread.Sleep(INJECTOR_AGENT_WAIT_INTERVALS); + continue; + } + + await Entity.Database.Update(queueItem, i => + { + i.Response = response; + i.ResponseDate = LocalTime.Now; + }); + + break; + } + } + } +} diff --git a/Services/Olive.Services.Integration/Olive.Services.Integration.csproj b/Services/Olive.Services.Integration/Olive.Services.Integration.csproj new file mode 100644 index 000000000..59bfd6669 --- /dev/null +++ b/Services/Olive.Services.Integration/Olive.Services.Integration.csproj @@ -0,0 +1,19 @@ + + + + netcoreapp2.0 + Olive.Services.Integration + + + + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.Integration.xml + 1701;1702;1705;1591;1573 + + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Integration/Package.nuspec b/Services/Olive.Services.Integration/Package.nuspec new file mode 100644 index 000000000..c2c866317 --- /dev/null +++ b/Services/Olive.Services.Integration/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.Integration + 1.0.3 + Olive Integration (Service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.IpFilter/IpFilter.cs b/Services/Olive.Services.IpFilter/IpFilter.cs new file mode 100644 index 000000000..0d47f9645 --- /dev/null +++ b/Services/Olive.Services.IpFilter/IpFilter.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Olive.Entities; +using Olive.Services.CSV; + +namespace Olive.Web +{ + public class IpFilter + { + static List BlockedCountryCodes = new List(); + static FileInfo CountryIpsFile => + Path.Combine(Blob.GetPhysicalFilesRoot(Blob.AccessMode.Secure).FullName, "IPBlock\\dbip-country.csv").AsFile(); + + static List> BlockedIpRanges; + + public static List SpecificallyAllowedIps = new List(); + public static List SpecificallyDisallowedIps = new List(); + + public static string BlockedAttemptResponse = "This website is not available in your region."; + public static Action OnBlockedAccessAttempt = EndWithMessage; + + #region Country and Region codes + + public static readonly string[] CountryCodes = "US,AU,CN,JP,TH,IN,MY,KR,SG,TW,HK,PH,VN,FR,DE,ES,IL,AT,NL,GB,SE,IT,AR,BE,FI,RU,GR,IE,BR,DK,PL,AE,UA,KZ,PT,SA,IR,NO,AS,CA,BA,EE,HU,RS,BG,ZA,VU,SY,KW,BH,LB,QA,OM,JO,CZ,PK,CH,IQ,TR,RO,BZ,MX,CL,CO,GE,MA,LV,AF,VG,EG,CY,HR,NG,LU,GT,UZ,KE,UY,MK,MT,PA,AZ,BI,ZM,ZW,PS,LT,SK,IS,SI,MD,AO,LI,SC,SN,JE,PY,BY,KG,RE,IM,BS,GG,GI,LY,AM,YE,CU,CR,BD,BO,GP,MQ,GY,ID,LK,XK,CX,DZ,EH,GN,KI,SO,AW,BB,BF,BJ,BL,BM,BN,BV,BW,CC,CD,CF,CG,CI,CK,CM,CV,CW,DJ,DM,DO,EC,ET,FJ,FK,FM,FO,GA,GD,GF,GH,GL,GM,GQ,GS,GU,GW,HT,JM,KM,KN,KY,LA,LC,LR,LS,MC,ME,MG,MH,ML,MM,MN,MO,MR,MS,MU,MV,MW,MZ,NA,NC,NE,NF,NI,NP,NR,NU,NZ,PG,PM,PN,PR,RW,SD,SH,SL,SM,SR,ST,SV,SZ,TC,TD,TG,TJ,TM,TN,TO,TT,TV,TZ,UG,UM,VA,VC,VE,VI,WF,WS,YT,AD,HN,IO,PE,AL,AI,KH,TK,AG,AQ,AX,BQ,BT,ER,HM,KP,MP,PF,PW,SB,SJ,TL,SS,TF,SX,MF".Split(','); + + public static readonly Dictionary CountriesInRegions = "US:NA,AU:OC,CN:AS,JP:AS,TH:AS,IN:AS,MY:AS,KR:AS,SG:AS,TW:AS,HK:AS,PH:AS,VN:AS,FR:EU,DE:EU,ES:EU,IL:AS,AT:EU,NL:EU,GB:EU,SE:EU,IT:EU,AR:SA,BE:EU,FI:EU,RU:EU,GR:EU,IE:EU,BR:SA,DK:EU,PL:EU,AE:AS,UA:EU,KZ:AS,PT:EU,SA:AS,IR:AS,NO:EU,AS:OC,CA:NA,BA:EU,EE:EU,HU:EU,RS:EU,BG:EU,ZA:AF,VU:OC,SY:AS,KW:AS,BH:AS,LB:AS,QA:AS,OM:AS,JO:AS,CZ:EU,PK:AS,CH:EU,IQ:AS,TR:EU,RO:EU,BZ:NA,MX:NA,CL:SA,CO:SA,GE:AS,MA:AF,LV:EU,AF:AS,VG:NA,EG:AF,CY:AS,HR:EU,NG:AF,LU:EU,GT:NA,UZ:AS,KE:AF,UY:SA,MK:EU,MT:EU,PA:NA,AZ:AS,BI:AF,ZM:AF,ZW:AF,PS:AS,LT:EU,SK:EU,IS:EU,SI:EU,MD:EU,AO:AF,LI:EU,SC:AF,SN:AF,JE:EU,PY:SA,BY:EU,KG:AS,RE:AF,IM:EU,BS:NA,GG:EU,GI:EU,LY:AF,AM:AS,YE:AS,CU:NA,CR:NA,BD:AS,BO:SA,GP:NA,MQ:NA,GY:SA,ID:AS,LK:AS,XK:EU,CX:AS,DZ:AF,EH:AF,GN:AF,KI:OC,SO:AF,AW:NA,BB:NA,BF:AF,BJ:AF,BL:NA,BM:NA,BN:AS,BV:AN,BW:AF,CC:AS,CD:AF,CF:AF,CG:AF,CI:AF,CK:OC,CM:AF,CV:AF,CW:SA,DJ:AF,DM:NA,DO:NA,EC:SA,ET:AF,FJ:OC,FK:SA,FM:OC,FO:EU,GA:AF,GD:NA,GF:SA,GH:AF,GL:NA,GM:AF,GQ:AF,GS:AN,GU:OC,GW:AF,HT:NA,JM:NA,KM:AF,KN:NA,KY:NA,LA:AS,LC:NA,LR:AF,LS:AF,MC:EU,ME:EU,MG:AF,MH:OC,ML:AF,MM:AS,MN:AS,MO:AS,MR:AF,MS:NA,MU:AF,MV:AS,MW:AF,MZ:AF,NA:AF,NC:OC,NE:AF,NF:OC,NI:NA,NP:AS,NR:OC,NU:OC,NZ:OC,PG:OC,PM:NA,PN:OC,PR:NA,RW:AF,SD:AF,SH:AF,SL:AF,SM:EU,SR:SA,ST:AF,SV:NA,SZ:AF,TC:NA,TD:AF,TG:AF,TJ:AS,TM:AS,TN:AF,TO:OC,TT:NA,TV:OC,TZ:AF,UG:AF,UM:OC,VA:EU,VC:NA,VE:SA,VI:NA,WF:OC,WS:OC,YT:AF,AD:EU,HN:NA,IO:AS,PE:SA,AL:EU,AI:NA,KH:AS,TK:OC,AG:NA,AQ:AN,AX:EU,BQ:SA,BT:AS,ER:AF,HM:AN,KP:AS,MP:OC,PF:OC,PW:OC,SB:OC,SJ:EU,TL:AS,SS:AF,TF:AN,SX:SA,MF:NA".Split(',').ToDictionary(x => x.Split(':').First(), x => x.Split(':').Last()); + + #endregion + + public static string[] GetCountryCodes(string regionCode) + { + if (!CountryIpsFile.Exists()) + throw new Exception($"Could not find the file '{CountryIpsFile.FullName}'.\r\nYou can download it from https://db-ip.com/db/download/country"); + + return CountriesInRegions.Where(i => i.Value == regionCode).Select(x => x.Key).ToArray(); + } + + /// + /// Sets the default policy for all IP addresses. + /// + public static void SetGlobalPolicy(Policy policy) + { + if (policy == Policy.Disallow) BlockedCountryCodes = CountryCodes.ToList(); + else if (policy == Policy.Allow) BlockedCountryCodes = new List(); + } + + /// + /// Sets the policy for specific IP addresses. These will override the global, region and country policies. + /// + public static void SetSpecificIpPolicy(Policy policy, params string[] ipAddresses) + { + foreach (var ip in ipAddresses) + { + var value = ToIpValue(ip); + + if (policy == Policy.Allow) + { + SpecificallyAllowedIps.Add(value); + SpecificallyDisallowedIps.Remove(value); + } + + if (policy == Policy.Disallow) + { + SpecificallyDisallowedIps.Add(value); + SpecificallyAllowedIps.Remove(value); + } + } + } + + /// + /// Sets the IP Filter policy. All Disallow policies should be set first, then all Allow policies. + /// + public static void SetCountryPolicy(Policy policy, params string[] countryCodes) + { + if (policy == Policy.Disallow) + BlockedCountryCodes = BlockedCountryCodes.Concat(countryCodes).Distinct().ToList(); + + if (policy == Policy.Allow) + BlockedCountryCodes = BlockedCountryCodes.Except(countryCodes).ToList(); + } + + /// + /// Sets the IP Filter policy. All Disallow policies should be set first, then all Allow policies. + /// + public static void SetRegionPolicy(Policy policy, params string[] regionCodes) => + regionCodes.Do(r => SetCountryPolicy(policy, GetCountryCodes(r))); + + /// + /// If the IP address of the current user is in a blocked list, then it will terminate the request with a response saying: + /// This website is not available in your region. + /// + public static async Task BlockIfNecessary(HttpContext httpContext) + { + if (await IsAllowed(httpContext.Connection.RemoteIpAddress.ToStringOrEmpty())) + OnBlockedAccessAttempt(httpContext); + } + + public static async Task IsAllowed(string ipAddress) + { + if (BlockedIpRanges == null) await LoadBlockedIpRanges(); + + var address = ToIpValue(ipAddress); + + if (SpecificallyDisallowedIps.Contains(address)) return false; + if (SpecificallyAllowedIps.Contains(address)) return true; + + return BlockedIpRanges.None(range => range.Contains(address)); + } + + static void EndWithMessage(HttpContext httpContext) => httpContext.Response.EndWith(BlockedAttemptResponse); + + static async Task LoadBlockedIpRanges() + { + var table = await CsvReader.Read(CountryIpsFile, isFirstRowHeaders: false); + + BlockedIpRanges = new List>(); + + foreach (var row in table.GetRows()) + { + if (!BlockedCountryCodes.Contains((string)row[2])) + continue; + + var from = ToIpValue((string)row[0]); + var to = ToIpValue((string)row[1]); + + BlockedIpRanges.Add(new Range(from, to)); + } + } + + static uint ToIpValue(string ipAddress) + { + try + { + var bytes = System.Net.IPAddress.Parse(ipAddress).GetAddressBytes(); + if (BitConverter.IsLittleEndian) + bytes = bytes.Reverse().ToArray(); + + return BitConverter.ToUInt32(bytes, 0); + } + catch (Exception ex) + { + throw new Exception($"Cannot convert the specified IP address string of '{ipAddress}' to unit IP address value.", ex); + } + } + + public enum Policy { Allow, Disallow } + + public class Region + { + public readonly static string Antarctica = "AN"; + public readonly static string Africa = "AF"; + public readonly static string Asia = "AS"; + public readonly static string Europe = "EU"; + public readonly static string NorthAmerica = "NA"; + public readonly static string Oceania = "OC"; + public readonly static string SouthAmerica = "SA"; + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.IpFilter/Olive.Services.IpFilter.csproj b/Services/Olive.Services.IpFilter/Olive.Services.IpFilter.csproj new file mode 100644 index 000000000..fc077a791 --- /dev/null +++ b/Services/Olive.Services.IpFilter/Olive.Services.IpFilter.csproj @@ -0,0 +1,22 @@ + + + + netcoreapp2.0 + + + + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.IpFilter.xml + 1701;1702;1705;1591;1573 + + + + + + + + + + + + diff --git a/Services/Olive.Services.IpFilter/Package.nuspec b/Services/Olive.Services.IpFilter/Package.nuspec new file mode 100644 index 000000000..d11057ae4 --- /dev/null +++ b/Services/Olive.Services.IpFilter/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.IpFilter + 1.0.3 + Olive Ip Filter (Service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.PDF/IHtml2PdfConverter.cs b/Services/Olive.Services.PDF/IHtml2PdfConverter.cs new file mode 100644 index 000000000..919cb38b1 --- /dev/null +++ b/Services/Olive.Services.PDF/IHtml2PdfConverter.cs @@ -0,0 +1,7 @@ +namespace Olive.Services +{ + public interface IHtml2PdfConverter + { + byte[] GetPdfFromUrlBytes(string url); + } +} diff --git a/Services/Olive.Services.PDF/Olive.Services.PDF.csproj b/Services/Olive.Services.PDF/Olive.Services.PDF.csproj new file mode 100644 index 000000000..f6918c34b --- /dev/null +++ b/Services/Olive.Services.PDF/Olive.Services.PDF.csproj @@ -0,0 +1,19 @@ + + + + netcoreapp2.0 + Olive.Services.PDF + + + + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.PDF.xml + 1701;1702;1705;1591;1573 + + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.PDF/Package.nuspec b/Services/Olive.Services.PDF/Package.nuspec new file mode 100644 index 000000000..28a88ea1b --- /dev/null +++ b/Services/Olive.Services.PDF/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.PDF + 1.0.3 + Olive PDF (Service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.PDF/PdfService.cs b/Services/Olive.Services.PDF/PdfService.cs new file mode 100644 index 000000000..bec2595f1 --- /dev/null +++ b/Services/Olive.Services.PDF/PdfService.cs @@ -0,0 +1,38 @@ +using System; + +namespace Olive.Services +{ + /// + /// Provides PDF services. + /// + public static class PdfService + { + const string HTML2PDF_CONVERTER_CONFIG_KEY = "Html2Pdf.Converter.Type"; + const string DEFAULT_HTML2PDF_TYPE = "Geeks.Html2PDF.Winnovative.Html2PdfConverter, Geeks.Html2PDF.Winnovative"; + + /// + /// Creates an instance of Html 2 PDF converter service. + /// + public static IHtml2PdfConverter CreateHtml2PdfConverter() + { + var typeName = Config.Get(HTML2PDF_CONVERTER_CONFIG_KEY, DEFAULT_HTML2PDF_TYPE); + + if (typeName.IsEmpty()) + throw new Exception($"Could not find the Html2Pdf converter type. The AppSetting of '{HTML2PDF_CONVERTER_CONFIG_KEY}' is not defined."); + + Type type; + + try + { + type = Type.GetType(typeName); + if (type == null) throw new Exception("Could not load the type: " + typeName); + } + catch (Exception ex) + { + throw new Exception("Unable to find the specified type: " + typeName, ex); + } + + return type.CreateInstance(); + } + } +} diff --git a/Services/Olive.Services.SMS/ISMSSender.cs b/Services/Olive.Services.SMS/ISMSSender.cs new file mode 100644 index 000000000..60de5f9be --- /dev/null +++ b/Services/Olive.Services.SMS/ISMSSender.cs @@ -0,0 +1,15 @@ +namespace Olive.Services.SMS +{ + /// + /// Represents a component that actually delivers SMS messages. + /// This should be implemented for any 3rd party SMS gateway. + /// + public interface ISMSSender + { + /// + /// Delivers the specified SMS message. + /// The implementation of this method should not handle exceptions. Any exceptions will be logged by the engine. + /// + void Deliver(ISmsQueueItem sms); + } +} diff --git a/Services/Olive.Services.SMS/ISmsQueueItem.cs b/Services/Olive.Services.SMS/ISmsQueueItem.cs new file mode 100644 index 000000000..0d8c7f684 --- /dev/null +++ b/Services/Olive.Services.SMS/ISmsQueueItem.cs @@ -0,0 +1,44 @@ +using System; +using Olive.Entities; + +namespace Olive.Services.SMS +{ + /// + /// Represents a sendable SMS item generated by the application. + /// + [LogEvents(false)] + [CacheObjects(false)] + public interface ISmsQueueItem : IEntity + { + /// + /// Gets or sets the date this SMS should be sent. + /// + DateTime Date { get; set; } + + /// + /// Gets or sets the date when this SMS was successfully sent. + /// + DateTime? DateSent { get; set; } + + /// + /// Gets or sets the Sender Name. + /// + string SenderName { get; set; } + + /// + /// Gets or sets the SMS text. + /// + string Text { get; set; } + + /// + /// Gets or sets the SMS recipient number. + /// + string To { get; set; } + + /// + /// Gets or sets the number of times sending this email has been tried. + /// + int Retries { get; set; } + + } +} diff --git a/Services/Olive.Services.SMS/Olive.Services.SMS.csproj b/Services/Olive.Services.SMS/Olive.Services.SMS.csproj new file mode 100644 index 000000000..dee204e27 --- /dev/null +++ b/Services/Olive.Services.SMS/Olive.Services.SMS.csproj @@ -0,0 +1,19 @@ + + + + netcoreapp2.0 + Olive.Services.SMS + + + + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.SMS.xml + 1701;1702;1705;1591;1573 + + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.SMS/Package.nuspec b/Services/Olive.Services.SMS/Package.nuspec new file mode 100644 index 000000000..174173e2c --- /dev/null +++ b/Services/Olive.Services.SMS/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.SMS + 1.0.3 + Olive SMS (Service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.SMS/SmsExtensions.cs b/Services/Olive.Services.SMS/SmsExtensions.cs new file mode 100644 index 000000000..4ad056240 --- /dev/null +++ b/Services/Olive.Services.SMS/SmsExtensions.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading.Tasks; +using Olive.Entities; + +namespace Olive.Services.SMS +{ + public static class SmsExtensions + { + /// + /// Records an unsuccessful attempt to send this SMS. + /// + public static async Task RecordRetry(this ISmsQueueItem sms) + { + if (sms.IsNew) throw new InvalidOperationException(); + + await Entity.Database.Update(sms, s => s.Retries++); + + // Also update this local instance: + sms.Retries++; + } + + /// + /// Updates the DateSent field of this item and then soft deletes it. + /// + public static Task MarkSent(this ISmsQueueItem sms) + { + return Entity.Database.EnlistOrCreateTransaction(() => Entity.Database.Update(sms, o => o.DateSent = LocalTime.Now)); + } + + /// + /// Sends the specified SMS item. + /// It will try several times to deliver the message. The number of retries can be specified in AppConfig of "SMS.Maximum.Retries". + /// If it is not declared in web.config, then 3 retires will be used. + /// Note: The actual SMS Sender component must be implemented as a public type that implements ISMSSender interface. + /// The assembly qualified name of that component, must be specified in AppConfig of "SMS.Sender.Type". + /// + public static Task Send(this ISmsQueueItem sms) => SmsService.Send(sms); + } +} \ No newline at end of file diff --git a/Services/Olive.Services.SMS/SmsSendingEventArgs.cs b/Services/Olive.Services.SMS/SmsSendingEventArgs.cs new file mode 100644 index 000000000..859b1710c --- /dev/null +++ b/Services/Olive.Services.SMS/SmsSendingEventArgs.cs @@ -0,0 +1,16 @@ +namespace Olive.Services.SMS +{ + using System; + + public class SmsSendingEventArgs + { + public ISmsQueueItem Item { get; } + + public Exception Error { get; internal set; } + + public SmsSendingEventArgs(ISmsQueueItem item) + { + Item = item; + } + } +} diff --git a/Services/Olive.Services.SMS/SmsService.cs b/Services/Olive.Services.SMS/SmsService.cs new file mode 100644 index 000000000..80ccf2438 --- /dev/null +++ b/Services/Olive.Services.SMS/SmsService.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading.Tasks; +using Olive.Entities; + +namespace Olive.Services.SMS +{ + public static class SmsService + { + /// + /// Occurs when an exception happens when sending an sms. Sender parameter will be the ISmsQueueItem instance that couldn't be sent. + /// + public static readonly AsyncEvent SendError = new AsyncEvent(); + + /// + /// Sends the specified SMS item. + /// It will try several times to deliver the message. The number of retries can be specified in AppConfig of "SMS.Maximum.Retries". + /// If it is not declared in web.config, then 3 retires will be used. + /// Note: The actual SMS Sender component must be implemented as a public type that implements ISMSSender interface. + /// The assembly qualified name of that component, must be specified in AppConfig of "SMS.Sender.Type". + /// + public static async Task Send(ISmsQueueItem smsItem) + { + if (smsItem.Retries > Config.Get("SMS.Maximum.Retries", 3)) + return false; + try + { + ISMSSender sender; + try + { + sender = Activator.CreateInstance(Type.GetType(Config.Get("SMS.Sender.Type"))) as ISMSSender; + + if (sender == null) + throw new Exception("Type is not defined, or it does not implement ISMSSender"); + } + catch (Exception ex) + { + Log.Error("Can not instantiate the sms sender from App config of " + Config.Get("SMS.Sender.Type"), ex); + return false; + } + + sender.Deliver(smsItem); + + await Entity.Database.Update(smsItem, o => o.DateSent = LocalTime.Now); + return true; + } + catch (Exception ex) + { + await SendError.Raise(new SmsSendingEventArgs(smsItem) { Error = ex }); + Log.Error("Can not send the SMS queue item.", ex); + await smsItem.RecordRetry(); + return false; + } + } + + public static async Task SendAll() + { + foreach (var sms in await Entity.Database.GetList(i => i.DateSent == null)) + await sms.Send(); + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.TaskAutomation/AutomatedTask.Logic.cs b/Services/Olive.Services.TaskAutomation/AutomatedTask.Logic.cs new file mode 100644 index 000000000..1fb785fae --- /dev/null +++ b/Services/Olive.Services.TaskAutomation/AutomatedTask.Logic.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Olive.Entities; + +namespace Olive.Services.TaskAutomation +{ + partial class AutomatedTask + { + Action Action; + + public TimeSpan Intervals { get; set; } + + Task RunnerTask; + + CancellationTokenSource CancellationTokenSource; + + static AsyncLock ExecutionPersistenceAsyncLock = new AsyncLock(); + + /// + /// Creates a new AutomatedTask instance. + /// + public AutomatedTask(Action action) + : this() + { + Action = action ?? throw new ArgumentNullException(nameof(action)); + + CancellationTokenSource = new CancellationTokenSource(); + + Status = AutomatedTaskStatus.AwaitingFirstRun; + } + + public AutomatedTaskStatus Status { get; private set; } + + /// + /// Starts this automated task. + /// + public void Start() => RunnerTask = Process(CancellationTokenSource.Token); + + /// + /// Restarts this task. + /// + public void Restart() + { + try + { + CancellationTokenSource.Cancel(); + } + catch + { + // No Logging needed + } + + CancellationTokenSource = new CancellationTokenSource(); + RunnerTask = Process(CancellationTokenSource.Token); + } + + #region Persistent Execution Log + + async Task GetInitialNextTry() + { + var result = LocalTime.Now; + + using (await ExecutionPersistenceAsyncLock.Lock()) + { + if (ShouldPersistExecution()) + { + var file = GetExecutionStatusPath(); + + if (file.Exists()) + { + try + { + var content = await file.ReadAllText(); + if (content.HasValue()) + { + var taskData = XElement.Parse(content).Elements().FirstOrDefault(e => e.GetValue("@Name") == Name); + + if (taskData != null) + { + result = DateTime.FromOADate(taskData.GetValue("@LastRun").To()).ToLocalTime(); + result = result.Add(Intervals); + } + } + } + catch + { + // No Logging needed + // The file is perhaps corrupted. + } + } + } + } + + return result; + } + + static FileInfo GetExecutionStatusPath() + { + var result = Config.Get("Automated.Tasks:Status.Path"); + + if (result.HasValue()) + { + if (!result.StartsWith("\\") && result[1] != ':') + { + // Relative pth: + result = AppDomain.CurrentDomain.GetPath(result); + } + + result.AsFile().Directory.EnsureExists(); + return result.AsFile(); + } + + return Blob.GetPhysicalFilesRoot(Blob.AccessMode.Secure).EnsureExists().GetFile("AutomatedTasks.Status.xml"); + } + + static bool ShouldPersistExecution() => Config.Get("Automated.Tasks:Persist.Execution", defaultValue: false); + + public static async Task DeleteExecutionStatusHistory() + { + using (await ExecutionPersistenceAsyncLock.Lock()) + await GetExecutionStatusPath().Delete(harshly: true); + } + + async Task PersistExecution() + { + if (!ShouldPersistExecution()) return; + + var path = GetExecutionStatusPath(); + + using (await ExecutionPersistenceAsyncLock.Lock()) + { + var data = new XElement("Tasks"); + + if (path.Exists()) + { + await new Func(async () => + { + try + { + var content = await path.ReadAllText(); + if (content.HasValue()) data = XElement.Parse(content); + } + catch (FileNotFoundException) + { + // Somehow another thread has deleted it. + } + }).Invoke(10, TimeSpan.FromMilliseconds(300)); + } + + var element = data.Elements().FirstOrDefault(e => e.GetValue("@Name") == Name); + + if (element == null) + data.Add(new XElement("Task", new XAttribute("Name", Name), new XAttribute("LastRun", LocalTime.Now.ToUniversalTime().ToOADate().ToString()))); + else + element.Attribute("LastRun").Value = LocalTime.Now.ToUniversalTime().ToOADate().ToString(); + + try + { + await path.WriteAllText(data.ToString()); + } + catch + { + // No Logging needed + // Error? + } + } + } + + #endregion + + [System.Diagnostics.DebuggerStepThrough] + async Task Process(CancellationToken cancellationToken) + { + NextTry = await GetInitialNextTry(); + + // Startup delay: + if (Delay > TimeSpan.Zero) + { + NextTry = NextTry.Value.Add(Delay); + await Task.Delay(Delay, cancellationToken); + } + + if (cancellationToken.IsCancellationRequested) return; + + // Should we still wait? + var stillWait = NextTry.Value - LocalTime.Now; + if (stillWait.TotalMilliseconds > int.MaxValue) await Task.Delay(int.MaxValue, cancellationToken); + else if (stillWait > TimeSpan.Zero) await Task.Delay(stillWait, cancellationToken); + + for (; /* ever */ ; ) + { + if (cancellationToken.IsCancellationRequested) + break; + + await Execute(); + + if (cancellationToken.IsCancellationRequested) + break; + + // Now wait for the next itnerval: + await WaitEnough(cancellationToken); + } + } + + [System.Diagnostics.DebuggerStepThrough] + async Task WaitEnough(CancellationToken cancellationToken) => await Task.Delay(Intervals, cancellationToken); + + public async Task Execute() + { + NextTry = null; + + if (AsyncGroup != null) + { + Status = AutomatedTaskStatus.WaitingForLock; + using (await AsyncGroup.Lock()) await DoExecute(); + } + else + { + await DoExecute(); + } + + NextTry = LocalTime.Now.Add(Intervals); + } + + async Task DoExecute() + { + CurrentStartTime = LastRunStart = LocalTime.Now; + + try + { + Status = AutomatedTaskStatus.Running; + Action?.Invoke(this); + + if (RecordSuccess) + { + try { await ApplicationEventManager.RecordScheduledTask(Name, CurrentStartTime.Value); } + catch { /*Problem in logging*/ } + } + + Status = AutomatedTaskStatus.CompletedAwaitingNextRun; + } + catch (Exception ex) + { + // if (!WebTestManager.IsTddExecutionMode()) + { + if (RecordFailure) + { + try { await ApplicationEventManager.RecordScheduledTask(Name, CurrentStartTime.Value, ex); } + catch { /*Problem in logging*/ } + } + } + + Status = AutomatedTaskStatus.FailedAwaitingNextRun; + } + finally + { + CurrentStartTime = null; + LastRunEnd = LocalTime.Now; + await PersistExecution(); + } + } + + public static IEnumerable GetAllTasks() + { + var classes = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("TaskManager")).ExceptNull().Distinct().ToList(); + + if (classes.None()) + throw new Exception("There is no class named TaskManager in the current application domain."); + + if (classes.HasMany()) + throw new Exception("There are multiple classes named TaskManager in the current application domain."); + + var tasks = classes.First().GetProperty("Tasks", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public).GetValue(null) + as IEnumerable; + + if (tasks == null) + throw new Exception("Class TaskManager doesn't have a property named Tasks of type IEnumerable."); + + return tasks; + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.TaskAutomation/AutomatedTask.cs b/Services/Olive.Services.TaskAutomation/AutomatedTask.cs new file mode 100644 index 000000000..98af5dcf9 --- /dev/null +++ b/Services/Olive.Services.TaskAutomation/AutomatedTask.cs @@ -0,0 +1,155 @@ +using System; +using System.Threading.Tasks; +using Olive.Entities; + +namespace Olive.Services.TaskAutomation +{ + /// + /// Represents an instance of Automated task entity type. + /// + [TransientEntity] + public partial class AutomatedTask : GuidEntity + { + const int DEFAULT_DELAY_IN_SECONDS = 30, NAME_MAX_LENGTH = 200; + + /* -------------------------- Constructor -----------------------*/ + + /// + /// Initializes a new instance of the AutomatedTask class. + /// + public AutomatedTask() + { + RecordFailure = true; + + Delay = TimeSpan.FromSeconds(DEFAULT_DELAY_IN_SECONDS); + } + + /* -------------------------- Properties -------------------------*/ + + #region Current execution duration Property + + /// + /// Gets the CurrentExecutionDuration property. + /// + public string CurrentExecutionDuration => CurrentStartTime == null ? "" : "Since " + CurrentStartTime.Value.ToTimeDifferenceString(); + + #endregion + + #region Current start time Property + + /// + /// Gets or sets the value of CurrentStartTime on this Automated task instance. + /// + public DateTime? CurrentStartTime { get; set; } + + #endregion + + #region Last run duration Property + + /// + /// Gets the LastRunDuration property. + /// + public TimeSpan? LastRunDuration + { + get + { + if (LastRunStart == null || LastRunEnd == null) return null; + else return LastRunEnd.Value.Subtract(LastRunStart.Value); + } + } + + #endregion + + #region Last run end Property + + /// + /// Gets or sets the value of LastRunEnd on this Automated task instance. + /// + public DateTime? LastRunEnd { get; set; } + + #endregion + + #region Last run start Property + + /// + /// Gets or sets the value of LastRunStart on this Automated task instance. + /// + public DateTime? LastRunStart { get; set; } + + #endregion + + #region Name Property + + /// + /// Gets or sets the value of Name on this Automated task instance. + /// + public string Name { get; set; } + + #endregion + + #region Next try Property + + /// + /// Gets or sets the value of NextTry on this Automated task instance. + /// + public DateTime? NextTry { get; set; } + + #endregion + + #region Record failure Property + + /// + /// Gets or sets a value indicating whether this Automated task instance Record failure. + /// + public bool RecordFailure { get; set; } + + #endregion + + #region Record success Property + + /// + /// Gets or sets a value indicating whether this Automated task instance Record success. + /// + public bool RecordSuccess { get; set; } + + #endregion + + #region Delay + /// + /// Gets or sets the Delay of this AutomatedTask. + /// + public TimeSpan Delay { get; set; } + #endregion + + #region SyncGroup + /// + /// Gets or sets the SyncGroup of this AutomatedTask. + /// + public AsyncLock AsyncGroup { private get; set; } + #endregion + + /* -------------------------- Methods ----------------------------*/ + + /// + /// Returns a textual representation of this Automated task. + /// + /// A string value that represents this Automated task instance. + public override string ToString() => Name; + + /// + /// Validates the data for the properties of this Automated task. + /// It throws a ValidationException if an error is detected. + /// + protected override Task ValidateProperties() + { + // Validate Name property: + if (Name.IsEmpty()) + throw new ValidationException("Name cannot be empty."); + + if (Name.Length > NAME_MAX_LENGTH) + throw new ValidationException("Name field allows a maximum of 200 characters. You have provided {0} characters which exceeds this limit.", Name.Length); + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.TaskAutomation/AutomatedTaskStatus.Logic.cs b/Services/Olive.Services.TaskAutomation/AutomatedTaskStatus.Logic.cs new file mode 100644 index 000000000..a1574e110 --- /dev/null +++ b/Services/Olive.Services.TaskAutomation/AutomatedTaskStatus.Logic.cs @@ -0,0 +1,16 @@ +namespace Olive.Services.TaskAutomation +{ + partial class AutomatedTaskStatus + { + /// + /// Creates a new AutomatedTaskStatus instance. + /// + public AutomatedTaskStatus(string name) => Name = name; + + internal static AutomatedTaskStatus AwaitingFirstRun = new AutomatedTaskStatus("Awaiting First Run"); + internal static AutomatedTaskStatus Running = new AutomatedTaskStatus("Running"); + internal static AutomatedTaskStatus WaitingForLock = new AutomatedTaskStatus("Waiting For Lock"); + internal static AutomatedTaskStatus CompletedAwaitingNextRun = new AutomatedTaskStatus("Completed, Awaiting Next Run"); + internal static AutomatedTaskStatus FailedAwaitingNextRun = new AutomatedTaskStatus("Failed, Awaiting Next Run"); + } +} \ No newline at end of file diff --git a/Services/Olive.Services.TaskAutomation/AutomatedTaskStatus.cs b/Services/Olive.Services.TaskAutomation/AutomatedTaskStatus.cs new file mode 100644 index 000000000..c97ebda1b --- /dev/null +++ b/Services/Olive.Services.TaskAutomation/AutomatedTaskStatus.cs @@ -0,0 +1,30 @@ +using Olive.Entities; + +namespace Olive.Services.TaskAutomation +{ + /// + /// Represents an instance of Automated Task Status entity type. + /// + [TransientEntity] + public partial class AutomatedTaskStatus //: Entity + { + /* -------------------------- Properties -------------------------*/ + + #region Name Property + + /// + /// Gets or sets the value of Name on this Automated Task Status instance. + /// + public string Name { get; set; } + + #endregion + + /* -------------------------- Methods ----------------------------*/ + + /// + /// Returns a textual representation of this Automated Task Status. + /// + /// A string value that represents this Automated Task Status instance. + public override string ToString() => Name.Or(string.Empty); + } +} \ No newline at end of file diff --git a/Services/Olive.Services.TaskAutomation/Olive.Services.TaskAutomation.csproj b/Services/Olive.Services.TaskAutomation/Olive.Services.TaskAutomation.csproj new file mode 100644 index 000000000..ffab01f85 --- /dev/null +++ b/Services/Olive.Services.TaskAutomation/Olive.Services.TaskAutomation.csproj @@ -0,0 +1,18 @@ + + + + netcoreapp2.0 + + + + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.TaskAutomation.xml + 1701;1702;1705;1591;1573 + + + + + + + + diff --git a/Services/Olive.Services.TaskAutomation/Package.nuspec b/Services/Olive.Services.TaskAutomation/Package.nuspec new file mode 100644 index 000000000..7fdbcfcb2 --- /dev/null +++ b/Services/Olive.Services.TaskAutomation/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.TaskAutomation + 1.0.3 + Olive Task Automation (Service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Testing/DatabaseChangeWatcher.cs b/Services/Olive.Services.Testing/DatabaseChangeWatcher.cs new file mode 100644 index 000000000..74b24e21f --- /dev/null +++ b/Services/Olive.Services.Testing/DatabaseChangeWatcher.cs @@ -0,0 +1,45 @@ +using Olive.Entities.Data; +using Olive.Web; +using System; +using System.Collections.Generic; +using System.Text; +using System.Xml.Linq; + +namespace Olive.Services.Testing +{ + class DatabaseChangeWatcher + { + static List Changes = new List(); + + static DatabaseChangeWatcher() + { + DatabaseStateChangeCommand.ExecutedChangeCommand += DatabaseStateChangeCommand_ExecutedChangeCommand; + } + + static void DatabaseStateChangeCommand_ExecutedChangeCommand(DatabaseStateChangeCommand change) + { + var node = new XElement("Change"); + if (change.CommandType != System.Data.CommandType.Text) + node.Add(new XAttribute("Type", change.CommandType.ToString())); + + node.Add(new XAttribute("Command", change.CommandText)); + + foreach (var p in change.Params) + node.Add(new XElement("Param", + new XAttribute("Name", p.ParameterName), + new XAttribute("Value", p.Value), + new XAttribute("Type", p.DbType))); + + Changes.Add(node); + } + + internal static void Restart() => Changes.Clear(); + + internal static void DispatchChanges() + { + var response = new XElement("Changes", Changes).ToString(); + Changes.Clear(); + Context.Response.EndWith(response, "text/xml"); + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Testing/Example.cs b/Services/Olive.Services.Testing/Example.cs new file mode 100644 index 000000000..297fd510f --- /dev/null +++ b/Services/Olive.Services.Testing/Example.cs @@ -0,0 +1,127 @@ +using System.Threading.Tasks; +using Olive.Services.Integration; + +namespace Olive.Services.Testing +{ + // Note: This is a service implementation example. + // MyService can be replaced by a proper name depending on your project's requirements. + + + /// + /// This simple class will not have any implementation body. It's used to invoke the service. + /// + class MyService : IntegrationService { } + + /// + /// A basic C# class to contain all parameters which are logically part of your application. + /// Do not add properties which are used only to establish the pipelines such as constant security keys, access tokens, etc. + /// This class should be minimal and only represent what has a meaning in yoru application's domain for the purpose of integration. + /// + class MyServiceRequest + { + public string Param1, Param2; + } + + /// + /// Same as MyServiceRequest, it should only have data fields that are relevant to the domain of your application. + /// Keep it pure and simple. It can have a full hierarchy such as List classes, nested objects, etc. + /// + class MyServiceResponse + { + public string Ourcome1, Ourcome2; + } + + namespace My.VSProject + { + /// + /// The actual service implementation. Keep it in a separate DLL (project). + /// Your Model project should not reference this DLL, but your website project should. + /// This dll should reference your Model dll. + /// It should be self sufficient class that can work simply from ServiceRequest and web.config data. + /// + class MyServiceImplementor : IServiceImplementor + { + /// + /// In this method, write the actual implementation. Make calls to the external web service, Http, etc. + /// You can use any set of implementation classes for this, including those typically generated by WSDL utilities. + /// You can make several calls to the external service, establish security, read from web.config, etc. + /// At the end of the process, the final relevant information should be written into an instance of MyServiceResponse and returned. + /// + public MyServiceResponse GetResponse(MyServiceRequest request) + { + // Dirty work goes here. + // ... + return new MyServiceResponse { Ourcome1 = "sample", Ourcome2 = "sample" }; + } + } + } + + class Website + { + public async Task Global_ASAX() + { + // The actual service implementation will be registerd here once for live use (non-test mode). + // Throughout the development period when the application is in TDD mode, this service is essentially not registered, so + // there is no dependency on the external service. + + // Perhaps only once when the ServiceImplementation is created during development cycle, + // you can remove the condition to test it once or twice. + if (WebTestManager.IsTddExecutionMode() == false) + { + MyService.RegisterImplementor(); + + // Optional: Add an Automated Task to call the following. + // It will frequently process all outstanding items in the queue. + // Mainly relevant for multi-try situations in live operations. Avoid unless necessary. + await IntegrationManager.ProcessOutstandingItems(); + } + else + { + // Note: In case you need to provide a Mock implementation for the service, instead of manually injecting the responses + // through sanity, you can create a class that fakes responses to requests and inject it here: + // e.g: MyService.Register(); + } + } + } + + class Model + { + internal void SomeBusinessLogicMethod() + { + // In the consumer classes (mostly in your Model project), all requests to the integration services will be via the following method. + // Note: The following method will serialize the request and insert it in the IntegrationQueueItem table. + // It will then wait for the response to be provided. + + // In TDD mode, the ServiceImplementation will not have been registered, and therefore this call will wait indefinitely for a response. + // In practice, whilst this method is waiting for the response, the idea is that you manually go to the Database and inject the response + // in the Queue. As soon as the response is provided, the waiting method will then pick the result and proceed. + + // In Sanity there is a command called "$check integartion queue" used to manually inject the response. + + var result = MyService.Request(new MyServiceRequest { Param1 = "1", Param2 = "X" }); + } + } + + class UnitTest + { + public void UnitTest_For_Business_Logic_Layer() + { + // When the service consumer unit (business logic layer) is invoked, it will in turn insert its IntegrationQueueItem and wait for its response. + // So instead of directly calling the method in your Unit Test, you should invoke that in a Task. + var app = new Model(); + var task = new System.Threading.Tasks.Task(app.SomeBusinessLogicMethod); + task.Start(); + + // At this stage the logic unit is waiting for the response. So here we just inject a response, so the unit will continue then. + var assumedResponse = new MyServiceResponse { Ourcome1 = "Something", Ourcome2 = "anything" }; + MyService.InjectResponse(assumedResponse); + + // At this stage, the MyService.Request() called inside the logic unit, will come out of freezing and return the injected + // response to the consumer unit. + + task.Wait(); + + // Note: This way, the dependency to the external service is bypassed by manually injecting the response. + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Testing/Olive.Services.Testing.csproj b/Services/Olive.Services.Testing/Olive.Services.Testing.csproj new file mode 100644 index 000000000..14b4aa9f3 --- /dev/null +++ b/Services/Olive.Services.Testing/Olive.Services.Testing.csproj @@ -0,0 +1,28 @@ + + + + netcoreapp2.0 + + + + ..\..\@Assemblies\ + ..\..\@Assemblies\netcoreapp2.0\Olive.Services.Testing.xml + 1701;1702;1705;1591;1573 + + + + + + + + + + + + + + + + + + diff --git a/Services/Olive.Services.Testing/Package.nuspec b/Services/Olive.Services.Testing/Package.nuspec new file mode 100644 index 000000000..780fdd33e --- /dev/null +++ b/Services/Olive.Services.Testing/Package.nuspec @@ -0,0 +1,18 @@ + + + + Olive.Testing + 1.0.3 + Olive Testing (Service) + Geeks Ltd + https://github.com/Geeksltd/Olive + http://licensing.msharp.co.uk/Images/OliveComponent.png + Copyright ©2017 Geeks Ltd - All rights reserved. + Olive Framework + + + + + + + \ No newline at end of file diff --git a/Services/Olive.Services.Testing/Snapshot.cs b/Services/Olive.Services.Testing/Snapshot.cs new file mode 100644 index 000000000..07d130bbd --- /dev/null +++ b/Services/Olive.Services.Testing/Snapshot.cs @@ -0,0 +1,454 @@ +using System; +using System.Collections.Generic; +using System.Data.SqlClient; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using Olive.Entities; +using Olive.Web; + +namespace Olive.Services.Testing +{ + class Snapshot + { + const string TEMP_DATABASES_LOCATION_KEY = "Temp.Databases.Location"; + const string URL_FILE_NAME = "url.txt"; + const string DATE_FILE_NAME = "date.txt"; + static string DatabaseName = GetDatabaseName(); + string SnapshotName; + bool IsInShareSnapshotMode; + static Mutex SnapshotRestoreLock; + + DirectoryInfo SnapshotsDirectory; + + public Snapshot(string name, bool isSharedSNapshotMode) + { + IsInShareSnapshotMode = isSharedSNapshotMode; + SnapshotName = CreateSnapshotName(name); + SnapshotsDirectory = GetSnapshotsRoot(IsInShareSnapshotMode).GetSubDirectory(SnapshotName); + } + + public async Task Create(HttpContext context) + { + if (IsSnapshotsDisabled) return; + + SetupDirecory(); + await SnapshotDatabase(); + await CreateSnapshotCookies(context); + await CopyUploadedFiles(CopyProcess.Backup); + await SaveDate(); + await SaveUrl(context); + } + + static bool IsSnapshotsDisabled => Config.Get("WebTestManager.DisableSnapshots"); + + public bool Exists() + { + if (IsSnapshotsDisabled) return false; + + return SnapshotsDirectory.Exists(); + } + + public async Task Restore(HttpContext context) + { + if (!Exists()) + throw new DirectoryNotFoundException("Cannot find snapshot " + SnapshotName); + + var restoreDatabase = LocalTime.Now; + await RestoreDatabase(); + Debug.WriteLine("Total time for restoring including mutex: " + LocalTime.Now.Subtract(restoreDatabase).Milliseconds); + + var restoreCookies = LocalTime.Now; + await RestoreCookies(context); + Debug.WriteLine("Total time for restoring cookies: " + LocalTime.Now.Subtract(restoreCookies).Milliseconds); + + var restoreFiles = LocalTime.Now; + await CopyUploadedFiles(CopyProcess.Restore); + Debug.WriteLine("Total time for restoring files: " + LocalTime.Now.Subtract(restoreFiles).Milliseconds); + + var restoreDate = LocalTime.Now; + await RestoreDate(); + Debug.WriteLine("Total time for restoring date: " + LocalTime.Now.Subtract(restoreDate).Milliseconds); + + var restoreUrl = LocalTime.Now; + await RestoreUrl(context); + Debug.WriteLine("Total time for restoring url: " + LocalTime.Now.Subtract(restoreUrl).Milliseconds); + } + + async Task CopyUploadedFiles(CopyProcess process) + { + var copyTasks = new List(); + + foreach (var key in new[] { "UploadFolder", "UploadFolder.Secure" }) + { + var source = Config.Get(key); + if (source.IsEmpty()) + { + Debug.WriteLine("Destination directory not configured in App.Config for key: " + key); + continue; + } + + string folder = Config.Get(key); + if (folder.ToCharArray()[0] == '/') folder = folder.Substring(1); + + if (process == CopyProcess.Restore) + { + source = Path.Combine(SnapshotsDirectory.ToString(), folder); + if (!Directory.Exists(source)) continue; + copyTasks.Add(new DirectoryInfo(source).CopyTo(AppDomain.CurrentDomain.GetPath(Config.Get(key)), overwrite: true)); + } + else if (process == CopyProcess.Backup) + { + source = AppDomain.CurrentDomain.GetPath(source); + if (!Directory.Exists(source)) continue; + copyTasks.Add(new DirectoryInfo(source).CopyTo(Path.Combine(SnapshotsDirectory.ToString(), folder), overwrite: true)); + } + } + + await Task.WhenAll(copyTasks); + } + + async Task SaveDate() + { + if (LocalTime.IsRedefined) + { + await File.WriteAllTextAsync(SnapshotsDirectory.GetFile(DATE_FILE_NAME).FullName, LocalTime.Now.ToString()); + } + } + + async Task RestoreDate() + { + var dateFile = SnapshotsDirectory.GetFile(DATE_FILE_NAME); + if (dateFile.Exists()) + { + var dateTime = Convert.ToDateTime(await dateFile.ReadAllText()); + LocalTime.RedefineNow(() => dateTime); + } + } + + public static void RemoveSnapshots() + { + var sharedSnapshots = GetSnapshotsRoot(isSharedSnapshotMode: true); + if (sharedSnapshots.Exists) + { + DeleteDirectory(sharedSnapshots); + sharedSnapshots.EnsureExists(); + } + + var normalSnapshots = GetSnapshotsRoot(isSharedSnapshotMode: false); + if (normalSnapshots.Exists) + { + DeleteDirectory(normalSnapshots); + normalSnapshots.EnsureExists(); + } + + Context.Response.Redirect("~/"); + } + + public static void RemoveSnapshot(string name) + { + var snapshotName = CreateSnapshotName(name); + + var normalSnapshotDirectory = Path.Combine(GetSnapshotsRoot(isSharedSnapshotMode: false).FullName, snapshotName).AsDirectory(); + if (normalSnapshotDirectory.Exists) + DeleteDirectory(normalSnapshotDirectory); + + var shardSnapshotDirectory = Path.Combine(GetSnapshotsRoot(isSharedSnapshotMode: true).FullName, snapshotName).AsDirectory(); + if (shardSnapshotDirectory.Exists) + DeleteDirectory(shardSnapshotDirectory); + + Context.Response.Redirect("~/"); + } + + public static void DeleteDirectory(DirectoryInfo targetDirectory) + { + var files = targetDirectory.GetFiles(); + var dirs = targetDirectory.GetDirectories(); + + foreach (var file in files) + { + file.Attributes = FileAttributes.Normal; + file.Delete(); + } + + foreach (var dir in dirs) + DeleteDirectory(dir); + + targetDirectory.Delete(); + } + + #region URL + + async Task SaveUrl(HttpContext context) + { + var uri = new Uri(Context.Request.ToAbsoluteUri()); + var url = uri.PathAndQuery; + + url = url.Substring(0, url.IndexOf("Web.Test.Command", StringComparison.OrdinalIgnoreCase) - 1); + if (url.HasValue()) + { + await File.WriteAllTextAsync(SnapshotsDirectory.GetFile(URL_FILE_NAME).FullName, url); + context.Response.Redirect(url); + } + } + + async Task RestoreUrl(HttpContext context) + { + var urlFile = SnapshotsDirectory.GetFile(URL_FILE_NAME); + if (urlFile.Exists()) + context.Response.Redirect(context.Request.GetWebsiteRoot() + (await urlFile.ReadAllText()).TrimStart("/")); + } + + #endregion + + #region Cookie + async Task CreateSnapshotCookies(HttpContext context) + { + var json = JsonConvert.SerializeObject(context.Request.GetCookies().ToArray()); + + await GetCookiesFile().WriteAllText(json); + } + + async Task RestoreCookies(HttpContext context) + { + var cookiesFile = GetCookiesFile(); + + if (!cookiesFile.Exists()) return; + + var cookies = JsonConvert.DeserializeObject[]>(await cookiesFile.ReadAllText()); + + foreach (var cookie in cookies) + context.Response.Cookies.Append(cookie.Key, cookie.Value); + } + + FileInfo GetCookiesFile() => SnapshotsDirectory.GetFile("cookies.json"); + + #endregion + + #region DB + async Task SnapshotDatabase() + { + FileInfo[] files; + + SqlConnection.ClearAllPools(); + + using (var connection = new SqlConnection(GetMasterConnectionString())) + { + connection.Open(); + files = await GetPhysicalFiles(connection); + + await TakeDatabaseOffline(connection); + await files.Do(async f => + { + if (IsInShareSnapshotMode) + { + await f.CopyTo(Path.Combine(SnapshotsDirectory.FullName, GetSnapshotFileName(f) + f.Extension).AsFile()); + + // keep the snashptname of the database in a .origin file + await File.WriteAllTextAsync(SnapshotsDirectory.GetFile( + GetSnapshotFileName(f) + f.Extension + ".origin").FullName, + f.FullName.Replace(DatabaseName, GetSnapshotFileName(f))); + } + else + { + await f.CopyTo(SnapshotsDirectory); + // keep the original location of the database file in a .origin file + await File.WriteAllTextAsync(SnapshotsDirectory.GetFile(f.Name + ".origin").FullName, f.FullName); + } + }); + await TakeDatabaseOnline(connection); + } + } + + string GetSnapshotFileName(FileInfo file) => file.Name.Split('.').First() + ".Temp"; + + // TODO: create a connection string for MASTER + async Task RestoreDatabase() + { + SnapshotRestoreLock = new Mutex(false, "SnapshotRestore"); + bool lockTaken = false; + + try + { + lockTaken = SnapshotRestoreLock.WaitOne(); + var restoreTime = LocalTime.Now; + using (var connection = new SqlConnection(GetMasterConnectionString())) + { + connection.Open(); + var detachTime = LocalTime.Now; + await DetachDatabase(connection); + + Debug.WriteLine("Total time for detaching database: " + LocalTime.Now.Subtract(detachTime).Milliseconds); + + FileInfo mdfFile = null, ldfFile = null; + + var copyTime = LocalTime.Now; + // copy each database file to its old place + foreach (var originFile in SnapshotsDirectory.GetFiles("*.origin")) + { + originFile.IsReadOnly = true; + + var destination = await File.ReadAllTextAsync(originFile.FullName); + var source = originFile.FullName.TrimEnd(originFile.Extension).AsFile(); + + if (IsInShareSnapshotMode) + { + destination = destination.Replace(GetSnapshotFileName(originFile), DatabaseName); + } + + if (destination.ToLower().EndsWith(".mdf")) + mdfFile = destination.AsFile(); + + if (destination.ToLower().EndsWith(".ldf")) + ldfFile = destination.AsFile(); + + await source.CopyTo(destination.AsFile(), overwrite: true); + // shall we backup the existing one and in case of any error restore it? + } + + Debug.WriteLine("Total time for copying database: " + LocalTime.Now.Subtract(copyTime).Milliseconds); + + if (mdfFile == null) + throw new Exception("Cannot find any MDF file in snapshot directory " + SnapshotsDirectory.FullName); + + if (ldfFile == null) + throw new Exception("Cannot find any LDF file in snapshot directory " + SnapshotsDirectory.FullName); + var attachTime = LocalTime.Now; + await AttachDatabase(connection, mdfFile, ldfFile); + Debug.WriteLine("Total time for attaching database: " + LocalTime.Now.Subtract(attachTime).Milliseconds); + await Entity.Database.Refresh(); + } + + Debug.WriteLine("Total time for restoreing database: " + LocalTime.Now.Subtract(restoreTime).Milliseconds); + } + finally + { + if (lockTaken == true) + { + SnapshotRestoreLock.ReleaseMutex(); + } + } + } + + async Task DetachDatabase(SqlConnection connection) + { + SqlConnection.ClearAllPools(); + + using (var cmd = new SqlCommand( + "USE Master; ALTER DATABASE [{0}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; ALTER DATABASE [{0}] SET MULTI_USER; exec sp_detach_db '{0}'" + .FormatWith(DatabaseName), connection)) + await cmd.ExecuteNonQueryAsync(); + } + + async Task AttachDatabase(SqlConnection connection, FileInfo mdfFile, FileInfo ldfFile) + { + using (var cmd = new SqlCommand( + "USE Master; CREATE DATABASE [{0}] ON (FILENAME = '{1}'), (FILENAME = '{2}') FOR ATTACH" + .FormatWith(DatabaseName, mdfFile.FullName, ldfFile.FullName), connection)) + await cmd.ExecuteNonQueryAsync(); + } + + async Task TakeDatabaseOffline(SqlConnection connection) + { + SqlConnection.ClearAllPools(); + + using (var cmd = new SqlCommand( + "USE Master; ALTER DATABASE [{0}] SET OFFLINE WITH ROLLBACK IMMEDIATE;" + .FormatWith(DatabaseName), connection)) + await cmd.ExecuteNonQueryAsync(); + } + + async Task TakeDatabaseOnline(SqlConnection connection) + { + using (var cmd = new SqlCommand( + "USE Master; ALTER DATABASE [{0}] SET ONLINE;" + .FormatWith(DatabaseName), connection)) + await cmd.ExecuteNonQueryAsync(); + } + + async Task GetPhysicalFiles(SqlConnection connection) + { + var files = new List(); + + using (var cmd = new SqlCommand( + "USE Master; SELECT physical_name FROM sys.master_files where database_id = DB_ID('{0}')" + .FormatWith(DatabaseName), connection)) + using (var reader = await cmd.ExecuteReaderAsync()) + { + while (reader.Read()) + files.Add(Convert.ToString(reader[0]).AsFile()); + } + + if (files.Count == 0) + throw new Exception("Cannot find physical file name for database: " + DatabaseName); + + return files.ToArray(); + } + + #endregion + + void SetupDirecory() + { + // make sure it is empty + if (SnapshotsDirectory.Exists()) + { + SnapshotsDirectory.Delete(recursive: true); + } + + SnapshotsDirectory.Create(); + } + + /// + /// Gets the list of current snapshots on disk. + /// + public static List GetList(bool isSharedSnapshotMode) + { + if (!GetSnapshotsRoot(isSharedSnapshotMode).Exists()) return null; + + return GetSnapshotsRoot(isSharedSnapshotMode).GetDirectories().Select(f => f.Name.Substring(0, f.Name.LastIndexOf('_'))).ToList(); + } + + static DirectoryInfo GetSnapshotsRoot(bool isSharedSnapshotMode) + { + if (isSharedSnapshotMode) + { + return Path.Combine(Config.Get(TEMP_DATABASES_LOCATION_KEY), DatabaseName.Split('.').First() + " SNAPSHOTS").AsDirectory(); + } + else + { + return Path.Combine(Config.Get(TEMP_DATABASES_LOCATION_KEY), DatabaseName, "SNAPSHOTS").AsDirectory(); + } + } + + static string GetMasterConnectionString() + { + var builder = new SqlConnectionStringBuilder(Config.GetConnectionString("AppDatabase")) + { + InitialCatalog = "master" + }; + + return builder.ToString(); + } + + static string GetDatabaseName() + { + return new SqlConnectionStringBuilder(Config.GetConnectionString("AppDatabase")) + .InitialCatalog + .Or("") + .TrimStart("[") + .TrimEnd("]"); + } + + static string CreateSnapshotName(string name) + { + var schemaHash = new TestDatabaseGenerator(false, false).GetCurrentDatabaseCreationHash(); + return "{0}_{1}".FormatWith(name, schemaHash).Except(Path.GetInvalidFileNameChars()).ToString(""); + } + + enum CopyProcess { Backup, Restore } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Testing/TestDatabaseGenerator.cs b/Services/Olive.Services.Testing/TestDatabaseGenerator.cs new file mode 100644 index 000000000..be2aa8f7b --- /dev/null +++ b/Services/Olive.Services.Testing/TestDatabaseGenerator.cs @@ -0,0 +1,427 @@ +using System; +using System.Collections.Generic; +using System.Data.SqlClient; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Olive.Entities.Data; + +namespace Olive.Services.Testing +{ + public class TestDatabaseGenerator + { + const string TEMP_DATABASES_LOCATION_KEY = "Temp.Databases.Location"; + + static AsyncLock AsyncLock = new AsyncLock(); + static AsyncLock ProcessAsyncLock = new AsyncLock(); + + readonly string ConnectionString; + SqlServerManager MasterDatabaseAgent; + string TempDatabaseName, ReferenceDatabaseName; + + FileInfo ReferenceMDFFile, ReferenceLDFFile; + DirectoryInfo TempBackupsRoot, ProjectTempRoot, DbDirectory, CurrentHashDirectory; + + readonly bool IsTempDatabaseOptional, MustRenew; + + public bool CreatedNewDatabase { get; private set; } + + /// + /// Creates a new TestDatabaseGenerator instance. + /// Determines whether use of the temp database is optional. + /// When this class is used in a Unit Test project, then it must be set to false. + /// For Website project, it must be set to true. + /// Specifies whether the temp database must be recreated on application start up even if it looks valid already. + /// + public TestDatabaseGenerator(bool isTempDatabaseOptional, bool mustRenew) + { + ConnectionString = Config.GetConnectionString("AppDatabase"); + + IsTempDatabaseOptional = isTempDatabaseOptional; + + MustRenew = mustRenew; + } + + FileInfo[] GetCreateDbFiles() + { + if (DbDirectory == null) + LoadMSharpMetaDirectory(); + + var potentialSources = new List(); + + var tableScripts = DbDirectory.GetSubDirectory("Tables").GetFilesOrEmpty("*.sql"); + + // Create tables: + potentialSources.Add(DbDirectory.GetFile("@Create.Database.sql")); + potentialSources.AddRange(tableScripts.Except(x => x.Name.ToLower().EndsWithAny(".fk.sql", ".data.sql"))); + + // Insert data: + potentialSources.Add(DbDirectory.GetFile("@Create.Database.Data.sql")); + potentialSources.AddRange(tableScripts.Where(x => x.Name.ToLower().EndsWith(".data.sql"))); + + potentialSources.Add(DbDirectory.GetFile("Customize.Database.sql")); + + // Add foreign keys + potentialSources.AddRange(tableScripts.Where(x => x.Name.ToLower().EndsWith(".fk.sql"))); + + var sources = potentialSources.Where(f => f.Exists()).ToArray(); + + if (sources.None()) + throw new Exception("No SQL creation script file was found. I checked:\r\n" + potentialSources.ToLinesString()); + + return sources; + } + + async Task> GetExecutableCreateDbScripts() + { + var sources = GetCreateDbFiles(); + + var result = new Dictionary(); + + foreach (var file in sources) + { + var script = await file.ReadAllText(); + + // The first few lines contain #DATABASE.NAME# which should be replaced. + script = script.ToLines().Select((line, index) => + { + if (index < 10) + { + return line + .Replace("#DATABASE.NAME#", ReferenceDatabaseName) + .Replace("#STORAGE.PATH#", CurrentHashDirectory.FullName); + } + + return line; + }).ToLinesString(); + + if (file.Name.Lacks("Create.Database.sql", caseSensitive: false)) + { + script = "USE [" + ReferenceDatabaseName + "];\r\nGO\r\n" + script; + } + + result.Add(file, script); + } + + return result; + } + + internal async Task GetCurrentDatabaseCreationHash() + { + var createScript = (await GetCreateDbFiles().Select(async x => await x.ReadAllText()).AwaitAll()).ToLinesString(); + + return createScript.ToSimplifiedSHA1Hash(); + } + + async Task CreateDatabaseFromScripts() + { + await MasterDatabaseAgent.DeleteDatabase(ReferenceDatabaseName); + + var newDatabaseAgent = MasterDatabaseAgent.CloneFor(ReferenceDatabaseName); + + foreach (var file in await GetExecutableCreateDbScripts()) + { + try + { + await MasterDatabaseAgent.ExecuteSql(file.Value); + } + catch (Exception ex) + { + throw new Exception("Could not execute sql file '" + file.Key.FullName + "' becuase '" + ex.Message + "'", ex); + } + } + } + + public async Task CloneReferenceDatabaseToTemp() + { + // Make sure if it exists in database already, it's deleted first. + await MasterDatabaseAgent.DeleteDatabase(TempDatabaseName); + + var directory = ProjectTempRoot.GetOrCreateSubDirectory("Current"); + + var newMDFPath = directory.GetFile(TempDatabaseName + ".mdf"); + var newLDFPath = directory.GetFile(TempDatabaseName + "_log.ldf"); + + try + { + await ReferenceMDFFile.CopyTo(newMDFPath); + await ReferenceLDFFile.CopyTo(newLDFPath); + } + catch (IOException ex) + { + if (ex.InnerException != null && ex.InnerException is UnauthorizedAccessException) + throw new Exception("Consider setting the IIS Application Pool identity to LocalSystem.", ex); + + throw; + } + + var script = "CREATE DATABASE [{0}] ON (FILENAME = '{1}'), (FILENAME = '{2}') FOR ATTACH" + .FormatWith(TempDatabaseName, newMDFPath.FullName, newLDFPath.FullName); + + try + { + await MasterDatabaseAgent.ExecuteSql(script); + } + catch (SqlException ex) + { + throw new Exception("Could not attach the database from file " + newMDFPath.FullName + "." + Environment.NewLine + + "Hint: Ensure SQL instance service has access to the folder. E.g. 'Local Service' may not have access to '{0}'" + + newMDFPath.Directory.FullName, ex); + } + } + + internal async Task TryAccessNewTempDatabase() + { + Exception error = null; + for (var i = 0; i < 10; i++) + { + try + { + await Database.Instance.GetAccess().ExecuteQuery("SELECT TABLE_NAME FROM [{0}].INFORMATION_SCHEMA.TABLES".FormatWith(TempDatabaseName)); + return; + } + catch (Exception ex) + { + SqlConnection.ClearAllPools(); + error = ex; + System.Threading.Thread.Sleep(TimeSpan.FromSeconds(0.5)); + } + } + + throw new Exception("Could not access the new database:" + error.Message, error); + } + + public async Task Process() + { + if (ConnectionString.IsEmpty()) return false; + + var builder = new SqlConnectionStringBuilder(ConnectionString); + TempDatabaseName = builder.InitialCatalog.Or("").TrimStart("[").TrimEnd("]"); + + if (TempDatabaseName.IsEmpty()) + { + // None of my business. + return false; + } + else if (!TempDatabaseName.ToLower().EndsWith(".temp") && IsTempDatabaseOptional) + { + // Optional and irrelevant + return false; + } + + EnsurePermissions(); + + builder.InitialCatalog = "master"; + + MasterDatabaseAgent = new SqlServerManager(builder.ToString()); + + LoadTempDatabaseLocation(); + LoadMSharpMetaDirectory(); + + if (!IsTempDatabaseOptional) + { + if (!IsExplicitlyTempDatabase()) + { + throw new Exception("For unit tests project the database name must end in '.Temp'."); + } + } + + if (!IsExplicitlyTempDatabase()) + { + // Not Temp mode: + return false; + } + + return await DoProcess(); + } + + /// + /// Ensures the right permissions are configured. + /// + void EnsurePermissions() + { + var identity = System.Security.Principal.WindowsIdentity.GetCurrent()?.Name; + + var error = "\r\n\r\nRecommended action: If using IIS, update the Application Pool (Advanced Settings) and set Identity to LocalSystem."; + + if (identity.IsEmpty()) + { + error = "Current IIS process model Identity not found!" + error; + throw new Exception(error); + } + else + { + error = "Current IIS process model Identity: " + identity + error; + } + + if (identity.ContainsAny(new[] { "IIS APPPOOL", "LOCAL SERVICE", "NETWORK SERVICE" })) + { + error = "In TDD mode full system access is needed in order to create temporary database files." + error; + throw new Exception(error); + } + } + + void LoadTempDatabaseLocation() + { + var specifiedLocation = Config.Get(TEMP_DATABASES_LOCATION_KEY); + + if (specifiedLocation.IsEmpty()) + { + throw new Exception("You must specify a valid path for AppSetting of '{0}'.".FormatWith(TEMP_DATABASES_LOCATION_KEY)); + } + + if (!specifiedLocation.AsDirectory().Exists()) + { + // Try to build once: + try + { + Directory.CreateDirectory(specifiedLocation); + } + catch + { + throw new Exception("Could not create the folder '{0}'. Ensure it exists and is accessible. Otherwise specify a different location in AppSetting of '{1}'." + .FormatWith(specifiedLocation, TEMP_DATABASES_LOCATION_KEY)); + } + } + + TempBackupsRoot = specifiedLocation.AsDirectory(); + ProjectTempRoot = TempBackupsRoot.GetOrCreateSubDirectory(TempDatabaseName); + } + + void LoadMSharpMetaDirectory() + { + // Not explicitly specified. Take a guess: + var folder = AppDomain.CurrentDomain.BaseDirectory.AsDirectory().Parent; + while (folder.Parent != null) + { + DbDirectory = folder.GetSubDirectory("DB"); + if (DbDirectory.Exists()) return; + + folder = folder.Parent; + } + + throw new Exception("Failed to find the DB folder from which to create the temp database."); + } + + async Task DoProcess() + { + var hash = (await GetCurrentDatabaseCreationHash()).Replace("/", "-").Replace("\\", "-"); + + using (await AsyncLock.Lock()) + { + ReferenceDatabaseName = TempDatabaseName + ".Ref"; + + CurrentHashDirectory = ProjectTempRoot.GetOrCreateSubDirectory(hash); + ReferenceMDFFile = CurrentHashDirectory.GetFile(ReferenceDatabaseName + ".mdf"); + ReferenceLDFFile = CurrentHashDirectory.GetFile(ReferenceDatabaseName + "_log.ldf"); + + using (await ProcessAsyncLock.Lock()) + { + var createdNewReference = await CreateReferenceDatabase(); + + var tempDatabaseDoesntExist = !await MasterDatabaseAgent.DatabaseExists(TempDatabaseName); + + if (MustRenew || createdNewReference || tempDatabaseDoesntExist) + { + await RefreshTempDataWorld(); + } + } + + return true; + } + } + + async Task RefreshTempDataWorld() + { + await CloneReferenceDatabaseToTemp(); + + SqlConnection.ClearAllPools(); + + await CopyFiles(); + + // Do we really need this? + await TryAccessNewTempDatabase(); + + CreatedNewDatabase = true; + } + + async Task CreateReferenceDatabase() + { + if (ReferenceMDFFile.Exists() && ReferenceLDFFile.Exists()) + { + return false; + } + + var error = false; + + // create database + data + try + { + await CreateDatabaseFromScripts(); + } + catch + { + error = true; + throw; + } + finally + { + // Detach it + await MasterDatabaseAgent.DetachDatabase(ReferenceDatabaseName); + + if (error) + { + await ReferenceMDFFile.Delete(harshly: true); + await ReferenceLDFFile.Delete(harshly: true); + } + } + + return true; + } + + bool IsExplicitlyTempDatabase() => TempDatabaseName.ToLower().EndsWith(".temp"); + + public async Task CleanUp() => await MasterDatabaseAgent.DeleteDatabase(TempDatabaseName); + + async Task CopyFiles() + { + var copyTasks = new List(); + + foreach ( + var key in + new[] + { + Tuple.Create("Test.Files.Origin:Open", "UploadFolder"), + Tuple.Create("Test.Files.Origin:Secure", "UploadFolder.Secure") + }) + { + var source = Config.Get(key.Item1); + if (source.IsEmpty()) continue; + else source = AppDomain.CurrentDomain.GetPath(source); + if (!Directory.Exists(source) || source.AsDirectory().GetDirectories().None()) + { + // No files to copy + continue; + } + + var destination = Config.Get(key.Item2); + if (destination.IsEmpty()) + throw new Exception("Destination directory not configured in App.Config for key: " + key.Item2); + else destination = AppDomain.CurrentDomain.GetPath(destination); + + if (!Directory.Exists(destination)) + { + if (new DirectoryInfo(source).IsEmpty()) continue; + + Directory.CreateDirectory(destination); + } + + await new DirectoryInfo(destination).Clear(); + + copyTasks.Add(new DirectoryInfo(source).CopyTo(destination, overwrite: true)); + } + + await Task.WhenAll(copyTasks); + } + } +} \ No newline at end of file diff --git a/Services/Olive.Services.Testing/WebTestManager.cs b/Services/Olive.Services.Testing/WebTestManager.cs new file mode 100644 index 000000000..e0b45f853 --- /dev/null +++ b/Services/Olive.Services.Testing/WebTestManager.cs @@ -0,0 +1,253 @@ +using System; +using System.Data.SqlClient; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using Olive.Entities; +using Olive.Services.Email; +using Olive.Services.Integration; +using Olive.Services.TaskAutomation; +using Olive.Web; + +namespace Olive.Services.Testing +{ + public class WebTestManager + { + internal static bool IsDatabaseBeingCreated; + internal static bool? TempDatabaseInitiated; + static bool? isTddExecutionMode; + static Func ReferenceDataCreator; + + internal static void AwaitReadiness() + { + while (IsDatabaseBeingCreated) Thread.Sleep(100); // Wait until it's done. + } + + public static string CurrentRunner { get; set; } + + /// + /// Determines if the application is currently being ran by Sanity. + /// + public static bool IsSanityExecutionMode() => CurrentRunner == "Sanity"; + + /// + /// Registers a factory method that should be invoked upon creation of a new database to create reference objects. + /// + public static void CreateReferenceDataBy(Func referenceDataCreator) + => ReferenceDataCreator = referenceDataCreator; + + /// + /// Determines whether the application is running under Temp database mode. + /// + public static bool IsTddExecutionMode() + { + if (isTddExecutionMode.HasValue) return isTddExecutionMode.Value; + + var db = Config.GetConnectionString("AppDatabase").Get(c => + new SqlConnectionStringBuilder(c).InitialCatalog); + + db = db.Or("").ToLower().TrimStart("[").TrimEnd("]"); + + isTddExecutionMode = db.EndsWith(".temp"); + + return isTddExecutionMode.Value; + } + + public static async Task InitiateTempDatabase(bool enforceRestart, bool mustRenew) + { + if (!IsTddExecutionMode()) return; + + IsDatabaseBeingCreated = true; + var createdNew = false; + + try + { + SqlConnection.ClearAllPools(); + await AutomatedTask.DeleteExecutionStatusHistory(); + if (enforceRestart) TempDatabaseInitiated = null; + if (TempDatabaseInitiated.HasValue) return; + + var generator = new TestDatabaseGenerator(isTempDatabaseOptional: true, mustRenew: mustRenew); + TempDatabaseInitiated = await generator.Process(); + createdNew = generator.CreatedNewDatabase; + + await Entity.Database.Refresh(); + SqlConnection.ClearAllPools(); + } + finally { IsDatabaseBeingCreated = false; } + + if (ReferenceDataCreator != null && createdNew) + // A new database is created. Add the reference data + await ReferenceDataCreator(); + } + + public static async Task ProcessCommand(string command) + { + if (command.IsEmpty()) return; + + if (!IsTddExecutionMode()) throw new Exception("Invalid command in non TDD mode."); + + var request = Context.Http.Request; + var response = Context.Http.Response; + + var isShared = request.GetValue("mode") == "shared"; + + if (command == "snap") + { + await new Snapshot(request.GetValue("name"), isShared).Create(Context.Http); + } + else if (command == "restore") + { + await new Snapshot(request.GetValue("name"), isShared).Restore(Context.Http); + } + else if (command == "remove_snapshots") + { + Snapshot.RemoveSnapshots(); + } + else if (command == "snapshots_list") + { + response.EndWith(JsonConvert.SerializeObject(Snapshot.GetList(isShared))); + } + else if (command == "snapExists") + { + if (new Snapshot(request.GetValue("name"), isShared).Exists()) + { + response.EndWith("true"); + } + else + { + response.EndWith("false"); + } + } + else if (command.IsAnyOf("start", "run", "ran", "cancel", "restart")) + { + await InitiateTempDatabase(enforceRestart: true, mustRenew: true); + DatabaseChangeWatcher.Restart(); + if (request.Has("runner")) CurrentRunner = request.GetValue("runner"); + } + else if (command == "testEmail") + { + await new EmailTestService(request, response).Process(); + } + else if (command == "dbChanges") + { + DatabaseChangeWatcher.DispatchChanges(); + } + else if (command == "tasks") + { + await DispatchTasksList(); + } + else if (command == "setLocalDate") + { + if (request.GetValue("date") == "now") + { + // reset to normal + LocalTime.RedefineNow(overriddenNow: null); + response.EndWith(LocalTime.Now.ToString("yyyy-MM-dd @ HH:mm:ss")); + } + + var time = LocalTime.Now.TimeOfDay; + if (request.Has("time")) time = TimeSpan.Parse(request.GetValue("time")); + + var date = LocalTime.Today; + if (request.Has("date")) date = request.GetValue("date").To(); + + date = date.Add(time); + + var trueOrigin = DateTime.Now; + + LocalTime.RedefineNow(() => { return date.Add(DateTime.Now.Subtract(trueOrigin)); }); + response.Clear(); + response.EndWith(date.ToString("yyyy-MM-dd @ HH:mm:ss")); + } + else if (command == "remove_snapshot") + { + Snapshot.RemoveSnapshot(request.GetValue("name")); + } + else if (command == "inject.service.response") + { + var serviceType = AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(a => a.GetTypes()) + .Where(x => x.InhritsFrom(typeof(IntegrationService))) + .SingleOrDefault(x => x.Name == request.GetValue("service")); + + if (serviceType == null) + throw new Exception("Cannot find a class named " + request.GetValue("service") + " in the currently loaded assemblies, which inherits from IntegrationService<,>."); + + new Thread(new ThreadStart(async () => + await IntegrationTestInjector.Inject(serviceType, request.GetValue("request"), request.GetValue("response")))) + { IsBackground = true } + .Start(); + } + } + + /// + /// To invoke this, send a request to /?web.test.command=tasks + /// + public static async Task DispatchTasksList() + { + var response = Context.Http.Response; + var request = Context.Http.Request; + + response.ContentType = "text/html"; + + response.WriteAsync("").RunSynchronously(); + + response.WriteAsync("").RunSynchronously(); + + if (request.Has("t")) + { + await AutomatedTask.GetAllTasks().Single(t => t.Name == request.GetValue("t")).Execute(); + response.WriteAsync($"Done: {request.GetValue("t")}

    ").RunSynchronously(); + } + + // Render a list of tasks + response.WriteAsync(AutomatedTask.GetAllTasks().Select(t => "{0}".FormatWith(t.Name)).ToString("
    ") + + "

    Restart Temp Database").RunSynchronously(); + + response.WriteAsync("").RunSynchronously(); + + response.WriteAsync("").RunSynchronously(); + + response.WriteAsync("").RunSynchronously(); + } + + internal static string GetSanityAdaptorScript() + { + return string.Empty; // 24 Oct 2016 - See if Sanity still needs it. + + // var r = new StringBuilder(); + + // r.AppendLine("window.OpenBrowserWindow = function(url, target) { if (target && target != '_parent' && target != 'parent') target='_self'; window.open(url, target); }"); + + // r.AppendLine("$(function() { "); + + // r.AppendLine("$(window).off('click.SanityAdapter').on('click.SanityAdapter', function(e) {"); + // r.AppendLine("var link = $(e.target).filter('a').removeAttr('target'); } );"); + + // r.AppendLine("});"); + + // return r.ToString(); + } + + public static string GetWebTestWidgetHtml(HttpRequest request) + { + var uri = new Uri(request.ToAbsoluteUri()); + var url = uri.RemoveQueryString("Web.Test.Command").ToString(); + if (url.Contains("?")) url += "&"; else url += "?"; + + return @"".Replace("[URL]", url + "Web.Test.Command="); + } + } +} diff --git a/nuget.exe b/nuget.exe new file mode 100644 index 000000000..305300ad4 Binary files /dev/null and b/nuget.exe differ