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 (
+
+ );
+}
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);
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 3be9574ac..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
@@ -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 {
@@ -44,6 +75,24 @@ 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 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 static final Pattern ALNUM_PATTERN = Pattern.compile(".*\\p{Alnum}.*");
private final MetricRegistry metrics;
@@ -83,57 +132,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. "
@@ -142,51 +191,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 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. "
+ + "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}
)
@@ -197,103 +262,109 @@ 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);
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)
- .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)
- .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);
@@ -304,7 +375,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);
@@ -312,6 +383,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);
@@ -324,6 +418,42 @@ 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 (!ALNUM_PATTERN.matcher(trimmed).matches()) {
+ throw new IllegalArgumentException(SEARCH_TEXT + " must contain at least one letter or digit");
+ }
+
+ 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 + " search terms, excluding operators like AND, OR, and NOT");
+ }
+
+ return trimmed;
+ }
+
@OpenApi(tags = {"Catalog"}, ignore = true)
@Override
public void update(Context ctx, @NotNull String entry) {
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 04bcf6eb8..0cf6e2bfe 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 cdffb65d9..048d604d8 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
@@ -24,6 +24,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;
@@ -42,6 +43,7 @@ private CatalogRequestParameters(Builder builder) {
this.includeAliases = builder.includeAliases;
this.filterBaseLocations = builder.filterBaseLocations;
this.negateLocationKindLike = builder.negateLocationKindLike;
+ this.searchText = builder.searchText;
}
public String getBoundingOfficeLike() {
@@ -108,6 +110,10 @@ public boolean isNegateLocationKindLike() {
return negateLocationKindLike;
}
+ public String getSearchText() {
+ return searchText;
+ }
+
public static class Builder {
String office;
String idLike;
@@ -125,6 +131,7 @@ public static class Builder {
private boolean includeAliases = false;
private boolean filterBaseLocations = false;
private boolean negateLocationKindLike = false;
+ private String searchText;
public Builder() {
@@ -210,6 +217,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()
@@ -228,6 +240,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 7a783df3c..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
@@ -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;
@@ -102,7 +103,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) {
@@ -620,6 +621,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);
@@ -637,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(0, int.class);
} else {
cursorLocation = catPage.getCursorId();
cursorOffice = catPage.getCurOffice();
@@ -767,6 +774,18 @@ private static Condition buildWhereCondition(CatalogRequestParameters params) {
condition = condition.and(fieldMapping.getSubLocationId().isNotNull());
}
+ String textSearch = params.getSearchText();
+ if (textSearch != null && !textSearch.isBlank()) {
+ Field containsScore = DSL.field(
+ "CONTAINS({0}, {1})",
+ Integer.class,
+ fieldMapping.getSearchDoc(),
+ DSL.inline(textSearch)
+ );
+
+ condition = condition.and(containsScore.gt(0));
+ }
+
return condition;
}
@@ -797,6 +816,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) {
@@ -925,6 +956,8 @@ private interface FieldMapping {
boolean includesAliases();
Table getTable();
+
+ Field getSearchDoc();
}
private static class AvLoc2FieldMapping implements FieldMapping {
@@ -1082,6 +1115,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 {
@@ -1235,6 +1273,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;
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/CatalogControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/CatalogControllerTestIT.java
index 72bfc3717..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
@@ -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;
@@ -23,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;
@@ -75,6 +77,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 +793,89 @@ 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)));
}
+
+ @MinimumSchema(LATEST_SCHEMA)
+ @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));
+ }
+
+ @MinimumSchema(LATEST_SCHEMA)
+ @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"));
+ }
+
+ @MinimumSchema(LATEST_SCHEMA)
+ @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"));
+ }
+
+ @MinimumSchema(LATEST_SCHEMA)
+ @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"));
+ }
+
+ @MinimumSchema(LATEST_SCHEMA)
+ @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));
+ }
}
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")) {