diff --git a/CHANGELOG.md b/CHANGELOG.md index f318ac7..5b7c2c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,3 @@ # CHANGELOG -@todo \ No newline at end of file +Initial implementation diff --git a/LICENSE b/LICENSE index 6142f72..9d7d2b2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023-present Sqids maintainers. +Copyright (c) 2025-present Markus Fischer. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 77d98f3..8aa2d76 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,229 @@ -# [Sqids T-SQL](https://sqids.org/t-sql) +# Sqids T-SQL -Sqids (pronounced "squids") is a small library that lets you generate YouTube-looking IDs from numbers. It's good for link shortening, fast & URL-safe ID generation and decoding back into numbers for quicker database lookups. +Sqids (pronounced **“squids”**) is a small, deterministic ID-encoding algorithm that generates **short, URL-safe, YouTube-style IDs** from integers and can decode them back to the original numbers. -## Getting started +This repository provides a **pure T-SQL implementation for SQL Server 2019+**, closely following the official Sqids specification and reference implementations (Swift, TypeScript, etc.). -@todo +Typical use cases: -## Examples +- URL-safe public IDs instead of sequential integers +- Link shortening +- Obfuscating database IDs +- Fast round-trip encoding/decoding without lookups +- Deterministic IDs across platforms (when using the same alphabet & settings) -@todo +--- + +## Features + +- ✅ Deterministic and reversible (no hashing) +- ✅ Fully compatible with official Sqids implementations +- ✅ Supports single and multiple numbers +- ✅ URL-safe default alphabet +- ✅ Configurable minimum ID length (`minLength`) +- ✅ Built-in **default blocklist** (automatic profanity filtering) +- ✅ No parameters required for encode/decode calls +- ✅ SQL Server 2019 compatible +- ✅ Extensive test coverage in T-SQL + +--- + +## Requirements + +- SQL Server **2019 or newer** +- Database collation does **not** need to be case-sensitive + (binary comparisons are used internally where required) + +--- + +## Installation + +1. Create the `sqids` schema +2. Deploy all objects from this repository: + - `sqids.Config` table + - `sqids.Init` + - `sqids.ToId` + - `sqids.ToNumber` + - `sqids.EncodeJson` + - `sqids.DecodeJson` + - `sqids.IsBlocked` + - Test procedures (`sqids.RunTests`, `sqids.RunMinLengthTests`) + +No external dependencies are required. + +--- + +## Getting Started + +### 1. Initialize Sqids + +Initialization stores all settings in the `sqids.Config` table. +**Encode and decode functions read exclusively from this table.** + +```sql +EXEC sqids.Init + @ConfigName = N'default', + @Alphabet = N'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + @MinLength = 0, + @BlocklistJson = NULL; -- NULL = use built-in default blocklist +``` + +> ℹ️ If `@BlocklistJson` is `NULL`, the **official Sqids default blocklist** is automatically applied. + +--- + +### 2. Encode Numbers + +#### Single number +```sql +SELECT sqids.ToId(12345); +-- e.g. 'Z3mJ' +``` + +#### Multiple numbers (JSON) +```sql +SELECT sqids.EncodeJson(N'[1,2,3]'); +-- e.g. '86Rf07' +``` + +--- + +### 3. Decode IDs + +#### Single number +```sql +SELECT sqids.ToNumber('Z3mJ'); +-- 12345 +``` + +#### Multiple numbers +```sql +SELECT sqids.DecodeJson('86Rf07'); +-- [1,2,3] +``` + +--- + +## Minimum Length (`minLength`) + +You can enforce a minimum ID length during initialization. + +```sql +EXEC sqids.Init + @ConfigName = N'default', + @Alphabet = N'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + @MinLength = 12, + @BlocklistJson = NULL; +``` + +- IDs shorter than `minLength` are **padded deterministically** +- Padding does **not** affect decoding +- Fully compatible with official Sqids behavior + +--- + +## Blocklist + +Sqids includes a built-in **default profanity blocklist**, identical to the official implementations. + +Rules: + +- Blocklist words are case-insensitive +- Words shorter than 3 characters are ignored +- Words containing characters outside the alphabet are ignored +- IDs containing blocked words are **automatically regenerated** using the next increment + +You can optionally provide a custom blocklist as JSON: + +```sql +EXEC sqids.Init + @ConfigName = N'default', + @Alphabet = N'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + @MinLength = 0, + @BlocklistJson = N'["foo","bar","baz"]'; +``` + +--- + +## Configuration Storage + +All runtime settings are stored in: + +```sql +sqids.Config +``` + +Including: + +- Alphabet +- Minimum length +- Blocklist (JSON) + +Encode/decode functions **do not accept parameters** and always use the active configuration. + +This ensures: + +- Consistent behavior across calls +- Simpler usage +- Deterministic results inside SQL + +--- + +## Testing + +Two comprehensive test procedures are included. + +### 1. Core Tests + +```sql +EXEC sqids.RunTests; +``` + +Covers: + +- Alphabet validation +- Single number round-trips +- Multi-number JSON round-trips +- `minLength` enforcement +- Invalid character handling +- Blocklist filtering +- Regression tests up to large ranges + +--- + +### 2. Official MinLength Test Suite + +```sql +EXEC sqids.RunMinLengthTests; +``` + +Covers **official Sqids reference tests**, including: + +- Fixed expected IDs +- Incremental `minLength` behavior +- Multi-number deterministic outputs +- Edge cases (0, large values, Int64 max) +- Full encode/decode validation + +These tests mirror the **Swift reference test suite**. + +--- + +## Compatibility + +This implementation is tested against: + +- sqids.org playground +- Official Swift implementation +- Official TypeScript implementation + +Using the same alphabet and settings produces **identical IDs**. + +--- ## License -[MIT](LICENSE) +MIT License + +Copyright (c) 2023-present Sqids maintainers +T-SQL implementation and tests © 2025 diff --git a/src/sqids.sql b/src/sqids.sql new file mode 100644 index 0000000..55dffb5 --- /dev/null +++ b/src/sqids.sql @@ -0,0 +1,1181 @@ +/* ======================= + Cleanup + ======================= */ +DROP FUNCTION IF EXISTS sqids.ToId; +DROP FUNCTION IF EXISTS sqids.ToNumber; +DROP FUNCTION IF EXISTS sqids.EncodeJson; +DROP FUNCTION IF EXISTS sqids.DecodeJson; +DROP FUNCTION IF EXISTS sqids._Shuffle; +DROP FUNCTION IF EXISTS sqids._SplitReverse; +DROP FUNCTION IF EXISTS sqids._ToIdBase; +DROP FUNCTION IF EXISTS sqids._ToNumberBase; +DROP FUNCTION IF EXISTS sqids._IsBlocked; +DROP FUNCTION IF EXISTS sqids._FilterBlocklistJson; +DROP FUNCTION IF EXISTS sqids._ToNumberBase; +DROP FUNCTION IF EXISTS sqids._IndexOfChar; +drop function IF EXISTS sqids._ConsistentShuffle; +drop function IF EXISTS sqids._ToNumBase; +drop procedure IF EXISTS sqids.init; +drop procedure IF EXISTS sqids.RunTests; +drop procedure IF EXISTS sqids.RunMinLengthTests; +drop procedure IF EXISTS sqids.RunEncodingTests; +drop table IF EXISTS sqids.Config; +drop schema sqids; +drop schema IF EXISTS sqids; +GO + +IF SCHEMA_ID('sqids') IS NULL EXEC('CREATE SCHEMA sqids'); +GO + +IF OBJECT_ID('sqids.Config', 'U') IS NULL +BEGIN + CREATE TABLE sqids.Config + ( + ConfigName SYSNAME NOT NULL PRIMARY KEY, + Alphabet NVARCHAR(255) NOT NULL, + MinLength INT NOT NULL CONSTRAINT DF_SqidsConfig_MinLength DEFAULT(0), + BlocklistJson NVARCHAR(MAX) NULL, + UpdatedAt DATETIME2(0) NOT NULL CONSTRAINT DF_SqidsConfig_UpdatedAt DEFAULT(SYSDATETIME()) + ); +END +GO + +CREATE OR ALTER PROCEDURE sqids.Init +( + @ConfigName SYSNAME, + @Alphabet NVARCHAR(255), + @MinLength INT = 0, + @BlocklistJson NVARCHAR(MAX) = NULL +) +AS +BEGIN + SET NOCOUNT ON; + + IF @ConfigName IS NULL OR LTRIM(RTRIM(@ConfigName)) = N'' + THROW 50000, 'ConfigName required.', 1; + + IF @Alphabet IS NULL OR LEN(@Alphabet) < 3 + THROW 50001, 'Alphabet length must be at least 3.', 1; + + IF @MinLength < 0 OR @MinLength > 255 + THROW 50002, 'MinLength must be between 0 and 255.', 1; + + -- ASCII only (wie Swift assert isASCII) + IF EXISTS ( + SELECT 1 + WHERE EXISTS ( + SELECT 1 + FROM (SELECT TOP (LEN(@Alphabet)) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n + FROM sys.all_objects) t + WHERE UNICODE(SUBSTRING(@Alphabet, t.n, 1)) > 127 + ) + ) + THROW 50003, 'Alphabet cannot contain multibyte characters.', 1; + + DECLARE @len INT = LEN(@Alphabet); + + if @BlocklistJson is null + set @BlocklistJson = '[ + "0rgasm", + "1d10t", + "1d1ot", + "1di0t", + "1diot", + "1eccacu10", + "1eccacu1o", + "1eccacul0", + "1eccaculo", + "1mbec11e", + "1mbec1le", + "1mbeci1e", + "1mbecile", + "a11upat0", + "a11upato", + "a1lupat0", + "a1lupato", + "aand", + "ah01e", + "ah0le", + "aho1e", + "ahole", + "al1upat0", + "al1upato", + "allupat0", + "allupato", + "ana1", + "ana1e", + "anal", + "anale", + "anus", + "arrapat0", + "arrapato", + "arsch", + "arse", + "ass", + "b00b", + "b00be", + "b01ata", + "b0ceta", + "b0iata", + "b0ob", + "b0obe", + "b0sta", + "b1tch", + "b1te", + "b1tte", + "ba1atkar", + "balatkar", + "bastard0", + "bastardo", + "batt0na", + "battona", + "bitch", + "bite", + "bitte", + "bo0b", + "bo0be", + "bo1ata", + "boceta", + "boiata", + "boob", + "boobe", + "bosta", + "bran1age", + "bran1er", + "bran1ette", + "bran1eur", + "bran1euse", + "branlage", + "branler", + "branlette", + "branleur", + "branleuse", + "c0ck", + "c0g110ne", + "c0g11one", + "c0g1i0ne", + "c0g1ione", + "c0gl10ne", + "c0gl1one", + "c0gli0ne", + "c0glione", + "c0na", + "c0nnard", + "c0nnasse", + "c0nne", + "c0u111es", + "c0u11les", + "c0u1l1es", + "c0u1lles", + "c0ui11es", + "c0ui1les", + "c0uil1es", + "c0uilles", + "c11t", + "c11t0", + "c11to", + "c1it", + "c1it0", + "c1ito", + "cabr0n", + "cabra0", + "cabrao", + "cabron", + "caca", + "cacca", + "cacete", + "cagante", + "cagar", + "cagare", + "cagna", + "cara1h0", + "cara1ho", + "caracu10", + "caracu1o", + "caracul0", + "caraculo", + "caralh0", + "caralho", + "cazz0", + "cazz1mma", + "cazzata", + "cazzimma", + "cazzo", + "ch00t1a", + "ch00t1ya", + "ch00tia", + "ch00tiya", + "ch0d", + "ch0ot1a", + "ch0ot1ya", + "ch0otia", + "ch0otiya", + "ch1asse", + "ch1avata", + "ch1er", + "ch1ng0", + "ch1ngadaz0s", + "ch1ngadazos", + "ch1ngader1ta", + "ch1ngaderita", + "ch1ngar", + "ch1ngo", + "ch1ngues", + "ch1nk", + "chatte", + "chiasse", + "chiavata", + "chier", + "ching0", + "chingadaz0s", + "chingadazos", + "chingader1ta", + "chingaderita", + "chingar", + "chingo", + "chingues", + "chink", + "cho0t1a", + "cho0t1ya", + "cho0tia", + "cho0tiya", + "chod", + "choot1a", + "choot1ya", + "chootia", + "chootiya", + "cl1t", + "cl1t0", + "cl1to", + "clit", + "clit0", + "clito", + "cock", + "cog110ne", + "cog11one", + "cog1i0ne", + "cog1ione", + "cogl10ne", + "cogl1one", + "cogli0ne", + "coglione", + "cona", + "connard", + "connasse", + "conne", + "cou111es", + "cou11les", + "cou1l1es", + "cou1lles", + "coui11es", + "coui1les", + "couil1es", + "couilles", + "cracker", + "crap", + "cu10", + "cu1att0ne", + "cu1attone", + "cu1er0", + "cu1ero", + "cu1o", + "cul0", + "culatt0ne", + "culattone", + "culer0", + "culero", + "culo", + "cum", + "cunt", + "d11d0", + "d11do", + "d1ck", + "d1ld0", + "d1ldo", + "damn", + "de1ch", + "deich", + "depp", + "di1d0", + "di1do", + "dick", + "dild0", + "dildo", + "dyke", + "encu1e", + "encule", + "enema", + "enf01re", + "enf0ire", + "enfo1re", + "enfoire", + "estup1d0", + "estup1do", + "estupid0", + "estupido", + "etr0n", + "etron", + "f0da", + "f0der", + "f0ttere", + "f0tters1", + "f0ttersi", + "f0tze", + "f0utre", + "f1ca", + "f1cker", + "f1ga", + "fag", + "fica", + "ficker", + "figa", + "foda", + "foder", + "fottere", + "fotters1", + "fottersi", + "fotze", + "foutre", + "fr0c10", + "fr0c1o", + "fr0ci0", + "fr0cio", + "fr0sc10", + "fr0sc1o", + "fr0sci0", + "fr0scio", + "froc10", + "froc1o", + "froci0", + "frocio", + "frosc10", + "frosc1o", + "frosci0", + "froscio", + "fuck", + "g00", + "g0o", + "g0u1ne", + "g0uine", + "gandu", + "go0", + "goo", + "gou1ne", + "gouine", + "gr0gnasse", + "grognasse", + "haram1", + "harami", + "haramzade", + "hund1n", + "hundin", + "id10t", + "id1ot", + "idi0t", + "idiot", + "imbec11e", + "imbec1le", + "imbeci1e", + "imbecile", + "j1zz", + "jerk", + "jizz", + "k1ke", + "kam1ne", + "kamine", + "kike", + "leccacu10", + "leccacu1o", + "leccacul0", + "leccaculo", + "m1erda", + "m1gn0tta", + "m1gnotta", + "m1nch1a", + "m1nchia", + "m1st", + "mam0n", + "mamahuev0", + "mamahuevo", + "mamon", + "masturbat10n", + "masturbat1on", + "masturbate", + "masturbati0n", + "masturbation", + "merd0s0", + "merd0so", + "merda", + "merde", + "merdos0", + "merdoso", + "mierda", + "mign0tta", + "mignotta", + "minch1a", + "minchia", + "mist", + "musch1", + "muschi", + "n1gger", + "neger", + "negr0", + "negre", + "negro", + "nerch1a", + "nerchia", + "nigger", + "orgasm", + "p00p", + "p011a", + "p01la", + "p0l1a", + "p0lla", + "p0mp1n0", + "p0mp1no", + "p0mpin0", + "p0mpino", + "p0op", + "p0rca", + "p0rn", + "p0rra", + "p0uff1asse", + "p0uffiasse", + "p1p1", + "p1pi", + "p1r1a", + "p1rla", + "p1sc10", + "p1sc1o", + "p1sci0", + "p1scio", + "p1sser", + "pa11e", + "pa1le", + "pal1e", + "palle", + "pane1e1r0", + "pane1e1ro", + "pane1eir0", + "pane1eiro", + "panele1r0", + "panele1ro", + "paneleir0", + "paneleiro", + "patakha", + "pec0r1na", + "pec0rina", + "pecor1na", + "pecorina", + "pen1s", + "pendej0", + "pendejo", + "penis", + "pip1", + "pipi", + "pir1a", + "pirla", + "pisc10", + "pisc1o", + "pisci0", + "piscio", + "pisser", + "po0p", + "po11a", + "po1la", + "pol1a", + "polla", + "pomp1n0", + "pomp1no", + "pompin0", + "pompino", + "poop", + "porca", + "porn", + "porra", + "pouff1asse", + "pouffiasse", + "pr1ck", + "prick", + "pussy", + "put1za", + "puta", + "puta1n", + "putain", + "pute", + "putiza", + "puttana", + "queca", + "r0mp1ba11e", + "r0mp1ba1le", + "r0mp1bal1e", + "r0mp1balle", + "r0mpiba11e", + "r0mpiba1le", + "r0mpibal1e", + "r0mpiballe", + "rand1", + "randi", + "rape", + "recch10ne", + "recch1one", + "recchi0ne", + "recchione", + "retard", + "romp1ba11e", + "romp1ba1le", + "romp1bal1e", + "romp1balle", + "rompiba11e", + "rompiba1le", + "rompibal1e", + "rompiballe", + "ruff1an0", + "ruff1ano", + "ruffian0", + "ruffiano", + "s1ut", + "sa10pe", + "sa1aud", + "sa1ope", + "sacanagem", + "sal0pe", + "salaud", + "salope", + "saugnapf", + "sb0rr0ne", + "sb0rra", + "sb0rrone", + "sbattere", + "sbatters1", + "sbattersi", + "sborr0ne", + "sborra", + "sborrone", + "sc0pare", + "sc0pata", + "sch1ampe", + "sche1se", + "sche1sse", + "scheise", + "scheisse", + "schlampe", + "schwachs1nn1g", + "schwachs1nnig", + "schwachsinn1g", + "schwachsinnig", + "schwanz", + "scopare", + "scopata", + "sexy", + "sh1t", + "shit", + "slut", + "sp0mp1nare", + "sp0mpinare", + "spomp1nare", + "spompinare", + "str0nz0", + "str0nza", + "str0nzo", + "stronz0", + "stronza", + "stronzo", + "stup1d", + "stupid", + "succh1am1", + "succh1ami", + "succhiam1", + "succhiami", + "sucker", + "t0pa", + "tapette", + "test1c1e", + "test1cle", + "testic1e", + "testicle", + "tette", + "topa", + "tr01a", + "tr0ia", + "tr0mbare", + "tr1ng1er", + "tr1ngler", + "tring1er", + "tringler", + "tro1a", + "troia", + "trombare", + "turd", + "twat", + "vaffancu10", + "vaffancu1o", + "vaffancul0", + "vaffanculo", + "vag1na", + "vagina", + "verdammt", + "verga", + "w1chsen", + "wank", + "wichsen", + "x0ch0ta", + "x0chota", + "xana", + "xoch0ta", + "xochota", + "z0cc01a", + "z0cc0la", + "z0cco1a", + "z0ccola", + "z1z1", + "z1zi", + "ziz1", + "zizi", + "zocc01a", + "zocc0la", + "zocco1a", + "zoccola", +]'; + + + IF @len <> ( + SELECT COUNT(DISTINCT SUBSTRING(@Alphabet, v.number, 1) COLLATE Latin1_General_100_BIN2) + FROM master..spt_values v + WHERE v.type = 'P' AND v.number BETWEEN 1 AND @len + ) + THROW 50004, 'Alphabet must contain unique characters.', 1; + + MERGE sqids.Config AS tgt + USING (SELECT @ConfigName AS ConfigName) AS src + ON tgt.ConfigName = src.ConfigName + WHEN MATCHED THEN UPDATE SET + Alphabet = @Alphabet, + MinLength = @MinLength, + BlocklistJson = @BlocklistJson, + UpdatedAt = SYSDATETIME() + WHEN NOT MATCHED THEN INSERT (ConfigName, Alphabet, MinLength, BlocklistJson) + VALUES (@ConfigName, @Alphabet, @MinLength, @BlocklistJson); +END +GO + +CREATE OR ALTER FUNCTION sqids._IndexOfChar +( + @alphabet NVARCHAR(255), + @ch NCHAR(1) +) +RETURNS INT +AS +BEGIN + DECLARE @target INT = UNICODE(@ch); + DECLARE @i INT = 1; + DECLARE @L INT = LEN(@alphabet); + + WHILE @i <= @L + BEGIN + IF UNICODE(SUBSTRING(@alphabet, @i, 1)) = @target + RETURN @i - 1; -- 0-based like Swift + SET @i += 1; + END + + RETURN -1; +END +GO + + + +/* ======================= + shuffle(alphabet: [Character]) -> [Character] + Swift: r = (i * j + ci + cj) % count; swap(i, r); i++, j-- + ======================= */ +CREATE OR ALTER FUNCTION sqids._Shuffle(@alphabet NVARCHAR(255)) +RETURNS NVARCHAR(255) +AS +BEGIN + DECLARE @chars NVARCHAR(255) = @alphabet; + DECLARE @count INT = LEN(@chars); + IF @count <= 1 RETURN @chars; + + DECLARE @i INT = 0; + DECLARE @j INT = @count - 1; + + WHILE @j > 0 + BEGIN + DECLARE @ci INT = UNICODE(SUBSTRING(@chars, @i + 1, 1)); + DECLARE @cj INT = UNICODE(SUBSTRING(@chars, @j + 1, 1)); + DECLARE @r INT = (@i * @j + @ci + @cj) % @count; + + IF @r <> @i + BEGIN + DECLARE @a NCHAR(1) = SUBSTRING(@chars, @i + 1, 1); + DECLARE @b NCHAR(1) = SUBSTRING(@chars, @r + 1, 1); + SET @chars = STUFF(@chars, @i + 1, 1, @b); + SET @chars = STUFF(@chars, @r + 1, 1, @a); + END + + SET @i += 1; + SET @j -= 1; + END + + RETURN @chars; +END +GO + +/* ======================= + splitReverse(offset: Int) -> [Character] + Swift: + alphabet = suffix(from: offset) + prefix(offset) + return reversed() + ======================= */ +CREATE OR ALTER FUNCTION sqids._SplitReverse(@alphabetShuffled NVARCHAR(255), @offset INT) +RETURNS NVARCHAR(255) +AS +BEGIN + DECLARE @a NVARCHAR(255) = @alphabetShuffled; + DECLARE @len INT = LEN(@a); + IF @len = 0 RETURN N''; + IF @offset < 0 OR @offset >= @len RETURN N''; + + DECLARE @rot NVARCHAR(255) = + SUBSTRING(@a, @offset + 1, @len - @offset) + SUBSTRING(@a, 1, @offset); + + /* reverse string */ + DECLARE @rev NVARCHAR(255) = N''; + DECLARE @p INT = LEN(@rot); + WHILE @p >= 1 + BEGIN + SET @rev += SUBSTRING(@rot, @p, 1); + SET @p -= 1; + END + + RETURN @rev; +END +GO + +/* ======================= + toId(number, alphabet) (base conversion) + Swift uses alphabet = suffix(from: 1) of working alphabet + ======================= */ +CREATE OR ALTER FUNCTION sqids._ToIdBase(@number BIGINT, @alphabet NVARCHAR(255)) +RETURNS NVARCHAR(4000) +AS +BEGIN + IF @number IS NULL OR @number < 0 OR LEN(@alphabet) < 1 RETURN NULL; + + DECLARE @count BIGINT = LEN(@alphabet); + DECLARE @n BIGINT = @number; + DECLARE @out NVARCHAR(4000) = N''; + + WHILE 1=1 + BEGIN + SET @out = SUBSTRING(@alphabet, CONVERT(INT, (@n % @count)) + 1, 1) + @out; + SET @n = @n / @count; + IF @n <= 0 BREAK; + END + + RETURN @out; +END +GO + +/* ======================= + toNumber(id, alphabet) with overflow checks like Swift + Swift: + acc = acc * count + index + ======================= */ + + +CREATE OR ALTER FUNCTION sqids._ToNumberBase +( + @id NVARCHAR(4000), + @alphabet NVARCHAR(255) +) +RETURNS BIGINT +AS +BEGIN + IF @id IS NULL OR LEN(@id) = 0 RETURN NULL; + + DECLARE @count BIGINT = LEN(@alphabet); + IF @count <= 0 RETURN NULL; + + DECLARE @acc BIGINT = 0; + DECLARE @p INT = 1; + DECLARE @L INT = LEN(@id); + + WHILE @p <= @L + BEGIN + DECLARE @ch NCHAR(1) = SUBSTRING(@id, @p, 1); + DECLARE @idx INT = sqids._IndexOfChar(@alphabet, @ch); + IF @idx < 0 RETURN NULL; + + -- Swift overflow checks + IF @acc > (9223372036854775807 - @idx) / @count RETURN NULL; + + SET @acc = @acc * @count + @idx; + SET @p += 1; + END + + RETURN @acc; +END +GO + + +/* ======================= + Blocklist filtering like init(): + - takes blocklistJson = JSON array of strings + - keeps only: + * len >= 3 + * word lowercased consists only of chars from lowercased alphabet + Returns filtered JSON array (lowercased). + ======================= */ +CREATE OR ALTER FUNCTION sqids._FilterBlocklistJson +( + @alphabet NVARCHAR(255), + @blocklistJson NVARCHAR(MAX) +) +RETURNS NVARCHAR(MAX) +AS +BEGIN + IF @blocklistJson IS NULL OR ISJSON(@blocklistJson) <> 1 RETURN N'[]'; + + DECLARE @alphaLower NVARCHAR(255) = LOWER(@alphabet); + + DECLARE @out NVARCHAR(MAX) = N'['; + DECLARE @first BIT = 1; + + ;WITH words AS ( + SELECT LOWER(CONVERT(NVARCHAR(255), value)) AS w + FROM OPENJSON(@blocklistJson) + WHERE type = 1 + ), + ok AS ( + SELECT w + FROM words + WHERE LEN(w) >= 3 + AND NOT EXISTS ( + SELECT 1 + FROM ( + /* explode characters by position */ + SELECT TOP (LEN(w)) + SUBSTRING(w, v.number, 1) AS c + FROM master..spt_values v + WHERE v.type='P' AND v.number BETWEEN 1 AND LEN(w) + ) x + WHERE CHARINDEX(x.c, @alphaLower) = 0 + ) + ) + SELECT @out = @out + + CASE WHEN @first=1 THEN N'' ELSE N',' END + + N'"' + REPLACE(w, N'"', N'\"') + N'"', + @first = 0 + FROM ok; + + SET @out += N']'; + RETURN @out; +END +GO + +/* ======================= + isBlocked(id) 1:1 zur Swift-Logik + blocklistJson: JSON array of strings (unfiltered ok; we filter like init) + ======================= */ +CREATE OR ALTER FUNCTION sqids._IsBlocked +( + @id NVARCHAR(4000), + @alphabet NVARCHAR(255), + @blocklistJson NVARCHAR(MAX) = NULL +) +RETURNS BIT +AS +BEGIN + DECLARE @blocked BIT = 0; + IF @id IS NULL RETURN 0; + + DECLARE @idLower NVARCHAR(4000) = LOWER(@id); + + DECLARE @bl NVARCHAR(MAX) = sqids._FilterBlocklistJson(@alphabet, @blocklistJson); + IF ISJSON(@bl) <> 1 RETURN 0; + + DECLARE @idLen INT = LEN(@idLower); + + DECLARE cur CURSOR LOCAL FAST_FORWARD FOR + SELECT CONVERT(NVARCHAR(255), value) AS w + FROM OPENJSON(@bl) + WHERE type = 1; + + DECLARE @w NVARCHAR(255); + + OPEN cur; + FETCH NEXT FROM cur INTO @w; + + WHILE @@FETCH_STATUS = 0 + BEGIN + DECLARE @wLen INT = LEN(@w); + + IF @wLen <= @idLen + BEGIN + IF @idLen <= 3 OR @wLen <= 3 + BEGIN + IF @idLower = @w + BEGIN + SET @blocked = 1; BREAK; + END + END + ELSE IF @w LIKE N'%[0-9]%' -- word contains digit + BEGIN + IF LEFT(@idLower, @wLen) = @w OR RIGHT(@idLower, @wLen) = @w + BEGIN + SET @blocked = 1; BREAK; + END + END + ELSE + BEGIN + IF CHARINDEX(@w, @idLower) > 0 + BEGIN + SET @blocked = 1; BREAK; + END + END + END + + FETCH NEXT FROM cur INTO @w; + END + + CLOSE cur; DEALLOCATE cur; + RETURN @blocked; +END +GO + +/* ======================= + encode(numbers) -> String + Input as JSON array of int64: [1,2,3] + 1:1 zu Swift _encode + ======================= */ +CREATE OR ALTER FUNCTION sqids.EncodeJson +( + @numbersJson NVARCHAR(MAX) +) +RETURNS NVARCHAR(4000) +AS +BEGIN + DECLARE @alphabet NVARCHAR(255); + DECLARE @minLength INT; + DECLARE @BlocklistJson NVARCHAR(MAX); + + SELECT @alphabet = Alphabet, @minLength = MinLength, @BlocklistJson = BlocklistJson + FROM sqids.Config + WHERE ConfigName = N'default'; + + IF @alphabet IS NULL RETURN NULL; + + /* "class variables" as DECLAREs */ + DECLARE @minAlphabetLength INT = 3; + DECLARE @minLengthLimit INT = 255; + + IF @numbersJson IS NULL OR ISJSON(@numbersJson) <> 1 RETURN N''; + IF @alphabet IS NULL OR LEN(@alphabet) < @minAlphabetLength RETURN NULL; + IF @minLength < 0 OR @minLength > @minLengthLimit RETURN NULL; + + /* parse numbers */ + DECLARE @Numbers TABLE (idx INT PRIMARY KEY, n BIGINT NOT NULL); + INSERT INTO @Numbers(idx, n) + SELECT CONVERT(INT, [key]), TRY_CONVERT(BIGINT, value) + FROM OPENJSON(@numbersJson) + WHERE type IN (2,3); -- number + + IF NOT EXISTS (SELECT 1 FROM @Numbers) RETURN N''; -- empty array -> "" + + IF EXISTS (SELECT 1 FROM @Numbers WHERE n < 0) RETURN NULL; + + /* self.alphabet = shuffle(alphabet) */ + DECLARE @selfAlphabet NVARCHAR(255) = sqids._Shuffle(@alphabet); + DECLARE @alphaCount INT = LEN(@selfAlphabet); + DECLARE @count BIGINT = @alphaCount; -- Swift Id(alphabet.count) + + DECLARE @increment INT = 0; + + WHILE @increment < @alphaCount + BEGIN + /* offset calc: + numbers.enumerated().reduce(numbers.count) { res + i + ascii(self.alphabet[number%count]) } % self.alphabet.count + then + increment + */ + DECLARE @numCount INT = (SELECT COUNT(*) FROM @Numbers); + DECLARE @offset INT; + + SELECT @offset = + ( + @numCount + + ISNULL(SUM( + idx + + UNICODE(SUBSTRING(@selfAlphabet, CONVERT(INT, (n % @count)) + 1, 1)) + ), 0) + ) % @alphaCount + FROM @Numbers; + + SET @offset = (@offset + @increment) % @alphaCount; + + SET @offset = @offset % @alphaCount; + SET @offset = (@offset + @increment) % @alphaCount; + + /* alphabet = splitReverse(offset) */ + DECLARE @work NVARCHAR(255) = sqids._SplitReverse(@selfAlphabet, @offset); + + /* result starts with self.alphabet[offset] (NOTE: from selfAlphabet, not work) */ + DECLARE @result NVARCHAR(4000) = SUBSTRING(@selfAlphabet, @offset + 1, 1); + + /* loop numbers */ + DECLARE @i INT = 0; + DECLARE @n BIGINT; + + DECLARE numCur CURSOR LOCAL FAST_FORWARD FOR + SELECT n FROM @Numbers ORDER BY idx; + + OPEN numCur; + FETCH NEXT FROM numCur INTO @n; + + WHILE @@FETCH_STATUS = 0 + BEGIN + /* id = toId(number, alphabet: Array(work.suffix(from: 1))) */ + DECLARE @suffix NVARCHAR(255) = SUBSTRING(@work, 2, LEN(@work) - 1); + SET @result += sqids._ToIdBase(@n, @suffix); + + /* if not last: append separator work[0], then shuffle(work) */ + IF @i < (SELECT COUNT(*) FROM @Numbers) - 1 + BEGIN + SET @result += SUBSTRING(@work, 1, 1); + SET @work = sqids._Shuffle(@work); + END + + SET @i += 1; + FETCH NEXT FROM numCur INTO @n; + END + + CLOSE numCur; DEALLOCATE numCur; + + /* minLength padding 1:1 */ + IF @minLength > LEN(@result) + BEGIN + SET @result += SUBSTRING(@work, 1, 1); + + WHILE @minLength > LEN(@result) + BEGIN + DECLARE @need INT = @minLength - LEN(@result); + DECLARE @take INT = IIF(@need < LEN(@work), @need, LEN(@work)); + + SET @work = sqids._Shuffle(@work); + SET @result += SUBSTRING(@work, 1, @take); + END + END + + /* blocklist check */ + IF sqids._IsBlocked(@result, @alphabet, @blocklistJson) = 0 + RETURN @result; + + SET @increment += 1; + END + + RETURN NULL; /* maximumAttemptsReached */ +END +GO + +/* ======================= + decode(id) -> Ids (JSON) + 1:1 zu Swift decode() + ======================= */ + +/* ===== decode(id) -> Ids (JSON) CHARINDEX case-sensitive ===== */ +/* --- BIN2-sicherer Decoder (Swift decode 1:1) --- */ +CREATE OR ALTER FUNCTION sqids.DecodeJson +( + @id NVARCHAR(4000) +) +RETURNS NVARCHAR(MAX) +AS +BEGIN + DECLARE @alphabet NVARCHAR(255); + + SELECT @alphabet = Alphabet + FROM sqids.Config + WHERE ConfigName = N'default'; + + IF @alphabet IS NULL RETURN NULL; + + IF @id IS NULL OR @id = N'' RETURN N'[]'; + IF @alphabet IS NULL OR LEN(@alphabet) < 3 RETURN N'[]'; + + DECLARE @selfAlphabet NVARCHAR(255) = sqids._Shuffle(@alphabet); + DECLARE @selfAlphabetCS NVARCHAR(255) = @selfAlphabet COLLATE Latin1_General_100_BIN2; + + /* validate: all chars must exist in alphabet (case-sensitive) */ + DECLARE @p INT = 1, @L INT = LEN(@id); + WHILE @p <= @L + BEGIN + IF CHARINDEX(SUBSTRING(@id, @p, 1) COLLATE Latin1_General_100_BIN2, @selfAlphabetCS) = 0 + RETURN N'[]'; + SET @p += 1; + END + + /* offset = index of first char in shuffled alphabet */ + DECLARE @offset INT = + CHARINDEX(SUBSTRING(@id, 1, 1) COLLATE Latin1_General_100_BIN2, @selfAlphabetCS) - 1; + IF @offset < 0 RETURN N'[]'; + + DECLARE @work NVARCHAR(255) = sqids._SplitReverse(@selfAlphabet, @offset); + DECLARE @value NVARCHAR(4000) = SUBSTRING(@id, 2, LEN(@id) - 1); + + DECLARE @out NVARCHAR(MAX) = N'['; + DECLARE @first BIT = 1; + + WHILE @value <> N'' + BEGIN + DECLARE @sep NCHAR(1) = SUBSTRING(@work, 1, 1); + + /* IMPORTANT: separator search must be BIN2 */ + DECLARE @posSep INT = CHARINDEX( + @sep COLLATE Latin1_General_100_BIN2, + @value COLLATE Latin1_General_100_BIN2 + ); + + DECLARE @chunk NVARCHAR(4000) = + CASE WHEN @posSep = 0 THEN @value ELSE LEFT(@value, @posSep - 1) END; + + IF @chunk = N'' BREAK; -- padding marker like Swift + + DECLARE @suffix NVARCHAR(255) = SUBSTRING(@work, 2, LEN(@work) - 1); + DECLARE @num BIGINT = sqids._ToNumberBase(@chunk, @suffix); + IF @num IS NULL RETURN N'[]'; + + SET @out += CASE WHEN @first=1 THEN N'' ELSE N',' END + CONVERT(NVARCHAR(40), @num); + SET @first = 0; + + IF @posSep = 0 + SET @value = N''; + ELSE + BEGIN + SET @work = sqids._Shuffle(@work); + SET @value = SUBSTRING(@value, @posSep + 1, LEN(@value) - @posSep); + END + END + + SET @out += N']'; + RETURN @out; +END +GO + +CREATE OR ALTER FUNCTION sqids.ToId(@n BIGINT) +RETURNS NVARCHAR(4000) +AS +BEGIN + + RETURN sqids.EncodeJson(N'[' + CONVERT(NVARCHAR(40), @n) + N']'); +END +GO + +CREATE OR ALTER FUNCTION sqids.ToNumber(@id NVARCHAR(4000)) +RETURNS BIGINT +AS +BEGIN + DECLARE @json NVARCHAR(MAX) = sqids.DecodeJson(@id); + RETURN TRY_CONVERT(BIGINT, JSON_VALUE(@json, '$[0]')); +END +GO + \ No newline at end of file diff --git a/tests/EncodingTests.sql b/tests/EncodingTests.sql new file mode 100644 index 0000000..083be02 --- /dev/null +++ b/tests/EncodingTests.sql @@ -0,0 +1,275 @@ +CREATE OR ALTER PROCEDURE sqids.RunEncodingTests +AS +BEGIN + SET NOCOUNT ON; + + /* ========================================================= + Test-Konstanten (fest, keine Parameter) + ========================================================= */ + DECLARE @ConfigName SYSNAME = N'default'; + DECLARE @Alphabet NVARCHAR(255) = N'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + DECLARE @MinLength INT = 0; + + /* ========================================================= + Init (BlocklistJson = NULL => Default-Blocklist) + Encode/Decode lesen intern aus sqids.Config + ========================================================= */ + EXEC sqids.Init + @ConfigName = @ConfigName, + @Alphabet = @Alphabet, + @MinLength = @MinLength, + @BlocklistJson = NULL; + + IF NOT EXISTS (SELECT 1 FROM sqids.Config WHERE ConfigName = @ConfigName) + THROW 56001, 'Config row not found after Init.', 1; + + /* ========================================================= + Fail-Collector + ========================================================= */ + CREATE TABLE #Fail + ( + TestName NVARCHAR(140) NOT NULL, + CaseName NVARCHAR(140) NULL, + Input NVARCHAR(MAX) NULL, + Id NVARCHAR(4000) NULL, + Got NVARCHAR(MAX) NULL, + Expected NVARCHAR(MAX) NULL + ); + + /* Helper: JSON arrays compare by [key]+value (order sensitive like original arrays) */ + DECLARE @E NVARCHAR(MAX), @G NVARCHAR(MAX); + + /* ========================================================= + 1) EncodeAndDecode_SingleNumber_ReturnsExactMatch (0..9) + ========================================================= */ + DECLARE @single TABLE (n BIGINT NOT NULL, id NVARCHAR(50) NOT NULL); + INSERT INTO @single(n,id) VALUES + (0, N'bM'), + (1, N'Uk'), + (2, N'gb'), + (3, N'Ef'), + (4, N'Vq'), + (5, N'uw'), + (6, N'OI'), + (7, N'AX'), + (8, N'p6'), + (9, N'nJ'); + + DECLARE @n BIGINT, @idExp NVARCHAR(50), @idGot NVARCHAR(4000), @back BIGINT; + + DECLARE c1 CURSOR LOCAL FAST_FORWARD FOR + SELECT n, id FROM @single ORDER BY n; + OPEN c1; + FETCH NEXT FROM c1 INTO @n, @idExp; + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @idGot = sqids.ToId(@n); + IF @idGot <> @idExp + INSERT INTO #Fail(TestName, CaseName, Input, Id, Got, Expected) + VALUES (N'EncodeAndDecode_SingleNumber_ReturnsExactMatch', CONVERT(NVARCHAR(40),@n), + CONVERT(NVARCHAR(40),@n), @idGot, @idGot, @idExp); + + SET @back = sqids.ToNumber(@idExp); + IF @back <> @n + INSERT INTO #Fail(TestName, CaseName, Input, Id, Got, Expected) + VALUES (N'EncodeAndDecode_SingleNumber_ReturnsExactMatch', CONVERT(NVARCHAR(40),@n), + @idExp, @idExp, CONVERT(NVARCHAR(40),@back), CONVERT(NVARCHAR(40),@n)); + + FETCH NEXT FROM c1 INTO @n, @idExp; + END + CLOSE c1; DEALLOCATE c1; + + /* ========================================================= + 2) EncodeAndDecode_MultipleNumbers_ReturnsExactMatch + ========================================================= */ + DECLARE @multiExact TABLE (CaseName NVARCHAR(140) NOT NULL, NumbersJson NVARCHAR(MAX) NOT NULL, IdExpected NVARCHAR(4000) NOT NULL); + INSERT INTO @multiExact(CaseName, NumbersJson, IdExpected) VALUES + (N'simple_[1,2,3]', N'[1,2,3]', N'86Rf07'), + + (N'inc_[0,0]', N'[0,0]', N'SvIz'), + (N'inc_[0,1]', N'[0,1]', N'n3qa'), + (N'inc_[0,2]', N'[0,2]', N'tryF'), + (N'inc_[0,3]', N'[0,3]', N'eg6q'), + (N'inc_[0,4]', N'[0,4]', N'rSCF'), + (N'inc_[0,5]', N'[0,5]', N'sR8x'), + (N'inc_[0,6]', N'[0,6]', N'uY2M'), + (N'inc_[0,7]', N'[0,7]', N'74dI'), + (N'inc_[0,8]', N'[0,8]', N'30WX'), + (N'inc_[0,9]', N'[0,9]', N'moxr'), + + (N'inc_[1,0]', N'[1,0]', N'nWqP'), + (N'inc_[2,0]', N'[2,0]', N'tSyw'), + (N'inc_[3,0]', N'[3,0]', N'eX68'), + (N'inc_[4,0]', N'[4,0]', N'rxCY'), + (N'inc_[5,0]', N'[5,0]', N'sV8a'), + (N'inc_[6,0]', N'[6,0]', N'uf2K'), + (N'inc_[7,0]', N'[7,0]', N'7Cdk'), + (N'inc_[8,0]', N'[8,0]', N'3aWP'), + (N'inc_[9,0]', N'[9,0]', N'm2xn'), + + (N'empty_array', N'[]', N''); + + DECLARE @case NVARCHAR(140), @nums NVARCHAR(MAX); + + DECLARE c2 CURSOR LOCAL FAST_FORWARD FOR + SELECT CaseName, NumbersJson, IdExpected FROM @multiExact ORDER BY CaseName; + OPEN c2; + FETCH NEXT FROM c2 INTO @case, @nums, @idExp; + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @idGot = sqids.EncodeJson(@nums); + IF ISNULL(@idGot,N'') <> @idExp + INSERT INTO #Fail(TestName, CaseName, Input, Id, Got, Expected) + VALUES (N'EncodeAndDecode_MultipleNumbers_ReturnsExactMatch.encode', @case, @nums, @idGot, @idGot, @idExp); + + SET @G = sqids.DecodeJson(@idExp); + SET @E = @nums; + + IF EXISTS (SELECT [key], value FROM OPENJSON(@E) EXCEPT SELECT [key], value FROM OPENJSON(@G)) + OR EXISTS (SELECT [key], value FROM OPENJSON(@G) EXCEPT SELECT [key], value FROM OPENJSON(@E)) + INSERT INTO #Fail(TestName, CaseName, Input, Id, Got, Expected) + VALUES (N'EncodeAndDecode_MultipleNumbers_ReturnsExactMatch.decode', @case, @nums, @idExp, @G, @E); + + FETCH NEXT FROM c2 INTO @case, @nums, @idExp; + END + CLOSE c2; DEALLOCATE c2; + + /* ========================================================= + 3) EncodeAndDecode_MultipleNumbers_RoundTripsSuccessfully + ========================================================= */ + DECLARE @roundTrips TABLE (CaseName NVARCHAR(140) NOT NULL, NumbersJson NVARCHAR(MAX) NOT NULL); + INSERT INTO @roundTrips(CaseName, NumbersJson) VALUES + (N'rt_mixed', N'[0,0,0,1,2,3,100,1000,100000,1000000,2147483647]'), + (N'rt_0to99', N'[' + + N'0,1,2,3,4,5,6,7,8,9,' + + N'10,11,12,13,14,15,16,17,18,19,' + + N'20,21,22,23,24,25,26,27,28,29,' + + N'30,31,32,33,34,35,36,37,38,39,' + + N'40,41,42,43,44,45,46,47,48,49,' + + N'50,51,52,53,54,55,56,57,58,59,' + + N'60,61,62,63,64,65,66,67,68,69,' + + N'70,71,72,73,74,75,76,77,78,79,' + + N'80,81,82,83,84,85,86,87,88,89,' + + N'90,91,92,93,94,95,96,97,98,99' + + N']'); + + DECLARE c3 CURSOR LOCAL FAST_FORWARD FOR + SELECT CaseName, NumbersJson FROM @roundTrips ORDER BY CaseName; + OPEN c3; + FETCH NEXT FROM c3 INTO @case, @nums; + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @idGot = sqids.EncodeJson(@nums); + SET @G = sqids.DecodeJson(@idGot); + SET @E = @nums; + + IF EXISTS (SELECT [key], value FROM OPENJSON(@E) EXCEPT SELECT [key], value FROM OPENJSON(@G)) + OR EXISTS (SELECT [key], value FROM OPENJSON(@G) EXCEPT SELECT [key], value FROM OPENJSON(@E)) + INSERT INTO #Fail(TestName, CaseName, Input, Id, Got, Expected) + VALUES (N'EncodeAndDecode_MultipleNumbers_RoundTripsSuccessfully', @case, @nums, @idGot, @G, @E); + + FETCH NEXT FROM c3 INTO @case, @nums; + END + CLOSE c3; DEALLOCATE c3; + + /* ========================================================= + 4) Decode_WithInvalidCharacters_ReturnsEmptyArray ("*") + ========================================================= */ + SET @G = sqids.DecodeJson(N'*'); + IF @G <> N'[]' + INSERT INTO #Fail(TestName, CaseName, Input, Got, Expected) + VALUES (N'Decode_WithInvalidCharacters_ReturnsEmptyArray', N'*', N'*', @G, N'[]'); + + /* ========================================================= + 5) Encode_OutOfRangeNumber_Throws (in T-SQL: error OR NULL is acceptable) + ========================================================= */ + BEGIN TRY + SET @idGot = sqids.ToId(-1); + IF @idGot IS NOT NULL + INSERT INTO #Fail(TestName, CaseName, Input, Id, Got, Expected) + VALUES (N'Encode_OutOfRangeNumber', N'-1', N'-1', @idGot, N'(not NULL)', N'ERROR or NULL'); + END TRY + BEGIN CATCH + -- ok: throwing is acceptable + -- (optional) you could assert the error number/message here if you standardized it + END CATCH; + + /* ========================================================= + 6) SingleNumberOfDifferentIntegerTypes_RoundTripsSuccessfully (SQL analog) + ========================================================= */ + DECLARE @singleTypes TABLE (CaseName NVARCHAR(60), n BIGINT); + INSERT INTO @singleTypes(CaseName,n) VALUES + (N'byte.MaxValue', 255), + (N'sbyte.MaxValue', 127), + (N'short.MaxValue', 32767), + (N'ushort.MaxValue', 65535), + (N'int.MaxValue', 2147483647), + (N'uint.MaxValue', 4294967295), + (N'long.MaxValue', 9223372036854775807); + + DECLARE c4 CURSOR LOCAL FAST_FORWARD FOR + SELECT CaseName, n FROM @singleTypes ORDER BY CaseName; + OPEN c4; + FETCH NEXT FROM c4 INTO @case, @n; + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @idGot = sqids.ToId(@n); + SET @back = sqids.ToNumber(@idGot); + + IF @back <> @n + INSERT INTO #Fail(TestName, CaseName, Input, Id, Got, Expected) + VALUES (N'SingleNumberOfDifferentIntegerTypes_RoundTripsSuccessfully', @case, + CONVERT(NVARCHAR(40),@n), @idGot, + CONVERT(NVARCHAR(40),@back), CONVERT(NVARCHAR(40),@n)); + + FETCH NEXT FROM c4 INTO @case, @n; + END + CLOSE c4; DEALLOCATE c4; + + /* ========================================================= + 7) MultipleNumbersOfDifferentIntegerTypes_RoundTripsSuccessfully (SQL analog) + (entspricht: [0, 1*part, 2*part, 3*part, 4*part, Max]) + ========================================================= */ + DECLARE @multiTypes TABLE (CaseName NVARCHAR(60), NumbersJson NVARCHAR(MAX)); + INSERT INTO @multiTypes(CaseName, NumbersJson) VALUES + (N'byte', N'[0,25,50,75,100,255]'), -- 255/10 = 25 + (N'sbyte', N'[0,12,24,36,48,127]'), -- 127/10 = 12 + (N'short', N'[0,3276,6552,9828,13104,32767]'), + (N'ushort', N'[0,6553,13106,19659,26212,65535]'), + (N'int', N'[0,214748364,429496728,644245092,858993456,2147483647]'), + (N'uint', N'[0,429496729,858993458,1288490187,1717986916,4294967295]'), + (N'long', N'[0,922337203685477580,1844674407370955160,2767011611056432740,3689348814741910320,9223372036854775807]'); + + DECLARE c5 CURSOR LOCAL FAST_FORWARD FOR + SELECT CaseName, NumbersJson FROM @multiTypes ORDER BY CaseName; + OPEN c5; + FETCH NEXT FROM c5 INTO @case, @nums; + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @idGot = sqids.EncodeJson(@nums); + SET @G = sqids.DecodeJson(@idGot); + SET @E = @nums; + + IF EXISTS (SELECT [key], value FROM OPENJSON(@E) EXCEPT SELECT [key], value FROM OPENJSON(@G)) + OR EXISTS (SELECT [key], value FROM OPENJSON(@G) EXCEPT SELECT [key], value FROM OPENJSON(@E)) + INSERT INTO #Fail(TestName, CaseName, Input, Id, Got, Expected) + VALUES (N'MultipleNumbersOfDifferentIntegerTypes_RoundTripsSuccessfully', @case, @nums, @idGot, @G, @E); + + FETCH NEXT FROM c5 INTO @case, @nums; + END + CLOSE c5; DEALLOCATE c5; + + /* ========================================================= + Output + ========================================================= */ + SELECT + ConfigName = @ConfigName, + Failures = (SELECT COUNT(*) FROM #Fail); + + SELECT * + FROM #Fail + ORDER BY TestName, CaseName; +END +GO + +EXEC sqids.RunEncodingTests diff --git a/tests/GeneralTests.sql b/tests/GeneralTests.sql new file mode 100644 index 0000000..e230801 --- /dev/null +++ b/tests/GeneralTests.sql @@ -0,0 +1,214 @@ +CREATE OR ALTER PROCEDURE sqids.RunTests +AS +BEGIN + SET NOCOUNT ON; + + /* ========================================================= + Test-Konstanten (bei Bedarf hier ndern) + ========================================================= */ + DECLARE @ConfigName SYSNAME = N'default'; + DECLARE @Alphabet NVARCHAR(255) = N'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + DECLARE @MinLength INT = 0; + + -- NULL => Init verwendet Default-Blocklist (deine nderung) + DECLARE @BlocklistJson NVARCHAR(MAX) = NULL; + + DECLARE @MaxN INT = 10000; -- Roundtrip 0..MaxN + DECLARE @BlocklistSample INT = 100000; -- Sample frs Blocklist-Scanning + + /* ========================================================= + Constructor/Init-Validierungen (wie TS) + ========================================================= */ + IF @Alphabet IS NULL OR LEN(@Alphabet) < 3 + THROW 55001, 'Alphabet length must be at least 3', 1; + + IF @MinLength < 0 OR @MinLength > 255 + THROW 55002, 'Minimum length has to be between 0 and 255', 1; + + -- ASCII only + IF EXISTS ( + SELECT 1 + FROM master..spt_values v + WHERE v.type='P' + AND v.number BETWEEN 1 AND LEN(@Alphabet) + AND UNICODE(SUBSTRING(@Alphabet, v.number, 1)) > 127 + ) + THROW 55003, 'Alphabet cannot contain multibyte characters', 1; + + -- Unique (case-sensitive) + IF LEN(@Alphabet) <> ( + SELECT COUNT(DISTINCT SUBSTRING(@Alphabet, v.number, 1) COLLATE Latin1_General_100_BIN2) + FROM master..spt_values v + WHERE v.type='P' AND v.number BETWEEN 1 AND LEN(@Alphabet) + ) + THROW 55004, 'Alphabet must contain unique characters', 1; + + /* ========================================================= + Init (Config wird befllt; Encode/Decode lesen intern aus Config) + ========================================================= */ + EXEC sqids.Init + @ConfigName = @ConfigName, + @Alphabet = @Alphabet, + @MinLength = @MinLength, + @BlocklistJson = @BlocklistJson; + + -- Sanity: Config muss existieren und Blocklist muss (durch Default) gesetzt sein + IF NOT EXISTS (SELECT 1 FROM sqids.Config WHERE ConfigName = @ConfigName) + THROW 55005, 'Config row not found after Init.', 1; + + IF (SELECT BlocklistJson FROM sqids.Config WHERE ConfigName = @ConfigName) IS NULL + THROW 55006, 'BlocklistJson is NULL after Init (expected default blocklist).', 1; + + /* ========================================================= + Fehler sammeln + ========================================================= */ + CREATE TABLE #Fail + ( + TestName NVARCHAR(100) NOT NULL, + n INT NULL, + id NVARCHAR(4000) NULL, + got NVARCHAR(4000) NULL, + expected NVARCHAR(4000) NULL + ); + + /* ========================================================= + 1) Roundtrip Single: ToNumber(ToId(n)) = n + ========================================================= */ + ;WITH nums AS ( + SELECT TOP (@MaxN + 1) + ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) - 1 AS n + FROM sys.all_objects a + CROSS JOIN sys.all_objects b + ), + rt AS ( + SELECT + n, + id = sqids.ToId(n), + back = sqids.ToNumber(sqids.ToId(n)) + FROM nums + ) + INSERT INTO #Fail(TestName, n, id, got, expected) + SELECT N'roundtrip_single', n, id, CONVERT(NVARCHAR(40), back), CONVERT(NVARCHAR(40), n) + FROM rt + WHERE id IS NULL OR back IS NULL OR back <> n; + + /* ========================================================= + 2) MinLength muss eingehalten werden (wenn > 0) + ========================================================= */ + IF EXISTS (SELECT 1 FROM sqids.Config WHERE ConfigName=@ConfigName AND MinLength > 0) + BEGIN + DECLARE @StoredMinLength INT = (SELECT MinLength FROM sqids.Config WHERE ConfigName=@ConfigName); + + ;WITH nums AS ( + SELECT TOP (5000) + ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) - 1 AS n + FROM sys.all_objects a + CROSS JOIN sys.all_objects b + ), + ids AS ( + SELECT n, id = sqids.ToId(n) + FROM nums + ) + INSERT INTO #Fail(TestName, n, id, got, expected) + SELECT N'minLength', n, id, + CONVERT(NVARCHAR(40), LEN(id)), + CONVERT(NVARCHAR(40), @StoredMinLength) + FROM ids + WHERE id IS NULL OR LEN(id) < @StoredMinLength; + END + + /* ========================================================= + 3) Multi-number Roundtrip: DecodeJson(EncodeJson(nums)) = nums + ========================================================= */ + DECLARE @cases TABLE (name NVARCHAR(60) NOT NULL, nums NVARCHAR(MAX) NOT NULL); + INSERT INTO @cases(name, nums) VALUES + (N'multi_[1,2,3]', N'[1,2,3]'), + (N'multi_[0,0,0]', N'[0,0,0]'), + (N'multi_[10,100,1000,1108]', N'[10,100,1000,1108]'), + (N'multi_[999,1,999,2]', N'[999,1,999,2]'), + (N'multi_[0,1,0,1,0,1]', N'[0,1,0,1,0,1]'); + + DECLARE @caseName NVARCHAR(60), @numsJson NVARCHAR(MAX); + DECLARE @id NVARCHAR(4000), @dec NVARCHAR(MAX); + + DECLARE c CURSOR LOCAL FAST_FORWARD FOR + SELECT name, nums FROM @cases ORDER BY name; + + OPEN c; + FETCH NEXT FROM c INTO @caseName, @numsJson; + + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @id = sqids.EncodeJson(@numsJson); + SET @dec = sqids.DecodeJson(@id); + + IF @id IS NULL + INSERT INTO #Fail(TestName, id, got, expected) + VALUES (@caseName, NULL, N'EncodeJson returned NULL', @numsJson); + ELSE IF @dec <> @numsJson + INSERT INTO #Fail(TestName, id, got, expected) + VALUES (@caseName, @id, @dec, @numsJson); + + FETCH NEXT FROM c INTO @caseName, @numsJson; + END + + CLOSE c; DEALLOCATE c; + + /* ========================================================= + 4) Decode: ungltiges Zeichen => [] + ========================================================= */ + DECLARE @invalid NVARCHAR(50) = N'abc-def'; + DECLARE @decodedInvalid NVARCHAR(MAX) = sqids.DecodeJson(@invalid); + + IF @decodedInvalid <> N'[]' + INSERT INTO #Fail(TestName, id, got, expected) + VALUES (N'decode_invalid_char', @invalid, @decodedInvalid, N'[]'); + + /* ========================================================= + 5) Blocklist: erzeugte IDs drfen nicht blocked sein + (Init setzt immer Blocklist, auch Default) + ========================================================= */ + IF OBJECT_ID('sqids._IsBlocked', 'FN') IS NOT NULL + BEGIN + ;WITH nums AS ( + SELECT TOP (@BlocklistSample + 1) + ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) - 1 AS n + FROM sys.all_objects a + CROSS JOIN sys.all_objects b + ), + ids AS ( + SELECT n, id = sqids.ToId(n) + FROM nums + ) + INSERT INTO #Fail(TestName, n, id, got, expected) + SELECT TOP (200) + N'blocklist_hit', + n, + id, + N'blocked', + N'not blocked' + FROM ids + WHERE id IS NOT NULL + AND sqids._IsBlocked(id, @alphabet, @blocklistJson) = 1 + ORDER BY n; + END + ELSE + BEGIN + INSERT INTO #Fail(TestName, got, expected) + VALUES (N'blocklist_hit', N'skipped (sqids._IsBlocked not found)', N'function exists'); + END + + /* ========================================================= + Ergebnis + ========================================================= */ + SELECT + ConfigName = @ConfigName, + MinLengthStored = (SELECT MinLength FROM sqids.Config WHERE ConfigName=@ConfigName), + Failures = (SELECT COUNT(*) FROM #Fail); + + SELECT * FROM #Fail ORDER BY TestName, n; +END +GO + + +EXEC sqids.RunTests; \ No newline at end of file diff --git a/tests/MinLengthTests.sql b/tests/MinLengthTests.sql new file mode 100644 index 0000000..6d624c3 --- /dev/null +++ b/tests/MinLengthTests.sql @@ -0,0 +1,247 @@ +CREATE OR ALTER PROCEDURE sqids.RunMinLengthTests +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ConfigName SYSNAME = N'default'; + DECLARE @Alphabet NVARCHAR(255) = N'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + DECLARE @MinLength INT = len(@Alphabet); + + CREATE TABLE #Fail + ( + TestName NVARCHAR(120) NOT NULL, + MinLength INT NULL, + Id NVARCHAR(4000) NULL, + Got NVARCHAR(MAX) NULL, + Expected NVARCHAR(MAX) NULL + ); + + /* Helper: compare two JSON arrays by index+value (no whitespace sensitivity) */ + DECLARE @E NVARCHAR(MAX), @G NVARCHAR(MAX); + + /* ========================================================= + testSimple() + ========================================================= */ + EXEC sqids.Init @ConfigName=@ConfigName, @Alphabet=@Alphabet, @MinLength=@MinLength, @BlocklistJson=NULL; + + DECLARE @numbersSimple NVARCHAR(MAX) = N'[1,2,3]'; + DECLARE @idSimpleExpected NVARCHAR(4000) = + N'86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTM'; + + DECLARE @idSimpleGot NVARCHAR(4000) = sqids.EncodeJson(@numbersSimple); + IF @idSimpleGot <> @idSimpleExpected + INSERT INTO #Fail(TestName, MinLength, Id, Got, Expected) + VALUES (N'testSimple.encode', @MinLength, @idSimpleGot, @idSimpleGot, @idSimpleExpected); + + SET @G = sqids.DecodeJson(@idSimpleExpected); + SET @E = @numbersSimple; + + IF EXISTS ( + SELECT [key], value FROM OPENJSON(@E) + EXCEPT + SELECT [key], value FROM OPENJSON(@G) + ) OR EXISTS ( + SELECT [key], value FROM OPENJSON(@G) + EXCEPT + SELECT [key], value FROM OPENJSON(@E) + ) + INSERT INTO #Fail(TestName, MinLength, Id, Got, Expected) + VALUES (N'testSimple.decode', LEN(@Alphabet), @idSimpleExpected, @G, @E); + + /* ========================================================= + testIncremental() + ========================================================= */ + DECLARE @inc TABLE (MinLength INT NOT NULL, ExpectedId NVARCHAR(4000) NOT NULL); + INSERT INTO @inc(MinLength, ExpectedId) VALUES + (6, N'86Rf07'), + (7, N'86Rf07x'), + (8, N'86Rf07xd'), + (9, N'86Rf07xd4'), + (10, N'86Rf07xd4z'), + (11, N'86Rf07xd4zB'), + (12, N'86Rf07xd4zBm'), + (13, N'86Rf07xd4zBmi'), + (LEN(@Alphabet) + 0, N'86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTM'), + (LEN(@Alphabet) + 1, N'86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMy'), + (LEN(@Alphabet) + 2, N'86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMyf'), + (LEN(@Alphabet) + 3, N'86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMyf1'); + + DECLARE @ml INT, @idExp NVARCHAR(4000); + DECLARE inc CURSOR LOCAL FAST_FORWARD FOR + SELECT MinLength, ExpectedId FROM @inc ORDER BY MinLength; + + OPEN inc; + FETCH NEXT FROM inc INTO @ml, @idExp; + + WHILE @@FETCH_STATUS = 0 + BEGIN + EXEC sqids.Init @ConfigName=@ConfigName, @Alphabet=@Alphabet, @MinLength=@ml, @BlocklistJson=NULL; + + DECLARE @idGot NVARCHAR(4000) = sqids.EncodeJson(@numbersSimple); + IF @idGot <> @idExp + INSERT INTO #Fail(TestName, MinLength, Id, Got, Expected) + VALUES (N'testIncremental.encode', @ml, @idGot, @idGot, @idExp); + + IF LEN(@idGot) <> @ml + INSERT INTO #Fail(TestName, MinLength, Id, Got, Expected) + VALUES (N'testIncremental.length', @ml, @idGot, CONVERT(NVARCHAR(40), LEN(@idGot)), CONVERT(NVARCHAR(40), @ml)); + + SET @G = sqids.DecodeJson(@idExp); + SET @E = @numbersSimple; + + IF EXISTS ( + SELECT [key], value FROM OPENJSON(@E) + EXCEPT + SELECT [key], value FROM OPENJSON(@G) + ) OR EXISTS ( + SELECT [key], value FROM OPENJSON(@G) + EXCEPT + SELECT [key], value FROM OPENJSON(@E) + ) + INSERT INTO #Fail(TestName, MinLength, Id, Got, Expected) + VALUES (N'testIncremental.decode', @ml, @idExp, @G, @E); + + FETCH NEXT FROM inc INTO @ml, @idExp; + END + + CLOSE inc; + DEALLOCATE inc; + + /* ========================================================= + testIncrementalNumbers() + ========================================================= */ + EXEC sqids.Init @ConfigName=@ConfigName, @Alphabet=@Alphabet, @MinLength=@MinLength, @BlocklistJson=NULL; + + DECLARE @pairs TABLE (ExpectedId NVARCHAR(4000) NOT NULL, NumbersJson NVARCHAR(MAX) NOT NULL); + INSERT INTO @pairs(ExpectedId, NumbersJson) VALUES + (N'SvIzsqYMyQwI3GWgJAe17URxX8V924Co0DaTZLtFjHriEn5bPhcSkfmvOslpBu', N'[0,0]'), + (N'n3qafPOLKdfHpuNw3M61r95svbeJGk7aAEgYn4WlSjXURmF8IDqZBy0CT2VxQc', N'[0,1]'), + (N'tryFJbWcFMiYPg8sASm51uIV93GXTnvRzyfLleh06CpodJD42B7OraKtkQNxUZ', N'[0,2]'), + (N'eg6ql0A3XmvPoCzMlB6DraNGcWSIy5VR8iYup2Qk4tjZFKe1hbwfgHdUTsnLqE', N'[0,3]'), + (N'rSCFlp0rB2inEljaRdxKt7FkIbODSf8wYgTsZM1HL9JzN35cyoqueUvVWCm4hX', N'[0,4]'), + (N'sR8xjC8WQkOwo74PnglH1YFdTI0eaf56RGVSitzbjuZ3shNUXBrqLxEJyAmKv2', N'[0,5]'), + (N'uY2MYFqCLpgx5XQcjdtZK286AwWV7IBGEfuS9yTmbJvkzoUPeYRHr4iDs3naN0', N'[0,6]'), + (N'74dID7X28VLQhBlnGmjZrec5wTA1fqpWtK4YkaoEIM9SRNiC3gUJH0OFvsPDdy', N'[0,7]'), + (N'30WXpesPhgKiEI5RHTY7xbB1GnytJvXOl2p0AcUjdF6waZDo9Qk8VLzMuWrqCS', N'[0,8]'), + (N'moxr3HqLAK0GsTND6jowfZz3SUx7cQ8aC54Pl1RbIvFXmEJuBMYVeW9yrdOtin', N'[0,9]'); + + DECLARE @pid NVARCHAR(4000), @pnums NVARCHAR(MAX); + DECLARE pcur CURSOR LOCAL FAST_FORWARD FOR + SELECT ExpectedId, NumbersJson FROM @pairs ORDER BY ExpectedId; + + OPEN pcur; + FETCH NEXT FROM pcur INTO @pid, @pnums; + + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @idGot = sqids.EncodeJson(@pnums); + IF @idGot <> @pid + INSERT INTO #Fail(TestName, MinLength, Id, Got, Expected) + VALUES (N'testIncrementalNumbers.encode', LEN(@Alphabet), @idGot, @idGot, @pid); + + SET @G = sqids.DecodeJson(@pid); + SET @E = @pnums; + + IF EXISTS ( + SELECT [key], value FROM OPENJSON(@E) + EXCEPT + SELECT [key], value FROM OPENJSON(@G) + ) OR EXISTS ( + SELECT [key], value FROM OPENJSON(@G) + EXCEPT + SELECT [key], value FROM OPENJSON(@E) + ) + INSERT INTO #Fail(TestName, MinLength, Id, Got, Expected) + VALUES (N'testIncrementalNumbers.decode', LEN(@Alphabet), @pid, @G, @E); + + FETCH NEXT FROM pcur INTO @pid, @pnums; + END + + CLOSE pcur; + DEALLOCATE pcur; + + /* ========================================================= + testMinLengths() + ========================================================= */ + DECLARE @minLens TABLE (MinLength INT NOT NULL); + INSERT INTO @minLens(MinLength) VALUES (0),(1),(5),(10),(LEN(@Alphabet)); + + DECLARE @numSets TABLE (NumbersJson NVARCHAR(MAX) NOT NULL); + INSERT INTO @numSets(NumbersJson) VALUES + (N'[0]'), + (N'[0,0,0,0,0]'), + (N'[1,2,3,4,5,6,7,8,9,10]'), + (N'[100,200,300]'), + (N'[1000,2000,3000]'), + (N'[1000000]'), + (N'[9223372036854775807]'); -- Sqids.Id.max (Int64.max) + + DECLARE @m INT, @nums NVARCHAR(MAX); + DECLARE mcur CURSOR LOCAL FAST_FORWARD FOR SELECT MinLength FROM @minLens ORDER BY MinLength; + OPEN mcur; + FETCH NEXT FROM mcur INTO @m; + + WHILE @@FETCH_STATUS = 0 + BEGIN + EXEC sqids.Init @ConfigName=@ConfigName, @Alphabet=@Alphabet, @MinLength=@m, @BlocklistJson=NULL; + + DECLARE ncur CURSOR LOCAL FAST_FORWARD FOR SELECT NumbersJson FROM @numSets; + OPEN ncur; + FETCH NEXT FROM ncur INTO @nums; + + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @idGot = sqids.EncodeJson(@nums); + + IF @idGot IS NULL + INSERT INTO #Fail(TestName, MinLength, Got, Expected) + VALUES (N'testMinLengths.encode_NULL', @m, N'EncodeJson returned NULL', @nums); + ELSE + BEGIN + IF LEN(@idGot) < @m + INSERT INTO #Fail(TestName, MinLength, Id, Got, Expected) + VALUES (N'testMinLengths.length', @m, @idGot, CONVERT(NVARCHAR(40), LEN(@idGot)), CONVERT(NVARCHAR(40), @m)); + + SET @G = sqids.DecodeJson(@idGot); + SET @E = @nums; + + IF EXISTS ( + SELECT [key], value FROM OPENJSON(@E) + EXCEPT + SELECT [key], value FROM OPENJSON(@G) + ) OR EXISTS ( + SELECT [key], value FROM OPENJSON(@G) + EXCEPT + SELECT [key], value FROM OPENJSON(@E) + ) + INSERT INTO #Fail(TestName, MinLength, Id, Got, Expected) + VALUES (N'testMinLengths.decode', @m, @idGot, @G, @E); + END + + FETCH NEXT FROM ncur INTO @nums; + END + + CLOSE ncur; + DEALLOCATE ncur; + + FETCH NEXT FROM mcur INTO @m; + END + + CLOSE mcur; + DEALLOCATE mcur; + + /* ========================================================= + Output + ========================================================= */ + SELECT + ConfigName = @ConfigName, + Alphabet = @Alphabet, + Failures = (SELECT COUNT(*) FROM #Fail); + + SELECT * + FROM #Fail + ORDER BY TestName, MinLength, Id; +END +GO + +EXEC sqids.RunMinLengthTests;