All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.sap.cds.reflect.impl.DraftAdapter Maven / Gradle / Ivy

There is a newer version: 3.6.1
Show newest version
package com.sap.cds.reflect.impl;

import static com.sap.cds.reflect.impl.reader.model.CdsConstants.ANNOTATION_CASCADE_ALL;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.ANNOTATION_CASCADE_UPDATE;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.ANNOTATION_CDS_AUTOEXPOSED;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.ANNOTATION_DRAFT_ENABLED;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.ANNOTATION_DRAFT_PREPARE_ACTION;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.ANNOTATION_PERSISTENCE_NAME;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.ANNOTATION_READONLY;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.ASSOCIATION;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.COMPOSITION;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.ELEMENTS;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.KEY;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.LOCALIZEDCSN;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.ON;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.PROJECTION;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.QUERY;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.TARGET;
import static com.sap.cds.reflect.impl.reader.model.CdsConstants.TYPE;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiConsumer;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import com.google.common.collect.Maps;
import com.sap.cds.reflect.impl.reader.model.CdsConstants;

/**
 * This class can be used to adapt a CDS model that was generated for OData in a
 * way that it reflects the actual database objects and relations between them.
 *
 */
public class DraftAdapter {

	// suffixes
	private static final String DRAFTS_SUFFIX_ENTITY = "_drafts";
	private static final String DRAFTS_SUFFIX_TABLE = "_DRAFTS";

	// field names of draft entities
	public static final String IS_ACTIVE_ENTITY = "IsActiveEntity";
	private static final String HAS_ACTIVE_ENTITY = "HasActiveEntity";
	private static final String HAS_DRAFT_ENTITY = "HasDraftEntity";
	private static final String SIBLING_ENTITY = "SiblingEntity";
	private static final String SIBLING_ENTITY_UNSECURED = SIBLING_ENTITY + "_unsecured";
	private static final String DRAFT_ADMINISTRATIVE_DATA = "DraftAdministrativeData";
	private static final String ADMINSTRATIVE_DATA_TABLE = "DRAFT." + DRAFT_ADMINISTRATIVE_DATA;
	private static final String DRAFT_UUID = DRAFT_ADMINISTRATIVE_DATA + "_DraftUUID";

	private static final ObjectMapper mapper = new ObjectMapper();

	/**
	 * Flag to indicate if the model should really be adapted
	 */
	private final boolean adaptDrafts;

	/**
	 * A map of the entities in the model
	 */
	private final Map entityObjects;

	/**
	 * A map of the services in the model
	 */
	private final Map serviceObjects;

	/**
	 * The draft entities that were found in the model
	 */
	private final Map draftEntities = Maps.newLinkedHashMap();

	/**
	 * Constructor
	 *
	 * @param adaptDrafts    if {@code true}, the model should be adapted
	 * @param entityObjects  a map of the entities in the model
	 * @param serviceObjects a map of the services in the model
	 */
	public DraftAdapter(boolean adaptDrafts, Map entityObjects,
			Map serviceObjects) {
		this.adaptDrafts = adaptDrafts;
		this.entityObjects = entityObjects;
		this.serviceObjects = serviceObjects;
	}

	/**
	 * Checks if the entity is a draft entity
	 *
	 * @param name   the name of the entity
	 * @param object the JSON node
	 */
	public void processEntity(String name, JsonNode object) {
		if (adaptDrafts) {
			if (isDraftEnabled(object)) {
				draftEntities.put(name, object);
			}
		}
	}

