CodeValueServiceImpl.java

/*
 * Copyright 2020 Global Crop Diversity Trust
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.gringlobal.service.impl;

import static org.gringlobal.service.LanguageService.MCPD_IETF_TAG;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;

import javax.annotation.Resource;
import javax.persistence.Entity;

import lombok.extern.slf4j.Slf4j;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.custom.validation.javax.CodeValueField;
import org.gringlobal.model.CodeValue;
import org.gringlobal.model.CodeValueLang;
import org.gringlobal.model.QCodeValue;
import org.gringlobal.model.SysLang;
import org.gringlobal.persistence.CodeValueLangRepository;
import org.gringlobal.persistence.CodeValueRepository;
import org.gringlobal.service.CodeValueService;
import org.gringlobal.service.CodeValueTranslationService;
import org.gringlobal.service.CodeValueTranslationService.TranslatedCodeValue;
import org.gringlobal.service.LanguageService;
import org.gringlobal.service.filter.CodeValueFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.PageRequest;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ReflectionUtils;
import org.springframework.validation.annotation.Validated;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.PathBuilder;

@Service
@Transactional(readOnly = true)
@Validated
@CacheConfig(cacheNames = CodeValueServiceImpl.CACHE_NAME)
@Slf4j
public class CodeValueServiceImpl extends FilteredTranslatedCRUDServiceImpl<CodeValue, CodeValueLang, TranslatedCodeValue, CodeValueFilter, CodeValueRepository> implements CodeValueService {
	public static final String CACHE_NAME="codeValues";

	@Autowired
	private CodeValueLangRepository cvLangRepository;

	@Resource
	private Set<Class<?>> entityClassSet;

	@Component
	protected static class CodeValueTranslationSupport extends BaseTranslationSupport<CodeValue, CodeValueLang, TranslatedCodeValue, CodeValueFilter, CodeValueLangRepository> implements CodeValueTranslationService {
		
		@Autowired
		private CodeValueRepository codeValueRepository;
		
		public CodeValueTranslationSupport() {
			super();
		}

		@Override
		protected TranslatedCodeValue toTranslated(CodeValue entity, String title, String description) {
			return TranslatedCodeValue.from(entity, title, description);
		}

		@Override
		@Cacheable(value = CACHE_NAME, key = "'translatedcodevalue-' + #groupName + '-' + #code + '-' + #locale.displayName") // may return old values, but will eventually refresh
		public Optional<TranslatedCodeValue> findTranslatedCodeValue(String groupName, String code, Locale locale) {
			SysLang targetLanguage = languageService.getLanguage(locale);
			if (targetLanguage == null) {
				targetLanguage = languageService.getLanguage(Locale.ENGLISH);
			}

			Optional<TranslatedCodeValue> translatedCodeValue = Optional.empty();

			CodeValue codeValue = codeValueRepository.getByGroupNameAndValue(groupName, code);
			if (codeValue != null) {
				PathBuilder<CodeValue> entity = new PathBuilder<>(CodeValue.class, QCodeValue.codeValue.getMetadata());
				var translations = fetchTranslations(entity.eq(codeValue), PageRequest.of(0, 1), targetLanguage).getContent();
				if (!translations.isEmpty()) {
					translatedCodeValue = Optional.ofNullable(translations.get(0));
				}
			}
			return translatedCodeValue;
		}
	}

	@Override
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@Cacheable(key = "'tcv-' + #id", unless = "#result == null") // must use special cache key!
	public TranslatedCodeValue loadTranslated(long id) {
		return super.loadTranslated(id);
	}

	@Override
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@CacheEvict(allEntries = true)
	@Transactional
	public CodeValue create(CodeValue source) {
		var target = new CodeValue();
		target.apply(source);
		return repository.save(target);
	}

	@Override
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@CacheEvict(allEntries = true)
	@Transactional
	public CodeValue createFast(CodeValue source) {
		return repository.save(source);
	}

	@Override
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@CacheEvict(allEntries = true)
	public TranslatedCodeValue create(TranslatedCodeValue source) {
		return super.create(source);
	}

	@Override
	@Transactional
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@CacheEvict(allEntries = true)
	public CodeValue update(CodeValue updated, CodeValue target) {
		target.apply(updated);
		return repository.save(target);
	}

	@Override
	@Transactional
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@CacheEvict(allEntries = true)
	public CodeValue updateFast(CodeValue updated, CodeValue target) {
		target.apply(updated);
		return repository.save(target);
	}

	@Override
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@CacheEvict(allEntries = true)
	public CodeValue update(CodeValue updated) {
		return super.update(updated);
	}

	@Override
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@CacheEvict(allEntries = true)
	public CodeValueLang upsert(CodeValue entity, SysLang sysLang, CodeValueLang input) {
		return super.upsert(entity, sysLang, input);
	}

	@Override
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@CacheEvict(allEntries = true)
	@Transactional
	public CodeValue remove(CodeValue entity) {
		entity = get(entity);
		Map<Class<?>, Set<Field>> codeValueFields = findCodeValueReferences(entity.getGroupName());

		if (!codeValueFields.isEmpty()) {
			checkCodeValueUsage(codeValueFields, entity.getValue());
		}
		return super.remove(entity);
	}

	@Override
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@CacheEvict(allEntries = true)
	@Transactional
	public CodeValue replaceAndRemove(CodeValue entity, CodeValue replacement) {
		entity = get(entity);
		replacement = get(replacement);
		Map<Class<?>, Set<Field>> codeValueFields = findCodeValueReferences(entity.getGroupName());

		if (!codeValueFields.isEmpty()) {
			replaceCodeValue(codeValueFields, entity, replacement);
		}
		return remove(entity);
	}

	@Override
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@CacheEvict(allEntries = true)
	public CodeValueLang remove(CodeValue entity, SysLang sysLang) {
		return super.remove(entity, sysLang);
	}

	@Override
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	public List<CodeValueLang> getLangs(long entityId) {
		return super.getLangs(entityId);
	}

	@Override
	@Transactional(propagation = Propagation.REQUIRES_NEW)
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@CacheEvict(allEntries = true)
	public CodeValue ensureCodeValue(String groupName, String value, String title, String description) {
		CodeValue codeValue = repository.getByGroupNameAndValue(groupName, value);

		if (codeValue == null) {
			log.info("Adding CodeValue group={} value={} {} {}", groupName, value, title, description);
			var translatedCodeValue = create(TranslatedCodeValue.from(new CodeValue(groupName, value), title, description));
			codeValue = translatedCodeValue.entity;
		}

		return codeValue;
	}

	@Override
	@Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true)
	@Cacheable(key = "'codevalue-' + #groupName + '-' + #value", sync = true)
	public boolean validate(String groupName, String value) {
		return repository.existsCodeValueByGroupNameAndValue(groupName, value);
	}

	@Override
	@Cacheable(key = "'mcpdOfCode-' + #groupName + '-' + #value", sync = true)
	public String findMcpdOfCodeValue(String groupName, String value) {
		SysLang sl = languageService.getLanguage(MCPD_IETF_TAG);
		CodeValue cv = repository.getByGroupNameAndValue(groupName, value);
		if (cv == null)
			return null;

		CodeValueLang cvl = cvLangRepository.getByEntityAndSysLang(cv, sl);
		return cvl != null ? cvl.getTitle() : null;
	}

	@Override
	@Cacheable(key = "'codeOfMcpd-' + #groupName + '-' + #mcpd", sync = true)
	public String findCodeValueOfMCPD(String groupName, Integer mcpd) {
		if (mcpd == null)
			return null;

		CodeValue cv = cvLangRepository.findCodeValueOfMCPD(groupName, String.valueOf(mcpd));
		return cv != null ? cv.getValue() : null;
	}


	@Override
	@Cacheable(key = "'codeOfMcpd-' + #groupName + '-' + #mcpd", sync = true)
	public String findCodeValueOfMCPD(String groupName, String mcpd) {
		if (mcpd == null)
			return null;

		CodeValue cv = cvLangRepository.findCodeValueOfMCPD(groupName, mcpd);
		return cv != null ? cv.getValue() : null;
	}


	@Override
	public Map<Class<?>, Set<Field>> findCodeValueReferences(String groupName) {
		// map of classes and fields that use CodeValues with given groupName
		Map<Class<?>, Set<Field>> usageInFieldsMap = new HashMap<>();

		// searching for CodeValues usages with given groupName in entity classes
		entityClassSet.forEach(aClass -> ReflectionUtils.doWithFields(aClass, field -> {
			ReflectionUtils.makeAccessible(field);

			var codeValueField = field.getAnnotation(CodeValueField.class);
			if (Objects.nonNull(codeValueField) && Objects.equals(codeValueField.value(), groupName)) {
				usageInFieldsMap.compute(aClass, (key, list) -> {
					if (list == null) {
						list = new HashSet<>();
					}
					list.add(field);
					return list;
				});
			}
		}));
		return usageInFieldsMap;
	}

	@Override
	public List<CodeValueStats> getStatistics(String groupName) {
		List<CodeValueStats> codeValueStatsList = new ArrayList<>();
		Map<Class<?>, Set<Field>> codeValueFields = findCodeValueReferences(groupName);
		if (codeValueFields.isEmpty()) {
			return codeValueStatsList;
		}
		for (Map.Entry<Class<?>, Set<Field>> entry: codeValueFields.entrySet()) {
			Class<?> targetClass = entry.getKey();
			if (! targetClass.isAnnotationPresent(Entity.class)) {
				continue;
			}

			var root = new PathBuilder<>(targetClass, "t");

			for (Field field: entry.getValue()) {
				PathBuilder<Object> cvField = root.get(field.getName());
				var q = jpaQueryFactory.select(cvField, root.count()).from(root).groupBy(cvField).where(cvField.isNotNull());
				q.fetch().forEach(result -> {
					CodeValueStats codeValueStats = new CodeValueStats();
					codeValueStats.value = result.get(0, String.class);
					codeValueStats.number = Optional.ofNullable(result.get(1, Long.class)).orElse(0L).intValue();
					codeValueStats.entity = targetClass.getSimpleName();
					codeValueStats.property = field.getName();
					codeValueStatsList.add(codeValueStats);
				});
			}
		}

		return codeValueStatsList;
	}

	private void checkCodeValueUsage(Map<Class<?>, Set<Field>> codeValueFields, String targetValue) {

		for (Map.Entry<Class<?>, Set<Field>> entry: codeValueFields.entrySet()) {
			Class<?> targetClass = entry.getKey();
			if (! targetClass.isAnnotationPresent(Entity.class)) {
				continue;
			}

			var root = new PathBuilder<>(targetClass, "t");
			var q = jpaQueryFactory.selectFrom(root);
			
			BooleanExpression predicate = null;
			for (Field field: entry.getValue()) {
				PathBuilder<Object> cvField = root.get(field.getName());
				if (predicate != null) {
					predicate = predicate.or(cvField.eq(targetValue));
				} else {
					predicate = cvField.eq(targetValue);
				}
			}

			q.where(predicate);
			List<?> result = q.limit(1).fetch();
			if (Objects.nonNull(result) && !result.isEmpty()) {
				throw new InvalidApiUsageException("Can't delete a code value that is in use");
			}
		}
	}

	private void replaceCodeValue(Map<Class<?>, Set<Field>> codeValueFields, CodeValue existingCode, CodeValue replacementCode) {
		for (Map.Entry<Class<?>, Set<Field>> entry: codeValueFields.entrySet()) {
			Class<?> targetClass = entry.getKey();
			if (! targetClass.isAnnotationPresent(Entity.class)) {
				continue;
			}

			var root = new PathBuilder<>(targetClass, "t");

			for (Field field: entry.getValue()) {
				var q = jpaQueryFactory.update(root);
				PathBuilder<Object> cvField = root.get(field.getName());
				q.set(cvField, replacementCode.getValue());
				q.where(cvField.eq(existingCode.getValue()));
				var updateCount = q.execute();
				if (updateCount > 0) {
					log.info("Replaced {} code values in {}.{} from {} to {}", updateCount, targetClass.getSimpleName(), field.getName(), existingCode.getValue(), replacementCode.getValue());
				}
			}
		}
	}

	public static class CodeValueStats {
		public String value;
		public String entity;
		public String property;
		public Integer number;
	}


}