Skip to content

Commit ec0221d

Browse files
committed
json: Serialize constructed Value and Unit
Serialize with constructed Unit and _value instead of base unit and base unit value. By using _value we serialize using the original value type (decimal vs double), since UnitsNet public API is primarily using double. Cast to QuantityValueDecimal for types that use decimal internally. NOTE: This mix of double and decimal representations is a bit messy right now and needs a better fix. We already discuss this topic in #285 about either moving everything to decimal or to better support multiple numeric types (float, double, long, decimal etc).
1 parent 7600917 commit ec0221d

File tree

2 files changed

+136
-71
lines changed

2 files changed

+136
-71
lines changed

UnitsNet.Serialization.JsonNet.Tests/UnitsNetJsonConverterTests.cs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,24 +51,35 @@ public class Serialize : UnitsNetJsonConverterTests
5151
public void Information_CanSerializeVeryLargeValues()
5252
{
5353
Information i = Information.FromExabytes(1E+9);
54-
var expectedJson = "{\n \"Unit\": \"InformationUnit.Bit\",\n \"Value\": 8E+27\n}";
54+
var expectedJson = "{\n \"Unit\": \"InformationUnit.Exabyte\",\n \"Value\": 1000000000.0\n}";
5555

5656
string json = SerializeObject(i);
5757

5858
Assert.Equal(expectedJson, json);
5959
}
6060

6161
[Fact]
62-
public void Mass_ExpectKilogramsUsedAsBaseValueAndUnit()
62+
public void Mass_ExpectConstructedValueAndUnit()
6363
{
6464
Mass mass = Mass.FromPounds(200);
65-
var expectedJson = "{\n \"Unit\": \"MassUnit.Kilogram\",\n \"Value\": 90.718474\n}";
65+
var expectedJson = "{\n \"Unit\": \"MassUnit.Pound\",\n \"Value\": 200.0\n}";
6666

6767
string json = SerializeObject(mass);
6868

6969
Assert.Equal(expectedJson, json);
7070
}
7171

72+
[Fact]
73+
public void Information_ExpectConstructedValueAndUnit()
74+
{
75+
Information quantity = Information.FromKilobytes(54);
76+
var expectedJson = "{\n \"Unit\": \"InformationUnit.Kilobyte\",\n \"Value\": 54.0\n}";
77+
78+
string json = SerializeObject(quantity);
79+
80+
Assert.Equal(expectedJson, json);
81+
}
82+
7283
[Fact]
7384
public void NonNullNullableValue_ExpectJsonUnaffected()
7485
{
@@ -122,7 +133,7 @@ public void NullValue_ExpectJsonContainsNullString()
122133
public void Ratio_ExpectDecimalFractionsUsedAsBaseValueAndUnit()
123134
{
124135
Ratio ratio = Ratio.FromPartsPerThousand(250);
125-
var expectedJson = "{\n \"Unit\": \"RatioUnit.DecimalFraction\",\n \"Value\": 0.25\n}";
136+
var expectedJson = "{\n \"Unit\": \"RatioUnit.PartPerThousand\",\n \"Value\": 250.0\n}";
126137

127138
string json = SerializeObject(ratio);
128139

@@ -376,4 +387,4 @@ private class TestObjWithThreeIComparable
376387
public IComparable Value3 { get; set; }
377388
}
378389
}
379-
}
390+
}

UnitsNet.Serialization.JsonNet/UnitsNetJsonConverter.cs

Lines changed: 120 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,19 @@
2020
// THE SOFTWARE.
2121

2222
using System;
23-
using System.Globalization;
2423
using System.Linq;
2524
using System.Reflection;
2625
using JetBrains.Annotations;
2726
using Newtonsoft.Json;
2827
using Newtonsoft.Json.Linq;
28+
using UnitsNet.InternalHelpers;
2929

