Added improved sample data generation.

This commit is contained in:
Andrew Lalis 2023-02-07 12:01:41 +01:00
parent 54f17d4cec
commit 184491b9ea
12 changed files with 439 additions and 225 deletions

View File

@ -0,0 +1,27 @@
package nl.andrewlalis.gymboard_api.util;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVRecord;
import java.io.FileReader;
import java.io.IOException;
import java.nio.file.Path;
public final class CsvUtil {
private CsvUtil() {}
public static void load(Path csvFile, ThrowableConsumer<CSVRecord> recordConsumer) throws IOException {
var reader = new FileReader(csvFile.toFile());
CSVFormat format = CSVFormat.DEFAULT.builder()
.setHeader()
.setSkipHeaderRecord(true)
.build();
for (var record : format.parse(reader)) {
try {
recordConsumer.accept(record);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

View File

@ -1,223 +0,0 @@
package nl.andrewlalis.gymboard_api.util;
import nl.andrewlalis.gymboard_api.domains.api.dao.CityRepository;
import nl.andrewlalis.gymboard_api.domains.api.dao.CountryRepository;
import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseRepository;
import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionPayload;
import nl.andrewlalis.gymboard_api.domains.api.model.*;
import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise;
import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.CdnClient;
import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
import nl.andrewlalis.gymboard_api.domains.auth.dao.RoleRepository;
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserPersonalDetailsRepository;
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserPreferencesRepository;
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
import nl.andrewlalis.gymboard_api.domains.auth.dto.UserCreationPayload;
import nl.andrewlalis.gymboard_api.domains.auth.model.Role;
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import nl.andrewlalis.gymboard_api.domains.auth.model.UserPersonalDetails;
import nl.andrewlalis.gymboard_api.domains.auth.service.UserService;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.io.FileReader;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
/**
* Simple component that loads sample data that's useful when testing the application.
*/
@Component
public class SampleDataLoader implements ApplicationListener<ContextRefreshedEvent> {
private static final Logger log = LoggerFactory.getLogger(SampleDataLoader.class);
private final CountryRepository countryRepository;
private final CityRepository cityRepository;
private final GymRepository gymRepository;
private final ExerciseRepository exerciseRepository;
private final ExerciseSubmissionService submissionService;
private final RoleRepository roleRepository;
private final UserRepository userRepository;
private final UserPersonalDetailsRepository personalDetailsRepository;
private final UserPreferencesRepository preferencesRepository;
private final UserService userService;
@Value("${app.cdn-origin}")
private String cdnOrigin;
public SampleDataLoader(
CountryRepository countryRepository,
CityRepository cityRepository,
GymRepository gymRepository,
ExerciseRepository exerciseRepository,
ExerciseSubmissionService submissionService,
RoleRepository roleRepository, UserRepository userRepository, UserPersonalDetailsRepository personalDetailsRepository, UserPreferencesRepository preferencesRepository, UserService userService) {
this.countryRepository = countryRepository;
this.cityRepository = cityRepository;
this.gymRepository = gymRepository;
this.exerciseRepository = exerciseRepository;
this.submissionService = submissionService;
this.roleRepository = roleRepository;
this.userRepository = userRepository;
this.personalDetailsRepository = personalDetailsRepository;
this.preferencesRepository = preferencesRepository;
this.userService = userService;
}
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
Path markerFile = Path.of(".sample_data");
if (Files.exists(markerFile)) return;
log.info("Generating sample data.");
try {
generateSampleData();
secondPassGenerateSampleData();
Files.writeString(markerFile, "Yes");
} catch (Exception e) {
e.printStackTrace();
}
}
@Transactional
protected void generateSampleData() throws Exception {
loadCsv("exercises", record -> {
exerciseRepository.save(new Exercise(record.get("short-name"), record.get("name")));
});
loadCsv("countries", record -> {
countryRepository.save(new Country(record.get("code"), record.get("name")));
});
loadCsv("cities", record -> {
var country = countryRepository.findById(record.get("country-code")).orElseThrow();
String shortName = record.get("short-name");
String name = record.get("name");
cityRepository.save(new City(shortName, name, country));
});
loadCsv("gyms", record -> {
var city = cityRepository.findByShortNameAndCountryCode(
record.get("city-short-name"),
record.get("country-code")
).orElseThrow();
gymRepository.save(new Gym(
city,
record.get("short-name"),
record.get("name"),
record.get("website-url"),
new GeoPoint(
new BigDecimal(record.get("latitude")),
new BigDecimal(record.get("longitude"))
),
record.get("street-address")
));
});
// Loading sample submissions involves sending content to the Gymboard CDN service.
// We upload a video for each submission, and wait until all uploads are processed before continuing.
final CdnClient cdnClient = new CdnClient(cdnOrigin);
loadCsv("submissions", record -> {
var exercise = exerciseRepository.findById(record.get("exercise-short-name")).orElseThrow();
BigDecimal weight = new BigDecimal(record.get("raw-weight"));
WeightUnit unit = WeightUnit.parse(record.get("weight-unit"));
int reps = Integer.parseInt(record.get("reps"));
String name = record.get("submitter-name");
CompoundGymId gymId = CompoundGymId.parse(record.get("gym-id"));
String videoFilename = record.get("video-filename");
log.info("Uploading video {} to CDN...", videoFilename);
var video = cdnClient.uploads.uploadVideo(Path.of("sample_data", videoFilename), "video/mp4");
submissionService.createSubmission(gymId, new ExerciseSubmissionPayload(
name,
exercise.getShortName(),
weight.floatValue(),
unit.name(),
reps,
video.id()
));
});
loadCsv("users", record -> {
String email = record.get("email");
String password = record.get("password");
String name = record.get("name");
String[] roleNames = record.get("roles").split("\\s*\\n\\s*");
LocalDate birthDate = LocalDate.parse(record.get("birth-date"));
BigDecimal currentWeight = new BigDecimal(record.get("current-weight"));
WeightUnit currentWeightUnit = WeightUnit.parse(record.get("current-weight-unit"));
BigDecimal metricWeight = new BigDecimal(currentWeight.toString());
if (currentWeightUnit == WeightUnit.POUNDS) {
metricWeight = WeightUnit.toKilograms(metricWeight);
}
UserPersonalDetails.PersonSex sex = UserPersonalDetails.PersonSex.parse(record.get("sex"));
UserCreationPayload payload = new UserCreationPayload(email, password, name);
var resp = userService.createUser(payload, false);
User user = userRepository.findByIdWithRoles(resp.id()).orElseThrow();
for (var roleName : roleNames) {
if (roleName.isBlank()) continue;
Role role = roleRepository.findById(roleName.strip().toLowerCase())
.orElseGet(() -> roleRepository.save(new Role(roleName.strip().toLowerCase())));
user.getRoles().add(role);
}
userRepository.save(user);
var pd = personalDetailsRepository.findById(user.getId()).orElseThrow();
pd.setBirthDate(birthDate);
pd.setCurrentWeight(currentWeight);
pd.setCurrentWeightUnit(currentWeightUnit);
pd.setCurrentMetricWeight(metricWeight);
pd.setSex(sex);
personalDetailsRepository.save(pd);
var p = preferencesRepository.findById(user.getId()).orElseThrow();
p.setLocale(record.get("locale"));
p.setAccountPrivate(Boolean.parseBoolean(record.get("account-private")));
preferencesRepository.save(p);
});
}
@Transactional
protected void secondPassGenerateSampleData() throws Exception {
loadCsv("users", record -> {
String email = record.get("email");
String[] followingEmails = record.get("following").split("\\s*\\n\\s*");
User user = userRepository.findByEmail(email).orElseThrow();
for (String followingEmail : followingEmails) {
User userToFollow = userRepository.findByEmail(followingEmail).orElseThrow();
userService.followUser(user.getId(), userToFollow.getId());
}
});
}
@FunctionalInterface
interface ThrowableConsumer<T> {
void accept(T item) throws Exception;
}
private void loadCsv(String csvName, ThrowableConsumer<CSVRecord> recordConsumer) throws IOException {
String path = "sample_data/" + csvName + ".csv";
log.info("Loading data from {}...", path);
var reader = new FileReader(path);
CSVFormat format = CSVFormat.DEFAULT.builder()
.setHeader()
.setSkipHeaderRecord(true)
.build();
for (var record : format.parse(reader)) {
try {
recordConsumer.accept(record);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

View File

@ -0,0 +1,5 @@
package nl.andrewlalis.gymboard_api.util;
public interface ThrowableConsumer<T> {
void accept(T item) throws Exception;
}

View File

@ -0,0 +1,24 @@
package nl.andrewlalis.gymboard_api.util.sample_data;
import java.util.Collection;
import java.util.Collections;
/**
* Interface that defines a component that can generate sample data for testing
* the application. It must define a method to generate data, and optionally, it
* can specify a collection of <em>other</em> sample data generator classes that
* it depends on. For example, a gym generator might depend on a generator that
* creates countries and cities.
* <p>
* Note that all classes which implement this interface should be annotated
* as a <code>@Component</code> and <code>@Profile("development")</code> to
* ensure that we keep sample data generation away from production.
* </p>
*/
public interface SampleDataGenerator {
void generate() throws Exception;
default Collection<Class<? extends SampleDataGenerator>> dependencies() {
return Collections.emptySet();
}
}

View File

@ -0,0 +1,70 @@
package nl.andrewlalis.gymboard_api.util.sample_data;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Simple component that loads sample data that's useful when testing the application.
*/
@Component
@Profile("development")
public class SampleDataLoader implements ApplicationListener<ContextRefreshedEvent> {
private static final Logger log = LoggerFactory.getLogger(SampleDataLoader.class);
/**
* The list of all sample data generators that the application has loaded.
*/
private final List<SampleDataGenerator> generators;
public SampleDataLoader(List<SampleDataGenerator> generators) {
this.generators = generators;
}
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
Path markerFile = Path.of(".sample_data");
if (Files.exists(markerFile)) return;
log.info("Generating sample data.");
try {
Set<SampleDataGenerator> completedGenerators = new HashSet<>();
for (var gen : generators) {
runGenerator(gen, completedGenerators);
}
Files.writeString(markerFile, "Yes");
} catch (Exception e) {
e.printStackTrace();
}
}
private void runGenerator(SampleDataGenerator gen, Set<SampleDataGenerator> completed) {
for (var dep : gen.dependencies()) {
runGenerator(getGeneratorByClass(dep), completed);
}
if (!completed.contains(gen)) {
try {
log.info("Running sample data generator: {}", gen.getClass().getSimpleName());
gen.generate();
completed.add(gen);
} catch (Exception e) {
throw new RuntimeException("Generator failed: " + gen.getClass().getSimpleName());
}
}
}
private SampleDataGenerator getGeneratorByClass(Class<? extends SampleDataGenerator> c) {
for (var gen : generators) {
if (gen.getClass().equals(c)) return gen;
}
throw new RuntimeException("Missing generator: " + c.getSimpleName());
}
}

View File

@ -0,0 +1,26 @@
package nl.andrewlalis.gymboard_api.util.sample_data;
import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseRepository;
import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise;
import nl.andrewlalis.gymboard_api.util.CsvUtil;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.nio.file.Path;
@Component
@Profile("development")
public class SampleExerciseGenerator implements SampleDataGenerator {
private final ExerciseRepository exerciseRepository;
public SampleExerciseGenerator(ExerciseRepository exerciseRepository) {
this.exerciseRepository = exerciseRepository;
}
@Override
public void generate() throws Exception {
CsvUtil.load(Path.of("sample_data", "exercises.csv"), r -> {
exerciseRepository.save(new Exercise(r.get("short-name"), r.get("name")));
});
}
}

View File

@ -0,0 +1,34 @@
package nl.andrewlalis.gymboard_api.util.sample_data;
import nl.andrewlalis.gymboard_api.domains.api.dao.CityRepository;
import nl.andrewlalis.gymboard_api.domains.api.dao.CountryRepository;
import nl.andrewlalis.gymboard_api.domains.api.model.City;
import nl.andrewlalis.gymboard_api.domains.api.model.Country;
import nl.andrewlalis.gymboard_api.util.CsvUtil;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.nio.file.Path;
@Component
@Profile("development")
public class SampleGeoDataGenerator implements SampleDataGenerator {
private final CityRepository cityRepository;
private final CountryRepository countryRepository;
public SampleGeoDataGenerator(CityRepository cityRepository, CountryRepository countryRepository) {
this.cityRepository = cityRepository;
this.countryRepository = countryRepository;
}
@Override
public void generate() throws Exception {
CsvUtil.load(Path.of("sample_data", "countries.csv"), r -> {
countryRepository.save(new Country(r.get("code"), r.get("name")));
});
CsvUtil.load(Path.of("sample_data", "cities.csv"), r -> {
var country = countryRepository.findById(r.get("country-code")).orElseThrow();
cityRepository.save(new City(r.get("short-name"), r.get("name"), country));
});
}
}

View File

@ -0,0 +1,52 @@
package nl.andrewlalis.gymboard_api.util.sample_data;
import nl.andrewlalis.gymboard_api.domains.api.dao.CityRepository;
import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.domains.api.model.GeoPoint;
import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
import nl.andrewlalis.gymboard_api.util.CsvUtil;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Collection;
import java.util.Set;
@Component
@Profile("development")
public class SampleGymGenerator implements SampleDataGenerator {
private final CityRepository cityRepository;
private final GymRepository gymRepository;
public SampleGymGenerator(CityRepository cityRepository, GymRepository gymRepository) {
this.cityRepository = cityRepository;
this.gymRepository = gymRepository;
}
@Override
public void generate() throws Exception {
CsvUtil.load(Path.of("sample_data", "gyms.csv"), r -> {
var city = cityRepository.findByShortNameAndCountryCode(
r.get("city-short-name"),
r.get("country-code")
).orElseThrow();
gymRepository.save(new Gym(
city,
r.get("short-name"),
r.get("name"),
r.get("website-url"),
new GeoPoint(
new BigDecimal(r.get("latitude")),
new BigDecimal(r.get("longitude"))
),
r.get("street-address")
));
});
}
@Override
public Collection<Class<? extends SampleDataGenerator>> dependencies() {
return Set.of(SampleGeoDataGenerator.class);
}
}

View File

@ -0,0 +1,61 @@
package nl.andrewlalis.gymboard_api.util.sample_data;
import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseRepository;
import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionPayload;
import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit;
import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.CdnClient;
import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
import nl.andrewlalis.gymboard_api.util.CsvUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Collection;
import java.util.Set;
@Component
@Profile("development")
public class SampleSubmissionGenerator implements SampleDataGenerator {
private final ExerciseRepository exerciseRepository;
private final ExerciseSubmissionService submissionService;
@Value("${app.cdn-origin}")
private String cdnOrigin;
public SampleSubmissionGenerator(ExerciseRepository exerciseRepository, ExerciseSubmissionService submissionService) {
this.exerciseRepository = exerciseRepository;
this.submissionService = submissionService;
}
@Override
public void generate() throws Exception {
final CdnClient cdnClient = new CdnClient(cdnOrigin);
CsvUtil.load(Path.of("sample_data", "submissions.csv"), r -> {
var exercise = exerciseRepository.findById(r.get("exercise-short-name")).orElseThrow();
BigDecimal weight = new BigDecimal(r.get("raw-weight"));
WeightUnit unit = WeightUnit.parse(r.get("weight-unit"));
int reps = Integer.parseInt(r.get("reps"));
String name = r.get("submitter-name");
CompoundGymId gymId = CompoundGymId.parse(r.get("gym-id"));
String videoFilename = r.get("video-filename");
var video = cdnClient.uploads.uploadVideo(Path.of("sample_data", videoFilename), "video/mp4");
submissionService.createSubmission(gymId, new ExerciseSubmissionPayload(
name,
exercise.getShortName(),
weight.floatValue(),
unit.name(),
reps,
video.id()
));
});
}
@Override
public Collection<Class<? extends SampleDataGenerator>> dependencies() {
return Set.of(SampleExerciseGenerator.class, SampleUserGenerator.class);
}
}

View File

@ -0,0 +1,98 @@
package nl.andrewlalis.gymboard_api.util.sample_data;
import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit;
import nl.andrewlalis.gymboard_api.domains.auth.dao.RoleRepository;
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserFollowingRepository;
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
import nl.andrewlalis.gymboard_api.domains.auth.model.Role;
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import nl.andrewlalis.gymboard_api.domains.auth.model.UserFollowing;
import nl.andrewlalis.gymboard_api.domains.auth.model.UserPersonalDetails;
import nl.andrewlalis.gymboard_api.util.CsvUtil;
import nl.andrewlalis.gymboard_api.util.ULID;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.LocalDate;
@Component
@Profile("development")
public class SampleUserGenerator implements SampleDataGenerator {
private final ULID ulid;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final RoleRepository roleRepository;
private final UserFollowingRepository followingRepository;
public SampleUserGenerator(ULID ulid, UserRepository userRepository, PasswordEncoder passwordEncoder, RoleRepository roleRepository, UserFollowingRepository followingRepository) {
this.ulid = ulid;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.roleRepository = roleRepository;
this.followingRepository = followingRepository;
}
@Override
public void generate() throws Exception {
Path usersCsvPath = Path.of("sample_data", "users.csv");
CsvUtil.load(usersCsvPath, r -> {
User user = new User(
ulid.nextULID(),
true,
r.get("email"),
passwordEncoder.encode(r.get("password")),
r.get("name")
);
String[] roleNames = r.get("roles").split("\\s*\\n\\s*");
for (var roleName : roleNames) {
if (roleName.isBlank()) continue;
Role role = roleRepository.findById(roleName.strip().toLowerCase())
.orElseGet(() -> roleRepository.save(new Role(roleName.strip().toLowerCase())));
user.getRoles().add(role);
}
// Set up the user's personal details.
var pd = user.getPersonalDetails();
String birthDateStr = r.get("birth-date");
if (birthDateStr != null && !birthDateStr.isBlank()) {
pd.setBirthDate(LocalDate.parse(birthDateStr));
}
String currentWeightStr = r.get("current-weight");
String currentWeightUnitStr = r.get("current-weight-unit");
if (
currentWeightStr != null && !currentWeightStr.isBlank() &&
currentWeightUnitStr != null && !currentWeightUnitStr.isBlank()
) {
BigDecimal currentWeight = new BigDecimal(currentWeightStr);
WeightUnit currentWeightUnit = WeightUnit.parse(currentWeightUnitStr);
BigDecimal metricWeight = new BigDecimal(currentWeightStr);
if (currentWeightUnit == WeightUnit.POUNDS) {
metricWeight = WeightUnit.toKilograms(metricWeight);
}
pd.setCurrentWeight(currentWeight);
pd.setCurrentWeightUnit(currentWeightUnit);
pd.setCurrentMetricWeight(metricWeight);
}
pd.setSex(UserPersonalDetails.PersonSex.parse(r.get("sex")));
// Set up the user's preferences.
var p = user.getPreferences();
p.setLocale(r.get("locale"));
p.setAccountPrivate(Boolean.parseBoolean(r.get("account-private")));
userRepository.save(user);
});
// Do a second pass to add follower information.
CsvUtil.load(usersCsvPath, r -> {
User user = userRepository.findByEmail(r.get("email")).orElseThrow();
String[] followingEmails = r.get("following").split("\\s+");
for (String followingEmail : followingEmails) {
User userToFollow = userRepository.findByEmail(followingEmail).orElseThrow();
followingRepository.save(new UserFollowing(userToFollow, user));
}
});
}
}

View File

@ -10,3 +10,8 @@ export interface GymSearchResult {
latitude: number; latitude: number;
longitude: number; longitude: number;
} }
export interface UserSearchResult {
id: string;
name: string;
}

View File

@ -1,18 +1,25 @@
package nl.andrewlalis.gymboardcdn.service; package nl.andrewlalis.gymboardcdn.service;
import nl.andrewlalis.gymboardcdn.model.StoredFile; import nl.andrewlalis.gymboardcdn.model.StoredFile;
import nl.andrewlalis.gymboardcdn.model.StoredFileRepository;
import nl.andrewlalis.gymboardcdn.util.ULID; import nl.andrewlalis.gymboardcdn.util.ULID;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.transaction.annotation.Transactional;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
/** /**
* The service that manages storing and retrieving files from a base filesystem. * The service that manages storing and retrieving files from a base filesystem.
@ -27,9 +34,11 @@ public class FileService {
@Value("${app.files.temp-dir}") @Value("${app.files.temp-dir}")
private String tempDir; private String tempDir;
private final StoredFileRepository storedFileRepository;
private final ULID ulid; private final ULID ulid;
public FileService(ULID ulid) { public FileService(StoredFileRepository storedFileRepository, ULID ulid) {
this.storedFileRepository = storedFileRepository;
this.ulid = ulid; this.ulid = ulid;
} }
@ -78,4 +87,30 @@ public class FileService {
} }
return dir; return dir;
} }
/**
* Scheduled task that removes any StoredFile entities for which no more
* physical file exists.
*/
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.MINUTES)
@Transactional
public void removeOrphanedFiles() {
Pageable pageable = PageRequest.of(0, 100, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<StoredFile> page = storedFileRepository.findAll(pageable);
while (!page.isEmpty()) {
for (var storedFile : page) {
try {
Path filePath = getStoragePathForFile(storedFile);
if (Files.notExists(filePath)) {
log.warn("Removing stored file {} because it no longer exists on the disk.", storedFile.getId());
storedFileRepository.delete(storedFile);
}
} catch (IOException e) {
log.error("Couldn't get storage path for stored file.", e);
}
}
pageable = pageable.next();
page = storedFileRepository.findAll(pageable);
}
}
} }