CRUDServiceImpl.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 java.io.IOException;
import java.io.Writer;
import java.lang.reflect.ParameterizedType;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.persistence.EntityManager;
import javax.persistence.Id;
import javax.persistence.PersistenceContext;
import javax.servlet.http.HttpServletResponse;

import com.google.common.collect.Ordering;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.genesys.blocks.model.EmptyModel;
import org.genesys.filerepository.service.RepositoryService;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.api.exception.NotFoundElement;
import org.gringlobal.api.v1.MultiOp;
import org.gringlobal.api.v1.Pagination;
import org.gringlobal.model.AuditedModel;
import org.gringlobal.model.DateVersionEntityId;
import org.gringlobal.model.DateVersionEntityId.EntityIdAndModifiedDate;
import org.gringlobal.model.LazyLoading;
import org.gringlobal.service.AppSettingsService;
import org.gringlobal.service.CRUDService;
import org.gringlobal.service.JasperReportService;
import org.gringlobal.service.TemplatingService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

import com.querydsl.core.types.dsl.NumberPath;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;

/**
 * The basic FilteredCRUDServiceImpl.
 *
 * @param <T> the model type
 * @param <R> the repository type
 */
@Transactional(readOnly = true)
@Slf4j
public abstract class CRUDServiceImpl<T extends EmptyModel, R extends JpaRepository<T, Long>> implements InitializingBean, CRUDService<T> {

	@SuppressWarnings("unchecked")
	private final Class<T> modelClass = (Class<T>) ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[0];

	/** The repository. */
	@Autowired
	protected R repository;

	@Autowired
	protected JPAQueryFactory jpaQueryFactory;

	@Autowired
	protected RepositoryService repositoryService;

	@Autowired
	protected JasperReportService jasperReportService;

	@Autowired
	@Lazy
	protected AppSettingsService appSettingsService;

	@Autowired
	protected TemplatingService templatingService;

	@PersistenceContext
	protected EntityManager entityManager;

	protected Set<String> idSortParams = new HashSet<>();

	@Override
	public void afterPropertiesSet() throws Exception {
//		JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager);
//		this.builder = new PathBuilderFactory().create(domainClass);
		idSortParams = getIdSortParams();
	}

	protected Set<String> getIdSortParams() {
		Set<String> idParamNames = new HashSet<>();
		ReflectionUtils.doWithFields(modelClass, field -> idParamNames.add(field.getName()), field -> field.getAnnotation(Id.class) != null);
		return idParamNames;
	}

	// Until .lazyLoad() was in an interface
	protected T _lazyLoad(T entity) {
		if (entity == null) {
			return entity;
		}
		if (entity instanceof LazyLoading<?>) {
			((LazyLoading<?>) entity).lazyLoad();
			return entity;
		} else {
//			throw new RuntimeException("Class " + entity.getClass() + " does not implement LazyLoading<> interface. You must provide an implementation");
			return entity;
		}
	}

	/**
	 * Save the new entity
	 *
	 * @param source incoming data
	 * @return the saved entity
	 */
	@Override
	@Transactional
	public abstract T create(T source);

	/**
	 * The default MultiOp implementation calls {@link #create(EmptyModel))} for each insert.
	 * If any one insert fails, the transaction is marked as roll-back only and cannot be used for further writes.
	 */
	@Override
	@Transactional
	public MultiOp<T> create(List<T> inserts) {
		var result = new MultiOp<T>();
		result.success = new ArrayList<T>(inserts.size());
		for (T one : inserts) {
			result.success.add(this.create(one));
		}
		return result;
	}

	/**
	 * Fetch entity by id, don't lazy load
	 *
	 * @param id the id
	 * @return the entity
	 */
	@Override
	public T get(long id) {
		return repository.findById(id).orElseThrow(() -> new NotFoundElement("No record with id=" + id));
	}