	/**
	 * Adapts the model, if required
	 */
	public void adaptDraftEntities() {
		if (adaptDrafts) {
			entityObjects.forEach((name, entityNode) -> {
				addAssociationsToDraftEntities(entityNode, name);
				if (isDraftAdminDataEntity(name)) {
					addAccessControlAnnotations(entityNode);
				}
			});
			for (Map.Entry entry : draftEntities.entrySet()) {
				JsonNode object = entry.getValue();
				String name = entry.getKey();
				ObjectNode copy = object.deepCopy();
				changeCompositionTargets(copy);
				changeAssociations(copy, name);
				if (copy.has(ANNOTATION_PERSISTENCE_NAME)) {
					copy.put(ANNOTATION_PERSISTENCE_NAME,
							copy.get(ANNOTATION_PERSISTENCE_NAME).asText() + DRAFTS_SUFFIX_TABLE);
				}

				copy.remove(QUERY);
				copy.remove(PROJECTION); // required for cds-compiler v2 compatibility
				removeSiblingActiveEntityCondition(copy);
				removeActiveEntityKeyQualifier(copy);
				adaptDraftAdministrativeData(copy);
				entityObjects.put(getDraftsEntity(name), copy);
				removeDraftFieldsAndChangeSibling(object);
				removeLocalizedFromElements(copy);
				removeCascadeAnnotationsFromAssociations(copy);
			}
		}
	}

	private void removeCascadeAnnotationsFromAssociations(ObjectNode object) {
		if (object.has(ELEMENTS)) {
			ObjectNode elements = (ObjectNode) object.findValue(ELEMENTS);
			elements.fields().forEachRemaining(e -> {
				if (!DRAFT_ADMINISTRATIVE_DATA.equals(e.getKey()) && e.getValue().has(TYPE)
						&& CdsConstants.ASSOCIATION.equals(e.getValue().get(TYPE).asText())) {
					((ObjectNode) e.getValue()).remove(CdsConstants.ANNOTATION_CASCADE_ALL);
					((ObjectNode) e.getValue()).remove(CdsConstants.ANNOTATION_CASCADE_UPDATE);
					((ObjectNode) e.getValue()).remove(CdsConstants.ANNOTATION_CASCADE_INSERT);
					((ObjectNode) e.getValue()).remove(CdsConstants.ANNOTATION_CASCADE_DELETE);
				}

			});
		}
	}

	private boolean isDraftAdminDataEntity(String name) {
		int draftAdminDataIndex = name.lastIndexOf("." + DRAFT_ADMINISTRATIVE_DATA);
		if (draftAdminDataIndex > 0) {
			String serviceName = name.substring(0, draftAdminDataIndex);
			return serviceObjects.get(serviceName) != null;
		}
		return false;
	}

	private void addAccessControlAnnotations(JsonNode entityNode) {
		if (entityNode.isObject()) {
			ObjectNode entityObject = (ObjectNode) entityNode;
			// DraftAdministrativeData may only be accessed indirectly (navigation link)
			// with 'READ'.
			// Draft handler implementation works on persistence service layer and hence
			// should not be affected.
			// Note: @cds.autoexposed is not fully correct, but this is to indicate the
			// runtime that this table has been added by cds tools (compiler or umbrella).
			// The annotation will only be visible internally.
			entityObject.put(ANNOTATION_CDS_AUTOEXPOSED, true);
			entityObject.put(ANNOTATION_READONLY, true);
		}
	}

	private void removeLocalizedFromElements(JsonNode object) {
		if (object.has(ELEMENTS)) {
			ObjectNode elements = (ObjectNode) object.findValue(ELEMENTS);
			elements.forEach(e -> {
				if (e.has(LOCALIZEDCSN)) {
					ObjectNode o = (ObjectNode) e;
					o.remove(LOCALIZEDCSN);
				}
			});
		}
	}

	private static boolean isDraftEnabled(JsonNode entity) {
		return (entity.has(ANNOTATION_DRAFT_ENABLED) && entity.get(ANNOTATION_DRAFT_ENABLED).asBoolean(false))
				|| entity.has(ANNOTATION_DRAFT_PREPARE_ACTION);
	}

