Repository: p2-inc/keycloak-orgs
Pull request #321: Add attribute-based filtering to organization count API
Author: login: arbha1erao
Created: 2025-03-08T11:42:16Z | Merged: 2025-03-11T16:59:48Z

fixes: #320


Diff:
diff --git a/src/main/java/io/phasetwo/service/model/OrganizationProvider.java b/src/main/java/io/phasetwo/service/model/OrganizationProvider.java
index 645a02c3..ca8b025a 100644
--- a/src/main/java/io/phasetwo/service/model/OrganizationProvider.java
+++ b/src/main/java/io/phasetwo/service/model/OrganizationProvider.java
@@ -30,7 +30,7 @@ Stream<OrganizationModel> searchForOrganizationStream(
       Integer maxResults,
       Optional<UserModel> member);
 
-  Long getOrganizationsCount(RealmModel realm, String search);
+  Long getOrganizationsCount(RealmModel realm, String search, Map<String, String> attributes);
 
   boolean removeOrganization(RealmModel realm, String id);
 
diff --git a/src/main/java/io/phasetwo/service/model/jpa/JpaOrganizationProvider.java b/src/main/java/io/phasetwo/service/model/jpa/JpaOrganizationProvider.java
index a5c65f93..db43e6f2 100644
--- a/src/main/java/io/phasetwo/service/model/jpa/JpaOrganizationProvider.java
+++ b/src/main/java/io/phasetwo/service/model/jpa/JpaOrganizationProvider.java
@@ -157,11 +157,25 @@ public Stream<OrganizationModel> searchForOrganizationStream(
   }
 
   @Override
