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/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)); } } } 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..dbc0cf1 --- /dev/null +++ b/friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java @@ -0,0 +1,65 @@ +package com.devskiller.friendly_id; + +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 ElegantPairing() { + } + + 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); + } + + /** + * 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 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. + */ + static BigInteger sqrt(BigInteger n) { + if (n.signum() == 0) { + return n; + } + 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); + } + 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/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/ElegantPairingTest.java b/friendly-id/src/test/java/com/devskiller/friendly_id/ElegantPairingTest.java new file mode 100644 index 0000000..67d6cb4 --- /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 below 2^128 (roots up to 64 bits); go a bit beyond that range + @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); + } + +} 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); + } + +}