From f115d1a6357a5c298cb972b193906d4ac99d51d3 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Fri, 13 Mar 2026 16:21:08 -0700 Subject: [PATCH 01/13] initial location catalog text search support demonstrating usage of: https://github.com/HydrologicEngineeringCenter/cwms-database/pull/123 TODO: - [ ] merge schema update PR - [ ] integration testing - [ ] OpenAPI documentation --- .../java/cwms/cda/api/CatalogController.java | 3 ++ .../main/java/cwms/cda/api/Controllers.java | 1 + .../data/dao/CatalogRequestParameters.java | 13 ++++++ .../cwms/cda/data/dao/LocationsDaoImpl.java | 43 ++++++++++++++++++- 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java b/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java index a4d04a7e4..151eed16a 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java @@ -237,6 +237,8 @@ public void getOne(@NotNull Context ctx, @NotNull String dataSet) { String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); boolean includeAliases = ctx.queryParamAsClass(INCLUDE_ALIASES, Boolean.class) .getOrDefault(false); + String searchText = ctx.queryParamAsClass(SEARCH_TEXT, String.class) + .getOrDefault(null); String acceptHeader = ctx.header(ACCEPT); ContentType contentType = Formats.parseHeader(acceptHeader, Catalog.class); Catalog cat = null; @@ -281,6 +283,7 @@ public void getOne(@NotNull Context ctx, @NotNull String dataSet) { .withLocationType(locationType) .withFilterBaseLocations(filterBaseLocations) .withNegateLocationKindLike(negateLocationKind) + .withSearchText(searchText) .withIncludeAliases(includeAliases) .build(); diff --git a/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java b/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java index e4059e4e1..5e24b88b3 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java @@ -164,6 +164,7 @@ public final class Controllers { public static final String QUALITY = "quality"; public static final String NAMES = "names"; public static final String FILTER_BASE_LOCATIONS = "filter-base-locations"; + public static final String SEARCH_TEXT = "search-text"; public static final String GROUP_ID = "group-id"; public static final String REPLACE_ASSIGNED_LOCS = "replace-assigned-locs"; diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/CatalogRequestParameters.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/CatalogRequestParameters.java index e031e8f50..6e65a546f 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/CatalogRequestParameters.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/CatalogRequestParameters.java @@ -23,6 +23,7 @@ public class CatalogRequestParameters { private final boolean includeAliases; private final boolean filterBaseLocations; private final boolean negateLocationKindLike; + private final String searchText; private CatalogRequestParameters(Builder builder) { this.office = builder.office; @@ -40,6 +41,7 @@ private CatalogRequestParameters(Builder builder) { this.includeAliases = builder.includeAliases; this.filterBaseLocations = builder.filterBaseLocations; this.negateLocationKindLike = builder.negateLocationKindLike; + this.searchText = builder.searchText; } public String getBoundingOfficeLike() { @@ -102,6 +104,10 @@ public boolean isNegateLocationKindLike() { return negateLocationKindLike; } + public String getSearchText() { + return searchText; + } + public static class Builder { String office; String idLike; @@ -118,6 +124,7 @@ public static class Builder { private boolean includeAliases = false; private boolean filterBaseLocations = false; private boolean negateLocationKindLike = false; + private String searchText; public Builder() { @@ -198,6 +205,11 @@ public Builder withNegateLocationKindLike(boolean negateLocationKindLike) { return this; } + public Builder withSearchText(String searchText) { + this.searchText = searchText; + return this; + } + public static Builder from(CatalogRequestParameters params) { // This NEEDS to include every field in the CatalogRequestParameters return new Builder() @@ -215,6 +227,7 @@ public static Builder from(CatalogRequestParameters params) { .withLocationType(params.locationType) .withFilterBaseLocations(params.filterBaseLocations) .withNegateLocationKindLike(params.negateLocationKindLike) + .withSearchText(params.searchText) ; } diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java index 41921287c..d2a3ac97a 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java @@ -102,7 +102,7 @@ public class LocationsDaoImpl extends JooqDao implements LocationsDao { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private static final long DELETED_TS_MARKER = 0L; - + private static Boolean HAS_SEARCH_COLUMN; public LocationsDaoImpl(DSLContext dsl) { @@ -581,6 +581,12 @@ private Catalog getLocationCatalog(Catalog.CatalogPage catPage, int pageSize, Ca //location codes (previous implementation used location_id) for joins, feel free to implement. Objects.requireNonNull(params.getIdLike(), "A value must be provided for the idLike field. Specify .* if you don't care."); + String textSearch = params.getSearchText(); + if (textSearch != null && !textSearch.isBlank() && !hasSearchDocColumn(dsl, table)) { + throw new IllegalArgumentException( + "Text search is not supported because SEARCH_DOC is not present in " + table.getName() + ); + } // "condition" needs to be used by the count query and the results query. Condition condition = buildWhereCondition(params); @@ -728,6 +734,17 @@ private static Condition buildWhereCondition(CatalogRequestParameters params) { condition = condition.and(fieldMapping.getSubLocationId().isNotNull()); } + String textSearch = params.getSearchText(); + if (textSearch != null && !textSearch.isBlank()) { + condition = condition.and( + DSL.condition( + "CONTAINS({0}, ?) > 0", + fieldMapping.getSearchDoc(), + textSearch + ) + ); + } + return condition; } @@ -758,6 +775,18 @@ private LocationAlias buildLocationAlias(Record row, FieldMapping mapping) { row.get(mapping.getLocationId())); } + private static synchronized boolean hasSearchDocColumn(DSLContext dsl, Table table) { + if(HAS_SEARCH_COLUMN != null) { + return HAS_SEARCH_COLUMN; + } + HAS_SEARCH_COLUMN = dsl.meta() + .getTables(table.getName()) + .stream() + .flatMap(t -> Arrays.stream(t.fields())) + .anyMatch(c -> c.getName().equalsIgnoreCase("SEARCH_DOC")); + return HAS_SEARCH_COLUMN; + } + @NotNull private static LocationCatalogEntry buildCatalogEntry(Record loc, Set aliases, FieldMapping mapping) { @@ -886,6 +915,8 @@ private interface FieldMapping { boolean includesAliases(); Table getTable(); + + Field getSearchDoc(); } private static class AvLoc2FieldMapping implements FieldMapping { @@ -1043,6 +1074,11 @@ public boolean includesAliases() { public Table getTable() { return AV_LOC2.AV_LOC2; } + + @Override + public Field getSearchDoc() { + return DSL.field(DSL.name(getTable().getName(), "SEARCH_DOC"), String.class); + } } private static class AvLocFieldMapping implements FieldMapping { @@ -1196,6 +1232,11 @@ public boolean includesAliases() { return false; } + @Override + public Field getSearchDoc() { + return DSL.field(DSL.name(getTable().getName(), "SEARCH_DOC"), String.class); + } + @Override public Table getTable() { return AV_LOC; From 102aeecd26a4d8d06c6912ba04f033526be27d80 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Fri, 8 May 2026 16:55:24 -0700 Subject: [PATCH 02/13] add openapi and integration tests --- .../java/cwms/cda/api/CatalogController.java | 11 +++ .../cwms/cda/api/CatalogControllerTestIT.java | 81 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java b/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java index dcd0e7b1a..cdb42f13e 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java @@ -175,6 +175,14 @@ public void getAll(Context ctx) { description = "Whether to add aliases to the catalog entries. " + "Default is false. If true, the aliases will be added to the " + "catalog entries in the response."), + @OpenApiParam(name = SEARCH_TEXT, + description = "This parameter allows the user to specify a text string to " + + "search locations' metadata. The search is performed " + + "against the following fields: base location ID, sub location ID, " + + "combined location ID, public name, long name, description, " + + "map label, nearest city, location kind, and location type. " + + "Note: This parameter is unsupported when dataset is Timeseries." + ), }, pathParams = { @OpenApiParam(name = "dataset", @@ -251,6 +259,9 @@ public void getOne(@NotNull Context ctx, @NotNull String dataSet) { ContentType contentType = Formats.parseHeader(acceptHeader, Catalog.class); Catalog cat = null; if (TIMESERIES.equalsIgnoreCase(valDataSet)) { + if (searchText != null && !searchText.isBlank()) { + throw new UnsupportedOperationException("Search text is not yet enabled for timeseries."); + } TimeSeriesDao tsDao = new TimeSeriesDaoImpl(dsl, metrics); boolean includeExtents = ctx.queryParamAsClass(INCLUDE_EXTENTS, Boolean.class) diff --git a/cwms-data-api/src/test/java/cwms/cda/api/CatalogControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/CatalogControllerTestIT.java index 72bfc3717..00a1bdbe2 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/CatalogControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/CatalogControllerTestIT.java @@ -75,6 +75,7 @@ static void setup_data() throws Exception { createLocation("Flat Lake",true, OFFICE); createProject("Flat Project", OFFICE); + createProject("Paonia", OFFICE); createTimeseries(OFFICE,"Alder Springs.Precip-Cumulative.Inst.15Minutes.0.raw-cda"); createTimeseries(OFFICE,"Alder Springs.Precip-INC.Total.15Minutes.15Minutes.calc-cda"); createTimeseries(OFFICE,"Pine Flat-Outflow.Stage.Inst.15Minutes.0.raw-cda"); @@ -790,4 +791,84 @@ void test_locations_unsupported_params_multiple() { // Order of parameters in the message is not guaranteed; verify as a set assertTrue(List.of(parts).containsAll(List.of(INCLUDE_EXTENTS, EXCLUDE_EMPTY))); } + + @Test + void test_timeseries_unsupported_search_text() { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .queryParam(SEARCH_TEXT, "test") + .when() + .get("/catalog/" + TIMESERIES) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_IMPLEMENTED)); + } + + @Test + void test_location_search_text_basic() { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .queryParam(SEARCH_TEXT, "outflow") + .when() + .get("/catalog/" + LOCATIONS) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("entries.name", hasItem("Pine Flat-Outflow")); + } + + @Test + void test_location_search_text_on_location_kind() { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .queryParam(SEARCH_TEXT, "project") + .when() + .get("/catalog/" + LOCATIONS) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("entries.name", hasItem("Paonia")); + } + + @Test + void test_location_search_text_combines_with_location_kind_filter() { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .queryParam(OFFICE, OFFICE) + .queryParam(SEARCH_TEXT, "flat") + .queryParam(LOCATION_KIND_LIKE, "PROJECT") + .when() + .get("/catalog/" + LOCATIONS) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("total", is(1)) + .body("entries.size()", is(1)) + .body("entries[0].name", is("Flat Project")); + } + + @Test + void test_location_search_text_no_matches() { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .queryParam(OFFICE, OFFICE) + .queryParam(SEARCH_TEXT, "zzzzzzzzzz-not-a-location") + .when() + .get("/catalog/" + LOCATIONS) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("total", is(0)) + .body("entries.size()", is(0)); + } } From c41dba3b5340e22bc71c7ea42b98cd12ccdb81b0 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Fri, 8 May 2026 17:06:30 -0700 Subject: [PATCH 03/13] add validation to the text search --- .../java/cwms/cda/api/CatalogController.java | 46 ++++++++++++++++--- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java b/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java index cdb42f13e..dfc159beb 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java @@ -44,6 +44,8 @@ public class CatalogController implements CrudHandler { public static final boolean INCLUDE_EXTENTS_DEFAULT = true; public static final boolean INCLUDE_VERSIONS_DEFAULT = true; public static final boolean EXCLUDE_EMPTY_DEFAULT = true; + private static final int MAX_SEARCH_TEXT_LENGTH = 128; + private static final int MAX_SEARCH_TEXT_TOKENS = 10; private final MetricRegistry metrics; @@ -177,11 +179,13 @@ public void getAll(Context ctx) { + "catalog entries in the response."), @OpenApiParam(name = SEARCH_TEXT, description = "This parameter allows the user to specify a text string to " - + "search locations' metadata. The search is performed " - + "against the following fields: base location ID, sub location ID, " - + "combined location ID, public name, long name, description, " - + "map label, nearest city, location kind, and location type. " - + "Note: This parameter is unsupported when dataset is Timeseries." + + "search locations' metadata. The search is performed " + + "against the following fields: base location ID, sub location ID, " + + "combined location ID, public name, long name, description, " + + "map label, nearest city, location kind, and location type. " + + "Search text must be no longer than " + MAX_SEARCH_TEXT_LENGTH + + " characters, and contain no more than " + MAX_SEARCH_TEXT_TOKENS + + " terms. Note: This parameter is unsupported when dataset is Timeseries." ), }, pathParams = { @@ -253,8 +257,8 @@ public void getOne(@NotNull Context ctx, @NotNull String dataSet) { String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); boolean includeAliases = ctx.queryParamAsClass(INCLUDE_ALIASES, Boolean.class) .getOrDefault(false); - String searchText = ctx.queryParamAsClass(SEARCH_TEXT, String.class) - .getOrDefault(null); + String searchText = validateSearchText(ctx.queryParamAsClass(SEARCH_TEXT, String.class) + .getOrDefault(null)); String acceptHeader = ctx.header(ACCEPT); ContentType contentType = Formats.parseHeader(acceptHeader, Catalog.class); Catalog cat = null; @@ -338,6 +342,34 @@ private static void warnAboutNotSupported(@NotNull Context ctx, String[] warnAbo } } + private static String validateSearchText(String searchText) { + if (searchText == null) { + return null; + } + + String trimmed = searchText.trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException(SEARCH_TEXT + " must not be blank"); + } + + if (trimmed.length() > MAX_SEARCH_TEXT_LENGTH) { + throw new IllegalArgumentException(SEARCH_TEXT + " must be no longer than " + + MAX_SEARCH_TEXT_LENGTH + " characters"); + } + + if (!trimmed.matches(".*[\\p{Alnum}].*")) { + throw new IllegalArgumentException(SEARCH_TEXT + " must contain at least one letter or digit"); + } + + String[] tokens = trimmed.split("\\s+"); + if (tokens.length > MAX_SEARCH_TEXT_TOKENS) { + throw new IllegalArgumentException(SEARCH_TEXT + " must contain no more than " + + MAX_SEARCH_TEXT_TOKENS + " terms"); + } + + return trimmed; + } + @OpenApi(tags = {"Catalog"}, ignore = true) @Override public void update(Context ctx, @NotNull String entry) { From 160ba00d7f4325e75dfe74b20d1732946838976c Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Fri, 8 May 2026 17:20:13 -0700 Subject: [PATCH 04/13] verify column exists in the database --- .../cwms/cda/data/dao/LocationsDaoImpl.java | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java index 2e170b734..6de2ae7ba 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java @@ -42,6 +42,7 @@ import static org.jooq.impl.DSL.name; import static org.jooq.impl.DSL.noCondition; import static org.jooq.impl.DSL.select; +import static org.jooq.impl.DSL.table; import static usace.cwms.db.jooq.codegen.tables.AV_LOC.AV_LOC; import static usace.cwms.db.jooq.codegen.tables.AV_LOC_ALIAS.AV_LOC_ALIAS; @@ -726,7 +727,7 @@ private Catalog getLocationCatalog(Catalog.CatalogPage catPage, int pageSize, Ca } } - private static Condition buildWhereCondition(CatalogRequestParameters params) { + private Condition buildWhereCondition(CatalogRequestParameters params) { String idLike = params.getIdLike(); FieldMapping fieldMapping = null; if (params.includeAliases()) { @@ -775,6 +776,9 @@ private static Condition buildWhereCondition(CatalogRequestParameters params) { String textSearch = params.getSearchText(); if (textSearch != null && !textSearch.isBlank()) { + if(!supportsSearchDocColumn(fieldMapping)) { + throw new IllegalArgumentException("Text search is not supported yet supported"); + } condition = condition.and( DSL.condition( "CONTAINS({0}, ?) > 0", @@ -787,6 +791,22 @@ private static Condition buildWhereCondition(CatalogRequestParameters params) { return condition; } + private boolean supportsSearchDocColumn(FieldMapping mapping) { + if (HAS_SEARCH_COLUMN != null) { + return HAS_SEARCH_COLUMN; + } + + Record searchDocSupport = dsl.select(asterisk()) + .from(table("ALL_TAB_COLUMNS")) + .where(field("TABLE_NAME").eq(mapping.getTable().getName())) + .and(field("COLUMN_NAME").eq(mapping.getSearchDoc().getName())) + .and(field("OWNER").eq("CWMS_20")) + .fetchOne(); + + HAS_SEARCH_COLUMN = searchDocSupport != null; + return HAS_SEARCH_COLUMN; + } + private static Condition addCursorConditions(Condition condition, String cursorOffice, String cursorLocation, FieldMapping mapping) { if (cursorOffice != null) { From 2e77dab2ad7138c619c38bc2a96755f76857f265 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Fri, 8 May 2026 17:21:20 -0700 Subject: [PATCH 05/13] only test on latest schema --- .../src/test/java/cwms/cda/api/CatalogControllerTestIT.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cwms-data-api/src/test/java/cwms/cda/api/CatalogControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/CatalogControllerTestIT.java index 00a1bdbe2..3a57d8078 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/CatalogControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/CatalogControllerTestIT.java @@ -13,6 +13,7 @@ import cwms.cda.data.dto.stream.Stream; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.json.JsonV2; +import fixtures.MinimumSchema; import fixtures.TestAccounts; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -792,6 +793,7 @@ void test_locations_unsupported_params_multiple() { assertTrue(List.of(parts).containsAll(List.of(INCLUDE_EXTENTS, EXCLUDE_EMPTY))); } + @MinimumSchema(20261231) @Test void test_timeseries_unsupported_search_text() { given() @@ -806,6 +808,7 @@ void test_timeseries_unsupported_search_text() { .statusCode(is(HttpServletResponse.SC_NOT_IMPLEMENTED)); } + @MinimumSchema(20261231) @Test void test_location_search_text_basic() { given() @@ -821,6 +824,7 @@ void test_location_search_text_basic() { .body("entries.name", hasItem("Pine Flat-Outflow")); } + @MinimumSchema(20261231) @Test void test_location_search_text_on_location_kind() { given() @@ -836,6 +840,7 @@ void test_location_search_text_on_location_kind() { .body("entries.name", hasItem("Paonia")); } + @MinimumSchema(20261231) @Test void test_location_search_text_combines_with_location_kind_filter() { given() @@ -855,6 +860,7 @@ void test_location_search_text_combines_with_location_kind_filter() { .body("entries[0].name", is("Flat Project")); } + @MinimumSchema(20261231) @Test void test_location_search_text_no_matches() { given() From 5dcee7e31c37f4e5a94af3156530f65a0c6781da Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Mon, 11 May 2026 12:37:04 -0700 Subject: [PATCH 06/13] fix count query --- .../java/cwms/cda/data/dao/LocationsDaoImpl.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java index 6de2ae7ba..880bb76b0 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java @@ -644,7 +644,7 @@ private Catalog getLocationCatalog(Catalog.CatalogPage catPage, int pageSize, Ca .from(table) .where(condition); logger.atFiner().log("%s", lazy(() -> count.getSQL(ParamType.INLINED))); - total = count.fetchOne().value1(); + total = count.fetchOne(1, int.class); } else { cursorLocation = catPage.getCursorId(); cursorOffice = catPage.getCurOffice(); @@ -779,13 +779,14 @@ private Condition buildWhereCondition(CatalogRequestParameters params) { if(!supportsSearchDocColumn(fieldMapping)) { throw new IllegalArgumentException("Text search is not supported yet supported"); } - condition = condition.and( - DSL.condition( - "CONTAINS({0}, ?) > 0", - fieldMapping.getSearchDoc(), - textSearch - ) + Field containsScore = DSL.field( + "CONTAINS({0}, {1})", + Integer.class, + fieldMapping.getSearchDoc(), + DSL.inline(textSearch) ); + + condition = condition.and(containsScore.gt(0)); } return condition; From 2879850363139e59f5512302f21c9f8a7c0b8c4e Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Mon, 11 May 2026 14:47:21 -0700 Subject: [PATCH 07/13] fix index --- .../src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java index 880bb76b0..f764d9282 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java @@ -644,7 +644,7 @@ private Catalog getLocationCatalog(Catalog.CatalogPage catPage, int pageSize, Ca .from(table) .where(condition); logger.atFiner().log("%s", lazy(() -> count.getSQL(ParamType.INLINED))); - total = count.fetchOne(1, int.class); + total = count.fetchOne(0, int.class); } else { cursorLocation = catPage.getCursorId(); cursorOffice = catPage.getCurOffice(); From a963c07e4b15d198470b3967d8b21fe8ab974190 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Mon, 11 May 2026 16:56:09 -0700 Subject: [PATCH 08/13] add some extra search term validation --- .../java/cwms/cda/api/CatalogController.java | 357 +++++++++++------- 1 file changed, 220 insertions(+), 137 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java b/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java index dfc159beb..b6d36a985 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java @@ -1,13 +1,42 @@ package cwms.cda.api; import static com.codahale.metrics.MetricRegistry.name; -import static cwms.cda.api.Controllers.*; +import static cwms.cda.api.Controllers.ACCEPT; +import static cwms.cda.api.Controllers.BOUNDING_OFFICE_LIKE; +import static cwms.cda.api.Controllers.CURSOR; +import static cwms.cda.api.Controllers.EXCLUDE_EMPTY; +import static cwms.cda.api.Controllers.FILTER_BASE_LOCATIONS; +import static cwms.cda.api.Controllers.GET_ONE; +import static cwms.cda.api.Controllers.INCLUDE_ALIASES; +import static cwms.cda.api.Controllers.INCLUDE_EXTENTS; +import static cwms.cda.api.Controllers.INCLUDE_VERSIONS; +import static cwms.cda.api.Controllers.LIKE; +import static cwms.cda.api.Controllers.LOCATIONS; +import static cwms.cda.api.Controllers.LOCATION_CATEGORY_LIKE; +import static cwms.cda.api.Controllers.LOCATION_GROUP_LIKE; +import static cwms.cda.api.Controllers.LOCATION_KIND_LIKE; +import static cwms.cda.api.Controllers.LOCATION_TYPE_LIKE; +import static cwms.cda.api.Controllers.NEGATE_LOCATION_KIND_LIKE; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.PAGE; +import static cwms.cda.api.Controllers.PAGE_SIZE; +import static cwms.cda.api.Controllers.RESULTS; +import static cwms.cda.api.Controllers.SEARCH_TEXT; +import static cwms.cda.api.Controllers.SIZE; +import static cwms.cda.api.Controllers.STATUS_200; +import static cwms.cda.api.Controllers.TIMESERIES; +import static cwms.cda.api.Controllers.TIMESERIES_CATEGORY_LIKE; +import static cwms.cda.api.Controllers.TIMESERIES_GROUP_LIKE; +import static cwms.cda.api.Controllers.UNIT_SYSTEM; +import static cwms.cda.api.Controllers.queryParamAsClass; import com.codahale.metrics.Histogram; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.Timer; +import com.google.common.flogger.FluentLogger; import cwms.cda.api.enums.UnitSystem; import cwms.cda.api.errors.CdaError; +import cwms.cda.api.errors.UnsupportedParametersException; import cwms.cda.data.dao.CatalogRequestParameters; import cwms.cda.data.dao.JooqDao; import cwms.cda.data.dao.LocationsDao; @@ -25,17 +54,19 @@ import io.javalin.plugin.openapi.annotations.OpenApiContent; import io.javalin.plugin.openapi.annotations.OpenApiParam; import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; -import com.google.common.flogger.FluentLogger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.servlet.http.HttpServletResponse; import org.jetbrains.annotations.NotNull; import org.jooq.DSLContext; import org.owasp.html.PolicyFactory; -import cwms.cda.api.errors.UnsupportedParametersException; public class CatalogController implements CrudHandler { @@ -46,6 +77,21 @@ public class CatalogController implements CrudHandler { public static final boolean EXCLUDE_EMPTY_DEFAULT = true; private static final int MAX_SEARCH_TEXT_LENGTH = 128; private static final int MAX_SEARCH_TEXT_TOKENS = 10; + private static final Set SEARCH_TEXT_OPERATORS = Set.of("AND", + "OR", + "NOT", + "ABOUT", + "EQUIV", + "MINUS", + "NEAR", + "WITHIN", + "HASPATH", + "INPATH", + "FUZZY", + "STEM", + "SOUNDEX"); + private static final Pattern TOKENIZE_PATTERN = Pattern.compile("\"[^\"]+\"|\\S+"); + private static final Pattern NORMALIZE_PATTERN = Pattern.compile("^[()]+|[()]+$"); private final MetricRegistry metrics; @@ -85,57 +131,57 @@ public void getAll(Context ctx) { @OpenApi( queryParams = { @OpenApiParam(name = PAGE, - description = "This end point can return a lot of data, this " - + "identifies where in the request you are." + description = "This end point can return a lot of data, this " + + "identifies where in the request you are." ), @OpenApiParam(name = CURSOR, deprecated = true, - description = "This end point can return a lot of data, this " - + "identifies where in the request you are. This is an opaque" - + " value, and can be obtained from the 'next-page' value in " - + "the response. Deprecated, use " + PAGE + " instead."), + description = "This end point can return a lot of data, this " + + "identifies where in the request you are. This is an opaque" + + " value, and can be obtained from the 'next-page' value in " + + "the response. Deprecated, use " + PAGE + " instead."), @OpenApiParam(name = PAGE_SIZE, - type = Integer.class, - description = "How many entries per page returned. Default 500." + type = Integer.class, + description = "How many entries per page returned. Default 500." ), @OpenApiParam(name = UNIT_SYSTEM, - type = UnitSystem.class, - description = UnitSystem.DESCRIPTION + type = UnitSystem.class, + description = UnitSystem.DESCRIPTION ), @OpenApiParam(name = OFFICE, - description = "3-4 letter office name representing the district you " - + "want to isolate data to." + description = "3-4 letter office name representing the district you " + + "want to isolate data to." ), @OpenApiParam(name = LIKE, - description = "Posix regular expression " - + "matching against the id" + description = "Posix regular expression " + + "matching against the id" ), @OpenApiParam(name = TIMESERIES_CATEGORY_LIKE, - description = "Posix regular expression " - + "matching against the timeseries category id. Note: This parameter is " - + "unsupported when dataset is Locations." + description = "Posix regular expression " + + "matching against the timeseries category id. Note: This parameter is " + + "unsupported when dataset is Locations." ), @OpenApiParam(name = TIMESERIES_GROUP_LIKE, - description = "Posix regular expression " - + "matching against the timeseries group id. Note: This parameter is " - + "unsupported when dataset is Locations." + description = "Posix regular expression " + + "matching against the timeseries group id. Note: This parameter is " + + "unsupported when dataset is Locations." ), @OpenApiParam(name = LOCATION_CATEGORY_LIKE, - description = "Posix regular expression " - + "matching against the location category id" + description = "Posix regular expression " + + "matching against the location category id" ), @OpenApiParam(name = LOCATION_GROUP_LIKE, - description = "Posix regular expression " - + "matching against the location group id" + description = "Posix regular expression " + + "matching against the location group id" ), @OpenApiParam(name = BOUNDING_OFFICE_LIKE, - description = "Posix regular expression " + description = "Posix regular expression " + "matching against the location bounding office. When this field is used " - + "items with no bounding office set will not be present in results."), + + "items with no bounding office set will not be present in results."), @OpenApiParam(name = INCLUDE_EXTENTS, type = Boolean.class, - description = "Whether the returned catalog entries should include timeseries " - + "extents. Only valid for TIMESERIES. Note: This parameter is " - + "unsupported when dataset is Locations." - + "Default is " + INCLUDE_EXTENTS_DEFAULT + "."), + description = "Whether the returned catalog entries should include timeseries " + + "extents. Only valid for TIMESERIES. Note: This parameter is " + + "unsupported when dataset is Locations." + + "Default is " + INCLUDE_EXTENTS_DEFAULT + "."), @OpenApiParam(name = INCLUDE_VERSIONS, type = Boolean.class, description = "Whether the returned catalog entries should include timeseries " + "versions in the extents block. " @@ -144,61 +190,67 @@ public void getAll(Context ctx) { + "unsupported when dataset is Locations." + "Default is " + INCLUDE_VERSIONS_DEFAULT + "."), @OpenApiParam(name = EXCLUDE_EMPTY, type = Boolean.class, - description = "Specifies " - + "whether Timeseries that have empty extents " - + "should be excluded from the results. For purposes of this parameter " - + "'empty' is defined as VERSION_TIME, EARLIEST_TIME, LATEST_TIME " - + "and LAST_UPDATE all being null. This parameter does not control " - + "whether the extents are returned to the user, only whether matching " - + "timeseries are excluded. Only valid for TIMESERIES. Note: This parameter is " - + "unsupported when dataset is Locations." - + "Default is " + EXCLUDE_EMPTY_DEFAULT + "."), + description = "Specifies " + + "whether Timeseries that have empty extents " + + "should be excluded from the results. For purposes of this parameter " + + "'empty' is defined as VERSION_TIME, EARLIEST_TIME, LATEST_TIME " + + "and LAST_UPDATE all being null. This parameter does not control " + + "whether the extents are returned to the user, only whether matching " + + "timeseries are excluded. Only valid for TIMESERIES. Note: This parameter is " + + "unsupported when dataset is Locations." + + "Default is " + EXCLUDE_EMPTY_DEFAULT + "."), @OpenApiParam(name = LOCATION_KIND_LIKE, - description = "Posix regular expression matching " - + "against the location kind. The location-kind is typically unset " - + "or one of the following: {\"SITE\", \"EMBANKMENT\", \"OVERFLOW\", " - + "\"TURBINE\", \"STREAM\", \"PROJECT\", \"STREAMGAGE\", \"BASIN\", " - + "\"OUTLET\", \"LOCK\", \"GATE\"}. Multiple kinds can be matched " - + "by using Regular Expression OR clauses. For example: " - + "\"(SITE|STREAM)\"" - ), + description = "Posix regular expression matching " + + "against the location kind. The location-kind is typically unset " + + "or one of the following: {\"SITE\", \"EMBANKMENT\", \"OVERFLOW\", " + + "\"TURBINE\", \"STREAM\", \"PROJECT\", \"STREAMGAGE\", \"BASIN\", " + + "\"OUTLET\", \"LOCK\", \"GATE\"}. Multiple kinds can be matched " + + "by using Regular Expression OR clauses. For example: " + + "\"(SITE|STREAM)\"" + ), @OpenApiParam(name = FILTER_BASE_LOCATIONS, type = Boolean.class, description = "Specifies whether to filter the locations based on the " + "base location. Default: false. If true, only sublocations " + "locations will be returned. If false, all locations will be returned. " + "Only supported for JSON format."), @OpenApiParam(name = NEGATE_LOCATION_KIND_LIKE, description = "Whether to use the location kind " - + "regular expression to exclude locations with the specified kinds. Default is false."), + + "regular expression to exclude locations with the specified kinds. Default is false."), @OpenApiParam(name = LOCATION_TYPE_LIKE, - description = "Posix regular expression matching " - + "against the location type." - ), + description = "Posix regular expression matching " + + "against the location type." + ), @OpenApiParam(name = INCLUDE_ALIASES, type = Boolean.class, - description = "Whether to add aliases to the catalog entries. " - + "Default is false. If true, the aliases will be added to the " - + "catalog entries in the response."), + description = "Whether to add aliases to the catalog entries. " + + "Default is false. If true, the aliases will be added to the " + + "catalog entries in the response."), @OpenApiParam(name = SEARCH_TEXT, - description = "This parameter allows the user to specify a text string to " - + "search locations' metadata. The search is performed " - + "against the following fields: base location ID, sub location ID, " - + "combined location ID, public name, long name, description, " - + "map label, nearest city, location kind, and location type. " - + "Search text must be no longer than " + MAX_SEARCH_TEXT_LENGTH - + " characters, and contain no more than " + MAX_SEARCH_TEXT_TOKENS - + " terms. Note: This parameter is unsupported when dataset is Timeseries." + description = "This parameter allows the user to specify a text string to " + + "search locations' metadata. The search is performed " + + "against the following fields: base location ID, sub location ID, " + + "combined location ID, public name, long name, description, " + + "map label, nearest city, location kind, and location type. " + + "Boolean operators are supported using AND, OR, and NOT. For example, " + + "'cat AND dog' requires both terms, 'cat OR dog' matches either term, " + + "and 'cat NOT dog' matches cat while excluding dog. Use quotes to search " + + "for an exact phrase, for example, '\"cat dog\"'. If multiple terms are " + + "provided without an operator, they are treated according to the internal " + + "text-search behavior and should not be assumed to mean AND. " + + "Search text must be no longer than " + MAX_SEARCH_TEXT_LENGTH + + " characters, and contain no more than " + MAX_SEARCH_TEXT_TOKENS + + " terms. Note: This parameter is unsupported when dataset is Timeseries." ), }, pathParams = { @OpenApiParam(name = "dataset", - type = CatalogableEndpoint.class, - description = "A list of what data? E.g. Timeseries, Locations, Ratings, etc") + type = CatalogableEndpoint.class, + description = "A list of what data? E.g. Timeseries, Locations, Ratings, etc") }, responses = {@OpenApiResponse(status = STATUS_200, - description = "A list of elements the data set you've selected.", - content = { - @OpenApiContent(from = Catalog.class, type = Formats.JSONV2), - @OpenApiContent(from = Catalog.class, type = Formats.XML) - }) + description = "A list of elements the data set you've selected.", + content = { + @OpenApiContent(from = Catalog.class, type = Formats.JSONV2), + @OpenApiContent(from = Catalog.class, type = Formats.XML) + }) }, tags = {TAG} ) @@ -209,54 +261,54 @@ public void getOne(@NotNull Context ctx, @NotNull String dataSet) { DSLContext dsl = JooqDao.getDslContext(ctx); String valDataSet = - ((PolicyFactory) ctx.appAttribute("PolicyFactory")).sanitize(dataSet); + ((PolicyFactory) ctx.appAttribute("PolicyFactory")).sanitize(dataSet); - String cursor = queryParamAsClass(ctx, new String[]{PAGE, CURSOR}, - String.class, "", metrics, name(CatalogController.class.getName(), GET_ONE)); + String cursor = queryParamAsClass(ctx, new String[] {PAGE, CURSOR}, + String.class, "", metrics, name(CatalogController.class.getName(), GET_ONE)); - int pageSize = queryParamAsClass(ctx, new String[]{PAGE_SIZE }, - Integer.class, DEFAULT_PAGE_SIZE, metrics, - name(CatalogController.class.getName(), GET_ONE)); + int pageSize = queryParamAsClass(ctx, new String[] {PAGE_SIZE}, + Integer.class, DEFAULT_PAGE_SIZE, metrics, + name(CatalogController.class.getName(), GET_ONE)); String unitSystem = queryParamAsClass(ctx, - new String[]{UNIT_SYSTEM, }, - String.class, UnitSystem.SI.getValue(), metrics, - name(CatalogController.class.getName(), GET_ONE)); + new String[] {UNIT_SYSTEM,}, + String.class, UnitSystem.SI.getValue(), metrics, + name(CatalogController.class.getName(), GET_ONE)); String office = ctx.queryParamAsClass(OFFICE, String.class).allowNullable() - .check(Office::validOfficeCanNull, "Invalid office provided") - .get(); + .check(Office::validOfficeCanNull, "Invalid office provided") + .get(); String like = ctx.queryParamAsClass(LIKE, String.class).getOrDefault(".*"); - String tsCategoryLike = queryParamAsClass(ctx, new String[]{TIMESERIES_CATEGORY_LIKE}, - String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); + String tsCategoryLike = queryParamAsClass(ctx, new String[] {TIMESERIES_CATEGORY_LIKE}, + String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); - String tsGroupLike = queryParamAsClass(ctx, new String[]{TIMESERIES_GROUP_LIKE}, - String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); + String tsGroupLike = queryParamAsClass(ctx, new String[] {TIMESERIES_GROUP_LIKE}, + String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); - String locCategoryLike = queryParamAsClass(ctx, new String[]{LOCATION_CATEGORY_LIKE}, - String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); + String locCategoryLike = queryParamAsClass(ctx, new String[] {LOCATION_CATEGORY_LIKE}, + String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); - String locGroupLike = queryParamAsClass(ctx, new String[]{LOCATION_GROUP_LIKE }, - String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); + String locGroupLike = queryParamAsClass(ctx, new String[] {LOCATION_GROUP_LIKE}, + String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); - String boundingOfficeLike = queryParamAsClass(ctx, new String[]{BOUNDING_OFFICE_LIKE}, - String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); + String boundingOfficeLike = queryParamAsClass(ctx, new String[] {BOUNDING_OFFICE_LIKE}, + String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); - String locationKind = queryParamAsClass(ctx, new String[]{LOCATION_KIND_LIKE}, - String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); + String locationKind = queryParamAsClass(ctx, new String[] {LOCATION_KIND_LIKE}, + String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); boolean negateLocationKind = ctx.queryParamAsClass(NEGATE_LOCATION_KIND_LIKE, Boolean.class) - .getOrDefault(false); + .getOrDefault(false); boolean filterBaseLocations = ctx.queryParamAsClass(FILTER_BASE_LOCATIONS, Boolean.class) - .getOrDefault(false); + .getOrDefault(false); - String locationType = queryParamAsClass(ctx, new String[]{LOCATION_TYPE_LIKE}, - String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); + String locationType = queryParamAsClass(ctx, new String[] {LOCATION_TYPE_LIKE}, + String.class, null, metrics, name(CatalogController.class.getName(), GET_ONE)); boolean includeAliases = ctx.queryParamAsClass(INCLUDE_ALIASES, Boolean.class) - .getOrDefault(false); + .getOrDefault(false); String searchText = validateSearchText(ctx.queryParamAsClass(SEARCH_TEXT, String.class) .getOrDefault(null)); String acceptHeader = ctx.header(ACCEPT); @@ -269,49 +321,49 @@ public void getOne(@NotNull Context ctx, @NotNull String dataSet) { TimeSeriesDao tsDao = new TimeSeriesDaoImpl(dsl, metrics); boolean includeExtents = ctx.queryParamAsClass(INCLUDE_EXTENTS, Boolean.class) - .getOrDefault(INCLUDE_EXTENTS_DEFAULT); + .getOrDefault(INCLUDE_EXTENTS_DEFAULT); boolean includeVersions = ctx.queryParamAsClass(INCLUDE_VERSIONS, Boolean.class) .getOrDefault(INCLUDE_VERSIONS_DEFAULT); boolean excludeExtents = ctx.queryParamAsClass(EXCLUDE_EMPTY, Boolean.class) - .getOrDefault(EXCLUDE_EMPTY_DEFAULT); + .getOrDefault(EXCLUDE_EMPTY_DEFAULT); CatalogRequestParameters parameters = new CatalogRequestParameters.Builder() - .withOffice(office) - .withIdLike(like) - .withLocCatLike(locCategoryLike) - .withLocGroupLike(locGroupLike) - .withTsCatLike(tsCategoryLike) - .withTsGroupLike(tsGroupLike) - .withBoundingOfficeLike(boundingOfficeLike) - .withIncludeExtents(includeExtents) - .withIncludeVersions(includeVersions) - .withExcludeEmpty(excludeExtents) - .withLocationKind(locationKind) - .withLocationType(locationType) - .withIncludeAliases(includeAliases) - .build(); + .withOffice(office) + .withIdLike(like) + .withLocCatLike(locCategoryLike) + .withLocGroupLike(locGroupLike) + .withTsCatLike(tsCategoryLike) + .withTsGroupLike(tsGroupLike) + .withBoundingOfficeLike(boundingOfficeLike) + .withIncludeExtents(includeExtents) + .withIncludeVersions(includeVersions) + .withExcludeEmpty(excludeExtents) + .withLocationKind(locationKind) + .withLocationType(locationType) + .withIncludeAliases(includeAliases) + .build(); cat = tsDao.getTimeSeriesCatalog(cursor, pageSize, parameters); } else if (LOCATIONS.equalsIgnoreCase(valDataSet)) { - warnAboutNotSupported(ctx, new String[]{TIMESERIES_CATEGORY_LIKE, - TIMESERIES_GROUP_LIKE, EXCLUDE_EMPTY, INCLUDE_EXTENTS, INCLUDE_VERSIONS}); + warnAboutNotSupported(ctx, new String[] {TIMESERIES_CATEGORY_LIKE, + TIMESERIES_GROUP_LIKE, EXCLUDE_EMPTY, INCLUDE_EXTENTS, INCLUDE_VERSIONS}); CatalogRequestParameters parameters = new CatalogRequestParameters.Builder() - .withUnitSystem(unitSystem) - .withOffice(office) - .withIdLike(like) - .withLocCatLike(locCategoryLike) - .withLocGroupLike(locGroupLike) - .withBoundingOfficeLike(boundingOfficeLike) - .withLocationKind(locationKind) - .withLocationType(locationType) - .withFilterBaseLocations(filterBaseLocations) - .withNegateLocationKindLike(negateLocationKind) - .withSearchText(searchText) - .withIncludeAliases(includeAliases) - .build(); + .withUnitSystem(unitSystem) + .withOffice(office) + .withIdLike(like) + .withLocCatLike(locCategoryLike) + .withLocGroupLike(locGroupLike) + .withBoundingOfficeLike(boundingOfficeLike) + .withLocationKind(locationKind) + .withLocationType(locationType) + .withFilterBaseLocations(filterBaseLocations) + .withNegateLocationKindLike(negateLocationKind) + .withSearchText(searchText) + .withIncludeAliases(includeAliases) + .build(); LocationsDao dao = new LocationsDaoImpl(dsl); cat = dao.getLocationCatalog(cursor, pageSize, parameters); @@ -322,7 +374,7 @@ public void getOne(@NotNull Context ctx, @NotNull String dataSet) { requestResultSize.update(data.length()); } else { final CdaError re = new CdaError("Cannot create catalog of requested " - + "information"); + + "information"); logger.atInfo().log("%s with url:%s", re, ctx.fullUrl()); ctx.json(re).status(HttpCode.NOT_FOUND); @@ -330,6 +382,29 @@ public void getOne(@NotNull Context ctx, @NotNull String dataSet) { } } + private static List tokenizeSearchText(String searchText) { + List tokens = new ArrayList<>(); + Matcher matcher = TOKENIZE_PATTERN.matcher(searchText); + while (matcher.find()) { + tokens.add(matcher.group()); + } + return tokens; + } + + private static String normalizeSearchTextToken(String token) { + return NORMALIZE_PATTERN.matcher(token).replaceAll("").toUpperCase(Locale.ROOT); + } + + private static void validateBalancedQuotes(String searchText) { + long quoteCount = searchText.chars() + .filter(ch -> ch == '"') + .count(); + + if (quoteCount % 2 != 0) { + throw new IllegalArgumentException(SEARCH_TEXT + " must contain balanced quotes"); + } + } + private static void warnAboutNotSupported(@NotNull Context ctx, String[] warnAbout) { Set notSupported = new LinkedHashSet<>(); Collections.addAll(notSupported, warnAbout); @@ -354,17 +429,25 @@ private static String validateSearchText(String searchText) { if (trimmed.length() > MAX_SEARCH_TEXT_LENGTH) { throw new IllegalArgumentException(SEARCH_TEXT + " must be no longer than " - + MAX_SEARCH_TEXT_LENGTH + " characters"); + + MAX_SEARCH_TEXT_LENGTH + " characters"); } - if (!trimmed.matches(".*[\\p{Alnum}].*")) { + if (!trimmed.matches(".*\\p{Alnum}.*")) { throw new IllegalArgumentException(SEARCH_TEXT + " must contain at least one letter or digit"); } - String[] tokens = trimmed.split("\\s+"); - if (tokens.length > MAX_SEARCH_TEXT_TOKENS) { + validateBalancedQuotes(trimmed); + + List tokens = tokenizeSearchText(trimmed); + long searchTermCount = tokens.stream() + .map(CatalogController::normalizeSearchTextToken) + .filter(token -> !token.isEmpty()) + .filter(token -> !SEARCH_TEXT_OPERATORS.contains(token)) + .count(); + + if (searchTermCount > MAX_SEARCH_TEXT_TOKENS) { throw new IllegalArgumentException(SEARCH_TEXT + " must contain no more than " - + MAX_SEARCH_TEXT_TOKENS + " terms"); + + MAX_SEARCH_TEXT_TOKENS + " search terms, excluding operators like AND, OR, and NOT"); } return trimmed; From aa861b97416e4f8ec98373a2a90a65b13bc49afa Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Mon, 11 May 2026 16:57:15 -0700 Subject: [PATCH 09/13] add tool for querying locations that can showcase the search --- cda-gui/src/links/header-links.js | 5 + cda-gui/src/main.jsx | 2 + cda-gui/src/pages/LocationSearch.jsx | 381 +++++++++++++++++++++++++++ cda-gui/src/route-paths.js | 5 + 4 files changed, 393 insertions(+) create mode 100644 cda-gui/src/pages/LocationSearch.jsx diff --git a/cda-gui/src/links/header-links.js b/cda-gui/src/links/header-links.js index ec672bd37..794f30e22 100644 --- a/cda-gui/src/links/header-links.js +++ b/cda-gui/src/links/header-links.js @@ -31,6 +31,11 @@ export default [ text: "Data Query", href: "/data-query", }, + { + id: "location-search", + text: "Location Search", + href: "/location-search", + }, ], }, { diff --git a/cda-gui/src/main.jsx b/cda-gui/src/main.jsx index 56bf70029..4191f3b36 100644 --- a/cda-gui/src/main.jsx +++ b/cda-gui/src/main.jsx @@ -13,6 +13,7 @@ import SwaggerUI from "./pages/swagger-ui/index"; import Regexp from "./pages/regexp/index"; import DataQuery from "./pages/data-query"; import Layout from "./components/Layout"; +import LocationSearch from "./pages/LocationSearch.jsx"; // Styles import "@usace/groundwork/dist/style.css"; @@ -32,6 +33,7 @@ const routeComponents = { "filter-expressions": FilterExpressions, timestamps: Timestamps, "legacy-format": LegacyFormat, + "location-search": LocationSearch, }; const router = createBrowserRouter( diff --git a/cda-gui/src/pages/LocationSearch.jsx b/cda-gui/src/pages/LocationSearch.jsx new file mode 100644 index 000000000..f604b3688 --- /dev/null +++ b/cda-gui/src/pages/LocationSearch.jsx @@ -0,0 +1,381 @@ +/* + * MIT License + * + * Copyright (c) 2026 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { UsaceBox, Button, Input, H3 } from "@usace/groundwork"; +import { useState } from "react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { Configuration, OfficesApi, CatalogApi } from "cwmsjs"; + +const offices_api = new OfficesApi( + new Configuration({ + basePath: import.meta.env.VITE_CDA_API_ROOT, + }), +); +const catalog_api = new CatalogApi( + new Configuration({ + basePath: import.meta.env.VITE_CDA_API_ROOT, + headers: { accept: "application/json;version=2" }, + }), +); + +export default function LocationSearch() { + const [office, setOffice] = useState(""); + const [searchText, setSearchText] = useState(""); + const [unitSystem, setUnitSystem] = useState("EN"); + const [like, setLike] = useState(""); + const [locationCategoryLike, setLocationCategoryLike] = useState(""); + const [locationGroupLike, setLocationGroupLike] = useState(""); + const [boundingOfficeLike, setBoundingOfficeLike] = useState(""); + const [locationKindLike, setLocationKindLike] = useState(""); + const [locationTypeLike, setLocationTypeLike] = useState(""); + const [triggerSearch, setTriggerSearch] = useState(false); + const [searchCount, setSearchCount] = useState(0); + + const buildCatalogParams = (page) => { + const params = { + dataset: "LOCATIONS", + office, + }; + + if (page) params.page = page; + if (searchText.trim()) params.searchText = searchText.trim(); + if (unitSystem) params.unitSystem = unitSystem; + if (like.trim()) params.like = like.trim(); + if (locationCategoryLike.trim()) + params.locationCategoryLike = locationCategoryLike.trim(); + if (locationGroupLike.trim()) params.locationGroupLike = locationGroupLike.trim(); + if (boundingOfficeLike.trim()) + params.boundingOfficeLike = boundingOfficeLike.trim(); + if (locationKindLike.trim()) params.locationKindLike = locationKindLike.trim(); + if (locationTypeLike.trim()) params.locationTypeLike = locationTypeLike.trim(); + + return params; + }; + + const offices = useQuery({ + queryKey: ["offices"], + queryFn: async () => { + const entries = await offices_api.getOffices({ + hasData: true, + }); + return [...new Set(entries.map((e) => e.name))]; + }, + retry: 1, + staleTime: 1000 * 60 * 60 * 24, + }); + + const searchResults = useInfiniteQuery({ + queryKey: [ + "location-search", + searchCount, + office, + searchText, + unitSystem, + like, + locationCategoryLike, + locationGroupLike, + boundingOfficeLike, + locationKindLike, + locationTypeLike, + ], + queryFn: ({ pageParam }) => + catalog_api.getCatalogWithDataset(buildCatalogParams(pageParam)), + initialPageParam: undefined, + getNextPageParam: (lastPage) => lastPage.nextPage || undefined, + enabled: triggerSearch && !!office && searchCount > 0, + retry: 1, + }); + + const allResults = searchResults.data?.pages.flatMap((page) => page.entries) ?? []; + const nextPage = searchResults.hasNextPage; + + const handleSearch = () => { + setTriggerSearch(true); + setSearchCount((prev) => prev + 1); + }; + + const handleLoadMore = () => { + if (!searchResults.hasNextPage || searchResults.isFetchingNextPage) return; + searchResults.fetchNextPage(); + }; + + if (offices.isLoading) return
Loading offices...
; + + return ( +
+ +
+
+ + +
+
+ + +
+
+ {office && ( + <> +
+ setLike(e.target.value)} + placeholder="Regex for location ID" + /> + setLocationCategoryLike(e.target.value)} + placeholder="Category filter" + /> + setLocationGroupLike(e.target.value)} + placeholder="Group filter" + /> + setBoundingOfficeLike(e.target.value)} + placeholder="Bounding office filter" + /> + setLocationKindLike(e.target.value)} + placeholder="Kind filter" + /> + setLocationTypeLike(e.target.value)} + placeholder="Type filter" + /> +
+
+ setSearchText(e.target.value)} + placeholder="Enter text to search in location metadata" + /> + +
+ + )} + {searchResults.isLoading && allResults.length === 0 && ( +
Loading results...
+ )} + {searchResults.isLoading && allResults.length > 0 && ( +
Refreshing results...
+ )} + {searchResults.error &&
Error: {searchResults.error.message}
} + {allResults.length > 0 && ( +
+

Search Results ({allResults.length})

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {allResults.map((loc) => ( + + + + + + + + + + + + + + + + + + + + + + + + + ))} + +
Name + Public Name + + Long Name + + Description + KindType + Time Zone + + Nearest City + + Latitude + + Longitude + + Published Latitude + + Published Longitude + + Horizontal Datum + + Elevation + Unit + Vertical Datum + + Nation + + State + + County + + Bounding Office + + Map Label + + Active +
{loc.name} + {loc.publicName} + + {loc.longName} + + {loc.description} + {loc.kind}{loc.type} + {loc.timeZone} + + {loc.nearestCity} + + {loc.latitude} + + {loc.longitude} + + {loc.publishedLatitude} + + {loc.publishedLongitude} + + {loc.horizontalDatum} + + {loc.elevation} + {loc.unit} + {loc.verticalDatum} + {loc.nation}{loc.state}{loc.county} + {loc.boundingOffice} + + {loc.mapLabel} + + {loc.active ? "Yes" : "No"} +
+ {nextPage && ( + + )} +
+
+ )} + {allResults.length === 0 && triggerSearch && !searchResults.isLoading && ( +
No locations found matching the criteria.
+ )} +
+
+ ); +} diff --git a/cda-gui/src/route-paths.js b/cda-gui/src/route-paths.js index f24a09e49..9d4f5b5d0 100644 --- a/cda-gui/src/route-paths.js +++ b/cda-gui/src/route-paths.js @@ -35,6 +35,11 @@ export const routePaths = [ path: "legacy-format", sitemapPath: "legacy-format", }, + { + id: "location-search", + path: "location-search", + sitemapPath: "location-search", + }, ]; export const sitemapPaths = routePaths.map(({ sitemapPath }) => sitemapPath); From b840dee7b8d175c4654f1fa6652353727a9dade5 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Tue, 12 May 2026 10:40:45 -0700 Subject: [PATCH 10/13] code review feedback --- .../cwms/cda/data/dao/LocationsDaoImpl.java | 19 ------------------- .../cwms/cda/helpers/DatabaseHelpers.java | 5 +++-- .../cda/api/MeasurementControllerTestIT.java | 9 +++++---- .../cda/api/TimeseriesControllerTestIT.java | 12 ++++++------ .../cda/data/dao/MeasurementDaoTestIT.java | 5 +++-- .../fixtures/CwmsDataApiSetupCallback.java | 3 ++- 6 files changed, 19 insertions(+), 34 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java index f764d9282..625cf9147 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java @@ -776,9 +776,6 @@ private Condition buildWhereCondition(CatalogRequestParameters params) { String textSearch = params.getSearchText(); if (textSearch != null && !textSearch.isBlank()) { - if(!supportsSearchDocColumn(fieldMapping)) { - throw new IllegalArgumentException("Text search is not supported yet supported"); - } Field containsScore = DSL.field( "CONTAINS({0}, {1})", Integer.class, @@ -792,22 +789,6 @@ private Condition buildWhereCondition(CatalogRequestParameters params) { return condition; } - private boolean supportsSearchDocColumn(FieldMapping mapping) { - if (HAS_SEARCH_COLUMN != null) { - return HAS_SEARCH_COLUMN; - } - - Record searchDocSupport = dsl.select(asterisk()) - .from(table("ALL_TAB_COLUMNS")) - .where(field("TABLE_NAME").eq(mapping.getTable().getName())) - .and(field("COLUMN_NAME").eq(mapping.getSearchDoc().getName())) - .and(field("OWNER").eq("CWMS_20")) - .fetchOne(); - - HAS_SEARCH_COLUMN = searchDocSupport != null; - return HAS_SEARCH_COLUMN; - } - private static Condition addCursorConditions(Condition condition, String cursorOffice, String cursorLocation, FieldMapping mapping) { if (cursorOffice != null) { diff --git a/cwms-data-api/src/main/java/cwms/cda/helpers/DatabaseHelpers.java b/cwms-data-api/src/main/java/cwms/cda/helpers/DatabaseHelpers.java index 36daa401d..b1a1cd526 100644 --- a/cwms-data-api/src/main/java/cwms/cda/helpers/DatabaseHelpers.java +++ b/cwms-data-api/src/main/java/cwms/cda/helpers/DatabaseHelpers.java @@ -3,10 +3,11 @@ public class DatabaseHelpers { + public static final int LATEST_SCHEMA = 999999; - public static enum SCHEMA_VERSION { + public enum SCHEMA_VERSION { V2025_07_01(250701, "25.07.01"), - LATEST_DEV(999999, "99.99.99"), + LATEST_DEV(LATEST_SCHEMA, "99.99.99"), BYPASS(-1, "Bypass") ; diff --git a/cwms-data-api/src/test/java/cwms/cda/api/MeasurementControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/MeasurementControllerTestIT.java index ebac3a18f..cd1622124 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/MeasurementControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/MeasurementControllerTestIT.java @@ -29,13 +29,14 @@ import cwms.cda.data.dao.DeleteRule; import cwms.cda.data.dao.MeasurementDao; import cwms.cda.data.dao.MeasurementDaoTestIT; -import static cwms.cda.data.dao.MeasurementDaoTestIT.MINIMUM_SCHEMA; import cwms.cda.data.dao.StreamDao; import cwms.cda.data.dto.CwmsId; import cwms.cda.data.dto.measurement.Measurement; import cwms.cda.data.dto.stream.Stream; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; + +import static cwms.cda.helpers.DatabaseHelpers.LATEST_SCHEMA; import static cwms.cda.security.ApiKeyIdentityProvider.AUTH_HEADER; import fixtures.CwmsDataApiSetupCallback; import fixtures.MinimumSchema; @@ -139,7 +140,7 @@ static void tearDown() { @ParameterizedTest @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) - @MinimumSchema(MINIMUM_SCHEMA) + @MinimumSchema(LATEST_SCHEMA) void test_create_retrieve_delete_measurement(String format) throws IOException { InputStream resource = this.getClass().getResourceAsStream("/cwms/cda/api/measurement.json"); assertNotNull(resource); @@ -271,7 +272,7 @@ void test_create_retrieve_delete_measurement(String format) throws IOException { @ParameterizedTest @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) - @MinimumSchema(MINIMUM_SCHEMA) + @MinimumSchema(LATEST_SCHEMA) void test_create_retrieve_delete_measurement_multiple(String format) throws IOException { InputStream resource = this.getClass().getResourceAsStream("/cwms/cda/api/measurements.json"); assertNotNull(resource); @@ -451,7 +452,7 @@ void test_create_retrieve_delete_measurement_multiple(String format) throws IOEx } @Test - @MinimumSchema(MINIMUM_SCHEMA) + @MinimumSchema(LATEST_SCHEMA) void test_delete_does_not_exist() { TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; // Delete a Measurement diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TimeseriesControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TimeseriesControllerTestIT.java index 9e69627c8..38401b370 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/TimeseriesControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeseriesControllerTestIT.java @@ -15,6 +15,7 @@ import static cwms.cda.api.Controllers.UNIT; import static cwms.cda.api.Controllers.VERSION_DATE; import static cwms.cda.data.dao.JooqDao.getDslContext; +import static cwms.cda.helpers.DatabaseHelpers.LATEST_SCHEMA; import static helpers.FloatCloseTo.floatCloseTo; import static io.restassured.RestAssured.given; import static io.restassured.config.JsonConfig.jsonConfig; @@ -68,7 +69,6 @@ @Tag("integration") final class TimeseriesControllerTestIT extends DataApiTestIT { - public static final int MINIMUM_SCHEMA = 999999; @Test void test_lrl_timeseries_psuedo_reg1hour() throws Exception { @@ -136,7 +136,7 @@ void test_lrl_timeseries_psuedo_reg1hour() throws Exception { } @Test - @MinimumSchema(MINIMUM_SCHEMA) + @MinimumSchema(LATEST_SCHEMA) void test_local_regular_new_LRTS_ID() throws Exception { ObjectMapper mapper = new ObjectMapper(); @@ -475,7 +475,7 @@ void test_lrl_1day_max_version() throws Exception { } @Test - @MinimumSchema(MINIMUM_SCHEMA) + @MinimumSchema(LATEST_SCHEMA) void test_lrl_1day_max_version_with_entry_date() throws Exception { ObjectMapper mapper = new ObjectMapper(); @@ -802,7 +802,7 @@ void test_lrl_1day_malicious_units() throws Exception { } @Test - @MinimumSchema(MINIMUM_SCHEMA) + @MinimumSchema(LATEST_SCHEMA) void test_include_data_entry_date() throws Exception { ObjectMapper mapper = new ObjectMapper(); @@ -992,7 +992,7 @@ void test_get_with_units() throws Exception { } @Test - @MinimumSchema(MINIMUM_SCHEMA) + @MinimumSchema(LATEST_SCHEMA) void test_attempt_store_with_entry_date() throws Exception { ObjectMapper mapper = new ObjectMapper(); @@ -1376,7 +1376,7 @@ void test_lrl_trim() throws Exception { } @Test - @MinimumSchema(MINIMUM_SCHEMA) + @MinimumSchema(LATEST_SCHEMA) void test_lrl_trim_with_data_entry_date() throws Exception { ObjectMapper mapper = new ObjectMapper(); diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dao/MeasurementDaoTestIT.java b/cwms-data-api/src/test/java/cwms/cda/data/dao/MeasurementDaoTestIT.java index ffd404b56..d550376c8 100644 --- a/cwms-data-api/src/test/java/cwms/cda/data/dao/MeasurementDaoTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/data/dao/MeasurementDaoTestIT.java @@ -30,6 +30,8 @@ import org.jooq.SelectConditionStep; import org.jooq.Table; import org.jooq.impl.DSL; + +import static cwms.cda.helpers.DatabaseHelpers.LATEST_SCHEMA; import static org.jooq.impl.DSL.inline; import org.junit.jupiter.api.AfterAll; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -46,7 +48,6 @@ public final class MeasurementDaoTestIT extends DataApiTestIT { private static final String OFFICE_ID = TestAccounts.KeyUser.SPK_NORMAL.getOperatingOffice(); private static final List STREAM_LOC_IDS = new ArrayList<>(); private static final List STREAMS_CREATED = new ArrayList<>(); - public static final int MINIMUM_SCHEMA = 999999; @BeforeAll public static void setup() { @@ -144,7 +145,7 @@ public static void tearDown() { } @Test - @MinimumSchema(MINIMUM_SCHEMA) + @MinimumSchema(LATEST_SCHEMA) void testRoundTripStore() throws Exception { CwmsDatabaseContainer databaseLink = CwmsDataApiSetupCallback.getDatabaseLink(); String webUser = CwmsDataApiSetupCallback.getWebUser(); diff --git a/cwms-data-api/src/test/java/fixtures/CwmsDataApiSetupCallback.java b/cwms-data-api/src/test/java/fixtures/CwmsDataApiSetupCallback.java index 7781b7ca0..782117036 100644 --- a/cwms-data-api/src/test/java/fixtures/CwmsDataApiSetupCallback.java +++ b/cwms-data-api/src/test/java/fixtures/CwmsDataApiSetupCallback.java @@ -36,6 +36,7 @@ import javax.servlet.http.HttpServletResponse; import org.testcontainers.images.PullPolicy; +import static cwms.cda.helpers.DatabaseHelpers.LATEST_SCHEMA; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.is; @@ -97,7 +98,7 @@ private static int versionInt() int ret; String tmp = schemaVersion(); if (tmp.equalsIgnoreCase("latest-dev")) { - ret = 999999; + ret = LATEST_SCHEMA; } else if (tmp.equalsIgnoreCase("Bypass")) { ret = -1; } else if(tmp.toLowerCase().endsWith("staging")) { From f5aa75655cc894c47b6bde3622a35a4a77ff9d21 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Tue, 12 May 2026 10:43:24 -0700 Subject: [PATCH 11/13] code review feedback --- .../src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java index 625cf9147..ec4be5630 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/LocationsDaoImpl.java @@ -727,7 +727,7 @@ private Catalog getLocationCatalog(Catalog.CatalogPage catPage, int pageSize, Ca } } - private Condition buildWhereCondition(CatalogRequestParameters params) { + private static Condition buildWhereCondition(CatalogRequestParameters params) { String idLike = params.getIdLike(); FieldMapping fieldMapping = null; if (params.includeAliases()) { From cb9d77b64bf7a552f83f5d299b044ea84df5d80b Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Tue, 12 May 2026 12:14:55 -0700 Subject: [PATCH 12/13] code review feedback --- .../main/java/cwms/cda/api/CatalogController.java | 2 +- .../java/cwms/cda/api/CatalogControllerTestIT.java | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java b/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java index b6d36a985..9dd4b0879 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java @@ -225,7 +225,7 @@ public void getAll(Context ctx) { + "catalog entries in the response."), @OpenApiParam(name = SEARCH_TEXT, description = "This parameter allows the user to specify a text string to " - + "search locations' metadata. The search is performed " + + "search locations' metadata. The search is case insensitive and is performed " + "against the following fields: base location ID, sub location ID, " + "combined location ID, public name, long name, description, " + "map label, nearest city, location kind, and location type. " diff --git a/cwms-data-api/src/test/java/cwms/cda/api/CatalogControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/CatalogControllerTestIT.java index 3a57d8078..566db51b6 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/CatalogControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/CatalogControllerTestIT.java @@ -24,6 +24,7 @@ import java.util.stream.Collectors; import static cwms.cda.data.dao.JsonRatingUtilsTest.loadResourceAsString; +import static cwms.cda.helpers.DatabaseHelpers.LATEST_SCHEMA; import static org.junit.jupiter.api.Assertions.*; import cwms.cda.data.dao.DeleteRule; @@ -793,7 +794,7 @@ void test_locations_unsupported_params_multiple() { assertTrue(List.of(parts).containsAll(List.of(INCLUDE_EXTENTS, EXCLUDE_EMPTY))); } - @MinimumSchema(20261231) + @MinimumSchema(LATEST_SCHEMA) @Test void test_timeseries_unsupported_search_text() { given() @@ -804,11 +805,11 @@ void test_timeseries_unsupported_search_text() { .get("/catalog/" + TIMESERIES) .then() .log().ifValidationFails(LogDetail.ALL, true) - .assertThat() + .assertThat() .statusCode(is(HttpServletResponse.SC_NOT_IMPLEMENTED)); } - @MinimumSchema(20261231) + @MinimumSchema(LATEST_SCHEMA) @Test void test_location_search_text_basic() { given() @@ -824,7 +825,7 @@ void test_location_search_text_basic() { .body("entries.name", hasItem("Pine Flat-Outflow")); } - @MinimumSchema(20261231) + @MinimumSchema(LATEST_SCHEMA) @Test void test_location_search_text_on_location_kind() { given() @@ -840,7 +841,7 @@ void test_location_search_text_on_location_kind() { .body("entries.name", hasItem("Paonia")); } - @MinimumSchema(20261231) + @MinimumSchema(LATEST_SCHEMA) @Test void test_location_search_text_combines_with_location_kind_filter() { given() @@ -860,7 +861,7 @@ void test_location_search_text_combines_with_location_kind_filter() { .body("entries[0].name", is("Flat Project")); } - @MinimumSchema(20261231) + @MinimumSchema(LATEST_SCHEMA) @Test void test_location_search_text_no_matches() { given() From 7774c459a68ae09b87f15b09d2758528f9723920 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Tue, 12 May 2026 12:19:08 -0700 Subject: [PATCH 13/13] code review feedback --- .../src/main/java/cwms/cda/api/CatalogController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java b/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java index 9dd4b0879..ca468a118 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/CatalogController.java @@ -92,6 +92,7 @@ public class CatalogController implements CrudHandler { "SOUNDEX"); private static final Pattern TOKENIZE_PATTERN = Pattern.compile("\"[^\"]+\"|\\S+"); private static final Pattern NORMALIZE_PATTERN = Pattern.compile("^[()]+|[()]+$"); + private static final Pattern ALNUM_PATTERN = Pattern.compile(".*\\p{Alnum}.*"); private final MetricRegistry metrics; @@ -432,7 +433,7 @@ private static String validateSearchText(String searchText) { + MAX_SEARCH_TEXT_LENGTH + " characters"); } - if (!trimmed.matches(".*\\p{Alnum}.*")) { + if (!ALNUM_PATTERN.matcher(trimmed).matches()) { throw new IllegalArgumentException(SEARCH_TEXT + " must contain at least one letter or digit"); }