Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for custom primitive types #1498

Merged
merged 35 commits into from
Aug 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
59ac901
Prepared sample page for type ids
tomasherceg Oct 27, 2022
29cbda6
Proof of concept of type metadata for custom primitive types
tomasherceg Oct 27, 2022
304a4ec
Custom primitive types URL and route parameter support
tomasherceg Oct 27, 2022
9c728cb
Custom primitive types - added sample for commands and fixed JSON con…
tomasherceg Oct 27, 2022
83237f5
Fixed issue in validation when property is not found on the client side
tomasherceg Oct 27, 2022
b9f8604
Custom primitive types - added sample for JS translation
tomasherceg Oct 27, 2022
06e33f5
Refactoring in samples
tomasherceg Oct 27, 2022
a6118f1
Fixed issue with converters in static commands
tomasherceg Oct 27, 2022
05afdb0
Configuration of custom primitive types changed to work via attributes
tomasherceg Nov 5, 2022
50e38a9
Removed unnecessary changes in the code
tomasherceg Nov 5, 2022
f1175ba
UI tests added
tomasherceg Nov 5, 2022
02a61b8
Fixed nullability issues
tomasherceg Nov 5, 2022
4d637d1
Handled exception when creating custom primitive type converter
tomasherceg Nov 20, 2022
5688dbc
Feature flag added for static command serialization
tomasherceg Nov 20, 2022
b4ac339
CustomPrimitiveTypeAttribute moved to DotVVM.Core package
tomasherceg Nov 23, 2022
e890f53
Create an universal JsonConverter for all custom primitive types
tomasherceg Nov 23, 2022
f0fe7d4
Fixed nullability error
tomasherceg Dec 11, 2022
876d386
Fixed issues after rebase
tomasherceg Jul 30, 2023
e53148b
Support for dotvvm serialization of static command arguments
exyi Jul 30, 2023
1ed4119
Validate contents of custom primitive types
exyi Jul 30, 2023
c1f8830
ICustomPrimitiveTypeConverter replaced by ToString and TryParse metho…
tomasherceg Jul 30, 2023
3290fc7
IsCustomPrimitiveTypes function added
tomasherceg Jul 30, 2023
079c966
CustomPrimitiveType ToString and Parse JS translations
exyi Jul 30, 2023
36011bc
UI tests added
tomasherceg Jul 30, 2023
6b51cb7
Fixed unnecessary changes
tomasherceg Jul 30, 2023
b65d7ed
Removed unnecessary change in DotVVM.Core project
tomasherceg Jul 30, 2023
1e1a3ea
Fix deserialization of Dictionary into pre-initialized dict
exyi Jul 30, 2023
4ffbb69
Allow validation of properties inside custom primitives
exyi Jul 30, 2023
be55d5a
Covered all overloads of ToString and TryParse
tomasherceg Jul 30, 2023
d45fc50
Merge branch 'feature/custom-primitive-types' of https://github.com/r…
tomasherceg Jul 30, 2023
3912740
Fixed build warnings
tomasherceg Jul 30, 2023
b37dca9
Switch custom primitive types to a marker interface
exyi Aug 1, 2023
a9561ea
Remove the CustomPrimitiveType attribute
exyi Aug 2, 2023
5754127
Merge pull request #1682 from riganti/feature/custom-primitive-types-…
exyi Aug 2, 2023
21486c8
Merge branch 'main' into feature/custom-primitive-types
exyi Aug 3, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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