3030
namespace UnitsNet.Serialization.JsonNet
3131
{
32+
/// <inheritdoc />
3233
/// <summary>
33-
/// A JSON.net <see cref="JsonConverter" /> for converting to/from JSON and Units.NET
34-
/// units like <see cref="Length" /> and <see cref="Mass" />.
34+
/// A JSON.net <see cref="T:Newtonsoft.Json.JsonConverter" /> for converting to/from JSON and Units.NET
35+
/// units like <see cref="T:UnitsNet.Length" /> and <see cref="T:UnitsNet.Mass" />.
3536
/// </summary>
3637
/// <remarks>
3738
/// Relies on reflection and the type names and namespaces as of 3.x.x of Units.NET.
@@ -42,6 +43,11 @@ namespace UnitsNet.Serialization.JsonNet
4243
/// </remarks>
4344
public class UnitsNetJsonConverter : JsonConverter
4445
{
46+
/// <summary>
47+
/// Numeric value field of a quantity, typically of type double or decimal.
48+
/// </summary>
49+
private const string ValueFieldName = "_value";
50+
4551
/// <summary>
4652
/// Reads the JSON representation of the object.
4753
/// </summary>
@@ -61,9 +67,8 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist
6167
return reader.Value;
6268
}
6369
object obj = TryDeserializeIComparable(reader, serializer);
64-
var vu = obj as ValueUnit;
6570
// A null System.Nullable value or a comparable type was deserialized so return this
66-
if (vu == null)
71+
if (!(obj is ValueUnit vu))
6772
{
6873
return obj;
6974
}
@@ -73,53 +78,82 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist
7378
string unitEnumValue = vu.Unit.Split('.')[1];
7479

7580
// "MassUnit" => "Mass"
76-
string unitTypeName = unitEnumTypeName.Substring(0, unitEnumTypeName.Length - "Unit".Length);
81+
string quantityTypeName = unitEnumTypeName.Substring(0, unitEnumTypeName.Length - "Unit".Length);
7782

7883
// "UnitsNet.Units.MassUnit,UnitsNet"
7984
string unitEnumTypeAssemblyQualifiedName = "UnitsNet.Units." + unitEnumTypeName + ",UnitsNet";
8085

8186
// "UnitsNet.Mass,UnitsNet"
82-
string unitTypeAssemblyQualifiedName = "UnitsNet." + unitTypeName + ",UnitsNet";
87+
string quantityTypeAssemblyQualifiedName = "UnitsNet." + quantityTypeName + ",UnitsNet";
8388

8489
// -- see http://stackoverflow.com/a/6465096/1256096 for details
85-
Type reflectedUnitEnumType = Type.GetType(unitEnumTypeAssemblyQualifiedName);
86-
if (reflectedUnitEnumType == null)
90+
Type unitEnumType = Type.GetType(unitEnumTypeAssemblyQualifiedName);
91+
if (unitEnumType == null)
8792
{
8893
var ex = new UnitsNetException("Unable to find enum type.");
8994
ex.Data["type"] = unitEnumTypeAssemblyQualifiedName;
9095
throw ex;
9196
}
9297

93-
Type reflectedUnitType = Type.GetType(unitTypeAssemblyQualifiedName);
94-
if (reflectedUnitType == null)
98+
Type quantityType = Type.GetType(quantityTypeAssemblyQualifiedName);
99+
if (quantityType == null)
95100
{
96101
var ex = new UnitsNetException("Unable to find unit type.");
97-
ex.Data["type"] = unitTypeAssemblyQualifiedName;
102+
ex.Data["type"] = quantityTypeAssemblyQualifiedName;
98103
throw ex;
99104
}
100105

101-
object unit = Enum.Parse(reflectedUnitEnumType, unitEnumValue);
106+
double value = vu.Value;
107+
object unitValue = Enum.Parse(unitEnumType, unitEnumValue); // Ex: MassUnit.Kilogram
102108

103-
// Mass.From() method, assume no overloads exist
104-
var fromMethod = reflectedUnitType
105-
#if (NETSTANDARD1_0)
106-
.GetTypeInfo()
107-
.GetDeclaredMethods("From")
108-
.Single(m => !m.ReturnType.IsConstructedGenericType);
109-
#else
110-
.GetMethods()
111-
.Single(m => m.Name.Equals("From", StringComparison.InvariantCulture) &&
112-
!m.ReturnType.IsGenericType);
113-
#endif
109+
return CreateQuantity(quantityType, value, unitValue);
110+
}
114111

