/*
* (C) Copyright 2014 Nuxeo SA (http://nuxeo.com/) and others.
*
* 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.
*
* Contributors:
* Florent Guillaume
*/
package org.nuxeo.ecm.core.storage;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import com.google.common.collect.ImmutableSet;
/**
* Abstraction for a Map<String, Serializable> that is Serializable.
* <p>
* Internal storage is optimized to avoid a full {@link HashMap} when there is a small number of keys.
*
* @since 5.9.5
*/
public class State implements StateAccessor, Serializable {
private static final long serialVersionUID = 1L;
protected static final Log log = LogFactory.getLog(State.class);
private static final int HASHMAP_DEFAULT_INITIAL_CAPACITY = 16;
private static final float HASHMAP_DEFAULT_LOAD_FACTOR = 0.75f;
// maximum size to use an array after which we switch to a full HashMap
public static final int ARRAY_MAX = 5;
private static final int DEBUG_MAX_STRING = 100;
private static final int DEBUG_MAX_ARRAY = 10;
public static final State EMPTY = new State(Collections.<String, Serializable> emptyMap());
private static final String[] EMPTY_STRING_ARRAY = new String[0];
/** Initial key order for the {@link #toString} method. */
private static final Set<String> TO_STRING_KEY_ORDER = new LinkedHashSet<>(Arrays.asList(
new String[] { "ecm:id", "ecm:primaryType", "ecm:name", "ecm:parentId", "ecm:isVersion", "ecm:isProxy" }));
/**
* A diff for a {@link State}.
* <p>
* Each value is applied to the existing {@link State}. An element can be:
* <ul>
* <li>a {@link StateDiff}, to be applied on a {@link State},
* <li>a {@link ListDiff}, to be applied on an array/{@link List},
* <li>an actual value to be set (including {@code null}).
* </ul>
*
* @since 5.9.5
*/
public static class StateDiff extends State {
private static final long serialVersionUID = 1L;
@Override
public void put(String key, Serializable value) {
// for a StateDiff, we don't have concurrency problems
// and we want to store nulls explicitly
putEvenIfNull(key, value);
}
}
/**
* Singleton marker.
*/
private static enum Nop {
NOP
}
/**
* Denotes no change to an element.
*/
public static final Nop NOP = Nop.NOP;
/**
* A diff for an array or {@link List}.
* <p>
* This diff is applied onto an existing array/{@link List} in the following manner:
* <ul>
* <li>{@link #diff}, if any, is applied,
* <li>{@link #rpush}, if any, is applied.
* </ul>
*
* @since 5.9.5
*/
public static class ListDiff implements Serializable {
private static final long serialVersionUID = 1L;
/**
* Whether this {@link ListDiff} applies to an array ({@code true}) or a {@link List} ({@code false}).
*/
public boolean isArray;
/**
* If diff is not {@code null}, each element of the list is applied to the existing array/{@link List}. An
* element can be:
* <ul>
* <li>a {@link StateDiff}, to be applied on a {@link State},
* <li>an actual value to be set (including {@code null}),
* <li>{@link #NOP} if no change is needed.
* </ul>
*/
public List<Object> diff;
/**
* If rpush is not {@code null}, this is appended to the right of the existing array/{@link List}.
*/
public List<Object> rpush;
@Override
public String toString() {
return getClass().getSimpleName() + '(' + (isArray ? "array" : "list")
+ (diff == null ? "" : ", DIFF " + diff) + (rpush == null ? "" : ", RPUSH " + rpush) + ')';
}
}
// if map != null then use it
protected Map<String, Serializable> map;
// else use keys / values
protected List<String> keys;
protected List<Serializable> values;
/**
* Private constructor with explicit map.
*/
private State(Map<String, Serializable> map) {
this.map = map;
}
/**
* Constructor with default capacity.
*/
public State() {
this(0, false);
}
/**
* Constructor with default capacity, optionally thread-safe.
*
* @param threadSafe if {@code true}, then a {@link ConcurrentHashMap} is used
*/
public State(boolean threadSafe) {
this(0, threadSafe);
}
/**
* Constructor for a given default size.
*/
public State(int size) {
this(size, false);
}
/**
* Constructor for a given default size, optionally thread-safe.
*
* @param threadSafe if {@code true}, then a {@link ConcurrentHashMap} is used
*/
public State(int size, boolean threadSafe) {
if (threadSafe) {
map = new ConcurrentHashMap<String, Serializable>(initialCapacity(size));
} else {
if (size > ARRAY_MAX) {
map = new HashMap<>(initialCapacity(size));
} else {
keys = new ArrayList<String>(size);
values = new ArrayList<Serializable>(size);
}
}
}
protected static int initialCapacity(int size) {
return Math.max((int) (size / HASHMAP_DEFAULT_LOAD_FACTOR) + 1, HASHMAP_DEFAULT_INITIAL_CAPACITY);
}
/**
* Gets the number of elements.
*/
public int size() {
if (map != null) {
return map.size();
} else {
return keys.size();
}
}
/**
* Checks if the state is empty.
*/
public boolean isEmpty() {
if (map != null) {
return map.isEmpty();
} else {
return keys.isEmpty();
}
}
/**
* Gets a value for a key, or {@code null} if the key is not present.
*/
public Serializable get(Object key) {
if (map != null) {
return map.get(key);
} else {
int i = keys.indexOf(key);
return i >= 0 ? values.get(i) : null;
}
}
/**
* Sets a key/value.
*/
public void put(String key, Serializable value) {
if (value == null) {
// if we're using a ConcurrentHashMap
// then null values are forbidden
// this is ok given our semantics of null vs absent key
if (map != null) {
map.remove(key);
} else {
int i = keys.indexOf(key);
if (i >= 0) {
// cost is not trivial but we don't use this often, if at all
keys.remove(i);
values.remove(i);
}
}
} else {
putEvenIfNull(key, value);
}
}
protected void putEvenIfNull(String key, Serializable value) {
if (map != null) {
map.put(key, value);
} else {
int i = keys.indexOf(key);
if (i >= 0) {
// existing key
values.set(i, value);
} else {
// new key
if (keys.size() < ARRAY_MAX) {
keys.add(key);
values.add(value);
} else {
// upgrade to a full HashMap
map = new HashMap<>(initialCapacity(keys.size() + 1));
for (int j = 0; j < keys.size(); j++) {
map.put(keys.get(j), values.get(j));
}
map.put(key, value);
keys = null;
values = null;
}
}
}
}
/**
* Removes the mapping for a key.
*
* @return the previous value associated with the key, or {@code null} if there was no mapping for the key
*/
public Serializable remove(Object key) {
if (map != null) {
return map.remove(key);
} else {
int i = keys.indexOf(key);
if (i >= 0) {
keys.remove(i);
return values.remove(i);
} else {
return null;
}
}
}
/**
* Gets the key set. IT MUST NOT BE MODIFIED.
*/
public Set<String> keySet() {
if (map != null) {
return map.keySet();
} else {
return ImmutableSet.copyOf(keys);
}
}
/**
* Gets an array of keys.
*/
public String[] keyArray() {
if (map != null) {
return map.keySet().toArray(EMPTY_STRING_ARRAY);
} else {
return keys.toArray(EMPTY_STRING_ARRAY);
}
}
/**
* Checks if there is a mapping for the given key.
*/
public boolean containsKey(Object key) {
if (map != null) {
return map.containsKey(key);
} else {
return keys.contains(key);
}
}
/**
* Gets the entry set. IT MUST NOT BE MODIFIED.
*/
public Set<Entry<String, Serializable>> entrySet() {
if (map != null) {
return map.entrySet();
} else {
return new ArraysEntrySet();
}
}
/** EntrySet optimized to just return a simple Iterator on the entries. */
protected class ArraysEntrySet implements Set<Entry<String, Serializable>> {
@Override
public int size() {
return keys.size();
}
@Override
public boolean isEmpty() {
return keys.isEmpty();
}
@Override
public Iterator<Entry<String, Serializable>> iterator() {
return new ArraysEntryIterator();
}
@Override
public boolean contains(Object o) {
throw new UnsupportedOperationException();
}
@Override
public Object[] toArray() {
throw new UnsupportedOperationException();
}
@Override
public <T> T[] toArray(T[] a) {
throw new UnsupportedOperationException();
}
@Override
public boolean add(Entry<String, Serializable> e) {
throw new UnsupportedOperationException();
}
@Override
public boolean remove(Object o) {
throw new UnsupportedOperationException();
}
@Override
public boolean containsAll(Collection<?> c) {
throw new UnsupportedOperationException();
}
@Override
public boolean addAll(Collection<? extends Entry<String, Serializable>> c) {
throw new UnsupportedOperationException();
}
@Override
public boolean retainAll(Collection<?> c) {
throw new UnsupportedOperationException();
}
@Override
public boolean removeAll(Collection<?> c) {
throw new UnsupportedOperationException();
}
@Override
public void clear() {
throw new UnsupportedOperationException();
}
}
public class ArraysEntryIterator implements Iterator<Entry<String, Serializable>> {
private int index;
@Override
public boolean hasNext() {
return index < keys.size();
}
@Override
public Entry<String, Serializable> next() {
return new ArraysEntry(index++);
}
}
public class ArraysEntry implements Entry<String, Serializable> {
private final int index;
public ArraysEntry(int index) {
this.index = index;
}
@Override
public String getKey() {
return keys.get(index);
}
@Override
public Serializable getValue() {
return values.get(index);
}
@Override
public Serializable setValue(Serializable value) {
throw new UnsupportedOperationException();
}
}
/**
* Overridden to display Calendars and arrays better, and truncate long strings and arrays.
* <p>
* Also displays some keys first (ecm:id, ecm:name, ecm:primaryType)
*/
@Override
public String toString() {
if (isEmpty()) {
return "{}";
}
StringBuilder buf = new StringBuilder();
buf.append('{');
boolean empty = true;
// some keys go first
for (String key : TO_STRING_KEY_ORDER) {
if (containsKey(key)) {
if (!empty) {
buf.append(", ");
}
empty = false;
buf.append(key);
buf.append('=');
toString(buf, get(key));
}
}
// sort keys
String[] keys = keyArray();
Arrays.sort(keys);
for (String key : keys) {
if (TO_STRING_KEY_ORDER.contains(key)) {
// already done
continue;
}
if (!empty) {
buf.append(", ");
}
empty = false;
buf.append(key);
buf.append('=');
toString(buf, get(key));
}
buf.append('}');
return buf.toString();
}
@SuppressWarnings("boxing")
protected static void toString(StringBuilder buf, Object value) {
if (value instanceof String) {
String v = (String) value;
if (v.length() > DEBUG_MAX_STRING) {
v = v.substring(0, DEBUG_MAX_STRING) + "...(" + v.length() + " chars)...";
}
buf.append(v);
} else if (value instanceof Calendar) {
Calendar cal = (Calendar) value;
char sign;
int offset = cal.getTimeZone().getOffset(cal.getTimeInMillis()) / 60000;
if (offset < 0) {
offset = -offset;
sign = '-';
} else {
sign = '+';
}
buf.append(String.format("Calendar(%04d-%02d-%02dT%02d:%02d:%02d.%03d%c%02d:%02d)", cal.get(Calendar.YEAR), //
cal.get(Calendar.MONTH) + 1, //
cal.get(Calendar.DAY_OF_MONTH), //
cal.get(Calendar.HOUR_OF_DAY), //
cal.get(Calendar.MINUTE), //
cal.get(Calendar.SECOND), //
cal.get(Calendar.MILLISECOND), //
sign, offset / 60, offset % 60));
} else if (value instanceof Object[]) {
Object[] v = (Object[]) value;
buf.append('[');
for (int i = 0; i < v.length; i++) {
if (i > 0) {
buf.append(',');
if (i > DEBUG_MAX_ARRAY) {
buf.append("...(" + v.length + " items)...");
break;
}
}
toString(buf, v[i]);
}
buf.append(']');
} else {
buf.append(value);
}
}
@Override
public Object getSingle(String name) {
Serializable object = get(name);
if (object instanceof Object[]) {
Object[] array = (Object[]) object;
if (array.length == 0) {
return null;
} else if (array.length == 1) {
// data migration not done in database, return a simple value anyway
return array[0];
} else {
log.warn("Property " + name + ": expected a simple value but read an array: " + Arrays.toString(array));
return array[0];
}
} else {
return object;
}
}
@Override
public Object[] getArray(String name) {
Serializable object = get(name);
if (object == null) {
return null;
} else if (object instanceof Object[]) {
return (Object[]) object;
} else {
// data migration not done in database, return an array anyway
return new Object[] { object };
}
}
@Override
public void setSingle(String name, Object value) {
put(name, (Serializable) value);
}
@Override
public void setArray(String name, Object[] value) {
put(name, value);
}
@Override
public boolean equals(Object other) {
return StateHelper.equalsStrict(this, other);
}
}