CropTraitObservationDataServiceImpl.java

/*
 * Copyright 2021 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 java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.api.v1.MultiOp;
import org.gringlobal.api.v1.impl.CropTraitObservationController;
import org.gringlobal.model.CropTrait;
import org.gringlobal.model.CropTraitObservation;
import org.gringlobal.model.CropTraitObservationData;
import org.gringlobal.model.Inventory;
import org.gringlobal.model.Method;
import org.gringlobal.model.QCropTrait;
import org.gringlobal.model.QCropTraitObservationData;
import org.gringlobal.model.QInventory;
import org.gringlobal.model.community.CommunityCodeValues;
import org.gringlobal.persistence.CropTraitObservationDataRepository;
import org.gringlobal.persistence.CropTraitRepository;
import org.gringlobal.persistence.InventoryRepository;
import org.gringlobal.persistence.MethodRepository;
import org.gringlobal.service.CropTraitCodeService;
import org.gringlobal.service.CropTraitObservationDataService;
import org.gringlobal.service.CropTraitService;
import org.gringlobal.service.CropTraitTranslationService.TranslatedCropTrait;
import org.gringlobal.service.filter.CropTraitObservationDataFilter;
import org.hibernate.Hibernate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.util.Pair;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;

import com.querydsl.core.BooleanBuilder;
import com.querydsl.jpa.impl.JPAQuery;


@Service
@Validated
@Transactional(readOnly = true)
@Slf4j
public class CropTraitObservationDataServiceImpl extends FilteredCRUDServiceImpl<CropTraitObservationData, CropTraitObservationDataFilter, CropTraitObservationDataRepository> implements CropTraitObservationDataService {

	@Autowired
	private CropTraitService cropTraitService;

	@Autowired
	private CropTraitCodeService cropTraitCodeService;

	@Autowired
	private MethodRepository methodRepository;

	@Autowired
	private InventoryRepository inventoryRepository;

	@Autowired
	private CropTraitRepository cropTraitRepository;

	@Override
	protected JPAQuery<CropTraitObservationData> entityListQuery() {
		return jpaQueryFactory.selectFrom(QCropTraitObservationData.cropTraitObservationData)
				// method
				.join(QCropTraitObservationData.cropTraitObservationData.method()).fetchJoin()
				// inventory
				.join(QCropTraitObservationData.cropTraitObservationData.inventory()).fetchJoin()
				// trait
				.join(QCropTraitObservationData.cropTraitObservationData.cropTrait()).fetchJoin()
				// trait
				.leftJoin(QCropTraitObservationData.cropTraitObservationData.cropTraitCode()).fetchJoin()
			;
	}

	@Override
	@PreAuthorize("@ggceSec.actionAllowed('CropTraitObservation', 'CREATE')")
	@Transactional
	public CropTraitObservationData create(CropTraitObservationData source) {
		assert(source.getId() == null);
		source.setCropTrait(cropTraitService.get(source.getCropTrait().getId()));
		if (source.getCropTraitCode() != null) {
			source.setCropTraitCode(cropTraitCodeService.get(source.getCropTraitCode().getId()));
			if (!source.getCropTrait().getId().equals(source.getCropTraitCode().getCropTrait().getId())) {
				throw new InvalidApiUsageException("cropTraitCode does not belong to cropTrait");
			}
		}
		CropTraitObservationData observationData = new CropTraitObservationData();
		observationData.apply(source);
//		observationData.setCropTraitObservation(null);

		var saved = repository.save(observationData);
		return _lazyLoad(saved);
	}

	@Override
	@PreAuthorize("@ggceSec.actionAllowed('CropTraitObservation', 'CREATE')")
	@Transactional
	public MultiOp<CropTraitObservationData> create(List<CropTraitObservationData> inserts) {
		return super.create(inserts);
	}

	@Override
	@PreAuthorize("@ggceSec.actionAllowed('CropTraitObservation', 'WRITE')")
	@Transactional
	public CropTraitObservationData update(CropTraitObservationData input, CropTraitObservationData target) {
		assert(input.getId() != null);
		input.setCropTrait(cropTraitService.get(input.getCropTrait().getId()));
		if (input.getCropTraitCode() != null) {
			input.setCropTraitCode(cropTraitCodeService.get(input.getCropTraitCode().getId()));
			if (!input.getCropTrait().getId().equals(input.getCropTraitCode().getCropTrait().getId())) {
				throw new InvalidApiUsageException("cropTraitCode does not belong to cropTrait");
			}
		}
		target.apply(input);
//		target.setCropTraitObservation(null);

		var saved = repository.save(target);
		return _lazyLoad(saved);
	}

	@Override
	@PreAuthorize("@ggceSec.actionAllowed('CropTraitObservation', 'WRITE')")
	@Transactional
	public MultiOp<CropTraitObservationData> update(List<CropTraitObservationData> updates) {
		return super.update(updates);
	}

	@Override
	@PreAuthorize("@ggceSec.actionAllowed('CropTraitObservation', 'DELETE')")
	@Transactional
	public CropTraitObservationData remove(CropTraitObservationData entity) {
		return super.remove(entity);
	}

	@Override
	@PreAuthorize("@ggceSec.actionAllowed('CropTraitObservation', 'DELETE')")
	@Transactional
	public MultiOp<CropTraitObservationData> remove(List<CropTraitObservationData> deletes) {
		return super.remove(deletes);
	}

	/**
	 * We generate CTO records based on raw CTOD. These records are not persisted!
	 */
	@Override
	public List<CropTraitObservation> generateObservations(Method method, CropTrait cropTrait) {

		var qCtod = QCropTraitObservationData.cropTraitObservationData;
		var ctods = (Collection<CropTraitObservationData>) repository.findAll(
			qCtod.method().eq(method)
				.and(qCtod.cropTrait().eq(cropTrait)));

		log.debug("Found corresponding {} CTODs", ctods.size());

		var ctos = new HashMap<String, CropTraitObservation>();

		// Generate a map by method+inventory+trait(+traitCode) key containing a List<CTOD>
		var ctodByKey = ctods.stream().collect(Collectors.toMap(
			ctod -> {
				if (StringUtils.equals("Y", ctod.getCropTrait().getIsCoded())) {
					return ctod.getMethod().getId() + "-" + ctod.getInventory().getId() + "-" + ctod.getCropTrait().getId() + "-" + ctod.getCropTraitCode().getId();
				}
				if (StringUtils.equals(CommunityCodeValues.CROP_TRAIT_DATA_TYPE_CHAR.value, ctod.getCropTrait().getDataTypeCode())) {
					return ctod.getMethod().getId() + "-" + ctod.getInventory().getId() + "-" + ctod.getCropTrait().getId() + "-" + ctod.getStringValue();
				}
				return ctod.getMethod().getId() + "-" + ctod.getInventory().getId() + "-" + ctod.getCropTrait().getId();
			},
			ctod -> List.of(ctod),
			(s, a) -> {
				if (s instanceof ArrayList<?>) {
					s.addAll(a);
					return s;
				} else {
					var merged = new ArrayList<>(s);
					merged.addAll(a);
					return merged;
				}
			}));

		log.info("Generated {} CTO keys for {} CTODs", ctodByKey.size(), ctods.size());

		for (var entry : ctodByKey.entrySet()) {
			var key = entry.getKey();
			var rawCtods = entry.getValue(); // These all tally up by method+inventory+trait(+traitCode)
			log.info("Processing {} with {} CTOD", key, rawCtods.size());

			var cto = makeCropTraitObservation(rawCtods);
			ctos.put(key, cto);
		}

		var generatedCtos = new ArrayList<>(ctos.values());

		// Update frequency
		var traitCtos = generatedCtos.stream().collect(Collectors.groupingBy(CropTraitObservation::getInventory, Collectors.toList()));
		traitCtos.entrySet().forEach(entry -> {
			var iCtos = entry.getValue();
			if (iCtos.size() > 1) {
				log.debug("Updating frequency and rank for inventory={} size={}", entry.getKey().getId(), iCtos.size());
				var sumSize = iCtos.stream().collect(Collectors.summingInt(CropTraitObservation::getSampleSize));
				iCtos.forEach(o -> o.setFrequency(o.getSampleSize().doubleValue() / sumSize));
				iCtos.sort((a, b) -> Integer.compare(b.getSampleSize(), a.getSampleSize()));
				iCtos.forEach(o -> o.setRank(iCtos.indexOf(o)));
			}
		});

		return generatedCtos;
	}

	private CropTraitObservation makeCropTraitObservation(List<CropTraitObservationData> ctods) {
		assert(ctods.size() > 0); // Must always have CTODs
		var cto = new CropTraitObservation();

		cto.setObservationData(ctods);

		// Populate keys
		var ctod1 = ctods.get(0);
		cto.setMethod(ctod1.getMethod());
		cto.setInventory(ctod1.getInventory());
		cto.setCropTrait(ctod1.getCropTrait());
		cto.setCropTraitCode(ctod1.getCropTraitCode());

		cto.setSampleSize(ctods.size()); // We have data on individuals, so sample size is the count

		if (cto.getCropTraitCode() != null) {
			// This is obviously a coded trait


		} else if (StringUtils.equals(CommunityCodeValues.CROP_TRAIT_DATA_TYPE_CHAR.value, cto.getCropTrait().getDataTypeCode())) {
			// For same for text observations
			cto.setStringValue(ctod1.getStringValue());


		} else if (StringUtils.equals(CommunityCodeValues.CROP_TRAIT_DATA_TYPE_NUMERIC.value, cto.getCropTrait().getDataTypeCode())) {
			// Numeric trait observations are summarized in CTO

			var numericValues = ctods.stream().map(CropTraitObservationData::getNumericValue).filter(f -> f != null).collect(Collectors.toList());
			if (numericValues.size() > 1) {
				var sum = numericValues.stream().collect(Collectors.summarizingDouble(Double::doubleValue));
				cto.setNumericValue(sum.getAverage());
				cto.setMaximumValue(sum.getMax());
				cto.setMeanValue(sum.getAverage());
				cto.setMinimumValue(sum.getMin());
	
				// STDDEV
				var x = numericValues.stream().collect(Collectors.summingDouble(v -> Math.pow(v - sum.getAverage(), 2)));
				cto.setStandardDeviation(Math.sqrt(x / (sum.getCount() - 1)));
	
			} else if (numericValues.size() > 0) {
				cto.setNumericValue(numericValues.get(0));
			}
		}

		return cto;
	}

	@Override
	public FilteredObservationsData search(CropTraitObservationDataFilter filter, Pageable page) {
		if (filter == null || CollectionUtils.isEmpty(filter.cropTraitId)) {
			throw new InvalidApiUsageException("List of traits is required");
		}

		BooleanBuilder predicate = new BooleanBuilder();
		predicate.and(filter.buildPredicate());

		Page<CropTraitObservationData> resultPage = repository.findAll(predicate, page);

		Set<Method> methods = new HashSet<>();
		Set<TranslatedCropTrait> cropTraits = new HashSet<>();
		filter.cropTraitId.forEach((cropTraitId) -> cropTraits.add(cropTraitService.loadTranslated(cropTraitId)));

		Map<Inventory, Set<CropTraitObservationData>> observationsData = new HashMap<>();

		resultPage.forEach(o -> {
			// initialize lazy data
			Hibernate.initialize(o.getMethod());
			Hibernate.initialize(o.getInventory());

			methods.add(o.getMethod());

			Set<CropTraitObservationData> ctod = observationsData.getOrDefault(o.getInventory(), new HashSet<>());
			ctod.add(o);
			observationsData.put(o.getInventory(), ctod);
		});

		FilteredObservationsData result = new FilteredObservationsData();
		result.filter = filter;
		result.methods = methods;
		result.cropTraits = cropTraits;
		result.observationsData = new HashSet<>(observationsData.size());

		observationsData.keySet().forEach(i -> result.observationsData.add(new InventoryWithObservationsData(i, observationsData.get(i))));

		return result;
	}

	@Override
	public Page<Inventory> getObservationDataInventoriesByMethod(Long methodId, Pageable pageable) {
		var method = methodRepository.getReferenceById(methodId);
		var observationDataPath = QCropTraitObservationData.cropTraitObservationData;
		var inventoryIds = jpaQueryFactory.select(observationDataPath.inventory().id).distinct().from(observationDataPath)
			.where(observationDataPath.method().id.eq(method.getId()).and(observationDataPath.inventory().isNotNull()))
			.fetch();
		return inventoryRepository.findAll(QInventory.inventory.id.in(inventoryIds), pageable);
	}

	@Override
	public Page<CropTrait> getObservationDataTraitsByMethod(Long methodId, Pageable pageable) {
		var method = methodRepository.getReferenceById(methodId);
		var observationDataPath = QCropTraitObservationData.cropTraitObservationData;
		var cropTraitIds = jpaQueryFactory.select(observationDataPath.cropTrait().id).distinct().from(observationDataPath)
			.where(observationDataPath.method().id.eq(method.getId()).and(observationDataPath.cropTrait().isNotNull()))
			.fetch();
		return cropTraitRepository.findAll(QCropTrait.cropTrait.id.in(cropTraitIds), pageable);
	}

	@Override
	@PreAuthorize("@ggceSec.actionAllowed('CropTraitObservation', 'CREATE')")
	@Transactional
	public int ensureObservationData(CropTraitObservationController.EnsureObservationsRequest request) {
		if (request.methodId == null || CollectionUtils.isEmpty(request.inventoryId) || CollectionUtils.isEmpty(request.cropTraitId)) {
			throw new InvalidApiUsageException("Method id, CropTrait ids and inventory ids must be provided");
		}
		var method = methodRepository.getReferenceById(request.methodId);
		var observationDataPath = QCropTraitObservationData.cropTraitObservationData;
		var observations = jpaQueryFactory.selectFrom(observationDataPath).distinct()
			.where(observationDataPath.method().id.in(method.getId())
				.and(observationDataPath.cropTrait().id.in(request.cropTraitId))
				.and(observationDataPath.inventory().id.in(request.inventoryId))).fetch();

		var idPairs = observations.stream().map(obs -> Pair.of(obs.getCropTrait().getId(), obs.getInventory().getId()))
			.collect(Collectors.toList());

		List<CropTraitObservationData> observationDataForSave = new ArrayList<>();
		for (var traitId: request.cropTraitId) {
			for (var inventoryId: request.inventoryId) {
				if (!idPairs.contains(Pair.of(traitId, inventoryId))) {
					CropTraitObservationData observationData = new CropTraitObservationData();
					observationData.setCropTrait(cropTraitRepository.getReferenceById(traitId));
					observationData.setInventory(inventoryRepository.getReferenceById(inventoryId));
					observationData.setMethod(method);
					observationDataForSave.add(observationData);
				}
			}
		}

		var saved = repository.saveAll(observationDataForSave);
		return saved.size();
	}
}