Skip to content

Commit

Permalink
JN-573 global participant search bar (#551)
Browse files Browse the repository at this point in the history
  • Loading branch information
devonbush authored Sep 18, 2023
1 parent 9571e05 commit a6a35ae
Show file tree
Hide file tree
Showing 14 changed files with 347 additions and 83 deletions.
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
package bio.terra.pearl.core.service.participant.search.facets;

import bio.terra.pearl.core.service.participant.search.facets.sql.ParticipantTaskFacetSqlGenerator;
import bio.terra.pearl.core.service.participant.search.facets.sql.ProfileAgeFacetSqlGenerator;
import bio.terra.pearl.core.service.participant.search.facets.sql.ProfileFacetSqlGenerator;
import bio.terra.pearl.core.service.participant.search.facets.sql.SqlSearchableFacet;
import bio.terra.pearl.core.service.participant.search.facets.sql.*;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
Expand All @@ -19,6 +16,9 @@ public class FacetValueFactory {
),
"participantTask", Map.of(
"status", new FacetDefinition(CombinedStableIdFacetValue.class, new ParticipantTaskFacetSqlGenerator())
),
"keyword", Map.of(
"keyword", new FacetDefinition(StringFacetValue.class, new KeywordFacetSqlGenerator())
)
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.jdbi.v3.core.statement.Query;

public interface FacetSqlGenerator<T extends FacetValue> {
// the table name the facet uses for join/selects. Used for deduping queries. Use "" if the facet does not need any additional tables
String getTableName();
String getSelectQuery(T facetValue);
String getJoinQuery();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package bio.terra.pearl.core.service.participant.search.facets.sql;

import bio.terra.pearl.core.dao.BaseJdbiDao;
import bio.terra.pearl.core.service.participant.search.EnrolleeSearchUtils;
import bio.terra.pearl.core.service.participant.search.facets.StringFacetValue;
import org.jdbi.v3.core.statement.Query;

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class KeywordFacetSqlGenerator implements FacetSqlGenerator<StringFacetValue> {

public KeywordFacetSqlGenerator() {}

@Override
public String getTableName() {
// this facet is only tied to enrollee and Profile tables that are already joined
return "";
}

@Override
public String getJoinQuery() {
return "";
}

@Override
public String getSelectQuery(StringFacetValue facetValue) {
return null; // already included in base query
}

@Override
public String getWhereClause(StringFacetValue facetValue, int facetIndex) {
if (facetValue.getValues().isEmpty()) {
return " 1 = 1";
}
return IntStream.range(0, facetValue.getValues().size())
.mapToObj(index -> {
String paramName = EnrolleeSearchUtils.getSqlParamName("keyword", facetValue.getKeyName(), index);
return """
(profile.given_name ilike :%1$s
OR profile.family_name ilike :%1$s
OR profile.contact_email ilike :%1$s
OR enrollee.shortcode ilike :%1$s)
"""
.formatted(paramName);
})
.collect(Collectors.joining(" AND"));
}

@Override
public String getCombinedWhereClause(List<StringFacetValue> facetValues) {
return "";
}

@Override
public void bindSqlParameters(StringFacetValue facetValue, int facetIndex, Query query) {
for(int i = 0; i < facetValue.getValues().size(); i++) {
query.bind(EnrolleeSearchUtils.getSqlParamName("keyword",
facetValue.getKeyName(), i), "%" + facetValue.getValues().get(i) + "%");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
databaseChangeLog:
- changeSet:
id: profile_gin_indexes
author: dbush
changes:
- sql:
sql: CREATE EXTENSION IF NOT EXISTS pg_trgm with schema pg_catalog;
- sql:
sql: CREATE INDEX profile_given_name_gindx ON profile USING gin (given_name gin_trgm_ops);
- sql:
sql: CREATE INDEX profile_family_name_gindx ON profile USING gin (family_name gin_trgm_ops);
3 changes: 3 additions & 0 deletions core/src/main/resources/db/changelog/db.changelog-master.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ databaseChangeLog:
- include:
file: changesets/2023_08_30_publish_versions.yaml
relativeToChangelogFile: true
- include:
file: changesets/2023_09_14_add_profile_gin_indexes.yaml
relativeToChangelogFile: true

# README: it is a best practice to put each DDL statement in its own change set. DDL statements
# are atomic. When they are grouped in a changeset and one fails the changeset cannot be
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,8 @@
import bio.terra.pearl.core.service.participant.search.facets.IntRangeFacetValue;
import bio.terra.pearl.core.service.participant.search.facets.StableIdStringFacetValue;
import bio.terra.pearl.core.service.participant.search.facets.StringFacetValue;
import bio.terra.pearl.core.service.participant.search.facets.sql.ParticipantTaskFacetSqlGenerator;
import bio.terra.pearl.core.service.participant.search.facets.sql.ProfileAgeFacetSqlGenerator;
import bio.terra.pearl.core.service.participant.search.facets.sql.ProfileFacetSqlGenerator;
import bio.terra.pearl.core.service.participant.search.facets.sql.SqlSearchableFacet;
import bio.terra.pearl.core.service.participant.search.facets.sql.*;

import java.time.LocalDate;
import java.util.List;
import static org.hamcrest.CoreMatchers.equalTo;
Expand Down Expand Up @@ -96,6 +94,58 @@ public void testProfileSearch() {
assertThat(result.get(0).getProfile().getSexAtBirth(), equalTo("male"));
}

@Test
@Transactional
public void testKeywordSearchGivenFamilyName() {
StudyEnvironment studyEnv = studyEnvironmentFactory.buildPersisted("testKeywordSearch");

Profile profile = Profile.builder().givenName("mark").familyName("stewart").build();
Enrollee markGivenNameEnrollee = enrolleeFactory.buildPersisted("testKeywordSearch", studyEnv, profile);
Profile profile2 = Profile.builder().givenName("matt").familyName("stover").build();
Enrollee mattGivenNameEnrollee = enrolleeFactory.buildPersisted("testKeywordSearch", studyEnv, profile2);
Profile profile3 = Profile.builder().givenName("steve").familyName("mallory").build();
Enrollee steveGivenNameEnrollee = enrolleeFactory.buildPersisted("testKeywordSearch", studyEnv, profile3);

SqlSearchableFacet facet = new SqlSearchableFacet(new StringFacetValue(
"keyword", List.of("mark")), new KeywordFacetSqlGenerator());
var result = enrolleeSearchDao.search(studyEnv.getId(), List.of(facet));
assertThat(result, hasSize(1));
assertThat(result.get(0).getEnrollee().getShortcode(), equalTo(markGivenNameEnrollee.getShortcode()));

facet = new SqlSearchableFacet(new StringFacetValue(
"keyword", List.of("ma")), new KeywordFacetSqlGenerator());
result = enrolleeSearchDao.search(studyEnv.getId(), List.of(facet));
assertThat(result, hasSize(3));

facet = new SqlSearchableFacet(new StringFacetValue(
"keyword", List.of("allo")), new KeywordFacetSqlGenerator());
result = enrolleeSearchDao.search(studyEnv.getId(), List.of(facet));
assertThat(result, hasSize(1));
assertThat(result.get(0).getEnrollee().getShortcode(), equalTo(steveGivenNameEnrollee.getShortcode()));
}

@Test
@Transactional
public void testKeywordSearchEmailShortcode() {
StudyEnvironment studyEnv = studyEnvironmentFactory.buildPersisted("testKeywordSearch");

Profile profile = Profile.builder().contactEmail("[email protected]").build();
Enrollee maEmail = enrolleeFactory.buildPersisted("testKeywordSearch", studyEnv, profile);
Profile profile2 = Profile.builder().contactEmail("[email protected]").familyName("stover").build();
Enrollee fooEmail = enrolleeFactory.buildPersisted("testKeywordSearch", studyEnv, profile2);

SqlSearchableFacet facet = new SqlSearchableFacet(new StringFacetValue(
"keyword", List.of(maEmail.getShortcode())), new KeywordFacetSqlGenerator());
var result = enrolleeSearchDao.search(studyEnv.getId(), List.of(facet));
assertThat(result, hasSize(1));
assertThat(result.get(0).getEnrollee().getShortcode(), equalTo(maEmail.getShortcode()));

facet = new SqlSearchableFacet(new StringFacetValue(
"keyword", List.of("a.com")), new KeywordFacetSqlGenerator());
result = enrolleeSearchDao.search(studyEnv.getId(), List.of(facet));
assertThat(result, hasSize(2));
}

@Test
@Transactional
public void testProfileAgeSearch() {
Expand Down
14 changes: 8 additions & 6 deletions ui-admin/src/api/enrolleeSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
IntRangeFacetValue,
newFacetValue,
facetValuesToString, StableIdStringArrayFacetValue,
StringFacetValue, facetValuesFromString
StringOptionsFacetValue, facetValuesFromString
} from './enrolleeSearch'

const rangeFacet: Facet = {
Expand All @@ -19,7 +19,7 @@ const stringFacet: Facet = {
category: 'profile',
keyName: 'sexAtBirth',
label: 'Sex at birth',
type: 'STRING',
type: 'STRING_OPTIONS',
options: [
{ value: 'male', label: 'male' },
{ value: 'female', label: 'female' },
Expand Down Expand Up @@ -61,13 +61,14 @@ describe('enrolleeSearch newFacetValue', () => {
})

it('gets a default value for string facets', () => {
const facetVal: StringFacetValue = newFacetValue(stringFacet) as StringFacetValue
const facetVal: StringOptionsFacetValue = newFacetValue(stringFacet) as StringOptionsFacetValue
expect(facetVal.isDefault()).toEqual(true)
expect(facetVal.values).toEqual([])
})

it('gets a facet value with specified value for string values', () => {
const facetVal: StringFacetValue = newFacetValue(stringFacet, { values: ['male'] }) as StringFacetValue
const facetVal: StringOptionsFacetValue =
newFacetValue(stringFacet, { values: ['male'] }) as StringOptionsFacetValue
expect(facetVal.isDefault()).toEqual(false)
expect(facetVal.values).toEqual(['male'])
})
Expand All @@ -92,12 +93,13 @@ describe('enrolleeSearch facetValuesToString', () => {
})

it('renders an empty object for facet list only containing defaults', () => {
const facetVal: StringFacetValue = newFacetValue(stringFacet) as StringFacetValue
const facetVal: StringOptionsFacetValue = newFacetValue(stringFacet) as StringOptionsFacetValue
expect(facetValuesToString([facetVal])).toEqual('{}')
})

it('renders an object for facet list containing non-default values', () => {
const facetVal: StringFacetValue = newFacetValue(stringFacet, { values: ['male'] }) as StringFacetValue
const facetVal: StringOptionsFacetValue = newFacetValue(stringFacet,
{ values: ['male'] }) as StringOptionsFacetValue
expect(facetValuesToString([facetVal])).toEqual('{"profile":{"sexAtBirth":{"values":["male"]}}}')
})
})
Expand Down
49 changes: 44 additions & 5 deletions ui-admin/src/api/enrolleeSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@ export interface IFacetValue {
facet: Facet
}
export type StringFacetValueFields = { values: string[] }
export class StringOptionsFacetValue implements IFacetValue {
values: string[]

facet: StringOptionsFacet

constructor(facet: StringOptionsFacet, facetVal: StringFacetValueFields = { values: [] }) {
this.values = facetVal.values
this.facet = facet
}

isDefault() {
return this.values.length === 0
}
}

export class StringFacetValue implements IFacetValue {
values: string[]

Expand All @@ -17,6 +32,7 @@ export class StringFacetValue implements IFacetValue {
return this.values.length === 0
}
}

export type IntRangeFacetValueFields = {
min: number | null
max: number | null
Expand Down Expand Up @@ -69,9 +85,10 @@ export class StableIdStringArrayFacetValue implements IFacetValue {
}
}

export type FacetValue = StringFacetValue | IntRangeFacetValue | StableIdStringArrayFacetValue
export type FacetValue = StringOptionsFacetValue | IntRangeFacetValue |
StableIdStringArrayFacetValue | StringFacetValue

export type FacetType = | 'INT_RANGE' | 'STRING' | 'STABLEID_STRING'
export type FacetType = | 'INT_RANGE' | 'STRING' | 'STRING_OPTIONS' | 'STABLEID_STRING'

export type BaseFacet = {
keyName: string,
Expand All @@ -92,6 +109,12 @@ export type FacetOption = {

export type StringFacet = BaseFacet & {
type: 'STRING',
title: string,
placeholder: string
}

export type StringOptionsFacet = BaseFacet & {
type: 'STRING_OPTIONS',
options: FacetOption[]
}

Expand All @@ -101,9 +124,9 @@ export type StableIdStringArrayFacet = BaseFacet & {
stableIdOptions: FacetOption[]
}

export type Facet = StringFacet | StableIdStringArrayFacet | IntRangeFacet
export type Facet = StringFacet | StringOptionsFacet | StableIdStringArrayFacet | IntRangeFacet

export const SAMPLE_FACETS: Facet[] = [{
export const ADVANCED_FACETS: Facet[] = [{
category: 'profile',
keyName: 'age',
label: 'Age',
Expand All @@ -114,7 +137,7 @@ export const SAMPLE_FACETS: Facet[] = [{
category: 'profile',
keyName: 'sexAtBirth',
label: 'Sex at birth',
type: 'STRING',
type: 'STRING_OPTIONS',
options: [
{ value: 'male', label: 'male' },
{ value: 'female', label: 'female' },
Expand All @@ -138,6 +161,20 @@ export const SAMPLE_FACETS: Facet[] = [{
]
}]

export const KEYWORD_FACET: Facet = {
category: 'keyword',
keyName: 'keyword',
label: 'Keyword',
type: 'STRING',
title: 'search name, email and shortcode',
placeholder: 'Search name, email and shortcode...'
}

export const ALL_FACETS = [
...ADVANCED_FACETS,
KEYWORD_FACET
]

/** helper function for making sure a function addresses all facet types.
* returnPlaceholder isn't used, but is helpful for when this is needed at the end of a function for return type */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand All @@ -157,6 +194,8 @@ export const newFacetValue = (facet: Facet, facetValue?: object): FacetValue =>
new StableIdStringValue(stableIdVal.stableId, stableIdVal.values)
) : []
return new StableIdStringArrayFacetValue(facet, { values: newValues })
} else if (facetType === 'STRING_OPTIONS') {
return new StringOptionsFacetValue(facet, facetValue as StringFacetValueFields)
} else if (facetType === 'STRING') {
return new StringFacetValue(facet, facetValue as StringFacetValueFields)
}
Expand Down
Loading

0 comments on commit a6a35ae

Please sign in to comment.