	/**
	 * For every association to a draft enabled entity a second association is added
	 * which has the draft table as target
	 *
	 * @param entity
	 */
	private void addAssociationsToDraftEntities(JsonNode entity, String name) {
		if (entity.has(ELEMENTS)) {
			List> tmpList = new ArrayList<>(entity.get(ELEMENTS).size());
			entity.get(ELEMENTS).fields().forEachRemaining(tmpList::add);
			tmpList.forEach(e -> addDraftAssociation(entity, e.getKey(), e.getValue(), name));
		}
	}

	private void addDraftAssociation(JsonNode entity, String fieldName, JsonNode element, String entityName) {
		if (isAssociation(element) && !SIBLING_ENTITY.equals(fieldName) && !isParent(element, entityName)
				&& !isAssociationToCompositionTarget(entity, element)) {
			String targetName = element.get(TARGET).textValue();
			JsonNode target = entityObjects.get(targetName);
			if (isDraftEnabled(target)) {
				// if the target of the association is draft enabled a second association
				// with the changed target is added
				ObjectNode copy = element.deepCopy();
				copy.put(TARGET, getDraftsEntity(copy.get(TARGET).textValue()));
				String draftAssociationName = getDraftsEntity(fieldName);
				if (copy.has(ON) && copy.get(ON).isArray()) {
					// change the references in the on condition
					changeOnCondition(copy, fieldName, draftAssociationName);
				} else {
					// TODO remove this workaround once $generatedFieldName is supported
					// currently we need this because cds4j uses the wrong field name
					ArrayNode on = mapper.createArrayNode();
					ObjectNode obj1 = mapper.createObjectNode();
					ArrayNode ref1 = mapper.createArrayNode();
					ref1.insert(0, draftAssociationName);
					ref1.insert(1, copy.get("keys").get(0).get("ref").get(0).asText());
					obj1.set("ref", ref1);
					on.insert(0, obj1);
					on.insert(1, "=");
					ObjectNode obj2 = mapper.createObjectNode();
					ArrayNode ref2 = mapper.createArrayNode();
					ref2.insert(0, fieldName + "_ID");
					obj2.set("ref", ref2);
					on.insert(2, obj2);
					copy.set("on", on);
				}
				((ObjectNode) entity.get(ELEMENTS)).set(draftAssociationName, copy);
			}
		}
	}

	private boolean isAssociationToCompositionTarget(JsonNode entity, JsonNode element) {
		String targetName = element.get(TARGET).textValue();
		AtomicBoolean compositionWithSameTarget = new AtomicBoolean(false);
		forCompositions(entity, (elem, compositionTarget) -> {
			compositionWithSameTarget
					.set(compositionTarget.equals(targetName) || compositionTarget.equals(getDraftsEntity(targetName)));
		});
		return compositionWithSameTarget.get();
	}

	private boolean isAssociation(JsonNode node) {
		return node.has(TYPE) && ASSOCIATION.equals(node.get(TYPE).textValue());
	}

	private void changeOnCondition(ObjectNode copy, String fieldName, String draftAssociationName) {
		ArrayNode onCopy = (ArrayNode) copy.get(ON);
		for (int j = 0; j < onCopy.size(); ++j) {
			if (onCopy.get(j).has("ref") && onCopy.get(j).get("ref").isArray()) {
				ArrayNode refCopy = (ArrayNode) onCopy.get(j).get("ref");
				for (int i = 0; i < refCopy.size(); ++i) {
					if (fieldName.equals(refCopy.get(i).asText())) {
						refCopy.remove(i);
						refCopy.insert(i, draftAssociationName);
					}
				}
			}
		}
	}

	/**
	 * change the composition targets to the drafts tables
	 *
	 * @param entity the JSON node
	 */
	private void changeCompositionTargets(JsonNode entity) {
		forCompositions(entity, (node, target) -> {
			if (draftEntities.containsKey(target)) {
				node.put(TARGET, getDraftsEntity(target));
			}
		});
	}

