| 1 | package edu.ucsb.cs.scaffold.services; | |
| 2 | ||
| 3 | import com.fasterxml.jackson.core.JsonProcessingException; | |
| 4 | import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; | |
| 5 | import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; | |
| 6 | import edu.ucsb.cs.scaffold.entity.Concept; | |
| 7 | import edu.ucsb.cs.scaffold.entity.ConceptEdge; | |
| 8 | import edu.ucsb.cs.scaffold.entity.Course; | |
| 9 | import edu.ucsb.cs.scaffold.entity.PracticeProblem; | |
| 10 | import edu.ucsb.cs.scaffold.errors.EntityNotFoundException; | |
| 11 | import edu.ucsb.cs.scaffold.model.ConceptGraphYamlDTO; | |
| 12 | import edu.ucsb.cs.scaffold.model.UserState; | |
| 13 | import edu.ucsb.cs.scaffold.repository.ConceptEdgeRepository; | |
| 14 | import edu.ucsb.cs.scaffold.repository.ConceptRepository; | |
| 15 | import edu.ucsb.cs.scaffold.repository.CourseRepository; | |
| 16 | import edu.ucsb.cs.scaffold.repository.PracticeProblemRepository; | |
| 17 | import edu.ucsb.cs.scaffold.repository.UserStateRepository; | |
| 18 | import java.io.IOException; | |
| 19 | import java.io.InputStream; | |
| 20 | import java.util.ArrayList; | |
| 21 | import java.util.Comparator; | |
| 22 | import java.util.HashMap; | |
| 23 | import java.util.HashSet; | |
| 24 | import java.util.LinkedHashMap; | |
| 25 | import java.util.List; | |
| 26 | import java.util.Map; | |
| 27 | import java.util.Set; | |
| 28 | import java.util.stream.Collectors; | |
| 29 | import lombok.RequiredArgsConstructor; | |
| 30 | import org.springframework.stereotype.Service; | |
| 31 | import org.springframework.transaction.annotation.Transactional; | |
| 32 | ||
| 33 | /** | |
| 34 | * Exports and imports a course's entire concept-graph content (concepts, subconcepts, prerequisite | |
| 35 | * edges, and practice problems) as a human-editable YAML document. See docs/yaml-format.md for the | |
| 36 | * format specification. | |
| 37 | */ | |
| 38 | @Service | |
| 39 | @RequiredArgsConstructor | |
| 40 | public class ConceptYamlService { | |
| 41 | ||
| 42 | // Emits multi-line Markdown as | block scalars and leaves simple strings unquoted, so the | |
| 43 | // export is pleasant to hand-edit. Reading is strict (FAIL_ON_UNKNOWN_PROPERTIES is on by | |
| 44 | // default outside Spring): an unknown key in an uploaded file is almost always a typo. | |
| 45 | private static final YAMLMapper YAML_MAPPER = | |
| 46 | YAMLMapper.builder() | |
| 47 | .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) | |
| 48 | .enable(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE) | |
| 49 | .enable(YAMLGenerator.Feature.INDENT_ARRAYS_WITH_INDICATOR) | |
| 50 | .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) | |
| 51 | .build(); | |
| 52 | ||
| 53 | // The order subconcepts are listed under their parent: the author's ordering, with the same | |
| 54 | // id tiebreaker as the /api/concepts/graph endpoint. | |
| 55 | private static final Comparator<Concept> SUBCONCEPT_DISPLAY_ORDER = | |
| 56 | Comparator.comparing(Concept::getSortOrder, Comparator.nullsLast(Comparator.naturalOrder())) | |
| 57 | .thenComparing(Concept::getId); | |
| 58 | ||
| 59 | // Matches the practice_problems.url column width. | |
| 60 | public static final int MAX_URL_LENGTH = 512; | |
| 61 | ||
| 62 | private final ConceptRepository conceptRepository; | |
| 63 | private final ConceptEdgeRepository conceptEdgeRepository; | |
| 64 | private final PracticeProblemRepository practiceProblemRepository; | |
| 65 | private final CourseRepository courseRepository; | |
| 66 | private final UserStateRepository userStateRepository; | |
| 67 | private final MarkdownService markdownService; | |
| 68 | private final ConceptGraphService conceptGraphService; | |
| 69 | ||
| 70 | /** | |
| 71 | * The complete concept-graph content of the course as a YAML document (see docs/yaml-format.md). | |
| 72 | * Top-level concepts are numbered with consecutive external ids 1..n in database-id order; edges | |
| 73 | * refer to those external ids. | |
| 74 | */ | |
| 75 | public String createYAML(long courseId) throws EntityNotFoundException, JsonProcessingException { | |
| 76 | Course course = | |
| 77 | courseRepository | |
| 78 | .findById(courseId) | |
| 79 |
1
1. lambda$createYAML$0 : replaced return value with null for edu/ucsb/cs/scaffold/services/ConceptYamlService::lambda$createYAML$0 → KILLED |
.orElseThrow(() -> new EntityNotFoundException(Course.class, courseId)); |
| 80 | ||
| 81 | List<Concept> concepts = conceptRepository.findByCourseId(courseId); | |
| 82 | ||
| 83 | List<Concept> topLevelConcepts = | |
| 84 | concepts.stream() | |
| 85 |
2
1. lambda$createYAML$1 : replaced boolean return with true for edu/ucsb/cs/scaffold/services/ConceptYamlService::lambda$createYAML$1 → KILLED 2. lambda$createYAML$1 : negated conditional → KILLED |
.filter(concept -> !concept.isSubconcept()) |
| 86 | .sorted(Comparator.comparing(Concept::getId)) | |
| 87 | .toList(); | |
| 88 | ||
| 89 | Map<Long, List<Concept>> subconceptsByParentId = | |
| 90 | concepts.stream() | |
| 91 | .filter(Concept::isSubconcept) | |
| 92 | .sorted(SUBCONCEPT_DISPLAY_ORDER) | |
| 93 | .collect( | |
| 94 | Collectors.groupingBy( | |
| 95 |
1
1. lambda$createYAML$2 : replaced Long return value with 0L for edu/ucsb/cs/scaffold/services/ConceptYamlService::lambda$createYAML$2 → KILLED |
concept -> concept.getParent().getId(), HashMap::new, Collectors.toList())); |
| 96 | ||
| 97 | Map<Long, List<String>> urlsByConceptId = urlsByConceptId(courseId); | |
| 98 | ||
| 99 | Map<Long, Long> externalIdByInternalId = new HashMap<>(); | |
| 100 |
2
1. createYAML : changed conditional boundary → KILLED 2. createYAML : negated conditional → KILLED |
for (int i = 0; i < topLevelConcepts.size(); i++) { |
| 101 |
1
1. createYAML : Replaced integer addition with subtraction → KILLED |
externalIdByInternalId.put(topLevelConcepts.get(i).getId(), (long) (i + 1)); |
| 102 | } | |
| 103 | ||
| 104 | List<ConceptGraphYamlDTO.ConceptNodeDTO> conceptNodes = new ArrayList<>(); | |
| 105 | for (Concept concept : topLevelConcepts) { | |
| 106 | List<ConceptGraphYamlDTO.SubconceptNodeDTO> subconceptNodes = | |
| 107 | subconceptsByParentId.getOrDefault(concept.getId(), List.of()).stream() | |
| 108 | .map( | |
| 109 | sub -> | |
| 110 |
1
1. lambda$createYAML$3 : replaced return value with null for edu/ucsb/cs/scaffold/services/ConceptYamlService::lambda$createYAML$3 → KILLED |
ConceptGraphYamlDTO.SubconceptNodeDTO.builder() |
| 111 | .label(sub.getLabel()) | |
| 112 | .description(sub.getDescription()) | |
| 113 | .example(sub.getExample()) | |
| 114 | .practiceProblems(urlsByConceptId.get(sub.getId())) | |
| 115 | .build()) | |
| 116 | .toList(); | |
| 117 | conceptNodes.add( | |
| 118 | ConceptGraphYamlDTO.ConceptNodeDTO.builder() | |
| 119 | .id(externalIdByInternalId.get(concept.getId())) | |
| 120 | .label(concept.getLabel()) | |
| 121 | .color(concept.getColor()) | |
| 122 | .level(concept.getLevel()) | |
| 123 | .x(concept.getX()) | |
| 124 | .y(concept.getY()) | |
| 125 | .description(concept.getDescription()) | |
| 126 | .example(concept.getExample()) | |
| 127 | .practiceProblems(urlsByConceptId.get(concept.getId())) | |
| 128 |
1
1. createYAML : negated conditional → KILLED |
.subconcepts(subconceptNodes.isEmpty() ? null : subconceptNodes) |
| 129 | .build()); | |
| 130 | } | |
| 131 | ||
| 132 | List<ConceptGraphYamlDTO.EdgeNodeDTO> edgeNodes = | |
| 133 | conceptEdgeRepository.findByCourseId(courseId).stream() | |
| 134 | .map( | |
| 135 | edge -> | |
| 136 |
1
1. lambda$createYAML$4 : replaced return value with null for edu/ucsb/cs/scaffold/services/ConceptYamlService::lambda$createYAML$4 → KILLED |
ConceptGraphYamlDTO.EdgeNodeDTO.builder() |
| 137 | .from(externalIdByInternalId.get(edge.getSource().getId())) | |
| 138 | .to(externalIdByInternalId.get(edge.getTarget().getId())) | |
| 139 | .build()) | |
| 140 | .sorted( | |
| 141 | Comparator.comparing(ConceptGraphYamlDTO.EdgeNodeDTO::getFrom) | |
| 142 | .thenComparing(ConceptGraphYamlDTO.EdgeNodeDTO::getTo)) | |
| 143 | .toList(); | |
| 144 | ||
| 145 | ConceptGraphYamlDTO dto = | |
| 146 | ConceptGraphYamlDTO.builder().format(1).concepts(conceptNodes).edges(edgeNodes).build(); | |
| 147 | ||
| 148 | String header = | |
| 149 | "# Concept graph for course %d (%s)%n# See docs/yaml-format.md for the format.%n" | |
| 150 | .formatted(course.getId(), course.getCourseName()); | |
| 151 |
1
1. createYAML : replaced return value with "" for edu/ucsb/cs/scaffold/services/ConceptYamlService::createYAML → KILLED |
return header + YAML_MAPPER.writeValueAsString(dto); |
| 152 | } | |
| 153 | ||
| 154 | /** | |
| 155 | * Replaces the course's entire concept-graph content with the content of a YAML document in the | |
| 156 | * docs/yaml-format.md format, reporting the outcome as a JSON-shaped map. | |
| 157 | * | |
| 158 | * <p>The replacement is all-or-nothing: the document is fully parsed and validated first, and if | |
| 159 | * anything is wrong the course is left untouched and every problem found is reported under {@code | |
| 160 | * errors}. Only a valid document deletes the course's concepts, edges, practice problems, and | |
| 161 | * per-user scaffold state ({@code user_state} rows, which refer to concepts by ids that no longer | |
| 162 | * exist after a replacement) before creating the new content. | |
| 163 | */ | |
| 164 | @Transactional | |
| 165 | public Map<String, Object> replaceFromYAML(long courseId, InputStream yamlStream) | |
| 166 | throws EntityNotFoundException { | |
| 167 | Course course = | |
| 168 | courseRepository | |
| 169 | .findById(courseId) | |
| 170 |
1
1. lambda$replaceFromYAML$5 : replaced return value with null for edu/ucsb/cs/scaffold/services/ConceptYamlService::lambda$replaceFromYAML$5 → KILLED |
.orElseThrow(() -> new EntityNotFoundException(Course.class, courseId)); |
| 171 | ||
| 172 | List<String> errors = new ArrayList<>(); | |
| 173 | ConceptGraphYamlDTO dto = parseYaml(yamlStream, errors); | |
| 174 |
1
1. replaceFromYAML : negated conditional → KILLED |
if (dto != null) { |
| 175 |
1
1. replaceFromYAML : removed call to edu/ucsb/cs/scaffold/services/ConceptYamlService::validate → KILLED |
validate(dto, errors); |
| 176 | } | |
| 177 |
1
1. replaceFromYAML : negated conditional → KILLED |
if (!errors.isEmpty()) { |
| 178 |
1
1. replaceFromYAML : replaced return value with Collections.emptyMap for edu/ucsb/cs/scaffold/services/ConceptYamlService::replaceFromYAML → KILLED |
return report(false, errors, 0, 0, 0, 0, 0); |
| 179 | } | |
| 180 | ||
| 181 | int userStatesCleared = deleteCourseContentAndUserState(courseId); | |
| 182 | ||
| 183 | int conceptsCreated = 0; | |
| 184 | int subconceptsCreated = 0; | |
| 185 | int edgesCreated = 0; | |
| 186 | int practiceProblemsCreated = 0; | |
| 187 | ||
| 188 | Map<Long, Concept> savedByExternalId = new HashMap<>(); | |
| 189 | for (ConceptGraphYamlDTO.ConceptNodeDTO node : dto.getConcepts()) { | |
| 190 |
1
1. replaceFromYAML : negated conditional → KILLED |
int level = node.getLevel() != null ? node.getLevel() : 1; |
| 191 | Concept saved = | |
| 192 | conceptRepository.save( | |
| 193 | Concept.builder() | |
| 194 | .course(course) | |
| 195 | .label(markdownService.clean(node.getLabel())) | |
| 196 | .description(markdownService.clean(node.getDescription())) | |
| 197 | .example(markdownService.clean(node.getExample())) | |
| 198 | .color( | |
| 199 |
1
1. replaceFromYAML : negated conditional → KILLED |
node.getColor() != null |
| 200 | ? node.getColor() | |
| 201 | : conceptGraphService.colorForLevel(level)) | |
| 202 | .level(level) | |
| 203 |
1
1. replaceFromYAML : negated conditional → KILLED |
.x(node.getX() != null ? node.getX() : 0) |
| 204 |
1
1. replaceFromYAML : negated conditional → KILLED |
.y(node.getY() != null ? node.getY() : 0) |
| 205 | .build()); | |
| 206 | savedByExternalId.put(node.getId(), saved); | |
| 207 |
1
1. replaceFromYAML : Changed increment from 1 to -1 → KILLED |
conceptsCreated++; |
| 208 |
1
1. replaceFromYAML : Replaced integer addition with subtraction → KILLED |
practiceProblemsCreated += savePracticeProblems(course, saved, node.getPracticeProblems()); |
| 209 | ||
| 210 | List<ConceptGraphYamlDTO.SubconceptNodeDTO> subconcepts = | |
| 211 |
1
1. replaceFromYAML : negated conditional → KILLED |
node.getSubconcepts() != null ? node.getSubconcepts() : List.of(); |
| 212 |
2
1. replaceFromYAML : changed conditional boundary → KILLED 2. replaceFromYAML : negated conditional → KILLED |
for (int i = 0; i < subconcepts.size(); i++) { |
| 213 | ConceptGraphYamlDTO.SubconceptNodeDTO subNode = subconcepts.get(i); | |
| 214 | Concept savedSub = | |
| 215 | conceptRepository.save( | |
| 216 | Concept.builder() | |
| 217 | .course(course) | |
| 218 | .label(markdownService.clean(subNode.getLabel())) | |
| 219 | .description(markdownService.clean(subNode.getDescription())) | |
| 220 | .example(markdownService.clean(subNode.getExample())) | |
| 221 | .parent(saved) | |
| 222 | .sortOrder(i) | |
| 223 | .build()); | |
| 224 |
1
1. replaceFromYAML : Changed increment from 1 to -1 → KILLED |
subconceptsCreated++; |
| 225 | practiceProblemsCreated += | |
| 226 |
1
1. replaceFromYAML : Replaced integer addition with subtraction → KILLED |
savePracticeProblems(course, savedSub, subNode.getPracticeProblems()); |
| 227 | } | |
| 228 | } | |
| 229 | ||
| 230 | for (ConceptGraphYamlDTO.EdgeNodeDTO edgeNode : | |
| 231 |
1
1. replaceFromYAML : negated conditional → KILLED |
dto.getEdges() != null ? dto.getEdges() : List.<ConceptGraphYamlDTO.EdgeNodeDTO>of()) { |
| 232 | conceptEdgeRepository.save( | |
| 233 | ConceptEdge.builder() | |
| 234 | .course(course) | |
| 235 | .source(savedByExternalId.get(edgeNode.getFrom())) | |
| 236 | .target(savedByExternalId.get(edgeNode.getTo())) | |
| 237 | .build()); | |
| 238 |
1
1. replaceFromYAML : Changed increment from 1 to -1 → KILLED |
edgesCreated++; |
| 239 | } | |
| 240 | ||
| 241 |
1
1. replaceFromYAML : replaced return value with Collections.emptyMap for edu/ucsb/cs/scaffold/services/ConceptYamlService::replaceFromYAML → KILLED |
return report( |
| 242 | true, | |
| 243 | errors, | |
| 244 | conceptsCreated, | |
| 245 | subconceptsCreated, | |
| 246 | edgesCreated, | |
| 247 | practiceProblemsCreated, | |
| 248 | userStatesCleared); | |
| 249 | } | |
| 250 | ||
| 251 | private ConceptGraphYamlDTO parseYaml(InputStream yamlStream, List<String> errors) { | |
| 252 | // Read the stream ourselves before handing the text to Jackson: Jackson wraps stream | |
| 253 | // IOExceptions in JsonProcessingException, which would make an unreadable upload look | |
| 254 | // like a syntax error in the file. | |
| 255 | try { | |
| 256 | String yaml = new String(yamlStream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); | |
| 257 |
1
1. parseYaml : replaced return value with null for edu/ucsb/cs/scaffold/services/ConceptYamlService::parseYaml → KILLED |
return YAML_MAPPER.readValue(yaml, ConceptGraphYamlDTO.class); |
| 258 | } catch (JsonProcessingException e) { | |
| 259 | errors.add("could not parse YAML: " + e.getOriginalMessage()); | |
| 260 | } catch (IOException e) { | |
| 261 | errors.add("could not read file: " + e.getMessage()); | |
| 262 | } | |
| 263 | return null; | |
| 264 | } | |
| 265 | ||
| 266 | /** | |
| 267 | * Checks everything that could make the document unloadable or leave the course in a state the | |
| 268 | * rest of the app does not expect (the same invariants the single-concept endpoints enforce). | |
| 269 | * Every problem found is appended to {@code errors}; an empty list afterward means the document | |
| 270 | * is safe to load. | |
| 271 | */ | |
| 272 | private void validate(ConceptGraphYamlDTO dto, List<String> errors) { | |
| 273 |
1
1. validate : negated conditional → KILLED |
if (dto.getFormat() == null) { |
| 274 | errors.add("format is required (expected: format: 1)"); | |
| 275 |
1
1. validate : negated conditional → KILLED |
} else if (dto.getFormat() != 1) { |
| 276 | errors.add("unsupported format %d (expected: format: 1)".formatted(dto.getFormat())); | |
| 277 | } | |
| 278 |
1
1. validate : negated conditional → KILLED |
if (dto.getConcepts() == null) { |
| 279 | errors.add("concepts is required (may be an empty list: concepts: [])"); | |
| 280 | } | |
| 281 | ||
| 282 | Set<Long> externalIds = new HashSet<>(); | |
| 283 | List<ConceptGraphYamlDTO.ConceptNodeDTO> concepts = | |
| 284 |
1
1. validate : negated conditional → KILLED |
dto.getConcepts() != null ? dto.getConcepts() : List.of(); |
| 285 |
2
1. validate : negated conditional → KILLED 2. validate : changed conditional boundary → KILLED |
for (int i = 0; i < concepts.size(); i++) { |
| 286 | ConceptGraphYamlDTO.ConceptNodeDTO node = concepts.get(i); | |
| 287 | String where = "concepts[%d]".formatted(i); | |
| 288 |
1
1. validate : negated conditional → KILLED |
if (node == null) { |
| 289 | errors.add(where + " is empty"); | |
| 290 | continue; | |
| 291 | } | |
| 292 |
1
1. validate : negated conditional → KILLED |
if (node.getId() == null) { |
| 293 | errors.add(where + ": id is required"); | |
| 294 |
1
1. validate : negated conditional → KILLED |
} else if (!externalIds.add(node.getId())) { |
| 295 | errors.add(where + ": duplicate id " + node.getId()); | |
| 296 | } | |
| 297 | validateLabel(where, node.getLabel(), errors, Concept.MAX_RENDERED_LABEL_LENGTH); | |
| 298 |
1
1. validate : removed call to edu/ucsb/cs/scaffold/services/ConceptYamlService::validateUrls → KILLED |
validateUrls(where, node.getPracticeProblems(), errors); |
| 299 | ||
| 300 | Set<String> subconceptLabels = new HashSet<>(); | |
| 301 | List<ConceptGraphYamlDTO.SubconceptNodeDTO> subconcepts = | |
| 302 |
1
1. validate : negated conditional → KILLED |
node.getSubconcepts() != null ? node.getSubconcepts() : List.of(); |
| 303 |
2
1. validate : negated conditional → KILLED 2. validate : changed conditional boundary → KILLED |
for (int j = 0; j < subconcepts.size(); j++) { |
| 304 | ConceptGraphYamlDTO.SubconceptNodeDTO subNode = subconcepts.get(j); | |
| 305 | String subWhere = where + ".subconcepts[%d]".formatted(j); | |
| 306 |
1
1. validate : negated conditional → KILLED |
if (subNode == null) { |
| 307 | errors.add(subWhere + " is empty"); | |
| 308 | continue; | |
| 309 | } | |
| 310 | String cleanLabel = | |
| 311 | validateLabel( | |
| 312 | subWhere, subNode.getLabel(), errors, Concept.MAX_RENDERED_SUBCONCEPT_LABEL_LENGTH); | |
| 313 |
2
1. validate : negated conditional → KILLED 2. validate : negated conditional → KILLED |
if (cleanLabel != null && !subconceptLabels.add(cleanLabel)) { |
| 314 | errors.add(subWhere + ": duplicate subconcept label " + cleanLabel); | |
| 315 | } | |
| 316 |
1
1. validate : removed call to edu/ucsb/cs/scaffold/services/ConceptYamlService::validateUrls → KILLED |
validateUrls(subWhere, subNode.getPracticeProblems(), errors); |
| 317 | } | |
| 318 | } | |
| 319 | ||
| 320 | // Edges are checked in order against the edges accepted so far, so the cycle message points | |
| 321 | // at the edge that closes the cycle. | |
| 322 | List<ConceptEdge> acceptedEdges = new ArrayList<>(); | |
| 323 | Set<List<Long>> seenEndpoints = new HashSet<>(); | |
| 324 | List<ConceptGraphYamlDTO.EdgeNodeDTO> edges = | |
| 325 |
1
1. validate : negated conditional → KILLED |
dto.getEdges() != null ? dto.getEdges() : List.of(); |
| 326 |
2
1. validate : changed conditional boundary → KILLED 2. validate : negated conditional → KILLED |
for (int i = 0; i < edges.size(); i++) { |
| 327 | ConceptGraphYamlDTO.EdgeNodeDTO edge = edges.get(i); | |
| 328 | String where = "edges[%d]".formatted(i); | |
| 329 |
1
1. validate : negated conditional → KILLED |
if (edge == null) { |
| 330 | errors.add(where + " is empty"); | |
| 331 | continue; | |
| 332 | } | |
| 333 |
2
1. validate : negated conditional → KILLED 2. validate : negated conditional → KILLED |
if (edge.getFrom() == null || edge.getTo() == null) { |
| 334 | errors.add(where + ": from and to are required"); | |
| 335 | continue; | |
| 336 | } | |
| 337 | boolean endpointsExist = true; | |
| 338 |
1
1. validate : negated conditional → KILLED |
if (!externalIds.contains(edge.getFrom())) { |
| 339 | errors.add(where + ": no concept with id " + edge.getFrom()); | |
| 340 | endpointsExist = false; | |
| 341 | } | |
| 342 |
1
1. validate : negated conditional → KILLED |
if (!externalIds.contains(edge.getTo())) { |
| 343 | errors.add(where + ": no concept with id " + edge.getTo()); | |
| 344 | endpointsExist = false; | |
| 345 | } | |
| 346 |
1
1. validate : negated conditional → KILLED |
if (!endpointsExist) { |
| 347 | continue; | |
| 348 | } | |
| 349 |
1
1. validate : negated conditional → KILLED |
if (edge.getFrom().equals(edge.getTo())) { |
| 350 | errors.add(where + ": an edge cannot connect a concept to itself"); | |
| 351 | continue; | |
| 352 | } | |
| 353 |
1
1. validate : negated conditional → KILLED |
if (!seenEndpoints.add(List.of(edge.getFrom(), edge.getTo()))) { |
| 354 | errors.add( | |
| 355 | where + ": duplicate edge from %d to %d".formatted(edge.getFrom(), edge.getTo())); | |
| 356 | continue; | |
| 357 | } | |
| 358 |
1
1. validate : negated conditional → KILLED |
if (conceptGraphService.wouldCreateCycle(acceptedEdges, edge.getFrom(), edge.getTo())) { |
| 359 | errors.add( | |
| 360 | where | |
| 361 | + ": edge from %d to %d would create a cycle" | |
| 362 | .formatted(edge.getFrom(), edge.getTo())); | |
| 363 | continue; | |
| 364 | } | |
| 365 | acceptedEdges.add( | |
| 366 | ConceptEdge.builder() | |
| 367 | .source(Concept.builder().id(edge.getFrom()).build()) | |
| 368 | .target(Concept.builder().id(edge.getTo()).build()) | |
| 369 | .build()); | |
| 370 | } | |
| 371 | } | |
| 372 | ||
| 373 | /** | |
| 374 | * Validates a concept or subconcept label the same way the single-concept endpoints do, and | |
| 375 | * returns the cleaned label (used for duplicate detection), or null if it was invalid. | |
| 376 | */ | |
| 377 | private String validateLabel( | |
| 378 | String where, String label, List<String> errors, int maxRenderedLength) { | |
| 379 | String cleanLabel = markdownService.clean(label); | |
| 380 |
2
1. validateLabel : negated conditional → KILLED 2. validateLabel : negated conditional → KILLED |
if (cleanLabel == null || cleanLabel.isEmpty()) { |
| 381 | errors.add(where + ": label may not be empty"); | |
| 382 |
1
1. validateLabel : replaced return value with "" for edu/ucsb/cs/scaffold/services/ConceptYamlService::validateLabel → KILLED |
return null; |
| 383 | } | |
| 384 | int renderedLength = markdownService.renderedLength(cleanLabel); | |
| 385 |
2
1. validateLabel : changed conditional boundary → KILLED 2. validateLabel : negated conditional → KILLED |
if (renderedLength > maxRenderedLength) { |
| 386 | errors.add( | |
| 387 | where | |
| 388 | + ": label renders to %d characters; the maximum is %d" | |
| 389 | .formatted(renderedLength, maxRenderedLength)); | |
| 390 |
1
1. validateLabel : replaced return value with "" for edu/ucsb/cs/scaffold/services/ConceptYamlService::validateLabel → KILLED |
return null; |
| 391 | } | |
| 392 |
1
1. validateLabel : replaced return value with "" for edu/ucsb/cs/scaffold/services/ConceptYamlService::validateLabel → KILLED |
return cleanLabel; |
| 393 | } | |
| 394 | ||
| 395 | private void validateUrls(String where, List<String> urls, List<String> errors) { | |
| 396 |
1
1. validateUrls : negated conditional → KILLED |
if (urls == null) { |
| 397 | return; | |
| 398 | } | |
| 399 | Set<String> seen = new HashSet<>(); | |
| 400 |
2
1. validateUrls : negated conditional → KILLED 2. validateUrls : changed conditional boundary → KILLED |
for (int i = 0; i < urls.size(); i++) { |
| 401 | String url = urls.get(i); | |
| 402 | String urlWhere = where + ".practiceProblems[%d]".formatted(i); | |
| 403 |
2
1. validateUrls : negated conditional → KILLED 2. validateUrls : negated conditional → KILLED |
if (url == null || url.isBlank()) { |
| 404 | errors.add(urlWhere + ": url may not be empty"); | |
| 405 | continue; | |
| 406 | } | |
| 407 | String cleanUrl = url.strip(); | |
| 408 |
2
1. validateUrls : changed conditional boundary → KILLED 2. validateUrls : negated conditional → KILLED |
if (cleanUrl.length() > MAX_URL_LENGTH) { |
| 409 | errors.add( | |
| 410 | urlWhere | |
| 411 | + ": url is %d characters long; the maximum is %d" | |
| 412 | .formatted(cleanUrl.length(), MAX_URL_LENGTH)); | |
| 413 | continue; | |
| 414 | } | |
| 415 |
1
1. validateUrls : negated conditional → KILLED |
if (!seen.add(cleanUrl)) { |
| 416 | errors.add(urlWhere + ": duplicate practice problem url " + cleanUrl); | |
| 417 | } | |
| 418 | } | |
| 419 | } | |
| 420 | ||
| 421 | /** | |
| 422 | * Deletes the course's practice problems, edges, and concepts (children before parents, in an | |
| 423 | * order that never breaks a foreign key), plus every user's per-course scaffold state. Returns | |
| 424 | * how many user-state rows were cleared. | |
| 425 | */ | |
| 426 | private int deleteCourseContentAndUserState(long courseId) { | |
| 427 |
1
1. deleteCourseContentAndUserState : removed call to edu/ucsb/cs/scaffold/repository/PracticeProblemRepository::deleteAll → KILLED |
practiceProblemRepository.deleteAll(practiceProblemRepository.findByCourseId(courseId)); |
| 428 |
1
1. deleteCourseContentAndUserState : removed call to edu/ucsb/cs/scaffold/repository/ConceptEdgeRepository::deleteAll → KILLED |
conceptEdgeRepository.deleteAll(conceptEdgeRepository.findByCourseId(courseId)); |
| 429 | ||
| 430 | List<Concept> existingConcepts = conceptRepository.findByCourseId(courseId); | |
| 431 |
1
1. deleteCourseContentAndUserState : removed call to edu/ucsb/cs/scaffold/repository/ConceptRepository::deleteAll → KILLED |
conceptRepository.deleteAll(existingConcepts.stream().filter(Concept::isSubconcept).toList()); |
| 432 |
1
1. deleteCourseContentAndUserState : removed call to edu/ucsb/cs/scaffold/repository/ConceptRepository::deleteAll → KILLED |
conceptRepository.deleteAll( |
| 433 |
2
1. lambda$deleteCourseContentAndUserState$6 : replaced boolean return with true for edu/ucsb/cs/scaffold/services/ConceptYamlService::lambda$deleteCourseContentAndUserState$6 → KILLED 2. lambda$deleteCourseContentAndUserState$6 : negated conditional → KILLED |
existingConcepts.stream().filter(concept -> !concept.isSubconcept()).toList()); |
| 434 | ||
| 435 | List<UserState> userStates = userStateRepository.findByCourseId(courseId); | |
| 436 |
1
1. deleteCourseContentAndUserState : removed call to edu/ucsb/cs/scaffold/repository/UserStateRepository::deleteAll → KILLED |
userStateRepository.deleteAll(userStates); |
| 437 |
1
1. deleteCourseContentAndUserState : replaced int return with 0 for edu/ucsb/cs/scaffold/services/ConceptYamlService::deleteCourseContentAndUserState → KILLED |
return userStates.size(); |
| 438 | } | |
| 439 | ||
| 440 | private int savePracticeProblems(Course course, Concept concept, List<String> urls) { | |
| 441 |
1
1. savePracticeProblems : negated conditional → KILLED |
if (urls == null) { |
| 442 | return 0; | |
| 443 | } | |
| 444 | for (String url : urls) { | |
| 445 | practiceProblemRepository.save( | |
| 446 | PracticeProblem.builder().course(course).concept(concept).url(url.strip()).build()); | |
| 447 | } | |
| 448 |
1
1. savePracticeProblems : replaced int return with 0 for edu/ucsb/cs/scaffold/services/ConceptYamlService::savePracticeProblems → KILLED |
return urls.size(); |
| 449 | } | |
| 450 | ||
| 451 | private Map<String, Object> report( | |
| 452 | boolean success, | |
| 453 | List<String> errors, | |
| 454 | int conceptsCreated, | |
| 455 | int subconceptsCreated, | |
| 456 | int edgesCreated, | |
| 457 | int practiceProblemsCreated, | |
| 458 | int userStatesCleared) { | |
| 459 | Map<String, Object> report = new LinkedHashMap<>(); | |
| 460 | report.put("success", success); | |
| 461 | report.put("errors", errors); | |
| 462 | report.put("conceptsCreated", conceptsCreated); | |
| 463 | report.put("subconceptsCreated", subconceptsCreated); | |
| 464 | report.put("edgesCreated", edgesCreated); | |
| 465 | report.put("practiceProblemsCreated", practiceProblemsCreated); | |
| 466 | report.put("userStatesCleared", userStatesCleared); | |
| 467 |
1
1. report : replaced return value with Collections.emptyMap for edu/ucsb/cs/scaffold/services/ConceptYamlService::report → KILLED |
return report; |
| 468 | } | |
| 469 | ||
| 470 | /** | |
| 471 | * Every practice problem URL in the course, grouped by concept id, each concept's URLs in | |
| 472 | * database-id order (the order they were added). Concepts with no practice problems are absent, | |
| 473 | * so lookups feed straight into the NON_EMPTY-serialized DTO fields. | |
| 474 | */ | |
| 475 | private Map<Long, List<String>> urlsByConceptId(long courseId) { | |
| 476 | Map<Long, List<String>> result = new HashMap<>(); | |
| 477 | practiceProblemRepository.findByCourseId(courseId).stream() | |
| 478 | .sorted(Comparator.comparing(PracticeProblem::getId)) | |
| 479 |
1
1. urlsByConceptId : removed call to java/util/stream/Stream::forEach → KILLED |
.forEach( |
| 480 | problem -> | |
| 481 | result | |
| 482 |
1
1. lambda$urlsByConceptId$7 : replaced return value with Collections.emptyList for edu/ucsb/cs/scaffold/services/ConceptYamlService::lambda$urlsByConceptId$7 → KILLED |
.computeIfAbsent(problem.getConcept().getId(), k -> new ArrayList<>()) |
| 483 | .add(problem.getUrl())); | |
| 484 |
1
1. urlsByConceptId : replaced return value with Collections.emptyMap for edu/ucsb/cs/scaffold/services/ConceptYamlService::urlsByConceptId → KILLED |
return result; |
| 485 | } | |
| 486 | } | |
Mutations | ||
| 79 |
1.1 |
|
| 85 |
1.1 2.2 |
|
| 95 |
1.1 |
|
| 100 |
1.1 2.2 |
|
| 101 |
1.1 |
|
| 110 |
1.1 |
|
| 128 |
1.1 |
|
| 136 |
1.1 |
|
| 151 |
1.1 |
|
| 170 |
1.1 |
|
| 174 |
1.1 |
|
| 175 |
1.1 |
|
| 177 |
1.1 |
|
| 178 |
1.1 |
|
| 190 |
1.1 |
|
| 199 |
1.1 |
|
| 203 |
1.1 |
|
| 204 |
1.1 |
|
| 207 |
1.1 |
|
| 208 |
1.1 |
|
| 211 |
1.1 |
|
| 212 |
1.1 2.2 |
|
| 224 |
1.1 |
|
| 226 |
1.1 |
|
| 231 |
1.1 |
|
| 238 |
1.1 |
|
| 241 |
1.1 |
|
| 257 |
1.1 |
|
| 273 |
1.1 |
|
| 275 |
1.1 |
|
| 278 |
1.1 |
|
| 284 |
1.1 |
|
| 285 |
1.1 2.2 |
|
| 288 |
1.1 |
|
| 292 |
1.1 |
|
| 294 |
1.1 |
|
| 298 |
1.1 |
|
| 302 |
1.1 |
|
| 303 |
1.1 2.2 |
|
| 306 |
1.1 |
|
| 313 |
1.1 2.2 |
|
| 316 |
1.1 |
|
| 325 |
1.1 |
|
| 326 |
1.1 2.2 |
|
| 329 |
1.1 |
|
| 333 |
1.1 2.2 |
|
| 338 |
1.1 |
|
| 342 |
1.1 |
|
| 346 |
1.1 |
|
| 349 |
1.1 |
|
| 353 |
1.1 |
|
| 358 |
1.1 |
|
| 380 |
1.1 2.2 |
|
| 382 |
1.1 |
|
| 385 |
1.1 2.2 |
|
| 390 |
1.1 |
|
| 392 |
1.1 |
|
| 396 |
1.1 |
|
| 400 |
1.1 2.2 |
|
| 403 |
1.1 2.2 |
|
| 408 |
1.1 2.2 |
|
| 415 |
1.1 |
|
| 427 |
1.1 |
|
| 428 |
1.1 |
|
| 431 |
1.1 |
|
| 432 |
1.1 |
|
| 433 |
1.1 2.2 |
|
| 436 |
1.1 |
|
| 437 |
1.1 |
|
| 441 |
1.1 |
|
| 448 |
1.1 |
|
| 467 |
1.1 |
|
| 479 |
1.1 |
|
| 482 |
1.1 |
|
| 484 |
1.1 |