Fix Oracle uppercase column keys: return lowercase in all API responses

- Add LowerCaseKeyAdvice (ResponseBodyAdvice) to auto-convert Map keys
- Add LowerCaseJdbcTemplate with overridden getColumnMapRowMapper
- Update all repository/service code to use lowercase key access
- Add lowerKeys utility to JsonUtil

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-09 20:38:43 +09:00
parent a844fd44cc
commit 91d0ad4598
11 changed files with 159 additions and 51 deletions

View File

@@ -0,0 +1,53 @@
package com.tasteby.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.ColumnMapRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import javax.sql.DataSource;
import java.util.Map;
/**
* Custom JdbcTemplate that returns lowercase column names.
* Oracle returns UPPERCASE column names by default; this normalizes them
* so the JSON API returns snake_case keys matching the frontend expectations.
*/
@Configuration
public class JdbcConfig {
@Bean
@Primary
public NamedParameterJdbcTemplate namedParameterJdbcTemplate(DataSource dataSource) {
JdbcTemplate jdbc = new LowerCaseJdbcTemplate(dataSource);
return new NamedParameterJdbcTemplate(jdbc);
}
@Bean
@Primary
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new LowerCaseJdbcTemplate(dataSource);
}
/**
* JdbcTemplate subclass that uses a lowercase ColumnMapRowMapper.
*/
static class LowerCaseJdbcTemplate extends JdbcTemplate {
LowerCaseJdbcTemplate(DataSource dataSource) {
super(dataSource);
}
@Override
protected RowMapper<Map<String, Object>> getColumnMapRowMapper() {
return new ColumnMapRowMapper() {
@Override
protected String getColumnKey(String columnName) {
return columnName.toLowerCase();
}
};
}
}
}

View File

@@ -0,0 +1,46 @@
package com.tasteby.config;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import java.util.*;
/**
* Automatically converts Oracle UPPERCASE map keys to lowercase in all API responses.
*/
@RestControllerAdvice(basePackages = "com.tasteby")
public class LowerCaseKeyAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
return convertKeys(body);
}
@SuppressWarnings("unchecked")
private Object convertKeys(Object obj) {
if (obj instanceof Map<?, ?> map) {
var result = new LinkedHashMap<String, Object>();
for (var entry : map.entrySet()) {
String key = entry.getKey() instanceof String s ? s.toLowerCase() : String.valueOf(entry.getKey());
result.put(key, convertKeys(entry.getValue()));
}
return result;
}
if (obj instanceof List<?> list) {
return list.stream().map(this::convertKeys).toList();
}
return obj;
}
}

View File

