diff --git a/README.md b/README.md index af8ec5b..661e628 100644 --- a/README.md +++ b/README.md @@ -52,32 +52,31 @@ Simple encode & decode: [embedmd]:# (examples/sqids-encode-decode/sqids-encode-decode.go /.+sqids.New/ /\[1, 2, 3\]/) ```go s, _ := sqids.New() - id, _ := s.Encode([]uint64{1, 2, 3}) // "8QRLaD" + id, _ := s.Encode([]uint64{1, 2, 3}) // "86Rf07" numbers := s.Decode(id) // [1, 2, 3] ``` > **Note** > 🚧 Because of the algorithm's design, **multiple IDs can decode back into the same sequence of numbers**. If it's important to your design that IDs are canonical, you have to manually re-encode decoded numbers and check that the generated ID matches. -Randomize IDs by providing a custom alphabet: +Enforce a *minimum* length for IDs: -[embedmd]:# (examples/sqids-custom-alphabet/sqids-custom-alphabet.go /.+sqids.New/ /\[1, 2, 3\]/) +[embedmd]:# (examples/sqids-minimum-length/sqids-minimum-length.go /.+sqids.New/ /\[1, 2, 3\]/) ```go s, _ := sqids.New(sqids.Options{ - Alphabet: "FxnXM1kBN6cuhsAvjW3Co7l2RePyY8DwaU04Tzt9fHQrqSVKdpimLGIJOgb5ZE", + MinLength: 10, }) - id, _ := s.Encode([]uint64{1, 2, 3}) // "B5aMa3" + id, _ := s.Encode([]uint64{1, 2, 3}) // "86Rf07xd4z" numbers := s.Decode(id) // [1, 2, 3] ``` +Randomize IDs by providing a custom alphabet: -Enforce a *minimum* length for IDs: - -[embedmd]:# (examples/sqids-minimum-length/sqids-minimum-length.go /.+sqids.New/ /\[1, 2, 3\]/) +[embedmd]:# (examples/sqids-custom-alphabet/sqids-custom-alphabet.go /.+sqids.New/ /\[1, 2, 3\]/) ```go s, _ := sqids.New(sqids.Options{ - MinLength: 10, + Alphabet: "FxnXM1kBN6cuhsAvjW3Co7l2RePyY8DwaU04Tzt9fHQrqSVKdpimLGIJOgb5ZE", }) - id, _ := s.Encode([]uint64{1, 2, 3}) // "75JT1cd0dL" + id, _ := s.Encode([]uint64{1, 2, 3}) // "B4aajs" numbers := s.Decode(id) // [1, 2, 3] ``` @@ -86,9 +85,9 @@ Prevent specific words from appearing anywhere in the auto-generated IDs: [embedmd]:# (examples/sqids-blocklist/sqids-blocklist.go /.+sqids.New/ /\[1, 2, 3\]/) ```go s, _ := sqids.New(sqids.Options{ - Blocklist: []string{"word1", "word2"}, + Blocklist: []string{"86Rf07"}, }) - id, _ := s.Encode([]uint64{1, 2, 3}) // "8QRLaD" + id, _ := s.Encode([]uint64{1, 2, 3}) // "se8ojk" numbers := s.Decode(id) // [1, 2, 3] ``` diff --git a/alphabet_test.go b/alphabet_test.go index 6c7c907..4a3d62a 100644 --- a/alphabet_test.go +++ b/alphabet_test.go @@ -17,7 +17,7 @@ func TestMultibyteAlphabet(t *testing.T) { func TestAlphabetSimple(t *testing.T) { numbers := []uint64{1, 2, 3} - id := "4d9fd2" + id := "489158" alphabet := "0123456789abcdef" s, err := New(Options{ @@ -44,7 +44,7 @@ func TestAlphabetSimple(t *testing.T) { func TestAlphabetShort(t *testing.T) { s, err := New(Options{ - Alphabet: "abcde", + Alphabet: "abc", }) if err != nil { t.Fatal(err) @@ -94,7 +94,7 @@ func TestRepeatingAlphabetCharacters(t *testing.T) { func TestTooShortOfAnAlphabet(t *testing.T) { if _, err := New(Options{ - Alphabet: "abcd", + Alphabet: "ab", }); err == nil { t.Errorf("Should not accept too short of an alphabet") } diff --git a/blocklist_test.go b/blocklist_test.go index 3ab3eef..d1b38fd 100644 --- a/blocklist_test.go +++ b/blocklist_test.go @@ -21,9 +21,9 @@ func TestBlocklist(t *testing.T) { } func TestBlocklistDefault(t *testing.T) { - numbers := []uint64{200044} - blockedID := "sexy" - unblockedID := "d171vI" + numbers := []uint64{4572721} + blockedID := "aho1e" + unblockedID := "JExTR" s, err := New() if err != nil { @@ -45,8 +45,8 @@ func TestBlocklistDefault(t *testing.T) { } func TestBlocklistEmpty(t *testing.T) { - numbers := []uint64{200044} - id := "sexy" + numbers := []uint64{4572721} + id := "aho1e" s, err := New(Options{ Blocklist: []string{}, @@ -70,12 +70,12 @@ func TestBlocklistEmpty(t *testing.T) { } func TestBlocklistNonEmpty(t *testing.T) { - numbers := []uint64{200044} - id := "sexy" + numbers := []uint64{4572721} + id := "aho1e" s, err := New(Options{ Blocklist: []string{ - "AvTg", // originally encoded [100000] + "ArUO", // originally encoded [100000] }, }) if err != nil { @@ -98,7 +98,7 @@ func TestBlocklistNonEmpty(t *testing.T) { } // make sure we are using the passed blocklist - decodedNumbers = s.Decode("AvTg") + decodedNumbers = s.Decode("ArUO") if !reflect.DeepEqual([]uint64{100_000}, decodedNumbers) { t.Errorf("Decoding `%v` should produce `%v`, but instead produced `%v`", id, []uint64{100_000}, decodedNumbers) } @@ -108,27 +108,27 @@ func TestBlocklistNonEmpty(t *testing.T) { t.Fatal(err) } - if generatedID != "7T1X8k" { - t.Errorf("Encoding `%v` should produce `%v`, but instead produced `%v`", []uint64{100_000}, "7T1X8k", generatedID) + if generatedID != "QyG4" { + t.Errorf("Encoding `%v` should produce `%v`, but instead produced `%v`", []uint64{100_000}, "QyG4", generatedID) } - decodedNumbers = s.Decode("7T1X8k") + decodedNumbers = s.Decode("QyG4") if !reflect.DeepEqual([]uint64{100_000}, decodedNumbers) { t.Errorf("Decoding `%v` should produce `%v`, but instead produced `%v`", id, []uint64{100_000}, decodedNumbers) } } func TestNewBlocklist(t *testing.T) { - numbers := []uint64{1, 2, 3} - id := "TM0x1Mxz" + numbers := []uint64{1000000, 2000000} + id := "1aYeB7bRUt" s, err := New(Options{ Blocklist: []string{ - "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 }, }) if err != nil { @@ -152,7 +152,7 @@ func TestNewBlocklist(t *testing.T) { func TestDecodingBlocklistedIDs(t *testing.T) { numbers := []uint64{1, 2, 3} - blocklist := []string{"8QRLaD", "7T1cd0dL", "RA8UeIe7", "WM3Limhw", "LfUQh4HN"} + blocklist := []string{"86Rf07", "se8ojk", "ARsz1p", "Q8AI49", "5sQRZO"} s, err := New(Options{ Blocklist: blocklist, @@ -173,7 +173,7 @@ func TestShortBlocklistMatch(t *testing.T) { numbers := []uint64{1_000} s, err := New(Options{ - Blocklist: []string{"pPQ"}, + Blocklist: []string{"pnd"}, }) if err != nil { @@ -193,11 +193,11 @@ func TestShortBlocklistMatch(t *testing.T) { func TestUpperCaseAlphabetBlocklistFiltering(t *testing.T) { numbers := []uint64{1, 2, 3} - id := "ULPBZGBM" + id := "IBSHOZ" s, err := New(Options{ Alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", - Blocklist: []string{"sqnmpn"}, // lowercase blocklist in only-uppercase alphabet + Blocklist: []string{"sxnzkl"}, // lowercase blocklist in only-uppercase alphabet }) if err != nil { t.Fatal(err) @@ -210,7 +210,7 @@ func TestUpperCaseAlphabetBlocklistFiltering(t *testing.T) { decodedNumbers := s.Decode(id) - // without blocklist, would've been "SQNMPN" + // without blocklist, would've been "SXNZKL" if id != generatedID { t.Errorf("Encoding `%v` should produce `%v`, but instead produced `%v`", numbers, id, generatedID) } @@ -220,6 +220,29 @@ func TestUpperCaseAlphabetBlocklistFiltering(t *testing.T) { } } +func TestMaxEncodingAttempts(t *testing.T) { + alphabet := "abc" + minLength := uint8(3) + blocklist := []string{"cab", "abc", "bca"} + + s, err := New(Options{ + Alphabet: alphabet, + MinLength: minLength, + Blocklist: blocklist, + }) + if err != nil { + t.Fatal(err) + } + + if len(alphabet) != int(minLength) || len(blocklist) != int(minLength) { + t.Errorf("`TestMaxEncodingAttempts` is not setup properly") + } + + if _, err := s.Encode([]uint64{0}); err == nil { + t.Errorf("Should throw error about max regeneration attempts") + } +} + func TestFilterBlocklist(t *testing.T) { t.Run("no words less than 3 chars", func(t *testing.T) { filtered := filterBlocklist("YESNO", []string{"yes", "no"}) diff --git a/encoding_test.go b/encoding_test.go index 0864ce3..b2d3fb4 100644 --- a/encoding_test.go +++ b/encoding_test.go @@ -27,6 +27,7 @@ func BenchmarkEncodeDecode(b *testing.B) { } func TestEncodingSimple(t *testing.T) { + id := "86Rf07" numbers := []uint64{1, 2, 3} s, err := New() @@ -34,12 +35,16 @@ func TestEncodingSimple(t *testing.T) { t.Fatal(err) } - id, err := s.Encode(numbers) + generatedID, err := s.Encode(numbers) if err != nil { t.Fatal(err) } - decodedNumbers := s.Decode(id) + if id != generatedID { + t.Errorf("Encoding `%v` should produce `%v`, but instead produced `%v`", numbers, id, generatedID) + } + + decodedNumbers := s.Decode(generatedID) if !reflect.DeepEqual(numbers, decodedNumbers) { t.Errorf("Could not encode/decode `%v`", numbers) } @@ -71,16 +76,16 @@ func TestEncodingIncrementalNumbers(t *testing.T) { } ids := map[string][]uint64{ - "bV": {0}, - "U9": {1}, - "g8": {2}, - "Ez": {3}, - "V8": {4}, - "ul": {5}, - "O3": {6}, - "AF": {7}, - "ph": {8}, - "n8": {9}, + "bM": {0}, + "Uk": {1}, + "gb": {2}, + "Ef": {3}, + "Vq": {4}, + "uw": {5}, + "OI": {6}, + "AX": {7}, + "p6": {8}, + "nJ": {9}, } for id, numbers := range ids { @@ -107,16 +112,16 @@ func TestEncodingIncrementalNumbersSameIndex0(t *testing.T) { } ids := map[string][]uint64{ - "SrIu": {0, 0}, - "nZqE": {0, 1}, - "tJyf": {0, 2}, - "e86S": {0, 3}, - "rtC7": {0, 4}, - "sQ8R": {0, 5}, - "uz2n": {0, 6}, - "7Td9": {0, 7}, - "3nWE": {0, 8}, - "mIxM": {0, 9}, + "SvIz": {0, 0}, + "n3qa": {0, 1}, + "tryF": {0, 2}, + "eg6q": {0, 3}, + "rSCF": {0, 4}, + "sR8x": {0, 5}, + "uY2M": {0, 6}, + "74dI": {0, 7}, + "30WX": {0, 8}, + "moxr": {0, 9}, } for id, numbers := range ids { @@ -143,16 +148,16 @@ func TestEncodingIncrementalNumbersSameIndex1(t *testing.T) { } ids := map[string][]uint64{ - "SrIu": {0, 0}, - "nbqh": {1, 0}, - "t4yj": {2, 0}, - "eQ6L": {3, 0}, - "r4Cc": {4, 0}, - "sL82": {5, 0}, - "uo2f": {6, 0}, - "7Zdq": {7, 0}, - "36Wf": {8, 0}, - "m4xT": {9, 0}, + "SvIz": {0, 0}, + "nWqP": {1, 0}, + "tSyw": {2, 0}, + "eX68": {3, 0}, + "rxCY": {4, 0}, + "sV8a": {5, 0}, + "uf2K": {6, 0}, + "7Cdk": {7, 0}, + "3aWP": {8, 0}, + "m2xn": {9, 0}, } for id, numbers := range ids { @@ -234,16 +239,5 @@ func TestEncodingInvalidCharacter(t *testing.T) { } } -func TestDecodeInvalidIDWithRepeatingReservedCharacter(t *testing.T) { - s, err := New() - if err != nil { - t.Fatal(err) - } - - if !reflect.DeepEqual(s.Decode("fff"), []uint64{}) { - t.Errorf("Could not decode invalid ID with repeating reserved character") - } -} - // TestEncodingOutOfRange - no need since `[]uint64` handles ranges // func TestEncodingOutOfRange(t *testing.T) {} diff --git a/examples/sqids-blocklist/sqids-blocklist.go b/examples/sqids-blocklist/sqids-blocklist.go index 8c7dd23..fdf2d07 100644 --- a/examples/sqids-blocklist/sqids-blocklist.go +++ b/examples/sqids-blocklist/sqids-blocklist.go @@ -8,9 +8,9 @@ import ( func main() { s, _ := sqids.New(sqids.Options{ - Blocklist: []string{"word1", "word2"}, + Blocklist: []string{"86Rf07"}, }) - id, _ := s.Encode([]uint64{1, 2, 3}) // "8QRLaD" + id, _ := s.Encode([]uint64{1, 2, 3}) // "se8ojk" numbers := s.Decode(id) // [1, 2, 3] fmt.Println(id, numbers) diff --git a/examples/sqids-custom-alphabet/sqids-custom-alphabet.go b/examples/sqids-custom-alphabet/sqids-custom-alphabet.go index c876d0d..df3ac9b 100644 --- a/examples/sqids-custom-alphabet/sqids-custom-alphabet.go +++ b/examples/sqids-custom-alphabet/sqids-custom-alphabet.go @@ -10,7 +10,7 @@ func main() { s, _ := sqids.New(sqids.Options{ Alphabet: "FxnXM1kBN6cuhsAvjW3Co7l2RePyY8DwaU04Tzt9fHQrqSVKdpimLGIJOgb5ZE", }) - id, _ := s.Encode([]uint64{1, 2, 3}) // "B5aMa3" + id, _ := s.Encode([]uint64{1, 2, 3}) // "B4aajs" numbers := s.Decode(id) // [1, 2, 3] fmt.Println(id, numbers) diff --git a/examples/sqids-encode-decode/sqids-encode-decode.go b/examples/sqids-encode-decode/sqids-encode-decode.go index c354491..eeb5bcc 100644 --- a/examples/sqids-encode-decode/sqids-encode-decode.go +++ b/examples/sqids-encode-decode/sqids-encode-decode.go @@ -8,7 +8,7 @@ import ( func main() { s, _ := sqids.New() - id, _ := s.Encode([]uint64{1, 2, 3}) // "8QRLaD" + id, _ := s.Encode([]uint64{1, 2, 3}) // "86Rf07" numbers := s.Decode(id) // [1, 2, 3] fmt.Println(id, numbers) diff --git a/examples/sqids-minimum-length/sqids-minimum-length.go b/examples/sqids-minimum-length/sqids-minimum-length.go index 5419068..69bedf3 100644 --- a/examples/sqids-minimum-length/sqids-minimum-length.go +++ b/examples/sqids-minimum-length/sqids-minimum-length.go @@ -10,7 +10,7 @@ func main() { s, _ := sqids.New(sqids.Options{ MinLength: 10, }) - id, _ := s.Encode([]uint64{1, 2, 3}) // "75JT1cd0dL" + id, _ := s.Encode([]uint64{1, 2, 3}) // "86Rf07xd4z" numbers := s.Decode(id) // [1, 2, 3] fmt.Println(id, numbers) diff --git a/minlength_test.go b/minlength_test.go index 83f5866..0d38d5b 100644 --- a/minlength_test.go +++ b/minlength_test.go @@ -7,7 +7,7 @@ import ( func TestMinLengthSimple(t *testing.T) { s, err := New(Options{ - MinLength: len(defaultAlphabet), + MinLength: uint8(len(defaultAlphabet)), }) if err != nil { t.Fatal(err) @@ -15,7 +15,7 @@ func TestMinLengthSimple(t *testing.T) { numbers := []uint64{1, 2, 3} - id := "75JILToVsGerOADWmHlY38xvbaNZKQ9wdFS0B6kcMEtnRpgizhjU42qT1cd0dL" + id := "86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTM" generatedID, err := s.Encode(numbers) if err != nil { @@ -32,25 +32,70 @@ func TestMinLengthSimple(t *testing.T) { } } +func TestMinLengthIncremental(t *testing.T) { + numbers := []uint64{1, 2, 3} + m := map[uint8]string{ + 6: "86Rf07", + 7: "86Rf07x", + 8: "86Rf07xd", + 9: "86Rf07xd4", + 10: "86Rf07xd4z", + 11: "86Rf07xd4zB", + 12: "86Rf07xd4zBm", + 13: "86Rf07xd4zBmi", + } + m[uint8(len(defaultAlphabet))+0] = "86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTM" + m[uint8(len(defaultAlphabet))+1] = "86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMy" + m[uint8(len(defaultAlphabet))+2] = "86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMyf" + m[uint8(len(defaultAlphabet))+3] = "86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMyf1" + + for minLength, id := range m { + s, err := New(Options{ + MinLength: minLength, + }) + if err != nil { + t.Fatal(err) + } + + generatedID, err := s.Encode(numbers) + if err != nil { + t.Fatal(err) + } + + if id != generatedID { + t.Errorf("Encoding `%v` should produce `%v`, but instead produced `%v`", numbers, id, generatedID) + } + + if len(generatedID) != int(minLength) { + t.Errorf("Encoding `%v` should produce `%v` length, but produced `%v` length instead", numbers, minLength, len(generatedID)) + } + + decodedNumbers := s.Decode(id) + if !reflect.DeepEqual(numbers, decodedNumbers) { + t.Errorf("Decoding `%v` should produce `%v`, but instead produced `%v`", id, numbers, decodedNumbers) + } + } +} + func TestMinLengthIncrementalNumbers(t *testing.T) { s, err := New(Options{ - MinLength: len(defaultAlphabet), + MinLength: uint8(len(defaultAlphabet)), }) if err != nil { t.Fatal(err) } ids := map[string][]uint64{ - "jf26PLNeO5WbJDUV7FmMtlGXps3CoqkHnZ8cYd19yIiTAQuvKSExzhrRghBlwf": {0, 0}, - "vQLUq7zWXC6k9cNOtgJ2ZK8rbxuipBFAS10yTdYeRa3ojHwGnmMV4PDhESI2jL": {0, 1}, - "YhcpVK3COXbifmnZoLuxWgBQwtjsSaDGAdr0ReTHM16yI9vU8JNzlFq5Eu2oPp": {0, 2}, - "OTkn9daFgDZX6LbmfxI83RSKetJu0APihlsrYoz5pvQw7GyWHEUcN2jBqd4kJ9": {0, 3}, - "h2cV5eLNYj1x4ToZpfM90UlgHBOKikQFvnW36AC8zrmuJ7XdRytIGPawqYEbBe": {0, 4}, - "7Mf0HeUNkpsZOTvmcj836P9EWKaACBubInFJtwXR2DSzgYGhQV5i4lLxoT1qdU": {0, 5}, - "APVSD1ZIY4WGBK75xktMfTev8qsCJw6oyH2j3OnLcXRlhziUmpbuNEar05QCsI": {0, 6}, - "P0LUhnlT76rsWSofOeyRGQZv1cC5qu3dtaJYNEXwk8Vpx92bKiHIz4MgmiDOF7": {0, 7}, - "xAhypZMXYIGCL4uW0te6lsFHaPc3SiD1TBgw5O7bvodzjqUn89JQRfk2Nvm4JI": {0, 8}, - "94dRPIZ6irlXWvTbKywFuAhBoECQOVMjDJp53s2xeqaSzHY8nc17tmkLGwfGNl": {0, 9}, + "SvIzsqYMyQwI3GWgJAe17URxX8V924Co0DaTZLtFjHriEn5bPhcSkfmvOslpBu": {0, 0}, + "n3qafPOLKdfHpuNw3M61r95svbeJGk7aAEgYn4WlSjXURmF8IDqZBy0CT2VxQc": {0, 1}, + "tryFJbWcFMiYPg8sASm51uIV93GXTnvRzyfLleh06CpodJD42B7OraKtkQNxUZ": {0, 2}, + "eg6ql0A3XmvPoCzMlB6DraNGcWSIy5VR8iYup2Qk4tjZFKe1hbwfgHdUTsnLqE": {0, 3}, + "rSCFlp0rB2inEljaRdxKt7FkIbODSf8wYgTsZM1HL9JzN35cyoqueUvVWCm4hX": {0, 4}, + "sR8xjC8WQkOwo74PnglH1YFdTI0eaf56RGVSitzbjuZ3shNUXBrqLxEJyAmKv2": {0, 5}, + "uY2MYFqCLpgx5XQcjdtZK286AwWV7IBGEfuS9yTmbJvkzoUPeYRHr4iDs3naN0": {0, 6}, + "74dID7X28VLQhBlnGmjZrec5wTA1fqpWtK4YkaoEIM9SRNiC3gUJH0OFvsPDdy": {0, 7}, + "30WXpesPhgKiEI5RHTY7xbB1GnytJvXOl2p0AcUjdF6waZDo9Qk8VLzMuWrqCS": {0, 8}, + "moxr3HqLAK0GsTND6jowfZz3SUx7cQ8aC54Pl1RbIvFXmEJuBMYVeW9yrdOtin": {0, 9}, } for id, numbers := range ids { @@ -71,7 +116,7 @@ func TestMinLengthIncrementalNumbers(t *testing.T) { } func TestMinLengths(t *testing.T) { - for _, minLength := range []int{0, 1, 5, 10, len(defaultAlphabet)} { + for _, minLength := range []uint8{0, 1, 5, 10, uint8(len(defaultAlphabet))} { for _, numbers := range [][]uint64{ {minUint64Value}, {0, 0, 0, 0, 0}, @@ -93,7 +138,7 @@ func TestMinLengths(t *testing.T) { t.Fatal(err) } - if len(generatedID) < minLength { + if uint8(len(generatedID)) < minLength { t.Errorf("Encoding `%v` with min length `%v` produced `%v`", numbers, minLength, generatedID) } @@ -104,17 +149,3 @@ func TestMinLengths(t *testing.T) { } } } - -func TestOutOfRangeInvalidMinLength(t *testing.T) { - if _, err := New(Options{ - MinLength: -1, - }); err == nil { - t.Errorf("Should not allow out of range min length") - } - - if _, err := New(Options{ - MinLength: len(defaultAlphabet) + 1, - }); err == nil { - t.Errorf("Should not allow out of range min length") - } -} diff --git a/sqids.go b/sqids.go index 7ceda84..44a5788 100644 --- a/sqids.go +++ b/sqids.go @@ -4,14 +4,13 @@ package sqids import ( "errors" - "fmt" "math" "strings" ) const ( defaultAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - minAlphabetLength = 5 + minAlphabetLength = 3 minUint64Value = uint64(0) maxUint64Value = uint64(math.MaxUint64) ) @@ -20,23 +19,23 @@ var defaultBlocklist []string = newDefaultBlocklist() // Alphabet validation errors var ( - errAlphabetMultibyte = errors.New("alphabet must not contain any multibyte characters") - errAlphabetTooShort = errors.New("alphabet length must be at least 5") - errAlphabetNotUniqueChars = errors.New("alphabet must contain unique characters") - errAlphabetMinLength = errors.New("alphabet minimum length") + errAlphabetMultibyte = errors.New("alphabet must not contain any multibyte characters") + errAlphabetTooShort = errors.New("alphabet length must be at least 3") + errAlphabetNotUniqueChars = errors.New("alphabet must contain unique characters") + errMaxRegenerationAttempts = errors.New("reached max attempts to re-generate the id") ) // Options for a custom instance of Sqids type Options struct { Alphabet string - MinLength int + MinLength uint8 Blocklist []string } // Sqids lets you generate unique IDs from numbers type Sqids struct { alphabet string - minLength int + minLength uint8 blocklist []string } @@ -82,11 +81,6 @@ func validatedOptions(o Options) (Options, error) { return Options{}, errAlphabetNotUniqueChars } - // test min length (type [might be lang-specific] + min length + max length) - if o.MinLength < int(minUint64Value) || o.MinLength > len(o.Alphabet) { - return Options{}, fmt.Errorf("%w has to be between %d and %d", errAlphabetMinLength, minUint64Value, len(o.Alphabet)) - } - o.Blocklist = filterBlocklist(o.Alphabet, o.Blocklist) return o, nil @@ -99,70 +93,46 @@ func (s *Sqids) Encode(numbers []uint64) (string, error) { return "", nil } - return s.encodeNumbers(numbers, false) + return s.encodeNumbers(numbers, 0) } -func (s *Sqids) encodeNumbers(numbers []uint64, partitioned bool) (string, error) { +func (s *Sqids) encodeNumbers(numbers []uint64, increment int) (string, error) { + if increment > len(s.alphabet) { + return "", errMaxRegenerationAttempts + } + var ( - err error - offset = calculateOffset(s.alphabet, numbers) - alphabet = alphabetOffset(s.alphabet, offset) - prefix = alphabet[0] - partition = alphabet[1] - ret = []rune{prefix} + err error + offset = calculateOffset(s.alphabet, numbers, increment) + alphabet = alphabetOffset(s.alphabet, offset) + prefix = alphabet[0] + ret = []rune{prefix} ) - alphabet = alphabet[2:] + alphabet = reverseRunes(alphabet) for i, num := range numbers { - alphabetWithoutSeparator := alphabet[:len(alphabet)-1] - - ret = append(ret, []rune(toID(num, string(alphabetWithoutSeparator)))...) + ret = append(ret, []rune(toID(num, string(alphabet[1:])))...) if i < len(numbers)-1 { - var separator rune - - if partitioned && i == 0 { - separator = partition - } else { - separator = alphabet[len(alphabet)-1] - } - - ret = append(ret, separator) - + ret = append(ret, alphabet[0]) alphabet = []rune(shuffle(string(alphabet))) } } id := string(ret) - if s.minLength > len(id) { - if !partitioned { - numbers = append([]uint64{0}, numbers...) - - id, err = s.encodeNumbers(numbers, true) - if err != nil { - return "", err - } - } + if int(s.minLength) > len(id) { + id += string(alphabet[0]) - if s.minLength > len(id) { - id = id[:1] + string(alphabet[:s.minLength-len(id)]) + id[1:] + for int(s.minLength)-len(id) > 0 { + alphabet = []rune(shuffle(string(alphabet))) + id += string(alphabet[:min(int(s.minLength)-len(id), len(alphabet))]) } } if s.isBlockedID(id) { - if partitioned { - if numbers[0] == maxUint64Value { - return "", errors.New("ran out of range checking against the blocklist") - } - - numbers[0]++ - } else { - numbers = append([]uint64{0}, numbers...) - } - - id, err = s.encodeNumbers(numbers, true) + id, err = s.encodeNumbers(numbers, increment+1) if err != nil { return "", err } @@ -193,37 +163,20 @@ func (s *Sqids) Decode(id string) []uint64 { offset := index(alphabet, prefix) alphabet = alphabetOffset(s.alphabet, offset) - - partition := alphabet[1] + alphabet = reverseRunes(alphabet) rid = rid[1:] - alphabet = alphabet[2:] - - if pi := index(rid, partition); pi > 0 && pi < len(rid)-1 { - rid = rid[pi+1:] - alphabet = shuffleRunes(alphabet) - } for len(rid) > 0 { - separator := alphabet[len(alphabet)-1] + separator := alphabet[0] chunks := splitChunks(rid, separator) - if len(chunks) > 0 { - alphabetWithoutSeparator := alphabet[:len(alphabet)-1] - charSet := make(map[rune]bool) - - for _, c := range alphabetWithoutSeparator { - charSet[c] = true - } - - for _, c := range chunks[0] { - if _, exists := charSet[c]; !exists { - return []uint64{} - } + if len(chunks[0]) == 0 { + return ret } - ret = append(ret, toNumber(chunks[0], alphabetWithoutSeparator)) + ret = append(ret, toNumber(chunks[0], alphabet[1:])) if len(chunks) > 1 { alphabet = shuffleRunes(alphabet) @@ -262,25 +215,20 @@ func joinRuneSlices(rs [][]rune, separator rune) []rune { } func splitChunks(runes []rune, separator rune) [][]rune { - var n int - - var out [][]rune + var chunks [][]rune + chunk := []rune{} for _, r := range runes { if r == separator { - n++ - } - - if len(out) == n { - out = append(out, []rune{}) - } - - if r != separator { - out[n] = append(out[n], r) + chunks = append(chunks, chunk) + chunk = []rune{} + } else { + chunk = append(chunk, r) } } - return out + chunks = append(chunks, chunk) + return chunks } func (s *Sqids) isBlockedID(id string) bool { @@ -305,17 +253,7 @@ func (s *Sqids) isBlockedID(id string) bool { return false } -// MinValue returns the minimum uint64 value, which is 0 -func MinValue() uint64 { - return minUint64Value -} - -// MaxValue returns the maximum uint64 value, which is 18446744073709551615 -func MaxValue() uint64 { - return maxUint64Value -} - -func calculateOffset(alphabet string, numbers []uint64) int { +func calculateOffset(alphabet string, numbers []uint64, increment int) int { var ( offset = len(numbers) runes = []rune(alphabet) @@ -330,7 +268,8 @@ func calculateOffset(alphabet string, numbers []uint64) int { offset += int(runes[v%count]) + i } - return offset % len(runes) + offset = offset % len(runes) + return (offset + increment) % len(runes) } func shuffle(alphabet string) string { @@ -346,6 +285,14 @@ func shuffleRunes(runes []rune) []rune { return runes } +func reverseRunes(runes []rune) []rune { + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + + return runes +} + func toID(num uint64, alphabet string) string { var ( id = []rune{} @@ -421,3 +368,10 @@ func hasDigit(word string) bool { return false } + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/sqids_test.go b/sqids_test.go index 8e159d3..d27e418 100644 --- a/sqids_test.go +++ b/sqids_test.go @@ -2,18 +2,6 @@ package sqids import "testing" -func TestMinValue(t *testing.T) { - if got, want := MinValue(), uint64(0); got != want { - t.Fatalf("MinValue() = %d, want %d", got, want) - } -} - -func TestMaxValue(t *testing.T) { - if got, want := MaxValue(), uint64(18446744073709551615); got != want { - t.Fatalf("MaxValue() = %d, want %d", got, want) - } -} - func TestCalculateOffset(t *testing.T) { for _, tt := range []struct { alphabet string @@ -37,7 +25,7 @@ func TestCalculateOffset(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if got := calculateOffset(tt.alphabet, tt.numbers); got != tt.want { + if got := calculateOffset(tt.alphabet, tt.numbers, 0); got != tt.want { t.Fatalf("calculateOffset(%q, %#v) = %d, want %d", tt.alphabet, tt.numbers, got, tt.want) } } diff --git a/uniques_test.go b/uniques_test.go index 5eb201e..26f7d91 100644 --- a/uniques_test.go +++ b/uniques_test.go @@ -10,7 +10,7 @@ func TestUniques(t *testing.T) { t.Run("WithPadding", func(t *testing.T) { s, err := New(Options{ - MinLength: len(defaultAlphabet), + MinLength: uint8(len(defaultAlphabet)), }) if err != nil { t.Fatal(err)