diff --git a/README.md b/README.md index a087d19..f124b2b 100644 --- a/README.md +++ b/README.md @@ -76,14 +76,14 @@ var sqids = new SqidsEncoder(); #### Single number: ```cs -string id = sqids.Encode(1); // "UfB" +string id = sqids.Encode(1); // "Uk" int number = sqids.Decode(id).Single(); // 1 ``` #### Multiple numbers: ```cs -string id = sqids.Encode(1, 2, 3); // "8QRLaD" +string id = sqids.Encode(1, 2, 3); // "86Rf07" int[] numbers = sqids.Decode(id); // new[] { 1, 2, 3 } ``` @@ -112,7 +112,7 @@ var sqids = new SqidsEncoder(new() > It's recommended that you at least provide a shuffled alphabet when using Sqids — even if you want to use the same characters as those in the default alphabet — so that your IDs will be unique to you. You can use an online tool like [this one](https://codebeautify.org/shuffle-letters) to do that. > **Warning** -> Sqids needs an alphabet that contains at least 5 unique characters. +> The alphabet needs to contain at least 3 unique characters. #### Minimum Length: diff --git a/src/Sqids/SqidsEncoder.cs b/src/Sqids/SqidsEncoder.cs index 28b4899..23cfaef 100644 --- a/src/Sqids/SqidsEncoder.cs +++ b/src/Sqids/SqidsEncoder.cs @@ -21,41 +21,14 @@ public sealed class SqidsEncoder where T : unmanaged, IBinaryInteger, IMin public sealed class SqidsEncoder #endif { - private const int MinAlphabetLength = 5; + private const int MinAlphabetLength = 3; + private const int MaxMinLength = 255; private const int MaxStackallocSize = 256; // NOTE: In bytes — this value is essentially arbitrary, the Microsoft docs is using 1024 but recommends being more conservative when choosing the value (https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/stackalloc), Hashids apparently uses 512 (https://github.com/ullmark/hashids.net/blob/9b1c69de4eedddf9d352c96117d8122af202e90f/src/Hashids.net/Hashids.cs#L17), and this article (https://vcsjones.dev/stackalloc/) uses 256. I've tried to be pretty cautious and gone with a low value. private readonly char[] _alphabet; private readonly int _minLength; private readonly string[] _blockList; -#if NET7_0_OR_GREATER - /// - /// The minimum numeric value that can be encoded/decoded using . - /// This is always zero across all ports of Sqids. - /// - public static T MinValue => T.Zero; -#else - /// - /// The minimum numeric value that can be encoded/decoded using . - /// This is always zero across all ports of Sqids. - /// - public const int MinValue = 0; -#endif - -#if NET7_0_OR_GREATER - /// - /// The maximum numeric value that can be encoded/decoded using . - /// This is equal to `T.MaxValue`. - /// - public static T MaxValue => T.MaxValue; -#else - /// - /// The maximum numeric value that can be encoded/decoded using . - /// This is equal to `int.MaxValue`. - /// - public const int MaxValue = int.MaxValue; -#endif - #if NET7_0_OR_GREATER /// /// Initializes a new instance of with the default options. @@ -67,7 +40,6 @@ public sealed class SqidsEncoder #endif public SqidsEncoder() : this(new()) { } - #if NET7_0_OR_GREATER /// /// Initializes a new instance of with custom options. @@ -77,6 +49,8 @@ public SqidsEncoder() : this(new()) { } /// All properties of are optional and will fall back to their /// defaults if not explicitly set. /// + /// + /// #else /// /// Initializes a new instance of with custom options. @@ -86,21 +60,38 @@ public SqidsEncoder() : this(new()) { } /// All properties of are optional and will fall back to their /// defaults if not explicitly set. /// + /// + /// #endif public SqidsEncoder(SqidsOptions options) { - if (options.Alphabet.Length < MinAlphabetLength) - throw new ArgumentException("The alphabet must contain at least 5 characters."); + _ = options ?? throw new ArgumentNullException(nameof(options)); + _ = options.Alphabet ?? throw new ArgumentNullException(nameof(options.Alphabet)); + _ = options.BlockList ?? throw new ArgumentNullException(nameof(options.BlockList)); if (options.Alphabet.Distinct().Count() != options.Alphabet.Length) - throw new ArgumentException("The alphabet must not contain duplicate characters."); + throw new ArgumentOutOfRangeException( + nameof(options.MinLength), + "The alphabet must not contain duplicate characters." + ); -#if NET7_0_OR_GREATER - if (T.CreateChecked(options.MinLength) < MinValue || options.MinLength > options.Alphabet.Length) -#else - if (options.MinLength < MinValue || options.MinLength > options.Alphabet.Length) -#endif - throw new ArgumentException($"The minimum length must be between {MinValue} and {options.Alphabet.Length}."); + if (Encoding.UTF8.GetByteCount(options.Alphabet) != options.Alphabet.Length) + throw new ArgumentOutOfRangeException( + nameof(options.MinLength), + "The alphabet must not contain multi-byte characters." + ); + + if (options.Alphabet.Length < MinAlphabetLength) + throw new ArgumentOutOfRangeException( + nameof(options.Alphabet), + $"The alphabet must contain at least {MinAlphabetLength} characters." + ); + + if (options.MinLength < 0 || options.MinLength > MaxMinLength) + throw new ArgumentOutOfRangeException( + nameof(options.MinLength), + $"The minimum length must be between 0 and {MaxMinLength}." + ); _minLength = options.MinLength; @@ -135,16 +126,23 @@ public SqidsEncoder(SqidsOptions options) /// /// The number to encode. /// A string containing the encoded ID. - /// If any of the integers passed is smaller than (i.e. negative) or greater than (i.e. `int.MaxValue`). - /// If the decoded number overflows integer. + /// If the number passed is smaller than 0 (i.e. negative). + /// If the encoding reaches maximum re-generation attempts due to the blocklist. #if NET7_0_OR_GREATER public string Encode(T number) #else public string Encode(int number) #endif { - if (number < MinValue || number > MaxValue) - throw new ArgumentOutOfRangeException($"Encoding supports numbers between '{MinValue}' and '{MaxValue}'."); +#if NET7_0_OR_GREATER + if (number < T.Zero) +#else + if (number < 0) +#endif + throw new ArgumentOutOfRangeException( + nameof(number), + "Encoding is only supported for zero and positive numbers." + ); return Encode(stackalloc[] { number }); // NOTE: We use `stackalloc` here in order not to incur the cost of allocating an array on the heap, since we know the array will only have one element, we can use `stackalloc` safely. } @@ -154,8 +152,8 @@ public string Encode(int number) /// /// The numbers to encode. /// A string containing the encoded IDs, or an empty string if the array passed is empty. - /// If any of the integers passed is smaller than (i.e. negative) or greater than (i.e. `int.MaxValue`). - /// If the decoded number overflows integer. + /// If any of the numbers passed is smaller than 0 (i.e. negative). + /// If the encoding reaches maximum re-generation attempts due to the blocklist. #if NET7_0_OR_GREATER public string Encode(params T[] numbers) #else @@ -165,8 +163,15 @@ public string Encode(params int[] numbers) if (numbers.Length == 0) return string.Empty; - if (numbers.Any(n => n < MinValue || n > MaxValue)) - throw new ArgumentOutOfRangeException($"Encoding supports numbers between '{MinValue}' and '{MaxValue}'."); +#if NET7_0_OR_GREATER + if (numbers.Any(n => n < T.Zero)) +#else + if (numbers.Any(n => n < 0)) +#endif + throw new ArgumentOutOfRangeException( + nameof(numbers), + "Encoding is only supported for zero and positive numbers." + ); return Encode(numbers.AsSpan()); } @@ -176,8 +181,8 @@ public string Encode(params int[] numbers) /// /// The numbers to encode. /// A string containing the encoded IDs, or an empty string if the `IEnumerable` passed is empty. - /// If any of the integers passed is smaller than (i.e. negative) or greater than (i.e. `int.MaxValue`). - /// If the decoded number overflows integer. + /// If any of the numbers passed is smaller than 0 (i.e. negative). + /// If the encoding reaches maximum re-generation attempts due to the blocklist. #if NET7_0_OR_GREATER public string Encode(IEnumerable numbers) => #else @@ -187,11 +192,14 @@ public string Encode(IEnumerable numbers) => // TODO: Consider using `ArrayPool` if possible #if NET7_0_OR_GREATER - private string Encode(ReadOnlySpan numbers, bool partitioned = false) + private string Encode(ReadOnlySpan numbers, int increment = 0) #else - private string Encode(ReadOnlySpan numbers, bool partitioned = false) + private string Encode(ReadOnlySpan numbers, int increment = 0) #endif { + if (increment > _alphabet.Length) + throw new ArgumentException("Reached max attempts to re-generate the ID."); + int offset = 0; for (int i = 0; i < numbers.Length; i++) #if NET7_0_OR_GREATER @@ -199,8 +207,8 @@ private string Encode(ReadOnlySpan numbers, bool partitioned = false) #else offset += _alphabet[numbers[i] % _alphabet.Length] + i; #endif - offset = (numbers.Length + offset) % _alphabet.Length; + offset = (offset + increment) % _alphabet.Length; Span alphabetTemp = _alphabet.Length * sizeof(char) > MaxStackallocSize ? new char[_alphabet.Length] @@ -210,8 +218,7 @@ private string Encode(ReadOnlySpan numbers, bool partitioned = false) alphabetSpan[..offset].CopyTo(alphabetTemp[^offset..]); char prefix = alphabetTemp[0]; - char partition = alphabetTemp[1]; - alphabetTemp = alphabetTemp[2..]; + alphabetTemp.Reverse(); var builder = new StringBuilder(); // TODO: pool a la Hashids.net? builder.Append(prefix); @@ -219,104 +226,35 @@ private string Encode(ReadOnlySpan numbers, bool partitioned = false) for (int i = 0; i < numbers.Length; i++) { var number = numbers[i]; - var alphabetWithoutSeparator = alphabetTemp[..^1]; + var alphabetWithoutSeparator = alphabetTemp[1..]; // NOTE: Excludes the first character — which is the separator var encodedNumber = ToId(number, alphabetWithoutSeparator); builder.Append(encodedNumber); if (i >= numbers.Length - 1) // NOTE: If the last one continue; - char separator = alphabetTemp[^1]; // NOTE: Exclude the last character - - builder.Append( - partitioned && i == 0 - ? partition - : separator - ); - + char separator = alphabetTemp[0]; + builder.Append(separator); ConsistentShuffle(alphabetTemp); } - string result = builder.ToString(); // TODO: Can't we get a span here as opposed to allocating a string? - - if (result.Length < _minLength) + if (builder.Length < _minLength) { - if (!partitioned) - { -#if NET7_0_OR_GREATER - Span newNumbers = (numbers.Length + 1) * sizeof(long) > MaxStackallocSize - ? new T[numbers.Length + 1] - : stackalloc T[numbers.Length + 1]; - - newNumbers[0] = T.Zero; -#else - Span newNumbers = (numbers.Length + 1) * sizeof(int) > MaxStackallocSize - ? new int[numbers.Length + 1] - : stackalloc int[numbers.Length + 1]; - - newNumbers[0] = 0; -#endif - - numbers.CopyTo(newNumbers[1..]); - result = Encode(newNumbers, partitioned: true); - } + char separator = alphabetTemp[0]; + builder.Append(separator); - if (result.Length < _minLength) + while (builder.Length < _minLength) { - var leftToMeetMinLength = _minLength - result.Length; - var paddingFromAlphabet = alphabetTemp[..leftToMeetMinLength]; - builder.Insert(1, paddingFromAlphabet); - result = builder.ToString(); + ConsistentShuffle(alphabetTemp); + int toIndex = Math.Min(_minLength - builder.Length, _alphabet.Length); + builder.Append(alphabetTemp[..toIndex]); } } - if (IsBlockedId(result.AsSpan())) - { -#if NET7_0_OR_GREATER - Span newNumbers = numbers.Length * sizeof(long) > MaxStackallocSize - ? new T[numbers.Length] - : stackalloc T[numbers.Length]; -#else - Span newNumbers = numbers.Length * sizeof(int) > MaxStackallocSize - ? new int[numbers.Length] - : stackalloc int[numbers.Length]; -#endif - numbers.CopyTo(newNumbers); - - if (partitioned) - { -#if NET7_0_OR_GREATER - if (numbers[0] + T.One > MaxValue) - throw new OverflowException("Ran out of range checking against the blocklist."); - else - newNumbers[0] += T.One; -#else - if (numbers[0] + 1 > MaxValue) - throw new OverflowException("Ran out of range checking against the blocklist."); - else - newNumbers[0] += 1; -#endif - } - else - { -#if NET7_0_OR_GREATER - newNumbers = (numbers.Length + 1) * sizeof(long) > MaxStackallocSize - ? new T[numbers.Length + 1] - : stackalloc T[numbers.Length + 1]; - - newNumbers[0] = T.Zero; -#else - newNumbers = (numbers.Length + 1) * sizeof(int) > MaxStackallocSize - ? new int[numbers.Length + 1] - : stackalloc int[numbers.Length + 1]; + string result = builder.ToString(); - newNumbers[0] = 0; -#endif - numbers.CopyTo(newNumbers[1..]); - } - - result = Encode(newNumbers, partitioned: true); - } + if (IsBlockedId(result.AsSpan())) + result = Encode(numbers, increment + 1); return result; } @@ -326,7 +264,7 @@ private string Encode(ReadOnlySpan numbers, bool partitioned = false) /// /// The encoded ID. /// - /// An array of integers containing the decoded number(s) (it would contain only one element + /// An array containing the decoded number(s) (it would contain only one element /// if the ID represents a single number); or an empty array if the input ID is null, /// empty, or includes characters not found in the alphabet. /// @@ -362,16 +300,9 @@ public int[] Decode(ReadOnlySpan id) alphabetSpan[offset..].CopyTo(alphabetTemp[..^offset]); alphabetSpan[..offset].CopyTo(alphabetTemp[^offset..]); - char partition = alphabetTemp[1]; - alphabetTemp = alphabetTemp[2..]; - id = id[1..]; + alphabetTemp.Reverse(); - int partitionIndex = id.IndexOf(partition); - if (partitionIndex > 0 && partitionIndex < id.Length - 1) - { - id = id[(partitionIndex + 1)..]; - ConsistentShuffle(alphabetTemp); - } + id = id[1..]; // NOTE: Exclude the prefix #if NET7_0_OR_GREATER var result = new List(); @@ -380,25 +311,16 @@ public int[] Decode(ReadOnlySpan id) #endif while (!id.IsEmpty) { - char separator = alphabetTemp[^1]; + char separator = alphabetTemp[0]; var separatorIndex = id.IndexOf(separator); var chunk = separatorIndex == -1 ? id : id[..separatorIndex]; // NOTE: The first part of `id` (every thing to the left of the separator) represents the number that we ought to decode. id = separatorIndex == -1 ? default : id[(separatorIndex + 1)..]; // NOTE: Everything to the right of the separator will be `id` for the next iteration if (chunk.IsEmpty) - continue; - - var alphabetWithoutSeparator = alphabetTemp[..^1]; // NOTE: Exclude the last character from the alphabet (which is the separator) - - foreach (char c in chunk) - if (!alphabetWithoutSeparator.Contains(c)) -#if NET7_0_OR_GREATER - return Array.Empty(); -#else - return Array.Empty(); -#endif + return result.ToArray(); + var alphabetWithoutSeparator = alphabetTemp[1..]; // NOTE: Exclude the first character — which is the separator var decodedNumber = ToNumber(chunk, alphabetWithoutSeparator); result.Add(decodedNumber); @@ -416,7 +338,7 @@ public int[] Decode(ReadOnlySpan id) /// /// The encoded ID. /// - /// An array of integers containing the decoded number(s) (it would contain only one element + /// An array containing the decoded number(s) (it would contain only one element /// if the ID represents a single number); or an empty array if the input ID is null, /// empty, or includes characters not found in the alphabet. /// diff --git a/src/Sqids/SqidsOptions.cs b/src/Sqids/SqidsOptions.cs index 1d85d9a..1988c25 100644 --- a/src/Sqids/SqidsOptions.cs +++ b/src/Sqids/SqidsOptions.cs @@ -25,13 +25,14 @@ public sealed class SqidsOptions /// /// The minimum length for the IDs. /// The default is 0; meaning the IDs will be as short as possible. + /// 255 is the maximum. /// public int MinLength { get; set; } = 0; /// /// List of blocked words that must not appear in the IDs. /// - public HashSet BlockList { get; set; } = new() // todo: should this be a hash set? we don't do lookups on it, we merely iterate over it + public HashSet BlockList { get; set; } = new() { "0rgasm", "1d10t", diff --git a/test/Sqids.Tests/AlphabetTests.cs b/test/Sqids.Tests/AlphabetTests.cs index 42cbd82..31ab9b3 100644 --- a/test/Sqids.Tests/AlphabetTests.cs +++ b/test/Sqids.Tests/AlphabetTests.cs @@ -2,7 +2,7 @@ namespace Sqids.Tests; public class AlphabetTests { - [TestCase("0123456789abcdef", new[] { 1, 2, 3 }, "4d9fd2")] + [TestCase("0123456789abcdef", new[] { 1, 2, 3 }, "489158")] public void EncodeAndDecode_WithCustomAlphabet_ReturnsExactMatch( string alphabet, int[] numbers, @@ -20,8 +20,8 @@ string id sqids.Decode(id).ShouldBeEquivalentTo(numbers); } - [TestCase("abcde", new[] { 1, 2, 3 })] // NOTE: Short alphabet - [TestCase("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_+|{}[];:'\"/?.>,<`~", new[] { 1, 2, 3 })] // NOTE: Long short + [TestCase("abc", new[] { 1, 2, 3 })] // NOTE: Shortest possible alphabet + [TestCase("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_+|{}[];:'\"/?.>,<`~", new[] { 1, 2, 3 })] // NOTE: Long alphabet public void EncodeAndDecode_WithCustomAlphabet_RoundTripsSuccessfully( string alphabet, int[] numbers @@ -38,7 +38,8 @@ int[] numbers } [TestCase("aabcdefg")] // NOTE: Repeated characters - [TestCase("abcd")] // NOTE: Too short + [TestCase("ab")] // NOTE: Too short + [TestCase("ë1092")] // NOTE: Contains a multi-byte character public void Instantiate_WithInvalidAlphabet_Throws(string invalidAlphabet) { #if NET7_0_OR_GREATER @@ -46,6 +47,6 @@ public void Instantiate_WithInvalidAlphabet_Throws(string invalidAlphabet) #else var act = () => new SqidsEncoder(new() { Alphabet = invalidAlphabet }); #endif - act.ShouldThrow(); + act.ShouldThrow(); } } diff --git a/test/Sqids.Tests/BlockListTests.cs b/test/Sqids.Tests/BlockListTests.cs index 080b1dc..a67da77 100644 --- a/test/Sqids.Tests/BlockListTests.cs +++ b/test/Sqids.Tests/BlockListTests.cs @@ -11,8 +11,8 @@ public void EncodeAndDecode_WithDefaultBlockList_BlocksWordsInDefaultBlockList() var sqids = new SqidsEncoder(); #endif - sqids.Decode("sexy").ShouldBeEquivalentTo(new[] { 200044 }); - sqids.Encode(200044).ShouldBe("d171vI"); + sqids.Decode("aho1e").ShouldBeEquivalentTo(new[] { 4572721 }); + sqids.Encode(4572721).ShouldBe("JExTR"); } [Test] @@ -27,8 +27,8 @@ public void EncodeAndDecode_WithEmptyBlockList_DoesNotBlockWords() BlockList = new(), }); - sqids.Decode("sexy").ShouldBeEquivalentTo(new[] { 200044 }); - sqids.Encode(200044).ShouldBe("sexy"); + sqids.Decode("aho1e").ShouldBeEquivalentTo(new[] { 4572721 }); + sqids.Encode(4572721).ShouldBe("aho1e"); } [Test] @@ -42,18 +42,18 @@ public void EncodeAndDecode_WithCustomBlockList_OnlyBlocksWordsInCustomBlockList { BlockList = new() { - "AvTg" // NOTE: The default encoding of 100000. + "ArUO" // NOTE: The default encoding of 100000. }, }); // NOTE: Make sure the default blocklist isn't used - sqids.Decode("sexy").ShouldBeEquivalentTo(new[] { 200044 }); - sqids.Encode(200044).ShouldBe("sexy"); + sqids.Decode("aho1e").ShouldBeEquivalentTo(new[] { 4572721 }); + sqids.Encode(4572721).ShouldBe("aho1e"); // NOTE: Make sure the passed blocklist IS used: - sqids.Decode("AvTg").ShouldBeEquivalentTo(new[] { 100000 }); - sqids.Encode(100000).ShouldBe("7T1X8k"); - sqids.Decode("7T1X8k").ShouldBeEquivalentTo(new[] { 100000 }); + sqids.Decode("ArUO").ShouldBeEquivalentTo(new[] { 100000 }); + sqids.Encode(100000).ShouldBe("QyG4"); + sqids.Decode("QyG4").ShouldBeEquivalentTo(new[] { 100000 }); } [Test] @@ -67,16 +67,16 @@ public void EncodeAndDecode_WithBlockListBlockingMultipleEncodings_RespectsBlock { BlockList = new() { - "8QRLaD", // normal result of 1st encoding, let's block that word on purpose - "7T1cd0dL", // result of 2nd encoding - "UeIe", // result of 3rd encoding is `RA8UeIe7`, let's block a substring - "imhw", // result of 4th encoding is `WM3Limhw`, let's block the postfix - "LfUQ", // result of 4th encoding is `LfUQh4HN`, let's block the prefix + "JSwXFaosAN", // normal result of 1st encoding, let's block that word on purpose + "OCjV9JK64o", // result of 2nd encoding + "rBHf", // result of 3rd encoding is `4rBHfOiqd3`, let's block a substring + "79SM", // result of 4th encoding is `dyhgw479SM`, let's block the postfix + "7tE6", // result of 4th encoding is `7tE6jdAHLe`, let's block the prefix }, }); - sqids.Encode(1, 2, 3).ShouldBe("TM0x1Mxz"); - sqids.Decode("TM0x1Mxz").ShouldBeEquivalentTo(new[] { 1, 2, 3 }); + sqids.Encode(1_000_000, 2_000_000).ShouldBe("1aYeB7bRUt"); + sqids.Decode("1aYeB7bRUt").ShouldBeEquivalentTo(new[] { 1_000_000, 2_000_000 }); } [Test] @@ -90,19 +90,19 @@ public void Decode_BlockedIds_StillDecodesSuccessfully() { BlockList = new() { - "8QRLaD", - "7T1cd0dL", - "RA8UeIe7", - "WM3Limhw", - "LfUQh4HN", + "86Rf07", + "se8ojk", + "ARsz1p", + "Q8AI49", + "5sQRZO", }, }); - sqids.Decode("8QRLaD").ShouldBeEquivalentTo(new[] { 1, 2, 3 }); - sqids.Decode("7T1cd0dL").ShouldBeEquivalentTo(new[] { 1, 2, 3 }); - sqids.Decode("RA8UeIe7").ShouldBeEquivalentTo(new[] { 1, 2, 3 }); - sqids.Decode("WM3Limhw").ShouldBeEquivalentTo(new[] { 1, 2, 3 }); - sqids.Decode("LfUQh4HN").ShouldBeEquivalentTo(new[] { 1, 2, 3 }); + sqids.Decode("86Rf07").ShouldBeEquivalentTo(new[] { 1, 2, 3 }); + sqids.Decode("se8ojk").ShouldBeEquivalentTo(new[] { 1, 2, 3 }); + sqids.Decode("ARsz1p").ShouldBeEquivalentTo(new[] { 1, 2, 3 }); + sqids.Decode("Q8AI49").ShouldBeEquivalentTo(new[] { 1, 2, 3 }); + sqids.Decode("5sQRZO").ShouldBeEquivalentTo(new[] { 1, 2, 3 }); } [Test] @@ -116,7 +116,7 @@ public void EncodeAndDecode_WithShortCustomBlockList_RoundTripsSuccessfully() { BlockList = new() { - "pPQ", // NOTE: This is the default encoding of `1000`. + "pnd", // NOTE: This is the default encoding of `1000` — and blocklist words with three characters are the shortest possible }, }); @@ -135,11 +135,34 @@ public void EncodeAndDecode_WithLowerCaseBlockListAndUpperCaseAlphabet_IgnoresCa Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ", BlockList = new() { - "sqnmpn", // NOTE: The uppercase version of this is the default encoding of [1,2,3] + "sxnzkl", // NOTE: The uppercase version of this is the default encoding of [1,2,3] }, }); - sqids.Encode(1, 2, 3).ShouldBe("ULPBZGBM"); // NOTE: Without the blocklist, would've been "SQNMPN". - sqids.Decode("ULPBZGBM").ShouldBeEquivalentTo(new[] { 1, 2, 3 }); + sqids.Encode(1, 2, 3).ShouldBe("IBSHOZ"); // NOTE: Without the blocklist, would've been "SQNMPN". + sqids.Decode("IBSHOZ").ShouldBeEquivalentTo(new[] { 1, 2, 3 }); + } + + [Test] + public void Encode_WithNumerousEncodingsBlocked_ThrowsExceptionForReachingMaxReEncodingAttempts() + { +#if NET7_0_OR_GREATER + var sqids = new SqidsEncoder(new() +#else + var sqids = new SqidsEncoder(new() +#endif + { + Alphabet = "abc", + MinLength = 3, + BlockList = new() + { + "cab", + "abc", + "bca", + }, + }); + + var act = () => sqids.Encode(0); + act.ShouldThrow(); // TODO: It might be better if we actually check the exception messages too, to make sure it threw exactly the specific exception we expected. } } diff --git a/test/Sqids.Tests/EncodingTests.cs b/test/Sqids.Tests/EncodingTests.cs index a5df5b3..f211983 100644 --- a/test/Sqids.Tests/EncodingTests.cs +++ b/test/Sqids.Tests/EncodingTests.cs @@ -7,16 +7,16 @@ namespace Sqids.Tests; public class EncodingTests { // NOTE: Incremental - [TestCase(0, "bV")] - [TestCase(1, "U9")] - [TestCase(2, "g8")] - [TestCase(3, "Ez")] - [TestCase(4, "V8")] - [TestCase(5, "ul")] - [TestCase(6, "O3")] - [TestCase(7, "AF")] - [TestCase(8, "ph")] - [TestCase(9, "n8")] + [TestCase(0, "bM")] + [TestCase(1, "Uk")] + [TestCase(2, "gb")] + [TestCase(3, "Ef")] + [TestCase(4, "Vq")] + [TestCase(5, "uw")] + [TestCase(6, "OI")] + [TestCase(7, "AX")] + [TestCase(8, "p6")] + [TestCase(9, "nJ")] public void EncodeAndDecode_SingleNumber_ReturnsExactMatch(int number, string id) { #if NET7_0_OR_GREATER @@ -30,29 +30,29 @@ public void EncodeAndDecode_SingleNumber_ReturnsExactMatch(int number, string id } // NOTE: Simple case - [TestCase(new[] { 1, 2, 3 }, "8QRLaD")] + [TestCase(new[] { 1, 2, 3 }, "86Rf07")] // NOTE: Incremental - [TestCase(new[] { 0, 0 }, "SrIu")] - [TestCase(new[] { 0, 1 }, "nZqE")] - [TestCase(new[] { 0, 2 }, "tJyf")] - [TestCase(new[] { 0, 3 }, "e86S")] - [TestCase(new[] { 0, 4 }, "rtC7")] - [TestCase(new[] { 0, 5 }, "sQ8R")] - [TestCase(new[] { 0, 6 }, "uz2n")] - [TestCase(new[] { 0, 7 }, "7Td9")] - [TestCase(new[] { 0, 8 }, "3nWE")] - [TestCase(new[] { 0, 9 }, "mIxM")] + [TestCase(new[] { 0, 0 }, "SvIz")] + [TestCase(new[] { 0, 1 }, "n3qa")] + [TestCase(new[] { 0, 2 }, "tryF")] + [TestCase(new[] { 0, 3 }, "eg6q")] + [TestCase(new[] { 0, 4 }, "rSCF")] + [TestCase(new[] { 0, 5 }, "sR8x")] + [TestCase(new[] { 0, 6 }, "uY2M")] + [TestCase(new[] { 0, 7 }, "74dI")] + [TestCase(new[] { 0, 8 }, "30WX")] + [TestCase(new[] { 0, 9 }, "moxr")] // NOTE: Incremental - [TestCase(new[] { 0, 0 }, "SrIu")] - [TestCase(new[] { 1, 0 }, "nbqh")] - [TestCase(new[] { 2, 0 }, "t4yj")] - [TestCase(new[] { 3, 0 }, "eQ6L")] - [TestCase(new[] { 4, 0 }, "r4Cc")] - [TestCase(new[] { 5, 0 }, "sL82")] - [TestCase(new[] { 6, 0 }, "uo2f")] - [TestCase(new[] { 7, 0 }, "7Zdq")] - [TestCase(new[] { 8, 0 }, "36Wf")] - [TestCase(new[] { 9, 0 }, "m4xT")] + [TestCase(new[] { 0, 0 }, "SvIz")] + [TestCase(new[] { 1, 0 }, "nWqP")] + [TestCase(new[] { 2, 0 }, "tSyw")] + [TestCase(new[] { 3, 0 }, "eX68")] + [TestCase(new[] { 4, 0 }, "rxCY")] + [TestCase(new[] { 5, 0 }, "sV8a")] + [TestCase(new[] { 6, 0 }, "uf2K")] + [TestCase(new[] { 7, 0 }, "7Cdk")] + [TestCase(new[] { 8, 0 }, "3aWP")] + [TestCase(new[] { 9, 0 }, "m2xn")] // NOTE: Empty array should encode into empty string [TestCase(new int[] { }, "")] public void EncodeAndDecode_MultipleNumbers_ReturnsExactMatch(int[] numbers, string id) @@ -68,7 +68,15 @@ public void EncodeAndDecode_MultipleNumbers_ReturnsExactMatch(int[] numbers, str sqids.Decode(id).ShouldBeEquivalentTo(numbers); } - [TestCaseSource(nameof(MultipleNumbersTestCaseSource))] + [TestCase(new[] { 0, 0, 0, 1, 2, 3, 100, 1_000, 100_000, 1_000_000, int.MaxValue })] + [TestCase(new[] + { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, + 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, + 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, + 94, 95, 96, 97, 98, 99 + })] public void EncodeAndDecode_MultipleNumbers_RoundTripsSuccessfully(int[] numbers) { #if NET7_0_OR_GREATER @@ -80,30 +88,7 @@ public void EncodeAndDecode_MultipleNumbers_RoundTripsSuccessfully(int[] numbers sqids.Decode(sqids.Encode(numbers)).ShouldBeEquivalentTo(numbers); } - private static int[][] MultipleNumbersTestCaseSource => new[] - { -#if NET7_0_OR_GREATER - new[] - { - 0, 0, 0, 1, 2, 3, 100, 1_000, 100_000, 1_000_000, SqidsEncoder.MaxValue - }, -#else - new[] - { - 0, 0, 0, 1, 2, 3, 100, 1_000, 100_000, 1_000_000, SqidsEncoder.MaxValue - }, -#endif - new[] { - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, - 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, - 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, - 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, - 94, 95, 96, 97, 98, 99 - } - }; - [TestCase("*")] // NOTE: Character not found in the alphabet - [TestCase("fff")] // NOTE: Repeating reserved character public void Decode_WithInvalidCharacters_ReturnsEmptyArray(string id) { #if NET7_0_OR_GREATER @@ -120,11 +105,10 @@ public void Encode_OutOfRangeNumber_Throws() { #if NET7_0_OR_GREATER var sqids = new SqidsEncoder(); - var act = () => sqids.Encode(SqidsEncoder.MinValue - 1); #else var sqids = new SqidsEncoder(); - var act = () => sqids.Encode(SqidsEncoder.MinValue - 1); #endif + var act = () => sqids.Encode(-1); act.ShouldThrow(); // NOTE: We don't check for `MaxValue + 1` because that's a compile time error anyway } @@ -155,16 +139,16 @@ T[] numbers sqids.Decode(sqids.Encode(numbers)).ShouldBeEquivalentTo(numbers); } - private static TestCaseData[] MultipleNumbersOfDifferentIntegerTypesTestCaseSource => new[] + private static TestCaseData[] MultipleNumbersOfDifferentIntegerTypesTestCaseSource => new TestCaseData[] { - new TestCaseData(GenerateMultipleNumbersOfType()), - new TestCaseData(GenerateMultipleNumbersOfType()), - new TestCaseData(GenerateMultipleNumbersOfType()), - new TestCaseData(GenerateMultipleNumbersOfType()), - new TestCaseData(GenerateMultipleNumbersOfType()), - new TestCaseData(GenerateMultipleNumbersOfType()), - new TestCaseData(GenerateMultipleNumbersOfType()), - new TestCaseData(GenerateMultipleNumbersOfType()), + new(GenerateMultipleNumbersOfType()), + new(GenerateMultipleNumbersOfType()), + new(GenerateMultipleNumbersOfType()), + new(GenerateMultipleNumbersOfType()), + new(GenerateMultipleNumbersOfType()), + new(GenerateMultipleNumbersOfType()), + new(GenerateMultipleNumbersOfType()), + new(GenerateMultipleNumbersOfType()), }; private static T[] GenerateMultipleNumbersOfType() diff --git a/test/Sqids.Tests/MinLengthTests.cs b/test/Sqids.Tests/MinLengthTests.cs index 24389d8..d58cab7 100644 --- a/test/Sqids.Tests/MinLengthTests.cs +++ b/test/Sqids.Tests/MinLengthTests.cs @@ -2,33 +2,94 @@ namespace Sqids.Tests; public class MinLengthTests { - [TestCase(new[] { 1, 2, 3 }, "75JILToVsGerOADWmHlY38xvbaNZKQ9wdFS0B6kcMEtnRpgizhjU42qT1cd0dL")] - [TestCase(new[] { 0, 0 }, "jf26PLNeO5WbJDUV7FmMtlGXps3CoqkHnZ8cYd19yIiTAQuvKSExzhrRghBlwf")] - [TestCase(new[] { 0, 1 }, "vQLUq7zWXC6k9cNOtgJ2ZK8rbxuipBFAS10yTdYeRa3ojHwGnmMV4PDhESI2jL")] - [TestCase(new[] { 0, 2 }, "YhcpVK3COXbifmnZoLuxWgBQwtjsSaDGAdr0ReTHM16yI9vU8JNzlFq5Eu2oPp")] - [TestCase(new[] { 0, 3 }, "OTkn9daFgDZX6LbmfxI83RSKetJu0APihlsrYoz5pvQw7GyWHEUcN2jBqd4kJ9")] - [TestCase(new[] { 0, 4 }, "h2cV5eLNYj1x4ToZpfM90UlgHBOKikQFvnW36AC8zrmuJ7XdRytIGPawqYEbBe")] - [TestCase(new[] { 0, 5 }, "7Mf0HeUNkpsZOTvmcj836P9EWKaACBubInFJtwXR2DSzgYGhQV5i4lLxoT1qdU")] - [TestCase(new[] { 0, 6 }, "APVSD1ZIY4WGBK75xktMfTev8qsCJw6oyH2j3OnLcXRlhziUmpbuNEar05QCsI")] - [TestCase(new[] { 0, 7 }, "P0LUhnlT76rsWSofOeyRGQZv1cC5qu3dtaJYNEXwk8Vpx92bKiHIz4MgmiDOF7")] - [TestCase(new[] { 0, 8 }, "xAhypZMXYIGCL4uW0te6lsFHaPc3SiD1TBgw5O7bvodzjqUn89JQRfk2Nvm4JI")] - [TestCase(new[] { 0, 9 }, "94dRPIZ6irlXWvTbKywFuAhBoECQOVMjDJp53s2xeqaSzHY8nc17tmkLGwfGNl")] - public void EncodeAndDecode_WithMaximumMinLength_ReturnsExactMatch(int[] numbers, string id) + [TestCase(new[] { 1, 2, 3 }, "86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTM")] + [TestCase(new[] { 0, 0 }, "SvIzsqYMyQwI3GWgJAe17URxX8V924Co0DaTZLtFjHriEn5bPhcSkfmvOslpBu")] + [TestCase(new[] { 0, 1 }, "n3qafPOLKdfHpuNw3M61r95svbeJGk7aAEgYn4WlSjXURmF8IDqZBy0CT2VxQc")] + [TestCase(new[] { 0, 2 }, "tryFJbWcFMiYPg8sASm51uIV93GXTnvRzyfLleh06CpodJD42B7OraKtkQNxUZ")] + [TestCase(new[] { 0, 3 }, "eg6ql0A3XmvPoCzMlB6DraNGcWSIy5VR8iYup2Qk4tjZFKe1hbwfgHdUTsnLqE")] + [TestCase(new[] { 0, 4 }, "rSCFlp0rB2inEljaRdxKt7FkIbODSf8wYgTsZM1HL9JzN35cyoqueUvVWCm4hX")] + [TestCase(new[] { 0, 5 }, "sR8xjC8WQkOwo74PnglH1YFdTI0eaf56RGVSitzbjuZ3shNUXBrqLxEJyAmKv2")] + [TestCase(new[] { 0, 6 }, "uY2MYFqCLpgx5XQcjdtZK286AwWV7IBGEfuS9yTmbJvkzoUPeYRHr4iDs3naN0")] + [TestCase(new[] { 0, 7 }, "74dID7X28VLQhBlnGmjZrec5wTA1fqpWtK4YkaoEIM9SRNiC3gUJH0OFvsPDdy")] + [TestCase(new[] { 0, 8 }, "30WXpesPhgKiEI5RHTY7xbB1GnytJvXOl2p0AcUjdF6waZDo9Qk8VLzMuWrqCS")] + [TestCase(new[] { 0, 9 }, "moxr3HqLAK0GsTND6jowfZz3SUx7cQ8aC54Pl1RbIvFXmEJuBMYVeW9yrdOtin")] + public void EncodeAndDecode_WithHighMinLength_ReturnsExactMatch(int[] numbers, string id) { #if NET7_0_OR_GREATER - var sqids = new SqidsEncoder(new() { MinLength = new SqidsOptions().Alphabet.Length }); // NOTE: This is how we get the default alphabet + var sqids = new SqidsEncoder(new() #else - var sqids = new SqidsEncoder(new() { MinLength = new SqidsOptions().Alphabet.Length }); // NOTE: This is how we get the default alphabet + var sqids = new SqidsEncoder(new() #endif + { + MinLength = new SqidsOptions().Alphabet.Length // NOTE: This is how we get the default alphabet + }); sqids.Encode(numbers).ShouldBe(id); sqids.Decode(id).ShouldBeEquivalentTo(numbers); } + [TestCaseSource(nameof(IncrementalMinLengthsSource))] + public void EncodeAndDecode_WithIncrementalMinLengths_RespectsMinLengthAndRoundTripsSuccessfully( + int minLength, + string id + ) + { + var numbers = new[] { 1, 2, 3 }; // NOTE: Constant + +#if NET7_0_OR_GREATER + var sqids = new SqidsEncoder(new() +#else + var sqids = new SqidsEncoder(new() +#endif + { + MinLength = minLength + }); + + sqids.Encode(numbers).ShouldBe(id); + sqids.Encode(numbers).Length.ShouldBeGreaterThanOrEqualTo(minLength); + sqids.Decode(id).ShouldBeEquivalentTo(numbers); + } + private static TestCaseData[] IncrementalMinLengthsSource => new TestCaseData[] + { + new(6, "86Rf07"), + new(7, "86Rf07x"), + new(8, "86Rf07xd"), + new(9, "86Rf07xd4"), + new(10, "86Rf07xd4z"), + new(11, "86Rf07xd4zB"), + new(12, "86Rf07xd4zBm"), + new(13, "86Rf07xd4zBmi"), + new( + new SqidsOptions().Alphabet.Length, + "86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTM" + ), + new( + new SqidsOptions().Alphabet.Length + 1, + "86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMy" + ), + new( + new SqidsOptions().Alphabet.Length + 2, + "86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMyf" + ), + new( + new SqidsOptions().Alphabet.Length + 3, + "86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMyf1" + ) + }; + [Test, Combinatorial] public void EncodeAndDecode_WithDifferentMinLengths_RespectsMinLengthAndRoundTripsSuccessfully( [ValueSource(nameof(MinLengthsValueSource))] int minLength, - [ValueSource(nameof(NumbersValueSource))] int[] numbers + [Values( + new[] { 0 }, + new[] { 0, 0, 0, 0, 0 }, + new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, + new[] { 100, 200, 300 }, + new[] { 1_000, 2_000, 3_000 }, + new[] { 1_000_000 }, + new[] { int.MaxValue } + )] + int[] numbers ) { #if NET7_0_OR_GREATER @@ -45,26 +106,9 @@ public void EncodeAndDecode_WithDifferentMinLengths_RespectsMinLengthAndRoundTri { 0, 1, 5, 10, new SqidsOptions().Alphabet.Length // NOTE: We can't use `new SqidsOptions().Alphabet.Length` in the `[Values]` attribute since only constants are allowed for attribute arguments; so we have to use a value source like this. }; - private static int[][] NumbersValueSource => new[] - { -#if NET7_0_OR_GREATER - new[] { SqidsEncoder.MinValue }, -#else - new[] { SqidsEncoder.MinValue }, -#endif - new[] { 0, 0, 0, 0, 0 }, - new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, - new[] { 100, 200, 300 }, - new[] { 1_000, 2_000, 3_000 }, - new[] { 1_000_000 }, -#if NET7_0_OR_GREATER - new[] { SqidsEncoder.MaxValue } -#else - new[] { SqidsEncoder.MaxValue } -#endif - }; - [TestCaseSource(nameof(OutOfRangeMinLengthsTestCaseSource))] + [TestCase(-1)] // NOTE: Negative min lengths are not acceptable + [TestCase(256)] // NOTE: Max min length is 255 public void Instantiate_WithOutOfRangeMinLength_Throws(int outOfRangeMinLength) { #if NET7_0_OR_GREATER @@ -72,10 +116,6 @@ public void Instantiate_WithOutOfRangeMinLength_Throws(int outOfRangeMinLength) #else var act = () => new SqidsEncoder(new() { MinLength = outOfRangeMinLength }); #endif - act.ShouldThrow(); + act.ShouldThrow(); } - private static int[] OutOfRangeMinLengthsTestCaseSource => new[] - { - -1, new SqidsOptions().Alphabet.Length + 1 // NOTE: We can't use `new SqidsOptions().Alphabet.Length` in the `[TestCase]` attribute since only constants are allowed for attribute arguments; so we have to use a value source like this. - }; }