Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
10 changes: 10 additions & 0 deletions friendly-id-spring-boot-starter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,15 @@
<artifactId>spring-boot-autoconfigure-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
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;

/**
* Auto-configuration for FriendlyId integration with Spring Boot.
* <p>
* 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.
* <p>
* 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
Expand All @@ -20,7 +25,12 @@
havingValue = "true",
matchIfMissing = true
)
@EnableConfigurationProperties(FriendlyIdProperties.class)
@EnableFriendlyId
public class FriendlyIdAutoConfiguration {

FriendlyIdAutoConfiguration(FriendlyIdProperties properties) {
FriendlyIds.setEncoding(properties.getEncoding());
}

}
Original file line number Diff line number Diff line change
@@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -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));
}

}
5 changes: 4 additions & 1 deletion friendly-id/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
<profiles>
<profile>
<id>jmh</id>
<properties>
<jmh.version>1.37</jmh.version>
</properties>
<build>
<plugins>
<plugin>
Expand Down Expand Up @@ -111,7 +114,7 @@
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
<version>${jmh.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +25,9 @@ public class FriendlyIdBenchmark {

static final int SIZE = 1_000_000;

@Param({"STANDARD", "LEGACY"})
FriendlyIdEncoding encoding;

UUID[] uuids;
String[] ids;

Expand All @@ -37,27 +41,28 @@ 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]);
}
}

@Benchmark
@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]));
}
}

@Benchmark
@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]));
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -26,6 +26,9 @@ public class UuidConverterBenchmark {

static final int SIZE = 1_000_000;

@Param({"STANDARD", "LEGACY"})
FriendlyIdEncoding encoding;

UUID[] uuids;
BigInteger[] ids;

Expand All @@ -42,23 +45,24 @@ 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);
}
}

@Benchmark
@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));
}
}

@Benchmark
@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));
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* <p>
* TODO: replace with {@link BigInteger#sqrt()} after moving to JDK 25 — it is ~9x slower than this on

Check warning on line 44 in friendly-id/src/main/java/com/devskiller/friendly_id/ElegantPairing.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=Devskiller_friendly-id&issues=AaCkc9wUy60FSjRIcL74&open=AaCkc9wUy60FSjRIcL74&pullRequest=22
* 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;
}

}
Loading
Loading