Skip to content

Commit fd391d1

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 fae4c39 commit fd391d1

File tree

2 files changed

+141
-71
lines changed

2 files changed

+141
-71
lines changed

UnitsNet.Serialization.JsonNet.Tests/UnitsNetJsonConverterTests.cs

+16-5
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

+125-66
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,87 @@ 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+
// Either of type QuantityValue or QuantityValueDecimal
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"/> or <see cref="QuantityValueDecimal"/>, depending
140+
/// on what type the first parameter. Two examples are <see cref="Mass.From(UnitsNet.QuantityValue,UnitsNet.Units.MassUnit)"/> and
141+
/// <see cref="Information.From(UnitsNet.QuantityValueDecimal,UnitsNet.Units.InformationUnit)"/>.
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+
// Implicit cast: we use this type to avoid explosion of method overloads to handle multiple number types
152+
return (QuantityValue) value;
153+
}
154+
155+
if (valueParameterType == typeof(QuantityValueDecimal))
156+
{
157+
return (QuantityValueDecimal) value;
158+
}
159+
160+
throw new Exception(
161+
$"The first parameter of the reflected quantity From() method was expected to be either UnitsNet.QuantityValue or UnitsNet.QuantityValueDecimal, but was instead {valueParameterType}.");
123162
}
124163

125164
private static object TryDeserializeIComparable(JsonReader reader, JsonSerializer serializer)
@@ -147,77 +186,97 @@ private static object TryDeserializeIComparable(JsonReader reader, JsonSerialize
147186
/// Writes the JSON representation of the object.
148187
/// </summary>
149188
/// <param name="writer">The <see cref="T:Newtonsoft.Json.JsonWriter" /> to write to.</param>
150-
/// <param name="value">The value to write.</param>
189+
/// <param name="obj">The value to write.</param>
151190
/// <param name="serializer">The calling serializer.</param>
152191
/// <exception cref="UnitsNetException">Can't serialize 'null' value.</exception>
153-
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
192+
public override void WriteJson(JsonWriter writer, object obj, JsonSerializer serializer)
154193
{
155-
Type unitType = value.GetType();
194+
Type quantityType = obj.GetType();
156195

157196
// ValueUnit should be written as usual (but read in a custom way)
158-
if(unitType == typeof(ValueUnit))
197+
if(quantityType == typeof(ValueUnit))
159198
{
160199
JsonSerializer localSerializer = new JsonSerializer()
161200
{
162201
TypeNameHandling = serializer.TypeNameHandling,
163202
};
164-
JToken t = JToken.FromObject(value, localSerializer);
203+
JToken t = JToken.FromObject(obj, localSerializer);
165204

166205
t.WriteTo(writer);
167206
return;
168207
}
208+
209+
object quantityValue = GetValueOfQuantity(obj, quantityType); // double or decimal value
210+
string quantityUnitName = GetUnitFullNameOfQuantity(obj, quantityType); // Example: "MassUnit.Kilogram"
211+
212+
serializer.Serialize(writer, new ValueUnit
213+
{
214+
// This might throw OverflowException for very large values?
215+
// TODO Should we serialize long, decimal and long differently?
216+
Value = Convert.ToDouble(quantityValue),
217+
Unit = quantityUnitName
218+
});
219+
}
220+
221+
/// <summary>
222+
/// 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.
223+
/// </summary>
224+
/// <param name="obj">Quantity, such as <see cref="Mass"/>.</param>
225+
/// <param name="quantityType">The type of <paramref name="obj"/>, passed in here to reuse a previous lookup.</param>
226+
/// <returns>"MassUnit.Kilogram" for a mass quantity whose Unit property is MassUnit.Kilogram.</returns>
227+
private static string GetUnitFullNameOfQuantity(object obj, Type quantityType)
228+
{
229+
// Get value of Unit property
230+
PropertyInfo unitProperty = quantityType.GetPropety("Unit");
231+
Enum quantityUnit = (Enum) unitProperty.GetValue(obj, null); // MassUnit.Kilogram
232+
233+
Type unitType = quantityUnit.GetType(); // MassUnit
234+
return $"{unitType.Name}.{quantityUnit}"; // "MassUnit.Kilogram"
235+
}
236+
237+
private static object GetValueOfQuantity(object value, Type quantityType)
238+
{
239+
FieldInfo valueField = GetPrivateInstanceField(quantityType, ValueFieldName);
240+
241+
// Unit base type can be double, long or decimal,
242+
// so make sure we serialize the real type to avoid
243+
// loss of precision
244+
object quantityValue = valueField.GetValue(value);
245+
return quantityValue;
246+
}
247+
248+
private static FieldInfo GetPrivateInstanceField(Type quantityType, string fieldName)
249+
{
169250
FieldInfo baseValueField;
170251
try
171252
{
172-
baseValueField = unitType
253+
baseValueField = quantityType
173254
#if (NETSTANDARD1_0)
174255
.GetTypeInfo()
175-
176256
.DeclaredFields
177-
.SingleOrDefault(f => !f.IsPublic && !f.IsStatic);
257+
.Where(f => !f.IsPublic && !f.IsStatic)
178258
#else
179259
.GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)
180-
.SingleOrDefault();
181260
#endif
261+
.SingleOrDefault(f => f.Name == fieldName);
182262
}
183263
catch (InvalidOperationException)
184264
{
185-
var ex = new UnitsNetException("Expected exactly 1 private field, but found multiple.");
186-
ex.Data["type"] = unitType;
265+
var ex = new UnitsNetException($"Expected exactly one private field named [{fieldName}], but found multiple.");
266+
ex.Data["type"] = quantityType;
267+
ex.Data["fieldName"] = fieldName;
187268
throw ex;
188269
}
270+
189271
if (baseValueField == null)
190272
{
191273
var ex = new UnitsNetException("No private fields found in type.");
192-
ex.Data["type"] = unitType;
274+
ex.Data["type"] = quantityType;
275+
ex.Data["fieldName"] = fieldName;
193276
throw ex;
194277
}
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);
199278

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-
});
279+
return baseValueField;
221280
}
222281

223282
/// <summary>
@@ -261,7 +320,7 @@ public override bool CanConvert(Type objectType)
261320
/// </summary>
262321
/// <param name="objectType">Type of the object.</param>
263322
/// <returns><c>true</c> if the object type is nullable; otherwise <c>false</c>.</returns>
264-
protected bool IsNullable(Type objectType)
323+
private static bool IsNullable(Type objectType)
265324
{
266325
return Nullable.GetUnderlyingType(objectType) != null;
267326
}
@@ -280,4 +339,4 @@ protected virtual bool CanConvertNullable(Type objectType)
280339

281340
#endregion
282341
}
283-
}
342+
}

0 commit comments

Comments
 (0)