From fb11f1294b9a34601b4428f8b47c0a93fa11b68c Mon Sep 17 00:00:00 2001 From: mariuszs Date: Fri, 17 Jul 2026 15:38:19 +0200 Subject: [PATCH 1/5] feat: add configurable encoding with legacy 1.0.x compatibility mode Version 1.1.0 switched the UUID pairing algorithm from Szudzik's elegant pairing to bit-shifting BigIntegerPairing, silently changing every generated FriendlyId and making old identifiers decode to wrong UUIDs. Services still on 1.0.x (with identifiers persisted by external consumers) could never upgrade without breaking their public ID space. - new public enum FriendlyIdEncoding: STANDARD (default, 1.1.0+ compatible) and LEGACY (1.0.x compatible, restored ElegantPairing) - global selection via FriendlyIds.setEncoding(...); all modules (Jackson, Jackson2, JPA, jOOQ, OpenFeign, Spring) funnel through it - Spring Boot starter property: com.devskiller.friendly-id.encoding=legacy (new FriendlyIdProperties, bound in FriendlyIdAutoConfiguration) - wire-format pinned by test vectors generated from released 1.0.4 and 1.1.0/2.0.0-beta5 artifacts, incl. edge cases (zero UUID, all-bits UUID, lsb sign bit) --- CHANGELOG.md | 7 ++ README.md | 26 ++++++ friendly-id-spring-boot-starter/pom.xml | 10 +++ .../boot/FriendlyIdAutoConfiguration.java | 10 +++ .../boot/FriendlyIdProperties.java | 40 +++++++++ .../boot/FriendlyIdAutoConfigurationTest.java | 49 +++++++++++ .../friendly_id/ElegantPairing.java | 52 ++++++++++++ .../friendly_id/FriendlyIdEncoding.java | 60 ++++++++++++++ .../devskiller/friendly_id/FriendlyIds.java | 32 +++++++ .../com/devskiller/friendly_id/Url62.java | 4 +- .../devskiller/friendly_id/UuidConverter.java | 8 +- .../friendly_id/AnalyzeGeneratedIdsTest.java | 2 +- .../friendly_id/FriendlyIdEncodingTest.java | 83 +++++++++++++++++++ 13 files changed, 376 insertions(+), 7 deletions(-) create mode 100644 friendly-id-spring-boot-starter/src/main/java/com/devskiller/friendly_id/boot/FriendlyIdProperties.java create mode 100644 friendly-id-spring-boot-starter/src/test/java/com/devskiller/friendly_id/boot/FriendlyIdAutoConfigurationTest.java create mode 100644 friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java create mode 100644 friendly-id/src/main/java/com/devskiller/friendly_id/FriendlyIdEncoding.java create mode 100644 friendly-id/src/test/java/com/devskiller/friendly_id/FriendlyIdEncodingTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fe3762..4829901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Configurable UUID⇄FriendlyId encoding (`FriendlyIdEncoding`): `STANDARD` (default, the bit-shifting + pairing used since 1.1.0) and `LEGACY` (Szudzik's elegant pairing from the 1.0.x line). Set globally + with `FriendlyIds.setEncoding(...)` or, with the Spring Boot starter, via the + `com.devskiller.friendly-id.encoding=legacy` property. The two encodings are wire-incompatible — + decoding an identifier with the wrong one silently yields a different UUID — so services must keep + the encoding their identifiers were issued with (pinned by test vectors generated from released + 1.0.4 and 1.1.0 artifacts). - FriendlyId value object type (`com.devskiller.friendly_id.type.FriendlyId`) as an alternative to raw UUID - JPA integration module (`friendly-id-jpa`) with automatic AttributeConverter - OpenFeign integration module (`friendly-id-openfeign`) for FriendlyId support in Feign clients diff --git a/README.md b/README.md index 5a00822..d088b8b 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,32 @@ UUID and `FriendlyId` parameters are automatically converted to FriendlyId strin Version 2.0 introduces several breaking changes to support Spring Boot 4 and Jackson 3. +#### Encoding compatibility (1.0.x vs 1.1.0+) + +Version 1.1.0 changed the internal UUID pairing algorithm, so **1.0.x and 1.1.0+ produce +different FriendlyId strings for the same UUID** — and decoding an identifier with the wrong +algorithm silently yields a different UUID. Since 2.0 the algorithm is selectable: + +| Encoding | Wire-compatible with | Notes | +|------------|----------------------|-------| +| `STANDARD` | 1.1.0 and newer | default | +| `LEGACY` | 1.0.x | Szudzik's elegant pairing | + +Services upgrading **from 1.0.x** must opt into the legacy encoding to keep their published +identifiers stable — either programmatically at startup: + +```java +FriendlyIds.setEncoding(FriendlyIdEncoding.LEGACY); +``` + +or, with the Spring Boot starter, via a property: + +```properties +com.devskiller.friendly-id.encoding=legacy +``` + +Services upgrading from 1.1.0+ need no changes — `STANDARD` is the default. + #### Requirements | Version | Java | Spring Boot | Jackson | diff --git a/friendly-id-spring-boot-starter/pom.xml b/friendly-id-spring-boot-starter/pom.xml index 18f3d2b..6dd1768 100644 --- a/friendly-id-spring-boot-starter/pom.xml +++ b/friendly-id-spring-boot-starter/pom.xml @@ -35,5 +35,15 @@ spring-boot-autoconfigure-processor true + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-web + test + \ No newline at end of file diff --git a/friendly-id-spring-boot-starter/src/main/java/com/devskiller/friendly_id/boot/FriendlyIdAutoConfiguration.java b/friendly-id-spring-boot-starter/src/main/java/com/devskiller/friendly_id/boot/FriendlyIdAutoConfiguration.java index 3a30185..892740d 100644 --- a/friendly-id-spring-boot-starter/src/main/java/com/devskiller/friendly_id/boot/FriendlyIdAutoConfiguration.java +++ b/friendly-id-spring-boot-starter/src/main/java/com/devskiller/friendly_id/boot/FriendlyIdAutoConfiguration.java @@ -3,7 +3,9 @@ import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import com.devskiller.friendly_id.FriendlyIds; import com.devskiller.friendly_id.spring.EnableFriendlyId; /** @@ -11,6 +13,9 @@ *

