diff --git a/builder-api/src/main/java/org/acme/functions/LocationService.java b/builder-api/src/main/java/org/acme/functions/LocationService.java index e54a3dce..e9098f86 100644 --- a/builder-api/src/main/java/org/acme/functions/LocationService.java +++ b/builder-api/src/main/java/org/acme/functions/LocationService.java @@ -11,76 +11,90 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; @Unremovable @ApplicationScoped public class LocationService { + private static final Logger LOG = Logger.getLogger(LocationService.class.getName()); + private static final Set ALLOWED_COLUMNS = Set.of( + "zipCode", "countyName", "countyFips", "stateAbbreviation" + ); + @Inject AgroalDataSource dataSource; - public Connection getDbConnection() throws SQLException { + Connection getDbConnection() throws SQLException { return dataSource.getConnection(); } + /** + * Like {@link #lookup}, but matches filter values as case-insensitive prefix patterns. + * Trailing periods are stripped so abbreviations like "Mont." match "Montgomery". + */ + public static List lookupFuzzy(String column, Map filters) { + Map patterns = new LinkedHashMap<>(); + for (Map.Entry entry : filters.entrySet()) { + String raw = entry.getValue().toString().trim(); + String pattern = raw.endsWith(".") ? raw.substring(0, raw.length() - 1) + "%" : raw + "%"; + patterns.put(entry.getKey(), pattern); + } + return query(column, patterns, "LIKE"); + } + public static List lookup(String column, Map filters) { + return query(column, filters, "="); + } + + private static List query(String column, Map filters, String operator) { + if (!ALLOWED_COLUMNS.contains(column)) { + throw new IllegalArgumentException("Invalid column: " + column); + } + if (filters.isEmpty()) { + throw new IllegalArgumentException("filters must not be empty"); + } + for (String key : filters.keySet()) { + if (!ALLOWED_COLUMNS.contains(key)) { + throw new IllegalArgumentException("Invalid filter key: " + key); + } + } + List results = new ArrayList<>(); - StringBuilder sql = new StringBuilder("SELECT DISTINCT ").append(column).append(" FROM locations WHERE "); - // construct WHERE clause; assume everything is ANDed together + StringBuilder sql = new StringBuilder("SELECT DISTINCT ").append(column).append(" FROM locations WHERE "); boolean first = true; for (String key : filters.keySet()) { - if (!first) { - sql.append(" AND "); - } - sql.append(key).append(" = ?"); + if (!first) sql.append(" AND "); + sql.append(key).append(" ").append(operator).append(" ? COLLATE NOCASE"); first = false; } LocationService service = Arc.container().instance(LocationService.class).get(); - Connection connection = null; - PreparedStatement pstmt = null; - ResultSet rs = null; + try (Connection connection = service.getDbConnection(); + PreparedStatement pstmt = connection.prepareStatement(sql.toString())) { - try { - connection = service.getDbConnection(); - pstmt = connection.prepareStatement(sql.toString()); - - // Set the values dynamically int index = 1; for (Object value : filters.values()) { - String stringValue = value.toString(); // db only has strings - pstmt.setString(index++, stringValue); + pstmt.setString(index++, value.toString().trim()); } - rs = pstmt.executeQuery(); - - while(rs.next()) { - String thisString = rs.getString(column); - results.add(thisString); - } - } catch (SQLException e) { - e.printStackTrace(); - } finally { - try { - if (rs != null) { - rs.close(); - } - if (pstmt != null) { - pstmt.close(); - } - if (connection != null) { - connection.close(); + try (ResultSet rs = pstmt.executeQuery()) { + while (rs.next()) { + results.add(rs.getString(column)); } - } catch (SQLException e) { - e.printStackTrace(); } + + } catch (SQLException e) { + LOG.severe("Location lookup failed for column=" + column + " filters=" + filters + ": " + e.getMessage()); + throw new RuntimeException("Location lookup failed", e); } return results; - } } diff --git a/library-api/src/main/java/org/codeforphilly/bdt/functions/LocationService.java b/library-api/src/main/java/org/codeforphilly/bdt/functions/LocationService.java index 11c49d44..971a3052 100644 --- a/library-api/src/main/java/org/codeforphilly/bdt/functions/LocationService.java +++ b/library-api/src/main/java/org/codeforphilly/bdt/functions/LocationService.java @@ -10,76 +10,90 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; @Unremovable @ApplicationScoped public class LocationService { + private static final Logger LOG = Logger.getLogger(LocationService.class.getName()); + private static final Set ALLOWED_COLUMNS = Set.of( + "zipCode", "countyName", "countyFips", "stateAbbreviation" + ); + @Inject AgroalDataSource dataSource; - public Connection getDbConnection() throws SQLException { + Connection getDbConnection() throws SQLException { return dataSource.getConnection(); } + /** + * Like {@link #lookup}, but matches filter values as case-insensitive prefix patterns. + * Trailing periods are stripped so abbreviations like "Mont." match "Montgomery". + */ + public static List lookupFuzzy(String column, Map filters) { + Map patterns = new LinkedHashMap<>(); + for (Map.Entry entry : filters.entrySet()) { + String raw = entry.getValue().toString().trim(); + String pattern = raw.endsWith(".") ? raw.substring(0, raw.length() - 1) + "%" : raw + "%"; + patterns.put(entry.getKey(), pattern); + } + return query(column, patterns, "LIKE"); + } + public static List lookup(String column, Map filters) { + return query(column, filters, "="); + } + + private static List query(String column, Map filters, String operator) { + if (!ALLOWED_COLUMNS.contains(column)) { + throw new IllegalArgumentException("Invalid column: " + column); + } + if (filters.isEmpty()) { + throw new IllegalArgumentException("filters must not be empty"); + } + for (String key : filters.keySet()) { + if (!ALLOWED_COLUMNS.contains(key)) { + throw new IllegalArgumentException("Invalid filter key: " + key); + } + } + List results = new ArrayList<>(); - StringBuilder sql = new StringBuilder("SELECT DISTINCT ").append(column).append(" FROM locations WHERE "); - // construct WHERE clause; assume everything is ANDed together + StringBuilder sql = new StringBuilder("SELECT DISTINCT ").append(column).append(" FROM locations WHERE "); boolean first = true; for (String key : filters.keySet()) { - if (!first) { - sql.append(" AND "); - } - sql.append(key).append(" = ?"); + if (!first) sql.append(" AND "); + sql.append(key).append(" ").append(operator).append(" ? COLLATE NOCASE"); first = false; } LocationService service = Arc.container().instance(LocationService.class).get(); - Connection connection = null; - PreparedStatement pstmt = null; - ResultSet rs = null; + try (Connection connection = service.getDbConnection(); + PreparedStatement pstmt = connection.prepareStatement(sql.toString())) { - try { - connection = service.getDbConnection(); - pstmt = connection.prepareStatement(sql.toString()); - - // Set the values dynamically int index = 1; for (Object value : filters.values()) { - String stringValue = value.toString(); // db only has strings - pstmt.setString(index++, stringValue); + pstmt.setString(index++, value.toString().trim()); } - rs = pstmt.executeQuery(); - - while(rs.next()) { - String thisString = rs.getString(column); - results.add(thisString); - } - } catch (SQLException e) { - e.printStackTrace(); - } finally { - try { - if (rs != null) { - rs.close(); - } - if (pstmt != null) { - pstmt.close(); - } - if (connection != null) { - connection.close(); + try (ResultSet rs = pstmt.executeQuery()) { + while (rs.next()) { + results.add(rs.getString(column)); } - } catch (SQLException e) { - e.printStackTrace(); } + + } catch (SQLException e) { + LOG.severe("Location lookup failed for column=" + column + " filters=" + filters + ": " + e.getMessage()); + throw new RuntimeException("Location lookup failed", e); } return results; - - } -} \ No newline at end of file + } +} diff --git a/library-api/src/test/java/org/codeforphilly/bdt/functions/LocationServiceTest.java b/library-api/src/test/java/org/codeforphilly/bdt/functions/LocationServiceTest.java new file mode 100644 index 00000000..de56392c --- /dev/null +++ b/library-api/src/test/java/org/codeforphilly/bdt/functions/LocationServiceTest.java @@ -0,0 +1,107 @@ +package org.codeforphilly.bdt.functions; + +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@QuarkusTest +public class LocationServiceTest { + + // --- lookup() --- + + @Test + public void testLookupExactMatch() { + List results = LocationService.lookup("countyName", Map.of("zipCode", "19107")); + assertFalse(results.isEmpty(), "Should find a county for zip 19107"); + assertTrue(results.contains("Philadelphia")); + } + + @Test + public void testLookupCaseInsensitiveFilterValue() { + List uppercase = LocationService.lookup("zipCode", Map.of("stateAbbreviation", "PA", "countyName", "Philadelphia")); + List lowercase = LocationService.lookup("zipCode", Map.of("stateAbbreviation", "pa", "countyName", "philadelphia")); + assertFalse(uppercase.isEmpty(), "Exact case should return results"); + assertEquals(uppercase.size(), lowercase.size(), + "Lowercase filter values should return same number of results as title-case"); + assertTrue(lowercase.containsAll(uppercase)); + } + + @Test + public void testLookupCaseInsensitiveMixedCase() { + List titleCase = LocationService.lookup("zipCode", Map.of("stateAbbreviation", "PA", "countyName", "Philadelphia")); + List upperCase = LocationService.lookup("zipCode", Map.of("stateAbbreviation", "PA", "countyName", "PHILADELPHIA")); + assertEquals(titleCase.size(), upperCase.size(), + "UPPERCASE filter value should return same results as title-case"); + } + + @Test + public void testLookupTrimsLeadingWhitespace() { + List trimmed = LocationService.lookup("countyName", Map.of("zipCode", "19107")); + List withSpace = LocationService.lookup("countyName", Map.of("zipCode", " 19107")); + assertEquals(trimmed, withSpace, "Leading whitespace on filter value should be trimmed"); + } + + @Test + public void testLookupTrimsTrailingWhitespace() { + List trimmed = LocationService.lookup("countyName", Map.of("zipCode", "19107")); + List withSpace = LocationService.lookup("countyName", Map.of("zipCode", "19107 ")); + assertEquals(trimmed, withSpace, "Trailing whitespace on filter value should be trimmed"); + } + + @Test + public void testLookupReturnsEmptyForNoMatch() { + List results = LocationService.lookup("countyName", Map.of("stateAbbreviation", "ZZ")); + assertTrue(results.isEmpty(), "Unknown state abbreviation should return an empty list"); + } + + // --- lookupFuzzy() --- + + @Test + public void testLookupFuzzyAbbreviationWithPeriod() { + // "Mont." should match both "Montgomery" and "Montour" in PA + List results = LocationService.lookupFuzzy("countyName", + Map.of("countyName", "Mont.", "stateAbbreviation", "PA")); + assertFalse(results.isEmpty(), "Abbreviated 'Mont.' should match counties starting with 'Mont'"); + assertTrue(results.contains("Montgomery"), + "Expected 'Montgomery' in fuzzy results for 'Mont.'"); + } + + @Test + public void testLookupFuzzyPrefixNoTrailingPeriod() { + // "Los" should match "Los Angeles" and other "Los ..." counties + List results = LocationService.lookupFuzzy("countyName", + Map.of("countyName", "Los")); + assertFalse(results.isEmpty(), "Prefix 'Los' should match counties starting with 'Los'"); + assertTrue(results.stream().allMatch(name -> name.toLowerCase().startsWith("los")), + "All results should start with 'Los'"); + } + + @Test + public void testLookupFuzzyIsCaseInsensitive() { + List upper = LocationService.lookupFuzzy("countyName", + Map.of("countyName", "PHILA", "stateAbbreviation", "PA")); + List lower = LocationService.lookupFuzzy("countyName", + Map.of("countyName", "phila", "stateAbbreviation", "PA")); + assertEquals(upper.size(), lower.size(), + "Fuzzy lookup should be case-insensitive"); + assertTrue(lower.containsAll(upper)); + } + + @Test + public void testLookupFuzzyTrimsWhitespace() { + List trimmed = LocationService.lookupFuzzy("countyName", Map.of("countyName", "Phila", "stateAbbreviation", "PA")); + List withSpace = LocationService.lookupFuzzy("countyName", Map.of("countyName", " Phila ", "stateAbbreviation", "PA")); + assertEquals(trimmed, withSpace, "Fuzzy lookup should trim whitespace before matching"); + } + + @Test + public void testLookupFuzzyReturnsEmptyForNoMatch() { + List results = LocationService.lookupFuzzy("countyName", + Map.of("countyName", "ZZZZZ")); + assertTrue(results.isEmpty(), "Fuzzy lookup should return empty list when nothing matches"); + } +} diff --git a/library-api/src/test/resources/application.properties b/library-api/src/test/resources/application.properties new file mode 100644 index 00000000..b212bca1 --- /dev/null +++ b/library-api/src/test/resources/application.properties @@ -0,0 +1,2 @@ +# Use a random port so tests don't conflict with other running services +quarkus.http.test-port=0