BaseTranslationSupport.java

/*
 * Copyright 2023 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.genesys.server.service.impl;

import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.ExpressionUtils;
import com.querydsl.core.types.Order;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.Predicate;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.ListPath;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.apache.commons.lang3.StringUtils;
import org.genesys.blocks.model.BasicModel;
import org.genesys.blocks.model.EmptyModel;
import org.genesys.blocks.model.EntityId;
import org.genesys.blocks.model.filters.EmptyModelFilter;
import org.genesys.server.model.impl.QTranslatedUuidModel;
import org.genesys.server.model.impl.QLangModel;
import org.genesys.server.model.impl.ITranslatedModel;
import org.genesys.server.model.impl.LangModel;
import org.genesys.server.persistence.LangModelRepository;
import org.genesys.server.service.TranslationService;
import org.jetbrains.annotations.Nullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

import java.lang.reflect.ParameterizedType;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

@Transactional(readOnly = true)
public abstract class BaseTranslationSupport<E extends BasicModel & ITranslatedModel, L extends LangModel<L, E>,
	T extends TranslationService.Translation<E, L>,
	F extends EmptyModelFilter<?, E>, LR extends LangModelRepository<L, E>> extends CRUDServiceImpl<L, LR> implements TranslationService<E, L, T, F> {

	@Autowired
	protected JPAQueryFactory jpaQueryFactory;

	@Resource
	private Set<String> supportedLocales;

	@PersistenceContext
	protected EntityManager em;

	@Autowired
	protected JpaRepository<E, Long> owningEntityRepository;

	@Autowired
	protected LR langsRepository;

	private final Class<E> targetType;
	private final Class<L> langType;
	private final Class<T> translatedType;
	private final String variableName; // used in JPA query construction

	@SuppressWarnings("unchecked")
	protected BaseTranslationSupport() {
		this.targetType = ((Class<E>) ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[0]);
		this.langType = ((Class<L>) ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[1]);
		this.translatedType = ((Class<T>) ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[2]);
		String simpleName = targetType.getSimpleName();
		this.variableName = Character.toLowerCase(simpleName.charAt(0)) + simpleName.substring(1);
	}

	@Override
	public Page<T> list(F filter, Pageable page) {
		BooleanBuilder predicate = new BooleanBuilder();
		if (filter != null) {
			predicate.and(filter.buildPredicate());
		}
		
		return fetchTranslations(predicate, page, LocaleContextHolder.getLocale().toLanguageTag(), null);
	}

	@Override
	public final T getTranslated(E input) {
		return getTranslated(input, LocaleContextHolder.getLocale());
	}

	@Override
	public T getTranslated(E input, Locale locale) {
//		E savedEntity = owningEntityRepository.getReferenceById(input.getId());
		assert(input != null);
		assert(! input.isNew());

		return fetchTranslation(input, locale.toLanguageTag());
	}

	@Override
	public final List<T> getTranslated(List<E> input) {
		return getTranslated(input, LocaleContextHolder.getLocale());
	}

	@Override
	public List<T> getTranslated(List<E> input, Locale locale) {
//		E savedEntity = owningEntityRepository.getReferenceById(input.getId());
		if (input == null) return null;
		if (input.isEmpty()) return List.of();

		assert(input != null && !input.isEmpty());
		assert(input.stream().noneMatch(EmptyModel::isNew));

		PathBuilder<E> entity = new PathBuilder<E>(targetType, variableName);

		var idList = input.stream().map(EntityId::getId).collect(Collectors.toList());
		Comparator<T> sorter = (a, b) -> Integer.compare(idList.indexOf(a.getEntity().getId()), idList.indexOf(b.getEntity().getId())); // Keep order of items
		var translated = fetchTranslations(entity.get("id").in(idList), Pageable.unpaged(), locale.toLanguageTag(), sorter).getContent();

		return translated;
	}

	protected T fetchTranslation(final E entity, final String languageTag) {
		PathBuilder<L> langPath = new PathBuilder<L>(langType, QLangModel.langModel.getMetadata().getName());
		var lang = jpaQueryFactory.selectFrom(langPath).where(QLangModel.langModel.entity().id.eq(entity.getId()).and(QLangModel.langModel.languageTag.eq(languageTag))).fetchFirst();
		return toTranslated(entity, languageTag, lang);
	}

	protected Page<T> fetchTranslations(final Predicate predicate, final Pageable page, final String languageTag, Comparator<? super T> sorter) {
		final var TITLE_PROP = QLangModel.langModel.title.getMetadata().getName();

		PathBuilder<E> entity = new PathBuilder<E>(targetType, variableName);
		ListPath<L, PathBuilder<L>> langs = entity.getList(QTranslatedUuidModel.translatedUuidModel.langs.getMetadata().getName(), langType);

		PathBuilder<L> lan = new PathBuilder<L>(langType, "lan"); // join alias
		PathBuilder<L> def = new PathBuilder<L>(langType, "def"); // join alias
		
		PathBuilder<String> lanTitle = lan.get(TITLE_PROP, String.class);
		
		PathBuilder<String> defTitle = def.get(TITLE_PROP, String.class);

		var title = Expressions.cases().when(lanTitle.isNotNull()).then(lanTitle).otherwise(defTitle);

		// query aliases for use in orderby clause
		var titlePath = ExpressionUtils.path(String.class, TITLE_PROP);

		// Note: This join in allows for sorting by translated title and description
		JPAQuery<Tuple> query = jpaQueryFactory.from(entity)
			.select(entity, Expressions.as(title, titlePath), def, lan)
			// Default language
			.leftJoin(langs, def).on(def.get(QLangModel.langModel.languageTag).eq(Locale.ENGLISH.getLanguage()))
			// Target language
			.leftJoin(langs, lan).on(lan.get(QLangModel.langModel.languageTag).eq(languageTag))
			// Filters
			.where(predicate);

		if (page.isPaged()) {
			query.offset(page.getOffset()).limit(page.getPageSize());
		}

		var totalElements = query.fetchCount();

		for (Sort.Order o : page.getSort()) {
			if (o.getProperty().equalsIgnoreCase(TITLE_PROP)) {
				query.orderBy(new OrderSpecifier<String>(o.isAscending() ? Order.ASC : Order.DESC, titlePath));
			} else {
				query.orderBy(new OrderSpecifier(o.isAscending() ? Order.ASC : Order.DESC, entity.get(o.getProperty())));
			}
		}

		var contentStream = query.fetch().stream().map(tuple -> {
			var defaultLang = tuple.get(2, langType);
			var requestedLang = tuple.get(3, langType);
			return toTranslated(tuple.get(0, targetType), languageTag, requestedLang == null ? defaultLang : requestedLang);
		});

		if (sorter != null) {
			contentStream = contentStream.sorted(sorter);
		}
		var content = contentStream.collect(Collectors.toList());

		return new PageImpl<>(content, page, totalElements);
	}

	private final T toTranslated(@Nullable E entity, String requestedLang, @Nullable L translation) {
		try {
			var translated = translatedType.getConstructor().newInstance();
			translated.setEntity(entity);
			if (Objects.equals(requestedLang, entity.getOriginalLanguageTag())) {
				// Don't include translation if entity is already in the requested language
			} else {
				translated.setTranslation(translation);
			}
			return translated;
		} catch (Throwable e) {
			LOG.error("Cannot create translated class: {}", e.getMessage(), e);
			throw new RuntimeException("Cannot create translated class", e);
		}
	}

	@Transactional
	public L deleteTranslation(E entity, String languageTag) {
		L existing = langsRepository.getByEntityAndLanguageTag(entity, languageTag);
		if (existing == null) {
			return null;
		}
		return remove(existing);
	}

	public List<L> listExistingTranslations(E entity) {
		var list = langsRepository.findAllByEntity(entity);
		return list;
	}

	public List<L> listTranslations(E entity) {
		var list = langsRepository.findAllByEntity(entity);

		List<L> res = list;
		res.sort((lang1, lang2) -> {
			if (Locale.ENGLISH.getLanguage().equals(lang1.getLanguageTag())) {
				return -1;
			} else if(Locale.ENGLISH.getLanguage().equals(lang2.getLanguageTag())) {
				return 1;
			} else {
				return lang1.getLanguageTag().compareToIgnoreCase(lang2.getLanguageTag());
			}
		});

		return res;
	}

	@Transactional
	@Override
	public L addLang(E entity, L input) {
		assert(entity != null);
		assert(input.getId() == null);
		assert(input.getEntity() == null);
		assert(StringUtils.isNotBlank(input.getLanguageTag()));
		assert(!Objects.equals(entity.getOriginalLanguageTag(), input.getLanguageTag())); // Ensure we don't recreate translations of the original language

		input.setEntity(entity);
		return create(input);
	}

	@Override
	public L getLang(E entity, String languageTag) {
		return langsRepository.getByEntityAndLanguageTag(entity, languageTag);
	}

	@Override
	@Transactional
	public L upsertLang(E entity, L input) {
		assert(input.getEntity() == null || entity.getId().equals(input.getEntity().getId()));
		assert(StringUtils.isNotBlank(input.getLanguageTag()));
		L existing = getLang(entity, input.getLanguageTag());

		if (existing == null) {
			assert(input.isNew());
			input.setEntity(entity);
			return create(input);
		} else {
			return update(input, existing);
		}
	}

	/**
	 * The default implementation reloads the entity and sysLang, saves incoming data
	 */
	@Override
	public L create(L source) {
		assert(source.isNew());
		source.setEntity(owningEntityRepository.getReferenceById(source.getEntity().getId()));
		return langsRepository.save(source);
	}

	/**
	 * The default implementation uses Copyable
	 */
	@Override
	public L update(L updated, L target) {
		target.apply(updated);
		return repository.save(target);
	}
}