-  public Long getOrganizationsCount(RealmModel realm, String search) {
-    TypedQuery<Long> query = em.createNamedQuery("countOrganizationsByRealmIdAndName", Long.class);
-    query.setParameter("realmId", realm.getId());
-    search = createSearchString(search);
-    query.setParameter("search", search);
+  public Long getOrganizationsCount(RealmModel realm, String search, Map<String, String> attributes) {
+    if (attributes == null) {
+      attributes = ImmutableMap.of();
+    }
+
+    CriteriaBuilder builder = em.getCriteriaBuilder();
+    CriteriaQuery<Long> queryBuilder = builder.createQuery(Long.class);
+    Root<ExtOrganizationEntity> root = queryBuilder.from(ExtOrganizationEntity.class);
+
+    List<Predicate> predicates = attributePredicates(attributes, root);
+    predicates.add(builder.equal(root.get("realmId"), realm.getId()));
+
+    if (search != null && !search.trim().isEmpty()) {
+        String searchPattern = "%" + search.toLowerCase() + "%";
+        predicates.add(builder.like(builder.lower(root.get("name")), searchPattern));
+    }
+
+    queryBuilder.select(builder.count(root)).where(predicates.toArray(new Predicate[0]));
+    TypedQuery<Long> query = em.createQuery(queryBuilder);
     return query.getSingleResult();
   }
 
diff --git a/src/main/java/io/phasetwo/service/resource/OrganizationsResource.java b/src/main/java/io/phasetwo/service/resource/OrganizationsResource.java
index 377e9585..1e68fc23 100644
--- a/src/main/java/io/phasetwo/service/resource/OrganizationsResource.java
+++ b/src/main/java/io/phasetwo/service/resource/OrganizationsResource.java
@@ -128,12 +128,24 @@ public Stream<Organization> listOrgs(
   @GET
   @Path("count")
   @Produces(MediaType.APPLICATION_JSON)
-  public Long countOrgs(@QueryParam("search") String searchQuery) {
-    log.debugf("countOrgs %s %s", realm.getName(), searchQuery);
+  public Long countOrgs(
+          @QueryParam("search") String searchQuery,
+          @QueryParam("q") String searchAttributes) {
+
+    log.debugf("countOrgs realm: %s, search: %s, query: %s", realm.getName(), searchQuery, searchAttributes);
+
     if (!auth.hasViewOrgs()) {
       throw new NotAuthorizedException("Insufficient permission to count organizations.");
     }
-    return orgs.getOrganizationsCount(realm, searchQuery);
+
+    Map<String, String> attributes =
+            (searchAttributes == null) ? Maps.newHashMap() : SearchQueryUtils.getFields(searchAttributes);
+
+    if (searchQuery != null) {
+      attributes.put("name", searchQuery.trim());
+    }
+
+    return orgs.getOrganizationsCount(realm, searchQuery, attributes);
   }
 
   @POST
diff --git a/src/test/java/io/phasetwo/service/resource/OrganizationResourceTest.java b/src/test/java/io/phasetwo/service/resource/OrganizationResourceTest.java
index 9d13b2dd..40c95c89 100644
--- a/src/test/java/io/phasetwo/service/resource/OrganizationResourceTest.java
+++ b/src/test/java/io/phasetwo/service/resource/OrganizationResourceTest.java
@@ -211,7 +211,7 @@ void testSearchOrganizations() throws IOException {
                 new OrganizationRepresentation()
                     .name("qux")
                     .domains(List.of("baz.com"))
-                    .attributes(Map.of("foo", List.of("bar"))))
+                    .attributes(Map.of("foo", List.of("bar"), "foo2", List.of("bar2"))))
             .getId());
 
     Response response = givenSpec().when().queryParam("search", "foo").get().andReturn();
@@ -272,6 +272,35 @@ void testSearchOrganizations() throws IOException {
     assertThat(orgs, notNullValue());
     assertThat(cnt, is(6L));
 
+    // orgs count with attribute filter
+    response = givenSpec("orgs", "count").when().queryParam("q", "foo:bar").get().andReturn();
+    assertThat(response.statusCode(), is(Status.OK.getStatusCode()));
+    cnt = objectMapper().readValue(response.getBody().asString(), Long.class);
+    assertThat(cnt, is(2L));
+
+    // orgs count with name + attribute filter
+    response = givenSpec("orgs", "count").when().queryParam("search", "qu").queryParam("q", "foo:bar").get().andReturn();
+    assertThat(response.statusCode(), is(Status.OK.getStatusCode()));
+    cnt = objectMapper().readValue(response.getBody().asString(), Long.class);
+    assertThat(cnt, is(1L));
+
+    // orgs count with multiple attribute filters
+    response = givenSpec("orgs", "count").when().queryParam("q", "foo:bar foo2:bar2").get().andReturn();
+    assertThat(response.statusCode(), is(Status.OK.getStatusCode()));
+    cnt = objectMapper().readValue(response.getBody().asString(), Long.class);
+    assertThat(cnt, is(1L));
+
+    response = givenSpec("orgs", "count").when().queryParam("q", "foo:bar foo3:bar3").get().andReturn();
+    assertThat(response.statusCode(), is(Status.OK.getStatusCode()));
+    cnt = objectMapper().readValue(response.getBody().asString(), Long.class);
+    assertThat(cnt, is(0L));
+
+    // orgs count with name + multiple attribute filters
+    response = givenSpec("orgs", "count").when().queryParam("search", "qu").queryParam("q", "foo:bar foo2:bar2").get().andReturn();
+    assertThat(response.statusCode(), is(Status.OK.getStatusCode()));
+    cnt = objectMapper().readValue(response.getBody().asString(), Long.class);
+    assertThat(cnt, is(1L));
+
     for (String id : ids) {
       deleteOrganization(id);
     }


Reviews:
user: login: xgp
body: This needs a test.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-08T14:26:37Z
user: login: rtufisi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-11T10:56:49Z
user: login: rtufisi
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-11T10:57:00Z
user: login: arbha1erao
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-11T11:55:53Z
user: login: xgp
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-11T16:59:31Z

Review comments:
diff_hunk: @@ -272,6 +272,18 @@ void testSearchOrganizations() throws IOException {
     assertThat(orgs, notNullValue());
     assertThat(cnt, is(6L));
 
+    // orgs count with attribute filter
+    response = givenSpec("orgs", "count").when().queryParam("q", "foo:bar").get().andReturn();
+    assertThat(response.statusCode(), is(Status.OK.getStatusCode()));
+    cnt = objectMapper().readValue(response.getBody().asString(), Long.class);
+    assertThat(cnt, is(2L));
+
+    // orgs count with name + attribute filter
+    response = givenSpec("orgs", "count").when().queryParam("search", "qu").queryParam("q", "foo:bar").get().andReturn();
path: src/test/java/io/phasetwo/service/resource/OrganizationResourceTest.java
user: login: rtufisi
body: Thanks a lot @arbha1erao . Maybe one suggestion is to add a multi-attribute filter test case : 
` response = givenSpec("orgs", "count").when().queryParam("search", "qu").queryParam("q", "foo:bar foo2:bar").get().andReturn();`

In rest it looks fantastic

created_at: 2025-03-11T10:56:49Z
author_association: COLLABORATOR
diff_hunk: @@ -272,6 +272,18 @@ void testSearchOrganizations() throws IOException {
     assertThat(orgs, notNullValue());
     assertThat(cnt, is(6L));
 
+    // orgs count with attribute filter
+    response = givenSpec("orgs", "count").when().queryParam("q", "foo:bar").get().andReturn();
+    assertThat(response.statusCode(), is(Status.OK.getStatusCode()));
+    cnt = objectMapper().readValue(response.getBody().asString(), Long.class);
+    assertThat(cnt, is(2L));
+
+    // orgs count with name + attribute filter
+    response = givenSpec("orgs", "count").when().queryParam("search", "qu").queryParam("q", "foo:bar").get().andReturn();
path: src/test/java/io/phasetwo/service/resource/OrganizationResourceTest.java
user: login: arbha1erao
body: @rtufisi Thanks for the suggestion! I've added the multi-attribute filter test case.
created_at: 2025-03-11T11:55:53Z
author_association: NONE

PR comments:
user: login: arbha1erao
created_at: 2025-03-08T18:32:09Z
body: > This needs a test.

Hi @xgp , 
Added tests in https://github.com/p2-inc/keycloak-orgs/pull/321/commits/8effb9aed0eb2e66b6713bb469dc4c67c065fd80
author_association: NONE
user: login: xgp
created_at: 2025-03-11T17:00:33Z
body: need to update the openapi.yml
author_association: MEMBER
user: login: arbha1erao
created_at: 2025-03-11T17:17:34Z
body: > need to update the openapi.yml

Done! Here is the update: https://github.com/p2-inc/phasetwo-docs/pull/219
author_association: NONE