Skip to content

Commit 5f2b075

Browse files
authored
Merge pull request #389 from angularsen/angularsen/preserve-value-unit
### Motivation Preserve the original value and unit and add getter properties `Value` and `Unit` to expose them. Use `Unit` in `ToString()` instead of the base unit representation, to better match the units the user is working with. This is a significant rewrite of how values are stored and how conversions are performed in quantity types. It is also the first baby steps of introducing some common values in base types #371 . **There should be no breaking changes in neither main lib nor JSON serialization.** ### Changes Using example of `Length`, but this applies to all quantities: - Change private field `_meters` to `_value` (still `double` or `decimal`, as before) - Add private field `LengthUnit? _unit` - Add `Value` getter prop (`double` or `decimal`) - Add `LengthUnit Unit` getter prop, with fallback to base unit `LengthUnit.Meter` (1) - Ctors without unit param assumes `BaseUnit`, backwards compatible - Change `ToString()` to use `Unit` instead of `DefaultToStringUnit`, new test cases - Mark `DefaultToStringUnit` as obsolete - Add support for preserving `decimal` precision in `QuantityValue` (2) - Defer conversion to base unit until calling properties like `.Centimeters` (3) - Serialize `Value`+`Unit` instead of base unit and base unit value, backwards compatible, update test cases - Make internals of UnitsNet visible to Json to reuse reflection abstraction code - Rename variables in JSON serialization lib (use "quantity" term instead of "unit") 1) Default ctor (`struct`) and ctors without unit param assumes base unit, to be backwards compatible. We might want to change this in the future to require explicitly specifying the unit. 2) Needed to avoid precision issues going via double to construct decimal values. 3) Getting the value of unit properties is now twice as slow, as it first converts from `Unit` to base unit, then to target unit. Before this change, the first conversion was initially made when constructing the quantity. However, there are optimizations in-place to avoid converting if `Unit` already matches either base unit or the target unit. ### Gotchas - Serialization output changed (different value and unit for same quantity) - In #389 (comment) I discuss the choice to not go with breaking change with regards to unspecified unit, but why we may want to do so later to require explicitly specifying the unit
2 parents 995bda4 + 13bb546 commit 5f2b075

File tree

102 files changed

+25106
-21470
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

102 files changed

+25106
-21470
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/UnitsNet.Serialization.JsonNet.Signed.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
<!-- Enable strong name signing -->
1111
<PropertyGroup>
12+
<DefineConstants>SIGNED</DefineConstants>
1213
<SignAssembly>true</SignAssembly>
1314
<AssemblyOriginatorKeyFile>$(MSBuildProjectDirectory)\..\UnitsNet.snk</AssemblyOriginatorKeyFile>
1415
</PropertyGroup>

UnitsNet.Serialization.JsonNet/UnitsNetJsonConverter.cs

+119-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,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,96 @@ 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+
// TODO Should we serialize long, decimal and long differently?
210+
Value = Convert.ToDouble(quantityValue),
211+
Unit = quantityUnitName
212+
});
213+
}
214+
215+
/// <summary>
216+
/// 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.
217+
/// </summary>
218+
/// <param name="obj">Quantity, such as <see cref="Mass"/>.</param>
219+
/// <param name="quantityType">The type of <paramref name="obj"/>, passed in here to reuse a previous lookup.</param>
220+
/// <returns>"MassUnit.Kilogram" for a mass quantity whose Unit property is MassUnit.Kilogram.</returns>
221+
private static string GetUnitFullNameOfQuantity(object obj, Type quantityType)
222+
{
223+
// Get value of Unit property
224+
PropertyInfo unitProperty = quantityType.GetPropety("Unit");
225+
Enum quantityUnit = (Enum) unitProperty.GetValue(obj, null); // MassUnit.Kilogram
226+
227+
Type unitType = quantityUnit.GetType(); // MassUnit
228+
return $"{unitType.Name}.{quantityUnit}"; // "MassUnit.Kilogram"
229+
}
230+
231+
private static object GetValueOfQuantity(object value, Type quantityType)
232+
{
233+
FieldInfo valueField = GetPrivateInstanceField(quantityType, ValueFieldName);
234+
235+
// Unit base type can be double, long or decimal,
236+
// so make sure we serialize the real type to avoid
237+
// loss of precision
238+
object quantityValue = valueField.GetValue(value);
239+
return quantityValue;
240+
}
241+
242+
private static FieldInfo GetPrivateInstanceField(Type quantityType, string fieldName)
243+
{
169244
FieldInfo baseValueField;
170245
try
171246
{
172-
baseValueField = unitType
247+
baseValueField = quantityType
173248
#if (NETSTANDARD1_0)
174249
.GetTypeInfo()
175-
176250
.DeclaredFields
177-
.SingleOrDefault(f => !f.IsPublic && !f.IsStatic);
251+
.Where(f => !f.IsPublic && !f.IsStatic)
178252
#else
179253
.GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)
180-
.SingleOrDefault();
181254
#endif
255+
.SingleOrDefault(f => f.Name == fieldName);
182256
}
183257
catch (InvalidOperationException)
184258
{
185-
var ex = new UnitsNetException("Expected exactly 1 private field, but found multiple.");
186-
ex.Data["type"] = unitType;
259+
var ex = new UnitsNetException($"Expected exactly one private field named [{fieldName}], but found multiple.");
260+
ex.Data["type"] = quantityType;
261+
ex.Data["fieldName"] = fieldName;
187262
throw ex;
188263
}
264+
189265
if (baseValueField == null)
190266
{
191267
var ex = new UnitsNetException("No private fields found in type.");
192-
ex.Data["type"] = unitType;
268+
ex.Data["type"] = quantityType;
269+
ex.Data["fieldName"] = fieldName;
193270
throw ex;
194271
}
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);
199-
200-
// Mass => "MassUnit.Kilogram"
201-
PropertyInfo baseUnitPropInfo = unitType
202-
#if (NETSTANDARD1_0)
203-
.GetTypeInfo()
204-
.GetDeclaredProperty("BaseUnit");
205-
#else
206-
.GetProperty("BaseUnit");
207-
#endif
208272

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-
});
273+
return baseValueField;
221274
}
222275

223276
/// <summary>
@@ -261,7 +314,7 @@ public override bool CanConvert(Type objectType)
261314
/// </summary>
262315
/// <param name="objectType">Type of the object.</param>
263316
/// <returns><c>true</c> if the object type is nullable; otherwise <c>false</c>.</returns>
264-
protected bool IsNullable(Type objectType)
317+
private static bool IsNullable(Type objectType)
265318
{
266319
return Nullable.GetUnderlyingType(objectType) != null;
267320
}
@@ -280,4 +333,4 @@ protected virtual bool CanConvertNullable(Type objectType)
280333

281334
#endregion
282335
}
283-
}
336+
}

0 commit comments

Comments
 (0)