@@ -54,8 +54,8 @@ public class AdminUserController {
""";
var rows = jdbc.queryForList(sql, new MapSqlParameterSource("u", userId));
rows.forEach(r -> {
Object text = r.get("REVIEW_TEXT");
r.put("REVIEW_TEXT", JsonUtil.readClob(text));
Object text = r.get("review_text");
r.put("review_text", JsonUtil.readClob(text));
});
return rows;
}

View File

@@ -30,14 +30,14 @@ public class DaemonController {
if (rows.isEmpty()) return Map.of();
var row = rows.getFirst();
var result = new LinkedHashMap<String, Object>();
result.put("scan_enabled", toInt(row.get("SCAN_ENABLED")) == 1);
result.put("scan_interval_min", row.get("SCAN_INTERVAL_MIN"));
result.put("process_enabled", toInt(row.get("PROCESS_ENABLED")) == 1);
result.put("process_interval_min", row.get("PROCESS_INTERVAL_MIN"));
result.put("process_limit", row.get("PROCESS_LIMIT"));
result.put("last_scan_at", row.get("LAST_SCAN_AT") != null ? row.get("LAST_SCAN_AT").toString() : null);
result.put("last_process_at", row.get("LAST_PROCESS_AT") != null ? row.get("LAST_PROCESS_AT").toString() : null);
result.put("updated_at", row.get("UPDATED_AT") != null ? row.get("UPDATED_AT").toString() : null);
result.put("scan_enabled", toInt(row.get("scan_enabled")) == 1);
result.put("scan_interval_min", row.get("scan_interval_min"));
result.put("process_enabled", toInt(row.get("process_enabled")) == 1);
result.put("process_interval_min", row.get("process_interval_min"));
result.put("process_limit", row.get("process_limit"));
result.put("last_scan_at", row.get("last_scan_at") != null ? row.get("last_scan_at").toString() : null);
result.put("last_process_at", row.get("last_process_at") != null ? row.get("last_process_at").toString() : null);
result.put("updated_at", row.get("updated_at") != null ? row.get("updated_at").toString() : null);
return result;
}

View File

@@ -230,14 +230,14 @@ public class RestaurantRepository {
var rows = namedJdbc.queryForList(
"SELECT id FROM restaurants WHERE google_place_id = :gid",
new MapSqlParameterSource("gid", placeId));
return rows.isEmpty() ? null : (String) rows.getFirst().get("ID");
return rows.isEmpty() ? null : (String) rows.getFirst().get("id");
}
private String findIdByName(String name) {
var rows = namedJdbc.queryForList(
"SELECT id FROM restaurants WHERE name = :n",
new MapSqlParameterSource("n", name));
return rows.isEmpty() ? null : (String) rows.getFirst().get("ID");
return rows.isEmpty() ? null : (String) rows.getFirst().get("id");
}
private MapSqlParameterSource buildUpsertParams(Map<String, Object> data,
@@ -267,7 +267,7 @@ public class RestaurantRepository {
}
private void attachChannels(List<Map<String, Object>> rows) {
List<String> ids = rows.stream().map(r -> (String) r.get("ID")).filter(Objects::nonNull).toList();
List<String> ids = rows.stream().map(r -> (String) r.get("id")).filter(Objects::nonNull).toList();
if (ids.isEmpty()) return;
var params = new MapSqlParameterSource();
@@ -290,13 +290,13 @@ public class RestaurantRepository {
.add(rs.getString("CHANNEL_NAME"));
});
for (var r : rows) {
String id = (String) r.get("ID");
String id = (String) r.get("id");
r.put("channels", chMap.getOrDefault(id, List.of()));
}
}
private void attachFoodsMentioned(List<Map<String, Object>> rows) {
List<String> ids = rows.stream().map(r -> (String) r.get("ID")).filter(Objects::nonNull).toList();
List<String> ids = rows.stream().map(r -> (String) r.get("id")).filter(Objects::nonNull).toList();
if (ids.isEmpty()) return;
var params = new MapSqlParameterSource();
@@ -323,7 +323,7 @@ public class RestaurantRepository {
}
});
for (var r : rows) {
String id = (String) r.get("ID");
String id = (String) r.get("id");
List<String> all = foodsMap.getOrDefault(id, List.of());
r.put("foods_mentioned", all.size() > 10 ? all.subList(0, 10) : all);
}

View File

@@ -101,8 +101,8 @@ public class ReviewRepository {
""";
var row = jdbc.queryForMap(sql, new MapSqlParameterSource("rid", restaurantId));
return Map.of(
"avg_rating", row.get("AVG_RATING") != null ? ((Number) row.get("AVG_RATING")).doubleValue() : null,
"review_count", ((Number) row.get("REVIEW_COUNT")).intValue()
"avg_rating", row.get("avg_rating") != null ? ((Number) row.get("avg_rating")).doubleValue() : null,
"review_count", ((Number) row.get("review_count")).intValue()
);
}

View File

