fix: use JdbcTemplate for audit queries (match server pattern)
All checks were successful
CI / build (push) Successful in 53s
CI / docker (push) Successful in 34s

Replace JPQL @Query with dynamic SQL via JdbcTemplate to avoid
Hibernate null parameter type issues (bytea vs text). Conditionally
appends WHERE clauses only for non-null filters, matching the proven
pattern from cameleer3-server's PostgresAuditRepository.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 13:31:02 +02:00
parent 0d47c2ec7c
commit bcad83cc40
6 changed files with 109 additions and 44 deletions

View File

@@ -1,10 +1,6 @@
package net.siegeln.cameleer.saas.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.Instant;
@@ -12,31 +8,9 @@ import java.util.List;
import java.util.UUID;
@Repository
public interface AuditRepository extends JpaRepository<AuditEntity, UUID> {
public interface AuditRepository extends JpaRepository<AuditEntity, UUID>, AuditRepositoryCustom {
List<AuditEntity> findByTenantIdAndCreatedAtBetween(UUID tenantId, Instant from, Instant to);
List<AuditEntity> findByActorId(UUID actorId);
@Query("""
SELECT a FROM AuditEntity a
WHERE (:tenantId IS NULL OR a.tenantId = :tenantId)
AND (:action IS NULL OR a.action = :action)
AND (:result IS NULL OR a.result = :result)
AND (:from IS NULL OR a.createdAt >= :from)
AND (:to IS NULL OR a.createdAt <= :to)
AND (:search = ''
OR LOWER(a.actorEmail) LIKE LOWER(CONCAT('%', :search, '%'))
OR LOWER(a.resource) LIKE LOWER(CONCAT('%', :search, '%')))
ORDER BY a.createdAt DESC
""")
Page<AuditEntity> findFiltered(
@Param("tenantId") UUID tenantId,
@Param("action") String action,
@Param("result") String result,
@Param("from") Instant from,
@Param("to") Instant to,
@Param("search") String search,
Pageable pageable
);
}

View File

@@ -0,0 +1,14 @@
package net.siegeln.cameleer.saas.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.time.Instant;
import java.util.UUID;
public interface AuditRepositoryCustom {
Page<AuditDto.AuditLogEntry> findFiltered(UUID tenantId, String action, String result,
Instant from, Instant to, String search,
Pageable pageable);
}

View File

@@ -0,0 +1,88 @@
package net.siegeln.cameleer.saas.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Component
public class AuditRepositoryImpl implements AuditRepositoryCustom {
private final JdbcTemplate jdbc;
public AuditRepositoryImpl(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public Page<AuditDto.AuditLogEntry> findFiltered(UUID tenantId, String action, String result,
Instant from, Instant to, String search,
Pageable pageable) {
StringBuilder where = new StringBuilder("WHERE 1=1");
List<Object> params = new ArrayList<>();
if (tenantId != null) {
where.append(" AND tenant_id = ?");
params.add(tenantId);
}
if (action != null && !action.isBlank()) {
where.append(" AND action = ?");
params.add(action);
}
if (result != null && !result.isBlank()) {
where.append(" AND result = ?");
params.add(result);
}
if (from != null) {
where.append(" AND created_at >= ?");
params.add(Timestamp.from(from));
}
if (to != null) {
where.append(" AND created_at <= ?");
params.add(Timestamp.from(to));
}
if (search != null && !search.isBlank()) {
where.append(" AND (actor_email ILIKE ? OR resource ILIKE ?)");
String like = "%" + search + "%";
params.add(like);
params.add(like);
}
String countSql = "SELECT COUNT(*) FROM audit_log " + where;
Long total = jdbc.queryForObject(countSql, Long.class, params.toArray());
long totalCount = total != null ? total : 0;
String dataSql = "SELECT * FROM audit_log " + where
+ " ORDER BY created_at DESC LIMIT ? OFFSET ?";
List<Object> dataParams = new ArrayList<>(params);
dataParams.add(pageable.getPageSize());
dataParams.add(pageable.getOffset());
List<AuditDto.AuditLogEntry> items = jdbc.query(dataSql, (rs, rowNum) -> mapRow(rs), dataParams.toArray());
return new PageImpl<>(items, pageable, totalCount);
}
private AuditDto.AuditLogEntry mapRow(ResultSet rs) throws SQLException {
Timestamp ts = rs.getTimestamp("created_at");
return new AuditDto.AuditLogEntry(
rs.getObject("id", UUID.class),
rs.getString("actor_email"),
rs.getObject("tenant_id", UUID.class),
rs.getString("action"),
rs.getString("resource"),
rs.getString("environment"),
rs.getString("result"),
rs.getString("source_ip"),
ts != null ? ts.toInstant() : null
);
}
}

View File

@@ -34,10 +34,9 @@ public class AuditService {
auditRepository.save(entry);
}
public Page<AuditEntity> search(UUID tenantId, String action, String result,
Instant from, Instant to, String search,
Pageable pageable) {
String safeSearch = (search != null && !search.isBlank()) ? search.trim() : "";
return auditRepository.findFiltered(tenantId, action, result, from, to, safeSearch, pageable);
public Page<AuditDto.AuditLogEntry> search(UUID tenantId, String action, String result,
Instant from, Instant to, String search,
Pageable pageable) {
return auditRepository.findFiltered(tenantId, action, result, from, to, search, pageable);
}
}

View File

@@ -1,6 +1,5 @@
package net.siegeln.cameleer.saas.portal;
import net.siegeln.cameleer.saas.audit.AuditDto.AuditLogEntry;
import net.siegeln.cameleer.saas.audit.AuditDto.AuditLogPage;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.config.TenantContext;
@@ -39,12 +38,8 @@ public class TenantAuditController {
var pageResult = auditService.search(tenantId, action, result, from, to, search,
PageRequest.of(page, size));
var entries = pageResult.getContent().stream()
.map(AuditLogEntry::from)
.toList();
return ResponseEntity.ok(new AuditLogPage(
entries, pageResult.getNumber(), pageResult.getSize(),
pageResult.getContent(), pageResult.getNumber(), pageResult.getSize(),
pageResult.getTotalElements(), pageResult.getTotalPages()));
}
}

View File

@@ -1,6 +1,5 @@
package net.siegeln.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.audit.AuditDto.AuditLogEntry;
import net.siegeln.cameleer.saas.audit.AuditDto.AuditLogPage;
import net.siegeln.cameleer.saas.audit.AuditService;
import org.springframework.data.domain.PageRequest;
@@ -40,12 +39,8 @@ public class VendorAuditController {
var pageResult = auditService.search(tenantId, action, result, from, to, search,
PageRequest.of(page, size));
var entries = pageResult.getContent().stream()
.map(AuditLogEntry::from)
.toList();
return ResponseEntity.ok(new AuditLogPage(
entries, pageResult.getNumber(), pageResult.getSize(),
pageResult.getContent(), pageResult.getNumber(), pageResult.getSize(),
pageResult.getTotalElements(), pageResult.getTotalPages()));
}
}