diff --git a/src/Argon.DataSets/DataTableConverter.cs b/src/Argon.DataSets/DataTableConverter.cs
index c2344d88..215644d4 100644
--- a/src/Argon.DataSets/DataTableConverter.cs
+++ b/src/Argon.DataSets/DataTableConverter.cs
@@ -18,14 +18,25 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s
var table = (DataTable) value;
var resolver = serializer.ContractResolver as DefaultContractResolver;
+ // resolve column names once per table, not once per cell
+ var columnCount = table.Columns.Count;
+ var columns = new DataColumn[columnCount];
+ var resolvedColumnNames = new string[columnCount];
+ for (var i = 0; i < columnCount; i++)
+ {
+ var column = table.Columns[i];
+ columns[i] = column;
+ resolvedColumnNames[i] = resolver?.GetResolvedPropertyName(column.ColumnName) ?? column.ColumnName;
+ }
+
writer.WriteStartArray();
foreach (DataRow row in table.Rows)
{
writer.WriteStartObject();
- foreach (DataColumn column in row.Table.Columns)
+ for (var i = 0; i < columnCount; i++)
{
- var columnValue = row[column];
+ var columnValue = row[columns[i]];
if (serializer.NullValueHandling == NullValueHandling.Ignore &&
columnValue == DBNull.Value)
@@ -33,7 +44,7 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s
continue;
}
- writer.WritePropertyName(resolver?.GetResolvedPropertyName(column.ColumnName) ?? column.ColumnName);
+ writer.WritePropertyName(resolvedColumnNames[i]);
serializer.Serialize(writer, columnValue);
}
diff --git a/src/Argon.FSharp/DiscriminatedUnionConverter.cs b/src/Argon.FSharp/DiscriminatedUnionConverter.cs
index 5f54911c..4e72b993 100644
--- a/src/Argon.FSharp/DiscriminatedUnionConverter.cs
+++ b/src/Argon.FSharp/DiscriminatedUnionConverter.cs
@@ -165,6 +165,10 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s
///
/// true if this instance can convert the specified object type; otherwise, false.
///
+ // memoized: CanConvert runs for every value (de)serialized once this converter is
+ // registered, and FSharpType.IsUnion does attribute reflection on each call
+ static readonly ThreadSafeStore isUnionCache = new(_ => FSharpType.IsUnion(_, null));
+
public override bool CanConvert(Type type) =>
- FSharpType.IsUnion(type, null);
+ isUnionCache.Get(type);
}
\ No newline at end of file
diff --git a/src/Argon.FSharp/FSharpListConverter.cs b/src/Argon.FSharp/FSharpListConverter.cs
index 01fcf847..e0fa0b07 100644
--- a/src/Argon.FSharp/FSharpListConverter.cs
+++ b/src/Argon.FSharp/FSharpListConverter.cs
@@ -9,6 +9,15 @@ public class FSharpListConverter :
{
static MethodInfo readList = typeof(FSharpListConverter).GetMethod("ReadList")!;
+ // cached closed delegates: MakeGenericMethod + Invoke per call allocates and wraps
+ // exceptions in TargetInvocationException
+ static ThreadSafeStore> readListCache = new(CreateReadListDelegate);
+
+ static Func CreateReadListDelegate(Type genericArgument) =>
+ (Func) Delegate.CreateDelegate(
+ typeof(Func),
+ readList.MakeGenericMethod(genericArgument));
+
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
writer.WriteStartArray();
@@ -27,13 +36,7 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s
}
var genericArgument = type.GetGenericArguments()[0];
- return readList.MakeGenericMethod(genericArgument)
- .Invoke(
- null,
- [
- reader,
- serializer
- ]);
+ return readListCache.Get(genericArgument)(reader, serializer);
}
public static FSharpList ReadList(JsonReader reader, JsonSerializer serializer)
diff --git a/src/Argon.FSharp/FSharpMapConverter.cs b/src/Argon.FSharp/FSharpMapConverter.cs
index 1b0a1413..5cf57848 100644
--- a/src/Argon.FSharp/FSharpMapConverter.cs
+++ b/src/Argon.FSharp/FSharpMapConverter.cs
@@ -7,17 +7,28 @@
public class FSharpMapConverter :
JsonConverter
{
- static MethodInfo writeMap = typeof(FSharpMapConverter).GetMethod("WriteMap")!;
+ // cached closed delegates: MakeGenericMethod + Invoke per call allocates and wraps
+ // exceptions in TargetInvocationException
+ static ThreadSafeStore> writeMapCache = new(CreateWriteMapDelegate);
- public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
+ static Action CreateWriteMapDelegate(Type mapType)
{
- var genericArguments = value.GetType().GetGenericArguments();
- writeMap.MakeGenericMethod(genericArguments[0], genericArguments[1])
- .Invoke(
- null,
- [writer, value, serializer]);
+ var arguments = mapType.GetGenericArguments();
+ var method = typeof(FSharpMapConverter)
+ .GetMethod(nameof(WriteMapBoxed), BindingFlags.NonPublic | BindingFlags.Static)!
+ .MakeGenericMethod(arguments[0], arguments[1]);
+ return (Action) Delegate.CreateDelegate(
+ typeof(Action),
+ method);
}
+ static void WriteMapBoxed(JsonWriter writer, object value, JsonSerializer serializer)
+ where T : notnull =>
+ WriteMap(writer, (FSharpMap) value, serializer);
+
+ public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) =>
+ writeMapCache.Get(value.GetType())(writer, value, serializer);
+
public static void WriteMap(JsonWriter writer, FSharpMap value, JsonSerializer serializer)
where T : notnull =>
serializer.Serialize(writer, value.ToDictionary(_ => _.Key, _ => _.Value));
@@ -29,15 +40,21 @@ public static void WriteMap(JsonWriter writer, FSharpMap value, Json
return null;
}
- var arguments = type.GetGenericArguments();
- return readMap.MakeGenericMethod(arguments[0], arguments[1])
- .Invoke(
- null,
- [reader, serializer]);
+ return readMapCache.Get(type)(reader, serializer);
}
static MethodInfo readMap = typeof(FSharpMapConverter).GetMethod("ReadMap")!;
+ static ThreadSafeStore> readMapCache = new(CreateReadMapDelegate);
+
+ static Func CreateReadMapDelegate(Type mapType)
+ {
+ var arguments = mapType.GetGenericArguments();
+ return (Func) Delegate.CreateDelegate(
+ typeof(Func),
+ readMap.MakeGenericMethod(arguments[0], arguments[1]));
+ }
+
public static FSharpMap ReadMap(JsonReader reader, JsonSerializer serializer)
where T : notnull
{
diff --git a/src/Argon.JsonPath/BooleanQueryExpression.cs b/src/Argon.JsonPath/BooleanQueryExpression.cs
index 30d46473..2c75306a 100644
--- a/src/Argon.JsonPath/BooleanQueryExpression.cs
+++ b/src/Argon.JsonPath/BooleanQueryExpression.cs
@@ -6,13 +6,13 @@ class BooleanQueryExpression(QueryOperator @operator, object left, object? right
public readonly object Left = left;
public readonly object? Right = right;
- static IEnumerable GetResult(JToken root, JToken t, object? o)
- {
- if (o is JToken resultToken)
- {
- return [resultToken];
- }
+ // constant operands never change, so the single-element wrappers IsMatch needs
+ // are built once instead of per candidate token
+ readonly JToken[]? leftConstant = left is JToken leftToken ? [leftToken] : null;
+ readonly JToken[]? rightConstant = right is JToken rightToken ? [rightToken] : null;
+ static IEnumerable GetFilterResult(JToken root, JToken t, object? o)
+ {
if (o is List pathFilters)
{
return JPath.Evaluate(pathFilters, root, t, JTokenExtensions.DefaultSettings);
@@ -25,13 +25,35 @@ public override bool IsMatch(JToken root, JToken t, JsonSelectSettings settings)
{
if (Operator == QueryOperator.Exists)
{
- return GetResult(root, t, Left).Any();
+ return leftConstant != null ||
+ GetFilterResult(root, t, Left).Any();
+ }
+
+ // single constant right operand is the dominant filter shape (e.g. @.a == 1):
+ // compare directly without materializing result collections
+ if (rightConstant != null)
+ {
+ var rightResult = rightConstant[0];
+ if (leftConstant != null)
+ {
+ return MatchTokens(leftConstant[0], rightResult, settings);
+ }
+
+ foreach (var leftResult in GetFilterResult(root, t, Left))
+ {
+ if (MatchTokens(leftResult, rightResult, settings))
+ {
+ return true;
+ }
+ }
+
+ return false;
}
- using var leftResults = GetResult(root, t, Left).GetEnumerator();
+ using var leftResults = (leftConstant ?? GetFilterResult(root, t, Left)).GetEnumerator();
if (leftResults.MoveNext())
{
- var rightResultsEn = GetResult(root, t, Right);
+ var rightResultsEn = GetFilterResult(root, t, Right);
var rightResults = rightResultsEn as ICollection ?? rightResultsEn.ToList();
do
diff --git a/src/Argon.JsonPath/JTokenExtensions.cs b/src/Argon.JsonPath/JTokenExtensions.cs
index 717bfa19..ecdcc733 100644
--- a/src/Argon.JsonPath/JTokenExtensions.cs
+++ b/src/Argon.JsonPath/JTokenExtensions.cs
@@ -1,8 +1,34 @@
-///
+using System.Collections.Concurrent;
+
+///
/// Extensions to .
///
public static class JTokenExtensions
{
+ // A parsed JPath is immutable and evaluation holds no per-query state, so parsing
+ // (per-segment substrings + filter tree) can be paid once per distinct expression.
+ // The wholesale clear keeps the cache bounded for pathological dynamic paths.
+ const int pathCacheLimit = 512;
+ static readonly ConcurrentDictionary pathCache = new(StringComparer.Ordinal);
+
+ static JPath ParsePath(string path)
+ {
+ if (pathCache.TryGetValue(path, out var jPath))
+ {
+ return jPath;
+ }
+
+ jPath = new(path);
+
+ if (pathCache.Count >= pathCacheLimit)
+ {
+ pathCache.Clear();
+ }
+
+ pathCache[path] = jPath;
+ return jPath;
+ }
+
///
/// Selects a using a JSONPath expression. Selects the token that matches the object path.
///
@@ -37,7 +63,7 @@ public static class JTokenExtensions
/// A .
public static JToken? SelectToken(this JToken token, string path, JsonSelectSettings? settings)
{
- var jPath = new JPath(path);
+ var jPath = ParsePath(path);
settings ??= DefaultSettings;
JToken? result = null;
@@ -95,7 +121,7 @@ public static IEnumerable SelectTokens(this JToken token, string path, J
{
settings ??= DefaultSettings;
- var p = new JPath(path);
+ var p = ParsePath(path);
return p.Evaluate(token, token, settings);
}
}
\ No newline at end of file
diff --git a/src/Argon/JsonSerializer.cs b/src/Argon/JsonSerializer.cs
index 2c6cd41b..3f943a34 100644
--- a/src/Argon/JsonSerializer.cs
+++ b/src/Argon/JsonSerializer.cs
@@ -685,8 +685,11 @@ internal IReferenceResolver GetReferenceResolver() =>
{
if (converters != null)
{
- foreach (var converter in converters)
+ // indexed loop: foreach over IList boxes the List enumerator, and this
+ // runs for every value serialized/deserialized
+ for (var i = 0; i < converters.Count; i++)
{
+ var converter = converters[i];
if (converter.CanConvert(type))
{
return converter;
diff --git a/src/Argon/JsonTextReader.cs b/src/Argon/JsonTextReader.cs
index 2844cea8..ca832f68 100644
--- a/src/Argon/JsonTextReader.cs
+++ b/src/Argon/JsonTextReader.cs
@@ -109,6 +109,7 @@ void ParseReadString(char quote, ReadType readType)
break;
case ReadType.ReadAsInt32:
case ReadType.ReadAsDecimal:
+ case ReadType.ReadAsDouble:
case ReadType.ReadAsBoolean:
// caller will convert result
break;
@@ -647,7 +648,8 @@ JsonReaderException CreateUnexpectedCharacterException(char c) =>
break;
case '"':
case '\'':
- ParseString(currentChar, ReadType.Read);
+ // ReadAsBoolean skips the intermediate string token; the span is re-parsed below
+ ParseString(currentChar, ReadType.ReadAsBoolean);
return ReadBooleanString(stringReference.AsSpan());
case 'n':
HandleNull();
@@ -1724,7 +1726,7 @@ void ParseReadNumber(ReadType readType, char firstChar, int initialPosition)
if (singleDigit)
{
// digit char values start at 48
- numberValue = (decimal) firstChar - 48;
+ numberValue = BoxedPrimitives.Get((decimal) firstChar - 48);
}
else
{
@@ -1747,13 +1749,11 @@ void ParseReadNumber(ReadType readType, char firstChar, int initialPosition)
if (singleDigit)
{
// digit char values start at 48
- numberValue = (double) firstChar - 48;
+ numberValue = BoxedPrimitives.Get((double) firstChar - 48);
}
else
{
- var number = stringReference.ToString();
-
- if (double.TryParse(number, NumberStyles.Float, InvariantCulture, out var value))
+ if (double.TryParse(stringReference.AsSpan(), NumberStyles.Float, InvariantCulture, out var value))
{
numberValue = BoxedPrimitives.Get(value);
}
@@ -1772,7 +1772,7 @@ void ParseReadNumber(ReadType readType, char firstChar, int initialPosition)
if (singleDigit)
{
// digit char values start at 48
- numberValue = (long) firstChar - 48;
+ numberValue = BoxedPrimitives.Get((long) firstChar - 48);
numberType = JsonToken.Integer;
}
else
@@ -1811,9 +1811,7 @@ void ParseReadNumber(ReadType readType, char firstChar, int initialPosition)
}
else
{
- var number = stringReference.ToString();
-
- if (double.TryParse(number, NumberStyles.Float, InvariantCulture, out var d))
+ if (double.TryParse(stringReference.AsSpan(), NumberStyles.Float, InvariantCulture, out var d))
{
numberValue = BoxedPrimitives.Get(d);
}
diff --git a/src/Argon/JsonTextWriter.cs b/src/Argon/JsonTextWriter.cs
index d982c0bc..85feffa8 100644
--- a/src/Argon/JsonTextWriter.cs
+++ b/src/Argon/JsonTextWriter.cs
@@ -136,8 +136,16 @@ protected override void WriteEnd(JsonToken token)
///
/// Writes the property name of a name/value pair on a JSON object.
///
- public override void WritePropertyName(string name) =>
- WritePropertyName(name.AsSpan());
+ public override void WritePropertyName(string name)
+ {
+ // keep the original string: routing through the span overload would
+ // re-materialize it in InternalWritePropertyName for position tracking
+ InternalWritePropertyName(name);
+
+ WriteEscapedString(name.AsSpan(), QuoteName);
+
+ writer.Write(':');
+ }
///
/// Writes the property name of a name/value pair on a JSON object.
@@ -155,8 +163,31 @@ public override void WritePropertyName(CharSpan name)
/// Writes the property name of a name/value pair on a JSON object.
///
/// A flag to indicate whether the text should be escaped when it is written as a JSON property name.
- public override void WritePropertyName(string name, bool escape) =>
- WritePropertyName(name.AsSpan(), escape);
+ public override void WritePropertyName(string name, bool escape)
+ {
+ InternalWritePropertyName(name);
+
+ if (escape)
+ {
+ WriteEscapedString(name.AsSpan(), QuoteName);
+ }
+ else
+ {
+ if (QuoteName)
+ {
+ writer.Write(quoteChar);
+ }
+
+ writer.Write(name);
+
+ if (QuoteName)
+ {
+ writer.Write(quoteChar);
+ }
+ }
+
+ writer.Write(':');
+ }
///
/// Writes the property name of a name/value pair on a JSON object.
@@ -382,7 +413,7 @@ public override void WriteValue(ulong value)
public override void WriteValue(float value)
{
InternalWriteValue(JsonToken.Float);
- WriteValueInternal(JsonConvert.ToString(value, FloatFormatHandling, QuoteChar, false, FloatFormat));
+ WriteFloatValue(value, false);
}
///
@@ -397,7 +428,7 @@ public override void WriteValue(float? value)
else
{
InternalWriteValue(JsonToken.Float);
- WriteValueInternal(JsonConvert.ToString(value.GetValueOrDefault(), FloatFormatHandling, QuoteChar, true, FloatFormat));
+ WriteFloatValue(value.GetValueOrDefault(), true);
}
}
@@ -407,7 +438,7 @@ public override void WriteValue(float? value)
public override void WriteValue(double value)
{
InternalWriteValue(JsonToken.Float);
- WriteValueInternal(JsonConvert.ToString(value, FloatFormatHandling, QuoteChar, false, FloatFormat));
+ WriteDoubleValue(value, false);
}
///
@@ -422,7 +453,7 @@ public override void WriteValue(double? value)
else
{
InternalWriteValue(JsonToken.Float);
- WriteValueInternal(JsonConvert.ToString(value.GetValueOrDefault(), FloatFormatHandling, QuoteChar, true, FloatFormat));
+ WriteDoubleValue(value.GetValueOrDefault(), true);
}
}
@@ -486,7 +517,24 @@ public override void WriteValue(sbyte value)
public override void WriteValue(decimal value)
{
InternalWriteValue(JsonToken.Float);
- WriteValueInternal(JsonConvert.ToString(value));
+
+ EnsureBuffer();
+ var buffer = writeBuffer!;
+ // reserve two chars so the decimal place can always be appended in-buffer
+ if (value.TryFormat(buffer.AsSpan(0, buffer.Length - 2), out var written, default, InvariantCulture))
+ {
+ if (buffer.AsSpan(0, written).IndexOf('.') == -1)
+ {
+ buffer[written++] = '.';
+ buffer[written++] = '0';
+ }
+
+ writer.Write(buffer, 0, written);
+ }
+ else
+ {
+ WriteValueInternal(JsonConvert.ToString(value));
+ }
}
///
@@ -585,19 +633,23 @@ public override void WriteValue(Guid value)
{
InternalWriteValue(JsonToken.String);
- var text = value.ToString("D", InvariantCulture);
-
+ // 36 chars for the "D" format plus the quotes
+ var buffer = EnsureBuffer(38, 0);
+ var pos = 0;
if (QuoteValue)
{
- writer.Write(quoteChar);
+ buffer[pos++] = quoteChar;
}
- writer.Write(text);
+ value.TryFormat(buffer.AsSpan(pos), out var written);
+ pos += written;
if (QuoteValue)
{
- writer.Write(quoteChar);
+ buffer[pos++] = quoteChar;
}
+
+ writer.Write(buffer, 0, pos);
}
///
@@ -607,19 +659,24 @@ public override void WriteValue(TimeSpan value)
{
InternalWriteValue(JsonToken.String);
- var text = value.ToString(null, InvariantCulture);
-
+ // constant "c" format is at most 26 chars plus the quotes
+ EnsureBuffer();
+ var buffer = writeBuffer!;
+ var pos = 0;
if (QuoteValue)
{
- writer.Write(quoteChar);
+ buffer[pos++] = quoteChar;
}
- writer.Write(text);
+ value.TryFormat(buffer.AsSpan(pos), out var written, "c");
+ pos += written;
if (QuoteValue)
{
- writer.Write(quoteChar);
+ buffer[pos++] = quoteChar;
}
+
+ writer.Write(buffer, 0, pos);
}
///
@@ -666,6 +723,61 @@ void EnsureBuffer() =>
// maximum buffer sized used when writing iso date
writeBuffer ??= BufferUtils.RentBuffer(35);
+ void WriteDoubleValue(double value, bool nullable)
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value))
+ {
+ WriteValueInternal(JsonConvert.ToString(value, FloatFormatHandling, QuoteChar, nullable, FloatFormat));
+ return;
+ }
+
+ EnsureBuffer();
+ var buffer = writeBuffer!;
+ // reserve two chars so the decimal place can always be appended in-buffer;
+ // a custom FloatPrecision format that overflows falls back to the string path
+ if (value.TryFormat(buffer.AsSpan(0, buffer.Length - 2), out var written, FloatFormat, InvariantCulture))
+ {
+ written = EnsureDecimalPlace(buffer, written);
+ writer.Write(buffer, 0, written);
+ }
+ else
+ {
+ WriteValueInternal(JsonConvert.ToString(value, FloatFormatHandling, QuoteChar, nullable, FloatFormat));
+ }
+ }
+
+ void WriteFloatValue(float value, bool nullable)
+ {
+ if (float.IsNaN(value) || float.IsInfinity(value))
+ {
+ WriteValueInternal(JsonConvert.ToString(value, FloatFormatHandling, QuoteChar, nullable, FloatFormat));
+ return;
+ }
+
+ EnsureBuffer();
+ var buffer = writeBuffer!;
+ if (value.TryFormat(buffer.AsSpan(0, buffer.Length - 2), out var written, FloatFormat, InvariantCulture))
+ {
+ written = EnsureDecimalPlace(buffer, written);
+ writer.Write(buffer, 0, written);
+ }
+ else
+ {
+ WriteValueInternal(JsonConvert.ToString(value, FloatFormatHandling, QuoteChar, nullable, FloatFormat));
+ }
+ }
+
+ static int EnsureDecimalPlace(char[] buffer, int written)
+ {
+ if (buffer.AsSpan(0, written).IndexOfAny('.', 'E', 'e') == -1)
+ {
+ buffer[written++] = '.';
+ buffer[written++] = '0';
+ }
+
+ return written;
+ }
+
internal char[] EnsureBuffer(int length, int copyTo)
{
if (length < 35)
diff --git a/src/Argon/Linq/JContainer.cs b/src/Argon/Linq/JContainer.cs
index 51ef1055..575ba6a2 100644
--- a/src/Argon/Linq/JContainer.cs
+++ b/src/Argon/Linq/JContainer.cs
@@ -23,11 +23,12 @@ internal JContainer()
internal JContainer(JContainer other) :
this()
{
- var i = 0;
- foreach (var child in other)
+ // indexed loop: enumerating the container goes through IList and
+ // boxes an enumerator per cloned container
+ var children = other.ChildrenTokens;
+ for (var i = 0; i < children.Count; i++)
{
- TryAddInternal(i, child, false);
- i++;
+ TryAddInternal(i, children[i], false);
}
SetLineInfo(this, null);
@@ -130,16 +131,31 @@ IEnumerable GetDescendants(bool self)
yield return this;
}
- foreach (var o in ChildrenTokens)
+ // pre-order pointer walk over the sibling/parent links: a single iterator for
+ // the whole traversal instead of nested iterators per container depth
+ var current = First;
+ while (current != null)
{
- yield return o;
- if (o is JContainer c)
+ yield return current;
+
+ if (current is JContainer {HasValues: true} container)
{
- foreach (var d in c.Descendants())
- {
- yield return d;
- }
+ current = container.First;
+ continue;
+ }
+
+ // climb out of finished containers
+ while (current != null && current != this && current == current.Parent!.Last)
+ {
+ current = current.Parent;
+ }
+
+ if (current == null || current == this)
+ {
+ yield break;
}
+
+ current = current.Next;
}
}
@@ -527,7 +543,22 @@ void ReadContentFrom(JsonReader r, JsonLoadSettings? settings)
case JsonToken.Date:
case JsonToken.Boolean:
case JsonToken.Bytes:
- var v = new JValue(r.Value);
+ var value = r.Value;
+ // for the typical value shapes the token type already determines the
+ // JTokenType, skipping the type-test ladder in the JValue(object) ctor;
+ // unusual shapes (custom readers) keep the general path
+ var knownType = r.TokenType switch
+ {
+ JsonToken.String when value is string => JTokenType.String,
+ JsonToken.Integer when value is long or int or ulong or BigInteger => JTokenType.Integer,
+ JsonToken.Float when value is double or decimal or float => JTokenType.Float,
+ JsonToken.Date when value is DateTime or DateTimeOffset => JTokenType.Date,
+ JsonToken.Boolean when value is bool => JTokenType.Boolean,
+ _ => JTokenType.None
+ };
+ var v = knownType == JTokenType.None
+ ? new JValue(value)
+ : new JValue(value, knownType);
v.SetLineInfo(lineInfo, settings);
parent.Add(v);
break;
@@ -564,22 +595,17 @@ static JProperty ReadProperty(JsonReader reader, JsonLoadSettings? settings, IJs
{
var parentObject = (JObject) parent;
var propertyName = (string) reader.GetValue();
- var existingPropertyWithName = parentObject.PropertyOrNull(propertyName);
- if (existingPropertyWithName != null)
- {
- throw JsonReaderException.Create(reader, $"Property with the name '{propertyName}' already exists in the current JSON object.");
- }
-
var property = new JProperty(propertyName);
property.SetLineInfo(lineInfo, settings);
- // handle multiple properties with the same name in JSON
- if (existingPropertyWithName == null)
+ try
{
- parent.Add(property);
+ // JObject.ValidateToken rejects duplicate names, so a separate
+ // pre-check would hash the name a second time on every property
+ parentObject.Add(property);
}
- else
+ catch (ArgumentException)
{
- existingPropertyWithName.Replace(property);
+ throw JsonReaderException.Create(reader, $"Property with the name '{propertyName}' already exists in the current JSON object.");
}
return property;
diff --git a/src/Argon/Linq/JObject.cs b/src/Argon/Linq/JObject.cs
index b8e10524..9c36f4a2 100644
--- a/src/Argon/Linq/JObject.cs
+++ b/src/Argon/Linq/JObject.cs
@@ -353,7 +353,7 @@ public override void WriteTo(JsonWriter writer, params IList conv
{
writer.WriteStartObject();
- foreach (var property in properties)
+ foreach (var property in properties.InnerList)
{
property.WriteTo(writer, converters);
}
@@ -535,8 +535,9 @@ internal override int GetDeepHashCode() =>
///
public IEnumerator> GetEnumerator()
{
- foreach (JProperty property in properties)
+ foreach (var token in properties.InnerList)
{
+ var property = (JProperty) token;
yield return new(property.Name, property.Value);
}
}
diff --git a/src/Argon/Linq/JPropertyKeyedCollection.cs b/src/Argon/Linq/JPropertyKeyedCollection.cs
index 763eff74..0ef18060 100644
--- a/src/Argon/Linq/JPropertyKeyedCollection.cs
+++ b/src/Argon/Linq/JPropertyKeyedCollection.cs
@@ -129,6 +129,10 @@ public ICollection Values
}
}
+ // Same backing list Collection wraps, exposed so hot loops can foreach
+ // without boxing the enumerator through the IList interface dispatch.
+ internal List InnerList => (List) Items;
+
public int IndexOfReference(JToken t) =>
((List) Items).IndexOfReference(t);
diff --git a/src/Argon/Linq/JToken.cs b/src/Argon/Linq/JToken.cs
index 66f3d6a5..8e561724 100644
--- a/src/Argon/Linq/JToken.cs
+++ b/src/Argon/Linq/JToken.cs
@@ -419,7 +419,10 @@ public void Replace(JToken value)
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "WriteTo without converters is safe.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "WriteTo without converters is safe.")]
public void WriteTo(JsonWriter writer) =>
- WriteTo(writer, []);
+ // a collection expression here would allocate a fresh list per call
+ WriteTo(writer, emptyConverters);
+
+ static readonly JsonConverter[] emptyConverters = [];
///
/// Writes this token to a .
diff --git a/src/Argon/Serialization/JsonObjectContract.cs b/src/Argon/Serialization/JsonObjectContract.cs
index 623702a4..1f4167d0 100644
--- a/src/Argon/Serialization/JsonObjectContract.cs
+++ b/src/Argon/Serialization/JsonObjectContract.cs
@@ -79,6 +79,47 @@ internal bool HasRequiredOrDefaultValueProperties
}
}
+ List? presenceRelevantProperties;
+ List? nonIgnoredProperties;
+
+ // Presence tracking only matters for properties EndProcessProperty can act on: required
+ // (directly, or for every property via the ItemRequired / serializer-global Populate
+ // fallbacks) or default-value populated. Ignored properties are excluded - the end
+ // sweep is a no-op for them.
+ internal List GetPresenceRelevantProperties(bool globalPopulate)
+ {
+ if (globalPopulate ||
+ ItemRequired.GetValueOrDefault(Required.Default) != Required.Default)
+ {
+ return nonIgnoredProperties ??= BuildPropertyList(requireRelevance: false);
+ }
+
+ return presenceRelevantProperties ??= BuildPropertyList(requireRelevance: true);
+ }
+
+ List BuildPropertyList(bool requireRelevance)
+ {
+ var list = new List();
+ foreach (var property in Properties.List)
+ {
+ if (property.Ignored)
+ {
+ continue;
+ }
+
+ if (requireRelevance &&
+ property.Required == Required.Default &&
+ (property.DefaultValueHandling & DefaultValueHandling.Populate) != DefaultValueHandling.Populate)
+ {
+ continue;
+ }
+
+ list.Add(property);
+ }
+
+ return list;
+ }
+
///
/// Initializes a new instance of the class.
///
diff --git a/src/Argon/Serialization/JsonPropertyCollection.cs b/src/Argon/Serialization/JsonPropertyCollection.cs
index f4b8effc..d9e349f4 100644
--- a/src/Argon/Serialization/JsonPropertyCollection.cs
+++ b/src/Argon/Serialization/JsonPropertyCollection.cs
@@ -25,6 +25,10 @@ public JsonPropertyCollection(Type type)
list = (List) Items;
}
+ // Same backing list, exposed so hot serializer loops can foreach without
+ // boxing the enumerator through Collection's interface dispatch.
+ internal List List => list;
+
internal void AddRange(IList collection)
{
foreach (var value in collection)
diff --git a/src/Argon/Serialization/JsonSerializerInternalReader.cs b/src/Argon/Serialization/JsonSerializerInternalReader.cs
index 0c65af08..9828c610 100644
--- a/src/Argon/Serialization/JsonSerializerInternalReader.cs
+++ b/src/Argon/Serialization/JsonSerializerInternalReader.cs
@@ -1663,19 +1663,27 @@ object CreateObjectUsingCreatorWithParameters(JsonReader reader, JsonObjectContr
var propertyContexts = ResolvePropertyAndCreatorValues(contract, containerProperty, reader, contract.UnderlyingType);
if (trackPresence)
{
- foreach (var property in contract.Properties)
+ // hash-set membership instead of scanning the context list per property
+ var seenProperties = new HashSet();
+ foreach (var context in propertyContexts)
{
- if (!property.Ignored)
+ if (context.Property != null)
{
- if (propertyContexts.All(_ => _.Property != property))
- {
- propertyContexts.Add(
- new()
- {
- Property = property,
- Presence = PropertyPresence.None
- });
- }
+ seenProperties.Add(context.Property);
+ }
+ }
+
+ foreach (var property in contract.Properties.List)
+ {
+ if (!property.Ignored &&
+ !seenProperties.Contains(property))
+ {
+ propertyContexts.Add(
+ new()
+ {
+ Property = property,
+ Presence = PropertyPresence.None
+ });
}
}
}
@@ -2009,11 +2017,19 @@ object PopulateObject(object newObject, JsonReader reader, JsonObjectContract co
{
OnDeserializing(reader, newObject);
- // only need to keep a track of properties' presence if they are required or a value should be defaulted if missing
- var propertiesPresence = contract.HasRequiredOrDefaultValueProperties ||
- HasFlag(Serializer.DefaultValueHandling, DefaultValueHandling.Populate)
- ? contract.Properties.ToDictionary(_ => _, _ => PropertyPresence.None)
- : null;
+ // only need to keep a track of properties' presence if they are required or a value should be defaulted if missing,
+ // and only for the properties EndProcessProperty can act on
+ var globalPopulate = HasFlag(Serializer.DefaultValueHandling, DefaultValueHandling.Populate);
+ Dictionary? propertiesPresence = null;
+ if (contract.HasRequiredOrDefaultValueProperties || globalPopulate)
+ {
+ var relevantProperties = contract.GetPresenceRelevantProperties(globalPopulate);
+ propertiesPresence = new(relevantProperties.Count);
+ foreach (var property in relevantProperties)
+ {
+ propertiesPresence[property] = PropertyPresence.None;
+ }
+ }
if (id != null)
{
@@ -2211,7 +2227,9 @@ Required.AllowNull or
static void SetPropertyPresence(JsonReader reader, JsonProperty property, Dictionary? requiredProperties)
{
- if (requiredProperties == null)
+ // the dictionary only tracks presence-relevant properties; do not add others back
+ if (requiredProperties == null ||
+ !requiredProperties.ContainsKey(property))
{
return;
}
diff --git a/src/Argon/Serialization/JsonSerializerInternalWriter.cs b/src/Argon/Serialization/JsonSerializerInternalWriter.cs
index f5aef7f7..d719b071 100644
--- a/src/Argon/Serialization/JsonSerializerInternalWriter.cs
+++ b/src/Argon/Serialization/JsonSerializerInternalWriter.cs
@@ -390,7 +390,7 @@ void SerializeObject(JsonWriter writer, object value, JsonObjectContract contrac
var initialDepth = writer.Top;
- foreach (var property in contract.Properties)
+ foreach (var property in contract.Properties.List)
{
try
{
@@ -755,7 +755,7 @@ void SerializeDynamic(JsonWriter writer, IDynamicMetaObjectProvider value, JsonD
var initialDepth = writer.Top;
- foreach (var property in contract.Properties)
+ foreach (var property in contract.Properties.List)
{
// only write non-dynamic properties that have an explicit attribute
if (property.HasMemberAttribute)
@@ -902,9 +902,18 @@ void SerializeDictionary(JsonWriter writer, IDictionary values, JsonDictionaryCo
static IEnumerable Items(IDictionary values)
{
- foreach (DictionaryEntry entry in values)
+ // manual IDictionaryEnumerator use: foreach boxes a DictionaryEntry per item
+ var e = values.GetEnumerator();
+ try
+ {
+ while (e.MoveNext())
+ {
+ yield return e.Entry;
+ }
+ }
+ finally
{
- yield return entry;
+ (e as IDisposable)?.Dispose();
}
}
@@ -927,9 +936,19 @@ static IEnumerable Items(IDictionary values)
}
else
{
- foreach (DictionaryEntry entry in values)
+ // manual IDictionaryEnumerator use: foreach boxes a DictionaryEntry per item
+ var e = values.GetEnumerator();
+ try
+ {
+ while (e.MoveNext())
+ {
+ var entry = e.Entry;
+ SerializeDictionaryItem(writer, contract, member, entry.Key, entry.Value, keyContract, underlying);
+ }
+ }
+ finally
{
- SerializeDictionaryItem(writer, contract, member, entry.Key, entry.Value, keyContract, underlying);
+ (e as IDisposable)?.Dispose();
}
}
diff --git a/src/Argon/Serialization/JsonTypeReflector.cs b/src/Argon/Serialization/JsonTypeReflector.cs
index 6c1e625c..2c82e425 100644
--- a/src/Argon/Serialization/JsonTypeReflector.cs
+++ b/src/Argon/Serialization/JsonTypeReflector.cs
@@ -19,32 +19,47 @@ private static class CreatorCache
internal static readonly ThreadSafeStore Instance = new(GetCreator);
}
- [RequiresUnreferencedCode("Generic TypeConverters may require the generic types to be annotated. For example, NullableConverter requires the underlying type to be DynamicallyAccessedMembers All.")]
- public static bool TryGetStringConverter(
- [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type type,
- [NotNullWhen(true)] out TypeConverter? typeConverter)
+ [RequiresUnreferencedCode(MiscellaneousUtils.TrimWarning)]
+ static class StringConverterCache
{
- typeConverter = TypeDescriptor.GetConverter(type);
-
- // use the type's TypeConverter can convert to a string
- var converterType = typeConverter.GetType();
+ // TypeDescriptor.GetConverter costs ~340 ns and runs per value written for
+ // string-contract types (Uri etc.); the answer for a type never changes within
+ // the contract cache's lifetime, so cache it (null = cannot convert to string).
+ internal static readonly ThreadSafeStore Instance = new(Create);
- var converterName = converterType.FullName;
- if (converterType == typeof(TypeConverter) ||
- string.Equals(converterName, "System.ComponentModel.ComponentConverter", StringComparison.Ordinal) ||
- string.Equals(converterName, "System.ComponentModel.ReferenceConverter", StringComparison.Ordinal) ||
- string.Equals(converterName, "System.Windows.Forms.Design.DataSourceConverter", StringComparison.Ordinal))
+ [RequiresUnreferencedCode("Generic TypeConverters may require the generic types to be annotated. For example, NullableConverter requires the underlying type to be DynamicallyAccessedMembers All.")]
+ static TypeConverter? Create(Type type)
{
- return false;
- }
+ var typeConverter = TypeDescriptor.GetConverter(type);
- if (typeConverter.CanConvertTo(typeof(string)))
- {
- return true;
+ // use the type's TypeConverter can convert to a string
+ var converterType = typeConverter.GetType();
+
+ var converterName = converterType.FullName;
+ if (converterType == typeof(TypeConverter) ||
+ string.Equals(converterName, "System.ComponentModel.ComponentConverter", StringComparison.Ordinal) ||
+ string.Equals(converterName, "System.ComponentModel.ReferenceConverter", StringComparison.Ordinal) ||
+ string.Equals(converterName, "System.Windows.Forms.Design.DataSourceConverter", StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ if (typeConverter.CanConvertTo(typeof(string)))
+ {
+ return typeConverter;
+ }
+
+ return null;
}
+ }
- typeConverter = null;
- return false;
+ [RequiresUnreferencedCode("Generic TypeConverters may require the generic types to be annotated. For example, NullableConverter requires the underlying type to be DynamicallyAccessedMembers All.")]
+ public static bool TryGetStringConverter(
+ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type type,
+ [NotNullWhen(true)] out TypeConverter? typeConverter)
+ {
+ typeConverter = StringConverterCache.Instance.Get(type);
+ return typeConverter != null;
}
[RequiresUnreferencedCode(MiscellaneousUtils.TrimWarning)]
diff --git a/src/Argon/Utilities/ConvertUtils.cs b/src/Argon/Utilities/ConvertUtils.cs
index 361c6c2d..e0b2e183 100644
--- a/src/Argon/Utilities/ConvertUtils.cs
+++ b/src/Argon/Utilities/ConvertUtils.cs
@@ -531,6 +531,23 @@ public static bool IsInteger(object value) =>
_ => false
};
+ // Overload for when the type code is already known - the object overload would box the
+ // enum and re-derive its own type code through reflection. Includes the nullable codes:
+ // contracts for nullable types carry them (a boxed value never does).
+ public static bool IsInteger(PrimitiveTypeCode typeCode) =>
+ typeCode switch
+ {
+ PrimitiveTypeCode.SByte or PrimitiveTypeCode.SByteNullable or
+ PrimitiveTypeCode.Byte or PrimitiveTypeCode.ByteNullable or
+ PrimitiveTypeCode.Int16 or PrimitiveTypeCode.Int16Nullable or
+ PrimitiveTypeCode.UInt16 or PrimitiveTypeCode.UInt16Nullable or
+ PrimitiveTypeCode.Int32 or PrimitiveTypeCode.Int32Nullable or
+ PrimitiveTypeCode.UInt32 or PrimitiveTypeCode.UInt32Nullable or
+ PrimitiveTypeCode.Int64 or PrimitiveTypeCode.Int64Nullable or
+ PrimitiveTypeCode.UInt64 or PrimitiveTypeCode.UInt64Nullable => true,
+ _ => false
+ };
+
public static ParseResult Int32TryParse(char[] chars, int start, int length, out int value)
{
value = 0;
diff --git a/src/Argon/Utilities/DateTimeUtils.cs b/src/Argon/Utilities/DateTimeUtils.cs
index 85bda8ef..e0e60888 100644
--- a/src/Argon/Utilities/DateTimeUtils.cs
+++ b/src/Argon/Utilities/DateTimeUtils.cs
@@ -118,12 +118,11 @@ internal static bool TryParseDateTimeOffset(string s, out DateTimeOffset dt)
{
if (s.Length is >= 19 and <= 40 && char.IsDigit(s[0]) && s[10] == 'T')
{
+ // TryParseExact fully validates and produces the same result the custom ISO
+ // parser would; re-parsing (and the ToCharArray copy) was pure overhead.
if (DateTimeOffset.TryParseExact(s, IsoDateFormat, InvariantCulture, DateTimeStyles.RoundtripKind, out dt))
{
- if (TryParseDateTimeOffsetIso(new(s.ToCharArray(), 0, s.Length), out dt))
- {
- return true;
- }
+ return true;
}
}
}
diff --git a/src/Argon/Utilities/DictionaryWrapper.cs b/src/Argon/Utilities/DictionaryWrapper.cs
index f99d843a..8aa764b5 100644
--- a/src/Argon/Utilities/DictionaryWrapper.cs
+++ b/src/Argon/Utilities/DictionaryWrapper.cs
@@ -446,11 +446,11 @@ object IDictionary.this[object key]
readonly struct DictionaryEnumerator(IEnumerator> e)
: IDictionaryEnumerator
{
- public DictionaryEntry Entry => (DictionaryEntry) Current;
+ public DictionaryEntry Entry => new(e.Current.Key, e.Current.Value);
- public object Key => Entry.Key;
+ public object Key => e.Current.Key;
- public object Value => Entry.Value;
+ public object Value => e.Current.Value;
public object Current => new DictionaryEntry(e.Current.Key, e.Current.Value);
diff --git a/src/Argon/Utilities/EnumUtils.cs b/src/Argon/Utilities/EnumUtils.cs
index 52055dc1..3631f506 100644
--- a/src/Argon/Utilities/EnumUtils.cs
+++ b/src/Argon/Utilities/EnumUtils.cs
@@ -7,12 +7,16 @@ static class EnumUtils
const char EnumSeparatorChar = ',';
const string EnumSeparatorString = ", ";
- static readonly ThreadSafeStore, EnumInfo> ValuesAndNamesPerEnum = new(InitializeValuesAndNames);
+ // struct key: a Tuple class key would allocate on every cache probe,
+ // i.e. once per enum value read or written
+ readonly record struct EnumKey(Type EnumType, NamingStrategy? NamingStrategy);
+
+ static readonly ThreadSafeStore ValuesAndNamesPerEnum = new(InitializeValuesAndNames);
[UnconditionalSuppressMessage("TrimAnalysis", "IL2075", Justification = "Enum fields are not trimmed")]
- static EnumInfo InitializeValuesAndNames(Tuple key)
+ static EnumInfo InitializeValuesAndNames(EnumKey key)
{
- var enumType = key.Item1;
+ var enumType = key.EnumType;
var names = Enum.GetNames(enumType);
var resolvedNames = new string[names.Length];
var values = new ulong[names.Length];
@@ -35,9 +39,9 @@ static EnumInfo InitializeValuesAndNames(Tuple key)
throw new InvalidOperationException($"Enum name '{resolvedName}' already exists on enum '{enumType.Name}'.");
}
- resolvedNames[i] = key.Item2 == null
+ resolvedNames[i] = key.NamingStrategy == null
? resolvedName
- : key.Item2.GetPropertyName(resolvedName, hasSpecifiedName);
+ : key.NamingStrategy.GetPropertyName(resolvedName, hasSpecifiedName);
}
var isFlags = enumType.IsDefined(typeof(FlagsAttribute), false);
diff --git a/src/Argon/Utilities/JavaScriptUtils.cs b/src/Argon/Utilities/JavaScriptUtils.cs
index 02cf2bec..15152fd6 100644
--- a/src/Argon/Utilities/JavaScriptUtils.cs
+++ b/src/Argon/Utilities/JavaScriptUtils.cs
@@ -36,10 +36,61 @@ static JavaScriptUtils()
{
htmlEscapeFlags[escapeChar] = true;
}
+
+#if NET8_0_OR_GREATER
+ // must run after the flag arrays are populated (field initializers would run first)
+ singleQuoteSearchValues = BuildSearchValues(SingleQuoteEscapeFlags);
+ doubleQuoteSearchValues = BuildSearchValues(DoubleQuoteEscapeFlags);
+ htmlSearchValues = BuildSearchValues(htmlEscapeFlags);
+#endif
}
const string escapedUnicodeText = "!";
+#if NET8_0_OR_GREATER
+ static readonly SearchValues singleQuoteSearchValues;
+ static readonly SearchValues doubleQuoteSearchValues;
+ static readonly SearchValues htmlSearchValues;
+
+ static SearchValues BuildSearchValues(bool[] flags)
+ {
+ var chars = new List();
+ for (var i = 0; i < flags.Length; i++)
+ {
+ if (flags[i])
+ {
+ chars.Add((char) i);
+ }
+ }
+
+ // high chars FirstCharToEscape treats as escapes outside the flag range
+ chars.Add('\u0085');
+ chars.Add('\u2028');
+ chars.Add('\u2029');
+ return SearchValues.Create(chars.ToArray());
+ }
+
+ static SearchValues? TryGetSearchValues(bool[] escapeFlags)
+ {
+ if (ReferenceEquals(escapeFlags, DoubleQuoteEscapeFlags))
+ {
+ return doubleQuoteSearchValues;
+ }
+
+ if (ReferenceEquals(escapeFlags, SingleQuoteEscapeFlags))
+ {
+ return singleQuoteSearchValues;
+ }
+
+ if (ReferenceEquals(escapeFlags, htmlEscapeFlags))
+ {
+ return htmlSearchValues;
+ }
+
+ return null;
+ }
+#endif
+
public static bool[] GetCharEscapeFlags(EscapeHandling escapeHandling, char quoteChar)
{
if (escapeHandling == EscapeHandling.None)
@@ -115,17 +166,10 @@ static void WriteEscapedJavaScriptNonNullString(TextWriter writer, CharSpan valu
if (lastWritePosition != 0)
{
- if (buffer == null || buffer.Length < lastWritePosition)
- {
- buffer = BufferUtils.EnsureBufferSize(lastWritePosition, buffer);
- }
-
// write unchanged chars at start of text.
- value.Slice(0, lastWritePosition).CopyTo(buffer);
- writer.Write(buffer, 0, lastWritePosition);
+ writer.Write(value.Slice(0, lastWritePosition));
}
- int length;
for (var i = lastWritePosition; i < value.Length; i++)
{
var c = value[i];
@@ -213,32 +257,8 @@ static void WriteEscapedJavaScriptNonNullString(TextWriter writer, CharSpan valu
if (i > lastWritePosition)
{
- length = i - lastWritePosition + (isEscapedUnicodeText ? unicodeTextLength : 0);
- var start = isEscapedUnicodeText ? unicodeTextLength : 0;
-
- if (buffer == null || buffer.Length < length)
- {
- var newBuffer = BufferUtils.RentBuffer(length);
-
- // the unicode text is already in the buffer
- // copy it over when creating new buffer
- if (isEscapedUnicodeText)
- {
- MiscellaneousUtils.Assert(buffer != null, "Write buffer should never be null because it is set when the escaped unicode text is encountered.");
-
- Array.Copy(buffer, newBuffer, unicodeTextLength);
- }
-
- BufferUtils.ReturnBuffer(buffer);
-
- buffer = newBuffer;
- }
-
- var count = length - start;
- value.Slice(lastWritePosition, count).CopyTo(buffer.AsSpan(start: start, length: count));
-
// write unchanged chars before writing escaped text
- writer.Write(buffer, start, count);
+ writer.Write(value.Slice(lastWritePosition, i - lastWritePosition));
}
lastWritePosition = i + 1;
@@ -253,18 +273,10 @@ static void WriteEscapedJavaScriptNonNullString(TextWriter writer, CharSpan valu
}
MiscellaneousUtils.Assert(lastWritePosition != 0);
- length = value.Length - lastWritePosition;
- if (length > 0)
+ if (value.Length - lastWritePosition > 0)
{
- if (buffer == null || buffer.Length < length)
- {
- buffer = BufferUtils.EnsureBufferSize(length, buffer);
- }
-
- value.Slice(lastWritePosition, length).CopyTo(buffer);
-
// write remaining text
- writer.Write(buffer, 0, length);
+ writer.Write(value.Slice(lastWritePosition));
}
}
@@ -280,6 +292,20 @@ public static string ToEscapedJavaScriptString(CharSpan value, char delimiter, b
static int FirstCharToEscape(CharSpan value, bool[] escapeFlags, EscapeHandling escapeHandling)
{
+#if NET8_0_OR_GREATER
+ // vectorized scan for the fixed escape sets; the scalar loop stays for short
+ // strings (vector setup costs more than it saves there) and for EscapeNonAscii,
+ // where every non-ascii char is an escape target
+ if (value.Length >= 16 &&
+ escapeHandling != EscapeHandling.EscapeNonAscii)
+ {
+ var searchValues = TryGetSearchValues(escapeFlags);
+ if (searchValues != null)
+ {
+ return value.IndexOfAny(searchValues);
+ }
+ }
+#endif
for (var i = 0; i != value.Length; i++)
{
var c = value[i];
@@ -309,4 +335,4 @@ static int FirstCharToEscape(CharSpan value, bool[] escapeFlags, EscapeHandling
return -1;
}
-}
\ No newline at end of file
+}
diff --git a/src/Argon/Utilities/StringUtils.cs b/src/Argon/Utilities/StringUtils.cs
index ca091777..3cd86775 100644
--- a/src/Argon/Utilities/StringUtils.cs
+++ b/src/Argon/Utilities/StringUtils.cs
@@ -31,34 +31,45 @@ public static void ToCharAsUnicode(char c, char[] buffer)
public static JsonProperty? ForgivingCaseSensitiveFind(this JsonPropertyCollection source, string testValue)
{
- if (source.Count == 0)
+ // single allocation-free pass; runs per unmatched member for every object
+ // deserialized through a parameterized constructor
+ JsonProperty? caseInsensitiveMatch = null;
+ var caseInsensitiveCount = 0;
+ JsonProperty? caseSensitiveMatch = null;
+ var caseSensitiveCount = 0;
+
+ foreach (var property in source.List)
{
- return null;
- }
+ if (!string.Equals(property.PropertyName, testValue, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
- var caseInsensitiveResults = source.Where(_ => string.Equals(_.PropertyName, testValue, StringComparison.OrdinalIgnoreCase))
- .ToArray();
- if (caseInsensitiveResults.Length == 0)
- {
- return null;
- }
+ caseInsensitiveCount++;
+ caseInsensitiveMatch ??= property;
- if (caseInsensitiveResults.Length == 1)
- {
- return caseInsensitiveResults[0];
+ if (string.Equals(property.PropertyName, testValue, StringComparison.Ordinal))
+ {
+ caseSensitiveCount++;
+ caseSensitiveMatch ??= property;
+ }
}
- // multiple results returned. now filter using case sensitivity
- var caseSensitiveResults = source.Where(_ => string.Equals(_.PropertyName, testValue, StringComparison.Ordinal))
- .ToArray();
- if (caseSensitiveResults.Length == 0)
+ switch (caseInsensitiveCount)
{
- return null;
+ case 0:
+ return null;
+ case 1:
+ return caseInsensitiveMatch;
}
- if (caseSensitiveResults.Length == 1)
+ // multiple case-insensitive results. now filter using case sensitivity
+ switch (caseSensitiveCount)
{
- return caseSensitiveResults[0];
+ case 0:
+ return null;
+ case 1:
+ return caseSensitiveMatch;
}
throw new("Multiple matches found for testValue");
@@ -85,6 +96,24 @@ static string ToSeparatedCase(string s, char separator)
return s;
}
+ // no upper-case char and no space means the transform is the identity:
+ // lower chars, digits and separators are copied through verbatim.
+ // Common for dictionary keys that are already snake/kebab cased.
+ var needsTransform = false;
+ foreach (var ch in s)
+ {
+ if (ch == ' ' || char.IsUpper(ch))
+ {
+ needsTransform = true;
+ break;
+ }
+ }
+
+ if (!needsTransform)
+ {
+ return s;
+ }
+
var stringBuilder = new StringBuilder(s.Length + s.Length / 4);
var state = SeparatedCaseState.Start;
diff --git a/src/ArgonTests/Benchmarks/CreatorDeserializeBenchmark.cs b/src/ArgonTests/Benchmarks/CreatorDeserializeBenchmark.cs
new file mode 100644
index 00000000..092451d7
--- /dev/null
+++ b/src/ArgonTests/Benchmarks/CreatorDeserializeBenchmark.cs
@@ -0,0 +1,56 @@
+// 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.
+
+using BenchmarkDotNet.Attributes;
+
+[MemoryDiagnoser]
+public class CreatorDeserializeBenchmark
+{
+ const string creatorJson = """{"a":1,"b":2,"c":3,"extra1":4,"extra2":5,"extra3":6,"extra4":7,"extra5":8}""";
+ string requiredJson;
+
+ [GlobalSetup]
+ public void Setup() =>
+ requiredJson = JsonConvert.SerializeObject(new RequiredHolder());
+
+ [Benchmark]
+ public CreatorRecord DeserializeThroughCreator() =>
+ JsonConvert.DeserializeObject(creatorJson);
+
+ [Benchmark]
+ public RequiredHolder DeserializeWithRequiredProperty() =>
+ JsonConvert.DeserializeObject(requiredJson);
+
+ public class CreatorRecord
+ {
+ public CreatorRecord(int a, int b)
+ {
+ A = a;
+ B = b;
+ }
+
+ public int A { get; }
+ public int B { get; }
+ public int C { get; set; }
+ public int Extra1 { get; set; }
+ public int Extra2 { get; set; }
+ public int Extra3 { get; set; }
+ public int Extra4 { get; set; }
+ public int Extra5 { get; set; }
+ }
+
+ public class RequiredHolder
+ {
+ [JsonRequired]
+ public int A { get; set; } = 1;
+
+ public int B { get; set; } = 2;
+ public int C { get; set; } = 3;
+ public int D { get; set; } = 4;
+ public int E { get; set; } = 5;
+ public int F { get; set; } = 6;
+ public int G { get; set; } = 7;
+ public int H { get; set; } = 8;
+ }
+}
diff --git a/src/ArgonTests/Benchmarks/LinqBenchmarks.cs b/src/ArgonTests/Benchmarks/LinqBenchmarks.cs
new file mode 100644
index 00000000..eb3cf851
--- /dev/null
+++ b/src/ArgonTests/Benchmarks/LinqBenchmarks.cs
@@ -0,0 +1,81 @@
+// 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.
+
+using BenchmarkDotNet.Attributes;
+
+[MemoryDiagnoser]
+public class LinqBenchmarks
+{
+ JObject deepDocument;
+ string parseJson;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ deepDocument = BuildDeepDocument();
+ parseJson = deepDocument.ToString(Formatting.None);
+ }
+
+ [Benchmark]
+ public int Descendants()
+ {
+ var count = 0;
+ foreach (var _ in deepDocument.Descendants())
+ {
+ count++;
+ }
+
+ return count;
+ }
+
+ [Benchmark]
+ public JToken DeepClone() =>
+ deepDocument.DeepClone();
+
+ [Benchmark]
+ public string WriteTo()
+ {
+ var stringWriter = new StringWriter();
+ using (var writer = new JsonTextWriter(stringWriter))
+ {
+ deepDocument.WriteTo(writer);
+ }
+
+ return stringWriter.ToString();
+ }
+
+ [Benchmark]
+ public JObject Parse() =>
+ JObject.Parse(parseJson);
+
+ [Benchmark]
+ public JToken SelectTokenRepeatedPath() =>
+ deepDocument.SelectToken("$.child1.child2.items[2].name");
+
+ [Benchmark]
+ public List FilterQuery() =>
+ deepDocument.SelectTokens("$..[?(@.name=='name3')]").ToList();
+
+ static JObject BuildDeepDocument()
+ {
+ JObject Leafs(int start) => new(
+ Enumerable.Range(start, 5).Select(i => new JProperty($"p{i}", i)));
+
+ var items = new JArray(Enumerable.Range(0, 8).Select(i => new JObject(
+ new JProperty("name", $"name{i}"),
+ new JProperty("value", i * 1.5),
+ new JProperty("flags", new JArray(i, i + 1, i + 2)),
+ new JProperty("leaf", Leafs(i)))));
+
+ return new(
+ new JProperty("child1", new JObject(
+ new JProperty("child2", new JObject(
+ new JProperty("items", items),
+ new JProperty("meta", Leafs(100)))),
+ new JProperty("side", new JArray(Enumerable.Range(0, 20))))),
+ new JProperty("top", new JArray(Enumerable.Range(0, 10).Select(i => new JObject(
+ new JProperty("k", $"key{i}"),
+ new JProperty("v", Leafs(i * 10)))))));
+ }
+}
diff --git a/src/ArgonTests/Benchmarks/ReaderBenchmarks.cs b/src/ArgonTests/Benchmarks/ReaderBenchmarks.cs
new file mode 100644
index 00000000..70079762
--- /dev/null
+++ b/src/ArgonTests/Benchmarks/ReaderBenchmarks.cs
@@ -0,0 +1,72 @@
+// 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.
+
+using BenchmarkDotNet.Attributes;
+
+[MemoryDiagnoser]
+public class ReaderBenchmarks
+{
+ string singleDigitJson;
+ string floatJson;
+ string quotedDoubleJson;
+ string dateOffsetJson;
+ string stringHeavyJson;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ singleDigitJson = "[" + string.Join(",", Enumerable.Range(0, 200).Select(i => i % 9)) + "]";
+ floatJson = "[" + string.Join(",", Enumerable.Range(0, 200).Select(i => $"{i}.5")) + "]";
+ quotedDoubleJson = "[" + string.Join(",", Enumerable.Range(0, 200).Select(i => $"\"{i}.25\"")) + "]";
+ dateOffsetJson = "[" + string.Join(",", Enumerable.Range(0, 100).Select(i => $"\"2024-0{i % 9 + 1}-15T10:20:30+02:00\"")) + "]";
+ stringHeavyJson = "[" + string.Join(",", Enumerable.Range(0, 50).Select(i => $"\"a perfectly clean string with no escapable characters at all number {i} padded padded padded\"")) + "]";
+ }
+
+ [Benchmark]
+ public void ReadSingleDigitIntegers()
+ {
+ var reader = new JsonTextReader(new StringReader(singleDigitJson));
+ while (reader.Read())
+ {
+ }
+ }
+
+ [Benchmark]
+ public void ReadFloats()
+ {
+ var reader = new JsonTextReader(new StringReader(floatJson));
+ while (reader.Read())
+ {
+ }
+ }
+
+ [Benchmark]
+ public void ReadQuotedAsDouble()
+ {
+ var reader = new JsonTextReader(new StringReader(quotedDoubleJson));
+ reader.Read();
+ while (reader.ReadAsDouble() != null)
+ {
+ }
+ }
+
+ [Benchmark]
+ public void ReadDateTimeOffsets()
+ {
+ var reader = new JsonTextReader(new StringReader(dateOffsetJson));
+ reader.Read();
+ while (reader.ReadAsDateTimeOffset() != null)
+ {
+ }
+ }
+
+ [Benchmark]
+ public void ReadStringHeavy()
+ {
+ var reader = new JsonTextReader(new StringReader(stringHeavyJson));
+ while (reader.Read())
+ {
+ }
+ }
+}
diff --git a/src/ArgonTests/Benchmarks/SatelliteBenchmarks.cs b/src/ArgonTests/Benchmarks/SatelliteBenchmarks.cs
new file mode 100644
index 00000000..380454e4
--- /dev/null
+++ b/src/ArgonTests/Benchmarks/SatelliteBenchmarks.cs
@@ -0,0 +1,78 @@
+// 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.
+
+using System.Data;
+using BenchmarkDotNet.Attributes;
+using Microsoft.FSharp.Collections;
+
+[MemoryDiagnoser]
+public class SatelliteBenchmarks
+{
+ DataTable table;
+ JsonSerializerSettings dataTableSettings;
+ string fsharpListJson;
+ JsonConverter[] fsharpConverters;
+ JsonSerializerSettings fsharpTaxSettings;
+ List pocoList;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ table = new();
+ for (var c = 0; c < 10; c++)
+ {
+ table.Columns.Add($"ColumnName{c}", typeof(int));
+ }
+
+ for (var r = 0; r < 500; r++)
+ {
+ table.Rows.Add(Enumerable.Range(0, 10).Cast