	/**
	 * Fetch entity by EntityIdAndModifiedDate, check version
	 *
	 * @param id the EntityIdAndModifiedDate
	 * @return the entity
	 */
	@Override
	public T get(EntityIdAndModifiedDate id) {
		if (id.modifiedDate == null || id.id == null) {
			throw new InvalidApiUsageException("Entity id and modifiedDate must be provided.");
		}
		T current = get(id.id);
		DateVersionEntityId audited = (DateVersionEntityId) current;
		// check that target has the same modifiedDate as source
		if (audited.getModifiedDate() != null && !audited.getModifiedDate().equals(id.modifiedDate)) {
			log.warn("modifiedDate mismatch got={} want={}", id.modifiedDate, audited.getModifiedDate());
			throw new ObjectOptimisticLockingFailureException(current.getClass(), id.id);
		}
		return current;
	}

	/**
	 * Fetch entity by id, check version
	 *
	 * @param id the id
	 * @param modifiedDate the modifiedDate
	 * @return the entity
	 */
	@Override
	public T get(long id, Instant modifiedDate) {
		return get(new EntityIdAndModifiedDate(id, modifiedDate));
	}

	/**
	 * Re-get the entity based on identifiers in the source.
	 *
	 * @param source the source entity
	 * @return the reloaded entity, lazy-loading included
	 */
	public T get(T source) {
		if (source.getId() == null) {
			throw new InvalidApiUsageException("Entity id must be provided.");
		}
		T current;
		if (source instanceof AuditedModel) {
			current = get(source.getId(), ((AuditedModel) source).getModifiedDate());
		} else {
			current = get(source.getId());
		}
		return current;
	}

	/**
	 * Load entity by id and lazy load.
	 *
	 * @param id the id
	 * @return the entity, lazy-loading included
	 */
	@Override
	public T load(long id) {
		T entity = get(id);
		return _lazyLoad(entity);
	}

	/**
	 * Re-load the entity based on identifiers in the source.
	 *
	 * @param source the source entity
	 * @return the reloaded entity, lazy-loading included
	 */
	@Override
	public final T reload(T source) {
		return _lazyLoad(get(source));
	}

	/**
	 * List records.
	 *
	 * @param page Pageable
	 * @return the page
	 */
	@Override
	public Page<T> list(Pageable page) {
		page = Pagination.addSortByParams(page, idSortParams);
		return repository.findAll(page);
	}

	/**
	 * Save entity.
	 *
	 * @param updated data
	 * @param target current entity
	 * @return the updated entity
	 */
	@Override
	@Transactional
	public abstract T update(T updated, T target);

	@Override
	@Transactional
	public T update(T updated) {
		entityManager.detach(updated); // Ensure that EM does not reuse incoming entity
		T target = get(updated);
		return update(updated, target);
	}

	/**
	 * The default MultiOp update implementation calls {@link #update(EmptyModel))} for each update.
	 * If any one update fails, the transaction is marked as roll-back only and cannot be used for further writes.
	 */
	@Override
	@Transactional
	public MultiOp<T> update(List<T> updates) {
		var result = new MultiOp<T>();
		result.success = new ArrayList<T>(updates.size());
		for (T one : updates) {
			result.success.add(this.update(one));
		}
		return result;
	}

	/**
	 * Removes the record.
	 *
	 * @param entity record to delete
	 * @return the removed entity
	 */
	@Override
	@Transactional
	public T remove(T entity) {
		if (entity == null) {
			throw new InvalidApiUsageException("Entity must be provided.");
		}
		entity = get(entity); // Remove, the entity should have the correct id + version!
		repository.delete(entity); // FIXME for AuditedModel this should be automatic!
		// FIXME clear id?
		// entity.setId(null);
		return entity;
	}

	/**
	 * The default MultiOp delete implementation calls {@link #remove(EmptyModel))} for each update.
	 * If any one delete fails, the transaction is marked as roll-back only and cannot be used for further writes.
	 */
	@Override
	@Transactional
	public MultiOp<T> remove(List<T> deletes) {
		var result = new MultiOp<T>();
		result.success = new ArrayList<T>(deletes.size());
		for (T one : deletes) {
			result.success.add(this.remove(one));
		}
		return result;
	}

