diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 15bf44ba88..d1ec15d3ef 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -131,6 +131,15 @@ jobs: # title: Analyzer Tests # github-token: ${{ secrets.GITHUB_TOKEN }} # target-framework: net7.0 + - name: Adapters.WebForms.Tests (net472) + uses: ./.github/unittest + if: matrix.os == 'windows-2022' + with: + project: src/Adapters/Tests/WebForms + name: webforms-adapters-tests + title: WebForms Adapter Tests + github-token: ${{ secrets.GITHUB_TOKEN }} + target-framework: net472 js-tests: runs-on: ubuntu-latest diff --git a/ci/scripts/Get-PublicProjects.ps1 b/ci/scripts/Get-PublicProjects.ps1 index 9669670080..f1adf33d67 100644 --- a/ci/scripts/Get-PublicProjects.ps1 +++ b/ci/scripts/Get-PublicProjects.ps1 @@ -108,5 +108,10 @@ return @( Name = "DotVVM.Tracing.MiniProfiler.Owin"; Path = "src/Tracing/MiniProfiler.Owin"; Type = "standard" + }, + [PSCustomObject]@{ + Name = "DotVVM.Adapters.WebForms"; + Path = "src/Adapters/WebForms"; + Type = "standard" } ) diff --git a/src/Adapters/Tests/WebForms/DotVVM.Adapters.WebForms.Tests.csproj b/src/Adapters/Tests/WebForms/DotVVM.Adapters.WebForms.Tests.csproj new file mode 100644 index 0000000000..08d3a2e33f --- /dev/null +++ b/src/Adapters/Tests/WebForms/DotVVM.Adapters.WebForms.Tests.csproj @@ -0,0 +1,28 @@ + + + + net472 + false + + + false + + + + + + + + + + + + + + + + + + + + diff --git a/src/Adapters/Tests/WebForms/HybridRouteLinkTests.cs b/src/Adapters/Tests/WebForms/HybridRouteLinkTests.cs new file mode 100644 index 0000000000..fe3cc1616d --- /dev/null +++ b/src/Adapters/Tests/WebForms/HybridRouteLinkTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Web; +using CheckTestOutput; +using DotVVM.Framework.Configuration; +using DotVVM.Framework.Testing; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotVVM.Adapters.WebForms.Tests +{ + [TestClass] + public class HybridRouteLinkTests + { + private static readonly ControlTestHelper cth = new ControlTestHelper(config: config => config.AddWebFormsAdapters()); + OutputChecker check = new OutputChecker("testoutputs"); + + [ClassInitialize] + public static void Init(TestContext testContext) + { + WebFormsRouteTableInit.EnsureInitialized(); + } + + [TestMethod] + public async Task HybridRouteLink_NoBindings() + { + HttpContext.Current = new HttpContext( + new HttpRequest("", "http://tempuri.org", ""), + new HttpResponse(new StringWriter()) + ); + + var r = await cth.RunPage(typeof(ControlTestViewModel), @" + + + + + + + "); + + check.CheckString(r.FormattedHtml, fileExtension: "html"); + } + + [TestMethod] + public async Task HybridRouteLink_ValueBinding() + { + HttpContext.Current = new HttpContext( + new HttpRequest("", "http://tempuri.org", ""), + new HttpResponse(new StringWriter()) + ); + + var r = await cth.RunPage(typeof(ControlTestViewModel), @" + + + + "); + + check.CheckString(r.FormattedHtml, fileExtension: "html"); + } + + [TestMethod] + public async Task HybridRouteLink_SuffixAndQueryString() + { + HttpContext.Current = new HttpContext( + new HttpRequest("", "http://tempuri.org", ""), + new HttpResponse(new StringWriter()) + ); + + var r = await cth.RunPage(typeof(ControlTestViewModel), @" + + + + + "); + + check.CheckString(r.FormattedHtml, fileExtension: "html"); + } + } + + class ControlTestViewModel + { + public int Value { get; set; } = 15; + + public List Items { get; set; } = new() + { + new ControlTestChildViewModel() { Id = 1, Name = "one" }, + new ControlTestChildViewModel() { Id = 2, Name = "two" }, + new ControlTestChildViewModel() { Id = 3, Name = "three" } + }; + } + + class ControlTestChildViewModel + { + public int Id { get; set; } + public string Name { get; set; } + } +} diff --git a/src/Adapters/Tests/WebForms/WebFormsRouteTableInit.cs b/src/Adapters/Tests/WebForms/WebFormsRouteTableInit.cs new file mode 100644 index 0000000000..c56508ce8b --- /dev/null +++ b/src/Adapters/Tests/WebForms/WebFormsRouteTableInit.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Web; +using System.Web.Routing; + +namespace DotVVM.Adapters.WebForms.Tests +{ + public static class WebFormsRouteTableInit + { + + static WebFormsRouteTableInit() + { + RouteTable.Routes.Add("NoParams", new Route("", new EmptyHandler())); + RouteTable.Routes.Add("SingleParam", new Route("page/{Index}", new EmptyHandler())); + RouteTable.Routes.Add("MultipleOptionalParams", new Route("catalog/{Tag}/{SubTag}", new EmptyHandler()) { Defaults = new RouteValueDictionary(new { Tag = "xx", SubTag = "yy" })}); + } + + public static void EnsureInitialized() + { + } + + } + + public class EmptyHandler : IRouteHandler + { + public IHttpHandler GetHttpHandler(RequestContext requestContext) => throw new NotImplementedException(); + } +} diff --git a/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_NoBindings.html b/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_NoBindings.html new file mode 100644 index 0000000000..8c77847baa --- /dev/null +++ b/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_NoBindings.html @@ -0,0 +1,12 @@ + + + + hello 1 + hello 2 + hello 3 + hello 4 + hello 5 + hello 6 + hello 6 + + diff --git a/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_SuffixAndQueryString.html b/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_SuffixAndQueryString.html new file mode 100644 index 0000000000..7d179a3a4a --- /dev/null +++ b/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_SuffixAndQueryString.html @@ -0,0 +1,10 @@ + + + + hello 1 + hello 2 + hello 3 + hello 4 + hello 5 + + diff --git a/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_ValueBinding.html b/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_ValueBinding.html new file mode 100644 index 0000000000..cc45494e0d --- /dev/null +++ b/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_ValueBinding.html @@ -0,0 +1,10 @@ + + + + hello 3 +
+ + + + + diff --git a/src/Adapters/WebForms/Controls/HybridRouteLink.cs b/src/Adapters/WebForms/Controls/HybridRouteLink.cs new file mode 100644 index 0000000000..0606f75011 --- /dev/null +++ b/src/Adapters/WebForms/Controls/HybridRouteLink.cs @@ -0,0 +1,57 @@ +using System; +using DotVVM.Framework.Controls; +using DotVVM.Framework.Hosting; + +#if NETFRAMEWORK +using System.Web.Routing; +#endif + +namespace DotVVM.Adapters.WebForms.Controls +{ + /// + /// Renders a hyperlink pointing to the specified DotVVM route if such route exists; otherwise it falls back to a Web Forms route with the specified name. + /// +#if !NETFRAMEWORK + [Obsolete("This control is used only during the Web Forms migration and is not needed in .NET Core. Use the standard RouteLink control.")] +#endif + public class HybridRouteLink : CompositeControl + { + private readonly IDotvvmRequestContext context; + + public HybridRouteLink(IDotvvmRequestContext context) + { + this.context = context; + } + + public DotvvmControl GetContents( + HtmlCapability htmlCapability, + TextOrContentCapability textOrContent, + RouteLinkCapability routeLinkCapability + ) + { + if (context.Configuration.RouteTable.Contains(routeLinkCapability.RouteName)) + { + return GenerateDotvvmRouteLink(htmlCapability, textOrContent, routeLinkCapability); + } +#if NETFRAMEWORK + else if (RouteTable.Routes[routeLinkCapability.RouteName] is Route webFormsRoute) + { + return WebFormsLinkUtils.BuildWebFormsRouteLink(this, context, htmlCapability, textOrContent, routeLinkCapability, webFormsRoute); + } +#endif + else + { + throw new DotvvmControlException($"Route '{routeLinkCapability.RouteName}' does not exist."); + } + } + + private static DotvvmControl GenerateDotvvmRouteLink(HtmlCapability htmlCapability, TextOrContentCapability textOrContent, RouteLinkCapability routeLinkCapability) + { + return new RouteLink() + .SetCapability(htmlCapability) + .SetCapability(textOrContent) + .SetCapability(routeLinkCapability); + } + + } +} diff --git a/src/Adapters/WebForms/Controls/WebFormsLinkUtils.cs b/src/Adapters/WebForms/Controls/WebFormsLinkUtils.cs new file mode 100644 index 0000000000..ba730df1d9 --- /dev/null +++ b/src/Adapters/WebForms/Controls/WebFormsLinkUtils.cs @@ -0,0 +1,132 @@ +#if NETFRAMEWORK +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using DotVVM.Framework.Binding; +using DotVVM.Framework.Controls; +using DotVVM.Framework.Routing; +using DotVVM.Framework.Utils; +using System.Web.Routing; +using System.Web; +using DotVVM.Framework.Binding.Expressions; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Adapters.WebForms.Controls +{ + public class WebFormsLinkUtils + { + public static HtmlGenericControl BuildWebFormsRouteLink(DotvvmControl container, IDotvvmRequestContext context, HtmlCapability htmlCapability, TextOrContentCapability textOrContent, RouteLinkCapability routeLinkCapability, Route webFormsRoute) + { + var link = new HtmlGenericControl("a", textOrContent, htmlCapability); + + var parameters = BuildParameters(context, routeLinkCapability, webFormsRoute); + if (routeLinkCapability.UrlSuffix is { HasBinding: true, BindingOrDefault: IValueBinding } + || routeLinkCapability.Params.Any(p => p.Value is { HasBinding: true, BindingOrDefault: IValueBinding })) + { + // bindings are used, we have to generate client-script code + var fragments = new List { KnockoutHelper.MakeStringLiteral(context.TranslateVirtualPath("~/")) }; + + // generate binding and embed it in the function call + var routeUrlExpression = GenerateRouteUrlExpression(container, webFormsRoute, parameters); + fragments.Add(routeUrlExpression); + + // generate URL suffix + if (GenerateUrlSuffixExpression(container, routeLinkCapability) is string urlSuffix) + { + fragments.Add(urlSuffix); + } + + // render the binding and try to evaluate it on the server + link.AddAttribute("data-bind", "attr: { 'href': " + fragments.StringJoin("+") + "}"); + if (container.DataContext != null) + { + try + { + var url = context.TranslateVirtualPath(EvaluateRouteUrl(container, webFormsRoute, parameters, routeLinkCapability)); + link.SetAttribute("href", url); + } + catch (Exception ex) + { + } + } + } + else + { + // the value can be built on the server + var url = context.TranslateVirtualPath(EvaluateRouteUrl(container, webFormsRoute, parameters, routeLinkCapability)); + link.SetAttribute("href", url); + } + + return link; + } + + private static IDictionary> BuildParameters(IDotvvmRequestContext context, RouteLinkCapability routeLinkCapability, Route webFormsRoute) + { + var parameters = webFormsRoute.Defaults?.ToDictionary(t => t.Key, t => ValueOrBinding.FromBoxedValue(t.Value)) + ?? new Dictionary>(); + foreach (var param in context.Parameters) + { + parameters[param.Key] = ValueOrBinding.FromBoxedValue(param.Value); + } + + foreach (var item in routeLinkCapability.Params) + { + parameters[item.Key] = item.Value; + } + + return parameters; + } + + private static string EvaluateRouteUrl(DotvvmControl container, Route webFormsRoute, IDictionary> parameters, RouteLinkCapability routeLinkCapability) + { + // evaluate bindings on server + var routeValues = new RouteValueDictionary(); + foreach (Match param in Regex.Matches(webFormsRoute.Url, @"\{([^{}/]+)\}")) // https://referencesource.microsoft.com/#System.Web/Routing/RouteParser.cs,48 + { + var paramName = param.Groups[1].Value; + parameters.TryGetValue(paramName, out var value); + routeValues[paramName] = value.Evaluate(container) ?? ""; + } + + // generate the URL + return "~/" + + webFormsRoute.GetVirtualPath(HttpContext.Current.Request.RequestContext, routeValues)?.VirtualPath + + UrlHelper.BuildUrlSuffix(routeLinkCapability.UrlSuffix?.Evaluate(container), routeLinkCapability.QueryParameters.OrderBy(p => p.Key).Select(p => new KeyValuePair(p.Key, p.Value.Evaluate(container)))); + } + + private static string GenerateRouteUrlExpression(DotvvmControl container, Route webFormsRoute, IDictionary> parameters) + { + var parametersExpression = parameters + .OrderBy(p => p.Key) + .Select(p => $"{KnockoutHelper.MakeStringLiteral(p.Key)}: {p.Value.GetJsExpression(container)}") + .StringJoin(","); + var routeUrlExpression = $"dotvvm.buildRouteUrl({KnockoutHelper.MakeStringLiteral(webFormsRoute.Url)}, {{{parametersExpression}}})"; + return routeUrlExpression; + } + + private static string GenerateUrlSuffixExpression(DotvvmControl container, RouteLinkCapability routeLinkCapability) + { + var urlSuffixBase = routeLinkCapability.UrlSuffix?.GetJsExpression(container) ?? "\"\""; + var queryParams = routeLinkCapability.QueryParameters + .OrderBy(p => p.Key) + .Select(p => $"{KnockoutHelper.MakeStringLiteral(p.Key)}: {p.Value.GetJsExpression(container)}") + .StringJoin(","); + + // generate the function call + if (queryParams.Any()) + { + return $"dotvvm.buildUrlSuffix({urlSuffixBase}, {{{queryParams}}})"; + } + else if (urlSuffixBase != "\"\"") + { + return urlSuffixBase; + } + else + { + return null; + } + } + } +} +#endif diff --git a/src/Adapters/WebForms/DotVVM.Adapters.WebForms.csproj b/src/Adapters/WebForms/DotVVM.Adapters.WebForms.csproj new file mode 100644 index 0000000000..096c07a02b --- /dev/null +++ b/src/Adapters/WebForms/DotVVM.Adapters.WebForms.csproj @@ -0,0 +1,27 @@ + + + + $(DefaultTargetFrameworks) + DotVVM.Adapters.WebForms + + This package contains helpers for migration of ASP.NET Web Forms application to DotVVM. + $(Description) + + + + + + + True + dotvvmwizard.snk + + + + + + + + + + + diff --git a/src/Adapters/WebForms/DotvvmConfigurationExtensions.cs b/src/Adapters/WebForms/DotvvmConfigurationExtensions.cs new file mode 100644 index 0000000000..a5e6cbd2bc --- /dev/null +++ b/src/Adapters/WebForms/DotvvmConfigurationExtensions.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Adapters.WebForms.Controls; + +// ReSharper disable once CheckNamespace +namespace DotVVM.Framework.Configuration +{ + public static class DotvvmConfigurationExtensions + { + + public static void AddWebFormsAdapters(this DotvvmConfiguration config) + { + config.Markup.AddCodeControls("webforms", typeof(HybridRouteLink)); + config.Markup.Assemblies.Add(typeof(DotvvmConfigurationExtensions).Assembly.FullName); + } + } +} diff --git a/src/Adapters/WebForms/WebFormsAdaptersExtensions.cs b/src/Adapters/WebForms/WebFormsAdaptersExtensions.cs new file mode 100644 index 0000000000..30fd66e68a --- /dev/null +++ b/src/Adapters/WebForms/WebFormsAdaptersExtensions.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Web; +using DotVVM.Framework.Routing; + +#if NETFRAMEWORK +using System.Web.Routing; +#endif + +// ReSharper disable once CheckNamespace +namespace DotVVM.Framework.Hosting +{ + public static class WebFormsAdaptersExtensions + { + + /// + /// Redirects to the specified DotVVM route if such route exists; otherwise it redirects to the specified Web Forms route. + /// +#if !NETFRAMEWORK + [Obsolete("This method is used only during the Web Forms migration and is not needed in .NET Core. Use the standard RedirectToRoute method.")] +#endif + public static void RedirectToRouteHybrid(this IDotvvmRequestContext context, string routeName, object routeValues = null, string urlSuffix = null, object query = null) + { + if (context.Configuration.RouteTable.Contains(routeName)) + { + // we have DotVVM route - use it + var url = context.Configuration.RouteTable[routeName].BuildUrl(routeValues); + url += UrlHelper.BuildUrlSuffix(urlSuffix, query); + context.RedirectToUrl(url); + } +#if NETFRAMEWORK + else if (RouteTable.Routes[routeName] is Route webFormsRoute) + { + // fall back to the Web Forms route + var url = webFormsRoute.GetVirtualPath(HttpContext.Current.Request.RequestContext, new RouteValueDictionary(routeValues))!.VirtualPath; + url += UrlHelper.BuildUrlSuffix(urlSuffix, query); + context.RedirectToUrl(url); + } +#endif + else + { + throw new ArgumentException($"The route {routeName} doesn't exist!"); + } + } + } +} diff --git a/src/Adapters/WebForms/dotvvmwizard.snk b/src/Adapters/WebForms/dotvvmwizard.snk new file mode 100644 index 0000000000..34430b3c78 Binary files /dev/null and b/src/Adapters/WebForms/dotvvmwizard.snk differ diff --git a/src/DotVVM.sln b/src/DotVVM.sln index 3542ef1634..0c677c9d39 100644 --- a/src/DotVVM.sln +++ b/src/DotVVM.sln @@ -123,6 +123,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DotVVM.Framework.Controls.D EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DotVVM.Framework.Controls.DynamicData", "DynamicData\DynamicData\DotVVM.Framework.Controls.DynamicData.csproj", "{9E19A537-E1B2-4D1E-A904-D99D4222474F}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Adapters", "Adapters", "{11C116EC-5E5A-400A-9311-0732DD69401C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WebForms", "WebForms", "{42513853-3772-46D2-94C2-965101E2406D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{05A3401A-C541-4F7C-AAD8-02A23648CD27}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DotVVM.Adapters.WebForms.Tests", "Adapters\Tests\WebForms\DotVVM.Adapters.WebForms.Tests.csproj", "{A6A8451E-99D8-4296-BBA9-69E1E289270A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DotVVM.Adapters.WebForms", "Adapters\WebForms\DotVVM.Adapters.WebForms.csproj", "{25442AA8-7E4D-47EC-8CCB-F9E2B45EB998}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -697,6 +707,30 @@ Global {9E19A537-E1B2-4D1E-A904-D99D4222474F}.Release|x64.Build.0 = Release|Any CPU {9E19A537-E1B2-4D1E-A904-D99D4222474F}.Release|x86.ActiveCfg = Release|Any CPU {9E19A537-E1B2-4D1E-A904-D99D4222474F}.Release|x86.Build.0 = Release|Any CPU + {A6A8451E-99D8-4296-BBA9-69E1E289270A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6A8451E-99D8-4296-BBA9-69E1E289270A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6A8451E-99D8-4296-BBA9-69E1E289270A}.Debug|x64.ActiveCfg = Debug|Any CPU + {A6A8451E-99D8-4296-BBA9-69E1E289270A}.Debug|x64.Build.0 = Debug|Any CPU + {A6A8451E-99D8-4296-BBA9-69E1E289270A}.Debug|x86.ActiveCfg = Debug|Any CPU + {A6A8451E-99D8-4296-BBA9-69E1E289270A}.Debug|x86.Build.0 = Debug|Any CPU + {A6A8451E-99D8-4296-BBA9-69E1E289270A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6A8451E-99D8-4296-BBA9-69E1E289270A}.Release|Any CPU.Build.0 = Release|Any CPU + {A6A8451E-99D8-4296-BBA9-69E1E289270A}.Release|x64.ActiveCfg = Release|Any CPU + {A6A8451E-99D8-4296-BBA9-69E1E289270A}.Release|x64.Build.0 = Release|Any CPU + {A6A8451E-99D8-4296-BBA9-69E1E289270A}.Release|x86.ActiveCfg = Release|Any CPU + {A6A8451E-99D8-4296-BBA9-69E1E289270A}.Release|x86.Build.0 = Release|Any CPU + {25442AA8-7E4D-47EC-8CCB-F9E2B45EB998}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25442AA8-7E4D-47EC-8CCB-F9E2B45EB998}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25442AA8-7E4D-47EC-8CCB-F9E2B45EB998}.Debug|x64.ActiveCfg = Debug|Any CPU + {25442AA8-7E4D-47EC-8CCB-F9E2B45EB998}.Debug|x64.Build.0 = Debug|Any CPU + {25442AA8-7E4D-47EC-8CCB-F9E2B45EB998}.Debug|x86.ActiveCfg = Debug|Any CPU + {25442AA8-7E4D-47EC-8CCB-F9E2B45EB998}.Debug|x86.Build.0 = Debug|Any CPU + {25442AA8-7E4D-47EC-8CCB-F9E2B45EB998}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25442AA8-7E4D-47EC-8CCB-F9E2B45EB998}.Release|Any CPU.Build.0 = Release|Any CPU + {25442AA8-7E4D-47EC-8CCB-F9E2B45EB998}.Release|x64.ActiveCfg = Release|Any CPU + {25442AA8-7E4D-47EC-8CCB-F9E2B45EB998}.Release|x64.Build.0 = Release|Any CPU + {25442AA8-7E4D-47EC-8CCB-F9E2B45EB998}.Release|x86.ActiveCfg = Release|Any CPU + {25442AA8-7E4D-47EC-8CCB-F9E2B45EB998}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -753,6 +787,10 @@ Global {DB0AB0C3-DA5E-4B5A-9CD4-036D37B50AED} = {E57EE0B8-30FC-4702-B310-FB82C19D7473} {3209E1B1-88BB-4A95-B234-950E89EFCEE0} = {CF90322D-63BC-4047-BFEA-EE87E45020AF} {9E19A537-E1B2-4D1E-A904-D99D4222474F} = {CF90322D-63BC-4047-BFEA-EE87E45020AF} + {42513853-3772-46D2-94C2-965101E2406D} = {11C116EC-5E5A-400A-9311-0732DD69401C} + {05A3401A-C541-4F7C-AAD8-02A23648CD27} = {42513853-3772-46D2-94C2-965101E2406D} + {A6A8451E-99D8-4296-BBA9-69E1E289270A} = {05A3401A-C541-4F7C-AAD8-02A23648CD27} + {25442AA8-7E4D-47EC-8CCB-F9E2B45EB998} = {42513853-3772-46D2-94C2-965101E2406D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {61F8A195-365E-47B1-A6F2-CD3534E918F8} diff --git a/src/Framework/Framework/Binding/BindingFactory.cs b/src/Framework/Framework/Binding/BindingFactory.cs index dc6ae7d584..6b60c2b842 100644 --- a/src/Framework/Framework/Binding/BindingFactory.cs +++ b/src/Framework/Framework/Binding/BindingFactory.cs @@ -44,7 +44,7 @@ public static IBinding CreateBinding(this BindingCompilationService service, Typ if (ctor == null) throw new NotSupportedException($"Could not find .ctor(BindingCompilationService service, object[] properties) on binding '{type.FullName}'."); var bindingServiceParam = Expression.Parameter(typeof(BindingCompilationService)); var propertiesParam = Expression.Parameter(typeof(object?[])); - var expression = Expression.New(ctor, bindingServiceParam, TypeConversion.ImplicitConversion(propertiesParam, ctor.GetParameters()[1].ParameterType, throwException: true)!); + var expression = Expression.New(ctor, bindingServiceParam, TypeConversion.EnsureImplicitConversion(propertiesParam, ctor.GetParameters()[1].ParameterType)); return Expression.Lambda>(expression, bindingServiceParam, propertiesParam).CompileFast(flags: CompilerFlags.ThrowOnNotSupportedExpression); })(service, properties); } diff --git a/src/Framework/Framework/Compilation/Binding/ExpressionBuildingVisitor.cs b/src/Framework/Framework/Compilation/Binding/ExpressionBuildingVisitor.cs index dda018f62d..4d387a8742 100644 --- a/src/Framework/Framework/Compilation/Binding/ExpressionBuildingVisitor.cs +++ b/src/Framework/Framework/Compilation/Binding/ExpressionBuildingVisitor.cs @@ -123,6 +123,7 @@ protected override Expression VisitInterpolatedStringExpression(InterpolatedStri { // Translate to a String.Format(...) call var arguments = node.Arguments.Select((arg, index) => HandleErrors(node.Arguments[index], Visit)!).ToArray(); + ThrowOnErrors(); return memberExpressionFactory.Call(target, new[] { Expression.Constant(node.Format) }.Concat(arguments).ToArray()); } else @@ -294,7 +295,7 @@ protected override Expression VisitAssemblyQualifiedName(AssemblyQualifiedNameBi protected override Expression VisitConditionalExpression(ConditionalExpressionBindingParserNode node) { - var condition = HandleErrors(node.ConditionExpression, n => TypeConversion.ImplicitConversion(Visit(n), typeof(bool), true)); + var condition = HandleErrors(node.ConditionExpression, n => TypeConversion.EnsureImplicitConversion(Visit(n), typeof(bool))); var trueExpr = HandleErrors(node.TrueExpression, Visit)!; var falseExpr = HandleErrors(node.FalseExpression, Visit)!; ThrowOnErrors(); diff --git a/src/Framework/Framework/Compilation/Binding/ExpressionNullPropagationVisitor.cs b/src/Framework/Framework/Compilation/Binding/ExpressionNullPropagationVisitor.cs index 2c0f9c1729..51ead1e4ee 100644 --- a/src/Framework/Framework/Compilation/Binding/ExpressionNullPropagationVisitor.cs +++ b/src/Framework/Framework/Compilation/Binding/ExpressionNullPropagationVisitor.cs @@ -123,8 +123,8 @@ protected override Expression VisitConditional(ConditionalExpression node) if (ifTrue.Type != ifFalse.Type) { var nullable = ifTrue.Type.IsNullable() ? ifTrue.Type : ifFalse.Type; - ifTrue = TypeConversion.ImplicitConversion(ifTrue, nullable, throwException: true)!; - ifFalse = TypeConversion.ImplicitConversion(ifFalse, nullable, throwException: true)!; + ifTrue = TypeConversion.EnsureImplicitConversion(ifTrue, nullable); + ifFalse = TypeConversion.EnsureImplicitConversion(ifFalse, nullable); } return Expression.Condition(test, ifTrue, ifFalse); }); @@ -181,7 +181,7 @@ protected override Expression VisitMethodCall(MethodCallExpression node) { return CheckForNull(Visit(node.Arguments.First()), index => { - var convertedIndex = TypeConversion.ImplicitConversion(index, node.Method.GetParameters().First().ParameterType, throwException: true)!; + var convertedIndex = TypeConversion.EnsureImplicitConversion(index, node.Method.GetParameters().First().ParameterType); return Expression.Call(target, node.Method, new[] { convertedIndex }.Concat(node.Arguments.Skip(1))); }); }, suppress: node.Object?.Type?.IsNullable() ?? true); @@ -244,7 +244,7 @@ protected Expression CheckForNull(Expression? parameter, Func().Distinct().ToArray(); + + + // https://github.com/dotnet/csharpstandard/blob/standard-v6/standard/expressions.md#1145-binary-operator-overload-resolution + // The set of candidate user-defined operators provided by X and Y for the operation operator «op»(x, y) is determined. The set consists of the union of the candidate operators provided by X and the candidate operators provided by Y, each determined using the rules of §11.4.6. + + var candidateMethods = + searchTypes + .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)) + .Where(m => m.Name == operatorName && !m.IsGenericMethod && m.GetParameters().Length == 2) + .Distinct() + .ToArray(); + + // The overload resolution rules of §11.6.4 are applied to the set of candidate operators to select the best operator with respect to the argument list (x, y), and this operator becomes the result of the overload resolution process. If overload resolution fails to select a single best operator, a binding-time error occurs. + + var matchingMethods = FindValidMethodOverloads(candidateMethods, operatorName, false, null, new[] { a, b }, null); + var liftToNull = matchingMethods.Count == 0 && (a.Type.IsNullable() || b.Type.IsNullable()); + if (liftToNull) + { + matchingMethods = FindValidMethodOverloads(candidateMethods, operatorName, false, null, new[] { a.UnwrapNullable(), b.UnwrapNullable() }, null); + } + + if (matchingMethods.Count == 0) + return null; + var overload = BestOverload(matchingMethods, searchTypes, operatorName); + var parameters = overload.Method.GetParameters(); + + return Expression.MakeBinary( + operatorType, + TypeConversion.EnsureImplicitConversion(a, parameters[0].ParameterType), + TypeConversion.EnsureImplicitConversion(b, parameters[1].ParameterType), + liftToNull: liftToNull, + method: overload.Method + ); + } private MethodRecognitionResult FindValidMethodOverloads(Expression? target, Type type, string name, BindingFlags flags, Type[]? typeArguments, Expression[] arguments, IDictionary? namedArgs) { + bool extensionMethods = false; var methods = FindValidMethodOverloads(type.GetAllMethods(flags), name, false, typeArguments, arguments, namedArgs); if (methods.Count == 1) return methods[0]; if (methods.Count == 0) { // We did not find any match in regular methods => try extension methods - if (target != null) + if (target != null && flags.HasFlag(BindingFlags.Instance)) { + extensionMethods = true; // Change to a static call var newArguments = new[] { target }.Concat(arguments).ToArray(); var extensions = FindValidMethodOverloads(GetAllExtensionMethods(), name, true, typeArguments, newArguments, namedArgs); @@ -270,13 +313,25 @@ private MethodRecognitionResult FindValidMethodOverloads(Expression? target, Typ } // There are multiple method candidates - methods = methods.OrderBy(s => s.CastCount).ThenBy(s => s.AutomaticTypeArgCount).ThenBy(s => s.HasParamsAttribute).ToList(); + return BestOverload(methods, extensionMethods ? Type.EmptyTypes : new[] { type }, name); + } + + private MethodRecognitionResult BestOverload(List methods, Type[] callingOnType, string name) + { + if (methods.Count == 1) + return methods[0]; + + methods = methods + .OrderBy(s => GetNearestInheritanceDistance(s.Method.DeclaringType, callingOnType)) + .ThenBy(s => s.CastCount) + .ThenBy(s => s.AutomaticTypeArgCount) + .ThenBy(s => s.HasParamsAttribute).ToList(); var method = methods.First(); var method2 = methods.Skip(1).First(); - if (method.AutomaticTypeArgCount == method2.AutomaticTypeArgCount && method.CastCount == method2.CastCount && method.HasParamsAttribute == method2.HasParamsAttribute) + if (method.AutomaticTypeArgCount == method2.AutomaticTypeArgCount && method.CastCount == method2.CastCount && method.HasParamsAttribute == method2.HasParamsAttribute && GetNearestInheritanceDistance(method.Method.DeclaringType, callingOnType) == GetNearestInheritanceDistance(method2.Method.DeclaringType, callingOnType)) { // TODO: this behavior is not completed. Implement the same behavior as in roslyn. - var foundOverloads = $"{method.Method}, {method2.Method}"; + var foundOverloads = $"{ReflectionUtils.FormatMethodInfo(method.Method, stripNamespace: true)}, {ReflectionUtils.FormatMethodInfo(method2.Method, stripNamespace: true)}"; throw new InvalidOperationException($"Found ambiguous overloads of method '{name}'. The following overloads were found: {foundOverloads}."); } return method; @@ -305,6 +360,40 @@ private List FindValidMethodOverloads(IEnumerable t.ToCode()))}'."); + return distance; + } + sealed class MethodRecognitionResult { public MethodRecognitionResult(int automaticTypeArgCount, int castCount, Expression[] arguments, MethodInfo method, int paramsArrayCount, bool isExtension, bool hasParamsAttribute) @@ -329,6 +418,7 @@ public MethodRecognitionResult(int automaticTypeArgCount, int castCount, Express private MethodRecognitionResult? TryCallMethod(MethodInfo method, Type[]? typeArguments, Expression[] positionalArguments, IDictionary? namedArguments) { + if (positionalArguments.Contains(null)) throw new ArgumentNullException("positionalArguments[]"); var parameters = method.GetParameters(); if (!TryPrepareArguments(parameters, positionalArguments, namedArguments, out var args, out var castCount)) @@ -406,7 +496,7 @@ public MethodRecognitionResult(int automaticTypeArgCount, int castCount, Express if (args.Length == i + 1 && hasParamsArrayAttributes && !args[i].Type.IsArray) { var converted = positionalArguments.Skip(i) - .Select(a => TypeConversion.ImplicitConversion(a, elm, throwException: true)!) + .Select(a => TypeConversion.EnsureImplicitConversion(a, elm)) .ToArray(); args[i] = NewArrayExpression.NewArrayInit(elm, converted); } @@ -561,131 +651,11 @@ private static bool TryPrepareArguments(ParameterInfo[] parameters, Expression[] return null; } - public Expression EqualsMethod(Expression left, Expression right) - { - Expression? equatable = null; - Expression? theOther = null; - if (typeof(IEquatable<>).IsAssignableFrom(left.Type)) - { - equatable = left; - theOther = right; - } - else if (typeof(IEquatable<>).IsAssignableFrom(right.Type)) - { - equatable = right; - theOther = left; - } - - if (equatable != null) - { - var m = CallMethod(equatable, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy, "Equals", null, new[] { theOther! }); - if (m != null) return m; - } - - if (left.Type.IsValueType) - { - equatable = left; - theOther = right; - } - else if (left.Type.IsValueType) - { - equatable = right; - theOther = left; - } - - if (equatable != null) - { - theOther = TypeConversion.ImplicitConversion(theOther!, equatable.Type); - if (theOther != null) return Expression.Equal(equatable, theOther); - } - - return CallMethod(left, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy, "Equals", null, new[] { right }); - } - - public Expression CompareMethod(Expression left, Expression right) - { - Type compareType = typeof(object); - Expression? equatable = null; - Expression? theOther = null; - if (typeof(IComparable<>).IsAssignableFrom(left.Type)) - { - equatable = left; - theOther = right; - } - else if (typeof(IComparable<>).IsAssignableFrom(right.Type)) - { - equatable = right; - theOther = left; - } - else if (typeof(IComparable).IsAssignableFrom(left.Type)) - { - equatable = left; - theOther = right; - } - else if (typeof(IComparable).IsAssignableFrom(right.Type)) - { - equatable = right; - theOther = left; - } - - if (equatable != null) - { - return CallMethod(equatable, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy, "Compare", null, new[] { theOther! }); - } - throw new NotSupportedException("IComparable is not implemented on any of specified types"); - } - public Expression GetUnaryOperator(Expression expr, ExpressionType operation) { var binder = (DynamicMetaObjectBinder)Microsoft.CSharp.RuntimeBinder.Binder.UnaryOperation( CSharpBinderFlags.None, operation, typeof(object), ExpressionHelper.GetBinderArguments(1)); return ExpressionHelper.ApplyBinder(binder, true, expr)!; } - - public Expression GetBinaryOperator(Expression left, Expression right, ExpressionType operation) - { - if (operation == ExpressionType.Coalesce) - { - // in bindings, most expressions will be nullable due to automatic null-propagation - // the null propagation visitor however runs after this, so we need to convert left to nullable - // to make the validation in Expression.Coalesce happy - var leftNullable = - left.Type.IsValueType && !left.Type.IsNullable() - ? Expression.Convert(left, typeof(Nullable<>).MakeGenericType(left.Type)) - : left; - return Expression.Coalesce(leftNullable, right); - } - if (operation == ExpressionType.Assign) - { - return UpdateMember(left, TypeConversion.ImplicitConversion(right, left.Type, true, true)!) - .NotNull($"Expression '{right}' cannot be assigned into '{left}'."); - } - - // TODO: type conversions - if (operation == ExpressionType.AndAlso) return Expression.AndAlso(left, right); - else if (operation == ExpressionType.OrElse) return Expression.OrElse(left, right); - - var binder = (DynamicMetaObjectBinder)Microsoft.CSharp.RuntimeBinder.Binder.BinaryOperation( - CSharpBinderFlags.None, operation, typeof(object), ExpressionHelper.GetBinderArguments(2)); - var result = ExpressionHelper.ApplyBinder(binder, false, left, right); - if (result != null) return result; - if (operation == ExpressionType.Equal) return EqualsMethod(left, right); - if (operation == ExpressionType.NotEqual) return Expression.Not(EqualsMethod(left, right)); - - - // try converting left to right.Type and vice versa - // needed to enum with pseudo-string literal operations - // if (TypeConversion.ImplicitConversion(left, right.Type) is {} leftConverted) - // return GetBinaryOperator(leftConverted, right, operation); - // if (TypeConversion.ImplicitConversion(right, left.Type) is {} rightConverted) - // return GetBinaryOperator(left, rightConverted, operation); - - // lift the operator - if (left.Type.IsNullable() || right.Type.IsNullable()) - return GetBinaryOperator(left.UnwrapNullable(), right.UnwrapNullable(), operation); - - throw new Exception($"could not apply { operation } binary operator to { left } and { right }"); - // TODO: comparison operators - } } } diff --git a/src/Framework/Framework/Compilation/Binding/MethodGroupExpression.cs b/src/Framework/Framework/Compilation/Binding/MethodGroupExpression.cs index 3876324916..6a8e7f9c3c 100644 --- a/src/Framework/Framework/Compilation/Binding/MethodGroupExpression.cs +++ b/src/Framework/Framework/Compilation/Binding/MethodGroupExpression.cs @@ -92,6 +92,9 @@ public Expression CreateDelegateExpression() public Expression CreateMethodCall(IEnumerable args, MemberExpressionFactory memberExpressionFactory) { var argsArray = args.ToArray(); + if (Array.FindIndex(argsArray, a => a is null || a.Type == typeof(UnknownTypeSentinel)) is var argIdx && argIdx >= 0) + throw new Exception($"Argument {argIdx} is invalid: {this.MethodName}({string.Join(", ", argsArray.Select(a => a))})"); + if (IsStatic) { return memberExpressionFactory.CallMethod(((StaticClassIdentifierExpression)Target).Type, BindingFlags.Static | BindingFlags.Public | BindingFlags.FlattenHierarchy, MethodName, TypeArgs, argsArray); diff --git a/src/Framework/Framework/Compilation/Binding/OperatorResolution.cs b/src/Framework/Framework/Compilation/Binding/OperatorResolution.cs new file mode 100644 index 0000000000..ba475be74d --- /dev/null +++ b/src/Framework/Framework/Compilation/Binding/OperatorResolution.cs @@ -0,0 +1,222 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using DotVVM.Framework.Utils; +using FastExpressionCompiler; + +namespace DotVVM.Framework.Compilation.Binding +{ + static class OperatorResolution + { + public static Expression GetBinaryOperator( + this MemberExpressionFactory expressionFactory, + Expression left, + Expression right, + ExpressionType operation) + { + if (operation == ExpressionType.Coalesce) + { + // in bindings, most expressions will be nullable due to automatic null-propagation + // the null propagation visitor however runs after this, so we need to convert left to nullable + // to make the validation in Expression.Coalesce happy + var leftNullable = + left.Type.IsValueType && !left.Type.IsNullable() + ? Expression.Convert(left, typeof(Nullable<>).MakeGenericType(left.Type)) + : left; + return Expression.Coalesce(leftNullable, right); + } + + if (operation == ExpressionType.Assign) + { + return expressionFactory.UpdateMember(left, TypeConversion.EnsureImplicitConversion(right, left.Type, true)) + .NotNull($"Expression '{right}' cannot be assigned into '{left}'."); + } + + // lift to nullable types when one side is `null` + if (left is ConstantExpression { Value: null } && right.Type.IsValueType) + { + left = Expression.Constant(null, right.Type.MakeNullableType()); + right = Expression.Convert(right, right.Type.MakeNullableType()); + } + if (right is ConstantExpression { Value: null } && left.Type.IsValueType) + { + left = Expression.Convert(left, left.Type.MakeNullableType()); + right = Expression.Constant(null, left.Type.MakeNullableType()); + } + + // lift the other side to null + if (left.Type.IsNullable() && right.Type.IsValueType && !right.Type.IsNullable()) + { + right = Expression.Convert(right, right.Type.MakeNullableType()); + } + else if (right.Type.IsNullable() && left.Type.IsValueType && !left.Type.IsNullable()) + { + left = Expression.Convert(left, left.Type.MakeNullableType()); + } + + var leftType = left.Type.UnwrapNullableType(); + var rightType = right.Type.UnwrapNullableType(); + + // we only support booleans + if (operation == ExpressionType.AndAlso) + return Expression.AndAlso(TypeConversion.EnsureImplicitConversion(left, typeof(bool)), TypeConversion.EnsureImplicitConversion(right, typeof(bool))); + else if (operation == ExpressionType.OrElse) + return Expression.OrElse(TypeConversion.EnsureImplicitConversion(left, typeof(bool)), TypeConversion.EnsureImplicitConversion(right, typeof(bool))); + + // skip the slow overload resolution if possible + if (leftType == rightType && !leftType.IsEnum && leftType.IsPrimitive) + return Expression.MakeBinary(operation, left, right); + if (operation == ExpressionType.Add && leftType == typeof(string) && rightType == typeof(string)) + { + return Expression.Add(left, right, typeof(string).GetMethod("Concat", new[] { typeof(string), typeof(string) })); + } + + var customOperator = operation switch { + ExpressionType.Add => "op_Addition", + ExpressionType.Subtract => "op_Subtraction", + ExpressionType.Multiply => "op_Multiply", + ExpressionType.Divide => "op_Division", + ExpressionType.Modulo => "op_Modulus", + ExpressionType.LeftShift => "op_LeftShift", + ExpressionType.RightShift => "op_RightShift", + ExpressionType.And => "op_BitwiseAnd", + ExpressionType.Or => "op_BitwiseOr", + ExpressionType.ExclusiveOr => "op_ExclusiveOr", + ExpressionType.Equal => "op_Equality", + ExpressionType.NotEqual => "op_Inequality", + ExpressionType.GreaterThan => "op_GreaterThan", + ExpressionType.LessThan => "op_LessThan", + ExpressionType.GreaterThanOrEqual => "op_GreaterThanOrEqual", + ExpressionType.LessThanOrEqual => "op_LessThanOrEqual", + _ => null + }; + + // Try to find user defined operator + if (customOperator != null && (!leftType.IsPrimitive || !rightType.IsPrimitive)) + { + var customOperatorExpr = expressionFactory.TryCallCustomBinaryOperator(left, right, customOperator, operation); + if (customOperatorExpr is {}) + return customOperatorExpr; + } + + if (leftType.IsEnum && rightType.IsEnum && leftType != rightType) + { + throw new InvalidOperationException($"Cannot apply {operation} operator to two different enum types: {leftType.Name}, {rightType.Name}."); + } + + if (operation is ExpressionType.Equal or ExpressionType.NotEqual && !leftType.IsValueType && !rightType.IsValueType) + { + // https://github.com/dotnet/csharpstandard/blob/standard-v6/standard/expressions.md#11117-reference-type-equality-operators + // Every class type C implicitly provides the following predefined reference type equality operators: + // bool operator ==(C x, C y); + // bool operator !=(C x, C y); + return ReferenceEquality(left, right, operation == ExpressionType.NotEqual); + } + + if (operation is ExpressionType.LeftShift or ExpressionType.RightShift) + { + // https://github.com/dotnet/csharpstandard/blob/standard-v6/standard/expressions.md#1110-shift-operators + // * shift operators always take int32 as the second argument + var rightConverted = ConvertToMaybeNullable(right, typeof(int), true)!; + // * the first argument is int, uint, long, ulong (in this order) + var leftConverted = ConvertToMaybeNullable(left, typeof(int), false) ?? ConvertToMaybeNullable(left, typeof(uint), false) ?? ConvertToMaybeNullable(left, typeof(long), false) ?? ConvertToMaybeNullable(left, typeof(ulong), false)!; + if (leftConverted is null) + throw new InvalidOperationException($"Cannot apply {operation} operator to type {leftType.ToCode()}. The type must be convertible to an integer or have a custom operator defined."); + return operation == ExpressionType.LeftShift ? Expression.LeftShift(leftConverted, rightConverted) : Expression.RightShift(leftConverted, rightConverted); + } + + // List of types in order of precendence + var enumType = leftType.IsEnum ? leftType : rightType.IsEnum ? rightType : null; + // all operators have defined "overloads" for two + var typeList = operation switch { + ExpressionType.Or or ExpressionType.And or ExpressionType.ExclusiveOr => + new[] { typeof(bool), enumType, typeof(int), typeof(uint), typeof(long), typeof(ulong) }, + _ => + new[] { enumType, typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(float), typeof(double), typeof(decimal) } + }; + + foreach (var commonType in typeList) + { + if (commonType == null) continue; + + var leftConverted = ConvertToMaybeNullable(left, commonType, throwExceptions: false); + var rightConverted = ConvertToMaybeNullable(right, commonType, throwExceptions: false); + + if (leftConverted != null && rightConverted != null) + { + return MakeBinary(operation, leftConverted, rightConverted); + } + } + + if (operation == ExpressionType.Add && (leftType == typeof(string) || rightType == typeof(string))) + { + return Expression.Add( + Expression.Convert(left, typeof(object)), + Expression.Convert(right, typeof(object)), + typeof(string).GetMethod("Concat", new[] { typeof(object), typeof(object) }) + ); + } + + + // if (left.Type.IsNullable() || right.Type.IsNullable()) + // return GetBinaryOperator(expressionFactory, left.UnwrapNullable(), right.UnwrapNullable(), operation); + + throw new InvalidOperationException($"Cannot apply {operation} operator to types {left.Type.Name} and {right.Type.Name}."); + } + + static Expression ReferenceEquality(Expression left, Expression right, bool not) + { + // * It is a binding-time error to use the predefined reference type equality operators to compare two references that are known to be different at binding-time. For example, if the binding-time types of the operands are two class types, and if neither derives from the other, then it would be impossible for the two operands to reference the same object. Thus, the operation is considered a binding-time error. + var leftT = left.Type; + var rightT = right.Type; + if (leftT != rightT && !(leftT.IsAssignableFrom(rightT) || rightT.IsAssignableFrom(leftT))) + { + if (!leftT.IsInterface && rightT.IsInterface) + throw new InvalidOperationException($"Cannot compare types {leftT.ToCode()} and {rightT.ToCode()}, because the classes are unrelated."); + if (leftT.IsSealed || rightT.IsSealed) + throw new InvalidOperationException($"Cannot compare types {leftT.ToCode()} and {rightT.ToCode()}, because {(leftT.IsSealed ? leftT : rightT).ToCode(stripNamespace: true)} is sealed and does not implement {rightT.ToCode(stripNamespace: true)}."); + } + return not ? Expression.ReferenceNotEqual(left, right) : Expression.ReferenceEqual(left, right); + } + + static Expression? ConvertToMaybeNullable( + Expression expression, + Type targetType, + bool throwExceptions + ) + { + return TypeConversion.ImplicitConversion(expression, targetType) ?? + TypeConversion.ImplicitConversion(expression, targetType.MakeNullableType(), throwExceptions); + } + + static Expression MakeBinary(ExpressionType type, Expression left, Expression right) + { + // Expression.MakeBinary doesn't handle enums, we need to convert it to the int and back + // It works however, for Equals/NotEquals + Type? enumType = null; + + if (type != ExpressionType.Equal && type != ExpressionType.NotEqual) + { + if (left.Type.UnwrapNullableType().IsEnum) + { + enumType = left.Type.UnwrapNullableType(); + left = ConvertToMaybeNullable(left, Enum.GetUnderlyingType(enumType), true)!; + } + if (right.Type.UnwrapNullableType().IsEnum) + { + enumType = right.Type.UnwrapNullableType(); + right = ConvertToMaybeNullable(right, Enum.GetUnderlyingType(enumType), true)!; + } + } + + var result = Expression.MakeBinary(type, left, right); + if (enumType != null && result.Type != typeof(bool)) + { + return Expression.Convert(result, enumType); + } + return result; + } + } +} diff --git a/src/Framework/Framework/Compilation/Binding/TypeConversions.cs b/src/Framework/Framework/Compilation/Binding/TypeConversions.cs index f9a297d5d9..0b2d47c600 100644 --- a/src/Framework/Framework/Compilation/Binding/TypeConversions.cs +++ b/src/Framework/Framework/Compilation/Binding/TypeConversions.cs @@ -11,78 +11,24 @@ using DotVVM.Framework.Compilation.ControlTree.Resolved; using DotVVM.Framework.Binding; using DotVVM.Framework.Binding.HelperNamespace; +using FastExpressionCompiler; namespace DotVVM.Framework.Compilation.Binding { public class TypeConversion { - private static Dictionary> ImplicitNumericConversions = new Dictionary>(); - private static readonly Dictionary typePrecedence; - - /// - /// Performs implicit conversion between two expressions depending on their type precedence - /// - /// - /// - internal static void Convert(ref Expression le, ref Expression re) - { - if (typePrecedence.ContainsKey(le.Type) && typePrecedence.ContainsKey(re.Type)) - { - if (typePrecedence[le.Type] > typePrecedence[re.Type]) re = Expression.Convert(re, le.Type); - if (typePrecedence[le.Type] < typePrecedence[re.Type]) le = Expression.Convert(le, re.Type); - } - } - - /// - /// Performs implicit conversion on an expression against a specified type - /// - /// - /// - /// - internal static Expression Convert(Expression le, Type type) - { - if (typePrecedence.ContainsKey(le.Type) && typePrecedence.ContainsKey(type)) - { - if (typePrecedence[le.Type] < typePrecedence[type]) return Expression.Convert(le, type); - } - if (le.Type.IsNullable() && Nullable.GetUnderlyingType(le.Type) == type) - { - le = Expression.Property(le, "Value"); - } - if (type.IsNullable() && Nullable.GetUnderlyingType(type) == le.Type) - { - le = Expression.Convert(le, type); - } - if (type == typeof(object)) - { - return Expression.Convert(le, type); - } - if (le.Type == typeof(object)) - { - return Expression.Convert(le, type); - } - return le; - } - - /// - /// Compares two types for implicit conversion - /// - /// The source type - /// The destination type - /// -1 if conversion is not possible, 0 if no conversion necessary, +1 if conversion possible - internal static int CanConvert(Type from, Type to) - { - if (typePrecedence.ContainsKey(@from) && typePrecedence.ContainsKey(to)) - { - return typePrecedence[to] - typePrecedence[@from]; - } - else - { - if (@from == to) return 0; - if (to.IsAssignableFrom(@from)) return 1; - } - return -1; - } + private static Dictionary ImplicitNumericConversions = new() { + [typeof(sbyte)] = new Type[] { typeof(short), typeof(int), typeof(long), typeof(float), typeof(double), typeof(decimal) }, + [typeof(byte)] = new Type[] { typeof(short), typeof(ushort), typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(float), typeof(double), typeof(decimal) }, + [typeof(short)] = new Type[] { typeof(int), typeof(long), typeof(float), typeof(double), typeof(decimal) }, + [typeof(ushort)] = new Type[] { typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(float), typeof(double), typeof(decimal) }, + [typeof(int)] = new Type[] { typeof(long), typeof(float), typeof(double), typeof(decimal) }, + [typeof(uint)] = new Type[] { typeof(long), typeof(ulong), typeof(float), typeof(double), typeof(decimal) }, + [typeof(long)] = new Type[] { typeof(float), typeof(double), typeof(decimal) }, + [typeof(ulong)] = new Type[] { typeof(float), typeof(double), typeof(decimal) }, + [typeof(char)] = new Type[] { typeof(ushort), typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(float), typeof(double), typeof(decimal) }, + [typeof(float)] = new Type[] { typeof(double) }, + }; // 6.1.7 Boxing Conversions // A boxing conversion permits a value-type to be implicitly converted to a reference type. A boxing conversion exists from any non-nullable-value-type to object and dynamic, @@ -160,12 +106,14 @@ public static Expression BoxToObject(Expression src) } //TODO: Refactor ImplicitConversion usages to EnsureImplicitConversion where applicable to take advantage of nullability - public static Expression EnsureImplicitConversion(Expression src, Type destType) - => ImplicitConversion(src, destType, true, false)!; + public static Expression EnsureImplicitConversion(Expression src, Type destType, bool allowToString = false) + => ImplicitConversion(src, destType, throwException: true, allowToString: allowToString)!; // 6.1 Implicit Conversions public static Expression? ImplicitConversion(Expression src, Type destType, bool throwException = false, bool allowToString = false) { + if (src is null) throw new ArgumentNullException(nameof(src)); + if (destType is null) throw new ArgumentNullException(nameof(destType)); if (src is MethodGroupExpression methodGroup) { return methodGroup.CreateDelegateExpression(destType, throwException); @@ -182,7 +130,7 @@ public static Expression EnsureImplicitConversion(Expression src, Type destType) { result = ToStringConversion(src); } - if (throwException && result == null) throw new InvalidOperationException($"Could not implicitly convert expression of type { src.Type } to { destType }."); + if (throwException && result == null) throw new InvalidOperationException($"Could not implicitly convert expression of type { src.Type.ToCode() } to { destType.ToCode() }."); return result; } @@ -241,42 +189,42 @@ public static bool IsStringConversionAllowed(Type fromType) { if (value >= SByte.MinValue && value <= SByte.MinValue) { - return Expression.Constant((sbyte)srcValue, typeof(sbyte)); + return Expression.Constant((sbyte)value, typeof(sbyte)); } } if (destType == typeof(byte)) { if (value >= Byte.MinValue && value <= Byte.MaxValue) { - return Expression.Constant((byte)srcValue, typeof(byte)); + return Expression.Constant((byte)value, typeof(byte)); } } if (destType == typeof(short)) { if (value >= Int16.MinValue && value <= Int16.MaxValue) { - return Expression.Constant((short)srcValue, typeof(short)); + return Expression.Constant((short)value, typeof(short)); } } if (destType == typeof(ushort)) { if (value >= UInt16.MinValue && value <= UInt16.MaxValue) { - return Expression.Constant((ushort)srcValue, typeof(ushort)); + return Expression.Constant((ushort)value, typeof(ushort)); } } if (destType == typeof(uint)) { if (value >= uint.MinValue) { - return Expression.Constant((uint)srcValue, typeof(uint)); + return Expression.Constant((uint)value, typeof(uint)); } } if (destType == typeof(ulong)) { if (value >= 0) { - return Expression.Constant((ulong)srcValue, typeof(ulong)); + return Expression.Constant((ulong)value, typeof(ulong)); } } } @@ -288,7 +236,7 @@ public static bool IsStringConversionAllowed(Type fromType) { if (value >= 0) { - return Expression.Constant((ulong)srcValue, typeof(ulong)); + return Expression.Constant((ulong)value, typeof(ulong)); } } } @@ -449,31 +397,5 @@ private static Type GetTaskType(Type taskType) else return null; } - - static TypeConversion() - { - typePrecedence = new Dictionary - { - {typeof (object), 0}, - {typeof (bool), 1}, - {typeof (byte), 2}, - {typeof (int), 3}, - {typeof (short), 4}, - {typeof (long), 5}, - {typeof (float), 6}, - {typeof (double), 7} - }; - - ImplicitNumericConversions.Add(typeof(sbyte), new List() { typeof(short), typeof(int), typeof(long), typeof(float), typeof(double), typeof(decimal) }); - ImplicitNumericConversions.Add(typeof(byte), new List() { typeof(short), typeof(ushort), typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(float), typeof(double), typeof(decimal) }); - ImplicitNumericConversions.Add(typeof(short), new List() { typeof(int), typeof(long), typeof(float), typeof(double), typeof(decimal) }); - ImplicitNumericConversions.Add(typeof(ushort), new List() { typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(float), typeof(double), typeof(decimal) }); - ImplicitNumericConversions.Add(typeof(int), new List() { typeof(long), typeof(float), typeof(double), typeof(decimal) }); - ImplicitNumericConversions.Add(typeof(uint), new List() { typeof(long), typeof(ulong), typeof(float), typeof(double), typeof(decimal) }); - ImplicitNumericConversions.Add(typeof(long), new List() { typeof(float), typeof(double), typeof(decimal) }); - ImplicitNumericConversions.Add(typeof(ulong), new List() { typeof(float), typeof(double), typeof(decimal) }); - ImplicitNumericConversions.Add(typeof(char), new List() { typeof(ushort), typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(float), typeof(double), typeof(decimal) }); - ImplicitNumericConversions.Add(typeof(float), new List() { typeof(double) }); - } } } diff --git a/src/Framework/Framework/Controls/DotvvmControl.cs b/src/Framework/Framework/Controls/DotvvmControl.cs index e4b60e573f..b03c192a39 100644 --- a/src/Framework/Framework/Controls/DotvvmControl.cs +++ b/src/Framework/Framework/Controls/DotvvmControl.cs @@ -115,7 +115,7 @@ public ClientIDMode ClientIDMode /// /// Essentially wraps Knockout's 'if' binding. /// - [MarkupOptions(AllowHardCodedValue = false)] + [MarkupOptions] public bool IncludeInPage { get { return (bool)GetValue(IncludeInPageProperty)!; } diff --git a/src/Framework/Framework/Controls/HtmlGenericControl.cs b/src/Framework/Framework/Controls/HtmlGenericControl.cs index b77fe47295..0c8ed73525 100644 --- a/src/Framework/Framework/Controls/HtmlGenericControl.cs +++ b/src/Framework/Framework/Controls/HtmlGenericControl.cs @@ -134,7 +134,7 @@ public string? InnerText /// /// Gets or sets whether the control is visible. When set to false, `style="display: none"` will be added to this control. /// - [MarkupOptions(AllowHardCodedValue = false)] + [MarkupOptions] public bool Visible { get { return (bool)GetValue(VisibleProperty)!; } diff --git a/src/Framework/Framework/Controls/RouteLink.cs b/src/Framework/Framework/Controls/RouteLink.cs index 355d0233b4..d5f5b4c804 100644 --- a/src/Framework/Framework/Controls/RouteLink.cs +++ b/src/Framework/Framework/Controls/RouteLink.cs @@ -64,11 +64,17 @@ public string Text public static readonly DotvvmProperty TextProperty = DotvvmProperty.Register(c => c.Text, ""); + /// + /// Gets or sets a collection of parameters to be substituted in the route URL. If the current route contains a parameter with the same name, its value will be reused unless another value is specified here. + /// [PropertyGroup("Param-")] public VirtualPropertyGroupDictionary Params => new VirtualPropertyGroupDictionary(this, ParamsGroupDescriptor); public static DotvvmPropertyGroup ParamsGroupDescriptor = DotvvmPropertyGroup.Register("Param-", "Params"); + /// + /// Gets or sets a collection of parameters to be added in the query string. + /// [PropertyGroup("Query-")] public VirtualPropertyGroupDictionary QueryParameters => new VirtualPropertyGroupDictionary(this, QueryParametersGroupDescriptor); public static DotvvmPropertyGroup QueryParametersGroupDescriptor = @@ -87,6 +93,13 @@ public TextOrContentCapability TextOrContentCapability } ); + public RouteLinkCapability RouteLinkCapability + { + get => (RouteLinkCapability)RouteLinkCapabilityProperty.GetValue(this); + set => RouteLinkCapabilityProperty.SetValue(this, value); + } + public static readonly DotvvmCapabilityProperty RouteLinkCapabilityProperty = DotvvmCapabilityProperty.RegisterCapability(); + public RouteLink() : base("a", false) { } diff --git a/src/Framework/Framework/Controls/RouteLinkCapability.cs b/src/Framework/Framework/Controls/RouteLinkCapability.cs new file mode 100644 index 0000000000..e80e18bca8 --- /dev/null +++ b/src/Framework/Framework/Controls/RouteLinkCapability.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.ComponentModel; +using DotVVM.Framework.Binding; + +namespace DotVVM.Framework.Controls +{ + [DotvvmControlCapability()] + public sealed record RouteLinkCapability + { + [PropertyGroup("Query-")] + [DefaultValue(null)] + public IReadOnlyDictionary> QueryParameters { get; init; } = new Dictionary>(); + + [PropertyGroup("Param-")] + [DefaultValue(null)] + public IReadOnlyDictionary> Params { get; init; } = new Dictionary>(); + + public string RouteName { get; init; } = null!; + + [DefaultValue(null)] + public ValueOrBinding? UrlSuffix { get; init; } + } +} diff --git a/src/Framework/Framework/ResourceManagement/ViewModuleInitResource.cs b/src/Framework/Framework/ResourceManagement/ViewModuleInitResource.cs index bcb2965f2b..8e97077f08 100644 --- a/src/Framework/Framework/ResourceManagement/ViewModuleInitResource.cs +++ b/src/Framework/Framework/ResourceManagement/ViewModuleInitResource.cs @@ -29,7 +29,15 @@ public ViewModuleInitResource(string[] referencedModules, string name, string vi this.ReferencedModules = referencedModules.ToArray(); this.Dependencies = dependencies; - this.registrationScript = string.Join("\r\n", this.ReferencedModules.Select(m => $"dotvvm.viewModules.init({KnockoutHelper.MakeStringLiteral(m)}, {KnockoutHelper.MakeStringLiteral(viewId)}, document.body);")); + var initCalls = this.ReferencedModules.Select(m => $"dotvvm.viewModules.init({KnockoutHelper.MakeStringLiteral(m)}, {KnockoutHelper.MakeStringLiteral(viewId)}, document.body);"); + + // Run the module init in the init event + // * dotvvm.state will be available + // * executed before applying bindings to the controls, so the page module will initialize before control modules + this.registrationScript = + "dotvvm.events.init.subscribeOnce(() => {\n" + + " " + string.Join("\n", initCalls) + "\n" + + "})"; } public void Render(IHtmlWriter writer, IDotvvmRequestContext context, string resourceName) diff --git a/src/Framework/Framework/Utils/ReflectionUtils.cs b/src/Framework/Framework/Utils/ReflectionUtils.cs index 55d3e33ef4..9af973509f 100644 --- a/src/Framework/Framework/Utils/ReflectionUtils.cs +++ b/src/Framework/Framework/Utils/ReflectionUtils.cs @@ -444,6 +444,9 @@ public static bool Implements(this Type type, Type ifc, [NotNullWhen(true)] out return (concreteInterface = type.GetInterfaces().FirstOrDefault(i => isInterface(i, ifc))) != null; } + public static bool IsAssignableFromNull(this Type t) => + !t.IsValueType || t.IsNullable(); + public static bool IsNullable(this Type type) { return Nullable.GetUnderlyingType(type) != null; diff --git a/src/Framework/Testing/BindingTestHelper.cs b/src/Framework/Testing/BindingTestHelper.cs index ebbd42e81c..4d11ca65d8 100644 --- a/src/Framework/Testing/BindingTestHelper.cs +++ b/src/Framework/Testing/BindingTestHelper.cs @@ -75,7 +75,7 @@ public Expression ParseBinding(string expression, DataContextStack context, Type var parsedExpression = ExpressionBuilder.ParseWithLambdaConversion(expression, context, BindingParserOptions.Value.AddImports(imports), expectedType); return TypeConversion.MagicLambdaConversion(parsedExpression, expectedType) ?? - TypeConversion.ImplicitConversion(parsedExpression, expectedType, true, true)!; + TypeConversion.EnsureImplicitConversion(parsedExpression, expectedType, allowToString: true)!; } /// Returns JavaScript code to which the translates. diff --git a/src/Framework/Testing/DotVVM.Framework.Testing.csproj b/src/Framework/Testing/DotVVM.Framework.Testing.csproj index c1527c56ec..bef84dc8bb 100644 --- a/src/Framework/Testing/DotVVM.Framework.Testing.csproj +++ b/src/Framework/Testing/DotVVM.Framework.Testing.csproj @@ -14,7 +14,7 @@ True - True + true dotvvmwizard.snk diff --git a/src/Samples/Api.AspNetCore/Properties/launchSettings.json b/src/Samples/Api.AspNetCore/Properties/launchSettings.json index 244e3813ad..9eac61f38a 100644 --- a/src/Samples/Api.AspNetCore/Properties/launchSettings.json +++ b/src/Samples/Api.AspNetCore/Properties/launchSettings.json @@ -1,25 +1,10 @@ { "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "swag": { + "DotVVM.Samples.BasicSamples.Api.AspNetCore": { "commandName": "Project", "launchBrowser": true, "launchUrl": "http://localhost:50001", "applicationUrl": "http://localhost:50001" } - }, - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:50001/", - "sslPort": 0 - } } -} \ No newline at end of file +} diff --git a/src/Samples/Api.AspNetCoreLatest/Properties/launchSettings.json b/src/Samples/Api.AspNetCoreLatest/Properties/launchSettings.json index af65e5130e..9eac61f38a 100644 --- a/src/Samples/Api.AspNetCoreLatest/Properties/launchSettings.json +++ b/src/Samples/Api.AspNetCoreLatest/Properties/launchSettings.json @@ -1,22 +1,10 @@ { - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:5003/", - "sslPort": 0 - } - }, "profiles": { - "IIS Express": { - "commandName": "IISExpress", + "DotVVM.Samples.BasicSamples.Api.AspNetCore": { + "commandName": "Project", "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "swag": { - "commandName": "Project" + "launchUrl": "http://localhost:50001", + "applicationUrl": "http://localhost:50001" } } } diff --git a/src/Samples/Api.Owin/Properties/launchSettings.json b/src/Samples/Api.Owin/Properties/launchSettings.json new file mode 100644 index 0000000000..8d33da700a --- /dev/null +++ b/src/Samples/Api.Owin/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "DotVVM.Samples.BasicSamples.Api.Owin": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:61453" + } + } +} diff --git a/src/Samples/Api.Owin/Web.config b/src/Samples/Api.Owin/Web.config index 41ff4385af..66d4915c97 100644 --- a/src/Samples/Api.Owin/Web.config +++ b/src/Samples/Api.Owin/Web.config @@ -62,8 +62,8 @@ - - + + diff --git a/src/Samples/ApplicationInsights.Owin/Web.config b/src/Samples/ApplicationInsights.Owin/Web.config index 7063dfb4f1..6063647d33 100644 --- a/src/Samples/ApplicationInsights.Owin/Web.config +++ b/src/Samples/ApplicationInsights.Owin/Web.config @@ -59,8 +59,8 @@ - - + + diff --git a/src/Samples/AspNetCore/Properties/launchSettings.json b/src/Samples/AspNetCore/Properties/launchSettings.json index f539c094c5..70ca5a293a 100644 --- a/src/Samples/AspNetCore/Properties/launchSettings.json +++ b/src/Samples/AspNetCore/Properties/launchSettings.json @@ -1,24 +1,9 @@ { - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:16018/", - "sslPort": 0 - } - }, "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, "DotVVM.Samples.BasicSamples": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "http://localhost:5000", + "launchUrl": "http://localhost:16019", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Samples/AspNetCoreLatest/Properties/launchSettings.json b/src/Samples/AspNetCoreLatest/Properties/launchSettings.json index 5cf93a20d2..70ca5a293a 100644 --- a/src/Samples/AspNetCoreLatest/Properties/launchSettings.json +++ b/src/Samples/AspNetCoreLatest/Properties/launchSettings.json @@ -1,24 +1,9 @@ { - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:16019/", - "sslPort": 0 - } - }, "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, "DotVVM.Samples.BasicSamples": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "http://localhost:5000", + "launchUrl": "http://localhost:16019", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Samples/Common/DotvvmStartup.cs b/src/Samples/Common/DotvvmStartup.cs index 6594f91726..71e666731f 100644 --- a/src/Samples/Common/DotvvmStartup.cs +++ b/src/Samples/Common/DotvvmStartup.cs @@ -40,9 +40,27 @@ public class DotvvmStartup : IDotvvmStartup public const string GitHubTokenEnvName = "GITHUB_TOKEN"; public const string GitHubTokenConfigName = "githubApiToken"; + private bool IsInInvariantCultureMode() + { + // Makes the samples run even if only invariant culture is enabled + // This is useful for testing older versions of .NET Core which rely on ICU which is no longer installed + try + { + new System.Globalization.CultureInfo("en-US"); + return false; + } + catch (System.Globalization.CultureNotFoundException) + { + return true; + } + } + public void Configure(DotvvmConfiguration config, string applicationPath) { - config.DefaultCulture = "en-US"; + if (!IsInInvariantCultureMode()) + { + config.DefaultCulture = "en-US"; + } config.ExperimentalFeatures.UseDotvvmSerializationForStaticCommandArguments.Enable(); AddControls(config); diff --git a/src/Samples/MiniProfiler.Owin/Web.config b/src/Samples/MiniProfiler.Owin/Web.config index 0ae952b2c6..40cc6cbbf4 100644 --- a/src/Samples/MiniProfiler.Owin/Web.config +++ b/src/Samples/MiniProfiler.Owin/Web.config @@ -71,5 +71,11 @@ + + + + + + \ No newline at end of file diff --git a/src/Samples/Owin/Properties/launchSettings.json b/src/Samples/Owin/Properties/launchSettings.json new file mode 100644 index 0000000000..15d9c829a0 --- /dev/null +++ b/src/Samples/Owin/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "DotVVM.Samples.BasicSamples.Owin": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:5407" + } + } +} diff --git a/src/Samples/Tests/Tests/DotVVM.Samples.Tests.csproj b/src/Samples/Tests/Tests/DotVVM.Samples.Tests.csproj index ced2bba49f..1e4167c204 100644 --- a/src/Samples/Tests/Tests/DotVVM.Samples.Tests.csproj +++ b/src/Samples/Tests/Tests/DotVVM.Samples.Tests.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Samples/Tests/Tests/seleniumconfig.json b/src/Samples/Tests/Tests/seleniumconfig.json index bb70470fd1..7a5fb817d1 100644 --- a/src/Samples/Tests/Tests/seleniumconfig.json +++ b/src/Samples/Tests/Tests/seleniumconfig.json @@ -5,7 +5,7 @@ } }, "baseUrls": [ - "http://localhost:5407/" + "http://localhost:16019/" ], "testRunOptions": { "runInParallel": false, diff --git a/src/Tests/Binding/BindingCompilationTests.cs b/src/Tests/Binding/BindingCompilationTests.cs index 11d6fce6ab..48c3bc9e0d 100755 --- a/src/Tests/Binding/BindingCompilationTests.cs +++ b/src/Tests/Binding/BindingCompilationTests.cs @@ -586,6 +586,16 @@ public void BindingCompiler_Invalid_EnumStringComparison() }); } + [TestMethod] + public void BindingCompiler_Valid_EnumBitOps() + { + var viewModel = new TestViewModel { EnumProperty = TestEnum.A }; + Assert.AreEqual(TestEnum.A, ExecuteBinding("EnumProperty & 1", viewModel)); + Assert.AreEqual(TestEnum.B, ExecuteBinding("EnumProperty | 1", viewModel)); + Assert.AreEqual(TestEnum.B, ExecuteBinding("EnumProperty | 'B'", viewModel)); + Assert.AreEqual(TestEnum.C, ExecuteBinding("(EnumProperty | 'D') & 'C'", viewModel)); + } + [TestMethod] public void BindingCompiler_Valid_GenericMethodCall() @@ -945,7 +955,7 @@ public void BindingCompiler_MultiBlockExpression_EnumAtEnd_CorrectResult() } [TestMethod] - [ExpectedExceptionMessageSubstring(typeof(BindingPropertyException), "Could not implicitly convert expression of type System.Void to System.Object")] + [ExpectedExceptionMessageSubstring(typeof(BindingPropertyException), "Could not implicitly convert expression of type void to object")] public void BindingCompiler_MultiBlockExpression_EmptyBlockAtEnd_Throws() { TestViewModel vm = new TestViewModel { StringProp = "a" }; @@ -953,7 +963,7 @@ public void BindingCompiler_MultiBlockExpression_EmptyBlockAtEnd_Throws() } [TestMethod] - [ExpectedExceptionMessageSubstring(typeof(BindingPropertyException), "Could not implicitly convert expression of type System.Void to System.Object")] + [ExpectedExceptionMessageSubstring(typeof(BindingPropertyException), "Could not implicitly convert expression of type void to object")] public void BindingCompiler_MultiBlockExpression_WhitespaceBlockAtEnd_Throws() { TestViewModel vm = new TestViewModel { StringProp = "a" }; @@ -1051,7 +1061,7 @@ public void BindingCompiler_Errors_AssigningToType() { var aggEx = Assert.ThrowsException(() => ExecuteBinding("System.String = 123", new [] { new TestViewModel() })); var ex = aggEx.GetBaseException(); - StringAssert.Contains(ex.Message, "cannot be assigned into"); + StringAssert.Contains(ex.Message, "Expression '123' cannot be assigned into 'System.String'."); } [TestMethod] @@ -1104,6 +1114,51 @@ public void Error_DifferentDataContext() ); } + [DataTestMethod] + [DataRow("IntProp + 1L", 101L)] + [DataRow("1L + IntProp", 101L)] + [DataRow("1L + UIntProp", 3_000_000_001L)] + [DataRow("1 + UIntProp", (uint)3_000_000_001)] + [DataRow("ShortProp", short.MaxValue)] + [DataRow("ShortProp - 1", short.MaxValue - 1)] + [DataRow("DoubleProp - 1", 0.5)] + [DataRow("DoubleProp + ShortProp", short.MaxValue + 1.5)] + [DataRow("NullableDoubleProp + ShortProp", null)] + [DataRow("ByteProp | ByteProp", (byte)255)] + [DataRow("DateTime == DateTime", true)] + [DataRow("NullableTimeOnly == NullableTimeOnly", true)] + [DataRow("NullableTimeOnly != NullableTimeOnly", false)] + [DataRow("NullableTimeOnly == TimeOnly", false)] + [DataRow("EnumList[0] > EnumList[1]", false)] + [DataRow("EnumList[0] < EnumList[1]", true)] + [DataRow("EnumList[0] == 'A'", true)] + [DataRow("EnumList[0] < 'C'", true)] + [DataRow("(EnumList[1] | 'C') == 'C'", false)] + [DataRow("(EnumList[2] & 1) != 0", false)] + public void BindingCompiler_BinaryOperator_ResultType(string expr, object expectedResult) + { + var vm = new TestViewModel { IntProp = 100, DoubleProp = 1.5, EnumList = new () { TestEnum.A, TestEnum.B, TestEnum.C, TestEnum.D } }; + Assert.AreEqual(expectedResult, ExecuteBinding(expr, vm)); + } + + [TestMethod] + [ExpectedExceptionMessageSubstring(typeof(BindingPropertyException), "Reference equality is not defined for the types 'DotVVM.Framework.Tests.Binding.TestViewModel2' and 'DotVVM.Framework.Tests.Binding.TestViewModel'")] + public void BindingCompiler_InvalidReferenceComparison() => + ExecuteBinding("TestViewModel2 == _this", new TestViewModel()); + + [TestMethod] + [ExpectedExceptionMessageSubstring(typeof(BindingPropertyException), "Cannot apply Equal operator to types DateTime and Object.")] + public void BindingCompiler_InvalidStructReferenceComparison() => + ExecuteBinding("DateTime == Time", new TestViewModel()); + + [TestMethod] + [ExpectedExceptionMessageSubstring(typeof(BindingPropertyException), "Cannot apply Equal operator to types DateTime and Object.")] + public void BindingCompiler_InvalidStructComparison() => + ExecuteBinding("DateTime == Time", new TestViewModel()); + [TestMethod] + [ExpectedExceptionMessageSubstring(typeof(BindingPropertyException), "Cannot apply And operator to types TestEnum and Boolean")] + public void BindingCompiler_InvalidBitAndComparison() => + ExecuteBinding("EnumProperty & 2 == 0", new TestViewModel()); } class TestViewModel { @@ -1138,6 +1193,12 @@ class TestViewModel public TestViewModel2[] VmArray => new TestViewModel2[] { new TestViewModel2() }; public int[] IntArray { get; set; } public decimal DecimalProp { get; set; } + public byte ByteProp { get; set; } = 255; + public sbyte SByteProp { get; set; } = 127; + public short ShortProp { get; set; } = 32767; + public ushort UShortProp { get; set; } = 65535; + public uint UIntProp { get; set; } = 3_000_000_000; + public double? NullableDoubleProp { get; set; } public VehicleNumber? VehicleNumber { get; set; } diff --git a/src/Tests/Binding/ImplicitConversionTests.cs b/src/Tests/Binding/ImplicitConversionTests.cs index 94a56465ed..8400dac2d3 100644 --- a/src/Tests/Binding/ImplicitConversionTests.cs +++ b/src/Tests/Binding/ImplicitConversionTests.cs @@ -13,30 +13,30 @@ public class ImplicitConversionTests [TestMethod] public void Conversion_IntToNullableDouble() { - TypeConversion.ImplicitConversion(Expression.Parameter(typeof(int)), typeof(double?), throwException: true); + TypeConversion.EnsureImplicitConversion(Expression.Parameter(typeof(int)), typeof(double?)); } [TestMethod] public void Conversion_DoubleNullable() { - TypeConversion.ImplicitConversion(Expression.Parameter(typeof(double)), typeof(double?), throwException: true); + TypeConversion.EnsureImplicitConversion(Expression.Parameter(typeof(double)), typeof(double?)); } [TestMethod] public void Conversion_IntToDouble() { - TypeConversion.ImplicitConversion(Expression.Parameter(typeof(int)), typeof(double), throwException: true); + TypeConversion.EnsureImplicitConversion(Expression.Parameter(typeof(int)), typeof(double)); } [TestMethod] public void Conversion_ValidToString() { - TypeConversion.ImplicitConversion(Expression.Parameter(typeof(DateTime)), typeof(string), throwException: true, allowToString: true); - TypeConversion.ImplicitConversion(Expression.Parameter(typeof(int)), typeof(string), throwException: true, allowToString: true); - TypeConversion.ImplicitConversion(Expression.Parameter(typeof(string)), typeof(string), throwException: true, allowToString: true); - TypeConversion.ImplicitConversion(Expression.Parameter(typeof(double)), typeof(string), throwException: true, allowToString: true); - TypeConversion.ImplicitConversion(Expression.Parameter(typeof(TimeSpan)), typeof(string), throwException: true, allowToString: true); - TypeConversion.ImplicitConversion(Expression.Parameter(typeof(Tuple)), typeof(string), throwException: true, allowToString: true); + TypeConversion.EnsureImplicitConversion(Expression.Parameter(typeof(DateTime)), typeof(string), allowToString: true); + TypeConversion.EnsureImplicitConversion(Expression.Parameter(typeof(int)), typeof(string), allowToString: true); + TypeConversion.EnsureImplicitConversion(Expression.Parameter(typeof(string)), typeof(string), allowToString: true); + TypeConversion.EnsureImplicitConversion(Expression.Parameter(typeof(double)), typeof(string), allowToString: true); + TypeConversion.EnsureImplicitConversion(Expression.Parameter(typeof(TimeSpan)), typeof(string), allowToString: true); + TypeConversion.EnsureImplicitConversion(Expression.Parameter(typeof(Tuple)), typeof(string), allowToString: true); } [TestMethod] diff --git a/src/Tests/Binding/JavascriptCompilationTests.cs b/src/Tests/Binding/JavascriptCompilationTests.cs index 3ebea1dc06..81de562a92 100644 --- a/src/Tests/Binding/JavascriptCompilationTests.cs +++ b/src/Tests/Binding/JavascriptCompilationTests.cs @@ -58,11 +58,57 @@ public string CompileBinding(Func, Expression> ex return JavascriptTranslator.FormatKnockoutScript(jsExpression); } - [TestMethod] - public void JavascriptCompilation_EnumComparison() - { - var js = CompileBinding($"_this == 'Local'", typeof(DateTimeKind)); - Assert.AreEqual("$data==\"Local\"", js); + [DataTestMethod] + [DataRow("_this == 'Local'", "$data==\"Local\"")] + [DataRow("'Local' == _this", "\"Local\"==$data")] + [DataRow("_this == 2", "$data==\"Local\"")] + [DataRow("2 == _this", "\"Local\"==$data")] + [DataRow("_this & 'Local'", "dotvvm.translations.enums.fromInt(dotvvm.translations.enums.toInt($data,\"G0/GAE51KlQlMR5T\")&2,\"G0/GAE51KlQlMR5T\")")] + [DataRow("'Local' & _this", "dotvvm.translations.enums.fromInt(2&dotvvm.translations.enums.toInt($data,\"G0/GAE51KlQlMR5T\"),\"G0/GAE51KlQlMR5T\")")] + [DataRow("_this | 'Local'", "dotvvm.translations.enums.fromInt(dotvvm.translations.enums.toInt($data,\"G0/GAE51KlQlMR5T\")|2,\"G0/GAE51KlQlMR5T\")")] + [DataRow("_this & 1", "dotvvm.translations.enums.fromInt(dotvvm.translations.enums.toInt($data,\"G0/GAE51KlQlMR5T\")&1,\"G0/GAE51KlQlMR5T\")")] + [DataRow("1 & _this", "dotvvm.translations.enums.fromInt(1&dotvvm.translations.enums.toInt($data,\"G0/GAE51KlQlMR5T\"),\"G0/GAE51KlQlMR5T\")")] + public void JavascriptCompilation_EnumOperators(string expr, string expectedJs) + { + var js = CompileBinding(expr, typeof(DateTimeKind)); + Assert.AreEqual(expectedJs, js); + } + + [DataTestMethod] + [DataRow("StringProp + StringProp", "(StringProp()??\"\")+(StringProp()??\"\")")] + [DataRow("StringProp + null", "StringProp()??\"\"")] + [DataRow("null + StringProp", "StringProp()??\"\"")] + [DataRow("'' + StringProp", "StringProp()??\"\"")] + [DataRow("BoolProp + StringProp", "BoolProp()+(StringProp()??\"\")")] + [DataRow("IntProp + IntProp + 'aa'", "IntProp()+IntProp()+\"aa\"")] + [DataRow("DoubleProp + 'aa'", "DoubleProp()+\"aa\"")] + [DataRow("'a' + DoubleProp", "\"a\"+DoubleProp()")] + [DataRow("'a' + NullableIntProp + null", "\"a\"+(NullableIntProp()??\"\")")] + public void JavascriptCompilation_StringPlus(string expr, string expectedJs) + { + var js = CompileBinding(expr, typeof(TestViewModel)); + Assert.AreEqual(expectedJs, js); + } + + [DataTestMethod] + [DataRow("NullableIntProp + NullableDoubleProp", "NullableIntProp()+NullableDoubleProp()")] + [DataRow("NullableIntProp & NullableIntProp", "NullableIntProp()&NullableIntProp()")] + [DataRow("NullableIntProp | NullableIntProp", "NullableIntProp()|NullableIntProp()")] + [DataRow("NullableIntProp ^ NullableIntProp", "NullableIntProp()^NullableIntProp()")] + [DataRow("NullableIntProp + 10", "NullableIntProp()+10")] + [DataRow("NullableIntProp - 10L", "NullableIntProp()-10")] + [DataRow("NullableIntProp / 10.0", "NullableIntProp()/10.0")] + [DataRow("10.0 / NullableIntProp", "10.0/NullableIntProp()")] + [DataRow("null / NullableIntProp", "null/NullableIntProp()")] + [DataRow("null == NullableIntProp", "null==NullableIntProp()")] + [DataRow("10 > NullableIntProp", "10>NullableIntProp()")] + [DataRow("NullableDoubleProp < 10", "NullableDoubleProp()<10")] + [DataRow("10+null", "10+null")] + [DataRow("10+null", "10+null")] + public void JavascriptCompilation_NullableOps(string expr, string expectedJs) + { + var js = CompileBinding(expr, typeof(TestViewModel)); + Assert.AreEqual(expectedJs, js); } [TestMethod] @@ -132,6 +178,12 @@ public void JavascriptCompilation_Parent() [DataRow("IntProp ^ 1", "IntProp()^1", DisplayName = "IntProp ^ 1")] [DataRow("'xx' + IntProp", "\"xx\"+IntProp()", DisplayName = "'xx' + IntProp")] [DataRow("true == (IntProp == 1)", "true==(IntProp()==1)", DisplayName = "true == (IntProp == 1)")] + [DataRow("TestViewModel2 == null", "TestViewModel2()==null", DisplayName = "TestViewModel2 == null")] + [DataRow("NullableDateOnly == null", "NullableDateOnly()==null", DisplayName = "NullableDateOnly == null")] + [DataRow("NullableDateOnly == NullableDateOnly", "NullableDateOnly()==NullableDateOnly()", DisplayName = "NullableDateOnly == NullableDateOnly")] + [DataRow("null != StringProp", "null!=StringProp()", DisplayName = "null != StringProp")] + [DataRow("(EnumProperty & 2) == 0", "dotvvm.translations.enums.fromInt(dotvvm.translations.enums.toInt(EnumProperty(),\"nEayAzHQ5xyCfSP6\")&2,\"nEayAzHQ5xyCfSP6\")==\"A\"", DisplayName = "(EnumProperty & 2) == 0")] + [DataRow("EnumProperty == 'B'", "EnumProperty()==\"B\"", DisplayName = "EnumProperty & 2 == 0")] public void JavascriptCompilation_BinaryExpressions(string expr, string expectedJs) { var js = CompileBinding(expr, new [] { typeof(TestViewModel) }); diff --git a/src/Tests/ControlTests/testoutputs/ViewModulesServerSideTests.IncludeViewModule.html b/src/Tests/ControlTests/testoutputs/ViewModulesServerSideTests.IncludeViewModule.html index 46df5cdf5a..f92075b260 100644 --- a/src/Tests/ControlTests/testoutputs/ViewModulesServerSideTests.IncludeViewModule.html +++ b/src/Tests/ControlTests/testoutputs/ViewModulesServerSideTests.IncludeViewModule.html @@ -15,7 +15,9 @@ - + import * as m0 from '/dotvvmResource/viewModule/viewModule';dotvvm.viewModules.registerMany({'viewModule': m0}); - +
diff --git a/src/Tests/DotVVM.Framework.Tests.csproj b/src/Tests/DotVVM.Framework.Tests.csproj index e9c7067ff5..fa3ee599c0 100644 --- a/src/Tests/DotVVM.Framework.Tests.csproj +++ b/src/Tests/DotVVM.Framework.Tests.csproj @@ -17,7 +17,8 @@ net6.0 - True + true + true dotvvmwizard.snk diff --git a/src/Tests/Parser/Binding/BindingParserTests.cs b/src/Tests/Parser/Binding/BindingParserTests.cs index 550ee53584..4db7414a04 100644 --- a/src/Tests/Parser/Binding/BindingParserTests.cs +++ b/src/Tests/Parser/Binding/BindingParserTests.cs @@ -803,7 +803,7 @@ public void BindingParser_GenericExpression_MultipleInside() var parser = bindingParserNodeFactory.SetupParser(originalString); var node = parser.ReadExpression(); - Assert.IsTrue(node is TypeOrFunctionReferenceBindingParserNode); + Assert.IsInstanceOfType(node, typeof(TypeOrFunctionReferenceBindingParserNode)); Assert.IsTrue(string.Equals(originalString, node.ToDisplayString())); } diff --git a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json index 803a7b58c2..192cc06a4c 100644 --- a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json +++ b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json @@ -564,8 +564,7 @@ }, "IncludeInPage": { "type": "System.Boolean", - "defaultValue": true, - "onlyBindings": true + "defaultValue": true } }, "DotVVM.Framework.Controls.EmptyData": { @@ -1016,8 +1015,7 @@ }, "Visible": { "type": "System.Boolean", - "defaultValue": true, - "onlyBindings": true + "defaultValue": true } }, "DotVVM.Framework.Controls.HtmlLiteral": { @@ -1720,6 +1718,9 @@ } }, "DotVVM.Framework.Controls.RouteLink": { + "RouteLinkCapability": { + "type": "DotVVM.Framework.Controls.RouteLinkCapability, DotVVM.Framework" + }, "TextOrContentCapability": { "type": "DotVVM.Framework.Controls.TextOrContentCapability, DotVVM.Framework" }