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/.gitignore b/.gitignore index 5eac309..54f2b0f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,7 @@ build/ !**/src/test/**/build/ ### VS Code ### -.vscode/ \ No newline at end of file +.vscode/ + +### Enviroment ### +env.properties diff --git a/pom.xml b/pom.xml index 14ce122..80015cb 100644 --- a/pom.xml +++ b/pom.xml @@ -34,17 +34,27 @@ org.springframework.boot spring-boot-starter-web - + com.mysql mysql-connector-j - runtime + 9.1.0 + + + org.springframework.boot + spring-boot-starter-data-jpa org.projectlombok 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 new file mode 100644 index 0000000..ed541ec --- /dev/null +++ b/src/main/java/com/carpi/carpibackend/controller/CourseController.java @@ -0,0 +1,54 @@ +package com.carpi.carpibackend.controller; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +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 +@RequestMapping("/api/v1/course") +public class CourseController { + + @Autowired + private CourseSearchService courseSearchService; + + @Autowired + private ModelMapper modelMapper; + + @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 new file mode 100644 index 0000000..f662b97 --- /dev/null +++ b/src/main/java/com/carpi/carpibackend/entity/Course.java @@ -0,0 +1,39 @@ +package com.carpi.carpibackend.entity; + +import com.carpi.carpibackend.keys.CourseKey; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter + +@Entity +@Table(name = "course") +public class Course { + + @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; +} 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 new file mode 100644 index 0000000..18704b8 --- /dev/null +++ b/src/main/java/com/carpi/carpibackend/keys/CourseKey.java @@ -0,0 +1,40 @@ +package com.carpi.carpibackend.keys; + +import java.io.Serializable; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor + +@Embeddable +public class CourseKey implements Serializable { + + @Column(name = "dept", insertable = false, updatable = false, nullable = false) + private String department; + + @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 new file mode 100644 index 0000000..fd63c14 --- /dev/null +++ b/src/main/java/com/carpi/carpibackend/repository/CourseRepository.java @@ -0,0 +1,12 @@ +package com.carpi.carpibackend.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +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/application.properties b/src/main/resources/application.properties index ddfa32a..8d87d89 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,7 @@ spring.application.name=carpi-backend + +spring.config.import=file:src/main/resources/env.properties + +spring.datasource.url=${DB_URL} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} diff --git a/src/main/resources/env.properties.example b/src/main/resources/env.properties.example new file mode 100644 index 0000000..b721dd4 --- /dev/null +++ b/src/main/resources/env.properties.example @@ -0,0 +1,3 @@ +DB_URL= +DB_USERNAME= +DB_PASSWORD=