	/**
	 * Get a JPA query that (optionally) includes lazy loaded data.
	 * <example><code>jpaQueryFactory.selectFrom(QTaxonomySpecies.taxonomySpecies).join(...).fetchJoin()</code></example>
	 * 
	 * @return a custom JPA query for T
	 */
	protected JPAQuery<T> entityListQuery() {
		return null;
	}

	/**
	 * The Entity id predicate of {link {@link #entityListQuery()}
	 * <example><code>QTaxonomySpecies.taxonomySpecies.id</code></example>
	 *
	 * @return the number path
	 */
	protected NumberPath<Long> entityIdPredicate() {
		throw new UnsupportedOperationException("You must implement #entityIdPredicate() if you declare #entityListQuery()");
	}
	
	/**
	 * Get a list of entities with specified IDs in the order specified (if incoming collection of IDs is ordered).
	 * Override this method to lazy load more than default data.
	 *
	 * @param entityIds the entity ids
	 * @return the list
	 */
	public List<T> list(Collection<Long> entityIds) {
		List<T> unsorted;
		if (entityListQuery() != null) {
			unsorted = entityListQuery().where(entityIdPredicate().in(entityIds)).fetch();
		} else {
			unsorted = repository.findAllById(entityIds);
		}
		if (entityIds instanceof List<?>) {
			List<Long> idList = (List<Long>) entityIds;
			unsorted.sort((e1, e2) -> idList.indexOf(e1.getId()) - idList.indexOf(e2.getId()));
		}
		return unsorted;
	}

	@Override
	public ResponseEntity<StreamingResponseBody> generateReport(String reportTemplate, Set<Long> entityIds, Locale locale, HttpServletResponse response) throws Exception {
		String entityName = getServiceEntityName();
		List<T> entities = repository.findAllById(entityIds);
		entities.forEach(this::_lazyLoad);

		return jasperReportService.generatePdfReport(entityName, reportTemplate, Arrays.asList(entities.toArray()), locale, null);
	}


	/**
	 * Override if you need to generate more than one label per entity.
	 *
	 * @param template the actual template
	 * @param params context parameters
	 * @param entity the entity
	 */
	protected Collection<String> generateLabelsForEntity(String template, Map<String, Object> params, T entity) {
		return List.of(templatingService.fillTemplate(template, params));
	}

	@Override
	public void generateLabels(LabelConfig labelConfig, List<Long> ids, Writer writer) throws IOException {
		var template = appSettingsService.getSetting("LABEL", labelConfig.getTemplate(), labelConfig.getSortOrder()).getValue();
		var entities = repository.findAllById(ids);
		entities.sort(Ordering.explicit(ids).onResultOf(EmptyModel::getId));

		List<String> labels = new LinkedList<>();
		Map<String, Object> params = new HashMap<>();
		for (int i = 0; i < entities.size(); i++) {
			var entity = entities.get(i);

			params.clear();
			params.put("_counter", i + 1);
			prepareLabelContext(params, entity);
			labels.addAll(escapeZpl(generateLabelsForEntity(template, params, entity)));
		}
		
		if (labelConfig.isCollated()) {
			for (int i = 0; i < labelConfig.getNumberOfCopies(); i++) {
				for (var label : labels) {
					writer.append(label).append("\n\n\n");
				}
			}
		} else {
			for (String label : labels) {
				for (int i = 0; i < labelConfig.getNumberOfCopies(); i++) {
					writer.append(label).append("\n\n\n");
				}
			}
		}
	}