115-
// Implicit cast: we use this type to avoid explosion of method overloads to handle multiple number types
116-
QuantityValue quantityValue = vu.Value;
112+
/// <summary>
113+
/// Creates a quantity (ex: Mass) based on the reflected quantity type, a numeric value and a unit value (ex: MassUnit.Kilogram).
114+
/// </summary>
115+
/// <param name="quantityType">Type of quantity, such as <see cref="Mass"/>.</param>
116+
/// <param name="value">Numeric value.</param>
117+
/// <param name="unitValue">The unit, such as <see cref="MassUnit.Kilogram"/>.</param>
118+
/// <returns>The constructed quantity, such as <see cref="Mass"/>.</returns>
119+
private static object CreateQuantity(Type quantityType, double value, object unitValue)
120+
{
121+
// We want the non-nullable return type, example candidates if quantity type is Mass:
122+
// double Mass.From(double, MassUnit)
123+
// double? Mass.From(double?, MassUnit)
124+
MethodInfo notNullableFromMethod = quantityType
125+
.GetDeclaredMethods()
126+
.Single(m => m.Name == "From" && Nullable.GetUnderlyingType(m.ReturnType) == null);
127+
128+
// Of type QuantityValue
129+
object quantityValue = GetFromMethodValueArgument(notNullableFromMethod, value);
117130

118131
// Ex: Mass.From(55, MassUnit.Gram)
119132
// TODO: there is a possible loss of precision if base value requires higher precision than double can represent.
120133
// Example: Serializing Information.FromExabytes(100) then deserializing to Information
121134
// will likely return a very different result. Not sure how we can handle this?
122-
return fromMethod.Invoke(null, new[] {quantityValue, unit});
135+
return notNullableFromMethod.Invoke(null, new[] {quantityValue, unitValue});
136+
}
137+
138+
/// <summary>
139+
/// Returns numeric value wrapped as <see cref="QuantityValue"/>, based on the type of argument
140+
/// of <paramref name="fromMethod"/>. Today this is always <see cref="QuantityValue"/>, but
141+
/// we may extend to other types later such as QuantityValueDecimal.
142+
/// </summary>
143+
/// <param name="fromMethod">The reflected From(value, unit) method.</param>
144+
/// <param name="value">The value to convert to the correct wrapper type.</param>
145+
/// <returns></returns>
146+
private static object GetFromMethodValueArgument(MethodInfo fromMethod, double value)
147+
{
148+
Type valueParameterType = fromMethod.GetParameters()[0].ParameterType;
149+
if (valueParameterType == typeof(QuantityValue))
150+
{
151+
// We use this type that takes implicit cast from all number types to avoid explosion of method overloads that take a numeric value.
152+
return (QuantityValue) value;
153+
}
154+
155+
throw new Exception(
156+
$"The first parameter of the reflected quantity From() method was expected to be either UnitsNet.QuantityValue, but was instead {valueParameterType}.");
123157
}
124158

