diff --git a/backend/src/main/java/com/redhat/cloud/notifications/config/BackendConfig.java b/backend/src/main/java/com/redhat/cloud/notifications/config/BackendConfig.java index c86bd3c416..c1819fce6c 100644 --- a/backend/src/main/java/com/redhat/cloud/notifications/config/BackendConfig.java +++ b/backend/src/main/java/com/redhat/cloud/notifications/config/BackendConfig.java @@ -51,6 +51,7 @@ public class BackendConfig { private String sourcesOidcAuthToggle; private String toggleUseBetaTemplatesEnabled; private String showHiddenEventTypesToggle; + private String normalizedQueriesToggle; @ConfigProperty(name = UNLEASH, defaultValue = "false") @Deprecated(forRemoval = true, since = "To be removed when we're done migrating to Unleash in all environments") @@ -130,6 +131,7 @@ void postConstruct() { sourcesOidcAuthToggle = toggleRegistry.register("sources-oidc-auth", true); toggleUseBetaTemplatesEnabled = toggleRegistry.register("use-beta-templates", true); showHiddenEventTypesToggle = toggleRegistry.register("show-hidden-event-types", true); + normalizedQueriesToggle = toggleRegistry.register("normalized-queries", true); } void logConfigAtStartup(@Observes Startup event) { @@ -318,4 +320,12 @@ public boolean isShowHiddenEventTypes(String orgId) { return false; } } + + public boolean isNormalizedQueriesEnabled(String orgId) { + if (unleashEnabled) { + return unleash.isEnabled(normalizedQueriesToggle, buildUnleashContextWithOrgId(orgId), false); + } else { + return false; + } + } } diff --git a/backend/src/main/java/com/redhat/cloud/notifications/db/repositories/DrawerNotificationRepository.java b/backend/src/main/java/com/redhat/cloud/notifications/db/repositories/DrawerNotificationRepository.java index c37bd1be82..7f951d368c 100644 --- a/backend/src/main/java/com/redhat/cloud/notifications/db/repositories/DrawerNotificationRepository.java +++ b/backend/src/main/java/com/redhat/cloud/notifications/db/repositories/DrawerNotificationRepository.java @@ -1,5 +1,6 @@ package com.redhat.cloud.notifications.db.repositories; +import com.redhat.cloud.notifications.config.BackendConfig; import com.redhat.cloud.notifications.db.Query; import com.redhat.cloud.notifications.db.Sort; import com.redhat.cloud.notifications.models.DrawerEntryPayload; @@ -23,6 +24,9 @@ public class DrawerNotificationRepository { @Inject EntityManager entityManager; + @Inject + BackendConfig backendConfig; + @Transactional public Integer updateReadStatus(String orgId, String username, Set notificationIds, Boolean readStatus) { @@ -40,13 +44,32 @@ public Integer updateReadStatus(String orgId, String username, Set notific public List getNotifications(String orgId, String username, Set bundleIds, Set appIds, Set eventTypeIds, LocalDateTime startDate, LocalDateTime endDate, Boolean readStatus, Query query) { - Optional sort = Sort.getSort(query, "created:DESC", DrawerNotification.SORT_FIELDS); - - String hql = "SELECT dn.id.eventId, dn.read, " + - "dn.event.bundleDisplayName, dn.event.applicationDisplayName, dn.event.eventTypeDisplayName, dn.created, dn.event.renderedDrawerNotification, bundle.name, dn.event.severity " - + "FROM DrawerNotification dn join Bundle bundle on dn.event.bundleId = bundle.id where dn.id.orgId = :orgId and dn.id.userId = :userid"; + boolean useNormalized = backendConfig.isNormalizedQueriesEnabled(orgId); + Optional sort = Sort.getSort(query, "created:DESC", DrawerNotification.getSortFields(useNormalized)); + + // Calculate once for reuse + boolean bundlesNotEmpty = bundleIds != null && !bundleIds.isEmpty(); + boolean applicationsNotEmpty = appIds != null && !appIds.isEmpty(); + boolean eventTypesNotEmpty = eventTypeIds != null && !eventTypeIds.isEmpty(); + + String hql; + if (useNormalized) { + hql = "SELECT dn.id.eventId, dn.read, " + + "bundle.displayName, app.displayName, et.displayName, dn.created, dn.event.renderedDrawerNotification, bundle.name, dn.event.severity " + + "FROM DrawerNotification dn " + + "JOIN dn.event e " + + "JOIN e.eventType et " + + "JOIN et.application app " + + "JOIN app.bundle bundle " + + "WHERE dn.id.orgId = :orgId AND dn.id.userId = :userid"; + } else { + hql = "SELECT dn.id.eventId, dn.read, " + + "dn.event.bundleDisplayName, dn.event.applicationDisplayName, dn.event.eventTypeDisplayName, dn.created, dn.event.renderedDrawerNotification, bundle.name, dn.event.severity " + + "FROM DrawerNotification dn JOIN Bundle bundle ON dn.event.bundleId = bundle.id " + + "WHERE dn.id.orgId = :orgId AND dn.id.userId = :userid"; + } - hql = addHqlConditions(hql, bundleIds, appIds, eventTypeIds, startDate, endDate, readStatus); + hql = addHqlConditions(hql, useNormalized, bundlesNotEmpty, applicationsNotEmpty, eventTypesNotEmpty, startDate, endDate, readStatus); if (sort.isPresent()) { hql += getOrderBy(sort.get()); } @@ -65,10 +88,32 @@ public List getNotifications(String orgId, String username, public Long count(String orgId, String username, Set bundleIds, Set appIds, Set eventTypeIds, LocalDateTime startDate, LocalDateTime endDate, Boolean readStatus) { - String hql = "SELECT count(dn.id.userId) FROM DrawerNotification dn " - + "where dn.id.orgId = :orgId and dn.id.userId = :userid"; + boolean useNormalized = backendConfig.isNormalizedQueriesEnabled(orgId); - hql = addHqlConditions(hql, bundleIds, appIds, eventTypeIds, startDate, endDate, readStatus); + // Calculate once for reuse + boolean bundlesNotEmpty = bundleIds != null && !bundleIds.isEmpty(); + boolean applicationsNotEmpty = appIds != null && !appIds.isEmpty(); + boolean eventTypesNotEmpty = eventTypeIds != null && !eventTypeIds.isEmpty(); + + String hql = "SELECT count(dn.id.userId) FROM DrawerNotification dn "; + + // Add selective JOINs for normalized approach - only join what we need + if (useNormalized && (bundlesNotEmpty || applicationsNotEmpty || eventTypesNotEmpty)) { + hql += "JOIN dn.event e "; + hql += "JOIN e.eventType et "; + + if (bundlesNotEmpty || applicationsNotEmpty) { + hql += "JOIN et.application app "; + } + + if (bundlesNotEmpty) { + hql += "JOIN app.bundle bundle "; + } + } + + hql += "WHERE dn.id.orgId = :orgId AND dn.id.userId = :userid"; + + hql = addHqlConditions(hql, useNormalized, bundlesNotEmpty, applicationsNotEmpty, eventTypesNotEmpty, startDate, endDate, readStatus); TypedQuery typedQuery = entityManager.createQuery(hql, Long.class); setQueryParams(typedQuery, orgId, username, bundleIds, appIds, eventTypeIds, startDate, endDate, readStatus); @@ -76,7 +121,8 @@ public Long count(String orgId, String username, Set bundleIds, Set return typedQuery.getSingleResult(); } - private static String addHqlConditions(String hql, Set bundleIds, Set appIds, Set eventTypeIds, + private static String addHqlConditions(String hql, boolean useNormalized, + boolean bundlesNotEmpty, boolean applicationsNotEmpty, boolean eventTypesNotEmpty, LocalDateTime startDate, LocalDateTime endDate, Boolean readStatus) { if (startDate != null && endDate != null) { @@ -91,23 +137,28 @@ private static String addHqlConditions(String hql, Set bundleIds, Set getEventsWithCriterion(String orgId, Set bundleIds, Set appIds, String eventTypeDisplayName, LocalDate startDate, LocalDate endDate, Set endpointTypes, Set compositeEndpointTypes, Set invocationResults, Set status) { - String hql = "FROM Event e WHERE e.orgId = :orgId"; + boolean useNormalized = backendConfig.isNormalizedQueriesEnabled(orgId); + boolean bundlesNotEmpty = bundleIds != null && !bundleIds.isEmpty(); + boolean applicationsNotEmpty = appIds != null && !appIds.isEmpty(); + boolean eventTypeNameNotEmpty = eventTypeDisplayName != null; + + String hql = "FROM Event e "; + + // Add selective JOINs for normalized approach - only join what we need + if (useNormalized) { + hql += "JOIN e.eventType et JOIN et.application app JOIN app.bundle bundle "; + } + + hql += "WHERE e.orgId = :orgId"; - hql = addHqlConditions(hql, bundleIds, appIds, eventTypeDisplayName, startDate, endDate, endpointTypes, compositeEndpointTypes, invocationResults, status, null, Optional.empty(), true); + hql = addHqlConditions(hql, useNormalized, bundlesNotEmpty, applicationsNotEmpty, eventTypeNameNotEmpty, startDate, endDate, endpointTypes, compositeEndpointTypes, invocationResults, status, null, Optional.empty(), true); // we are looking for events with auth criterion only hql += " AND e.hasAuthorizationCriterion is true"; @@ -47,6 +64,16 @@ public List getEventsWithCriterion(String orgId, Se setQueryParams(typedQuery, orgId, bundleIds, appIds, eventTypeDisplayName, startDate, endDate, endpointTypes, compositeEndpointTypes, invocationResults, status, null, Optional.empty()); List eventsWithAuthorizationCriterion = typedQuery.getResultList(); + + // Populate denormalized display name fields from joined entities + if (useNormalized && !eventsWithAuthorizationCriterion.isEmpty()) { + for (Event event : eventsWithAuthorizationCriterion) { + event.setBundleDisplayName(event.getEventType().getApplication().getBundle().getDisplayName()); + event.setApplicationDisplayName(event.getEventType().getApplication().getDisplayName()); + event.setEventTypeDisplayName(event.getEventType().getDisplayName()); + } + } + List eventAuthorizationCriterion = new ArrayList<>(); for (Event event : eventsWithAuthorizationCriterion) { eventAuthorizationCriterion.add(new EventAuthorizationCriterion(event.getId(), recipientsAuthorizationCriterionExtractor.extract(event))); @@ -59,36 +86,90 @@ public List getEvents(String orgId, Set bundleIds, Set appIds Set invocationResults, boolean fetchNotificationHistory, Set status, Set severities, Query query, Optional> uuidToExclude, boolean includeEventsWithAuthCriterion) { - Optional sort = Sort.getSort(query, "created:DESC", Event.SORT_FIELDS); + boolean useNormalized = backendConfig.isNormalizedQueriesEnabled(orgId); + Optional sort = Sort.getSort(query, "created:DESC", Event.getSortFields(useNormalized)); - List eventIds = getEventIds(orgId, bundleIds, appIds, eventTypeDisplayName, startDate, endDate, endpointTypes, compositeEndpointTypes, invocationResults, status, severities, query, uuidToExclude, includeEventsWithAuthCriterion); + List eventIds = getEventIds(orgId, useNormalized, bundleIds, appIds, eventTypeDisplayName, startDate, endDate, endpointTypes, compositeEndpointTypes, invocationResults, status, severities, query, uuidToExclude, includeEventsWithAuthCriterion); if (eventIds.isEmpty()) { return new ArrayList<>(); } String hql; - if (fetchNotificationHistory) { - hql = "SELECT DISTINCT e FROM Event e LEFT JOIN FETCH e.historyEntries he WHERE e.id IN (:eventIds)"; + if (useNormalized) { + String joinClause = "JOIN e.eventType et JOIN et.application app JOIN app.bundle bundle "; + + if (fetchNotificationHistory) { + // Remove DISTINCT to allow ORDER BY with joined columns + hql = "SELECT e FROM Event e " + joinClause + "LEFT JOIN FETCH e.historyEntries he WHERE e.id IN (:eventIds)"; + } else { + hql = "FROM Event e " + joinClause + "WHERE e.id IN (:eventIds)"; + } + } else { - hql = "FROM Event e WHERE e.id IN (:eventIds)"; + if (fetchNotificationHistory) { + hql = "SELECT DISTINCT e FROM Event e LEFT JOIN FETCH e.historyEntries he WHERE e.id IN (:eventIds)"; + } else { + hql = "FROM Event e WHERE e.id IN (:eventIds)"; + } } if (sort.isPresent()) { hql += getOrderBy(sort.get()); } - return entityManager.createQuery(hql, Event.class) + List events = entityManager.createQuery(hql, Event.class) .setParameter("eventIds", eventIds) .getResultList(); + + if (useNormalized && !events.isEmpty()) { + // LEFT JOIN FETCH on one-to-many can create duplicate Event objects + // Only deduplicate for normalized queries (denormalized already has SQL DISTINCT) + if (fetchNotificationHistory) { + events = events.stream() + .distinct() + .collect(Collectors.toList()); + } + + // Populate denormalized display name fields from joined entities + for (Event event : events) { + event.setBundleDisplayName(event.getEventType().getApplication().getBundle().getDisplayName()); + event.setApplicationDisplayName(event.getEventType().getApplication().getDisplayName()); + event.setEventTypeDisplayName(event.getEventType().getDisplayName()); + } + } + + return events; } public Long count(String orgId, Set bundleIds, Set appIds, String eventTypeDisplayName, LocalDate startDate, LocalDate endDate, Set endpointTypes, Set compositeEndpointTypes, Set invocationResults, Set status, Set severities, Optional> uuidToExclude, Boolean includeEventsWithAuthCriterion) { - String hql = "SELECT COUNT(*) FROM Event e WHERE e.orgId = :orgId"; + boolean useNormalized = backendConfig.isNormalizedQueriesEnabled(orgId); + + // Calculate once for reuse + boolean bundlesNotEmpty = bundleIds != null && !bundleIds.isEmpty(); + boolean applicationsNotEmpty = appIds != null && !appIds.isEmpty(); + boolean eventTypeNameNotEmpty = eventTypeDisplayName != null; + + String hql = "SELECT COUNT(*) FROM Event e "; - hql = addHqlConditions(hql, bundleIds, appIds, eventTypeDisplayName, startDate, endDate, endpointTypes, compositeEndpointTypes, invocationResults, status, severities, uuidToExclude, includeEventsWithAuthCriterion); + // Add selective JOINs for normalized approach - only join what we need + if (useNormalized && (bundlesNotEmpty || applicationsNotEmpty || eventTypeNameNotEmpty)) { + hql += "JOIN e.eventType et "; + + if (bundlesNotEmpty || applicationsNotEmpty) { + hql += "JOIN et.application app "; + } + + if (bundlesNotEmpty) { + hql += "JOIN app.bundle bundle "; + } + } + + hql += "WHERE e.orgId = :orgId"; + + hql = addHqlConditions(hql, useNormalized, bundlesNotEmpty, applicationsNotEmpty, eventTypeNameNotEmpty, startDate, endDate, endpointTypes, compositeEndpointTypes, invocationResults, status, severities, uuidToExclude, includeEventsWithAuthCriterion); TypedQuery query = entityManager.createQuery(hql, Long.class); setQueryParams(query, orgId, bundleIds, appIds, eventTypeDisplayName, startDate, endDate, endpointTypes, compositeEndpointTypes, invocationResults, status, severities, uuidToExclude); @@ -104,13 +185,39 @@ private String getOrderBy(Sort sort) { } } - private List getEventIds(String orgId, Set bundleIds, Set appIds, String eventTypeDisplayName, + private List getEventIds(String orgId, boolean useNormalized, Set bundleIds, Set appIds, String eventTypeDisplayName, LocalDate startDate, LocalDate endDate, Set endpointTypes, Set compositeEndpointTypes, Set invocationResults, Set status, Set severities, Query query, Optional> uuidToExclude, boolean includeEventsWithAuthCriterion) { - String hql = "SELECT e.id FROM Event e WHERE e.orgId = :orgId"; + boolean bundlesNotEmpty = bundleIds != null && !bundleIds.isEmpty(); + boolean applicationsNotEmpty = appIds != null && !appIds.isEmpty(); + boolean eventTypeNameNotEmpty = eventTypeDisplayName != null; + Optional sort = Sort.getSort(query, "created:DESC", Event.getSortFields(useNormalized)); + + String hql = "SELECT e.id FROM Event e "; + + if (useNormalized) { + // Determine which JOINs are needed based on filters and sort + String sortColumn = sort.isPresent() ? sort.get().getSortColumn() : ""; - hql = addHqlConditions(hql, bundleIds, appIds, eventTypeDisplayName, startDate, endDate, endpointTypes, compositeEndpointTypes, invocationResults, status, severities, uuidToExclude, includeEventsWithAuthCriterion); - Optional sort = Sort.getSort(query, "created:DESC", Event.SORT_FIELDS); + boolean needsBundle = bundlesNotEmpty || sortColumn.startsWith("bundle."); + boolean needsApp = applicationsNotEmpty || needsBundle || sortColumn.startsWith("app."); + boolean needsEventType = eventTypeNameNotEmpty || needsApp || sortColumn.startsWith("et."); + + // Add selective JOINs (order matters - must follow FK chain: e → et → app → bundle) + if (needsEventType) { + hql += "JOIN e.eventType et "; + } + if (needsApp) { + hql += "JOIN et.application app "; + } + if (needsBundle) { + hql += "JOIN app.bundle bundle "; + } + } + + hql += "WHERE e.orgId = :orgId"; + + hql = addHqlConditions(hql, useNormalized, bundlesNotEmpty, applicationsNotEmpty, eventTypeNameNotEmpty, startDate, endDate, endpointTypes, compositeEndpointTypes, invocationResults, status, severities, uuidToExclude, includeEventsWithAuthCriterion); if (sort.isPresent()) { hql += getOrderBy(sort.get()); @@ -127,7 +234,7 @@ private List getEventIds(String orgId, Set bundleIds, Set appI return typedQuery.getResultList(); } - private static String addHqlConditions(String hql, Set bundleIds, Set appIds, String eventTypeDisplayName, + private String addHqlConditions(String hql, boolean useNormalized, boolean bundlesNotEmpty, boolean applicationsNotEmpty, boolean eventTypeNameNotEmpty, LocalDate startDate, LocalDate endDate, Set endpointTypes, Set compositeEndpointTypes, Set invocationResults, Set status, Set severities, Optional> uuidToExclude, boolean includeEventsWithAuthCriterion) { @@ -137,19 +244,31 @@ private static String addHqlConditions(String hql, Set bundleIds, Set 0) { hql += " AND (" + String.join(" OR ", bundleOrAppsConditions) + ")"; } - if (eventTypeDisplayName != null) { - hql += " AND LOWER(e.eventTypeDisplayName) LIKE :eventTypeDisplayName"; + if (eventTypeNameNotEmpty) { + if (useNormalized) { + hql += " AND LOWER(e.eventType.displayName) LIKE :eventTypeDisplayName"; + } else { + hql += " AND LOWER(e.eventTypeDisplayName) LIKE :eventTypeDisplayName"; + } } if (startDate != null && endDate != null) { hql += " AND e.created BETWEEN :startDate AND :endDate"; diff --git a/backend/src/test/java/com/redhat/cloud/notifications/routers/handlers/drawer/DrawerResourceTest.java b/backend/src/test/java/com/redhat/cloud/notifications/routers/handlers/drawer/DrawerResourceTest.java index 3a3b187153..f6fa6e5ba3 100644 --- a/backend/src/test/java/com/redhat/cloud/notifications/routers/handlers/drawer/DrawerResourceTest.java +++ b/backend/src/test/java/com/redhat/cloud/notifications/routers/handlers/drawer/DrawerResourceTest.java @@ -25,7 +25,8 @@ import jakarta.inject.Inject; import jakarta.persistence.EntityManager; import jakarta.transaction.Transactional; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.time.LocalDateTime; import java.util.Map; @@ -42,6 +43,7 @@ import static java.time.ZoneOffset.UTC; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; @QuarkusTest @@ -60,9 +62,11 @@ public class DrawerResourceTest extends DbIsolatedTest { @InjectMock BackendConfig backendConfig; - @Test - void testMultiplePages() { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void testMultiplePages(boolean useNormalizedQueries) { when(backendConfig.isDrawerEnabled()).thenReturn(true); + when(backendConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); final String USERNAME = "user-1"; Header defaultIdentityHeader = mockRbac(DEFAULT_ACCOUNT_ID, DEFAULT_ORG_ID, USERNAME, FULL_ACCESS); @@ -100,9 +104,11 @@ void testMultiplePages() { assertTrue(page.getLinks().get("last").contains("limit=3&offset=27")); } - @Test - void testFilters() { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void testFilters(boolean useNormalizedQueries) { when(backendConfig.isDrawerEnabled()).thenReturn(true); + when(backendConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); Bundle createdBundle = resourceHelpers.createBundle("test-drawer-event-resource-bundle"); Bundle createdBundle2 = resourceHelpers.createBundle("test-drawer-event-resource-bundle2"); Application createdApplication = resourceHelpers.createApplication(createdBundle.getId(), "test-drawer-event-resource-application"); diff --git a/backend/src/test/java/com/redhat/cloud/notifications/routers/handlers/event/EventResourceTest.java b/backend/src/test/java/com/redhat/cloud/notifications/routers/handlers/event/EventResourceTest.java index 49f8e0be9a..56906e74fb 100644 --- a/backend/src/test/java/com/redhat/cloud/notifications/routers/handlers/event/EventResourceTest.java +++ b/backend/src/test/java/com/redhat/cloud/notifications/routers/handlers/event/EventResourceTest.java @@ -45,6 +45,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mockito; import org.project_kessel.api.inventory.v1beta2.Allowed; @@ -159,10 +160,11 @@ void setUp() { } @ParameterizedTest - @ValueSource(booleans = {true, false}) - void shouldNotBeAllowedTogetEventLogsWhenUserHasNotificationsAccessRightsOnly(boolean kesselEnabled) { + @CsvSource({"false,false", "false,true", "true,false", "true,true"}) + void shouldNotBeAllowedToGetEventLogsWhenUserHasNotificationsAccessRightsOnly(boolean kesselEnabled, boolean useNormalizedQueries) { when(backendConfig.isKesselEnabled(anyString())).thenReturn(kesselEnabled); + when(backendConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); if (kesselEnabled) { mockKesselPermission(DEFAULT_ORG_ID, "non-default-user", EVENTS_VIEW, ALLOWED_FALSE); } @@ -176,10 +178,11 @@ void shouldNotBeAllowedTogetEventLogsWhenUserHasNotificationsAccessRightsOnly(bo } @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testAllQueryParams(boolean kesselEnabled) { + @CsvSource({"false,false", "false,true", "true,false", "true,true"}) + void testAllQueryParams(boolean kesselEnabled, boolean useNormalizedQueries) { when(backendConfig.isKesselEnabled(anyString())).thenReturn(kesselEnabled); + when(backendConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); if (kesselEnabled) { mockDefaultKesselPermission(EVENTS_VIEW, ALLOWED_TRUE); when(workspaceUtils.getDefaultWorkspaceId(OTHER_ORG_ID)).thenReturn(UUID.randomUUID()); @@ -696,10 +699,11 @@ void testAllQueryParams(boolean kesselEnabled) { } @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testInsufficientPrivileges(boolean kesselEnabled) { + @CsvSource({"false,false", "false,true", "true,false", "true,true"}) + void testInsufficientPrivileges(boolean kesselEnabled, boolean useNormalizedQueries) { when(backendConfig.isKesselEnabled(anyString())).thenReturn(kesselEnabled); + when(backendConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); if (kesselEnabled) { mockKesselPermission(DEFAULT_ORG_ID, DEFAULT_USER + "no-access", EVENTS_VIEW, ALLOWED_FALSE); } @@ -713,10 +717,11 @@ void testInsufficientPrivileges(boolean kesselEnabled) { } @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testInvalidSortBy(boolean kesselEnabled) { + @CsvSource({"false,false", "false,true", "true,false", "true,true"}) + void testInvalidSortBy(boolean kesselEnabled, boolean useNormalizedQueries) { when(backendConfig.isKesselEnabled(anyString())).thenReturn(kesselEnabled); + when(backendConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); if (kesselEnabled) { mockDefaultKesselPermission(EVENTS_VIEW, ALLOWED_TRUE); } @@ -733,10 +738,11 @@ void testInvalidSortBy(boolean kesselEnabled) { } @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testInvalidLimit(boolean kesselEnabled) { + @CsvSource({"false,false", "false,true", "true,false", "true,true"}) + void testInvalidLimit(boolean kesselEnabled, boolean useNormalizedQueries) { when(backendConfig.isKesselEnabled(anyString())).thenReturn(kesselEnabled); + when(backendConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); if (kesselEnabled) { mockDefaultKesselPermission(EVENTS_VIEW, ALLOWED_TRUE); } @@ -760,10 +766,11 @@ void testInvalidLimit(boolean kesselEnabled) { } @ParameterizedTest - @ValueSource(booleans = {true, false}) - void shouldBeAllowedToGetEventLogs(boolean kesselEnabled) { + @CsvSource({"false,false", "false,true", "true,false", "true,true"}) + void shouldBeAllowedToGetEventLogs(boolean kesselEnabled, boolean useNormalizedQueries) { when(backendConfig.isKesselEnabled(anyString())).thenReturn(kesselEnabled); + when(backendConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); if (kesselEnabled) { mockDefaultKesselPermission(EVENTS_VIEW, ALLOWED_TRUE); } @@ -1017,9 +1024,11 @@ private static void assertLinks(Map links, String... expectedKey } } - @Test - void testEventsWithKesselCriterion() { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void testEventsWithKesselCriterion(boolean useNormalizedQueries) { when(backendConfig.isKesselChecksOnEventLogEnabled(anyString())).thenReturn(true); + when(backendConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); Header defaultIdentityHeader = mockRbac(DEFAULT_ACCOUNT_ID, DEFAULT_ORG_ID, DEFAULT_USER, FULL_ACCESS); @@ -1116,6 +1125,116 @@ void testEventsWithKesselCriterion() { assertLinks(page.getLinks(), "first", "last"); } + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void testEventsWithKesselCriterionAndBundleAppFilters(boolean useNormalizedQueries) { + when(backendConfig.isKesselChecksOnEventLogEnabled(anyString())).thenReturn(true); + when(backendConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); + + Header defaultIdentityHeader = mockRbac(DEFAULT_ACCOUNT_ID, DEFAULT_ORG_ID, DEFAULT_USER, FULL_ACCESS); + + mockDefaultKesselPermission(EVENTS_VIEW, ALLOWED_TRUE); + + Bundle bundle1 = resourceHelpers.createBundle("bundle-filter-1", "Bundle Filter 1"); + Bundle bundle2 = resourceHelpers.createBundle("bundle-filter-2", "Bundle Filter 2"); + Application app1 = resourceHelpers.createApplication(bundle1.getId(), "app-filter-1", "Application Filter 1"); + Application app2 = resourceHelpers.createApplication(bundle2.getId(), "app-filter-2", "Application Filter 2"); + EventType eventType1 = resourceHelpers.createEventType(app1.getId(), "event-type-filter-1", "Event type filter 1", "Event type filter 1"); + EventType eventType2 = resourceHelpers.createEventType(app2.getId(), "event-type-filter-2", "Event type filter 2", "Event type filter 2"); + + String kesselPayload1 = buildPayloadWithAuthorizationCriterion(DEFAULT_ORG_ID, bundle1.getName(), app1.getName(), eventType1.getName()); + String kesselPayload2 = buildPayloadWithAuthorizationCriterion(DEFAULT_ORG_ID, bundle2.getName(), app2.getName(), eventType2.getName()); + + Event event1Bundle1 = createEvent(DEFAULT_ACCOUNT_ID, DEFAULT_ORG_ID, bundle1, app1, eventType1, NOW.minusDays(1L), kesselPayload1, true, UUID.randomUUID()); + Event event2Bundle1 = createEvent(DEFAULT_ACCOUNT_ID, DEFAULT_ORG_ID, bundle1, app1, eventType1, NOW.minusDays(2L), kesselPayload1, true, UUID.randomUUID()); + Event event3Bundle2 = createEvent(DEFAULT_ACCOUNT_ID, DEFAULT_ORG_ID, bundle2, app2, eventType2, NOW.minusDays(3L), kesselPayload2, true, UUID.randomUUID()); + Event event4Bundle2 = createEvent(DEFAULT_ACCOUNT_ID, DEFAULT_ORG_ID, bundle2, app2, eventType2, NOW.minusDays(4L), kesselPayload2, true, UUID.randomUUID()); + Event eventNoAuth = createEvent(DEFAULT_ACCOUNT_ID, DEFAULT_ORG_ID, bundle1, app1, eventType1, NOW.minusDays(5L)); + + Endpoint endpoint1 = resourceHelpers.createEndpoint(DEFAULT_ACCOUNT_ID, DEFAULT_ORG_ID, WEBHOOK); + NotificationHistory history1 = resourceHelpers.createNotificationHistory(event1Bundle1, endpoint1, NotificationStatus.SUCCESS); + NotificationHistory history2 = resourceHelpers.createNotificationHistory(event2Bundle1, endpoint1, NotificationStatus.SUCCESS); + NotificationHistory history3 = resourceHelpers.createNotificationHistory(event3Bundle2, endpoint1, NotificationStatus.SUCCESS); + NotificationHistory history4 = resourceHelpers.createNotificationHistory(event4Bundle2, endpoint1, NotificationStatus.SUCCESS); + NotificationHistory historyNoAuth = resourceHelpers.createNotificationHistory(eventNoAuth, endpoint1, NotificationStatus.SUCCESS); + + endpointRepository.deleteEndpoint(DEFAULT_ORG_ID, endpoint1.getId()); + + CheckResponse kesselInventoryCheckResponse = CheckResponse.newBuilder().setAllowed(ALLOWED_TRUE).build(); + when(kesselCheckClient.check(any(CheckRequest.class))).thenReturn(kesselInventoryCheckResponse); + + /* + * Test #1 + * Account: DEFAULT_ACCOUNT_ID + * Request: Filter by bundle1 + * Expected response: All events from bundle1 (events with auth criterion that pass Kessel + events without auth criterion) + */ + Page page = getEventLogPage(defaultIdentityHeader, Set.of(bundle1.getId()), null, null, null, null, null, null, null, null, null, null, false, true); + assertEquals(3, page.getMeta().getCount()); + assertEquals(3, page.getData().size()); + assertSameEvent(page.getData().get(0), event1Bundle1, history1); + assertSameEvent(page.getData().get(1), event2Bundle1, history2); + assertSameEvent(page.getData().get(2), eventNoAuth, historyNoAuth); + + /* + * Test #2 + * Account: DEFAULT_ACCOUNT_ID + * Request: Filter by bundle2 + */ + page = getEventLogPage(defaultIdentityHeader, Set.of(bundle2.getId()), null, null, null, null, null, null, null, null, null, null, false, true); + assertEquals(2, page.getMeta().getCount()); + assertEquals(2, page.getData().size()); + assertSameEvent(page.getData().get(0), event3Bundle2, history3); + assertSameEvent(page.getData().get(1), event4Bundle2, history4); + + /* + * Test #3 + * Account: DEFAULT_ACCOUNT_ID + * Request: Filter by app2 + */ + page = getEventLogPage(defaultIdentityHeader, null, Set.of(app2.getId()), null, null, null, null, null, null, null, null, null, false, true); + assertEquals(2, page.getMeta().getCount()); + assertEquals(2, page.getData().size()); + assertSameEvent(page.getData().get(0), event3Bundle2, history3); + assertSameEvent(page.getData().get(1), event4Bundle2, history4); + + /* + * Test #4 + * Account: DEFAULT_ACCOUNT_ID + * Request: Filter by bundle1 and app2 + */ + page = getEventLogPage(defaultIdentityHeader, Set.of(bundle1.getId()), Set.of(app2.getId()), null, null, null, null, null, null, null, null, null, false, true); + assertEquals(5, page.getMeta().getCount()); + assertEquals(5, page.getData().size()); + + /* + * Test #5 + * Account: DEFAULT_ACCOUNT_ID + * Request: Filter by both bundles + */ + page = getEventLogPage(defaultIdentityHeader, Set.of(bundle1.getId(), bundle2.getId()), null, null, null, null, null, null, null, null, null, null, false, true); + assertEquals(5, page.getMeta().getCount()); + assertEquals(5, page.getData().size()); + + /* + * Test #6 + * Account: DEFAULT_ACCOUNT_ID + * Request: No filter + */ + page = getEventLogPage(defaultIdentityHeader, null, null, null, null, null, null, null, null, null, null, null, false, true); + assertEquals(5, page.getMeta().getCount()); + assertEquals(5, page.getData().size()); + + /* + * Test #7 + * Account: DEFAULT_ACCOUNT_ID + * Request: Unknown bundle + */ + page = getEventLogPage(defaultIdentityHeader, Set.of(UUID.randomUUID()), null, null, null, null, null, null, null, null, null, null, false, true); + assertEquals(0, page.getMeta().getCount()); + assertTrue(page.getData().isEmpty()); + } + private void mockKesselDenyAll() { when(kesselCheckClient .check(any(CheckRequest.class))) @@ -1135,8 +1254,10 @@ private void mockKesselPermission(String orgId, String subjectUsername, Workspac .thenReturn(kesselTestHelper.buildCheckResponse(allowed)); } - @Test - void testEventLogEntriesIncludeSeverity() { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void testEventLogEntriesIncludeSeverity(boolean useNormalizedQueries) { + when(backendConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); Header defaultIdentityHeader = mockRbac(DEFAULT_ACCOUNT_ID, DEFAULT_ORG_ID, DEFAULT_USER, FULL_ACCESS); Bundle bundle = resourceHelpers.createBundle("test-bundle", "Test Bundle"); diff --git a/common/src/main/java/com/redhat/cloud/notifications/models/DrawerNotification.java b/common/src/main/java/com/redhat/cloud/notifications/models/DrawerNotification.java index 3cca4a93c8..aad7c84fae 100644 --- a/common/src/main/java/com/redhat/cloud/notifications/models/DrawerNotification.java +++ b/common/src/main/java/com/redhat/cloud/notifications/models/DrawerNotification.java @@ -20,14 +20,26 @@ @JsonNaming(SnakeCaseStrategy.class) public class DrawerNotification extends CreationTimestamped { - public static final Map SORT_FIELDS = Map.of( - "bundle", "dn.event.bundleDisplayName", - "application", "dn.event.applicationDisplayName", - "event", "dn.event.eventTypeDisplayName", - "created", "dn.created", - "read", "dn.read" + private static final Map SORT_FIELDS_NORMALIZED = Map.of( + "bundle", "bundle.displayName", + "application", "app.displayName", + "event", "et.displayName", + "created", "dn.created", + "read", "dn.read" ); + private static final Map SORT_FIELDS_DENORMALIZED = Map.of( + "bundle", "dn.event.bundleDisplayName", + "application", "dn.event.applicationDisplayName", + "event", "dn.event.eventTypeDisplayName", + "created", "dn.created", + "read", "dn.read" + ); + + public static Map getSortFields(boolean useNormalized) { + return useNormalized ? SORT_FIELDS_NORMALIZED : SORT_FIELDS_DENORMALIZED; + } + @EmbeddedId private DrawerNotificationId id; diff --git a/common/src/main/java/com/redhat/cloud/notifications/models/Event.java b/common/src/main/java/com/redhat/cloud/notifications/models/Event.java index 15db954198..5858fb1798 100644 --- a/common/src/main/java/com/redhat/cloud/notifications/models/Event.java +++ b/common/src/main/java/com/redhat/cloud/notifications/models/Event.java @@ -34,13 +34,24 @@ @Table(name = "event") public class Event { - public static final Map SORT_FIELDS = Map.of( + private static final Map SORT_FIELDS_NORMALIZED = Map.of( + "bundle", "bundle.displayName", + "application", "app.displayName", + "event", "et.displayName", + "created", "e.created" + ); + + private static final Map SORT_FIELDS_DENORMALIZED = Map.of( "bundle", "e.bundleDisplayName", "application", "e.applicationDisplayName", "event", "e.eventTypeDisplayName", "created", "e.created" ); + public static Map getSortFields(boolean useNormalized) { + return useNormalized ? SORT_FIELDS_NORMALIZED : SORT_FIELDS_DENORMALIZED; + } + @Id @GeneratedValue private UUID id; diff --git a/database/src/main/resources/db/migration/V1.130.0__RHCLOUD-46150_add_indexes_for_normalized_event_queries.sql b/database/src/main/resources/db/migration/V1.130.0__RHCLOUD-46150_add_indexes_for_normalized_event_queries.sql new file mode 100644 index 0000000000..7e9fa7802e --- /dev/null +++ b/database/src/main/resources/db/migration/V1.130.0__RHCLOUD-46150_add_indexes_for_normalized_event_queries.sql @@ -0,0 +1,7 @@ +CREATE INDEX ix_event_event_type_id ON event (event_type_id); +CREATE INDEX ix_applications_bundle_id ON applications (bundle_id); +CREATE INDEX ix_event_type_application_id ON event_type (application_id); +CREATE INDEX ix_bundles_display_name ON bundles (display_name); +CREATE INDEX ix_applications_display_name ON applications (display_name); +CREATE INDEX ix_event_type_display_name ON event_type (display_name); +CREATE INDEX ix_event_bundle_id_covering ON event (bundle_id, event_type_id) INCLUDE (id, severity); \ No newline at end of file diff --git a/engine/src/main/java/com/redhat/cloud/notifications/config/EngineConfig.java b/engine/src/main/java/com/redhat/cloud/notifications/config/EngineConfig.java index 867c18bdf7..18e3006b77 100644 --- a/engine/src/main/java/com/redhat/cloud/notifications/config/EngineConfig.java +++ b/engine/src/main/java/com/redhat/cloud/notifications/config/EngineConfig.java @@ -63,6 +63,7 @@ public class EngineConfig { private String toggleIncludeSeverityToFilterRecipients; private String toggleSkipProcessingMessagesOnReplayService; private String toggleSubscriptionsDeduplicationWillBeNotified; + private String normalizedQueriesToggle; @ConfigProperty(name = UNLEASH, defaultValue = "false") @Deprecated(forRemoval = true, since = "To be removed when we're done migrating to Unleash in all environments") @@ -172,6 +173,7 @@ void postConstruct() { toggleIncludeSeverityToFilterRecipients = toggleRegistry.register("include-severity-to-filter-recipients", true); toggleSkipProcessingMessagesOnReplayService = toggleRegistry.register("skip-processing-on-replay-service", true); toggleSubscriptionsDeduplicationWillBeNotified = toggleRegistry.register("subscriptions-deduplication-will-be-notified", true); + normalizedQueriesToggle = toggleRegistry.register("normalized-queries", true); } void logConfigAtStartup(@Observes Startup event) { @@ -371,4 +373,12 @@ public boolean isSubscriptionsDeduplicationWillBeNotifiedEnabled(String orgId) { return false; } } + + public boolean isNormalizedQueriesEnabled(String orgId) { + if (unleashEnabled) { + return unleash.isEnabled(normalizedQueriesToggle, UnleashContextBuilder.buildUnleashContextWithOrgId(orgId), false); + } else { + return false; + } + } } diff --git a/engine/src/main/java/com/redhat/cloud/notifications/db/repositories/EventRepository.java b/engine/src/main/java/com/redhat/cloud/notifications/db/repositories/EventRepository.java index 25a7343601..6c132f5d68 100644 --- a/engine/src/main/java/com/redhat/cloud/notifications/db/repositories/EventRepository.java +++ b/engine/src/main/java/com/redhat/cloud/notifications/db/repositories/EventRepository.java @@ -1,5 +1,6 @@ package com.redhat.cloud.notifications.db.repositories; +import com.redhat.cloud.notifications.config.EngineConfig; import com.redhat.cloud.notifications.models.Event; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -21,6 +22,9 @@ public class EventRepository { @Inject EntityManager entityManager; + @Inject + EngineConfig engineConfig; + @Transactional public Event create(Event event) { entityManager.persist(event); @@ -38,18 +42,37 @@ public Event create(Event event) { */ public List findEventsToExport(final String orgId, final LocalDate from, final LocalDate to) { final StringBuilder findEventsQuery = new StringBuilder(); - findEventsQuery.append( - "SELECT NEW com.redhat.cloud.notifications.models.Event( " + - "e.id, " + - "e.bundleDisplayName, " + - "e.applicationDisplayName, " + - "e.eventTypeDisplayName, " + - "e.created) " + - "FROM " + - "Event AS e " + - "WHERE " + - "e.orgId = :orgId" - ); + + if (engineConfig.isNormalizedQueriesEnabled(orgId)) { + findEventsQuery.append( + "SELECT NEW com.redhat.cloud.notifications.models.Event( " + + "e.id, " + + "bundle.displayName, " + + "app.displayName, " + + "et.displayName, " + + "e.created) " + + "FROM " + + "Event AS e " + + "JOIN e.eventType et " + + "JOIN et.application app " + + "JOIN app.bundle bundle " + + "WHERE " + + "e.orgId = :orgId" + ); + } else { + findEventsQuery.append( + "SELECT NEW com.redhat.cloud.notifications.models.Event( " + + "e.id, " + + "e.bundleDisplayName, " + + "e.applicationDisplayName, " + + "e.eventTypeDisplayName, " + + "e.created) " + + "FROM " + + "Event AS e " + + "WHERE " + + "e.orgId = :orgId" + ); + } final Map parameters = new HashMap<>(); parameters.put("orgId", orgId); diff --git a/engine/src/test/java/com/redhat/cloud/notifications/db/repositories/EventRepositoryTest.java b/engine/src/test/java/com/redhat/cloud/notifications/db/repositories/EventRepositoryTest.java index 9a8e6982a6..a90d58657e 100644 --- a/engine/src/test/java/com/redhat/cloud/notifications/db/repositories/EventRepositoryTest.java +++ b/engine/src/test/java/com/redhat/cloud/notifications/db/repositories/EventRepositoryTest.java @@ -1,11 +1,13 @@ package com.redhat.cloud.notifications.db.repositories; import com.redhat.cloud.notifications.TestLifecycleManager; +import com.redhat.cloud.notifications.config.EngineConfig; import com.redhat.cloud.notifications.db.ResourceHelpers; import com.redhat.cloud.notifications.models.Application; import com.redhat.cloud.notifications.models.Bundle; import com.redhat.cloud.notifications.models.Event; import com.redhat.cloud.notifications.models.EventType; +import io.quarkus.test.InjectMock; import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; import jakarta.inject.Inject; @@ -14,7 +16,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.time.LocalDate; import java.time.LocalDateTime; @@ -25,6 +28,8 @@ import java.util.stream.Collectors; import static com.redhat.cloud.notifications.TestConstants.DEFAULT_ORG_ID; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; @QuarkusTest @QuarkusTestResource(TestLifecycleManager.class) @@ -46,6 +51,9 @@ public class EventRepositoryTest { @Inject ResourceHelpers resourceHelpers; + @InjectMock + EngineConfig engineConfig; + /** * Inserts five event fixtures in the database. The fixtures then get * their "created at" timestamp modified by removing days from their dates. @@ -96,9 +104,13 @@ void removeFixtures() { /** * Tests that when no date ranges are provided all the events related to * the org id are fetched. + * Tests both denormalized (false) and normalized (true) query modes. */ - @Test - void testGetAll() { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void testGetAll(boolean useNormalizedQueries) { + when(engineConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); + final List result = this.eventRepository.findEventsToExport(DEFAULT_ORG_ID, null, null); Assertions.assertEquals(this.createdEvents.size(), result.size(), "unexpected number of fetched events"); @@ -111,9 +123,13 @@ void testGetAll() { /** * Tests that when just the "from" date is provided, the events are * filtered as expected. + * Tests both denormalized (false) and normalized (true) query modes. */ - @Test - void testGetJustFrom() { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void testGetJustFrom(boolean useNormalizedQueries) { + when(engineConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); + final LocalDate fourDaysAgo = TODAY.minusDays(4); final List result = this.eventRepository.findEventsToExport(DEFAULT_ORG_ID, fourDaysAgo, null); @@ -138,9 +154,13 @@ void testGetJustFrom() { /** * Tests that when just the "to" date is provided, the events are filtered * as expected. + * Tests both denormalized (false) and normalized (true) query modes. */ - @Test - void testGetJustTo() { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void testGetJustTo(boolean useNormalizedQueries) { + when(engineConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); + final LocalDate threeDaysAgo = TODAY.minusDays(3); final List result = this.eventRepository.findEventsToExport(DEFAULT_ORG_ID, null, threeDaysAgo); @@ -164,9 +184,13 @@ void testGetJustTo() { /** * Tests that when a date range is provided, only the events that comply * with that range are fetched. + * Tests both denormalized (false) and normalized (true) query modes. */ - @Test - void testGetDateRange() { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void testGetDateRange(boolean useNormalizedQueries) { + when(engineConfig.isNormalizedQueriesEnabled(anyString())).thenReturn(useNormalizedQueries); + final LocalDate fourDaysAgo = TODAY.minusDays(4); final LocalDate threeDaysAgo = TODAY.minusDays(3);