Implement chats name and username history

This commit is contained in:
Andrea Cavalli 2021-11-30 23:10:53 +01:00
parent e7424c811c
commit 041804eb1f
10 changed files with 594 additions and 169 deletions

View File

@ -5,16 +5,22 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.quarkus.hibernate.reactive.panache.PanacheEntityBase;
import io.smallrye.mutiny.Uni;
import java.util.SortedSet;
import java.util.StringJoiner;
import java.util.TreeSet;
import javax.persistence.Cacheable;
import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Enumerated;
import javax.persistence.FetchType;
import javax.persistence.Id;
import javax.persistence.OneToMany;
import javax.persistence.OrderBy;
import javax.validation.constraints.Max;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Positive;
import javax.validation.constraints.Size;
import org.hibernate.annotations.SortNatural;
@Entity
@Cacheable
@ -27,17 +33,43 @@ public class Chat extends PanacheEntityBase {
@JsonSerialize(using = ChatIdJsonSerializer.class)
@JsonDeserialize(using = ChatIdJsonDeserializer.class)
public Long id;
/**
* null = unknown username
* "" = empty username
*/
@Column(length = 128)
public String name;
@Size(message = "Username length is not valid", min = 5)
// Field definition and bounds
@OneToMany(orphanRemoval = true, mappedBy = "chat", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@OrderBy("time DESC")
@SortNatural
// Field serialization
@JsonIgnore
public SortedSet<HistoricChatName> nameHistory = new TreeSet<>();
/**
* null = unknown username
* "" = empty username
*/
@Column(length = 48)
@Size(message = "Username must not be an empty string", min = 1, max = 12 + 32)
@Pattern(message = "Username contains invalid characters", regexp = "^(?:[a-zA-Z\\d][_]?)+$")
@Pattern(message = "Username is not valid", regexp = "^(?:translation_|mv_)?[a-zA-Z]([a-zA-Z_\\d]){1,30}[a-zA-Z\\d]$")
@Pattern(message = "Username contains invalid characters", regexp = "^(?:|(?:[a-zA-Z\\d][_]?)+)$")
@Pattern(message = "Username is not valid", regexp = "^(?:|(?:translation_|mv_)?[a-zA-Z]([a-zA-Z_\\d]){1,30}[a-zA-Z\\d])$")
public String username;
// Field definition and bounds
@OneToMany(orphanRemoval = true, mappedBy = "chat", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@OrderBy("time DESC")
@SortNatural
// Field serialization
@JsonIgnore
public SortedSet<HistoricChatUsername> usernameHistory = new TreeSet<>();
@Enumerated
public Status status;
@JsonIgnore
public ChatId getChatId() {
return ChatId.fromLong(id);

View File

@ -1,11 +1,11 @@
package io.volvox.chats;
import io.quarkus.hibernate.reactive.panache.Panache;
import io.quarkus.hibernate.reactive.panache.PanacheEntityBase;
import io.reactiverse.elasticsearch.client.mutiny.RestHighLevelClient;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import io.vertx.core.json.JsonObject;
import java.sql.Date;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@ -15,9 +15,8 @@ import javax.ws.rs.NotFoundException;
import org.elasticsearch.action.delete.DeleteRequest;
import org.elasticsearch.action.delete.DeleteResponse;
import org.elasticsearch.action.get.GetRequest;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.action.index.IndexResponse;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.update.UpdateResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.common.xcontent.XContentType;
import org.elasticsearch.index.query.QueryBuilders;
@ -30,11 +29,10 @@ public class ChatService {
@Inject
RestHighLevelClient restHighLevelClient;
private Uni<IndexResponse> updateIndex(Chat chat) {
var request = new IndexRequest("chats");
request.id(ChatId.toString(chat.id));
request.source(JsonObject.mapFrom(chat).toString(), XContentType.JSON);
return restHighLevelClient.indexAsync(request, RequestOptions.DEFAULT);
private Uni<UpdateResponse> updateIndex(Chat chat) {
var request = new org.elasticsearch.action.update.UpdateRequest("chats", ChatId.toString(chat.id))
.docAsUpsert(true).doc(JsonObject.mapFrom(chat).toString(), XContentType.JSON);
return restHighLevelClient.updateAsync(request, RequestOptions.DEFAULT);
}
private Uni<DeleteResponse> removeFromIndex(Long id) {
@ -66,9 +64,17 @@ public class ChatService {
}
public Uni<Void> delete(Long id) {
return Panache.withTransaction(() -> Chat.findById(id)
HistoricChatName.deleteByChatId(id);
return Panache.withTransaction(() -> Chat.<Chat>findById(id)
.onItem().ifNull().failWith(NotFoundException::new)
.flatMap(PanacheEntityBase::delete)
.flatMap(entity -> {
var nameDelete = HistoricChatName.deleteByChatId(id);
var usernameDelete = HistoricChatUsername.deleteByChatId(id);
var entityDelete = entity.delete();
return Uni.combine().all().unis(nameDelete, usernameDelete).combinedWith((a, b) -> null)
.replaceWith(entityDelete);
})
.replaceWith(removeFromIndex(id))
.onItem().transform(DeleteResponse::status)
.replaceWithVoid()
@ -79,14 +85,16 @@ public class ChatService {
if (chat.id != null && id != null && !Objects.equals(chat.id, id)) {
throw new IllegalArgumentException("Chat id is different than id");
}
return Panache.withTransaction(() -> {
var transactionTimestamp = new Date(System.currentTimeMillis());
// Find chat by id
return Panache.withTransaction(() -> Chat.<Chat>findById(id)
var oldChat = Chat.<Chat>findById(id);
return oldChat
.flatMap(entity -> {
if (entity == null) {
// Persist the chat if not found
return Chat.persist(chat)
// Return the chat
.replaceWith(chat);
return chat.persist();
} else {
// Update all fields
if (chat.name != null) {
@ -102,8 +110,44 @@ public class ChatService {
return Uni.createFrom().item(entity);
}
})
.flatMap(updatedEntity -> {
// Update the username with history
var usernameUpdater = HistoricChatUsername.findNewest(updatedEntity.id).flatMap(newestUsername -> {
if (chat.username != null
&& (newestUsername == null || !Objects.equals(newestUsername.username, chat.username))) {
updatedEntity.username = chat.username;
var newUsername = new HistoricChatUsername();
newUsername.chat = updatedEntity;
newUsername.username = chat.username;
newUsername.time = transactionTimestamp;
return newUsername.persist().replaceWithVoid();
} else {
return Uni.createFrom().voidItem();
}
});
// Update the name with history
var nameUpdater = HistoricChatName.findNewest(updatedEntity.id).flatMap(newestName -> {
if (chat.name != null
&& (newestName == null || !Objects.equals(newestName.name, chat.name))) {
updatedEntity.name = chat.name;
var newName = new HistoricChatName();
newName.chat = updatedEntity;
newName.name = chat.name;
newName.time = transactionTimestamp;
return newName.persist().replaceWithVoid();
} else {
return Uni.createFrom().voidItem();
}
});
return nameUpdater.replaceWith(usernameUpdater).replaceWith(updatedEntity);
})
// Update index
.onItem().transformToUni(updatedChat -> updateIndex(updatedChat).replaceWith(updatedChat))
.chain(updatedChat -> updateIndex(updatedChat).replaceWith(updatedChat));
}
);
}

View File

@ -0,0 +1,66 @@
package io.volvox.chats;
import io.quarkus.hibernate.reactive.panache.PanacheEntityBase;
import io.quarkus.panache.common.Sort;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import java.sql.Date;
import java.util.StringJoiner;
import javax.persistence.Cacheable;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
@Entity(name = "chat_name")
@Cacheable
public class HistoricChatName extends PanacheEntityBase implements Comparable<HistoricChatName> {
@Id
@GeneratedValue
public Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = true)
@JoinColumn(name = "chat_id", referencedColumnName = "id", nullable = true)
Chat chat;
// Field definition and bounds
@Column(name = "time", nullable = false)
public Date time;
// Field definition and bounds
@Column(length = 128)
public String name;
public static Uni<HistoricChatName> findNewest(Long chatId) {
if (chatId == null) {
throw new NullPointerException("Id must not be null");
}
return find("from chat_name where chat_id = ?1", Sort.by("time").descending(), chatId).firstResult();
}
public static Multi<HistoricChatName> listAll(Long chatId) {
if (chatId == null) {
throw new NullPointerException("Id must not be null");
}
return find("from chat_name where chat_id = ?1", Sort.by("time").ascending(), chatId).stream();
}
public static Uni<Long> deleteByChatId(Long chatId) {
return delete("from chat_name where chat_id = ?1", chatId);
}
@Override public int compareTo(HistoricChatName o) {
return o.time.compareTo(this.time);
}
@Override public String toString() {
return new StringJoiner(", ", HistoricChatName.class.getSimpleName() + "[", "]")
.add("id=" + id)
.add("time=" + time)
.add("name='" + name + "'")
.toString();
}
}

View File

@ -0,0 +1,73 @@
package io.volvox.chats;
import io.quarkus.hibernate.reactive.panache.PanacheEntityBase;
import io.quarkus.panache.common.Sort;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import java.sql.Date;
import java.util.StringJoiner;
import javax.persistence.Cacheable;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
@Entity(name = "chat_username")
@Cacheable
public class HistoricChatUsername extends PanacheEntityBase implements Comparable<HistoricChatUsername> {
@Id
@GeneratedValue
public Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = true)
@JoinColumn(name = "chat_id", referencedColumnName = "id", nullable = true)
Chat chat;
// Field definition and bounds
@Column(name = "time", nullable = false)
public Date time;
// Field definition and bounds
@Size(message = "Username length is not valid", min = 5)
@Column(length = 48)
@Size(message = "Username must not be an empty string", min = 1, max = 12 + 32)
@Pattern(message = "Username contains invalid characters", regexp = "^(?:[a-zA-Z\\d][_]?)+$")
@Pattern(message = "Username is not valid", regexp = "^(?:translation_|mv_)?[a-zA-Z]([a-zA-Z_\\d]){1,30}[a-zA-Z\\d]$")
// Keep field history
public String username;
public static Uni<HistoricChatUsername> findNewest(Long chatId) {
if (chatId == null) {
throw new NullPointerException("Id must not be null");
}
return find("from chat_username where chat_id = ?1", Sort.by("time").descending(), chatId).firstResult();
}
public static Multi<HistoricChatUsername> listAll(Long chatId) {
if (chatId == null) {
throw new NullPointerException("Id must not be null");
}
return find("from chat_username where chat_id = ?1", Sort.by("time").ascending(), chatId).stream();
}
public static Uni<Long> deleteByChatId(Long chatId) {
return delete("from chat_username where chat_id = ?1", chatId);
}
@Override public int compareTo(HistoricChatUsername o) {
return o.time.compareTo(this.time);
}
@Override public String toString() {
return new StringJoiner(", ", HistoricChatUsername.class.getSimpleName() + "[", "]")
.add("id=" + id)
.add("time=" + time)
.add("username='" + username + "'")
.toString();
}
}

View File

@ -1,4 +1,14 @@
INSERT INTO chat(id, name, username, status) VALUES (9007199256673076, 'My Supergroup', 'mysupergroup', 1);
INSERT INTO chat(id, name, username, status) VALUES (777000, 'Telegram', 'telegram', null);
INSERT INTO chat(id, name, username, status) VALUES (4503599627464345, 'School group', null, 1);
INSERT INTO chat(id, name, username, status) VALUES (4503599627382355, 'Old school group', null, 0);
INSERT INTO chat(id, status, name, username) VALUES (9007199256673076, 1, 'My Supergroup', 'mysupergroup');
INSERT INTO chat_name(chat_id, id, time, name) VALUES (9007199256673076, 12345000, current_timestamp, 'My Supergroup');
INSERT INTO chat_username(chat_id, id, time, username) VALUES (9007199256673076, 12345001, current_timestamp, 'mysupergroup');
INSERT INTO chat(id, status, name, username) VALUES (777000, 1, 'Telegram', 'telegram');
INSERT INTO chat_name(chat_id, id, time, name) VALUES (777000, 12345002, current_timestamp, 'Telegram');
INSERT INTO chat_username(chat_id, id, time, username) VALUES (777000, 12345003, current_timestamp, 'telegram');
INSERT INTO chat(id, status, name) VALUES (4503599627464345, 1, 'School group');
INSERT INTO chat_name(chat_id, id, time, name) VALUES (4503599627464345, 12345004, current_timestamp, 'School group');
INSERT INTO chat(id, status, name) VALUES (4503599627382355, 0, 'Old school group');
INSERT INTO chat_name(chat_id, id, time, name) VALUES (4503599627382355, 12345005, current_timestamp, 'Old school group');

View File

@ -0,0 +1,122 @@
package io.volvox.chats;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import io.quarkus.hibernate.reactive.panache.Panache;
import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import javax.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@QuarkusTest
@QuarkusTestResource(ElasticsearchContainerTestResource.class)
public class ChatServiceTest {
@Inject ChatService chatService;
@Inject ChatsServiceWarmup chatsServiceWarmup;
@BeforeEach
public void beforeEach() {
chatsServiceWarmup.warmup();
}
@Test
public void testCount() {
var count = chatService.count().await().indefinitely();
assertEquals(4, count);
}
@Test
public void testListAll() {
var list = chatService.listAll().map(Chat::getChatId).collect().asList().await().indefinitely();
assertThat(list)
.containsExactlyInAnyOrder(ChatId.fromLong(9007199256673076L), ChatId.fromLong(777000),
ChatId.fromLong(4503599627464345L), ChatId.fromLong(4503599627382355L)
);
}
@Test
public void testSearchByName() {
var chat = chatService.searchByName("Telegram").await().indefinitely();
assertThat(chat.size()).isEqualTo(1L);
assertThat(chat.get(0).getChatId()).isEqualTo(ChatId.fromLong(777000L));
}
@Test
public void testSearchByUsername() {
var chat = chatService.searchByUsername("telegram").await().indefinitely();
assertThat(chat.size()).isEqualTo(1L);
assertThat(chat.get(0).getChatId()).isEqualTo(ChatId.fromLong(777000));
}
@Test
public void testCreateNonexistent() {
var newChat = new Chat();
newChat.id = 777234L;
newChat.name = "TestChat";
newChat.username = "uname";
newChat.status = Status.ALIVE;
chatService.create(newChat).await().indefinitely();
Panache.getSession().onItem().invoke(() -> {
assertThat(newChat.isPersistent()).isEqualTo(true);
assertThat(newChat.id).isEqualTo(777234L);
assertThat(newChat.name).isEqualTo("TestChat");
assertThat(newChat.username).isEqualTo("uname");
assertThat(newChat.status).isEqualTo(Status.ALIVE);
}).await().indefinitely();
}
@Test
public void testUpdateNonexistent() {
var chat = new Chat();
chat.id = 777234L;
chat.name = "TestChat";
chat.username = "uname";
chat.status = Status.ALIVE;
var newChat = chatService.update(777234L, chat).await().indefinitely();
Panache.getSession().onItem().invoke(() -> {
assertThat(newChat).isNotNull();
assertThat(newChat.isPersistent()).isEqualTo(true);
assertThat(newChat.id).isEqualTo(777234L);
assertThat(newChat.name).isEqualTo("TestChat");
assertThat(newChat.username).isEqualTo("uname");
assertThat(newChat.status).isEqualTo(Status.ALIVE);
}).await().indefinitely();
}
@Test
public void testUpdateExisting() {
// Create chat
{
var chat = new Chat();
chat.id = 777234L;
chat.name = "TestChat";
chat.username = "uname";
chat.status = Status.ALIVE;
chatService.create(chat).await().indefinitely();
}
// Test update
{
var chat = new Chat();
chat.id = 777234L;
chat.username = "mario";
var newChat = chatService.update(777234L, chat).await().indefinitely();
Panache.getSession().onItem().invoke(() -> {
assertThat(newChat).isNotNull();
assertThat(newChat.isPersistent()).isEqualTo(true);
assertThat(newChat.id).isEqualTo(777234L);
assertThat(newChat.name).isEqualTo("TestChat");
assertThat(newChat.username).isEqualTo("mario");
assertThat(newChat.status).isEqualTo(Status.ALIVE);
}).await().indefinitely();
}
}
@BeforeEach
public void tearDown(){
Panache.withTransaction(() -> Chat.deleteById(777234L)).await().indefinitely();
}
}

View File

@ -2,15 +2,14 @@ package io.volvox.chats;
import static io.restassured.RestAssured.given;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.emptyString;
import static org.hamcrest.Matchers.is;
import io.quarkus.hibernate.reactive.panache.Panache;
import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.response.Response;
import javax.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -18,6 +17,13 @@ import org.junit.jupiter.api.Test;
@QuarkusTestResource(ElasticsearchContainerTestResource.class)
public class ChatsEndpointTest {
@Inject ChatsServiceWarmup chatsServiceWarmup;
@BeforeEach
public void beforeEach() {
chatsServiceWarmup.warmup();
}
@Test
public void testListAllChats() {
//List all, should have all 3 usernames the database has initially:
@ -123,19 +129,6 @@ public class ChatsEndpointTest {
.statusCode(500);
}
@Test
public void testHealth() {
given()
.when()
.get("/q/health/ready")
.then()
.statusCode(200)
.contentType("application/json")
.body("status", is("UP"),
"checks.status", containsInAnyOrder("UP"),
"checks.name", containsInAnyOrder("Elasticsearch cluster health check"));
}
@BeforeEach
public void tearDown(){
Panache.withTransaction(() -> Chat.deleteById(777234L)).await().indefinitely();

View File

@ -0,0 +1,59 @@
package io.volvox.chats;
import io.quarkus.logging.Log;
import io.reactiverse.elasticsearch.client.mutiny.RestHighLevelClient;
import io.smallrye.mutiny.Uni;
import io.vertx.core.json.JsonObject;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import org.elasticsearch.ElasticsearchStatusException;
import org.elasticsearch.action.admin.indices.flush.FlushRequest;
import org.elasticsearch.action.admin.indices.refresh.RefreshRequest;
import org.elasticsearch.action.update.UpdateRequest;
import org.elasticsearch.action.update.UpdateResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.indices.CreateIndexRequest;
import org.elasticsearch.common.xcontent.XContentType;
import org.elasticsearch.rest.RestStatus;
@ApplicationScoped
public class ChatsServiceWarmup {
@Inject
ChatService chatService;
@Inject
RestHighLevelClient restHighLevelClient;
public void warmup() {
createIndices();
chatService.listAll().onItem().transformToUni(this::updateIndex).merge().select().last().toUni().await()
.indefinitely();
restHighLevelClient.indices().flushAsyncAndAwait(new FlushRequest("chats"), RequestOptions.DEFAULT);
restHighLevelClient.indices().refreshAsyncAndAwait(new RefreshRequest("chats"), RequestOptions.DEFAULT);
}
private void createIndices() {
var req = new CreateIndexRequest("chats");
try {
restHighLevelClient.indices().createAsyncAndAwait(req, RequestOptions.DEFAULT);
} catch (ElasticsearchStatusException ex) {
if (ex.status() != RestStatus.BAD_REQUEST) {
throw ex;
}
}
}
private Uni<UpdateResponse> updateIndex(Chat chat) {
var request = new UpdateRequest("chats", ChatId.toString(chat.id)).docAsUpsert(true);
request.doc(JsonObject.mapFrom(chat).toString(), XContentType.JSON);
Log.infof("Index chat %s", chat);
return restHighLevelClient.updateAsync(request, RequestOptions.DEFAULT).onItem().invoke(response -> {
if (response.status() != RestStatus.CREATED && response.status() != RestStatus.OK) {
throw new UnsupportedOperationException("Unexpected status: " + response.status().toString());
}
});
}
}

View File

@ -1,12 +1,10 @@
package io.volvox.chats;
import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;
import java.util.Map;
import org.testcontainers.elasticsearch.ElasticsearchContainer;
import org.testcontainers.utility.DockerImageName;
import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;
public class ElasticsearchContainerTestResource implements QuarkusTestResourceLifecycleManager {
static ElasticsearchContainer elasticsearchContainer = new ElasticsearchContainer(
@ -17,8 +15,7 @@ public class ElasticsearchContainerTestResource implements QuarkusTestResourceLi
public Map<String, String> start() {
elasticsearchContainer.withEnv("action.auto_create_index", "true");
elasticsearchContainer.start();
return Map.of(
"quarkus.elasticsearch.hosts", elasticsearchContainer.getHttpHostAddress());
return Map.of("quarkus.elasticsearch.hosts", elasticsearchContainer.getHttpHostAddress());
}
@Override

View File

@ -0,0 +1,29 @@
package io.volvox.chats;
import static org.junit.jupiter.api.Assertions.assertEquals;
import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.reactiverse.elasticsearch.client.mutiny.RestHighLevelClient;
import javax.inject.Inject;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.core.CountRequest;
import org.elasticsearch.rest.RestStatus;
import org.junit.jupiter.api.Test;
@QuarkusTest
@QuarkusTestResource(ElasticsearchContainerTestResource.class)
public class IndexWarmupTest {
@Inject
RestHighLevelClient restHighLevelClient;
@Inject ChatsServiceWarmup chatsServiceWarmup;
@Test
public void test() {
chatsServiceWarmup.warmup();
var count = restHighLevelClient.countAsyncAndAwait(new CountRequest("chats"), RequestOptions.DEFAULT);
assertEquals(RestStatus.OK, count.status());
assertEquals(4, count.getCount());
}
}