	/**
	 * Checks if an association is part of a composition relationship and adapt it
	 * in this case
	 *
	 * @param entity the JSON node
	 * @param name   the name of the entity
	 */
	private void changeAssociations(JsonNode entity, String name) {
		for (JsonNode node : entity.findParents(TYPE)) {
			String type = node.get(TYPE).asText();
			if (ASSOCIATION.equals(type)) {
				if (isAssociationToCompositionTarget(entity, node)) {
					String targetEntityName = node.get(TARGET).asText();
					JsonNode targetEntity = entityObjects.get(targetEntityName);
					if (isDraftEnabled(targetEntity)) {
						((ObjectNode) node).put(TARGET, getDraftsEntity(targetEntityName));
					}
				} else {
					checkTarget(node, name);
				}
			}
		}
	}

	/**
	 * Checks if the target of an association is draft enabled and has this entity
	 * as composition entity
	 *
	 * @param node the JSON node
	 * @param name the name of the entity
	 */
	private void checkTarget(JsonNode node, String name) {
		if (node.has(TARGET)) {
			String target = node.get(TARGET).asText();
			if (draftEntities.containsKey(target)) {
				JsonNode parent = draftEntities.get(target);
				forCompositions(parent, (n, tParent) -> {
					if (tParent.equals(name)) {
						((ObjectNode) node).put(TARGET, getDraftsEntity(target));
					}
				});
			}
		}
	}

	private boolean isParent(JsonNode node, String name) {
		AtomicBoolean result = new AtomicBoolean(false);
		if (node.has(TARGET)) {
			String target = node.get(TARGET).asText();
			if (draftEntities.containsKey(target)) {
				JsonNode parent = draftEntities.get(target);
				forCompositions(parent, (n, tParent) -> {
					if (tParent.equals(name)) {
						result.set(true);
					}
				});
			}
		}
		return result.get();
	}

	private static String getDraftsEntity(String entity) {
		return entity + DRAFTS_SUFFIX_ENTITY;
	}

	/**
	 * Adapts the target of the DraftAdministrativeData association to the actual
	 * table because insertions do not work otherwise on SQLite
	 *
	 * @param entity the JSON node
	 */
	private void adaptDraftAdministrativeData(ObjectNode entity) {
		ObjectNode adminData = (ObjectNode) entity.findValue(DRAFT_ADMINISTRATIVE_DATA);
		if (adminData != null && adminData.has(TARGET)) {
			adminData.put(TARGET, ADMINSTRATIVE_DATA_TABLE);
			if (entity.has(ANNOTATION_DRAFT_ENABLED) && entity.get(ANNOTATION_DRAFT_ENABLED).asBoolean()) {
				adminData.put(ANNOTATION_CASCADE_ALL, true);
			} else {
				adminData.put(ANNOTATION_CASCADE_UPDATE, true);
			}
		}
	}

	/**
	 * Removes the "key" qualifier for the IsActiveEntity field because it is no key
	 * on the database
	 *
	 * @param entity the JSON node
	 */
	private void removeActiveEntityKeyQualifier(ObjectNode entity) {
		ObjectNode activeEntity = (ObjectNode) entity.findValue(IS_ACTIVE_ENTITY);
		if (activeEntity != null) {
			activeEntity.remove(KEY);
		}
	}

	/**
	 * Iterates over all composition types and executes {@code action}.
	 *
	 * @param entity the JSON node
	 * @param action the consumer
	 */
	private void forCompositions(JsonNode entity, BiConsumer action) {
		for (JsonNode node : entity.findParents(TYPE)) {
			String type = node.get(TYPE).asText();
			if (COMPOSITION.equals(type) && node.has(TARGET)) {
				String target = node.get(TARGET).asText();
				action.accept((ObjectNode) node, target);
			}
		}
	}

	/**
	 * Removes the active entity condition for the sibling since we reference a
	 * different table for the association
	 *
	 * @param entity the JSON node
	 */
	private void removeSiblingActiveEntityCondition(ObjectNode entity) {
		JsonNode sibling = entity.findValue(SIBLING_ENTITY);
		if (sibling != null) {
			JsonNode on = sibling.findValue(ON);
			if (on != null) {
				removeActiveEntityCondition(on);
				// remove the last textual node
				Iterator iter = on.elements();
				JsonNode node = null;
				while (iter.hasNext()) {
					node = iter.next();
				}
				if (node != null && node.isTextual()) {
					iter.remove();
				}
			}
		}
	}

