Skip to content

Commit

Permalink
Add slug updates.
Browse files Browse the repository at this point in the history
Updates of the title of a banner will change the canonical slug of the
banner.
A banner can be addressed by all previous slugs.
  • Loading branch information
ewoerner committed Aug 9, 2021
1 parent 99aedd5 commit 1fda0cf
Show file tree
Hide file tree
Showing 14 changed files with 106 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ public void postSettings(@PathVariable final String id, @Valid @RequestBody Bann

private BannerDto toSummary(Banner banner) {
BannerDto dto = new BannerDto();
dto.id = banner.getSlug();
dto.id = banner.getCanonicalSlug();
dto.title = banner.getTitle();
dto.numberOfMissions = banner.getNumberOfMissions();
dto.lengthMeters = banner.getLengthMeters();
Expand Down Expand Up @@ -230,7 +230,7 @@ private void amendUserSettings(Principal principal, Collection<BannerDto> banner
List<BannerSettings> bannerSettings = bannerSettingsService.getBannerSettings(principal.getName(),
bannerDtos.stream().map(b -> b.id).collect(Collectors.toList()));
Map<String, BannerListType> bannerListTypes = bannerSettings.stream()
.collect(Collectors.toMap(s -> s.getBanner().getSlug(), s -> s.getListType()));
.collect(Collectors.toMap(s -> s.getBanner().getCanonicalSlug(), s -> s.getListType()));
for (BannerDto bannerDto : bannerDtos) {
BannerListType listType = bannerListTypes.get(bannerDto.id);
bannerDto.listType = listType == BannerListType.none ? null : listType;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public String getBannerMeta(@PathVariable String id) {
String distance = getDistance(banner);
String place = getPlaceName(banner);
String description = String.format("%s Missions, %s\n%s", banner.getNumberOfMissions(), distance, place);
String url = siteUrls.getBannerUrl(banner.getSlug());
String url = siteUrls.getBannerUrl(banner.getCanonicalSlug());
String pictureUrl = banner.getPicture() == null ? null
: siteUrls.getPictureUrl(banner.getPicture().getHash());
return new MetaBuilder() //
Expand Down
36 changes: 27 additions & 9 deletions src/main/java/com/bannergress/backend/entities/Banner.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,21 @@ public class Banner {
private UUID uuid;

/**
* Slug (ID which is suitable for use in URLs).
* Canonical slug (ID which is suitable for use in URLs).
*/
@NaturalId
@NaturalId(mutable = true)
@Column(name = "canonical_slug", nullable = false)
@NotAudited
private String canonicalSlug;

/**
* All slugs, including the {@link #canonicalSlug}.
*/
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "banner_slug", joinColumns = @JoinColumn(name = "banner"))
@Column(name = "slug", nullable = false)
@NotAudited
private String slug;
private Set<String> slugs = new HashSet<>();

/**
* Title.
Expand Down Expand Up @@ -153,12 +162,20 @@ public void setUuid(UUID uuid) {
this.uuid = uuid;
}

public String getSlug() {
return slug;
public String getCanonicalSlug() {
return canonicalSlug;
}

public void setCanonicalSlug(String canonicalSlug) {
this.canonicalSlug = canonicalSlug;
}

public Set<String> getSlugs() {
return slugs;
}

public void setSlug(String slug) {
this.slug = slug;
public void setSlugs(Set<String> slugs) {
this.slugs = slugs;
}

public String getTitle() {
Expand Down Expand Up @@ -279,12 +296,13 @@ public boolean equals(final Object o) {
&& Objects.equals(description, banner.description) && Objects.equals(missions, banner.missions)
&& Objects.equals(startPoint, banner.startPoint) && Objects.equals(lengthMeters, banner.lengthMeters)
&& Objects.equals(picture, banner.picture) && Objects.equals(startPlaces, banner.startPlaces)
&& Objects.equals(created, banner.created) && type == banner.type && Objects.equals(slug, banner.slug);
&& Objects.equals(created, banner.created) && type == banner.type
&& Objects.equals(canonicalSlug, banner.canonicalSlug);
}

@Override
public int hashCode() {
return Objects.hash(uuid, title, description, numberOfMissions, missions, startPoint, lengthMeters, complete,
online, picture, startPlaces, created, type, slug);
online, picture, startPlaces, created, type, canonicalSlug);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ public interface BannerRepository extends JpaRepository<Banner, UUID>, JpaSpecif
@Query("SELECT b.uuid FROM Banner b")
List<UUID> getAllUUIDs();

@Query("SELECT b.slug FROM Banner b")
@Query("SELECT b.canonicalSlug FROM Banner b")
List<String> getAllSlugs();

Optional<Banner> findBySlug(String slug);
Optional<Banner> findByCanonicalSlug(String slug);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*/
@Repository
public interface BannerSettingsRepository extends JpaRepository<BannerSettings, UUID> {
Optional<BannerSettings> findByUserIdAndBannerSlug(String userId, String bannerSlug);
Optional<BannerSettings> findByUserIdAndBannerCanonicalSlug(String userId, String bannerSlug);

List<BannerSettings> findByUserIdAndBannerSlugIn(String userId, Collection<String> bannerSlugs);
List<BannerSettings> findByUserIdAndBannerCanonicalSlugIn(String userId, Collection<String> bannerSlugs);
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ public static Specification<Banner> hasMissionId(String missionId) {
}

public static Specification<Banner> hasSlug(String slug) {
return (banner, cq, cb) -> cb.equal(banner.get(Banner_.slug), slug);
return (banner, cq, cb) -> {
Join<Banner, String> slugs = banner.join(Banner_.slugs);
return cb.equal(slugs, slug);
};
}

public static Specification<Banner> hasStartPlaceSlug(String startPlaceSlug) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public interface BannerSettingsService {
* Retrieves banner settings user and a collection of banners.
*
* @param userId ID of the user.
* @param banners Slugs of banner for which to retrieve the settings.
* @param banners Canonical slugs of banner for which to retrieve the settings.
* @return Banner settings.
*/
List<BannerSettings> getBannerSettings(String userId, Collection<String> bannerSlugs);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

import java.time.Instant;
import java.util.*;
import java.util.stream.Stream;

/**
* Default implementation of {@link BannerService}.
Expand Down Expand Up @@ -173,10 +174,20 @@ public Optional<Banner> findBySlugWithDetails(String slug) {
@Override
public String create(BannerDto bannerDto) throws MissionAlreadyUsedException {
Banner banner = createTransient(bannerDto, List.of());
banner.setSlug(deriveSlug(banner));
calculateSlug(banner);
bannerRepository.save(banner);
banner.getStartPlaces().forEach(place -> place.setNumberOfBanners(place.getNumberOfBanners() + 1));
return banner.getSlug();
return banner.getCanonicalSlug();
}

private void calculateSlug(Banner banner) {
// Slug candidates: 1. current canonical slug 2. any previous slug 3. new slug
Stream<String> slugCandidates = Stream.concat(
Stream.concat(Optional.ofNullable(banner.getCanonicalSlug()).stream(), banner.getSlugs().stream()),
Stream.generate(() -> deriveSlug(banner)));
String slug = slugCandidates.filter(s -> slugGenerator.isDerivedFrom(s, banner.getTitle())).findFirst().get();
banner.setCanonicalSlug(slug);
banner.getSlugs().add(slug);
}

private String deriveSlug(Banner banner) {
Expand Down Expand Up @@ -224,6 +235,7 @@ public void update(String slug, BannerDto bannerDto) throws MissionAlreadyUsedEx
banner.getMissions().clear();
banner.getMissions()
.putAll(Maps.transformValues(bannerDto.missions, missionDto -> missionRepository.getOne(missionDto.id)));
calculateSlug(banner);
bannerService.calculateData(banner);
pictureService.refresh(banner);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,16 @@ public void addBannerToList(String userId, String bannerSlug, BannerListType lis

@Override
public List<BannerSettings> getBannerSettings(String userId, Collection<String> bannerSlugs) {
return bannerSettingsRepository.findByUserIdAndBannerSlugIn(userId, bannerSlugs);
return bannerSettingsRepository.findByUserIdAndBannerCanonicalSlugIn(userId, bannerSlugs);
}

private BannerSettings getOrCreate(String userId, String bannerSlug) {
Optional<BannerSettings> optionalBannerSettings = bannerSettingsRepository.findByUserIdAndBannerSlug(userId,
bannerSlug);
Optional<BannerSettings> optionalBannerSettings = bannerSettingsRepository
.findByUserIdAndBannerCanonicalSlug(userId, bannerSlug);
return optionalBannerSettings.orElseGet(() -> {
BannerSettings bannerSettings = new BannerSettings();
bannerSettings.setUser(userService.getOrCreate(userId));
bannerSettings.setBanner(bannerRepository.findBySlug(bannerSlug).get());
bannerSettings.setBanner(bannerRepository.findByCanonicalSlug(bannerSlug).get());
return bannerSettingsRepository.save(bannerSettings);
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public Collection<Mission> findByIds(Collection<String> ids) {
public void assertNotAlreadyUsedInBanners(Collection<String> ids, List<String> acceptableBannerSlugs)
throws MissionAlreadyUsedException {
String acceptableBannerSlugsPart = acceptableBannerSlugs.isEmpty() ? ""
: " AND b.slug NOT IN :acceptableBannerSlugs";
: " AND b.canonicalSlug NOT IN :acceptableBannerSlugs";
TypedQuery<Long> query = entityManager.createQuery("SELECT COUNT(m) FROM Mission m WHERE m.id IN :ids "
+ "AND NOT EXISTS (SELECT 1 FROM Banner b WHERE b MEMBER OF m.banners" + acceptableBannerSlugsPart + ")",
Long.class);
Expand Down
28 changes: 25 additions & 3 deletions src/main/java/com/bannergress/backend/utils/SlugGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@ public SlugGenerator(int suffixBytes) {
* @return Slug.
*/
public String generateSlug(String base, Predicate<String> isAvailable) {
String lowerCase = base.toLowerCase(Locale.ROOT);
String onlyAlphanum = replacedCharacters.matcher(lowerCase).replaceAll("-");
String prefix = onlyAlphanum.replaceAll("^-+|-+$", "");
String prefix = getPrefix(base);
while (true) {
byte[] random = new byte[suffixBytes];
numberGenerator.nextBytes(random);
Expand All @@ -47,4 +45,28 @@ public String generateSlug(String base, Predicate<String> isAvailable) {
}
}
}

private String getPrefix(String base) {
String lowerCase = base.toLowerCase(Locale.ROOT);
String onlyAlphanum = replacedCharacters.matcher(lowerCase).replaceAll("-");
String prefix = onlyAlphanum.replaceAll("^-+|-+$", "");
return prefix;
}

/**
* Checks whether a slug is derived from a base string.
*
* @param slug Slug.
* @param base Base string.
* @return <code>true</code> whether the slug is derived from the base string.
*/
public boolean isDerivedFrom(String slug, String base) {
String prefix = getPrefix(base);
if (slug.startsWith(prefix)) {
String remainder = slug.substring(prefix.length());
return remainder.matches("^-[0-9a-f]+$");
} else {
return false;
}
}
}
11 changes: 11 additions & 0 deletions src/main/resources/db/migration/V2_12__slugs.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE "banner_slug" (
"slug" text NOT NULL,
"banner" "uuid" NOT NULL,
PRIMARY KEY ("slug"),
UNIQUE ("banner", "slug"),
FOREIGN KEY ("banner") REFERENCES "banner"("uuid")
);
INSERT INTO "banner_slug" ("slug", "banner") SELECT "slug", "uuid" FROM "banner";

ALTER TABLE "banner" RENAME COLUMN "slug" TO "canonical_slug";
ALTER TABLE "banner" ADD FOREIGN KEY ("uuid", "canonical_slug") REFERENCES "banner_slug" ("banner", "slug") DEFERRABLE INITIALLY DEFERRED;
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ void list() {
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).hasSize(1);
final var bannerDto = result.getBody().get(0);
assertThat(bannerDto.id).isEqualTo(banner.getSlug());
assertThat(bannerDto.id).isEqualTo(banner.getCanonicalSlug());
assertThat(bannerDto.numberOfMissions).isEqualTo(banner.getNumberOfMissions());
assertThat(bannerDto.lengthMeters).isEqualTo(banner.getLengthMeters());
}
Expand Down Expand Up @@ -86,7 +86,7 @@ void list_withBoundingBox() {
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).hasSize(1);
final var bannerDto = result.getBody().get(0);
assertThat(bannerDto.id).isEqualTo(banner.getSlug());
assertThat(bannerDto.id).isEqualTo(banner.getCanonicalSlug());
assertThat(bannerDto.numberOfMissions).isEqualTo(banner.getNumberOfMissions());
assertThat(bannerDto.lengthMeters).isEqualTo(banner.getLengthMeters());
}
Expand All @@ -106,7 +106,7 @@ void get() {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
final BannerDto bannerDto = response.getBody();
assertThat(bannerDto).isNotNull();
assertThat(bannerDto.id).isEqualTo(banner.getSlug());
assertThat(bannerDto.id).isEqualTo(banner.getCanonicalSlug());
assertThat(bannerDto.numberOfMissions).isEqualTo(banner.getNumberOfMissions());
assertThat(bannerDto.lengthMeters).isEqualTo(banner.getLengthMeters());
assertThat(bannerDto.type).isEqualTo(banner.getType());
Expand All @@ -133,8 +133,8 @@ void post() throws MissionAlreadyUsedException {
final BannerDto banner = a($BannerDto());
final Banner savedBanner = fixPlaceInformation(a($Banner()));

when(bannerService.create(banner)).thenReturn(savedBanner.getSlug());
when(bannerService.findBySlugWithDetails(savedBanner.getSlug())).thenReturn(Optional.of(savedBanner));
when(bannerService.create(banner)).thenReturn(savedBanner.getCanonicalSlug());
when(bannerService.findBySlugWithDetails(savedBanner.getCanonicalSlug())).thenReturn(Optional.of(savedBanner));

// THEN
final var response = testController.post(banner, null);
Expand All @@ -143,7 +143,7 @@ void post() throws MissionAlreadyUsedException {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
final BannerDto bannerDto = response.getBody();
assertThat(bannerDto).isNotNull();
assertThat(bannerDto.id).isEqualTo(savedBanner.getSlug());
assertThat(bannerDto.id).isEqualTo(savedBanner.getCanonicalSlug());
assertThat(bannerDto.numberOfMissions).isEqualTo(savedBanner.getNumberOfMissions());
assertThat(bannerDto.lengthMeters).isEqualTo(savedBanner.getLengthMeters());
assertThat(bannerDto.type).isEqualTo(savedBanner.getType());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ class TestSlugGenerator {
void testReplacements() {
SlugGenerator generator = new SlugGenerator(2);

String actual1 = generator.generateSlug("[Augsburg ist schön seit 15 v. Chr.]", Predicates.alwaysTrue());
String base1 = "[Augsburg ist schön seit 15 v. Chr.]";
String actual1 = generator.generateSlug(base1, Predicates.alwaysTrue());
assertThat(actual1).matches("^augsburg-ist-schön-seit-15-v-chr-[0-9a-f]{4}$");
assertThat(generator.isDerivedFrom(actual1, base1));

String actual2 = generator.generateSlug("Волковское кладбище", Predicates.alwaysTrue());
String base2 = "Волковское кладбище";
String actual2 = generator.generateSlug(base2, Predicates.alwaysTrue());
assertThat(actual2).matches("^волковское-кладбище-[0-9a-f]{4}$");
assertThat(generator.isDerivedFrom(actual2, base2));
}

@Test
Expand Down

0 comments on commit 1fda0cf

Please sign in to comment.