feat(02-03): ClickHouse search engine, search controller, and 13 integration tests
- ClickHouseSearchEngine with dynamic WHERE clause building and LIKE escape - SearchController with GET (basic filters) and POST (advanced JSON body) - SearchBeanConfig wiring SearchEngine, SearchService, DetailService beans - 13 integration tests covering all filter types, combinations, pagination, empty results Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
package com.cameleer3.server.app.search;
|
||||
|
||||
import com.cameleer3.server.core.search.ExecutionSummary;
|
||||
import com.cameleer3.server.core.search.SearchEngine;
|
||||
import com.cameleer3.server.core.search.SearchRequest;
|
||||
import com.cameleer3.server.core.search.SearchResult;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* ClickHouse implementation of {@link SearchEngine}.
|
||||
* <p>
|
||||
* Builds dynamic WHERE clauses from non-null {@link SearchRequest} fields
|
||||
* and queries the {@code route_executions} table. LIKE patterns are properly
|
||||
* escaped to prevent injection.
|
||||
*/
|
||||
public class ClickHouseSearchEngine implements SearchEngine {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public ClickHouseSearchEngine(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchResult<ExecutionSummary> search(SearchRequest request) {
|
||||
var conditions = new ArrayList<String>();
|
||||
var params = new ArrayList<Object>();
|
||||
|
||||
buildWhereClause(request, conditions, params);
|
||||
|
||||
String where = conditions.isEmpty() ? "" : " WHERE " + String.join(" AND ", conditions);
|
||||
|
||||
// Count query
|
||||
var countParams = params.toArray();
|
||||
Long total = jdbcTemplate.queryForObject(
|
||||
"SELECT count() FROM route_executions" + where, Long.class, countParams);
|
||||
if (total == null) total = 0L;
|
||||
|
||||
if (total == 0) {
|
||||
return SearchResult.empty(request.offset(), request.limit());
|
||||
}
|
||||
|
||||
// Data query
|
||||
params.add(request.limit());
|
||||
params.add(request.offset());
|
||||
String dataSql = "SELECT execution_id, route_id, agent_id, status, start_time, end_time, " +
|
||||
"duration_ms, correlation_id, error_message, diagram_content_hash " +
|
||||
"FROM route_executions" + where +
|
||||
" ORDER BY start_time DESC LIMIT ? OFFSET ?";
|
||||
|
||||
List<ExecutionSummary> data = jdbcTemplate.query(dataSql, (rs, rowNum) -> {
|
||||
Timestamp endTs = rs.getTimestamp("end_time");
|
||||
return new ExecutionSummary(
|
||||
rs.getString("execution_id"),
|
||||
rs.getString("route_id"),
|
||||
rs.getString("agent_id"),
|
||||
rs.getString("status"),
|
||||
rs.getTimestamp("start_time").toInstant(),
|
||||
endTs != null ? endTs.toInstant() : null,
|
||||
rs.getLong("duration_ms"),
|
||||
rs.getString("correlation_id"),
|
||||
rs.getString("error_message"),
|
||||
rs.getString("diagram_content_hash")
|
||||
);
|
||||
}, params.toArray());
|
||||
|
||||
return new SearchResult<>(data, total, request.offset(), request.limit());
|
||||
}
|
||||
|
||||
@Override
|
||||
public long count(SearchRequest request) {
|
||||
var conditions = new ArrayList<String>();
|
||||
var params = new ArrayList<Object>();
|
||||
buildWhereClause(request, conditions, params);
|
||||
|
||||
String where = conditions.isEmpty() ? "" : " WHERE " + String.join(" AND ", conditions);
|
||||
Long result = jdbcTemplate.queryForObject(
|
||||
"SELECT count() FROM route_executions" + where, Long.class, params.toArray());
|
||||
return result != null ? result : 0L;
|
||||
}
|
||||
|
||||
private void buildWhereClause(SearchRequest req, List<String> conditions, List<Object> params) {
|
||||
if (req.status() != null && !req.status().isBlank()) {
|
||||
conditions.add("status = ?");
|
||||
params.add(req.status());
|
||||
}
|
||||
if (req.timeFrom() != null) {
|
||||
conditions.add("start_time >= ?");
|
||||
params.add(Timestamp.from(req.timeFrom()));
|
||||
}
|
||||
if (req.timeTo() != null) {
|
||||
conditions.add("start_time <= ?");
|
||||
params.add(Timestamp.from(req.timeTo()));
|
||||
}
|
||||
if (req.durationMin() != null) {
|
||||
conditions.add("duration_ms >= ?");
|
||||
params.add(req.durationMin());
|
||||
}
|
||||
if (req.durationMax() != null) {
|
||||
conditions.add("duration_ms <= ?");
|
||||
params.add(req.durationMax());
|
||||
}
|
||||
if (req.correlationId() != null && !req.correlationId().isBlank()) {
|
||||
conditions.add("correlation_id = ?");
|
||||
params.add(req.correlationId());
|
||||
}
|
||||
if (req.text() != null && !req.text().isBlank()) {
|
||||
String pattern = "%" + escapeLike(req.text()) + "%";
|
||||
conditions.add("(error_message LIKE ? OR error_stacktrace LIKE ? OR exchange_bodies LIKE ? OR exchange_headers LIKE ?)");
|
||||
params.add(pattern);
|
||||
params.add(pattern);
|
||||
params.add(pattern);
|
||||
params.add(pattern);
|
||||
}
|
||||
if (req.textInBody() != null && !req.textInBody().isBlank()) {
|
||||
conditions.add("exchange_bodies LIKE ?");
|
||||
params.add("%" + escapeLike(req.textInBody()) + "%");
|
||||
}
|
||||
if (req.textInHeaders() != null && !req.textInHeaders().isBlank()) {
|
||||
conditions.add("exchange_headers LIKE ?");
|
||||
params.add("%" + escapeLike(req.textInHeaders()) + "%");
|
||||
}
|
||||
if (req.textInErrors() != null && !req.textInErrors().isBlank()) {
|
||||
String pattern = "%" + escapeLike(req.textInErrors()) + "%";
|
||||
conditions.add("(error_message LIKE ? OR error_stacktrace LIKE ?)");
|
||||
params.add(pattern);
|
||||
params.add(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special LIKE characters to prevent LIKE injection.
|
||||
*/
|
||||
static String escapeLike(String input) {
|
||||
return input
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user