125159
private static object TryDeserializeIComparable(JsonReader reader, JsonSerializer serializer)
@@ -147,77 +181,97 @@ private static object TryDeserializeIComparable(JsonReader reader, JsonSerialize
147181
/// Writes the JSON representation of the object.
148182
/// </summary>
149183
/// <param name="writer">The <see cref="T:Newtonsoft.Json.JsonWriter" /> to write to.</param>
150-
/// <param name="value">The value to write.</param>
184+
/// <param name="obj">The value to write.</param>
151185
/// <param name="serializer">The calling serializer.</param>
152186
/// <exception cref="UnitsNetException">Can't serialize 'null' value.</exception>
153-
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
187+
public override void WriteJson(JsonWriter writer, object obj, JsonSerializer serializer)
154188
{
155-
Type unitType = value.GetType();
189+
Type quantityType = obj.GetType();
156190

157191
// ValueUnit should be written as usual (but read in a custom way)
158-
if(unitType == typeof(ValueUnit))
192+
if(quantityType == typeof(ValueUnit))
159193
{
160194
JsonSerializer localSerializer = new JsonSerializer()
161195
{
162196
TypeNameHandling = serializer.TypeNameHandling,
163197
};
164-
JToken t = JToken.FromObject(value, localSerializer);
198+
JToken t = JToken.FromObject(obj, localSerializer);
165199

166200
t.WriteTo(writer);
167201
return;
168202
}
203+
204+
object quantityValue = GetValueOfQuantity(obj, quantityType); // double or decimal value
205+
string quantityUnitName = GetUnitFullNameOfQuantity(obj, quantityType); // Example: "MassUnit.Kilogram"
206+
207+
serializer.Serialize(writer, new ValueUnit
208+
{
209+
// This might throw OverflowException for very large values?
210+
// TODO Should we serialize long, decimal and long differently?
211+
Value = Convert.ToDouble(quantityValue),
212+
Unit = quantityUnitName
213+
});
214+
}
215+
216+
/// <summary>
217+
/// Given quantity (ex: <see cref="Mass"/>), returns the full name (ex: "MassUnit.Kilogram") of the constructed unit given by the <see cref="Mass.Unit"/> property.
218+
/// </summary>
219+
/// <param name="obj">Quantity, such as <see cref="Mass"/>.</param>
220+
/// <param name="quantityType">The type of <paramref name="obj"/>, passed in here to reuse a previous lookup.</param>
221+
/// <returns>"MassUnit.Kilogram" for a mass quantity whose Unit property is MassUnit.Kilogram.</returns>
222+
private static string GetUnitFullNameOfQuantity(object obj, Type quantityType)
223+
{
224+
// Get value of Unit property
225+
PropertyInfo unitProperty = quantityType.GetPropety("Unit");
226+
Enum quantityUnit = (Enum) unitProperty.GetValue(obj, null); // MassUnit.Kilogram
227+
228+
Type unitType = quantityUnit.GetType(); // MassUnit
229+
return $"{unitType.Name}.{quantityUnit}"; // "MassUnit.Kilogram"
230+
}
231+
232+
private static object GetValueOfQuantity(object value, Type quantityType)
233+
{
234+
FieldInfo valueField = GetPrivateInstanceField(quantityType, ValueFieldName);
235+
236+
// Unit base type can be double, long or decimal,
237+
// so make sure we serialize the real type to avoid
238+
// loss of precision
239+
object quantityValue = valueField.GetValue(value);
240+
return quantityValue;
241+
}
242+
243+
private static FieldInfo GetPrivateInstanceField(Type quantityType, string fieldName)
244+
{
169245
FieldInfo baseValueField;
170246
try
171247
{
172-
baseValueField = unitType
248+
baseValueField = quantityType
173249
#if (NETSTANDARD1_0)
174250
.GetTypeInfo()
175-
176251
.DeclaredFields
177-
.SingleOrDefault(f => !f.IsPublic && !f.IsStatic);
252+
.Where(f => !f.IsPublic && !f.IsStatic)
178253
#else
179254
.GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)
180-
.SingleOrDefault();
181255
#endif
256+
.SingleOrDefault(f => f.Name == fieldName);
182257
}
183258
catch (InvalidOperationException)
184259
{
185-
var ex = new UnitsNetException("Expected exactly 1 private field, but found multiple.");
186-
ex.Data["type"] = unitType;
260+
var ex = new UnitsNetException($"Expected exactly one private field named [{fieldName}], but found multiple.");
261+
ex.Data["type"] = quantityType;
262+
ex.Data["fieldName"] = fieldName;
187263
throw ex;
188264
}
265+
189266
if (baseValueField == null)
190267
{
191268
var ex = new UnitsNetException("No private fields found in type.");
192-
ex.Data["type"] = unitType;
269+
ex.Data["type"] = quantityType;
270+
ex.Data["fieldName"] = fieldName;
193271
throw ex;
194272
}
195-
// Unit base type can be double, long or decimal,
196-
// so make sure we serialize the real type to avoid
197-
// loss of precision
198-
object baseValue = baseValueField.GetValue(value);
199273

200-
// Mass => "MassUnit.Kilogram"
201-
PropertyInfo baseUnitPropInfo = unitType
202-
#if (NETSTANDARD1_0)
203-
.GetTypeInfo()
204-
.GetDeclaredProperty("BaseUnit");
205-
#else
206-
.GetProperty("BaseUnit");
207-
#endif
208-
209-
// Read static BaseUnit property value
210-
var baseUnitEnumValue = (Enum) baseUnitPropInfo.GetValue(null, null);
211-
Type baseUnitType = baseUnitEnumValue.GetType();
212-
string baseUnit = $"{baseUnitType.Name}.{baseUnitEnumValue}";
213-
214-
serializer.Serialize(writer, new ValueUnit
215-
{
216-
// This might throw OverflowException for very large values?
217-
// TODO Should we serialize long, decimal and long differently?
218-
Value = Convert.ToDouble(baseValue),
219-
Unit = baseUnit
220-
});
274+
return baseValueField;
221275
}
222276

223277
/// <summary>
@@ -261,7 +315,7 @@ public override bool CanConvert(Type objectType)
261315
/// </summary>
262316
/// <param name="objectType">Type of the object.</param>
263317
/// <returns><c>true</c> if the object type is nullable; otherwise <c>false</c>.</returns>
264-
protected bool IsNullable(Type objectType)
318+
private static bool IsNullable(Type objectType)
265319
{
266320
return Nullable.GetUnderlyingType(objectType) != null;
267321
}
@@ -280,4 +334,4 @@ protected virtual bool CanConvertNullable(Type objectType)
280334

281335
#endregion
282336
}
283-
}
337+
}

0 commit comments

Comments
 (0)