diff --git a/src/Clast.DatabaseDecimal/DecimalType.cs b/src/Clast.DatabaseDecimal/DecimalType.cs index e6da690..831e2a2 100644 --- a/src/Clast.DatabaseDecimal/DecimalType.cs +++ b/src/Clast.DatabaseDecimal/DecimalType.cs @@ -1,6 +1,8 @@ // Copyright (c) clast-project. All rights reserved. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +using System.Runtime.CompilerServices; + namespace Clast.DatabaseDecimal; /// @@ -9,7 +11,12 @@ namespace Clast.DatabaseDecimal; /// Scale is the number of digits after the decimal point (0..precision). /// The backing integer width is derived from the precision. /// -public readonly record struct DecimalType(byte Precision, byte Scale) +/// +/// Every constructed value is validated, so is never +/// negative. The one unvalidated value is default(DecimalType), which a +/// struct always permits: it has a precision of 0 and behaves as NUMERIC(0,0). +/// +public readonly record struct DecimalType { /// Max digits for a 32-bit mantissa: floor(log10(2^31)) = 9. public const int MaxPrecision32 = 9; @@ -23,6 +30,32 @@ public readonly record struct DecimalType(byte Precision, byte Scale) /// Max digits for a 256-bit mantissa: floor(log10(2^255)) = 76. public const int MaxPrecision256 = 76; + /// + /// Creates a DecimalType, validating precision and scale. + /// + /// Total number of significant digits, 1..76. + /// Digits after the decimal point, 0... + /// + /// The precision is outside 1..76, or the scale exceeds the precision. + /// + public DecimalType(byte precision, byte scale) + { + if (precision < 1 || precision > MaxPrecision256) + ThrowPrecisionOutOfRange(precision); + + if (scale > precision) + ThrowScaleOutOfRange(precision, scale); + + Precision = precision; + Scale = scale; + } + + /// The total number of significant digits, 1..76. + public byte Precision { get; } + + /// The number of digits after the decimal point, 0... + public byte Scale { get; } + /// /// The backing integer width tier, derived from the precision. /// @@ -40,20 +73,40 @@ public readonly record struct DecimalType(byte Precision, byte Scale) public int IntegerDigits => Precision - Scale; /// - /// Creates a DecimalType with validation. + /// Creates a DecimalType with validation. Equivalent to the constructor, but + /// takes arguments so out-of-range values are rejected rather + /// than silently truncated by the conversion to . /// + /// + /// The precision is outside 1..76, or the scale is negative or exceeds the precision. + /// public static DecimalType Numeric(int precision, int scale) { if (precision < 1 || precision > MaxPrecision256) - throw new ArgumentOutOfRangeException(nameof(precision), - $"Precision must be between 1 and {MaxPrecision256}, got {precision}."); + ThrowPrecisionOutOfRange(precision); if (scale < 0 || scale > precision) - throw new ArgumentOutOfRangeException(nameof(scale), - $"Scale must be between 0 and precision ({precision}), got {scale}."); + ThrowScaleOutOfRange(precision, scale); return new DecimalType((byte)precision, (byte)scale); } + /// Splits the type into its precision and scale. + public void Deconstruct(out byte precision, out byte scale) + { + precision = Precision; + scale = Scale; + } + public override string ToString() => $"NUMERIC({Precision},{Scale})"; + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowPrecisionOutOfRange(int precision) => + throw new ArgumentOutOfRangeException(nameof(precision), + $"Precision must be between 1 and {MaxPrecision256}, got {precision}."); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowScaleOutOfRange(int precision, int scale) => + throw new ArgumentOutOfRangeException(nameof(scale), + $"Scale must be between 0 and precision ({precision}), got {scale}."); } diff --git a/tests/Clast.DatabaseDecimal.Tests/DecimalTypeTests.cs b/tests/Clast.DatabaseDecimal.Tests/DecimalTypeTests.cs index 6ea125b..75584fb 100644 --- a/tests/Clast.DatabaseDecimal.Tests/DecimalTypeTests.cs +++ b/tests/Clast.DatabaseDecimal.Tests/DecimalTypeTests.cs @@ -75,4 +75,51 @@ public void Inequality_DifferentScale() var b = DecimalType.Numeric(5, 3); Assert.NotEqual(a, b); } + + [Fact] + public void Constructor_ScaleExceedsPrecision_Throws() + { + var ex = Assert.Throws(() => new DecimalType(10, 30)); + Assert.Equal("scale", ex.ParamName); + } + + [Fact] + public void Constructor_PrecisionZero_Throws() + { + var ex = Assert.Throws(() => new DecimalType(0, 0)); + Assert.Equal("precision", ex.ParamName); + } + + [Fact] + public void Constructor_PrecisionAboveMax_Throws() + { + var ex = Assert.Throws(() => new DecimalType(77, 0)); + Assert.Equal("precision", ex.ParamName); + } + + [Fact] + public void Constructor_MatchesNumeric() + { + Assert.Equal(DecimalType.Numeric(18, 4), new DecimalType(18, 4)); + } + + [Fact] + public void Deconstruct_YieldsPrecisionAndScale() + { + var (precision, scale) = DecimalType.Numeric(18, 4); + Assert.Equal(18, precision); + Assert.Equal(4, scale); + } + + [Fact] + public void IntegerDigits_NeverNegative_ForConstructibleTypes() + { + for (int precision = 1; precision <= DecimalType.MaxPrecision256; precision++) + { + for (int scale = 0; scale <= precision; scale++) + Assert.True(DecimalType.Numeric(precision, scale).IntegerDigits >= 0); + + Assert.Throws(() => DecimalType.Numeric(precision, precision + 1)); + } + } }