diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ec40f34..c38cb1f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,6 +14,12 @@ jobs: distribution: 'temurin' java-version: '17' + - name: Setup env.properties + run: | + echo "DB_URL=${{ secrets.DB_URL }}" >> src/main/resources/env.properties + echo "DB_USERNAME=${{ secrets.DB_USERNAME }}" >> src/main/resources/env.properties + echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> src/main/resources/env.properties + - name: Cache Maven packages uses: actions/cache@v4 with: diff --git a/pom.xml b/pom.xml index 72aa981..80015cb 100644 --- a/pom.xml +++ b/pom.xml @@ -34,11 +34,11 @@ org.springframework.boot spring-boot-starter-web - + com.mysql mysql-connector-j - runtime + 9.1.0 org.springframework.boot @@ -49,6 +49,12 @@ lombok true + + + org.modelmapper + modelmapper + 3.2.1 + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/com/carpi/carpibackend/Application.java b/src/main/java/com/carpi/carpibackend/Application.java index 21f18c2..3fa06d1 100644 --- a/src/main/java/com/carpi/carpibackend/Application.java +++ b/src/main/java/com/carpi/carpibackend/Application.java @@ -9,5 +9,4 @@ public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } - } diff --git a/src/main/java/com/carpi/carpibackend/ApplicationConfig.java b/src/main/java/com/carpi/carpibackend/ApplicationConfig.java new file mode 100644 index 0000000..0d2198e --- /dev/null +++ b/src/main/java/com/carpi/carpibackend/ApplicationConfig.java @@ -0,0 +1,35 @@ +package com.carpi.carpibackend; + +import org.modelmapper.Converter; +import org.modelmapper.ModelMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.carpi.carpibackend.dto.CourseDto; +import com.carpi.carpibackend.entity.CourseSearchResult; + +@Configuration +public class ApplicationConfig { + + private static final String[] EMPTY_LIST = new String[0]; + + @Bean + public ModelMapper modelMapper() { + ModelMapper modelMapper = new ModelMapper(); + Converter split = + ctx -> ctx.getSource() == null ? EMPTY_LIST : ctx.getSource().split(","); + modelMapper.typeMap(CourseSearchResult.class, CourseDto.class).addMappings( + mapper -> { + mapper.using(split).map( + CourseSearchResult::getSemesterList, + CourseDto::setSemesterList + ); + mapper.using(split).map( + CourseSearchResult::getAttributeList, + CourseDto::setAttributeList + ); + } + ); + return modelMapper; + } +} diff --git a/src/main/java/com/carpi/carpibackend/controller/CourseController.java b/src/main/java/com/carpi/carpibackend/controller/CourseController.java index 350c05a..ed541ec 100644 --- a/src/main/java/com/carpi/carpibackend/controller/CourseController.java +++ b/src/main/java/com/carpi/carpibackend/controller/CourseController.java @@ -1,13 +1,21 @@ package com.carpi.carpibackend.controller; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.beans.factory.annotation.Autowired; - +import java.util.Arrays; import java.util.List; +import java.util.stream.Collectors; -import com.carpi.carpibackend.entity.Course; -import com.carpi.carpibackend.repository.CourseRepository; +import org.modelmapper.ModelMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.carpi.carpibackend.dto.CourseDto; +import com.carpi.carpibackend.entity.CourseSearchResult; +import com.carpi.carpibackend.service.CourseSearchService; @CrossOrigin @RestController @@ -15,12 +23,32 @@ public class CourseController { @Autowired - private CourseRepository courseRepository; + private CourseSearchService courseSearchService; + + @Autowired + private ModelMapper modelMapper; - @ResponseBody - @GetMapping - public ResponseEntity> getAll() { - return ResponseEntity.ok(courseRepository.findAll()); + @GetMapping("/all") + public ResponseEntity> getAll() { + return searchCourses(null, null, null, null); } + @GetMapping("/search") + public ResponseEntity> searchCourses( + @RequestParam(required = false) String searchPrompt, + @RequestParam(required = false) String[] deptFilters, + @RequestParam(required = false) String[] attrFilters, + @RequestParam(required = false) String[] semFilters + ) { + List searchResults = courseSearchService.searchCourses( + searchPrompt, + deptFilters, + attrFilters, + semFilters + ); + List courseDtos = searchResults.stream().map( + result -> modelMapper.map(result, CourseDto.class) + ).collect(Collectors.toList()); + return ResponseEntity.ok(courseDtos); + } } diff --git a/src/main/java/com/carpi/carpibackend/dto/CourseDto.java b/src/main/java/com/carpi/carpibackend/dto/CourseDto.java new file mode 100644 index 0000000..db391e7 --- /dev/null +++ b/src/main/java/com/carpi/carpibackend/dto/CourseDto.java @@ -0,0 +1,26 @@ +package com.carpi.carpibackend.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter + +public class CourseDto { + + private String department; + + private int code; + + private String title; + + private String description; + + private short creditMin; + + private short creditMax; + + private String[] semesterList; + + private String[] attributeList; +} diff --git a/src/main/java/com/carpi/carpibackend/entity/Course.java b/src/main/java/com/carpi/carpibackend/entity/Course.java index 5e70efc..f662b97 100644 --- a/src/main/java/com/carpi/carpibackend/entity/Course.java +++ b/src/main/java/com/carpi/carpibackend/entity/Course.java @@ -6,7 +6,6 @@ import jakarta.persistence.EmbeddedId; import jakarta.persistence.Entity; import jakarta.persistence.Table; - import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/carpi/carpibackend/entity/CourseSearchResult.java b/src/main/java/com/carpi/carpibackend/entity/CourseSearchResult.java new file mode 100644 index 0000000..41c33be --- /dev/null +++ b/src/main/java/com/carpi/carpibackend/entity/CourseSearchResult.java @@ -0,0 +1,61 @@ +package com.carpi.carpibackend.entity; + +import com.carpi.carpibackend.keys.CourseKey; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter + +@Entity +public class CourseSearchResult { + + @EmbeddedId + private CourseKey pkCourses; + + @Column(name = "dept", nullable = false) + private String department; + + @Column(name = "code_num", nullable = false) + private int code; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "desc_text", nullable = false) + private String description; + + @Column(name = "credit_min", nullable = false) + private short creditMin; + + @Column(name = "credit_max", nullable = false) + private short creditMax; + + @Column(name = "sem_list", nullable = false) + private String semesterList; + + @Column(name = "attr_list", nullable = true) + private String attributeList; + + @Column(name = "code_match", nullable = false) + private boolean codeMatch; + + @Column(name = "title_exact_match", nullable = false) + private boolean titleExactMatch; + + @Column(name = "title_start_match", nullable = false) + private boolean titleStartMatch; + + @Column(name = "title_match", nullable = false) + private boolean titleMatch; + + @Column(name = "title_acronym", nullable = false) + private boolean titleAcronym; + + @Column(name = "title_abbrev", nullable = false) + private boolean titleAbbrev; +} diff --git a/src/main/java/com/carpi/carpibackend/keys/CourseKey.java b/src/main/java/com/carpi/carpibackend/keys/CourseKey.java index c13f09e..18704b8 100644 --- a/src/main/java/com/carpi/carpibackend/keys/CourseKey.java +++ b/src/main/java/com/carpi/carpibackend/keys/CourseKey.java @@ -1,12 +1,11 @@ package com.carpi.carpibackend.keys; +import java.io.Serializable; + import jakarta.persistence.Column; import jakarta.persistence.Embeddable; - -import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; - -import java.io.Serializable; +import lombok.NoArgsConstructor; @NoArgsConstructor @AllArgsConstructor @@ -20,4 +19,22 @@ public class CourseKey implements Serializable { @Column(name = "code_num", insertable = false, updatable = false, nullable = false) private int code; + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj instanceof CourseKey) { + CourseKey other = (CourseKey) obj; + return department.equals(other.department) && code == other.code; + } + return false; + } + + @Override + public int hashCode() { + int result = department.hashCode(); + result = 31 * result + Integer.hashCode(code); + return result; + } } diff --git a/src/main/java/com/carpi/carpibackend/repository/CourseRepository.java b/src/main/java/com/carpi/carpibackend/repository/CourseRepository.java index 8edc1ee..fd63c14 100644 --- a/src/main/java/com/carpi/carpibackend/repository/CourseRepository.java +++ b/src/main/java/com/carpi/carpibackend/repository/CourseRepository.java @@ -1,10 +1,12 @@ package com.carpi.carpibackend.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; -import com.carpi.carpibackend.keys.CourseKey; import com.carpi.carpibackend.entity.Course; +import com.carpi.carpibackend.keys.CourseKey; +@Repository public interface CourseRepository extends JpaRepository { } diff --git a/src/main/java/com/carpi/carpibackend/repository/CourseSearchResultRepository.java b/src/main/java/com/carpi/carpibackend/repository/CourseSearchResultRepository.java new file mode 100644 index 0000000..b37bd30 --- /dev/null +++ b/src/main/java/com/carpi/carpibackend/repository/CourseSearchResultRepository.java @@ -0,0 +1,86 @@ +package com.carpi.carpibackend.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import com.carpi.carpibackend.entity.CourseSearchResult; +import com.carpi.carpibackend.keys.CourseKey; + +@Repository +public interface CourseSearchResultRepository extends JpaRepository { + + @Query( + value = """ + SELECT + course.dept AS dept, + course.code_num AS code_num, + course.title AS title, + course.desc_text AS desc_text, + course.credit_min AS credit_min, + course.credit_max AS credit_max, + GROUP_CONCAT(DISTINCT CONCAT(course_seats.semester, ' ', course_seats.sem_year)) AS sem_list, + GROUP_CONCAT(DISTINCT course_attribute.attr ORDER BY course_attribute.attr ASC) AS attr_list, + REGEXP_LIKE(CONCAT(course.dept, ' ', course.code_num), ?1, 'i') AS code_match, + REGEXP_LIKE(course.title, ?2, 'i') AS title_exact_match, + REGEXP_LIKE(course.title, ?3, 'i') AS title_start_match, + REGEXP_LIKE(course.title, ?4, 'i') AS title_match, + REGEXP_LIKE(course.title, ?5, 'i') AS title_acronym, + REGEXP_LIKE(course.title, ?6, 'i') AS title_abbrev + FROM + course + INNER JOIN course_seats USING(dept, code_num) + LEFT JOIN course_attribute USING(dept, code_num) + WHERE + REGEXP_LIKE(dept, ?7, 'i') > 0 + GROUP BY + dept, + code_num, + title, + desc_text, + credit_min, + credit_max, + code_match, + title_exact_match, + title_start_match, + title_match, + title_acronym, + title_abbrev + HAVING + ( + code_match > 0 + OR title_exact_match > 0 + OR title_start_match > 0 + OR title_match > 0 + OR title_acronym > 0 + OR title_abbrev > 0 + ) + AND REGEXP_LIKE(IFNULL(attr_list, ''), ?8, 'i') > 0 + AND REGEXP_LIKE(sem_list, ?9, 'i') > 0 + ORDER BY + code_match DESC, + title_exact_match DESC, + title_start_match DESC, + title_match DESC, + title_acronym DESC, + title_abbrev DESC, + code_num ASC, + dept ASC + ; + """, + nativeQuery = true + ) + public List searchCourses( + String searchCodeRegex, + String searchFullRegex, + String searchStartRegex, + String searchAnyRegex, + String searchAcronymRegex, + String searchAbbrevRegex, + String deptFilterRegex, + String attrFilterRegex, + String semFilterRegex + ); +} diff --git a/src/main/java/com/carpi/carpibackend/service/CourseSearchService.java b/src/main/java/com/carpi/carpibackend/service/CourseSearchService.java new file mode 100644 index 0000000..b29d772 --- /dev/null +++ b/src/main/java/com/carpi/carpibackend/service/CourseSearchService.java @@ -0,0 +1,106 @@ +package com.carpi.carpibackend.service; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.carpi.carpibackend.entity.CourseSearchResult; +import com.carpi.carpibackend.repository.CourseSearchResultRepository; + +@Service +public class CourseSearchService { + + @Autowired + private CourseSearchResultRepository courseSearchResultRepository; + + /** + * @author Jack Zgombic + * @author Raymond Chen + * @param searchPrompt Prompt used to search for relevant courses. May be null. + * @param deptFilters Department filters (e.g. "CSCI", "MATH"). May be null. + * @param attrFilters Attribute filters (e.g. "HASS Inquiry"). May be null. + * @param semFilters Semester filters (e.g. "Fall", "Spring"). May be null. + * @return A list containing the most relevant courses according to the given + * search prompt and filters, or a list of all courses if all arguments + * are null. + */ + public List searchCourses( + String searchPrompt, + String[] deptFilters, + String[] attrFilters, + String[] semFilters + ) { + String[] deptFiltersCopy = null, + attrFiltersCopy = null, + semFiltersCopy = null; + String deptFilterRegex = ".*", + attrFilterRegex = ".*", + semFilterRegex = ".*"; + if (deptFilters != null && deptFilters.length > 0) { + deptFiltersCopy = Arrays.copyOf(deptFilters, deptFilters.length); + Arrays.sort(deptFiltersCopy); + deptFilterRegex = String.join("|", deptFiltersCopy); + } + if (attrFilters != null && attrFilters.length > 0) { + attrFiltersCopy = Arrays.copyOf(attrFilters, attrFilters.length); + Arrays.sort(attrFiltersCopy); + attrFilterRegex = String.join(".*", attrFiltersCopy); + } + if (semFilters != null && semFilters.length > 0) { + semFiltersCopy = Arrays.copyOf(semFilters, semFilters.length); + Arrays.sort(semFiltersCopy); + semFilterRegex = String.join(".*", semFiltersCopy); + } + if (searchPrompt == null) { + return courseSearchResultRepository.searchCourses( + ".*", + ".*", + ".*", + ".*", + ".*", + ".*", + deptFilterRegex, + attrFilterRegex, + semFilterRegex + ); + } + final String regStartOrSpace = "(^|.* )"; + String searchCodeRegex = "^" + searchPrompt + "$", + searchFullRegex = "^" + searchPrompt + "$", + searchStartRegex = "^" + searchPrompt, + searchAnyRegex = searchPrompt, + searchAcronymRegex = regStartOrSpace; + for (int i = 0; i < searchPrompt.length(); ++i) { + char ch = searchPrompt.charAt(i); + if (ch != ' ') { + searchAcronymRegex += ch + ".* "; + } + } + searchAcronymRegex = searchAcronymRegex.substring(0, searchAcronymRegex.length() - 3); + String searchAbbrevRegex = ""; + String[] tokens = searchPrompt.split(" "); + if (tokens.length > 1) { + searchAbbrevRegex += regStartOrSpace; + for (int i = 0; i < tokens.length; ++i) { + searchAbbrevRegex += tokens[i] + ".* "; + } + searchAbbrevRegex = searchAbbrevRegex.substring(0, searchAbbrevRegex.length() - 3); + } + else { + searchAbbrevRegex = "a^"; + } + return courseSearchResultRepository.searchCourses( + searchCodeRegex, + searchFullRegex, + searchStartRegex, + searchAnyRegex, + searchAcronymRegex, + searchAbbrevRegex, + deptFilterRegex, + attrFilterRegex, + semFilterRegex + ); + } +} diff --git a/src/main/resources/.env.properties.example b/src/main/resources/env.properties.example similarity index 100% rename from src/main/resources/.env.properties.example rename to src/main/resources/env.properties.example