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