* Automatically enables FriendlyId converters and Jackson module when Spring Boot is detected. * Can be disabled by setting {@code com.devskiller.friendly-id.enabled=false} in application properties. + *

+ * The encoding can be selected with {@code com.devskiller.friendly-id.encoding} — set it to + * {@code legacy} to stay wire-compatible with identifiers issued by the friendly-id 1.0.x line. */ @AutoConfiguration @ConditionalOnWebApplication @@ -20,7 +25,12 @@ havingValue = "true", matchIfMissing = true ) +@EnableConfigurationProperties(FriendlyIdProperties.class) @EnableFriendlyId public class FriendlyIdAutoConfiguration { + FriendlyIdAutoConfiguration(FriendlyIdProperties properties) { + FriendlyIds.setEncoding(properties.getEncoding()); + } + } diff --git a/friendly-id-spring-boot-starter/src/main/java/com/devskiller/friendly_id/boot/FriendlyIdProperties.java b/friendly-id-spring-boot-starter/src/main/java/com/devskiller/friendly_id/boot/FriendlyIdProperties.java new file mode 100644 index 0000000..26c52d4 --- /dev/null +++ b/friendly-id-spring-boot-starter/src/main/java/com/devskiller/friendly_id/boot/FriendlyIdProperties.java @@ -0,0 +1,40 @@ +package com.devskiller.friendly_id.boot; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import com.devskiller.friendly_id.FriendlyIdEncoding; + +/** + * Configuration properties for the FriendlyId Spring Boot integration. + */ +@ConfigurationProperties(prefix = "com.devskiller.friendly-id") +public class FriendlyIdProperties { + + /** + * Whether to enable the FriendlyId auto-configuration. + */ + private boolean enabled = true; + + /** + * Encoding used for UUID to FriendlyId conversion. Use LEGACY to stay + * wire-compatible with identifiers issued by the friendly-id 1.0.x line. + */ + private FriendlyIdEncoding encoding = FriendlyIdEncoding.STANDARD; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public FriendlyIdEncoding getEncoding() { + return encoding; + } + + public void setEncoding(FriendlyIdEncoding encoding) { + this.encoding = encoding; + } + +} diff --git a/friendly-id-spring-boot-starter/src/test/java/com/devskiller/friendly_id/boot/FriendlyIdAutoConfigurationTest.java b/friendly-id-spring-boot-starter/src/test/java/com/devskiller/friendly_id/boot/FriendlyIdAutoConfigurationTest.java new file mode 100644 index 0000000..76eff8e --- /dev/null +++ b/friendly-id-spring-boot-starter/src/test/java/com/devskiller/friendly_id/boot/FriendlyIdAutoConfigurationTest.java @@ -0,0 +1,49 @@ +package com.devskiller.friendly_id.boot; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; + +import com.devskiller.friendly_id.FriendlyIdEncoding; +import com.devskiller.friendly_id.FriendlyIds; + +import static org.assertj.core.api.Assertions.assertThat; + +class FriendlyIdAutoConfigurationTest { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FriendlyIdAutoConfiguration.class)); + + @AfterEach + void restoreDefaultEncoding() { + FriendlyIds.setEncoding(FriendlyIdEncoding.STANDARD); + } + + @Test + void usesStandardEncodingByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(FriendlyIdAutoConfiguration.class); + assertThat(FriendlyIds.getEncoding()).isEqualTo(FriendlyIdEncoding.STANDARD); + }); + } + + @Test + void encodingPropertySwitchesToLegacy() { + contextRunner + .withPropertyValues("com.devskiller.friendly-id.encoding=legacy") + .run(context -> { + assertThat(context).hasSingleBean(FriendlyIdAutoConfiguration.class); + assertThat(FriendlyIds.getEncoding()).isEqualTo(FriendlyIdEncoding.LEGACY); + }); + } + + @Test + void canBeDisabled() { + contextRunner + .withPropertyValues("com.devskiller.friendly-id.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(FriendlyIdAutoConfiguration.class)); + } + +} diff --git a/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java b/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java new file mode 100644 index 0000000..3936995 --- /dev/null +++ b/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java @@ -0,0 +1,52 @@ +package com.devskiller.friendly_id; + +import java.math.BigInteger; + +import static java.math.BigInteger.ONE; + +/** + * https://stackoverflow.com/questions/919612/mapping-two-integers-to-one-in-a-unique-and-deterministic-way/13871379#13871379 + */ +class ElegantPairing { + + private static final BigInteger TWO = new BigInteger("2"); + + static BigInteger pair(BigInteger first, BigInteger second) { + BigInteger a = first.signum() >= 0 ? TWO.multiply(first) : TWO.negate().multiply(first).subtract(ONE); + BigInteger b = second.signum() >= 0 ? TWO.multiply(second) : TWO.negate().multiply(second).subtract(ONE); + if (a.compareTo(b) >= 0) { + return a.multiply(a).add(a).add(b); + } else { + return b.multiply(b).add(a); + } + } + + static BigInteger[] unpair(BigInteger value) { + BigInteger a = sqrt(value); + BigInteger b = value.subtract(a.multiply(a)); + return a.compareTo(b) > 0 ? + new BigInteger[]{recoverSignedValue(b), recoverSignedValue(a)} : + new BigInteger[]{recoverSignedValue(a), recoverSignedValue(b.subtract(a))}; + } + + private static BigInteger recoverSignedValue(BigInteger value) { + return value.testBit(0) ? value.divide(TWO).negate().subtract(ONE) : value.divide(TWO); + } + + /** + * Source: https://stackoverflow.com/a/36187890/516167 + */ + private static BigInteger sqrt(BigInteger n) { + BigInteger a = BigInteger.ONE; + BigInteger b = n.shiftRight(1).add(TWO); // (n >> 1) + 2 (ensure 0 doesn't show up) + while (b.compareTo(a) >= 0) { + BigInteger mid = a.add(b).shiftRight(1); // (a+b) >> 1 + if (mid.multiply(mid).compareTo(n) > 0) + b = mid.subtract(BigInteger.ONE); + else + a = mid.add(BigInteger.ONE); + } + return a.subtract(BigInteger.ONE); + } + +} diff --git a/friendly-id/src/main/java/com/devskiller/friendly_id/FriendlyIdEncoding.java b/friendly-id/src/main/java/com/devskiller/friendly_id/FriendlyIdEncoding.java new file mode 100644 index 0000000..16189c8 --- /dev/null +++ b/friendly-id/src/main/java/com/devskiller/friendly_id/FriendlyIdEncoding.java @@ -0,0 +1,60 @@ +package com.devskiller.friendly_id; + +import java.math.BigInteger; + +/** + * Strategy used to map the two 64-bit halves of a UUID onto the single + * {@link BigInteger} that is Base62-encoded into a FriendlyId string. + *

+ * The two strategies produce incompatible FriendlyId strings for + * the same UUID. Decoding a FriendlyId with the wrong strategy does not fail — + * it silently yields a different UUID — so a service must keep using the strategy + * its identifiers were originally issued with. + * + *

+ * + * @since 2.0.0-beta6 + * @see FriendlyIds#setEncoding(FriendlyIdEncoding) + */ +public enum FriendlyIdEncoding { + + /** + * Bit-shifting pairing ({@code hi * 2^64 + unsigned(lo)}), the default encoding + * since friendly-id 1.1.0. + */ + STANDARD { + @Override + BigInteger pair(BigInteger hi, BigInteger lo) { + return BigIntegerPairing.pair(hi, lo); + } + + @Override + BigInteger[] unpair(BigInteger value) { + return BigIntegerPairing.unpair(value); + } + }, + + /** + * Szudzik's elegant pairing, the encoding used by the friendly-id 1.0.x line. + * Use this to stay wire-compatible with identifiers issued by 1.0.x. + */ + LEGACY { + @Override + BigInteger pair(BigInteger hi, BigInteger lo) { + return ElegantPairing.pair(hi, lo); + } + + @Override + BigInteger[] unpair(BigInteger value) { + return ElegantPairing.unpair(value); + } + }; + + abstract BigInteger pair(BigInteger hi, BigInteger lo); + + abstract BigInteger[] unpair(BigInteger value); + +} diff --git a/friendly-id/src/main/java/com/devskiller/friendly_id/FriendlyIds.java b/friendly-id/src/main/java/com/devskiller/friendly_id/FriendlyIds.java index 5899747..e7330d0 100644 --- a/friendly-id/src/main/java/com/devskiller/friendly_id/FriendlyIds.java +++ b/friendly-id/src/main/java/com/devskiller/friendly_id/FriendlyIds.java @@ -27,10 +27,42 @@ */ public final class FriendlyIds { + private static volatile FriendlyIdEncoding encoding = FriendlyIdEncoding.STANDARD; + private FriendlyIds() { // utility class } + /** + * Sets the global {@link FriendlyIdEncoding} used by all conversions in this library + * (including the Jackson, JPA, jOOQ, OpenFeign and Spring integrations). + *

+ * Intended to be called once during application startup, before any conversion happens. + * Identifiers encoded with one strategy silently decode to a different UUID + * under the other, so switching at runtime on live traffic is not supported. + *

+ * With the Spring Boot starter this can be set declaratively via the + * {@code com.devskiller.friendly-id.encoding} property. + * + * @param friendlyIdEncoding encoding to use, must not be null + * @throws NullPointerException if friendlyIdEncoding is null + * @since 2.0.0-beta6 + */ + public static void setEncoding(FriendlyIdEncoding friendlyIdEncoding) { + Objects.requireNonNull(friendlyIdEncoding, "Encoding cannot be null"); + encoding = friendlyIdEncoding; + } + + /** + * Returns the global {@link FriendlyIdEncoding}, {@link FriendlyIdEncoding#STANDARD} by default. + * + * @return the encoding used by all conversions in this library + * @since 2.0.0-beta6 + */ + public static FriendlyIdEncoding getEncoding() { + return encoding; + } + /** * Creates a random FriendlyId string. * diff --git a/friendly-id/src/main/java/com/devskiller/friendly_id/Url62.java b/friendly-id/src/main/java/com/devskiller/friendly_id/Url62.java index b7e27f0..df2dcb8 100644 --- a/friendly-id/src/main/java/com/devskiller/friendly_id/Url62.java +++ b/friendly-id/src/main/java/com/devskiller/friendly_id/Url62.java @@ -15,7 +15,7 @@ class Url62 { * @return url62 encoded UUID */ static String encode(UUID uuid) { - BigInteger pair = UuidConverter.toBigInteger(uuid); + BigInteger pair = UuidConverter.toBigInteger(uuid, FriendlyIds.getEncoding()); return Base62.encode(pair); } @@ -27,7 +27,7 @@ static String encode(UUID uuid) { */ static UUID decode(String id) { BigInteger decoded = Base62.decode(id); - return UuidConverter.toUuid(decoded); + return UuidConverter.toUuid(decoded, FriendlyIds.getEncoding()); } } diff --git a/friendly-id/src/main/java/com/devskiller/friendly_id/UuidConverter.java b/friendly-id/src/main/java/com/devskiller/friendly_id/UuidConverter.java index 5417c6c..4dd4b6a 100644 --- a/friendly-id/src/main/java/com/devskiller/friendly_id/UuidConverter.java +++ b/friendly-id/src/main/java/com/devskiller/friendly_id/UuidConverter.java @@ -5,15 +5,15 @@ class UuidConverter { - static BigInteger toBigInteger(UUID uuid) { - return BigIntegerPairing.pair( + static BigInteger toBigInteger(UUID uuid, FriendlyIdEncoding encoding) { + return encoding.pair( BigInteger.valueOf(uuid.getMostSignificantBits()), BigInteger.valueOf(uuid.getLeastSignificantBits()) ); } - static UUID toUuid(BigInteger value) { - BigInteger[] unpaired = BigIntegerPairing.unpair(value); + static UUID toUuid(BigInteger value, FriendlyIdEncoding encoding) { + BigInteger[] unpaired = encoding.unpair(value); return new UUID(unpaired[0].longValueExact(), unpaired[1].longValueExact()); } diff --git a/friendly-id/src/test/java/com/devskiller/friendly_id/AnalyzeGeneratedIdsTest.java b/friendly-id/src/test/java/com/devskiller/friendly_id/AnalyzeGeneratedIdsTest.java index 0eac895..64a8fdf 100644 --- a/friendly-id/src/test/java/com/devskiller/friendly_id/AnalyzeGeneratedIdsTest.java +++ b/friendly-id/src/test/java/com/devskiller/friendly_id/AnalyzeGeneratedIdsTest.java @@ -17,7 +17,7 @@ class AnalyzeGeneratedIdsTest { @Test void analyzeGeneratedValueStatistics() { for (int i = 0; i < 100_000; i++) { - this.ids.add(Base62.encode(UuidConverter.toBigInteger(UUID.randomUUID()))); + this.ids.add(Base62.encode(UuidConverter.toBigInteger(UUID.randomUUID(), FriendlyIds.getEncoding()))); } IntSummaryStatistics stats = ids.stream().map(String::length).mapToInt(Integer::intValue).summaryStatistics(); diff --git a/friendly-id/src/test/java/com/devskiller/friendly_id/FriendlyIdEncodingTest.java b/friendly-id/src/test/java/com/devskiller/friendly_id/FriendlyIdEncodingTest.java new file mode 100644 index 0000000..5fc9502 --- /dev/null +++ b/friendly-id/src/test/java/com/devskiller/friendly_id/FriendlyIdEncodingTest.java @@ -0,0 +1,83 @@ +package com.devskiller.friendly_id; + +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins the wire format of both encodings against vectors generated with released + * artifacts: LEGACY vectors come from friendly-id 1.0.4 (ElegantPairing era), + * STANDARD vectors from 1.1.0/2.0.0-beta5 (BigIntegerPairing). These must never + * change — consumers persist FriendlyIds externally. + */ +class FriendlyIdEncodingTest { + + private static final UUID UUID_1 = UUID.fromString("d493e6d3-6a6a-4cf2-8990-46366c75064f"); + private static final UUID UUID_2 = UUID.fromString("1024138f-684f-4311-b539-774fd3ef3a9c"); + private static final UUID UUID_ZERO = UUID.fromString("00000000-0000-0000-0000-000000000000"); + private static final UUID UUID_ALL_BITS = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"); + private static final UUID UUID_LSB_SIGN_BIT = UUID.fromString("00000000-0000-0000-8000-000000000000"); + + @AfterEach + void restoreDefaultEncoding() { + FriendlyIds.setEncoding(FriendlyIdEncoding.STANDARD); + } + + @Test + void standardEncodingIsTheDefault() { + assertThat(FriendlyIds.getEncoding()).isEqualTo(FriendlyIdEncoding.STANDARD); + } + + @Test + void standardEncodingMatchesVectorsFrom110AndNewer() { + assertThat(FriendlyIds.toFriendlyId(UUID_1)).isEqualTo("6T7xno6b7EKyT6xrj2iJo7"); + assertThat(FriendlyIds.toFriendlyId(UUID_2)).isEqualTo("USMZy4J8qe2K9yZBxR4Hs"); + assertThat(FriendlyIds.toFriendlyId(UUID_ZERO)).isEqualTo("0"); + assertThat(FriendlyIds.toFriendlyId(UUID_ALL_BITS)).isEqualTo("7n42DGM5Tflk9n8mt7Fhc7"); + assertThat(FriendlyIds.toFriendlyId(UUID_LSB_SIGN_BIT)).isEqualTo("AzL8n0Y58m8"); + } + + @Test + void legacyEncodingMatchesVectorsFrom104() { + FriendlyIds.setEncoding(FriendlyIdEncoding.LEGACY); + + assertThat(FriendlyIds.toFriendlyId(UUID_1)).isEqualTo("6fZlmOHtnSlxGJGX7SMFg4"); + assertThat(FriendlyIds.toFriendlyId(UUID_2)).isEqualTo("2er4Hm3VwGW3J0lfwrvQYd"); + assertThat(FriendlyIds.toFriendlyId(UUID_ZERO)).isEqualTo("0"); + assertThat(FriendlyIds.toFriendlyId(UUID_ALL_BITS)).isEqualTo("3"); + assertThat(FriendlyIds.toFriendlyId(UUID_LSB_SIGN_BIT)).isEqualTo("7n42DGM5Tfl2CQZcquv8Vd"); + } + + @Test + void legacyDecodingMatchesVectorsFrom104() { + FriendlyIds.setEncoding(FriendlyIdEncoding.LEGACY); + + assertThat(FriendlyIds.toUuid("6fZlmOHtnSlxGJGX7SMFg4")).isEqualTo(UUID_1); + assertThat(FriendlyIds.toUuid("2er4Hm3VwGW3J0lfwrvQYd")).isEqualTo(UUID_2); + assertThat(FriendlyIds.toUuid("3")).isEqualTo(UUID_ALL_BITS); + } + + @Test + void legacyEncodingIsReversible() { + FriendlyIds.setEncoding(FriendlyIdEncoding.LEGACY); + + for (int i = 0; i < 1000; i++) { + UUID uuid = UUID.randomUUID(); + assertThat(FriendlyIds.toUuid(FriendlyIds.toFriendlyId(uuid))).isEqualTo(uuid); + } + } + + @Test + void encodingSwitchAffectsAllEntryPoints() { + FriendlyIds.setEncoding(FriendlyIdEncoding.LEGACY); + + assertThat(com.devskiller.friendly_id.type.FriendlyId.of(UUID_1).value()) + .isEqualTo("6fZlmOHtnSlxGJGX7SMFg4"); + assertThat(com.devskiller.friendly_id.type.FriendlyId.parse("6fZlmOHtnSlxGJGX7SMFg4").toUuid()) + .isEqualTo(UUID_1); + } + +} From 946e8ef1cd170e2e935f282ee1af31f79bf9b756 Mon Sep 17 00:00:00 2001 From: mariuszs Date: Fri, 17 Jul 2026 17:05:39 +0200 Subject: [PATCH 2/5] refactor: address Sonar issues in ElegantPairing Hide implicit public constructor (java:S1118) and use BigInteger.TWO instead of the string constructor (java:S2129). --- .../main/java/com/devskiller/friendly_id/ElegantPairing.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java b/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java index 3936995..79d0f16 100644 --- a/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java +++ b/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java @@ -3,13 +3,15 @@ import java.math.BigInteger; import static java.math.BigInteger.ONE; +import static java.math.BigInteger.TWO; /** * https://stackoverflow.com/questions/919612/mapping-two-integers-to-one-in-a-unique-and-deterministic-way/13871379#13871379 */ class ElegantPairing { - private static final BigInteger TWO = new BigInteger("2"); + private ElegantPairing() { + } static BigInteger pair(BigInteger first, BigInteger second) { BigInteger a = first.signum() >= 0 ? TWO.multiply(first) : TWO.negate().multiply(first).subtract(ONE); From 6811e9090bb6d80a1feced52698428f6d9f20481 Mon Sep 17 00:00:00 2001 From: mariuszs Date: Tue, 15 Sep 2026 11:41:35 +0200 Subject: [PATCH 3/5] test: benchmark both encodings and fix the jmh profile build The jmh profile referenced an undefined jmh.version property, and UuidConverterBenchmark no longer compiled after UuidConverter started taking an encoding. Benchmarks now run for STANDARD and LEGACY via @Param and decode real encoder output instead of random 127-bit values. --- friendly-id/pom.xml | 5 ++++- .../devskiller/friendly_id/FriendlyIdBenchmark.java | 11 ++++++++--- .../friendly_id/UuidConverterBenchmark.java | 12 ++++++++---- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/friendly-id/pom.xml b/friendly-id/pom.xml index f3497c3..b0cbdbb 100644 --- a/friendly-id/pom.xml +++ b/friendly-id/pom.xml @@ -36,6 +36,9 @@ jmh + + 1.37 + @@ -111,7 +114,7 @@ org.openjdk.jmh jmh-core - 1.37 + ${jmh.version} test diff --git a/friendly-id/src/jmh/java/com/devskiller/friendly_id/FriendlyIdBenchmark.java b/friendly-id/src/jmh/java/com/devskiller/friendly_id/FriendlyIdBenchmark.java index 3afcac5..b0c8d49 100644 --- a/friendly-id/src/jmh/java/com/devskiller/friendly_id/FriendlyIdBenchmark.java +++ b/friendly-id/src/jmh/java/com/devskiller/friendly_id/FriendlyIdBenchmark.java @@ -6,6 +6,7 @@ import org.openjdk.jmh.annotations.Fork; import org.openjdk.jmh.annotations.Measurement; import org.openjdk.jmh.annotations.OperationsPerInvocation; +import org.openjdk.jmh.annotations.Param; import org.openjdk.jmh.annotations.Scope; import org.openjdk.jmh.annotations.Setup; import org.openjdk.jmh.annotations.State; @@ -24,6 +25,9 @@ public class FriendlyIdBenchmark { static final int SIZE = 1_000_000; + @Param({"STANDARD", "LEGACY"}) + FriendlyIdEncoding encoding; + UUID[] uuids; String[] ids; @@ -37,11 +41,12 @@ public static void main(String[] args) throws RunnerException { @Setup public void setup() { + FriendlyIds.setEncoding(encoding); uuids = new UUID[SIZE]; ids = new String[SIZE]; for (int i = 0; i < SIZE; i++) { uuids[i] = UUID.randomUUID(); - ids[i] = FriendlyId.toFriendlyId(uuids[i]); + ids[i] = FriendlyIds.toFriendlyId(uuids[i]); } } @@ -49,7 +54,7 @@ public void setup() { @OperationsPerInvocation(SIZE) public void serializeUuid(Blackhole blackhole) { for (int i = 0; i < SIZE; i++) { - blackhole.consume(FriendlyId.toFriendlyId(uuids[i])); + blackhole.consume(FriendlyIds.toFriendlyId(uuids[i])); } } @@ -57,7 +62,7 @@ public void serializeUuid(Blackhole blackhole) { @OperationsPerInvocation(SIZE) public void deserializeId(Blackhole blackhole) { for (int i = 0; i < SIZE; i++) { - blackhole.consume(FriendlyId.toUuid(ids[i])); + blackhole.consume(FriendlyIds.toUuid(ids[i])); } } } diff --git a/friendly-id/src/jmh/java/com/devskiller/friendly_id/UuidConverterBenchmark.java b/friendly-id/src/jmh/java/com/devskiller/friendly_id/UuidConverterBenchmark.java index 06376ee..b787beb 100644 --- a/friendly-id/src/jmh/java/com/devskiller/friendly_id/UuidConverterBenchmark.java +++ b/friendly-id/src/jmh/java/com/devskiller/friendly_id/UuidConverterBenchmark.java @@ -1,13 +1,13 @@ package com.devskiller.friendly_id; import java.math.BigInteger; -import java.util.Random; import java.util.UUID; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Fork; import org.openjdk.jmh.annotations.Measurement; import org.openjdk.jmh.annotations.OperationsPerInvocation; +import org.openjdk.jmh.annotations.Param; import org.openjdk.jmh.annotations.Scope; import org.openjdk.jmh.annotations.Setup; import org.openjdk.jmh.annotations.State; @@ -26,6 +26,9 @@ public class UuidConverterBenchmark { static final int SIZE = 1_000_000; + @Param({"STANDARD", "LEGACY"}) + FriendlyIdEncoding encoding; + UUID[] uuids; BigInteger[] ids; @@ -42,7 +45,8 @@ public void setup() { ids = new BigInteger[SIZE]; for (int i = 0; i < SIZE; i++) { uuids[i] = UUID.randomUUID(); - ids[i] = new BigInteger(127, new Random()); + // each encoding maps UUIDs onto a different value range, so decode real encoder output + ids[i] = UuidConverter.toBigInteger(uuids[i], encoding); } } @@ -50,7 +54,7 @@ public void setup() { @OperationsPerInvocation(SIZE) public void convertToBigInteger(Blackhole blackhole) { for (int i = 0; i < SIZE; i++) { - blackhole.consume(UuidConverter.toBigInteger(uuids[i])); + blackhole.consume(UuidConverter.toBigInteger(uuids[i], encoding)); } } @@ -58,7 +62,7 @@ public void convertToBigInteger(Blackhole blackhole) { @OperationsPerInvocation(SIZE) public void convertFromBigInteger(Blackhole blackhole) { for (int i = 0; i < SIZE; i++) { - blackhole.consume(UuidConverter.toUuid(ids[i])); + blackhole.consume(UuidConverter.toUuid(ids[i], encoding)); } } } From 7303094520b9e3516dc1c8c0d695816c2f347705 Mon Sep 17 00:00:00 2001 From: mariuszs Date: Tue, 15 Sep 2026 11:41:40 +0200 Subject: [PATCH 4/5] perf: speed up LEGACY decoding with a Newton-based integer sqrt ElegantPairing.unpair computed floor(sqrt) with a ~130-step BigInteger binary search. A double estimate followed by one Newton step and an off-by-one correction returns the same root, so the wire format is unchanged (1.0.4 vectors still pass). JMH on JDK 21 (ops/s): - UuidConverter.toUuid LEGACY: 274k -> 4.85M (~18x) - FriendlyIds.toUuid LEGACY: 178k -> 515k (on par with STANDARD) BigInteger.sqrt() is not used yet: on JDK 21 it is ~9x slower than this implementation; it becomes faster on JDK 25. --- .../friendly_id/ElegantPairing.java | 31 ++++++---- .../friendly_id/ElegantPairingTest.java | 60 +++++++++++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 friendly-id/src/test/java/com/devskiller/friendly_id/ElegantPairingTest.java diff --git a/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java b/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java index 79d0f16..2cf195c 100644 --- a/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java +++ b/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java @@ -1,5 +1,6 @@ package com.devskiller.friendly_id; +import java.math.BigDecimal; import java.math.BigInteger; import static java.math.BigInteger.ONE; @@ -36,19 +37,27 @@ private static BigInteger recoverSignedValue(BigInteger value) { } /** - * Source: https://stackoverflow.com/a/36187890/516167 + * Returns floor(sqrt(n)) for a non-negative {@code n}, the same result as the binary search used by 1.0.x. + *

+ * A double estimate is accurate to ~53 bits, one Newton step fixes the rest of a root of up to 66 bits, + * and the loops correct the final off-by-one. + *

+ * TODO: replace with {@link BigInteger#sqrt()} after moving to JDK 25 — it is ~9x slower than this on + * JDK 21 but faster on JDK 25. */ - private static BigInteger sqrt(BigInteger n) { - BigInteger a = BigInteger.ONE; - BigInteger b = n.shiftRight(1).add(TWO); // (n >> 1) + 2 (ensure 0 doesn't show up) - while (b.compareTo(a) >= 0) { - BigInteger mid = a.add(b).shiftRight(1); // (a+b) >> 1 - if (mid.multiply(mid).compareTo(n) > 0) - b = mid.subtract(BigInteger.ONE); - else - a = mid.add(BigInteger.ONE); + static BigInteger sqrt(BigInteger n) { + if (n.signum() == 0) { + return n; } - return a.subtract(BigInteger.ONE); + BigInteger a = new BigDecimal(Math.sqrt(n.doubleValue())).toBigInteger(); + a = a.add(n.divide(a)).shiftRight(1); + while (a.multiply(a).compareTo(n) > 0) { + a = a.subtract(ONE); + } + for (BigInteger next = a.add(ONE); next.multiply(next).compareTo(n) <= 0; next = a.add(ONE)) { + a = next; + } + return a; } } diff --git a/friendly-id/src/test/java/com/devskiller/friendly_id/ElegantPairingTest.java b/friendly-id/src/test/java/com/devskiller/friendly_id/ElegantPairingTest.java new file mode 100644 index 0000000..7a91a2f --- /dev/null +++ b/friendly-id/src/test/java/com/devskiller/friendly_id/ElegantPairingTest.java @@ -0,0 +1,60 @@ +package com.devskiller.friendly_id; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.math.BigInteger; +import java.util.Random; +import java.util.stream.IntStream; + +import static com.devskiller.friendly_id.ElegantPairing.pair; +import static com.devskiller.friendly_id.ElegantPairing.sqrt; +import static com.devskiller.friendly_id.ElegantPairing.unpair; +import static java.math.BigInteger.ONE; +import static java.math.BigInteger.valueOf; +import static org.assertj.core.api.Assertions.assertThat; + +class ElegantPairingTest { + + private static final long[] EXTREMES = {0, 1, -1, Long.MAX_VALUE, Long.MIN_VALUE, Long.MAX_VALUE - 1, Long.MIN_VALUE + 1}; + + @Test + void sqrtShouldMatchFloorSqrtForSmallValues() { + for (long n = 0; n < 10_000; n++) { + assertThat(sqrt(valueOf(n))).as("sqrt(%d)", n).isEqualTo(valueOf(n).sqrt()); + } + } + + // pairing two longs yields values up to ~2^130, so roots span up to 66 bits + @ParameterizedTest + @MethodSource("rootBitLengths") + void sqrtShouldMatchFloorSqrtAroundPerfectSquares(int rootBitLength) { + Random random = new Random(rootBitLength); + for (int i = 0; i < 1000; i++) { + BigInteger root = new BigInteger(rootBitLength, random).setBit(rootBitLength - 1); + BigInteger square = root.multiply(root); + + assertThat(sqrt(square.subtract(ONE))).isEqualTo(square.subtract(ONE).sqrt()); + assertThat(sqrt(square)).isEqualTo(root); + assertThat(sqrt(square.add(ONE))).isEqualTo(root); + } + } + + @Test + void pairingExtremeLongsShouldBeReversible() { + for (long x : EXTREMES) { + for (long y : EXTREMES) { + BigInteger paired = pair(valueOf(x), valueOf(y)); + + assertThat(sqrt(paired)).isEqualTo(paired.sqrt()); + assertThat(unpair(paired)).containsExactly(valueOf(x), valueOf(y)); + } + } + } + + static IntStream rootBitLengths() { + return IntStream.rangeClosed(1, 66); + } + +} From 171fae4bc59fa6929db373904249429d99722b05 Mon Sep 17 00:00:00 2001 From: mariuszs Date: Tue, 15 Sep 2026 12:01:08 +0200 Subject: [PATCH 5/5] fix: avoid BigDecimal(double) in ElegantPairing.sqrt SonarCloud flags new BigDecimal(double) as a bug (java:S2111), failing the quality gate. Convert the double estimate to BigInteger exactly via Math.scalb and shiftLeft instead, which is also slightly faster (UuidConverter.toUuid LEGACY: 4.85M -> 5.43M ops/s). --- .../com/devskiller/friendly_id/ElegantPairing.java | 10 ++++++---- .../com/devskiller/friendly_id/ElegantPairingTest.java | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java b/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java index 2cf195c..dbc0cf1 100644 --- a/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java +++ b/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java @@ -1,6 +1,5 @@ package com.devskiller.friendly_id; -import java.math.BigDecimal; import java.math.BigInteger; import static java.math.BigInteger.ONE; @@ -39,8 +38,8 @@ private static BigInteger recoverSignedValue(BigInteger value) { /** * Returns floor(sqrt(n)) for a non-negative {@code n}, the same result as the binary search used by 1.0.x. *

- * A double estimate is accurate to ~53 bits, one Newton step fixes the rest of a root of up to 66 bits, - * and the loops correct the final off-by-one. + * A double estimate is accurate to ~53 bits, one Newton step fixes the rest of a root of up to 64 bits + * (paired UUIDs are below 2^128), and the loops correct the final off-by-one. *

* TODO: replace with {@link BigInteger#sqrt()} after moving to JDK 25 — it is ~9x slower than this on * JDK 21 but faster on JDK 25. @@ -49,7 +48,10 @@ static BigInteger sqrt(BigInteger n) { if (n.signum() == 0) { return n; } - BigInteger a = new BigDecimal(Math.sqrt(n.doubleValue())).toBigInteger(); + double root = Math.sqrt(n.doubleValue()); + // bits below the 53 significant ones are zero, so scaling them off keeps the conversion exact + int shift = Math.max(0, Math.getExponent(root) - 52); + BigInteger a = BigInteger.valueOf((long) Math.scalb(root, -shift)).shiftLeft(shift); a = a.add(n.divide(a)).shiftRight(1); while (a.multiply(a).compareTo(n) > 0) { a = a.subtract(ONE); diff --git a/friendly-id/src/test/java/com/devskiller/friendly_id/ElegantPairingTest.java b/friendly-id/src/test/java/com/devskiller/friendly_id/ElegantPairingTest.java index 7a91a2f..67d6cb4 100644 --- a/friendly-id/src/test/java/com/devskiller/friendly_id/ElegantPairingTest.java +++ b/friendly-id/src/test/java/com/devskiller/friendly_id/ElegantPairingTest.java @@ -26,7 +26,7 @@ void sqrtShouldMatchFloorSqrtForSmallValues() { } } - // pairing two longs yields values up to ~2^130, so roots span up to 66 bits + // pairing two longs yields values below 2^128 (roots up to 64 bits); go a bit beyond that range @ParameterizedTest @MethodSource("rootBitLengths") void sqrtShouldMatchFloorSqrtAroundPerfectSquares(int rootBitLength) {