package io.kaif.model.debate;
import static io.kaif.util.MoreCollectors.toImmutableMap;
import static java.util.stream.Collectors.*;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import javax.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.util.concurrent.UncheckedExecutionException;
import io.kaif.database.DaoOperations;
import io.kaif.flake.FlakeId;
import io.kaif.model.KaifIdGenerator;
import io.kaif.model.account.Account;
import io.kaif.model.article.Article;
import io.kaif.model.zone.Zone;
@Repository
public class DebateDao implements DaoOperations {
private static final Logger logger = LoggerFactory.getLogger(DebateDao.class);
@Autowired
private NamedParameterJdbcTemplate namedParameterJdbcTemplate;
@Autowired
private KaifIdGenerator kaifIdGenerator;
private final RowMapper<Debate> debateMapper = (rs, rowNum) -> {
return new Debate(FlakeId.valueOf(rs.getLong("articleId")),
FlakeId.valueOf(rs.getLong("debateId")),
Zone.valueOf(rs.getString("zone")),
FlakeId.valueOf(rs.getLong("parentDebateId")),
UUID.fromString(rs.getString("replyToAccountId")),
rs.getInt("level"),
rs.getString("content"),
DebateContentType.valueOf(rs.getString("contentType")),
UUID.fromString(rs.getString("debaterId")),
rs.getString("debaterName"),
rs.getLong("upVote"),
rs.getLong("downVote"),
rs.getTimestamp("createTime").toInstant(),
rs.getTimestamp("lastUpdateTime").toInstant());
};
/**
* although cached debate will be evict when content updated, but the total vote is not real time
* value. so the voting count will be 10 minutes delayed currently. and DebateTree is not yet
* cache yet, so user may see inconsistent.
* <p>
* TODO we should use refreshAfterWrite() here but due to guava loadAll() issue
* (https://github.com/google/guava/issues/1975)
* we have to use expireAfterWrite() to allow more loadAll() optimization
*/
private final LoadingCache<FlakeId, Debate> debatesCache = CacheBuilder.newBuilder()
.maximumSize(2000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<FlakeId, Debate>() {
@Override
public Debate load(FlakeId key) throws Exception {
return loadDebateWithoutCache(key);
}
@Override
public Map<FlakeId, Debate> loadAll(Iterable<? extends FlakeId> keys) throws Exception {
return listDebatesByIdWithoutCache(Lists.newArrayList(keys));
}
});
@Override
public NamedParameterJdbcTemplate namedJdbc() {
return namedParameterJdbcTemplate;
}
private Debate insertDebate(Debate debate) {
jdbc().update(""
+ " INSERT "
+ " INTO Debate "
+ " (articleid, debateid, zone, parentdebateid,replyToAccountId, level, "
+ " content, contenttype, "
+ " debaterid, debatername, upvote, downvote, createtime, lastupdatetime)"
+ " VALUES "
+ questions(14),
debate.getArticleId().value(),
debate.getDebateId().value(),
debate.getZone().value(),
debate.getParentDebateId().value(),
debate.getReplyToAccountId(),
debate.getLevel(),
debate.getContent(),
debate.getContentType().name(),
debate.getDebaterId(),
debate.getDebaterName(),
debate.getUpVote(),
debate.getDownVote(),
Timestamp.from(debate.getCreateTime()),
Timestamp.from(debate.getLastUpdateTime()));
return debate;
}
public Optional<Debate> findDebate(FlakeId debateId) {
if (Debate.NO_PARENT.equals(debateId)) {
return Optional.empty();
}
final String sql = " SELECT * FROM Debate WHERE debateId = ? LIMIT 1 ";
return jdbc().query(sql, debateMapper, debateId.value()).stream().findAny();
}
public Debate create(Article article,
@Nullable Debate parent,
String content,
Account debater,
Instant now) {
//TODO evict DebateTree cache
FlakeId debateId = kaifIdGenerator.next();
return insertDebate(Debate.create(article, debateId, parent, content, debater, now));
}
public DebateTree listDebateTreeByArticle(FlakeId articleId, @Nullable FlakeId parentDebateId) {
//TODO cache whole DebateTree
List<Debate> flatten = listDepthFirstDebatesByArticle(articleId, parentDebateId);
return DebateTree.fromDepthFirst(flatten).sortByBestScore();
}
List<Debate> listDepthFirstDebatesByArticle(FlakeId articleId, @Nullable FlakeId parentDebateId) {
// TODO LIMIT in query, compute score in SQL
// http://stackoverflow.com/a/25486998
final String sql = ""
+ " WITH RECURSIVE DebateTree "
+ " AS "
+ " ( "
+ " SELECT *, "
+ " ARRAY[debateId] AS path "
+ " FROM Debate "
+ " WHERE articleId = :articleId "
+ " AND parentDebateId = :parentDebateId "
+ " UNION "
+ " SELECT d.*,"
+ " DebateTree.path || d.debateId AS path "
+ " FROM DebateTree "
+ " JOIN Debate d ON d.parentDebateId = DebateTree.debateId "
+ " WHERE d.articleId = :articleId "
+ " ) "
+ " SELECT * FROM DebateTree ORDER BY path ";
FlakeId parent = Optional.ofNullable(parentDebateId).orElse(Debate.NO_PARENT);
Map<String, Object> params = ImmutableMap.of(//
"articleId", articleId.value(), "parentDebateId", parent.value());
return namedJdbc().query(sql, params, debateMapper);
}
public void changeTotalVote(FlakeId debateId, long upVoteDelta, long downVoteDelta) {
if (upVoteDelta == 0 && downVoteDelta == 0) {
return;
}
jdbc().update(""
+ " UPDATE Debate "
+ " SET upVote = upVote + (?) "
+ " , downVote = downVote + (?) "
+ " WHERE debateId = ? ", upVoteDelta, downVoteDelta, debateId.value());
//should we evict debatesCache ?
}
public Debate loadDebateWithCache(FlakeId debateId) throws EmptyResultDataAccessException {
try {
return debatesCache.get(debateId);
} catch (ExecutionException | UncheckedExecutionException e) {
logger.warn("loadDebateWithCache by id cache failed", e);
return loadDebateWithoutCache(debateId);
}
}
public Debate loadDebateWithoutCache(FlakeId debateId) {
return jdbc().queryForObject(" SELECT * FROM Debate WHERE debateId = ? ",
debateMapper,
debateId.value());
}
public void updateContent(FlakeId debateId, String content, Instant now) {
jdbc().update(""
+ " UPDATE Debate "
+ " SET content = ?"
+ " , lastUpdateTime = ? "
+ " WHERE debateId = ? ", content, Timestamp.from(now), debateId.value());
debatesCache.invalidate(debateId);
//TODO evict DebateTree
}
public List<Debate> listLatestDebateByReplyTo(UUID replyToAccountId,
@Nullable FlakeId startDebateId,
int size) {
FlakeId start = Optional.ofNullable(startDebateId).orElse(FlakeId.MAX);
ImmutableMap<String, Object> params = ImmutableMap.of("accountId",
replyToAccountId,
"start",
start.value(),
"size",
size);
return namedJdbc().query(""
+ " SELECT * "
+ " FROM Debate "
+ " WHERE replyToAccountId = :accountId "
+ " AND debaterid <> :accountId "
+ " AND debateId < :start "
+ " ORDER BY debateid DESC "
+ " LIMIT :size ", params, debateMapper);
}
public List<Debate> listDebatesByTimeDesc(FlakeId startDebateId, int size) {
FlakeId start = Optional.ofNullable(startDebateId).orElse(FlakeId.MAX);
return jdbc().query(""
+ " SELECT * "
+ " FROM Debate "
+ " WHERE debateId < ? "
+ " ORDER BY debateId DESC "
+ " LIMIT ? ", debateMapper, start.value(), size);
}
public List<Debate> listZoneDebatesByTimeDesc(Zone zone, FlakeId startDebateId, int size) {
FlakeId start = Optional.ofNullable(startDebateId).orElse(FlakeId.MAX);
return jdbc().query(""
+ " SELECT * "
+ " FROM Debate "
+ " WHERE debateId < ? "
+ " AND zone = ? "
+ " ORDER BY debateId DESC "
+ " LIMIT ? ", debateMapper, start.value(), zone.value(), size);
}
public List<Debate> listDebatesByIdWithCache(List<FlakeId> debateIds) {
if (debateIds.isEmpty()) {
return Collections.emptyList();
}
Map<FlakeId, Debate> results;
try {
results = debatesCache.getAll(debateIds);
} catch (ExecutionException | UncheckedExecutionException e) {
logger.warn("list debates by id cache failed", e);
results = listDebatesByIdWithoutCache(debateIds);
}
return results.values().stream().distinct().collect(toList());
}
private Map<FlakeId, Debate> listDebatesByIdWithoutCache(List<FlakeId> debateIds) {
if (debateIds.isEmpty()) {
return Collections.emptyMap();
}
List<Debate> debates = namedJdbc().query(" SELECT * FROM Debate WHERE debateId IN (:ids) ",
ImmutableMap.of("ids", debateIds.stream().map(FlakeId::value).collect(toList())),
debateMapper);
return debates.stream().collect(toImmutableMap(Debate::getDebateId, Function.identity()));
}
public List<Debate> listDebatesByDebater(UUID debaterId,
@Nullable FlakeId startDebateId,
int size) {
FlakeId start = Optional.ofNullable(startDebateId).orElse(FlakeId.MAX);
return jdbc().query(""
+ " SELECT * "
+ " FROM Debate "
+ " WHERE debateId < ? "
+ " AND debaterId = ? "
+ " ORDER BY debateId DESC "
+ " LIMIT ? ", debateMapper, start.value(), debaterId, size);
}
}