diff --git a/.github/workflows/milestone-release.yml b/.github/workflows/milestone-release.yml index 55c1b934..c954d2cb 100644 --- a/.github/workflows/milestone-release.yml +++ b/.github/workflows/milestone-release.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Sync Release with Milestone - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const { owner, repo } = context.repo; diff --git a/.github/workflows/on-push-do-doco.yml b/.github/workflows/on-push-do-doco.yml index 1489e09e..24c3fd22 100644 --- a/.github/workflows/on-push-do-doco.yml +++ b/.github/workflows/on-push-do-doco.yml @@ -5,7 +5,13 @@ jobs: docs: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + # global.json pins a preview SDK that the runner image does not carry, so every dotnet + # command in the repo directory fails to resolve one. Install the pinned SDK rather than + # relying on what happens to be preinstalled + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json - name: Run MarkdownSnippets run: | dotnet tool install --global MarkdownSnippets.Tool diff --git a/src/Argon.JsonPath/ArrayIndexFilter.cs b/src/Argon.JsonPath/ArrayIndexFilter.cs index aad799be..03de7bdc 100644 --- a/src/Argon.JsonPath/ArrayIndexFilter.cs +++ b/src/Argon.JsonPath/ArrayIndexFilter.cs @@ -1,4 +1,4 @@ -class ArrayIndexFilter : +class ArrayIndexFilter : PathFilter { public int? Index { get; set; } @@ -9,11 +9,13 @@ public override IEnumerable ExecuteFilter(JToken root, IEnumerable + long.TryParse(text, NumberStyles.Integer, InvariantCulture, out value); + + static bool TryParseDouble(CharSpan text, out double value) => + double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, InvariantCulture, out value); +#else + static bool TryParseInt64(CharSpan text, out long value) => + long.TryParse(text.ToString(), NumberStyles.Integer, InvariantCulture, out value); + + static bool TryParseDouble(CharSpan text, out double value) => + double.TryParse(text.ToString(), NumberStyles.Float | NumberStyles.AllowThousands, InvariantCulture, out value); +#endif + string ReadQuotedString() { - var stringBuilder = new StringBuilder(); + // the builder is only created once an escape is found: a string with no escapes in it + // is a slice of the expression + StringBuilder? stringBuilder = null; + var start = currentIndex + 1; + var copiedTo = start; currentIndex++; while (currentIndex < expression.Length) @@ -633,6 +653,9 @@ string ReadQuotedString() var currentChar = expression[currentIndex]; if (currentChar == '\\' && currentIndex + 1 < expression.Length) { + stringBuilder ??= new(); + stringBuilder.Append(expression, copiedTo, currentIndex - copiedTo); + currentIndex++; currentChar = expression[currentIndex]; @@ -667,16 +690,24 @@ string ReadQuotedString() stringBuilder.Append(resolvedChar); currentIndex++; + copiedTo = currentIndex; } else if (currentChar == '\'') { + if (stringBuilder == null) + { + var text = expression.Substring(start, currentIndex - start); + currentIndex++; + return text; + } + + stringBuilder.Append(expression, copiedTo, currentIndex - copiedTo); currentIndex++; return stringBuilder.ToString(); } else { currentIndex++; - stringBuilder.Append(currentChar); } } diff --git a/src/Argon.JsonPath/QueryFilter.cs b/src/Argon.JsonPath/QueryFilter.cs index b37a996c..d815be5e 100644 --- a/src/Argon.JsonPath/QueryFilter.cs +++ b/src/Argon.JsonPath/QueryFilter.cs @@ -1,4 +1,4 @@ -class QueryFilter(QueryExpression expression) : +class QueryFilter(QueryExpression expression) : PathFilter { internal QueryExpression Expression = expression; @@ -7,12 +7,22 @@ public override IEnumerable ExecuteFilter(JToken root, IEnumerable + str1.AsSpan().SequenceEqual(str2.AsSpan(str2Start, str2Length)); class Entry { diff --git a/src/Argon/GlobalUsings.cs b/src/Argon/GlobalUsings.cs index d4e152d6..cd1c192a 100644 --- a/src/Argon/GlobalUsings.cs +++ b/src/Argon/GlobalUsings.cs @@ -6,3 +6,5 @@ global using System.Reflection.Emit; global using System.Text.RegularExpressions; global using System.Collections.Frozen; +global using System.Runtime.CompilerServices; +global using System.Runtime.InteropServices; diff --git a/src/Argon/JsonConvert.cs b/src/Argon/JsonConvert.cs index d3640caa..332c3645 100644 --- a/src/Argon/JsonConvert.cs +++ b/src/Argon/JsonConvert.cs @@ -89,8 +89,12 @@ public static string ToString(bool value) => /// /// Converts the to its JSON string representation. /// - public static string ToString(char value) => - ToString(new[]{value}.AsSpan()); + public static string ToString(char value) + { + Span chars = stackalloc char[1]; + chars[0] = value; + return ToString(chars); + } /// /// Converts the to its JSON string representation. diff --git a/src/Argon/JsonPosition.cs b/src/Argon/JsonPosition.cs index 30f873bb..b151f499 100644 --- a/src/Argon/JsonPosition.cs +++ b/src/Argon/JsonPosition.cs @@ -16,10 +16,29 @@ struct JsonPosition(JsonContainerType type) internal string? PropertyName = null; internal bool HasIndex = TypeHasIndex(type); + // set instead of PropertyName when the name arrived as a span. the chars are copied into a + // buffer owned by this position rather than materialized into a string, because the name is + // only ever read back to build a path for an error message + internal char[]? NameChars = null; + internal int NameLength = 0; + + readonly CharSpan Name + { + get + { + if (PropertyName != null) + { + return PropertyName.AsSpan(); + } + + return NameChars.AsSpan(0, NameLength); + } + } + int CalculateLength() => Type switch { - JsonContainerType.Object => PropertyName!.Length + 5, + JsonContainerType.Object => Name.Length + 5, JsonContainerType.Array => MathUtils.IntLength((ulong) Position) + 2, _ => throw new ArgumentOutOfRangeException(nameof(Type)) }; @@ -34,7 +53,7 @@ void WriteTo(StringBuilder builder, ref StringWriter? writer, ref char[]? buffer switch (Type) { case JsonContainerType.Object: - var propertyName = PropertyName!.AsSpan(); + var propertyName = Name; if (propertyName.IndexOfAny(specialCharacters) != -1) { builder.Append("['"); diff --git a/src/Argon/JsonReader.cs b/src/Argon/JsonReader.cs index 972b1797..c151542a 100644 --- a/src/Argon/JsonReader.cs +++ b/src/Argon/JsonReader.cs @@ -757,7 +757,7 @@ bool ReadArrayElementIntoByteArrayReportDone(List buffer) return d; } - if (ConvertUtils.DecimalTryParse(s.ToCharArray(), 0, s.Length, out d) == ParseResult.Success) + if (ConvertUtils.DecimalTryParse(s.AsSpan(), 0, s.Length, out d) == ParseResult.Success) { // This is to handle strings like "96.014e-05" that are not supported by traditional decimal.TryParse SetToken(d); @@ -783,8 +783,7 @@ bool ReadArrayElementIntoByteArrayReportDone(List buffer) } // fallback handles strings like "96.014e-05" not supported by decimal.TryParse - var chars = s.ToArray(); - if (ConvertUtils.DecimalTryParse(chars, 0, chars.Length, out d) == ParseResult.Success) + if (ConvertUtils.DecimalTryParse(s, 0, s.Length, out d) == ParseResult.Success) { SetToken(d); return d; diff --git a/src/Argon/JsonTextReader.cs b/src/Argon/JsonTextReader.cs index fffdb66d..3ecae55a 100644 --- a/src/Argon/JsonTextReader.cs +++ b/src/Argon/JsonTextReader.cs @@ -95,7 +95,7 @@ void ParseReadString(char quote, ReadType readType) data = []; } else if (stringReference.Length == 36 && - ConvertUtils.TryConvertGuid(stringReference.ToString(), out var g)) + ConvertUtils.TryConvertGuid(stringReference.AsSpan(), out var g)) { data = g.ToByteArray(); } @@ -137,8 +137,10 @@ void ShiftBufferIfNeeded() // once in the last 10% of the buffer, or buffer is already very large then // shift the remaining content to the start to avoid unnecessarily increasing // the buffer size when reading numbers/strings + // integer math: this runs once per string/number token, and the double conversion and + // multiply it replaces showed up next to the actual copy var length = charBuffer.Length; - if (length - charPos <= length * 0.1 || length >= LargeBufferLength) + if ((length - charPos) * 10L <= length || length >= LargeBufferLength) { var count = charsUsed - charPos; if (count > 0) @@ -963,6 +965,35 @@ bool ReadNullChar() return false; } +#if NET8_0_OR_GREATER + // every char a JSON string can contain that ReadStringIntoBuffer has to act on. '\0' is + // included because the buffer is always '\0' terminated at charsUsed, so the terminator + // doubles as the "need more data" sentinel and stops the scan at the end of valid content + static readonly SearchValues stringDelimiters = SearchValues.Create("\0\\\r\n\"'"); + + // vectorized skip past the run of chars needing no handling, so the per char switch in + // ReadStringIntoBuffer only runs on chars that actually do something. most strings contain + // none of these, so this collapses the whole scan into a single IndexOfAny + int SkipToNextStringDelimiter(int charPos) + { + var remaining = charsUsed - charPos; + if (remaining <= 0) + { + return charPos; + } + + var index = charBuffer.AsSpan(charPos, remaining).IndexOfAny(stringDelimiters); + if (index == -1) + { + // no delimiter in the buffered content, so jump to the '\0' terminator at + // charsUsed and let the switch trigger a read for more data + return charsUsed; + } + + return charPos + index; + } +#endif + void ReadStringIntoBuffer(char quote) { var charPos = this.charPos; @@ -972,6 +1003,9 @@ void ReadStringIntoBuffer(char quote) while (true) { +#if NET8_0_OR_GREATER + charPos = SkipToNextStringDelimiter(charPos); +#endif switch (charBuffer[charPos++]) { case '\0': diff --git a/src/Argon/JsonWriter.cs b/src/Argon/JsonWriter.cs index 5a9428d0..19fbe9e3 100644 --- a/src/Argon/JsonWriter.cs +++ b/src/Argon/JsonWriter.cs @@ -1517,8 +1517,25 @@ internal void InternalWritePropertyName(string name) AutoComplete(PropertyName); } - internal void InternalWritePropertyName(CharSpan name) => - InternalWritePropertyName(name.ToString()); + internal void InternalWritePropertyName(CharSpan name) + { + // copy the chars into a buffer owned by the current position instead of allocating a + // string. the buffer is reused by every property written at this depth, and the name is + // only read back when a path is built for an error message + var nameChars = currentPosition.NameChars; + if (nameChars == null || + nameChars.Length < name.Length) + { + nameChars = new char[Math.Max(name.Length, 16)]; + currentPosition.NameChars = nameChars; + } + + name.CopyTo(nameChars); + currentPosition.NameLength = name.Length; + currentPosition.PropertyName = null; + + AutoComplete(PropertyName); + } internal void InternalWriteStart(JsonToken token, JsonContainerType container) { diff --git a/src/Argon/Linq/JArray.cs b/src/Argon/Linq/JArray.cs index d6a296b9..edefc52a 100644 --- a/src/Argon/Linq/JArray.cs +++ b/src/Argon/Linq/JArray.cs @@ -221,6 +221,11 @@ public JToken this[int index] set => SetItem(index, value); } + // index the backing list directly: the base implementation goes through the + // virtual ChildrenTokens property and then an IList interface dispatch + internal override JToken GetItem(int index) => + values[index]; + internal override int IndexOfItem(JToken? item) { if (item == null) diff --git a/src/Argon/Linq/JContainer.cs b/src/Argon/Linq/JContainer.cs index c5b19d3f..8cfa7661 100644 --- a/src/Argon/Linq/JContainer.cs +++ b/src/Argon/Linq/JContainer.cs @@ -207,7 +207,9 @@ internal virtual bool InsertItem(int index, JToken? item, bool skipParentCheck) // haven't inserted new token yet so next token is still at the inserting index var next = index == children.Count ? null : children[index]; - ValidateToken(item, null); + // skipParentCheck is only set by the writer, which has already removed any property + // with the same name, so JObject can skip hashing the name a second time + ValidateToken(item, null, skipParentCheck); item.Parent = this; @@ -292,7 +294,7 @@ internal virtual void SetItem(int index, JToken? item) item = EnsureParentToken(item, false); - ValidateToken(item, existing); + ValidateToken(item, existing, false); var previous = index == 0 ? null : children[index - 1]; var next = index == children.Count - 1 ? null : children[index + 1]; @@ -314,10 +316,12 @@ internal virtual void SetItem(int index, JToken? item) internal virtual void ClearItems() { + // indexed loop: children is interface typed, so foreach boxes an enumerator var children = ChildrenTokens; - foreach (var item in children) + for (var i = 0; i < children.Count; i++) { + var item = children[i]; item.Parent = null; item.Previous = null; item.Next = null; @@ -357,11 +361,10 @@ internal virtual void CopyItemsTo(Array array, int arrayIndex) throw new ArgumentException("The number of elements in the source JObject is greater than the available space from arrayIndex to the end of the destination array."); } - var index = 0; - foreach (var token in ChildrenTokens) + var children = ChildrenTokens; + for (var i = 0; i < children.Count; i++) { - array.SetValue(token, arrayIndex + index); - index++; + array.SetValue(children[i], arrayIndex + i); } } @@ -381,7 +384,11 @@ internal static bool IsTokenUnchanged(JToken currentValue, JToken? newValue) return false; } - internal virtual void ValidateToken(JToken o, JToken? existing) + /// + /// Set when the caller guarantees the token can not collide with an existing one. Only + /// acts on it, to skip its duplicate property name check. + /// + internal virtual void ValidateToken(JToken o, JToken? existing, bool skipDuplicateNameCheck) { if (o.Type == JTokenType.Property) { @@ -614,9 +621,10 @@ static JProperty ReadProperty(JsonReader reader, JsonLoadSettings? settings, IJs internal int ContentsHashCode() { var hashCode = 0; - foreach (var item in ChildrenTokens) + var children = ChildrenTokens; + for (var i = 0; i < children.Count; i++) { - hashCode ^= item.GetDeepHashCode(); + hashCode ^= children[i].GetDeepHashCode(); } return hashCode; diff --git a/src/Argon/Linq/JObject.cs b/src/Argon/Linq/JObject.cs index f62d8634..4bf294a0 100644 --- a/src/Argon/Linq/JObject.cs +++ b/src/Argon/Linq/JObject.cs @@ -82,13 +82,18 @@ internal override bool InsertItem(int index, JToken? item, bool skipParentCheck) return base.InsertItem(index, item, skipParentCheck); } - internal override void ValidateToken(JToken o, JToken? existing) + internal override void ValidateToken(JToken o, JToken? existing, bool skipDuplicateNameCheck) { if (o.Type != JTokenType.Property) { throw new ArgumentException($"Can not add {o.GetType()} to {GetType()}."); } + if (skipDuplicateNameCheck) + { + return; + } + var newProperty = (JProperty) o; if (existing != null) @@ -119,8 +124,15 @@ internal override JToken CloneToken() => /// Gets an of of this object's properties. /// /// An of of this object's properties. - public IEnumerable Properties() => - properties.Cast(); + public IEnumerable Properties() + { + // iterate InnerList rather than using Cast: avoids the LINQ wrapper + // enumerable and the boxed interface enumerator on every call + foreach (var token in properties.InnerList) + { + yield return (JProperty) token; + } + } /// /// Gets the with the specified name. @@ -502,8 +514,9 @@ public bool TryGetValue(string propertyName, [NotNullWhen(true)] out JToken? val } var index = 0; - foreach (JProperty property in properties) + foreach (var token in properties.InnerList) { + var property = (JProperty) token; array[arrayIndex + index] = new(property.Name, property.Value); index++; } diff --git a/src/Argon/Linq/JTokenWriter.cs b/src/Argon/Linq/JTokenWriter.cs index 9d550983..9d851e71 100644 --- a/src/Argon/Linq/JTokenWriter.cs +++ b/src/Argon/Linq/JTokenWriter.cs @@ -283,7 +283,9 @@ public override void WriteValue(CharSpan value) public override void WriteValue(int value) { base.WriteValue(value); - AddRawValue(value, JTokenType.Integer); + // int is the most common CLR type for a JSON number, so use the shared + // boxes for small values instead of boxing at the call site + AddRawValue(BoxedPrimitives.Get(value), JTokenType.Integer); } /// diff --git a/src/Argon/NamingStrategy/NamingStrategy.cs b/src/Argon/NamingStrategy/NamingStrategy.cs index 7d477784..b13705f9 100644 --- a/src/Argon/NamingStrategy/NamingStrategy.cs +++ b/src/Argon/NamingStrategy/NamingStrategy.cs @@ -9,6 +9,12 @@ namespace Argon; /// public abstract class NamingStrategy { + // dictionary keys are user data, so the cache is capped; once it is full later keys are + // resolved without being added + const int dictionaryKeyCacheMax = 512; + + ConcurrentDictionary? dictionaryKeyCache; + /// /// A flag indicating whether dictionary keys should be processed. /// Defaults to false. @@ -45,14 +51,40 @@ public virtual string GetPropertyName(string name, bool hasSpecifiedName) /// The serialized dictionary key. public virtual string GetDictionaryKey(string name, object original) { - if (ProcessDictionaryKeys) + if (!ProcessDictionaryKeys) + { + return name; + } + + if (!CacheDictionaryKeys) { return ResolvePropertyName(name); } - return name; + // the same keys usually repeat across the entries of a dictionary, and across calls + var cache = dictionaryKeyCache ??= new(); + if (cache.TryGetValue(name, out var resolved)) + { + return resolved; + } + + resolved = ResolvePropertyName(name); + if (cache.Count < dictionaryKeyCacheMax) + { + cache.TryAdd(name, resolved); + } + + return resolved; } + /// + /// A flag indicating whether resolved dictionary keys can be cached, which requires + /// to return the same result every time it is passed the same + /// name. True for all the naming strategies included in Argon. Override to false in a + /// strategy that resolves a name differently depending on state outside that name. + /// + protected virtual bool CacheDictionaryKeys => true; + /// /// Resolves the specified property name. /// diff --git a/src/Argon/Serialization/DefaultContractResolver.cs b/src/Argon/Serialization/DefaultContractResolver.cs index 37d34085..2920e5a9 100644 --- a/src/Argon/Serialization/DefaultContractResolver.cs +++ b/src/Argon/Serialization/DefaultContractResolver.cs @@ -102,8 +102,10 @@ protected virtual IEnumerable GetSerializableMembers(Type type) var dataContractAttribute = JsonTypeReflector.GetDataContractAttribute(type); // Exclude index properties and ByRef types - var defaultMembers = type.GetFieldsAndProperties(BindingFlags.Instance | BindingFlags.Public) - .Where(FilterMembers).ToList(); + // HashSet: ShouldSerialize probes this for every member of the type, and a list probe is linear + var defaultMembers = new HashSet( + type.GetFieldsAndProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(FilterMembers)); // Do not filter ByRef types here because accessing FieldType/PropertyType can trigger additional assembly loads foreach (var member in type.GetFieldsAndProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) @@ -132,7 +134,7 @@ protected virtual IEnumerable GetSerializableMembers(Type type) return serializableMembers; } - bool ShouldSerialize(MemberInfo member, List defaultMembers, DataContractAttribute? dataContractAttribute) + bool ShouldSerialize(MemberInfo member, HashSet defaultMembers, DataContractAttribute? dataContractAttribute) { // exclude members that are compiler generated if set if (!SerializeCompilerGeneratedMembers && diff --git a/src/Argon/Serialization/JsonObjectContract.cs b/src/Argon/Serialization/JsonObjectContract.cs index 4c05fab4..27c1650b 100644 --- a/src/Argon/Serialization/JsonObjectContract.cs +++ b/src/Argon/Serialization/JsonObjectContract.cs @@ -48,6 +48,36 @@ public class JsonObjectContract : JsonContainerContract internal ObjectConstructor? ParameterizedCreator { get; set; } + Dictionary? creatorParameterIndexes; + + /// + /// Gets the index of a creator parameter within , or -1 when it is not one. + /// + /// + /// Backed by a lookup rather than a scan of : the index is needed + /// once per matched parameter while deserializing, so scanning makes every object built through a + /// parameterized constructor - records and immutable types - quadratic in its parameter count. + /// Built on first use because the contract resolver populates + /// after the contract is constructed, and rebuilt if that collection is later changed. + /// + internal int IndexOfCreatorParameter(JsonProperty property) + { + var indexes = creatorParameterIndexes; + if (indexes == null || + indexes.Count != CreatorParameters.Count) + { + indexes = new(CreatorParameters.Count); + for (var index = 0; index < CreatorParameters.Count; index++) + { + indexes[CreatorParameters[index]] = index; + } + + creatorParameterIndexes = indexes; + } + + return indexes.GetValueOrDefault(property, -1); + } + bool? hasRequiredOrDefaultValueProperties; internal bool HasRequiredOrDefaultValueProperties diff --git a/src/Argon/Serialization/JsonSerializerInternalBase.cs b/src/Argon/Serialization/JsonSerializerInternalBase.cs index 03ed3b08..7eb13ffb 100644 --- a/src/Argon/Serialization/JsonSerializerInternalBase.cs +++ b/src/Argon/Serialization/JsonSerializerInternalBase.cs @@ -40,6 +40,39 @@ protected static bool HasFlag(DefaultValueHandling? value, DefaultValueHandling "A different value already has the Id '{0}'.", "A different Id has already been assigned for value '{0}'. This error may be caused by an object being reused multiple times during deserialization and can be fixed with the setting ObjectCreationHandling.Replace."); + Dictionary? matchingConverters; + + /// + /// Resolves the converter for a type from the converters registered on the serializer. + /// + /// + /// Memoizes the scan. The underlying lookup calls virtual + /// on every registered converter, and it runs for every value serialized and every property, + /// collection item and dictionary entry deserialized. The result cannot be cached on the + /// contract because contracts are shared between serializers with different converter lists, + /// so it is cached here instead - a fresh instance is created for each serialize/deserialize + /// call, so converters registered between calls are always picked up. + /// + protected JsonConverter? GetMatchingConverter(Type type) + { + var converters = Serializer.Converters; + if (converters.Count == 0) + { + return null; + } + + matchingConverters ??= []; + + if (matchingConverters.TryGetValue(type, out var converter)) + { + return converter; + } + + converter = Serializer.GetMatchingConverter(type); + matchingConverters[type] = converter; + return converter; + } + protected NullValueHandling ResolvedNullValueHandling(JsonObjectContract? containerContract, JsonProperty property) => property.NullValueHandling ?? containerContract?.ItemNullValueHandling ?? diff --git a/src/Argon/Serialization/JsonSerializerInternalReader.cs b/src/Argon/Serialization/JsonSerializerInternalReader.cs index 9828c610..aa206881 100644 --- a/src/Argon/Serialization/JsonSerializerInternalReader.cs +++ b/src/Argon/Serialization/JsonSerializerInternalReader.cs @@ -2,7 +2,6 @@ // Use of this source code is governed by The MIT License, // as found in the license.md file. - // ReSharper disable NullableWarningSuppressionIsUsed // ReSharper disable RedundantSuppressNullableWarningExpression @@ -29,6 +28,31 @@ enum PropertyPresence JsonContract GetContract(Type type) => Serializer.ResolveContract(type); + // type names are untrusted input, so the cache is capped; once it is full later names are + // split without being added + const int typeNameCacheMax = 128; + + Dictionary? typeNameKeys; + + // the same $type repeats for every item of a polymorphic collection, and splitting it + // allocates a substring for the type and one for the assembly every time + TypeNameKey SplitTypeName(string qualifiedTypeName) + { + var keys = typeNameKeys ??= new(); + if (keys.TryGetValue(qualifiedTypeName, out var key)) + { + return key; + } + + key = ReflectionUtils.SplitFullyQualifiedTypeName(qualifiedTypeName); + if (keys.Count < typeNameCacheMax) + { + keys.Add(qualifiedTypeName, key); + } + + return key; + } + [RequiresUnreferencedCode(MiscellaneousUtils.TrimWarning)] [RequiresDynamicCode(MiscellaneousUtils.AotWarning)] public object? Deserialize(JsonReader reader, Type? type, bool? checkAdditionalContent) @@ -273,7 +297,7 @@ static string GetExpectedDescription(JsonContract contract) return contract.Converter; } - if (Serializer.GetMatchingConverter(contract.UnderlyingType) is { } matchingConverter) + if (GetMatchingConverter(contract.UnderlyingType) is { } matchingConverter) { // passed in converters return matchingConverter; @@ -645,7 +669,7 @@ void ResolveTypeName(JsonReader reader, ref Type? type, ref JsonContract? contra if (resolvedTypeNameHandling != TypeNameHandling.None) { - var typeNameKey = ReflectionUtils.SplitFullyQualifiedTypeName(qualifiedTypeName); + var typeNameKey = SplitTypeName(qualifiedTypeName); var binder = Serializer.SerializationBinder ?? DefaultSerializationBinder.Instance; Type specifiedType; @@ -978,8 +1002,9 @@ JsonToken.StartArray or } else { - propertyContract = GetContract(currentValue.GetType()); - + // currentValue is only ever non null when the block above ran, and that already + // resolved propertyContract from the same currentValue.GetType(). resolving it + // again would repeat a GetType and a contract dictionary lookup per property if (propertyContract != property.PropertyContract) { propertyConverter = GetConverter(propertyContract, property.Converter, containerContract, containerProperty); @@ -1746,7 +1771,7 @@ object CreateObjectUsingCreatorWithParameters(JsonReader reader, JsonObjectContr } } - var i = contract.CreatorParameters.IndexOf(constructorProperty); + var i = contract.IndexOfCreatorParameter(constructorProperty); creatorParameterValues[i] = context.Value; context.Used = true; @@ -2228,11 +2253,25 @@ Required.AllowNull or static void SetPropertyPresence(JsonReader reader, JsonProperty property, Dictionary? requiredProperties) { // the dictionary only tracks presence-relevant properties; do not add others back - if (requiredProperties == null || - !requiredProperties.ContainsKey(property)) + if (requiredProperties == null) + { + return; + } + +#if NET6_0_OR_GREATER + // one hash lookup rather than a ContainsKey probe followed by an indexer set, + // which runs for every property of every object with tracked presence + ref var presenceSlot = ref CollectionsMarshal.GetValueRefOrNullRef(requiredProperties, property); + if (Unsafe.IsNullRef(ref presenceSlot)) + { + return; + } +#else + if (!requiredProperties.ContainsKey(property)) { return; } +#endif PropertyPresence propertyPresence; switch (reader.TokenType) @@ -2257,7 +2296,11 @@ static void SetPropertyPresence(JsonReader reader, JsonProperty property, Dictio break; } +#if NET6_0_OR_GREATER + presenceSlot = propertyPresence; +#else requiredProperties[property] = propertyPresence; +#endif } void HandleError(JsonReader reader, bool readPastError, int initialDepth) @@ -2317,4 +2360,4 @@ protected bool IsDeserializeErrorHandled(object? currentObject, object? member, return currentDeserializeErrorContext.Handled; } -} \ No newline at end of file +} diff --git a/src/Argon/Serialization/JsonSerializerInternalWriter.cs b/src/Argon/Serialization/JsonSerializerInternalWriter.cs index d719b071..5341761d 100644 --- a/src/Argon/Serialization/JsonSerializerInternalWriter.cs +++ b/src/Argon/Serialization/JsonSerializerInternalWriter.cs @@ -14,6 +14,33 @@ class JsonSerializerInternalWriter(JsonSerializer serializer) : int rootLevel; readonly List serializeStack = []; + bool SerializeStackContains(object value) + { + var comparer = Serializer.EqualityComparer; + if (comparer == null) + { + return serializeStack.Contains(value); + } + + // indexed loop: Enumerable.Contains boxes an enumerator for every value serialized + for (var i = 0; i < serializeStack.Count; i++) + { + if (comparer.Equals(serializeStack[i], value)) + { + return true; + } + } + + return false; + } + + // formatted $type names, cached for the duration of this serialization. building one + // concatenates the type and assembly names and then re-parses the result through + // RemoveAssemblyDetails, allocating a StringBuilder and a string, and polymorphic payloads + // repeat the same handful of types over and over. the binder and format handling are fixed + // for a run, so the type alone is a sufficient key + Dictionary? typeNames; + [RequiresUnreferencedCode(MiscellaneousUtils.TrimWarning)] [RequiresDynamicCode(MiscellaneousUtils.AotWarning)] public void Serialize(JsonWriter jsonWriter, object? value, Type? type) @@ -116,7 +143,7 @@ void SerializeValue(JsonWriter writer, object? value, JsonContract? valueContrac containerProperty?.ItemConverter ?? containerContract?.ItemConverter ?? valueContract.Converter ?? - Serializer.GetMatchingConverter(valueContract.UnderlyingType) ?? + GetMatchingConverter(valueContract.UnderlyingType) ?? valueContract.InternalConverter; if (converter is {CanWrite: true}) @@ -266,9 +293,7 @@ JsonContractType.Primitive or referenceLoopHandling = containerContract.ItemReferenceLoopHandling; } - var exists = Serializer.EqualityComparer == null - ? serializeStack.Contains(value) - : serializeStack.Contains(value, Serializer.EqualityComparer); + var exists = SerializeStackContains(value); if (!exists) { @@ -507,8 +532,15 @@ void WriteReferenceIdProperty(JsonWriter writer, object value) void WriteTypeProperty(JsonWriter writer, Type type) { - var binder = Serializer.SerializationBinder ?? DefaultSerializationBinder.Instance; - var typeName = type.GetTypeName(Serializer.TypeNameAssemblyFormatHandling, binder); + typeNames ??= []; + + if (!typeNames.TryGetValue(type, out var typeName)) + { + var binder = Serializer.SerializationBinder ?? DefaultSerializationBinder.Instance; + typeName = type.GetTypeName(Serializer.TypeNameAssemblyFormatHandling, binder); + typeNames[type] = typeName; + } + writer.WritePropertyName(JsonTypeReflector.TypePropertyName, false); writer.WriteValue(typeName); } @@ -1047,16 +1079,12 @@ static string GetDictionaryPropertyName(object key, JsonContract contract, out b var dt = (DateTime) key; escape = false; - var writer = new StringWriter(InvariantCulture); - DateTimeUtils.WriteDateTimeString(writer, dt); - return writer.ToString(); + return DateTimeUtils.ToDateTimeString(dt); } case PrimitiveTypeCode.DateTimeOffset: { escape = false; - var writer = new StringWriter(InvariantCulture); - DateTimeUtils.WriteDateTimeOffsetString(writer, (DateTimeOffset) key); - return writer.ToString(); + return DateTimeUtils.ToDateTimeOffsetString((DateTimeOffset) key); } case PrimitiveTypeCode.Double: { diff --git a/src/Argon/Utilities/ConvertUtils.cs b/src/Argon/Utilities/ConvertUtils.cs index a8ab52ea..d22448bc 100644 --- a/src/Argon/Utilities/ConvertUtils.cs +++ b/src/Argon/Utilities/ConvertUtils.cs @@ -548,7 +548,7 @@ PrimitiveTypeCode.Int64 or PrimitiveTypeCode.Int64Nullable or _ => false }; - public static ParseResult Int32TryParse(char[] chars, int start, int length, out int value) + public static ParseResult Int32TryParse(CharSpan chars, int start, int length, out int value) { value = 0; @@ -642,7 +642,7 @@ public static ParseResult Int32TryParse(char[] chars, int start, int length, out return ParseResult.Success; } - public static ParseResult Int64TryParse(char[] chars, int start, int length, out long value) + public static ParseResult Int64TryParse(CharSpan chars, int start, int length, out long value) { value = 0; @@ -734,7 +734,7 @@ public static ParseResult Int64TryParse(char[] chars, int start, int length, out return ParseResult.Success; } - public static ParseResult DecimalTryParse(char[] chars, int start, int length, out decimal value) + public static ParseResult DecimalTryParse(CharSpan chars, int start, int length, out decimal value) { value = 0M; const decimal decimalMaxValueHi28 = 7922816251426433759354395033M; @@ -987,6 +987,10 @@ public static bool TryConvertGuid(string s, out Guid g) => // GUID has to have format 00000000-0000-0000-0000-000000000000 Guid.TryParseExact(s, "D", out g); + public static bool TryConvertGuid(CharSpan s, out Guid g) => + // GUID has to have format 00000000-0000-0000-0000-000000000000 + Guid.TryParseExact(s, "D", out g); + public static bool TryHexTextToInt(char[] text, int start, int end, out int value) { value = 0; diff --git a/src/Argon/Utilities/DateTimeUtils.cs b/src/Argon/Utilities/DateTimeUtils.cs index e0e60888..a7879dbe 100644 --- a/src/Argon/Utilities/DateTimeUtils.cs +++ b/src/Argon/Utilities/DateTimeUtils.cs @@ -135,12 +135,19 @@ internal static bool TryParseDateTimeOffset(string s, out DateTimeOffset dt) internal static void WriteDateTimeString(TextWriter writer, DateTime value) { - var chars = new char[64]; + Span chars = stackalloc char[64]; var pos = WriteDateTimeString(chars, 0, value, null, value.Kind); - writer.Write(chars, 0, pos); + writer.Write(chars[..pos]); } - internal static int WriteDateTimeString(char[] chars, int start, DateTime value, TimeSpan? offset, DateTimeKind kind) + internal static string ToDateTimeString(DateTime value) + { + Span chars = stackalloc char[64]; + var pos = WriteDateTimeString(chars, 0, value, null, value.Kind); + return chars[..pos].ToString(); + } + + internal static int WriteDateTimeString(Span chars, int start, DateTime value, TimeSpan? offset, DateTimeKind kind) { var pos = WriteDefaultIsoDate(chars, start, value); @@ -157,7 +164,7 @@ internal static int WriteDateTimeString(char[] chars, int start, DateTime value, return pos; } - static int WriteDefaultIsoDate(char[] chars, int start, DateTime dt) + static int WriteDefaultIsoDate(Span chars, int start, DateTime dt) { var length = 19; @@ -195,7 +202,7 @@ static int WriteDefaultIsoDate(char[] chars, int start, DateTime dt) return start + length; } - static void CopyIntToCharArray(char[] chars, int start, int value, int digits) + static void CopyIntToCharArray(Span chars, int start, int value, int digits) { while (digits-- != 0) { @@ -204,7 +211,7 @@ static void CopyIntToCharArray(char[] chars, int start, int value, int digits) } } - internal static int WriteDateTimeOffset(char[] chars, int start, TimeSpan offset) + internal static int WriteDateTimeOffset(Span chars, int start, TimeSpan offset) { chars[start++] = offset.Ticks >= 0L ? '+' : '-'; @@ -223,10 +230,17 @@ internal static int WriteDateTimeOffset(char[] chars, int start, TimeSpan offset internal static void WriteDateTimeOffsetString(TextWriter writer, DateTimeOffset value) { - var chars = new char[64]; + Span chars = stackalloc char[64]; var pos = WriteDateTimeString(chars, 0, value.DateTime, value.Offset, DateTimeKind.Local); - writer.Write(chars, 0, pos); + writer.Write(chars[..pos]); + } + + internal static string ToDateTimeOffsetString(DateTimeOffset value) + { + Span chars = stackalloc char[64]; + var pos = WriteDateTimeString(chars, 0, value.DateTime, value.Offset, DateTimeKind.Local); + return chars[..pos].ToString(); } #endregion diff --git a/src/Argon/Utilities/JavaScriptUtils.cs b/src/Argon/Utilities/JavaScriptUtils.cs index 2a564ecd..90e6d744 100644 --- a/src/Argon/Utilities/JavaScriptUtils.cs +++ b/src/Argon/Utilities/JavaScriptUtils.cs @@ -170,15 +170,11 @@ static void WriteEscapedJavaScriptNonNullString(TextWriter writer, CharSpan valu writer.Write(value.Slice(0, lastWritePosition)); } - for (var i = lastWritePosition; i < value.Length; i++) + var i = lastWritePosition; + while (true) { var c = value[i]; - if (c < escapeFlags.Length && !escapeFlags[c]) - { - continue; - } - string? escapedValue; switch (c) @@ -245,31 +241,45 @@ static void WriteEscapedJavaScriptNonNullString(TextWriter writer, CharSpan valu break; } - if (escapedValue == null) + if (escapedValue != null) { - continue; - } + // Safe to use ReferenceEquals: escapedValue is either null (handled above), + // a string literal from the switch branches, or the escapedUnicodeText sentinel + // assigned directly in the default branch. No other branch produces "!". + var isEscapedUnicodeText = ReferenceEquals(escapedValue, escapedUnicodeText); - // Safe to use ReferenceEquals: escapedValue is either null (handled above), - // a string literal from the switch branches, or the escapedUnicodeText sentinel - // assigned directly at line 199. No other branch produces a string equal to "!". - var isEscapedUnicodeText = ReferenceEquals(escapedValue, escapedUnicodeText); + if (i > lastWritePosition) + { + // write unchanged chars before writing escaped text + writer.Write(value.Slice(lastWritePosition, i - lastWritePosition)); + } - if (i > lastWritePosition) - { - // write unchanged chars before writing escaped text - writer.Write(value.Slice(lastWritePosition, i - lastWritePosition)); + lastWritePosition = i + 1; + if (isEscapedUnicodeText) + { + writer.Write(buffer!, 0, unicodeTextLength); + } + else + { + writer.Write(escapedValue); + } } - lastWritePosition = i + 1; - if (isEscapedUnicodeText) + // jump to the next char needing an escape rather than stepping over the + // clean run one char at a time; FirstCharToEscape vectorizes the scan + i++; + if (i == value.Length) { - writer.Write(buffer!, 0, unicodeTextLength); + break; } - else + + var next = FirstCharToEscape(value.Slice(i), escapeFlags, escapeHandling); + if (next == -1) { - writer.Write(escapedValue); + break; } + + i += next; } MiscellaneousUtils.Assert(lastWritePosition != 0); @@ -284,6 +294,29 @@ public static string ToEscapedJavaScriptString(CharSpan value, char delimiter, b { var escapeFlags = GetCharEscapeFlags(escapeHandling, delimiter); + if (FirstCharToEscape(value, escapeFlags, escapeHandling) == -1) + { + // nothing to escape: build the result directly rather than through a StringBuilder + if (!appendDelimiters) + { + return value.ToString(); + } + + var length = value.Length + 2; + var quoted = BufferUtils.RentBuffer(length); + try + { + quoted[0] = delimiter; + value.CopyTo(quoted.AsSpan(1)); + quoted[length - 1] = delimiter; + return new(quoted, 0, length); + } + finally + { + BufferUtils.ReturnBuffer(quoted); + } + } + // size for the delimiters too, otherwise the StringBuilder always grows on the first write using var w = StringUtils.CreateStringWriter(value.Length + (appendDelimiters ? 2 : 0)); char[]? buffer = null; diff --git a/src/ArgonTests/AssemblyInfo.cs b/src/ArgonTests/AssemblyInfo.cs index 66fa1c02..d10339d7 100644 --- a/src/ArgonTests/AssemblyInfo.cs +++ b/src/ArgonTests/AssemblyInfo.cs @@ -2,4 +2,4 @@ // Use of this source code is governed by The MIT License, // as found in the license.md file. -[assembly: CollectionBehavior(CollectionBehavior.CollectionPerAssembly)] +[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] \ No newline at end of file diff --git a/src/ArgonTests/Benchmarks/HotPathBenchmarks.cs b/src/ArgonTests/Benchmarks/HotPathBenchmarks.cs new file mode 100644 index 00000000..92a9129f --- /dev/null +++ b/src/ArgonTests/Benchmarks/HotPathBenchmarks.cs @@ -0,0 +1,316 @@ +// Copyright (c) 2007 James Newton-King. All rights reserved. +// Use of this source code is governed by The MIT License, +// as found in the license.md file. + +// Benchmarks covering the hot path fixes from the performance review in todo.md. +// +// The reader string scan and the escape writer fixes are already measured by +// ReaderBenchmarks.ReadStringHeavy and WriterBenchmarks.WriteEscapedStrings/WriteCleanStrings, +// and the JsonPath regex fix by JsonPathRegexBenchmark, so they are not repeated here. + +// JsonWriter.InternalWritePropertyName(CharSpan): the span overload used to call ToString on the +// span so the name could be stored for path tracking, allocating exactly as much as the string +// overload did and defeating the point of the API. The chars are now copied into a buffer that is +// reused by every property written at that depth, so a whole object costs one buffer rather than +// one string per property. +[MemoryDiagnoser] +public class SpanPropertyNameBenchmark +{ + string source; + (int Start, int Length)[] slices; + + [GlobalSetup] + public void Setup() + { + // names sliced out of a larger buffer, which is what the span overload exists for + var builder = new StringBuilder(); + var found = new List<(int, int)>(); + foreach (var index in Enumerable.Range(0, 20)) + { + var name = $"someProperty{index}"; + found.Add((builder.Length, name.Length)); + builder.Append(name); + } + + source = builder.ToString(); + slices = found.ToArray(); + } + + [Benchmark] + public string WriteSpanPropertyNames() + { + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter)) + { + writer.WriteStartObject(); + foreach (var slice in slices) + { + writer.WritePropertyName(source.AsSpan(slice.Start, slice.Length)); + writer.WriteValue(1); + } + + writer.WriteEndObject(); + } + + return stringWriter.ToString(); + } +} + +// DefaultJsonNameTable.Get: the interned name comparison behind every property name lookup during +// deserialization was a per character loop, now a vectorized SequenceEqual. +[MemoryDiagnoser] +public class NameTableGetBenchmark +{ + DefaultJsonNameTable table; + char[] buffer; + (int Start, int Length)[] lookups; + + [GlobalSetup] + public void Setup() + { + table = new(); + var builder = new StringBuilder(); + var found = new List<(int, int)>(); + foreach (var index in Enumerable.Range(0, 64)) + { + // long enough for the comparison to be worth vectorizing, which is the realistic + // shape for the camel/pascal case property names of a typical model + var name = $"aFairlyLongPropertyName{index}"; + table.Add(name); + found.Add((builder.Length, name.Length)); + builder.Append(name); + } + + buffer = builder.ToString().ToCharArray(); + lookups = found.ToArray(); + } + + [Benchmark] + public string GetInternedNames() + { + string last = null; + foreach (var lookup in lookups) + { + last = table.Get(buffer, lookup.Start, lookup.Length); + } + + return last; + } +} + +// JsonSerializerInternalBase.GetMatchingConverter: the registered converter list was rescanned, +// calling virtual CanConvert on each converter, for every value written and every property, +// collection item and dictionary entry read. Resolution is now memoized for the duration of a +// single serialize or deserialize call. +[MemoryDiagnoser] +public class ConverterLookupBenchmark +{ + JsonSerializerSettings settings; + ConverterModel[] models; + string json; + + [GlobalSetup] + public void Setup() + { + settings = new() + { + Converters = + { + new VersionConverter(), + new StringEnumConverter(), + new IsoDateTimeConverter(), + new EncodingConverter(), + new StringBuilderConverter(), + new TimeZoneInfoConverter() + } + }; + + models = Enumerable.Range(0, 100) + .Select(_ => new ConverterModel + { + Name = $"item{_}", + Value = _, + Ratio = _ * 1.5, + Flag = _ % 2 == 0 + }) + .ToArray(); + + json = JsonConvert.SerializeObject(models, settings); + } + + [Benchmark] + public string SerializeWithConverters() => + JsonConvert.SerializeObject(models, settings); + + [Benchmark] + public ConverterModel[] DeserializeWithConverters() => + JsonConvert.DeserializeObject(json, settings); + + public class ConverterModel + { + public string Name { get; set; } + public int Value { get; set; } + public double Ratio { get; set; } + public bool Flag { get; set; } + } +} + +// JsonSerializerInternalWriter.WriteTypeProperty: the $type string was rebuilt for every object +// written, concatenating the type and assembly names and then re-parsing the result through +// RemoveAssemblyDetails. Polymorphic payloads repeat the same few types, so it is now cached for +// the duration of the serialization. +[MemoryDiagnoser] +public class TypeNameWriteBenchmark +{ + object[] items; + JsonSerializerSettings settings; + + [GlobalSetup] + public void Setup() + { + settings = new() + { + TypeNameHandling = TypeNameHandling.All + }; + + items = Enumerable.Range(0, 200) + .Select(object (_) => new TypeNameItem + { + Name = $"item{_}", + Value = _ + }) + .ToArray(); + } + + [Benchmark] + public string SerializeWithTypeNames() => + JsonConvert.SerializeObject(items, settings); + + public class TypeNameItem + { + public string Name { get; set; } + public int Value { get; set; } + } +} + +// JsonObjectContract.IndexOfCreatorParameter: locating the argument slot for a matched +// constructor parameter was an IndexOf scan of CreatorParameters, so every object built through a +// parameterized constructor was quadratic in its parameter count. Records and other immutable +// types with a wide constructor paid the most, so this uses a deliberately wide one. +[MemoryDiagnoser] +public class WideCreatorBenchmark +{ + string json; + + [GlobalSetup] + public void Setup() => + json = JsonConvert.SerializeObject( + new WideRecord(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)); + + [Benchmark] + public WideRecord DeserializeWideRecord() => + JsonConvert.DeserializeObject(json); + + public record WideRecord( + int A1, int A2, int A3, int A4, + int A5, int A6, int A7, int A8, + int A9, int A10, int A11, int A12, + int A13, int A14, int A15, int A16); +} + +// JsonSerializerInternalReader.CalculatePropertyDetails: the contract for an existing property +// value was resolved twice, so populating an object paid two GetType calls and two contract +// lookups for every read only property that gets populated in place. +[MemoryDiagnoser] +public class PopulateExistingBenchmark +{ + string json; + + [GlobalSetup] + public void Setup() => + json = JsonConvert.SerializeObject( + new PopulateTarget + { + Numbers = {1, 2, 3, 4, 5}, + Names = {"one", "two", "three"}, + Map = {["a"] = 1, ["b"] = 2}, + Nested = {Numbers = {9, 8, 7}} + }); + + [Benchmark] + public PopulateTarget PopulateExistingValues() => + JsonConvert.DeserializeObject(json); + + public class PopulateTarget + { + // read only and initialized by the constructor, so the serializer populates the existing + // instance in place rather than replacing it, which is the path that resolved the + // existing value's contract twice + public List Numbers { get; } = []; + public List Names { get; } = []; + public Dictionary Map { get; } = []; + public NestedTarget Nested { get; } = new(); + } + + public class NestedTarget + { + public List Numbers { get; } = []; + } +} + +// Linq to JSON hot paths: +// - JObject.Properties() built a LINQ Cast wrapper plus a boxed enumerator per call +// - JTokenWriter.WriteValue(int) boxed at the call site while the other numeric overloads +// already used the shared BoxedPrimitives boxes +// - JArray's indexer reached the backing list through a virtual property and an interface +// dispatch rather than indexing it directly +[MemoryDiagnoser] +public class JTokenHotPathBenchmark +{ + JObject document; + int[] numbers; + JArray array; + + [GlobalSetup] + public void Setup() + { + document = new( + Enumerable.Range(0, 50) + .Select(_ => new JProperty($"property{_}", _))); + + // weighted to the small values BoxedPrimitives caches, as JSON payloads tend to be + numbers = Enumerable.Range(0, 500) + .Select(_ => _ % 9) + .ToArray(); + + array = [with(Enumerable.Range(0, 500))]; + } + + [Benchmark] + public int EnumerateProperties() + { + var total = 0; + foreach (var property in document.Properties()) + { + total += property.Name.Length; + } + + return total; + } + + [Benchmark] + public JToken IntArrayFromObject() => + JToken.FromObject(numbers); + + [Benchmark] + public int IndexArray() + { + var total = 0; + for (var index = 0; index < array.Count; index++) + { + total += (int) array[index]; + } + + return total; + } +} diff --git a/src/ArgonTests/Benchmarks/PerValueBenchmarks.cs b/src/ArgonTests/Benchmarks/PerValueBenchmarks.cs new file mode 100644 index 00000000..34206a79 --- /dev/null +++ b/src/ArgonTests/Benchmarks/PerValueBenchmarks.cs @@ -0,0 +1,306 @@ +// Copyright (c) 2007 James Newton-King. All rights reserved. +// Use of this source code is governed by The MIT License, +// as found in the license.md file. + +// Benchmarks covering the medium and low priority items from the performance review in todo.md. +// The high priority items are covered by HotPathBenchmarks. + +// Dictionary keys pay for a conversion per entry per call: +// - DateTimeUtils.WriteDateTimeString allocated a 64 char array per call, and +// GetDictionaryPropertyName wrapped it in a StringWriter plus a StringBuilder to get a string +// back out. Both are gone: the date is formatted into a stackalloc buffer. +// - NamingStrategy.GetDictionaryKey re-ran the case conversion for every entry of every +// dictionary, and the same keys repeat across entries and across calls, so resolved keys are +// now cached on the strategy. +[MemoryDiagnoser] +public class DictionaryKeyBenchmark +{ + Dictionary dates; + Dictionary offsets; + Dictionary names; + JsonSerializerSettings camelCase; + + [GlobalSetup] + public void Setup() + { + var start = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + dates = Enumerable.Range(0, 100) + .ToDictionary(_ => start.AddDays(_), _ => _); + offsets = Enumerable.Range(0, 100) + .ToDictionary(_ => new DateTimeOffset(start.AddDays(_)).ToOffset(TimeSpan.FromHours(10)), _ => _); + + // the same key set serialized repeatedly, which is what a cache can help with. The + // names are the ones a dictionary keyed on a domain concept tends to have + names = Enumerable.Range(0, 100) + .ToDictionary(_ => $"SomeDictionaryKey{_}", _ => _); + + camelCase = new() + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy + { + ProcessDictionaryKeys = true + } + } + }; + } + + [Benchmark] + public string SerializeDateKeys() => + JsonConvert.SerializeObject(dates); + + [Benchmark] + public string SerializeDateTimeOffsetKeys() => + JsonConvert.SerializeObject(offsets); + + [Benchmark] + public string SerializeCamelCaseKeys() => + JsonConvert.SerializeObject(names, camelCase); +} + +// JavaScriptUtils.ToEscapedJavaScriptString always built its result through a StringWriter over a +// StringBuilder, even for the common case of a string with nothing in it to escape. When the +// vectorized scan finds no char to escape the result is now copied out directly. +[MemoryDiagnoser] +public class EscapeFreeStringBenchmark +{ + string[] values; + + [GlobalSetup] + public void Setup() => + values = Enumerable.Range(0, 100) + .Select(_ => $"a typical value with no escapes in it at all {_}") + .ToArray(); + + [Benchmark] + public string ToStringNoEscapes() + { + string last = null; + foreach (var value in values) + { + last = JsonConvert.ToString(value); + } + + return last; + } +} + +// JsonSerializerInternalReader.ResolveTypeName split every $type it read into its type and +// assembly halves, allocating a substring for each. A polymorphic payload repeats the same few +// type names, so the split is now memoized for the duration of the deserialization. +[MemoryDiagnoser] +public class TypeNameReadBenchmark +{ + string json; + JsonSerializerSettings settings; + + [GlobalSetup] + public void Setup() + { + settings = new() + { + TypeNameHandling = TypeNameHandling.All + }; + + var items = Enumerable.Range(0, 200) + .Select(object (_) => new TypeNameItem + { + Name = $"item{_}", + Value = _ + }) + .ToArray(); + + json = JsonConvert.SerializeObject(items, settings); + } + + [Benchmark] + public object DeserializeWithTypeNames() => + JsonConvert.DeserializeObject(json, settings); + + public class TypeNameItem + { + public string Name { get; set; } + public int Value { get; set; } + } +} + +// Per value reader allocations: +// - ReadDecimalString copied its input to a char array before handing it to the fallback parser +// that handles exponent form decimals, which decimal.TryParse does not accept. The parsers +// only ever index their input, so they take spans now. +// - ReadAsBytes probes a 36 char string for a Guid before treating it as base64, and that probe +// materialized the string even when the answer was base64. +[MemoryDiagnoser] +public class ReadValueBenchmark +{ + string decimals; + string byteStrings; + + [GlobalSetup] + public void Setup() + { + // exponent form, which is the path that falls through decimal.TryParse to Argon's parser + decimals = $"[{string.Join(",", Enumerable.Range(1, 200).Select(_ => $"\"{_}6.014e-05\""))}]"; + + // 27 bytes base64 encodes to exactly 36 chars, the length of a Guid in D format, so + // every one of these takes the Guid probe before being decoded as base64 + var random = new byte[27]; + byteStrings = $"[{string.Join(",", Enumerable.Range(0, 200).Select(_ => + { + random[0] = (byte) _; + return $"\"{Convert.ToBase64String(random)}\""; + }))}]"; + } + + [Benchmark] + public decimal ReadExponentDecimals() + { + using var reader = new JsonTextReader(new StringReader(decimals)); + reader.Read(); + decimal total = 0; + // ReadAsDecimal on a string token is what reaches ReadDecimalString, and exponent form + // is what falls through decimal.TryParse to the parser that took the copy + while (reader.ReadAsDecimal() is { } value) + { + total += value; + } + + return total; + } + + [Benchmark] + public int ReadBase64Strings() + { + using var reader = new JsonTextReader(new StringReader(byteStrings)); + reader.Read(); + var total = 0; + while (reader.ReadAsBytes() is { } bytes) + { + total += bytes.Length; + } + + return total; + } +} + +// JsonTextReader.ReadNumberIntoBuffer walks a number a char at a time through a 28 case switch. +// Replacing that with a vectorized IndexOfAnyExcept over a SearchValues of the number chars was +// measured and turned down: it costs more than it saves on the short numbers JSON is mostly made +// of. See todo.md. This stays as the cost profile of number reading by length, for whoever picks +// that up next. +[MemoryDiagnoser] +public class NumberScanBenchmark +{ + string json; + + // digits per number, spanning ids and small quantities through to high precision decimals + [Params(1, 3, 8, 18)] + public int Digits { get; set; } + + [GlobalSetup] + public void Setup() + { + var number = Digits <= 2 + ? new('7', Digits) + : $"{new string('7', Digits - 2)}.7"; + json = $"[{string.Join(",", Enumerable.Repeat(number, 500))}]"; + } + + [Benchmark] + public int ReadNumbers() + { + using var reader = new JsonTextReader(new StringReader(json)); + var count = 0; + while (reader.Read()) + { + if (reader.TokenType is JsonToken.Integer or JsonToken.Float) + { + count++; + } + } + + return count; + } +} + +// JTokenWriter.WritePropertyName removes any property of the same name before adding the new +// one, and the add then re-checked for a duplicate name before the keyed collection hashed the +// name a third time to index it. The check the writer's own remove already guarantees is skipped. +[MemoryDiagnoser] +public class JTokenPropertyWriteBenchmark +{ + WideModel model; + + [GlobalSetup] + public void Setup() => + model = new(); + + [Benchmark] + public JToken FromObjectWide() => + JToken.FromObject(model); + + public class WideModel + { + public string P01 { get; set; } = "one"; + public string P02 { get; set; } = "two"; + public string P03 { get; set; } = "three"; + public string P04 { get; set; } = "four"; + public int P05 { get; set; } = 5; + public int P06 { get; set; } = 6; + public int P07 { get; set; } = 7; + public int P08 { get; set; } = 8; + public bool P09 { get; set; } = true; + public bool P10 { get; set; } + public double P11 { get; set; } = 11.5; + public double P12 { get; set; } = 12.5; + public string P13 { get; set; } = "thirteen"; + public string P14 { get; set; } = "fourteen"; + public string P15 { get; set; } = "fifteen"; + public string P16 { get; set; } = "sixteen"; + } +} + +// JSONPath filters walked their input through JToken's enumerator, which goes through Children() +// and boxes an enumerator for every token the filter is handed. Both filters walk the container's +// children directly now. The path itself is parsed once and cached, but the parse also built a +// StringBuilder per number and per quoted string, which slicing the expression avoids. +[MemoryDiagnoser] +public class JsonPathFilterBenchmark +{ + JObject document; + int counter; + + [GlobalSetup] + public void Setup() => + document = new() + { + ["store"] = new JObject + { + ["book"] = new JArray( + Enumerable.Range(0, 200) + .Select(_ => new JObject + { + ["title"] = $"book{_}", + ["category"] = _ % 2 == 0 ? "fiction" : "reference", + ["price"] = _ % 40 + })) + } + }; + + [Benchmark] + public int QueryFilter() => + document.SelectTokens("$.store.book[?(@.price > 20)]").Count(); + + [Benchmark] + public int WildcardIndexFilter() => + document.SelectTokens("$.store.book[*].title").Count(); + + [Benchmark] + public int ParseAndQuery() + { + // a fresh path string each time, so the parse is not served from the path cache + counter++; + return document.SelectTokens($"$.store.book[?(@.category == 'fiction' && @.price > {counter % 20})]").Count(); + } +} diff --git a/src/ArgonTests/Converters/DataSetConverterTests.cs b/src/ArgonTests/Converters/DataSetConverterTests.cs index d8212b49..7a147d00 100644 --- a/src/ArgonTests/Converters/DataSetConverterTests.cs +++ b/src/ArgonTests/Converters/DataSetConverterTests.cs @@ -341,6 +341,8 @@ static DataTable CreateDataTable(string dataTableName, int rows) myNewRow["BooleanCol"] = true; myNewRow["TimeSpanCol"] = new TimeSpan(10, 22, 10, 15, 100); myNewRow["DateTimeCol"] = new DateTime(2000, 12, 29, 0, 0, 0, DateTimeKind.Utc); + // a decimal literal, not a double: the column is typed decimal, and converting a + // double to decimal is correctly rounded from .NET 11 on rather than truncated myNewRow["DecimalCol"] = 64.0021m; myTable.Rows.Add(myNewRow); } diff --git a/src/ArgonTests/Converters/DataTableConverterTests.cs b/src/ArgonTests/Converters/DataTableConverterTests.cs index 6f777218..827252ce 100644 --- a/src/ArgonTests/Converters/DataTableConverterTests.cs +++ b/src/ArgonTests/Converters/DataTableConverterTests.cs @@ -316,6 +316,8 @@ public void Serialize() myNewRow["BooleanCol"] = true; myNewRow["TimeSpanCol"] = new TimeSpan(10, 22, 10, 15, 100); myNewRow["DateTimeCol"] = new DateTime(2000, 12, 29, 0, 0, 0, DateTimeKind.Utc); + // a decimal literal, not a double: the column is typed decimal, and converting a double + // to decimal is correctly rounded from .NET 11 on rather than truncated to 15 digits myNewRow["DecimalCol"] = 64.0021m; myNewRow["ArrayCol"] = new[] {1}; myNewRow["BytesCol"] = "Hello world"u8.ToArray(); diff --git a/src/ArgonTests/Converters/XmlNodeConverterTest.cs b/src/ArgonTests/Converters/XmlNodeConverterTest.cs index ea7ba47a..ad250e8c 100644 --- a/src/ArgonTests/Converters/XmlNodeConverterTest.cs +++ b/src/ArgonTests/Converters/XmlNodeConverterTest.cs @@ -725,7 +725,10 @@ public class DecimalContainer [Fact] public void FloatParseHandlingDecimal() { - var d = (decimal) Math.PI + 1000000000m; + // a decimal literal rather than (decimal) Math.PI: the double to decimal conversion + // returns full precision on modern runtimes, so casting would make this a test of + // conversion precision rather than of the XML/JSON round trip + var d = 1000000003.14159265358979m; var x = new DecimalContainer {Number = d}; var json = JsonConvert.SerializeObject(x, Formatting.Indented); @@ -737,7 +740,7 @@ public void FloatParseHandlingDecimal() }); var xml = doc1.ToString(); - Assert.Equal("1000000003.1415926535897931160", xml); + Assert.Equal("1000000003.14159265358979", xml); var settings = new JsonSerializerSettings { diff --git a/src/ArgonTests/Documentation/LinqToJsonTests.cs b/src/ArgonTests/Documentation/LinqToJsonTests.cs index 012829b0..f92cd1d4 100644 --- a/src/ArgonTests/Documentation/LinqToJsonTests.cs +++ b/src/ArgonTests/Documentation/LinqToJsonTests.cs @@ -575,6 +575,8 @@ public void SelectTokenLinq() Assert.Equal(2, storeNames.Count); Assert.Equal(2, firstProductNames.Count); - Assert.Equal(149.95m, totalPrice, 2); + // the prices are read from JSON as doubles, and converting a double to decimal is + // correctly rounded from .NET 11 on, so the sum carries the binary expansion of 99.95 + Assert.Equal(149.95m, Math.Round(totalPrice, 2)); } } \ No newline at end of file diff --git a/src/ArgonTests/Documentation/Samples/JsonPath/QueryJsonSelectTokenWithLinq.cs b/src/ArgonTests/Documentation/Samples/JsonPath/QueryJsonSelectTokenWithLinq.cs index 2123901e..8618efe5 100644 --- a/src/ArgonTests/Documentation/Samples/JsonPath/QueryJsonSelectTokenWithLinq.cs +++ b/src/ArgonTests/Documentation/Samples/JsonPath/QueryJsonSelectTokenWithLinq.cs @@ -61,6 +61,8 @@ public void Example() #endregion - Assert.Equal(149.95m, totalPrice, 2); + // the prices are read from JSON as doubles, and converting a double to decimal is + // correctly rounded from .NET 11 on, so the sum carries the binary expansion of 99.95 + Assert.Equal(149.95m, Math.Round(totalPrice, 2)); } } \ No newline at end of file diff --git a/src/ArgonTests/JsonTextReaderTests/FloatTests.cs b/src/ArgonTests/JsonTextReaderTests/FloatTests.cs index eebc4df1..097f514c 100644 --- a/src/ArgonTests/JsonTextReaderTests/FloatTests.cs +++ b/src/ArgonTests/JsonTextReaderTests/FloatTests.cs @@ -206,6 +206,9 @@ public void FloatParseHandling() Assert.Equal(JsonToken.Float, reader.TokenType); Assert.True(reader.Read()); + // the reader parses the text straight to decimal, so this is exactly 0.000001. It used + // to be written as Convert.ToDecimal(1E-06), which only matched because that conversion + // truncated to 15 significant digits before .NET 11 Assert.Equal(0.000001m, reader.Value); Assert.Equal(typeof(decimal), reader.ValueType); Assert.Equal(JsonToken.Float, reader.TokenType); diff --git a/src/ArgonTests/JsonTextWriterTest.cs b/src/ArgonTests/JsonTextWriterTest.cs index 7177e273..0f5b5f25 100644 --- a/src/ArgonTests/JsonTextWriterTest.cs +++ b/src/ArgonTests/JsonTextWriterTest.cs @@ -1212,6 +1212,80 @@ public Task Path() return Verify(stringWriter); } + [Fact] + public void PathWithSpanPropertyNames() + { + // the span overload does not materialize the name into a string, it copies the chars into + // a buffer reused across the properties written at that depth, so the path has to stay + // correct as names change length, as depths are pushed and popped, and when the two + // overloads are mixed + var stringWriter = new StringWriter(); + + using (var jsonWriter = new JsonTextWriter(stringWriter)) + { + jsonWriter.WriteStartArray(); + jsonWriter.WriteStartObject(); + + jsonWriter.WritePropertyName("Property1".AsSpan()); + Assert.Equal("[0].Property1", jsonWriter.Path); + jsonWriter.WriteValue(1); + + // shorter name over a buffer a longer one already wrote into + jsonWriter.WritePropertyName("P2".AsSpan()); + Assert.Equal("[0].P2", jsonWriter.Path); + + // the parent name has to survive being pushed and popped + jsonWriter.WriteStartObject(); + jsonWriter.WritePropertyName("Child".AsSpan()); + Assert.Equal("[0].P2.Child", jsonWriter.Path); + jsonWriter.WriteValue(2); + jsonWriter.WriteEndObject(); + + jsonWriter.WritePropertyName("AfterNested".AsSpan()); + Assert.Equal("[0].AfterNested", jsonWriter.Path); + jsonWriter.WriteValue(3); + + // names with special characters take the escaping branch of the path builder + jsonWriter.WritePropertyName("has space".AsSpan()); + Assert.Equal("[0]['has space']", jsonWriter.Path); + jsonWriter.WriteValue(4); + + // a slice of a larger buffer must not pick up the chars around it + jsonWriter.WritePropertyName("XXXSlicedXXX".AsSpan(3, 6)); + Assert.Equal("[0].Sliced", jsonWriter.Path); + jsonWriter.WriteValue(5); + + // back to the string overload at the same depth + jsonWriter.WritePropertyName("Plain"); + Assert.Equal("[0].Plain", jsonWriter.Path); + jsonWriter.WriteValue(6); + + jsonWriter.WriteEndObject(); + jsonWriter.WriteEndArray(); + } + + Assert.Equal( + """[{"Property1":1,"P2":{"Child":2},"AfterNested":3,"has space":4,"Sliced":5,"Plain":6}]""", + stringWriter.ToString()); + } + + [Fact] + public void SpanPropertyNameInExceptionPath() + { + // the deferred name is read back when an error path is built, which is the only thing + // that consumes it + var stringWriter = new StringWriter(); + var jsonWriter = new JsonTextWriter(stringWriter); + jsonWriter.WriteStartObject(); + jsonWriter.WritePropertyName("badProperty".AsSpan()); + // nest so the position holding the span name is on the stack that ContainerPath builds from + jsonWriter.WriteStartObject(); + + var exception = Assert.Throws(() => jsonWriter.WriteValue(new Version(1, 2))); + + Assert.Contains("Path 'badProperty'", exception.Message); + } + [Fact] public Task BuildStateArray() { diff --git a/src/ArgonTests/Linq/DynamicTests.cs b/src/ArgonTests/Linq/DynamicTests.cs index 219479d4..1079c973 100644 --- a/src/ArgonTests/Linq/DynamicTests.cs +++ b/src/ArgonTests/Linq/DynamicTests.cs @@ -247,8 +247,10 @@ public void JValueEquals() Assert.True(d.Decimal > 0.0f); Assert.True(d.Decimal > null); Assert.True(d.Decimal >= null); + // comparing a decimal against a double converts the double with Convert.ToDecimal, which + // is correctly rounded from .NET 11 on: the double 1.1 converts to 1.100000000000000088817841970 + // rather than to 1.1, so it is no longer equal to the decimal 1.1 #if NET11_0_OR_GREATER - // net11.0 converts a double to decimal exactly, so 1.1d no longer compares equal to 1.1m. Assert.True(d.Decimal != 1.1); #else Assert.True(d.Decimal == 1.1); @@ -267,6 +269,7 @@ public void JValueEquals() Assert.True(d.Float < 2); Assert.True(d.Float <= 1.1); Assert.True(d.Float == 1.1); + // the same conversion as above, from the other side #if NET11_0_OR_GREATER Assert.True(d.Float != 1.1m); #else @@ -318,6 +321,10 @@ public void JValueEquals() [Fact] public void JValueAddition() { + // the decimal assertions compare to 10 decimal places: an operand that came through a + // double is converted with Convert.ToDecimal, which is correctly rounded from .NET 11 on + // rather than truncated to 15 significant digits, so those results carry a binary + // expansion far below the precision these expressions are actually testing var o = new JObject( new JProperty("Null", JValue.CreateNull()), new JProperty("Integer", new JValue(1)), @@ -357,12 +364,10 @@ public void JValueAddition() r += 2; Assert.Equal(4.1, (double) r); - // Where the operation produces a double, converting it to decimal is exact as of net11.0 and - // rounded on earlier targets, so these results are compared to 12 decimal places. r = d.Integer + 1.1d; - Assert.Equal(2.1m, (decimal) r, 12); + Assert.Equal(2.1m, (decimal) r, 10); r += 2; - Assert.Equal(4.1m, (decimal) r, 12); + Assert.Equal(4.1m, (decimal) r, 10); r = d.Integer + null; Assert.Null(r.Value); @@ -380,9 +385,9 @@ public void JValueAddition() Assert.Equal(4.2d, (double) r); r = d.Float + 1.1d; - Assert.Equal(2.2m, (decimal) r, 12); + Assert.Equal(2.2m, (decimal) r, 10); r += 2; - Assert.Equal(4.2m, (decimal) r, 12); + Assert.Equal(4.2m, (decimal) r, 10); r = d.Float + null; Assert.Null(r.Value); @@ -390,19 +395,19 @@ public void JValueAddition() Assert.Null(r.Value); r = d.Decimal + 1; - Assert.Equal(2.1m, (decimal) r); + Assert.Equal(2.1m, (decimal) r, 10); r += 2; - Assert.Equal(4.1m, (decimal) r); + Assert.Equal(4.1m, (decimal) r, 10); r = d.Decimal + 1.1; - Assert.Equal(2.2m, (decimal) r, 12); + Assert.Equal(2.2m, (decimal) r, 10); r += 2; - Assert.Equal(4.2m, (decimal) r, 12); + Assert.Equal(4.2m, (decimal) r, 10); r = d.Decimal + 1.1d; - Assert.Equal(2.2m, (decimal) r, 12); + Assert.Equal(2.2m, (decimal) r, 10); r += 2; - Assert.Equal(4.2m, (decimal) r, 12); + Assert.Equal(4.2m, (decimal) r, 10); r = d.Decimal + null; Assert.Null(r.Value); @@ -420,9 +425,9 @@ public void JValueAddition() Assert.Equal(103, (int) r); r = d.BigInteger + 1.1d; - Assert.Equal(101m, (decimal) r); + Assert.Equal(101m, (decimal) r, 10); r += 2; - Assert.Equal(103m, (decimal) r); + Assert.Equal(103m, (decimal) r, 10); #endregion @@ -439,9 +444,9 @@ public void JValueAddition() Assert.Equal(-2.1d, (double) r); r = d.Integer - 1.1d; - Assert.Equal(-0.1m, (decimal) r, 12); + Assert.Equal(-0.1m, (decimal) r, 10); r -= 2; - Assert.Equal(-2.1m, (decimal) r, 12); + Assert.Equal(-2.1m, (decimal) r, 10); r = d.Integer - null; Assert.Null(r.Value); @@ -459,9 +464,9 @@ public void JValueAddition() Assert.Equal(-2d, (double) r); r = d.Float - 1.1d; - Assert.Equal(0m, (decimal) r); + Assert.Equal(0m, (decimal) r, 10); r -= 2; - Assert.Equal(-2m, (decimal) r); + Assert.Equal(-2m, (decimal) r, 10); r = d.Float - null; Assert.Null(r.Value); @@ -469,19 +474,19 @@ public void JValueAddition() Assert.Null(r.Value); r = d.Decimal - 1; - Assert.Equal(0.1m, (decimal) r); + Assert.Equal(0.1m, (decimal) r, 10); r -= 2; - Assert.Equal(-1.9m, (decimal) r); + Assert.Equal(-1.9m, (decimal) r, 10); r = d.Decimal - 1.1; - Assert.Equal(0m, (decimal) r, 12); + Assert.Equal(0m, (decimal) r, 10); r -= 2; - Assert.Equal(-2m, (decimal) r, 12); + Assert.Equal(-2m, (decimal) r, 10); r = d.Decimal - 1.1d; - Assert.Equal(0m, (decimal) r, 12); + Assert.Equal(0m, (decimal) r, 10); r -= 2; - Assert.Equal(-2m, (decimal) r, 12); + Assert.Equal(-2m, (decimal) r, 10); r = d.Decimal - null; Assert.Null(r.Value); @@ -494,9 +499,9 @@ public void JValueAddition() Assert.Null(r.Value); r = d.BigInteger - 1.1d; - Assert.Equal(99m, (decimal) r); + Assert.Equal(99m, (decimal) r, 10); r -= 2; - Assert.Equal(97m, (decimal) r); + Assert.Equal(97m, (decimal) r, 10); #endregion @@ -513,9 +518,9 @@ public void JValueAddition() Assert.Equal(2.2d, (double) r); r = d.Integer * 1.1d; - Assert.Equal(1.1m, (decimal) r, 12); + Assert.Equal(1.1m, (decimal) r, 10); r *= 2; - Assert.Equal(2.2m, (decimal) r, 12); + Assert.Equal(2.2m, (decimal) r, 10); r = d.Integer * null; Assert.Null(r.Value); @@ -533,9 +538,9 @@ public void JValueAddition() Assert.Equal(2.42d, (double) r, 0.00001); r = d.Float * 1.1d; - Assert.Equal(1.21m, (decimal) r, 12); + Assert.Equal(1.21m, (decimal) r, 10); r *= 2; - Assert.Equal(2.42m, (decimal) r, 12); + Assert.Equal(2.42m, (decimal) r, 10); r = d.Float * null; Assert.Null(r.Value); @@ -543,19 +548,19 @@ public void JValueAddition() Assert.Null(r.Value); r = d.Decimal * 1; - Assert.Equal(1.1m, (decimal) r); + Assert.Equal(1.1m, (decimal) r, 10); r *= 2; - Assert.Equal(2.2m, (decimal) r); + Assert.Equal(2.2m, (decimal) r, 10); r = d.Decimal * 1.1; - Assert.Equal(1.21m, (decimal) r, 12); + Assert.Equal(1.21m, (decimal) r, 10); r *= 2; - Assert.Equal(2.42m, (decimal) r, 12); + Assert.Equal(2.42m, (decimal) r, 10); r = d.Decimal * 1.1d; - Assert.Equal(1.21m, (decimal) r, 12); + Assert.Equal(1.21m, (decimal) r, 10); r *= 2; - Assert.Equal(2.42m, (decimal) r, 12); + Assert.Equal(2.42m, (decimal) r, 10); r = d.Decimal * null; Assert.Null(r.Value); @@ -563,9 +568,9 @@ public void JValueAddition() Assert.Null(r.Value); r = d.BigInteger * 1.1d; - Assert.Equal(100m, (decimal) r); + Assert.Equal(100m, (decimal) r, 10); r *= 2; - Assert.Equal(200m, (decimal) r); + Assert.Equal(200m, (decimal) r, 10); r = d.BigInteger * null; Assert.Null(r.Value); @@ -587,9 +592,9 @@ public void JValueAddition() Assert.Equal(0.454545454545455d, (double) r, 0.00001); r = d.Integer / 1.1d; - Assert.Equal(0.909090909090909m, (decimal) r, 12); + Assert.Equal(0.909090909090909m, (decimal) r, 10); r /= 2; - Assert.Equal(0.454545454545454m, (decimal) r, 12); + Assert.Equal(0.454545454545454m, (decimal) r, 10); r = d.Integer / null; Assert.Null(r.Value); @@ -607,9 +612,9 @@ public void JValueAddition() Assert.Equal(0.5d, (double) r, 0.00001); r = d.Float / 1.1d; - Assert.Equal(1m, (decimal) r); + Assert.Equal(1m, (decimal) r, 10); r /= 2; - Assert.Equal(0.5m, (decimal) r); + Assert.Equal(0.5m, (decimal) r, 10); r = d.Float / null; Assert.Null(r.Value); @@ -617,19 +622,19 @@ public void JValueAddition() Assert.Null(r.Value); r = d.Decimal / 1; - Assert.Equal(1.1m, (decimal) r); + Assert.Equal(1.1m, (decimal) r, 10); r /= 2; - Assert.Equal(0.55m, (decimal) r); + Assert.Equal(0.55m, (decimal) r, 10); r = d.Decimal / 1.1; - Assert.Equal(1m, (decimal) r, 12); + Assert.Equal(1m, (decimal) r, 10); r /= 2; - Assert.Equal(0.5m, (decimal) r, 12); + Assert.Equal(0.5m, (decimal) r, 10); r = d.Decimal / 1.1d; - Assert.Equal(1m, (decimal) r, 12); + Assert.Equal(1m, (decimal) r, 10); r /= 2; - Assert.Equal(0.5m, (decimal) r, 12); + Assert.Equal(0.5m, (decimal) r, 10); r = d.Decimal / null; Assert.Null(r.Value); @@ -637,9 +642,9 @@ public void JValueAddition() Assert.Null(r.Value); r = d.BigInteger / 1.1d; - Assert.Equal(100m, (decimal) r); + Assert.Equal(100m, (decimal) r, 10); r /= 2; - Assert.Equal(50m, (decimal) r); + Assert.Equal(50m, (decimal) r, 10); r = d.BigInteger / null; Assert.Null(r.Value); @@ -729,6 +734,8 @@ public void JValueConvert() AssertValueConverted(99.9m); AssertValueConverted(99.9m); AssertValueConverted(1m); + // Convert.ToDecimal is what Argon uses for this, and it is correctly rounded from .NET 11 + // on: 1.1f converts to 1.10000002384185791015625 there and to 1.1 before that AssertValueConverted(1.1f, Convert.ToDecimal(1.1f)); AssertValueConverted("1.1", 1.1m); AssertValueConverted(99.9); diff --git a/src/ArgonTests/Linq/JsonPath/JPathExecuteTests.cs b/src/ArgonTests/Linq/JsonPath/JPathExecuteTests.cs index bb58c9ee..5b8a4a21 100644 --- a/src/ArgonTests/Linq/JsonPath/JPathExecuteTests.cs +++ b/src/ArgonTests/Linq/JsonPath/JPathExecuteTests.cs @@ -1333,7 +1333,9 @@ public void Example() Assert.Equal(2, firstProductNames.Count); Assert.Null(firstProductNames[0]); Assert.Equal("Headlight Fluid", firstProductNames[1]); - Assert.Equal(149.95m, totalPrice, 2); + // the prices are read from JSON as doubles, and converting a double to decimal is + // correctly rounded from .NET 11 on, so the sum carries the binary expansion of 99.95 + Assert.Equal(149.95m, Math.Round(totalPrice, 2)); } [Fact] diff --git a/src/ArgonTests/Linq/JsonPath/JPathParseTests.cs b/src/ArgonTests/Linq/JsonPath/JPathParseTests.cs index 581d81d7..b6e2181a 100644 --- a/src/ArgonTests/Linq/JsonPath/JPathParseTests.cs +++ b/src/ArgonTests/Linq/JsonPath/JPathParseTests.cs @@ -276,6 +276,37 @@ public void SinglePropertyAndFilterWithDoubleEscape() Assert.Equal("h\\i", (string) (JToken) expressions.Right); } + [Fact] + public void SinglePropertyAndFilterWithEscapesAroundText() + { + // the quoted string is sliced out of the expression until the first escape, and the + // text between escapes is copied in as each one is resolved + var path = new JPath(@"Blah[ ?( @.name=='one\ttwo\\three\nfour' ) ]"); + Assert.Equal(2, path.Filters.Count); + Assert.Equal("Blah", ((FieldFilter) path.Filters[0]).Name); + var expressions = (BooleanQueryExpression) ((QueryFilter) path.Filters[1]).Expression; + Assert.Equal(QueryOperator.Equals, expressions.Operator); + Assert.Equal("one\ttwo\\three\nfour", (string) (JToken) expressions.Right); + } + + [Fact] + public void SinglePropertyAndFilterWithEscapeAtEnd() + { + var path = new JPath(@"Blah[ ?( @.name=='hi\n' ) ]"); + Assert.Equal(2, path.Filters.Count); + var expressions = (BooleanQueryExpression) ((QueryFilter) path.Filters[1]).Expression; + Assert.Equal("hi\n", (string) (JToken) expressions.Right); + } + + [Fact] + public void SinglePropertyAndFilterWithEmptyString() + { + var path = new JPath("Blah[ ?( @.name=='' ) ]"); + Assert.Equal(2, path.Filters.Count); + var expressions = (BooleanQueryExpression) ((QueryFilter) path.Filters[1]).Expression; + Assert.Equal("", (string) (JToken) expressions.Right); + } + [Fact] public void SinglePropertyAndFilterWithRegexAndOptions() { @@ -795,4 +826,4 @@ public void AndImmediatelyFollowedByOr() [Fact] public void RegexOperatorWithoutSlashesThrowsAtParseTime() => Assert.Throws(() => new JPath("$[?(@.name =~ 'abc')]")); -} \ No newline at end of file +} diff --git a/src/ArgonTests/Serialization/SerializationEventTests.cs b/src/ArgonTests/Serialization/SerializationEventTests.cs index b63b9641..2407e0f5 100644 --- a/src/ArgonTests/Serialization/SerializationEventTests.cs +++ b/src/ArgonTests/Serialization/SerializationEventTests.cs @@ -185,7 +185,9 @@ public void ListEvents() 1.1m, 2.222222222m, int.MaxValue, - (decimal) Math.PI + // the value Convert.ToDecimal(Math.PI) produced before .NET 11 made that conversion + // correctly rounded; written out so the serialized output is the same on every runtime + 3.14159265358979m }; Assert.Equal(11, obj.Member1); @@ -204,7 +206,7 @@ public void ListEvents() 1.1, 2.222222222, 2147483647.0, - 3.1415926535897931159979634685 + 3.14159265358979 ] """, json); @@ -230,7 +232,8 @@ public void DictionaryEvents() {1.1m, "first"}, {2.222222222m, "second"}, {int.MaxValue, "third"}, - {(decimal) Math.PI, "fourth"} + // see ListEvents: a literal keeps the serialized key stable across runtimes + {3.14159265358979m, "fourth"} }; Assert.Equal(11, obj.Member1); @@ -247,7 +250,7 @@ public void DictionaryEvents() "1.1": "first", "2.222222222": "second", "2147483647": "third", - "3.1415926535897931159979634685": "fourth", + "3.14159265358979": "fourth", "79228162514264337593543950335": "Inserted on serializing" } """, diff --git a/src/Benchmark.Tests/Program.cs b/src/Benchmark.Tests/Program.cs index 49f1fb93..f190bb62 100644 --- a/src/Benchmark.Tests/Program.cs +++ b/src/Benchmark.Tests/Program.cs @@ -30,7 +30,21 @@ public static void Main(string[] args) typeof(NameTableAddBenchmark), typeof(EscapeToStringBenchmark), typeof(EnumWriteBenchmark), - typeof(JsonPathRegexBenchmark) + typeof(JsonPathRegexBenchmark), + typeof(SpanPropertyNameBenchmark), + typeof(NameTableGetBenchmark), + typeof(ConverterLookupBenchmark), + typeof(TypeNameWriteBenchmark), + typeof(WideCreatorBenchmark), + typeof(PopulateExistingBenchmark), + typeof(JTokenHotPathBenchmark), + typeof(DictionaryKeyBenchmark), + typeof(EscapeFreeStringBenchmark), + typeof(TypeNameReadBenchmark), + typeof(ReadValueBenchmark), + typeof(JTokenPropertyWriteBenchmark), + typeof(JsonPathFilterBenchmark), + typeof(NumberScanBenchmark) ]); if (args.Length == 0) { diff --git a/src/Directory.Build.props b/src/Directory.Build.props index af5e4425..4a608f47 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;CS1573;NU1605;NU1608;NU1109 preview - 0.35.2 + 0.36.0 1.0.0 Copyright © James Newton-King 2008, Copyright © Simon Cropp 2022 Json