	@Override
	public void generateLabels(Map<Long, LabelConfig> labelConfigs, Writer writer) throws IOException {
		var ids = new LinkedList<>(labelConfigs.keySet());
		var entities = repository.findAllById(ids);
		entities.sort(Ordering.explicit(ids).onResultOf(EmptyModel::getId));

		var templateCache = new HashMap<String, String>(); // Local template cache

		Map<Long, Collection<String>> entityLabels = new LinkedHashMap<>(labelConfigs.size());
		Map<String, Object> params = new HashMap<>();
		for (int i = 0; i < entities.size(); i++) {
			var entity = entities.get(i);
			var config = labelConfigs.get(entity.getId());

			params.clear();
			params.put("_counter", i + 1);
			prepareLabelContext(params, entity);
			var template = templateCache.computeIfAbsent(config.getTemplate() + " " + config.getSortOrder(), (k) -> appSettingsService.getSetting("LABEL", config.getTemplate(), config.getSortOrder()).getValue());
			entityLabels.put(entity.getId(), escapeZpl(generateLabelsForEntity(template, params, entity)));
		}

		Map<Long, Integer> labelCopiesCounter = labelConfigs.entrySet().stream()
			.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getNumberOfCopies()));

		int numberOfLabels = 0;

		// if config.isCollated == true then add one copy of label each iteration
		// else if config.isCollated == false then add all copies at once
		// iterate while numberOfLabels != 0
		do {
			for (Map.Entry<Long, Collection<String>> entityLabel : entityLabels.entrySet()) {
				var entityId = entityLabel.getKey();
				var config = labelConfigs.get(entityId);
				var counter = labelCopiesCounter.get(entityId);
				if (counter > 0) {
					if (config.isCollated()) {
						for (var label : entityLabel.getValue()) {
							writer.append(label).append("\n\n\n");
						}
						labelCopiesCounter.put(entityId, counter - 1);
					} else {
						for (var label : entityLabel.getValue()) {
							for (int i = 0; i < counter; i++) {
								writer.append(label).append("\n\n\n");
							}
						}
						labelCopiesCounter.put(entityId, 0);
					}
				}
			}

			numberOfLabels = labelCopiesCounter.values().stream().mapToInt(Integer::intValue).sum();
		} while (numberOfLabels != 0);
	}

	protected void prepareLabelContext(Map<String, Object> context, T entity) {
		context.put(StringUtils.uncapitalize(entity.getClass().getSimpleName()), entity);
	}

	/*
	 * Find fields that potentially need hex encoding in ZPL: ^FD ^FV (ignoring ^SN)
	 * See p.136 in ZPL II Programming Guide
	 * 
	 * Note: ~ and ˜ are different things!
	 */
	private static Pattern ZPL_FIELD = Pattern.compile("\\^(FD|FV)((.(?!\\^FS))*.?)\\^FS", Pattern.DOTALL);

	private Collection<String> escapeZpl(Collection<String> labels) {
		return labels.stream()
		.peek(zpl -> { log.debug("Source ZPL: {}", zpl); })
		.map(zpl -> {
			var matcher = ZPL_FIELD.matcher(zpl);

			// Not using matcher.replaceAll() because of how it handles \ and $ characters!
			if (! matcher.find()) return zpl;

			var repl = new StringBuffer(zpl);
			var diffChars = 0; // We're replacing source strings with longer strings
			do {
				log.debug("  Found {}", matcher.group());
				// for (var i = 0; i < matcher.groupCount(); i++) {
				// 	System.err.println("   group " + i + ": " + matcher.group(i));
				// }
				var data = matcher.group(2);
				if (! data.contains("~") && ! data.contains("^")) continue; // No weird characters
				var cmd = matcher.group(1);
				data = data.replaceAll("\\\\", "\\\\1f").replaceAll("\\~", "\\\\7e").replaceAll("\\^", "\\\\5e");
				var res = "^FH\\^" + cmd + data + "^FS";
				log.debug("  Replaced data={}", data);
				repl.replace(diffChars + matcher.start(), diffChars + matcher.end(), res);
				diffChars += res.length() - matcher.group().length();
			} while (matcher.find());
			return repl.toString();
		})
		.peek(zpl -> { log.debug("Escaped ZPL: {}", zpl); })
		.collect(Collectors.toList());
	}

	@Override
	public String getServiceEntityName() {
		return modelClass.getSimpleName();
	}
}