	private void removeActiveEntityCondition(JsonNode on) {
		Iterator iter = on.elements();
		while (iter.hasNext()) {
			JsonNode ref = iter.next().findValue("ref");
			if (ref != null && contains(ref, IS_ACTIVE_ENTITY)) {
				iter.remove();
				if (iter.hasNext()) {
					iter.next();
					iter.remove();
				}
			}
		}
	}

	private static boolean contains(JsonNode ref, String str) {
		Iterator iter = ref.elements();
		JsonNode current;
		while (iter.hasNext()) {
			current = iter.next();
			if (current.isTextual() && str.equals(current.asText())) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Removes the draft fields because the original table does not have the draft
	 * fields. Also adapts the SiblingEntity association
	 *
	 * @param object the JSON node
	 */
	private void removeDraftFieldsAndChangeSibling(JsonNode object) {
		if (object.has(ELEMENTS)) {
			ObjectNode elements = (ObjectNode) object.findValue(ELEMENTS);
			// change sibling entity
			if (elements.has(SIBLING_ENTITY)) {
				ObjectNode sibling = (ObjectNode) elements.get(SIBLING_ENTITY);
				if (sibling.has(TARGET) && sibling.get(TARGET).isTextual()) {
					String target = sibling.get(TARGET).asText();
					sibling.put(TARGET, getDraftsEntity(target));
					removeSiblingActiveEntityCondition(elements);
				}
				ObjectNode unsecuredSibling = sibling.deepCopy();
				addSecurityConstraint(sibling);
				changeReferences(unsecuredSibling);
				elements.set(SIBLING_ENTITY_UNSECURED, unsecuredSibling);
			}
		}
	}

	private void changeReferences(ObjectNode unsecuredSibling) {
		if (unsecuredSibling.findValue(ON) != null && unsecuredSibling.findValue(ON).isArray()) {
			ArrayNode on = (ArrayNode) unsecuredSibling.findValue(ON);
			on.forEach(node -> {
				if (node.has("ref") && node.get("ref").isArray()) {
					ArrayNode ref = (ArrayNode) node.get("ref");
					for (int i = 0; i < ref.size(); ++i) {
						if (ref.get(i).isTextual() && ((TextNode) ref.get(i)).asText().equals(SIBLING_ENTITY)) {
							ref.remove(i);
							ref.insert(i, SIBLING_ENTITY_UNSECURED);
						}
					}
				}
			});
		}
	}

	private void addSecurityConstraint(ObjectNode sibling) {
		if (sibling.findValue(ON) != null && sibling.findValue(ON).isArray()) {
			ArrayNode on = (ArrayNode) sibling.findValue(ON);
			on.add("and");
			ArrayNode refUuid = mapper.createArrayNode();
			refUuid.add(SIBLING_ENTITY);
			refUuid.add(DRAFT_UUID);
			on.add(createRefNode(refUuid));
			on.add("IN (SELECT DraftUUID FROM DRAFT_DraftAdministrativeData where CreatedByUser is null or");
			ArrayNode userIdRef = mapper.createArrayNode();
			userIdRef.add("$user");
			userIdRef.add("id");
			on.add(createRefNode(userIdRef));
			on.add("= CreatedByUser)");
		}
	}

	private ObjectNode createRefNode(ArrayNode ref) {
		ObjectNode node = mapper.createObjectNode();
		node.set("ref", ref);
		return node;
	}

	public static boolean isDraftElement(String element) {
		return IS_ACTIVE_ENTITY.equals(element) || HAS_ACTIVE_ENTITY.equals(element)
				|| HAS_DRAFT_ENTITY.equals(element);
	}

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy