Skip to content

Commit

Permalink
Merge pull request #1498 from riganti/feature/custom-primitive-types
Browse files Browse the repository at this point in the history
Support for custom primitive types
  • Loading branch information
exyi authored Aug 3, 2023
2 parents 29a3170 + 21486c8 commit 608b96a
Show file tree
Hide file tree
Showing 37 changed files with 988 additions and 26 deletions.
8 changes: 8 additions & 0 deletions src/Framework/Core/ViewModel/IDotvvmPrimitiveType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace DotVVM.Framework.ViewModel;

/// <summary>
/// Marker interface instructing DotVVM to treat the type as a primitive type.
/// The type is required to have a static TryParse(string, [IFormatProvider,] out T) method and expected to implement ToString() method which is compatible with the TryParse method.
/// Primitive types are then serialized as string in client-side view models.
/// </summary>
public interface IDotvvmPrimitiveType { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Reflection;
using DotVVM.Framework.Compilation.Javascript.Ast;
using DotVVM.Framework.Utils;

namespace DotVVM.Framework.Compilation.Javascript
{
public class CustomPrimitiveTypesConversionTranslator : IJavascriptMethodTranslator
{
public JsExpression? TryTranslateCall(LazyTranslatedExpression? context, LazyTranslatedExpression[] arguments, MethodInfo method)
{
var type = context?.OriginalExpression.Type ?? method.DeclaringType!;
type = type.UnwrapNullableType();
if (method.Name is "ToString" or "Parse" && ReflectionUtils.IsCustomPrimitiveType(type))
{
if (method.Name == "ToString" && arguments.Length == 0 && context is {})
{
return context.JsExpression();
}
else if (method.Name == "Parse" && arguments.Length == 1 && context is null)
{
return arguments[0].JsExpression();
}
}
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ public JavascriptTranslatorConfiguration()
{
Translators.Add(MethodCollection = new JavascriptTranslatableMethodCollection());
Translators.Add(new DelegateInvokeMethodTranslator());
Translators.Add(new CustomPrimitiveTypesConversionTranslator());
}

public JsExpression? TryTranslateCall(LazyTranslatedExpression? context, LazyTranslatedExpression[] arguments, MethodInfo method) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using DotVVM.Framework.Routing;
using DotVVM.Framework.Utils;
using DotVVM.Framework.ViewModel;

namespace DotVVM.Framework.Configuration
{
public sealed class CustomPrimitiveTypeRegistration
{
public Type Type { get; }

public Func<string, ParseResult> TryParseMethod { get; }

public Func<object, string> ToStringMethod { get; }

internal CustomPrimitiveTypeRegistration(Type type)
{
if (ReflectionUtils.IsCollection(type) || ReflectionUtils.IsDictionary(type))
{
throw new DotvvmConfigurationException($"The type {type} implements {nameof(IDotvvmPrimitiveType)}, but it cannot be used as a custom primitive type. Custom primitive types cannot be collections, dictionaries, and cannot be primitive types already supported by DotVVM.");
}

Type = type;

TryParseMethod = ResolveTryParseMethod(type);
ToStringMethod = typeof(IFormattable).IsAssignableFrom(type)
? obj => ((IFormattable)obj).ToString(null, CultureInfo.InvariantCulture)
: obj => obj.ToString()!;
}

internal static Func<string, ParseResult> ResolveTryParseMethod(Type type)
{
var tryParseMethod = type.GetMethod("TryParse", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy, null,
new[] { typeof(string), typeof(IFormatProvider), type.MakeByRefType() }, null)
?? type.GetMethod("TryParse", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy, null,
new[] { typeof(string), type.MakeByRefType() }, null)
?? throw new DotvvmConfigurationException($"The type {type} implements {nameof(IDotvvmPrimitiveType)} but it does not contain a public static method TryParse(string, IFormatProvider, out {type}) or TryParse(string, out {type})!");

var inputParameter = Expression.Parameter(typeof(string), "arg");
var resultVariable = Expression.Variable(type, "result");

var arguments = new Expression?[]
{
inputParameter,
tryParseMethod.GetParameters().Length == 3
? Expression.Constant(CultureInfo.InvariantCulture)
: null,
resultVariable
}
.Where(a => a != null)
.Cast<Expression>()
.ToArray();
var call = Expression.Call(tryParseMethod, arguments);

var body = Expression.Block(
new[] { resultVariable },
Expression.Condition(
Expression.IsTrue(call),
Expression.New(typeof(ParseResult).GetConstructor(new[] { typeof(object) })!, Expression.Convert(resultVariable, typeof(object))),
Expression.Constant(ParseResult.Failed)
)
);
return Expression.Lambda<Func<string, ParseResult>>(body, inputParameter).Compile();
}

public record ParseResult(object? Result = null)
{
public bool Successful { get; init; } = true;

public static readonly ParseResult Failed = new ParseResult() { Successful = false };
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ private JsonSerializerSettings CreateSettings()
new DotvvmTimeOnlyConverter(),
new StringEnumConverter(),
new DotvvmDictionaryConverter(),
new DotvvmByteArrayConverter()
new DotvvmByteArrayConverter(),
new DotvvmCustomPrimitiveTypeConverter()
},
MaxDepth = defaultMaxSerializationDepth
};
Expand All @@ -53,5 +54,7 @@ private DefaultSerializerSettingsProvider()
JsonConvert.DefaultSettings = () => new JsonSerializerSettings() { MaxDepth = defaultMaxSerializationDepth };
Settings = CreateSettings();
}

public static JsonSerializer CreateJsonSerializer() => JsonSerializer.Create(Instance.Settings);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,15 @@ public class DotvvmExperimentalFeaturesConfiguration
[JsonProperty("knockoutDeferUpdates")]
public DotvvmFeatureFlag KnockoutDeferUpdates { get; private set; } = new DotvvmFeatureFlag("KnockoutDeferUpdates");

[JsonProperty("useDotvvmSerializationForStaticCommandArguments")]
public DotvvmGlobalFeatureFlag UseDotvvmSerializationForStaticCommandArguments { get; private set; } = new DotvvmGlobalFeatureFlag("UseDotvvmSerializationForStaticCommandArguments");

public void Freeze()
{
LazyCsrfToken.Freeze();
ServerSideViewModelCache.Freeze();
ExplicitAssemblyLoading.Freeze();
UseDotvvmSerializationForStaticCommandArguments.Freeze();
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/Framework/Framework/Controls/HtmlGenericControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using System.Text;
using DotVVM.Framework.Compilation.Javascript;
using FastExpressionCompiler;
using DotVVM.Framework.ViewModel;

namespace DotVVM.Framework.Controls
{
Expand Down Expand Up @@ -405,6 +406,10 @@ private static string AttributeValueToString(object? value) =>
Enum enumValue => ReflectionUtils.ToEnumString(enumValue.GetType(), enumValue.ToString()),
Guid guid => guid.ToString(),
_ when ReflectionUtils.IsNumericType(value.GetType()) => Convert.ToString(value, CultureInfo.InvariantCulture) ?? "",
IDotvvmPrimitiveType => value switch {
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString() ?? ""
},
System.Collections.IEnumerable =>
throw new NotSupportedException($"Attribute value of type '{value.GetType().ToCode(stripNamespace: true)}' is not supported. Consider concatenating the values into a string or use the HtmlGenericControl.AttributeList if you need to pass multiple values."),
_ =>
Expand Down
12 changes: 11 additions & 1 deletion src/Framework/Framework/Hosting/StaticCommandExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using DotVVM.Framework.Utils;
using DotVVM.Framework.ViewModel;
using DotVVM.Framework.ViewModel.Validation;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace DotVVM.Framework.Hosting
Expand All @@ -21,13 +22,22 @@ public class StaticCommandExecutor
private readonly IViewModelProtector viewModelProtector;
private readonly IStaticCommandArgumentValidator validator;
private readonly DotvvmConfiguration configuration;
private readonly JsonSerializer jsonDeserializer;

public StaticCommandExecutor(IStaticCommandServiceLoader serviceLoader, IViewModelProtector viewModelProtector, IStaticCommandArgumentValidator validator, DotvvmConfiguration configuration)
{
this.serviceLoader = serviceLoader;
this.viewModelProtector = viewModelProtector;
this.validator = validator;
this.configuration = configuration;
if (configuration.ExperimentalFeatures.UseDotvvmSerializationForStaticCommandArguments.Enabled)
{
this.jsonDeserializer = DefaultSerializerSettingsProvider.CreateJsonSerializer();
}
else
{
this.jsonDeserializer = JsonSerializer.Create();
}
}
#pragma warning restore CS0618

Expand Down Expand Up @@ -56,7 +66,7 @@ IDotvvmRequestContext context
{
var (value, path) = a.Type switch {
StaticCommandParameterType.Argument =>
((object?)arguments.Dequeue().ToObject((Type)a.Arg!), argumentValidationPaths?.Dequeue()),
((object?)arguments.Dequeue().ToObject((Type)a.Arg!, this.jsonDeserializer), argumentValidationPaths?.Dequeue()),
StaticCommandParameterType.Constant or StaticCommandParameterType.DefaultValue =>
(a.Arg, null),
StaticCommandParameterType.Inject =>
Expand Down
51 changes: 45 additions & 6 deletions src/Framework/Framework/Utils/ReflectionUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using RecordExceptions;
using System.ComponentModel;
using DotVVM.Framework.Compilation;
using DotVVM.Framework.Routing;
using DotVVM.Framework.ViewModel;
using System.Diagnostics;

namespace DotVVM.Framework.Utils
{
Expand Down Expand Up @@ -231,6 +236,15 @@ public static bool IsAssignableToGenericType(this Type givenType, Type genericTy
// convert
try
{
// custom primitive types
if (TryGetCustomPrimitiveTypeRegistration(type) is { } registration)
{
var result = registration.TryParseMethod(Convert.ToString(value, CultureInfo.InvariantCulture)!);
return result.Successful
? result.Result
: throw new TypeConvertException(value, type, new Exception("The TryParse method of a custom primitive type failed to parse the value."));
}

return Convert.ChangeType(value, type, CultureInfo.InvariantCulture);
}
catch (Exception e)
Expand Down Expand Up @@ -293,6 +307,8 @@ public record TypeConvertException(object Value, Type Type, Exception InnerExcep
typeof (double),
typeof (decimal)
};
// mapping of server-side types to their client-side representation
private static readonly ConcurrentDictionary<Type, CustomPrimitiveTypeRegistration> CustomPrimitiveTypes = new();

public static IEnumerable<Type> GetNumericTypes()
{
Expand Down Expand Up @@ -348,19 +364,42 @@ public static bool IsCollection(Type type)
return type != typeof(string) && IsEnumerable(type) && !IsDictionary(type);
}

public static bool IsPrimitiveType(Type type)
/// <summary> Returns true if the type is a primitive type natively supported by DotVVM. "Primitive" means that it is serialized as a JavaScript primitive (not object nor array) </summary>
public static bool IsDotvvmNativePrimitiveType(Type type)
{
return PrimitiveTypes.Contains(type)
|| (IsNullableType(type) && IsPrimitiveType(type.UnwrapNullableType()))
|| (IsNullableType(type) && IsDotvvmNativePrimitiveType(type.UnwrapNullableType()))
|| type.IsEnum;
}

public static bool IsSerializationSupported(this Type type, bool includeNullables)
/// <summary> Returns true if the type is a custom primitive type.</summary>
public static bool IsCustomPrimitiveType(Type type)
{
return typeof(IDotvvmPrimitiveType).IsAssignableFrom(type);
}

/// <summary>Returns a custom primitive type registration for the given type, or null if the type is not a custom primitive type.</summary>
public static CustomPrimitiveTypeRegistration? TryGetCustomPrimitiveTypeRegistration(Type type)
{
if (IsCustomPrimitiveType(type))
return CustomPrimitiveTypes.GetOrAdd(type, DiscoverCustomPrimitiveType);
else
return null;
}

/// <summary> Returns true the type is serialized as a JavaScript primitive (not object nor array) </summary>
public static bool IsPrimitiveType(Type type)
{
if (includeNullables)
return IsPrimitiveType(type);
return PrimitiveTypes.Contains(type)
|| (IsNullableType(type) && IsPrimitiveType(type.UnwrapNullableType()))
|| type.IsEnum
|| IsCustomPrimitiveType(type);
}

return PrimitiveTypes.Contains(type);
private static CustomPrimitiveTypeRegistration DiscoverCustomPrimitiveType(Type type)
{
Debug.Assert(typeof(IDotvvmPrimitiveType).IsAssignableFrom(type));
return new CustomPrimitiveTypeRegistration(type);
}

public static bool IsNullableType(Type type)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using DotVVM.Framework.Utils;
using DotVVM.Framework.ViewModel;
using Newtonsoft.Json;

namespace DotVVM.Framework.ViewModel.Serialization
{
public class DotvvmCustomPrimitiveTypeConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return ReflectionUtils.IsCustomPrimitiveType(objectType);
}

public override object? ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
if (reader.TokenType is JsonToken.String
or JsonToken.Boolean
or JsonToken.Integer
or JsonToken.Float
or JsonToken.Date)
{
var registration = ReflectionUtils.TryGetCustomPrimitiveTypeRegistration(objectType)!;
var parseResult = registration.TryParseMethod(Convert.ToString(reader.Value, CultureInfo.InvariantCulture)!);
if (!parseResult.Successful)
{
throw new JsonSerializationException($"The value '{reader.Value}' cannot be deserialized as {objectType} because its TryParse method wasn't able to parse the value!");
}
return parseResult.Result;
}
else if (reader.TokenType == JsonToken.Null)
{
return null;
}
else
{
throw new JsonSerializationException($"Token {reader.TokenType} cannot be deserialized as {objectType}! Primitive value in JSON was expected.");
}
}

public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
if (value == null)
{
writer.WriteNull();
}
else
{
var registration = ReflectionUtils.TryGetCustomPrimitiveTypeRegistration(value.GetType())!;
writer.WriteValue(registration.ToStringMethod(value));
}
}


}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s
if (value is null) throw new Exception($"Could not deserialize object with path '{reader.Path}' as IEnumerable.");
foreach (var item in value)
{
dict.Add(keyProp.GetValue(item)!, valueProp.GetValue(item));
dict[keyProp.GetValue(item)!] = valueProp.GetValue(item);
}
return dict;
}
Expand Down
Loading

0 comments on commit 608b96a

Please sign in to comment.