@@ -31,15 +31,15 @@ public class UserRepository {
if (!rows.isEmpty()) {
// Update last_login_at
var row = rows.getFirst();
String userId = (String) row.get("ID");
String userId = (String) row.get("id");
jdbc.update("UPDATE tasteby_users SET last_login_at = SYSTIMESTAMP WHERE id = :id",
new MapSqlParameterSource("id", userId));
return Map.of(
"id", userId,
"email", row.getOrDefault("EMAIL", ""),
"nickname", row.getOrDefault("NICKNAME", ""),
"avatar_url", row.getOrDefault("AVATAR_URL", ""),
"is_admin", row.get("IS_ADMIN") != null && ((Number) row.get("IS_ADMIN")).intValue() == 1
"email", row.getOrDefault("email", ""),
"nickname", row.getOrDefault("nickname", ""),
"avatar_url", row.getOrDefault("avatar_url", ""),
"is_admin", row.get("is_admin") != null && ((Number) row.get("is_admin")).intValue() == 1
);
}
@@ -70,11 +70,11 @@ public class UserRepository {
if (rows.isEmpty()) return null;
var row = rows.getFirst();
return Map.of(
"id", row.get("ID"),
"email", row.getOrDefault("EMAIL", ""),
"nickname", row.getOrDefault("NICKNAME", ""),
"avatar_url", row.getOrDefault("AVATAR_URL", ""),
"is_admin", row.get("IS_ADMIN") != null && ((Number) row.get("IS_ADMIN")).intValue() == 1
"id", row.get("id"),
"email", row.getOrDefault("email", ""),
"nickname", row.getOrDefault("nickname", ""),
"avatar_url", row.getOrDefault("avatar_url", ""),
"is_admin", row.get("is_admin") != null && ((Number) row.get("is_admin")).intValue() == 1
);
}

View File

@@ -84,13 +84,13 @@ public class DaemonScheduler {
if (rows.isEmpty()) return null;
var row = rows.getFirst();
return new DaemonConfig(
toInt(row.get("SCAN_ENABLED")) == 1,
toInt(row.get("SCAN_INTERVAL_MIN")),
toInt(row.get("PROCESS_ENABLED")) == 1,
toInt(row.get("PROCESS_INTERVAL_MIN")),
toInt(row.get("PROCESS_LIMIT")),
row.get("LAST_SCAN_AT") instanceof java.sql.Timestamp ts ? ts.toInstant() : null,
row.get("LAST_PROCESS_AT") instanceof java.sql.Timestamp ts ? ts.toInstant() : null
toInt(row.get("scan_enabled")) == 1,
toInt(row.get("scan_interval_min")),
toInt(row.get("process_enabled")) == 1,
toInt(row.get("process_interval_min")),
toInt(row.get("process_limit")),
row.get("last_scan_at") instanceof java.sql.Timestamp ts ? ts.toInstant() : null,
row.get("last_process_at") instanceof java.sql.Timestamp ts ? ts.toInstant() : null
);
} catch (Exception e) {
log.debug("Cannot read daemon config: {}", e.getMessage());

View File

@@ -49,13 +49,11 @@ public class SearchService {
Set<String> seen = new HashSet<>();
var merged = new ArrayList<Map<String, Object>>();
for (var r : kw) {
String id = (String) r.get("ID");
if (id == null) id = (String) r.get("id");
String id = (String) r.get("id");
if (seen.add(id)) merged.add(r);
}
for (var r : sem) {
String id = (String) r.get("ID");
if (id == null) id = (String) r.get("id");
String id = (String) r.get("id");
if (seen.add(id)) merged.add(r);
}
result = merged.size() > limit ? merged.subList(0, limit) : merged;
@@ -123,11 +121,7 @@ public class SearchService {
private void attachChannels(List<Map<String, Object>> rows) {
List<String> ids = rows.stream()
.map(r -> {
Object id = r.get("ID");
if (id == null) id = r.get("id");
return (String) id;
})
.map(r -> (String) r.get("id"))
.filter(Objects::nonNull).toList();
if (ids.isEmpty()) return;
@@ -151,8 +145,7 @@ public class SearchService {
.add(rs.getString("CHANNEL_NAME"));
});
for (var r : rows) {
String id = (String) r.get("ID");
if (id == null) id = (String) r.get("id");
String id = (String) r.get("id");
r.put("channels", chMap.getOrDefault(id, List.of()));
}
}

View File

@@ -154,8 +154,8 @@ public class YouTubeService {
var ch = channelRepo.findByChannelId(channelId);
if (ch == null) return null;
String dbId = (String) ch.get("ID");
String titleFilter = (String) ch.get("TITLE_FILTER");
String dbId = (String) ch.get("id");
String titleFilter = (String) ch.get("title_filter");
String after = full ? null : videoRepo.getLatestVideoDate(dbId);
Set<String> existing = videoRepo.getExistingVideoIds(dbId);

View File

@@ -6,9 +6,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.Reader;
import java.sql.Clob;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.stream.Collectors;
/**
* JSON utility for handling Oracle CLOB JSON fields.
@@ -58,6 +57,23 @@ public final class JsonUtil {
}
}
/**
* Convert Oracle uppercase column keys to lowercase (e.g. "ID" → "id", "CUISINE_TYPE" → "cuisine_type").
*/
public static Map<String, Object> lowerKeys(Map<String, Object> row) {
if (row == null) return null;
var result = new LinkedHashMap<String, Object>(row.size());
for (var entry : row.entrySet()) {
result.put(entry.getKey().toLowerCase(), entry.getValue());
}
return result;
}
public static List<Map<String, Object>> lowerKeys(List<Map<String, Object>> rows) {
if (rows == null) return null;
return rows.stream().map(JsonUtil::lowerKeys).collect(Collectors.toList());
}
public static String toJson(Object value) {
try {
return MAPPER.writeValueAsString(value);