From 2e8832f500e08837ea969c3b5f2f3593d4009b50 Mon Sep 17 00:00:00 2001 From: Maxwell Weru Date: Wed, 5 Jun 2024 15:00:10 +0300 Subject: [PATCH] Implement `IParsable` (#268) `IParsable` is useful for generic parsing. This PR adds support for it on `ByteSize`, `Duration`, `Etag`, `Ksuid`, `Money`, and `SwiftCode`. Consequently: 1. Creating `Etag` from string has been marked as obsolete in favour of parsing. 2. Parsing of `Money` requires more explict currency specification where the symbol is used by multiple currencies, such as `$` which is used by 9 currencies. `Money.Parse(...)` will throw but `Money.TryParse(...)` will return false. --- .../Serializers/EtagBsonSerializer.cs | 2 +- src/Tingle.Extensions.Primitives/ByteSize.cs | 88 +++++--- .../Converters/EtagJsonConverter.cs | 2 +- src/Tingle.Extensions.Primitives/Duration.cs | 65 ++++-- src/Tingle.Extensions.Primitives/Etag.cs | 84 +++++++- .../NumberAbbreviationExtensions.cs | 24 +-- src/Tingle.Extensions.Primitives/Ksuid.cs | 110 ++++++---- src/Tingle.Extensions.Primitives/Money.cs | 200 ++++++++---------- .../SequenceNumber.cs | 2 +- src/Tingle.Extensions.Primitives/SwiftCode.cs | 89 ++++++-- .../EtagTests.cs | 8 +- .../MoneyTests.cs | 7 - 12 files changed, 429 insertions(+), 252 deletions(-) diff --git a/src/Tingle.Extensions.MongoDB/Serialization/Serializers/EtagBsonSerializer.cs b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/EtagBsonSerializer.cs index 2faad3c..f9c7f5f 100644 --- a/src/Tingle.Extensions.MongoDB/Serialization/Serializers/EtagBsonSerializer.cs +++ b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/EtagBsonSerializer.cs @@ -56,7 +56,7 @@ public override Etag Deserialize(BsonDeserializationContext context, BsonDeseria var bsonType = context.Reader.CurrentBsonType; return bsonType switch { - BsonType.String => new Etag(_stringSerializer.Deserialize(context)), + BsonType.String => Etag.Parse(_stringSerializer.Deserialize(context)), BsonType.Int64 => new Etag((ulong)_int64Serializer.Deserialize(context)), BsonType.Binary => new Etag(_byteArraySerializer.Deserialize(context)), _ => throw CreateCannotDeserializeFromBsonTypeException(bsonType), diff --git a/src/Tingle.Extensions.Primitives/ByteSize.cs b/src/Tingle.Extensions.Primitives/ByteSize.cs index 12e8167..b50eff8 100644 --- a/src/Tingle.Extensions.Primitives/ByteSize.cs +++ b/src/Tingle.Extensions.Primitives/ByteSize.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text.Json.Serialization; using Tingle.Extensions.Primitives.Converters; @@ -11,7 +12,7 @@ namespace Tingle.Extensions.Primitives; /// Number of bytes. [JsonConverter(typeof(ByteSizeJsonConverter))] [TypeConverter(typeof(ByteSizeTypeConverter))] -public readonly struct ByteSize(long bytes) : IEquatable, IComparable, IConvertible, IFormattable +public readonly struct ByteSize(long bytes) : IEquatable, IComparable, IConvertible, IFormattable, IParsable { #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member public static readonly ByteSize MinValue = FromBytes(long.MinValue); @@ -186,27 +187,27 @@ internal double LargestWholeNumberDecimalValue public string ToString(string? format) => ToString(format, CultureInfo.CurrentCulture); /// - public string ToString(string? format, IFormatProvider? formatProvider) => ToString(format, formatProvider, useBinaryByte: false); + public string ToString(string? format, IFormatProvider? provider) => ToString(format, provider, useBinaryByte: false); /// Formats the value of the current instance in binary format. /// The value of the current instance in the specified format. public string ToBinaryString() => ToString("0.##", CultureInfo.CurrentCulture, useBinaryByte: true); /// Formats the value of the current instance in binary format. - /// + /// /// The provider to use to format the value. -or- A null reference (Nothing in Visual /// Basic) to obtain the numeric format information from the current locale setting /// of the operating system. /// /// The value of the current instance in the specified format. - public string ToBinaryString(IFormatProvider? formatProvider) => ToString("0.##", formatProvider, useBinaryByte: true); + public string ToBinaryString(IFormatProvider? provider) => ToString("0.##", provider, useBinaryByte: true); /// Formats the value of the current instance using the specified format. /// /// The format to use. -or- A null reference (Nothing in Visual Basic) to use the /// default format defined for the type of the System.IFormattable implementation. /// - /// + /// /// The provider to use to format the value. -or- A null reference (Nothing in Visual /// Basic) to obtain the numeric format information from the current locale setting /// of the operating system. @@ -215,16 +216,16 @@ internal double LargestWholeNumberDecimalValue /// Whether to use binary format /// /// The value of the current instance in the specified format. - public string ToString(string? format, IFormatProvider? formatProvider, bool useBinaryByte) + public string ToString(string? format, IFormatProvider? provider, bool useBinaryByte) { format ??= "0.##"; - formatProvider ??= CultureInfo.CurrentCulture; + provider ??= CultureInfo.CurrentCulture; if (!format.Contains('#') && !format.Contains('0')) format = "0.## " + format; bool has(string s) => format.Contains(s, StringComparison.CurrentCultureIgnoreCase); - string output(double n) => n.ToString(format, formatProvider); + string output(double n) => n.ToString(format, provider); // Binary if (has("PiB")) return output(PebiBytes); @@ -245,8 +246,8 @@ public string ToString(string? format, IFormatProvider? formatProvider, bool use return output(Bytes); return useBinaryByte - ? string.Format("{0} {1}", LargestWholeNumberBinaryValue.ToString(format, formatProvider), LargestWholeNumberBinarySymbol) - : string.Format("{0} {1}", LargestWholeNumberDecimalValue.ToString(format, formatProvider), LargestWholeNumberDecimalSymbol); + ? string.Format("{0} {1}", LargestWholeNumberBinaryValue.ToString(format, provider), LargestWholeNumberBinarySymbol) + : string.Format("{0} {1}", LargestWholeNumberDecimalValue.ToString(format, provider), LargestWholeNumberDecimalSymbol); } #endregion @@ -339,11 +340,11 @@ public string ToString(string? format, IFormatProvider? formatProvider, bool use /// Converts a into a in a specified culture-specific format. /// A string containing the value to convert. - /// An object that supplies culture-specific formatting information about . + /// An object that supplies culture-specific formatting information about . /// A equivalent to the value specified in . /// is null. /// is not in a correct format. - public static ByteSize Parse(string s, IFormatProvider formatProvider) => Parse(s, NumberStyles.Float | NumberStyles.AllowThousands, formatProvider); + public static ByteSize Parse(string s, IFormatProvider? provider) => Parse(s, NumberStyles.Float | NumberStyles.AllowThousands, provider); /// Converts a into a in a specified style and culture-specific format. /// A string containing the value to convert. @@ -351,7 +352,7 @@ public string ToString(string? format, IFormatProvider? formatProvider, bool use /// A bitwise combination of values that indicates the permitted format of . /// A typical value to specify is combined with . /// - /// An object that supplies culture-specific formatting information about . + /// An object that supplies culture-specific formatting information about . /// A equivalent to the value specified in . /// is null. /// @@ -359,7 +360,7 @@ public string ToString(string? format, IFormatProvider? formatProvider, bool use /// -or- style includes the value. /// /// is not in a correct format. - public static ByteSize Parse(string s, NumberStyles style, IFormatProvider formatProvider) + public static ByteSize Parse(string s, NumberStyles style, IFormatProvider? provider) { if (string.IsNullOrWhiteSpace(s)) { @@ -371,7 +372,7 @@ public static ByteSize Parse(string s, NumberStyles style, IFormatProvider forma var found = false; - var numberFormatInfo = NumberFormatInfo.GetInstance(formatProvider); + var numberFormatInfo = NumberFormatInfo.GetInstance(provider); var decimalSeparator = Convert.ToChar(numberFormatInfo.NumberDecimalSeparator); var groupSeparator = Convert.ToChar(numberFormatInfo.NumberGroupSeparator); @@ -397,7 +398,7 @@ public static ByteSize Parse(string s, NumberStyles style, IFormatProvider forma string sizePart = s[lastNumber..].Trim(); // Get the numeric part - if (!double.TryParse(numberPart, style, formatProvider, out var number)) + if (!double.TryParse(numberPart, style, provider, out var number)) throw new FormatException($"No number found in value '{s}'."); // Get the magnitude part @@ -441,18 +442,49 @@ public static ByteSize Parse(string s, NumberStyles style, IFormatProvider forma /// any value originally supplied in result will be overwritten. /// /// if was converted successfully; otherwise, . - public static bool TryParse(string s, out ByteSize result) + public static bool TryParse([NotNullWhen(true)] string? s, out ByteSize result) { + result = default; + if (s is null) return false; + try { result = Parse(s); return true; } - catch + catch { } + return false; + } + + /// + /// Converts the into a in a specified style and culture-specific format. + /// A return value indicates whether the conversion succeeded or failed. + /// + /// A string containing the value to convert. + /// + /// An that supplies culture-specific formatting information about . + /// + /// + /// When this method returns, contains the equivalent of the parameter, + /// if the conversion succeeded, or default if the conversion failed. + /// The conversion fails if the parameter is or , + /// is not in a valid format, or represents a value less than + /// or greater than . This parameter is passed uninitialized; + /// any value originally supplied in result will be overwritten. + /// + /// if was converted successfully; otherwise, . + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out ByteSize result) + { + result = default; + if (s is null) return false; + + try { - result = new ByteSize(); - return false; + result = Parse(s, provider); + return true; } + catch { } + return false; } /// @@ -464,7 +496,7 @@ public static bool TryParse(string s, out ByteSize result) /// A bitwise combination of values that indicates the permitted format of . /// A typical value to specify is combined with . /// - /// + /// /// An that supplies culture-specific formatting information about . /// /// @@ -476,18 +508,18 @@ public static bool TryParse(string s, out ByteSize result) /// any value originally supplied in result will be overwritten. /// /// if was converted successfully; otherwise, . - public static bool TryParse(string s, NumberStyles style, IFormatProvider formatProvider, out ByteSize result) + public static bool TryParse([NotNullWhen(true)] string? s, NumberStyles style, IFormatProvider? provider, out ByteSize result) { + result = default; + if (s is null) return false; + try { - result = Parse(s, style, formatProvider); + result = Parse(s, style, provider); return true; } - catch - { - result = new ByteSize(); - return false; - } + catch { } + return false; } #endregion diff --git a/src/Tingle.Extensions.Primitives/Converters/EtagJsonConverter.cs b/src/Tingle.Extensions.Primitives/Converters/EtagJsonConverter.cs index 46d6b48..26a74dc 100644 --- a/src/Tingle.Extensions.Primitives/Converters/EtagJsonConverter.cs +++ b/src/Tingle.Extensions.Primitives/Converters/EtagJsonConverter.cs @@ -33,7 +33,7 @@ public override Etag Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSer } var str = reader.GetString(); - return new Etag(str!); + return Etag.Parse(str!); } /// diff --git a/src/Tingle.Extensions.Primitives/Duration.cs b/src/Tingle.Extensions.Primitives/Duration.cs index bf52c0b..9fcb40b 100644 --- a/src/Tingle.Extensions.Primitives/Duration.cs +++ b/src/Tingle.Extensions.Primitives/Duration.cs @@ -1,4 +1,6 @@ -using System.ComponentModel; +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text; using System.Text.Json.Serialization; @@ -16,7 +18,7 @@ namespace Tingle.Extensions.Primitives; /// [JsonConverter(typeof(DurationJsonConverter))] [TypeConverter(typeof(DurationTypeConverter))] -public readonly struct Duration : IEquatable, IConvertible +public readonly struct Duration : IEquatable, IConvertible, IParsable { /// Represents the zero value. public static readonly Duration Zero = new(0, 0, 0, 0); @@ -174,39 +176,58 @@ void AppendComponent(uint number, char symbol) #region Parsing /// Converts a in ISO8601 format into a . - /// A string containing the value to convert. - /// A equivalent to the value specified in . - /// is null. - /// is not in a correct format. - public static Duration Parse(string value) + /// A string containing the value to convert. + /// A equivalent to the value specified in . + /// is null. + /// is not in a correct format. + public static Duration Parse(string s) => Parse(s, null); + + /// Converts a in ISO8601 format into a . + /// A string containing the value to convert. + /// An object that supplies culture-specific formatting information about . + /// A equivalent to the value specified in . + /// is null. + /// is not in a correct format. + public static Duration Parse(string s, IFormatProvider? provider) { - if (string.IsNullOrWhiteSpace(value)) + if (string.IsNullOrWhiteSpace(s)) { - throw new ArgumentException($"'{nameof(value)}' cannot be null or whitespace.", nameof(value)); + throw new ArgumentException($"'{nameof(s)}' cannot be null or whitespace.", nameof(s)); } - if (TryParse(value, out var duration)) - return duration; - - throw new FormatException($"'{value}' is not a valid Duration representation."); + if (TryParse(s, out var duration)) return duration; + throw new FormatException($"'{s}' is not a valid Duration representation."); } /// Converts a in ISO8601 format into a . - /// A string containing the value to convert. + /// A string containing the value to convert. + /// + /// When this method returns, contains the value associated parsed, + /// if successful; otherwise, is returned. + /// This parameter is passed uninitialized. + /// + /// + /// if could be parsed; otherwise, false. + /// + public static bool TryParse([NotNullWhen(true)] string? s, out Duration result) => TryParse(s, null, out result); + + /// Converts a in ISO8601 format into a . + /// A string containing the value to convert. + /// An object that supplies culture-specific formatting information about . /// /// When this method returns, contains the value associated parsed, /// if successful; otherwise, is returned. /// This parameter is passed uninitialized. /// /// - /// if could be parsed; otherwise, false. + /// if could be parsed; otherwise, false. /// - public static bool TryParse(string value, out Duration result) + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out Duration result) { result = default; - if (value == null) return false; - if (value.Length < 3) return false; - if (value[0] != DurationChars.Prefix) return false; + if (s == null) return false; + if (s.Length < 3) return false; + if (s[0] != DurationChars.Prefix) return false; uint years = 0, months = 0, weeks = 0, days = 0, hours = 0, minutes = 0, seconds = 0; @@ -215,9 +236,9 @@ public static bool TryParse(string value, out Duration result) int numberStart = -1; var isTimeSpecified = false; - while (position < value.Length) + while (position < s.Length) { - char c = value[position]; + char c = s[position]; if (c == DurationChars.Time) { isTimeSpecified = true; @@ -228,7 +249,7 @@ public static bool TryParse(string value, out Duration result) if (numberStart < 0 || numberStart >= position) return false; // No number preceding letter - var numberString = value[numberStart..position]; + var numberString = s[numberStart..position]; if (!uint.TryParse(numberString, out uint n)) return false; // Not a valid number diff --git a/src/Tingle.Extensions.Primitives/Etag.cs b/src/Tingle.Extensions.Primitives/Etag.cs index fd13b43..694711a 100644 --- a/src/Tingle.Extensions.Primitives/Etag.cs +++ b/src/Tingle.Extensions.Primitives/Etag.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text.Json.Serialization; using Tingle.Extensions.Primitives.Converters; @@ -10,8 +11,10 @@ namespace Tingle.Extensions.Primitives; /// [JsonConverter(typeof(EtagJsonConverter))] [TypeConverter(typeof(EtagTypeConverter))] -public readonly struct Etag : IEquatable, IComparable, IConvertible, IFormattable +public readonly struct Etag : IEquatable, IComparable, IConvertible, IFormattable, IParsable { + private const string ObsoleteUseParsing = "Use Parse(...) or TryParse(...) instead"; + /// /// The default value starting from the beginning () /// @@ -49,6 +52,7 @@ namespace Tingle.Extensions.Primitives; /// /// does not have sufficient data to create . /// + [Obsolete(ObsoleteUseParsing)] public Etag(string value) { if (string.IsNullOrWhiteSpace(value)) @@ -136,7 +140,7 @@ public Etag(byte[] value, int startIndex) public string ToString(string? format) => ToString(format, CultureInfo.CurrentCulture); /// - public string ToString(string? format, IFormatProvider? formatProvider) + public string ToString(string? format, IFormatProvider? provider) { format ??= Base64Format; @@ -161,6 +165,78 @@ public string ToString(string? format, IFormatProvider? formatProvider) /// public Etag Next() => new(value + 1); + /// Converts a into a . + /// A string containing the value to convert. + /// A equivalent to the value specified in . + /// is null. + /// is not in a correct format. + public static Etag Parse(string s) => Parse(s, null); + + /// Converts a into a . + /// A string containing the value to convert. + /// An object that supplies culture-specific formatting information about . + /// A equivalent to the value specified in . + /// is null. + /// is not in a correct format. + public static Etag Parse(string s, IFormatProvider? provider) + { + ArgumentNullException.ThrowIfNull(s); + + if (TryParse(s, provider, out var result)) return result; + throw new FormatException($"'{s}' is not a valid Etag."); + } + + /// Converts a into a . + /// A string containing the value to convert. + /// + /// When this method returns, contains the value associated parsed, + /// if successful; otherwise, is returned. + /// This parameter is passed uninitialized. + /// + /// + /// if could be parsed; otherwise, false. + /// + public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out Etag value) => TryParse(s, null, out value); + + /// Converts a into a . + /// A string containing the value to convert. + /// An object that supplies culture-specific formatting information about . + /// + /// When this method returns, contains the value associated parsed, + /// if successful; otherwise, is returned. + /// This parameter is passed uninitialized. + /// + /// + /// if could be parsed; otherwise, false. + /// + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out Etag value) + { + value = default; + if (string.IsNullOrWhiteSpace(s)) return false; + + // remove quotes and parse the number + if (s.StartsWith(QuoteCharacter)) s = s.Trim(QuoteCharacter); + + if (s.StartsWith(HexSpecifier, StringComparison.OrdinalIgnoreCase)) + { + s = s[HexSpecifier.Length..]; + if (ulong.TryParse(s, NumberStyles.HexNumber, provider, out var ul)) + { + value = new Etag(ul); + return true; + } + } + else + { + var raw = Convert.FromBase64String(s); // convert from base64 string + var ul = BitConverter.ToUInt64(raw, 0); // convert to ulong + value = new Etag(ul); + return true; + } + + return false; + } + /// public static bool operator ==(Etag left, Etag right) => left.Equals(right); @@ -181,6 +257,7 @@ public string ToString(string? format, IFormatProvider? formatProvider) /// Converts a to a . /// + [Obsolete(ObsoleteUseParsing)] public static implicit operator Etag(string s) => new(value: s); /// Converts a to a . @@ -232,6 +309,7 @@ public static Etag Combine(params Etag[] etags) /// Combine multiple instances of into one . /// The values to be combined. /// + [Obsolete(ObsoleteUseParsing)] public static Etag Combine(IEnumerable etags) => Combine(etags.Select(e => new Etag(e))); /// Combine multiple instances of into one . @@ -287,7 +365,7 @@ internal class EtagTypeConverter : TypeConverter /// public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) { - return value is string s ? new Etag(s) : base.ConvertFrom(context, culture, value); + return value is string s ? Parse(s) : base.ConvertFrom(context, culture, value); } /// diff --git a/src/Tingle.Extensions.Primitives/Extensions/NumberAbbreviationExtensions.cs b/src/Tingle.Extensions.Primitives/Extensions/NumberAbbreviationExtensions.cs index a809d2a..53a27e9 100644 --- a/src/Tingle.Extensions.Primitives/Extensions/NumberAbbreviationExtensions.cs +++ b/src/Tingle.Extensions.Primitives/Extensions/NumberAbbreviationExtensions.cs @@ -24,15 +24,15 @@ public static class NumberAbbreviationExtensions /// using the specified culture-specific format information. /// /// the numeric value - /// An object that supplies culture-specific formatting information. + /// An object that supplies culture-specific formatting information. /// The string representation of the value of this instance as specified by format and provider. /// format is invalid or not supported. - public static string ToStringAbbreviated(this long source, IFormatProvider formatProvider) + public static string ToStringAbbreviated(this long source, IFormatProvider? provider) { - if (source > 999999999 || source < -999999999) return source.ToString(FormatBillions, formatProvider); - else if (source > 999999 || source < -999999) return source.ToString(FormatMillions, formatProvider); - else if (source > 999 || source < -999) return source.ToString(FormatKilo, formatProvider); - return source.ToString(formatProvider); + if (source > 999999999 || source < -999999999) return source.ToString(FormatBillions, provider); + else if (source > 999999 || source < -999999) return source.ToString(FormatMillions, provider); + else if (source > 999 || source < -999) return source.ToString(FormatKilo, provider); + return source.ToString(provider); } /// @@ -48,14 +48,14 @@ public static string ToStringAbbreviated(this long source, IFormatProvider forma /// using the specified culture-specific format information. /// /// the numeric value - /// An object that supplies culture-specific formatting information. + /// An object that supplies culture-specific formatting information. /// The string representation of the value of this instance as specified by format and provider. /// format is invalid or not supported. - public static string ToStringAbbreviated(this int source, IFormatProvider formatProvider) + public static string ToStringAbbreviated(this int source, IFormatProvider? provider) { - if (source > 999999999 || source < -999999999) return source.ToString(FormatBillions, formatProvider); - else if (source > 999999 || source < -999999) return source.ToString(FormatMillions, formatProvider); - else if (source > 999 || source < -999) return source.ToString(FormatKilo, formatProvider); - return source.ToString(formatProvider); + if (source > 999999999 || source < -999999999) return source.ToString(FormatBillions, provider); + else if (source > 999999 || source < -999999) return source.ToString(FormatMillions, provider); + else if (source > 999 || source < -999) return source.ToString(FormatKilo, provider); + return source.ToString(provider); } } diff --git a/src/Tingle.Extensions.Primitives/Ksuid.cs b/src/Tingle.Extensions.Primitives/Ksuid.cs index de5e9ca..4fd9445 100644 --- a/src/Tingle.Extensions.Primitives/Ksuid.cs +++ b/src/Tingle.Extensions.Primitives/Ksuid.cs @@ -23,7 +23,7 @@ namespace Tingle.Extensions.Primitives; /// [JsonConverter(typeof(KsuidJsonConverter))] [TypeConverter(typeof(KsuidTypeConverter))] -public readonly partial struct Ksuid : IEquatable, IConvertible, IFormattable +public readonly partial struct Ksuid : IEquatable, IConvertible, IFormattable, IParsable { /// Gets an instance of where the value is empty. public static readonly Ksuid Empty = default; @@ -80,16 +80,62 @@ internal Ksuid(uint timestamp, byte[] payload) /// Gets the timestamp represented in the instance. public DateTimeOffset Created => origin.AddSeconds(timestamp); + /// Returns a 20-element byte array that contains the value of this instance. + /// A 20-element byte array. + public byte[] ToByteArray() + { + var timestampBytes = BitConverter.GetBytes(timestamp); + if (BitConverter.IsLittleEndian) Array.Reverse(timestampBytes); + + var buffer = new byte[TotalBytesLength]; + Array.Copy(timestampBytes, 0, buffer, 0, timestampBytes.Length); + if (payload is not null) + { + Array.Copy(payload, 0, buffer, timestampBytes.Length, payload.Length); + } + + return buffer; + } + + /// + public override string ToString() => ToString(Base62Format); + + /// Returns the string representation of the . + /// A format string. Valid values are "B" for base32 format (27 char) and "H" for standard hex format (40 char). + /// The formatted string representation of this . + public string ToString(string? format) => ToString(format, CultureInfo.CurrentCulture); + + /// + public string ToString(string? format, IFormatProvider? provider) + { + format ??= Base62Format; + + return format.ToUpperInvariant() switch + { + Base62Format => KsuidBase62.ToBase62(ToByteArray()).PadLeft(Base62EncodedLength, '0'), + HexFormat => Convert.ToHexString(ToByteArray()), + _ => throw new FormatException($"The {format} format string is not supported."), + }; + } + /// Converts a into a . /// A string containing the value to convert. /// A equivalent to the value specified in . /// is null. /// is not in a correct format. - public static Ksuid Parse(string s) + public static Ksuid Parse(string s) => Parse(s, null); + + /// Converts a into a . + /// A string containing the value to convert. + /// An object that supplies culture-specific formatting information about . + /// A equivalent to the value specified in . + /// is null. + /// is not in a correct format. + public static Ksuid Parse(string s, IFormatProvider? provider) { ArgumentNullException.ThrowIfNull(s); - if (TryParse(s, out var result)) return result; + if (TryParse(s, provider, out var result)) return result; throw new FormatException($"'{s}' is not a valid KSUID."); } @@ -103,13 +149,23 @@ public static Ksuid Parse(string s) /// /// if could be parsed; otherwise, false. /// - public static bool TryParse(string s, [NotNullWhen(true)] out Ksuid value) + public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out Ksuid value) => TryParse(s, null, out value); + + /// Converts a into a . + /// A string containing the value to convert. + /// An object that supplies culture-specific formatting information about . + /// + /// When this method returns, contains the value associated parsed, + /// if successful; otherwise, is returned. + /// This parameter is passed uninitialized. + /// + /// + /// if could be parsed; otherwise, false. + /// + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out Ksuid value) { value = default; - if (string.IsNullOrWhiteSpace(s)) - { - return false; - } + if (string.IsNullOrWhiteSpace(s)) return false; if (s.Length == Base62EncodedLength) { @@ -125,44 +181,6 @@ public static bool TryParse(string s, [NotNullWhen(true)] out Ksuid value) return false; } - /// Returns a 20-element byte array that contains the value of this instance. - /// A 20-element byte array. - public byte[] ToByteArray() - { - var timestampBytes = BitConverter.GetBytes(timestamp); - if (BitConverter.IsLittleEndian) Array.Reverse(timestampBytes); - - var buffer = new byte[TotalBytesLength]; - Array.Copy(timestampBytes, 0, buffer, 0, timestampBytes.Length); - if (payload is not null) - { - Array.Copy(payload, 0, buffer, timestampBytes.Length, payload.Length); - } - - return buffer; - } - - /// - public override string ToString() => ToString(Base62Format); - - /// Returns the string representation of the . - /// A format string. Valid values are "B" for base32 format (27 char) and "H" for standard hex format (40 char). - /// The formatted string representation of this . - public string ToString(string? format) => ToString(format, CultureInfo.CurrentCulture); - - /// - public string ToString(string? format, IFormatProvider? formatProvider) - { - format ??= Base62Format; - - return format.ToUpperInvariant() switch - { - Base62Format => KsuidBase62.ToBase62(ToByteArray()).PadLeft(Base62EncodedLength, '0'), - HexFormat => Convert.ToHexString(ToByteArray()), - _ => throw new FormatException($"The {format} format string is not supported."), - }; - } - /// Generates a new with a unique value. public static Ksuid Generate() => Generate(DateTimeOffset.UtcNow); diff --git a/src/Tingle.Extensions.Primitives/Money.cs b/src/Tingle.Extensions.Primitives/Money.cs index a32387c..553efa5 100644 --- a/src/Tingle.Extensions.Primitives/Money.cs +++ b/src/Tingle.Extensions.Primitives/Money.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text.Json.Serialization; using Tingle.Extensions.Primitives.Converters; @@ -15,7 +16,7 @@ namespace Tingle.Extensions.Primitives; /// [JsonConverter(typeof(MoneyJsonConverter))] [TypeConverter(typeof(MoneyTypeConverter))] -public readonly struct Money(Currency currency, long amount) : IEquatable, IComparable, IConvertible, IFormattable +public readonly struct Money(Currency currency, long amount) : IEquatable, IComparable, IConvertible, IFormattable, IParsable { private readonly Currency currency = currency ?? throw new ArgumentNullException(nameof(currency)); private readonly long amount = amount; @@ -69,21 +70,20 @@ public int CompareTo(Money other) public string ToString(string? format) => ToString(format, null); /// - public string ToString(IFormatProvider? formatProvider) => ToString(null, formatProvider); + public string ToString(IFormatProvider? provider) => ToString(null, provider); // https://github.com/DynamicHands/NodaMoney/blob/c4c9a621fd002abecb855273581632a6814c0c6c/src/NodaMoney/Money.Formattable.cs /// - public string ToString(string? format, IFormatProvider? formatProvider) + public string ToString(string? format, IFormatProvider? provider) { - IFormatProvider provider; if (!string.IsNullOrWhiteSpace(format) && format.StartsWith('I') && format.Length >= 1 && format.Length <= 2) { format = format.Replace("I", "C", StringComparison.Ordinal); - provider = GetFormatProvider(currency, formatProvider, true); + provider = GetFormatProvider(currency, provider, true); } else { - provider = GetFormatProvider(currency, formatProvider); + provider = GetFormatProvider(currency, provider); } if (format == null || format == "G") @@ -113,117 +113,95 @@ public string ToString(string? format, IFormatProvider? formatProvider) #region Parsing /// Converts a into a . - /// A string containing the value to convert. - /// A equivalent to the value specified in . - /// is null. - /// is not in a correct format. - public static Money Parse(string value) - { - if (string.IsNullOrWhiteSpace(value)) - throw new ArgumentNullException(nameof(value)); - - var currency = ExtractCurrencyFromString(value, out var s); - return Parse(s, currency); - } + /// A string containing the value to convert. + /// A equivalent to the value specified in . + /// is null. + /// is not in a correct format. + public static Money Parse(string s) => Parse(s, null, null); /// Converts a into a . - /// A string containing the value to convert. + /// A string containing the value to convert. /// The currency to use for parsing the string representation. - /// A equivalent to the value specified in . - /// is null. - /// is not in a correct format. - public static Money Parse(string value, Currency currency) - { - if (string.IsNullOrWhiteSpace(value)) - throw new ArgumentNullException(nameof(value)); + /// A equivalent to the value specified in . + /// is null. + /// is not in a correct format. + public static Money Parse(string s, Currency currency) => Parse(s, null, currency); - return Parse(value, NumberStyles.Currency, GetFormatProvider(currency, null), currency); - } + /// Converts a into a . + /// A string containing the value to convert. + /// An object that supplies culture-specific parsing information about . + /// A equivalent to the value specified in . + /// is null. + /// is not in a correct format. + public static Money Parse(string s, IFormatProvider? provider) => Parse(s, provider, null); /// Converts a into a . - /// A string containing the value to convert. - /// - /// A bitwise combination of enumeration values that indicates the permitted format of value. - /// A typical value to specify is . - /// - /// An object that supplies culture-specific parsing information about . + /// A string containing the value to convert. + /// An object that supplies culture-specific parsing information about . /// The currency to use for parsing the string representation. - /// A equivalent to the value specified in . - /// is null. - /// is not in a correct format. - public static Money Parse(string value, NumberStyles style, IFormatProvider provider, Currency currency) + /// A equivalent to the value specified in . + /// is null. + /// is not in a correct format. + public static Money Parse(string s, IFormatProvider? provider, Currency? currency) { - if (string.IsNullOrWhiteSpace(value)) - throw new ArgumentNullException(nameof(value)); + if (string.IsNullOrWhiteSpace(s)) throw new ArgumentNullException(nameof(s)); - if (TryParse(value, style, provider, currency, out var result)) return result; - throw new FormatException($"'{value}' is not a valid Money representation."); + currency ??= ExtractCurrencyFromString(s, out s); + provider = GetFormatProvider(currency, provider); + if (TryParse(s, provider, currency, out var result)) return result; + throw new FormatException($"'{s}' is not a valid Money representation."); } /// Converts a into a . - /// A string containing the value to convert. + /// A string containing the value to convert. /// /// When this method returns, contains the value associated parsed, /// if successful; otherwise, is returned. /// This parameter is passed uninitialized. /// /// - /// if could be parsed; otherwise, false. + /// if could be parsed; otherwise, false. /// - public static bool TryParse(string value, out Money result) - { - result = default; - if (string.IsNullOrWhiteSpace(value)) - { - return false; - } - - Currency currency; - string s; - try - { - currency = ExtractCurrencyFromString(value, out s); - } - catch (FormatException) - { - return false; - } - - return TryParse(s, currency, out result); - } + public static bool TryParse([NotNullWhen(true)] string? s, out Money result) => TryParse(s, null, null, out result); /// Converts a into a . - /// A string containing the value to convert. + /// A string containing the value to convert. /// The currency to use for parsing the string representation. /// /// When this method returns, contains the value associated parsed, /// if successful; otherwise, is returned. /// This parameter is passed uninitialized. /// - /// if could be parsed; otherwise, false. - public static bool TryParse(string value, Currency currency, out Money result) - { - return TryParse(value, NumberStyles.Currency, GetFormatProvider(currency, null), currency, out result); - } + /// if could be parsed; otherwise, false. + public static bool TryParse([NotNullWhen(true)] string? s, Currency? currency, out Money result) => TryParse(s, null, currency, out result); /// Converts a into a . - /// A string containing the value to convert. - /// The currency to use for parsing the string representation. - /// - /// A bitwise combination of enumeration values that indicates the permitted format of value. - /// A typical value to specify is . + /// A string containing the value to convert. + /// An object that supplies culture-specific parsing information about . + /// + /// When this method returns, contains the value associated parsed, + /// if successful; otherwise, is returned. + /// This parameter is passed uninitialized. /// - /// An object that supplies culture-specific parsing information about . + /// if could be parsed; otherwise, false. + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out Money result) => TryParse(s, provider, null, out result); + + /// Converts a into a . + /// A string containing the value to convert. + /// The currency to use for parsing the string representation. + /// An object that supplies culture-specific parsing information about . /// /// When this method returns, contains the value associated parsed, /// if successful; otherwise, is returned. /// This parameter is passed uninitialized. /// - /// if could be parsed; otherwise, false. - public static bool TryParse(string value, NumberStyles style, IFormatProvider provider, Currency currency, out Money result) + /// if could be parsed; otherwise, false. + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, Currency? currency, out Money result) { result = default; - if (double.TryParse(value, style, GetFormatProvider(currency, provider), out var amountD)) + if (currency is null &&!TryExtractCurrencyFromString(s, out s, out currency)) return false; + + if (double.TryParse(s, NumberStyles.Currency, GetFormatProvider(currency, provider), out var amountD)) { var amount = Convert.ToInt64(amountD * Math.Pow(10, currency.DecimalDigits)); result = new Money(currency, amount); @@ -237,20 +215,17 @@ public static bool TryParse(string value, NumberStyles style, IFormatProvider pr #region Helpers - private static NumberFormatInfo GetFormatProvider(Currency currency, IFormatProvider? formatProvider, bool useCode = false) + private static NumberFormatInfo GetFormatProvider(Currency currency, IFormatProvider? provider, bool useCode = false) { var cc = CultureInfo.CurrentCulture; // var numberFormatInfo = (NumberFormatInfo)NumberFormatInfo.CurrentInfo.Clone(); var numberFormatInfo = (NumberFormatInfo)cc.NumberFormat.Clone(); - if (formatProvider != null) + if (provider != null) { - if (formatProvider is CultureInfo ci) - numberFormatInfo = (NumberFormatInfo)ci.NumberFormat.Clone(); - - if (formatProvider is NumberFormatInfo nfi) - numberFormatInfo = (NumberFormatInfo)nfi.Clone(); + if (provider is CultureInfo ci) numberFormatInfo = (NumberFormatInfo)ci.NumberFormat.Clone(); + if (provider is NumberFormatInfo nfi) numberFormatInfo = (NumberFormatInfo)nfi.Clone(); } numberFormatInfo.CurrencyDecimalDigits = Math.Max(0, currency.DecimalDigits); @@ -301,39 +276,40 @@ private static NumberFormatInfo GetFormatProvider(Currency currency, IFormatProv private static Currency ExtractCurrencyFromString(string value, out string repaired) { - var currencyAsString = new string(value.ToCharArray().Where(IsNotNumericCharacter()).ToArray()); + repaired = value; + var extracted = new string(value.ToCharArray().Where(IsNotNumericCharacter()).ToArray()); var current = Currency.CurrentCurrency; - Currency currency; - if (current is not null && (currencyAsString.Length == 0 || current.Symbol == currencyAsString || current.Code == currencyAsString)) + if (current is not null && extracted.Length == 0) return current; + + var matching = Currency.All.Where(c => c.Symbol == extracted || c.Code == extracted).ToList(); + if (matching.Count == 0) throw new FormatException($"{extracted} is an unknown currency sign or code!"); + if (matching.Count > 1) { - currency = current; + throw new FormatException($"Currency sign {extracted} matches with multiple known currencies! Specify currency or culture explicit."); } - else - { - var match = Currency.All.Where(c => c.Symbol == currencyAsString || c.Code == currencyAsString).ToList(); - if (match.Count == 0) - { - throw new FormatException($"{currencyAsString} is an unknown currency sign or code!"); - } + var currency = matching[0]; + repaired = value.Replace(extracted, currency.Symbol); // repair the currency to allow for parsing + return currency; + } - if (match.Count > 1) - { - throw new FormatException($"Currency sign {currencyAsString} matches with multiple known currencies! Specify currency or culture explicit."); - } + private static bool TryExtractCurrencyFromString([NotNullWhen(true)] string? value, out string? repaired, [MaybeNullWhen(false)] out Currency currency) + { + repaired = value; + currency = default; + if (string.IsNullOrWhiteSpace(value)) return false; - currency = match[0]; - } + var extracted = new string(value.ToCharArray().Where(IsNotNumericCharacter()).ToArray()); + if (extracted.Length == 0) return false; - // repair the currency to allow for parsing - repaired = value; - if (currencyAsString.Length > 0) - { - repaired = value.Replace(currencyAsString, currency.Symbol); - } + var matching = Currency.All.Where(c => c.Symbol == extracted || c.Code == extracted).ToList(); + if (matching.Count == 0) return false; + if (matching.Count > 1) return false; - return currency; + currency = matching[0]; + repaired = value.Replace(extracted, currency.Symbol); // repair the currency to allow for parsing + return true; } private static Func IsNotNumericCharacter() @@ -364,7 +340,7 @@ private static Func IsNotNumericCharacter() /// Converts a to a . /// - public static implicit operator Money(string s) => Parse(value: s); + public static implicit operator Money(string s) => Parse(s); /// Converts a to a string. /// diff --git a/src/Tingle.Extensions.Primitives/SequenceNumber.cs b/src/Tingle.Extensions.Primitives/SequenceNumber.cs index 4c7cd4c..5297286 100644 --- a/src/Tingle.Extensions.Primitives/SequenceNumber.cs +++ b/src/Tingle.Extensions.Primitives/SequenceNumber.cs @@ -100,7 +100,7 @@ internal SequenceNumber(long timestamp, ushort generator, ushort sequence) public override string ToString() => value.ToString(); /// - public string ToString(string? format, IFormatProvider? formatProvider) => value.ToString(format, formatProvider); + public string ToString(string? format, IFormatProvider? provider) => value.ToString(format, provider); /// public int CompareTo(SequenceNumber other) => value.CompareTo(other.value); diff --git a/src/Tingle.Extensions.Primitives/SwiftCode.cs b/src/Tingle.Extensions.Primitives/SwiftCode.cs index e3b3217..b4130ab 100644 --- a/src/Tingle.Extensions.Primitives/SwiftCode.cs +++ b/src/Tingle.Extensions.Primitives/SwiftCode.cs @@ -16,7 +16,7 @@ namespace Tingle.Extensions.Primitives; /// [JsonConverter(typeof(SwiftCodeJsonConverter))] [TypeConverter(typeof(SwiftCodeTypeConverter))] -public sealed partial class SwiftCode(string institution, string country, string location, string? branch = null) : IEquatable, IComparable, IConvertible +public sealed partial class SwiftCode(string institution, string country, string location, string? branch = null) : IEquatable, IComparable, IConvertible, IParsable { /// /// A 4-letter representation of the institution. @@ -122,16 +122,42 @@ public override int GetHashCode() /// /// The default expression used for validation is ^([a-zA-Z]{4})([a-zA-Z]{2})([a-zA-Z0-9]{2})([a-zA-Z0-9]{3})?$ /// - /// the string representation of a swift code as specified under ISO-9362. + /// the string representation of a swift code as specified under ISO-9362. /// - /// is null. - /// does not have a valid format. - public static SwiftCode Parse(string code) + /// is null. + /// does not have a valid format. + public static SwiftCode Parse(string s) => Parse(s, null); + + /// + /// Parses a code into . The format of a Swift Code is as specified under ISO-9362. + /// The Swift code can be either 8 or 11 characters long, an 8 digits code implies the primary office. + /// The code consists of 4 separate section, and the format arrange in the following manner: AAAA BB CC DDD. + /// The first 4 characters ("AAAA") specify the institution. Only letters. + /// + /// The next 2 characters("BB") specify the country where the institution's located. The code follows the format + /// of ISO 3166-1 alpha-2 country code. Only letters. + /// + /// + /// The next 2 characters ("CC") specify the institution's location. Can be letters and digits. + /// Passive participants will have "1" in the second character. + /// + /// + /// The last 3 characters("DDD") specify the institution's branch. This section is an optional. + /// When set to 'XXX' refers to a primary office. Can be letters and digits. + /// + /// The default expression used for validation is ^([a-zA-Z]{4})([a-zA-Z]{2})([a-zA-Z0-9]{2})([a-zA-Z0-9]{3})?$ + /// + /// the string representation of a swift code as specified under ISO-9362. + /// An object that supplies culture-specific formatting information about . + /// + /// is null. + /// does not have a valid format. + public static SwiftCode Parse(string s, IFormatProvider? provider) { - ArgumentNullException.ThrowIfNull(code); + ArgumentNullException.ThrowIfNull(s); - if (TryParse(code, out var result)) return result; - throw new FormatException($"'{code}' is not a valid Swift code."); + if (TryParse(s, provider, out var result)) return result; + throw new FormatException($"'{s}' is not a valid Swift code."); } /// @@ -153,19 +179,52 @@ public static SwiftCode Parse(string code) /// /// The default expression used for validation is ^([a-zA-Z]{4})([a-zA-Z]{2})([a-zA-Z0-9]{2})([a-zA-Z0-9]{3})?$ /// - /// the string representation of a swift code as specified under ISO-9362. + /// the string representation of a swift code as specified under ISO-9362. + /// + /// When this method returns, contains the value associated parsed, + /// if successful; otherwise, is returned. + /// This parameter is passed uninitialized. + /// + /// + /// if could be parsed; otherwise, false. + /// + public static bool TryParse(string? s, [MaybeNullWhen(false)] out SwiftCode value) => TryParse(s, null, out value); + + /// + /// Parses a code into . The format of a Swift Code is as specified under ISO-9362. + /// The Swift code can be either 8 or 11 characters long, an 8 digits code implies the primary office. + /// The code consists of 4 separate section, and the format arrange in the following manner: AAAA BB CC DDD. + /// The first 4 characters ("AAAA") specify the institution. Only letters. + /// + /// The next 2 characters("BB") specify the country where the institution's located. The code follows the format + /// of ISO 3166-1 alpha-2 country code. Only letters. + /// + /// + /// The next 2 characters ("CC") specify the institution's location. Can be letters and digits. + /// Passive participants will have "1" in the second character. + /// + /// + /// The last 3 characters("DDD") specify the institution's branch. This section is an optional. + /// When set to 'XXX' refers to a primary office. Can be letters and digits. + /// + /// The default expression used for validation is ^([a-zA-Z]{4})([a-zA-Z]{2})([a-zA-Z0-9]{2})([a-zA-Z0-9]{3})?$ + /// + /// the string representation of a swift code as specified under ISO-9362. + /// An object that supplies culture-specific formatting information about . /// /// When this method returns, contains the value associated parsed, /// if successful; otherwise, is returned. /// This parameter is passed uninitialized. /// /// - /// if could be parsed; otherwise, false. + /// if could be parsed; otherwise, false. /// - public static bool TryParse(string code, [NotNullWhen(true)] out SwiftCode? value) + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out SwiftCode value) { - value = null; - var match = GetPattern().Match(code); + value = default; + if (string.IsNullOrWhiteSpace(s)) return false; + + var match = GetPattern().Match(s); if (!match.Success) return false; value = new SwiftCode(institution: match.Groups[1].Value, @@ -183,8 +242,8 @@ public static bool TryParse(string code, [NotNullWhen(true)] out SwiftCode? valu public static bool operator !=(SwiftCode left, SwiftCode right) => !(left == right); /// Converts a string to a . - /// the string representation of the code - public static implicit operator SwiftCode(string code) => Parse(code: code); + /// the string representation of the code + public static implicit operator SwiftCode(string s) => Parse(s); /// Converts a to a string. /// the string representation of the code diff --git a/tests/Tingle.Extensions.Primitives.Tests/EtagTests.cs b/tests/Tingle.Extensions.Primitives.Tests/EtagTests.cs index 137db98..a164679 100644 --- a/tests/Tingle.Extensions.Primitives.Tests/EtagTests.cs +++ b/tests/Tingle.Extensions.Primitives.Tests/EtagTests.cs @@ -12,7 +12,7 @@ public class EtagTests [InlineData("CgAAAAAAAAA=", "CwAAAAAAAAA=")] public void Next_Works(string current, string expected) { - var etag = new Etag(current); + var etag = Etag.Parse(current); var actual = etag.Next().ToString(); Assert.Equal(expected, actual); } @@ -36,9 +36,9 @@ public void Combine_Works(ulong[] values, ulong expected) [InlineData(new string[] { "DAAAAAAAAAA=", "CAAAAAAAAAA=", "AQAAAAAAAAA=", "FQAAAAAAAAA=", }, "KgAAAAAAAAA=")] public void Combine_Works_ForStrings(string[] values, string expected) { - var tags = values.Select(u => (Etag)u).ToArray(); + var tags = values.Select(v => Etag.Parse(v)).ToArray(); var actual = Etag.Combine(tags); - Assert.Equal((Etag)expected, actual); + Assert.Equal(Etag.Parse(expected), actual); } [Theory] @@ -93,7 +93,7 @@ public void ToString_H_Works(ulong value, string expected) [InlineData("AQAAAAAAAAA=", "AQAAAAAAAAA=")] public void CreateFromString_Works(string value, string expected) { - var etag = new Etag(value); + var etag = Etag.Parse(value); var actual = etag.ToString(); Assert.Equal(expected, actual); } diff --git a/tests/Tingle.Extensions.Primitives.Tests/MoneyTests.cs b/tests/Tingle.Extensions.Primitives.Tests/MoneyTests.cs index f32ea58..f0ce94b 100644 --- a/tests/Tingle.Extensions.Primitives.Tests/MoneyTests.cs +++ b/tests/Tingle.Extensions.Primitives.Tests/MoneyTests.cs @@ -140,13 +140,6 @@ public void Parse_Works_For_YenYuanSymbolInJapan() Assert.Equal(new Money("JPY", 765), yen); } - [Fact, UseCulture("en-US")] - public void Parse_Works_DollarSymbolInUSA() - { - var dollar = Money.Parse("$765.43"); - Assert.Equal(new Money("USD", 76543), dollar); - } - [Fact, UseCulture("nl-NL")] public void Parsing_DollarSymbolInNetherlands_Fails() {