From fbb10c405afb316c288e03434cb24e492581711e Mon Sep 17 00:00:00 2001 From: rma-rripken <89810919+rma-rripken@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:32:43 -0800 Subject: [PATCH 1/3] Fixing time series cascade delete issues. --- .../cda/api/TimeSeriesCategoryController.java | 31 +++++++- .../cda/api/TimeSeriesGroupController.java | 24 ++---- .../src/main/java/cwms/cda/data/dao/Dao.java | 2 +- .../cda/data/dao/TimeSeriesCategoryDao.java | 54 +++++++++++-- .../cwms/cda/data/dao/TimeSeriesGroupDao.java | 49 ++++++++++-- .../TimeSeriesCategoryControllerTestIT.java | 75 +++++++++++++++++++ 6 files changed, 201 insertions(+), 34 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesCategoryController.java b/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesCategoryController.java index 4d73ac51b..04e483f37 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesCategoryController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesCategoryController.java @@ -190,10 +190,35 @@ public void create(Context ctx) { } } - @OpenApi(ignore = true) + @OpenApi( + description = "Update existing TimeSeriesCategory. Allows for renaming of the category.", + requestBody = @OpenApiRequestBody( + content = { + @OpenApiContent(from = TimeSeriesCategory.class, type = Formats.JSON) + }, + required = true), + pathParams = { + @OpenApiParam(name = CATEGORY_ID, required = true, description = "Specifies " + + "the original timeseries category to rename.") + }, + method = HttpMethod.PATCH, + tags = {TAG} + ) @Override - public void update(@NotNull Context ctx, @NotNull String locationCode) { - ctx.status(HttpServletResponse.SC_NOT_IMPLEMENTED).json(CdaError.notImplemented()); + public void update(@NotNull Context ctx, @NotNull String categoryId) { + try (Timer.Context ignored = markAndTime(UPDATE)) { + DSLContext dsl = getDslContext(ctx); + + String formatHeader = ctx.req.getContentType(); + String body = ctx.body(); + + ContentType contentType = Formats.parseHeader(formatHeader, TimeSeriesCategory.class); + TimeSeriesCategory deserialize = Formats.parseContent(contentType, body, TimeSeriesCategory.class); + + TimeSeriesCategoryDao dao = new TimeSeriesCategoryDao(dsl); + dao.update(categoryId, deserialize); + ctx.status(HttpServletResponse.SC_OK); + } } @OpenApi( diff --git a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesGroupController.java b/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesGroupController.java index 708629088..c5265ceab 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesGroupController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesGroupController.java @@ -25,6 +25,7 @@ package cwms.cda.api; import static com.codahale.metrics.MetricRegistry.name; +import static cwms.cda.api.Controllers.CASCADE_DELETE; import static cwms.cda.api.Controllers.CATEGORY_ID; import static cwms.cda.api.Controllers.CATEGORY_OFFICE_ID; import static cwms.cda.api.Controllers.CREATE; @@ -197,22 +198,8 @@ public void getOne(@NotNull Context ctx, @NotNull String groupId) { String formatHeader = ctx.header(Header.ACCEPT); ContentType contentType = Formats.parseHeader(formatHeader, TimeSeriesGroup.class); - TimeSeriesGroup group = null; - List timeSeriesGroups = dao.getTimeSeriesGroups(tsOffice, groupOffice, categoryOffice, - categoryId, groupId); - if (timeSeriesGroups != null && !timeSeriesGroups.isEmpty()) { - if (timeSeriesGroups.size() == 1) { - group = timeSeriesGroups.get(0); - } else { - // An error. [office, categoryId, groupId] should have, at most, one match - String message = String.format( - "Multiple TimeSeriesGroups returned from getTimeSeriesGroups " - + "for:%s category:%s groupId:%s At most one match was " - + "expected. Found:%s", - groupOffice, categoryId, groupId, timeSeriesGroups); - throw new IllegalArgumentException(message); - } - } + TimeSeriesGroup group = dao.getTimeSeriesGroup(tsOffice, groupOffice, categoryOffice, categoryId, groupId); + if (group != null) { String result = Formats.format(contentType, group); @@ -327,6 +314,8 @@ public void update(@NotNull Context ctx, @NotNull String oldGroupId) { + "time series category of the time series group to be deleted"), @OpenApiParam(name = OFFICE, required = true, description = "Specifies the " + "owning office of the time series group to be deleted"), + @OpenApiParam(name = CASCADE_DELETE, type = Boolean.class, + description = "Specifies whether to unassign time series in this group before deleting. Default: false"), }, method = HttpMethod.DELETE, tags = {TAG} @@ -337,9 +326,10 @@ public void delete(@NotNull Context ctx, @NotNull String groupId) { DSLContext dsl = getDslContext(ctx); TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dsl); + boolean cascadeDelete = ctx.queryParamAsClass(CASCADE_DELETE, Boolean.class).getOrDefault(false); String office = ctx.queryParam(OFFICE); String categoryId = ctx.queryParam(CATEGORY_ID); - dao.delete(categoryId, groupId, office); + dao.delete(categoryId, groupId, office, cascadeDelete); ctx.status(HttpServletResponse.SC_NO_CONTENT); } } diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/Dao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/Dao.java index 64de424ee..80dc72307 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/Dao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/Dao.java @@ -31,7 +31,6 @@ import com.google.common.flogger.FluentLogger; import cwms.cda.data.dto.CwmsDTO; import java.sql.Connection; -import java.sql.SQLException; import java.util.Optional; import java.util.concurrent.TimeUnit; import org.jooq.DSLContext; @@ -43,6 +42,7 @@ public abstract class Dao { public static final int CWMS_18_1_8 = 180108; public static final int CWMS_21_1_1 = 210101; public static final int CWMS_23_03_16 = 230316; + public static final int CWMS_25_07_01 = 250701; public static final String PROP_BASE = "cwms.cda.data.dao.dao"; public static final String VERSION_NAME = "version"; diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java index 20bd27b84..33648e350 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java @@ -27,10 +27,9 @@ import cwms.cda.data.dto.TimeSeriesCategory; import java.util.List; import java.util.Optional; -import org.jooq.DSLContext; -import org.jooq.Record3; -import org.jooq.Select; -import org.jooq.SelectWhereStep; + +import cwms.cda.data.dto.TimeSeriesGroup; +import org.jooq.*; import usace.cwms.db.jooq.codegen.packages.CWMS_TS_PACKAGE; import usace.cwms.db.jooq.codegen.tables.AV_TS_CAT_GRP; @@ -76,13 +75,43 @@ public List getTimeSeriesCategories() { } public void delete(String categoryId, boolean cascadeDelete, String office) { + + if(cascadeDelete){ + cascadeDelete(categoryId, office); + } else { + connection(dsl, conn -> { + DSLContext dslContext = getDslContext(conn, office); + CWMS_TS_PACKAGE.call_DELETE_TS_CATEGORY(dslContext.configuration(), categoryId, formatBool(false), office); + }); + } + } + + private void cascadeDelete(String categoryId, String office) { connection(dsl, conn -> { DSLContext dslContext = getDslContext(conn, office); - CWMS_TS_PACKAGE.call_DELETE_TS_CATEGORY( - dslContext.configuration(), categoryId, - formatBool(cascadeDelete), office); - }); + if (getDbVersion() > Dao.CWMS_25_07_01) { + // With newer schema it should just work, don't need transaction + Configuration config = dslContext.configuration(); + CWMS_TS_PACKAGE.call_DELETE_TS_CATEGORY(config, categoryId, formatBool(true), office); + } else { + // Before 2/3/2026 DELETE_TS_CATEGORY wasn't removing assignments from groups so we start a transaction and do the deletes + + dslContext.transaction((Configuration trx) -> { + Configuration config = trx.dsl().configuration(); + + TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dslContext); + List timeSeriesGroups = dao.getTimeSeriesGroups(null, null, office, false, categoryId, null); + for (TimeSeriesGroup group : timeSeriesGroups) { + dao.delete(categoryId, group.getId(), office, true); + } + + // Before 2/3/2026 DELETE_TS_CATEGORY wasn't removing assignments from groups + CWMS_TS_PACKAGE.call_DELETE_TS_CATEGORY(config, categoryId, formatBool(true), office); + }); + } + + }); } public void create(TimeSeriesCategory category, boolean failIfExists) { @@ -95,4 +124,13 @@ public void create(TimeSeriesCategory category, boolean failIfExists) { formatBool(failIfExists), "T", office); }); } + + public void update(String oldCategoryId, TimeSeriesCategory category) { + String office = category.getOfficeId(); + connection(dsl, conn -> { + DSLContext dslContext = getDslContext(conn, office); + CWMS_TS_PACKAGE.call_RENAME_TS_CATEGORY(dslContext.configuration(), oldCategoryId, category.getId(), office); + CWMS_TS_PACKAGE.call_STORE_TS_CATEGORY(dslContext.configuration(), category.getId(), category.getDescription(), "F", "T", office); + }); + } } diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java index 45598cd39..bfb6a6af4 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java @@ -98,6 +98,20 @@ public List getTimeSeriesGroups(String tsOfficeId, String group } + public TimeSeriesGroup getTimeSeriesGroup(String tsOfficeId, String groupOfficeId, String categoryOfficeId, + String categoryId, String groupId) { + List timeSeriesGroups = getTimeSeriesGroups(tsOfficeId, groupOfficeId, categoryOfficeId, categoryId, groupId); + if(timeSeriesGroups != null && !timeSeriesGroups.isEmpty()){ + if(timeSeriesGroups.size() == 1){ + return timeSeriesGroups.get(0); + } else { + throw new IllegalArgumentException("Multiple TimeSeriesGroups returned from getTimeSeriesGroups " + + "for office:" + tsOfficeId + " category:" + categoryId + " group:" + groupId + " At most one match was " + + "expected."); + } + } + return null; + } public List getTimeSeriesGroups(String tsOfficeId, String groupOfficeId, String categoryOfficeId, String categoryId, String groupId) { @@ -249,13 +263,38 @@ private Condition buildWhereCondition(String categoryId, String groupId) { return whereCondition; } + public void delete(String categoryId, String groupId, String office, boolean cascade) { + + boolean databaseHasCascadeFlag = getDbVersion() > Dao.CWMS_25_07_01; + + connection(dsl, conn -> { + if (databaseHasCascadeFlag) { + /* Normally we'd just call via jooq's CWMS_TS_PACKAGE.call_DELETE_TS_GROUP but the + * version that takes "cascade" is new and not in the codegen. + */ + DSLContext dslContext = getDslContext(conn, office); + dslContext.query("begin cwms_ts.delete_ts_group(p_category_id => ?, p_group_id => ?, p_cascade => ?, p_office_id => ?); end;", + categoryId, groupId, formatBool(cascade), office).execute(); + } else { + DSLContext dslContext = getDslContext(conn, office); + dslContext.transaction((Configuration trx) -> { + Configuration config = trx.dsl().configuration(); + if (cascade) { + TimeSeriesGroup group = getTimeSeriesGroup(null, office, null, categoryId, groupId); + if (group != null) { + unassignAllTs(group, office); + } + } + CWMS_TS_PACKAGE.call_DELETE_TS_GROUP( + config, categoryId, groupId, office); + }); + } + } + ); + } public void delete(String categoryId, String groupId, String office) { - connection(dsl, c -> - CWMS_TS_PACKAGE.call_DELETE_TS_GROUP( - getDslContext(c,office).configuration(), categoryId, groupId, office - ) - ); + delete(categoryId, groupId, office, false); } public void create(TimeSeriesGroup group, boolean failIfExists) { diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesCategoryControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesCategoryControllerTestIT.java index b5e28b6cd..ae9578414 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesCategoryControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesCategoryControllerTestIT.java @@ -51,6 +51,81 @@ class TimeSeriesCategoryControllerTestIT extends DataApiTestIT TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; TestAccounts.KeyUser user2 = TestAccounts.KeyUser.SWT_NORMAL; + @ParameterizedTest + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) + void test_create_update_delete(String format) { + String officeId = user.getOperatingOffice(); + String originalId = "test_create_update_delete"; + String updatedId = "test_updated_id"; + TimeSeriesCategory cat = new TimeSeriesCategory(officeId, originalId, "IntegrationTesting"); + ContentType contentType = Formats.parseHeader(Formats.JSON, TimeSeriesCategory.class); + String json = Formats.format(contentType, cat); + + // Create Category + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .body(json) + .header("Authorization", user.toHeaderValue()) + .when() + .post("/timeseries/category") + .then() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // Update Category (Rename and change description) + TimeSeriesCategory updatedCat = new TimeSeriesCategory(officeId, updatedId, "UpdatedDescription"); + String updatedJson = Formats.format(contentType, updatedCat); + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .body(updatedJson) + .header("Authorization", user.toHeaderValue()) + .when() + .patch("/timeseries/category/" + originalId) + .then() + .statusCode(is(HttpServletResponse.SC_OK)); + + // Read and verify update + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .queryParam(OFFICE, officeId) + .when() + .get("/timeseries/category/" + updatedId) + .then() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("office-id", equalTo(updatedCat.getOfficeId())) + .body("id", equalTo(updatedCat.getId())) + .body("description", equalTo(updatedCat.getDescription())); + + // Verify old ID is gone + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .queryParam(OFFICE, officeId) + .when() + .get("/timeseries/category/" + originalId) + .then() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)); + + // Delete Updated Category + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, officeId) + .queryParam(CASCADE_DELETE, "true") + .when() + .delete("/timeseries/category/" + updatedId) + .then() + .statusCode(is(HttpServletResponse.SC_NO_CONTENT)); + } + @ParameterizedTest @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) void test_create_read_delete(String format) { From 3458114b8ba20568efe430adbf6e345907f36f6f Mon Sep 17 00:00:00 2001 From: rma-rripken <89810919+rma-rripken@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:34:55 -0800 Subject: [PATCH 2/3] Fixed formatting --- .../cwms/cda/data/dao/TimeSeriesGroupDao.java | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java index bfb6a6af4..c841bc96b 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java @@ -268,29 +268,26 @@ public void delete(String categoryId, String groupId, String office, boolean cas boolean databaseHasCascadeFlag = getDbVersion() > Dao.CWMS_25_07_01; connection(dsl, conn -> { - if (databaseHasCascadeFlag) { - /* Normally we'd just call via jooq's CWMS_TS_PACKAGE.call_DELETE_TS_GROUP but the - * version that takes "cascade" is new and not in the codegen. - */ - DSLContext dslContext = getDslContext(conn, office); - dslContext.query("begin cwms_ts.delete_ts_group(p_category_id => ?, p_group_id => ?, p_cascade => ?, p_office_id => ?); end;", - categoryId, groupId, formatBool(cascade), office).execute(); - } else { - DSLContext dslContext = getDslContext(conn, office); - dslContext.transaction((Configuration trx) -> { - Configuration config = trx.dsl().configuration(); - if (cascade) { - TimeSeriesGroup group = getTimeSeriesGroup(null, office, null, categoryId, groupId); - if (group != null) { - unassignAllTs(group, office); - } - } - CWMS_TS_PACKAGE.call_DELETE_TS_GROUP( - config, categoryId, groupId, office); - }); + if (databaseHasCascadeFlag) { + /* Normally we'd just call via jooq's CWMS_TS_PACKAGE.call_DELETE_TS_GROUP but the + * version that takes "cascade" is new and not in the codegen. + */ + DSLContext dslContext = getDslContext(conn, office); + dslContext.query("begin cwms_ts.delete_ts_group(p_category_id => ?, p_group_id => ?, p_cascade => ?, p_office_id => ?); end;", categoryId, groupId, formatBool(cascade), office).execute(); + } else { + DSLContext dslContext = getDslContext(conn, office); + dslContext.transaction((Configuration trx) -> { + Configuration config = trx.dsl().configuration(); + if (cascade) { + TimeSeriesGroup group = getTimeSeriesGroup(null, office, null, categoryId, groupId); + if (group != null) { + unassignAllTs(group, office); + } } - } - ); + CWMS_TS_PACKAGE.call_DELETE_TS_GROUP(config, categoryId, groupId, office); + }); + } + }); } public void delete(String categoryId, String groupId, String office) { From d43efc3a0a4fd0e3f66ec43244037e5c3998b663 Mon Sep 17 00:00:00 2001 From: rma-rripken <89810919+rma-rripken@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:11:16 -0800 Subject: [PATCH 3/3] Adding ignore-nulls parameter to controller --- .../cda/api/TimeSeriesCategoryController.java | 14 +-- .../cda/data/dao/TimeSeriesCategoryDao.java | 10 +-- .../TimeSeriesCategoryControllerTestIT.java | 90 +++++++++++++++++++ 3 files changed, 104 insertions(+), 10 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesCategoryController.java b/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesCategoryController.java index 04e483f37..a1b980072 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesCategoryController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesCategoryController.java @@ -154,9 +154,7 @@ public void getOne(Context ctx, @NotNull String categoryId) { logger.atInfo().log("%s%nfor request %s", re, ctx.fullUrl()); ctx.status(HttpServletResponse.SC_NOT_FOUND).json(re); } - } - } @OpenApi( @@ -169,6 +167,8 @@ public void getOne(Context ctx, @NotNull String categoryId) { queryParams = { @OpenApiParam(name = FAIL_IF_EXISTS, type = Boolean.class, description = "Create will fail if provided ID already exists. Default: true"), + @OpenApiParam(name = IGNORE_NULLS, type = Boolean.class, + description = "Ignore null values in the request body. Default: true") }, method = HttpMethod.POST, tags = {TAG} @@ -184,8 +184,9 @@ public void create(Context ctx) { ContentType contentType = Formats.parseHeader(formatHeader, TimeSeriesCategory.class); TimeSeriesCategory deserialize = Formats.parseContent(contentType, body, TimeSeriesCategory.class); boolean failIfExists = ctx.queryParamAsClass(FAIL_IF_EXISTS, Boolean.class).getOrDefault(true); + boolean ignoreNulls = ctx.queryParamAsClass(IGNORE_NULLS, Boolean.class).getOrDefault(true); TimeSeriesCategoryDao dao = new TimeSeriesCategoryDao(dsl); - dao.create(deserialize, failIfExists); + dao.create(deserialize, failIfExists, ignoreNulls); ctx.status(HttpServletResponse.SC_CREATED); } } @@ -199,7 +200,9 @@ public void create(Context ctx) { required = true), pathParams = { @OpenApiParam(name = CATEGORY_ID, required = true, description = "Specifies " - + "the original timeseries category to rename.") + + "the original timeseries category to rename."), + @OpenApiParam(name = IGNORE_NULLS, type = Boolean.class, + description = "Ignore null values in the request body. Default: true") }, method = HttpMethod.PATCH, tags = {TAG} @@ -212,11 +215,12 @@ public void update(@NotNull Context ctx, @NotNull String categoryId) { String formatHeader = ctx.req.getContentType(); String body = ctx.body(); + boolean ignoreNulls = ctx.queryParamAsClass(IGNORE_NULLS, Boolean.class).getOrDefault(true); ContentType contentType = Formats.parseHeader(formatHeader, TimeSeriesCategory.class); TimeSeriesCategory deserialize = Formats.parseContent(contentType, body, TimeSeriesCategory.class); TimeSeriesCategoryDao dao = new TimeSeriesCategoryDao(dsl); - dao.update(categoryId, deserialize); + dao.update(categoryId, deserialize, ignoreNulls ); ctx.status(HttpServletResponse.SC_OK); } } diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java index 33648e350..8be3614e6 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java @@ -114,23 +114,23 @@ private void cascadeDelete(String categoryId, String office) { }); } - public void create(TimeSeriesCategory category, boolean failIfExists) { + public void create(TimeSeriesCategory category, boolean failIfExists, boolean ignoreNulls) { String office = category.getOfficeId(); connection(dsl, conn -> { DSLContext dslContext = getDslContext(conn, office); CWMS_TS_PACKAGE.call_STORE_TS_CATEGORY( - dslContext.configuration(), category.getId(), category.getDescription(), - formatBool(failIfExists), "T", office); + dslContext.configuration(), category.getId(), category.getDescription(), + formatBool(failIfExists), formatBool(ignoreNulls), office); }); } - public void update(String oldCategoryId, TimeSeriesCategory category) { + public void update(String oldCategoryId, TimeSeriesCategory category, boolean ignoreNulls) { String office = category.getOfficeId(); connection(dsl, conn -> { DSLContext dslContext = getDslContext(conn, office); CWMS_TS_PACKAGE.call_RENAME_TS_CATEGORY(dslContext.configuration(), oldCategoryId, category.getId(), office); - CWMS_TS_PACKAGE.call_STORE_TS_CATEGORY(dslContext.configuration(), category.getId(), category.getDescription(), "F", "T", office); + CWMS_TS_PACKAGE.call_STORE_TS_CATEGORY(dslContext.configuration(), category.getId(), category.getDescription(), "F", formatBool(ignoreNulls), office); }); } } diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesCategoryControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesCategoryControllerTestIT.java index ae9578414..789bb2a9c 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesCategoryControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesCategoryControllerTestIT.java @@ -40,6 +40,7 @@ import org.junit.jupiter.params.provider.ValueSource; import static cwms.cda.api.Controllers.CASCADE_DELETE; +import static cwms.cda.api.Controllers.IGNORE_NULLS; import static cwms.cda.api.Controllers.OFFICE; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.equalTo; @@ -448,4 +449,93 @@ void test_create_read_delete_same_category_different_office(String format) throw .assertThat() .statusCode(is(HttpServletResponse.SC_NOT_FOUND)); } + + @ParameterizedTest + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) + void test_create_update_ignore_nulls(String format) { + String officeId = user.getOperatingOffice(); + String catId = "test_ignore_nulls"; + TimeSeriesCategory cat = new TimeSeriesCategory(officeId, catId, "InitialDescription"); + ContentType contentType = Formats.parseHeader(Formats.JSON, TimeSeriesCategory.class); + String json = Formats.format(contentType, cat); + + // Create Category with ignore-nulls=false + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .queryParam(IGNORE_NULLS, "false") + .body(json) + .header("Authorization", user.toHeaderValue()) + .when() + .post("/timeseries/category") + .then() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // Update Category with ignore-nulls=true (default) and partial data + // We use a JSON string to send nulls directly + + String partialUpdateJson = "{\"office-id\":\"" + officeId + "\", \"id\":\"" + catId + "\", \"description\":null}"; + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .queryParam(IGNORE_NULLS, "true") + .body(partialUpdateJson) + .header("Authorization", user.toHeaderValue()) + .when() + .patch("/timeseries/category/" + catId) + .then() + .statusCode(is(HttpServletResponse.SC_OK)); + + // Verify description was NOT updated to null because ignore-nulls=true + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .queryParam(OFFICE, officeId) + .when() + .get("/timeseries/category/" + catId) + .then() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("description", equalTo("InitialDescription")); + + // Update Category with ignore-nulls=false and null description + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .queryParam(IGNORE_NULLS, "false") + .body(partialUpdateJson) + .header("Authorization", user.toHeaderValue()) + .when() + .patch("/timeseries/category/" + catId) + .then() + .statusCode(is(HttpServletResponse.SC_OK)); + + // Verify description WAS updated to null + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .queryParam(OFFICE, officeId) + .when() + .get("/timeseries/category/" + catId) + .then() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("description", is(org.hamcrest.Matchers.anyOf(equalTo(""), org.hamcrest.Matchers.nullValue()))); + + // Delete Category + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, officeId) + .queryParam(CASCADE_DELETE, "true") + .when() + .delete("/timeseries/category/" + catId) + .then() + .statusCode(is(HttpServletResponse.SC_NO_CONTENT)); + } }