package com.psddev.cms.db; import java.util.Collection; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.IntStream; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableMap; import com.psddev.dari.db.Database; import com.psddev.dari.db.DatabaseEnvironment; import com.psddev.dari.db.DistributedLock; import com.psddev.dari.db.Modification; import com.psddev.dari.db.ObjectField; import com.psddev.dari.db.ObjectType; import com.psddev.dari.db.Query; import com.psddev.dari.db.State; import com.psddev.dari.util.CompactMap; import com.psddev.dari.util.ObjectUtils; import com.psddev.dari.util.UuidUtils; /** Unpublished object or unsaved changes to an existing object. */ @ToolUi.Hidden public class Draft extends Content { private static final String OLD_VALUES_EXTRA = "cms.draft.oldValues"; private static final Object REMOVED = new Object(); @Indexed private DraftStatus status; @Indexed private Schedule schedule; private String name; @Indexed private ToolUser owner; @Indexed @Required private ObjectType objectType; @Indexed @Required private UUID objectId; @Deprecated private Map<String, Object> objectChanges; @Indexed private boolean newContent; @Raw private Map<String, Map<String, Object>> differences; /** * Finds the differences between the given {@code oldValues} and * {@code newValues}. * * @param environment * Can't be {@code null}. * * @param oldValues * May be {@code null}. * * @param newValues * May be {@code null}. * * @return Never {@code null}. */ @SuppressWarnings("unchecked") public static Map<String, Map<String, Object>> findDifferences( DatabaseEnvironment environment, Map<String, Object> oldValues, Map<String, Object> newValues) { oldValues = (Map<String, Object>) ObjectUtils.fromJson(ObjectUtils.toJson(oldValues)); newValues = (Map<String, Object>) ObjectUtils.fromJson(ObjectUtils.toJson(newValues)); Map<String, Map<String, Object>> newIdMaps = newValues != null ? findIdMaps(newValues) : new CompactMap<>(); if (oldValues == null) { return newIdMaps; } Map<String, Map<String, Object>> oldIdMaps = findIdMaps(oldValues); Map<String, Map<String, Object>> differences = new CompactMap<>(); newIdMaps.keySet().stream().forEach(id -> { Map<String, Object> oldIdMap = oldIdMaps.get(id); Map<String, Object> newIdMap = newIdMaps.get(id); Map<String, Object> changes = new CompactMap<>(); ObjectType type = environment.getTypeById(ObjectUtils.to(UUID.class, newIdMap.get(State.TYPE_KEY))); Set<String> keys = new LinkedHashSet<>(newIdMap.keySet()); if (oldIdMap != null) { keys.addAll(oldIdMap.keySet()); } keys.forEach(key -> { ObjectField field = null; if (type != null) { field = type.getField(key); if (field == null) { field = environment.getField(key); } } Object oldValue = oldIdMap != null ? oldIdMap.get(key) : null; Object newValue = newIdMap.get(key); if (!roughlyEquals(field, oldValue, newValue)) { changes.put(key, newValue); } }); if (!changes.isEmpty()) { differences.put(id, changes); } }); return differences; } private static Map<String, Map<String, Object>> findIdMaps(Object value) { Map<String, Map<String, Object>> valuesById = new CompactMap<>(); addIdMaps(valuesById, value); for (Map.Entry<String, Map<String, Object>> entry : valuesById.entrySet()) { entry.setValue(minify(entry.getValue())); } return valuesById; } private static void addIdMaps(Map<String, Map<String, Object>> valuesById, Object value) { Collection<?> collection = null; if (value instanceof Map) { @SuppressWarnings("unchecked") Map<String, Object> map = (Map<String, Object>) value; String id = ObjectUtils.to(String.class, map.get(State.ID_KEY)); if (id != null) { if (valuesById.containsKey(id)) { id = UuidUtils.createSequentialUuid().toString(); map.put(State.ID_KEY, id); } valuesById.put(id, new CompactMap<>(map)); } collection = map.values(); } else if (value instanceof Collection) { collection = (Collection<?>) value; } if (collection != null) { collection.forEach(item -> addIdMaps(valuesById, item)); } } private static Map<String, Object> minify(Map<String, Object> map) { Map<String, Object> minified = new CompactMap<>(); for (Map.Entry<String, Object> entry : map.entrySet()) { minified.put(entry.getKey(), minifyValue(entry.getValue())); } return minified; } @SuppressWarnings("unchecked") private static Object minifyValue(Object value) { if (value instanceof Map) { Map<String, Object> valueMap = (Map<String, Object>) value; String id = ObjectUtils.to(String.class, valueMap.get(State.ID_KEY)); if (id != null) { return ImmutableMap.of(State.ID_KEY, id); } else { return minify((Map<String, Object>) value); } } else if (value instanceof Collection) { return ((Collection<Object>) value) .stream() .map(v -> minifyValue(v)) .collect(Collectors.toList()); } else { return value; } } private static boolean roughlyEquals(ObjectField field, Object x, Object y) { if (field != null && field.getInternalType().startsWith(ObjectField.SET_TYPE + "/")) { x = ObjectUtils.to(Set.class, x); y = ObjectUtils.to(Set.class, y); } if (ObjectUtils.equals(x, y)) { return true; } // null equals false. if (x instanceof Boolean) { if (Boolean.TRUE.equals(x)) { return Boolean.TRUE.equals(y); } else { return !Boolean.TRUE.equals(y); } } else if (y instanceof Boolean) { if (Boolean.TRUE.equals(y)) { return Boolean.TRUE.equals(x); } else { return !Boolean.TRUE.equals(x); } } // null equals [ ], etc. if (ObjectUtils.isBlank(x)) { return ObjectUtils.isBlank(y); } else if (ObjectUtils.isBlank(y)) { return ObjectUtils.isBlank(x); } // Compare list items using roughlyEquals. if (x instanceof List && y instanceof List) { @SuppressWarnings("unchecked") List<Object> xList = (List<Object>) x; @SuppressWarnings("unchecked") List<Object> yList = (List<Object>) y; int xSize = xList.size(); int ySize = yList.size(); return xSize == ySize && IntStream.range(0, xSize).allMatch(i -> roughlyEquals(field, xList.get(i), yList.get(i))); } // Compare map values using roughlyEquals. if (x instanceof Map && y instanceof Map) { @SuppressWarnings("unchecked") Map<String, Object> xMap = (Map<String, Object>) x; @SuppressWarnings("unchecked") Map<String, Object> yMap = (Map<String, Object>) y; Set<String> xKeys = xMap.keySet(); Set<String> yKeys = yMap.keySet(); return xKeys.equals(yKeys) && xKeys.stream().allMatch(k -> roughlyEquals(field, xMap.get(k), yMap.get(k))); } return false; } /** * Merges the given {@code differences} into the given {@code oldValues}. * * @param environment * Can't be {@code null}. * * @param oldValues * Can't be {@code null}. * * @param differences * If blank, returns the given {@code oldValues} as is. * * @return Never {@code null}. */ @SuppressWarnings("unchecked") public static Map<String, Object> mergeDifferences( DatabaseEnvironment environment, Map<String, Object> oldValues, Map<String, Map<String, Object>> differences) { Preconditions.checkNotNull(oldValues); oldValues = (Map<String, Object>) cloneValue(oldValues); return differences != null && !differences.isEmpty() ? (Map<String, Object>) mergeValue(environment, findIdMaps(oldValues), differences, oldValues) : oldValues; } @SuppressWarnings("unchecked") private static Object cloneValue(Object value) { if (value instanceof List) { return ((List<Object>) value).stream() .map(v -> cloneValue(v)) .collect(Collectors.toList()); } else if (value instanceof Map) { Map<String, Object> clone = new CompactMap<>(); for (Map.Entry<String, Object> entry : ((Map<String, Object>) value).entrySet()) { clone.put(entry.getKey(), cloneValue(entry.getValue())); } return clone; } else { return value; } } @SuppressWarnings("unchecked") private static Object mergeValue( DatabaseEnvironment environment, Map<String, Map<String, Object>> oldIdMaps, Map<String, Map<String, Object>> differences, Object value) { if (value instanceof Map) { Map<String, Object> valueMap = (Map<String, Object>) value; Map<String, Object> newIdMap = new CompactMap<>(); String valueId = ObjectUtils.to(String.class, valueMap.get(State.ID_KEY)); if (valueId != null) { Map<String, Object> oldIdMap = oldIdMaps.get(valueId); Map<String, Object> changes = differences.get(valueId); if (oldIdMap != null) { newIdMap.putAll(oldIdMap); } if (changes != null) { newIdMap.putAll(changes); } for (Map.Entry<String, Object> entry : newIdMap.entrySet()) { entry.setValue(mergeValue(environment, oldIdMaps, differences, entry.getValue())); } if (newIdMap.get(State.ID_KEY) == null) { return REMOVED; } } else { valueMap.forEach((k, v) -> newIdMap.put(k, mergeValue(environment, oldIdMaps, differences, v))); } return newIdMap; } else if (value instanceof List) { return ((List<Object>) value) .stream() .map(item -> mergeValue(environment, oldIdMaps, differences, item)) .filter(item -> item != REMOVED) .collect(Collectors.toList()); } return value; } /** * Finds the old values of the given {@code object} before the draft * differences were merged. * * @param object * Can't be {@code null}. * * @return Never {@code null}. */ public static Map<String, Object> findOldValues(Object object) { Preconditions.checkNotNull(object); State state = State.getInstance(object); @SuppressWarnings("unchecked") Map<String, Object> oldValues = (Map<String, Object>) state.getExtra(OLD_VALUES_EXTRA); return oldValues != null ? oldValues : state.getSimpleValues(); } /** Returns the status. */ public DraftStatus getStatus() { return status; } /** Sets the status. */ public void setStatus(DraftStatus status) { this.status = status; } /** Returns the schedule. */ public Schedule getSchedule() { return schedule; } /** Sets the schedule. */ public void setSchedule(Schedule schedule) { this.schedule = schedule; } /** Returns the name. */ public String getName() { return name; } /** Sets the name. */ public void setName(String name) { this.name = name; } /** Returns the owner. */ public ToolUser getOwner() { return owner; } /** Sets the owner. */ public void setOwner(ToolUser owner) { this.owner = owner; } /** Returns the originating object's type. */ public ObjectType getObjectType() { return objectType; } /** Sets the originating object's type ID. */ public void setObjectType(ObjectType type) { this.objectType = type; } /** Returns the originating object's ID. */ public UUID getObjectId() { return objectId; } /** Sets the originating object's ID. */ public void setObjectId(UUID objectId) { this.objectId = objectId; } /** * Returns the map of all the values to be changed on the originating * object. * * @deprecated Use {@link #getDifferences()} instead. */ @Deprecated public Map<String, Object> getObjectChanges() { if (objectChanges == null) { objectChanges = new LinkedHashMap<String, Object>(); } return objectChanges; } /** * Sets the map of all the values to be changed on the originating * object. * * @deprecated Use {@link #setDifferences(Map)} instead. */ @Deprecated public void setObjectChanges(Map<String, Object> values) { this.objectChanges = values; } public boolean isNewContent() { return newContent; } public void setNewContent(boolean newContent) { this.newContent = newContent; } /** * @return Never {@code null}. */ @SuppressWarnings("deprecation") public Map<String, Map<String, Object>> getDifferences() { if ((differences == null || differences.isEmpty()) && objectChanges != null && !objectChanges.isEmpty()) { ObjectType type = getObjectType(); if (type != null) { UUID id = getObjectId(); if (id != null) { Map<String, Object> values = new CompactMap<>(objectChanges); values.put(State.ID_KEY, id.toString()); values.put(State.TYPE_KEY, type.getId().toString()); return findIdMaps(values); } } } if (differences == null) { differences = new CompactMap<>(); } return differences; } public void setDifferences(Map<String, Map<String, Object>> differences) { this.differences = differences; } /** * @deprecated Use {@link #recreate()} instead. */ @Deprecated public Object getObject() { return recreate(); } /** * Recreates the originating object with the differences merged. * * @return {@code null} if the object type is {@code null}. */ @SuppressWarnings("deprecation") public Object recreate() { ObjectType type = getObjectType(); if (type == null) { return null; } UUID id = getObjectId(); Object object = Query.fromAll() .where("_id = ?", id) .noCache() .resolveInvisible() .first(); if (object == null) { object = type.createObject(id); } merge(object); return object; } /** * @deprecated Use {@link #findOldValues(Object)} and * {@link #update(Map, Object)} instead. */ @Deprecated public void setObject(Object object) { update(findOldValues(object), object); } /** * Updates all necessary fields to recreate the object later using * the differences between the given {@code oldValues} and * {@code newObject}. * * @param oldValues * May be {@code null}. * * @param newObject * Can't be {@code null}. */ public void update(Map<String, Object> oldValues, Object newObject) { Preconditions.checkNotNull(newObject); State newState = State.getInstance(newObject); UUID newId = newState.getId(); setObjectType(newState.getType()); setObjectId(newId); setDifferences(findDifferences( newState.getDatabase().getEnvironment(), oldValues, newState.getSimpleValues())); State newStateCopy = State.getInstance(Query.fromAll() .where("_id = ?", newId) .noCache() .first()); if (newStateCopy == null) { setName("#1"); newState.as(NameData.class).setIndex(1); } else { DistributedLock lock = DistributedLock.Static.getInstance( Database.Static.getDefault(), getClass().getName() + "/" + newId); lock.lock(); try { NameData nameData = newStateCopy.as(NameData.class); Integer index = nameData.getIndex(); index = index != null ? index + 1 : 1; if (ObjectUtils.isBlank(getName())) { setName("#" + index); } nameData.setIndex(index); nameData.save(); } finally { lock.unlock(); } } } /** * Merges the differences into the given {@code object}. * * @param object * Can't be {@code null}. */ @SuppressWarnings("deprecation") public void merge(Object object) { Preconditions.checkNotNull(object); State state = State.getInstance(object); state.getExtras().put(OLD_VALUES_EXTRA, state.getSimpleValues()); state.setValues(mergeDifferences( state.getDatabase().getEnvironment(), state.getSimpleValues(), getDifferences())); } @Override public String getLabel() { Object object = recreate(); if (object != null) { return State.getInstance(object).getLabel(); } else { return super.getLabel(); } } @FieldInternalNamePrefix("cms.draft.name.") public static class NameData extends Modification<Object> { private Integer index; public Integer getIndex() { return index; } public void setIndex(Integer index) { this.index = index; } } }