Skip to content

Commit

Permalink
Merge pull request #147 from BIBSYSDEV/NP-1979-sortable-identifier
Browse files Browse the repository at this point in the history
NP-1979: SortableIdentifier in commons
  • Loading branch information
axthosarouris committed Jan 18, 2021
2 parents 2d33586 + 16cab85 commit 0d11569
Show file tree
Hide file tree
Showing 6 changed files with 264 additions and 0 deletions.
4 changes: 4 additions & 0 deletions identifiers/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dependencies{
compileOnly project (":core")
testImplementation project (":core")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package no.unit.nva.identifiers;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.util.Objects;
import java.util.UUID;

/**
* Generates ids of the form "0176f264a5ad-446893d8-3c02-4f64-936b-2997fec34e98". where the first part is an Instant
* encoded in 12 hex digits (enough until the year 10889) and the rest is a UUID. The timestamp is should be used only
* for sorting and not for identifying creation date
*/
@JsonSerialize(using = SortableIdentifierSerializer.class)
@JsonDeserialize(using = SortableIdentifierDeserializer.class)
public final class SortableIdentifier implements Comparable<SortableIdentifier> {

public static final int UUID_LENGTH = 36;
public static final int TIMESTAMP_LENGTH = 12;
public static final int EXTRA_DASH = 1;
public static final int SORTABLE_ID_LENGTH = UUID_LENGTH + TIMESTAMP_LENGTH + EXTRA_DASH;
private static final String IDENTIFIER_FORMATTING = "%0" + TIMESTAMP_LENGTH + "x-%s";

private final String identifier;

public SortableIdentifier(String identifier) {
validate(identifier);
this.identifier = identifier;
}

public static SortableIdentifier next() {
return new SortableIdentifier(newIdentifierString());
}

@Override
public int hashCode() {
return Objects.hash(identifier);
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
SortableIdentifier that = (SortableIdentifier) o;
return Objects.equals(toString(), that.toString());
}

@Override
public String toString() {
return identifier;
}

@Override
public int compareTo(SortableIdentifier o) {
return this.toString().compareTo(o.toString());
}

private static String newIdentifierString() {
return String.format(IDENTIFIER_FORMATTING, System.currentTimeMillis(), UUID.randomUUID());
}

private void validate(String identifier) {
if (identifier.length() == UUID_LENGTH || identifier.length() == SORTABLE_ID_LENGTH) {
return;
} else {
throw new IllegalArgumentException("Invalid sortable identifier");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package no.unit.nva.identifiers;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import java.io.IOException;
import nva.commons.core.JacocoGenerated;

public class SortableIdentifierDeserializer extends JsonDeserializer<SortableIdentifier> {

@JacocoGenerated
public SortableIdentifierDeserializer() {
super();
}

@JacocoGenerated
@Override
public SortableIdentifier deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
String value = p.getValueAsString();
return new SortableIdentifier(value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package no.unit.nva.identifiers;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.util.Objects;
import java.util.Optional;
import nva.commons.core.JacocoGenerated;

public class SortableIdentifierSerializer extends JsonSerializer<SortableIdentifier> {

public static final String NULL_AS_STRING = "null";
public static final String SERIALIZATION_EXCEPTION_ERROR = "Could not serialize SortableIdentifier with value: ";

@JacocoGenerated
public SortableIdentifierSerializer() {
super();
}

@Override
public void serialize(SortableIdentifier value, JsonGenerator gen, SerializerProvider serializers) {
try {
if (Objects.nonNull(value)) {
gen.writeString(value.toString());
} else {
gen.writeNull();
}
} catch (Exception e) {
throw new RuntimeException(SERIALIZATION_EXCEPTION_ERROR + printIdentifierValue(value), e);
}
}

private String printIdentifierValue(SortableIdentifier value) {
return Optional.ofNullable(value).map(SortableIdentifier::toString).orElse(NULL_AS_STRING);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package no.unit.nva.identifiers;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.core.Is.is;
import static org.hamcrest.core.IsEqual.equalTo;
import static org.hamcrest.core.StringContains.containsString;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import nva.commons.core.JsonUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;

public class SortableIdentifierTest {

public static final int MIN_NUMBER_OF_SHUFFLES = 5;
public static final int ADDITIONAL_SHUFFLES = 10;
public static final String DELIMITER = "-";
public static final SortableIdentifier SAMPLE_IDENTIFIER = SortableIdentifier.next();
public static final String SAMPLE_CLASS_ID_FIELD = String.format("\"id\" : \"%s\"",
SAMPLE_IDENTIFIER.toString());
public static final String SAMPLE_EXAMPLE_CLASS_JSON = "{" + SAMPLE_CLASS_ID_FIELD + "}";

@Test
public void sortableIdentifierStringContainsSixParts() {
SortableIdentifier identifier = SortableIdentifier.next();
String identifierString = identifier.toString();
String[] identifierParts = identifierString.split(DELIMITER);
assertThat(identifierParts.length, is(equalTo(6)));
}

@Test
public void sortableIdentifierAcceptsUuidString() {
UUID oldId = UUID.randomUUID();
SortableIdentifier identifier = new SortableIdentifier(oldId.toString());
assertThat(identifier.toString(), is(equalTo(oldId.toString())));
}

@Test
public void sortableIdentifierSerializesAsString() throws JsonProcessingException {
ExampleClass exampleClass = new ExampleClass();
exampleClass.setId(SAMPLE_IDENTIFIER);
String json = JsonUtils.objectMapper.writeValueAsString(exampleClass);
assertThat(json, containsString(SAMPLE_CLASS_ID_FIELD));
}

@Test
public void sortableIdentifierDeserializesFromString() throws JsonProcessingException {
ExampleClass actual = JsonUtils.objectMapper.readValue(SAMPLE_EXAMPLE_CLASS_JSON, ExampleClass.class);
SortableIdentifier actualIdentifier = actual.getId();
assertThat(actualIdentifier, is(equalTo(SAMPLE_IDENTIFIER)));
}

@Test
public void sortableIdentifierIsSortable() throws InterruptedException {
final Map<SortableIdentifier, Integer> expectedIdentifierOrder = sortableIdentifiersWithExpectedOrder();
List<SortableIdentifier> idStrings = new ArrayList<>(expectedIdentifierOrder.keySet());
shuffle(idStrings);
Collections.sort(idStrings);

for (int actualIndex = 0; actualIndex < idStrings.size(); actualIndex++) {
SortableIdentifier idString = idStrings.get(actualIndex);
int expectedIndex = expectedIdentifierOrder.get(idString);
assertThat(actualIndex, is(equalTo(expectedIndex)));
}
}

@Test
public void serializationErrorReturnsMessageWithSortableIdentifierValue() throws IOException {
SortableIdentifier identifier = SortableIdentifier.next();
String expectedMessage = "expectedMessage";

SortableIdentifierSerializer serializer = new SortableIdentifierSerializer();
IllegalStateException exceptionCause = new IllegalStateException(expectedMessage);

JsonGenerator jsonGenerator = mock(JsonGenerator.class);
doThrow(exceptionCause).when(jsonGenerator).writeString(anyString());

Executable action = () -> serializer.serialize(identifier, jsonGenerator, null);
RuntimeException actualException = assertThrows(RuntimeException.class, action);
Throwable cause = actualException.getCause();
assertThat(actualException.getMessage(), containsString(identifier.toString()));
assertThat(actualException.getMessage(),
containsString(SortableIdentifierSerializer.SERIALIZATION_EXCEPTION_ERROR));
assertThat(cause, is(equalTo(exceptionCause)));
}

private void shuffle(List<SortableIdentifier> idStrings) {
int numberOfShuffles = MIN_NUMBER_OF_SHUFFLES + new Random().nextInt(ADDITIONAL_SHUFFLES);
for (int i = 0; i < numberOfShuffles; i++) {
Collections.shuffle(idStrings);
}
}

private Map<SortableIdentifier, Integer> sortableIdentifiersWithExpectedOrder() throws InterruptedException {
final Map<SortableIdentifier, Integer> expectedIdentifierIndices = new ConcurrentHashMap<>();
int sampleSize = 1000;
for (int index = 0; index < sampleSize; index++) {
Thread.sleep(2);
expectedIdentifierIndices.put(SortableIdentifier.next(), index);
}
return expectedIdentifierIndices;
}

private static class ExampleClass {

private SortableIdentifier id;

public SortableIdentifier getId() {
return id;
}

public void setId(SortableIdentifier id) {
this.id = id;
}
}
}
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ include 'doi'
include 'lambdaauthorizer'
include 'logutils'
include 'eventhandlers'
include 'identifiers'

0 comments on commit 0d11569

Please sign in to comment.